Compare commits
10 Commits
feature_bu
...
feature_bu
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c49ddf42c | |||
| d64349b606 | |||
|
|
926ffa1b8f | ||
|
|
eebd207276 | ||
|
|
6b96744b2c | ||
|
|
463bdbf09c | ||
|
|
a15585c464 | ||
|
|
643c3db03e | ||
|
|
8e5623d723 | ||
|
|
57b4841b4c |
@@ -17,13 +17,13 @@ NODE_ENV=production
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 🔧 调试模式(生产环境临时调试用)
|
||||
# 开启后会在全局暴露 window.__DEBUG__ 调试 API
|
||||
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
|
||||
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
|
||||
# 使用方法:
|
||||
# 1. 设置为 true 并重新构建
|
||||
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
|
||||
# 3. 调试完成后设置为 false 并重新构建
|
||||
REACT_APP_ENABLE_DEBUG=false
|
||||
REACT_APP_ENABLE_DEBUG=true
|
||||
|
||||
# 后端 API 地址(生产环境)
|
||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||
@@ -49,3 +49,18 @@ TSC_COMPILE_ON_ERROR=true
|
||||
IMAGE_INLINE_SIZE_LIMIT=10000
|
||||
# Node.js 内存限制(适用于大型项目)
|
||||
NODE_OPTIONS=--max_old_space_size=4096
|
||||
|
||||
# ========================================
|
||||
# Bytedesk 客服系统配置
|
||||
# ========================================
|
||||
# Bytedesk 服务器地址
|
||||
REACT_APP_BYTEDESK_API_URL=http://43.143.189.195
|
||||
|
||||
# 组织 ID(从管理后台获取)
|
||||
REACT_APP_BYTEDESK_ORG=df_org_uid
|
||||
|
||||
# 工作组 ID(从管理后台获取)
|
||||
REACT_APP_BYTEDESK_SID=df_wg_aftersales
|
||||
|
||||
# 客服类型(2=人工客服, 1=机器人)
|
||||
REACT_APP_BYTEDESK_TYPE=2
|
||||
|
||||
653
app_vx.py
653
app_vx.py
@@ -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
8126
app_vx_raw.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -65,6 +65,9 @@
|
||||
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',
|
||||
@@ -84,91 +87,45 @@
|
||||
// name: 'YOU CAN DEFINE USER NAME HERE',
|
||||
},
|
||||
}
|
||||
|
||||
// 根据路径控制Dify机器人显示(只在首页/和home页/home显示)
|
||||
function controlDifyChatbot() {
|
||||
const path = window.location.pathname;
|
||||
const chatbotButton = document.getElementById('dify-chatbot-bubble-button');
|
||||
const chatbotWindow = document.getElementById('dify-chatbot-bubble-window');
|
||||
|
||||
// 只在首页(/)和home页(/home)显示Dify机器人
|
||||
// const shouldShowDify = (path === '/' || path === '/home');
|
||||
// 完全不显示Dify机器人(只使用Bytedesk客服)
|
||||
const shouldShowDify = false
|
||||
|
||||
if (chatbotButton) {
|
||||
chatbotButton.style.display = shouldShowDify ? 'block' : 'none';
|
||||
// 同时设置visibility确保完全隐藏
|
||||
chatbotButton.style.visibility = shouldShowDify ? 'visible' : 'hidden';
|
||||
}
|
||||
|
||||
if (chatbotWindow) {
|
||||
chatbotWindow.style.display = shouldShowDify ? '' : 'none';
|
||||
}
|
||||
|
||||
console.log('[Dify] Path:', path, 'Should show:', shouldShowDify, 'Button found:', !!chatbotButton);
|
||||
}
|
||||
|
||||
// 轮询检查Dify按钮(因为Dify脚本加载是异步的)
|
||||
let difyCheckCount = 0;
|
||||
const difyCheckInterval = setInterval(function() {
|
||||
const button = document.getElementById('dify-chatbot-bubble-button');
|
||||
if (button || difyCheckCount > 50) { // 最多检查5秒
|
||||
if (button) {
|
||||
console.log('[Dify] Button found, applying control');
|
||||
controlDifyChatbot();
|
||||
}
|
||||
clearInterval(difyCheckInterval);
|
||||
}
|
||||
difyCheckCount++;
|
||||
}, 100);
|
||||
|
||||
// 页面加载时执行
|
||||
window.addEventListener('load', function() {
|
||||
setTimeout(controlDifyChatbot, 1000);
|
||||
});
|
||||
|
||||
// 监听路由变化(React Router使用pushState)
|
||||
window.addEventListener('popstate', controlDifyChatbot);
|
||||
|
||||
// 监听pushState和replaceState(捕获React Router导航)
|
||||
const originalPushState = history.pushState;
|
||||
const originalReplaceState = history.replaceState;
|
||||
|
||||
history.pushState = function() {
|
||||
originalPushState.apply(history, arguments);
|
||||
setTimeout(controlDifyChatbot, 50);
|
||||
};
|
||||
|
||||
history.replaceState = function() {
|
||||
originalReplaceState.apply(history, arguments);
|
||||
setTimeout(controlDifyChatbot, 50);
|
||||
};
|
||||
|
||||
// 使用MutationObserver监听DOM变化(捕获Dify按钮插入)
|
||||
const observer = new MutationObserver(function(mutations) {
|
||||
for (const mutation of mutations) {
|
||||
if (mutation.addedNodes.length > 0) {
|
||||
for (const node of mutation.addedNodes) {
|
||||
if (node.id && (node.id.includes('dify') || node.id.includes('chatbot'))) {
|
||||
console.log('[Dify] Detected chatbot element insertion:', node.id);
|
||||
setTimeout(controlDifyChatbot, 100);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 观察body的变化
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false
|
||||
});
|
||||
});
|
||||
</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"
|
||||
@@ -250,7 +207,7 @@
|
||||
bottom: 80px !important;
|
||||
left: 10px !important;
|
||||
}
|
||||
|
||||
|
||||
#dify-chatbot-bubble-button {
|
||||
width: 56px !important;
|
||||
height: 56px !important;
|
||||
|
||||
@@ -35,7 +35,7 @@ self.addEventListener('notificationclick', (event) => {
|
||||
.then((windowClients) => {
|
||||
// 查找是否已有打开的窗口
|
||||
for (let client of windowClients) {
|
||||
if (client.url.includes(window.location.origin) && 'focus' in client) {
|
||||
if (client.url.includes(self.location.origin) && 'focus' in client) {
|
||||
// 聚焦现有窗口并导航到目标页面
|
||||
return client.focus().then(client => {
|
||||
return client.navigate(urlToOpen);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -82,65 +82,28 @@ const BytedeskWidget = ({
|
||||
return () => {
|
||||
console.log('[Bytedesk] 清理Widget');
|
||||
|
||||
try {
|
||||
// 调用Widget的destroy方法(如果存在)
|
||||
if (widgetRef.current && typeof widgetRef.current.destroy === 'function') {
|
||||
console.log('[Bytedesk] 调用Widget.destroy()');
|
||||
widgetRef.current.destroy();
|
||||
// 移除脚本
|
||||
if (scriptRef.current && document.body.contains(scriptRef.current)) {
|
||||
document.body.removeChild(scriptRef.current);
|
||||
}
|
||||
|
||||
// 移除Widget DOM元素
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
if (el && el.parentNode) {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] Widget.destroy()失败:', error.message);
|
||||
}
|
||||
});
|
||||
|
||||
try {
|
||||
// 移除脚本
|
||||
if (scriptRef.current) {
|
||||
if (document.body.contains(scriptRef.current)) {
|
||||
document.body.removeChild(scriptRef.current);
|
||||
}
|
||||
scriptRef.current = null;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 移除脚本失败:', error.message);
|
||||
// 清理全局对象
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
}
|
||||
|
||||
try {
|
||||
// 移除Widget DOM元素(使用更安全的remove()方法)
|
||||
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
|
||||
widgetElements.forEach(el => {
|
||||
try {
|
||||
if (el && el.parentNode) {
|
||||
// 优先使用remove()方法(更现代、更安全)
|
||||
if (typeof el.remove === 'function') {
|
||||
el.remove();
|
||||
} else {
|
||||
el.parentNode.removeChild(el);
|
||||
}
|
||||
}
|
||||
} catch (removeError) {
|
||||
console.warn('[Bytedesk] 移除DOM元素失败:', el, removeError.message);
|
||||
}
|
||||
});
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 清理DOM元素失败:', error.message);
|
||||
}
|
||||
|
||||
try {
|
||||
// 清理全局对象
|
||||
if (window.BytedeskWeb) {
|
||||
delete window.BytedeskWeb;
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('[Bytedesk] 清理全局对象失败:', error.message);
|
||||
}
|
||||
|
||||
console.log('[Bytedesk] Widget清理完成');
|
||||
};
|
||||
}, [config, autoLoad, onLoad, onError]);
|
||||
|
||||
// 不渲染任何元素(Widget会自动插入DOM到body)
|
||||
// 返回null避免React DOM管理冲突
|
||||
return null;
|
||||
// 不渲染任何可见元素(Widget会自动插入到body)
|
||||
return <div id="bytedesk-widget-container" style={{ display: 'none' }} />;
|
||||
};
|
||||
|
||||
BytedeskWidget.propTypes = {
|
||||
|
||||
@@ -64,16 +64,16 @@ export const bytedeskConfig = {
|
||||
* @returns {Object} Bytedesk配置对象
|
||||
*/
|
||||
export const getBytedeskConfig = () => {
|
||||
// 开发环境使用代理(绕过X-Frame-Options限制)
|
||||
// 开发环境使用代理(绕过 X-Frame-Options 限制)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return {
|
||||
...bytedeskConfig,
|
||||
apiUrl: '/bytedesk-api',
|
||||
htmlUrl: '/bytedesk-api/chat/',
|
||||
apiUrl: '/bytedesk-api', // 使用 CRACO 代理路径
|
||||
htmlUrl: '/bytedesk-api/chat/', // 使用 CRACO 代理路径
|
||||
};
|
||||
}
|
||||
|
||||
// 生产环境使用完整URL
|
||||
// 生产环境使用完整 URL
|
||||
return bytedeskConfig;
|
||||
};
|
||||
|
||||
@@ -118,8 +118,33 @@ export const getBytedeskConfigWithUser = (user) => {
|
||||
* @returns {boolean} 是否显示客服
|
||||
*/
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 所有页面都显示Bytedesk客服
|
||||
// 在以下页面隐藏客服(黑名单)
|
||||
const blockedPages = [
|
||||
'/home', // 登录页
|
||||
];
|
||||
|
||||
// 检查是否在黑名单
|
||||
if (blockedPages.some(page => pathname.startsWith(page))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 默认所有页面都显示客服
|
||||
return true;
|
||||
|
||||
/* ============================================
|
||||
白名单模式(备用,需要时取消注释)
|
||||
============================================
|
||||
const allowedPages = [
|
||||
'/', // 首页
|
||||
'/home', // 主页
|
||||
'/products', // 产品页
|
||||
'/pricing', // 价格页
|
||||
'/contact', // 联系我们
|
||||
];
|
||||
|
||||
// 只在白名单页面显示客服
|
||||
return allowedPages.some(page => pathname.startsWith(page));
|
||||
============================================ */
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -59,6 +59,11 @@ export const NotificationProvider = ({ children }) => {
|
||||
const MAX_PROCESSED_IDS = 1000; // 最多保留1000个ID,避免内存泄漏
|
||||
const notificationTimers = useRef(new Map()); // 跟踪所有通知的自动关闭定时器
|
||||
|
||||
// ⚡ 方案2: 使用 Ref 存储最新的回调函数引用(避免闭包陷阱)
|
||||
const addNotificationRef = useRef(null);
|
||||
const adaptEventToNotificationRef = useRef(null);
|
||||
const isFirstConnect = useRef(true); // 标记是否首次连接
|
||||
|
||||
// ⚡ 使用权限引导管理 Hook
|
||||
const { shouldShowGuide, markGuideAsShown } = usePermissionGuide();
|
||||
|
||||
@@ -595,26 +600,42 @@ export const NotificationProvider = ({ children }) => {
|
||||
return newNotification.id;
|
||||
}, [notifications, toast, sendBrowserNotification, addWebNotification, browserPermission, hasRequestedPermission, requestBrowserPermission]);
|
||||
|
||||
// 连接到 Socket 服务
|
||||
/**
|
||||
* ✅ 方案2: 同步最新的回调函数到 Ref
|
||||
* 确保 Socket 监听器始终使用最新的函数引用(避免闭包陷阱)
|
||||
*/
|
||||
useEffect(() => {
|
||||
addNotificationRef.current = addNotification;
|
||||
console.log('[NotificationContext] 📝 已更新 addNotificationRef');
|
||||
}, [addNotification]);
|
||||
|
||||
useEffect(() => {
|
||||
adaptEventToNotificationRef.current = adaptEventToNotification;
|
||||
console.log('[NotificationContext] 📝 已更新 adaptEventToNotificationRef');
|
||||
}, [adaptEventToNotification]);
|
||||
|
||||
|
||||
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次) ==========
|
||||
useEffect(() => {
|
||||
logger.info('NotificationContext', 'Initializing socket connection...');
|
||||
console.log('%c[NotificationContext] Initializing socket connection', 'color: #673AB7; font-weight: bold;');
|
||||
console.log('%c[NotificationContext] 🚀 初始化 Socket 连接(方案2:只注册一次)', 'color: #673AB7; font-weight: bold;');
|
||||
|
||||
// ✅ 第一步: 注册所有事件监听器
|
||||
console.log('%c[NotificationContext] Step 1: Registering event listeners...', 'color: #673AB7;');
|
||||
|
||||
// 监听连接状态
|
||||
// ========== 监听连接成功(首次连接 + 重连) ==========
|
||||
socket.on('connect', () => {
|
||||
const wasDisconnected = connectionStatus !== CONNECTION_STATUS.CONNECTED;
|
||||
setIsConnected(true);
|
||||
setReconnectAttempt(0);
|
||||
logger.info('NotificationContext', 'Socket connected', { wasDisconnected });
|
||||
console.log('%c[NotificationContext] ✅ Received connect event, updating state to connected', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// 如果之前断开过,显示 RECONNECTED 状态2秒后自动消失
|
||||
if (wasDisconnected) {
|
||||
// 判断是首次连接还是重连
|
||||
if (isFirstConnect.current) {
|
||||
console.log('%c[NotificationContext] ✅ 首次连接成功', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] Socket ID:', socket.getSocketId?.());
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
isFirstConnect.current = false;
|
||||
logger.info('NotificationContext', 'Socket connected (first time)');
|
||||
} else {
|
||||
console.log('%c[NotificationContext] 🔄 重连成功!', 'color: #FF9800; font-weight: bold;');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
|
||||
logger.info('NotificationContext', 'Reconnected, will auto-dismiss in 2s');
|
||||
logger.info('NotificationContext', 'Socket reconnected');
|
||||
|
||||
// 清除之前的定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
@@ -626,12 +647,10 @@ export const NotificationProvider = ({ children }) => {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
|
||||
}, 2000);
|
||||
} else {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
}
|
||||
|
||||
// 订阅事件推送
|
||||
console.log('%c[NotificationContext] 🔔 订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
|
||||
console.log('%c[NotificationContext] 🔔 重新订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
@@ -642,45 +661,47 @@ export const NotificationProvider = ({ children }) => {
|
||||
console.log('[NotificationContext] 订阅确认:', data);
|
||||
logger.info('NotificationContext', 'Events subscribed', data);
|
||||
},
|
||||
// ⚠️ 不需要 onNewEvent 回调,因为 NotificationContext 已经通过 socket.on('new_event') 监听
|
||||
});
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
});
|
||||
|
||||
// ========== 监听断开连接 ==========
|
||||
socket.on('disconnect', (reason) => {
|
||||
setIsConnected(false);
|
||||
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
|
||||
logger.warn('NotificationContext', 'Socket disconnected', { reason });
|
||||
console.log('%c[NotificationContext] ⚠️ Socket 已断开', 'color: #FF5722;', { reason });
|
||||
});
|
||||
|
||||
// 监听连接错误
|
||||
// ========== 监听连接错误 ==========
|
||||
socket.on('connect_error', (error) => {
|
||||
logger.error('NotificationContext', 'Socket connect_error', error);
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
|
||||
|
||||
// 获取重连次数
|
||||
const attempts = socket.getReconnectAttempts?.() || 0;
|
||||
setReconnectAttempt(attempts);
|
||||
logger.info('NotificationContext', 'Reconnection attempt', { attempts });
|
||||
console.log(`%c[NotificationContext] 🔄 重连中... (第 ${attempts} 次尝试)`, 'color: #FF9800;');
|
||||
});
|
||||
|
||||
// 监听重连失败
|
||||
// ========== 监听重连失败 ==========
|
||||
socket.on('reconnect_failed', () => {
|
||||
logger.error('NotificationContext', 'Socket reconnect_failed');
|
||||
setConnectionStatus(CONNECTION_STATUS.FAILED);
|
||||
console.error('[NotificationContext] ❌ 重连失败');
|
||||
|
||||
toast({
|
||||
title: '连接失败',
|
||||
description: '无法连接到服务器,请检查网络连接',
|
||||
status: 'error',
|
||||
duration: null, // 不自动关闭
|
||||
duration: null,
|
||||
isClosable: true,
|
||||
});
|
||||
});
|
||||
|
||||
// 监听新事件推送(统一事件名)
|
||||
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
|
||||
socket.on('new_event', (data) => {
|
||||
console.log('\n%c════════════════════════════════════════', 'color: #FF9800; font-weight: bold;');
|
||||
console.log('%c[NotificationContext] 📨 收到 new_event 事件!', 'color: #FF9800; font-weight: bold;');
|
||||
@@ -693,17 +714,24 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
logger.info('NotificationContext', 'Received new event', data);
|
||||
|
||||
// ========== Socket层去重检查 ==========
|
||||
// 生成更健壮的事件ID
|
||||
const eventId = data.id ||
|
||||
`${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
// ⚠️ 防御性检查:确保 ref 已初始化
|
||||
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
|
||||
console.error('%c[NotificationContext] ❌ Ref 未初始化,跳过处理', 'color: #F44336; font-weight: bold;');
|
||||
logger.error('NotificationContext', 'Refs not initialized', {
|
||||
addNotificationRef: !!addNotificationRef.current,
|
||||
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ========== Socket层去重检查 ==========
|
||||
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
// 如果缺少原始ID,记录警告
|
||||
if (!data.id) {
|
||||
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
|
||||
eventId,
|
||||
eventType: data.type,
|
||||
title: data.title
|
||||
title: data.title,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -711,55 +739,61 @@ export const NotificationProvider = ({ children }) => {
|
||||
logger.debug('NotificationContext', 'Duplicate event ignored at socket level', { eventId });
|
||||
console.warn('[NotificationContext] ⚠️ 重复事件,已忽略:', eventId);
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
return; // 重复事件,直接忽略
|
||||
return;
|
||||
}
|
||||
|
||||
// 记录已处理的事件ID
|
||||
processedEventIds.current.add(eventId);
|
||||
console.log('[NotificationContext] ✓ 事件已记录,防止重复处理');
|
||||
|
||||
// 限制Set大小,避免内存泄漏
|
||||
// 限制 Set 大小,避免内存泄漏
|
||||
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
|
||||
const idsArray = Array.from(processedEventIds.current);
|
||||
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
|
||||
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
|
||||
kept: MAX_PROCESSED_IDS
|
||||
kept: MAX_PROCESSED_IDS,
|
||||
});
|
||||
}
|
||||
// ========== Socket层去重检查结束 ==========
|
||||
|
||||
// 使用适配器转换事件格式
|
||||
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
|
||||
console.log('[NotificationContext] 正在转换事件格式...');
|
||||
const notification = adaptEventToNotification(data);
|
||||
const notification = adaptEventToNotificationRef.current(data);
|
||||
console.log('[NotificationContext] 转换后的通知对象:', notification);
|
||||
|
||||
// ✅ 使用 ref.current 访问最新的 addNotification 函数
|
||||
console.log('[NotificationContext] 准备添加通知到队列...');
|
||||
addNotification(notification);
|
||||
addNotificationRef.current(notification);
|
||||
console.log('[NotificationContext] ✅ 通知已添加到队列');
|
||||
console.log('%c════════════════════════════════════════\n', 'color: #FF9800; font-weight: bold;');
|
||||
});
|
||||
|
||||
// 保留系统通知监听(兼容性)
|
||||
// ========== 监听系统通知(兼容性) ==========
|
||||
socket.on('system_notification', (data) => {
|
||||
logger.info('NotificationContext', 'Received system notification', data);
|
||||
addNotification(data);
|
||||
console.log('[NotificationContext] 📢 收到系统通知:', data);
|
||||
|
||||
if (addNotificationRef.current) {
|
||||
addNotificationRef.current(data);
|
||||
} else {
|
||||
console.error('[NotificationContext] ❌ addNotificationRef 未初始化');
|
||||
}
|
||||
});
|
||||
|
||||
console.log('%c[NotificationContext] ✅ All event listeners registered', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('%c[NotificationContext] ✅ 所有监听器已注册(只注册一次)', 'color: #4CAF50; font-weight: bold;');
|
||||
|
||||
// ✅ 第二步: 获取最大重连次数
|
||||
// ========== 获取最大重连次数 ==========
|
||||
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
|
||||
setMaxReconnectAttempts(maxAttempts);
|
||||
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
|
||||
|
||||
// ✅ 第三步: 调用 socket.connect()
|
||||
console.log('%c[NotificationContext] Step 2: Calling socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
// ========== 启动连接 ==========
|
||||
console.log('%c[NotificationContext] 🔌 调用 socket.connect()...', 'color: #673AB7; font-weight: bold;');
|
||||
socket.connect();
|
||||
console.log('%c[NotificationContext] socket.connect() completed', 'color: #673AB7;');
|
||||
|
||||
// 清理函数
|
||||
// ========== 清理函数(组件卸载时) ==========
|
||||
return () => {
|
||||
logger.info('NotificationContext', 'Cleaning up socket connection');
|
||||
console.log('%c[NotificationContext] 🧹 清理 Socket 连接', 'color: #9E9E9E;');
|
||||
|
||||
// 清理 reconnected 状态定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
@@ -774,15 +808,20 @@ export const NotificationProvider = ({ children }) => {
|
||||
});
|
||||
notificationTimers.current.clear();
|
||||
|
||||
// 移除所有事件监听器
|
||||
socket.off('connect');
|
||||
socket.off('disconnect');
|
||||
socket.off('connect_error');
|
||||
socket.off('reconnect_failed');
|
||||
socket.off('new_event');
|
||||
socket.off('system_notification');
|
||||
|
||||
// 断开连接
|
||||
socket.disconnect();
|
||||
|
||||
console.log('%c[NotificationContext] ✅ 清理完成', 'color: #4CAF50;');
|
||||
};
|
||||
}, []); // ✅ 空依赖数组,确保只执行一次,避免 React 严格模式重复执行
|
||||
}, []); // ⚠️ 空依赖数组,确保只执行一次
|
||||
|
||||
// ==================== 智能自动重试 ====================
|
||||
|
||||
@@ -896,6 +935,92 @@ export const NotificationProvider = ({ children }) => {
|
||||
};
|
||||
}, [browserPermission, toast]);
|
||||
|
||||
// 🔧 开发环境调试:暴露方法到 window
|
||||
useEffect(() => {
|
||||
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_DEBUG === 'true') {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__TEST_NOTIFICATION__ = {
|
||||
// 手动触发网页通知
|
||||
testWebNotification: (type = 'event_alert', priority = 'normal') => {
|
||||
console.log('%c[Debug] 手动触发网页通知', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
const testData = {
|
||||
id: `test_${Date.now()}`,
|
||||
type: type,
|
||||
priority: priority,
|
||||
title: '🧪 测试网页通知',
|
||||
content: `这是一条测试${type === 'announcement' ? '公告' : type === 'stock_alert' ? '股票' : type === 'event_alert' ? '事件' : '分析'}通知 (优先级: ${priority})`,
|
||||
timestamp: Date.now(),
|
||||
clickable: true,
|
||||
link: '/home',
|
||||
};
|
||||
|
||||
console.log('测试数据:', testData);
|
||||
addNotification(testData);
|
||||
console.log('✅ 通知已添加到队列');
|
||||
},
|
||||
|
||||
// 测试所有类型
|
||||
testAllTypes: () => {
|
||||
console.log('%c[Debug] 测试所有通知类型', 'color: #FF9800; font-weight: bold;');
|
||||
const types = ['announcement', 'stock_alert', 'event_alert', 'analysis_report'];
|
||||
types.forEach((type, i) => {
|
||||
setTimeout(() => {
|
||||
window.__TEST_NOTIFICATION__.testWebNotification(type, 'normal');
|
||||
}, i * 2000); // 每 2 秒一个
|
||||
});
|
||||
},
|
||||
|
||||
// 测试所有优先级
|
||||
testAllPriorities: () => {
|
||||
console.log('%c[Debug] 测试所有优先级', 'color: #FF9800; font-weight: bold;');
|
||||
const priorities = ['normal', 'important', 'urgent'];
|
||||
priorities.forEach((priority, i) => {
|
||||
setTimeout(() => {
|
||||
window.__TEST_NOTIFICATION__.testWebNotification('event_alert', priority);
|
||||
}, i * 2000);
|
||||
});
|
||||
},
|
||||
|
||||
// 帮助
|
||||
help: () => {
|
||||
console.log('\n%c=== 网页通知测试 API ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
console.log('\n%c基础用法:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testWebNotification(type, priority)');
|
||||
console.log('\n%c参数说明:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' type (通知类型):');
|
||||
console.log(' - "announcement" 公告通知(蓝色)');
|
||||
console.log(' - "stock_alert" 股票动向(红色/绿色)');
|
||||
console.log(' - "event_alert" 事件动向(橙色)');
|
||||
console.log(' - "analysis_report" 分析报告(紫色)');
|
||||
console.log('\n priority (优先级):');
|
||||
console.log(' - "normal" 普通(15秒自动关闭)');
|
||||
console.log(' - "important" 重要(30秒自动关闭)');
|
||||
console.log(' - "urgent" 紧急(不自动关闭)');
|
||||
console.log('\n%c示例:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log(' // 测试紧急事件通知');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testWebNotification("event_alert", "urgent")');
|
||||
console.log('\n // 测试所有类型');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testAllTypes()');
|
||||
console.log('\n // 测试所有优先级');
|
||||
console.log(' window.__TEST_NOTIFICATION__.testAllPriorities()');
|
||||
console.log('\n');
|
||||
}
|
||||
};
|
||||
|
||||
console.log('[NotificationContext] 🔧 调试 API 已加载: window.__TEST_NOTIFICATION__');
|
||||
console.log('[NotificationContext] 💡 使用 window.__TEST_NOTIFICATION__.help() 查看帮助');
|
||||
}
|
||||
}
|
||||
|
||||
// 清理函数
|
||||
return () => {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
delete window.__TEST_NOTIFICATION__;
|
||||
}
|
||||
};
|
||||
}, [addNotification]); // 依赖 addNotification 函数
|
||||
|
||||
const value = {
|
||||
notifications,
|
||||
isConnected,
|
||||
|
||||
@@ -71,7 +71,10 @@ class DebugToolkit {
|
||||
console.log('');
|
||||
console.log('%c2️⃣ 通知调试:', 'color: #9C27B0; font-weight: bold;');
|
||||
console.log(' __DEBUG__.notification.getLogs() - 获取所有通知日志');
|
||||
console.log(' __DEBUG__.notification.forceNotification() - 发送测试通知');
|
||||
console.log(' __DEBUG__.notification.forceNotification() - 发送测试浏览器通知');
|
||||
console.log(' __DEBUG__.notification.testWebNotification(type, priority) - 测试网页通知 🆕');
|
||||
console.log(' __DEBUG__.notification.testAllNotificationTypes() - 测试所有类型 🆕');
|
||||
console.log(' __DEBUG__.notification.testAllNotificationPriorities() - 测试所有优先级 🆕');
|
||||
console.log(' __DEBUG__.notification.checkPermission() - 检查通知权限');
|
||||
console.log(' __DEBUG__.notification.exportLogs() - 导出通知日志');
|
||||
console.log('');
|
||||
|
||||
@@ -159,6 +159,44 @@ class NotificationDebugger {
|
||||
getRecentEvents(count = 10) {
|
||||
return this.eventLog.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试网页通知(需要 window.__TEST_NOTIFICATION__ 可用)
|
||||
*/
|
||||
testWebNotification(type = 'event_alert', priority = 'normal') {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
console.log('[Notification Debugger] 调用测试 API');
|
||||
window.__TEST_NOTIFICATION__.testWebNotification(type, priority);
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
console.error('💡 请确保:');
|
||||
console.error(' 1. REACT_APP_ENABLE_DEBUG=true');
|
||||
console.error(' 2. NotificationContext 已加载');
|
||||
console.error(' 3. 页面已刷新');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有通知类型
|
||||
*/
|
||||
testAllNotificationTypes() {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
window.__TEST_NOTIFICATION__.testAllTypes();
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试所有优先级
|
||||
*/
|
||||
testAllNotificationPriorities() {
|
||||
if (typeof window !== 'undefined' && window.__TEST_NOTIFICATION__) {
|
||||
window.__TEST_NOTIFICATION__.testAllPriorities();
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ window.__TEST_NOTIFICATION__ 不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
|
||||
50
src/index.js
50
src/index.js
@@ -44,24 +44,64 @@ function registerServiceWorker() {
|
||||
navigator.serviceWorker
|
||||
.register('/service-worker.js')
|
||||
.then((registration) => {
|
||||
console.log('[App] Service Worker registered successfully:', registration.scope);
|
||||
console.log('[App] ✅ Service Worker 注册成功');
|
||||
console.log('[App] Scope:', registration.scope);
|
||||
|
||||
// 监听更新
|
||||
// 检查当前激活状态
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log('[App] ✅ Service Worker 已激活并控制页面');
|
||||
} else {
|
||||
console.log('[App] ⏳ Service Worker 已注册,等待激活...');
|
||||
console.log('[App] 💡 刷新页面以激活 Service Worker');
|
||||
|
||||
// 监听 controller 变化(Service Worker 激活后触发)
|
||||
navigator.serviceWorker.addEventListener('controllerchange', () => {
|
||||
console.log('[App] ✅ Service Worker 控制器已更新');
|
||||
});
|
||||
}
|
||||
|
||||
// 监听 Service Worker 更新
|
||||
registration.addEventListener('updatefound', () => {
|
||||
const newWorker = registration.installing;
|
||||
console.log('[App] Service Worker update found');
|
||||
console.log('[App] 🔄 发现 Service Worker 更新');
|
||||
|
||||
if (newWorker) {
|
||||
newWorker.addEventListener('statechange', () => {
|
||||
console.log(`[App] Service Worker 状态: ${newWorker.state}`);
|
||||
if (newWorker.state === 'activated') {
|
||||
console.log('[App] Service Worker activated');
|
||||
console.log('[App] ✅ Service Worker 已激活');
|
||||
|
||||
// 如果有旧的 Service Worker 在控制页面,提示用户刷新
|
||||
if (navigator.serviceWorker.controller) {
|
||||
console.log('[App] 💡 Service Worker 已更新,建议刷新页面');
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('[App] Service Worker registration failed:', error);
|
||||
console.error('[App] ❌ Service Worker 注册失败');
|
||||
console.error('[App] 错误类型:', error.name);
|
||||
console.error('[App] 错误信息:', error.message);
|
||||
console.error('[App] 完整错误:', error);
|
||||
|
||||
// 额外检查:验证文件是否可访问
|
||||
fetch('/service-worker.js', { method: 'HEAD' })
|
||||
.then(response => {
|
||||
if (response.ok) {
|
||||
console.error('[App] Service Worker 文件存在但注册失败');
|
||||
console.error('[App] 💡 可能的原因:');
|
||||
console.error('[App] 1. Service Worker 文件有语法错误');
|
||||
console.error('[App] 2. 浏览器不支持某些 Service Worker 特性');
|
||||
console.error('[App] 3. HTTPS 证书问题(Service Worker 需要 HTTPS)');
|
||||
} else {
|
||||
console.error('[App] Service Worker 文件不存在(HTTP', response.status, ')');
|
||||
}
|
||||
})
|
||||
.catch(fetchError => {
|
||||
console.error('[App] 无法访问 Service Worker 文件:', fetchError.message);
|
||||
});
|
||||
});
|
||||
});
|
||||
} else {
|
||||
|
||||
@@ -10,6 +10,21 @@ import { socketService } from '../socketService';
|
||||
export const socket = socketService;
|
||||
export { socketService };
|
||||
|
||||
// ⚡ 新增:暴露 Socket 实例到 window(用于调试和验证)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.socket = socketService;
|
||||
window.socketService = socketService;
|
||||
|
||||
console.log(
|
||||
'%c[Socket Service] ✅ Socket instance exposed to window',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
console.log(' 📍 window.socket:', window.socket);
|
||||
console.log(' 📍 window.socketService:', window.socketService);
|
||||
console.log(' 📍 Socket.IO instance:', window.socket?.socket);
|
||||
console.log(' 📍 Connection status:', window.socket?.connected ? '✅ Connected' : '❌ Disconnected');
|
||||
}
|
||||
|
||||
// 打印当前使用的服务类型
|
||||
console.log(
|
||||
'%c[Socket Service] Using REAL Socket Service',
|
||||
|
||||
@@ -51,13 +51,21 @@ class SocketService {
|
||||
...options,
|
||||
});
|
||||
|
||||
// 注册所有暂存的事件监听器
|
||||
// 注册所有暂存的事件监听器(保留 pendingListeners,不清空)
|
||||
if (this.pendingListeners.length > 0) {
|
||||
console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
|
||||
this.pendingListeners.forEach(({ event, callback }) => {
|
||||
this.on(event, callback);
|
||||
// 直接在 Socket.IO 实例上注册(避免递归调用 this.on())
|
||||
const wrappedCallback = (...args) => {
|
||||
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
|
||||
console.log(`[socketService] 事件数据 (${event}):`, ...args);
|
||||
callback(...args);
|
||||
};
|
||||
|
||||
this.socket.on(event, wrappedCallback);
|
||||
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
|
||||
});
|
||||
this.pendingListeners = []; // 清空暂存队列
|
||||
// ⚠️ 重要:不清空 pendingListeners,保留用于重连
|
||||
}
|
||||
|
||||
// 监听连接成功
|
||||
@@ -157,10 +165,18 @@ class SocketService {
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.socket) {
|
||||
// Socket 未初始化,暂存监听器
|
||||
logger.info('socketService', 'Socket not ready, queuing listener', { event });
|
||||
console.log(`[socketService] 📦 Socket 未初始化,暂存事件监听器: ${event}`);
|
||||
this.pendingListeners.push({ event, callback });
|
||||
// Socket 未初始化,暂存监听器(检查是否已存在,避免重复)
|
||||
const exists = this.pendingListeners.some(
|
||||
(listener) => listener.event === event && listener.callback === callback
|
||||
);
|
||||
|
||||
if (!exists) {
|
||||
logger.info('socketService', 'Socket not ready, queuing listener', { event });
|
||||
console.log(`[socketService] 📦 Socket 未初始化,暂存事件监听器: ${event}`);
|
||||
this.pendingListeners.push({ event, callback });
|
||||
} else {
|
||||
console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user