Compare commits

..

2 Commits

Author SHA1 Message Date
1c49ddf42c update app_vx 2025-11-13 10:20:03 +08:00
d64349b606 update app_vx 2025-11-13 07:40:46 +08:00
14 changed files with 8925 additions and 263 deletions

View File

@@ -53,16 +53,14 @@ NODE_OPTIONS=--max_old_space_size=4096
# ========================================
# Bytedesk 客服系统配置
# ========================================
# Bytedesk 服务器地址(使用相对路径,通过 Nginx 代理)
# ⚠️ 重要:生产环境必须使用相对路径,避免 Mixed Content 错误
# Nginx 配置location /bytedesk-api/ { proxy_pass http://43.143.189.195/; }
REACT_APP_BYTEDESK_API_URL=/bytedesk-api
# Bytedesk 服务器地址
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# 组织 UUID从管理后台 -> 设置 -> 组织信息 -> 组织UUID
# 组织 ID从管理后台获取
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组 UUID从管理后台 -> 客服管理 -> 工作组 -> 工作组UUID
REACT_APP_BYTEDESK_SID=df_wg_uid
# 工作组 ID从管理后台获取
REACT_APP_BYTEDESK_SID=df_wg_aftersales
# 客服类型2=人工客服, 1=机器人)
REACT_APP_BYTEDESK_TYPE=2

View File

@@ -1,49 +0,0 @@
# Bytedesk 客服系统集成文件
以下文件和目录属于客服系统集成功能,未提交到当前分支:
## 1. Dify 机器人控制逻辑
**位置**: public/index.html
**状态**: 已存入 stash
**Stash ID**: stash@{0}
**说明**: 根据路径控制 Dify 机器人显示(已设置为完全不显示,只使用 Bytedesk 客服)
## 2. Bytedesk 集成代码
**位置**: src/bytedesk-integration/
**状态**: 未跟踪文件(需要手动管理)
**内容**:
- .env.bytedesk.example - Bytedesk 环境变量配置示例
- App.jsx.example - 集成 Bytedesk 的示例代码
- components/ - Bytedesk 相关组件
- config/ - Bytedesk 配置文件
- 前端工程师集成手册.md - 详细集成文档
## 恢复方法
### 恢复 public/index.html 的改动:
```bash
git stash apply stash@{0}
```
### 使用 Bytedesk 集成代码:
```bash
# 查看集成手册
cat src/bytedesk-integration/前端工程师集成手册.md
# 复制示例配置
cp src/bytedesk-integration/.env.bytedesk.example .env.bytedesk
cp src/bytedesk-integration/App.jsx.example src/App.jsx
```
## 注意事项
⚠️ **重要提示:**
- `src/bytedesk-integration/` 目录中的文件是未跟踪的untracked
- 如果需要提交客服功能,需要先添加到 git:
```bash
git add src/bytedesk-integration/
git commit -m "feat: 集成 Bytedesk 客服系统"
```
- 当前分支feature_bugfix/251110_event专注于非客服功能
- 建议在单独的分支中开发客服功能

653
app_vx.py
View File

@@ -15,6 +15,29 @@ from pathlib import Path
import time
from sqlalchemy import create_engine, text, func, or_, case, event, desc, asc
from flask import Flask, has_request_context, render_template, request, jsonify, redirect, url_for, flash, session, render_template_string, current_app, send_from_directory
# Flask 3.x 兼容性补丁flask-sqlalchemy 旧版本需要 _app_ctx_stack
try:
from flask import _app_ctx_stack
except ImportError:
import flask
from werkzeug.local import LocalStack
import threading
# 创建一个兼容的 LocalStack 子类
class CompatLocalStack(LocalStack):
@property
def __ident_func__(self):
# 返回当前线程的标识函数
# 优先使用 greenlet协程否则使用 threading
try:
from greenlet import getcurrent
return getcurrent
except ImportError:
return threading.get_ident
flask._app_ctx_stack = CompatLocalStack()
from flask_sqlalchemy import SQLAlchemy
from flask_login import LoginManager, UserMixin, login_user, logout_user, login_required, current_user
from flask_mail import Mail, Message
@@ -325,7 +348,7 @@ def subscription_required(level='pro'):
@subscription_required('pro') # 需要 Pro 或 Max 用户
@subscription_required('max') # 仅限 Max 用户
注意:此装饰器需要配合 @token_required 使用
注意:此装饰器需要配合 使用
"""
from functools import wraps
@@ -1052,8 +1075,6 @@ def get_clickhouse_client():
@app.route('/api/stock/<stock_code>/kline')
@token_required
@pro_or_max_required
def get_stock_kline(stock_code):
"""获取股票K线数据 - 仅限 Pro/Max 会员(小程序功能)"""
chart_type = request.args.get('chart_type', 'daily') # 默认改为daily
@@ -1519,9 +1540,6 @@ def like_post(post_id):
post.likes_count += 1
message = '已点赞'
# 可以在这里添加点赞通知
if post.user_id != request.user.id:
notify_user_post_liked(post)
db.session.commit()
return jsonify({
@@ -1598,15 +1616,6 @@ def add_comment(post_id):
db.session.add(comment)
post.comments_count += 1
# 如果是回复评论,可以添加通知
if parent_id:
parent_comment = Comment.query.get(parent_id)
if parent_comment and parent_comment.user_id != request.user.id:
notify_user_comment_replied(parent_comment)
# 如果是评论帖子,通知帖子作者
elif post.user_id != request.user.id:
notify_user_post_commented(post)
db.session.commit()
@@ -1628,7 +1637,7 @@ def add_comment(post_id):
@app.route('/post/comments/<int:post_id>')
@token_required
def get_comments(post_id):
"""获取帖子评论列表"""
page = request.args.get('page', 1, type=int)
@@ -2012,8 +2021,8 @@ def get_limit_rate(stock_code):
@app.route('/api/events', methods=['GET'])
@token_required
@pro_or_max_required
def api_get_events():
"""
获取事件列表API - 优化版本(保持完全兼容)
@@ -2555,11 +2564,7 @@ def api_get_events():
'week_change': week_change
}
# ==================== 获取整体统计信息 ====================
# 获取所有筛选条件下的事件和股票(用于统计)
all_filtered_events = query.limit(500).all()
all_event_ids = [e.id for e in all_filtered_events]
# ==================== 获取整体统计信息(应用所有筛选条件) ====================
overall_distribution = {
'limit_down': 0,
@@ -2573,40 +2578,105 @@ def api_get_events():
'limit_up': 0
}
if all_event_ids:
# 获取所有相关股票
all_stocks_for_stats = RelatedStock.query.filter(
RelatedStock.event_id.in_(all_event_ids)
# 使用当前筛选条件的query但不应用分页限制获取所有符合条件的事件
# 这样统计数据会跟随用户的筛选条件变化
all_filtered_events = query.limit(1000).all() # 限制最多1000个事件避免查询过慢
week_event_ids = [e.id for e in all_filtered_events]
if week_event_ids:
# 获取这些事件的所有关联股票
week_related_stocks = RelatedStock.query.filter(
RelatedStock.event_id.in_(week_event_ids)
).all()
# 统计涨跌分布
for stock in all_stocks_for_stats:
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
if clean_code in stock_changes:
daily_change = stock_changes[clean_code]['daily_change']
# 按事件ID分组
week_stocks_by_event = {}
for stock in week_related_stocks:
if stock.event_id not in week_stocks_by_event:
week_stocks_by_event[stock.event_id] = []
week_stocks_by_event[stock.event_id].append(stock)
# 计算涨跌停限制
limit_rate = get_limit_rate(stock.stock_code)
# 收集所有股票代码(用于批量查询行情)
week_stock_codes = []
week_code_mapping = {}
for stocks in week_stocks_by_event.values():
for stock in stocks:
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
week_stock_codes.append(clean_code)
week_code_mapping[clean_code] = stock.stock_code
# 分类统计
if daily_change <= -limit_rate + 0.01:
overall_distribution['limit_down'] += 1
elif daily_change >= limit_rate - 0.01:
overall_distribution['limit_up'] += 1
elif daily_change > 5:
overall_distribution['up_over_5'] += 1
elif daily_change > 1:
overall_distribution['up_1_to_5'] += 1
elif daily_change > 0.1:
overall_distribution['up_within_1'] += 1
elif daily_change >= -0.1:
overall_distribution['flat'] += 1
elif daily_change > -1:
overall_distribution['down_within_1'] += 1
elif daily_change > -5:
overall_distribution['down_5_to_1'] += 1
else:
overall_distribution['down_over_5'] += 1
week_stock_codes = list(set(week_stock_codes))
# 批量查询这些股票的最新行情数据
week_stock_changes = {}
if week_stock_codes:
codes_str = "'" + "', '".join(week_stock_codes) + "'"
recent_trades_sql = f"""
SELECT
SECCODE,
SECNAME,
F010N as daily_change,
ROW_NUMBER() OVER (PARTITION BY SECCODE ORDER BY TRADEDATE DESC) as rn
FROM ea_trade
WHERE SECCODE IN ({codes_str})
AND F010N IS NOT NULL
AND TRADEDATE >= DATE_SUB(CURDATE(), INTERVAL 3 DAY)
ORDER BY SECCODE, TRADEDATE DESC
"""
result = db.session.execute(text(recent_trades_sql))
for row in result.fetchall():
sec_code = row[0]
if row[3] == 1: # 只取最新的数据rn=1
week_stock_changes[sec_code] = {
'stock_name': row[1],
'daily_change': float(row[2]) if row[2] else 0
}
# 按事件统计平均涨跌幅分布
event_avg_changes = {}
for event in all_filtered_events:
event_stocks = week_stocks_by_event.get(event.id, [])
if not event_stocks:
continue
total_change = 0
valid_count = 0
for stock in event_stocks:
clean_code = stock.stock_code.replace('.SH', '').replace('.SZ', '').replace('.BJ', '')
if clean_code in week_stock_changes:
daily_change = week_stock_changes[clean_code]['daily_change']
total_change += daily_change
valid_count += 1
if valid_count > 0:
avg_change = total_change / valid_count
event_avg_changes[event.id] = avg_change
# 统计事件平均涨跌幅的分布
for event_id, avg_change in event_avg_changes.items():
# 对于事件平均涨幅,不使用涨跌停分类,使用通用分类
if avg_change <= -10:
overall_distribution['limit_down'] += 1
elif avg_change >= 10:
overall_distribution['limit_up'] += 1
elif avg_change > 5:
overall_distribution['up_over_5'] += 1
elif avg_change > 1:
overall_distribution['up_1_to_5'] += 1
elif avg_change > 0.1:
overall_distribution['up_within_1'] += 1
elif avg_change >= -0.1:
overall_distribution['flat'] += 1
elif avg_change > -1:
overall_distribution['down_within_1'] += 1
elif avg_change > -5:
overall_distribution['down_5_to_1'] += 1
else:
overall_distribution['down_over_5'] += 1
# ==================== 构建响应数据 ====================
@@ -2839,8 +2909,8 @@ def get_event_class(count):
else:
return 'bg-gradient-success'
@app.route('/api/calendar-event-counts')
@token_required
@pro_or_max_required
def get_calendar_event_counts():
"""获取整月的事件数量统计仅统计type为event的事件"""
try:
@@ -2930,8 +3000,8 @@ def to_dict(self):
# 1. 首页接口
@app.route('/api/home', methods=['GET'])
@token_required
@pro_or_max_required
def api_home():
try:
seven_days_ago = datetime.now() - timedelta(days=7)
@@ -3620,17 +3690,107 @@ def api_login_email():
# 5. 事件详情-相关标的接口
@app.route('/api/event/<int:event_id>/related-stocks-detail', methods=['GET'])
@token_required
@pro_or_max_required
def api_event_related_stocks(event_id):
"""事件相关标的详情接口 - 仅限 Pro/Max 会员"""
try:
from datetime import datetime, timedelta, time as dt_time
from sqlalchemy import text
event = Event.query.get_or_404(event_id)
related_stocks = event.related_stocks.order_by(RelatedStock.correlation.desc()).all()
# 获取ClickHouse客户端用于分时数据查询
client = get_clickhouse_client()
# 获取事件时间(如果事件有开始时间,使用开始时间;否则使用创建时间)
event_time = event.start_time if event.start_time else event.created_at
current_time = datetime.now()
# 定义交易日和时间范围计算函数(与 app.py 中的逻辑完全一致)
def get_trading_day_and_times(event_datetime):
event_date = event_datetime.date()
event_time_only = event_datetime.time()
# Trading hours
market_open = dt_time(9, 30)
market_close = dt_time(15, 0)
with engine.connect() as conn:
# First check if the event date itself is a trading day
is_trading_day = conn.execute(text("""
SELECT 1
FROM trading_days
WHERE EXCHANGE_DATE = :date
"""), {"date": event_date}).fetchone() is not None
if is_trading_day:
# If it's a trading day, determine time period based on event time
if event_time_only < market_open:
# Before market opens - use full trading day
return event_date, market_open, market_close
elif event_time_only > market_close:
# After market closes - get next trading day
next_trading_day = conn.execute(text("""
SELECT EXCHANGE_DATE
FROM trading_days
WHERE EXCHANGE_DATE > :date
ORDER BY EXCHANGE_DATE LIMIT 1
"""), {"date": event_date}).fetchone()
# Convert to date object if we found a next trading day
return (next_trading_day[0].date() if next_trading_day else None,
market_open, market_close)
else:
# During trading hours
return event_date, event_time_only, market_close
else:
# If not a trading day, get next trading day
next_trading_day = conn.execute(text("""
SELECT EXCHANGE_DATE
FROM trading_days
WHERE EXCHANGE_DATE > :date
ORDER BY EXCHANGE_DATE LIMIT 1
"""), {"date": event_date}).fetchone()
# Convert to date object if we found a next trading day
return (next_trading_day[0].date() if next_trading_day else None,
market_open, market_close)
trading_day, start_time, end_time = get_trading_day_and_times(event_time)
if not trading_day:
# 如果没有交易日,返回空数据
return jsonify({
'code': 200,
'message': 'success',
'data': {
'event_id': event_id,
'event_title': event.title,
'event_desc': event.description,
'event_type': event.event_type,
'event_importance': event.importance,
'event_status': event.status,
'event_created_at': event.created_at.strftime("%Y-%m-%d %H:%M:%S"),
'event_start_time': event.start_time.isoformat() if event.start_time else None,
'event_end_time': event.end_time.isoformat() if event.end_time else None,
'keywords': event.keywords,
'view_count': event.view_count,
'post_count': event.post_count,
'follower_count': event.follower_count,
'related_stocks': [],
'total_count': 0
}
})
# For historical dates, ensure we're using actual data
start_datetime = datetime.combine(trading_day, start_time)
end_datetime = datetime.combine(trading_day, end_time)
# If the trading day is in the future relative to current time, return only names without data
if trading_day > current_time.date():
start_datetime = datetime.combine(trading_day, start_time)
end_datetime = datetime.combine(trading_day, end_time)
print(f"事件时间: {event_time}, 交易日: {trading_day}, 时间范围: {start_datetime} - {end_datetime}")
def get_minute_chart_data(stock_code):
"""获取股票分时图数据"""
try:
@@ -3703,44 +3863,226 @@ def api_event_related_stocks(event_id):
print(f"Error fetching minute data for {stock_code}: {e}")
return []
# ==================== 性能优化:批量查询所有股票数据 ====================
# 1. 收集所有股票代码
stock_codes = [stock.stock_code for stock in related_stocks]
# 2. 批量查询股票基本信息
stock_info_map = {}
if stock_codes:
stock_infos = StockBasicInfo.query.filter(StockBasicInfo.SECCODE.in_(stock_codes)).all()
for info in stock_infos:
stock_info_map[info.SECCODE] = info
# 处理不带后缀的股票代码
base_codes = [code.split('.')[0] for code in stock_codes if '.' in code and code not in stock_info_map]
if base_codes:
base_infos = StockBasicInfo.query.filter(StockBasicInfo.SECCODE.in_(base_codes)).all()
for info in base_infos:
# 将不带后缀的信息映射到带后缀的代码
for code in stock_codes:
if code.split('.')[0] == info.SECCODE and code not in stock_info_map:
stock_info_map[code] = info
# 3. 批量查询 ClickHouse 数据(价格、涨跌幅、分时图数据)
price_data_map = {} # 存储价格和涨跌幅数据
minute_chart_map = {} # 存储分时图数据
try:
if stock_codes:
print(f"批量查询 {len(stock_codes)} 只股票的价格数据...")
# 3.1 批量查询价格和涨跌幅数据(使用子查询方式,避免窗口函数与 GROUP BY 冲突)
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 (
SELECT
code,
close as last_price,
open as open_price,
high as high_price,
low as low_price,
volume,
amt as amount,
ROW_NUMBER() OVER (PARTITION BY code ORDER BY timestamp DESC) as rn
FROM stock_minute
WHERE code IN %(codes)s
AND timestamp >= %(start)s
AND timestamp <= %(end)s
)
SELECT
fp.code,
fp.first_price,
lp.last_price,
(lp.last_price - fp.first_price) / fp.first_price * 100 as change_pct,
lp.open_price,
lp.high_price,
lp.low_price,
lp.volume,
lp.amount
FROM first_prices fp
INNER JOIN last_prices lp ON fp.code = lp.code
WHERE fp.rn = 1 AND lp.rn = 1
"""
price_data = client.execute(batch_price_query, {
'codes': stock_codes,
'start': start_datetime,
'end': end_datetime
})
print(f"批量查询返回 {len(price_data)} 条价格数据")
# 解析批量查询结果
for row in price_data:
code = row[0]
first_price = float(row[1]) if row[1] is not None else None
last_price = float(row[2]) if row[2] is not None else None
change_pct = float(row[3]) if row[3] is not None else None
open_price = float(row[4]) if row[4] is not None else None
high_price = float(row[5]) if row[5] is not None else None
low_price = float(row[6]) if row[6] is not None else None
volume = int(row[7]) if row[7] is not None else None
amount = float(row[8]) if row[8] is not None else None
change_amount = None
if last_price is not None and first_price is not None:
change_amount = last_price - first_price
price_data_map[code] = {
'latest_price': last_price,
'first_price': first_price,
'change_pct': change_pct,
'change_amount': change_amount,
'open_price': open_price,
'high_price': high_price,
'low_price': low_price,
'volume': volume,
'amount': amount,
}
# 3.2 批量查询分时图数据
print(f"批量查询分时图数据...")
minute_chart_query = """
SELECT
code,
timestamp,
open,
high,
low,
close,
volume,
amt
FROM stock_minute
WHERE code IN %(codes)s
AND timestamp >= %(start)s
AND timestamp <= %(end)s
ORDER BY code, timestamp
"""
minute_data = client.execute(minute_chart_query, {
'codes': stock_codes,
'start': start_datetime,
'end': end_datetime
})
print(f"批量查询返回 {len(minute_data)} 条分时数据")
# 按股票代码分组分时数据
for row in minute_data:
code = row[0]
if code not in minute_chart_map:
minute_chart_map[code] = []
minute_chart_map[code].append({
'time': row[1].strftime('%H:%M'),
'open': float(row[2]) if row[2] else None,
'high': float(row[3]) if row[3] else None,
'low': float(row[4]) if row[4] else None,
'close': float(row[5]) if row[5] else None,
'volume': float(row[6]) if row[6] else None,
'amount': float(row[7]) if row[7] else None
})
except Exception as e:
print(f"批量查询 ClickHouse 失败: {e}")
# 如果批量查询失败price_data_map 和 minute_chart_map 为空,后续会使用降级方案
# 4. 组装每个股票的数据(从批量查询结果中获取)
stocks_data = []
for stock in related_stocks:
# 获取股票基本信息 - 也使用灵活匹配
stock_info = StockBasicInfo.query.filter_by(SECCODE=stock.stock_code).first()
if not stock_info:
base_code = stock.stock_code.split('.')[0]
stock_info = StockBasicInfo.query.filter_by(SECCODE=base_code).first()
print(f"正在组装股票 {stock.stock_code} 的数据...")
# 获取最新交易数据 - 使用灵活匹配
latest_trade = None
search_codes = [stock.stock_code, stock.stock_code.split('.')[0]]
# 从批量查询结果中获取股票基本信息
stock_info = stock_info_map.get(stock.stock_code)
for code in search_codes:
latest_trade = TradeData.query.filter_by(SECCODE=code) \
.order_by(TradeData.TRADEDATE.desc()).first()
if latest_trade:
break
# 从批量查询结果中获取价格数据
price_info = price_data_map.get(stock.stock_code)
# 获取前一交易日数据
prev_trade = None
if latest_trade:
prev_trade = TradeData.query.filter_by(SECCODE=latest_trade.SECCODE) \
.filter(TradeData.TRADEDATE < latest_trade.TRADEDATE) \
.order_by(TradeData.TRADEDATE.desc()).first()
# 计算涨跌幅
latest_price = None
first_price = None
change_pct = None
change_amount = None
if latest_trade and prev_trade:
if prev_trade.F007N and prev_trade.F007N != 0:
change_amount = float(latest_trade.F007N) - float(prev_trade.F007N)
change_pct = (change_amount / float(prev_trade.F007N)) * 100
elif latest_trade and latest_trade.F010N:
change_pct = float(latest_trade.F010N)
change_amount = float(latest_trade.F009N) if latest_trade.F009N else None
open_price = None
high_price = None
low_price = None
volume = None
amount = None
trade_date = trading_day
# 获取分时图数据
minute_chart_data = get_minute_chart_data(stock.stock_code)
if price_info:
# 使用批量查询的结果
latest_price = price_info['latest_price']
first_price = price_info['first_price']
change_pct = price_info['change_pct']
change_amount = price_info['change_amount']
open_price = price_info['open_price']
high_price = price_info['high_price']
low_price = price_info['low_price']
volume = price_info['volume']
amount = price_info['amount']
else:
# 如果批量查询没有返回数据使用降级方案TradeData
print(f"股票 {stock.stock_code} 批量查询无数据,使用降级方案...")
try:
latest_trade = None
search_codes = [stock.stock_code, stock.stock_code.split('.')[0]]
for code in search_codes:
latest_trade = TradeData.query.filter_by(SECCODE=code) \
.order_by(TradeData.TRADEDATE.desc()).first()
if latest_trade:
break
if latest_trade:
latest_price = float(latest_trade.F007N) if latest_trade.F007N else None
open_price = float(latest_trade.F003N) if latest_trade.F003N else None
high_price = float(latest_trade.F005N) if latest_trade.F005N else None
low_price = float(latest_trade.F006N) if latest_trade.F006N else None
first_price = float(latest_trade.F002N) if latest_trade.F002N else None
volume = float(latest_trade.F004N) if latest_trade.F004N else None
amount = float(latest_trade.F011N) if latest_trade.F011N else None
trade_date = latest_trade.TRADEDATE
# 计算涨跌幅
if latest_trade.F010N:
change_pct = float(latest_trade.F010N)
if latest_trade.F009N:
change_amount = float(latest_trade.F009N)
except Exception as fallback_error:
print(f"降级查询也失败 {stock.stock_code}: {fallback_error}")
# 从批量查询结果中获取分时图数据
minute_chart_data = minute_chart_map.get(stock.stock_code, [])
stock_data = {
'id': stock.id,
@@ -3755,17 +4097,19 @@ def api_event_related_stocks(event_id):
# 交易数据
'trade_data': {
'latest_price': float(latest_trade.F007N) if latest_trade and latest_trade.F007N else None,
'open_price': float(latest_trade.F003N) if latest_trade and latest_trade.F003N else None,
'high_price': float(latest_trade.F005N) if latest_trade and latest_trade.F005N else None,
'low_price': float(latest_trade.F006N) if latest_trade and latest_trade.F006N else None,
'prev_close': float(latest_trade.F002N) if latest_trade and latest_trade.F002N else None,
'change_amount': change_amount,
'latest_price': latest_price,
'first_price': first_price, # 事件发生时的价格
'open_price': open_price,
'high_price': high_price,
'low_price': low_price,
'change_amount': round(change_amount, 2) if change_amount is not None else None,
'change_pct': round(change_pct, 2) if change_pct is not None else None,
'volume': float(latest_trade.F004N) if latest_trade and latest_trade.F004N else None,
'amount': float(latest_trade.F011N) if latest_trade and latest_trade.F011N else None,
'trade_date': latest_trade.TRADEDATE.isoformat() if latest_trade else None,
} if latest_trade else None,
'volume': volume,
'amount': amount,
'trade_date': trade_date.isoformat() if trade_date else None,
'event_start_time': start_datetime.isoformat() if start_datetime else None, # 事件开始时间
'event_end_time': end_datetime.isoformat() if end_datetime else None, # 查询结束时间
} if latest_price is not None else None,
# 分时图数据
'minute_chart_data': minute_chart_data,
@@ -3809,8 +4153,8 @@ def api_event_related_stocks(event_id):
@app.route('/api/stock/<stock_code>/minute-chart', methods=['GET'])
@token_required
@pro_or_max_required
def get_minute_chart_data(stock_code):
"""获取股票分时图数据 - 仅限 Pro/Max 会员"""
client = get_clickhouse_client()
@@ -3885,8 +4229,8 @@ def get_minute_chart_data(stock_code):
@app.route('/api/event/<int:event_id>/stock/<stock_code>/detail', methods=['GET'])
@token_required
@pro_or_max_required
def api_stock_detail(event_id, stock_code):
"""个股详情接口 - 仅限 Pro/Max 会员"""
try:
@@ -4146,8 +4490,8 @@ def get_stock_minute_chart_data(stock_code):
# 7. 事件详情-相关概念接口
@app.route('/api/event/<int:event_id>/related-concepts', methods=['GET'])
@token_required
@pro_or_max_required
def api_event_related_concepts(event_id):
"""事件相关概念接口"""
try:
@@ -4189,8 +4533,8 @@ def api_event_related_concepts(event_id):
# 8. 事件详情-历史事件接口
@app.route('/api/event/<int:event_id>/historical-events', methods=['GET'])
@token_required
@pro_or_max_required
def api_event_historical_events(event_id):
"""事件历史事件接口"""
try:
@@ -4290,8 +4634,8 @@ def api_event_historical_events(event_id):
@app.route('/api/event/<int:event_id>/comments', methods=['GET'])
@token_required
@pro_or_max_required
def get_event_comments(event_id):
"""获取事件的所有评论和帖子(嵌套格式)
@@ -4545,8 +4889,8 @@ def get_event_comments(event_id):
@app.route('/api/comment/<int:comment_id>/replies', methods=['GET'])
@token_required
@pro_or_max_required
def get_comment_replies(comment_id):
"""获取某条评论的所有回复
@@ -4689,10 +5033,64 @@ def get_comment_replies(comment_id):
}), 500
# 工具函数:清理 Markdown 文本
def clean_markdown_text(text):
"""清理文本中的 Markdown 符号和多余的换行符
Args:
text: 原始文本(可能包含 Markdown 符号)
Returns:
清理后的纯文本
"""
if not text:
return text
import re
# 1. 移除 Markdown 标题符号 (### , ## , # )
text = re.sub(r'^#{1,6}\s+', '', text, flags=re.MULTILINE)
# 2. 移除 Markdown 加粗符号 (**text** 或 __text__)
text = re.sub(r'\*\*(.+?)\*\*', r'\1', text)
text = re.sub(r'__(.+?)__', r'\1', text)
# 3. 移除 Markdown 斜体符号 (*text* 或 _text_)
text = re.sub(r'\*(.+?)\*', r'\1', text)
text = re.sub(r'_(.+?)_', r'\1', text)
# 4. 移除 Markdown 列表符号 (- , * , + , 1. )
text = re.sub(r'^[\s]*[-*+]\s+', '', text, flags=re.MULTILINE)
text = re.sub(r'^[\s]*\d+\.\s+', '', text, flags=re.MULTILINE)
# 5. 移除 Markdown 引用符号 (> )
text = re.sub(r'^>\s+', '', text, flags=re.MULTILINE)
# 6. 移除 Markdown 代码块符号 (``` 或 `)
text = re.sub(r'```[\s\S]*?```', '', text)
text = re.sub(r'`(.+?)`', r'\1', text)
# 7. 移除 Markdown 链接 ([text](url) -> text)
text = re.sub(r'\[(.+?)\]\(.+?\)', r'\1', text)
# 8. 清理多余的换行符
# 将多个连续的换行符(\n\n\n...)替换为单个换行符
text = re.sub(r'\n{3,}', '\n\n', text)
# 9. 清理行首行尾的空白字符
text = re.sub(r'^\s+|\s+$', '', text, flags=re.MULTILINE)
# 10. 移除多余的空格(连续多个空格替换为单个空格)
text = re.sub(r' {2,}', ' ', text)
# 11. 清理首尾空白
text = text.strip()
return text
# 10. 投资日历-事件接口(增强版)
@app.route('/api/calendar/events', methods=['GET'])
@token_required
@pro_or_max_required
def api_calendar_events():
"""投资日历事件接口 - 连接 future_events 表 (修正版)"""
try:
@@ -4895,10 +5293,15 @@ def api_calendar_events():
elif search_query.lower() in str(related_concepts).lower():
highlight_match = 'concepts'
# 清理 Markdown 符号和多余的换行符
cleaned_former = clean_markdown_text(event.former)
cleaned_forecast = clean_markdown_text(event.forecast)
cleaned_fact = clean_markdown_text(event.fact)
event_dict = {
'id': event.data_id,
'title': event.title,
'description': f"前值: {event.former}, 预测: {event.forecast}, 实际: {event.fact}" if event.former or event.forecast or event.fact else "",
'description': f"前值: {cleaned_former}, 预测: {cleaned_forecast}, 实际: {cleaned_fact}" if cleaned_former or cleaned_forecast or cleaned_fact else "",
'start_time': event.calendar_time.isoformat() if event.calendar_time else None,
'end_time': None, # future_events 表没有结束时间
'category': {
@@ -4914,9 +5317,9 @@ def api_calendar_events():
'related_avg_chg': round(related_avg_chg, 2),
'related_max_chg': round(related_max_chg, 2),
'related_week_chg': round(related_week_chg, 2),
'former': event.former,
'forecast': event.forecast,
'fact': event.fact
'former': cleaned_former,
'forecast': cleaned_forecast,
'fact': cleaned_fact
}
# 可选:添加搜索匹配标记
@@ -4948,8 +5351,8 @@ def api_calendar_events():
# 11. 投资日历-数据接口
@app.route('/api/calendar/data', methods=['GET'])
@token_required
@pro_or_max_required
def api_calendar_data():
"""投资日历数据接口"""
try:
@@ -5136,8 +5539,8 @@ def extract_concepts_from_concepts_field(concepts_text):
@app.route('/api/calendar/detail/<int:item_id>', methods=['GET'])
@token_required
@pro_or_max_required
def api_future_event_detail(item_id):
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员"""
try:
@@ -5372,8 +5775,8 @@ def api_future_event_detail(item_id):
# 13-15. 筛选弹窗接口(已有,优化格式)
@app.route('/api/filter/options', methods=['GET'])
@token_required
@pro_or_max_required
def api_filter_options():
"""筛选选项接口"""
try:
@@ -5952,7 +6355,7 @@ if __name__ == '__main__':
port=5002,
debug=True,
ssl_context=(
'/home/ubuntu/dify/docker/nginx/ssl/fullchain.pem',
'/home/ubuntu/dify/docker/nginx/ssl/privkey.pem'
'/etc/letsencrypt/live/api.valuefrontier.cn/fullchain.pem',
'/etc/letsencrypt/live/api.valuefrontier.cn/privkey.pem'
)
)

8126
app_vx_raw.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -110,6 +110,9 @@ module.exports = {
...webpackConfig.resolve,
alias: {
...webpackConfig.resolve.alias,
// 强制 'debug' 模块解析到 node_modules避免与 src/devtools/ 冲突)
'debug': path.resolve(__dirname, 'node_modules/debug'),
// 根目录别名
'@': path.resolve(__dirname, 'src'),
@@ -119,6 +122,7 @@ module.exports = {
'@constants': path.resolve(__dirname, 'src/constants'),
'@contexts': path.resolve(__dirname, 'src/contexts'),
'@data': path.resolve(__dirname, 'src/data'),
'@devtools': path.resolve(__dirname, 'src/devtools'),
'@hooks': path.resolve(__dirname, 'src/hooks'),
'@layouts': path.resolve(__dirname, 'src/layouts'),
'@lib': path.resolve(__dirname, 'src/lib'),
@@ -263,13 +267,33 @@ module.exports = {
logLevel: 'debug',
pathRewrite: { '^/concept-api': '' },
},
'/bytedesk': {
target: 'https://valuefrontier.cn', // 统一使用生产环境 Nginx 代理
'/bytedesk-api': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false, // 开发环境禁用 HTTPS 严格验证
secure: false,
logLevel: 'debug',
ws: true, // 支持 WebSocket
// 不使用 pathRewrite保留 /bytedesk 前缀,让生产 Nginx 处理
pathRewrite: { '^/bytedesk-api': '' },
},
'/chat': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
// 不需要pathRewrite保留/chat路径
},
'/config': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
// 不需要pathRewrite保留/config路径
},
'/visitor': {
target: 'http://43.143.189.195',
changeOrigin: true,
secure: false,
logLevel: 'debug',
// 不需要pathRewrite保留/visitor路径
},
},
}),

View File

@@ -1,3 +1,21 @@
<!--
/*!
=========================================================
* Argon Dashboard Chakra PRO - v1.0.0
=========================================================
* Product Page: https://www.creative-tim.com/product/argon-dashboard-chakra-pro
* Copyright 2022 Creative Tim (https://www.creative-tim.com/)
* Designed and Coded by Simmmple & Creative Tim
=========================================================
* The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
*/
-->
<!DOCTYPE html>
<html lang="en" dir="ltr" layout="admin">
<head>
@@ -7,6 +25,10 @@
content="width=device-width, initial-scale=1, shrink-to-fit=no"
/>
<meta name="theme-color" content="#000000" />
<!--
manifest.json provides metadata used when your web app is added to the
homescreen on Android. See https://developers.google.com/web/fundamentals/engage-and-retain/web-app-manifest/
-->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<link rel="shortcut icon" href="%PUBLIC_URL%/favicon.png" />
<link
@@ -14,11 +36,183 @@
sizes="76x76"
href="%PUBLIC_URL%/apple-icon.png"
/>
<link rel="shortcut icon" type="image/x-icon" href="./favicon.png" />
<!--
Notice the use of %PUBLIC_URL% in the tags above.
It will be replaced with the URL of the `public` folder during the build.
Only files inside the `public` folder can be referenced from the HTML.
Unlike "/favicon.png" or "favicon.png", "%PUBLIC_URL%/favicon.png" will
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>价值前沿——LLM赋能的分析平台</title>
</head>
<body>
<noscript> You need to enable JavaScript to run this app. </noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<!-- ============================================
Dify 机器人配置 - 只在 /home 页面显示
============================================ -->
<script>
window.difyChatbotConfig = {
token: 'DwN8qAKtYFQtWskM',
baseUrl: 'https://app.valuefrontier.cn',
inputs: {
// You can define the inputs from the Start node here
// key is the variable name
// e.g.
// name: "NAME"
},
systemVariables: {
// user_id: 'YOU CAN DEFINE USER ID HERE',
// conversation_id: 'YOU CAN DEFINE CONVERSATION ID HERE, IT MUST BE A VALID UUID',
},
userVariables: {
// avatar_url: 'YOU CAN DEFINE USER AVATAR URL HERE',
// name: 'YOU CAN DEFINE USER NAME HERE',
},
}
</script>
<!-- Dify 机器人显示控制脚本 -->
<script>
// 控制 Dify 机器人只在 /home 页面显示
function controlDifyVisibility() {
const currentPath = window.location.pathname;
const difyChatButton = document.getElementById('dify-chatbot-bubble-button');
if (difyChatButton) {
// 只在 /home 页面显示
if (currentPath === '/home') {
difyChatButton.style.display = 'flex';
console.log('[Dify] 显示机器人(当前路径: /home');
} else {
difyChatButton.style.display = 'none';
console.log('[Dify] 隐藏机器人(当前路径:', currentPath, '');
}
}
}
// 页面加载完成后执行
window.addEventListener('load', function() {
console.log('[Dify] 初始化显示控制');
// 初始检查(延迟执行,等待 Dify 按钮渲染)
setTimeout(controlDifyVisibility, 500);
setTimeout(controlDifyVisibility, 1500);
// 监听路由变化React Router 使用 pushState
const observer = setInterval(controlDifyVisibility, 1000);
// 清理函数(可选)
window.addEventListener('beforeunload', function() {
clearInterval(observer);
});
});
</script>
<script
src="https://app.valuefrontier.cn/embed.min.js"
id="DwN8qAKtYFQtWskM"
defer>
</script>
<style>
#dify-chatbot-bubble-button {
background-color: #1C64F2 !important;
width: 60px !important;
height: 60px !important;
box-shadow: 0 4px 12px rgba(28, 100, 242, 0.3) !important;
transition: all 0.3s ease !important;
}
#dify-chatbot-bubble-button:hover {
transform: scale(1.1) !important;
box-shadow: 0 6px 16px rgba(28, 100, 242, 0.4) !important;
}
#dify-chatbot-bubble-window {
width: 42rem !important;
height: 80vh !important;
max-height: calc(100vh - 2rem) !important;
position: fixed !important;
bottom: 100px !important;
right: 20px !important;
border-radius: 16px !important;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15) !important;
border: 1px solid rgba(28, 100, 242, 0.1) !important;
z-index: 9999 !important;
}
/* 确保Dify聊天窗口中的超链接正确显示 */
#dify-chatbot-bubble-window a,
#dify-chatbot-bubble-window a:link,
#dify-chatbot-bubble-window a:visited,
#dify-chatbot-bubble-window a:hover,
#dify-chatbot-bubble-window a:active {
color: #1C64F2 !important;
text-decoration: underline !important;
cursor: pointer !important;
pointer-events: auto !important;
}
/* 确保超链接在Dify消息区域中可见 */
#dify-chatbot-bubble-window .message-content a,
#dify-chatbot-bubble-window .markdown-content a,
#dify-chatbot-bubble-window [class*="message"] a {
color: #0066cc !important;
text-decoration: underline !important;
font-weight: 500 !important;
}
/* 桌面端大屏优化 */
@media (min-width: 1440px) {
#dify-chatbot-bubble-window {
width: 45rem !important;
height: 85vh !important;
}
}
/* 平板端适配 */
@media (max-width: 1024px) and (min-width: 641px) {
#dify-chatbot-bubble-window {
width: 38rem !important;
height: 75vh !important;
right: 15px !important;
bottom: 90px !important;
}
}
/* 移动端适配 */
@media (max-width: 640px) {
#dify-chatbot-bubble-window {
width: calc(100vw - 20px) !important;
height: 85vh !important;
max-height: 85vh !important;
right: 10px !important;
bottom: 80px !important;
left: 10px !important;
}
#dify-chatbot-bubble-button {
width: 56px !important;
height: 56px !important;
}
}
</style>
</body>
</html>

View File

@@ -1,11 +1,12 @@
Flask==2.3.3
Flask>=3.0.0
Flask-CORS==4.0.0
Flask-SQLAlchemy==3.0.5
Flask-Login==0.6.3
Flask-SQLAlchemy>=3.1.0
Flask-Login>=0.6.3
Flask-Compress==1.14
Flask-SocketIO==5.3.6
Flask-Mail==0.9.1
Flask-Migrate==4.0.5
Flask-Session>=0.5.0
pandas==2.0.3
numpy==1.24.3
requests==2.31.0

View File

@@ -29,16 +29,15 @@ REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
# Bytedesk组织和工作组配置必需
# ============================================================================
# 组织 UUIDOrganization UUID
# 获取方式: 登录管理后台 -> 设置 -> 组织信息 -> 组织UUID
# 注意: 不是"组织代码",是"组织UUID"df_org_uid
# 当前配置: df_org_uid默认组织
# 组织IDOrganization UID
# 获取方式: 登录管理后台 -> 设置 -> 组织信息 -> 复制UID
# 示例: df_org_uid
REACT_APP_BYTEDESK_ORG=df_org_uid
# 工作组 UUIDWorkgroup UUID
# 获取方式: 登录管理后台 -> 客服管理 -> 工作组 -> 工作组UUID
# 当前配置: df_wg_uid默认工作组
REACT_APP_BYTEDESK_SID=df_wg_uid
# 工作组IDWorkgroup SID
# 获取方式: 登录管理后台 -> 客服管理 -> 工作组 -> 复制工作组ID
# 示例: df_wg_aftersales (售后服务组)
REACT_APP_BYTEDESK_SID=df_wg_aftersales
# ============================================================================
# 可选配置

View File

@@ -1,35 +1,26 @@
/**
* Bytedesk客服配置文件
* 通过代理访问 Bytedesk 服务器(解决 HTTPS 混合内容问题)
* 指向43.143.189.195服务器
*
* 环境变量配置(.env文件:
* REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
* REACT_APP_BYTEDESK_ORG=df_org_uid
* REACT_APP_BYTEDESK_SID=df_wg_uid
*
* 架构说明:
* - iframe 使用完整域名https://valuefrontier.cn/bytedesk/chat/
* - 使用 HTTPS 协议,解决生产环境 Mixed Content 错误
* - 本地CRACO 代理 /bytedesk → valuefrontier.cn/bytedesk
* - 生产:前端 Nginx 代理 /bytedesk → 43.143.189.195
* - baseUrl 保持官方 CDN用于加载 SDK 外部模块)
*
* ⚠️ 注意:需要前端 Nginx 配置 /bytedesk/ 代理规则
* REACT_APP_BYTEDESK_SID=df_wg_aftersales
*/
// 从环境变量读取配置
const BYTEDESK_API_URL = process.env.REACT_APP_BYTEDESK_API_URL || 'http://43.143.189.195';
const BYTEDESK_ORG = process.env.REACT_APP_BYTEDESK_ORG || 'df_org_uid';
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_uid';
const BYTEDESK_SID = process.env.REACT_APP_BYTEDESK_SID || 'df_wg_aftersales';
/**
* Bytedesk客服基础配置
*/
export const bytedeskConfig = {
// API服务地址(如果 SDK 需要调用 API
apiUrl: '/bytedesk/',
// 聊天页面地址(使用完整 HTTPS 域名,通过 /bytedesk/ 代理避免 React Router 冲突)
htmlUrl: 'https://valuefrontier.cn/chat/',
// SDK 资源基础路径(保持 Bytedesk 官方 CDN用于加载外部模块
baseUrl: 'https://www.weiyuai.cn',
// API服务地址
apiUrl: BYTEDESK_API_URL,
// 聊天页面地址
htmlUrl: `${BYTEDESK_API_URL}/chat/`,
// 客服图标位置
placement: 'bottom-right', // bottom-right | bottom-left | top-right | top-left
@@ -62,7 +53,7 @@ export const bytedeskConfig = {
// 聊天配置(必需)
chatConfig: {
org: BYTEDESK_ORG, // 组织ID
t: '1', // 类型: 1=人工客服, 2=机器人
t: '2', // 类型: 2=客服, 1=机器人
sid: BYTEDESK_SID, // 工作组ID
},
};
@@ -73,7 +64,16 @@ export const bytedeskConfig = {
* @returns {Object} Bytedesk配置对象
*/
export const getBytedeskConfig = () => {
// 所有环境都使用公网地址(不使用代理
// 开发环境使用代理(绕过 X-Frame-Options 限制
if (process.env.NODE_ENV === 'development') {
return {
...bytedeskConfig,
apiUrl: '/bytedesk-api', // 使用 CRACO 代理路径
htmlUrl: '/bytedesk-api/chat/', // 使用 CRACO 代理路径
};
}
// 生产环境使用完整 URL
return bytedeskConfig;
};
@@ -120,7 +120,7 @@ export const getBytedeskConfigWithUser = (user) => {
export const shouldShowCustomerService = (pathname) => {
// 在以下页面隐藏客服(黑名单)
const blockedPages = [
// '/home', // 登录页
'/home', // 登录页
];
// 检查是否在黑名单

View File

@@ -6,9 +6,6 @@ import { BrowserRouter as Router } from 'react-router-dom';
import './styles/brainwave.css';
import './styles/brainwave-colors.css';
// 导入 Bytedesk 客服系统 z-index 覆盖样式(必须在所有样式之后导入)
import './styles/bytedesk-override.css';
// Import the main App component
import App from './App';

View File

@@ -217,7 +217,7 @@ export const REVENUE_EVENTS = {
};
// ============================================================================
// SPECIAL EVENTS (特殊事件) - Errors, performance
// SPECIAL EVENTS (特殊事件) - Errors, performance, chatbot
// ============================================================================
export const SPECIAL_EVENTS = {
// Errors
@@ -229,6 +229,13 @@ export const SPECIAL_EVENTS = {
PAGE_LOAD_TIME: 'Page Load Time',
API_RESPONSE_TIME: 'API Response Time',
// Chatbot (Dify)
CHATBOT_OPENED: 'Chatbot Opened',
CHATBOT_CLOSED: 'Chatbot Closed',
CHATBOT_MESSAGE_SENT: 'Chatbot Message Sent',
CHATBOT_MESSAGE_RECEIVED: 'Chatbot Message Received',
CHATBOT_FEEDBACK_PROVIDED: 'Chatbot Feedback Provided',
// Scroll depth
SCROLL_DEPTH_25: 'Scroll Depth 25%',
SCROLL_DEPTH_50: 'Scroll Depth 50%',

View File

@@ -1,35 +0,0 @@
/**
* Bytedesk 客服系统 z-index 覆盖样式
*
* 问题: Bytedesk 默认 z-index 为 10001项目中部分元素使用 z-index: 99999
* 导致客服 iframe 在首页被内容区覆盖,不可见
*
* 解决方案: 将所有 Bytedesk 相关元素的 z-index 提升到 999999
* 确保客服窗口始终显示在最上层
*/
/* Bytedesk 主容器 - 客服图标按钮 */
[class*="bytedesk"],
[id*="bytedesk"],
[class*="BytedeskWeb"] {
z-index: 999999 !important;
}
/* Bytedesk iframe - 聊天窗口 */
iframe[src*="bytedesk"],
iframe[src*="/chat/"],
iframe[src*="/visitor/"] {
z-index: 999999 !important;
}
/* Bytedesk 覆盖层(如果存在) */
.bytedesk-overlay,
[class*="bytedesk-overlay"] {
z-index: 999998 !important;
}
/* Bytedesk 通知气泡 */
.bytedesk-badge,
[class*="bytedesk-badge"] {
z-index: 1000000 !important;
}

View File

@@ -44,8 +44,6 @@ const Community = () => {
// Chakra UI hooks
const bgColor = useColorModeValue('gray.50', 'gray.900');
const alertBgColor = useColorModeValue('blue.50', 'blue.900');
const alertBorderColor = useColorModeValue('blue.200', 'blue.700');
// Ref用于首次滚动到内容区域
const containerRef = useRef(null);
@@ -147,9 +145,9 @@ const Community = () => {
borderRadius="lg"
mb={4}
boxShadow="md"
bg={alertBgColor}
bg={useColorModeValue('blue.50', 'blue.900')}
borderWidth="1px"
borderColor={alertBorderColor}
borderColor={useColorModeValue('blue.200', 'blue.700')}
>
<AlertIcon />
<Box flex="1">

View File

@@ -1,7 +1,6 @@
import React, { useState, useEffect, useCallback } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { logger } from '../../utils/logger';
import defaultEventImage from '../../assets/img/default-event.jpg';
import {
Box,
Container,
@@ -175,7 +174,7 @@ const ConceptCenter = () => {
const [stockMarketData, setStockMarketData] = useState({});
const [loadingStockData, setLoadingStockData] = useState(false);
// 默认图片路径
const defaultImage = defaultEventImage;
const defaultImage = '/assets/img/default-event.jpg';
// 获取最新交易日期
const fetchLatestTradeDate = useCallback(async () => {