Compare commits
9 Commits
origin_pro
...
0dfbac7248
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0dfbac7248 | ||
|
|
143933b480 | ||
| 06beeeaee4 | |||
| d1a222d9e9 | |||
| bd86ccce85 | |||
| ed14031d65 | |||
| 9b16d9d162 | |||
| 7708cb1a69 | |||
| 2395d92b17 |
Binary file not shown.
137
app.py
137
app.py
@@ -1510,8 +1510,8 @@ def initialize_subscription_plans_safe():
|
||||
|
||||
pro_plan = SubscriptionPlan(
|
||||
name='pro',
|
||||
display_name='Pro版本',
|
||||
description='适合个人投资者的基础功能套餐',
|
||||
display_name='Pro 专业版',
|
||||
description='事件关联股票深度分析 | 历史事件智能对比复盘 | 事件概念关联与挖掘 | 概念板块个股追踪 | 概念深度研报与解读 | 个股异动实时预警',
|
||||
monthly_price=0.01,
|
||||
yearly_price=0.08,
|
||||
features=json.dumps([
|
||||
@@ -1526,8 +1526,8 @@ def initialize_subscription_plans_safe():
|
||||
|
||||
max_plan = SubscriptionPlan(
|
||||
name='max',
|
||||
display_name='Max版本',
|
||||
description='适合专业投资者的全功能套餐',
|
||||
display_name='Max 旗舰版',
|
||||
description='包含Pro版全部功能 | 事件传导链路智能分析 | 概念演变时间轴追溯 | 个股全方位深度研究 | 价小前投研助手无限使用 | 新功能优先体验权 | 专属客服一对一服务',
|
||||
monthly_price=0.1,
|
||||
yearly_price=0.8,
|
||||
features=json.dumps([
|
||||
@@ -7289,6 +7289,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')
|
||||
|
||||
643
app_vx.py
643
app_vx.py
@@ -559,7 +559,86 @@ app.config['COMPRESS_MIMETYPES'] = [
|
||||
'application/javascript',
|
||||
'application/x-javascript'
|
||||
]
|
||||
user_tokens = {}
|
||||
|
||||
# ===================== Token 存储(支持多 worker 共享) =====================
|
||||
class TokenStore:
|
||||
"""
|
||||
Token 存储类 - 支持 Redis(多 worker 共享)或内存(单 worker)
|
||||
"""
|
||||
def __init__(self):
|
||||
self._redis_client = None
|
||||
self._memory_store = {}
|
||||
self._prefix = 'vf_token:'
|
||||
self._initialized = False
|
||||
|
||||
def _ensure_initialized(self):
|
||||
"""延迟初始化,确保在 fork 后才连接 Redis"""
|
||||
if self._initialized:
|
||||
return
|
||||
self._initialized = True
|
||||
|
||||
redis_url = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
try:
|
||||
import redis
|
||||
self._redis_client = redis.from_url(redis_url)
|
||||
self._redis_client.ping()
|
||||
logger.info(f"✅ Token 存储: Redis ({redis_url})")
|
||||
except Exception as e:
|
||||
logger.warning(f"⚠️ Redis 不可用 ({e}),Token 使用内存存储(多 worker 模式下会有问题!)")
|
||||
self._redis_client = None
|
||||
|
||||
def get(self, token):
|
||||
"""获取 token 数据"""
|
||||
self._ensure_initialized()
|
||||
if self._redis_client:
|
||||
try:
|
||||
data = self._redis_client.get(f"{self._prefix}{token}")
|
||||
if data:
|
||||
return json.loads(data)
|
||||
return None
|
||||
except Exception as e:
|
||||
logger.error(f"Redis get error: {e}")
|
||||
return self._memory_store.get(token)
|
||||
return self._memory_store.get(token)
|
||||
|
||||
def set(self, token, data, expire_seconds=30*24*3600):
|
||||
"""设置 token 数据"""
|
||||
self._ensure_initialized()
|
||||
if self._redis_client:
|
||||
try:
|
||||
# 将 datetime 转为字符串存储
|
||||
store_data = data.copy()
|
||||
if 'expires' in store_data and isinstance(store_data['expires'], datetime):
|
||||
store_data['expires'] = store_data['expires'].isoformat()
|
||||
self._redis_client.setex(
|
||||
f"{self._prefix}{token}",
|
||||
expire_seconds,
|
||||
json.dumps(store_data)
|
||||
)
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Redis set error: {e}")
|
||||
self._memory_store[token] = data
|
||||
|
||||
def delete(self, token):
|
||||
"""删除 token"""
|
||||
self._ensure_initialized()
|
||||
if self._redis_client:
|
||||
try:
|
||||
self._redis_client.delete(f"{self._prefix}{token}")
|
||||
return
|
||||
except Exception as e:
|
||||
logger.error(f"Redis delete error: {e}")
|
||||
self._memory_store.pop(token, None)
|
||||
|
||||
def __contains__(self, token):
|
||||
"""支持 'in' 操作符"""
|
||||
return self.get(token) is not None
|
||||
|
||||
|
||||
# 使用 TokenStore 替代内存字典
|
||||
user_tokens = TokenStore()
|
||||
|
||||
app.config['SECRET_KEY'] = 'vf7891574233241'
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/stock?charset=utf8mb4'
|
||||
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
|
||||
@@ -597,14 +676,36 @@ JWT_SECRET_KEY = 'vfllmgreat33818!' # 请修改为安全的密钥
|
||||
JWT_ALGORITHM = 'HS256'
|
||||
JWT_EXPIRATION_HOURS = 24 * 7 # Token有效期7天
|
||||
|
||||
# Session 配置 - 使用文件系统存储(替代 Redis)
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
app.config['SESSION_FILE_DIR'] = os.path.join(os.path.dirname(__file__), 'flask_session')
|
||||
# Session 配置
|
||||
# 优先使用 Redis(支持多 worker 共享),否则回退到文件系统
|
||||
_REDIS_URL = os.environ.get('REDIS_URL', 'redis://localhost:6379/0')
|
||||
_USE_REDIS_SESSION = os.environ.get('USE_REDIS_SESSION', 'true').lower() == 'true'
|
||||
|
||||
try:
|
||||
if _USE_REDIS_SESSION:
|
||||
import redis
|
||||
# 测试 Redis 连接
|
||||
_redis_client = redis.from_url(_REDIS_URL)
|
||||
_redis_client.ping()
|
||||
|
||||
app.config['SESSION_TYPE'] = 'redis'
|
||||
app.config['SESSION_REDIS'] = _redis_client
|
||||
app.config['SESSION_KEY_PREFIX'] = 'vf_session:'
|
||||
logger.info(f"✅ Session 存储: Redis ({_REDIS_URL})")
|
||||
else:
|
||||
raise Exception("Redis session disabled by config")
|
||||
except Exception as e:
|
||||
# Redis 不可用,回退到文件系统
|
||||
logger.warning(f"⚠️ Redis 不可用 ({e}),使用文件系统 session(多 worker 模式下可能不稳定)")
|
||||
app.config['SESSION_TYPE'] = 'filesystem'
|
||||
app.config['SESSION_FILE_DIR'] = os.path.join(os.path.dirname(__file__), 'flask_session')
|
||||
os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True)
|
||||
|
||||
app.config['SESSION_PERMANENT'] = True
|
||||
app.config['PERMANENT_SESSION_LIFETIME'] = timedelta(days=7) # Session 有效期 7 天
|
||||
|
||||
# 确保 session 目录存在
|
||||
os.makedirs(app.config['SESSION_FILE_DIR'], exist_ok=True)
|
||||
app.config['SESSION_COOKIE_SECURE'] = False # 生产环境 HTTPS 时设为 True
|
||||
app.config['SESSION_COOKIE_HTTPONLY'] = True
|
||||
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
|
||||
|
||||
# Cache directory setup
|
||||
CACHE_DIR = Path('cache')
|
||||
@@ -661,9 +762,12 @@ def token_required(f):
|
||||
if not token_data:
|
||||
return jsonify({'message': 'Token无效', 'code': 401}), 401
|
||||
|
||||
# 检查是否过期
|
||||
if token_data['expires'] < datetime.now():
|
||||
del user_tokens[token]
|
||||
# 检查是否过期(expires 可能是字符串或 datetime)
|
||||
expires = token_data['expires']
|
||||
if isinstance(expires, str):
|
||||
expires = datetime.fromisoformat(expires)
|
||||
if expires < datetime.now():
|
||||
user_tokens.delete(token)
|
||||
return jsonify({'message': 'Token已过期'}), 401
|
||||
|
||||
# 获取用户对象并添加到请求上下文
|
||||
@@ -3438,7 +3542,7 @@ def logout_with_token():
|
||||
token = data.get('token') if data else None
|
||||
|
||||
if token and token in user_tokens:
|
||||
del user_tokens[token]
|
||||
user_tokens.delete(token)
|
||||
|
||||
# 清除session
|
||||
session.clear()
|
||||
@@ -3595,10 +3699,10 @@ def login_with_phone():
|
||||
token = generate_token(32)
|
||||
|
||||
# 存储token映射(30天有效期)
|
||||
user_tokens[token] = {
|
||||
user_tokens.set(token, {
|
||||
'user_id': user.id,
|
||||
'expires': datetime.now() + timedelta(days=30)
|
||||
}
|
||||
})
|
||||
|
||||
# 清除验证码
|
||||
del verification_codes[phone]
|
||||
@@ -3648,9 +3752,12 @@ def verify_token():
|
||||
if not token_data:
|
||||
return jsonify({'valid': False, 'message': 'Token无效', 'code': 401}), 401
|
||||
|
||||
# 检查是否过期
|
||||
if token_data['expires'] < datetime.now():
|
||||
del user_tokens[token]
|
||||
# 检查是否过期(expires 可能是字符串或 datetime)
|
||||
expires = token_data['expires']
|
||||
if isinstance(expires, str):
|
||||
expires = datetime.fromisoformat(expires)
|
||||
if expires < datetime.now():
|
||||
user_tokens.delete(token)
|
||||
return jsonify({'valid': False, 'message': 'Token已过期'}), 401
|
||||
|
||||
# 获取用户信息
|
||||
@@ -3883,10 +3990,10 @@ def api_login_wechat():
|
||||
token = generate_token(32) # 使用相同的随机字符串生成器
|
||||
|
||||
# 存储token映射(与手机登录保持一致)
|
||||
user_tokens[token] = {
|
||||
user_tokens.set(token, {
|
||||
'user_id': user.id,
|
||||
'expires': datetime.now() + timedelta(days=30) # 30天有效期
|
||||
}
|
||||
})
|
||||
|
||||
# 设置session(可选,保持与手机登录一致)
|
||||
session.permanent = True
|
||||
@@ -5274,6 +5381,114 @@ def get_comment_replies(comment_id):
|
||||
}), 500
|
||||
|
||||
|
||||
# 工具函数:解析JSON字段
|
||||
def parse_json_field(field_value):
|
||||
"""解析JSON字段"""
|
||||
if not field_value:
|
||||
return []
|
||||
try:
|
||||
if isinstance(field_value, str):
|
||||
if field_value.startswith('['):
|
||||
return json.loads(field_value)
|
||||
else:
|
||||
return field_value.split(',')
|
||||
else:
|
||||
return field_value
|
||||
except:
|
||||
return []
|
||||
|
||||
|
||||
# 工具函数:获取 future_events 表字段值,支持新旧字段回退
|
||||
def get_future_event_field(row, new_field, old_field):
|
||||
"""
|
||||
获取 future_events 表字段值,支持新旧字段回退
|
||||
如果新字段存在且不为空,使用新字段;否则使用旧字段
|
||||
"""
|
||||
new_value = getattr(row, new_field, None) if hasattr(row, new_field) else None
|
||||
old_value = getattr(row, old_field, None) if hasattr(row, old_field) else None
|
||||
|
||||
# 如果新字段有值(不为空字符串),使用新字段
|
||||
if new_value is not None and str(new_value).strip():
|
||||
return new_value
|
||||
return old_value
|
||||
|
||||
|
||||
# 工具函数:解析新的 best_matches 数据结构(含研报引用信息)
|
||||
def parse_best_matches(best_matches_value):
|
||||
"""
|
||||
解析新的 best_matches 数据结构(含研报引用信息)
|
||||
|
||||
新结构示例:
|
||||
[
|
||||
{
|
||||
"stock_code": "300451.SZ",
|
||||
"company_name": "创业慧康",
|
||||
"original_description": "核心标的,医疗信息化...",
|
||||
"best_report_title": "报告标题",
|
||||
"best_report_author": "作者",
|
||||
"best_report_sentences": "相关内容",
|
||||
"best_report_match_score": "好",
|
||||
"best_report_match_ratio": 0.9285714285714286,
|
||||
"best_report_declare_date": "2023-04-25T00:00:00",
|
||||
"total_reports": 9,
|
||||
"high_score_reports": 6
|
||||
},
|
||||
...
|
||||
]
|
||||
|
||||
返回统一格式的股票列表,兼容旧格式
|
||||
"""
|
||||
if not best_matches_value:
|
||||
return []
|
||||
|
||||
try:
|
||||
# 解析 JSON
|
||||
if isinstance(best_matches_value, str):
|
||||
data = json.loads(best_matches_value)
|
||||
else:
|
||||
data = best_matches_value
|
||||
|
||||
if not isinstance(data, list):
|
||||
return []
|
||||
|
||||
result = []
|
||||
for item in data:
|
||||
if isinstance(item, dict):
|
||||
# 新结构:包含研报信息的字典
|
||||
stock_info = {
|
||||
'code': item.get('stock_code', ''),
|
||||
'name': item.get('company_name', ''),
|
||||
'description': item.get('original_description', ''),
|
||||
'score': item.get('best_report_match_ratio', 0),
|
||||
# 研报引用信息
|
||||
'report': {
|
||||
'title': item.get('best_report_title', ''),
|
||||
'author': item.get('best_report_author', ''),
|
||||
'sentences': item.get('best_report_sentences', ''),
|
||||
'match_score': item.get('best_report_match_score', ''),
|
||||
'match_ratio': item.get('best_report_match_ratio', 0),
|
||||
'declare_date': item.get('best_report_declare_date', ''),
|
||||
'total_reports': item.get('total_reports', 0),
|
||||
'high_score_reports': item.get('high_score_reports', 0)
|
||||
} if item.get('best_report_title') else None
|
||||
}
|
||||
result.append(stock_info)
|
||||
elif isinstance(item, (list, tuple)) and len(item) >= 2:
|
||||
# 旧结构:[code, name, description, score]
|
||||
result.append({
|
||||
'code': item[0],
|
||||
'name': item[1],
|
||||
'description': item[2] if len(item) > 2 else '',
|
||||
'score': item[3] if len(item) > 3 else 0,
|
||||
'report': None
|
||||
})
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
print(f"parse_best_matches error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
# 工具函数:处理转义字符,保留 Markdown 格式
|
||||
def unescape_markdown_text(text):
|
||||
"""
|
||||
@@ -5363,6 +5578,7 @@ def api_calendar_events():
|
||||
offset = (page - 1) * per_page
|
||||
|
||||
# 构建基础查询 - 使用 future_events 表
|
||||
# 添加新字段 second_modified_text, `second_modified_text.1`, best_matches 支持新旧回退
|
||||
query = """
|
||||
SELECT data_id, \
|
||||
calendar_time, \
|
||||
@@ -5374,7 +5590,10 @@ def api_calendar_events():
|
||||
fact, \
|
||||
related_stocks, \
|
||||
concepts, \
|
||||
inferred_tag
|
||||
inferred_tag, \
|
||||
second_modified_text, \
|
||||
`second_modified_text.1` as second_modified_text_1, \
|
||||
best_matches
|
||||
FROM future_events
|
||||
WHERE 1 = 1 \
|
||||
"""
|
||||
@@ -5445,90 +5664,114 @@ def api_calendar_events():
|
||||
|
||||
events_data = []
|
||||
for event in events:
|
||||
# 解析相关股票
|
||||
# 使用新字段回退机制获取 former 和 forecast
|
||||
# second_modified_text -> former
|
||||
former_value = get_future_event_field(event, 'second_modified_text', 'former')
|
||||
# second_modified_text.1 -> forecast
|
||||
forecast_new = getattr(event, 'second_modified_text_1', None)
|
||||
forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(event, 'forecast', None)
|
||||
|
||||
# 解析相关股票 - 优先使用 best_matches,回退到 related_stocks
|
||||
related_stocks_list = []
|
||||
related_avg_chg = 0
|
||||
related_max_chg = 0
|
||||
related_week_chg = 0
|
||||
|
||||
# 处理相关股票数据
|
||||
if event.related_stocks:
|
||||
# 优先使用 best_matches(新结构,含研报引用)
|
||||
best_matches = getattr(event, 'best_matches', None)
|
||||
if best_matches and str(best_matches).strip():
|
||||
# 使用新的 parse_best_matches 函数解析
|
||||
parsed_stocks = parse_best_matches(best_matches)
|
||||
else:
|
||||
# 回退到旧的 related_stocks 处理
|
||||
parsed_stocks = []
|
||||
if event.related_stocks:
|
||||
try:
|
||||
import ast
|
||||
if isinstance(event.related_stocks, str):
|
||||
try:
|
||||
stock_data = json.loads(event.related_stocks)
|
||||
except:
|
||||
stock_data = ast.literal_eval(event.related_stocks)
|
||||
else:
|
||||
stock_data = event.related_stocks
|
||||
|
||||
if stock_data:
|
||||
for stock_info in stock_data:
|
||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
||||
parsed_stocks.append({
|
||||
'code': stock_info[0],
|
||||
'name': stock_info[1],
|
||||
'description': stock_info[2] if len(stock_info) > 2 else '',
|
||||
'score': stock_info[3] if len(stock_info) > 3 else 0,
|
||||
'report': None
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error parsing related_stocks for event {event.data_id}: {e}")
|
||||
|
||||
# 处理解析后的股票数据,获取交易信息
|
||||
if parsed_stocks:
|
||||
try:
|
||||
import json
|
||||
import ast
|
||||
daily_changes = []
|
||||
week_changes = []
|
||||
|
||||
# 使用与detail接口相同的解析逻辑
|
||||
if isinstance(event.related_stocks, str):
|
||||
try:
|
||||
stock_data = json.loads(event.related_stocks)
|
||||
except:
|
||||
stock_data = ast.literal_eval(event.related_stocks)
|
||||
else:
|
||||
stock_data = event.related_stocks
|
||||
for stock_info in parsed_stocks:
|
||||
stock_code = stock_info.get('code', '')
|
||||
stock_name = stock_info.get('name', '')
|
||||
description = stock_info.get('description', '')
|
||||
score = stock_info.get('score', 0)
|
||||
report = stock_info.get('report', None)
|
||||
|
||||
if stock_data:
|
||||
daily_changes = []
|
||||
week_changes = []
|
||||
if stock_code:
|
||||
# 规范化股票代码,移除后缀
|
||||
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
|
||||
|
||||
# 处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]
|
||||
for stock_info in stock_data:
|
||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
||||
stock_code = stock_info[0] # 股票代码
|
||||
stock_name = stock_info[1] # 股票名称
|
||||
description = stock_info[2] if len(stock_info) > 2 else ''
|
||||
score = stock_info[3] if len(stock_info) > 3 else 0
|
||||
else:
|
||||
continue
|
||||
# 使用模糊匹配查询真实的交易数据
|
||||
trade_query = """
|
||||
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
|
||||
FROM ea_trade
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
ORDER BY TRADEDATE DESC LIMIT 7 \
|
||||
"""
|
||||
trade_result = db.session.execute(text(trade_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
trade_data = trade_result.fetchall()
|
||||
|
||||
if stock_code:
|
||||
# 规范化股票代码,移除后缀
|
||||
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
|
||||
daily_chg = 0
|
||||
week_chg = 0
|
||||
|
||||
# 使用模糊匹配查询真实的交易数据
|
||||
trade_query = """
|
||||
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
|
||||
FROM ea_trade
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
ORDER BY TRADEDATE DESC LIMIT 7 \
|
||||
"""
|
||||
trade_result = db.session.execute(text(trade_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
trade_data = trade_result.fetchall()
|
||||
if trade_data:
|
||||
# 日涨跌幅(当日)
|
||||
daily_chg = float(trade_data[0].change_pct or 0)
|
||||
|
||||
daily_chg = 0
|
||||
week_chg = 0
|
||||
# 周涨跌幅(5个交易日)
|
||||
if len(trade_data) >= 5:
|
||||
current_price = float(trade_data[0].close_price or 0)
|
||||
week_ago_price = float(trade_data[4].close_price or 0)
|
||||
if week_ago_price > 0:
|
||||
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
|
||||
|
||||
if trade_data:
|
||||
# 日涨跌幅(当日)
|
||||
daily_chg = float(trade_data[0].change_pct or 0)
|
||||
# 收集涨跌幅数据
|
||||
daily_changes.append(daily_chg)
|
||||
week_changes.append(week_chg)
|
||||
|
||||
# 周涨跌幅(5个交易日)
|
||||
if len(trade_data) >= 5:
|
||||
current_price = float(trade_data[0].close_price or 0)
|
||||
week_ago_price = float(trade_data[4].close_price or 0)
|
||||
if week_ago_price > 0:
|
||||
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
|
||||
related_stocks_list.append({
|
||||
'code': stock_code,
|
||||
'name': stock_name,
|
||||
'description': description,
|
||||
'score': score,
|
||||
'daily_chg': daily_chg,
|
||||
'week_chg': week_chg,
|
||||
'report': report # 添加研报引用信息
|
||||
})
|
||||
|
||||
# 收集涨跌幅数据
|
||||
daily_changes.append(daily_chg)
|
||||
week_changes.append(week_chg)
|
||||
# 计算平均收益率
|
||||
if daily_changes:
|
||||
related_avg_chg = round(sum(daily_changes) / len(daily_changes), 4)
|
||||
related_max_chg = round(max(daily_changes), 4)
|
||||
|
||||
related_stocks_list.append({
|
||||
'code': stock_code,
|
||||
'name': stock_name,
|
||||
'description': description,
|
||||
'score': score,
|
||||
'daily_chg': daily_chg,
|
||||
'week_chg': week_chg
|
||||
})
|
||||
|
||||
# 计算平均收益率
|
||||
if daily_changes:
|
||||
related_avg_chg = round(sum(daily_changes) / len(daily_changes), 4)
|
||||
related_max_chg = round(max(daily_changes), 4)
|
||||
|
||||
if week_changes:
|
||||
related_week_chg = round(sum(week_changes) / len(week_changes), 4)
|
||||
if week_changes:
|
||||
related_week_chg = round(sum(week_changes) / len(week_changes), 4)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing related stocks for event {event.data_id}: {e}")
|
||||
@@ -5553,8 +5796,9 @@ def api_calendar_events():
|
||||
highlight_match = 'concepts'
|
||||
|
||||
# 将转义的换行符转换为真正的换行符,保留 Markdown 格式
|
||||
cleaned_former = unescape_markdown_text(event.former)
|
||||
cleaned_forecast = unescape_markdown_text(event.forecast)
|
||||
# 使用新字段回退后的值(former_value, forecast_value)
|
||||
cleaned_former = unescape_markdown_text(former_value)
|
||||
cleaned_forecast = unescape_markdown_text(forecast_value)
|
||||
cleaned_fact = unescape_markdown_text(event.fact)
|
||||
|
||||
event_dict = {
|
||||
@@ -5800,6 +6044,7 @@ def api_future_event_detail(item_id):
|
||||
"""未来事件详情接口 - 连接 future_events 表 (修正数据解析) - 仅限 Pro/Max 会员"""
|
||||
try:
|
||||
# 从 future_events 表查询事件详情
|
||||
# 添加新字段 second_modified_text, `second_modified_text.1`, best_matches 支持新旧回退
|
||||
query = """
|
||||
SELECT data_id, \
|
||||
calendar_time, \
|
||||
@@ -5810,7 +6055,10 @@ def api_future_event_detail(item_id):
|
||||
forecast, \
|
||||
fact, \
|
||||
related_stocks, \
|
||||
concepts
|
||||
concepts, \
|
||||
second_modified_text, \
|
||||
`second_modified_text.1` as second_modified_text_1, \
|
||||
best_matches
|
||||
FROM future_events
|
||||
WHERE data_id = :item_id \
|
||||
"""
|
||||
@@ -5825,6 +6073,13 @@ def api_future_event_detail(item_id):
|
||||
'data': None
|
||||
}), 404
|
||||
|
||||
# 使用新字段回退机制获取 former 和 forecast
|
||||
# second_modified_text -> former
|
||||
former_value = get_future_event_field(event, 'second_modified_text', 'former')
|
||||
# second_modified_text.1 -> forecast
|
||||
forecast_new = getattr(event, 'second_modified_text_1', None)
|
||||
forecast_value = forecast_new if (forecast_new and str(forecast_new).strip()) else getattr(event, 'forecast', None)
|
||||
|
||||
extracted_concepts = extract_concepts_from_concepts_field(event.concepts)
|
||||
|
||||
# 解析相关股票
|
||||
@@ -5868,136 +6123,150 @@ def api_future_event_detail(item_id):
|
||||
'环保': '公共产业板块', '综合': '公共产业板块'
|
||||
}
|
||||
|
||||
# 处理相关股票
|
||||
# 处理相关股票 - 优先使用 best_matches,回退到 related_stocks
|
||||
related_avg_chg = 0
|
||||
related_max_chg = 0
|
||||
related_week_chg = 0
|
||||
|
||||
if event.related_stocks:
|
||||
# 优先使用 best_matches(新结构,含研报引用)
|
||||
best_matches = getattr(event, 'best_matches', None)
|
||||
if best_matches and str(best_matches).strip():
|
||||
# 使用新的 parse_best_matches 函数解析
|
||||
parsed_stocks = parse_best_matches(best_matches)
|
||||
else:
|
||||
# 回退到旧的 related_stocks 处理
|
||||
parsed_stocks = []
|
||||
if event.related_stocks:
|
||||
try:
|
||||
import ast
|
||||
if isinstance(event.related_stocks, str):
|
||||
try:
|
||||
stock_data = json.loads(event.related_stocks)
|
||||
except:
|
||||
stock_data = ast.literal_eval(event.related_stocks)
|
||||
else:
|
||||
stock_data = event.related_stocks
|
||||
|
||||
if stock_data:
|
||||
for stock_info in stock_data:
|
||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
||||
parsed_stocks.append({
|
||||
'code': stock_info[0],
|
||||
'name': stock_info[1],
|
||||
'description': stock_info[2] if len(stock_info) > 2 else '',
|
||||
'score': stock_info[3] if len(stock_info) > 3 else 0,
|
||||
'report': None
|
||||
})
|
||||
except Exception as e:
|
||||
print(f"Error parsing related_stocks for event {event.data_id}: {e}")
|
||||
|
||||
# 处理解析后的股票数据
|
||||
if parsed_stocks:
|
||||
try:
|
||||
import json
|
||||
import ast
|
||||
daily_changes = []
|
||||
week_changes = []
|
||||
|
||||
# **修正:正确解析related_stocks数据结构**
|
||||
if isinstance(event.related_stocks, str):
|
||||
try:
|
||||
# 先尝试JSON解析
|
||||
stock_data = json.loads(event.related_stocks)
|
||||
except:
|
||||
# 如果JSON解析失败,尝试ast.literal_eval解析
|
||||
stock_data = ast.literal_eval(event.related_stocks)
|
||||
else:
|
||||
stock_data = event.related_stocks
|
||||
for stock_info in parsed_stocks:
|
||||
stock_code = stock_info.get('code', '')
|
||||
stock_name = stock_info.get('name', '')
|
||||
description = stock_info.get('description', '')
|
||||
score = stock_info.get('score', 0)
|
||||
report = stock_info.get('report', None)
|
||||
|
||||
print(f"Parsed stock_data: {stock_data}") # 调试输出
|
||||
if stock_code:
|
||||
# 规范化股票代码,移除后缀
|
||||
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
|
||||
|
||||
if stock_data:
|
||||
daily_changes = []
|
||||
week_changes = []
|
||||
print(f"Processing stock: {clean_code} - {stock_name}") # 调试输出
|
||||
|
||||
# **修正:处理正确的数据格式 [股票代码, 股票名称, 描述, 分数]**
|
||||
for stock_info in stock_data:
|
||||
if isinstance(stock_info, list) and len(stock_info) >= 2:
|
||||
stock_code = stock_info[0] # 第一个元素是股票代码
|
||||
stock_name = stock_info[1] # 第二个元素是股票名称
|
||||
description = stock_info[2] if len(stock_info) > 2 else ''
|
||||
score = stock_info[3] if len(stock_info) > 3 else 0
|
||||
else:
|
||||
continue # 跳过格式不正确的数据
|
||||
# 使用模糊匹配LIKE查询申万一级行业F004V
|
||||
sector_query = """
|
||||
SELECT F004V as sw_primary_sector
|
||||
FROM ea_sector
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
AND F002V = '申银万国行业分类' LIMIT 1 \
|
||||
"""
|
||||
sector_result = db.session.execute(text(sector_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
sector_row = sector_result.fetchone()
|
||||
|
||||
if stock_code:
|
||||
# 规范化股票代码,移除后缀
|
||||
clean_code = stock_code.replace('.SZ', '').replace('.SH', '').replace('.BJ', '')
|
||||
# 根据申万一级行业(F004V)映射到主板块
|
||||
sw_primary_sector = sector_row.sw_primary_sector if sector_row else None
|
||||
primary_sector = sector_map.get(sw_primary_sector, '其他') if sw_primary_sector else '其他'
|
||||
|
||||
print(f"Processing stock: {clean_code} - {stock_name}") # 调试输出
|
||||
print(
|
||||
f"Stock: {clean_code}, SW Primary: {sw_primary_sector}, Primary Sector: {primary_sector}")
|
||||
|
||||
# 使用模糊匹配LIKE查询申万一级行业F004V
|
||||
sector_query = """
|
||||
SELECT F004V as sw_primary_sector
|
||||
FROM ea_sector
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
AND F002V = '申银万国行业分类' LIMIT 1 \
|
||||
"""
|
||||
sector_result = db.session.execute(text(sector_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
sector_row = sector_result.fetchone()
|
||||
# 通过SQL查询获取真实的日涨跌幅和周涨跌幅
|
||||
trade_query = """
|
||||
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
|
||||
FROM ea_trade
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
ORDER BY TRADEDATE DESC LIMIT 7 \
|
||||
"""
|
||||
trade_result = db.session.execute(text(trade_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
trade_data = trade_result.fetchall()
|
||||
|
||||
# 根据申万一级行业(F004V)映射到主板块
|
||||
sw_primary_sector = sector_row.sw_primary_sector if sector_row else None
|
||||
primary_sector = sector_map.get(sw_primary_sector, '其他') if sw_primary_sector else '其他'
|
||||
daily_chg = 0
|
||||
week_chg = 0
|
||||
|
||||
print(
|
||||
f"Stock: {clean_code}, SW Primary: {sw_primary_sector}, Primary Sector: {primary_sector}")
|
||||
if trade_data:
|
||||
# 日涨跌幅(当日)
|
||||
daily_chg = float(trade_data[0].change_pct or 0)
|
||||
|
||||
# 通过SQL查询获取真实的日涨跌幅和周涨跌幅
|
||||
trade_query = """
|
||||
SELECT F007N as close_price, F010N as change_pct, TRADEDATE
|
||||
FROM ea_trade
|
||||
WHERE SECCODE LIKE :stock_code_pattern
|
||||
ORDER BY TRADEDATE DESC LIMIT 7 \
|
||||
"""
|
||||
trade_result = db.session.execute(text(trade_query),
|
||||
{'stock_code_pattern': f'{clean_code}%'})
|
||||
trade_data = trade_result.fetchall()
|
||||
# 周涨跌幅(5个交易日)
|
||||
if len(trade_data) >= 5:
|
||||
current_price = float(trade_data[0].close_price or 0)
|
||||
week_ago_price = float(trade_data[4].close_price or 0)
|
||||
if week_ago_price > 0:
|
||||
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
|
||||
|
||||
daily_chg = 0
|
||||
week_chg = 0
|
||||
print(
|
||||
f"Trade data found: {len(trade_data) if trade_data else 0} records, daily_chg: {daily_chg}")
|
||||
|
||||
if trade_data:
|
||||
# 日涨跌幅(当日)
|
||||
daily_chg = float(trade_data[0].change_pct or 0)
|
||||
# 统计各分类数量
|
||||
sector_stats['全部股票'] += 1
|
||||
sector_stats[primary_sector] += 1
|
||||
|
||||
# 周涨跌幅(5个交易日)
|
||||
if len(trade_data) >= 5:
|
||||
current_price = float(trade_data[0].close_price or 0)
|
||||
week_ago_price = float(trade_data[4].close_price or 0)
|
||||
if week_ago_price > 0:
|
||||
week_chg = ((current_price - week_ago_price) / week_ago_price) * 100
|
||||
# 收集涨跌幅数据
|
||||
daily_changes.append(daily_chg)
|
||||
week_changes.append(week_chg)
|
||||
|
||||
print(
|
||||
f"Trade data found: {len(trade_data) if trade_data else 0} records, daily_chg: {daily_chg}")
|
||||
related_stocks_list.append({
|
||||
'code': stock_code, # 原始股票代码
|
||||
'name': stock_name, # 股票名称
|
||||
'description': description, # 关联描述
|
||||
'score': score, # 关联分数
|
||||
'sw_primary_sector': sw_primary_sector, # 申万一级行业(F004V)
|
||||
'primary_sector': primary_sector, # 主板块分类
|
||||
'daily_change': daily_chg, # 真实的日涨跌幅
|
||||
'week_change': week_chg, # 真实的周涨跌幅
|
||||
'report': report # 研报引用信息(新字段)
|
||||
})
|
||||
|
||||
# 统计各分类数量
|
||||
sector_stats['全部股票'] += 1
|
||||
sector_stats[primary_sector] += 1
|
||||
# 计算平均收益率
|
||||
if daily_changes:
|
||||
related_avg_chg = sum(daily_changes) / len(daily_changes)
|
||||
related_max_chg = max(daily_changes)
|
||||
|
||||
# 收集涨跌幅数据
|
||||
daily_changes.append(daily_chg)
|
||||
week_changes.append(week_chg)
|
||||
|
||||
related_stocks_list.append({
|
||||
'code': stock_code, # 原始股票代码
|
||||
'name': stock_name, # 股票名称
|
||||
'description': description, # 关联描述
|
||||
'score': score, # 关联分数
|
||||
'sw_primary_sector': sw_primary_sector, # 申万一级行业(F004V)
|
||||
'primary_sector': primary_sector, # 主板块分类
|
||||
'daily_change': daily_chg, # 真实的日涨跌幅
|
||||
'week_change': week_chg # 真实的周涨跌幅
|
||||
})
|
||||
|
||||
# 计算平均收益率
|
||||
if daily_changes:
|
||||
related_avg_chg = sum(daily_changes) / len(daily_changes)
|
||||
related_max_chg = max(daily_changes)
|
||||
|
||||
if week_changes:
|
||||
related_week_chg = sum(week_changes) / len(week_changes)
|
||||
if week_changes:
|
||||
related_week_chg = sum(week_changes) / len(week_changes)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing related stocks: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
|
||||
# 构建返回数据
|
||||
# 构建返回数据,使用新字段回退后的值
|
||||
detail_data = {
|
||||
'id': event.data_id,
|
||||
'title': event.title,
|
||||
'type': event.type,
|
||||
'star': event.star,
|
||||
'calendar_time': event.calendar_time.isoformat() if event.calendar_time else None,
|
||||
'former': event.former,
|
||||
'forecast': event.forecast,
|
||||
'former': former_value, # 使用回退后的值(优先 second_modified_text)
|
||||
'forecast': forecast_value, # 使用回退后的值(优先 second_modified_text.1)
|
||||
'fact': event.fact,
|
||||
'concepts': event.concepts,
|
||||
'extracted_concepts': extracted_concepts,
|
||||
|
||||
1176
concept_hierarchy.json
Normal file
1176
concept_hierarchy.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
|
||||
@@ -35,6 +35,13 @@ export const bytedeskConfig = {
|
||||
subtitle: '点击咨询', // 副标题
|
||||
},
|
||||
|
||||
// 按钮大小配置
|
||||
buttonConfig: {
|
||||
show: true,
|
||||
width: 40,
|
||||
height: 40,
|
||||
},
|
||||
|
||||
// 主题配置
|
||||
theme: {
|
||||
mode: 'system', // light | dark | system
|
||||
|
||||
@@ -18,7 +18,6 @@ import { FiStar, FiCalendar, FiUser, FiSettings, FiHome, FiLogOut } from 'react-
|
||||
import { FaCrown } from 'react-icons/fa';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import UserAvatar from './UserAvatar';
|
||||
import SubscriptionModal from '../../../Subscription/SubscriptionModal';
|
||||
import { useSubscription } from '../../../../hooks/useSubscription';
|
||||
|
||||
/**
|
||||
@@ -38,12 +37,7 @@ const TabletUserMenu = memo(({
|
||||
followingEvents
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const {
|
||||
subscriptionInfo,
|
||||
isSubscriptionModalOpen,
|
||||
openSubscriptionModal,
|
||||
closeSubscriptionModal
|
||||
} = useSubscription();
|
||||
const { subscriptionInfo } = useSubscription();
|
||||
|
||||
const borderColor = useColorModeValue('gray.200', 'gray.600');
|
||||
|
||||
@@ -90,8 +84,8 @@ const TabletUserMenu = memo(({
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 订阅管理 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={openSubscriptionModal}>
|
||||
{/* 订阅管理 - 移动端导航到订阅页面 */}
|
||||
<MenuItem icon={<FaCrown />} onClick={() => navigate('/home/pages/account/subscription')}>
|
||||
<Flex justify="space-between" align="center" w="100%">
|
||||
<Text>订阅管理</Text>
|
||||
<Badge colorScheme={getSubscriptionBadgeColor()}>
|
||||
@@ -149,14 +143,6 @@ const TabletUserMenu = memo(({
|
||||
</MenuList>
|
||||
</Menu>
|
||||
|
||||
{/* 订阅弹窗 */}
|
||||
{isSubscriptionModalOpen && (
|
||||
<SubscriptionModal
|
||||
isOpen={isSubscriptionModalOpen}
|
||||
onClose={closeSubscriptionModal}
|
||||
subscriptionInfo={subscriptionInfo}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -100,7 +100,7 @@ export const PerformancePanel: React.FC = () => {
|
||||
aria-label="Open performance panel"
|
||||
icon={<MdSpeed />}
|
||||
position="fixed"
|
||||
bottom="20px"
|
||||
bottom="100px"
|
||||
right="20px"
|
||||
colorScheme="blue"
|
||||
size="lg"
|
||||
|
||||
@@ -1049,10 +1049,26 @@ export default function SubscriptionContent() {
|
||||
</Text>
|
||||
</HStack>
|
||||
</Flex>
|
||||
<Flex justify="space-between" align="center" flexWrap="wrap" gap={2}>
|
||||
<Text fontSize="xs" color={secondaryText} pl={11} flex={1}>
|
||||
{plan.description}
|
||||
</Text>
|
||||
<Flex justify="space-between" align="flex-start" flexWrap="wrap" gap={2}>
|
||||
<VStack align="start" spacing={0.5} pl={11} flex={1}>
|
||||
{plan.description && plan.description.includes('|') ? (
|
||||
plan.description.split('|').map((item, idx) => (
|
||||
<Text
|
||||
key={idx}
|
||||
fontSize="sm"
|
||||
color={plan.name === 'max' ? 'purple.600' : 'blue.600'}
|
||||
lineHeight="1.5"
|
||||
fontWeight="medium"
|
||||
>
|
||||
✓ {item.trim()}
|
||||
</Text>
|
||||
))
|
||||
) : (
|
||||
<Text fontSize="xs" color={secondaryText}>
|
||||
{plan.description}
|
||||
</Text>
|
||||
)}
|
||||
</VStack>
|
||||
{(() => {
|
||||
// 获取当前选中的周期信息
|
||||
if (plan.pricing_options) {
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Input,
|
||||
Icon,
|
||||
Container,
|
||||
useBreakpointValue,
|
||||
} from '@chakra-ui/react';
|
||||
import {
|
||||
FaWeixin,
|
||||
@@ -42,6 +43,87 @@ import { useAuth } from '../../contexts/AuthContext';
|
||||
import { useSubscriptionEvents } from '../../hooks/useSubscriptionEvents';
|
||||
import { subscriptionConfig, themeColors } from '../../views/Pages/Account/subscription-content';
|
||||
|
||||
// 计费周期选择器组件 - 移动端垂直布局(年付在上),桌面端水平布局
|
||||
interface CycleSelectorProps {
|
||||
options: any[];
|
||||
selectedCycle: string;
|
||||
onSelectCycle: (cycle: string) => void;
|
||||
}
|
||||
|
||||
function CycleSelector({ options, selectedCycle, onSelectCycle }: CycleSelectorProps) {
|
||||
// 使用 useBreakpointValue 动态获取是否是移动端
|
||||
const isMobile = useBreakpointValue({ base: true, md: false });
|
||||
|
||||
// 移动端倒序显示(年付在上),桌面端正常顺序
|
||||
const displayOptions = isMobile ? [...options].reverse() : options;
|
||||
|
||||
return (
|
||||
<Flex
|
||||
direction={{ base: 'column', md: 'row' }}
|
||||
gap={3}
|
||||
p={2}
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
backdropFilter="blur(10px)"
|
||||
justify="center"
|
||||
align="center"
|
||||
w={{ base: 'full', md: 'auto' }}
|
||||
maxW={{ base: '320px', md: 'none' }}
|
||||
mx="auto"
|
||||
>
|
||||
{displayOptions.map((option: any) => (
|
||||
<Box key={option.cycleKey} position="relative" w={{ base: 'full', md: 'auto' }}>
|
||||
{option.discountPercent > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top={{ base: '50%', md: '-10px' }}
|
||||
right={{ base: '10px', md: '-10px' }}
|
||||
transform={{ base: 'translateY(-50%)', md: 'none' }}
|
||||
colorScheme="red"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
zIndex={1}
|
||||
>
|
||||
省{option.discountPercent}%
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
w={{ base: 'full', md: 'auto' }}
|
||||
px={6}
|
||||
py={6}
|
||||
borderRadius="lg"
|
||||
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
|
||||
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
|
||||
border="1px solid"
|
||||
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
|
||||
onClick={() => onSelectCycle(option.cycleKey)}
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.5)',
|
||||
shadow: selectedCycle === option.cycleKey
|
||||
? '0 0 20px rgba(212, 175, 55, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
fontWeight="bold"
|
||||
justifyContent={{ base: 'flex-start', md: 'center' }}
|
||||
pl={{ base: 6, md: 6 }}
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
);
|
||||
}
|
||||
|
||||
export default function SubscriptionContentNew() {
|
||||
const { user } = useAuth();
|
||||
const subscriptionEvents = useSubscriptionEvents({
|
||||
@@ -751,61 +833,11 @@ export default function SubscriptionContentNew() {
|
||||
选择计费周期 · 时长越长优惠越大
|
||||
</Text>
|
||||
|
||||
<HStack
|
||||
spacing={3}
|
||||
p={2}
|
||||
bg="rgba(255, 255, 255, 0.03)"
|
||||
borderRadius="xl"
|
||||
border="1px solid"
|
||||
borderColor="rgba(255, 255, 255, 0.1)"
|
||||
backdropFilter="blur(10px)"
|
||||
flexWrap="wrap"
|
||||
justify="center"
|
||||
>
|
||||
{getMergedPlans()[1]?.pricingOptions?.map((option: any, index: number) => (
|
||||
<Box key={index} position="relative">
|
||||
{option.discountPercent > 0 && (
|
||||
<Badge
|
||||
position="absolute"
|
||||
top="-10px"
|
||||
right="-10px"
|
||||
colorScheme="red"
|
||||
fontSize="xs"
|
||||
px={2}
|
||||
py={1}
|
||||
borderRadius="full"
|
||||
fontWeight="bold"
|
||||
zIndex={1}
|
||||
>
|
||||
省{option.discountPercent}%
|
||||
</Badge>
|
||||
)}
|
||||
|
||||
<Button
|
||||
size="lg"
|
||||
px={6}
|
||||
py={6}
|
||||
borderRadius="lg"
|
||||
bg={selectedCycle === option.cycleKey ? 'linear-gradient(135deg, #D4AF37, #B8941F)' : 'transparent'}
|
||||
color={selectedCycle === option.cycleKey ? '#000' : '#fff'}
|
||||
border="1px solid"
|
||||
borderColor={selectedCycle === option.cycleKey ? 'rgba(212, 175, 55, 0.3)' : 'rgba(255, 255, 255, 0.1)'}
|
||||
onClick={() => setSelectedCycle(option.cycleKey)}
|
||||
_hover={{
|
||||
transform: 'translateY(-2px)',
|
||||
borderColor: 'rgba(212, 175, 55, 0.5)',
|
||||
shadow: selectedCycle === option.cycleKey
|
||||
? '0 0 20px rgba(212, 175, 55, 0.3)'
|
||||
: '0 4px 12px rgba(0, 0, 0, 0.5)',
|
||||
}}
|
||||
transition="all 0.3s"
|
||||
fontWeight="bold"
|
||||
>
|
||||
{option.label}
|
||||
</Button>
|
||||
</Box>
|
||||
))}
|
||||
</HStack>
|
||||
<CycleSelector
|
||||
options={getMergedPlans()[1]?.pricingOptions || []}
|
||||
selectedCycle={selectedCycle}
|
||||
onSelectCycle={setSelectedCycle}
|
||||
/>
|
||||
|
||||
{(() => {
|
||||
const currentOption = getMergedPlans()[1]?.pricingOptions?.find(
|
||||
|
||||
261
src/hooks/useIndexQuote.js
Normal file
261
src/hooks/useIndexQuote.js
Normal file
@@ -0,0 +1,261 @@
|
||||
// src/hooks/useIndexQuote.js
|
||||
// 指数实时行情 Hook - 交易时间内每分钟自动更新
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { logger } from '../utils/logger';
|
||||
|
||||
// 交易日数据会从后端获取,这里只做时间判断
|
||||
const TRADING_SESSIONS = [
|
||||
{ start: { hour: 9, minute: 30 }, end: { hour: 11, minute: 30 } },
|
||||
{ start: { hour: 13, minute: 0 }, end: { hour: 15, minute: 0 } },
|
||||
];
|
||||
|
||||
/**
|
||||
* 判断当前时间是否在交易时段内
|
||||
*/
|
||||
const isInTradingSession = () => {
|
||||
const now = new Date();
|
||||
const currentMinutes = now.getHours() * 60 + now.getMinutes();
|
||||
|
||||
return TRADING_SESSIONS.some(session => {
|
||||
const startMinutes = session.start.hour * 60 + session.start.minute;
|
||||
const endMinutes = session.end.hour * 60 + session.end.minute;
|
||||
return currentMinutes >= startMinutes && currentMinutes <= endMinutes;
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取指数实时行情
|
||||
*/
|
||||
const fetchIndexRealtime = async (indexCode) => {
|
||||
try {
|
||||
const response = await fetch(`/api/index/${indexCode}/realtime`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP error! status: ${response.status}`);
|
||||
}
|
||||
const result = await response.json();
|
||||
if (result.success && result.data) {
|
||||
return result.data;
|
||||
}
|
||||
return null;
|
||||
} catch (error) {
|
||||
logger.error('useIndexQuote', 'fetchIndexRealtime error', { indexCode, error: error.message });
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 指数实时行情 Hook
|
||||
*
|
||||
* @param {string} indexCode - 指数代码,如 '000001' (上证指数) 或 '399001' (深证成指)
|
||||
* @param {Object} options - 配置选项
|
||||
* @param {number} options.refreshInterval - 刷新间隔(毫秒),默认 60000(1分钟)
|
||||
* @param {boolean} options.autoRefresh - 是否自动刷新,默认 true
|
||||
*
|
||||
* @returns {Object} { quote, loading, error, isTrading, refresh }
|
||||
*/
|
||||
export const useIndexQuote = (indexCode, options = {}) => {
|
||||
const {
|
||||
refreshInterval = 60000, // 默认1分钟
|
||||
autoRefresh = true,
|
||||
} = options;
|
||||
|
||||
const [quote, setQuote] = useState(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(null);
|
||||
const [isTrading, setIsTrading] = useState(false);
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 加载数据
|
||||
const loadQuote = useCallback(async () => {
|
||||
if (!indexCode) return;
|
||||
|
||||
try {
|
||||
const data = await fetchIndexRealtime(indexCode);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
if (data) {
|
||||
setQuote(data);
|
||||
setIsTrading(data.is_trading);
|
||||
setError(null);
|
||||
} else {
|
||||
setError('无法获取行情数据');
|
||||
}
|
||||
} catch (err) {
|
||||
if (isMountedRef.current) {
|
||||
setError(err.message);
|
||||
}
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [indexCode]);
|
||||
|
||||
// 手动刷新
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadQuote();
|
||||
}, [loadQuote]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
loadQuote();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadQuote]);
|
||||
|
||||
// 自动刷新逻辑
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || !indexCode) return;
|
||||
|
||||
// 清除旧的定时器
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
// 设置定时器,检查是否在交易时间内
|
||||
const checkAndRefresh = () => {
|
||||
const inSession = isInTradingSession();
|
||||
setIsTrading(inSession);
|
||||
|
||||
if (inSession) {
|
||||
loadQuote();
|
||||
}
|
||||
};
|
||||
|
||||
// 立即检查一次
|
||||
checkAndRefresh();
|
||||
|
||||
// 设置定时刷新
|
||||
intervalRef.current = setInterval(checkAndRefresh, refreshInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, indexCode, refreshInterval, loadQuote]);
|
||||
|
||||
return {
|
||||
quote,
|
||||
loading,
|
||||
error,
|
||||
isTrading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 批量获取多个指数的实时行情
|
||||
*
|
||||
* @param {string[]} indexCodes - 指数代码数组
|
||||
* @param {Object} options - 配置选项
|
||||
*/
|
||||
export const useMultiIndexQuotes = (indexCodes = [], options = {}) => {
|
||||
const {
|
||||
refreshInterval = 60000,
|
||||
autoRefresh = true,
|
||||
} = options;
|
||||
|
||||
const [quotes, setQuotes] = useState({});
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [isTrading, setIsTrading] = useState(false);
|
||||
|
||||
const intervalRef = useRef(null);
|
||||
const isMountedRef = useRef(true);
|
||||
|
||||
// 批量加载数据
|
||||
const loadQuotes = useCallback(async () => {
|
||||
if (!indexCodes || indexCodes.length === 0) return;
|
||||
|
||||
try {
|
||||
const results = await Promise.all(
|
||||
indexCodes.map(code => fetchIndexRealtime(code))
|
||||
);
|
||||
|
||||
if (!isMountedRef.current) return;
|
||||
|
||||
const newQuotes = {};
|
||||
let hasTrading = false;
|
||||
|
||||
results.forEach((data, idx) => {
|
||||
if (data) {
|
||||
newQuotes[indexCodes[idx]] = data;
|
||||
if (data.is_trading) hasTrading = true;
|
||||
}
|
||||
});
|
||||
|
||||
setQuotes(newQuotes);
|
||||
setIsTrading(hasTrading);
|
||||
} catch (err) {
|
||||
logger.error('useMultiIndexQuotes', 'loadQuotes error', err);
|
||||
} finally {
|
||||
if (isMountedRef.current) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
}, [indexCodes]);
|
||||
|
||||
// 手动刷新
|
||||
const refresh = useCallback(() => {
|
||||
setLoading(true);
|
||||
loadQuotes();
|
||||
}, [loadQuotes]);
|
||||
|
||||
// 初始加载
|
||||
useEffect(() => {
|
||||
isMountedRef.current = true;
|
||||
loadQuotes();
|
||||
|
||||
return () => {
|
||||
isMountedRef.current = false;
|
||||
};
|
||||
}, [loadQuotes]);
|
||||
|
||||
// 自动刷新逻辑
|
||||
useEffect(() => {
|
||||
if (!autoRefresh || indexCodes.length === 0) return;
|
||||
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
|
||||
const checkAndRefresh = () => {
|
||||
const inSession = isInTradingSession();
|
||||
setIsTrading(inSession);
|
||||
|
||||
if (inSession) {
|
||||
loadQuotes();
|
||||
}
|
||||
};
|
||||
|
||||
checkAndRefresh();
|
||||
intervalRef.current = setInterval(checkAndRefresh, refreshInterval);
|
||||
|
||||
return () => {
|
||||
if (intervalRef.current) {
|
||||
clearInterval(intervalRef.current);
|
||||
intervalRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [autoRefresh, indexCodes, refreshInterval, loadQuotes]);
|
||||
|
||||
return {
|
||||
quotes,
|
||||
loading,
|
||||
isTrading,
|
||||
refresh,
|
||||
};
|
||||
};
|
||||
|
||||
export default useIndexQuote;
|
||||
@@ -696,4 +696,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
|
||||
});
|
||||
}),
|
||||
];
|
||||
|
||||
@@ -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 秒后自动消失 ========== */
|
||||
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user