Compare commits
8 Commits
feature_20
...
feature_bu
| Author | SHA1 | Date | |
|---|---|---|---|
| 1c49ddf42c | |||
| d64349b606 | |||
|
|
926ffa1b8f | ||
|
|
eebd207276 | ||
|
|
6b96744b2c | ||
|
|
463bdbf09c | ||
|
|
a15585c464 | ||
|
|
643c3db03e |
@@ -16,6 +16,15 @@ NODE_ENV=production
|
||||
# Mock 配置(生产环境禁用 Mock)
|
||||
REACT_APP_ENABLE_MOCK=false
|
||||
|
||||
# 🔧 调试模式(生产环境临时调试用)
|
||||
# 开启后会在全局暴露 window.__DEBUG__ 和 window.__TEST_NOTIFICATION__ 调试 API
|
||||
# ⚠️ 警告: 调试模式会记录所有 API 请求/响应,调试完成后请立即关闭!
|
||||
# 使用方法:
|
||||
# 1. 设置为 true 并重新构建
|
||||
# 2. 在浏览器控制台使用 window.__DEBUG__.help() 查看命令
|
||||
# 3. 调试完成后设置为 false 并重新构建
|
||||
REACT_APP_ENABLE_DEBUG=true
|
||||
|
||||
# 后端 API 地址(生产环境)
|
||||
REACT_APP_API_URL=http://49.232.185.254:5001
|
||||
|
||||
@@ -40,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
@@ -110,6 +110,9 @@ module.exports = {
|
||||
...webpackConfig.resolve,
|
||||
alias: {
|
||||
...webpackConfig.resolve.alias,
|
||||
// 强制 'debug' 模块解析到 node_modules(避免与 src/devtools/ 冲突)
|
||||
'debug': path.resolve(__dirname, 'node_modules/debug'),
|
||||
|
||||
// 根目录别名
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
|
||||
@@ -119,6 +122,7 @@ module.exports = {
|
||||
'@constants': path.resolve(__dirname, 'src/constants'),
|
||||
'@contexts': path.resolve(__dirname, 'src/contexts'),
|
||||
'@data': path.resolve(__dirname, 'src/data'),
|
||||
'@devtools': path.resolve(__dirname, 'src/devtools'),
|
||||
'@hooks': path.resolve(__dirname, 'src/hooks'),
|
||||
'@layouts': path.resolve(__dirname, 'src/layouts'),
|
||||
'@lib': path.resolve(__dirname, 'src/lib'),
|
||||
@@ -270,6 +274,27 @@ module.exports = {
|
||||
logLevel: 'debug',
|
||||
pathRewrite: { '^/bytedesk-api': '' },
|
||||
},
|
||||
'/chat': {
|
||||
target: 'http://43.143.189.195',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
// 不需要pathRewrite,保留/chat路径
|
||||
},
|
||||
'/config': {
|
||||
target: 'http://43.143.189.195',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
// 不需要pathRewrite,保留/config路径
|
||||
},
|
||||
'/visitor': {
|
||||
target: 'http://43.143.189.195',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
logLevel: 'debug',
|
||||
// 不需要pathRewrite,保留/visitor路径
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
@@ -48,16 +48,18 @@ npm start
|
||||
|
||||
### 3. 触发通知
|
||||
|
||||
**Mock 模式**(默认):
|
||||
- 等待 60 秒,会自动推送 1-2 条通知
|
||||
- 或在控制台执行:
|
||||
**测试通知**:
|
||||
- 使用调试 API 发送测试通知:
|
||||
```javascript
|
||||
import { mockSocketService } from './services/mockSocketService.js';
|
||||
mockSocketService.sendTestNotification();
|
||||
```
|
||||
// 方式1: 使用调试工具(推荐)
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '测试通知',
|
||||
body: '验证暗色模式下的通知样式'
|
||||
});
|
||||
|
||||
**Real 模式**:
|
||||
- 创建测试事件(运行后端测试脚本)
|
||||
// 方式2: 等待后端真实推送
|
||||
// 确保已连接后端,等待真实事件推送
|
||||
```
|
||||
|
||||
### 4. 验证效果
|
||||
|
||||
@@ -139,61 +141,46 @@ npm start
|
||||
|
||||
### 手动触发各类型通知
|
||||
|
||||
```javascript
|
||||
// 引入服务
|
||||
import { mockSocketService } from './services/mockSocketService.js';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from './constants/notificationTypes.js';
|
||||
> **注意**: Mock Socket 已移除,请使用调试工具或真实后端测试。
|
||||
|
||||
// 测试公告通知(蓝色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
```javascript
|
||||
// 使用调试工具测试不同类型的通知
|
||||
// 确保已开启调试模式:REACT_APP_ENABLE_DEBUG=true
|
||||
|
||||
// 测试公告通知
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '测试公告通知',
|
||||
content: '这是暗色模式下的蓝色通知',
|
||||
timestamp: Date.now(),
|
||||
body: '这是暗色模式下的蓝色通知',
|
||||
tag: 'test_announcement',
|
||||
autoClose: 0,
|
||||
});
|
||||
|
||||
// 测试股票上涨(红色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.URGENT,
|
||||
title: '测试股票上涨',
|
||||
content: '宁德时代 +5.2%',
|
||||
extra: { priceChange: '+5.2%' },
|
||||
timestamp: Date.now(),
|
||||
autoClose: 0,
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '🔴 测试股票上涨',
|
||||
body: '宁德时代 +5.2%',
|
||||
tag: 'test_stock_up',
|
||||
});
|
||||
|
||||
// 测试股票下跌(绿色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '测试股票下跌',
|
||||
content: '比亚迪 -3.8%',
|
||||
extra: { priceChange: '-3.8%' },
|
||||
timestamp: Date.now(),
|
||||
autoClose: 0,
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '🟢 测试股票下跌',
|
||||
body: '比亚迪 -3.8%',
|
||||
tag: 'test_stock_down',
|
||||
});
|
||||
|
||||
// 测试事件动向(橙色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '测试事件动向',
|
||||
content: '央行宣布降准',
|
||||
timestamp: Date.now(),
|
||||
autoClose: 0,
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '🟠 测试事件动向',
|
||||
body: '央行宣布降准',
|
||||
tag: 'test_event',
|
||||
});
|
||||
|
||||
// 测试分析报告(紫色)
|
||||
mockSocketService.sendTestNotification({
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '测试分析报告',
|
||||
content: '医药行业深度报告',
|
||||
timestamp: Date.now(),
|
||||
autoClose: 0,
|
||||
window.__DEBUG__.notification.forceNotification({
|
||||
title: '🟣 测试分析报告',
|
||||
body: '医药行业深度报告',
|
||||
tag: 'test_report',
|
||||
});
|
||||
```
|
||||
|
||||
|
||||
@@ -330,13 +330,14 @@ if (Notification.permission === 'granted') {
|
||||
|
||||
### 关键文件
|
||||
|
||||
- `src/services/mockSocketService.js` - Mock Socket 服务
|
||||
- `src/services/socketService.js` - 真实 Socket.IO 服务
|
||||
- `src/services/socket/index.js` - 统一导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文(含适配器)
|
||||
- `src/services/socketService.js` - Socket.IO 服务
|
||||
- `src/services/socket/index.js` - Socket 服务导出
|
||||
- `src/contexts/NotificationContext.js` - 通知上下文
|
||||
- `src/hooks/useEventNotifications.js` - React Hook
|
||||
- `src/views/Community/components/EventList.js` - 事件列表集成
|
||||
|
||||
> **注意**: `mockSocketService.js` 已移除(2025-01-10),现仅使用真实 Socket 连接。
|
||||
|
||||
### 数据流
|
||||
|
||||
```
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
# 实时消息推送系统 - 完整技术文档
|
||||
|
||||
> **版本**: v2.11.0
|
||||
> **更新日期**: 2025-01-07
|
||||
> **更新日期**: 2025-01-10
|
||||
> **文档类型**: 快速入门 + 完整技术规格
|
||||
>
|
||||
> ⚠️ **重要更新**: Mock Socket 已移除(2025-01-10),文档中关于 `mockSocketService` 的内容仅供历史参考。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@
|
||||
if (difyChatButton) {
|
||||
// 只在 /home 页面显示
|
||||
if (currentPath === '/home') {
|
||||
difyChatButton.style.display = 'none';
|
||||
difyChatButton.style.display = 'flex';
|
||||
console.log('[Dify] 显示机器人(当前路径: /home)');
|
||||
} else {
|
||||
difyChatButton.style.display = 'none';
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
/**
|
||||
* Service Worker for Browser Notifications
|
||||
* 主要功能:支持浏览器通知的稳定运行
|
||||
*
|
||||
* 注意:此 Service Worker 仅用于通知功能,不拦截任何 HTTP 请求
|
||||
*/
|
||||
|
||||
const CACHE_NAME = 'valuefrontier-v1';
|
||||
|
||||
// Service Worker 安装事件
|
||||
self.addEventListener('install', (event) => {
|
||||
console.log('[Service Worker] Installing...');
|
||||
@@ -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);
|
||||
@@ -56,18 +56,6 @@ self.addEventListener('notificationclose', (event) => {
|
||||
console.log('[Service Worker] Notification closed:', event.notification.tag);
|
||||
});
|
||||
|
||||
// Fetch 事件 - 基础的网络优先策略
|
||||
self.addEventListener('fetch', (event) => {
|
||||
// 对于通知相关的资源,使用网络优先策略
|
||||
event.respondWith(
|
||||
fetch(event.request)
|
||||
.catch(() => {
|
||||
// 网络失败时,尝试从缓存获取
|
||||
return caches.match(event.request);
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
// 推送消息事件(预留,用于未来的 Push API 集成)
|
||||
self.addEventListener('push', (event) => {
|
||||
console.log('[Service Worker] Push message received:', event);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -21,8 +21,6 @@ export const bytedeskConfig = {
|
||||
apiUrl: BYTEDESK_API_URL,
|
||||
// 聊天页面地址
|
||||
htmlUrl: `${BYTEDESK_API_URL}/chat/`,
|
||||
// SDK 资源基础路径(用于加载内部模块 sdk.js, index.js 等)
|
||||
baseUrl: 'https://www.weiyuai.cn',
|
||||
|
||||
// 客服图标位置
|
||||
placement: 'bottom-right', // bottom-right | bottom-left | top-right | top-left
|
||||
@@ -66,7 +64,16 @@ export const bytedeskConfig = {
|
||||
* @returns {Object} Bytedesk配置对象
|
||||
*/
|
||||
export const getBytedeskConfig = () => {
|
||||
// 所有环境都使用公网地址(不使用代理)
|
||||
// 开发环境使用代理(绕过 X-Frame-Options 限制)
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
return {
|
||||
...bytedeskConfig,
|
||||
apiUrl: '/bytedesk-api', // 使用 CRACO 代理路径
|
||||
htmlUrl: '/bytedesk-api/chat/', // 使用 CRACO 代理路径
|
||||
};
|
||||
}
|
||||
|
||||
// 生产环境使用完整 URL
|
||||
return bytedeskConfig;
|
||||
};
|
||||
|
||||
@@ -113,7 +120,7 @@ export const getBytedeskConfigWithUser = (user) => {
|
||||
export const shouldShowCustomerService = (pathname) => {
|
||||
// 在以下页面隐藏客服(黑名单)
|
||||
const blockedPages = [
|
||||
// '/home', // 登录页
|
||||
'/home', // 登录页
|
||||
];
|
||||
|
||||
// 检查是否在黑名单
|
||||
|
||||
@@ -26,7 +26,6 @@ import {
|
||||
} from '@chakra-ui/react';
|
||||
import { MdNotifications, MdClose, MdVolumeOff, MdVolumeUp, MdCampaign, MdTrendingUp, MdArticle, MdAssessment, MdWarning } from 'react-icons/md';
|
||||
import { useNotification } from '../../contexts/NotificationContext';
|
||||
import { SOCKET_TYPE } from '../../services/socket';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../../constants/notificationTypes';
|
||||
|
||||
const NotificationTestTool = () => {
|
||||
@@ -295,7 +294,7 @@ const NotificationTestTool = () => {
|
||||
{isConnected ? 'Connected' : 'Disconnected'}
|
||||
</Badge>
|
||||
<Badge colorScheme="purple">
|
||||
{SOCKET_TYPE}
|
||||
REAL
|
||||
</Badge>
|
||||
<Badge colorScheme={getPermissionColor()}>
|
||||
浏览器: {getPermissionLabel()}
|
||||
|
||||
@@ -58,7 +58,9 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
// 创建超时控制器
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), 5000); // 5秒超时
|
||||
const timeoutId = setTimeout(() => {
|
||||
controller.abort(new Error('Session check timeout after 5 seconds'));
|
||||
}, 5000); // 5秒超时
|
||||
|
||||
const response = await fetch(`/api/auth/session`, {
|
||||
method: 'GET',
|
||||
@@ -96,8 +98,18 @@ export const AuthProvider = ({ children }) => {
|
||||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('AuthContext', 'checkSession', error);
|
||||
// 网络错误或超时,设置为未登录状态
|
||||
// ✅ 区分AbortError和真实错误
|
||||
if (error.name === 'AbortError') {
|
||||
logger.debug('AuthContext', 'Session check aborted', {
|
||||
reason: error.message || 'Request cancelled',
|
||||
isTimeout: error.message?.includes('timeout')
|
||||
});
|
||||
// AbortError不改变登录状态(保持原状态)
|
||||
return;
|
||||
}
|
||||
|
||||
// 只有真实错误才标记为未登录
|
||||
logger.error('AuthContext', 'checkSession failed', error);
|
||||
setUser((prev) => prev === null ? prev : null);
|
||||
setIsAuthenticated((prev) => prev === false ? prev : false);
|
||||
} finally {
|
||||
@@ -108,7 +120,16 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
// ⚡ 初始化时检查Session - 并行执行,不阻塞页面渲染
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
// 传递signal给checkSession(需要修改checkSession签名)
|
||||
// 暂时使用原有方式,但添加cleanup防止组件卸载时的内存泄漏
|
||||
checkSession(); // 直接调用,与页面渲染并行
|
||||
|
||||
// ✅ Cleanup: 组件卸载时abort可能正在进行的请求
|
||||
return () => {
|
||||
controller.abort(new Error('AuthProvider unmounted'));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -2,20 +2,15 @@
|
||||
/**
|
||||
* 通知上下文 - 管理实时消息推送和通知显示
|
||||
*
|
||||
* 环境说明:
|
||||
* - SOCKET_TYPE === 'REAL': 使用真实 Socket.IO 连接(生产环境),连接到 wss://valuefrontier.cn
|
||||
* - SOCKET_TYPE === 'MOCK': 使用模拟 Socket 服务(开发环境),用于本地测试
|
||||
*
|
||||
* 环境切换:
|
||||
* - 设置 REACT_APP_ENABLE_MOCK=true 或 REACT_APP_USE_MOCK_SOCKET=true 使用 MOCK 模式
|
||||
* - 否则使用 REAL 模式连接生产环境
|
||||
* 使用真实 Socket.IO 连接到后端服务器
|
||||
* 连接地址配置在环境变量中 (REACT_APP_API_URL)
|
||||
*/
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
|
||||
import { BellIcon } from '@chakra-ui/icons';
|
||||
import { logger } from '../utils/logger';
|
||||
import socket, { SOCKET_TYPE } from '../services/socket';
|
||||
import socket from '../services/socket';
|
||||
import notificationSound from '../assets/sounds/notification.wav';
|
||||
import { browserNotificationService } from '../services/browserNotificationService';
|
||||
import { notificationMetricsService } from '../services/notificationMetricsService';
|
||||
@@ -62,6 +57,12 @@ export const NotificationProvider = ({ children }) => {
|
||||
const reconnectedTimerRef = useRef(null); // 用于自动消失 RECONNECTED 状态
|
||||
const processedEventIds = useRef(new Set()); // 用于Socket层去重,记录已处理的事件ID
|
||||
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();
|
||||
@@ -71,9 +72,20 @@ export const NotificationProvider = ({ children }) => {
|
||||
try {
|
||||
audioRef.current = new Audio(notificationSound);
|
||||
audioRef.current.volume = 0.5;
|
||||
logger.info('NotificationContext', 'Audio initialized');
|
||||
} catch (error) {
|
||||
logger.error('NotificationContext', 'Audio initialization failed', error);
|
||||
}
|
||||
|
||||
// 清理函数:释放音频资源
|
||||
return () => {
|
||||
if (audioRef.current) {
|
||||
audioRef.current.pause();
|
||||
audioRef.current.src = '';
|
||||
audioRef.current = null;
|
||||
logger.info('NotificationContext', 'Audio resources cleaned up');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
@@ -104,6 +116,13 @@ export const NotificationProvider = ({ children }) => {
|
||||
const removeNotification = useCallback((id, wasClicked = false) => {
|
||||
logger.info('NotificationContext', 'Removing notification', { id, wasClicked });
|
||||
|
||||
// 清理对应的定时器
|
||||
if (notificationTimers.current.has(id)) {
|
||||
clearTimeout(notificationTimers.current.get(id));
|
||||
notificationTimers.current.delete(id);
|
||||
logger.info('NotificationContext', 'Cleared auto-close timer', { id });
|
||||
}
|
||||
|
||||
// 监控埋点:追踪关闭(非点击的情况)
|
||||
setNotifications(prev => {
|
||||
const notification = prev.find(n => n.id === id);
|
||||
@@ -119,6 +138,14 @@ export const NotificationProvider = ({ children }) => {
|
||||
*/
|
||||
const clearAllNotifications = useCallback(() => {
|
||||
logger.info('NotificationContext', 'Clearing all notifications');
|
||||
|
||||
// 清理所有定时器
|
||||
notificationTimers.current.forEach((timerId, id) => {
|
||||
clearTimeout(timerId);
|
||||
logger.info('NotificationContext', 'Cleared timer during clear all', { id });
|
||||
});
|
||||
notificationTimers.current.clear();
|
||||
|
||||
setNotifications([]);
|
||||
}, []);
|
||||
|
||||
@@ -446,9 +473,16 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
// 自动关闭
|
||||
if (newNotification.autoClose && newNotification.autoClose > 0) {
|
||||
setTimeout(() => {
|
||||
const timerId = setTimeout(() => {
|
||||
removeNotification(newNotification.id);
|
||||
}, newNotification.autoClose);
|
||||
|
||||
// 将定时器ID保存到Map中
|
||||
notificationTimers.current.set(newNotification.id, timerId);
|
||||
logger.info('NotificationContext', 'Set auto-close timer', {
|
||||
id: newNotification.id,
|
||||
delay: newNotification.autoClose
|
||||
});
|
||||
}
|
||||
}, [playNotificationSound, removeNotification]);
|
||||
|
||||
@@ -548,34 +582,11 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
const isPageHidden = document.hidden; // 页面是否在后台
|
||||
|
||||
// ========== 原分发策略(按优先级区分)- 已废弃 ==========
|
||||
// 策略 1: 紧急通知 - 双重保障(浏览器 + 网页)
|
||||
// if (priority === PRIORITY_LEVELS.URGENT) {
|
||||
// logger.info('NotificationContext', 'Urgent notification: sending browser + web');
|
||||
// // 总是发送浏览器通知
|
||||
// sendBrowserNotification(newNotification);
|
||||
// // 如果在前台,也显示网页通知
|
||||
// if (!isPageHidden) {
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
// }
|
||||
// 策略 2: 重要通知 - 智能切换(后台=浏览器,前台=网页)
|
||||
// else if (priority === PRIORITY_LEVELS.IMPORTANT) {
|
||||
// if (isPageHidden) {
|
||||
// logger.info('NotificationContext', 'Important notification (background): sending browser');
|
||||
// sendBrowserNotification(newNotification);
|
||||
// } else {
|
||||
// logger.info('NotificationContext', 'Important notification (foreground): sending web');
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
// }
|
||||
// 策略 3: 普通通知 - 仅网页通知
|
||||
// else {
|
||||
// logger.info('NotificationContext', 'Normal notification: sending web only');
|
||||
// addWebNotification(newNotification);
|
||||
// }
|
||||
|
||||
// ========== 新分发策略(仅区分前后台) ==========
|
||||
// ========== 通知分发策略(区分前后台) ==========
|
||||
// 策略: 根据页面可见性智能分发通知
|
||||
// - 页面在后台: 发送浏览器通知(系统级提醒)
|
||||
// - 页面在前台: 发送网页通知(页面内 Toast)
|
||||
// 注: 不再区分优先级,统一使用前后台策略
|
||||
if (isPageHidden) {
|
||||
// 页面在后台:发送浏览器通知
|
||||
logger.info('NotificationContext', 'Page hidden: sending browser notification');
|
||||
@@ -589,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 (type: ${SOCKET_TYPE})`, '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) {
|
||||
@@ -620,69 +647,61 @@ export const NotificationProvider = ({ children }) => {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
|
||||
}, 2000);
|
||||
} else {
|
||||
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
|
||||
}
|
||||
|
||||
// 如果使用 mock,可以启动定期推送
|
||||
if (SOCKET_TYPE === 'MOCK') {
|
||||
// 启动模拟推送:使用配置的间隔和数量
|
||||
const { interval, maxBatch } = NOTIFICATION_CONFIG.mockPush;
|
||||
socket.startMockPush(interval, maxBatch);
|
||||
logger.info('NotificationContext', 'Mock push started', { interval, maxBatch });
|
||||
} else {
|
||||
// ✅ 真实模式下,订阅事件推送
|
||||
console.log('%c[NotificationContext] 🔔 订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
// ⚡ 重连后只需重新订阅,不需要重新注册监听器
|
||||
console.log('%c[NotificationContext] 🔔 重新订阅事件推送...', 'color: #FF9800; font-weight: bold;');
|
||||
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onSubscribed: (data) => {
|
||||
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] 订阅确认:', data);
|
||||
logger.info('NotificationContext', 'Events subscribed', data);
|
||||
},
|
||||
// ⚠️ 不需要 onNewEvent 回调,因为 NotificationContext 已经通过 socket.on('new_event') 监听
|
||||
});
|
||||
} else {
|
||||
console.warn('[NotificationContext] ⚠️ socket.subscribeToEvents 方法不可用');
|
||||
}
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onSubscribed: (data) => {
|
||||
console.log('%c[NotificationContext] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log('[NotificationContext] 订阅确认:', data);
|
||||
logger.info('NotificationContext', 'Events subscribed', data);
|
||||
},
|
||||
});
|
||||
} 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);
|
||||
|
||||
// 获取重连次数(Real 和 Mock 都支持)
|
||||
const attempts = socket.getReconnectAttempts?.() || 0;
|
||||
setReconnectAttempt(attempts);
|
||||
logger.info('NotificationContext', 'Reconnection attempt', { attempts, socketType: SOCKET_TYPE });
|
||||
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;');
|
||||
@@ -695,77 +714,114 @@ export const NotificationProvider = ({ children }) => {
|
||||
|
||||
logger.info('NotificationContext', 'Received new event', data);
|
||||
|
||||
// ⚠️ 防御性检查:确保 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}_${data.publishTime}`;
|
||||
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
|
||||
|
||||
if (!data.id) {
|
||||
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
|
||||
eventId,
|
||||
eventType: data.type,
|
||||
title: data.title,
|
||||
});
|
||||
}
|
||||
|
||||
if (processedEventIds.current.has(eventId)) {
|
||||
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;');
|
||||
|
||||
// 如果是 mock service,停止推送
|
||||
if (SOCKET_TYPE === 'MOCK') {
|
||||
socket.stopMockPush();
|
||||
// 清理 reconnected 状态定时器
|
||||
if (reconnectedTimerRef.current) {
|
||||
clearTimeout(reconnectedTimerRef.current);
|
||||
reconnectedTimerRef.current = null;
|
||||
}
|
||||
|
||||
// 清理所有通知的自动关闭定时器
|
||||
notificationTimers.current.forEach((timerId, id) => {
|
||||
clearTimeout(timerId);
|
||||
logger.info('NotificationContext', 'Cleared timer during cleanup', { id });
|
||||
});
|
||||
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 严格模式重复执行
|
||||
}, []); // ⚠️ 空依赖数组,确保只执行一次
|
||||
|
||||
// ==================== 智能自动重试 ====================
|
||||
|
||||
@@ -776,11 +832,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'visible' && !isConnected && connectionStatus === CONNECTION_STATUS.FAILED) {
|
||||
logger.info('NotificationContext', 'Tab refocused, attempting auto-reconnect');
|
||||
if (SOCKET_TYPE === 'REAL') {
|
||||
socket.reconnect?.();
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
socket.reconnect?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -806,11 +858,7 @@ export const NotificationProvider = ({ children }) => {
|
||||
isClosable: true,
|
||||
});
|
||||
|
||||
if (SOCKET_TYPE === 'REAL') {
|
||||
socket.reconnect?.();
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
socket.reconnect?.();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -842,14 +890,137 @@ export const NotificationProvider = ({ children }) => {
|
||||
const retryConnection = useCallback(() => {
|
||||
logger.info('NotificationContext', 'Manual reconnection triggered');
|
||||
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
|
||||
|
||||
if (SOCKET_TYPE === 'REAL') {
|
||||
socket.reconnect?.();
|
||||
} else {
|
||||
socket.connect();
|
||||
}
|
||||
socket.reconnect?.();
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 同步浏览器通知权限状态
|
||||
* 场景:
|
||||
* 1. 用户在其他标签页授权后返回
|
||||
* 2. 用户在浏览器设置中修改权限
|
||||
* 3. 页面长时间打开后权限状态变化
|
||||
*/
|
||||
useEffect(() => {
|
||||
const checkPermission = () => {
|
||||
const current = browserNotificationService.getPermissionStatus();
|
||||
if (current !== browserPermission) {
|
||||
logger.info('NotificationContext', 'Browser permission changed', {
|
||||
old: browserPermission,
|
||||
new: current
|
||||
});
|
||||
setBrowserPermission(current);
|
||||
|
||||
// 如果权限被授予,显示成功提示
|
||||
if (current === 'granted' && browserPermission !== 'granted') {
|
||||
toast({
|
||||
title: '桌面通知已开启',
|
||||
description: '您现在可以在后台接收重要通知',
|
||||
status: 'success',
|
||||
duration: 3000,
|
||||
isClosable: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 页面聚焦时检查
|
||||
window.addEventListener('focus', checkPermission);
|
||||
|
||||
// 定期检查(可选,用于捕获浏览器设置中的变化)
|
||||
const intervalId = setInterval(checkPermission, 30000); // 每30秒检查一次
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('focus', checkPermission);
|
||||
clearInterval(intervalId);
|
||||
};
|
||||
}, [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,
|
||||
|
||||
253
src/devtools/apiDebugger.js
Normal file
253
src/devtools/apiDebugger.js
Normal file
@@ -0,0 +1,253 @@
|
||||
// src/debug/apiDebugger.js
|
||||
/**
|
||||
* API 调试工具
|
||||
* 生产环境临时调试使用,后期可整体删除 src/debug/ 目录
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import { getApiBase } from '@utils/apiConfig';
|
||||
|
||||
class ApiDebugger {
|
||||
constructor() {
|
||||
this.requestLog = [];
|
||||
this.maxLogSize = 100;
|
||||
this.isLogging = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化 Axios 拦截器
|
||||
*/
|
||||
init() {
|
||||
// 请求拦截器
|
||||
axios.interceptors.request.use(
|
||||
(config) => {
|
||||
if (this.isLogging) {
|
||||
const logEntry = {
|
||||
type: 'request',
|
||||
timestamp: new Date().toISOString(),
|
||||
method: config.method.toUpperCase(),
|
||||
url: config.url,
|
||||
baseURL: config.baseURL,
|
||||
fullURL: this._getFullURL(config),
|
||||
headers: config.headers,
|
||||
data: config.data,
|
||||
params: config.params,
|
||||
};
|
||||
|
||||
this._addLog(logEntry);
|
||||
|
||||
console.log(
|
||||
`%c[API Request] ${logEntry.method} ${logEntry.fullURL}`,
|
||||
'color: #2196F3; font-weight: bold;',
|
||||
{
|
||||
headers: config.headers,
|
||||
data: config.data,
|
||||
params: config.params,
|
||||
}
|
||||
);
|
||||
}
|
||||
return config;
|
||||
},
|
||||
(error) => {
|
||||
console.error('[API Request Error]', error);
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
// 响应拦截器
|
||||
axios.interceptors.response.use(
|
||||
(response) => {
|
||||
if (this.isLogging) {
|
||||
const logEntry = {
|
||||
type: 'response',
|
||||
timestamp: new Date().toISOString(),
|
||||
method: response.config.method.toUpperCase(),
|
||||
url: response.config.url,
|
||||
fullURL: this._getFullURL(response.config),
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
headers: response.headers,
|
||||
data: response.data,
|
||||
};
|
||||
|
||||
this._addLog(logEntry);
|
||||
|
||||
console.log(
|
||||
`%c[API Response] ${logEntry.method} ${logEntry.fullURL} - ${logEntry.status}`,
|
||||
'color: #4CAF50; font-weight: bold;',
|
||||
{
|
||||
status: response.status,
|
||||
data: response.data,
|
||||
headers: response.headers,
|
||||
}
|
||||
);
|
||||
}
|
||||
return response;
|
||||
},
|
||||
(error) => {
|
||||
if (this.isLogging) {
|
||||
const logEntry = {
|
||||
type: 'error',
|
||||
timestamp: new Date().toISOString(),
|
||||
method: error.config?.method?.toUpperCase() || 'UNKNOWN',
|
||||
url: error.config?.url || 'UNKNOWN',
|
||||
fullURL: error.config ? this._getFullURL(error.config) : 'UNKNOWN',
|
||||
status: error.response?.status,
|
||||
statusText: error.response?.statusText,
|
||||
message: error.message,
|
||||
data: error.response?.data,
|
||||
};
|
||||
|
||||
this._addLog(logEntry);
|
||||
|
||||
console.error(
|
||||
`%c[API Error] ${logEntry.method} ${logEntry.fullURL}`,
|
||||
'color: #F44336; font-weight: bold;',
|
||||
{
|
||||
status: error.response?.status,
|
||||
message: error.message,
|
||||
data: error.response?.data,
|
||||
}
|
||||
);
|
||||
}
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
console.log('%c[API Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取完整 URL
|
||||
*/
|
||||
_getFullURL(config) {
|
||||
const baseURL = config.baseURL || '';
|
||||
const url = config.url || '';
|
||||
const fullURL = baseURL + url;
|
||||
|
||||
// 添加查询参数
|
||||
if (config.params) {
|
||||
const params = new URLSearchParams(config.params).toString();
|
||||
return params ? `${fullURL}?${params}` : fullURL;
|
||||
}
|
||||
|
||||
return fullURL;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加日志
|
||||
*/
|
||||
_addLog(entry) {
|
||||
this.requestLog.unshift(entry);
|
||||
if (this.requestLog.length > this.maxLogSize) {
|
||||
this.requestLog = this.requestLog.slice(0, this.maxLogSize);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有日志
|
||||
*/
|
||||
getLogs(type = 'all') {
|
||||
if (type === 'all') {
|
||||
return this.requestLog;
|
||||
}
|
||||
return this.requestLog.filter((log) => log.type === type);
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.requestLog = [];
|
||||
console.log('[API Debugger] Logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志为 JSON
|
||||
*/
|
||||
exportLogs() {
|
||||
const blob = new Blob([JSON.stringify(this.requestLog, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `api-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[API Debugger] Logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印日志统计
|
||||
*/
|
||||
printStats() {
|
||||
const stats = {
|
||||
total: this.requestLog.length,
|
||||
requests: this.requestLog.filter((log) => log.type === 'request').length,
|
||||
responses: this.requestLog.filter((log) => log.type === 'response').length,
|
||||
errors: this.requestLog.filter((log) => log.type === 'error').length,
|
||||
};
|
||||
|
||||
console.table(stats);
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动发送 API 请求(测试用)
|
||||
*/
|
||||
async testRequest(method, endpoint, data = null, config = {}) {
|
||||
const apiBase = getApiBase();
|
||||
const url = `${apiBase}${endpoint}`;
|
||||
|
||||
console.log(`[API Debugger] Testing ${method.toUpperCase()} ${url}`);
|
||||
|
||||
try {
|
||||
const response = await axios({
|
||||
method,
|
||||
url,
|
||||
data,
|
||||
...config,
|
||||
});
|
||||
|
||||
console.log('[API Debugger] Test succeeded:', response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
console.error('[API Debugger] Test failed:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 开启/关闭日志记录
|
||||
*/
|
||||
toggleLogging(enabled) {
|
||||
this.isLogging = enabled;
|
||||
console.log(`[API Debugger] Logging ${enabled ? 'enabled' : 'disabled'}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的错误
|
||||
*/
|
||||
getRecentErrors(count = 10) {
|
||||
return this.requestLog.filter((log) => log.type === 'error').slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 按 URL 过滤日志
|
||||
*/
|
||||
getLogsByURL(urlPattern) {
|
||||
return this.requestLog.filter((log) => log.url && log.url.includes(urlPattern));
|
||||
}
|
||||
|
||||
/**
|
||||
* 按状态码过滤日志
|
||||
*/
|
||||
getLogsByStatus(status) {
|
||||
return this.requestLog.filter((log) => log.status === status);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const apiDebugger = new ApiDebugger();
|
||||
export default apiDebugger;
|
||||
271
src/devtools/index.js
Normal file
271
src/devtools/index.js
Normal file
@@ -0,0 +1,271 @@
|
||||
// src/debug/index.js
|
||||
/**
|
||||
* 调试工具统一入口
|
||||
*
|
||||
* 使用方法:
|
||||
* 1. 开启调试: 在 .env.production 中设置 REACT_APP_ENABLE_DEBUG=true
|
||||
* 2. 使用控制台命令: window.__DEBUG__.api.getLogs()
|
||||
* 3. 后期移除: 删除整个 src/debug/ 目录 + 从 src/index.js 移除导入
|
||||
*
|
||||
* 全局 API:
|
||||
* - window.__DEBUG__ - 调试 API 主对象
|
||||
* - window.__DEBUG__.api - API 调试工具
|
||||
* - window.__DEBUG__.notification - 通知调试工具
|
||||
* - window.__DEBUG__.socket - Socket 调试工具
|
||||
* - window.__DEBUG__.help() - 显示帮助信息
|
||||
* - window.__DEBUG__.exportAll() - 导出所有日志
|
||||
*/
|
||||
|
||||
import { apiDebugger } from './apiDebugger';
|
||||
import { notificationDebugger } from './notificationDebugger';
|
||||
import { socketDebugger } from './socketDebugger';
|
||||
|
||||
class DebugToolkit {
|
||||
constructor() {
|
||||
this.api = apiDebugger;
|
||||
this.notification = notificationDebugger;
|
||||
this.socket = socketDebugger;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化所有调试工具
|
||||
*/
|
||||
init() {
|
||||
console.log(
|
||||
'%c╔════════════════════════════════════════════════════════════════╗',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%c║ 🔧 调试模式已启用 (Debug Mode Enabled) ║',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%c╚════════════════════════════════════════════════════════════════╝',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
console.log('');
|
||||
|
||||
// 初始化各个调试工具
|
||||
this.api.init();
|
||||
this.notification.init();
|
||||
this.socket.init();
|
||||
|
||||
// 暴露到全局
|
||||
window.__DEBUG__ = this;
|
||||
|
||||
// 打印帮助信息
|
||||
this._printWelcome();
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印欢迎信息
|
||||
*/
|
||||
_printWelcome() {
|
||||
console.log('%c📚 调试工具使用指南:', 'color: #2196F3; font-weight: bold; font-size: 14px;');
|
||||
console.log('');
|
||||
console.log('%c1️⃣ API 调试:', 'color: #2196F3; font-weight: bold;');
|
||||
console.log(' __DEBUG__.api.getLogs() - 获取所有 API 日志');
|
||||
console.log(' __DEBUG__.api.getRecentErrors() - 获取最近的错误');
|
||||
console.log(' __DEBUG__.api.exportLogs() - 导出 API 日志');
|
||||
console.log(' __DEBUG__.api.testRequest(method, endpoint, data) - 测试 API 请求');
|
||||
console.log('');
|
||||
console.log('%c2️⃣ 通知调试:', 'color: #9C27B0; font-weight: bold;');
|
||||
console.log(' __DEBUG__.notification.getLogs() - 获取所有通知日志');
|
||||
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('');
|
||||
console.log('%c3️⃣ Socket 调试:', 'color: #00BCD4; font-weight: bold;');
|
||||
console.log(' __DEBUG__.socket.getLogs() - 获取所有 Socket 日志');
|
||||
console.log(' __DEBUG__.socket.getStatus() - 获取连接状态');
|
||||
console.log(' __DEBUG__.socket.reconnect() - 手动重连');
|
||||
console.log(' __DEBUG__.socket.exportLogs() - 导出 Socket 日志');
|
||||
console.log('');
|
||||
console.log('%c4️⃣ 通用命令:', 'color: #4CAF50; font-weight: bold;');
|
||||
console.log(' __DEBUG__.help() - 显示帮助信息');
|
||||
console.log(' __DEBUG__.exportAll() - 导出所有日志');
|
||||
console.log(' __DEBUG__.printStats() - 打印所有统计信息');
|
||||
console.log(' __DEBUG__.clearAll() - 清空所有日志');
|
||||
console.log('');
|
||||
console.log(
|
||||
'%c⚠️ 警告: 调试模式会记录所有 API 请求和响应,请勿在生产环境长期开启!',
|
||||
'color: #F44336; font-weight: bold;'
|
||||
);
|
||||
console.log('');
|
||||
}
|
||||
|
||||
/**
|
||||
* 显示帮助信息
|
||||
*/
|
||||
help() {
|
||||
this._printWelcome();
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出所有日志
|
||||
*/
|
||||
exportAll() {
|
||||
console.log('[Debug Toolkit] Exporting all logs...');
|
||||
|
||||
const allLogs = {
|
||||
timestamp: new Date().toISOString(),
|
||||
api: this.api.getLogs(),
|
||||
notification: this.notification.getLogs(),
|
||||
socket: this.socket.getLogs(),
|
||||
};
|
||||
|
||||
const blob = new Blob([JSON.stringify(allLogs, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `debug-all-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('[Debug Toolkit] ✅ All logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印所有统计信息
|
||||
*/
|
||||
printStats() {
|
||||
console.log('\n%c=== 📊 调试统计信息 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
console.log('\n%c[API 统计]', 'color: #2196F3; font-weight: bold;');
|
||||
const apiStats = this.api.printStats();
|
||||
|
||||
console.log('\n%c[通知统计]', 'color: #9C27B0; font-weight: bold;');
|
||||
const notificationStats = this.notification.printStats();
|
||||
|
||||
console.log('\n%c[Socket 统计]', 'color: #00BCD4; font-weight: bold;');
|
||||
const socketStats = this.socket.printStats();
|
||||
|
||||
return {
|
||||
api: apiStats,
|
||||
notification: notificationStats,
|
||||
socket: socketStats,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空所有日志
|
||||
*/
|
||||
clearAll() {
|
||||
console.log('[Debug Toolkit] Clearing all logs...');
|
||||
this.api.clearLogs();
|
||||
this.notification.clearLogs();
|
||||
this.socket.clearLogs();
|
||||
console.log('[Debug Toolkit] ✅ All logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速诊断(检查所有系统状态)
|
||||
*/
|
||||
diagnose() {
|
||||
console.log('\n%c=== 🔍 系统诊断 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
|
||||
// 1. Socket 状态
|
||||
console.log('\n%c[1/3] Socket 状态', 'color: #00BCD4; font-weight: bold;');
|
||||
const socketStatus = this.socket.getStatus();
|
||||
|
||||
// 2. 通知权限
|
||||
console.log('\n%c[2/3] 通知权限', 'color: #9C27B0; font-weight: bold;');
|
||||
const notificationStatus = this.notification.checkPermission();
|
||||
|
||||
// 3. API 错误
|
||||
console.log('\n%c[3/3] 最近的 API 错误', 'color: #F44336; font-weight: bold;');
|
||||
const recentErrors = this.api.getRecentErrors(5);
|
||||
if (recentErrors.length > 0) {
|
||||
console.table(
|
||||
recentErrors.map((err) => ({
|
||||
时间: err.timestamp,
|
||||
方法: err.method,
|
||||
URL: err.url,
|
||||
状态码: err.status,
|
||||
错误信息: err.message,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
console.log('✅ 没有 API 错误');
|
||||
}
|
||||
|
||||
// 4. 汇总报告
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
socket: socketStatus,
|
||||
notification: notificationStatus,
|
||||
apiErrors: recentErrors.length,
|
||||
};
|
||||
|
||||
console.log('\n%c=== 诊断报告 ===', 'color: #4CAF50; font-weight: bold;');
|
||||
console.table(report);
|
||||
|
||||
return report;
|
||||
}
|
||||
|
||||
/**
|
||||
* 性能监控
|
||||
*/
|
||||
performance() {
|
||||
console.log('\n%c=== ⚡ 性能监控 ===', 'color: #FF9800; font-weight: bold; font-size: 16px;');
|
||||
|
||||
// 计算 API 平均响应时间
|
||||
const apiLogs = this.api.getLogs();
|
||||
const responseTimes = [];
|
||||
|
||||
for (let i = 0; i < apiLogs.length - 1; i++) {
|
||||
const log = apiLogs[i];
|
||||
const prevLog = apiLogs[i + 1];
|
||||
|
||||
if (
|
||||
log.type === 'response' &&
|
||||
prevLog.type === 'request' &&
|
||||
log.url === prevLog.url
|
||||
) {
|
||||
const responseTime =
|
||||
new Date(log.timestamp).getTime() - new Date(prevLog.timestamp).getTime();
|
||||
responseTimes.push({
|
||||
url: log.url,
|
||||
method: log.method,
|
||||
time: responseTime,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (responseTimes.length > 0) {
|
||||
const avgTime =
|
||||
responseTimes.reduce((sum, item) => sum + item.time, 0) / responseTimes.length;
|
||||
const maxTime = Math.max(...responseTimes.map((item) => item.time));
|
||||
const minTime = Math.min(...responseTimes.map((item) => item.time));
|
||||
|
||||
console.log('API 响应时间统计:');
|
||||
console.table({
|
||||
平均响应时间: `${avgTime.toFixed(2)}ms`,
|
||||
最快响应: `${minTime}ms`,
|
||||
最慢响应: `${maxTime}ms`,
|
||||
请求总数: responseTimes.length,
|
||||
});
|
||||
|
||||
// 显示最慢的 5 个请求
|
||||
console.log('\n最慢的 5 个请求:');
|
||||
const slowest = responseTimes.sort((a, b) => b.time - a.time).slice(0, 5);
|
||||
console.table(
|
||||
slowest.map((item) => ({
|
||||
方法: item.method,
|
||||
URL: item.url,
|
||||
响应时间: `${item.time}ms`,
|
||||
}))
|
||||
);
|
||||
} else {
|
||||
console.log('暂无性能数据');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const debugToolkit = new DebugToolkit();
|
||||
export default debugToolkit;
|
||||
204
src/devtools/notificationDebugger.js
Normal file
204
src/devtools/notificationDebugger.js
Normal file
@@ -0,0 +1,204 @@
|
||||
// src/debug/notificationDebugger.js
|
||||
/**
|
||||
* 通知系统调试工具
|
||||
* 扩展现有的 window.__NOTIFY_DEBUG__,添加更多生产环境调试能力
|
||||
*/
|
||||
|
||||
import { browserNotificationService } from '@services/browserNotificationService';
|
||||
|
||||
class NotificationDebugger {
|
||||
constructor() {
|
||||
this.eventLog = [];
|
||||
this.maxLogSize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化调试工具
|
||||
*/
|
||||
init() {
|
||||
console.log('%c[Notification Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录通知事件
|
||||
*/
|
||||
logEvent(eventType, data) {
|
||||
const logEntry = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
this.eventLog.unshift(logEntry);
|
||||
if (this.eventLog.length > this.maxLogSize) {
|
||||
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`%c[Notification Event] ${eventType}`,
|
||||
'color: #9C27B0; font-weight: bold;',
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件日志
|
||||
*/
|
||||
getLogs() {
|
||||
return this.eventLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.eventLog = [];
|
||||
console.log('[Notification Debugger] Logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志
|
||||
*/
|
||||
exportLogs() {
|
||||
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `notification-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[Notification Debugger] Logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 强制发送浏览器通知(测试用)
|
||||
*/
|
||||
forceNotification(options = {}) {
|
||||
const defaultOptions = {
|
||||
title: '🧪 测试通知',
|
||||
body: `测试时间: ${new Date().toLocaleString()}`,
|
||||
tag: `test_${Date.now()}`,
|
||||
requireInteraction: false,
|
||||
autoClose: 5000,
|
||||
};
|
||||
|
||||
const finalOptions = { ...defaultOptions, ...options };
|
||||
|
||||
console.log('[Notification Debugger] Sending test notification:', finalOptions);
|
||||
|
||||
const notification = browserNotificationService.sendNotification(finalOptions);
|
||||
|
||||
if (notification) {
|
||||
console.log('[Notification Debugger] ✅ Notification sent successfully');
|
||||
} else {
|
||||
console.error('[Notification Debugger] ❌ Failed to send notification');
|
||||
}
|
||||
|
||||
return notification;
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查通知权限状态
|
||||
*/
|
||||
checkPermission() {
|
||||
const permission = browserNotificationService.getPermissionStatus();
|
||||
const isSupported = browserNotificationService.isSupported();
|
||||
|
||||
const status = {
|
||||
supported: isSupported,
|
||||
permission,
|
||||
canSend: isSupported && permission === 'granted',
|
||||
};
|
||||
|
||||
console.table(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求通知权限
|
||||
*/
|
||||
async requestPermission() {
|
||||
console.log('[Notification Debugger] Requesting notification permission...');
|
||||
const result = await browserNotificationService.requestPermission();
|
||||
console.log(`[Notification Debugger] Permission result: ${result}`);
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印事件统计
|
||||
*/
|
||||
printStats() {
|
||||
const stats = {
|
||||
total: this.eventLog.length,
|
||||
byType: {},
|
||||
};
|
||||
|
||||
this.eventLog.forEach((log) => {
|
||||
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log('=== Notification Stats ===');
|
||||
console.table(stats.byType);
|
||||
console.log(`Total events: ${stats.total}`);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型过滤日志
|
||||
*/
|
||||
getLogsByType(eventType) {
|
||||
return this.eventLog.filter((log) => log.type === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的事件
|
||||
*/
|
||||
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__ 不可用');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const notificationDebugger = new NotificationDebugger();
|
||||
export default notificationDebugger;
|
||||
194
src/devtools/socketDebugger.js
Normal file
194
src/devtools/socketDebugger.js
Normal file
@@ -0,0 +1,194 @@
|
||||
// src/debug/socketDebugger.js
|
||||
/**
|
||||
* Socket 调试工具
|
||||
* 扩展现有的 window.__SOCKET_DEBUG__,添加更多生产环境调试能力
|
||||
*/
|
||||
|
||||
import { socket } from '@services/socket';
|
||||
|
||||
class SocketDebugger {
|
||||
constructor() {
|
||||
this.eventLog = [];
|
||||
this.maxLogSize = 100;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化调试工具
|
||||
*/
|
||||
init() {
|
||||
// 监听所有 Socket 事件
|
||||
this._attachEventListeners();
|
||||
console.log('%c[Socket Debugger] Initialized', 'color: #FF9800; font-weight: bold;');
|
||||
}
|
||||
|
||||
/**
|
||||
* 附加事件监听器
|
||||
*/
|
||||
_attachEventListeners() {
|
||||
const events = [
|
||||
'connect',
|
||||
'disconnect',
|
||||
'connect_error',
|
||||
'reconnect',
|
||||
'reconnect_failed',
|
||||
'new_event',
|
||||
'system_notification',
|
||||
];
|
||||
|
||||
events.forEach((event) => {
|
||||
socket.on(event, (data) => {
|
||||
this.logEvent(event, data);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记录 Socket 事件
|
||||
*/
|
||||
logEvent(eventType, data) {
|
||||
const logEntry = {
|
||||
type: eventType,
|
||||
timestamp: new Date().toISOString(),
|
||||
data,
|
||||
};
|
||||
|
||||
this.eventLog.unshift(logEntry);
|
||||
if (this.eventLog.length > this.maxLogSize) {
|
||||
this.eventLog = this.eventLog.slice(0, this.maxLogSize);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`%c[Socket Event] ${eventType}`,
|
||||
'color: #00BCD4; font-weight: bold;',
|
||||
data
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有事件日志
|
||||
*/
|
||||
getLogs() {
|
||||
return this.eventLog;
|
||||
}
|
||||
|
||||
/**
|
||||
* 清空日志
|
||||
*/
|
||||
clearLogs() {
|
||||
this.eventLog = [];
|
||||
console.log('[Socket Debugger] Logs cleared');
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出日志
|
||||
*/
|
||||
exportLogs() {
|
||||
const blob = new Blob([JSON.stringify(this.eventLog, null, 2)], {
|
||||
type: 'application/json',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `socket-logs-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
console.log('[Socket Debugger] Logs exported');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
getStatus() {
|
||||
const status = {
|
||||
connected: socket.connected || false,
|
||||
type: window.SOCKET_TYPE || 'UNKNOWN',
|
||||
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
|
||||
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
|
||||
};
|
||||
|
||||
console.table(status);
|
||||
return status;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发连接
|
||||
*/
|
||||
connect() {
|
||||
console.log('[Socket Debugger] Manually connecting...');
|
||||
socket.connect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动断开连接
|
||||
*/
|
||||
disconnect() {
|
||||
console.log('[Socket Debugger] Manually disconnecting...');
|
||||
socket.disconnect();
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重连
|
||||
*/
|
||||
reconnect() {
|
||||
console.log('[Socket Debugger] Manually reconnecting...');
|
||||
socket.disconnect();
|
||||
setTimeout(() => {
|
||||
socket.connect();
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送测试事件
|
||||
*/
|
||||
emitTest(eventName, data = {}) {
|
||||
console.log(`[Socket Debugger] Emitting test event: ${eventName}`, data);
|
||||
socket.emit(eventName, data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 打印事件统计
|
||||
*/
|
||||
printStats() {
|
||||
const stats = {
|
||||
total: this.eventLog.length,
|
||||
byType: {},
|
||||
};
|
||||
|
||||
this.eventLog.forEach((log) => {
|
||||
stats.byType[log.type] = (stats.byType[log.type] || 0) + 1;
|
||||
});
|
||||
|
||||
console.log('=== Socket Stats ===');
|
||||
console.table(stats.byType);
|
||||
console.log(`Total events: ${stats.total}`);
|
||||
|
||||
return stats;
|
||||
}
|
||||
|
||||
/**
|
||||
* 按类型过滤日志
|
||||
*/
|
||||
getLogsByType(eventType) {
|
||||
return this.eventLog.filter((log) => log.type === eventType);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的事件
|
||||
*/
|
||||
getRecentEvents(count = 10) {
|
||||
return this.eventLog.slice(0, count);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取错误事件
|
||||
*/
|
||||
getErrors() {
|
||||
return this.eventLog.filter(
|
||||
(log) => log.type === 'connect_error' || log.type === 'reconnect_failed'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const socketDebugger = new SocketDebugger();
|
||||
export default socketDebugger;
|
||||
63
src/index.js
63
src/index.js
@@ -13,6 +13,19 @@ import App from './App';
|
||||
import { browserNotificationService } from './services/browserNotificationService';
|
||||
window.browserNotificationService = browserNotificationService;
|
||||
|
||||
// 🔧 条件导入调试工具(生产环境可选)
|
||||
// 开启方式: 在 .env 文件中设置 REACT_APP_ENABLE_DEBUG=true
|
||||
// 移除方式: 删除此段代码 + 删除 src/devtools/ 目录
|
||||
if (process.env.REACT_APP_ENABLE_DEBUG === 'true') {
|
||||
import('./devtools').then(({ debugToolkit }) => {
|
||||
debugToolkit.init();
|
||||
console.log(
|
||||
'%c✅ 调试工具已加载!使用 window.__DEBUG__.help() 查看命令',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 14px;'
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// 注册 Service Worker(用于支持浏览器通知)
|
||||
function registerServiceWorker() {
|
||||
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
|
||||
@@ -31,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 {
|
||||
|
||||
@@ -1,916 +0,0 @@
|
||||
// src/services/mockSocketService.js
|
||||
/**
|
||||
* Mock Socket 服务 - 用于开发环境模拟实时推送
|
||||
* 模拟金融资讯、事件动向、分析报告等实时消息推送
|
||||
*/
|
||||
|
||||
import { logger } from '../utils/logger';
|
||||
import { NOTIFICATION_TYPES, PRIORITY_LEVELS } from '../constants/notificationTypes';
|
||||
|
||||
// 模拟金融资讯数据
|
||||
const mockFinancialNews = [
|
||||
// ========== 公告通知 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '贵州茅台发布2024年度财报公告',
|
||||
content: '2024年度营收同比增长15.2%,净利润创历史新高,董事会建议每10股派息180元',
|
||||
publishTime: new Date('2024-03-28T15:30:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/ann001',
|
||||
extra: {
|
||||
announcementType: '财报',
|
||||
companyCode: '600519',
|
||||
companyName: '贵州茅台',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.URGENT,
|
||||
title: '宁德时代发布重大资产重组公告',
|
||||
content: '公司拟收购某新能源材料公司100%股权,交易金额约120亿元,预计增厚业绩20%',
|
||||
publishTime: new Date('2024-03-28T09:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/ann002',
|
||||
extra: {
|
||||
announcementType: '重组',
|
||||
companyCode: '300750',
|
||||
companyName: '宁德时代',
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANNOUNCEMENT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '中国平安发布分红派息公告',
|
||||
content: '2023年度利润分配方案:每10股派发现金红利23.0元(含税),分红率达30.5%',
|
||||
publishTime: new Date('2024-03-27T16:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/ann003',
|
||||
extra: {
|
||||
announcementType: '分红',
|
||||
companyCode: '601318',
|
||||
companyName: '中国平安',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
|
||||
// ========== 股票动向 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.URGENT,
|
||||
title: '您关注的股票触发预警',
|
||||
content: '宁德时代(300750) 当前价格 ¥245.50,盘中涨幅达 +5.2%,已触达您设置的目标价位',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/stock-overview?code=300750',
|
||||
extra: {
|
||||
stockCode: '300750',
|
||||
stockName: '宁德时代',
|
||||
priceChange: '+5.2%',
|
||||
currentPrice: '245.50',
|
||||
triggerType: '目标价',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '您关注的股票异常波动',
|
||||
content: '比亚迪(002594) 5分钟内跌幅达 -3.8%,当前价格 ¥198.20,建议关注',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/stock-overview?code=002594',
|
||||
extra: {
|
||||
stockCode: '002594',
|
||||
stockName: '比亚迪',
|
||||
priceChange: '-3.8%',
|
||||
currentPrice: '198.20',
|
||||
triggerType: '异常波动',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.STOCK_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '持仓股票表现',
|
||||
content: '隆基绿能(601012) 今日表现优异,涨幅 +4.5%,您当前持仓浮盈 +¥8,200',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/trading-simulation',
|
||||
extra: {
|
||||
stockCode: '601012',
|
||||
stockName: '隆基绿能',
|
||||
priceChange: '+4.5%',
|
||||
profit: '+8200',
|
||||
},
|
||||
autoClose: 8000,
|
||||
},
|
||||
|
||||
// ========== 事件动向 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '央行宣布降准0.5个百分点',
|
||||
content: '中国人民银行宣布下调金融机构存款准备金率0.5个百分点,释放长期资金约1万亿元,利好股市',
|
||||
publishTime: new Date('2024-03-28T09:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/evt001',
|
||||
extra: {
|
||||
eventId: 'evt001',
|
||||
relatedStocks: 12,
|
||||
impactLevel: '重大利好',
|
||||
sectors: ['银行', '地产', '基建'],
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '新能源汽车补贴政策延期',
|
||||
content: '财政部宣布新能源汽车购置补贴政策延长至2024年底,涉及比亚迪、理想汽车等5家龙头企业',
|
||||
publishTime: new Date('2024-03-28T10:30:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/evt002',
|
||||
extra: {
|
||||
eventId: 'evt002',
|
||||
relatedStocks: 5,
|
||||
impactLevel: '重大利好',
|
||||
sectors: ['新能源汽车'],
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '芯片产业扶持政策出台',
|
||||
content: '工信部发布《半导体产业发展指导意见》,未来三年投入500亿专项资金支持芯片研发',
|
||||
publishTime: new Date('2024-03-27T14:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/event-detail/evt003',
|
||||
extra: {
|
||||
eventId: 'evt003',
|
||||
relatedStocks: 8,
|
||||
impactLevel: '中长期利好',
|
||||
sectors: ['半导体', '芯片设计'],
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
|
||||
// ========== 预测通知 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【预测】央行可能宣布降准政策',
|
||||
content: '基于最新宏观数据分析,预计央行将在本周宣布降准0.5个百分点,释放长期资金',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: true,
|
||||
clickable: false, // ❌ 不可点击
|
||||
link: null,
|
||||
extra: {
|
||||
isPrediction: true,
|
||||
statusHint: '详细报告生成中...',
|
||||
relatedPredictionId: 'pred_001',
|
||||
},
|
||||
autoClose: 15000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.EVENT_ALERT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '【预测】新能源补贴政策或将延期',
|
||||
content: '根据政策趋势分析,财政部可能宣布新能源汽车购置补贴政策延长至2025年底',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
isAIGenerated: true,
|
||||
clickable: false, // ❌ 不可点击
|
||||
link: null,
|
||||
extra: {
|
||||
isPrediction: true,
|
||||
statusHint: '详细报告生成中...',
|
||||
relatedPredictionId: 'pred_002',
|
||||
},
|
||||
autoClose: 15000,
|
||||
},
|
||||
|
||||
// ========== 分析报告 ==========
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: '医药行业深度报告:创新药迎来政策拐点',
|
||||
content: 'CXO板块持续受益于全球创新药研发外包需求,建议关注药明康德、凯莱英等龙头企业',
|
||||
publishTime: new Date('2024-03-28T08:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: '李明',
|
||||
organization: '中信证券',
|
||||
},
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=rpt001',
|
||||
extra: {
|
||||
reportType: '行业研报',
|
||||
industry: '医药',
|
||||
rating: '强烈推荐',
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.IMPORTANT,
|
||||
title: 'AI产业链投资机会分析',
|
||||
content: '随着大模型应用加速落地,算力、数据、应用三大方向均存在投资机会,重点关注海光信息、寒武纪',
|
||||
publishTime: new Date('2024-03-28T07:30:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: '王芳',
|
||||
organization: '招商证券',
|
||||
},
|
||||
isAIGenerated: true,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=rpt002',
|
||||
extra: {
|
||||
reportType: '策略报告',
|
||||
industry: '人工智能',
|
||||
rating: '推荐',
|
||||
},
|
||||
autoClose: 12000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '比亚迪:新能源汽车龙头业绩持续超预期',
|
||||
content: '2024年销量目标400万辆,海外市场拓展顺利,维持"买入"评级,目标价280元',
|
||||
publishTime: new Date('2024-03-27T09:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: '张伟',
|
||||
organization: '国泰君安',
|
||||
},
|
||||
isAIGenerated: false,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=rpt003',
|
||||
extra: {
|
||||
reportType: '公司研报',
|
||||
industry: '新能源汽车',
|
||||
rating: '买入',
|
||||
targetPrice: '280',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
{
|
||||
type: NOTIFICATION_TYPES.ANALYSIS_REPORT,
|
||||
priority: PRIORITY_LEVELS.NORMAL,
|
||||
title: '2024年A股市场展望:结构性行情延续',
|
||||
content: 'AI应用、高端制造、自主可控三大主线贯穿全年,建议关注科技成长板块配置机会',
|
||||
publishTime: new Date('2024-03-26T16:00:00').getTime(),
|
||||
pushTime: Date.now(),
|
||||
author: {
|
||||
name: 'AI分析师',
|
||||
organization: '价值前沿',
|
||||
},
|
||||
isAIGenerated: true,
|
||||
clickable: true,
|
||||
link: '/forecast-report?id=rpt004',
|
||||
extra: {
|
||||
reportType: '策略报告',
|
||||
industry: '市场策略',
|
||||
rating: '谨慎乐观',
|
||||
},
|
||||
autoClose: 10000,
|
||||
},
|
||||
];
|
||||
|
||||
class MockSocketService {
|
||||
constructor() {
|
||||
this.connected = false;
|
||||
this.connecting = false; // 新增:正在连接标志,防止重复连接
|
||||
this.listeners = new Map();
|
||||
this.intervals = [];
|
||||
this.messageQueue = [];
|
||||
this.reconnectAttempts = 0;
|
||||
this.customReconnectTimer = null;
|
||||
this.failConnection = false; // 是否模拟连接失败
|
||||
this.pushPaused = false; // 新增:暂停推送标志(保持连接)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算指数退避延迟(Mock 模式使用更短的时间便于测试)
|
||||
* 第1次: 10秒, 第2次: 20秒, 第3次: 40秒, 第4次及以后: 40秒
|
||||
*/
|
||||
getReconnectionDelay(attempt) {
|
||||
const delays = [10000, 20000, 40000]; // 10s, 20s, 40s (缩短10倍便于测试)
|
||||
const index = Math.min(attempt - 1, delays.length - 1);
|
||||
return delays[index];
|
||||
}
|
||||
|
||||
/**
|
||||
* 连接到 mock socket
|
||||
*/
|
||||
connect() {
|
||||
// ✅ 防止重复连接
|
||||
if (this.connected) {
|
||||
logger.warn('mockSocketService', 'Already connected');
|
||||
console.log('%c[Mock Socket] Already connected, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.connecting) {
|
||||
logger.warn('mockSocketService', 'Connection in progress');
|
||||
console.log('%c[Mock Socket] Connection already in progress, skipping', 'color: #FF9800; font-weight: bold;');
|
||||
return;
|
||||
}
|
||||
|
||||
this.connecting = true; // 标记为连接中
|
||||
logger.info('mockSocketService', 'Connecting to mock socket service...');
|
||||
console.log('%c[Mock Socket] 🔌 Connecting...', 'color: #2196F3; font-weight: bold;');
|
||||
|
||||
// 模拟连接延迟
|
||||
setTimeout(() => {
|
||||
// 检查是否应该模拟连接失败
|
||||
if (this.failConnection) {
|
||||
this.connecting = false; // 清除连接中标志
|
||||
logger.warn('mockSocketService', 'Simulated connection failure');
|
||||
console.log('%c[Mock Socket] ❌ Connection failed (simulated)', 'color: #F44336; font-weight: bold;');
|
||||
|
||||
// 触发连接错误事件
|
||||
this.emit('connect_error', {
|
||||
message: 'Mock connection error for testing',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
// 安排下次重连(会继续失败,直到 failConnection 被清除)
|
||||
this.scheduleReconnection();
|
||||
return;
|
||||
}
|
||||
|
||||
// 正常连接成功
|
||||
this.connected = true;
|
||||
this.connecting = false; // 清除连接中标志
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 清除自定义重连定时器
|
||||
if (this.customReconnectTimer) {
|
||||
clearTimeout(this.customReconnectTimer);
|
||||
this.customReconnectTimer = null;
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Mock socket connected successfully');
|
||||
console.log('%c[Mock Socket] ✅ Connected successfully!', 'color: #4CAF50; font-weight: bold; font-size: 14px;');
|
||||
console.log(`%c[Mock Socket] Status: connected=${this.connected}, connecting=${this.connecting}`, 'color: #4CAF50;');
|
||||
|
||||
// ✅ 使用 setTimeout(0) 确保监听器已注册后再触发事件
|
||||
setTimeout(() => {
|
||||
console.log('%c[Mock Socket] Emitting connect event...', 'color: #9C27B0;');
|
||||
this.emit('connect', { timestamp: Date.now() });
|
||||
console.log('%c[Mock Socket] Connect event emitted', 'color: #9C27B0;');
|
||||
}, 0);
|
||||
|
||||
// 在连接后3秒发送欢迎消息
|
||||
setTimeout(() => {
|
||||
if (this.connected) {
|
||||
this.emit('new_event', {
|
||||
type: 'system_notification',
|
||||
severity: 'info',
|
||||
title: '连接成功',
|
||||
message: '实时消息推送服务已启动 (Mock 模式)',
|
||||
timestamp: Date.now(),
|
||||
autoClose: 5000,
|
||||
});
|
||||
}
|
||||
}, 3000);
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* 断开连接
|
||||
* @param {boolean} triggerReconnect - 是否触发自动重连(模拟意外断开)
|
||||
*/
|
||||
disconnect(triggerReconnect = false) {
|
||||
if (!this.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Disconnecting from mock socket service...');
|
||||
|
||||
// 清除所有定时器
|
||||
this.intervals.forEach(interval => clearInterval(interval));
|
||||
this.intervals = [];
|
||||
this.pushPaused = false; // 重置暂停状态
|
||||
|
||||
const wasConnected = this.connected;
|
||||
this.connected = false;
|
||||
this.emit('disconnect', {
|
||||
timestamp: Date.now(),
|
||||
reason: triggerReconnect ? 'transport close' : 'io client disconnect'
|
||||
});
|
||||
|
||||
// 如果需要触发重连(模拟意外断开)
|
||||
if (triggerReconnect && wasConnected) {
|
||||
this.scheduleReconnection();
|
||||
} else {
|
||||
// 清除重连定时器
|
||||
if (this.customReconnectTimer) {
|
||||
clearTimeout(this.customReconnectTimer);
|
||||
this.customReconnectTimer = null;
|
||||
}
|
||||
this.reconnectAttempts = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用指数退避策略安排重连
|
||||
*/
|
||||
scheduleReconnection() {
|
||||
// 清除之前的定时器
|
||||
if (this.customReconnectTimer) {
|
||||
clearTimeout(this.customReconnectTimer);
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = this.getReconnectionDelay(this.reconnectAttempts);
|
||||
logger.info('mockSocketService', `Scheduling reconnection in ${delay / 1000}s (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
// 触发 connect_error 事件通知UI
|
||||
this.emit('connect_error', {
|
||||
message: 'Mock connection error for testing',
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
this.customReconnectTimer = setTimeout(() => {
|
||||
if (!this.connected) {
|
||||
logger.info('mockSocketService', 'Attempting reconnection...', {
|
||||
attempt: this.reconnectAttempts,
|
||||
});
|
||||
this.connect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动重连
|
||||
* @returns {boolean} 是否触发重连
|
||||
*/
|
||||
reconnect() {
|
||||
if (this.connected) {
|
||||
logger.info('mockSocketService', 'Already connected, no need to reconnect');
|
||||
return false;
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', 'Manually triggering reconnection...');
|
||||
|
||||
// 清除自动重连定时器
|
||||
if (this.customReconnectTimer) {
|
||||
clearTimeout(this.customReconnectTimer);
|
||||
this.customReconnectTimer = null;
|
||||
}
|
||||
|
||||
// 重置重连计数
|
||||
this.reconnectAttempts = 0;
|
||||
|
||||
// 立即触发重连
|
||||
this.connect();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟意外断线(测试用)
|
||||
* @param {number} duration - 断线持续时间(毫秒),0表示需要手动重连
|
||||
*/
|
||||
simulateDisconnection(duration = 0) {
|
||||
logger.info('mockSocketService', `Simulating disconnection${duration > 0 ? ` for ${duration}ms` : ' (manual reconnect required)'}...`);
|
||||
|
||||
if (duration > 0) {
|
||||
// 短暂断线,自动重连
|
||||
this.disconnect(true);
|
||||
} else {
|
||||
// 需要手动重连
|
||||
this.disconnect(false);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟持续连接失败(测试用)
|
||||
* 连接会一直失败,直到调用 allowReconnection()
|
||||
*/
|
||||
simulateConnectionFailure() {
|
||||
logger.info('mockSocketService', '🚫 Simulating persistent connection failure...');
|
||||
logger.info('mockSocketService', 'Connection will keep failing until allowReconnection() is called');
|
||||
|
||||
// 设置失败标志
|
||||
this.failConnection = true;
|
||||
|
||||
// 如果当前已连接,先断开并触发重连(会失败)
|
||||
if (this.connected) {
|
||||
this.disconnect(true);
|
||||
} else {
|
||||
// 如果未连接,直接触发一次连接尝试(会失败)
|
||||
this.connect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 允许重连成功(测试用)
|
||||
* 清除连接失败标志,下次重连将会成功
|
||||
*/
|
||||
allowReconnection() {
|
||||
logger.info('mockSocketService', '✅ Allowing reconnection to succeed...');
|
||||
logger.info('mockSocketService', 'Next reconnection attempt will succeed');
|
||||
|
||||
// 清除失败标志
|
||||
this.failConnection = false;
|
||||
|
||||
// 不立即重连,等待自动重连或手动重连
|
||||
}
|
||||
|
||||
/**
|
||||
* 监听事件
|
||||
* @param {string} event - 事件名称
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
this.listeners.set(event, []);
|
||||
}
|
||||
this.listeners.get(event).push(callback);
|
||||
|
||||
logger.info('mockSocketService', `Event listener added: ${event}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除事件监听
|
||||
* @param {string} event - 事件名称
|
||||
* @param {Function} callback - 回调函数
|
||||
*/
|
||||
off(event, callback) {
|
||||
if (!this.listeners.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbacks = this.listeners.get(event);
|
||||
const index = callbacks.indexOf(callback);
|
||||
|
||||
if (index !== -1) {
|
||||
callbacks.splice(index, 1);
|
||||
logger.info('mockSocketService', `Event listener removed: ${event}`);
|
||||
}
|
||||
|
||||
// 如果没有监听器了,删除该事件
|
||||
if (callbacks.length === 0) {
|
||||
this.listeners.delete(event);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 触发事件
|
||||
* @param {string} event - 事件名称
|
||||
* @param {*} data - 事件数据
|
||||
*/
|
||||
emit(event, data) {
|
||||
if (!this.listeners.has(event)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const callbacks = this.listeners.get(event);
|
||||
callbacks.forEach(callback => {
|
||||
try {
|
||||
callback(data);
|
||||
} catch (error) {
|
||||
logger.error('mockSocketService', 'emit', error, { event, data });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动模拟消息推送
|
||||
* @param {number} interval - 推送间隔(毫秒)
|
||||
* @param {number} burstCount - 每次推送的消息数量(1-3条)
|
||||
*/
|
||||
startMockPush(interval = 15000, burstCount = 1) {
|
||||
if (!this.connected) {
|
||||
logger.warn('mockSocketService', 'Cannot start mock push: not connected');
|
||||
return;
|
||||
}
|
||||
|
||||
logger.info('mockSocketService', `Starting mock push: interval=${interval}ms, burst=${burstCount}`);
|
||||
|
||||
const pushInterval = setInterval(() => {
|
||||
// 检查是否暂停推送
|
||||
if (this.pushPaused) {
|
||||
logger.info('mockSocketService', '⏸️ Mock push is paused, skipping this cycle...');
|
||||
return;
|
||||
}
|
||||
|
||||
// 随机选择 1-burstCount 条消息
|
||||
const count = Math.floor(Math.random() * burstCount) + 1;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// 从模拟数据中随机选择一条
|
||||
const randomIndex = Math.floor(Math.random() * mockFinancialNews.length);
|
||||
const alert = {
|
||||
...mockFinancialNews[randomIndex],
|
||||
timestamp: Date.now(),
|
||||
id: `mock_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
||||
};
|
||||
|
||||
// 延迟发送(模拟层叠效果)
|
||||
setTimeout(() => {
|
||||
this.emit('new_event', alert);
|
||||
logger.info('mockSocketService', 'Mock notification sent', alert);
|
||||
}, i * 500); // 每条消息间隔500ms
|
||||
}
|
||||
}, interval);
|
||||
|
||||
this.intervals.push(pushInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 停止模拟推送
|
||||
*/
|
||||
stopMockPush() {
|
||||
this.intervals.forEach(interval => clearInterval(interval));
|
||||
this.intervals = [];
|
||||
this.pushPaused = false; // 重置暂停状态
|
||||
logger.info('mockSocketService', 'Mock push stopped');
|
||||
}
|
||||
|
||||
/**
|
||||
* 暂停自动推送(保持连接和定时器运行)
|
||||
*/
|
||||
pausePush() {
|
||||
this.pushPaused = true;
|
||||
logger.info('mockSocketService', '⏸️ Mock push paused (connection and intervals maintained)');
|
||||
}
|
||||
|
||||
/**
|
||||
* 恢复自动推送
|
||||
*/
|
||||
resumePush() {
|
||||
this.pushPaused = false;
|
||||
logger.info('mockSocketService', '▶️ Mock push resumed');
|
||||
}
|
||||
|
||||
/**
|
||||
* 查询推送暂停状态
|
||||
* @returns {boolean} 是否已暂停
|
||||
*/
|
||||
isPushPaused() {
|
||||
return this.pushPaused;
|
||||
}
|
||||
|
||||
/**
|
||||
* 手动触发一条测试消息
|
||||
* @param {object} customData - 自定义消息数据(可选)
|
||||
*/
|
||||
sendTestNotification(customData = null) {
|
||||
// 如果传入自定义数据,直接使用(向后兼容)
|
||||
if (customData) {
|
||||
this.emit('new_event', customData);
|
||||
logger.info('mockSocketService', 'Custom test notification sent', customData);
|
||||
return;
|
||||
}
|
||||
|
||||
// 默认发送新格式的测试通知(符合当前通知系统规范)
|
||||
const notification = {
|
||||
type: 'announcement', // 公告通知类型
|
||||
priority: 'important', // 重要优先级(30秒自动关闭)
|
||||
title: '🧪 测试通知',
|
||||
content: '这是一条手动触发的测试消息,用于验证通知系统是否正常工作',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
id: `test_${Date.now()}`,
|
||||
clickable: false,
|
||||
};
|
||||
|
||||
this.emit('new_event', notification);
|
||||
logger.info('mockSocketService', 'Test notification sent', notification);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接状态
|
||||
*/
|
||||
isConnected() {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取当前重连尝试次数
|
||||
*/
|
||||
getReconnectAttempts() {
|
||||
return this.reconnectAttempts;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最大重连次数(Mock 模式无限重试)
|
||||
*/
|
||||
getMaxReconnectAttempts() {
|
||||
return Infinity;
|
||||
}
|
||||
|
||||
/**
|
||||
* 订阅事件推送(Mock 实现)
|
||||
* @param {object} options - 订阅选项
|
||||
* @param {string} options.eventType - 事件类型 ('all' | 'policy' | 'market' | 'tech' | ...)
|
||||
* @param {string} options.importance - 重要性 ('all' | 'S' | 'A' | 'B' | 'C')
|
||||
* @param {Function} options.onNewEvent - 收到新事件时的回调函数
|
||||
* @param {Function} options.onSubscribed - 订阅成功的回调函数(可选)
|
||||
*/
|
||||
subscribeToEvents(options = {}) {
|
||||
const {
|
||||
eventType = 'all',
|
||||
importance = 'all',
|
||||
onNewEvent,
|
||||
onSubscribed,
|
||||
} = options;
|
||||
|
||||
logger.info('mockSocketService', 'Subscribing to events', { eventType, importance });
|
||||
|
||||
// Mock: 立即触发订阅成功回调
|
||||
if (onSubscribed) {
|
||||
setTimeout(() => {
|
||||
onSubscribed({
|
||||
success: true,
|
||||
event_type: eventType,
|
||||
importance: importance,
|
||||
message: 'Mock subscription confirmed'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Mock: 如果提供了 onNewEvent 回调,监听 'new_event' 事件
|
||||
if (onNewEvent) {
|
||||
// 先移除之前的监听器(避免重复)
|
||||
this.off('new_event', onNewEvent);
|
||||
// 添加新的监听器
|
||||
this.on('new_event', onNewEvent);
|
||||
logger.info('mockSocketService', 'Event listener registered for new_event');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 取消订阅事件推送(Mock 实现)
|
||||
* @param {object} options - 取消订阅选项
|
||||
* @param {string} options.eventType - 事件类型
|
||||
* @param {Function} options.onUnsubscribed - 取消订阅成功的回调函数(可选)
|
||||
*/
|
||||
unsubscribeFromEvents(options = {}) {
|
||||
const {
|
||||
eventType = 'all',
|
||||
onUnsubscribed,
|
||||
} = options;
|
||||
|
||||
logger.info('mockSocketService', 'Unsubscribing from events', { eventType });
|
||||
|
||||
// Mock: 移除 new_event 监听器
|
||||
this.off('new_event');
|
||||
|
||||
// Mock: 立即触发取消订阅成功回调
|
||||
if (onUnsubscribed) {
|
||||
setTimeout(() => {
|
||||
onUnsubscribed({
|
||||
success: true,
|
||||
event_type: eventType,
|
||||
message: 'Mock unsubscription confirmed'
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:订阅所有类型的事件(Mock 实现)
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||
*/
|
||||
subscribeToAllEvents(onNewEvent) {
|
||||
this.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance: 'all',
|
||||
onNewEvent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:订阅指定重要性的事件(Mock 实现)
|
||||
* @param {string} importance - 重要性级别 ('S' | 'A' | 'B' | 'C')
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||
*/
|
||||
subscribeToImportantEvents(importance, onNewEvent) {
|
||||
this.subscribeToEvents({
|
||||
eventType: 'all',
|
||||
importance,
|
||||
onNewEvent,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 快捷方法:订阅指定类型的事件(Mock 实现)
|
||||
* @param {string} eventType - 事件类型
|
||||
* @param {Function} onNewEvent - 收到新事件时的回调函数
|
||||
*/
|
||||
subscribeToEventType(eventType, onNewEvent) {
|
||||
this.subscribeToEvents({
|
||||
eventType,
|
||||
importance: 'all',
|
||||
onNewEvent,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 导出单例
|
||||
export const mockSocketService = new MockSocketService();
|
||||
|
||||
// 开发模式下添加全局测试函数
|
||||
if (process.env.NODE_ENV === 'development') {
|
||||
window.__mockSocket = {
|
||||
// 模拟意外断线(自动重连成功)
|
||||
simulateDisconnection: () => {
|
||||
logger.info('mockSocketService', '🔌 Simulating disconnection (will auto-reconnect)...');
|
||||
mockSocketService.simulateDisconnection(1); // 触发自动重连
|
||||
},
|
||||
|
||||
// 模拟持续连接失败
|
||||
simulateConnectionFailure: () => {
|
||||
logger.info('mockSocketService', '🚫 Simulating connection failure (will keep retrying)...');
|
||||
mockSocketService.simulateConnectionFailure();
|
||||
},
|
||||
|
||||
// 允许重连成功
|
||||
allowReconnection: () => {
|
||||
logger.info('mockSocketService', '✅ Allowing next reconnection to succeed...');
|
||||
mockSocketService.allowReconnection();
|
||||
},
|
||||
|
||||
// 获取连接状态
|
||||
isConnected: () => {
|
||||
const connected = mockSocketService.isConnected();
|
||||
logger.info('mockSocketService', `Connection status: ${connected ? '✅ Connected' : '❌ Disconnected'}`);
|
||||
return connected;
|
||||
},
|
||||
|
||||
// 手动重连
|
||||
reconnect: () => {
|
||||
logger.info('mockSocketService', '🔄 Manually triggering reconnection...');
|
||||
return mockSocketService.reconnect();
|
||||
},
|
||||
|
||||
// 获取重连尝试次数
|
||||
getAttempts: () => {
|
||||
const attempts = mockSocketService.getReconnectAttempts();
|
||||
logger.info('mockSocketService', `Current reconnection attempts: ${attempts}`);
|
||||
return attempts;
|
||||
},
|
||||
|
||||
// 暂停自动推送(保持连接)
|
||||
pausePush: () => {
|
||||
mockSocketService.pausePush();
|
||||
logger.info('mockSocketService', '⏸️ Auto push paused');
|
||||
return true;
|
||||
},
|
||||
|
||||
// 恢复自动推送
|
||||
resumePush: () => {
|
||||
mockSocketService.resumePush();
|
||||
logger.info('mockSocketService', '▶️ Auto push resumed');
|
||||
return true;
|
||||
},
|
||||
|
||||
// 查看推送暂停状态
|
||||
isPushPaused: () => {
|
||||
const paused = mockSocketService.isPushPaused();
|
||||
logger.info('mockSocketService', `Push status: ${paused ? '⏸️ Paused' : '▶️ Active'}`);
|
||||
return paused;
|
||||
},
|
||||
};
|
||||
|
||||
logger.info('mockSocketService', '💡 Mock Socket test functions available:');
|
||||
logger.info('mockSocketService', ' __mockSocket.simulateDisconnection() - 模拟断线(自动重连成功)');
|
||||
logger.info('mockSocketService', ' __mockSocket.simulateConnectionFailure() - 模拟连接失败(持续失败)');
|
||||
logger.info('mockSocketService', ' __mockSocket.allowReconnection() - 允许重连成功');
|
||||
logger.info('mockSocketService', ' __mockSocket.isConnected() - 查看连接状态');
|
||||
logger.info('mockSocketService', ' __mockSocket.reconnect() - 手动重连');
|
||||
logger.info('mockSocketService', ' __mockSocket.getAttempts() - 查看重连次数');
|
||||
logger.info('mockSocketService', ' __mockSocket.pausePush() - ⏸️ 暂停自动推送(保持连接)');
|
||||
logger.info('mockSocketService', ' __mockSocket.resumePush() - ▶️ 恢复自动推送');
|
||||
logger.info('mockSocketService', ' __mockSocket.isPushPaused() - 查看推送状态');
|
||||
}
|
||||
|
||||
export default mockSocketService;
|
||||
@@ -1,364 +1,34 @@
|
||||
// src/services/socket/index.js
|
||||
/**
|
||||
* Socket 服务统一导出
|
||||
* 根据环境变量自动选择使用 Mock 或真实 Socket.IO 服务
|
||||
* 使用真实 Socket.IO 服务连接后端
|
||||
*/
|
||||
|
||||
import { mockSocketService } from '../mockSocketService';
|
||||
import { socketService } from '../socketService';
|
||||
|
||||
// 判断是否使用 Mock
|
||||
const useMock = process.env.REACT_APP_ENABLE_MOCK === 'true' || process.env.REACT_APP_USE_MOCK_SOCKET === 'true';
|
||||
// 导出 socket 服务
|
||||
export const socket = socketService;
|
||||
export { socketService };
|
||||
|
||||
// 根据环境选择服务
|
||||
export const socket = useMock ? mockSocketService : socketService;
|
||||
// ⚡ 新增:暴露 Socket 实例到 window(用于调试和验证)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.socket = socketService;
|
||||
window.socketService = socketService;
|
||||
|
||||
// 同时导出两个服务,方便测试和调试
|
||||
export { mockSocketService, socketService };
|
||||
|
||||
// 导出服务类型标识
|
||||
export const SOCKET_TYPE = useMock ? 'MOCK' : 'REAL';
|
||||
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 ${SOCKET_TYPE} Socket Service`,
|
||||
`color: ${useMock ? '#FF9800' : '#4CAF50'}; font-weight: bold; font-size: 12px;`
|
||||
'%c[Socket Service] Using REAL Socket Service',
|
||||
'color: #4CAF50; font-weight: bold; font-size: 12px;'
|
||||
);
|
||||
|
||||
// ========== 暴露调试 API 到全局 ==========
|
||||
if (typeof window !== 'undefined') {
|
||||
// 暴露 Socket 类型到全局
|
||||
window.SOCKET_TYPE = SOCKET_TYPE;
|
||||
|
||||
// 暴露调试 API
|
||||
window.__SOCKET_DEBUG__ = {
|
||||
// 获取当前连接状态
|
||||
getStatus: () => {
|
||||
const isConnected = socket.connected || false;
|
||||
return {
|
||||
type: SOCKET_TYPE,
|
||||
connected: isConnected,
|
||||
reconnectAttempts: socket.getReconnectAttempts?.() || 0,
|
||||
maxReconnectAttempts: socket.getMaxReconnectAttempts?.() || Infinity,
|
||||
service: useMock ? 'mockSocketService' : 'socketService',
|
||||
};
|
||||
},
|
||||
|
||||
// 手动重连
|
||||
reconnect: () => {
|
||||
console.log('[Socket Debug] Manual reconnect triggered');
|
||||
if (socket.reconnect) {
|
||||
socket.reconnect();
|
||||
} else {
|
||||
socket.disconnect();
|
||||
socket.connect();
|
||||
}
|
||||
},
|
||||
|
||||
// 断开连接
|
||||
disconnect: () => {
|
||||
console.log('[Socket Debug] Manual disconnect triggered');
|
||||
socket.disconnect();
|
||||
},
|
||||
|
||||
// 连接
|
||||
connect: () => {
|
||||
console.log('[Socket Debug] Manual connect triggered');
|
||||
socket.connect();
|
||||
},
|
||||
|
||||
// 获取服务实例 (仅用于调试)
|
||||
getService: () => socket,
|
||||
|
||||
// 导出诊断信息
|
||||
exportDiagnostics: () => {
|
||||
const status = window.__SOCKET_DEBUG__.getStatus();
|
||||
const diagnostics = {
|
||||
...status,
|
||||
timestamp: new Date().toISOString(),
|
||||
userAgent: navigator.userAgent,
|
||||
url: window.location.href,
|
||||
env: {
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
|
||||
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
|
||||
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
|
||||
REACT_APP_ENV: process.env.REACT_APP_ENV,
|
||||
},
|
||||
};
|
||||
console.log('[Socket Diagnostics]', diagnostics);
|
||||
return diagnostics;
|
||||
},
|
||||
|
||||
// 手动订阅事件
|
||||
subscribe: (options = {}) => {
|
||||
const { eventType = 'all', importance = 'all' } = options;
|
||||
console.log(`[Socket Debug] Subscribing to events: type=${eventType}, importance=${importance}`);
|
||||
|
||||
if (socket.subscribeToEvents) {
|
||||
socket.subscribeToEvents({
|
||||
eventType,
|
||||
importance,
|
||||
onNewEvent: (event) => {
|
||||
console.log('[Socket Debug] ✅ New event received:', event);
|
||||
},
|
||||
onSubscribed: (data) => {
|
||||
console.log('[Socket Debug] ✅ Subscription confirmed:', data);
|
||||
},
|
||||
});
|
||||
} else {
|
||||
console.error('[Socket Debug] ❌ subscribeToEvents method not available');
|
||||
}
|
||||
},
|
||||
|
||||
// 测试连接质量
|
||||
testConnection: () => {
|
||||
console.log('[Socket Debug] Testing connection...');
|
||||
const start = Date.now();
|
||||
|
||||
if (socket.emit) {
|
||||
socket.emit('ping', { timestamp: start }, (response) => {
|
||||
const latency = Date.now() - start;
|
||||
console.log(`[Socket Debug] ✅ Connection OK - Latency: ${latency}ms`, response);
|
||||
});
|
||||
} else {
|
||||
console.error('[Socket Debug] ❌ Cannot test connection - socket.emit not available');
|
||||
}
|
||||
},
|
||||
|
||||
// 检查配置是否正确
|
||||
checkConfig: () => {
|
||||
const config = {
|
||||
socketType: SOCKET_TYPE,
|
||||
useMock,
|
||||
envVars: {
|
||||
REACT_APP_ENABLE_MOCK: process.env.REACT_APP_ENABLE_MOCK,
|
||||
REACT_APP_USE_MOCK_SOCKET: process.env.REACT_APP_USE_MOCK_SOCKET,
|
||||
NODE_ENV: process.env.NODE_ENV,
|
||||
REACT_APP_API_URL: process.env.REACT_APP_API_URL,
|
||||
},
|
||||
socketMethods: {
|
||||
connect: typeof socket.connect,
|
||||
disconnect: typeof socket.disconnect,
|
||||
on: typeof socket.on,
|
||||
emit: typeof socket.emit,
|
||||
subscribeToEvents: typeof socket.subscribeToEvents,
|
||||
},
|
||||
};
|
||||
|
||||
console.log('[Socket Debug] Configuration Check:', config);
|
||||
|
||||
// 检查潜在问题
|
||||
const issues = [];
|
||||
if (SOCKET_TYPE === 'MOCK' && process.env.NODE_ENV === 'production') {
|
||||
issues.push('⚠️ WARNING: Using MOCK socket in production!');
|
||||
}
|
||||
if (!socket.subscribeToEvents) {
|
||||
issues.push('❌ ERROR: subscribeToEvents method missing');
|
||||
}
|
||||
|
||||
if (issues.length > 0) {
|
||||
console.warn('[Socket Debug] Issues found:', issues);
|
||||
} else {
|
||||
console.log('[Socket Debug] ✅ No issues found');
|
||||
}
|
||||
|
||||
return { config, issues };
|
||||
},
|
||||
};
|
||||
|
||||
console.log(
|
||||
'%c[Socket Debug] Debug API available at window.__SOCKET_DEBUG__',
|
||||
'color: #2196F3; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%cTry: window.__SOCKET_DEBUG__.getStatus()',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
console.log(
|
||||
'%c window.__SOCKET_DEBUG__.checkConfig() - 检查配置',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
console.log(
|
||||
'%c window.__SOCKET_DEBUG__.subscribe() - 手动订阅事件',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
console.log(
|
||||
'%c window.__SOCKET_DEBUG__.testConnection() - 测试连接',
|
||||
'color: #2196F3;'
|
||||
);
|
||||
|
||||
// ========== 通知系统专用调试 API ==========
|
||||
window.__NOTIFY_DEBUG__ = {
|
||||
// 完整检查(配置+连接+订阅状态)
|
||||
checkAll: () => {
|
||||
console.log('\n==========【通知系统诊断】==========');
|
||||
|
||||
// 1. 检查 Socket 配置
|
||||
const socketCheck = window.__SOCKET_DEBUG__.checkConfig();
|
||||
console.log('\n✓ Socket 配置检查完成');
|
||||
|
||||
// 2. 检查连接状态
|
||||
const status = window.__SOCKET_DEBUG__.getStatus();
|
||||
console.log('\n✓ 连接状态:', status.connected ? '✅ 已连接' : '❌ 未连接');
|
||||
|
||||
// 3. 检查环境变量
|
||||
console.log('\n✓ API Base:', process.env.REACT_APP_API_URL || '(使用相对路径)');
|
||||
|
||||
// 4. 检查浏览器通知权限
|
||||
const browserPermission = Notification?.permission || 'unsupported';
|
||||
console.log('\n✓ 浏览器通知权限:', browserPermission);
|
||||
|
||||
// 5. 汇总报告
|
||||
const report = {
|
||||
timestamp: new Date().toISOString(),
|
||||
socket: {
|
||||
type: SOCKET_TYPE,
|
||||
connected: status.connected,
|
||||
reconnectAttempts: status.reconnectAttempts,
|
||||
},
|
||||
env: socketCheck.config.envVars,
|
||||
browserNotification: browserPermission,
|
||||
issues: socketCheck.issues,
|
||||
};
|
||||
|
||||
console.log('\n========== 诊断报告 ==========');
|
||||
console.table(report);
|
||||
|
||||
if (report.issues.length > 0) {
|
||||
console.warn('\n⚠️ 发现问题:', report.issues);
|
||||
} else {
|
||||
console.log('\n✅ 系统正常,未发现问题');
|
||||
}
|
||||
|
||||
// 提供修复建议
|
||||
if (!status.connected) {
|
||||
console.log('\n💡 修复建议:');
|
||||
console.log(' 1. 检查网络连接');
|
||||
console.log(' 2. 尝试手动重连: __SOCKET_DEBUG__.reconnect()');
|
||||
console.log(' 3. 检查后端服务是否运行');
|
||||
}
|
||||
|
||||
if (browserPermission === 'denied') {
|
||||
console.log('\n💡 浏览器通知已被拒绝,请在浏览器设置中允许通知权限');
|
||||
}
|
||||
|
||||
console.log('\n====================================\n');
|
||||
|
||||
return report;
|
||||
},
|
||||
|
||||
// 手动订阅事件(简化版)
|
||||
subscribe: (eventType = 'all', importance = 'all') => {
|
||||
console.log(`\n[通知调试] 手动订阅事件: type=${eventType}, importance=${importance}`);
|
||||
window.__SOCKET_DEBUG__.subscribe({ eventType, importance });
|
||||
},
|
||||
|
||||
// 模拟接收通知(用于测试UI)
|
||||
testNotify: (type = 'announcement') => {
|
||||
console.log('\n[通知调试] 模拟通知:', type);
|
||||
|
||||
const mockNotifications = {
|
||||
announcement: {
|
||||
id: `test_${Date.now()}`,
|
||||
type: 'announcement',
|
||||
priority: 'important',
|
||||
title: '🧪 测试公告通知',
|
||||
content: '这是一条测试消息,用于验证通知系统是否正常工作',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
},
|
||||
stock_alert: {
|
||||
id: `test_${Date.now()}`,
|
||||
type: 'stock_alert',
|
||||
priority: 'urgent',
|
||||
title: '🧪 测试股票预警',
|
||||
content: '贵州茅台触发价格预警: 1850.00元 (+5.2%)',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
},
|
||||
event_alert: {
|
||||
id: `test_${Date.now()}`,
|
||||
type: 'event_alert',
|
||||
priority: 'important',
|
||||
title: '🧪 测试事件动向',
|
||||
content: 'AI大模型新政策发布,影响科技板块',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
},
|
||||
analysis_report: {
|
||||
id: `test_${Date.now()}`,
|
||||
type: 'analysis_report',
|
||||
priority: 'normal',
|
||||
title: '🧪 测试分析报告',
|
||||
content: '2024年Q1市场策略报告已发布',
|
||||
publishTime: Date.now(),
|
||||
pushTime: Date.now(),
|
||||
},
|
||||
};
|
||||
|
||||
const notification = mockNotifications[type] || mockNotifications.announcement;
|
||||
|
||||
// 触发 new_event 事件
|
||||
if (socket.emit) {
|
||||
// 对于真实 Socket,模拟服务端推送(实际上客户端无法这样做,仅用于Mock模式)
|
||||
console.warn('⚠️ 真实 Socket 无法模拟服务端推送,请使用 Mock 模式或等待真实推送');
|
||||
}
|
||||
|
||||
// 直接触发事件监听器(如果是 Mock 模式)
|
||||
if (SOCKET_TYPE === 'MOCK' && socket.emit) {
|
||||
socket.emit('new_event', notification);
|
||||
console.log('✅ 已触发 Mock 通知事件');
|
||||
}
|
||||
|
||||
console.log('通知数据:', notification);
|
||||
return notification;
|
||||
},
|
||||
|
||||
// 导出完整诊断报告
|
||||
exportReport: () => {
|
||||
const report = window.__NOTIFY_DEBUG__.checkAll();
|
||||
|
||||
// 生成可下载的 JSON
|
||||
const blob = new Blob([JSON.stringify(report, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `notification-debug-${Date.now()}.json`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
console.log('✅ 诊断报告已导出');
|
||||
return report;
|
||||
},
|
||||
|
||||
// 快捷帮助
|
||||
help: () => {
|
||||
console.log('\n========== 通知系统调试 API ==========');
|
||||
console.log('window.__NOTIFY_DEBUG__.checkAll() - 完整诊断检查');
|
||||
console.log('window.__NOTIFY_DEBUG__.subscribe() - 手动订阅事件');
|
||||
console.log('window.__NOTIFY_DEBUG__.testNotify(type) - 模拟通知 (announcement/stock_alert/event_alert/analysis_report)');
|
||||
console.log('window.__NOTIFY_DEBUG__.exportReport() - 导出诊断报告');
|
||||
console.log('\n========== Socket 调试 API ==========');
|
||||
console.log('window.__SOCKET_DEBUG__.getStatus() - 获取连接状态');
|
||||
console.log('window.__SOCKET_DEBUG__.checkConfig() - 检查配置');
|
||||
console.log('window.__SOCKET_DEBUG__.reconnect() - 手动重连');
|
||||
console.log('====================================\n');
|
||||
},
|
||||
};
|
||||
|
||||
console.log(
|
||||
'%c[Notify Debug] Notification Debug API available at window.__NOTIFY_DEBUG__',
|
||||
'color: #FF9800; font-weight: bold;'
|
||||
);
|
||||
console.log(
|
||||
'%cTry: window.__NOTIFY_DEBUG__.checkAll() - 完整诊断',
|
||||
'color: #FF9800;'
|
||||
);
|
||||
console.log(
|
||||
'%c window.__NOTIFY_DEBUG__.help() - 查看所有命令',
|
||||
'color: #FF9800;'
|
||||
);
|
||||
}
|
||||
|
||||
export default socket;
|
||||
|
||||
@@ -16,6 +16,7 @@ class SocketService {
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = Infinity; // 无限重试
|
||||
this.customReconnectTimer = null; // 自定义重连定时器
|
||||
this.pendingListeners = []; // 暂存等待注册的事件监听器
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -50,6 +51,23 @@ class SocketService {
|
||||
...options,
|
||||
});
|
||||
|
||||
// 注册所有暂存的事件监听器(保留 pendingListeners,不清空)
|
||||
if (this.pendingListeners.length > 0) {
|
||||
console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
|
||||
this.pendingListeners.forEach(({ 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}`);
|
||||
});
|
||||
// ⚠️ 重要:不清空 pendingListeners,保留用于重连
|
||||
}
|
||||
|
||||
// 监听连接成功
|
||||
this.socket.on('connect', () => {
|
||||
this.connected = true;
|
||||
@@ -147,8 +165,18 @@ class SocketService {
|
||||
*/
|
||||
on(event, callback) {
|
||||
if (!this.socket) {
|
||||
logger.warn('socketService', 'Cannot listen to event: socket not initialized', { event });
|
||||
console.warn(`[socketService] ❌ 无法监听事件 ${event}: Socket 未初始化`);
|
||||
// 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