Compare commits

...

61 Commits

Author SHA1 Message Date
zdl
bad5290fe2 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  feat: 日k 和 分时h5UI调整
  fix: 弹窗固定高度
  feat: K线添加mock数据
  feat: 添加批量获取K线数据的 mock handler
2025-12-04 14:12:10 +08:00
zdl
a569a63a85 feat: 日k 和 分时h5UI调整 2025-12-04 14:11:37 +08:00
zdl
77af61a93a fix: 弹窗固定高度 2025-12-04 14:02:21 +08:00
zdl
999fd9b0a3 feat: K线添加mock数据 2025-12-04 14:02:03 +08:00
zdl
8d3e92dfaf feat: 添加批量获取K线数据的 mock handler
- 新增 /api/stock/batch-kline POST 接口 mock
- 支持批量获取多只股票的分时图和日K线数据
- 修复事件详情页面相关股票的K线和分时图无数据问题

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:46:47 +08:00
zdl
daee0427e4 fix: 修复 useWatchlist.js 合并冲突遗留问题
- 移除重复的 handleRemoveFromWatchlist 导出
- 移除 JSDoc 中重复的类型声明
- 清理残留的错误注释

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:35:51 +08:00
zdl
e8c21f7863 refactor: DynamicNewsDetailPanel 组件优化
- 使用 useReducer 整合 7 个折叠状态为统一的 sectionState
- 提取自选股逻辑到 useWatchlist Hook,移除 70 行重复代码
- 扩展 useWatchlist 添加 handleAddToWatchlist、isInWatchlist 方法
- 清理未使用的导入(HStack、useColorModeValue)
- 移除调试 console.log 日志
- RelatedStocksSection 改用 isInWatchlist 函数替代 watchlistSet

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 13:29:59 +08:00
zdl
3f518def09 fix: 预加载行业数据(解决第一次点击无数据问题) 2025-12-04 12:33:59 +08:00
zdl
f521b89c27 fix:修复添加自选股没反应 2025-12-04 12:20:27 +08:00
zdl
ac421011eb fix:修复事件中心刚进页面向上滚动了一部分 2025-12-04 11:57:30 +08:00
zdl
2a653afea1 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  fix: 导航效果UI修复
  feat: 个股添加个股列表弹窗
  fix: 概念中心UI
  fix: 个股中心页面日期数据源统一
  fix: 修改的后端代码 /api/market/statistics 接口 添加日期格式化逻辑 //api/concepts/daily-top 添加日期格式化逻辑 /api/market/heatmap 接口 已经有正确的格式化
2025-12-04 11:53:37 +08:00
zdl
6628ddc7b2 fix: 导航效果UI修复 2025-12-04 11:52:44 +08:00
zdl
5dc480f5f4 feat: 个股添加个股列表弹窗 2025-12-04 11:51:21 +08:00
zdl
99f102a213 fix: 概念中心UI 2025-12-04 11:35:29 +08:00
a37206ec97 update pay ui 2025-12-04 10:58:30 +08:00
zdl
9f6c98135f fix: 个股中心页面日期数据源统一
- fetchTopConcepts: 始终设置 selectedDate 和 availableDates
- fetchHeatmapData: 移除 setSelectedDate
- fetchMarketStats: 移除 setSelectedDate 和 setAvailableDates
- 新增 src/data/tradingDays.json: 交易日历数据(从 tdays.csv 转换)
- availableDates 基于交易日历生成,确保日期列表完整且包含最新日期

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-04 10:57:03 +08:00
5e5e2160b0 update pay ui 2025-12-04 10:43:17 +08:00
zdl
f0074bca42 fix: 修改的后端代码
/api/market/statistics 接口 添加日期格式化逻辑
//api/concepts/daily-top 添加日期格式化逻辑
/api/market/heatmap 接口 已经有正确的格式化
2025-12-04 10:20:42 +08:00
zdl
e8285599e8 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  fix: 去除个股中心动画,添加mock数据
  feat: 首页代码优化
2025-12-03 18:31:27 +08:00
0eb760fa31 update pay ui 2025-12-03 17:40:57 +08:00
zdl
cdca889083 fix: 去除个股中心动画,添加mock数据 2025-12-03 17:28:23 +08:00
zdl
c0d8bf20a3 feat: 首页代码优化 2025-12-03 17:15:48 +08:00
805b897afa update pay ui 2025-12-03 17:13:49 +08:00
2988af9806 Merge branch 'feature_bugfix/251201_py_h5_ui' of https://git.valuefrontier.cn/vf/vf_react into feature_bugfix/251201_py_h5_ui 2025-12-03 16:50:45 +08:00
63023adcf3 update pay ui 2025-12-03 16:50:39 +08:00
zdl
662d140439 feat: 添加mock数据 2025-12-03 15:56:24 +08:00
c136c2aed8 update pay ui 2025-12-03 15:19:23 +08:00
ea1adcb2ca update pay ui 2025-12-03 15:05:41 +08:00
43f32c5af2 update pay ui 2025-12-03 14:28:33 +08:00
6c69ad407d update pay ui 2025-12-03 14:12:14 +08:00
2e7ed4b899 update pay ui 2025-12-03 13:57:38 +08:00
be496290bb update pay ui 2025-12-03 13:51:48 +08:00
51ed56726c update pay ui 2025-12-03 13:43:55 +08:00
9a6230e51e update pay ui 2025-12-03 13:06:23 +08:00
5042d1ee46 update pay ui 2025-12-03 12:52:27 +08:00
01d0a06f6a update pay ui 2025-12-03 12:47:32 +08:00
dd975a65b2 update pay ui 2025-12-03 12:39:59 +08:00
ae9904cd03 update pay ui 2025-12-03 12:22:27 +08:00
368af3f498 update pay ui 2025-12-03 10:45:33 +08:00
03d0a6514c update pay ui 2025-12-03 10:30:49 +08:00
f7f9774caa fix: 恢复原有涨跌幅样式,将周涨幅改为超预期得分
- 恢复HorizontalDynamicNewsEventCard使用StockChangeIndicators组件
- 修改StockChangeIndicators:周涨幅→超预期得分,平均涨幅→平均超额,最大涨幅→最大超额
- 超预期得分显示为分数形式(如60分),根据分数显示不同颜色
2025-12-03 08:38:17 +08:00
1f592b6775 fix: 修复相关股票默认展开和添加超预期得分显示
- 修复事件切换时相关股票被设为折叠的问题,改为默认展开
- 在事件详情面板中添加超预期得分显示(带进度条和配色)
- 超预期得分显示在事件描述下方、相关股票上方
2025-12-03 08:34:41 +08:00
2f580c3c1f fix: 修复Community页面事件卡片显示,替换StockChangeIndicators为EventPriceDisplay
- HorizontalDynamicNewsEventCard 使用 EventPriceDisplay 替换 StockChangeIndicators
- 移除周涨幅、平均涨幅,改为显示最大超额和超预期得分
- 点击最大超额可切换显示平均超额
2025-12-03 08:29:21 +08:00
259b298ea6 update pay ui 2025-12-03 08:24:37 +08:00
5ff68d0790 update pay ui 2025-12-03 08:02:49 +08:00
a14313fdbd update pay ui 2025-12-03 07:26:12 +08:00
4ba6fd34ff update pay ui 2025-12-02 19:44:46 +08:00
642de62566 update pay ui 2025-12-02 18:55:59 +08:00
4ea1ef08f4 update pay ui 2025-12-02 18:50:01 +08:00
2b3700369f update pay ui 2025-12-02 17:55:01 +08:00
f60c6a8ae9 update pay ui 2025-12-02 17:36:35 +08:00
f24f37c50d update pay ui 2025-12-02 17:30:52 +08:00
zdl
0dfbac7248 Merge branch 'feature_bugfix/251201_vf_h5_ui' into feature_bugfix/251201_py_h5_ui
* feature_bugfix/251201_vf_h5_ui:
  feat: 修复 pc 客服弹窗UI展示问题
2025-12-02 16:10:54 +08:00
zdl
143933b480 feat: 修复 pc 客服弹窗UI展示问题 2025-12-02 16:07:41 +08:00
06beeeaee4 update pay ui 2025-12-02 14:30:27 +08:00
d1a222d9e9 update pay ui 2025-12-02 12:22:49 +08:00
bd86ccce85 update pay ui 2025-12-02 12:01:59 +08:00
ed14031d65 update pay ui 2025-12-02 11:07:45 +08:00
9b16d9d162 update pay ui 2025-12-02 10:49:50 +08:00
7708cb1a69 update pay ui 2025-12-02 10:33:55 +08:00
2395d92b17 update pay ui 2025-12-02 08:07:46 +08:00
56 changed files with 4854 additions and 1046 deletions

Binary file not shown.

604
app.py
View File

@@ -1510,8 +1510,8 @@ def initialize_subscription_plans_safe():
pro_plan = SubscriptionPlan(
name='pro',
display_name='Pro版',
description='适合个人投资者的基础功能套餐',
display_name='Pro 专业',
description='事件关联股票深度分析 | 历史事件智能对比复盘 | 事件概念关联与挖掘 | 概念板块个股追踪 | 概念深度研报与解读 | 个股异动实时预警',
monthly_price=0.01,
yearly_price=0.08,
features=json.dumps([
@@ -1526,8 +1526,8 @@ def initialize_subscription_plans_safe():
max_plan = SubscriptionPlan(
name='max',
display_name='Max版',
description='适合专业投资者的全功能套餐',
display_name='Max 旗舰',
description='包含Pro版全部功能 | 事件传导链路智能分析 | 概念演变时间轴追溯 | 个股全方位深度研究 | 价小前投研助手无限使用 | 新功能优先体验权 | 专属客服一对一服务',
monthly_price=0.1,
yearly_price=0.8,
features=json.dumps([
@@ -5601,24 +5601,31 @@ def get_historical_event_stocks(event_id):
if event_trading_date:
try:
# 查询股票在事件对应交易日的数据
# ea_trade 表字段F007N=最近成交价(收盘价), F010N=涨跌幅
base_stock_code = stock.stock_code.split('.')[0] if stock.stock_code else ''
# 日期格式转换为 YYYYMMDD 整数ea_trade.TRADEDATE 是 int 类型)
if hasattr(event_trading_date, 'strftime'):
trade_date_int = int(event_trading_date.strftime('%Y%m%d'))
else:
trade_date_int = int(str(event_trading_date).replace('-', ''))
with engine.connect() as conn:
query = text("""
SELECT close_price, change_pct
FROM ea_dailyline
WHERE seccode = :stock_code
AND date = :trading_date
ORDER BY date DESC
LIMIT 1
SELECT F007N as close_price, F010N as change_pct
FROM ea_trade
WHERE SECCODE = :stock_code
AND TRADEDATE = :trading_date
LIMIT 1
""")
result = conn.execute(query, {
'stock_code': stock.stock_code,
'trading_date': event_trading_date
'stock_code': base_stock_code,
'trading_date': trade_date_int
}).fetchone()
if result:
stock_data['event_day_close'] = float(result[0]) if result[0] else None
stock_data['event_day_change_pct'] = float(result[1]) if result[1] else None
print(f"[DEBUG] 股票{base_stock_code}{trade_date_int}: close={result[0]}, change_pct={result[1]}")
else:
stock_data['event_day_close'] = None
stock_data['event_day_change_pct'] = None
@@ -5801,6 +5808,23 @@ def get_stock_quotes():
if not codes:
return jsonify({'success': False, 'error': '请提供股票代码'}), 400
# 标准化股票代码(确保带后缀,用于 ClickHouse 查询)
def normalize_stock_code(code):
"""将股票代码标准化为带后缀格式(如 300274.SZ"""
if '.' in code:
return code # 已经带后缀
# 根据代码规则添加后缀6/0/3开头为深圳其他为上海
if code.startswith(('6',)):
return f"{code}.SH"
else:
return f"{code}.SZ"
# 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询
original_codes = codes
normalized_codes = [normalize_stock_code(code) for code in codes]
# 创建原始代码到标准化代码的映射
code_mapping = dict(zip(original_codes, normalized_codes))
# 处理事件时间
if event_time_str:
try:
@@ -5829,13 +5853,12 @@ def get_stock_quotes():
# 构建代码到名称的映射
base_name_map = {row[0]: row[1] for row in result}
# 为每个完整代码(带后缀)分配名称
for code in codes:
base_code = code.split('.')[0]
if base_code in base_name_map:
stock_names[code] = base_name_map[base_code]
else:
stock_names[code] = f"股票{base_code}"
# 为原始代码和标准化代码都分配名称
for orig_code, norm_code in code_mapping.items():
base_code = orig_code.split('.')[0]
name = base_name_map.get(base_code, f"股票{base_code}")
stock_names[orig_code] = name
stock_names[norm_code] = name
def get_trading_day_and_times(event_datetime):
event_date = event_datetime.date()
@@ -5897,6 +5920,21 @@ def get_stock_quotes():
start_datetime = datetime.combine(trading_day, start_time)
end_datetime = datetime.combine(trading_day, end_time)
# 获取前一个交易日(用于计算涨跌幅基准)
prev_trading_day = None
with engine.connect() as conn:
result = conn.execute(text("""
SELECT EXCHANGE_DATE
FROM trading_days
WHERE EXCHANGE_DATE < :date
ORDER BY EXCHANGE_DATE DESC
LIMIT 1
"""), {"date": trading_day}).fetchone()
if result:
prev_trading_day = result[0].date() if hasattr(result[0], 'date') else result[0]
print(f"当前交易日: {trading_day}, 前一交易日: {prev_trading_day}")
# If the trading day is in the future relative to current time,
# return only names without data
if trading_day > current_time.date():
@@ -5912,19 +5950,38 @@ def get_stock_quotes():
# ==================== 性能优化:批量查询所有股票数据 ====================
# 使用 IN 子句一次查询所有股票,避免逐只循环查询
try:
# 批量查询价格和涨跌幅数据(使用窗口函数
# 先从 MySQL ea_trade 表查询前一交易日的收盘价(日线数据,查询更快
prev_close_map = {}
if prev_trading_day:
with engine.connect() as conn:
# 提取不带后缀的股票代码用于查询
base_codes = list(set([code.split('.')[0] for code in codes]))
if base_codes:
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
params = {f'code{i}': code for i, code in enumerate(base_codes)}
params['trade_date'] = prev_trading_day
prev_close_result = conn.execute(text(f"""
SELECT SECCODE, F007N as close_price
FROM ea_trade
WHERE SECCODE IN ({placeholders})
AND TRADEDATE = :trade_date
"""), params).fetchall()
# 构建代码到收盘价的映射(需要匹配完整代码格式)
base_close_map = {row[0]: float(row[1]) if row[1] else None for row in prev_close_result}
# 为每个标准化代码(带后缀)分配收盘价,用于 ClickHouse 查询结果匹配
for norm_code in normalized_codes:
base_code = norm_code.split('.')[0]
if base_code in base_close_map:
prev_close_map[norm_code] = base_close_map[base_code]
print(f"前一交易日({prev_trading_day})收盘价查询返回 {len(prev_close_result)} 条数据")
# 批量查询当前价格数据
batch_price_query = """
WITH first_prices AS (
SELECT
code,
close as first_price,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp ASC) as rn
FROM stock_minute
WHERE code IN %(codes)s
AND timestamp >= %(start)s
AND timestamp <= %(end)s
),
last_prices AS (
WITH last_prices AS (
SELECT
code,
close as last_price,
@@ -5934,84 +5991,95 @@ def get_stock_quotes():
AND timestamp >= %(start)s
AND timestamp <= %(end)s
)
SELECT
fp.code,
lp.last_price,
(lp.last_price - fp.first_price) / fp.first_price * 100 as change_pct
FROM first_prices fp
INNER JOIN last_prices lp ON fp.code = lp.code
WHERE fp.rn = 1 AND lp.rn = 1
SELECT code, last_price
FROM last_prices
WHERE rn = 1
"""
batch_data = client.execute(batch_price_query, {
'codes': codes,
'codes': normalized_codes, # 使用标准化后的代码查询 ClickHouse
'start': start_datetime,
'end': end_datetime
})
print(f"批量查询返回 {len(batch_data)} 条价格数据")
# 解析批量查询结果
# 解析批量查询结果,使用前一交易日收盘价计算涨跌幅
price_data_map = {}
for row in batch_data:
code = row[0]
last_price = float(row[1]) if row[1] is not None else None
change_pct = float(row[2]) if row[2] is not None else None
prev_close = prev_close_map.get(code)
# 计算涨跌幅:(当前价 - 前一交易日收盘价) / 前一交易日收盘价 * 100
change_pct = None
if last_price is not None and prev_close is not None and prev_close > 0:
change_pct = (last_price - prev_close) / prev_close * 100
price_data_map[code] = {
'price': last_price,
'change': change_pct
}
# 组装结果(所有股票)
for code in codes:
price_info = price_data_map.get(code)
# 组装结果(所有股票)- 使用原始代码作为 key 返回
for orig_code in original_codes:
norm_code = code_mapping[orig_code]
price_info = price_data_map.get(norm_code)
if price_info:
results[code] = {
results[orig_code] = {
'price': price_info['price'],
'change': price_info['change'],
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
'name': stock_names.get(orig_code, stock_names.get(norm_code, f'股票{orig_code.split(".")[0]}'))
}
else:
# 批量查询没有返回的股票
results[code] = {
results[orig_code] = {
'price': None,
'change': None,
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
'name': stock_names.get(orig_code, stock_names.get(norm_code, f'股票{orig_code.split(".")[0]}'))
}
except Exception as e:
print(f"批量查询 ClickHouse 失败: {e},回退到逐只查询")
# 降级方案:逐只股票查询(保持向后兼容
for code in codes:
# 降级方案:逐只股票查询(使用前一交易日收盘价计算涨跌幅
for orig_code in original_codes:
norm_code = code_mapping[orig_code]
try:
data = client.execute("""
WITH first_price AS (
SELECT close FROM stock_minute
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
ORDER BY timestamp LIMIT 1
),
last_price AS (
SELECT close FROM stock_minute
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
ORDER BY timestamp DESC LIMIT 1
)
SELECT last_price.close as last_price,
(last_price.close - first_price.close) / first_price.close * 100 as change
FROM last_price CROSS JOIN first_price
WHERE EXISTS (SELECT 1 FROM first_price) AND EXISTS (SELECT 1 FROM last_price)
""", {'code': code, 'start': start_datetime, 'end': end_datetime})
# 查询当前价格(使用标准化代码查询 ClickHouse
current_data = client.execute("""
SELECT close FROM stock_minute
WHERE code = %(code)s AND timestamp >= %(start)s AND timestamp <= %(end)s
ORDER BY timestamp DESC LIMIT 1
""", {'code': norm_code, 'start': start_datetime, 'end': end_datetime})
if data and data[0] and data[0][0] is not None:
results[code] = {
'price': float(data[0][0]) if data[0][0] is not None else None,
'change': float(data[0][1]) if data[0][1] is not None else None,
'name': stock_names.get(code, f'股票{code.split(".")[0]}')
}
else:
results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
last_price = float(current_data[0][0]) if current_data and current_data[0] and current_data[0][0] else None
# 从 MySQL ea_trade 表查询前一交易日收盘价
prev_close = None
if prev_trading_day and last_price is not None:
base_code = orig_code.split('.')[0]
with engine.connect() as conn:
prev_result = conn.execute(text("""
SELECT F007N as close_price
FROM ea_trade
WHERE SECCODE = :code AND TRADEDATE = :trade_date
"""), {'code': base_code, 'trade_date': prev_trading_day}).fetchone()
prev_close = float(prev_result[0]) if prev_result and prev_result[0] else None
# 计算涨跌幅
change_pct = None
if last_price is not None and prev_close is not None and prev_close > 0:
change_pct = (last_price - prev_close) / prev_close * 100
# 使用原始代码作为 key 返回
results[orig_code] = {
'price': last_price,
'change': change_pct,
'name': stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}')
}
except Exception as inner_e:
print(f"Error processing stock {code}: {inner_e}")
results[code] = {'price': None, 'change': None, 'name': stock_names.get(code, f'股票{code.split(".")[0]}')}
print(f"Error processing stock {orig_code}: {inner_e}")
results[orig_code] = {'price': None, 'change': None, 'name': stock_names.get(orig_code, f'股票{orig_code.split(".")[0]}')}
# 返回标准格式
return jsonify({'success': True, 'data': results})
@@ -6243,6 +6311,228 @@ def get_stock_kline(stock_code):
return jsonify({'error': f'Unsupported chart type: {chart_type}'}), 400
@app.route('/api/stock/batch-kline', methods=['POST'])
def get_batch_kline_data():
"""批量获取多只股票的K线/分时数据
请求体:{
codes: string[],
type: 'timeline'|'daily',
event_time?: string,
days_before?: number, # 查询事件日期前多少天的数据默认60最大365
end_date?: string # 分页加载时指定结束日期(用于加载更早的数据)
}
返回:{ success: true, data: { [code]: { data: [], trade_date: '', ... } }, has_more: boolean }
"""
try:
data = request.json
codes = data.get('codes', [])
chart_type = data.get('type', 'timeline')
event_time = data.get('event_time')
days_before = min(int(data.get('days_before', 60)), 365) # 默认60天最多365天
custom_end_date = data.get('end_date') # 用于分页加载更早数据
if not codes:
return jsonify({'success': False, 'error': '请提供股票代码列表'}), 400
if len(codes) > 50:
return jsonify({'success': False, 'error': '单次最多查询50只股票'}), 400
# 标准化股票代码(确保带后缀,用于 ClickHouse 查询)
def normalize_stock_code(code):
"""将股票代码标准化为带后缀格式(如 300274.SZ"""
if '.' in code:
return code # 已经带后缀
# 根据代码规则添加后缀6开头为上海其他为深圳
if code.startswith(('6',)):
return f"{code}.SH"
else:
return f"{code}.SZ"
# 保留原始代码用于返回结果,同时创建标准化代码用于 ClickHouse 查询
original_codes = codes
normalized_codes = [normalize_stock_code(code) for code in codes]
code_mapping = dict(zip(original_codes, normalized_codes))
reverse_mapping = dict(zip(normalized_codes, original_codes))
try:
event_datetime = datetime.fromisoformat(event_time) if event_time else datetime.now()
except ValueError:
return jsonify({'success': False, 'error': 'Invalid event_time format'}), 400
client = get_clickhouse_client()
# 批量获取股票名称
stock_names = {}
with engine.connect() as conn:
base_codes = list(set([code.split('.')[0] for code in codes]))
if base_codes:
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
params = {f'code{i}': code for i, code in enumerate(base_codes)}
result = conn.execute(text(
f"SELECT SECCODE, SECNAME FROM ea_stocklist WHERE SECCODE IN ({placeholders})"
), params).fetchall()
for row in result:
stock_names[row[0]] = row[1]
# 确定目标交易日
target_date = get_trading_day_near_date(event_datetime.date())
is_after_market = event_datetime.time() > dt_time(15, 0)
if target_date and is_after_market:
next_trade_date = get_trading_day_near_date(target_date + timedelta(days=1))
if next_trade_date:
target_date = next_trade_date
if not target_date:
# 返回空数据(使用原始代码作为 key
return jsonify({
'success': True,
'data': {code: {'data': [], 'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'type': chart_type} for code in original_codes}
})
start_time = datetime.combine(target_date, dt_time(9, 30))
end_time = datetime.combine(target_date, dt_time(15, 0))
results = {}
if chart_type == 'timeline':
# 批量查询分时数据(使用标准化代码查询 ClickHouse
batch_data = client.execute("""
SELECT code, timestamp, close, volume
FROM stock_minute
WHERE code IN %(codes)s
AND timestamp BETWEEN %(start)s AND %(end)s
ORDER BY code, timestamp
""", {
'codes': normalized_codes, # 使用标准化代码
'start': start_time,
'end': end_time
})
# 按股票代码分组(标准化代码 -> 数据列表)
stock_data = {}
for row in batch_data:
norm_code = row[0]
if norm_code not in stock_data:
stock_data[norm_code] = []
stock_data[norm_code].append({
'time': row[1].strftime('%H:%M'),
'price': float(row[2]),
'volume': float(row[3])
})
# 组装结果(使用原始代码作为 key 返回)
for orig_code in original_codes:
norm_code = code_mapping[orig_code]
base_code = orig_code.split('.')[0]
stock_name = stock_names.get(base_code, f'股票{base_code}')
data_list = stock_data.get(norm_code, [])
results[orig_code] = {
'code': orig_code,
'name': stock_name,
'data': data_list,
'trade_date': target_date.strftime('%Y-%m-%d'),
'type': 'timeline'
}
elif chart_type == 'daily':
# 批量查询日线数据从MySQL ea_trade表
with engine.connect() as conn:
base_codes = list(set([code.split('.')[0] for code in codes]))
if base_codes:
placeholders = ','.join([f':code{i}' for i in range(len(base_codes))])
params = {f'code{i}': code for i, code in enumerate(base_codes)}
# 确定查询的日期范围
# 如果指定了 custom_end_date用于分页加载更早的数据
if custom_end_date:
try:
end_date_obj = datetime.strptime(custom_end_date, '%Y-%m-%d').date()
except ValueError:
end_date_obj = target_date
else:
end_date_obj = target_date
# TRADEDATE 是整数格式 YYYYMMDD需要转换日期格式
start_date = end_date_obj - timedelta(days=days_before)
params['start_date'] = int(start_date.strftime('%Y%m%d'))
params['end_date'] = int(end_date_obj.strftime('%Y%m%d'))
daily_result = conn.execute(text(f"""
SELECT SECCODE, TRADEDATE, F003N as open, F005N as high, F006N as low, F007N as close, F004N as volume
FROM ea_trade
WHERE SECCODE IN ({placeholders})
AND TRADEDATE BETWEEN :start_date AND :end_date
ORDER BY SECCODE, TRADEDATE
"""), params).fetchall()
# 按股票代码分组
stock_data = {}
for row in daily_result:
code_base = row[0]
if code_base not in stock_data:
stock_data[code_base] = []
# 日期格式处理TRADEDATE 可能是 datetime 或 int(YYYYMMDD)
trade_date_val = row[1]
if hasattr(trade_date_val, 'strftime'):
date_str = trade_date_val.strftime('%Y-%m-%d')
elif isinstance(trade_date_val, int):
# 整数格式 YYYYMMDD -> YYYY-MM-DD
date_str = f"{str(trade_date_val)[:4]}-{str(trade_date_val)[4:6]}-{str(trade_date_val)[6:8]}"
else:
date_str = str(trade_date_val)
stock_data[code_base].append({
'time': date_str, # 统一使用 time 字段,与前端期望一致
'open': float(row[2]) if row[2] else 0,
'high': float(row[3]) if row[3] else 0,
'low': float(row[4]) if row[4] else 0,
'close': float(row[5]) if row[5] else 0,
'volume': float(row[6]) if row[6] else 0
})
# 组装结果(使用原始代码作为 key 返回)
# 同时计算最早日期,用于判断是否还有更多数据
earliest_dates = {}
for orig_code in original_codes:
base_code = orig_code.split('.')[0]
stock_name = stock_names.get(base_code, f'股票{base_code}')
data_list = stock_data.get(base_code, [])
# 记录每只股票的最早日期
if data_list:
earliest_dates[orig_code] = data_list[0]['time']
results[orig_code] = {
'code': orig_code,
'name': stock_name,
'data': data_list,
'trade_date': target_date.strftime('%Y-%m-%d'),
'type': 'daily',
'earliest_date': data_list[0]['time'] if data_list else None
}
# 计算是否还有更多历史数据基于事件日期往前推365天
event_date = event_datetime.date()
one_year_ago = event_date - timedelta(days=365)
# 如果当前查询的起始日期还没到一年前,则还有更多数据
has_more = start_date > one_year_ago if chart_type == 'daily' else False
print(f"批量K线查询完成: {len(codes)} 只股票, 类型: {chart_type}, 交易日: {target_date}, days_before: {days_before}, has_more: {has_more}")
return jsonify({
'success': True,
'data': results,
'has_more': has_more,
'query_start_date': start_date.strftime('%Y-%m-%d') if chart_type == 'daily' else None,
'query_end_date': end_date_obj.strftime('%Y-%m-%d') if chart_type == 'daily' else None
})
except Exception as e:
print(f"批量K线查询错误: {e}")
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/stock/<stock_code>/latest-minute', methods=['GET'])
def get_latest_minute_data(stock_code):
"""获取最新交易日的分钟频数据"""
@@ -7289,6 +7579,135 @@ def get_timeline_data(stock_code, event_datetime, stock_name):
# ==================== 指数行情API与股票逻辑一致数据表为 index_minute ====================
@app.route('/api/index/<index_code>/realtime')
def get_index_realtime(index_code):
"""
获取指数实时行情(用于交易时间内的行情更新)
从 index_minute 表获取最新的分钟数据
返回: 最新价、涨跌幅、涨跌额、开盘价、最高价、最低价、昨收价
"""
# 确保指数代码包含后缀ClickHouse 中存储的是带后缀的代码)
# 上证指数: 000xxx.SH, 深证指数: 399xxx.SZ
if '.' not in index_code:
if index_code.startswith('399'):
index_code = f"{index_code}.SZ"
else:
# 000开头的上证指数以及其他指数默认上海
index_code = f"{index_code}.SH"
client = get_clickhouse_client()
today = date.today()
# 判断今天是否是交易日
if today not in trading_days_set:
# 非交易日,获取最近一个交易日的收盘数据
target_date = get_trading_day_near_date(today)
if not target_date:
return jsonify({
'success': False,
'error': 'No trading day found',
'data': None
})
is_trading = False
else:
target_date = today
# 判断是否在交易时间内
now = datetime.now()
current_minutes = now.hour * 60 + now.minute
# 9:30-11:30 = 570-690, 13:00-15:00 = 780-900
is_trading = (570 <= current_minutes <= 690) or (780 <= current_minutes <= 900)
try:
# 获取当天/最近交易日的第一条数据(开盘价)和最后一条数据(最新价)
# 同时获取最高价和最低价
data = client.execute(
"""
SELECT
min(open) as first_open,
max(high) as day_high,
min(low) as day_low,
argMax(close, timestamp) as latest_close,
argMax(timestamp, timestamp) as latest_time
FROM index_minute
WHERE code = %(code)s
AND toDate(timestamp) = %(date)s
""",
{
'code': index_code,
'date': target_date,
}
)
if not data or not data[0] or data[0][3] is None:
return jsonify({
'success': False,
'error': 'No data available',
'data': None
})
row = data[0]
first_open = float(row[0]) if row[0] else None
day_high = float(row[1]) if row[1] else None
day_low = float(row[2]) if row[2] else None
latest_close = float(row[3]) if row[3] else None
latest_time = row[4]
# 获取昨收价(从 MySQL ea_exchangetrade 表)
code_no_suffix = index_code.split('.')[0]
prev_close = None
with engine.connect() as conn:
# 获取前一个交易日的收盘价
prev_result = conn.execute(text(
"""
SELECT F006N
FROM ea_exchangetrade
WHERE INDEXCODE = :code
AND TRADEDATE < :today
ORDER BY TRADEDATE DESC LIMIT 1
"""
), {
'code': code_no_suffix,
'today': datetime.combine(target_date, dt_time(0, 0, 0))
}).fetchone()
if prev_result and prev_result[0]:
prev_close = float(prev_result[0])
# 计算涨跌额和涨跌幅
change_amount = None
change_pct = None
if latest_close is not None and prev_close is not None and prev_close > 0:
change_amount = latest_close - prev_close
change_pct = (change_amount / prev_close) * 100
return jsonify({
'success': True,
'data': {
'code': index_code,
'price': latest_close,
'open': first_open,
'high': day_high,
'low': day_low,
'prev_close': prev_close,
'change': change_amount,
'change_pct': change_pct,
'update_time': latest_time.strftime('%H:%M:%S') if latest_time else None,
'trade_date': target_date.strftime('%Y-%m-%d'),
'is_trading': is_trading,
}
})
except Exception as e:
logger.error(f"获取指数实时行情失败: {index_code}, 错误: {str(e)}")
return jsonify({
'success': False,
'error': str(e),
'data': None
}), 500
@app.route('/api/index/<index_code>/kline')
def get_index_kline(index_code):
chart_type = request.args.get('type', 'minute')
@@ -8202,6 +8621,7 @@ def api_get_events():
'related_week_chg': event.related_week_chg,
'invest_score': event.invest_score,
'trending_score': event.trending_score,
'expectation_surprise_score': event.expectation_surprise_score,
})
if include_creator:
event_dict['creator'] = {
@@ -8288,6 +8708,8 @@ def get_hot_events():
'importance': event.importance,
'created_at': event.created_at.isoformat() if event.created_at else None,
'related_avg_chg': event.related_avg_chg,
'related_max_chg': event.related_max_chg,
'expectation_surprise_score': event.expectation_surprise_score,
'creator': {
'username': event.creator.username if event.creator else 'Anonymous'
}
@@ -11810,12 +12232,19 @@ def get_market_statistics():
available_dates = [str(row.TRADEDATE) for row in available_dates_result]
# 格式化日期为 YYYY-MM-DD
formatted_trade_date = trade_date.strftime('%Y-%m-%d') if hasattr(trade_date, 'strftime') else str(trade_date).split(' ')[0][:10]
formatted_available_dates = [
d.strftime('%Y-%m-%d') if hasattr(d, 'strftime') else str(d).split(' ')[0][:10]
for d in [row.TRADEDATE for row in available_dates_result]
]
return jsonify({
'success': True,
'trade_date': str(trade_date),
'trade_date': formatted_trade_date,
'summary': summary,
'details': list(statistics.values()),
'available_dates': available_dates
'available_dates': formatted_available_dates
})
except Exception as e:
@@ -11855,19 +12284,30 @@ def get_daily_top_concepts():
top_concepts = []
for concept in data.get('results', []):
# 保持与 /concept-api/search 相同的字段结构
top_concepts.append({
'concept_id': concept.get('concept_id'),
'concept_name': concept.get('concept'),
'concept': concept.get('concept'), # 原始字段名
'concept_name': concept.get('concept'), # 兼容旧字段名
'description': concept.get('description'),
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0),
'stock_count': concept.get('stock_count', 0),
'stocks': concept.get('stocks', [])[:5] # 只返回前5只股票
'score': concept.get('score'),
'match_type': concept.get('match_type'),
'price_info': concept.get('price_info', {}), # 完整的价格信息
'change_percent': concept.get('price_info', {}).get('avg_change_pct', 0), # 兼容旧字段
'happened_times': concept.get('happened_times', []), # 历史触发时间
'stocks': concept.get('stocks', []), # 返回完整股票列表
'hot_score': concept.get('hot_score')
})
# 格式化日期为 YYYY-MM-DD
price_date = data.get('price_date', '')
formatted_date = str(price_date).split(' ')[0][:10] if price_date else ''
return jsonify({
'success': True,
'data': top_concepts,
'trade_date': data.get('price_date'),
'trade_date': formatted_date,
'count': len(top_concepts)
})
else:

643
app_vx.py
View File

@@ -559,7 +559,86 @@ app.config['COMPRESS_MIMETYPES'] = [
'application/javascript',
'application/x-javascript'
]
user_tokens = {}
# ===================== Token 存储(支持多 worker 共享) =====================
class TokenStore:
"""
Token 存储类 - 支持 Redis多 worker 共享)或内存(单 worker
"""
def __init__(self):
self._redis_client = None
self._memory_store = {}
self._prefix = 'vf_token:'
self._initialized = False
def _ensure_initialized(self):
"""延迟初始化,确保在 fork 后才连接 Redis"""
if self._initialized:
return
self._initialized = True
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
try:
import redis
self._redis_client = redis.from_url(redis_url)
self._redis_client.ping()
logger.info(f"✅ Token 存储: Redis ({redis_url})")
except Exception as e:
logger.warning(f"⚠️ Redis 不可用 ({e})Token 使用内存存储(多 worker 模式下会有问题!)")
self._redis_client = None
def get(self, token):
"""获取 token 数据"""
self._ensure_initialized()
if self._redis_client:
try:
data = self._redis_client.get(f"{self._prefix}{token}")
if data:
return json.loads(data)
return None
except Exception as e:
logger.error(f"Redis get error: {e}")
return self._memory_store.get(token)
return self._memory_store.get(token)
def set(self, token, data, expire_seconds=30*24*3600):
"""设置 token 数据"""
self._ensure_initialized()
if self._redis_client:
try:
# 将 datetime 转为字符串存储
store_data = data.copy()
if 'expires' in store_data and isinstance(store_data['expires'], datetime):
store_data['expires'] = store_data['expires'].isoformat()
self._redis_client.setex(
f"{self._prefix}{token}",
expire_seconds,
json.dumps(store_data)
)
return
except Exception as e:
logger.error(f"Redis set error: {e}")
self._memory_store[token] = data
def delete(self, token):
"""删除 token"""
self._ensure_initialized()
if self._redis_client:
try:
self._redis_client.delete(f"{self._prefix}{token}")
return
except Exception as e:
logger.error(f"Redis delete error: {e}")
self._memory_store.pop(token, None)
def __contains__(self, token):
"""支持 'in' 操作符"""
return self.get(token) is not None
# 使用 TokenStore 替代内存字典
user_tokens = TokenStore()
app.config['SECRET_KEY'] = 'vf7891574233241'
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
@@ -597,14 +676,36 @@ JWT_SECRET_KEY = 'vfllmgreat33818!' # 请修改为安全的密钥
JWT_ALGORITHM = 'HS256'
JWT_EXPIRATION_HOURS = 24 * 7 # Token有效期7天
# Session 配置 - 使用文件系统存储(替代 Redis
app.config['SESSION_TYPE'] = 'filesystem'
app.config['SESSION_FILE_DIR'] = os.path.join(os.path.dirname(__file__), 'flask_session')
# Session 配置
# 优先使用 Redis支持多 worker 共享),否则回退到文件系统
_REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
_USE_REDIS_SESSION = os.environ.get('USE_REDIS_SESSION', 'true').lower() == 'true'
try:
if _USE_REDIS_SESSION:
import redis
# 测试 Redis 连接
_redis_client = redis.from_url(_REDIS_URL)
_redis_client.ping()
app.config['SESSION_TYPE'] = 'redis'
app.config['SESSION_REDIS'] = _redis_client
app.config['SESSION_KEY_PREFIX'] = 'vf_session:'
logger.info(f"✅ Session 存储: Redis ({_REDIS_URL})")
else:
raise Exception("Redis session disabled by config")
except Exception as e:
# Redis 不可用,回退到文件系统
logger.warning(f"⚠️ Redis 不可用 ({e}),使用文件系统 session多 worker 模式下可能不稳定)")
app.config['SESSION_TYPE'] = 'filesystem'
app.config['SESSION_FILE_DIR'] = os.path.join(os.path.dirname(__file__), 'flask_session')
os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True)
app.config['SESSION_PERMANENT'] = True
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session 有效期 7 天
# 确保 session 目录存在
os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True)
app.config['SESSION_COOKIE_SECURE'] = False # 生产环境 HTTPS 时设为 True
app.config['SESSION_COOKIE_HTTPONLY'] = True
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
# Cache directory setup
CACHE_DIR = Path('cache')
@@ -661,9 +762,12 @@ def token_required(f):
if not token_data:
return jsonify({'message': 'Token无效', 'code': 401}), 401
# 检查是否过期
if token_data['expires'] < datetime.now():
del user_tokens[token]
# 检查是否过期expires 可能是字符串或 datetime
expires = token_data['expires']
if isinstance(expires, str):
expires = datetime.fromisoformat(expires)
if expires < datetime.now():
user_tokens.delete(token)
return jsonify({'message': 'Token已过期'}), 401
# 获取用户对象并添加到请求上下文
@@ -3438,7 +3542,7 @@ def logout_with_token():
token = data.get('token') if data else None
if token and token in user_tokens:
del user_tokens[token]
user_tokens.delete(token)
# 清除session
session.clear()
@@ -3595,10 +3699,10 @@ def login_with_phone():
token = generate_token(32)
# 存储token映射30天有效期
user_tokens[token] = {
user_tokens.set(token, {
'user_id': user.id,
'expires': datetime.now() + timedelta(days=30)
}
})
# 清除验证码
del verification_codes[phone]
@@ -3648,9 +3752,12 @@ def verify_token():
if not token_data:
return jsonify({'valid': False, 'message': 'Token无效', 'code': 401}), 401
# 检查是否过期
if token_data['expires'] < datetime.now():
del user_tokens[token]
# 检查是否过期expires 可能是字符串或 datetime
expires = token_data['expires']
if isinstance(expires, str):
expires = datetime.fromisoformat(expires)
if expires < datetime.now():
user_tokens.delete(token)
return jsonify({'valid': False, 'message': 'Token已过期'}), 401
# 获取用户信息
@@ -3883,10 +3990,10 @@ def api_login_wechat():
token = generate_token(32) # 使用相同的随机字符串生成器
# 存储token映射与手机登录保持一致
user_tokens[token] = {
user_tokens.set(token, {
'user_id': user.id,
'expires': datetime.now() + timedelta(days=30) # 30天有效期
}
})
# 设置session可选保持与手机登录一致
session.permanent = True
@@ -5274,6 +5381,114 @@ def get_comment_replies(comment_id):
}), 500
# 工具函数解析JSON字段
def parse_json_field(field_value):
"""解析JSON字段"""
if not field_value:
return []
try:
if isinstance(field_value, str):
if field_value.startswith('['):
return json.loads(field_value)
else:
return field_value.split(',')
else:
return field_value
except:
return []
# 工具函数:获取 future_events 表字段值,支持新旧字段回退
def get_future_event_field(row, new_field, old_field):
"""
获取 future_events 表字段值,支持新旧字段回退
如果新字段存在且不为空,使用新字段;否则使用旧字段
"""
new_value = getattr(row, new_field, None) if hasattr(row, new_field) else None
old_value = getattr(row, old_field, None) if hasattr(row, old_field) else None
# 如果新字段有值(不为空字符串),使用新字段
if new_value is not None and str(new_value).strip():
return new_value
return old_value
# 工具函数:解析新的 best_matches 数据结构(含研报引用信息)
def parse_best_matches(best_matches_value):
"""
解析新的 best_matches 数据结构(含研报引用信息)
新结构示例:
[
{
"stock_code": "300451.SZ",
"company_name": "创业慧康",
"original_description": "核心标的,医疗信息化...",
"best_report_title": "报告标题",
"best_report_author": "作者",
"best_report_sentences": "相关内容",
"best_report_match_score": "",
"best_report_match_ratio": 0.9285714285714286,
"best_report_declare_date": "2023-04-25T00:00:00",
"total_reports": 9,
"high_score_reports": 6
},
...
]
返回统一格式的股票列表,兼容旧格式
"""
if not best_matches_value:
return []
try:
# 解析 JSON
if isinstance(best_matches_value, str):
data = json.loads(best_matches_value)
else:
data = best_matches_value
if not isinstance(data, list):
return []
result = []
for item in data:
if isinstance(item, dict):
# 新结构:包含研报信息的字典
stock_info = {
'code': item.get('stock_code', ''),
'name': item.get('company_name', ''),
'description': item.get('original_description', ''),
'score': item.get('best_report_match_ratio', 0),
# 研报引用信息
'report': {
'title': item.get('best_report_title', ''),
'author': item.get('best_report_author', ''),
'sentences': item.get('best_report_sentences', ''),
'match_score': item.get('best_report_match_score', ''),
'match_ratio': item.get('best_report_match_ratio', 0),
'declare_date': item.get('best_report_declare_date', ''),
'total_reports': item.get('total_reports', 0),
'high_score_reports': item.get('high_score_reports', 0)
} if item.get('best_report_title') else None
}
result.append(stock_info)
elif isinstance(item, (list, tuple)) and len(item) >= 2:
# 旧结构:[code, name, description, score]
result.append({
'code': item[0],
'name': item[1],
'description': item[2] if len(item) > 2 else '',
'score': item[3] if len(item) > 3 else 0,
'report': None
})
return result
except Exception as e:
print(f"parse_best_matches error: {e}")
return []
# 工具函数:处理转义字符,保留 Markdown 格式
def unescape_markdown_text(text):
"""
@@ -5363,6 +5578,7 @@ def api_calendar_events():
offset = (page - 1) * per_page
# 构建基础查询 - 使用 future_events 表
# 添加新字段 second_modified_text, `second_modified_text.1`, best_matches 支持新旧回退
query = """
SELECT data_id, \
calendar_time, \
@@ -5374,7 +5590,10 @@ def api_calendar_events():
fact, \
related_stocks, \
concepts, \
inferred_tag
inferred_tag, \
second_modified_text, \
`second_modified_text.1` as second_modified_text_1, \
best_matches
FROM future_events
WHERE 1 = 1 \
"""
@@ -5445,90 +5664,114 @@ def api_calendar_events():
events_data = []
for event in events:
# 解析相关股票
# 使用新字段回退机制获取 former 和 forecast
# second_modified_text -> former
former_value = get_future_event_field(event, 'second_modified_text', 'former')
# second_modified_text.1 -> forecast
forecast_new = getattr(event, 'second_modified_text_1', None)
forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(event, 'forecast', None)
# 解析相关股票 - 优先使用 best_matches回退到 related_stocks
related_stocks_list = []
related_avg_chg = 0
related_max_chg = 0
related_week_chg = 0
# 处理相关股票数据
if event.related_stocks:
# 优先使用 best_matches新结构含研报引用
best_matches = getattr(event, 'best_matches', None)
if best_matches and str(best_matches).strip():
# 使用新的 parse_best_matches 函数解析
parsed_stocks = parse_best_matches(best_matches)
else:
# 回退到旧的 related_stocks 处理
parsed_stocks = []
if event.related_stocks:
try:
import ast
if isinstance(event.related_stocks, str):
try:
stock_data = json.loads(event.related_stocks)
except:
stock_data = ast.literal_eval(event.related_stocks)
else:
stock_data = event.related_stocks
if stock_data:
for stock_info in stock_data:
if isinstance(stock_info, list) and len(stock_info) >= 2:
parsed_stocks.append({
'code': stock_info[0],
'name': stock_info[1],
'description': stock_info[2] if len(stock_info) > 2 else '',
'score': stock_info[3] if len(stock_info) > 3 else 0,
'report': None
})
except Exception as e:
print(f"Error parsing related_stocks for event {event.data_id}: {e}")
# 处理解析后的股票数据,获取交易信息
if parsed_stocks:
try:
import json
import ast
daily_changes = []
week_changes = []
# 使用与detail接口相同的解析逻辑
if isinstance(event.related_stocks, str):
try:
stock_data = json.loads(event.related_stocks)
except:
stock_data = ast.literal_eval(event.related_stocks)
else:
stock_data = event.related_stocks
for stock_info in parsed_stocks:
stock_code = stock_info.get('code', '')
stock_name = stock_info.get('name', '')
description = stock_info.get('description', '')
score = stock_info.get('score', 0)
report = stock_info.get('report', None)
if stock_data:
daily_changes = []
week_changes = []
if stock_code:
# 规范化股票代码,移除后缀
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
# 处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]
for stock_info in stock_data:
if isinstance(stock_info, list) and len(stock_info) >= 2:
stock_code = stock_info[0] # 股票代码
stock_name = stock_info[1] # 股票名称
description = stock_info[2] if len(stock_info) > 2 else ''
score = stock_info[3] if len(stock_info) > 3 else 0
else:
continue
# 使用模糊匹配查询真实的交易数据
trade_query = """
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
FROM ea_trade
WHERE SECCODE LIKE :stock_code_pattern
ORDER BY TRADEDATE DESC LIMIT 7 \
"""
trade_result = db.session.execute(text(trade_query),
{'stock_code_pattern': f'{clean_code}%'})
trade_data = trade_result.fetchall()
if stock_code:
# 规范化股票代码,移除后缀
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
daily_chg = 0
week_chg = 0
# 使用模糊匹配查询真实的交易数据
trade_query = """
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
FROM ea_trade
WHERE SECCODE LIKE :stock_code_pattern
ORDER BY TRADEDATE DESC LIMIT 7 \
"""
trade_result = db.session.execute(text(trade_query),
{'stock_code_pattern': f'{clean_code}%'})
trade_data = trade_result.fetchall()
if trade_data:
# 日涨跌幅(当日)
daily_chg = float(trade_data[0].change_pct or 0)
daily_chg = 0
week_chg = 0
# 周涨跌幅5个交易日
if len(trade_data) >= 5:
current_price = float(trade_data[0].close_price or 0)
week_ago_price = float(trade_data[4].close_price or 0)
if week_ago_price > 0:
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
if trade_data:
# 日涨跌幅(当日)
daily_chg = float(trade_data[0].change_pct or 0)
# 收集涨跌幅数据
daily_changes.append(daily_chg)
week_changes.append(week_chg)
# 周涨跌幅5个交易日
if len(trade_data) >= 5:
current_price = float(trade_data[0].close_price or 0)
week_ago_price = float(trade_data[4].close_price or 0)
if week_ago_price > 0:
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
related_stocks_list.append({
'code': stock_code,
'name': stock_name,
'description': description,
'score': score,
'daily_chg': daily_chg,
'week_chg': week_chg,
'report': report # 添加研报引用信息
})
# 收集涨跌幅数据
daily_changes.append(daily_chg)
week_changes.append(week_chg)
# 计算平均收益率
if daily_changes:
related_avg_chg = round(sum(daily_changes) / len(daily_changes), 4)
related_max_chg = round(max(daily_changes), 4)
related_stocks_list.append({
'code': stock_code,
'name': stock_name,
'description': description,
'score': score,
'daily_chg': daily_chg,
'week_chg': week_chg
})
# 计算平均收益率
if daily_changes:
related_avg_chg = round(sum(daily_changes) / len(daily_changes), 4)
related_max_chg = round(max(daily_changes), 4)
if week_changes:
related_week_chg = round(sum(week_changes) / len(week_changes), 4)
if week_changes:
related_week_chg = round(sum(week_changes) / len(week_changes), 4)
except Exception as e:
print(f"Error processing related stocks for event {event.data_id}: {e}")
@@ -5553,8 +5796,9 @@ def api_calendar_events():
highlight_match = 'concepts'
# 将转义的换行符转换为真正的换行符,保留 Markdown 格式
cleaned_former = unescape_markdown_text(event.former)
cleaned_forecast = unescape_markdown_text(event.forecast)
# 使用新字段回退后的值former_value, forecast_value
cleaned_former = unescape_markdown_text(former_value)
cleaned_forecast = unescape_markdown_text(forecast_value)
cleaned_fact = unescape_markdown_text(event.fact)
event_dict = {
@@ -5800,6 +6044,7 @@ def api_future_event_detail(item_id):
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员"""
try:
# 从 future_events 表查询事件详情
# 添加新字段 second_modified_text, `second_modified_text.1`, best_matches 支持新旧回退
query = """
SELECT data_id, \
calendar_time, \
@@ -5810,7 +6055,10 @@ def api_future_event_detail(item_id):
forecast, \
fact, \
related_stocks, \
concepts
concepts, \
second_modified_text, \
`second_modified_text.1` as second_modified_text_1, \
best_matches
FROM future_events
WHERE data_id = :item_id \
"""
@@ -5825,6 +6073,13 @@ def api_future_event_detail(item_id):
'data': None
}), 404
# 使用新字段回退机制获取 former 和 forecast
# second_modified_text -> former
former_value = get_future_event_field(event, 'second_modified_text', 'former')
# second_modified_text.1 -> forecast
forecast_new = getattr(event, 'second_modified_text_1', None)
forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(event, 'forecast', None)
extracted_concepts = extract_concepts_from_concepts_field(event.concepts)
# 解析相关股票
@@ -5868,136 +6123,150 @@ def api_future_event_detail(item_id):
'环保': '公共产业板块', '综合': '公共产业板块'
}
# 处理相关股票
# 处理相关股票 - 优先使用 best_matches回退到 related_stocks
related_avg_chg = 0
related_max_chg = 0
related_week_chg = 0
if event.related_stocks:
# 优先使用 best_matches新结构含研报引用
best_matches = getattr(event, 'best_matches', None)
if best_matches and str(best_matches).strip():
# 使用新的 parse_best_matches 函数解析
parsed_stocks = parse_best_matches(best_matches)
else:
# 回退到旧的 related_stocks 处理
parsed_stocks = []
if event.related_stocks:
try:
import ast
if isinstance(event.related_stocks, str):
try:
stock_data = json.loads(event.related_stocks)
except:
stock_data = ast.literal_eval(event.related_stocks)
else:
stock_data = event.related_stocks
if stock_data:
for stock_info in stock_data:
if isinstance(stock_info, list) and len(stock_info) >= 2:
parsed_stocks.append({
'code': stock_info[0],
'name': stock_info[1],
'description': stock_info[2] if len(stock_info) > 2 else '',
'score': stock_info[3] if len(stock_info) > 3 else 0,
'report': None
})
except Exception as e:
print(f"Error parsing related_stocks for event {event.data_id}: {e}")
# 处理解析后的股票数据
if parsed_stocks:
try:
import json
import ast
daily_changes = []
week_changes = []
# **修正正确解析related_stocks数据结构**
if isinstance(event.related_stocks, str):
try:
# 先尝试JSON解析
stock_data = json.loads(event.related_stocks)
except:
# 如果JSON解析失败尝试ast.literal_eval解析
stock_data = ast.literal_eval(event.related_stocks)
else:
stock_data = event.related_stocks
for stock_info in parsed_stocks:
stock_code = stock_info.get('code', '')
stock_name = stock_info.get('name', '')
description = stock_info.get('description', '')
score = stock_info.get('score', 0)
report = stock_info.get('report', None)
print(f"Parsed stock_data: {stock_data}") # 调试输出
if stock_code:
# 规范化股票代码,移除后缀
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
if stock_data:
daily_changes = []
week_changes = []
print(f"Processing stock: {clean_code} - {stock_name}") # 调试输出
# **修正:处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]**
for stock_info in stock_data:
if isinstance(stock_info, list) and len(stock_info) >= 2:
stock_code = stock_info[0] # 第一个元素是股票代码
stock_name = stock_info[1] # 第二个元素是股票名称
description = stock_info[2] if len(stock_info) > 2 else ''
score = stock_info[3] if len(stock_info) > 3 else 0
else:
continue # 跳过格式不正确的数据
# 使用模糊匹配LIKE查询申万一级行业F004V
sector_query = """
SELECT F004V as sw_primary_sector
FROM ea_sector
WHERE SECCODE LIKE :stock_code_pattern
AND F002V = '申银万国行业分类' LIMIT 1 \
"""
sector_result = db.session.execute(text(sector_query),
{'stock_code_pattern': f'{clean_code}%'})
sector_row = sector_result.fetchone()
if stock_code:
# 规范化股票代码,移除后缀
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
# 根据申万一级行业F004V映射到主板块
sw_primary_sector = sector_row.sw_primary_sector if sector_row else None
primary_sector = sector_map.get(sw_primary_sector, '其他') if sw_primary_sector else '其他'
print(f"Processing stock: {clean_code} - {stock_name}") # 调试输出
print(
f"Stock: {clean_code}, SW Primary: {sw_primary_sector}, Primary Sector: {primary_sector}")
# 使用模糊匹配LIKE查询申万一级行业F004V
sector_query = """
SELECT F004V as sw_primary_sector
FROM ea_sector
WHERE SECCODE LIKE :stock_code_pattern
AND F002V = '申银万国行业分类' LIMIT 1 \
"""
sector_result = db.session.execute(text(sector_query),
{'stock_code_pattern': f'{clean_code}%'})
sector_row = sector_result.fetchone()
# 通过SQL查询获取真实的日涨跌幅和周涨跌幅
trade_query = """
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
FROM ea_trade
WHERE SECCODE LIKE :stock_code_pattern
ORDER BY TRADEDATE DESC LIMIT 7 \
"""
trade_result = db.session.execute(text(trade_query),
{'stock_code_pattern': f'{clean_code}%'})
trade_data = trade_result.fetchall()
# 根据申万一级行业F004V映射到主板块
sw_primary_sector = sector_row.sw_primary_sector if sector_row else None
primary_sector = sector_map.get(sw_primary_sector, '其他') if sw_primary_sector else '其他'
daily_chg = 0
week_chg = 0
print(
f"Stock: {clean_code}, SW Primary: {sw_primary_sector}, Primary Sector: {primary_sector}")
if trade_data:
# 日涨跌幅(当日)
daily_chg = float(trade_data[0].change_pct or 0)
# 通过SQL查询获取真实的日涨跌幅和周涨跌幅
trade_query = """
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
FROM ea_trade
WHERE SECCODE LIKE :stock_code_pattern
ORDER BY TRADEDATE DESC LIMIT 7 \
"""
trade_result = db.session.execute(text(trade_query),
{'stock_code_pattern': f'{clean_code}%'})
trade_data = trade_result.fetchall()
# 周涨跌幅5个交易日
if len(trade_data) >= 5:
current_price = float(trade_data[0].close_price or 0)
week_ago_price = float(trade_data[4].close_price or 0)
if week_ago_price > 0:
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
daily_chg = 0
week_chg = 0
print(
f"Trade data found: {len(trade_data) if trade_data else 0} records, daily_chg: {daily_chg}")
if trade_data:
# 日涨跌幅(当日)
daily_chg = float(trade_data[0].change_pct or 0)
# 统计各分类数量
sector_stats['全部股票'] += 1
sector_stats[primary_sector] += 1
# 涨跌幅5个交易日
if len(trade_data) >= 5:
current_price = float(trade_data[0].close_price or 0)
week_ago_price = float(trade_data[4].close_price or 0)
if week_ago_price > 0:
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
# 收集涨跌幅数据
daily_changes.append(daily_chg)
week_changes.append(week_chg)
print(
f"Trade data found: {len(trade_data) if trade_data else 0} records, daily_chg: {daily_chg}")
related_stocks_list.append({
'code': stock_code, # 原始股票代码
'name': stock_name, # 股票名称
'description': description, # 关联描述
'score': score, # 关联分数
'sw_primary_sector': sw_primary_sector, # 申万一级行业F004V
'primary_sector': primary_sector, # 主板块分类
'daily_change': daily_chg, # 真实的日涨跌幅
'week_change': week_chg, # 真实的周涨跌幅
'report': report # 研报引用信息(新字段)
})
# 统计各分类数量
sector_stats['全部股票'] += 1
sector_stats[primary_sector] += 1
# 计算平均收益率
if daily_changes:
related_avg_chg = sum(daily_changes) / len(daily_changes)
related_max_chg = max(daily_changes)
# 收集涨跌幅数据
daily_changes.append(daily_chg)
week_changes.append(week_chg)
related_stocks_list.append({
'code': stock_code, # 原始股票代码
'name': stock_name, # 股票名称
'description': description, # 关联描述
'score': score, # 关联分数
'sw_primary_sector': sw_primary_sector, # 申万一级行业F004V
'primary_sector': primary_sector, # 主板块分类
'daily_change': daily_chg, # 真实的日涨跌幅
'week_change': week_chg # 真实的周涨跌幅
})
# 计算平均收益率
if daily_changes:
related_avg_chg = sum(daily_changes) / len(daily_changes)
related_max_chg = max(daily_changes)
if week_changes:
related_week_chg = sum(week_changes) / len(week_changes)
if week_changes:
related_week_chg = sum(week_changes) / len(week_changes)
except Exception as e:
print(f"Error processing related stocks: {e}")
import traceback
traceback.print_exc()
# 构建返回数据
# 构建返回数据,使用新字段回退后的值
detail_data = {
'id': event.data_id,
'title': event.title,
'type': event.type,
'star': event.star,
'calendar_time': event.calendar_time.isoformat() if event.calendar_time else None,
'former': event.former,
'forecast': event.forecast,
'former': former_value, # 使用回退后的值(优先 second_modified_text
'forecast': forecast_value, # 使用回退后的值(优先 second_modified_text.1
'fact': event.fact,
'concepts': event.concepts,
'extracted_concepts': extracted_concepts,

1176
concept_hierarchy.json Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1999,7 +1999,7 @@ class MCPAgentIntegrated:
model=self.kimi_model,
messages=messages,
temperature=1.0, # Kimi 推荐
max_tokens=8192, # 足够容纳 reasoning_content
max_tokens=128000, # 足够容纳 reasoning_content
)
choice = response.choices[0]
@@ -2074,7 +2074,7 @@ class MCPAgentIntegrated:
model=self.deepmoney_model,
messages=messages,
temperature=0.7,
max_tokens=8192,
max_tokens=32784,
)
summary = response.choices[0].message.content
@@ -2268,7 +2268,7 @@ class MCPAgentIntegrated:
model="kimi-k2-turbo-preview", # 使用非思考模型,更快
messages=messages,
temperature=0.7,
max_tokens=8192, # 增加 token 限制以支持图表配置
max_tokens=128000, # 增加 token 限制以支持图表配置
)
summary = response.choices[0].message.content
@@ -2355,7 +2355,7 @@ class MCPAgentIntegrated:
model=self.deepmoney_model,
messages=messages,
temperature=0.3,
max_tokens=4096,
max_tokens=32768,
)
title = response.choices[0].message.content.strip()
@@ -2450,7 +2450,7 @@ class MCPAgentIntegrated:
model=planning_model,
messages=messages,
temperature=1.0,
max_tokens=8192,
max_tokens=32768,
stream=True, # 启用流式输出
)
@@ -2494,7 +2494,7 @@ class MCPAgentIntegrated:
model=self.deepmoney_model,
messages=messages,
temperature=0.7,
max_tokens=8192,
max_tokens=32768,
)
plan_content = fallback_response.choices[0].message.content
@@ -2690,7 +2690,7 @@ class MCPAgentIntegrated:
model="kimi-k2-turbo-preview",
messages=messages,
temperature=0.7,
max_tokens=8192,
max_tokens=32768,
stream=True, # 启用流式输出
)
@@ -2724,7 +2724,7 @@ class MCPAgentIntegrated:
model=self.deepmoney_model,
messages=messages,
temperature=0.7,
max_tokens=8192,
max_tokens=32768,
)
final_summary = fallback_response.choices[0].message.content
@@ -3676,7 +3676,7 @@ async def stream_role_response(
tool_choice="auto",
stream=False, # 工具调用不使用流式
temperature=0.7,
max_tokens=8192, # 增大 token 限制以避免输出被截断
max_tokens=32768, # 增大 token 限制以避免输出被截断
)
assistant_message = response.choices[0].message

View File

@@ -6,6 +6,8 @@ Flask-Compress==1.14
Flask-SocketIO==5.3.6
Flask-Mail==0.9.1
Flask-Migrate==4.0.5
Flask-Session==0.5.0
redis==5.0.1
pandas==2.0.3
numpy==1.24.3
requests==2.31.0

View File

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

View File

@@ -61,8 +61,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/community', '/concepts']) ? '2px solid' : 'none'}
borderColor={isActive(['/community', '/concepts']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={highFreqMenu.handleMouseEnter}
@@ -128,8 +128,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'}
borderColor={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={marketReviewMenu.handleMouseEnter}
@@ -204,8 +204,8 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
borderColor="white"
border={isActive(['/agent-chat', '/value-forum']) ? '2px solid' : 'none'}
borderColor={isActive(['/agent-chat', '/value-forum']) ? 'blue.300' : 'transparent'}
borderRadius="md"
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter}

View File

@@ -18,7 +18,6 @@ import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-
import { FaCrown } from 'react-icons/fa';
import { useNavigate } from 'react-router-dom';
import UserAvatar from './UserAvatar';
import SubscriptionModal from '../../../Subscription/SubscriptionModal';
import { useSubscription } from '../../../../hooks/useSubscription';
/**
@@ -38,12 +37,7 @@ const TabletUserMenu = memo(({
followingEvents
}) => {
const navigate = useNavigate();
const {
subscriptionInfo,
isSubscriptionModalOpen,
openSubscriptionModal,
closeSubscriptionModal
} = useSubscription();
const { subscriptionInfo } = useSubscription();
const borderColor = useColorModeValue('gray.200', 'gray.600');
@@ -90,8 +84,8 @@ const TabletUserMenu = memo(({
)}
</Box>
{/* 订阅管理 */}
<MenuItem icon={<FaCrown />} onClick={openSubscriptionModal}>
{/* 订阅管理 - 移动端导航到订阅页面 */}
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
<Flex justify="space-between" align="center" w="100%">
<Text>订阅管理</Text>
<Badge colorScheme={getSubscriptionBadgeColor()}>
@@ -149,14 +143,6 @@ const TabletUserMenu = memo(({
</MenuList>
</Menu>
{/* 订阅弹窗 */}
{isSubscriptionModalOpen && (
<SubscriptionModal
isOpen={isSubscriptionModalOpen}
onClose={closeSubscriptionModal}
subscriptionInfo={subscriptionInfo}
/>
)}
</>
);
});

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
// src/components/StockChart/KLineChartModal.tsx - K线图弹窗组件
import React, { useEffect, useRef, useState } from 'react';
import React, { useEffect, useRef, useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import { useSelector } from 'react-redux';
import * as echarts from 'echarts';
import dayjs from 'dayjs';
import { stockService } from '@services/eventService';
import { selectIsMobile } from '@store/slices/deviceSlice';
/**
* 股票信息
@@ -40,6 +43,31 @@ interface KLineDataPoint {
volume: number;
}
/**
* 批量K线API响应
*/
interface BatchKlineResponse {
success: boolean;
data: {
[stockCode: string]: {
code: string;
name: string;
data: KLineDataPoint[];
trade_date: string;
type: string;
earliest_date?: string;
};
};
has_more: boolean;
query_start_date?: string;
query_end_date?: string;
}
// 每次加载的天数
const DAYS_PER_LOAD = 60;
// 最大加载天数(一年)
const MAX_DAYS = 365;
const KLineChartModal: React.FC<KLineChartModalProps> = ({
isOpen,
onClose,
@@ -50,8 +78,15 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
const chartRef = useRef<HTMLDivElement>(null);
const chartInstance = useRef<echarts.ECharts | null>(null);
const [loading, setLoading] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<KLineDataPoint[]>([]);
const [hasMore, setHasMore] = useState(true);
const [earliestDate, setEarliestDate] = useState<string | null>(null);
const [totalDaysLoaded, setTotalDaysLoaded] = useState(0);
// H5 响应式适配
const isMobile = useSelector(selectIsMobile);
// 调试日志
console.log('[KLineChartModal] 渲染状态:', {
@@ -60,38 +95,102 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
eventTime,
dataLength: data.length,
loading,
error
loadingMore,
hasMore,
earliestDate
});
// 加载K线数据
const loadData = async () => {
// 加载更多历史数据
const loadMoreData = useCallback(async () => {
if (!stock?.stock_code || !hasMore || loadingMore || !earliestDate) return;
console.log('[KLineChartModal] 加载更多历史数据, earliestDate:', earliestDate);
setLoadingMore(true);
try {
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
// 请求更早的数据end_date 设置为当前最早日期的前一天
const endDate = dayjs(earliestDate).subtract(1, 'day').format('YYYY-MM-DD');
const response = await stockService.getBatchKlineData(
[stock.stock_code],
'daily',
stableEventTime,
{ days_before: DAYS_PER_LOAD, end_date: endDate }
) as BatchKlineResponse;
if (response?.success && response.data) {
const stockData = response.data[stock.stock_code];
const newData = stockData?.data || [];
if (newData.length > 0) {
// 将新数据添加到现有数据的前面
setData(prevData => [...newData, ...prevData]);
setEarliestDate(newData[0].time);
setTotalDaysLoaded(prev => prev + DAYS_PER_LOAD);
console.log('[KLineChartModal] 加载了更多数据:', newData.length, '条');
}
// 检查是否还有更多数据
const noMoreData = !response.has_more || totalDaysLoaded + DAYS_PER_LOAD >= MAX_DAYS || newData.length === 0;
setHasMore(!noMoreData);
}
} catch (err) {
console.error('[KLineChartModal] 加载更多数据失败:', err);
} finally {
setLoadingMore(false);
}
}, [stock?.stock_code, hasMore, loadingMore, earliestDate, eventTime, totalDaysLoaded]);
// 初始加载K线数据
const loadData = useCallback(async () => {
if (!stock?.stock_code) return;
setLoading(true);
setError(null);
setData([]);
setHasMore(true);
setEarliestDate(null);
setTotalDaysLoaded(0);
try {
const response = await stockService.getKlineData(
stock.stock_code,
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
// 使用新的带分页参数的接口
const response = await stockService.getBatchKlineData(
[stock.stock_code],
'daily',
eventTime || undefined
);
stableEventTime,
{ days_before: DAYS_PER_LOAD, end_date: '' }
) as BatchKlineResponse;
console.log('[KLineChartModal] API响应:', response);
if (response?.success && response.data) {
const stockData = response.data[stock.stock_code];
const klineData = stockData?.data || [];
if (!response || !response.data || response.data.length === 0) {
throw new Error('暂无K线数据');
if (klineData.length === 0) {
throw new Error('暂无K线数据');
}
console.log('[KLineChartModal] 初始数据条数:', klineData.length);
setData(klineData);
setEarliestDate(klineData[0]?.time || null);
setTotalDaysLoaded(DAYS_PER_LOAD);
setHasMore(response.has_more !== false);
} else {
throw new Error('数据加载失败');
}
console.log('[KLineChartModal] 数据条数:', response.data.length);
setData(response.data);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
setError(errorMsg);
} finally {
setLoading(false);
}
};
}, [stock?.stock_code, eventTime]);
// 用于防抖的 ref
const loadMoreDebounceRef = useRef<NodeJS.Timeout | null>(null);
// 初始化图表
useEffect(() => {
@@ -124,6 +223,9 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
return () => {
clearTimeout(timer);
if (loadMoreDebounceRef.current) {
clearTimeout(loadMoreDebounceRef.current);
}
if (chartInstance.current) {
chartInstance.current.dispose();
chartInstance.current = null;
@@ -131,6 +233,35 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
};
}, [isOpen]);
// 监听 dataZoom 事件,当滑到左边界时加载更多数据
useEffect(() => {
if (!chartInstance.current || !hasMore || loadingMore) return;
const handleDataZoom = (params: any) => {
// 获取当前 dataZoom 的 start 值
const start = params.start ?? params.batch?.[0]?.start ?? 0;
// 当 start 接近 0左边界触发加载更多
if (start <= 5 && hasMore && !loadingMore) {
console.log('[KLineChartModal] 检测到滑动到左边界,准备加载更多数据');
// 防抖处理
if (loadMoreDebounceRef.current) {
clearTimeout(loadMoreDebounceRef.current);
}
loadMoreDebounceRef.current = setTimeout(() => {
loadMoreData();
}, 300);
}
};
chartInstance.current.on('datazoom', handleDataZoom);
return () => {
chartInstance.current?.off('datazoom', handleDataZoom);
};
}, [hasMore, loadingMore, loadMoreData]);
// 更新图表数据
useEffect(() => {
if (data.length === 0) {
@@ -170,16 +301,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
}
}
// 图表配置
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 日K线`,
left: 'center',
top: 10,
top: isMobile ? 5 : 10,
textStyle: {
color: '#e0e0e0',
fontSize: 18,
fontSize: isMobile ? 14 : 18,
fontWeight: 'bold',
},
},
@@ -244,16 +375,16 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
grid: [
{
left: '5%',
right: '5%',
top: '12%',
height: '60%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '12%' : '12%',
height: isMobile ? '55%' : '60%',
},
{
left: '5%',
right: '5%',
top: '77%',
height: '18%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '72%' : '77%',
height: isMobile ? '20%' : '18%',
},
],
xAxis: [
@@ -268,7 +399,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(dates.length / 8),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
},
splitLine: {
show: false,
@@ -285,7 +417,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(dates.length / 8),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(dates.length / (isMobile ? 4 : 8)),
},
},
],
@@ -293,6 +426,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
{
scale: true,
gridIndex: 0,
splitNumber: isMobile ? 4 : 5,
splitLine: {
show: true,
lineStyle: {
@@ -306,12 +440,14 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => value.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitNumber: isMobile ? 2 : 3,
splitLine: {
show: false,
},
@@ -322,6 +458,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => {
if (value >= 100000000) {
return (value / 100000000).toFixed(1) + '亿';
@@ -419,7 +556,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
return () => clearTimeout(retryTimer);
}
}, [data, stock]);
}, [data, stock, isMobile]);
// 加载数据
useEffect(() => {
@@ -474,13 +611,13 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
top: '50%',
left: '50%',
transform: 'translate(-50%, -50%)',
width: '90vw',
maxWidth: '1400px',
maxHeight: '85vh',
width: isMobile ? '96vw' : '90vw',
maxWidth: isMobile ? 'none' : '1400px',
maxHeight: isMobile ? '85vh' : '85vh',
backgroundColor: '#1a1a1a',
border: '2px solid #ffd700',
boxShadow: '0 0 30px rgba(255, 215, 0, 0.5)',
borderRadius: '8px',
borderRadius: isMobile ? '12px' : '8px',
zIndex: 10002,
display: 'flex',
flexDirection: 'column',
@@ -490,7 +627,7 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
{/* Header */}
<div
style={{
padding: '16px 24px',
padding: isMobile ? '12px 16px' : '16px 24px',
borderBottom: '1px solid #404040',
display: 'flex',
justifyContent: 'space-between',
@@ -498,20 +635,35 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
}}
>
<div>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<span style={{ fontSize: '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '12px', flexWrap: isMobile ? 'wrap' : 'nowrap' }}>
<span style={{ fontSize: isMobile ? '14px' : '18px', fontWeight: 'bold', color: '#e0e0e0' }}>
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</span>
{data.length > 0 && (
<span style={{ fontSize: '12px', color: '#666', fontStyle: 'italic' }}>
{data.length}1
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666', fontStyle: 'italic' }}>
{data.length}
{hasMore ? '(向左滑动加载更多)' : '(已加载全部)'}
</span>
)}
{loadingMore && (
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#3182ce', display: 'flex', alignItems: 'center', gap: '4px' }}>
<span style={{
width: '12px',
height: '12px',
border: '2px solid #404040',
borderTop: '2px solid #3182ce',
borderRadius: '50%',
animation: 'spin 1s linear infinite',
display: 'inline-block'
}} />
...
</span>
)}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: '16px', marginTop: '4px' }}>
<span style={{ fontSize: '14px', color: '#999' }}>K线图</span>
<span style={{ fontSize: '12px', color: '#666' }}>
💡 |
<div style={{ display: 'flex', alignItems: 'center', gap: isMobile ? '8px' : '16px', marginTop: '4px' }}>
<span style={{ fontSize: isMobile ? '12px' : '14px', color: '#999' }}>K线图</span>
<span style={{ fontSize: isMobile ? '10px' : '12px', color: '#666' }}>
💡 {isMobile ? '滚轮缩放 | 拖动查看' : '鼠标滚轮缩放 | 拖动查看不同时间段'}
</span>
</div>
</div>
@@ -534,26 +686,33 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
</div>
{/* Body */}
<div style={{ padding: '16px', flex: 1, overflow: 'auto' }}>
<div style={{
padding: isMobile ? '8px' : '16px',
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
justifyContent: 'center',
}}>
{error && (
<div
style={{
backgroundColor: '#2a1a1a',
border: '1px solid #ef5350',
borderRadius: '4px',
padding: '12px 16px',
marginBottom: '16px',
padding: isMobile ? '8px 12px' : '12px 16px',
marginBottom: isMobile ? '8px' : '16px',
display: 'flex',
alignItems: 'center',
gap: '8px',
}}
>
<span style={{ color: '#ef5350' }}></span>
<span style={{ color: '#e0e0e0' }}>{error}</span>
<span style={{ color: '#e0e0e0', fontSize: isMobile ? '12px' : '14px' }}>{error}</span>
</div>
)}
<div style={{ position: 'relative', height: '680px', width: '100%' }}>
<div style={{ position: 'relative', height: isMobile ? '450px' : '680px', width: '100%' }}>
{loading && (
<div
style={{

View File

@@ -1,5 +1,6 @@
// src/components/StockChart/TimelineChartModal.tsx - 分时图弹窗组件
import React, { useEffect, useRef, useState } from 'react';
import { useSelector } from 'react-redux';
import {
Modal,
ModalOverlay,
@@ -17,7 +18,9 @@ import {
AlertIcon,
} from '@chakra-ui/react';
import * as echarts from 'echarts';
import { stockService } from '@services/eventService';
import dayjs from 'dayjs';
import { klineDataCache, getCacheKey, fetchKlineData } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
import { selectIsMobile } from '@store/slices/deviceSlice';
/**
* 股票信息
@@ -67,7 +70,10 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<TimelineDataPoint[]>([]);
// 加载分时图数据
// H5 响应式适配
const isMobile = useSelector(selectIsMobile);
// 加载分时图数据(优先使用缓存)
const loadData = async () => {
if (!stock?.stock_code) return;
@@ -75,20 +81,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
setError(null);
try {
const response = await stockService.getKlineData(
stock.stock_code,
'timeline',
eventTime || undefined
);
// 标准化事件时间
const stableEventTime = eventTime ? dayjs(eventTime).format('YYYY-MM-DD HH:mm') : '';
console.log('[TimelineChartModal] API响应:', response);
// 先检查缓存
const cacheKey = getCacheKey(stock.stock_code, stableEventTime, 'timeline');
const cachedData = klineDataCache.get(cacheKey);
if (!response || !response.data || response.data.length === 0) {
if (cachedData && cachedData.length > 0) {
console.log('[TimelineChartModal] 使用缓存数据, 数据条数:', cachedData.length);
setData(cachedData);
setLoading(false);
return;
}
// 缓存没有则请求(会自动存入缓存)
console.log('[TimelineChartModal] 缓存未命中,发起请求');
const result = await fetchKlineData(stock.stock_code, stableEventTime, 'timeline');
if (!result || result.length === 0) {
throw new Error('暂无分时数据');
}
console.log('[TimelineChartModal] 数据条数:', response.data.length);
setData(response.data);
console.log('[TimelineChartModal] 数据条数:', result.length);
setData(result);
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败';
setError(errorMsg);
@@ -176,16 +192,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
}
}
// 图表配置
// 图表配置H5 响应式)
const option: echarts.EChartsOption = {
backgroundColor: '#1a1a1a',
title: {
text: `${stock?.stock_name || stock?.stock_code} - 分时图`,
left: 'center',
top: 10,
top: isMobile ? 5 : 10,
textStyle: {
color: '#e0e0e0',
fontSize: 18,
fontSize: isMobile ? 14 : 18,
fontWeight: 'bold',
},
},
@@ -236,16 +252,16 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
grid: [
{
left: '5%',
right: '5%',
top: '15%',
height: '55%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '12%' : '15%',
height: isMobile ? '58%' : '55%',
},
{
left: '5%',
right: '5%',
top: '75%',
height: '15%',
left: isMobile ? '12%' : '5%',
right: isMobile ? '5%' : '5%',
top: isMobile ? '75%' : '75%',
height: isMobile ? '18%' : '15%',
},
],
xAxis: [
@@ -260,7 +276,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(times.length / 6),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
},
splitLine: {
show: true,
@@ -280,7 +297,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
interval: Math.floor(times.length / 6),
fontSize: isMobile ? 10 : 12,
interval: Math.floor(times.length / (isMobile ? 4 : 6)),
},
},
],
@@ -288,6 +306,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
{
scale: true,
gridIndex: 0,
splitNumber: isMobile ? 4 : 5,
splitLine: {
show: true,
lineStyle: {
@@ -301,12 +320,14 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => value.toFixed(2),
},
},
{
scale: true,
gridIndex: 1,
splitNumber: isMobile ? 2 : 3,
splitLine: {
show: false,
},
@@ -317,6 +338,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
},
axisLabel: {
color: '#999',
fontSize: isMobile ? 10 : 12,
formatter: (value: number) => {
if (value >= 10000) {
return (value / 10000).toFixed(1) + '万';
@@ -432,7 +454,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
return () => clearTimeout(retryTimer);
}
}, [data, stock]);
}, [data, stock, isMobile]);
// 加载数据
useEffect(() => {
@@ -444,29 +466,30 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
if (!stock) return null;
return (
<Modal isOpen={isOpen} onClose={onClose} size={size}>
<Modal isOpen={isOpen} onClose={onClose} size={size} isCentered>
<ModalOverlay bg="blackAlpha.700" />
<ModalContent
maxW="90vw"
maxW={isMobile ? '96vw' : '90vw'}
maxH="85vh"
borderRadius={isMobile ? '12px' : '8px'}
bg="#1a1a1a"
borderColor="#404040"
borderWidth="1px"
border="2px solid #ffd700"
boxShadow="0 0 30px rgba(255, 215, 0, 0.5)"
>
<ModalHeader pb={3} borderBottomWidth="1px" borderColor="#404040">
<VStack align="flex-start" spacing={1}>
<ModalHeader pb={isMobile ? 2 : 3} borderBottomWidth="1px" borderColor="#404040">
<VStack align="flex-start" spacing={0}>
<HStack>
<Text fontSize="lg" fontWeight="bold" color="#e0e0e0">
<Text fontSize={isMobile ? 'md' : 'lg'} fontWeight="bold" color="#e0e0e0">
{stock.stock_name || stock.stock_code} ({stock.stock_code})
</Text>
</HStack>
<Text fontSize="sm" color="#999">
<Text fontSize={isMobile ? 'xs' : 'sm'} color="#999">
</Text>
</VStack>
</ModalHeader>
<ModalCloseButton color="#999" _hover={{ color: '#e0e0e0' }} />
<ModalBody p={4}>
<ModalBody p={isMobile ? 2 : 4}>
{error && (
<Alert status="error" bg="#2a1a1a" borderColor="#ef5350" mb={4}>
<AlertIcon color="#ef5350" />
@@ -474,7 +497,7 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
</Alert>
)}
<Box position="relative" h="600px" w="100%">
<Box position="relative" h={isMobile ? '400px' : '600px'} w="100%">
{loading && (
<Flex
position="absolute"

View File

@@ -1049,10 +1049,26 @@ export default function SubscriptionContent() {
</Text>
</HStack>
</Flex>
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
<Text fontSize="xs" color={secondaryText} pl={11} flex={1}>
{plan.description}
</Text>
<Flex justify="space-between" align="flex-start" flexWrap="wrap" gap={2}>
<VStack align="start" spacing={0.5} pl={11} flex={1}>
{plan.description && plan.description.includes('|') ? (
plan.description.split('|').map((item, idx) => (
<Text
key={idx}
fontSize="sm"
color={plan.name === 'max' ? 'purple.600' : 'blue.600'}
lineHeight="1.5"
fontWeight="medium"
>
{item.trim()}
</Text>
))
) : (
<Text fontSize="xs" color={secondaryText}>
{plan.description}
</Text>
)}
</VStack>
{(() => {
// 获取当前选中的周期信息
if (plan.pricing_options) {

View File

@@ -22,6 +22,7 @@ import {
Input,
Icon,
Container,
useBreakpointValue,
} from '@chakra-ui/react';
import {
FaWeixin,
@@ -42,6 +43,87 @@ import { useAuth } from '../../contexts/AuthContext';
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
import { subscriptionConfig, themeColors } from '../../views/Pages/Account/subscription-content';
// 计费周期选择器组件 - 移动端垂直布局(年付在上),桌面端水平布局
interface CycleSelectorProps {
options: any[];
selectedCycle: string;
onSelectCycle: (cycle: string) => void;
}
function CycleSelector({ options, selectedCycle, onSelectCycle }: CycleSelectorProps) {
// 使用 useBreakpointValue 动态获取是否是移动端
const isMobile = useBreakpointValue({ base: true, md: false });
// 移动端倒序显示(年付在上),桌面端正常顺序
const displayOptions = isMobile ? [...options].reverse() : options;
return (
<Flex
direction={{ base: 'column', md: 'row' }}
gap={3}
p={2}
bg="rgba(255, 255, 255, 0.03)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(10px)"
justify="center"
align="center"
w={{ base: 'full', md: 'auto' }}
maxW={{ base: '320px', md: 'none' }}
mx="auto"
>
{displayOptions.map((option: any) => (
<Box key={option.cycleKey} position="relative" w={{ base: 'full', md: 'auto' }}>
{option.discountPercent > 0 && (
<Badge
position="absolute"
top={{ base: '50%', md: '-10px' }}
right={{ base: '10px', md: '-10px' }}
transform={{ base: 'translateY(-50%)', md: 'none' }}
colorScheme="red"
fontSize="xs"
px={2}
py={1}
borderRadius="full"
fontWeight="bold"
zIndex={1}
>
{option.discountPercent}%
</Badge>
)}
<Button
size="lg"
w={{ base: 'full', md: 'auto' }}
px={6}
py={6}
borderRadius="lg"
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
border="1px solid"
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
onClick={() => onSelectCycle(option.cycleKey)}
_hover={{
transform: 'translateY(-2px)',
borderColor: 'rgba(212, 175, 55, 0.5)',
shadow: selectedCycle === option.cycleKey
? '0 0 20px rgba(212, 175, 55, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.5)',
}}
transition="all 0.3s"
fontWeight="bold"
justifyContent={{ base: 'flex-start', md: 'center' }}
pl={{ base: 6, md: 6 }}
>
{option.label}
</Button>
</Box>
))}
</Flex>
);
}
export default function SubscriptionContentNew() {
const { user } = useAuth();
const subscriptionEvents = useSubscriptionEvents({
@@ -751,61 +833,11 @@ export default function SubscriptionContentNew() {
·
</Text>
<HStack
spacing={3}
p={2}
bg="rgba(255, 255, 255, 0.03)"
borderRadius="xl"
border="1px solid"
borderColor="rgba(255, 255, 255, 0.1)"
backdropFilter="blur(10px)"
flexWrap="wrap"
justify="center"
>
{getMergedPlans()[1]?.pricingOptions?.map((option: any, index: number) => (
<Box key={index} position="relative">
{option.discountPercent > 0 && (
<Badge
position="absolute"
top="-10px"
right="-10px"
colorScheme="red"
fontSize="xs"
px={2}
py={1}
borderRadius="full"
fontWeight="bold"
zIndex={1}
>
{option.discountPercent}%
</Badge>
)}
<Button
size="lg"
px={6}
py={6}
borderRadius="lg"
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
border="1px solid"
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
onClick={() => setSelectedCycle(option.cycleKey)}
_hover={{
transform: 'translateY(-2px)',
borderColor: 'rgba(212, 175, 55, 0.5)',
shadow: selectedCycle === option.cycleKey
? '0 0 20px rgba(212, 175, 55, 0.3)'
: '0 4px 12px rgba(0, 0, 0, 0.5)',
}}
transition="all 0.3s"
fontWeight="bold"
>
{option.label}
</Button>
</Box>
))}
</HStack>
<CycleSelector
options={getMergedPlans()[1]?.pricingOptions || []}
selectedCycle={selectedCycle}
onSelectCycle={setSelectedCycle}
/>
{(() => {
const currentOption = getMergedPlans()[1]?.pricingOptions?.find(

File diff suppressed because one or more lines are too long

261
src/hooks/useIndexQuote.js Normal file
View 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 - 刷新间隔(毫秒),默认 600001分钟
* @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;

View File

@@ -1,16 +1,19 @@
// 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 { logger } from '../utils/logger';
import { getApiBase } from '../utils/apiConfig';
import { toggleWatchlist as toggleWatchlistAction, loadWatchlist } from '../store/slices/stockSlice';
const WATCHLIST_PAGE_SIZE = 10;
/**
* 自选股管理 Hook
* 自选股管理 Hook(导航栏专用)
* 提供自选股加载、分页、移除等功能
* 监听 Redux 中的 watchlist 变化,自动刷新行情数据
*
* @returns {{
* watchlistQuotes: Array,
@@ -19,14 +22,39 @@ const WATCHLIST_PAGE_SIZE = 10;
* setWatchlistPage: Function,
* WATCHLIST_PAGE_SIZE: number,
* loadWatchlistQuotes: Function,
* handleRemoveFromWatchlist: Function
* followingEvents: Array,
* handleAddToWatchlist: Function,
* handleRemoveFromWatchlist: Function,
* isInWatchlist: Function
* }}
*/
export const useWatchlist = () => {
const toast = useToast();
const dispatch = useDispatch();
const [watchlistQuotes, setWatchlistQuotes] = useState([]);
const [watchlistLoading, setWatchlistLoading] = useState(false);
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 () => {
@@ -42,6 +70,7 @@ export const useWatchlist = () => {
const data = await resp.json();
if (data && data.success && Array.isArray(data.data)) {
setWatchlistQuotes(data.data);
logger.debug('useWatchlist', '自选股行情加载成功', { count: data.data.length });
} else {
setWatchlistQuotes([]);
}
@@ -58,35 +87,108 @@ 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]);
// 添加到自选股
const handleAddToWatchlist = useCallback(async (stockCode, stockName) => {
try {
const base = getApiBase();
const resp = await fetch(base + '/api/account/watchlist/add', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ stock_code: stockCode, stock_name: stockName })
});
const data = await resp.json().catch(() => ({}));
if (resp.ok && data.success) {
// 刷新自选股列表
loadWatchlistQuotes();
toast({ title: '已添加至自选股', status: 'success', duration: 1500 });
return true;
} else {
toast({ title: '添加失败', status: 'error', duration: 2000 });
return false;
}
} catch (e) {
toast({ title: '网络错误,添加失败', status: 'error', duration: 2000 });
return false;
}
}, [toast, loadWatchlistQuotes]);
// 从自选股移除
const handleRemoveFromWatchlist = useCallback(async (stockCode) => {
try {
const base = getApiBase();
const resp = await fetch(base + `/api/account/watchlist/${stockCode}`, {
method: 'DELETE',
credentials: 'include'
// 找到股票名称
const stockItem = watchlistQuotes.find(item => {
const normalize6 = (code) => {
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(() => ({}));
if (resp.ok && data && data.success !== false) {
setWatchlistQuotes((prev) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
setWatchlistPage((p) => Math.min(p, newMaxPage));
return updated;
});
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
} else {
toast({ title: '移除失败', status: 'error', duration: 2000 });
}
const stockName = stockItem?.stock_name || '';
// 通过 Redux action 移除(会同步更新 Redux 状态)
await dispatch(toggleWatchlistAction({
stockCode,
stockName,
isInWatchlist: true // 表示当前在自选股中,需要移除
})).unwrap();
// 更新本地状态(立即响应 UI
setWatchlistQuotes((prev) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
const updated = (prev || []).filter((x) => normalize6(x.stock_code) !== target);
const newMaxPage = Math.max(1, Math.ceil((updated.length || 0) / WATCHLIST_PAGE_SIZE));
setWatchlistPage((p) => Math.min(p, newMaxPage));
return updated;
});
toast({ title: '已从自选股移除', status: 'info', duration: 1500 });
} catch (e) {
toast({ title: '网络错误,移除失败', status: 'error', duration: 2000 });
logger.error('useWatchlist', '移除自选股失败', e);
toast({ title: e.message || '移除失败', status: 'error', duration: 2000 });
}
}, [toast]);
}, [dispatch, watchlistQuotes, toast]);
// 判断股票是否在自选股中
const isInWatchlist = useCallback((stockCode) => {
const normalize6 = (code) => {
const m = String(code || '').match(/(\d{6})/);
return m ? m[1] : String(code || '');
};
const target = normalize6(stockCode);
return watchlistQuotes.some(item => normalize6(item.stock_code) === target);
}, [watchlistQuotes]);
return {
watchlistQuotes,
@@ -95,6 +197,9 @@ export const useWatchlist = () => {
setWatchlistPage,
WATCHLIST_PAGE_SIZE,
loadWatchlistQuotes,
handleRemoveFromWatchlist
followingEvents,
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist
};
};

View File

@@ -61,6 +61,20 @@ export const generateDailyData = (indexCode, days = 30) => {
return data;
};
/**
* 计算简单移动均价(用于分时图均价线)
* @param {Array} data - 已有数据
* @param {number} currentPrice - 当前价格
* @param {number} period - 均线周期默认5
* @returns {number} 均价
*/
function calculateAvgPrice(data, currentPrice, period = 5) {
const recentPrices = data.slice(-period).map(d => d.price || d.close);
recentPrices.push(currentPrice);
const sum = recentPrices.reduce((acc, p) => acc + p, 0);
return parseFloat((sum / recentPrices.length).toFixed(2));
}
/**
* 生成时间范围内的数据
*/
@@ -80,6 +94,11 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
// ✅ 修复:为分时图添加完整的 OHLC 字段
const closePrice = parseFloat(price.toFixed(2));
// 计算均价和涨跌幅
const avgPrice = calculateAvgPrice(data, closePrice);
const changePercent = parseFloat(((closePrice - basePrice) / basePrice * 100).toFixed(2));
data.push({
time: formatTime(current),
timestamp: current.getTime(), // ✅ 新增:毫秒时间戳
@@ -88,6 +107,8 @@ function generateTimeRange(data, startTime, endTime, basePrice, session) {
low: parseFloat((price * 0.9997).toFixed(2)), // ✅ 新增:最低价(略低于收盘)
close: closePrice, // ✅ 保留:收盘价
price: closePrice, // ✅ 保留:兼容字段(供 MiniTimelineChart 使用)
avg_price: avgPrice, // ✅ 新增:均价(供 TimelineChartModal 使用)
change_percent: changePercent, // ✅ 新增:涨跌幅(供 TimelineChartModal 使用)
volume: volume,
prev_close: basePrice
});

View File

@@ -188,6 +188,22 @@ export const accountHandlers = [
mockWatchlist.push(newItem);
// 同步添加到 mockRealtimeQuotes导航栏自选股菜单使用此数组
mockRealtimeQuotes.push({
stock_code: stock_code,
stock_name: stock_name,
current_price: null,
change_percent: 0,
change: 0,
volume: 0,
turnover: 0,
high: 0,
low: 0,
open: 0,
prev_close: 0,
update_time: new Date().toTimeString().slice(0, 8)
});
return HttpResponse.json({
success: true,
message: '添加成功',
@@ -210,9 +226,20 @@ export const accountHandlers = [
const { id } = params;
console.log('[Mock] 删除自选股:', id);
const index = mockWatchlist.findIndex(item => item.id === parseInt(id));
// 支持按 stock_code 或 id 匹配删除
const index = mockWatchlist.findIndex(item =>
item.stock_code === id || item.id === parseInt(id)
);
if (index !== -1) {
const stockCode = mockWatchlist[index].stock_code;
mockWatchlist.splice(index, 1);
// 同步从 mockRealtimeQuotes 移除
const quotesIndex = mockRealtimeQuotes.findIndex(item => item.stock_code === stockCode);
if (quotesIndex !== -1) {
mockRealtimeQuotes.splice(quotesIndex, 1);
}
}
return HttpResponse.json({
@@ -263,6 +290,26 @@ export const accountHandlers = [
});
}),
// 10. 获取事件帖子(用户发布的评论/帖子)
http.get('/api/account/events/posts', async () => {
await delay(NETWORK_DELAY);
const currentUser = getCurrentUser();
if (!currentUser) {
return HttpResponse.json(
{ success: false, error: '未登录' },
{ status: 401 }
);
}
console.log('[Mock] 获取事件帖子');
return HttpResponse.json({
success: true,
data: mockEventComments // 复用 mockEventComments 数据
});
}),
// ==================== 投资计划与复盘 ====================
// 10. 获取投资计划列表
@@ -696,4 +743,81 @@ export const accountHandlers = [
}
});
}),
// 21. 获取订阅套餐列表
http.get('/api/subscription/plans', async () => {
await delay(NETWORK_DELAY);
const plans = [
{
id: 1,
name: 'pro',
display_name: 'Pro 专业版',
description: '事件关联股票深度分析 | 历史事件智能对比复盘 | 事件概念关联与挖掘 | 概念板块个股追踪 | 概念深度研报与解读 | 个股异动实时预警',
monthly_price: 299,
yearly_price: 2699,
pricing_options: [
{ cycle_key: 'monthly', label: '月付', months: 1, price: 299, original_price: null, discount_percent: 0 },
{ cycle_key: 'quarterly', label: '季付', months: 3, price: 799, original_price: 897, discount_percent: 11 },
{ cycle_key: 'semiannual', label: '半年付', months: 6, price: 1499, original_price: 1794, discount_percent: 16 },
{ cycle_key: 'yearly', label: '年付', months: 12, price: 2699, original_price: 3588, discount_percent: 25 }
],
features: [
'新闻信息流',
'历史事件对比',
'事件传导链分析(AI)',
'事件-相关标的分析',
'相关概念展示',
'AI复盘功能',
'企业概览',
'个股深度分析(AI) - 50家/月',
'高效数据筛选工具',
'概念中心(548大概念)',
'历史时间轴查询 - 100天',
'涨停板块数据分析',
'个股涨停分析'
],
sort_order: 1
},
{
id: 2,
name: 'max',
display_name: 'Max 旗舰版',
description: '包含Pro版全部功能 | 事件传导链路智能分析 | 概念演变时间轴追溯 | 个股全方位深度研究 | 价小前投研助手无限使用 | 新功能优先体验权 | 专属客服一对一服务',
monthly_price: 599,
yearly_price: 5399,
pricing_options: [
{ cycle_key: 'monthly', label: '月付', months: 1, price: 599, original_price: null, discount_percent: 0 },
{ cycle_key: 'quarterly', label: '季付', months: 3, price: 1599, original_price: 1797, discount_percent: 11 },
{ cycle_key: 'semiannual', label: '半年付', months: 6, price: 2999, original_price: 3594, discount_percent: 17 },
{ cycle_key: 'yearly', label: '年付', months: 12, price: 5399, original_price: 7188, discount_percent: 25 }
],
features: [
'新闻信息流',
'历史事件对比',
'事件传导链分析(AI)',
'事件-相关标的分析',
'相关概念展示',
'板块深度分析(AI)',
'AI复盘功能',
'企业概览',
'个股深度分析(AI) - 无限制',
'高效数据筛选工具',
'概念中心(548大概念)',
'历史时间轴查询 - 无限制',
'概念高频更新',
'涨停板块数据分析',
'个股涨停分析'
],
sort_order: 2
}
];
console.log('[Mock] 获取订阅套餐列表:', plans.length, '个套餐');
return HttpResponse.json({
success: true,
data: plans
});
}),
];

View File

@@ -71,4 +71,269 @@ export const marketHandlers = [
const data = generateMarketData(stockCode);
return HttpResponse.json(data.latestMinuteData);
}),
// 9. 热门概念数据(个股中心页面使用)
http.get('/api/concepts/daily-top', async ({ request }) => {
await delay(300);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '6');
const date = url.searchParams.get('date');
// 获取当前日期或指定日期
const tradeDate = date || new Date().toISOString().split('T')[0];
// 热门概念列表
const conceptPool = [
{ name: '人工智能', desc: '人工智能是"技术突破+政策扶持"双轮驱动的硬科技主题。随着大模型技术的突破AI应用场景不断拓展。' },
{ name: '新能源汽车', desc: '新能源汽车行业景气度持续向好,渗透率不断提升。政策支持力度大,产业链上下游企业均受益。' },
{ name: '半导体', desc: '国产半导体替代加速,自主可控需求强烈。政策和资金支持力度大,行业迎来黄金发展期。' },
{ name: '光伏', desc: '光伏装机量快速增长,成本持续下降,行业景气度维持高位。双碳目标下前景广阔。' },
{ name: '锂电池', desc: '锂电池技术进步,成本优势扩大,下游应用领域持续扩张。新能源汽车和储能需求旺盛。' },
{ name: '储能', desc: '储能市场爆发式增长,政策支持力度大,应用场景不断拓展。未来市场空间巨大。' },
{ name: '算力', desc: 'AI大模型推动算力需求爆发数据中心、服务器、芯片等产业链受益明显。' },
{ name: '机器人', desc: '人形机器人产业化加速,特斯拉、小米等巨头入局,产业链迎来发展机遇。' },
];
// 股票池(扩展到足够多的股票)
const stockPool = [
{ stock_code: '600519', stock_name: '贵州茅台' },
{ stock_code: '300750', stock_name: '宁德时代' },
{ stock_code: '601318', stock_name: '中国平安' },
{ stock_code: '002594', stock_name: '比亚迪' },
{ stock_code: '601012', stock_name: '隆基绿能' },
{ stock_code: '300274', stock_name: '阳光电源' },
{ stock_code: '688981', stock_name: '中芯国际' },
{ stock_code: '000725', stock_name: '京东方A' },
{ stock_code: '600036', stock_name: '招商银行' },
{ stock_code: '000858', stock_name: '五粮液' },
{ stock_code: '601166', stock_name: '兴业银行' },
{ stock_code: '600276', stock_name: '恒瑞医药' },
{ stock_code: '000333', stock_name: '美的集团' },
{ stock_code: '600887', stock_name: '伊利股份' },
{ stock_code: '002415', stock_name: '海康威视' },
{ stock_code: '601888', stock_name: '中国中免' },
{ stock_code: '300059', stock_name: '东方财富' },
{ stock_code: '002475', stock_name: '立讯精密' },
{ stock_code: '600900', stock_name: '长江电力' },
{ stock_code: '601398', stock_name: '工商银行' },
{ stock_code: '600030', stock_name: '中信证券' },
{ stock_code: '000568', stock_name: '泸州老窖' },
{ stock_code: '002352', stock_name: '顺丰控股' },
{ stock_code: '600809', stock_name: '山西汾酒' },
{ stock_code: '300015', stock_name: '爱尔眼科' },
{ stock_code: '002142', stock_name: '宁波银行' },
{ stock_code: '601899', stock_name: '紫金矿业' },
{ stock_code: '600309', stock_name: '万华化学' },
{ stock_code: '002304', stock_name: '洋河股份' },
{ stock_code: '600585', stock_name: '海螺水泥' },
{ stock_code: '601288', stock_name: '农业银行' },
{ stock_code: '600050', stock_name: '中国联通' },
{ stock_code: '000001', stock_name: '平安银行' },
{ stock_code: '601668', stock_name: '中国建筑' },
{ stock_code: '600028', stock_name: '中国石化' },
{ stock_code: '601857', stock_name: '中国石油' },
{ stock_code: '600000', stock_name: '浦发银行' },
{ stock_code: '601328', stock_name: '交通银行' },
{ stock_code: '000002', stock_name: '万科A' },
{ stock_code: '600104', stock_name: '上汽集团' },
{ stock_code: '601601', stock_name: '中国太保' },
{ stock_code: '600016', stock_name: '民生银行' },
{ stock_code: '601628', stock_name: '中国人寿' },
{ stock_code: '600031', stock_name: '三一重工' },
{ stock_code: '002230', stock_name: '科大讯飞' },
{ stock_code: '300124', stock_name: '汇川技术' },
{ stock_code: '002049', stock_name: '紫光国微' },
{ stock_code: '688012', stock_name: '中微公司' },
{ stock_code: '688008', stock_name: '澜起科技' },
{ stock_code: '603501', stock_name: '韦尔股份' },
];
// 生成历史触发时间
const generateHappenedTimes = (seed) => {
const times = [];
const count = 3 + (seed % 3); // 3-5个时间点
for (let k = 0; k < count; k++) {
const daysAgo = 30 + (seed * 7 + k * 11) % 330;
const d = new Date();
d.setDate(d.getDate() - daysAgo);
times.push(d.toISOString().split('T')[0]);
}
return times.sort().reverse();
};
const matchTypes = ['hybrid_knn', 'keyword', 'semantic'];
// 生成概念数据
const concepts = [];
for (let i = 0; i < Math.min(limit, conceptPool.length); i++) {
const concept = conceptPool[i];
const changePercent = parseFloat((Math.random() * 8 - 1).toFixed(2)); // -1% ~ 7%
const stockCount = Math.floor(Math.random() * 20) + 15; // 15-35只股票
// 生成与 stockCount 一致的股票列表(包含完整字段)
const relatedStocks = [];
for (let j = 0; j < stockCount; j++) {
const idx = (i * 7 + j) % stockPool.length;
const stock = stockPool[idx];
relatedStocks.push({
stock_code: stock.stock_code,
stock_name: stock.stock_name,
reason: `作为行业龙头企业,${stock.stock_name}在该领域具有核心竞争优势,市场份额领先。`,
change_pct: parseFloat((Math.random() * 15 - 5).toFixed(2)) // -5% ~ +10%
});
}
concepts.push({
concept_id: `CONCEPT_${1001 + i}`,
concept: concept.name, // 原始字段名
concept_name: concept.name, // 兼容字段名
description: concept.desc,
stock_count: stockCount,
score: parseFloat((Math.random() * 5 + 3).toFixed(2)), // 3-8 分数
match_type: matchTypes[i % 3],
price_info: {
avg_change_pct: changePercent,
avg_price: parseFloat((Math.random() * 100 + 10).toFixed(2)),
total_market_cap: parseFloat((Math.random() * 1000 + 100).toFixed(2))
},
change_percent: changePercent, // 兼容字段
happened_times: generateHappenedTimes(i),
stocks: relatedStocks,
hot_score: Math.floor(Math.random() * 100)
});
}
// 按涨跌幅降序排序
concepts.sort((a, b) => b.change_percent - a.change_percent);
console.log('[Mock Market] 获取热门概念:', { limit, date: tradeDate, count: concepts.length });
return HttpResponse.json({
success: true,
data: concepts,
trade_date: tradeDate
});
}),
// 10. 市值热力图数据(个股中心页面使用)
http.get('/api/market/heatmap', async ({ request }) => {
await delay(400);
const url = new URL(request.url);
const limit = parseInt(url.searchParams.get('limit') || '500');
const date = url.searchParams.get('date');
const tradeDate = date || new Date().toISOString().split('T')[0];
// 行业列表
const industries = ['食品饮料', '银行', '医药生物', '电子', '计算机', '汽车', '电力设备', '机械设备', '化工', '房地产', '有色金属', '钢铁'];
const provinces = ['北京', '上海', '广东', '浙江', '江苏', '山东', '四川', '湖北', '福建', '安徽'];
// 常见股票数据
const majorStocks = [
{ code: '600519', name: '贵州茅台', cap: 1850, industry: '食品饮料', province: '贵州' },
{ code: '601318', name: '中国平安', cap: 920, industry: '保险', province: '广东' },
{ code: '600036', name: '招商银行', cap: 850, industry: '银行', province: '广东' },
{ code: '300750', name: '宁德时代', cap: 780, industry: '电力设备', province: '福建' },
{ code: '601166', name: '兴业银行', cap: 420, industry: '银行', province: '福建' },
{ code: '000858', name: '五粮液', cap: 580, industry: '食品饮料', province: '四川' },
{ code: '002594', name: '比亚迪', cap: 650, industry: '汽车', province: '广东' },
{ code: '601012', name: '隆基绿能', cap: 320, industry: '电力设备', province: '陕西' },
{ code: '688981', name: '中芯国际', cap: 280, industry: '电子', province: '上海' },
{ code: '600900', name: '长江电力', cap: 520, industry: '公用事业', province: '湖北' },
];
// 生成热力图数据
const heatmapData = [];
let risingCount = 0;
let fallingCount = 0;
// 先添加主要股票
majorStocks.forEach(stock => {
const changePercent = parseFloat((Math.random() * 12 - 4).toFixed(2)); // -4% ~ 8%
const amount = parseFloat((Math.random() * 100 + 10).toFixed(2)); // 10-110亿
if (changePercent > 0) risingCount++;
else if (changePercent < 0) fallingCount++;
heatmapData.push({
stock_code: stock.code,
stock_name: stock.name,
market_cap: stock.cap,
change_percent: changePercent,
amount: amount,
industry: stock.industry,
province: stock.province
});
});
// 生成更多随机股票数据
for (let i = majorStocks.length; i < Math.min(limit, 200); i++) {
const changePercent = parseFloat((Math.random() * 14 - 5).toFixed(2)); // -5% ~ 9%
const marketCap = parseFloat((Math.random() * 500 + 20).toFixed(2)); // 20-520亿
const amount = parseFloat((Math.random() * 50 + 1).toFixed(2)); // 1-51亿
if (changePercent > 0) risingCount++;
else if (changePercent < 0) fallingCount++;
heatmapData.push({
stock_code: `${600000 + i}`,
stock_name: `股票${i}`,
market_cap: marketCap,
change_percent: changePercent,
amount: amount,
industry: industries[Math.floor(Math.random() * industries.length)],
province: provinces[Math.floor(Math.random() * provinces.length)]
});
}
console.log('[Mock Market] 获取热力图数据:', { limit, date: tradeDate, count: heatmapData.length });
return HttpResponse.json({
success: true,
data: heatmapData,
trade_date: tradeDate,
statistics: {
rising_count: risingCount,
falling_count: fallingCount
}
});
}),
// 11. 市场统计数据(个股中心页面使用)
http.get('/api/market/statistics', async ({ request }) => {
await delay(200);
const url = new URL(request.url);
const date = url.searchParams.get('date');
const tradeDate = date || new Date().toISOString().split('T')[0];
// 生成最近30个交易日
const availableDates = [];
const currentDate = new Date(tradeDate);
for (let i = 0; i < 30; i++) {
const d = new Date(currentDate);
d.setDate(d.getDate() - i);
// 跳过周末
if (d.getDay() !== 0 && d.getDay() !== 6) {
availableDates.push(d.toISOString().split('T')[0]);
}
}
console.log('[Mock Market] 获取市场统计数据:', { date: tradeDate });
return HttpResponse.json({
success: true,
summary: {
total_market_cap: parseFloat((Math.random() * 5000 + 80000).toFixed(2)), // 80000-85000亿
total_amount: parseFloat((Math.random() * 3000 + 8000).toFixed(2)), // 8000-11000亿
avg_pe: parseFloat((Math.random() * 5 + 12).toFixed(2)), // 12-17
avg_pb: parseFloat((Math.random() * 0.5 + 1.3).toFixed(2)), // 1.3-1.8
rising_stocks: Math.floor(Math.random() * 1500 + 1500), // 1500-3000
falling_stocks: Math.floor(Math.random() * 1500 + 1000), // 1000-2500
unchanged_stocks: Math.floor(Math.random() * 200 + 100) // 100-300
},
trade_date: tradeDate,
available_dates: availableDates.slice(0, 20) // 返回最近20个交易日
});
}),
];

View File

@@ -224,4 +224,59 @@ export const stockHandlers = [
);
}
}),
// 批量获取股票K线数据
http.post('/api/stock/batch-kline', async ({ request }) => {
await delay(400);
try {
const body = await request.json();
const { codes, type = 'timeline', event_time } = body;
console.log('[Mock Stock] 批量获取K线数据:', {
stockCount: codes?.length,
type,
eventTime: event_time
});
if (!codes || !Array.isArray(codes) || codes.length === 0) {
return HttpResponse.json(
{ error: '股票代码列表不能为空' },
{ status: 400 }
);
}
// 为每只股票生成数据
const batchData = {};
codes.forEach(stockCode => {
let data;
if (type === 'timeline') {
data = generateTimelineData('000001.SH');
} else if (type === 'daily') {
data = generateDailyData('000001.SH', 60);
} else {
data = [];
}
batchData[stockCode] = {
success: true,
data: data,
stock_code: stockCode
};
});
return HttpResponse.json({
success: true,
data: batchData,
type: type,
message: '批量获取成功'
});
} catch (error) {
console.error('[Mock Stock] 批量获取K线数据失败:', error);
return HttpResponse.json(
{ error: '批量获取K线数据失败' },
{ status: 500 }
);
}
}),
];

View File

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

View File

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

View File

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

View File

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

View File

@@ -93,6 +93,13 @@ const CompactSearchBox = ({
loadStocks();
}, []);
// 预加载行业数据(解决第一次点击无数据问题)
useEffect(() => {
if (!industryData || industryData.length === 0) {
dispatch(fetchIndustryData());
}
}, [dispatch, industryData]);
// 初始化筛选条件
const findIndustryPath = useCallback((targetCode, data, currentPath = []) => {
if (!data || data.length === 0) return null;

View File

@@ -1,10 +1,9 @@
// src/views/Community/components/DynamicNewsDetail/DynamicNewsDetailPanel.js
// 动态新闻详情面板主组件(组装所有子组件)
import React, { useState, useCallback, useEffect } from 'react';
import React, { useState, useCallback, useEffect, useReducer } from 'react';
import { useDispatch, useSelector } from 'react-redux';
import {
Box,
Card,
CardBody,
VStack,
@@ -13,14 +12,13 @@ import {
Center,
Wrap,
WrapItem,
useColorModeValue,
useToast,
Box,
} from '@chakra-ui/react';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { eventService } from '../../../../services/eventService';
import { getImportanceConfig } from '@constants/importanceLevels';
import { eventService } from '@services/eventService';
import { useEventStocks } from '../StockDetailPanel/hooks/useEventStocks';
import { toggleEventFollow, selectEventFollowStatus } from '../../../../store/slices/communityDataSlice';
import { useAuth } from '../../../../contexts/AuthContext';
import { toggleEventFollow, selectEventFollowStatus } from '@store/slices/communityDataSlice';
import { useAuth } from '@contexts/AuthContext';
import EventHeaderInfo from './EventHeaderInfo';
import CompactMetaBar from './CompactMetaBar';
import EventDescriptionSection from './EventDescriptionSection';
@@ -28,12 +26,56 @@ import RelatedConceptsSection from './RelatedConceptsSection';
import RelatedStocksSection from './RelatedStocksSection';
import CompactStockItem from './CompactStockItem';
import CollapsibleSection from './CollapsibleSection';
import HistoricalEvents from '../../../EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '../../../EventDetail/components/TransmissionChainAnalysis';
import SubscriptionBadge from '../../../../components/SubscriptionBadge';
import SubscriptionUpgradeModal from '../../../../components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
import EventCommentSection from '../../../../components/EventCommentSection';
import HistoricalEvents from '@views/EventDetail/components/HistoricalEvents';
import TransmissionChainAnalysis from '@views/EventDetail/components/TransmissionChainAnalysis';
import SubscriptionBadge from '@components/SubscriptionBadge';
import SubscriptionUpgradeModal from '@components/SubscriptionUpgradeModal';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import { useWatchlist } from '@hooks/useWatchlist';
import EventCommentSection from '@components/EventCommentSection';
// 折叠区块状态管理 - 使用 useReducer 整合
const initialSectionState = {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
const sectionReducer = (state, action) => {
switch (action.type) {
case 'TOGGLE':
return {
...state,
[action.section]: {
...state[action.section],
isOpen: !state[action.section].isOpen
}
};
case 'SET_LOADED':
return {
...state,
[action.section]: {
...state[action.section],
hasLoaded: true
}
};
case 'SET_QUOTES_LOADED':
return {
...state,
stocks: { ...state.stocks, hasLoadedQuotes: true }
};
case 'RESET_ALL':
return {
stocks: { isOpen: true, hasLoaded: false, hasLoadedQuotes: false },
concepts: { isOpen: false },
historical: { isOpen: false, hasLoaded: false },
transmission: { isOpen: false, hasLoaded: false }
};
default:
return state;
}
};
/**
* 动态新闻详情面板主组件
@@ -47,7 +89,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default;
const textColor = PROFESSIONAL_COLORS.text.secondary;
const toast = useToast();
// 使用 useWatchlist Hook 管理自选股
const {
handleAddToWatchlist,
handleRemoveFromWatchlist,
isInWatchlist,
loadWatchlistQuotes
} = useWatchlist();
// 获取用户会员等级(修复:字段名从 subscription_tier 改为 subscription_type
const userTier = user?.subscription_type || 'free';
@@ -77,7 +126,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
// 使用 Hook 获取实时数据
// - autoLoad: false - 禁用自动加载所有数据,改为手动触发
// - autoLoadQuotes: false - 禁用自动加载行情,延迟到展开时加载(减少请求
// - autoLoadQuotes: true - 股票数据加载后自动加载行情(相关股票默认展开
const {
stocks,
quotes,
@@ -89,7 +138,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
loadHistoricalData,
loadChainAnalysis,
refreshQuotes
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: false });
} = useEventStocks(event?.id, event?.created_at, { autoLoad: false, autoLoadQuotes: true });
// 🎯 加载事件详情(增加浏览量)- 与 EventDetailModal 保持一致
const loadEventDetail = useCallback(async () => {
@@ -100,11 +149,6 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const response = await eventService.getEventDetail(event.id);
if (response.success) {
setFullEventDetail(response.data);
console.log('%c📊 [浏览量] 事件详情加载成功', 'color: #10B981; font-weight: bold;', {
eventId: event.id,
viewCount: response.data.view_count,
title: response.data.title
});
}
} catch (error) {
console.error('[DynamicNewsDetailPanel] loadEventDetail 失败:', error, {
@@ -121,30 +165,8 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
const canAccessHistorical = hasAccess('pro');
const canAccessTransmission = hasAccess('max');
// 子区块折叠状态管理 + 加载追踪
// 相关股票默认折叠,只显示数量吸引点击
const [isStocksOpen, setIsStocksOpen] = useState(false);
const [hasLoadedStocks, setHasLoadedStocks] = useState(false); // 股票列表是否已加载(获取数量)
const [hasLoadedQuotes, setHasLoadedQuotes] = useState(false); // 行情数据是否已加载
const [isConceptsOpen, setIsConceptsOpen] = useState(false);
// 历史事件默认折叠,但预加载数量
const [isHistoricalOpen, setIsHistoricalOpen] = useState(false);
const [hasLoadedHistorical, setHasLoadedHistorical] = useState(false);
const [isTransmissionOpen, setIsTransmissionOpen] = useState(false);
const [hasLoadedTransmission, setHasLoadedTransmission] = useState(false);
// 自选股管理(使用 localStorage
const [watchlistSet, setWatchlistSet] = useState(() => {
try {
const saved = localStorage.getItem('stock_watchlist');
return saved ? new Set(JSON.parse(saved)) : new Set();
} catch {
return new Set();
}
});
// 子区块折叠状态管理 - 使用 useReducer 整合
const [sectionState, dispatchSection] = useReducer(sectionReducer, initialSectionState);
// 锁定点击处理 - 弹出升级弹窗
const handleLockedClick = useCallback((featureName, requiredLevel) => {
@@ -165,85 +187,62 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
}, []);
// 相关股票 - 展开时加载行情(需要 PRO 权限)
// 股票列表在事件切换时预加载(显示数量),行情在展开时才加载
const handleStocksToggle = useCallback(() => {
const newState = !isStocksOpen;
setIsStocksOpen(newState);
const willOpen = !sectionState.stocks.isOpen;
dispatchSection({ type: 'TOGGLE', section: 'stocks' });
// 展开时加载行情数据(如果还没加载过)
if (newState && !hasLoadedQuotes && stocks.length > 0) {
console.log('%c📈 [相关股票] 首次展开,加载行情数据', 'color: #10B981; font-weight: bold;', {
eventId: event?.id,
stockCount: stocks.length
});
if (willOpen && !sectionState.stocks.hasLoadedQuotes && stocks.length > 0) {
refreshQuotes();
setHasLoadedQuotes(true);
dispatchSection({ type: 'SET_QUOTES_LOADED' });
}
}, [isStocksOpen, hasLoadedQuotes, stocks.length, refreshQuotes, event?.id]);
}, [sectionState.stocks, stocks.length, refreshQuotes]);
// 相关概念 - 展开/收起(无需加载)
const handleConceptsToggle = useCallback(() => {
setIsConceptsOpen(!isConceptsOpen);
}, [isConceptsOpen]);
dispatchSection({ type: 'TOGGLE', section: 'concepts' });
}, []);
// 历史事件对比 - 数据已预加载,只需切换展开状态
const handleHistoricalToggle = useCallback(() => {
const newState = !isHistoricalOpen;
setIsHistoricalOpen(newState);
// 数据已在事件切换时预加载,这里只需展开
if (newState) {
console.log('%c📜 [历史事件] 展开(数据已预加载)', 'color: #3B82F6; font-weight: bold;', {
eventId: event?.id,
count: historicalEvents?.length || 0
});
}
}, [isHistoricalOpen, event?.id, historicalEvents?.length]);
dispatchSection({ type: 'TOGGLE', section: 'historical' });
}, []);
// 传导链分析 - 展开时加载
const handleTransmissionToggle = useCallback(() => {
const newState = !isTransmissionOpen;
setIsTransmissionOpen(newState);
const willOpen = !sectionState.transmission.isOpen;
dispatchSection({ type: 'TOGGLE', section: 'transmission' });
if (newState && !hasLoadedTransmission) {
console.log('%c🔗 [传导链] 首次展开,加载传导链数据', 'color: #8B5CF6; font-weight: bold;', { eventId: event?.id });
if (willOpen && !sectionState.transmission.hasLoaded) {
loadChainAnalysis();
setHasLoadedTransmission(true);
dispatchSection({ type: 'SET_LOADED', section: 'transmission' });
}
}, [isTransmissionOpen, hasLoadedTransmission, loadChainAnalysis, event?.id]);
}, [sectionState.transmission, loadChainAnalysis]);
// 事件切换时重置所有子模块状态
useEffect(() => {
console.log('%c🔄 [事件切换] 重置所有子模块状态', 'color: #F59E0B; font-weight: bold;', { eventId: event?.id });
// 🎯 加载事件详情(增加浏览量)
// 加载事件详情(增加浏览量)
loadEventDetail();
// 重置所有加载状态
setHasLoadedStocks(false);
setHasLoadedQuotes(false); // 重置行情加载状态
setHasLoadedHistorical(false);
setHasLoadedTransmission(false);
// 加载自选股数据(用于判断股票是否已关注)
loadWatchlistQuotes();
// 相关股票默认折叠,但预加载股票列表(显示数量吸引点击)
setIsStocksOpen(false);
// 重置所有折叠区块状态
dispatchSection({ type: 'RESET_ALL' });
// 相关股票默认展开,预加载股票列表和行情数据
if (canAccessStocks) {
console.log('%c📊 [相关股票] 事件切换,预加载股票列表(获取数量)', 'color: #10B981; font-weight: bold;', { eventId: event?.id });
loadStocksData();
setHasLoadedStocks(true);
dispatchSection({ type: 'SET_LOADED', section: 'stocks' });
dispatchSection({ type: 'SET_QUOTES_LOADED' });
}
// 历史事件默认折叠,但预加载数据(显示数量吸引点击)
setIsHistoricalOpen(false);
if (canAccessHistorical) {
console.log('%c📜 [历史事件] 事件切换,预加载历史事件(获取数量)', 'color: #3B82F6; font-weight: bold;', { eventId: event?.id });
loadHistoricalData();
setHasLoadedHistorical(true);
dispatchSection({ type: 'SET_LOADED', section: 'historical' });
}
setIsConceptsOpen(false);
setIsTransmissionOpen(false);
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail]);
}, [event?.id, canAccessStocks, canAccessHistorical, userTier, loadStocksData, loadHistoricalData, loadEventDetail, loadWatchlistQuotes]);
// 切换关注状态
const handleToggleFollow = useCallback(async () => {
@@ -251,42 +250,14 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
dispatch(toggleEventFollow(event.id));
}, [dispatch, event?.id]);
// 切换自选股
const handleWatchlistToggle = useCallback(async (stockCode, isInWatchlist) => {
try {
const newWatchlist = new Set(watchlistSet);
if (isInWatchlist) {
newWatchlist.delete(stockCode);
toast({
title: '已移除自选股',
status: 'info',
duration: 2000,
isClosable: true,
});
} else {
newWatchlist.add(stockCode);
toast({
title: '已添加至自选股',
status: 'success',
duration: 2000,
isClosable: true,
});
}
setWatchlistSet(newWatchlist);
localStorage.setItem('stock_watchlist', JSON.stringify(Array.from(newWatchlist)));
} catch (error) {
console.error('切换自选股失败:', error);
toast({
title: '操作失败',
description: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
// 切换自选股(使用 useWatchlist Hook
const handleWatchlistToggle = useCallback(async (stockCode, stockName, currentlyInWatchlist) => {
if (currentlyInWatchlist) {
await handleRemoveFromWatchlist(stockCode);
} else {
await handleAddToWatchlist(stockCode, stockName);
}
}, [watchlistSet, toast]);
}, [handleAddToWatchlist, handleRemoveFromWatchlist]);
// 空状态
if (!event) {
@@ -335,15 +306,10 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 相关股票(可折叠) - 懒加载 - 需要 PRO 权限 - 支持精简/详细模式 */}
<CollapsibleSection
title="相关股票"
isOpen={isStocksOpen}
isOpen={sectionState.stocks.isOpen}
onToggle={handleStocksToggle}
count={stocks?.length || 0}
subscriptionBadge={(() => {
if (!canAccessStocks) {
return <SubscriptionBadge tier="pro" size="sm" />;
}
return null;
})()}
subscriptionBadge={!canAccessStocks ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessStocks}
onLockedClick={() => handleLockedClick('相关股票', 'pro')}
showModeToggle={canAccessStocks}
@@ -378,7 +344,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
stocks={stocks}
quotes={quotes}
eventTime={event.created_at}
watchlistSet={watchlistSet}
isInWatchlist={isInWatchlist}
onWatchlistToggle={handleWatchlistToggle}
/>
)}
@@ -389,7 +355,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
eventTitle={event.title}
effectiveTradingDate={event.trading_date || event.created_at}
eventTime={event.created_at}
isOpen={isConceptsOpen}
isOpen={sectionState.concepts.isOpen}
onToggle={handleConceptsToggle}
subscriptionBadge={!canAccessConcepts ? <SubscriptionBadge tier="pro" size="sm" /> : null}
isLocked={!canAccessConcepts}
@@ -399,7 +365,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 历史事件对比(可折叠) - 懒加载 - 需要 PRO 权限 */}
<CollapsibleSection
title="历史事件对比"
isOpen={isHistoricalOpen}
isOpen={sectionState.historical.isOpen}
onToggle={handleHistoricalToggle}
count={historicalEvents?.length || 0}
subscriptionBadge={!canAccessHistorical ? <SubscriptionBadge tier="pro" size="sm" /> : null}
@@ -422,7 +388,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
{/* 传导链分析(可折叠) - 懒加载 - 需要 MAX 权限 */}
<CollapsibleSection
title="传导链分析"
isOpen={isTransmissionOpen}
isOpen={sectionState.transmission.isOpen}
onToggle={handleTransmissionToggle}
subscriptionBadge={!canAccessTransmission ? <SubscriptionBadge tier="max" size="sm" /> : null}
isLocked={!canAccessTransmission}
@@ -450,7 +416,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
featureName={upgradeModal.featureName}
currentLevel={userTier}
/>
): null }
) : null}
</Card>
);
};

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,9 +35,9 @@ const EventDetailModal: React.FC<EventDetailModalProps> = ({
className="event-detail-modal"
styles={{
mask: { background: 'transparent' },
content: { borderRadius: 24, padding: 0, maxWidth: 1400, background: 'transparent', margin: '0 auto' },
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px', borderRadius: '24px 24px 0 0', margin: 0 },
body: { padding: 0 },
content: { borderRadius: 24, padding: 0, maxWidth: 1400, background: 'transparent', margin: '0 auto', maxHeight: '80vh', display: 'flex', flexDirection: 'column' },
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px', borderRadius: '24px 24px 0 0', margin: 0, flexShrink: 0 },
body: { padding: 0, overflowY: 'auto', flex: 1 },
}}
>
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}

View File

@@ -1,5 +1,6 @@
// src/views/Community/components/HeroPanel.js
// 顶部说明面板组件:事件中心 + 沪深指数K线图 + 热门概念3D动画
// 交易时间内自动更新指数行情(每分钟一次)
import React, { useEffect, useState, useMemo, useCallback, useRef } from 'react';
import {
@@ -22,10 +23,12 @@ import {
ModalHeader,
ModalBody,
ModalCloseButton,
Tooltip,
} from '@chakra-ui/react';
import { AlertCircle, Clock, TrendingUp, Info } from 'lucide-react';
import { AlertCircle, Clock, TrendingUp, Info, RefreshCw } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { logger } from '../../../utils/logger';
import { useIndexQuote } from '../../../hooks/useIndexQuote';
// 定义动画
const animations = `
@@ -104,6 +107,7 @@ const isInTradingTime = () => {
/**
* 精美K线指数卡片 - 类似 KLineChartModal 风格
* 交易时间内自动更新实时行情(每分钟一次)
*/
const CompactIndexCard = ({ indexCode, indexName }) => {
const [chartData, setChartData] = useState(null);
@@ -113,38 +117,66 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
const upColor = '#ef5350'; // 涨 - 红色
const downColor = '#26a69a'; // 跌 - 绿色
const loadData = useCallback(async () => {
// 使用实时行情 Hook - 交易时间内每分钟自动更新
const { quote, isTrading, refresh: refreshQuote } = useIndexQuote(indexCode, {
refreshInterval: 60000, // 1分钟
autoRefresh: true,
});
// 加载日K线图数据
const loadChartData = useCallback(async () => {
const data = await fetchIndexKline(indexCode);
if (data?.data?.length > 0) {
const latest = data.data[data.data.length - 1];
const prevClose = latest.prev_close || data.data[data.data.length - 2]?.close || latest.open;
const changeAmount = latest.close - prevClose;
const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0;
setLatestData({
close: latest.close,
open: latest.open,
high: latest.high,
low: latest.low,
changeAmount: changeAmount,
changePct: changePct,
isPositive: changeAmount >= 0
});
const recentData = data.data.slice(-60); // 增加到60天
const recentData = data.data.slice(-60); // 最近60天
setChartData({
dates: recentData.map(item => item.time),
klineData: recentData.map(item => [item.open, item.close, item.low, item.high]),
volumes: recentData.map(item => item.volume || 0),
rawData: recentData
});
// 如果没有实时行情,使用日线数据的最新值
if (!quote) {
const latest = data.data[data.data.length - 1];
const prevClose = latest.prev_close || data.data[data.data.length - 2]?.close || latest.open;
const changeAmount = latest.close - prevClose;
const changePct = prevClose ? ((changeAmount / prevClose) * 100) : 0;
setLatestData({
close: latest.close,
open: latest.open,
high: latest.high,
low: latest.low,
changeAmount: changeAmount,
changePct: changePct,
isPositive: changeAmount >= 0
});
}
}
setLoading(false);
}, [indexCode]);
}, [indexCode, quote]);
// 初始加载日K数据
useEffect(() => {
loadData();
}, [loadData]);
loadChartData();
}, [loadChartData]);
// 当实时行情更新时,更新 latestData
useEffect(() => {
if (quote) {
setLatestData({
close: quote.price,
open: quote.open,
high: quote.high,
low: quote.low,
changeAmount: quote.change,
changePct: quote.change_pct,
isPositive: quote.change >= 0,
updateTime: quote.update_time,
isRealtime: true,
});
}
}, [quote]);
const chartOption = useMemo(() => {
if (!chartData) return {};
@@ -306,6 +338,30 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
<Text fontSize="sm" color="whiteAlpha.800" fontWeight="semibold">
{indexName}
</Text>
{/* 实时状态指示 */}
{isTrading && latestData?.isRealtime && (
<Tooltip label="实时行情,每分钟更新" placement="top">
<HStack
spacing={1}
px={1.5}
py={0.5}
bg="rgba(0,218,60,0.1)"
borderRadius="full"
border="1px solid rgba(0,218,60,0.3)"
>
<Box
w="5px"
h="5px"
borderRadius="full"
bg="#00da3c"
animation="pulse 1.5s infinite"
/>
<Text fontSize="9px" color="#00da3c" fontWeight="bold">
实时
</Text>
</HStack>
</Tooltip>
)}
</HStack>
<HStack spacing={3}>
<Text fontSize="lg" fontWeight="bold" color="white" fontFamily="monospace">
@@ -338,16 +394,22 @@ const CompactIndexCard = ({ indexCode, indexName }) => {
style={{ height: '100%', width: '100%' }}
opts={{ renderer: 'canvas' }}
/>
{/* 底部提示 */}
<Text
{/* 底部提示 - 显示更新时间 */}
<HStack
position="absolute"
bottom={0}
right={1}
fontSize="9px"
color="whiteAlpha.300"
spacing={2}
>
滚轮缩放 · 拖动查看
</Text>
{latestData?.updateTime && (
<Text fontSize="9px" color="whiteAlpha.400">
{latestData.updateTime}
</Text>
)}
<Text fontSize="9px" color="whiteAlpha.300">
滚轮缩放 · 拖动查看
</Text>
</HStack>
</Box>
</Flex>
);

View File

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

View File

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

View File

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

View File

@@ -5,6 +5,27 @@ import { loadWatchlist, toggleWatchlist as toggleWatchlistAction } from '../../.
import { message } from 'antd';
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
* 封装自选股的加载、添加、移除逻辑
@@ -19,9 +40,9 @@ export const useWatchlist = (shouldLoad = true) => {
const watchlistArray = useSelector(state => state.stock.watchlist, shallowEqual);
const loading = useSelector(state => state.stock.loading.watchlist);
// 转换为 Set 方便快速查询
// 转换为 Set 方便快速查询标准化为6位代码
const watchlistSet = useMemo(() => {
return new Set(watchlistArray);
return new Set(watchlistArray.map(normalizeStockCode));
}, [watchlistArray]);
// 初始化时加载自选股列表(只在 shouldLoad 为 true 时)
@@ -33,32 +54,36 @@ export const useWatchlist = (shouldLoad = true) => {
}, [dispatch, shouldLoad]);
/**
* 检查股票是否在自选股中
* @param {string} stockCode - 股票代码
* 检查股票是否在自选股中(支持带后缀的代码格式)
* @param {string} stockCode - 股票代码(支持 600000, 600000.SH 等格式)
* @returns {boolean}
*/
const isInWatchlist = useCallback((stockCode) => {
return watchlistSet.has(stockCode);
const normalized = normalizeStockCode(stockCode);
return watchlistSet.has(normalized);
}, [watchlistSet]);
/**
* 切换自选股状态
* @param {string} stockCode - 股票代码
* @param {string} stockCode - 股票代码(支持带后缀格式,会自动标准化)
* @param {string} stockName - 股票名称
* @returns {Promise<boolean>} 操作是否成功
*/
const toggleWatchlist = useCallback(async (stockCode, stockName) => {
const wasInWatchlist = watchlistSet.has(stockCode);
const normalized = normalizeStockCode(stockCode);
const wasInWatchlist = watchlistSet.has(normalized);
logger.debug('useWatchlist', '切换自选股状态', {
stockCode,
normalized,
stockName,
wasInWatchlist
});
try {
// 传递标准化后的6位代码给 Redux action
await dispatch(toggleWatchlistAction({
stockCode,
stockCode: normalized,
stockName,
isInWatchlist: wasInWatchlist
})).unwrap();
@@ -68,6 +93,7 @@ export const useWatchlist = (shouldLoad = true) => {
} catch (error) {
logger.error('useWatchlist', '切换自选股失败', error, {
stockCode,
normalized,
stockName
});
message.error(error.message || '操作失败,请稍后重试');
@@ -87,16 +113,17 @@ export const useWatchlist = (shouldLoad = true) => {
let successCount = 0;
const promises = stocks.map(async ({ code, name }) => {
if (!watchlistSet.has(code)) {
const normalized = normalizeStockCode(code);
if (!watchlistSet.has(normalized)) {
try {
await dispatch(toggleWatchlistAction({
stockCode: code,
stockCode: normalized,
stockName: name,
isInWatchlist: false
})).unwrap();
successCount++;
} catch (error) {
logger.error('useWatchlist', '添加失败', error, { code, name });
logger.error('useWatchlist', '添加失败', error, { code, normalized, name });
}
}
});

View File

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

View File

@@ -107,28 +107,6 @@ const Community = () => {
}
}, [events, loading, pagination, filters]);
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
const hasScrolled = useRef(false);
useEffect(() => {
// 只在第一次挂载时执行滚动
if (hasScrolled.current) return;
// 延迟执行确保DOM已完全渲染
const timer = setTimeout(() => {
if (containerRef.current) {
hasScrolled.current = true;
// 滚动到容器顶部,自动考虑导航栏的高度
containerRef.current.scrollIntoView({
behavior: 'auto',
block: 'start',
inline: 'nearest'
});
}
}, 100);
return () => clearTimeout(timer);
}, []); // 空依赖数组,只在组件挂载时执行一次
/**
* ⚡ 【核心逻辑】注册 Socket 新事件回调 - 当收到新事件时智能刷新列表
*

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1463,7 +1463,7 @@ const ConceptCenter = () => {
fontSize="md"
transition="all 0.2s"
border="none"
height="100%"
alignSelf="stretch"
boxShadow="inset 0 1px 0 rgba(255, 255, 255, 0.2)"
>
搜索

View File

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

View File

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

View File

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

View File

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

View File

@@ -27,7 +27,7 @@ export const subscriptionConfig = {
{
name: 'pro',
displayName: 'Pro 专业版',
description: '为专业投资者打造,解锁高级分析功能',
description: '事件关联股票深度分析\n历史事件智能对比复盘\n事件概念关联与挖掘\n概念板块个股追踪\n概念深度研报与解读\n个股异动实时预警',
icon: 'gem',
badge: '推荐',
badgeColor: 'gold',
@@ -68,27 +68,18 @@ export const subscriptionConfig = {
},
],
features: [
{ name: '新闻信息流', enabled: true },
{ name: '历史事件对比', enabled: true },
{ name: '事件传导链分析(AI)', enabled: true },
{ name: '事件-相关标的分析', enabled: true },
{ name: '相关概念展示', enabled: true },
{ name: 'AI复盘功能', enabled: true },
{ name: '企业概览', enabled: true },
{ name: '个股深度分析(AI)', enabled: true, limit: '50家/月' },
{ name: '高效数据筛选工具', enabled: true },
{ name: '概念中心(548大概念)', enabled: true },
{ name: '历史时间轴查询', enabled: true, limit: '100天' },
{ name: '涨停板块数据分析', enabled: true },
{ name: '个股涨停分析', enabled: true },
{ name: '板块深度分析(AI)', enabled: false },
{ name: '概念高频更新', enabled: false },
{ name: '事件关联股票深度分析', enabled: true },
{ name: '历史事件智能对比复盘', enabled: true },
{ name: '事件概念关联与挖掘', enabled: true },
{ name: '概念板块个股追踪', enabled: true },
{ name: '概念深度研报与解读', enabled: true },
{ name: '个股异动实时预警', enabled: true },
],
},
{
name: 'max',
displayName: 'Max 旗舰版',
description: '旗舰级体验,无限使用所有功能',
description: '包含Pro版全部功能\n事件传导链路智能分析\n概念演变时间轴追溯\n个股全方位深度研究\n价小前投研助手无限使用\n新功能优先体验权\n专属客服一对一服务',
icon: 'crown',
badge: '最受欢迎',
badgeColor: 'gold',
@@ -129,21 +120,13 @@ export const subscriptionConfig = {
},
],
features: [
{ name: '新闻信息流', enabled: true },
{ name: '历史事件对比', enabled: true },
{ name: '事件传导链分析(AI)', enabled: true },
{ name: '事件-相关标的分析', enabled: true },
{ name: '相关概念展示', enabled: true },
{ name: '板块深度分析(AI)', enabled: true },
{ name: 'AI复盘功能', enabled: true },
{ name: '企业概览', enabled: true },
{ name: '个股深度分析(AI)', enabled: true, limit: '无限制' },
{ name: '高效数据筛选工具', enabled: true },
{ name: '概念中心(548大概念)', enabled: true },
{ name: '历史时间轴查询', enabled: true, limit: '无限制' },
{ name: '概念高频更新', enabled: true },
{ name: '涨停板块数据分析', enabled: true },
{ name: '个股涨停分析', enabled: true },
{ name: '包含Pro版全部功能', enabled: true },
{ name: '事件传导链路智能分析', enabled: true },
{ name: '概念演变时间轴追溯', enabled: true },
{ name: '个股全方位深度研究', enabled: true },
{ name: '价小前投研助手无限使用', enabled: true },
{ name: '新功能优先体验权', enabled: true },
{ name: '专属客服一对一服务', enabled: true },
],
},
],

View 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;

View File

@@ -42,7 +42,6 @@ import {
useDisclosure,
Image,
Fade,
ScaleFade,
Collapse,
Stack,
Progress,
@@ -57,26 +56,18 @@ import {
} from '@chakra-ui/react';
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 ConceptStocksModal from './components/ConceptStocksModal';
import { BsGraphUp, BsLightningFill } from 'react-icons/bs';
import { keyframes } from '@emotion/react';
import * as echarts from 'echarts';
import { logger } from '../../utils/logger';
import tradingDays from '../../data/tradingDays.json';
import { useStockOverviewEvents } from './hooks/useStockOverviewEvents';
// 交易日 Set用于快速查找
const tradingDaysSet = new Set(tradingDays);
// Navigation bar now provided by MainLayout
// import HomeNavbar from '../../components/Navbars/HomeNavbar';
// 动画定义
const pulseAnimation = keyframes`
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
`;
const floatAnimation = keyframes`
0%, 100% { transform: translateY(0px); }
50% { transform: translateY(-10px); }
`;
const StockOverview = () => {
const navigate = useNavigate();
const toast = useToast();
@@ -112,6 +103,10 @@ const StockOverview = () => {
const [availableDates, setAvailableDates] = useState([]);
const [isCalendarOpen, setIsCalendarOpen] = useState(false);
// 个股列表弹窗状态
const [isStockModalOpen, setIsStockModalOpen] = useState(false);
const [selectedConcept, setSelectedConcept] = useState(null);
// 专业的颜色主题
const bgColor = useColorModeValue('white', '#0a0a0a');
const cardBg = useColorModeValue('white', '#1a1a1a');
@@ -124,6 +119,13 @@ const StockOverview = () => {
const accentColor = useColorModeValue('purple.600', goldColor);
const heroBg = useColorModeValue('linear(to-br, purple.600, pink.500)', 'linear(to-br, #0a0a0a, #1a1a1a)');
// 打开个股列表弹窗
const handleViewStocks = useCallback((e, concept) => {
e.stopPropagation();
setSelectedConcept(concept);
setIsStockModalOpen(true);
}, []);
// 防抖搜索
const debounceSearch = useCallback(
(() => {
@@ -187,7 +189,27 @@ const StockOverview = () => {
if (data.success) {
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', '热门概念加载成功', {
count: data.data?.length || 0,
date: data.trade_date
@@ -218,7 +240,7 @@ const StockOverview = () => {
falling_count: data.statistics.falling_count
}));
}
if (!selectedDate) setSelectedDate(data.trade_date);
// 日期由 fetchTopConcepts 统一设置,这里不再设置
logger.debug('StockOverview', '热力图数据加载成功', {
count: data.data?.length || 0,
date: data.trade_date
@@ -249,11 +271,9 @@ const StockOverview = () => {
date: data.trade_date
};
setMarketStats(newStats);
setAvailableDates(data.available_dates || []);
if (!selectedDate) setSelectedDate(data.trade_date);
// 日期和可选日期列表由 fetchTopConcepts 统一设置,这里不再设置
logger.debug('StockOverview', '市场统计数据加载成功', {
date: data.trade_date,
availableDatesCount: data.available_dates?.length || 0
date: data.trade_date
});
// 🎯 追踪市场统计数据查看
@@ -622,7 +642,7 @@ const StockOverview = () => {
<Container maxW="container.xl" position="relative">
<VStack spacing={8} align="center">
<VStack spacing={4} textAlign="center" maxW="3xl">
<HStack spacing={3} animation={`${floatAnimation} 3s ease-in-out infinite`}>
<HStack spacing={3}>
<Icon as={BsGraphUp} boxSize={12} color={colorMode === 'dark' ? goldColor : 'white'} />
<Heading
as="h1"
@@ -922,8 +942,8 @@ const StockOverview = () => {
) : (
<SimpleGrid columns={{ base: 1, md: 2, lg: 3 }} spacing={6}>
{topConcepts.map((concept, index) => (
<ScaleFade in={true} initialScale={0.9} key={concept.concept_id}>
<Card
key={concept.concept_id}
bg={cardBg}
borderWidth="1px"
borderColor={borderColor}
@@ -964,7 +984,6 @@ const StockOverview = () => {
px={3}
py={1}
borderRadius="full"
animation={Math.abs(concept.change_percent) > 5 ? `${pulseAnimation} 2s infinite` : 'none'}
border={colorMode === 'dark' ? '1px solid' : 'none'}
borderColor={colorMode === 'dark' ? concept.change_percent > 0 ? '#ff4d4d' : '#22c55e' : 'transparent'}
>
@@ -989,31 +1008,33 @@ const StockOverview = () => {
<Divider />
<Box w="100%">
<Box
w="100%"
cursor="pointer"
onClick={(e) => handleViewStocks(e, concept)}
_hover={{ bg: hoverBg }}
p={2}
borderRadius="md"
transition="background 0.2s"
>
<Text fontSize="xs" color="gray.500" mb={2}>
包含 {concept.stock_count} 只个股
</Text>
{concept.stocks && concept.stocks.length > 0 && (
<Flex flexWrap="wrap" gap={2}>
<Flex
flexWrap="nowrap"
gap={2}
overflow="hidden"
maxH="24px"
>
{concept.stocks.map((stock, idx) => (
<Tag
key={idx}
size="sm"
colorScheme="purple"
variant="subtle"
cursor="pointer"
onClick={(e) => {
e.stopPropagation();
// 🎯 追踪概念下的股票标签点击
trackConceptStockClicked({
code: stock.stock_code,
name: stock.stock_name
}, concept.concept_name);
navigate(`/company?scode=${stock.stock_code}`);
}}
flexShrink={0}
>
<TagLabel>{stock.stock_name}</TagLabel>
</Tag>
@@ -1039,7 +1060,6 @@ const StockOverview = () => {
</VStack>
</CardBody>
</Card>
</ScaleFade>
))}
</SimpleGrid>
)}
@@ -1114,7 +1134,14 @@ const StockOverview = () => {
</Card>
</Box>
</Container>
{/* 个股列表弹窗 */}
<ConceptStocksModal
isOpen={isStockModalOpen}
onClose={() => setIsStockModalOpen(false)}
concept={selectedConcept}
/>
</Box>
);
};