Compare commits

..

66 Commits

Author SHA1 Message Date
zdl
cf4fdf6a68 feat: 传导练UI调整 2025-11-28 07:14:52 +08:00
zdl
34338373cd fix: UI调试 2025-11-27 18:27:44 +08:00
zdl
589e1c20f9 fix: 调整相关概念卡片UI 2025-11-27 17:22:49 +08:00
zdl
60e9a40a1f fix: 文案调整 2025-11-27 17:03:35 +08:00
zdl
b8b24643fe fix: AI合成h5换行,pc一行,评论标题上方margin去掉 2025-11-27 16:55:25 +08:00
zdl
e9e9ec9051 fix: 调整AI合成UI 2025-11-27 16:40:35 +08:00
zdl
5b0e420770 fix: 分时图UI调整 2025-11-27 16:20:15 +08:00
zdl
93f43054fd fix:事件详情弹窗UI 2025-11-27 15:35:48 +08:00
zdl
101d042b0e fix:调整客服UI 2025-11-27 15:31:07 +08:00
zdl
a1aa6718e6 fix: 事件详情弹窗UI调整 2025-11-27 15:08:14 +08:00
zdl
753727c1c0 fix: 事件详情弹窗UI调整
重要性h5不展示
事件列表卡片间距调整
2025-11-27 14:40:38 +08:00
zdl
afc92ee583 fix: h5 去掉通知弹窗引导 2025-11-27 13:37:01 +08:00
zdl
d825e4fe59 fix: 关注按钮UI调整 2025-11-27 11:19:20 +08:00
zdl
62cf0a6c7d feat: 修改小程序跳转链接 2025-11-27 10:46:14 +08:00
zdl
805d446775 feat: 调整搜索框UI 2025-11-26 19:33:00 +08:00
zdl
24ddfcd4b5 feat: 新增:H5 时左右 padding 改为 8px 2025-11-26 19:31:12 +08:00
zdl
a90158239b feat: 模式切花移动到标题恻,通知UI调整 2025-11-26 19:11:33 +08:00
zdl
a8d4245595 pref: 文案调整 2025-11-26 17:49:39 +08:00
zdl
5aedde7528 feat:H5 移动端已隐藏整个顶部控制栏 2025-11-26 16:51:52 +08:00
zdl
f5f89a1c72 feat:箭头绝对定位
移除左右 padding
隐藏重复箭头
2025-11-26 16:50:46 +08:00
zdl
e0b7f8c59d feat: 调整事件列表h5模式调整 2025-11-26 16:44:53 +08:00
zdl
d22d75e761 pref: h5 分页UI调整 2025-11-26 16:35:49 +08:00
zdl
30fc156474 fix: 移动端事件中心事件列表添加时间 2025-11-26 16:23:28 +08:00
zdl
572665199a feat: 删除事件中心页面不再显示桌面通知提示横幅 2025-11-26 16:18:15 +08:00
zdl
a2831c82a8 feat: 移动端不显示政策标签 2025-11-26 16:02:59 +08:00
zdl
217551b6ab feat: H5 移动端将隐藏"开启通知"组件,桌面端保持正常显示 2025-11-26 16:01:58 +08:00
zdl
022271947a fix: 移动端抽屉菜单不再显示深色模式切换按钮 2025-11-26 15:47:26 +08:00
zdl
cd6ffdbe68 fix: 修复hooks报错 2025-11-26 15:45:46 +08:00
zdl
9df725b748 feat: 精简日志 2025-11-26 15:34:11 +08:00
zdl
64f8914951 feat: logger.js - 添加日志级别控制 2025-11-26 15:30:31 +08:00
zdl
506e5a448c feat: 本地优先启动服务拦截 2025-11-26 15:23:37 +08:00
zdl
e277352133 src/contexts/NotificationContext.js
- 添加 selectIsMobile 导入 在 NotificationProvider 组件开头添加移动端检测 移动端返回空壳 Provider
  - 桌面端保持原有完整功能
  移除 ConnectionStatusBar 组件和 ConnectionStatusBarWrapper(所有端)
  - 移除了不再使用的 useNotification、useLocation、logger 导入
  - 添加了 Redux selectIsMobile 检测
  - 移动端不渲染 NotificationContainer
2025-11-26 15:15:20 +08:00
zdl
87437ed229 feat: 增加 wechat_login=success 参数处理 2025-11-26 14:52:49 +08:00
zdl
037471d880 feat: 修复 Mock 路径从 h5-auth-url → h5-auth 2025-11-26 14:52:05 +08:00
zdl
0c482bc72c feat: 回调处理增加 H5 模式判断,重定向到前端回调页 2025-11-26 14:51:51 +08:00
zdl
4aebb3bf4b feat: 调整导航栏高度 2025-11-26 14:10:09 +08:00
zdl
ed241bd9c5 pref: 导航选中高亮 2025-11-26 14:01:58 +08:00
zdl
e6ede81c78 feat: 修复动态 reducer 注入导致的运行时错误 2025-11-26 13:59:26 +08:00
zdl
a0b688da80 feat: 移除 PerformanceMonitor 调试日志 2025-11-26 13:42:42 +08:00
zdl
6bd09b797d feat: PostHog 加载策略优化计划
目标

     改进 PostHog 延迟加载策略,平衡首屏性能和数据完整性:
     1. 使用 requestIdleCallback 替代固定 2 秒延迟
     2. 保留关键事件(first_visit)的同步追踪,确保数据不丢失
2025-11-26 13:41:09 +08:00
zdl
9c532b5f18 pref: 删除微信登陆日志 2025-11-26 13:38:26 +08:00
zdl
1d1d6c8169 pref: P0: PostHog 延迟加载 - 完成
P0: HeroPanel 懒加载 -  完成
 P0/P1: Charts/FullCalendar 懒加载 -  已通过路由懒加载隔离,无需额外处理
删除空的 CSS 文件
2025-11-26 13:33:58 +08:00
zdl
3507cfe9f7 pref: 删除调试工具 2025-11-26 13:16:30 +08:00
zdl
cc520893f8 fix: 添加 wechatStatusRef 用于跟踪最新状态
使用 wechatStatusRef.current 替代 wechatStatus
添加 AUTHORIZED 状态处理逻辑
添加 useEffect 同步 wechatStatusRef
2025-11-26 13:07:46 +08:00
zdl
dabedc1c0b feat: 之前的防重复逻辑 !subscriptionInfo.type 永远为 false(因为初始值是 free),导致订阅 API 从不被调用 2025-11-26 11:49:12 +08:00
zdl
7b4c4be7bf pref:点击手机登陆后日志优化 2025-11-26 11:43:16 +08:00
zdl
7a2c73f3ca :pref: 首屏优化 2025-11-26 11:30:12 +08:00
zdl
105a0b02ea fix:移除日志 2025-11-26 11:17:03 +08:00
zdl
d8a4c20565 fix: Login Page Viewed 事件仅在模态框首次打开时触发 1 次
- 验证码倒计时期间不再重复触发
     - 不影响其他事件追踪功能\
2025-11-26 11:15:04 +08:00
zdl
5f959fb44f feat: 添加 认证检查 和 首页渲染 的性能标记 2025-11-26 11:10:50 +08:00
zdl
ee78e00d3b feat: 添加设备检测功能 2025-11-26 11:03:13 +08:00
zdl
2fcc341213 feat: 修复通知初始化问题 2025-11-26 10:55:38 +08:00
zdl
1090a2fc67 feat: 客服接口mock添加 2025-11-26 10:55:18 +08:00
zdl
77f3949fe2 feat: 首页添加性能监控 2025-11-26 10:54:57 +08:00
zdl
742ab337dc feat: 更新测试用例 2025-11-26 10:54:20 +08:00
zdl
d2b6904a4a pref: 删除无用组件 2025-11-26 10:40:14 +08:00
zdl
789a6229a7 feat: 添加设备类型 2025-11-26 10:39:33 +08:00
zdl
6886a649f5 perf: Socket 连接异步化,使用 requestIdleCallback 不阻塞首屏
- NotificationContext: 将 Socket 初始化包裹在 requestIdleCallback 中
- 设置 3 秒超时保护,确保连接不会被无限延迟
- 不支持 requestIdleCallback 的浏览器自动降级到 setTimeout(0)
- socket/index.js: 移除模块加载时的 console.log,减少首屏阻塞感知
- 所有页面的首屏渲染都不再被 Socket 连接阻塞

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-26 10:34:45 +08:00
zdl
581e874b0d fix:修复类型提示错误 2025-11-26 10:11:02 +08:00
zdl
b23ed93020 Merge branch 'feature_2025/251117_pref' into feature_2025/251121_h5UI
* feature_2025/251117_pref:
  update pay function
  update pay function
  update pay function
2025-11-26 09:59:01 +08:00
zdl
84f70f3329 pref: 权限校验中 - 显示占位骨架,不显示登录按钮或用户菜单,/home页面添加骨架屏逻辑 2025-11-26 09:57:20 +08:00
zdl
601b06d79e fix: 修复 AgentChat hooks 中的 logger 调用 2025-11-26 09:47:54 +08:00
zdl
0818a7bff7 fix: 修复 logger 函数签名问题 2025-11-26 09:44:21 +08:00
ce19881181 update pay function 2025-11-26 09:06:02 +08:00
bef3e86f60 update pay function 2025-11-26 09:00:38 +08:00
65deea43e2 update pay function 2025-11-26 08:44:22 +08:00
99 changed files with 3537 additions and 4593 deletions

57
app.py
View File

@@ -3475,6 +3475,46 @@ def get_wechat_qrcode():
}}), 200 }}), 200
@app.route('/api/auth/wechat/h5-auth', methods=['POST'])
def get_wechat_h5_auth_url():
"""
获取微信 H5 网页授权 URL
用于手机浏览器跳转微信 App 授权
"""
data = request.get_json() or {}
frontend_redirect = data.get('redirect_url', '/home')
# 生成唯一 state
state = uuid.uuid4().hex
# 编码回调地址
redirect_uri = urllib.parse.quote_plus(WECHAT_REDIRECT_URI)
# 构建授权 URL使用 snsapi_userinfo 获取用户信息,仅限微信内 H5 使用)
auth_url = (
f"https://open.weixin.qq.com/connect/oauth2/authorize?"
f"appid={WECHAT_APPID}&redirect_uri={redirect_uri}"
f"&response_type=code&scope=snsapi_userinfo&state={state}"
"#wechat_redirect"
)
# 存储 session 信息
wechat_qr_sessions[state] = {
'status': 'waiting',
'expires': time.time() + 300,
'mode': 'h5', # 标记为 H5 模式
'frontend_redirect': frontend_redirect,
'user_info': None,
'wechat_openid': None,
'wechat_unionid': None
}
return jsonify({
'auth_url': auth_url,
'state': state
}), 200
@app.route('/api/account/wechat/qrcode', methods=['GET']) @app.route('/api/account/wechat/qrcode', methods=['GET'])
def get_wechat_bind_qrcode(): def get_wechat_bind_qrcode():
"""发起微信绑定二维码,会话标记为绑定模式""" """发起微信绑定二维码,会话标记为绑定模式"""
@@ -3714,14 +3754,23 @@ def wechat_callback():
# 更新微信session状态供前端轮询检测 # 更新微信session状态供前端轮询检测
if state in wechat_qr_sessions: if state in wechat_qr_sessions:
session_item = wechat_qr_sessions[state] session_item = wechat_qr_sessions[state]
# 仅处理登录/注册流程,不处理绑定流程 mode = session_item.get('mode')
if not session_item.get('mode'):
# 更新状态和用户信息 # H5 模式:重定向到前端回调页面
if mode == 'h5':
frontend_redirect = session_item.get('frontend_redirect', '/home/wechat-callback')
# 清理 session
del wechat_qr_sessions[state]
print(f"✅ H5 微信登录成功,重定向到: {frontend_redirect}")
return redirect(f"{frontend_redirect}?wechat_login=success")
# PC 扫码模式:更新状态供前端轮询
if not mode:
session_item['status'] = 'register_ready' if is_new_user else 'login_ready' session_item['status'] = 'register_ready' if is_new_user else 'login_ready'
session_item['user_info'] = {'user_id': user.id} session_item['user_info'] = {'user_id': user.id}
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}") print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
# 直接跳转到首页 # PC 模式直接跳转到首页
return redirect('/home') return redirect('/home')
except Exception as e: except Exception as e:

332
app_vx.py
View File

@@ -55,6 +55,9 @@ from datetime import datetime, timedelta, time as dt_time
from werkzeug.security import generate_password_hash, check_password_hash from werkzeug.security import generate_password_hash, check_password_hash
import json import json
from clickhouse_driver import Client as Cclient from clickhouse_driver import Client as Cclient
from queue import Queue, Empty, Full
from threading import Lock, RLock
from contextlib import contextmanager
import jwt import jwt
from docx import Document from docx import Document
from tencentcloud.common import credential from tencentcloud.common import credential
@@ -69,6 +72,289 @@ engine_med = create_engine("mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060
engine_2 = create_engine("mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/valuefrontier", echo=False) engine_2 = create_engine("mysql+pymysql://root:Zzl5588161!@222.128.1.157:33060/valuefrontier", echo=False)
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# ===================== ClickHouse 连接池实现 =====================
class ClickHouseConnectionPool:
"""
ClickHouse 连接池
- 支持连接复用,避免频繁创建/销毁连接
- 支持连接超时和健康检查
- 支持自动重连
- 线程安全
"""
def __init__(self, host, port, user, password, database,
pool_size=10, max_overflow=10,
connection_timeout=10, query_timeout=30,
health_check_interval=60):
"""
初始化连接池
Args:
host: ClickHouse 主机地址
port: ClickHouse 端口
user: 用户名
password: 密码
database: 数据库名
pool_size: 连接池核心大小(预创建连接数)
max_overflow: 最大溢出连接数(总连接数 = pool_size + max_overflow
connection_timeout: 获取连接超时时间(秒)
query_timeout: 查询超时时间(秒)
health_check_interval: 健康检查间隔(秒)
"""
self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
self.pool_size = pool_size
self.max_overflow = max_overflow
self.connection_timeout = connection_timeout
self.query_timeout = query_timeout
self.health_check_interval = health_check_interval
# 连接池队列
self._pool = Queue(maxsize=pool_size + max_overflow)
# 当前活跃连接数
self._active_connections = 0
# 锁
self._lock = RLock()
# 连接最后使用时间记录
self._last_used = {}
# 连接创建时间记录
self._created_at = {}
# 初始化核心连接
self._init_pool()
logger.info(f"ClickHouse 连接池初始化完成: pool_size={pool_size}, max_overflow={max_overflow}")
def _init_pool(self):
"""初始化连接池,预创建部分核心连接(非阻塞)"""
# 只预创建 2 个连接,其余按需创建
init_count = min(2, self.pool_size)
for i in range(init_count):
try:
conn = self._create_connection()
if conn:
self._pool.put(conn)
logger.info(f"预创建 ClickHouse 连接 {i+1}/{init_count} 成功")
except Exception as e:
logger.warning(f"预创建 ClickHouse 连接失败 ({i+1}/{init_count}): {e}")
# 预创建失败不阻塞启动,后续按需创建
break
def _create_connection(self):
"""创建新的 ClickHouse 连接"""
try:
client = Cclient(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
database=self.database,
connect_timeout=self.connection_timeout,
send_receive_timeout=self.query_timeout,
sync_request_timeout=self.query_timeout,
settings={
'max_execution_time': self.query_timeout,
'connect_timeout': self.connection_timeout,
}
)
conn_id = id(client)
self._created_at[conn_id] = time.time()
self._last_used[conn_id] = time.time()
with self._lock:
self._active_connections += 1
logger.debug(f"创建新的 ClickHouse 连接: {conn_id}")
return client
except Exception as e:
logger.error(f"创建 ClickHouse 连接失败: {e}")
raise
def _check_connection_health(self, conn):
"""检查连接健康状态"""
try:
conn_id = id(conn)
last_used = self._last_used.get(conn_id, 0)
# 如果连接长时间未使用,进行健康检查
if time.time() - last_used > self.health_check_interval:
# 执行简单查询检查连接
conn.execute("SELECT 1")
logger.debug(f"连接 {conn_id} 健康检查通过")
return True
except Exception as e:
logger.warning(f"连接健康检查失败: {e}")
return False
def _close_connection(self, conn):
"""关闭连接"""
try:
conn_id = id(conn)
conn.disconnect()
self._last_used.pop(conn_id, None)
self._created_at.pop(conn_id, None)
with self._lock:
self._active_connections = max(0, self._active_connections - 1)
logger.debug(f"关闭 ClickHouse 连接: {conn_id}")
except Exception as e:
logger.warning(f"关闭连接时出错: {e}")
def get_connection(self, timeout=None):
"""
从连接池获取连接
Args:
timeout: 获取连接的超时时间,默认使用 connection_timeout
Returns:
ClickHouse 客户端连接
Raises:
TimeoutError: 获取连接超时
Exception: 创建连接失败
"""
timeout = timeout or self.connection_timeout
# 首先尝试从池中获取连接
try:
conn = self._pool.get(block=True, timeout=timeout)
# 检查连接健康状态
if self._check_connection_health(conn):
self._last_used[id(conn)] = time.time()
return conn
else:
# 连接不健康,关闭并创建新连接
self._close_connection(conn)
return self._create_connection()
except Empty:
# 池中没有可用连接,检查是否可以创建新连接
with self._lock:
if self._active_connections < self.pool_size + self.max_overflow:
try:
return self._create_connection()
except Exception as e:
logger.error(f"创建溢出连接失败: {e}")
raise
# 已达到最大连接数,等待连接释放
logger.warning(f"连接池已满,等待连接释放... (当前连接数: {self._active_connections})")
raise TimeoutError(f"获取 ClickHouse 连接超时 (timeout={timeout}s)")
def release_connection(self, conn):
"""
释放连接回连接池
Args:
conn: 要释放的连接
"""
if conn is None:
return
conn_id = id(conn)
self._last_used[conn_id] = time.time()
try:
self._pool.put(conn, block=False)
logger.debug(f"连接 {conn_id} 已释放回连接池")
except Full:
# 池已满,关闭多余连接
logger.debug(f"连接池已满,关闭多余连接: {conn_id}")
self._close_connection(conn)
@contextmanager
def connection(self, timeout=None):
"""
上下文管理器方式获取连接
Usage:
with pool.connection() as conn:
result = conn.execute("SELECT * FROM table")
"""
conn = None
try:
conn = self.get_connection(timeout)
yield conn
except Exception as e:
# 发生异常时,检查连接是否需要重建
if conn:
try:
# 尝试简单查询检测连接状态
conn.execute("SELECT 1")
except:
# 连接已损坏,关闭它
self._close_connection(conn)
conn = None
raise
finally:
if conn:
self.release_connection(conn)
def execute(self, query, params=None, timeout=None):
"""
执行查询(自动管理连接)
Args:
query: SQL 查询语句
params: 查询参数
timeout: 查询超时时间
Returns:
查询结果
"""
with self.connection(timeout) as conn:
return conn.execute(query, params)
def get_pool_status(self):
"""获取连接池状态"""
return {
'pool_size': self.pool_size,
'max_overflow': self.max_overflow,
'active_connections': self._active_connections,
'available_connections': self._pool.qsize(),
'max_connections': self.pool_size + self.max_overflow
}
def close_all(self):
"""关闭所有连接"""
while not self._pool.empty():
try:
conn = self._pool.get_nowait()
self._close_connection(conn)
except Empty:
break
logger.info("ClickHouse 连接池已关闭所有连接")
# 初始化全局 ClickHouse 连接池(懒加载模式)
clickhouse_pool = None
_pool_lock = Lock()
def _init_clickhouse_pool():
"""懒加载初始化 ClickHouse 连接池"""
global clickhouse_pool
if clickhouse_pool is None:
with _pool_lock:
if clickhouse_pool is None:
clickhouse_pool = ClickHouseConnectionPool(
host='222.128.1.157',
port=18000,
user='default',
password='Zzl33818!',
database='stock',
pool_size=5, # 减少预创建连接数
max_overflow=20, # 增加溢出连接数,总共支持 25 并发
connection_timeout=10, # 连接超时 10 秒
query_timeout=30, # 查询超时 30 秒
health_check_interval=60 # 60 秒未使用的连接进行健康检查
)
return clickhouse_pool
# ===================== ClickHouse 连接池实现结束 =====================
app = Flask(__name__) app = Flask(__name__)
Compress(app) Compress(app)
UPLOAD_FOLDER = 'static/uploads/avatars' UPLOAD_FOLDER = 'static/uploads/avatars'
@@ -1150,13 +1436,40 @@ def update_investment_preferences():
def get_clickhouse_client(): def get_clickhouse_client():
return Cclient( """
host='222.128.1.157', 获取 ClickHouse 客户端(使用连接池,懒加载)
port=18000,
user='default', 返回连接池对象,支持两种使用方式:
password='Zzl33818!',
database='stock' 方式1推荐- 直接调用 execute
) client = get_clickhouse_client()
result = client.execute("SELECT * FROM table", {'param': value})
方式2 - 使用上下文管理器:
client = get_clickhouse_client()
with client.connection() as conn:
result = conn.execute("SELECT * FROM table")
"""
return _init_clickhouse_pool()
@app.route('/api/system/clickhouse-pool-status', methods=['GET'])
def api_clickhouse_pool_status():
"""获取 ClickHouse 连接池状态(仅供监控使用)"""
try:
pool = _init_clickhouse_pool()
status = pool.get_pool_status()
return jsonify({
'code': 200,
'message': 'success',
'data': status
})
except Exception as e:
return jsonify({
'code': 500,
'message': str(e),
'data': None
}), 500
@app.route('/api/stock/<stock_code>/kline') @app.route('/api/stock/<stock_code>/kline')
@@ -1292,6 +1605,7 @@ def get_daily_kline(stock_code, event_datetime, stock_name):
'name': stock_name, 'name': stock_name,
'data': kline_data, 'data': kline_data,
'trade_date': event_datetime.date().strftime('%Y-%m-%d'), 'trade_date': event_datetime.date().strftime('%Y-%m-%d'),
'event_time': event_datetime.isoformat(),
'type': 'daily', 'type': 'daily',
'is_history': True, 'is_history': True,
'data_count': len(kline_data) 'data_count': len(kline_data)
@@ -4120,7 +4434,9 @@ def api_stock_detail(event_id, stock_code):
'event_info': { 'event_info': {
'event_id': event.id, 'event_id': event.id,
'event_title': event.title, 'event_title': event.title,
'event_description': event.description 'event_description': event.description,
'event_start_time': event.start_time.isoformat() if event.start_time else None,
'event_created_at': event.created_at.strftime("%Y-%m-%d %H:%M:%S") if event.created_at else None
}, },
'basic_info': { 'basic_info': {
'stock_code': basic_info.SECCODE, 'stock_code': basic_info.SECCODE,

File diff suppressed because it is too large Load Diff

View File

@@ -27,17 +27,66 @@ import { PerformancePanel } from './components/PerformancePanel';
import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler'; import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler';
// Redux // Redux
import { initializePostHog } from './store/slices/posthogSlice'; // ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
// import { initializePostHog } from './store/slices/posthogSlice';
import { updateScreenSize } from './store/slices/deviceSlice';
import { injectReducer } from './store';
// Utils // Utils
import { logger } from './utils/logger'; import { logger } from './utils/logger';
import { performanceMonitor } from './utils/performanceMonitor';
// PostHog 追踪 // PostHog 延迟加载:移除同步导入
import { trackEvent, trackEventAsync } from '@lib/posthog'; // import { trackEvent, trackEventAsync } from '@lib/posthog';
// Contexts // Contexts
import { useAuth } from '@contexts/AuthContext'; import { useAuth } from '@contexts/AuthContext';
// ⚡ PostHog 延迟加载模块(动态导入后缓存)
let posthogModule = null;
let posthogSliceModule = null;
/**
* ⚡ 延迟加载 PostHog 模块
* 返回 { trackEvent, trackEventAsync, initializePostHog, posthogReducer }
*/
const loadPostHogModules = async () => {
if (posthogModule && posthogSliceModule) {
return { posthogModule, posthogSliceModule };
}
try {
const [posthog, posthogSlice] = await Promise.all([
import('@lib/posthog'),
import('./store/slices/posthogSlice'),
]);
posthogModule = posthog;
posthogSliceModule = posthogSlice;
return { posthogModule, posthogSliceModule };
} catch (error) {
logger.error('App', 'PostHog 模块加载失败', error);
return null;
}
};
/**
* ⚡ 异步追踪事件(延迟加载 PostHog 后调用)
* @param {string} eventName - 事件名称
* @param {object} properties - 事件属性
*/
const trackEventLazy = async (eventName, properties = {}) => {
// 等待模块加载完成
if (!posthogModule) {
const modules = await loadPostHogModules();
if (!modules) return;
}
// 使用异步追踪,不阻塞主线程
posthogModule.trackEventAsync(eventName, properties);
};
/** /**
* AppContent - 应用核心内容 * AppContent - 应用核心内容
* 负责 PostHog 初始化和渲染路由 * 负责 PostHog 初始化和渲染路由
@@ -51,28 +100,98 @@ function AppContent() {
const pageEnterTimeRef = useRef(Date.now()); const pageEnterTimeRef = useRef(Date.now());
const currentPathRef = useRef(location.pathname); const currentPathRef = useRef(location.pathname);
// 🎯 PostHog Redux 初始化 // 🎯 PostHog 空闲时加载 + Redux 初始化(首屏不加载 ~180KB
useEffect(() => { useEffect(() => {
dispatch(initializePostHog()); const initPostHogRedux = async () => {
logger.info('App', 'PostHog Redux 初始化已触发'); try {
const modules = await loadPostHogModules();
if (!modules) return;
const { posthogSliceModule } = modules;
// 动态注入 PostHog reducer
injectReducer('posthog', posthogSliceModule.default);
// 初始化 PostHog
dispatch(posthogSliceModule.initializePostHog());
// ⚡ 刷新注入前缓存的事件(避免丢失)
const pendingEvents = posthogSliceModule.flushPendingEventsBeforeInjection();
if (pendingEvents.length > 0) {
logger.info('App', `刷新 ${pendingEvents.length} 个注入前缓存的事件`);
pendingEvents.forEach(({ eventName, properties }) => {
posthogModule.trackEventAsync(eventName, properties);
});
}
logger.info('App', 'PostHog 模块空闲时加载完成Redux 初始化已触发');
} catch (error) {
logger.error('App', 'PostHog 加载失败', error);
}
};
// ⚡ 使用 requestIdleCallback 在浏览器空闲时加载,最长等待 3 秒
if ('requestIdleCallback' in window) {
const idleId = requestIdleCallback(initPostHogRedux, { timeout: 3000 });
return () => cancelIdleCallback(idleId);
} else {
// 降级Safari 等不支持 requestIdleCallback 的浏览器使用 setTimeout
const timer = setTimeout(initPostHogRedux, 1000);
return () => clearTimeout(timer);
}
}, [dispatch]); }, [dispatch]);
// ✅ 首次访问追踪 // ⚡ 性能监控:标记 React 初始化完成
useEffect(() => {
performanceMonitor.mark('react-ready');
}, []);
// 📱 设备检测:监听窗口尺寸变化
useEffect(() => {
let resizeTimer;
const handleResize = () => {
// 防抖:避免频繁触发
clearTimeout(resizeTimer);
resizeTimer = setTimeout(() => {
dispatch(updateScreenSize());
}, 150);
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
return () => {
clearTimeout(resizeTimer);
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
};
}, [dispatch]);
// ✅ 首次访问追踪(🔴 关键事件:立即加载模块,确保数据不丢失)
useEffect(() => { useEffect(() => {
const hasVisited = localStorage.getItem('has_visited'); const hasVisited = localStorage.getItem('has_visited');
if (!hasVisited) { if (!hasVisited) {
const urlParams = new URLSearchParams(location.search); const urlParams = new URLSearchParams(location.search);
const eventData = {
// ⚡ 使用异步追踪,不阻塞页面渲染
trackEventAsync('first_visit', {
referrer: document.referrer || 'direct', referrer: document.referrer || 'direct',
utm_source: urlParams.get('utm_source'), utm_source: urlParams.get('utm_source'),
utm_medium: urlParams.get('utm_medium'), utm_medium: urlParams.get('utm_medium'),
utm_campaign: urlParams.get('utm_campaign'), utm_campaign: urlParams.get('utm_campaign'),
landing_page: location.pathname, landing_page: location.pathname,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); };
// 🔴 关键事件:立即加载 PostHog 模块并同步追踪(不使用 trackEventLazy
// 确保首次访问数据不会因用户快速离开而丢失
(async () => {
const modules = await loadPostHogModules();
if (modules) {
// 使用同步追踪trackEvent而非异步追踪trackEventAsync
modules.posthogModule.trackEvent('first_visit', eventData);
logger.info('App', '首次访问事件已同步追踪', eventData);
}
})();
localStorage.setItem('has_visited', 'true'); localStorage.setItem('has_visited', 'true');
} }
@@ -87,8 +206,8 @@ function AppContent() {
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转) // 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
if (duration > 1) { if (duration > 1) {
// ⚡ 使用异步追踪,不阻塞页面切换 // ⚡ 使用延迟加载的异步追踪,不阻塞页面切换
trackEventAsync('page_view_duration', { trackEventLazy('page_view_duration', {
path: currentPathRef.current, path: currentPathRef.current,
duration_seconds: duration, duration_seconds: duration,
is_authenticated: isAuthenticated, is_authenticated: isAuthenticated,

View File

@@ -15,6 +15,10 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import PropTypes from 'prop-types'; import PropTypes from 'prop-types';
// ⚡ 模块级变量:防止 React StrictMode 双重初始化
let widgetInitialized = false;
let idleCallbackId = null;
const BytedeskWidget = ({ const BytedeskWidget = ({
config, config,
autoLoad = true, autoLoad = true,
@@ -27,13 +31,24 @@ const BytedeskWidget = ({
useEffect(() => { useEffect(() => {
// 如果不自动加载或配置未设置,跳过 // 如果不自动加载或配置未设置,跳过
if (!autoLoad || !config) { if (!autoLoad || !config) {
if (!config) {
console.warn('[Bytedesk] 配置未设置,客服组件未加载');
}
return; return;
} }
console.log('[Bytedesk] 开始加载客服Widget...', config); // ⚡ 防止重复初始化React StrictMode 会双重调用 useEffect
if (widgetInitialized) {
return;
}
// ⚡ 使用 requestIdleCallback 延迟加载,不阻塞首屏
const loadWidget = () => {
// 再次检查,防止竞态条件
if (widgetInitialized) return;
widgetInitialized = true;
// 检查脚本是否已存在
if (document.getElementById('bytedesk-web-script')) {
return;
}
// 加载Bytedesk Widget脚本 // 加载Bytedesk Widget脚本
const script = document.createElement('script'); const script = document.createElement('script');
@@ -42,28 +57,73 @@ const BytedeskWidget = ({
script.id = 'bytedesk-web-script'; script.id = 'bytedesk-web-script';
script.onload = () => { script.onload = () => {
console.log('[Bytedesk] Widget脚本加载成功');
try { try {
if (window.BytedeskWeb) { if (window.BytedeskWeb) {
console.log('[Bytedesk] 初始化Widget');
const bytedesk = new window.BytedeskWeb(config); const bytedesk = new window.BytedeskWeb(config);
bytedesk.init(); bytedesk.init();
widgetRef.current = bytedesk; widgetRef.current = bytedesk;
console.log('[Bytedesk] Widget初始化成功');
// ⚡ H5 端样式适配:使用 MutationObserver 立即应用样式(避免闪烁)
const isMobile = window.innerWidth <= 768;
const applyBytedeskStyles = () => {
const allElements = document.querySelectorAll('body > div');
allElements.forEach(el => {
const style = window.getComputedStyle(el);
// 检查是否是右下角固定定位的元素Bytedesk 按钮)
if (style.position === 'fixed' && style.right && style.bottom) {
const rightVal = parseInt(style.right);
const bottomVal = parseInt(style.bottom);
if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) {
// H5 端设置按钮尺寸为 48x48只执行一次
if (isMobile && !el.dataset.bytedeskStyled) {
el.dataset.bytedeskStyled = 'true';
const button = el.querySelector('button');
if (button) {
button.style.width = '48px';
button.style.height = '48px';
button.style.minWidth = '48px';
button.style.minHeight = '48px';
}
}
// 提示框 3 秒后隐藏(查找白色气泡框)
const children = el.querySelectorAll('div');
children.forEach(child => {
if (child.dataset.bytedeskTooltip) return; // 已处理过
const childStyle = window.getComputedStyle(child);
// 白色背景的提示框
if (childStyle.backgroundColor === 'rgb(255, 255, 255)') {
child.dataset.bytedeskTooltip = 'true';
setTimeout(() => {
child.style.transition = 'opacity 0.3s';
child.style.opacity = '0';
setTimeout(() => child.remove(), 300);
}, 3000);
}
});
}
}
});
};
// 立即执行一次
applyBytedeskStyles();
// 监听 DOM 变化,新元素出现时立即应用样式
const observer = new MutationObserver(applyBytedeskStyles);
observer.observe(document.body, { childList: true, subtree: true });
// 5 秒后停止监听(避免性能问题)
setTimeout(() => observer.disconnect(), 5000);
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能) // ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能)
// Bytedesk SDK 内部的 /stomp WebSocket 连接失败不影响核心客服功能
// SDK 会自动降级使用 HTTP 轮询
const originalConsoleError = console.error; const originalConsoleError = console.error;
console.error = function(...args) { console.error = function(...args) {
const errorMsg = args.join(' '); const errorMsg = args.join(' ');
// 忽略 /stomp 和 STOMP 相关错误
if (errorMsg.includes('/stomp') || if (errorMsg.includes('/stomp') ||
errorMsg.includes('stomp onWebSocketError') || errorMsg.includes('stomp onWebSocketError') ||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) { (errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
return; // 不输出日志 return;
} }
originalConsoleError.apply(console, args); originalConsoleError.apply(console, args);
}; };
@@ -75,7 +135,7 @@ const BytedeskWidget = ({
throw new Error('BytedeskWeb对象未定义'); throw new Error('BytedeskWeb对象未定义');
} }
} catch (error) { } catch (error) {
console.error('[Bytedesk] Widget初始化失败:', error); console.error('[Bytedesk] 初始化失败:', error);
if (onError) { if (onError) {
onError(error); onError(error);
} }
@@ -83,54 +143,39 @@ const BytedeskWidget = ({
}; };
script.onerror = (error) => { script.onerror = (error) => {
console.error('[Bytedesk] Widget脚本加载失败:', error); console.error('[Bytedesk] 脚本加载失败:', error);
widgetInitialized = false; // 允许重试
if (onError) { if (onError) {
onError(error); onError(error);
} }
}; };
// 添加脚本到页面
document.body.appendChild(script); document.body.appendChild(script);
scriptRef.current = script; scriptRef.current = script;
};
// 清理函数 - 增强错误处理,防止 React 18 StrictMode 双重清理报错 // ⚡ 使用 requestIdleCallback 在浏览器空闲时加载
if ('requestIdleCallback' in window) {
idleCallbackId = requestIdleCallback(loadWidget, { timeout: 3000 });
} else {
// 降级:使用 setTimeout
idleCallbackId = setTimeout(loadWidget, 100);
}
// 清理函数
return () => { return () => {
console.log('[Bytedesk] 清理Widget'); // 取消待执行的 idle callback
if (idleCallbackId) {
// 移除脚本 if ('cancelIdleCallback' in window) {
try { cancelIdleCallback(idleCallbackId);
if (scriptRef.current && scriptRef.current.parentNode) { } else {
scriptRef.current.parentNode.removeChild(scriptRef.current); clearTimeout(idleCallbackId);
} }
scriptRef.current = null; idleCallbackId = null;
} catch (error) {
console.warn('[Bytedesk] 移除脚本失败(可能已被移除):', error.message);
} }
// 移除Widget DOM元素 // ⚠️ 不重置 widgetInitialized保持单例
try { // 不清理 DOM因为客服 Widget 应该持久存在
const widgetElements = document.querySelectorAll('[class*="bytedesk"], [id*="bytedesk"]');
widgetElements.forEach(el => {
try {
if (el && el.parentNode && el.parentNode.contains(el)) {
el.parentNode.removeChild(el);
}
} catch (err) {
// 忽略单个元素移除失败(可能已被移除)
}
});
} catch (error) {
console.warn('[Bytedesk] 清理Widget DOM元素失败:', error.message);
}
// 清理全局对象
try {
if (window.BytedeskWeb) {
delete window.BytedeskWeb;
}
} catch (error) {
console.warn('[Bytedesk] 清理全局对象失败:', error.message);
}
}; };
}, [config, autoLoad, onLoad, onError]); }, [config, autoLoad, onLoad, onError]);

View File

@@ -216,12 +216,6 @@ export default function AuthFormContent() {
authEvents.trackVerificationCodeSent(credential, config.api.purpose); authEvents.trackVerificationCodeSent(credential, config.api.purpose);
} }
// ❌ 移除成功 toast静默处理
logger.info('AuthFormContent', '验证码发送成功', {
credential: cleanedCredential.substring(0, 3) + '****' + cleanedCredential.substring(7),
dev_code: data.dev_code
});
// ✅ 开发环境下在控制台显示验证码 // ✅ 开发环境下在控制台显示验证码
if (data.dev_code) { if (data.dev_code) {
console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;'); console.log(`%c✅ [验证码] ${cleanedCredential} -> ${data.dev_code}`, 'color: #16a34a; font-weight: bold; font-size: 14px;');
@@ -328,16 +322,6 @@ export default function AuthFormContent() {
} }
if (response.ok && data.success) { if (response.ok && data.success) {
// ⚡ Mock 模式:先在前端侧写入 localStorage确保时序正确
if (process.env.REACT_APP_ENABLE_MOCK === 'true' && data.user) {
setCurrentUser(data.user);
logger.debug('AuthFormContent', '前端侧设置当前用户(Mock模式)', {
userId: data.user?.id,
phone: data.user?.phone,
mockMode: true
});
}
// 更新session // 更新session
await checkSession(); await checkSession();
@@ -476,7 +460,8 @@ export default function AuthFormContent() {
return () => { return () => {
isMountedRef.current = false; isMountedRef.current = false;
}; };
}, [authEvents]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // 仅在挂载时执行一次,避免 countdown 倒计时导致重复触发
return ( return (
<> <>

View File

@@ -74,6 +74,7 @@ export default function WechatRegister() {
const isMountedRef = useRef(true); // 追踪组件挂载状态 const isMountedRef = useRef(true); // 追踪组件挂载状态
const containerRef = useRef(null); // 容器DOM引用 const containerRef = useRef(null); // 容器DOM引用
const sessionIdRef = useRef(null); // 存储最新的 sessionId避免闭包陷阱 const sessionIdRef = useRef(null); // 存储最新的 sessionId避免闭包陷阱
const wechatStatusRef = useRef(WECHAT_STATUS.NONE); // 存储最新的 wechatStatus避免闭包陷阱
const navigate = useNavigate(); const navigate = useNavigate();
const toast = useToast(); const toast = useToast();
@@ -128,12 +129,8 @@ export default function WechatRegister() {
*/ */
const handleLoginSuccess = useCallback(async (sessionId, status) => { const handleLoginSuccess = useCallback(async (sessionId, status) => {
try { try {
logger.info('WechatRegister', '开始调用登录接口', { sessionId: sessionId.substring(0, 8) + '...', status });
const response = await authService.loginWithWechat(sessionId); const response = await authService.loginWithWechat(sessionId);
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
if (response?.success) { if (response?.success) {
// 追踪微信登录成功 // 追踪微信登录成功
authEvents.trackLoginSuccess( authEvents.trackLoginSuccess(
@@ -182,40 +179,28 @@ export default function WechatRegister() {
const checkWechatStatus = useCallback(async () => { const checkWechatStatus = useCallback(async () => {
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId // 检查组件是否已卸载,使用 ref 获取最新的 sessionId
if (!isMountedRef.current || !sessionIdRef.current) { if (!isMountedRef.current || !sessionIdRef.current) {
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
isMounted: isMountedRef.current,
hasSessionId: !!sessionIdRef.current
});
return; return;
} }
const currentSessionId = sessionIdRef.current; const currentSessionId = sessionIdRef.current;
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
try { try {
const response = await authService.checkWechatStatus(currentSessionId); const response = await authService.checkWechatStatus(currentSessionId);
// 安全检查:确保 response 存在且包含 status // 安全检查:确保 response 存在且包含 status
if (!response || typeof response.status === 'undefined') { if (!response || typeof response.status === 'undefined') {
logger.warn('WechatRegister', '微信状态检查返回无效数据', { response });
return; return;
} }
const { status } = response; const { status } = response;
logger.debug('WechatRegister', '微信状态', { status });
logger.debug('WechatRegister', '检测到微信状态', {
sessionId: wechatSessionId.substring(0, 8) + '...',
status,
userInfo: response.user_info
});
// 组件卸载后不再更新状态 // 组件卸载后不再更新状态
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
// 追踪状态变化 // 追踪状态变化(使用 ref 获取最新状态,避免闭包陷阱)
if (wechatStatus !== status) { const previousStatus = wechatStatusRef.current;
authEvents.trackWechatStatusChanged(currentSessionId, wechatStatus, status); if (previousStatus !== status) {
authEvents.trackWechatStatusChanged(currentSessionId, previousStatus, status);
// 特别追踪扫码事件 // 特别追踪扫码事件
if (status === WECHAT_STATUS.SCANNED) { if (status === WECHAT_STATUS.SCANNED) {
@@ -227,7 +212,6 @@ export default function WechatRegister() {
// 处理成功状态 // 处理成功状态
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) { if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
logger.info('WechatRegister', '检测到登录成功状态,停止轮询', { status });
clearTimers(); // 停止轮询 clearTimers(); // 停止轮询
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null; // 清理 sessionId
@@ -277,6 +261,12 @@ export default function WechatRegister() {
}); });
} }
} }
// 处理授权成功AUTHORIZED- 用户已在微信端确认授权,调用登录 API
else if (status === WECHAT_STATUS.AUTHORIZED) {
clearTimers();
sessionIdRef.current = null; // 清理 sessionId
await handleLoginSuccess(currentSessionId, status);
}
} catch (error) { } catch (error) {
logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId }); logger.error('WechatRegister', 'checkWechatStatus', error, { sessionId: currentSessionId });
// 轮询过程中的错误不显示给用户,避免频繁提示 // 轮询过程中的错误不显示给用户,避免频繁提示
@@ -301,11 +291,6 @@ export default function WechatRegister() {
* 启动轮询 * 启动轮询
*/ */
const startPolling = useCallback(() => { const startPolling = useCallback(() => {
logger.debug('WechatRegister', '启动轮询', {
sessionId: sessionIdRef.current,
interval: POLL_INTERVAL
});
// 清理旧的定时器 // 清理旧的定时器
clearTimers(); clearTimers();
@@ -316,7 +301,6 @@ export default function WechatRegister() {
// 设置超时 // 设置超时
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
logger.debug('WechatRegister', '二维码超时');
clearTimers(); clearTimers();
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null; // 清理 sessionId
setWechatStatus(WECHAT_STATUS.EXPIRED); setWechatStatus(WECHAT_STATUS.EXPIRED);
@@ -368,11 +352,6 @@ export default function WechatRegister() {
setWechatSessionId(response.data.session_id); setWechatSessionId(response.data.session_id);
setWechatStatus(WECHAT_STATUS.WAITING); setWechatStatus(WECHAT_STATUS.WAITING);
logger.debug('WechatRegister', '获取二维码成功', {
sessionId: response.data.session_id,
authUrl: response.data.auth_url
});
// 启动轮询检查扫码状态 // 启动轮询检查扫码状态
startPolling(); startPolling();
} catch (error) { } catch (error) {
@@ -404,6 +383,14 @@ export default function WechatRegister() {
} }
}, [getWechatQRCode]); }, [getWechatQRCode]);
/**
* 同步 wechatStatusRef 与 wechatStatus state
* 确保 checkWechatStatus 回调中能获取到最新状态
*/
useEffect(() => {
wechatStatusRef.current = wechatStatus;
}, [wechatStatus]);
/** /**
* 组件卸载时清理定时器和标记组件状态 * 组件卸载时清理定时器和标记组件状态
*/ */

View File

@@ -1,7 +1,19 @@
import { Link } from "react-router-dom"; import React from 'react';
import { svgs } from "./svgs"; import { Link } from 'react-router-dom';
import { svgs } from './svgs';
const Button = ({ interface ButtonProps {
className?: string;
href?: string;
onClick?: () => void;
children?: React.ReactNode;
px?: string;
white?: boolean;
isPrimary?: boolean;
isSecondary?: boolean;
}
const Button: React.FC<ButtonProps> = ({
className, className,
href, href,
onClick, onClick,
@@ -10,21 +22,21 @@ const Button = ({
white, white,
}) => { }) => {
const classes = `button relative inline-flex items-center justify-center h-11 ${ const classes = `button relative inline-flex items-center justify-center h-11 ${
px || "px-7" px || 'px-7'
} ${white ? "text-n-8" : "text-n-1"} transition-colors hover:text-color-1 ${ } ${white ? 'text-n-8' : 'text-n-1'} transition-colors hover:text-color-1 ${
className || "" className || ''
}`; }`;
const spanClasses = `relative z-10`; const spanClasses = `relative z-10`;
return href ? ( return href ? (
href.startsWith("mailto:") ? ( href.startsWith('mailto:') ? (
<a href={href} className={classes}> <a href={href} className={classes}>
<span className={spanClasses}>{children}</span> <span className={spanClasses}>{children}</span>
{svgs(white)} {svgs(white)}
</a> </a>
) : ( ) : (
<Link href={href} className={classes}> <Link to={href} className={classes}>
<span className={spanClasses}>{children}</span> <span className={spanClasses}>{children}</span>
{svgs(white)} {svgs(white)}
</Link> </Link>

View File

@@ -1,53 +0,0 @@
import React from "react";
import Link, { LinkProps } from "next/link";
type CommonProps = {
className?: string;
children?: React.ReactNode;
isPrimary?: boolean;
isSecondary?: boolean;
};
type ButtonAsButton = {
as?: "button";
} & React.ButtonHTMLAttributes<HTMLButtonElement>;
type ButtonAsAnchor = {
as: "a";
} & React.AnchorHTMLAttributes<HTMLAnchorElement>;
type ButtonAsLink = {
as: "link";
} & LinkProps;
type ButtonProps = CommonProps &
(ButtonAsButton | ButtonAsAnchor | ButtonAsLink);
const Button: React.FC<ButtonProps> = ({
className,
children,
isPrimary,
isSecondary,
as = "button",
...props
}) => {
const isLink = as === "link";
const Component: React.ElementType = isLink ? Link : as;
return (
<Component
className={`relative inline-flex justify-center items-center h-10 px-3.5 rounded-lg text-title-5 cursor-pointer transition-all ${
isPrimary ? "bg-white text-black hover:bg-white/90" : ""
} ${
isSecondary
? "shadow-[0.0625rem_0.0625rem_0.0625rem_0_rgba(255,255,255,0.10)_inset] text-white after:absolute after:inset-0 after:border after:border-line after:rounded-lg after:pointer-events-none after:transition-colors hover:after:border-white"
: ""
} ${className || ""}`}
{...(isLink ? (props as LinkProps) : props)}
>
{children}
</Component>
);
};
export default Button;

View File

@@ -82,29 +82,9 @@ const CitedContent = ({
...containerStyle ...containerStyle
}} }}
> >
{/* AI 标识 - 固定在右上角 */}
{showAIBadge && (
<Tag
icon={<RobotOutlined />}
color="purple"
style={{
position: 'absolute',
top: 12,
right: 12,
margin: 0,
zIndex: 10,
fontSize: 12,
padding: '2px 8px'
}}
className="ai-badge-responsive"
>
AI合成
</Tag>
)}
{/* 标题栏 */} {/* 标题栏 */}
{title && ( {title && (
<div style={{ marginBottom: 12, paddingRight: 80 }}> <div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 14, color: finalTitleColor }}> <Text strong style={{ fontSize: 14, color: finalTitleColor }}>
{title} {title}
</Text> </Text>
@@ -112,10 +92,24 @@ const CitedContent = ({
)} )}
{/* 带引用的文本内容 */} {/* 带引用的文本内容 */}
<div style={{ <div style={{ lineHeight: 1.8 }}>
lineHeight: 1.8, {/* AI 标识 - 行内显示在文字前面 */}
paddingRight: title ? 0 : (showAIBadge ? 80 : 0) {showAIBadge && (
}}> <Tag
icon={<RobotOutlined />}
color="purple"
style={{
fontSize: 12,
padding: '2px 8px',
marginRight: 8,
verticalAlign: 'middle',
display: 'inline-flex',
}}
className="ai-badge-responsive"
>
AI合成
</Tag>
)}
{/* 前缀标签(如果有) */} {/* 前缀标签(如果有) */}
{prefix && ( {prefix && (
<Text style={{ <Text style={{

View File

@@ -1,9 +1,14 @@
import Image from "../Image"; import React from 'react';
import Image from '../Image';
const Generating = ({ className }) => ( interface GeneratingProps {
className?: string;
}
const Generating: React.FC<GeneratingProps> = ({ className }) => (
<div <div
className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${ className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${
className || "" className || ''
} text-base`} } text-base`}
> >
<Image <Image

View File

@@ -2,95 +2,48 @@
// 集中管理应用的全局组件 // 集中管理应用的全局组件
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { useLocation } from 'react-router-dom'; import { useSelector } from 'react-redux';
import { useNotification } from '../contexts/NotificationContext'; import { selectIsMobile } from '@/store/slices/deviceSlice';
import { logger } from '../utils/logger';
// Global Components // Global Components
import AuthModalManager from './Auth/AuthModalManager'; import AuthModalManager from './Auth/AuthModalManager';
import NotificationContainer from './NotificationContainer'; import NotificationContainer from './NotificationContainer';
import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop'; import ScrollToTop from './ScrollToTop';
// Bytedesk客服组件 // Bytedesk客服组件
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget'; import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config'; import { getBytedeskConfig } from '../bytedesk-integration/config/bytedesk.config';
/**
* ConnectionStatusBar 包装组件
* 需要在 NotificationProvider 内部使用,所以在这里包装
*/
function ConnectionStatusBarWrapper() {
const { connectionStatus, reconnectAttempt, maxReconnectAttempts, retryConnection } = useNotification();
const [isDismissed, setIsDismissed] = React.useState(false);
// 监听连接状态变化
React.useEffect(() => {
// 重连成功后,清除 dismissed 状态
if (connectionStatus === 'connected' && isDismissed) {
setIsDismissed(false);
// 从 localStorage 清除 dismissed 标记
localStorage.removeItem('connection_status_dismissed');
}
// 从 localStorage 恢复 dismissed 状态
if (connectionStatus !== 'connected' && !isDismissed) {
const dismissed = localStorage.getItem('connection_status_dismissed');
if (dismissed === 'true') {
setIsDismissed(true);
}
}
}, [connectionStatus, isDismissed]);
const handleClose = () => {
// 用户手动关闭,保存到 localStorage
setIsDismissed(true);
localStorage.setItem('connection_status_dismissed', 'true');
logger.info('App', 'Connection status bar dismissed by user');
};
return (
<ConnectionStatusBar
status={connectionStatus}
reconnectAttempt={reconnectAttempt}
maxReconnectAttempts={maxReconnectAttempts}
onRetry={retryConnection}
onClose={handleClose}
isDismissed={isDismissed}
/>
);
}
/** /**
* GlobalComponents - 全局组件容器 * GlobalComponents - 全局组件容器
* 集中管理所有全局级别的组件,如弹窗、通知、状态栏等 * 集中管理所有全局级别的组件,如弹窗、通知、状态栏等
* *
* 包含的组件: * 包含的组件:
* - ConnectionStatusBarWrapper: Socket 连接状态条
* - ScrollToTop: 路由切换时自动滚动到顶部 * - ScrollToTop: 路由切换时自动滚动到顶部
* - AuthModalManager: 认证弹窗管理器 * - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器 * - NotificationContainer: 通知容器(仅桌面端渲染)
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏) * - BytedeskWidget: Bytedesk在线客服
*
* 注意:
* - ConnectionStatusBar 已移除(所有端)
* - NotificationContainer 在移动端不渲染(通知功能已在 NotificationContext 层禁用)
*/ */
export function GlobalComponents() { export function GlobalComponents() {
const location = useLocation(); const isMobile = useSelector(selectIsMobile);
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载 // ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []); const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
return ( return (
<> <>
{/* Socket 连接状态条 */}
<ConnectionStatusBarWrapper />
{/* 路由切换时自动滚动到顶部 */} {/* 路由切换时自动滚动到顶部 */}
<ScrollToTop /> <ScrollToTop />
{/* 认证弹窗管理器 */} {/* 认证弹窗管理器 */}
<AuthModalManager /> <AuthModalManager />
{/* 通知容器 */} {/* 通知容器(仅桌面端渲染) */}
<NotificationContainer /> {!isMobile && <NotificationContainer />}
{/* Bytedesk在线客服 - 使用缓存的配置对象 */} {/* Bytedesk在线客服 - 使用缓存的配置对象 */}
<BytedeskWidget <BytedeskWidget

View File

@@ -1,13 +1,17 @@
import { useState } from "react"; import React, { useState } from 'react';
const Image = ({ className, ...props }) => { interface ImageProps extends React.ImgHTMLAttributes<HTMLImageElement> {
className?: string;
}
const Image: React.FC<ImageProps> = ({ className, ...props }) => {
const [loaded, setLoaded] = useState(false); const [loaded, setLoaded] = useState(false);
return ( return (
<img <img
className={`inline-block align-top opacity-0 transition-opacity ${ className={`inline-block align-top opacity-0 transition-opacity ${
loaded && "opacity-100" loaded && 'opacity-100'
} ${className}`} } ${className || ''}`}
onLoad={() => setLoaded(true)} onLoad={() => setLoaded(true)}
{...props} {...props}
/> />

View File

@@ -1,73 +0,0 @@
import Section from "@/components/Section";
import Image from "@/components/Image";
import Button from "@/components/Button";
type JoinProps = {};
const Join = ({}: JoinProps) => (
<Section crosses>
<div className="container">
<div className="relative max-w-[43.125rem] mx-auto py-8 md:py-14 xl:py-0">
<div className="relative z-1 text-center">
<h1 className="h1 mb-6">
Be part of the future of{" "}
<span className="inline-block relative">
Brainwave
<Image
className="absolute top-full left-0 w-full"
src="/images/curve.png"
width={624}
height={28}
alt="Curve"
/>
</span>
</h1>
<p className="body-1 mb-8 text-n-4">
Unleash the power of AI within Brainwave. Upgrade your
productivity with Brainwave, the open AI chat app.
</p>
<Button href="/pricing" white>
Get started
</Button>
</div>
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[39.25rem] h-[39.25rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[30.625rem] h-[30.625rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[21.5rem] h-[21.5rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[13.75rem] h-[13.75rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
</div>
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 opacity-60 mix-blend-color-dodge pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
<Image
className="w-full"
src="/images/gradient.png"
width={942}
height={942}
alt="Gradient"
/>
</div>
</div>
</div>
</div>
<div className="absolute -top-[5.75rem] left-[18.5rem] -z-1 w-[19.8125rem] pointer-events-none lg:-top-15 lg:left-[5.5rem]">
<Image
className="w-full"
src="/images/join/shapes-1.svg"
width={317}
height={293}
alt="Shapes 1"
/>
</div>
<div className="absolute right-[15rem] -bottom-[7rem] -z-1 w-[28.1875rem] pointer-events-none lg:right-7 lg:-bottom-[5rem]">
<Image
className="w-full"
src="/images/join/shapes-2.svg"
width={451}
height={266}
alt="Shapes 2"
/>
</div>
</Section>
);
export default Join;

View File

@@ -1,73 +0,0 @@
import Section from "@/components/Section";
import Image from "@/components/Image";
import Button from "@/components/Button";
type JoinProps = {};
const Join = ({}: JoinProps) => (
<Section crosses>
<div className="container">
<div className="relative max-w-[43.125rem] mx-auto py-8 md:py-14 xl:py-0">
<div className="relative z-1 text-center">
<h1 className="h1 mb-6">
Be part of the future of{" "}
<span className="inline-block relative">
Brainwave
<Image
className="absolute top-full left-0 w-full"
src="/images/curve.png"
width={624}
height={28}
alt="Curve"
/>
</span>
</h1>
<p className="body-1 mb-8 text-n-4">
Unleash the power of AI within Brainwave. Upgrade your
productivity with Brainwave, the open AI chat app.
</p>
<Button href="/pricing" white>
Get started
</Button>
</div>
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[39.25rem] h-[39.25rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[30.625rem] h-[30.625rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[21.5rem] h-[21.5rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
<div className="absolute top-1/2 left-1/2 w-[13.75rem] h-[13.75rem] border border-n-2/10 rounded-full -translate-x-1/2 -translate-y-1/2"></div>
</div>
<div className="absolute top-1/2 left-1/2 w-[46.5rem] h-[46.5rem] border border-n-2/5 rounded-full -translate-x-1/2 -translate-y-1/2 opacity-60 mix-blend-color-dodge pointer-events-none">
<div className="absolute top-1/2 left-1/2 w-[58.85rem] h-[58.85rem] -translate-x-3/4 -translate-y-1/2">
<Image
className="w-full"
src="/images/gradient.png"
width={942}
height={942}
alt="Gradient"
/>
</div>
</div>
</div>
</div>
<div className="absolute -top-[5.75rem] left-[18.5rem] -z-1 w-[19.8125rem] pointer-events-none lg:-top-15 lg:left-[5.5rem]">
<Image
className="w-full"
src="/images/join/shapes-1.svg"
width={317}
height={293}
alt="Shapes 1"
/>
</div>
<div className="absolute right-[15rem] -bottom-[7rem] -z-1 w-[28.1875rem] pointer-events-none lg:right-7 lg:-bottom-[5rem]">
<Image
className="w-full"
src="/images/join/shapes-2.svg"
width={451}
height={266}
alt="Shapes 2"
/>
</div>
</Section>
);
export default Join;

View File

@@ -1,53 +0,0 @@
import Image from "../Image";
const Logos = ({ className }) => (
<div className={className}>
<h5 className="tagline mb-6 text-center text-n-1/50">
Helping people create beautiful content at
</h5>
<ul className="flex">
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
</ul>
</div>
);
export default Logos;

View File

@@ -1,53 +0,0 @@
import Image from "../Image";
const Logos = ({ className }) => (
<div className={className}>
<h5 className="tagline mb-6 text-center text-n-1/50">
Helping people create beautiful content at
</h5>
<ul className="flex">
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
<li className="flex items-center justify-center flex-1 h-[8.5rem]">
<Image
src="/images/yourlogo.svg"
width={134}
height={28}
alt="Logo 3"
/>
</li>
</ul>
</div>
);
export default Logos;

View File

@@ -18,10 +18,8 @@ import {
Link, Link,
Divider, Divider,
Avatar, Avatar,
useColorMode,
useColorModeValue useColorModeValue
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { SunIcon, MoonIcon } from '@chakra-ui/icons';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
/** /**
@@ -46,7 +44,6 @@ const MobileDrawer = memo(({
}) => { }) => {
const navigate = useNavigate(); const navigate = useNavigate();
const location = useLocation(); const location = useLocation();
const { colorMode, toggleColorMode } = useColorMode();
const userBgColor = useColorModeValue('gray.50', 'whiteAlpha.100'); const userBgColor = useColorModeValue('gray.50', 'whiteAlpha.100');
const contactTextColor = useColorModeValue('gray.500', 'gray.300'); const contactTextColor = useColorModeValue('gray.500', 'gray.300');
const emailTextColor = useColorModeValue('gray.500', 'gray.300'); const emailTextColor = useColorModeValue('gray.500', 'gray.300');
@@ -82,17 +79,6 @@ const MobileDrawer = memo(({
</DrawerHeader> </DrawerHeader>
<DrawerBody> <DrawerBody>
<VStack spacing={4} align="stretch"> <VStack spacing={4} align="stretch">
{/* 移动端:日夜模式切换 */}
<Button
leftIcon={colorMode === 'light' ? <MoonIcon /> : <SunIcon />}
variant="ghost"
justifyContent="flex-start"
onClick={toggleColorMode}
size="sm"
>
切换到{colorMode === 'light' ? '深色' : '浅色'}模式
</Button>
{/* 移动端用户信息 */} {/* 移动端用户信息 */}
{isAuthenticated && user && ( {isAuthenticated && user && (
<> <>

View File

@@ -2,7 +2,7 @@
// Navbar 右侧功能区组件 // Navbar 右侧功能区组件
import React, { memo } from 'react'; import React, { memo } from 'react';
import { HStack, Spinner, IconButton, Box } from '@chakra-ui/react'; import { HStack, IconButton, Box } from '@chakra-ui/react';
import { HamburgerIcon } from '@chakra-ui/icons'; import { HamburgerIcon } from '@chakra-ui/icons';
// import ThemeToggleButton from '../ThemeToggleButton'; // ❌ 已删除 - 不再支持深色模式切换 // import ThemeToggleButton from '../ThemeToggleButton'; // ❌ 已删除 - 不再支持深色模式切换
import LoginButton from '../LoginButton'; import LoginButton from '../LoginButton';
@@ -41,9 +41,15 @@ const NavbarActions = memo(({
}) => { }) => {
return ( return (
<HStack spacing={{ base: 2, md: 4 }}> <HStack spacing={{ base: 2, md: 4 }}>
{/* 显示加载状态 */} {/* 权限校验中 - 显示占位骨架,不显示登录按钮或用户菜单 */}
{isLoading ? ( {isLoading ? (
<Spinner size="sm" color="blue.500" /> <Box
w={{ base: '80px', md: '120px' }}
h="36px"
borderRadius="md"
bg="whiteAlpha.100"
opacity={0.6}
/>
) : isAuthenticated && user ? ( ) : isAuthenticated && user ? (
// 已登录状态 - 用户菜单 + 功能菜单排列 // 已登录状态 - 用户菜单 + 功能菜单排列
<HStack spacing={{ base: 2, md: 3 }}> <HStack spacing={{ base: 2, md: 3 }}>

View File

@@ -57,13 +57,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
<MenuButton <MenuButton
as={Button} as={Button}
variant="ghost" variant="ghost"
rightIcon={<ChevronDownIcon />} rightIcon={<ChevronDownIcon color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'} />}
bg={isActive(['/community', '/concepts']) ? 'blue.50' : 'transparent'} bg={isActive(['/community', '/concepts']) ? 'blue.600' : 'transparent'}
color={isActive(['/community', '/concepts']) ? 'blue.600' : 'inherit'} color={isActive(['/community', '/concepts']) ? 'white' : 'inherit'}
fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'} fontWeight={isActive(['/community', '/concepts']) ? 'bold' : 'normal'}
borderBottom={isActive(['/community', '/concepts']) ? '2px solid' : 'none'} borderLeft={isActive(['/community', '/concepts']) ? '3px solid' : 'none'}
borderColor="blue.600" borderColor="white"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.100' : 'gray.50' }} borderRadius="md"
_hover={{ bg: isActive(['/community', '/concepts']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={highFreqMenu.handleMouseEnter} onMouseEnter={highFreqMenu.handleMouseEnter}
onMouseLeave={highFreqMenu.handleMouseLeave} onMouseLeave={highFreqMenu.handleMouseLeave}
onClick={highFreqMenu.handleClick} onClick={highFreqMenu.handleClick}
@@ -123,13 +124,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
<MenuButton <MenuButton
as={Button} as={Button}
variant="ghost" variant="ghost"
rightIcon={<ChevronDownIcon />} rightIcon={<ChevronDownIcon color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'} />}
bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.50' : 'transparent'} bg={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'transparent'}
color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.600' : 'inherit'} color={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'white' : 'inherit'}
fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'} fontWeight={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'bold' : 'normal'}
borderBottom={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '2px solid' : 'none'} borderLeft={isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? '3px solid' : 'none'}
borderColor="blue.600" borderColor="white"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.100' : 'gray.50' }} borderRadius="md"
_hover={{ bg: isActive(['/limit-analyse', '/stocks', '/trading-simulation']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={marketReviewMenu.handleMouseEnter} onMouseEnter={marketReviewMenu.handleMouseEnter}
onMouseLeave={marketReviewMenu.handleMouseLeave} onMouseLeave={marketReviewMenu.handleMouseLeave}
onClick={marketReviewMenu.handleClick} onClick={marketReviewMenu.handleClick}
@@ -198,13 +200,14 @@ const DesktopNav = memo(({ isAuthenticated, user }) => {
<MenuButton <MenuButton
as={Button} as={Button}
variant="ghost" variant="ghost"
rightIcon={<ChevronDownIcon />} rightIcon={<ChevronDownIcon color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'} />}
bg={isActive(['/agent-chat']) ? 'blue.50' : 'transparent'} bg={isActive(['/agent-chat', '/value-forum']) ? 'blue.600' : 'transparent'}
color={isActive(['/agent-chat']) ? 'blue.600' : 'inherit'} color={isActive(['/agent-chat', '/value-forum']) ? 'white' : 'inherit'}
fontWeight={isActive(['/agent-chat']) ? 'bold' : 'normal'} fontWeight={isActive(['/agent-chat', '/value-forum']) ? 'bold' : 'normal'}
borderBottom={isActive(['/agent-chat']) ? '2px solid' : 'none'} borderLeft={isActive(['/agent-chat', '/value-forum']) ? '3px solid' : 'none'}
borderColor="blue.600" borderColor="white"
_hover={{ bg: isActive(['/agent-chat']) ? 'blue.100' : 'gray.50' }} borderRadius="md"
_hover={{ bg: isActive(['/agent-chat', '/value-forum']) ? 'blue.700' : 'gray.50' }}
onMouseEnter={agentCommunityMenu.handleMouseEnter} onMouseEnter={agentCommunityMenu.handleMouseEnter}
onMouseLeave={agentCommunityMenu.handleMouseLeave} onMouseLeave={agentCommunityMenu.handleMouseLeave}
onClick={agentCommunityMenu.handleClick} onClick={agentCommunityMenu.handleClick}

View File

@@ -1,49 +0,0 @@
import Image from "../Image";
const Notification = ({ className, title }) => (
<div
className={`flex items-center p-4 pr-6 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl ${
className || ""
}`}
>
<div className="mr-5">
<Image
className="w-full rounded-xl"
src="/images/notification/image-1.png"
width={52}
height={52}
alt="Image"
/>
</div>
<div className="flex-1">
<h6 className="mb-1 font-semibold text-base">{title}</h6>
<div className="flex items-center justify-between">
<ul className="flex -m-0.5">
{[
"/images/notification/image-4.png",
"/images/notification/image-3.png",
"/images/notification/image-2.png",
].map((item, index) => (
<li
className={`flex w-6 h-6 border-2 border-[#2E2A41] rounded-full overflow-hidden ${
index !== 0 ? "-ml-2" : ""
}`}
key={index}
>
<Image
className="w-full"
src={item}
width={20}
height={20}
alt={item}
/>
</li>
))}
</ul>
<div className="body-2 text-[#6C7275]">1m ago</div>
</div>
</div>
</div>
);
export default Notification;

View File

@@ -1,49 +0,0 @@
import Image from "../Image";
const Notification = ({ className, title }) => (
<div
className={`flex items-center p-4 pr-6 bg-[#474060]/40 backdrop-blur border border-n-1/10 rounded-2xl ${
className || ""
}`}
>
<div className="mr-5">
<Image
className="w-full rounded-xl"
src="/images/notification/image-1.png"
width={52}
height={52}
alt="Image"
/>
</div>
<div className="flex-1">
<h6 className="mb-1 font-semibold text-base">{title}</h6>
<div className="flex items-center justify-between">
<ul className="flex -m-0.5">
{[
"/images/notification/image-4.png",
"/images/notification/image-3.png",
"/images/notification/image-2.png",
].map((item, index) => (
<li
className={`flex w-6 h-6 border-2 border-[#2E2A41] rounded-full overflow-hidden ${
index !== 0 ? "-ml-2" : ""
}`}
key={index}
>
<Image
className="w-full"
src={item}
width={20}
height={20}
alt={item}
/>
</li>
))}
</ul>
<div className="body-2 text-[#6C7275]">1m ago</div>
</div>
</div>
</div>
);
export default Notification;

View File

@@ -1,4 +1,14 @@
const Section = ({ import React from 'react';
interface SectionProps {
className?: string;
crosses?: boolean;
crossesOffset?: string;
customPaddings?: string;
children?: React.ReactNode;
}
const Section: React.FC<SectionProps> = ({
className, className,
crosses, crosses,
crossesOffset, crossesOffset,
@@ -8,8 +18,8 @@ const Section = ({
<div <div
className={`relative ${ className={`relative ${
customPaddings || customPaddings ||
`py-10 lg:py-16 xl:py-20 ${crosses ? "lg:py-32 xl:py-40" : ""}` `py-10 lg:py-16 xl:py-20 ${crosses ? 'lg:py-32 xl:py-40' : ''}`
} ${className || ""}`} } ${className || ''}`}
> >
{children} {children}
<div className="hidden absolute top-0 left-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:left-7.5 xl:left-10"></div> <div className="hidden absolute top-0 left-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:left-7.5 xl:left-10"></div>

View File

@@ -1,195 +0,0 @@
import Section from "@/components/Section";
import Generating from "@/components/Generating";
import Image from "@/components/Image";
import Heading from "@/components/Heading";
type ServicesProps = {
containerClassName?: string;
};
const Services = ({ containerClassName }: ServicesProps) => (
<Section>
<div className={`container ${containerClassName || ""}`}>
<Heading
title="Generative AI made for creators."
text="Brainwave unlocks the potential of AI-powered applications"
/>
<div className="relative">
<div className="relative z-1 flex items-center h-[38.75rem] mb-5 p-8 border border-n-1/10 rounded-3xl overflow-hidden lg:h-[38.75rem] lg:p-20 xl:h-[45.75rem]">
<div className="absolute top-0 left-0 w-full h-full pointer-events-none md:w-3/5 xl:w-auto">
<Image
className="w-full h-full object-cover md:object-right"
src="/images/services/service-1.png"
width={797}
height={733}
alt="Smartest AI"
/>
</div>
<div className="relative z-1 max-w-[17rem] ml-auto">
<h4 className="h4 mb-4">Smartest AI</h4>
<p className="bpdy-2 mb-[3.125rem] text-n-3">
Brainwave unlocks the potential of AI-powered
applications
</p>
<ul className="body-2">
{[
"Photo generating",
"Photo enhance",
"Seamless Integration",
].map((item, index) => (
<li
className="flex items-start py-4 border-t border-n-6"
key={index}
>
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<p className="ml-4">{item}</p>
</li>
))}
</ul>
</div>
<Generating className="absolute left-4 right-4 bottom-4 border border-n-1/10 lg:left-1/2 lg-right-auto lg:bottom-8 lg:-translate-x-1/2" />
</div>
<div className="relative z-1 grid gap-5 lg:grid-cols-2">
<div className="relative min-h-[38.75rem] border border-n-1/10 rounded-3xl overflow-hidden">
<div className="absolute inset-0">
<Image
className="w-full h-full object-cover"
src="/images/services/service-2.png"
width={630}
height={748}
alt="Smartest AI"
/>
</div>
<div className="absolute inset-0 flex flex-col justify-end p-8 bg-gradient-to-b from-n-8/0 to-n-8/90 lg:p-15">
<h4 className="h4 mb-4">Photo editing</h4>
<p className="body-2 text-n-3">
{`Automatically enhance your photos using our AI app's
photo editing feature. Try it now!`}
</p>
</div>
<div className="absolute top-8 right-8 max-w-[17.5rem] py-6 px-8 bg-black rounded-t-xl rounded-bl-xl font-code text-base lg:top-16 lg:right-[8.75rem] lg:max-w-[17.5rem]">
Hey Brainwave, enhance this photo
<svg
className="absolute left-full bottom-0"
xmlns="http://www.w3.org/2000/svg"
width="26"
height="37"
>
<path d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z" />
</svg>
</div>
</div>
<div className="p-4 bg-n-7 rounded-3xl overflow-hidden lg:min-h-[45.75rem]">
<div className="py-12 px-4 xl:px-8">
<h4 className="h4 mb-4">Video generation</h4>
<p className="body-2 mb-[2.25rem] text-n-3">
The worlds most powerful AI photo and video art
generation engine.What will you create?
</p>
<ul className="flex items-center justify-between">
{[
"/images/icons/recording-03.svg",
"/images/icons/recording-01.svg",
"/images/icons/disc-02.svg",
"/images/icons/chrome-cast.svg",
"/images/icons/sliders-04.svg",
].map((item, index) => (
<li
className={`flex items-center justify-center ${
index === 2
? "w-[3rem] h-[3rem] p-0.25 bg-conic-gradient rounded-2xl md:w-[4.5rem] md:h-[4.5rem]"
: "flex w-10 h-10 bg-n-6 rounded-2xl md:w-15 md:h-15"
}`}
key={index}
>
<div
className={
index === 2
? "flex items-center justify-center w-full h-full bg-n-7 rounded-[0.9375rem]"
: ""
}
>
<Image
src={item}
width={24}
height={24}
alt={item}
/>
</div>
</li>
))}
</ul>
</div>
<div className="relative h-[20.5rem] bg-n-8 rounded-xl overflow-hidden md:h-[25rem]">
<Image
className="w-full h-full object-cover"
src="/images/services/service-3.png"
width={517}
height={400}
alt="Smartest AI"
/>
<div className="absolute top-8 left-[3.125rem] w-full max-w-[14rem] pt-2.5 pr-2.5 pb-7 pl-5 bg-n-6 rounded-t-xl rounded-br-xl font-code text-base md:max-w-[17.5rem]">
Video generated!
<div className="absolute left-5 -bottom-[1.125rem] flex items-center justify-center w-[2.25rem] h-[2.25rem] bg-color-1 rounded-[0.75rem]">
<Image
src="/images/brainwave-symbol-white.svg"
width={26}
height={26}
alt="Brainwave"
/>
</div>
<div className="tagline absolute right-2.5 bottom-1 text-[0.625rem] text-n-3 uppercase">
just now
</div>
<svg
className="absolute right-full bottom-0 -scale-x-100"
xmlns="http://www.w3.org/2000/svg"
width="26"
height="37"
>
<path
className="fill-n-6"
d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z"
/>
</svg>
</div>
<div className="absolute left-0 bottom-0 w-full flex items-center p-6">
<svg
className="mr-3"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
>
<path
d="M8.006 2.802l.036.024 10.549 7.032.805.567c.227.183.494.437.648.808a2 2 0 0 1 0 1.532c-.154.371-.421.625-.648.808-.217.175-.5.364-.805.567L8.006 21.198l-.993.627c-.285.154-.676.331-1.132.303a2 2 0 0 1-1.476-.79c-.276-.365-.346-.788-.375-1.111S4 19.502 4 19.054V4.99v-.043l.029-1.174c.03-.323.1-.746.375-1.11a2 2 0 0 1 1.476-.79c.456-.027.847.149 1.132.304s.62.378.993.627z"
fill="#fff"
/>
</svg>
<div className="flex-1 bg-[#D9D9D9]">
<div className="w-1/2 h-0.5 bg-color-1"></div>
</div>
</div>
</div>
</div>
</div>
<div className="absolute top-0 -left-[10rem] w-[56.625rem] h-[56.625rem] opacity-50 mix-blend-color-dodge pointer-events-none">
<Image
className="absolute top-1/2 left-1/2 w-[79.5625rem] max-w-[79.5625rem] h-[88.5625rem] -translate-x-1/2 -translate-y-1/2"
src="/images/gradient.png"
width={1417}
height={1417}
alt="Gradient"
/>
</div>
</div>
</div>
</Section>
);
export default Services;

View File

@@ -1,195 +0,0 @@
import Section from "@/components/Section";
import Generating from "@/components/Generating";
import Image from "@/components/Image";
import Heading from "@/components/Heading";
type ServicesProps = {
containerClassName?: string;
};
const Services = ({ containerClassName }: ServicesProps) => (
<Section>
<div className={`container ${containerClassName || ""}`}>
<Heading
title="Generative AI made for creators."
text="Brainwave unlocks the potential of AI-powered applications"
/>
<div className="relative">
<div className="relative z-1 flex items-center h-[38.75rem] mb-5 p-8 border border-n-1/10 rounded-3xl overflow-hidden lg:h-[38.75rem] lg:p-20 xl:h-[45.75rem]">
<div className="absolute top-0 left-0 w-full h-full pointer-events-none md:w-3/5 xl:w-auto">
<Image
className="w-full h-full object-cover md:object-right"
src="/images/services/service-1.png"
width={797}
height={733}
alt="Smartest AI"
/>
</div>
<div className="relative z-1 max-w-[17rem] ml-auto">
<h4 className="h4 mb-4">Smartest AI</h4>
<p className="bpdy-2 mb-[3.125rem] text-n-3">
Brainwave unlocks the potential of AI-powered
applications
</p>
<ul className="body-2">
{[
"Photo generating",
"Photo enhance",
"Seamless Integration",
].map((item, index) => (
<li
className="flex items-start py-4 border-t border-n-6"
key={index}
>
<Image
src="/images/check.svg"
width={24}
height={24}
alt="Check"
/>
<p className="ml-4">{item}</p>
</li>
))}
</ul>
</div>
<Generating className="absolute left-4 right-4 bottom-4 border border-n-1/10 lg:left-1/2 lg-right-auto lg:bottom-8 lg:-translate-x-1/2" />
</div>
<div className="relative z-1 grid gap-5 lg:grid-cols-2">
<div className="relative min-h-[38.75rem] border border-n-1/10 rounded-3xl overflow-hidden">
<div className="absolute inset-0">
<Image
className="w-full h-full object-cover"
src="/images/services/service-2.png"
width={630}
height={748}
alt="Smartest AI"
/>
</div>
<div className="absolute inset-0 flex flex-col justify-end p-8 bg-gradient-to-b from-n-8/0 to-n-8/90 lg:p-15">
<h4 className="h4 mb-4">Photo editing</h4>
<p className="body-2 text-n-3">
{`Automatically enhance your photos using our AI app's
photo editing feature. Try it now!`}
</p>
</div>
<div className="absolute top-8 right-8 max-w-[17.5rem] py-6 px-8 bg-black rounded-t-xl rounded-bl-xl font-code text-base lg:top-16 lg:right-[8.75rem] lg:max-w-[17.5rem]">
Hey Brainwave, enhance this photo
<svg
className="absolute left-full bottom-0"
xmlns="http://www.w3.org/2000/svg"
width="26"
height="37"
>
<path d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z" />
</svg>
</div>
</div>
<div className="p-4 bg-n-7 rounded-3xl overflow-hidden lg:min-h-[45.75rem]">
<div className="py-12 px-4 xl:px-8">
<h4 className="h4 mb-4">Video generation</h4>
<p className="body-2 mb-[2.25rem] text-n-3">
The worlds most powerful AI photo and video art
generation engine.What will you create?
</p>
<ul className="flex items-center justify-between">
{[
"/images/icons/recording-03.svg",
"/images/icons/recording-01.svg",
"/images/icons/disc-02.svg",
"/images/icons/chrome-cast.svg",
"/images/icons/sliders-04.svg",
].map((item, index) => (
<li
className={`flex items-center justify-center ${
index === 2
? "w-[3rem] h-[3rem] p-0.25 bg-conic-gradient rounded-2xl md:w-[4.5rem] md:h-[4.5rem]"
: "flex w-10 h-10 bg-n-6 rounded-2xl md:w-15 md:h-15"
}`}
key={index}
>
<div
className={
index === 2
? "flex items-center justify-center w-full h-full bg-n-7 rounded-[0.9375rem]"
: ""
}
>
<Image
src={item}
width={24}
height={24}
alt={item}
/>
</div>
</li>
))}
</ul>
</div>
<div className="relative h-[20.5rem] bg-n-8 rounded-xl overflow-hidden md:h-[25rem]">
<Image
className="w-full h-full object-cover"
src="/images/services/service-3.png"
width={517}
height={400}
alt="Smartest AI"
/>
<div className="absolute top-8 left-[3.125rem] w-full max-w-[14rem] pt-2.5 pr-2.5 pb-7 pl-5 bg-n-6 rounded-t-xl rounded-br-xl font-code text-base md:max-w-[17.5rem]">
Video generated!
<div className="absolute left-5 -bottom-[1.125rem] flex items-center justify-center w-[2.25rem] h-[2.25rem] bg-color-1 rounded-[0.75rem]">
<Image
src="/images/brainwave-symbol-white.svg"
width={26}
height={26}
alt="Brainwave"
/>
</div>
<div className="tagline absolute right-2.5 bottom-1 text-[0.625rem] text-n-3 uppercase">
just now
</div>
<svg
className="absolute right-full bottom-0 -scale-x-100"
xmlns="http://www.w3.org/2000/svg"
width="26"
height="37"
>
<path
className="fill-n-6"
d="M21.843 37.001c3.564 0 5.348-4.309 2.829-6.828L3.515 9.015A12 12 0 0 1 0 .53v36.471h21.843z"
/>
</svg>
</div>
<div className="absolute left-0 bottom-0 w-full flex items-center p-6">
<svg
className="mr-3"
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
fill="none"
>
<path
d="M8.006 2.802l.036.024 10.549 7.032.805.567c.227.183.494.437.648.808a2 2 0 0 1 0 1.532c-.154.371-.421.625-.648.808-.217.175-.5.364-.805.567L8.006 21.198l-.993.627c-.285.154-.676.331-1.132.303a2 2 0 0 1-1.476-.79c-.276-.365-.346-.788-.375-1.111S4 19.502 4 19.054V4.99v-.043l.029-1.174c.03-.323.1-.746.375-1.11a2 2 0 0 1 1.476-.79c.456-.027.847.149 1.132.304s.62.378.993.627z"
fill="#fff"
/>
</svg>
<div className="flex-1 bg-[#D9D9D9]">
<div className="w-1/2 h-0.5 bg-color-1"></div>
</div>
</div>
</div>
</div>
</div>
<div className="absolute top-0 -left-[10rem] w-[56.625rem] h-[56.625rem] opacity-50 mix-blend-color-dodge pointer-events-none">
<Image
className="absolute top-1/2 left-1/2 w-[79.5625rem] max-w-[79.5625rem] h-[88.5625rem] -translate-x-1/2 -translate-y-1/2"
src="/images/gradient.png"
width={1417}
height={1417}
alt="Gradient"
/>
</div>
</div>
</div>
</Section>
);
export default Services;

View File

@@ -3,7 +3,6 @@ import React, { useEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom'; import { createPortal } from 'react-dom';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { stockService } from '@services/eventService'; import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/** /**
* 股票信息 * 股票信息
@@ -72,11 +71,6 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
setError(null); setError(null);
try { try {
logger.debug('KLineChartModal', 'loadData', '开始加载K线数据', {
stockCode: stock.stock_code,
eventTime,
});
const response = await stockService.getKlineData( const response = await stockService.getKlineData(
stock.stock_code, stock.stock_code,
'daily', 'daily',
@@ -91,12 +85,8 @@ const KLineChartModal: React.FC<KLineChartModalProps> = ({
console.log('[KLineChartModal] 数据条数:', response.data.length); console.log('[KLineChartModal] 数据条数:', response.data.length);
setData(response.data); setData(response.data);
logger.info('KLineChartModal', 'loadData', 'K线数据加载成功', {
dataCount: response.data.length,
});
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败'; const errorMsg = err instanceof Error ? err.message : '数据加载失败';
logger.error('KLineChartModal', 'loadData', err as Error);
setError(errorMsg); setError(errorMsg);
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -33,9 +33,6 @@ import {
// 工具函数 // 工具函数
import { createSubIndicators } from './utils'; import { createSubIndicators } from './utils';
// 日志
import { logger } from '@utils/logger';
// ==================== 组件 Props ==================== // ==================== 组件 Props ====================
export interface StockChartKLineModalProps { export interface StockChartKLineModalProps {
@@ -110,10 +107,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
const handleChartTypeChange = useCallback((e: RadioChangeEvent) => { const handleChartTypeChange = useCallback((e: RadioChangeEvent) => {
const newType = e.target.value as ChartType; const newType = e.target.value as ChartType;
setChartType(newType); setChartType(newType);
logger.debug('StockChartKLineModal', 'handleChartTypeChange', '切换图表类型', {
newType,
});
}, []); }, []);
/** /**
@@ -130,10 +123,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
// 先移除所有副图指标KLineChart 会自动移除) // 先移除所有副图指标KLineChart 会自动移除)
// 然后创建新的指标 // 然后创建新的指标
createSubIndicators(chart, values); createSubIndicators(chart, values);
logger.debug('StockChartKLineModal', 'handleIndicatorChange', '切换副图指标', {
indicators: values,
});
}, },
[chart] [chart]
); );
@@ -143,7 +132,6 @@ const StockChartKLineModal: React.FC<StockChartKLineModalProps> = ({
*/ */
const handleRefresh = useCallback(() => { const handleRefresh = useCallback(() => {
loadData(); loadData();
logger.debug('StockChartKLineModal', 'handleRefresh', '刷新数据');
}, [loadData]); }, [loadData]);
// ==================== 计算属性 ==================== // ==================== 计算属性 ====================

View File

@@ -18,7 +18,6 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import * as echarts from 'echarts'; import * as echarts from 'echarts';
import { stockService } from '@services/eventService'; import { stockService } from '@services/eventService';
import { logger } from '@utils/logger';
/** /**
* 股票信息 * 股票信息
@@ -76,11 +75,6 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
setError(null); setError(null);
try { try {
logger.debug('TimelineChartModal', 'loadData', '开始加载分时图数据', {
stockCode: stock.stock_code,
eventTime,
});
const response = await stockService.getKlineData( const response = await stockService.getKlineData(
stock.stock_code, stock.stock_code,
'timeline', 'timeline',
@@ -95,12 +89,8 @@ const TimelineChartModal: React.FC<TimelineChartModalProps> = ({
console.log('[TimelineChartModal] 数据条数:', response.data.length); console.log('[TimelineChartModal] 数据条数:', response.data.length);
setData(response.data); setData(response.data);
logger.info('TimelineChartModal', 'loadData', '分时图数据加载成功', {
dataCount: response.data.length,
});
} catch (err) { } catch (err) {
const errorMsg = err instanceof Error ? err.message : '数据加载失败'; const errorMsg = err instanceof Error ? err.message : '数据加载失败';
logger.error('TimelineChartModal', 'loadData', err as Error);
setError(errorMsg); setError(errorMsg);
} finally { } finally {
setLoading(false); setLoading(false);

View File

@@ -13,7 +13,6 @@ import {
createEventHighlightOverlay, createEventHighlightOverlay,
removeAllEventMarkers, removeAllEventMarkers,
} from '../utils/eventMarkerUtils'; } from '../utils/eventMarkerUtils';
import { logger } from '@utils/logger';
export interface UseEventMarkerOptions { export interface UseEventMarkerOptions {
/** KLineChart 实例 */ /** KLineChart 实例 */
@@ -77,10 +76,6 @@ export const useEventMarker = (
const createMarker = useCallback( const createMarker = useCallback(
(time: string, label: string, color?: string) => { (time: string, label: string, color?: string) => {
if (!chart || !data || data.length === 0) { if (!chart || !data || data.length === 0) {
logger.warn('useEventMarker', 'createMarker', '图表或数据未准备好', {
hasChart: !!chart,
dataLength: data?.length || 0,
});
return; return;
} }
@@ -93,9 +88,6 @@ export const useEventMarker = (
const overlay = createEventMarkerOverlay(eventMarker, data); const overlay = createEventMarkerOverlay(eventMarker, data);
if (!overlay) { if (!overlay) {
logger.warn('useEventMarker', 'createMarker', 'Overlay 创建失败', {
eventMarker,
});
return; return;
} }
@@ -103,9 +95,6 @@ export const useEventMarker = (
const id = chart.createOverlay(overlay); const id = chart.createOverlay(overlay);
if (!id || (Array.isArray(id) && id.length === 0)) { if (!id || (Array.isArray(id) && id.length === 0)) {
logger.warn('useEventMarker', 'createMarker', '标记添加失败', {
overlay,
});
return; return;
} }
@@ -118,23 +107,9 @@ export const useEventMarker = (
const highlightResult = chart.createOverlay(highlightOverlay); const highlightResult = chart.createOverlay(highlightOverlay);
const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult; const actualHighlightId = Array.isArray(highlightResult) ? highlightResult[0] : highlightResult;
setHighlightId(actualHighlightId as string); setHighlightId(actualHighlightId as string);
logger.info('useEventMarker', 'createMarker', '事件高亮背景创建成功', {
highlightId: actualHighlightId,
});
} }
logger.info('useEventMarker', 'createMarker', '事件标记创建成功', {
markerId: actualId,
label,
time,
chartId: chart.id,
});
} catch (err) { } catch (err) {
logger.error('useEventMarker', 'createMarker', err as Error, { // 忽略创建标记时的错误
time,
label,
});
} }
}, },
[chart, data] [chart, data]
@@ -150,26 +125,17 @@ export const useEventMarker = (
try { try {
if (markerId) { if (markerId) {
chart.removeOverlay(markerId); chart.removeOverlay({ id: markerId });
} }
if (highlightId) { if (highlightId) {
chart.removeOverlay(highlightId); chart.removeOverlay({ id: highlightId });
} }
setMarker(null); setMarker(null);
setMarkerId(null); setMarkerId(null);
setHighlightId(null); setHighlightId(null);
logger.debug('useEventMarker', 'removeMarker', '移除事件标记和高亮', {
markerId,
highlightId,
chartId: chart.id,
});
} catch (err) { } catch (err) {
logger.error('useEventMarker', 'removeMarker', err as Error, { // 忽略移除标记时的错误
markerId,
highlightId,
});
} }
}, [chart, markerId, highlightId]); }, [chart, markerId, highlightId]);
@@ -186,12 +152,8 @@ export const useEventMarker = (
setMarker(null); setMarker(null);
setMarkerId(null); setMarkerId(null);
setHighlightId(null); setHighlightId(null);
logger.debug('useEventMarker', 'removeAllMarkers', '移除所有事件标记和高亮', {
chartId: chart.id,
});
} catch (err) { } catch (err) {
logger.error('useEventMarker', 'removeAllMarkers', err as Error); // 忽略移除所有标记时的错误
} }
}, [chart]); }, [chart]);
@@ -216,10 +178,10 @@ export const useEventMarker = (
if (chart) { if (chart) {
try { try {
if (markerId) { if (markerId) {
chart.removeOverlay(markerId); chart.removeOverlay({ id: markerId });
} }
if (highlightId) { if (highlightId) {
chart.removeOverlay(highlightId); chart.removeOverlay({ id: highlightId });
} }
} catch (err) { } catch (err) {
// 忽略清理时的错误 // 忽略清理时的错误

View File

@@ -10,7 +10,6 @@ import type { Chart } from 'klinecharts';
// import { useColorMode } from '@chakra-ui/react'; // ❌ 已移除深色模式支持 // import { useColorMode } from '@chakra-ui/react'; // ❌ 已移除深色模式支持
import { getTheme, getTimelineTheme } from '../config/klineTheme'; import { getTheme, getTimelineTheme } from '../config/klineTheme';
import { CHART_INIT_OPTIONS } from '../config'; import { CHART_INIT_OPTIONS } from '../config';
import { logger } from '@utils/logger';
import { avgPriceIndicator } from '../indicators/avgPriceIndicator'; import { avgPriceIndicator } from '../indicators/avgPriceIndicator';
export interface UseKLineChartOptions { export interface UseKLineChartOptions {
@@ -65,11 +64,9 @@ export const useKLineChart = (
// 全局注册自定义均价线指标(只执行一次) // 全局注册自定义均价线指标(只执行一次)
useEffect(() => { useEffect(() => {
try { try {
registerIndicator(avgPriceIndicator); registerIndicator(avgPriceIndicator as any);
logger.debug('useKLineChart', '✅ 自定义均价线指标(AVG)注册成功');
} catch (err) { } catch (err) {
// 如果已注册会报错,忽略即可 // 如果已注册会报错,忽略即可
logger.debug('useKLineChart', 'AVG指标已注册或注册失败', err);
} }
}, []); }, []);
@@ -78,16 +75,10 @@ export const useKLineChart = (
// 图表初始化函数 // 图表初始化函数
const initChart = (): boolean => { const initChart = (): boolean => {
if (!chartRef.current) { if (!chartRef.current) {
logger.warn('useKLineChart', 'init', '图表容器未挂载,将在 50ms 后重试', { containerId });
return false; return false;
} }
try { try {
logger.debug('useKLineChart', 'init', '开始初始化图表', {
containerId,
height,
colorMode,
});
// 初始化图表实例KLineChart 10.0 API // 初始化图表实例KLineChart 10.0 API
// ✅ 根据 chartType 选择主题 // ✅ 根据 chartType 选择主题
@@ -112,29 +103,16 @@ export const useKLineChart = (
// ✅ 新增:创建成交量指标窗格 // ✅ 新增:创建成交量指标窗格
try { try {
const volumePaneId = chartInstance.createIndicator('VOL', false, { chartInstance.createIndicator('VOL', false, {
height: 100, // 固定高度 100px约占整体的 20-25% height: 100, // 固定高度 100px约占整体的 20-25%
}); });
logger.debug('useKLineChart', 'init', '成交量窗格创建成功', {
volumePaneId,
});
} catch (err) { } catch (err) {
logger.warn('useKLineChart', 'init', '成交量窗格创建失败', {
error: err,
});
// 不阻塞主流程,继续执行 // 不阻塞主流程,继续执行
} }
logger.info('useKLineChart', 'init', '✅ 图表初始化成功', {
containerId,
chartId: chartInstance.id,
});
return true; return true;
} catch (err) { } catch (err) {
const error = err as Error; const error = err as Error;
logger.error('useKLineChart', 'init', error, { containerId });
setError(error); setError(error);
setIsInitialized(false); setIsInitialized(false);
return false; return false;
@@ -146,11 +124,6 @@ export const useKLineChart = (
// 成功,直接返回清理函数 // 成功,直接返回清理函数
return () => { return () => {
if (chartInstanceRef.current) { if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
containerId,
chartId: chartInstanceRef.current.id,
});
dispose(chartInstanceRef.current); dispose(chartInstanceRef.current);
chartInstanceRef.current = null; chartInstanceRef.current = null;
setChartInstance(null); // ✅ 新增:清空 state setChartInstance(null); // ✅ 新增:清空 state
@@ -161,7 +134,6 @@ export const useKLineChart = (
// 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载) // 失败则延迟重试(处理 Modal 动画延迟导致的 DOM 未挂载)
const timer = setTimeout(() => { const timer = setTimeout(() => {
logger.debug('useKLineChart', 'init', '执行延迟重试', { containerId });
initChart(); initChart();
}, 50); }, 50);
@@ -169,11 +141,6 @@ export const useKLineChart = (
return () => { return () => {
clearTimeout(timer); clearTimeout(timer);
if (chartInstanceRef.current) { if (chartInstanceRef.current) {
logger.debug('useKLineChart', 'dispose', '销毁图表实例', {
containerId,
chartId: chartInstanceRef.current.id,
});
dispose(chartInstanceRef.current); dispose(chartInstanceRef.current);
chartInstanceRef.current = null; chartInstanceRef.current = null;
setChartInstance(null); // ✅ 新增:清空 state setChartInstance(null); // ✅ 新增:清空 state
@@ -195,14 +162,8 @@ export const useKLineChart = (
? getTimelineTheme(colorMode) ? getTimelineTheme(colorMode)
: getTheme(colorMode); : getTheme(colorMode);
chartInstanceRef.current.setStyles(newTheme); chartInstanceRef.current.setStyles(newTheme);
logger.debug('useKLineChart', 'updateTheme', '更新图表主题', {
colorMode,
chartType,
chartId: chartInstanceRef.current.id,
});
} catch (err) { } catch (err) {
logger.error('useKLineChart', 'updateTheme', err as Error, { colorMode, chartType }); // 忽略主题更新错误
} }
}, [colorMode, chartType, isInitialized]); }, [colorMode, chartType, isInitialized]);
@@ -215,7 +176,6 @@ export const useKLineChart = (
const handleResize = () => { const handleResize = () => {
if (chartInstanceRef.current) { if (chartInstanceRef.current) {
chartInstanceRef.current.resize(); chartInstanceRef.current.resize();
logger.debug('useKLineChart', 'resize', '调整图表大小');
} }
}; };

View File

@@ -8,7 +8,6 @@ import { useEffect, useState, useCallback } from 'react';
import type { Chart } from 'klinecharts'; import type { Chart } from 'klinecharts';
import type { ChartType, KLineDataPoint, RawDataPoint } from '../types'; import type { ChartType, KLineDataPoint, RawDataPoint } from '../types';
import { processChartData } from '../utils/dataAdapter'; import { processChartData } from '../utils/dataAdapter';
import { logger } from '@utils/logger';
import { stockService } from '@services/eventService'; import { stockService } from '@services/eventService';
import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache'; import { klineDataCache, getCacheKey } from '@views/Community/components/StockDetailPanel/utils/klineDataCache';
@@ -78,7 +77,6 @@ export const useKLineData = (
*/ */
const loadData = useCallback(async () => { const loadData = useCallback(async () => {
if (!stockCode) { if (!stockCode) {
logger.warn('useKLineData', 'loadData', '股票代码为空', { chartType });
return; return;
} }
@@ -86,11 +84,6 @@ export const useKLineData = (
setError(null); setError(null);
try { try {
logger.debug('useKLineData', 'loadData', '开始加载数据', {
stockCode,
chartType,
eventTime,
});
// 1. 先检查缓存 // 1. 先检查缓存
const cacheKey = getCacheKey(stockCode, eventTime, chartType); const cacheKey = getCacheKey(stockCode, eventTime, chartType);
@@ -125,19 +118,8 @@ export const useKLineData = (
const processedData = processChartData(rawDataList, chartType, eventTime); const processedData = processChartData(rawDataList, chartType, eventTime);
setData(processedData); setData(processedData);
logger.info('useKLineData', 'loadData', '数据加载成功', {
stockCode,
chartType,
rawCount: rawDataList.length,
processedCount: processedData.length,
});
} catch (err) { } catch (err) {
const error = err as Error; const error = err as Error;
logger.error('useKLineData', 'loadData', error, {
stockCode,
chartType,
});
setError(error); setError(error);
setData([]); setData([]);
setRawData([]); setRawData([]);
@@ -207,9 +189,7 @@ export const useKLineData = (
(chart as any).setOffsetRightDistance(50); (chart as any).setOffsetRightDistance(50);
} }
} catch (err) { } catch (err) {
logger.error('useKLineData', 'updateChartData', err as Error, { // 忽略调整可见范围时的错误
step: '调整可见范围失败',
});
} }
}, 100); // 延迟 100ms 确保数据已加载和渲染 }, 100); // 延迟 100ms 确保数据已加载和渲染
@@ -259,14 +239,8 @@ export const useKLineData = (
}, 200); // 延迟 200ms确保均价线创建完成后再添加 }, 200); // 延迟 200ms确保均价线创建完成后再添加
} }
logger.debug(
'useKLineData',
`updateChartData - ${stockCode} (${chartType}) - ${klineData.length}条数据加载成功`
);
} catch (err) { } catch (err) {
logger.error('useKLineData', 'updateChartData', err as Error, { // 忽略更新图表数据时的错误
dataCount: klineData.length,
});
} }
}, },
[chart, stockCode, chartType] [chart, stockCode, chartType]
@@ -279,11 +253,6 @@ export const useKLineData = (
(newData: KLineDataPoint[]) => { (newData: KLineDataPoint[]) => {
setData(newData); setData(newData);
updateChartData(newData); updateChartData(newData);
logger.debug(
'useKLineData',
`updateData - ${stockCode} (${chartType}) - ${newData.length}条数据手动更新`
);
}, },
[updateChartData] [updateChartData]
); );
@@ -298,7 +267,6 @@ export const useKLineData = (
if (chart) { if (chart) {
chart.resetData(); chart.resetData();
logger.debug('useKLineData', `clearData - chartId: ${(chart as any).id}`);
} }
}, [chart]); }, [chart]);

View File

@@ -5,17 +5,18 @@
* 计算公式:累计成交额 / 累计成交量 * 计算公式:累计成交额 / 累计成交量
*/ */
import type { Indicator, KLineData } from 'klinecharts'; import type { KLineData } from 'klinecharts';
export const avgPriceIndicator: Indicator = { // 使用部分类型定义,因为 Indicator 类型很复杂
export const avgPriceIndicator = {
name: 'AVG', name: 'AVG',
shortName: 'AVG', shortName: 'AVG',
calcParams: [], calcParams: [] as number[],
shouldOhlc: false, // 不显示 OHLC 信息 shouldOhlc: false, // 不显示 OHLC 信息
shouldFormatBigNumber: false, shouldFormatBigNumber: false,
precision: 2, precision: 2,
minValue: null, minValue: null as number | null,
maxValue: null, maxValue: null as number | null,
figures: [ figures: [
{ {
@@ -61,33 +62,27 @@ export const avgPriceIndicator: Indicator = {
}, },
/** /**
* Tooltip 格式化(显示均价 + 涨跌幅) * 自定义 Tooltip 数据源
* 符合 IndicatorTooltipData 接口要求
*/ */
createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => { createTooltipDataSource: ({ kLineData, indicator, defaultStyles }: any) => {
if (!indicator?.avg) { const avgValue = kLineData?.avg;
return { const lineColor = defaultStyles?.lines?.[0]?.color || '#FF9800';
title: { text: '均价', color: defaultStyles.tooltip.text.color },
value: { text: '--', color: '#FF9800' },
};
}
const avgPrice = indicator.avg;
const prevClose = kLineData?.prev_close;
// 计算均价涨跌幅
let changeText = `¥${avgPrice.toFixed(2)}`;
if (prevClose && prevClose > 0) {
const changePercent = ((avgPrice - prevClose) / prevClose * 100).toFixed(2);
const changeValue = (avgPrice - prevClose).toFixed(2);
changeText = `¥${avgPrice.toFixed(2)} (${changeValue}, ${changePercent}%)`;
}
return { return {
title: { text: '均价', color: defaultStyles.tooltip.text.color }, name: 'AVG',
calcParamsText: '',
features: [] as any[],
legends: [
{
title: { text: '均价: ', color: lineColor },
value: { value: {
text: changeText, text: avgValue !== undefined ? avgValue.toFixed(2) : '--',
color: '#FF9800', color: lineColor,
}, },
},
],
}; };
}, },
}; };

View File

@@ -4,8 +4,7 @@
* 包含图表初始化、技术指标管理等通用逻辑 * 包含图表初始化、技术指标管理等通用逻辑
*/ */
import type { Chart } from 'klinecharts'; import type { Chart, ActionType } from 'klinecharts';
import { logger } from '@utils/logger';
/** /**
* 安全地执行图表操作(捕获异常) * 安全地执行图表操作(捕获异常)
@@ -21,7 +20,6 @@ export const safeChartOperation = <T>(
try { try {
return fn(); return fn();
} catch (error) { } catch (error) {
logger.error('chartUtils', operation, error as Error);
return null; return null;
} }
}; };
@@ -50,13 +48,6 @@ export const createIndicator = (
isStack isStack
); );
logger.debug('chartUtils', 'createIndicator', '创建技术指标', {
indicatorName,
params,
isStack,
indicatorId,
});
return indicatorId; return indicatorId;
}); });
}; };
@@ -69,8 +60,11 @@ export const createIndicator = (
*/ */
export const removeIndicator = (chart: Chart, indicatorId?: string): void => { export const removeIndicator = (chart: Chart, indicatorId?: string): void => {
safeChartOperation('removeIndicator', () => { safeChartOperation('removeIndicator', () => {
chart.removeIndicator(indicatorId); if (indicatorId) {
logger.debug('chartUtils', 'removeIndicator', '移除技术指标', { indicatorId }); chart.removeIndicator({ id: indicatorId });
} else {
chart.removeIndicator({});
}
}); });
}; };
@@ -94,11 +88,6 @@ export const createSubIndicators = (
} }
}); });
logger.debug('chartUtils', 'createSubIndicators', '批量创建副图指标', {
indicators,
createdIds: ids,
});
return ids; return ids;
}; };
@@ -130,10 +119,6 @@ export const setChartZoom = (chart: Chart, zoom: number): void => {
}, },
}); });
logger.debug('chartUtils', 'setChartZoom', '设置图表缩放', {
zoom,
newBarSpace,
});
}); });
}; };
@@ -147,8 +132,6 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
safeChartOperation('scrollToTimestamp', () => { safeChartOperation('scrollToTimestamp', () => {
// KLineChart 10.0: 使用 scrollToTimestamp 方法 // KLineChart 10.0: 使用 scrollToTimestamp 方法
chart.scrollToTimestamp(timestamp); chart.scrollToTimestamp(timestamp);
logger.debug('chartUtils', 'scrollToTimestamp', '滚动到指定时间', { timestamp });
}); });
}; };
@@ -160,7 +143,6 @@ export const scrollToTimestamp = (chart: Chart, timestamp: number): void => {
export const resizeChart = (chart: Chart): void => { export const resizeChart = (chart: Chart): void => {
safeChartOperation('resizeChart', () => { safeChartOperation('resizeChart', () => {
chart.resize(); chart.resize();
logger.debug('chartUtils', 'resizeChart', '调整图表大小');
}); });
}; };
@@ -194,7 +176,6 @@ export const getVisibleRange = (chart: Chart): { from: number; to: number } | nu
export const clearChartData = (chart: Chart): void => { export const clearChartData = (chart: Chart): void => {
safeChartOperation('clearChartData', () => { safeChartOperation('clearChartData', () => {
chart.resetData(); chart.resetData();
logger.debug('chartUtils', 'clearChartData', '清空图表数据');
}); });
}; };
@@ -213,11 +194,6 @@ export const exportChartImage = (
// KLineChart 10.0: 使用 getConvertPictureUrl 方法 // KLineChart 10.0: 使用 getConvertPictureUrl 方法
const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff'); const imageData = chart.getConvertPictureUrl(includeOverlay, 'png', '#ffffff');
logger.debug('chartUtils', 'exportChartImage', '导出图表图片', {
includeOverlay,
hasData: !!imageData,
});
return imageData; return imageData;
}); });
}; };
@@ -235,8 +211,6 @@ export const toggleCrosshair = (chart: Chart, show: boolean): void => {
show, show,
}, },
}); });
logger.debug('chartUtils', 'toggleCrosshair', '切换十字光标', { show });
}); });
}; };
@@ -253,8 +227,6 @@ export const toggleGrid = (chart: Chart, show: boolean): void => {
show, show,
}, },
}); });
logger.debug('chartUtils', 'toggleGrid', '切换网格', { show });
}); });
}; };
@@ -267,12 +239,11 @@ export const toggleGrid = (chart: Chart, show: boolean): void => {
*/ */
export const subscribeChartEvent = ( export const subscribeChartEvent = (
chart: Chart, chart: Chart,
eventName: string, eventName: ActionType,
handler: (...args: any[]) => void handler: (...args: any[]) => void
): void => { ): void => {
safeChartOperation(`subscribeChartEvent:${eventName}`, () => { safeChartOperation(`subscribeChartEvent:${eventName}`, () => {
chart.subscribeAction(eventName, handler); chart.subscribeAction(eventName, handler);
logger.debug('chartUtils', 'subscribeChartEvent', '订阅图表事件', { eventName });
}); });
}; };
@@ -285,11 +256,10 @@ export const subscribeChartEvent = (
*/ */
export const unsubscribeChartEvent = ( export const unsubscribeChartEvent = (
chart: Chart, chart: Chart,
eventName: string, eventName: ActionType,
handler: (...args: any[]) => void handler: (...args: any[]) => void
): void => { ): void => {
safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => { safeChartOperation(`unsubscribeChartEvent:${eventName}`, () => {
chart.unsubscribeAction(eventName, handler); chart.unsubscribeAction(eventName, handler);
logger.debug('chartUtils', 'unsubscribeChartEvent', '取消订阅图表事件', { eventName });
}); });
}; };

View File

@@ -6,7 +6,6 @@
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import type { KLineDataPoint, RawDataPoint, ChartType } from '../types'; import type { KLineDataPoint, RawDataPoint, ChartType } from '../types';
import { logger } from '@utils/logger';
/** /**
* 将后端原始数据转换为 KLineChart 标准格式 * 将后端原始数据转换为 KLineChart 标准格式
@@ -22,7 +21,6 @@ export const convertToKLineData = (
eventTime?: string eventTime?: string
): KLineDataPoint[] => { ): KLineDataPoint[] => {
if (!rawData || !Array.isArray(rawData) || rawData.length === 0) { if (!rawData || !Array.isArray(rawData) || rawData.length === 0) {
logger.warn('dataAdapter', 'convertToKLineData', '原始数据为空', { chartType });
return []; return [];
} }
@@ -37,15 +35,11 @@ export const convertToKLineData = (
low: Number(item.low) || 0, low: Number(item.low) || 0,
close: Number(item.close) || 0, close: Number(item.close) || 0,
volume: Number(item.volume) || 0, volume: Number(item.volume) || 0,
turnover: item.turnover ? Number(item.turnover) : undefined, turnover: (item as any).turnover ? Number((item as any).turnover) : undefined,
prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线) prev_close: item.prev_close ? Number(item.prev_close) : undefined, // ✅ 新增:昨收价(用于百分比计算和基准线)
}; };
}); });
} catch (error) { } catch (error) {
logger.error('dataAdapter', 'convertToKLineData', error as Error, {
chartType,
dataLength: rawData.length,
});
return []; return [];
} }
}; };
@@ -90,7 +84,6 @@ const parseTimestamp = (
} }
// 默认返回当前时间(避免图表崩溃) // 默认返回当前时间(避免图表崩溃)
logger.warn('dataAdapter', 'parseTimestamp', '无法解析时间戳,使用当前时间', { item });
return Date.now(); return Date.now();
}; };
@@ -109,7 +102,6 @@ const parseTimelineTimestamp = (time: string, eventTime: string): number => {
const eventDate = dayjs(eventTime).startOf('day'); const eventDate = dayjs(eventTime).startOf('day');
return eventDate.hour(hours).minute(minutes).second(0).valueOf(); return eventDate.hour(hours).minute(minutes).second(0).valueOf();
} catch (error) { } catch (error) {
logger.error('dataAdapter', 'parseTimelineTimestamp', error as Error, { time, eventTime });
return dayjs(eventTime).valueOf(); return dayjs(eventTime).valueOf();
} }
}; };
@@ -126,19 +118,16 @@ export const validateAndCleanData = (data: KLineDataPoint[]): KLineDataPoint[] =
return data.filter((item) => { return data.filter((item) => {
// 移除价格为 0 或负数的数据 // 移除价格为 0 或负数的数据
if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) { if (item.open <= 0 || item.high <= 0 || item.low <= 0 || item.close <= 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '价格异常,已移除', { item });
return false; return false;
} }
// 移除 high < low 的数据(数据错误) // 移除 high < low 的数据(数据错误)
if (item.high < item.low) { if (item.high < item.low) {
logger.warn('dataAdapter', 'validateAndCleanData', '最高价 < 最低价,已移除', { item });
return false; return false;
} }
// 移除成交量为负数的数据 // 移除成交量为负数的数据
if (item.volume < 0) { if (item.volume < 0) {
logger.warn('dataAdapter', 'validateAndCleanData', '成交量异常,已移除', { item });
return false; return false;
} }
@@ -213,17 +202,8 @@ export const trimDataByEventTime = (
return item.timestamp >= startTime && item.timestamp <= endTime; return item.timestamp >= startTime && item.timestamp <= endTime;
}); });
logger.debug('dataAdapter', 'trimDataByEventTime', '数据时间范围裁剪完成', {
originalLength: data.length,
trimmedLength: trimmedData.length,
eventTime,
chartType,
dateRange: `${dayjs(startTime).format('YYYY-MM-DD')} ~ ${dayjs(endTime).format('YYYY-MM-DD')}`,
});
return trimmedData; return trimmedData;
} catch (error) { } catch (error) {
logger.error('dataAdapter', 'trimDataByEventTime', error as Error, { eventTime });
return data; // 出错时返回原始数据 return data; // 出错时返回原始数据
} }
}; };
@@ -260,13 +240,6 @@ export const processChartData = (
data = trimDataByEventTime(data, eventTime, chartType); data = trimDataByEventTime(data, eventTime, chartType);
} }
logger.debug('dataAdapter', 'processChartData', '数据处理完成', {
rawLength: rawData.length,
processedLength: data.length,
chartType,
hasEventTime: !!eventTime,
});
return data; return data;
}; };

View File

@@ -9,7 +9,6 @@ import type { OverlayCreate } from 'klinecharts';
import type { EventMarker, KLineDataPoint } from '../types'; import type { EventMarker, KLineDataPoint } from '../types';
import { EVENT_MARKER_CONFIG } from '../config'; import { EVENT_MARKER_CONFIG } from '../config';
import { findClosestDataPoint } from './dataAdapter'; import { findClosestDataPoint } from './dataAdapter';
import { logger } from '@utils/logger';
/** /**
* 创建事件标记 OverlayKLineChart 10.0 格式) * 创建事件标记 OverlayKLineChart 10.0 格式)
@@ -27,10 +26,6 @@ export const createEventMarkerOverlay = (
const closestPoint = findClosestDataPoint(data, marker.timestamp); const closestPoint = findClosestDataPoint(data, marker.timestamp);
if (!closestPoint) { if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventMarkerOverlay', '未找到匹配的数据点', {
markerId: marker.id,
timestamp: marker.timestamp,
});
return null; return null;
} }
@@ -64,10 +59,6 @@ export const createEventMarkerOverlay = (
style: 'fill', style: 'fill',
color: marker.color, color: marker.color,
borderRadius: EVENT_MARKER_CONFIG.text.borderRadius, borderRadius: EVENT_MARKER_CONFIG.text.borderRadius,
paddingLeft: EVENT_MARKER_CONFIG.text.padding,
paddingRight: EVENT_MARKER_CONFIG.text.padding,
paddingTop: EVENT_MARKER_CONFIG.text.padding,
paddingBottom: EVENT_MARKER_CONFIG.text.padding,
}, },
}, },
// 标记文本内容 // 标记文本内容
@@ -77,17 +68,8 @@ export const createEventMarkerOverlay = (
}, },
}; };
logger.debug('eventMarkerUtils', 'createEventMarkerOverlay', '创建事件标记', {
markerId: marker.id,
timestamp: closestPoint.timestamp,
label: marker.label,
});
return overlay; return overlay;
} catch (error) { } catch (error) {
logger.error('eventMarkerUtils', 'createEventMarkerOverlay', error as Error, {
markerId: marker.id,
});
return null; return null;
} }
}; };
@@ -108,7 +90,6 @@ export const createEventHighlightOverlay = (
const closestPoint = findClosestDataPoint(data, eventTimestamp); const closestPoint = findClosestDataPoint(data, eventTimestamp);
if (!closestPoint) { if (!closestPoint) {
logger.warn('eventMarkerUtils', 'createEventHighlightOverlay', '未找到匹配的数据点');
return null; return null;
} }
@@ -135,14 +116,8 @@ export const createEventHighlightOverlay = (
}, },
}; };
logger.debug('eventMarkerUtils', 'createEventHighlightOverlay', '创建事件高亮覆盖层', {
timestamp: closestPoint.timestamp,
eventTime,
});
return overlay; return overlay;
} catch (error) { } catch (error) {
logger.error('eventMarkerUtils', 'createEventHighlightOverlay', error as Error);
return null; return null;
} }
}; };
@@ -219,11 +194,6 @@ export const createEventMarkerOverlays = (
} }
}); });
logger.debug('eventMarkerUtils', 'createEventMarkerOverlays', '批量创建事件标记', {
totalMarkers: markers.length,
createdOverlays: overlays.length,
});
return overlays; return overlays;
}; };
@@ -235,10 +205,9 @@ export const createEventMarkerOverlays = (
*/ */
export const removeEventMarker = (chart: any, markerId: string): void => { export const removeEventMarker = (chart: any, markerId: string): void => {
try { try {
chart.removeOverlay(markerId); chart.removeOverlay({ id: markerId });
logger.debug('eventMarkerUtils', 'removeEventMarker', '移除事件标记', { markerId });
} catch (error) { } catch (error) {
logger.error('eventMarkerUtils', 'removeEventMarker', error as Error, { markerId }); // 忽略移除标记时的错误
} }
}; };
@@ -251,9 +220,8 @@ export const removeAllEventMarkers = (chart: any): void => {
try { try {
// KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays // KLineChart 10.0 API: removeOverlay() 不传参数时移除所有 overlays
chart.removeOverlay(); chart.removeOverlay();
logger.debug('eventMarkerUtils', 'removeAllEventMarkers', '移除所有事件标记');
} catch (error) { } catch (error) {
logger.error('eventMarkerUtils', 'removeAllEventMarkers', error as Error); // 忽略移除所有标记时的错误
} }
}; };
@@ -275,13 +243,8 @@ export const updateEventMarker = (
// 重新创建标记KLineChart 10.0 不支持直接更新 overlay // 重新创建标记KLineChart 10.0 不支持直接更新 overlay
// 注意:需要在调用方重新创建并添加 overlay // 注意:需要在调用方重新创建并添加 overlay
logger.debug('eventMarkerUtils', 'updateEventMarker', '更新事件标记', {
markerId,
updates,
});
} catch (error) { } catch (error) {
logger.error('eventMarkerUtils', 'updateEventMarker', error as Error, { markerId }); // 忽略更新标记时的错误
} }
}; };
@@ -309,12 +272,8 @@ export const highlightEventMarker = (
}, },
}); });
logger.debug('eventMarkerUtils', 'highlightEventMarker', '高亮事件标记', {
markerId,
highlight,
});
} catch (error) { } catch (error) {
logger.error('eventMarkerUtils', 'highlightEventMarker', error as Error, { markerId }); // 忽略高亮标记时的错误
} }
}; };

View File

@@ -2,11 +2,61 @@
import React, { createContext, useContext, useState, useEffect } from 'react'; import React, { createContext, useContext, useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useToast } from '@chakra-ui/react'; import { useToast } from '@chakra-ui/react';
import { logger } from '../utils/logger'; import { logger } from '@utils/logger';
import { useNotification } from '../contexts/NotificationContext'; import { performanceMonitor } from '@utils/performanceMonitor';
import { identifyUser, resetUser, trackEvent } from '@lib/posthog'; import { useNotification } from '@contexts/NotificationContext';
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
// import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
import { SPECIAL_EVENTS } from '@lib/constants'; import { SPECIAL_EVENTS } from '@lib/constants';
// ⚡ PostHog 延迟加载模块(动态导入后缓存)
let posthogModule = null;
/**
* ⚡ 延迟加载 PostHog 模块
*/
const loadPostHogModule = async () => {
if (posthogModule) return posthogModule;
try {
posthogModule = await import('@lib/posthog');
return posthogModule;
} catch (error) {
logger.error('AuthContext', 'PostHog 模块加载失败', error);
return null;
}
};
/**
* ⚡ 延迟调用 identifyUser
*/
const identifyUserLazy = async (userId, userProperties) => {
const module = await loadPostHogModule();
if (module) {
module.identifyUser(userId, userProperties);
}
};
/**
* ⚡ 延迟调用 resetUser
*/
const resetUserLazy = async () => {
const module = await loadPostHogModule();
if (module) {
module.resetUser();
}
};
/**
* ⚡ 延迟调用 trackEvent使用异步版本
*/
const trackEventLazy = async (eventName, properties) => {
const module = await loadPostHogModule();
if (module) {
module.trackEventAsync(eventName, properties);
}
};
// 创建认证上下文 // 创建认证上下文
const AuthContext = createContext(); const AuthContext = createContext();
@@ -37,6 +87,9 @@ export const AuthProvider = ({ children }) => {
// 检查Session状态 // 检查Session状态
const checkSession = async () => { const checkSession = async () => {
// ⚡ 性能标记:认证检查开始
performanceMonitor.mark('auth-check-start');
// 节流检查 // 节流检查
const now = Date.now(); const now = Date.now();
const timeSinceLastCheck = now - lastCheckTimeRef.current; const timeSinceLastCheck = now - lastCheckTimeRef.current;
@@ -47,6 +100,8 @@ export const AuthProvider = ({ children }) => {
minInterval: `${MIN_CHECK_INTERVAL}ms`, minInterval: `${MIN_CHECK_INTERVAL}ms`,
reason: '距离上次请求间隔太短' reason: '距离上次请求间隔太短'
}); });
// ⚡ 性能标记:认证检查结束(节流情况)
performanceMonitor.mark('auth-check-end');
return; return;
} }
@@ -93,8 +148,8 @@ export const AuthProvider = ({ children }) => {
return prevUser; return prevUser;
} }
// ✅ 识别用户身份到 PostHog // ✅ 识别用户身份到 PostHog(延迟加载)
identifyUser(data.user.id, { identifyUserLazy(data.user.id, {
email: data.user.email, email: data.user.email,
username: data.user.username, username: data.user.username,
subscription_tier: data.user.subscription_tier, subscription_tier: data.user.subscription_tier,
@@ -125,6 +180,8 @@ export const AuthProvider = ({ children }) => {
setUser((prev) => prev === null ? prev : null); setUser((prev) => prev === null ? prev : null);
setIsAuthenticated((prev) => prev === false ? prev : false); setIsAuthenticated((prev) => prev === false ? prev : false);
} finally { } finally {
// ⚡ 性能标记:认证检查结束
performanceMonitor.mark('auth-check-end');
// ⚡ 只在 isLoading 为 true 时才设置为 false避免不必要的状态更新 // ⚡ 只在 isLoading 为 true 时才设置为 false避免不必要的状态更新
setIsLoading((prev) => prev === false ? prev : false); setIsLoading((prev) => prev === false ? prev : false);
} }
@@ -346,8 +403,8 @@ export const AuthProvider = ({ children }) => {
credentials: 'include' credentials: 'include'
}); });
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份 // ✅ 追踪登出事件(延迟加载,必须在 resetUser() 之前)
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, { trackEventLazy(SPECIAL_EVENTS.USER_LOGGED_OUT, {
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
user_id: user?.id || null, user_id: user?.id || null,
session_duration_minutes: user?.session_start session_duration_minutes: user?.session_start
@@ -355,8 +412,8 @@ export const AuthProvider = ({ children }) => {
: null, : null,
}); });
// ✅ 重置 PostHog 用户会话 // ✅ 重置 PostHog 用户会话(延迟加载)
resetUser(); resetUserLazy();
// 清除本地状态 // 清除本地状态
setUser(null); setUser(null);

View File

@@ -9,6 +9,8 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react'; import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
import { BellIcon } from '@chakra-ui/icons'; import { BellIcon } from '@chakra-ui/icons';
import { useSelector } from 'react-redux';
import { selectIsMobile } from '@/store/slices/deviceSlice';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import socket from '../services/socket'; import socket from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav'; import notificationSound from '../assets/sounds/notification.wav';
@@ -27,6 +29,9 @@ const CONNECTION_STATUS = {
RECONNECTED: 'reconnected', // 重连成功显示2秒后自动变回 CONNECTED RECONNECTED: 'reconnected', // 重连成功显示2秒后自动变回 CONNECTED
}; };
// ⚡ 模块级变量:防止 React Strict Mode 导致的重复初始化
let socketInitialized = false;
// 创建通知上下文 // 创建通知上下文
const NotificationContext = createContext(); const NotificationContext = createContext();
@@ -41,6 +46,10 @@ export const useNotification = () => {
// 通知提供者组件 // 通知提供者组件
export const NotificationProvider = ({ children }) => { export const NotificationProvider = ({ children }) => {
// ⚡ 移动端检测(使用 Redux 状态)
const isMobile = useSelector(selectIsMobile);
// ========== 所有 Hooks 必须在条件判断之前调用React 规则) ==========
const toast = useToast(); const toast = useToast();
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@@ -562,8 +571,8 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Auto-requesting browser permission on notification'); logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
await requestBrowserPermission(); await requestBrowserPermission();
} }
// 如果权限是denied已拒绝提供设置指引 // 如果权限是denied已拒绝提供设置指引(仅 PC 端显示)
else if (browserPermission === 'denied') { else if (browserPermission === 'denied' && !isMobile) {
const toastId = 'browser-permission-denied-guide'; const toastId = 'browser-permission-denied-guide';
if (!toast.isActive(toastId)) { if (!toast.isActive(toastId)) {
toast({ toast({
@@ -649,9 +658,24 @@ export const NotificationProvider = ({ children }) => {
}, [adaptEventToNotification]); }, [adaptEventToNotification]);
// ========== 连接到 Socket 服务(⚡ 方案2: 只执行一次 ========== // ========== 连接到 Socket 服务(⚡ 异步初始化,不阻塞首屏 ==========
useEffect(() => { useEffect(() => {
logger.info('NotificationContext', '初始化 Socket 连接方案2只注册一次'); // ⚡ 防止 React Strict Mode 导致的重复初始化
if (socketInitialized) {
logger.debug('NotificationContext', 'Socket 已初始化跳过重复执行Strict Mode 保护)');
return;
}
let cleanupCalled = false;
let idleCallbackId;
let timeoutId;
// ⚡ Socket 初始化函数(将在浏览器空闲时执行)
const initSocketConnection = () => {
if (cleanupCalled || socketInitialized) return; // 防止组件卸载后执行或重复初始化
socketInitialized = true; // 标记已初始化
logger.info('NotificationContext', '初始化 Socket 连接(异步执行,不阻塞首屏)');
// ========== 监听连接成功(首次连接 + 重连) ========== // ========== 监听连接成功(首次连接 + 重连) ==========
socket.on('connect', () => { socket.on('connect', () => {
@@ -682,6 +706,8 @@ export const NotificationProvider = ({ children }) => {
} }
// ⚡ 重连后只需重新订阅,不需要重新注册监听器 // ⚡ 重连后只需重新订阅,不需要重新注册监听器
// 使用 setTimeout(0) 确保 socketService 内部状态已同步
setTimeout(() => {
logger.info('NotificationContext', '重新订阅事件推送'); logger.info('NotificationContext', '重新订阅事件推送');
if (socket.subscribeToEvents) { if (socket.subscribeToEvents) {
@@ -695,6 +721,7 @@ export const NotificationProvider = ({ children }) => {
} else { } else {
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用'); logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
} }
}, 0);
}); });
// ========== 监听断开连接 ========== // ========== 监听断开连接 ==========
@@ -811,7 +838,7 @@ export const NotificationProvider = ({ children }) => {
} }
}); });
logger.info('NotificationContext', '所有监听器已注册(只注册一次)'); logger.info('NotificationContext', '所有监听器已注册');
// ========== 获取最大重连次数 ========== // ========== 获取最大重连次数 ==========
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity; const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
@@ -821,11 +848,33 @@ export const NotificationProvider = ({ children }) => {
// ========== 启动连接 ========== // ========== 启动连接 ==========
logger.info('NotificationContext', '调用 socket.connect()'); logger.info('NotificationContext', '调用 socket.connect()');
socket.connect(); socket.connect();
};
// ⚡ 使用 requestIdleCallback 在浏览器空闲时初始化 Socket
// 降级到 setTimeout(0) 以兼容不支持的浏览器(如 Safari
if ('requestIdleCallback' in window) {
idleCallbackId = window.requestIdleCallback(initSocketConnection, {
timeout: 3000 // 最多等待 3 秒,确保连接不会延迟太久
});
logger.debug('NotificationContext', 'Socket 初始化已排入 requestIdleCallback');
} else {
timeoutId = setTimeout(initSocketConnection, 0);
logger.debug('NotificationContext', 'Socket 初始化已排入 setTimeout(0)(降级模式)');
}
// ========== 清理函数(组件卸载时) ========== // ========== 清理函数(组件卸载时) ==========
return () => { return () => {
cleanupCalled = true;
logger.info('NotificationContext', '清理 Socket 连接'); logger.info('NotificationContext', '清理 Socket 连接');
// 取消待执行的初始化
if (idleCallbackId && 'cancelIdleCallback' in window) {
window.cancelIdleCallback(idleCallbackId);
}
if (timeoutId) {
clearTimeout(timeoutId);
}
// 清理 reconnected 状态定时器 // 清理 reconnected 状态定时器
if (reconnectedTimerRef.current) { if (reconnectedTimerRef.current) {
clearTimeout(reconnectedTimerRef.current); clearTimeout(reconnectedTimerRef.current);
@@ -966,6 +1015,39 @@ export const NotificationProvider = ({ children }) => {
}; };
}, [browserPermission, toast]); }, [browserPermission, toast]);
// ⚡ 移动端禁用完整通知能力:返回空壳 Provider
// 注意:此判断必须在所有 Hooks 之后React 规则要求 Hooks 调用顺序一致)
if (isMobile) {
const emptyValue = {
notifications: [],
isConnected: false,
soundEnabled: false,
browserPermission: 'default',
connectionStatus: CONNECTION_STATUS.DISCONNECTED,
reconnectAttempt: 0,
maxReconnectAttempts: 0,
addNotification: () => null,
removeNotification: () => {},
clearAllNotifications: () => {},
toggleSound: () => {},
requestBrowserPermission: () => Promise.resolve('default'),
trackNotificationClick: () => {},
retryConnection: () => {},
showWelcomeGuide: () => {},
showCommunityGuide: () => {},
showFirstFollowGuide: () => {},
registerEventUpdateCallback: () => () => {},
unregisterEventUpdateCallback: () => {},
};
return (
<NotificationContext.Provider value={emptyValue}>
{children}
</NotificationContext.Provider>
);
}
// ========== 桌面端:完整通知功能 ==========
const value = { const value = {
notifications, notifications,
isConnected, isConnected,

View File

@@ -82,7 +82,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
...getBaseProperties(), ...getBaseProperties(),
source, source,
}); });
logger.debug('useAuthEvents', '💬 WeChat Login Initiated', { source });
}, [track, getBaseProperties]); }, [track, getBaseProperties]);
// ==================== 手机验证码流程 ==================== // ==================== 手机验证码流程 ====================
@@ -186,7 +185,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
session_id: sessionId?.substring(0, 8) + '...', session_id: sessionId?.substring(0, 8) + '...',
has_auth_url: Boolean(authUrl), has_auth_url: Boolean(authUrl),
}); });
logger.debug('useAuthEvents', '🔲 WeChat QR Code Displayed', { sessionId: sessionId?.substring(0, 8) });
}, [track, getBaseProperties]); }, [track, getBaseProperties]);
/** /**
@@ -198,7 +196,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
...getBaseProperties(), ...getBaseProperties(),
session_id: sessionId?.substring(0, 8) + '...', session_id: sessionId?.substring(0, 8) + '...',
}); });
logger.debug('useAuthEvents', '📱 WeChat QR Code Scanned', { sessionId: sessionId?.substring(0, 8) });
}, [track, getBaseProperties]); }, [track, getBaseProperties]);
/** /**
@@ -212,7 +209,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
session_id: sessionId?.substring(0, 8) + '...', session_id: sessionId?.substring(0, 8) + '...',
time_elapsed: timeElapsed, time_elapsed: timeElapsed,
}); });
logger.debug('useAuthEvents', '⏰ WeChat QR Code Expired', { sessionId: sessionId?.substring(0, 8), timeElapsed });
}, [track, getBaseProperties]); }, [track, getBaseProperties]);
/** /**
@@ -226,7 +222,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
old_session_id: oldSessionId?.substring(0, 8) + '...', old_session_id: oldSessionId?.substring(0, 8) + '...',
new_session_id: newSessionId?.substring(0, 8) + '...', new_session_id: newSessionId?.substring(0, 8) + '...',
}); });
logger.debug('useAuthEvents', '🔄 WeChat QR Code Refreshed');
}, [track, getBaseProperties]); }, [track, getBaseProperties]);
/** /**
@@ -242,7 +237,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
old_status: oldStatus, old_status: oldStatus,
new_status: newStatus, new_status: newStatus,
}); });
logger.debug('useAuthEvents', '🔄 WeChat Status Changed', { oldStatus, newStatus });
}, [track, getBaseProperties]); }, [track, getBaseProperties]);
/** /**
@@ -250,7 +244,6 @@ export const useAuthEvents = ({ component = 'AuthFormContent', isMobile = false
*/ */
const trackWechatH5Redirect = useCallback(() => { const trackWechatH5Redirect = useCallback(() => {
track(ACTIVATION_EVENTS.WECHAT_H5_REDIRECT, getBaseProperties()); track(ACTIVATION_EVENTS.WECHAT_H5_REDIRECT, getBaseProperties());
logger.debug('useAuthEvents', '🔗 WeChat H5 Redirect');
}, [track, getBaseProperties]); }, [track, getBaseProperties]);
// ==================== 登录/注册结果 ==================== // ==================== 登录/注册结果 ====================

35
src/hooks/useDevice.js Normal file
View File

@@ -0,0 +1,35 @@
/**
* useDevice Hook
*
* 封装设备类型检测,提供简洁的 API 供组件使用
*
* @example
* const { isMobile, isTablet, isDesktop, deviceType } = useDevice();
*
* if (isMobile) return <MobileView />;
* if (isTablet) return <TabletView />;
* return <DesktopView />;
*/
import { useSelector } from 'react-redux';
import {
selectIsMobile,
selectIsTablet,
selectIsDesktop,
selectDeviceType,
} from '@/store/slices/deviceSlice';
export const useDevice = () => {
const isMobile = useSelector(selectIsMobile);
const isTablet = useSelector(selectIsTablet);
const isDesktop = useSelector(selectIsDesktop);
const deviceType = useSelector(selectDeviceType);
return {
isMobile,
isTablet,
isDesktop,
deviceType,
};
};
export default useDevice;

View File

@@ -12,6 +12,7 @@ import {
resetToFree, resetToFree,
selectSubscriptionInfo, selectSubscriptionInfo,
selectSubscriptionLoading, selectSubscriptionLoading,
selectSubscriptionLoaded,
selectSubscriptionError, selectSubscriptionError,
selectSubscriptionModalOpen selectSubscriptionModalOpen
} from '../store/slices/subscriptionSlice'; } from '../store/slices/subscriptionSlice';
@@ -66,21 +67,24 @@ export const useSubscription = () => {
// Redux 状态 // Redux 状态
const subscriptionInfo = useSelector(selectSubscriptionInfo); const subscriptionInfo = useSelector(selectSubscriptionInfo);
const loading = useSelector(selectSubscriptionLoading); const loading = useSelector(selectSubscriptionLoading);
const loaded = useSelector(selectSubscriptionLoaded);
const error = useSelector(selectSubscriptionError); const error = useSelector(selectSubscriptionError);
const isSubscriptionModalOpen = useSelector(selectSubscriptionModalOpen); const isSubscriptionModalOpen = useSelector(selectSubscriptionModalOpen);
// 自动加载订阅信息 // 自动加载订阅信息(带防重复逻辑)
useEffect(() => { useEffect(() => {
if (isAuthenticated && user) { if (isAuthenticated && user) {
// 用户已登录,加载订阅信息 // 只在未加载且未在加载中时才请求,避免多个组件重复调用
if (!loaded && !loading) {
dispatch(fetchSubscriptionInfo()); dispatch(fetchSubscriptionInfo());
logger.debug('useSubscription', '加载订阅信息', { userId: user.id }); logger.debug('useSubscription', '加载订阅信息', { userId: user.id });
}
} else { } else {
// 用户未登录,重置为免费版 // 用户未登录,重置为免费版
dispatch(resetToFree()); dispatch(resetToFree());
logger.debug('useSubscription', '用户未登录,重置为免费版');
} }
}, [isAuthenticated, user, dispatch]); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [isAuthenticated, user?.id, dispatch, loaded, loading]);
// 获取订阅级别数值 // 获取订阅级别数值
const getSubscriptionLevel = (type = null) => { const getSubscriptionLevel = (type = null) => {

View File

@@ -1,394 +0,0 @@
// src/hooks/useSubscriptionEvents.js
// 订阅和支付事件追踪 Hook
import { useCallback } from 'react';
import { usePostHogTrack } from './usePostHogRedux';
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
import { logger } from '../utils/logger';
/**
* 订阅和支付事件追踪 Hook
* @param {Object} options - 配置选项
* @param {Object} options.currentSubscription - 当前订阅信息
* @returns {Object} 事件追踪处理函数集合
*/
export const useSubscriptionEvents = ({ currentSubscription = null } = {}) => {
const { track } = usePostHogTrack();
/**
* 追踪付费墙展示
* @param {string} feature - 被限制的功能名称
* @param {string} requiredPlan - 需要的订阅计划
* @param {string} triggerLocation - 触发位置
*/
const trackPaywallShown = useCallback((feature, requiredPlan = 'pro', triggerLocation = '') => {
if (!feature) {
logger.warn('useSubscriptionEvents', 'trackPaywallShown: feature is required');
return;
}
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
feature,
required_plan: requiredPlan,
current_plan: currentSubscription?.plan || 'free',
trigger_location: triggerLocation,
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '🚧 Paywall Shown', {
feature,
requiredPlan,
triggerLocation,
});
}, [track, currentSubscription]);
/**
* 追踪付费墙关闭
* @param {string} feature - 功能名称
* @param {string} closeMethod - 关闭方式 ('dismiss' | 'upgrade_clicked' | 'back_button')
*/
const trackPaywallDismissed = useCallback((feature, closeMethod = 'dismiss') => {
if (!feature) {
logger.warn('useSubscriptionEvents', 'trackPaywallDismissed: feature is required');
return;
}
track(REVENUE_EVENTS.PAYWALL_DISMISSED, {
feature,
close_method: closeMethod,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '❌ Paywall Dismissed', {
feature,
closeMethod,
});
}, [track, currentSubscription]);
/**
* 追踪升级按钮点击
* @param {string} targetPlan - 目标订阅计划
* @param {string} source - 来源位置
* @param {string} feature - 关联的功能(如果从付费墙点击)
*/
const trackUpgradePlanClicked = useCallback((targetPlan = 'pro', source = '', feature = '') => {
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
current_plan: currentSubscription?.plan || 'free',
target_plan: targetPlan,
source,
feature: feature || null,
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '⬆️ Upgrade Plan Clicked', {
currentPlan: currentSubscription?.plan,
targetPlan,
source,
feature,
});
}, [track, currentSubscription]);
/**
* 追踪订阅页面查看
* @param {string} source - 来源
*/
const trackSubscriptionPageViewed = useCallback((source = '') => {
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
current_plan: currentSubscription?.plan || 'free',
subscription_status: currentSubscription?.status || 'unknown',
is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free',
source,
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '💳 Subscription Page Viewed', {
currentPlan: currentSubscription?.plan,
source,
});
}, [track, currentSubscription]);
/**
* 追踪定价计划查看
* @param {string} planName - 计划名称 ('free' | 'pro' | 'enterprise')
* @param {number} price - 价格
*/
const trackPricingPlanViewed = useCallback((planName, price = 0) => {
if (!planName) {
logger.warn('useSubscriptionEvents', 'trackPricingPlanViewed: planName is required');
return;
}
track('Pricing Plan Viewed', {
plan_name: planName,
price,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '👀 Pricing Plan Viewed', {
planName,
price,
});
}, [track, currentSubscription]);
/**
* 追踪定价计划选择
* @param {string} planName - 选择的计划名称
* @param {string} billingCycle - 计费周期 ('monthly' | 'yearly')
* @param {number} price - 价格
*/
const trackPricingPlanSelected = useCallback((planName, billingCycle = 'monthly', price = 0) => {
if (!planName) {
logger.warn('useSubscriptionEvents', 'trackPricingPlanSelected: planName is required');
return;
}
track('Pricing Plan Selected', {
plan_name: planName,
billing_cycle: billingCycle,
price,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '✅ Pricing Plan Selected', {
planName,
billingCycle,
price,
});
}, [track, currentSubscription]);
/**
* 追踪支付页面查看
* @param {string} planName - 购买的计划
* @param {number} amount - 支付金额
*/
const trackPaymentPageViewed = useCallback((planName, amount = 0) => {
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
plan_name: planName,
amount,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '💰 Payment Page Viewed', {
planName,
amount,
});
}, [track, currentSubscription]);
/**
* 追踪支付方式选择
* @param {string} paymentMethod - 支付方式 ('wechat_pay' | 'alipay' | 'credit_card')
* @param {number} amount - 支付金额
*/
const trackPaymentMethodSelected = useCallback((paymentMethod, amount = 0) => {
if (!paymentMethod) {
logger.warn('useSubscriptionEvents', 'trackPaymentMethodSelected: paymentMethod is required');
return;
}
track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, {
payment_method: paymentMethod,
amount,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '💳 Payment Method Selected', {
paymentMethod,
amount,
});
}, [track, currentSubscription]);
/**
* 追踪支付发起
* @param {Object} paymentInfo - 支付信息
* @param {string} paymentInfo.planName - 计划名称
* @param {string} paymentInfo.paymentMethod - 支付方式
* @param {number} paymentInfo.amount - 金额
* @param {string} paymentInfo.billingCycle - 计费周期
* @param {string} paymentInfo.orderId - 订单ID
*/
const trackPaymentInitiated = useCallback((paymentInfo = {}) => {
track(REVENUE_EVENTS.PAYMENT_INITIATED, {
plan_name: paymentInfo.planName,
payment_method: paymentInfo.paymentMethod,
amount: paymentInfo.amount,
billing_cycle: paymentInfo.billingCycle,
order_id: paymentInfo.orderId,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '🚀 Payment Initiated', {
planName: paymentInfo.planName,
amount: paymentInfo.amount,
paymentMethod: paymentInfo.paymentMethod,
});
}, [track, currentSubscription]);
/**
* 追踪支付成功
* @param {Object} paymentInfo - 支付信息
*/
const trackPaymentSuccessful = useCallback((paymentInfo = {}) => {
track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, {
plan_name: paymentInfo.planName,
payment_method: paymentInfo.paymentMethod,
amount: paymentInfo.amount,
billing_cycle: paymentInfo.billingCycle,
order_id: paymentInfo.orderId,
transaction_id: paymentInfo.transactionId,
previous_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '✅ Payment Successful', {
planName: paymentInfo.planName,
amount: paymentInfo.amount,
orderId: paymentInfo.orderId,
});
}, [track, currentSubscription]);
/**
* 追踪支付失败
* @param {Object} paymentInfo - 支付信息
* @param {string} errorReason - 失败原因
*/
const trackPaymentFailed = useCallback((paymentInfo = {}, errorReason = '') => {
track(REVENUE_EVENTS.PAYMENT_FAILED, {
plan_name: paymentInfo.planName,
payment_method: paymentInfo.paymentMethod,
amount: paymentInfo.amount,
error_reason: errorReason,
order_id: paymentInfo.orderId,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '❌ Payment Failed', {
planName: paymentInfo.planName,
errorReason,
orderId: paymentInfo.orderId,
});
}, [track, currentSubscription]);
/**
* 追踪订阅创建成功
* @param {Object} subscription - 订阅信息
*/
const trackSubscriptionCreated = useCallback((subscription = {}) => {
track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, {
plan_name: subscription.plan,
billing_cycle: subscription.billingCycle,
amount: subscription.amount,
start_date: subscription.startDate,
end_date: subscription.endDate,
previous_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '🎉 Subscription Created', {
plan: subscription.plan,
billingCycle: subscription.billingCycle,
});
}, [track, currentSubscription]);
/**
* 追踪订阅续费
* @param {Object} subscription - 订阅信息
*/
const trackSubscriptionRenewed = useCallback((subscription = {}) => {
track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, {
plan_name: subscription.plan,
amount: subscription.amount,
previous_end_date: subscription.previousEndDate,
new_end_date: subscription.newEndDate,
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '🔄 Subscription Renewed', {
plan: subscription.plan,
amount: subscription.amount,
});
}, [track]);
/**
* 追踪订阅取消
* @param {string} reason - 取消原因
* @param {boolean} cancelImmediately - 是否立即取消
*/
const trackSubscriptionCancelled = useCallback((reason = '', cancelImmediately = false) => {
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
plan_name: currentSubscription?.plan,
reason,
has_reason: Boolean(reason),
cancel_immediately: cancelImmediately,
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', '🚫 Subscription Cancelled', {
plan: currentSubscription?.plan,
reason,
cancelImmediately,
});
}, [track, currentSubscription]);
/**
* 追踪优惠券应用
* @param {string} couponCode - 优惠券代码
* @param {number} discountAmount - 折扣金额
* @param {boolean} success - 是否成功
*/
const trackCouponApplied = useCallback((couponCode, discountAmount = 0, success = true) => {
if (!couponCode) {
logger.warn('useSubscriptionEvents', 'trackCouponApplied: couponCode is required');
return;
}
track('Coupon Applied', {
coupon_code: couponCode,
discount_amount: discountAmount,
success,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
logger.debug('useSubscriptionEvents', success ? '🎟️ Coupon Applied' : '❌ Coupon Failed', {
couponCode,
discountAmount,
success,
});
}, [track, currentSubscription]);
return {
// 付费墙事件
trackPaywallShown,
trackPaywallDismissed,
trackUpgradePlanClicked,
// 订阅页面事件
trackSubscriptionPageViewed,
trackPricingPlanViewed,
trackPricingPlanSelected,
// 支付流程事件
trackPaymentPageViewed,
trackPaymentMethodSelected,
trackPaymentInitiated,
trackPaymentSuccessful,
trackPaymentFailed,
// 订阅管理事件
trackSubscriptionCreated,
trackSubscriptionRenewed,
trackSubscriptionCancelled,
// 优惠券事件
trackCouponApplied,
};
};
export default useSubscriptionEvents;

View File

@@ -0,0 +1,382 @@
// src/hooks/useSubscriptionEvents.ts
// 订阅和支付事件追踪 Hook
import { useCallback } from 'react';
import { usePostHogTrack } from './usePostHogRedux';
import { RETENTION_EVENTS, REVENUE_EVENTS } from '../lib/constants';
/**
* 当前订阅信息
*/
interface SubscriptionInfo {
plan?: string;
status?: string;
}
/**
* useSubscriptionEvents Hook 配置选项
*/
interface UseSubscriptionEventsOptions {
currentSubscription?: SubscriptionInfo | null;
}
/**
* 支付信息
*/
interface PaymentInfo {
planName?: string;
paymentMethod?: string;
amount?: number;
billingCycle?: string;
orderId?: string;
transactionId?: string;
}
/**
* 订阅信息
*/
interface SubscriptionData {
plan?: string;
billingCycle?: string;
amount?: number;
startDate?: string;
endDate?: string;
previousEndDate?: string;
newEndDate?: string;
}
/**
* useSubscriptionEvents Hook 返回值
*/
interface UseSubscriptionEventsReturn {
trackPaywallShown: (feature: string, requiredPlan?: string, triggerLocation?: string) => void;
trackPaywallDismissed: (feature: string, closeMethod?: string) => void;
trackUpgradePlanClicked: (targetPlan?: string, source?: string, feature?: string) => void;
trackSubscriptionPageViewed: (source?: string) => void;
trackPricingPlanViewed: (planName: string, price?: number) => void;
trackPricingPlanSelected: (planName: string, billingCycle?: string, price?: number) => void;
trackPaymentPageViewed: (planName: string, amount?: number) => void;
trackPaymentMethodSelected: (paymentMethod: string, amount?: number) => void;
trackPaymentInitiated: (paymentInfo?: PaymentInfo) => void;
trackPaymentSuccessful: (paymentInfo?: PaymentInfo) => void;
trackPaymentFailed: (paymentInfo?: PaymentInfo, errorReason?: string) => void;
trackSubscriptionCreated: (subscription?: SubscriptionData) => void;
trackSubscriptionRenewed: (subscription?: SubscriptionData) => void;
trackSubscriptionCancelled: (reason?: string, cancelImmediately?: boolean) => void;
trackCouponApplied: (couponCode: string, discountAmount?: number, success?: boolean) => void;
}
/**
* 订阅和支付事件追踪 Hook
* @param options - 配置选项
* @returns 事件追踪处理函数集合
*/
export const useSubscriptionEvents = ({
currentSubscription = null,
}: UseSubscriptionEventsOptions = {}): UseSubscriptionEventsReturn => {
const { track } = usePostHogTrack();
/**
* 追踪付费墙展示
*/
const trackPaywallShown = useCallback(
(feature: string, requiredPlan: string = 'pro', triggerLocation: string = '') => {
if (!feature) {
console.warn('useSubscriptionEvents: trackPaywallShown - feature is required');
return;
}
track(REVENUE_EVENTS.PAYWALL_SHOWN, {
feature,
required_plan: requiredPlan,
current_plan: currentSubscription?.plan || 'free',
trigger_location: triggerLocation,
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪付费墙关闭
*/
const trackPaywallDismissed = useCallback(
(feature: string, closeMethod: string = 'dismiss') => {
if (!feature) {
console.warn('useSubscriptionEvents: trackPaywallDismissed - feature is required');
return;
}
track(REVENUE_EVENTS.PAYWALL_DISMISSED, {
feature,
close_method: closeMethod,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪升级按钮点击
*/
const trackUpgradePlanClicked = useCallback(
(targetPlan: string = 'pro', source: string = '', feature: string = '') => {
track(REVENUE_EVENTS.PAYWALL_UPGRADE_CLICKED, {
current_plan: currentSubscription?.plan || 'free',
target_plan: targetPlan,
source,
feature: feature || null,
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪订阅页面查看
*/
const trackSubscriptionPageViewed = useCallback(
(source: string = '') => {
track(RETENTION_EVENTS.SUBSCRIPTION_PAGE_VIEWED, {
current_plan: currentSubscription?.plan || 'free',
subscription_status: currentSubscription?.status || 'unknown',
is_paid_user: currentSubscription?.plan && currentSubscription.plan !== 'free',
source,
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪定价计划查看
*/
const trackPricingPlanViewed = useCallback(
(planName: string, price: number = 0) => {
if (!planName) {
console.warn('useSubscriptionEvents: trackPricingPlanViewed - planName is required');
return;
}
track('Pricing Plan Viewed', {
plan_name: planName,
price,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪定价计划选择
*/
const trackPricingPlanSelected = useCallback(
(planName: string, billingCycle: string = 'monthly', price: number = 0) => {
if (!planName) {
console.warn('useSubscriptionEvents: trackPricingPlanSelected - planName is required');
return;
}
track('Pricing Plan Selected', {
plan_name: planName,
billing_cycle: billingCycle,
price,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付页面查看
*/
const trackPaymentPageViewed = useCallback(
(planName: string, amount: number = 0) => {
track(REVENUE_EVENTS.PAYMENT_PAGE_VIEWED, {
plan_name: planName,
amount,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付方式选择
*/
const trackPaymentMethodSelected = useCallback(
(paymentMethod: string, amount: number = 0) => {
if (!paymentMethod) {
console.warn('useSubscriptionEvents: trackPaymentMethodSelected - paymentMethod is required');
return;
}
track(REVENUE_EVENTS.PAYMENT_METHOD_SELECTED, {
payment_method: paymentMethod,
amount,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付发起
*/
const trackPaymentInitiated = useCallback(
(paymentInfo: PaymentInfo = {}) => {
track(REVENUE_EVENTS.PAYMENT_INITIATED, {
plan_name: paymentInfo.planName,
payment_method: paymentInfo.paymentMethod,
amount: paymentInfo.amount,
billing_cycle: paymentInfo.billingCycle,
order_id: paymentInfo.orderId,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付成功
*/
const trackPaymentSuccessful = useCallback(
(paymentInfo: PaymentInfo = {}) => {
track(REVENUE_EVENTS.PAYMENT_SUCCESSFUL, {
plan_name: paymentInfo.planName,
payment_method: paymentInfo.paymentMethod,
amount: paymentInfo.amount,
billing_cycle: paymentInfo.billingCycle,
order_id: paymentInfo.orderId,
transaction_id: paymentInfo.transactionId,
previous_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪支付失败
*/
const trackPaymentFailed = useCallback(
(paymentInfo: PaymentInfo = {}, errorReason: string = '') => {
track(REVENUE_EVENTS.PAYMENT_FAILED, {
plan_name: paymentInfo.planName,
payment_method: paymentInfo.paymentMethod,
amount: paymentInfo.amount,
error_reason: errorReason,
order_id: paymentInfo.orderId,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪订阅创建成功
*/
const trackSubscriptionCreated = useCallback(
(subscription: SubscriptionData = {}) => {
track(REVENUE_EVENTS.SUBSCRIPTION_CREATED, {
plan_name: subscription.plan,
billing_cycle: subscription.billingCycle,
amount: subscription.amount,
start_date: subscription.startDate,
end_date: subscription.endDate,
previous_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪订阅续费
*/
const trackSubscriptionRenewed = useCallback(
(subscription: SubscriptionData = {}) => {
track(REVENUE_EVENTS.SUBSCRIPTION_RENEWED, {
plan_name: subscription.plan,
amount: subscription.amount,
previous_end_date: subscription.previousEndDate,
new_end_date: subscription.newEndDate,
timestamp: new Date().toISOString(),
});
},
[track]
);
/**
* 追踪订阅取消
*/
const trackSubscriptionCancelled = useCallback(
(reason: string = '', cancelImmediately: boolean = false) => {
track(REVENUE_EVENTS.SUBSCRIPTION_CANCELLED, {
plan_name: currentSubscription?.plan,
reason,
has_reason: Boolean(reason),
cancel_immediately: cancelImmediately,
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
/**
* 追踪优惠券应用
*/
const trackCouponApplied = useCallback(
(couponCode: string, discountAmount: number = 0, success: boolean = true) => {
if (!couponCode) {
console.warn('useSubscriptionEvents: trackCouponApplied - couponCode is required');
return;
}
track('Coupon Applied', {
coupon_code: couponCode,
discount_amount: discountAmount,
success,
current_plan: currentSubscription?.plan || 'free',
timestamp: new Date().toISOString(),
});
},
[track, currentSubscription]
);
return {
// 付费墙事件
trackPaywallShown,
trackPaywallDismissed,
trackUpgradePlanClicked,
// 订阅页面事件
trackSubscriptionPageViewed,
trackPricingPlanViewed,
trackPricingPlanSelected,
// 支付流程事件
trackPaymentPageViewed,
trackPaymentMethodSelected,
trackPaymentInitiated,
trackPaymentSuccessful,
trackPaymentFailed,
// 订阅管理事件
trackSubscriptionCreated,
trackSubscriptionRenewed,
trackSubscriptionCancelled,
// 优惠券事件
trackCouponApplied,
};
};
export default useSubscriptionEvents;

View File

@@ -3,8 +3,12 @@ import React from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { BrowserRouter as Router } from 'react-router-dom'; import { BrowserRouter as Router } from 'react-router-dom';
// 导入 Brainwave 样式(空文件,保留以避免错误) // ⚡ 性能监控:在应用启动时尽早标记
import './styles/brainwave.css'; import { performanceMonitor } from './utils/performanceMonitor';
performanceMonitor.mark('app-start');
// ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效
// import './styles/brainwave.css';
// 导入 Select 下拉框颜色修复样式 // 导入 Select 下拉框颜色修复样式
import './styles/select-fix.css'; import './styles/select-fix.css';
@@ -36,98 +40,26 @@ if (process.env.REACT_APP_ENABLE_DEBUG === 'true') {
function registerServiceWorker() { function registerServiceWorker() {
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突) // ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
if (process.env.REACT_APP_ENABLE_MOCK === 'true') { if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
console.log(
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
'color: #FF9800; font-weight: bold;'
);
return; return;
} }
// 仅在支持 Service Worker 的浏览器中注册 // 仅在支持 Service Worker 的浏览器中注册
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
// 在页面加载完成后注册
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker navigator.serviceWorker
.register('/service-worker.js') .register('/service-worker.js')
.then((registration) => {
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 更新');
if (newWorker) {
newWorker.addEventListener('statechange', () => {
console.log(`[App] Service Worker 状态: ${newWorker.state}`);
if (newWorker.state === 'activated') {
console.log('[App] ✅ Service Worker 已激活');
// 如果有旧的 Service Worker 在控制页面,提示用户刷新
if (navigator.serviceWorker.controller) {
console.log('[App] 💡 Service Worker 已更新,建议刷新页面');
}
}
});
}
});
})
.catch((error) => { .catch((error) => {
console.error('[App] Service Worker 注册失败'); console.error('[App] Service Worker 注册失败:', error.message);
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 {
console.warn('[App] Service Worker is not supported in this browser');
} }
} }
// 启动 Mock Service Worker如果启用 // 渲染应用
async function startApp() { function renderApp() {
// 只在开发环境启动 MSW
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
const { startMockServiceWorker } = await import('./mocks/browser');
await startMockServiceWorker();
}
// Create root
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
// Render the app with Router wrapper // StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
// ✅ StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
root.render( root.render(
<React.StrictMode> <React.StrictMode>
<Router <Router
@@ -141,9 +73,26 @@ async function startApp() {
</React.StrictMode> </React.StrictMode>
); );
// 注册 Service Worker // 注册 Service Worker(非 Mock 模式)
registerServiceWorker(); registerServiceWorker();
} }
// 启动应用
async function startApp() {
// ✅ 开发环境 Mock 模式:先启动 MSW再渲染应用
// 确保所有 API 请求(包括 AuthContext.checkSession都被正确拦截
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
try {
const { startMockServiceWorker } = await import('./mocks/browser');
await startMockServiceWorker();
} catch (error) {
console.error('[MSW] 启动失败:', error);
}
}
// 渲染应用
renderApp();
}
// 启动应用 // 启动应用
startApp(); startApp();

View File

@@ -35,7 +35,7 @@ export default function MainLayout() {
<MemoizedHomeNavbar /> <MemoizedHomeNavbar />
{/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */} {/* 页面内容区域 - flex: 1 占据剩余空间,包含错误边界、懒加载 */}
<Box flex="1" pt="72px"> <Box flex="1" pt="60px">
<ErrorBoundary> <ErrorBoundary>
<Suspense fallback={<PageLoader message="页面加载中..." />}> <Suspense fallback={<PageLoader message="页面加载中..." />}>
<Outlet /> <Outlet />

View File

@@ -47,18 +47,8 @@ export async function startMockServiceWorker() {
}); });
isStarted = true; isStarted = true;
console.log( // 精简日志:只保留一行启动提示
'%c[MSW] Mock Service Worker 已启动 🎭 (警告模式)', console.log('%c[MSW] Mock 已启用 🎭', 'color: #4CAF50; font-weight: bold;');
'color: #4CAF50; font-weight: bold; font-size: 14px;'
);
console.log(
'%c警告模式已定义 Mock → 返回假数据 | 未定义 Mock → 显示警告 ⚠️ | 允许 passthrough',
'color: #FF9800; font-weight: bold; font-size: 12px;'
);
console.log(
'%c查看 src/mocks/handlers/ 目录管理 Mock 接口',
'color: #2196F3; font-size: 12px;'
);
} catch (error) { } catch (error) {
console.error('[MSW] 启动失败:', error); console.error('[MSW] 启动失败:', error);
} finally { } finally {

View File

@@ -102,7 +102,6 @@ export function setCurrentUser(user) {
subscription_days_left: user.subscription_days_left || 0 subscription_days_left: user.subscription_days_left || 0
}; };
localStorage.setItem('mock_current_user', JSON.stringify(normalizedUser)); localStorage.setItem('mock_current_user', JSON.stringify(normalizedUser));
console.log('[Mock State] 设置当前登录用户:', normalizedUser);
} }
} }

View File

@@ -613,14 +613,6 @@ export const accountHandlers = [
end_date: currentUser.subscription_end_date || null end_date: currentUser.subscription_end_date || null
}; };
console.log('[Mock API] 获取当前订阅详情:', {
user_id: currentUser.id,
phone: currentUser.phone,
subscription_type: userSubscriptionType,
subscription_status: subscriptionDetails.status,
days_left: subscriptionDetails.days_left
});
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
data: subscriptionDetails data: subscriptionDetails

View File

@@ -12,7 +12,8 @@ import {
} from '../data/users'; } from '../data/users';
// 模拟网络延迟(毫秒) // 模拟网络延迟(毫秒)
const NETWORK_DELAY = 500; // ⚡ 开发环境使用较短延迟,加快首屏加载速度
const NETWORK_DELAY = 50;
export const authHandlers = [ export const authHandlers = [
// ==================== 手机验证码登录 ==================== // ==================== 手机验证码登录 ====================
@@ -31,21 +32,6 @@ export const authHandlers = [
expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期 expiresAt: Date.now() + 5 * 60 * 1000 // 5分钟后过期
}); });
// 超醒目的验证码提示 - 方便开发调试
console.log(
`%c\n` +
`╔════════════════════════════════════════════╗\n` +
`║ 验证码: ${code.padEnd(22)}\n` +
`╚════════════════════════════════════════════╝\n`,
'color: #ffffff; background: #16a34a; font-weight: bold; font-size: 16px; padding: 20px; line-height: 1.8;'
);
// 额外的高亮提示
console.log(
`%c 验证码: ${code} `,
'color: #ffffff; background: #dc2626; font-weight: bold; font-size: 24px; padding: 15px 30px; border-radius: 8px; margin: 10px 0;'
);
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
message: `验证码已发送到 ${credential}Mock: ${code}`, message: `验证码已发送到 ${credential}Mock: ${code}`,
@@ -141,8 +127,6 @@ export const authHandlers = [
const body = await request.json(); const body = await request.json();
const { credential, verification_code, login_type } = body; const { credential, verification_code, login_type } = body;
console.log('[Mock] 验证码登录:', { credential, verification_code, login_type });
// 验证验证码 // 验证验证码
const storedCode = mockVerificationCodes.get(credential); const storedCode = mockVerificationCodes.get(credential);
if (!storedCode) { if (!storedCode) {
@@ -194,11 +178,8 @@ export const authHandlers = [
subscription_days_left: 0 subscription_days_left: 0
}; };
mockUsers[credential] = user; mockUsers[credential] = user;
console.log('[Mock] 创建新用户:', user);
} }
console.log('[Mock] 登录成功:', user);
// 设置当前登录用户 // 设置当前登录用户
setCurrentUser(user); setCurrentUser(user);
@@ -345,25 +326,22 @@ export const authHandlers = [
}); });
}), }),
// 6. 获取微信 H5 授权 URL // 6. 获取微信 H5 授权 URL(手机浏览器用)
http.post('/api/auth/wechat/h5-auth-url', async ({ request }) => { http.post('/api/auth/wechat/h5-auth', async ({ request }) => {
await delay(NETWORK_DELAY); await delay(NETWORK_DELAY);
const body = await request.json(); const body = await request.json();
const { redirect_url } = body; const { redirect_url } = body;
const state = generateWechatSessionId(); const state = generateWechatSessionId();
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=mock&redirect_uri=${encodeURIComponent(redirect_url)}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`; // Mock 模式下直接返回前端回调地址(模拟授权成功)
const authUrl = `${redirect_url}?wechat_login=success&state=${state}`;
console.log('[Mock] 生成微信 H5 授权 URL:', authUrl); console.log('[Mock] 生成微信 H5 授权 URL:', authUrl);
return HttpResponse.json({ return HttpResponse.json({
code: 0,
message: '成功',
data: {
auth_url: authUrl, auth_url: authUrl,
state state
}
}); });
}), }),
@@ -371,13 +349,11 @@ export const authHandlers = [
// 7. 检查 SessionAuthContext 使用的正确端点) // 7. 检查 SessionAuthContext 使用的正确端点)
http.get('/api/auth/session', async () => { http.get('/api/auth/session', async () => {
await delay(300); await delay(NETWORK_DELAY); // ⚡ 使用统一延迟配置
// 获取当前登录用户 // 获取当前登录用户
const currentUser = getCurrentUser(); const currentUser = getCurrentUser();
console.log('[Mock] 检查 Session:', currentUser);
if (currentUser) { if (currentUser) {
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
@@ -395,13 +371,11 @@ export const authHandlers = [
// 8. 检查 Session旧端点保留兼容 // 8. 检查 Session旧端点保留兼容
http.get('/api/auth/check-session', async () => { http.get('/api/auth/check-session', async () => {
await delay(300); await delay(NETWORK_DELAY); // ⚡ 使用统一延迟配置
// 获取当前登录用户 // 获取当前登录用户
const currentUser = getCurrentUser(); const currentUser = getCurrentUser();
console.log('[Mock] 检查 Session (旧端点):', currentUser);
if (currentUser) { if (currentUser) {
return HttpResponse.json({ return HttpResponse.json({
success: true, success: true,
@@ -432,91 +406,3 @@ export const authHandlers = [
}); });
}) })
]; ];
// ==================== Mock 调试工具(仅开发环境) ====================
/**
* 暴露全局API方便手动触发微信扫码模拟
* 使用方式:
* 1. 浏览器控制台输入window.mockWechatScan()
* 2. 或者在组件中调用window.mockWechatScan(sessionId)
*/
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
window.mockWechatScan = (sessionId) => {
// 如果没有传入sessionId尝试获取最新的session
let targetSessionId = sessionId;
if (!targetSessionId) {
// 获取最新创建的session
const sessions = Array.from(mockWechatSessions.entries());
if (sessions.length === 0) {
console.warn('[Mock API] 没有活跃的微信session请先获取二维码');
return false;
}
// 按创建时间排序,获取最新的
const latestSession = sessions.sort((a, b) => b[1].createdAt - a[1].createdAt)[0];
targetSessionId = latestSession[0];
}
const session = mockWechatSessions.get(targetSessionId);
if (!session) {
console.error('[Mock API] Session不存在:', targetSessionId);
return false;
}
if (session.status !== 'waiting') {
console.warn('[Mock API] Session状态不是waiting当前状态:', session.status);
return false;
}
// 立即触发扫码
session.status = 'scanned';
console.log(`[Mock API] ✅ 模拟扫码成功: ${targetSessionId}`);
// 1秒后自动确认登录
setTimeout(() => {
const session2 = mockWechatSessions.get(targetSessionId);
if (session2 && session2.status === 'scanned') {
session2.status = 'authorized'; // ✅ 使用 'authorized' 状态,与自动扫码流程保持一致
session2.user = {
id: 999,
nickname: '微信测试用户',
wechat_openid: 'mock_openid_' + targetSessionId,
avatar_url: 'https://ui-avatars.com/api/?name=微信测试用户&size=150&background=4299e1&color=fff',
phone: null,
email: null,
has_wechat: true,
created_at: new Date().toISOString(),
subscription_type: 'free',
subscription_status: 'active',
subscription_end_date: null,
is_subscription_active: true,
subscription_days_left: 0
};
session2.user_info = { user_id: session2.user.id }; // ✅ 添加 user_info 字段
console.log(`[Mock API] ✅ 模拟确认登录: ${targetSessionId}`, session2.user);
}
}, 1000);
return true;
};
// 暴露获取当前sessions的方法调试用
window.getMockWechatSessions = () => {
const sessions = Array.from(mockWechatSessions.entries()).map(([id, session]) => ({
sessionId: id,
status: session.status,
createdAt: new Date(session.createdAt).toLocaleString(),
hasUser: !!session.user
}));
console.table(sessions);
return sessions;
};
console.log('%c[Mock API] 微信登录调试工具已加载', 'color: #00D084; font-weight: bold');
console.log('%c使用方法:', 'color: #666');
console.log(' window.mockWechatScan() - 触发最新session的扫码');
console.log(' window.mockWechatScan(sessionId) - 触发指定session的扫码');
console.log(' window.getMockWechatSessions() - 查看所有活跃的sessions');
}

View File

@@ -0,0 +1,22 @@
// src/mocks/handlers/bytedesk.js
/**
* Bytedesk 客服 Widget MSW Handler
* 使用 passthrough 让请求通过到真实服务器,消除 MSW 警告
*/
import { http, passthrough } from 'msw';
export const bytedeskHandlers = [
// Bytedesk API 请求 - 直接 passthrough
// 匹配 /bytedesk/* 路径(通过代理访问后端)
http.all('/bytedesk/*', () => {
return passthrough();
}),
// Bytedesk 外部 CDN/服务请求
http.all('https://www.weiyuai.cn/*', () => {
return passthrough();
}),
];
export default bytedeskHandlers;

View File

@@ -16,6 +16,7 @@ import { limitAnalyseHandlers } from './limitAnalyse';
import { posthogHandlers } from './posthog'; import { posthogHandlers } from './posthog';
import { externalHandlers } from './external'; import { externalHandlers } from './external';
import { agentHandlers } from './agent'; import { agentHandlers } from './agent';
import { bytedeskHandlers } from './bytedesk';
// 可以在这里添加更多的 handlers // 可以在这里添加更多的 handlers
// import { userHandlers } from './user'; // import { userHandlers } from './user';
@@ -36,5 +37,6 @@ export const handlers = [
...posthogHandlers, ...posthogHandlers,
...externalHandlers, ...externalHandlers,
...agentHandlers, ...agentHandlers,
...bytedeskHandlers, // ⚡ Bytedesk 客服 Widget passthrough
// ...userHandlers, // ...userHandlers,
]; ];

View File

@@ -188,46 +188,3 @@ export const paymentHandlers = [
}); });
}) })
]; ];
// ==================== Mock 调试工具(仅开发环境) ====================
/**
* 暴露全局API方便手动触发支付成功
* 使用方式window.mockPaymentSuccess(orderId)
*/
if (process.env.NODE_ENV === 'development' || process.env.REACT_APP_ENABLE_MOCK === 'true') {
window.mockPaymentSuccess = (orderId) => {
const order = mockOrders.get(orderId);
if (!order) {
console.error('[Mock Payment] 订单不存在:', orderId);
return false;
}
if (order.status !== 'pending') {
console.warn('[Mock Payment] 订单状态不是待支付:', order.status);
return false;
}
order.status = 'paid';
order.paid_at = new Date().toISOString();
console.log('[Mock Payment] ✅ 支付成功:', orderId);
return true;
};
window.getMockOrders = () => {
const orders = Array.from(mockOrders.entries()).map(([id, order]) => ({
orderId: id,
status: order.status,
amount: order.amount,
plan: `${order.plan_name} - ${order.billing_cycle}`,
createdAt: new Date(order.created_at).toLocaleString()
}));
console.table(orders);
return orders;
};
console.log('%c[Mock Payment] 支付调试工具已加载', 'color: #00D084; font-weight: bold');
console.log('%c使用方法:', 'color: #666');
console.log(' window.mockPaymentSuccess(orderId) - 手动触发订单支付成功');
console.log(' window.getMockOrders() - 查看所有模拟订单');
}

View File

@@ -9,43 +9,44 @@ import React from 'react';
*/ */
export const lazyComponents = { export const lazyComponents = {
// Home 模块 // Home 模块
HomePage: React.lazy(() => import('../views/Home/HomePage')), // ⚡ 直接引用 HomePage无需中间层静态页面不需要骨架屏
CenterDashboard: React.lazy(() => import('../views/Dashboard/Center')), HomePage: React.lazy(() => import('@views/Home/HomePage')),
ProfilePage: React.lazy(() => import('../views/Profile')), CenterDashboard: React.lazy(() => import('@views/Dashboard/Center')),
SettingsPage: React.lazy(() => import('../views/Settings/SettingsPage')), ProfilePage: React.lazy(() => import('@views/Profile')),
Subscription: React.lazy(() => import('../views/Pages/Account/Subscription')), SettingsPage: React.lazy(() => import('@views/Settings/SettingsPage')),
PrivacyPolicy: React.lazy(() => import('../views/Pages/PrivacyPolicy')), Subscription: React.lazy(() => import('@views/Pages/Account/Subscription')),
UserAgreement: React.lazy(() => import('../views/Pages/UserAgreement')), PrivacyPolicy: React.lazy(() => import('@views/Pages/PrivacyPolicy')),
WechatCallback: React.lazy(() => import('../views/Pages/WechatCallback')), UserAgreement: React.lazy(() => import('@views/Pages/UserAgreement')),
WechatCallback: React.lazy(() => import('@views/Pages/WechatCallback')),
// 社区/内容模块 // 社区/内容模块
Community: React.lazy(() => import('../views/Community')), Community: React.lazy(() => import('@views/Community')),
ConceptCenter: React.lazy(() => import('../views/Concept')), ConceptCenter: React.lazy(() => import('@views/Concept')),
StockOverview: React.lazy(() => import('../views/StockOverview')), StockOverview: React.lazy(() => import('@views/StockOverview')),
LimitAnalyse: React.lazy(() => import('../views/LimitAnalyse')), LimitAnalyse: React.lazy(() => import('@views/LimitAnalyse')),
// 交易模块 // 交易模块
TradingSimulation: React.lazy(() => import('../views/TradingSimulation')), TradingSimulation: React.lazy(() => import('@views/TradingSimulation')),
// 事件模块 // 事件模块
EventDetail: React.lazy(() => import('../views/EventDetail')), EventDetail: React.lazy(() => import('@views/EventDetail')),
// 公司相关模块 // 公司相关模块
CompanyIndex: React.lazy(() => import('../views/Company')), CompanyIndex: React.lazy(() => import('@views/Company')),
ForecastReport: React.lazy(() => import('../views/Company/ForecastReport')), ForecastReport: React.lazy(() => import('@views/Company/ForecastReport')),
FinancialPanorama: React.lazy(() => import('../views/Company/FinancialPanorama')), FinancialPanorama: React.lazy(() => import('@views/Company/FinancialPanorama')),
MarketDataView: React.lazy(() => import('../views/Company/MarketDataView')), MarketDataView: React.lazy(() => import('@views/Company/MarketDataView')),
// Agent模块 // Agent模块
AgentChat: React.lazy(() => import('../views/AgentChat')), AgentChat: React.lazy(() => import('@views/AgentChat')),
// 价值论坛模块 // 价值论坛模块
ValueForum: React.lazy(() => import('../views/ValueForum')), ValueForum: React.lazy(() => import('@views/ValueForum')),
ForumPostDetail: React.lazy(() => import('../views/ValueForum/PostDetail')), ForumPostDetail: React.lazy(() => import('@views/ValueForum/PostDetail')),
PredictionTopicDetail: React.lazy(() => import('../views/ValueForum/PredictionTopicDetail')), PredictionTopicDetail: React.lazy(() => import('@views/ValueForum/PredictionTopicDetail')),
// 数据浏览器模块 // 数据浏览器模块
DataBrowser: React.lazy(() => import('../views/DataBrowser')), DataBrowser: React.lazy(() => import('@views/DataBrowser')),
}; };
/** /**

View File

@@ -10,25 +10,12 @@ import { socketService } from '../socketService';
export const socket = socketService; export const socket = socketService;
export { socketService }; export { socketService };
// ⚡ 新增:暴露 Socket 实例到 window用于调试和验证 // ⚡ 暴露 Socket 实例到 window用于调试和验证
// 注意:移除首屏加载时的日志,避免阻塞感知
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.socket = socketService; window.socket = socketService;
window.socketService = socketService; window.socketService = socketService;
// 日志已移除,如需调试可在控制台执行: console.log(window.socket)
console.log(
'%c[Socket Service] ✅ Socket instance exposed to window',
'color: #4CAF50; font-weight: bold; font-size: 14px;'
);
console.log(' 📍 window.socket:', window.socket);
console.log(' 📍 window.socketService:', window.socketService);
console.log(' 📍 Socket.IO instance:', window.socket?.socket);
console.log(' 📍 Connection status:', window.socket?.connected ? '✅ Connected' : '❌ Disconnected');
} }
// 打印当前使用的服务类型
console.log(
'%c[Socket Service] Using REAL Socket Service',
'color: #4CAF50; font-weight: bold; font-size: 12px;'
);
export default socket; export default socket;

View File

@@ -56,17 +56,8 @@ class SocketService {
// 注册所有暂存的事件监听器(保留 pendingListeners不清空 // 注册所有暂存的事件监听器(保留 pendingListeners不清空
if (this.pendingListeners.length > 0) { if (this.pendingListeners.length > 0) {
console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
this.pendingListeners.forEach(({ event, callback }) => { this.pendingListeners.forEach(({ event, callback }) => {
// 直接在 Socket.IO 实例上注册(避免递归调用 this.on() this.socket.on(event, callback);
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保留用于重连 // ⚠️ 重要:不清空 pendingListeners保留用于重连
} }
@@ -82,15 +73,8 @@ class SocketService {
this.customReconnectTimer = null; this.customReconnectTimer = null;
} }
logger.info('socketService', 'Socket.IO connected successfully', { logger.info('socketService', 'Socket.IO connected', { socketId: this.socket.id });
socketId: this.socket.id,
});
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] Socket ID:', this.socket.id);
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅 // ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
// this.subscribeToAllEvents();
}); });
// 监听断开连接 // 监听断开连接
@@ -174,25 +158,12 @@ class SocketService {
); );
if (!exists) { if (!exists) {
logger.info('socketService', 'Socket not ready, queuing listener', { event });
console.log(`[socketService] 📦 Socket 未初始化,暂存事件监听器: ${event}`);
this.pendingListeners.push({ event, callback }); this.pendingListeners.push({ event, callback });
} else {
console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
} }
return; return;
} }
// 包装回调函数,添加日志 this.socket.on(event, callback);
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);
logger.info('socketService', `Event listener added: ${event}`);
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
} }
/** /**
@@ -210,8 +181,6 @@ class SocketService {
} else { } else {
this.socket.off(event); this.socket.off(event);
} }
logger.info('socketService', `Event listener removed: ${event}`);
} }
/** /**
@@ -231,8 +200,6 @@ class SocketService {
} else { } else {
this.socket.emit(event, data); this.socket.emit(event, data);
} }
logger.info('socketService', `Event emitted: ${event}`, data);
} }
/** /**
@@ -329,10 +296,18 @@ class SocketService {
onSubscribed, onSubscribed,
} = options; } = options;
if (!this.socket || !this.connected) { // ⚡ 改进状态检查:同时检查 this.connected 和 socket.connected
logger.warn('socketService', 'Cannot subscribe: socket not connected'); // 解决 connect 回调中 this.connected 尚未更新的竞争条件
const isReady = this.socket && (this.socket.connected || this.connected);
if (!isReady) {
logger.debug('socketService', 'Socket 尚未就绪,等待连接后订阅');
if (!this.socket) {
// 自动连接 // 自动连接
this.connect(); this.connect();
}
// 等待连接成功后再订阅 // 等待连接成功后再订阅
this.socket.once('connect', () => { this.socket.once('connect', () => {
this._doSubscribe(eventType, importance, onNewEvent, onSubscribed); this._doSubscribe(eventType, importance, onNewEvent, onSubscribed);
@@ -347,65 +322,31 @@ class SocketService {
* 执行订阅操作(内部方法) * 执行订阅操作(内部方法)
*/ */
_doSubscribe(eventType, importance, onNewEvent, onSubscribed) { _doSubscribe(eventType, importance, onNewEvent, onSubscribed) {
console.log('\n========== [SocketService DEBUG] 开始订阅 ==========');
console.log('[SocketService DEBUG] 事件类型:', eventType);
console.log('[SocketService DEBUG] 重要性:', importance);
console.log('[SocketService DEBUG] Socket 连接状态:', this.connected);
console.log('[SocketService DEBUG] Socket ID:', this.socket?.id);
// 发送订阅请求 // 发送订阅请求
const subscribeData = { const subscribeData = {
event_type: eventType, event_type: eventType,
importance: importance, importance: importance,
}; };
console.log('[SocketService DEBUG] 准备发送 subscribe_events:', subscribeData);
this.emit('subscribe_events', subscribeData); this.emit('subscribe_events', subscribeData);
console.log('[SocketService DEBUG] ✓ 已发送 subscribe_events');
// 监听订阅确认 // 监听订阅确认
this.socket.once('subscription_confirmed', (data) => { this.socket.once('subscription_confirmed', (data) => {
console.log('\n[SocketService DEBUG] ========== 收到订阅确认 ==========');
console.log('[SocketService DEBUG] 订阅确认数据:', data);
logger.info('socketService', 'Subscription confirmed', data);
if (onSubscribed) { if (onSubscribed) {
console.log('[SocketService DEBUG] 调用 onSubscribed 回调');
onSubscribed(data); onSubscribed(data);
} }
console.log('[SocketService DEBUG] ========== 订阅确认处理完成 ==========\n');
}); });
// 监听订阅错误 // 监听订阅错误
this.socket.once('subscription_error', (error) => { this.socket.once('subscription_error', (error) => {
console.error('\n[SocketService ERROR] ========== 订阅错误 ==========');
console.error('[SocketService ERROR] 错误信息:', error);
logger.error('socketService', 'Subscription error', error); logger.error('socketService', 'Subscription error', error);
console.error('[SocketService ERROR] ========== 订阅错误处理完成 ==========\n');
}); });
// 监听新事件推送 // 监听新事件推送
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext
// 多个监听器可以共存,都会被触发
if (onNewEvent) { if (onNewEvent) {
console.log('[SocketService DEBUG] 设置 new_event 监听器');
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
// 添加新的监听器(与其他监听器共存)
this.socket.on('new_event', (eventData) => { this.socket.on('new_event', (eventData) => {
console.log('\n[SocketService DEBUG] ========== 收到新事件推送 ==========');
console.log('[SocketService DEBUG] 事件数据:', eventData);
console.log('[SocketService DEBUG] 事件 ID:', eventData?.id);
console.log('[SocketService DEBUG] 事件标题:', eventData?.title);
logger.info('socketService', 'New event received', eventData);
console.log('[SocketService DEBUG] 准备调用 onNewEvent 回调');
onNewEvent(eventData); onNewEvent(eventData);
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
}); });
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
} }
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
} }
/** /**
@@ -432,11 +373,7 @@ class SocketService {
// 监听取消订阅确认 // 监听取消订阅确认
this.socket.once('unsubscription_confirmed', (data) => { this.socket.once('unsubscription_confirmed', (data) => {
logger.info('socketService', 'Unsubscription confirmed', data);
// 移除新事件监听器
this.socket.off('new_event'); this.socket.off('new_event');
if (onUnsubscribed) { if (onUnsubscribed) {
onUnsubscribed(data); onUnsubscribed(data);
} }
@@ -454,22 +391,10 @@ class SocketService {
* @returns {Function} 取消订阅的函数 * @returns {Function} 取消订阅的函数
*/ */
subscribeToAllEvents(onNewEvent) { subscribeToAllEvents(onNewEvent) {
console.log('%c[socketService] 🔔 自动订阅所有事件...', 'color: #FF9800; font-weight: bold;');
// 如果没有提供回调,添加一个默认的日志回调
const defaultCallback = (event) => {
console.log('%c[socketService] 📨 收到新事件(默认回调)', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 事件数据:', event);
};
this.subscribeToEvents({ this.subscribeToEvents({
eventType: 'all', eventType: 'all',
importance: 'all', importance: 'all',
onNewEvent: onNewEvent || defaultCallback, onNewEvent: onNewEvent || (() => {}),
onSubscribed: (data) => {
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 订阅确认:', data);
},
}); });
// 返回取消订阅的清理函数 // 返回取消订阅的清理函数

View File

@@ -1,26 +1,38 @@
// src/store/index.js // src/store/index.js
import { configureStore } from '@reduxjs/toolkit'; import { configureStore, combineReducers } from '@reduxjs/toolkit';
import communityDataReducer from './slices/communityDataSlice'; import communityDataReducer from './slices/communityDataSlice';
import posthogReducer from './slices/posthogSlice'; // ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
// import posthogReducer from './slices/posthogSlice';
// import posthogMiddleware from './middleware/posthogMiddleware';
import industryReducer from './slices/industrySlice'; import industryReducer from './slices/industrySlice';
import stockReducer from './slices/stockSlice'; import stockReducer from './slices/stockSlice';
import authModalReducer from './slices/authModalSlice'; import authModalReducer from './slices/authModalSlice';
import subscriptionReducer from './slices/subscriptionSlice'; import subscriptionReducer from './slices/subscriptionSlice';
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理 import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
import posthogMiddleware from './middleware/posthogMiddleware';
import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API import { eventsApi } from './api/eventsApi'; // ✅ RTK Query API
export const store = configureStore({ // ⚡ 基础 reducers首屏必需
reducer: { const staticReducers = {
communityData: communityDataReducer, communityData: communityDataReducer,
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
industry: industryReducer, // ✅ 行业分类数据管理 industry: industryReducer, // ✅ 行业分类数据管理
stock: stockReducer, // ✅ 股票和事件数据管理 stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理 authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理 subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端) device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API [eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
}, };
// ⚡ 动态 reducers 注册表
const asyncReducers = {};
// ⚡ 创建根 reducer 的工厂函数
const createRootReducer = () => combineReducers({
...staticReducers,
...asyncReducers,
});
export const store = configureStore({
reducer: createRootReducer(),
middleware: (getDefaultMiddleware) => middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({ getDefaultMiddleware({
serializableCheck: { serializableCheck: {
@@ -28,14 +40,27 @@ export const store = configureStore({
ignoredActions: [ ignoredActions: [
'communityData/fetchPopularKeywords/fulfilled', 'communityData/fetchPopularKeywords/fulfilled',
'communityData/fetchHotEvents/fulfilled', 'communityData/fetchHotEvents/fulfilled',
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪 'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪(延迟加载后仍需)
'stock/fetchEventStocks/fulfilled', 'stock/fetchEventStocks/fulfilled',
'stock/fetchStockQuotes/fulfilled', 'stock/fetchStockQuotes/fulfilled',
], ],
}, },
}) })
.concat(posthogMiddleware) // PostHog 自动追踪中间件 // PostHog 中间件延迟加载,首屏不再需要
.concat(eventsApi.middleware), // ✅ RTK Query 中间件(自动缓存、去重、重试) .concat(eventsApi.middleware), // ✅ RTK Query 中间件(自动缓存、去重、重试)
}); });
/**
* ⚡ 动态注入 reducer用于延迟加载模块
* @param {string} key - reducer 的键名
* @param {Function} reducer - reducer 函数
*/
export const injectReducer = (key, reducer) => {
if (asyncReducers[key]) {
return; // 已注入,避免重复
}
asyncReducers[key] = reducer;
store.replaceReducer(createRootReducer());
};
export default store; export default store;

View File

@@ -2,27 +2,81 @@
import { createSlice } from '@reduxjs/toolkit'; import { createSlice } from '@reduxjs/toolkit';
/** /**
* 检测当前设备是否为移动设备 * 设备类型枚举
*
* 判断逻辑:
* 1. User Agent 检测(移动设备标识)
* 2. 屏幕宽度检测(<= 768px
* 3. 触摸屏检测(支持触摸事件)
*
* @returns {boolean} true 表示移动设备false 表示桌面设备
*/ */
const detectIsMobile = () => { export const DeviceType = {
const userAgent = navigator.userAgent || navigator.vendor || window.opera; MOBILE: 'mobile',
const mobileRegex = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i; TABLET: 'tablet',
const isMobileUA = mobileRegex.test(userAgent); DESKTOP: 'desktop',
const isMobileWidth = window.innerWidth <= 768;
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
return isMobileUA || (isMobileWidth && hasTouchScreen);
}; };
/**
* 检测设备类型
*
* 判断逻辑:
* - Mobile: 手机设备iPhone、Android 手机等,宽度 <= 768px
* - Tablet: 平板设备iPad、Android 平板等,宽度 769px - 1024px
* - Desktop: 桌面设备PC、Mac 等,宽度 > 1024px
*
* @returns {{ isMobile: boolean, isTablet: boolean, isDesktop: boolean, deviceType: string }}
*/
const detectDeviceType = () => {
const userAgent = navigator.userAgent || navigator.vendor || window.opera;
const screenWidth = window.innerWidth;
// iPad 检测(包括 iPadOS 13+ 的 Safari其 UA 不再包含 iPad
const isIPad =
/iPad/i.test(userAgent) ||
(navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
// Android 平板检测Android 设备但 UA 不包含 Mobile
const isAndroidTablet = /Android/i.test(userAgent) && !/Mobile/i.test(userAgent);
// 手机 UA 检测(排除平板)
const mobileRegex = /iPhone|iPod|BlackBerry|IEMobile|Opera Mini/i;
const isAndroidPhone = /Android/i.test(userAgent) && /Mobile/i.test(userAgent);
const isMobileUA = mobileRegex.test(userAgent) || isAndroidPhone;
// 触摸屏检测
const hasTouchScreen = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
// 综合判断设备类型
let isMobile = false;
let isTablet = false;
let isDesktop = false;
if (isIPad || isAndroidTablet) {
// UA 明确是平板
isTablet = true;
} else if (isMobileUA) {
// UA 明确是手机
isMobile = true;
} else if (screenWidth <= 768 && hasTouchScreen) {
// 小屏幕 + 触摸屏 = 手机
isMobile = true;
} else if (screenWidth > 768 && screenWidth <= 1024 && hasTouchScreen) {
// 中等屏幕 + 触摸屏 = 平板
isTablet = true;
} else {
// 其他情况为桌面设备
isDesktop = true;
}
// 确定设备类型字符串
let deviceType = DeviceType.DESKTOP;
if (isMobile) deviceType = DeviceType.MOBILE;
else if (isTablet) deviceType = DeviceType.TABLET;
return { isMobile, isTablet, isDesktop, deviceType };
};
const initialDeviceState = detectDeviceType();
const initialState = { const initialState = {
isMobile: detectIsMobile(), isMobile: initialDeviceState.isMobile,
isTablet: initialDeviceState.isTablet,
isDesktop: initialDeviceState.isDesktop,
deviceType: initialDeviceState.deviceType,
}; };
const deviceSlice = createSlice({ const deviceSlice = createSlice({
@@ -37,7 +91,11 @@ const deviceSlice = createSlice({
* - 屏幕方向变化时调用orientationchange * - 屏幕方向变化时调用orientationchange
*/ */
updateScreenSize: (state) => { updateScreenSize: (state) => {
state.isMobile = detectIsMobile(); const { isMobile, isTablet, isDesktop, deviceType } = detectDeviceType();
state.isMobile = isMobile;
state.isTablet = isTablet;
state.isDesktop = isDesktop;
state.deviceType = deviceType;
}, },
}, },
}); });
@@ -47,6 +105,9 @@ export const { updateScreenSize } = deviceSlice.actions;
// Selectors // Selectors
export const selectIsMobile = (state) => state.device.isMobile; export const selectIsMobile = (state) => state.device.isMobile;
export const selectIsTablet = (state) => state.device.isTablet;
export const selectIsDesktop = (state) => state.device.isDesktop;
export const selectDeviceType = (state) => state.device.deviceType;
// Reducer // Reducer
export default deviceSlice.reducer; export default deviceSlice.reducer;

View File

@@ -7,26 +7,49 @@
* 3. selector 函数测试 * 3. selector 函数测试
*/ */
import deviceReducer, { updateScreenSize, selectIsMobile } from './deviceSlice'; import deviceReducer, {
updateScreenSize,
selectIsMobile,
selectIsTablet,
selectIsDesktop,
selectDeviceType,
DeviceType,
} from './deviceSlice';
describe('deviceSlice', () => { describe('deviceSlice', () => {
describe('reducer', () => { describe('reducer', () => {
it('should return the initial state', () => { it('should return the initial state', () => {
const initialState = deviceReducer(undefined, { type: '@@INIT' }); const initialState = deviceReducer(undefined, { type: '@@INIT' });
expect(initialState).toHaveProperty('isMobile'); expect(initialState).toHaveProperty('isMobile');
expect(initialState).toHaveProperty('isTablet');
expect(initialState).toHaveProperty('isDesktop');
expect(initialState).toHaveProperty('deviceType');
expect(typeof initialState.isMobile).toBe('boolean'); expect(typeof initialState.isMobile).toBe('boolean');
expect(typeof initialState.isTablet).toBe('boolean');
expect(typeof initialState.isDesktop).toBe('boolean');
expect(typeof initialState.deviceType).toBe('string');
}); });
it('should handle updateScreenSize', () => { it('should handle updateScreenSize', () => {
// 模拟初始状态 // 模拟初始状态
const initialState = { isMobile: false }; const initialState = {
isMobile: false,
isTablet: false,
isDesktop: true,
deviceType: DeviceType.DESKTOP,
};
// 执行 action注意实际 isMobile 值由 detectIsMobile() 决定) // 执行 action注意实际值由 detectDeviceType() 决定)
const newState = deviceReducer(initialState, updateScreenSize()); const newState = deviceReducer(initialState, updateScreenSize());
// 验证状态结构 // 验证状态结构
expect(newState).toHaveProperty('isMobile'); expect(newState).toHaveProperty('isMobile');
expect(newState).toHaveProperty('isTablet');
expect(newState).toHaveProperty('isDesktop');
expect(newState).toHaveProperty('deviceType');
expect(typeof newState.isMobile).toBe('boolean'); expect(typeof newState.isMobile).toBe('boolean');
expect(typeof newState.isTablet).toBe('boolean');
expect(typeof newState.isDesktop).toBe('boolean');
}); });
}); });
@@ -35,22 +58,48 @@ describe('deviceSlice', () => {
const mockState = { const mockState = {
device: { device: {
isMobile: true, isMobile: true,
isTablet: false,
isDesktop: false,
deviceType: DeviceType.MOBILE,
}, },
}; };
const result = selectIsMobile(mockState); expect(selectIsMobile(mockState)).toBe(true);
expect(result).toBe(true); expect(selectIsTablet(mockState)).toBe(false);
expect(selectIsDesktop(mockState)).toBe(false);
expect(selectDeviceType(mockState)).toBe(DeviceType.MOBILE);
}); });
it('selectIsMobile should return false for desktop', () => { it('selectIsTablet should return correct value', () => {
const mockState = { const mockState = {
device: { device: {
isMobile: false, isMobile: false,
isTablet: true,
isDesktop: false,
deviceType: DeviceType.TABLET,
}, },
}; };
const result = selectIsMobile(mockState); expect(selectIsMobile(mockState)).toBe(false);
expect(result).toBe(false); expect(selectIsTablet(mockState)).toBe(true);
expect(selectIsDesktop(mockState)).toBe(false);
expect(selectDeviceType(mockState)).toBe(DeviceType.TABLET);
});
it('selectIsDesktop should return correct value', () => {
const mockState = {
device: {
isMobile: false,
isTablet: false,
isDesktop: true,
deviceType: DeviceType.DESKTOP,
},
};
expect(selectIsMobile(mockState)).toBe(false);
expect(selectIsTablet(mockState)).toBe(false);
expect(selectIsDesktop(mockState)).toBe(true);
expect(selectDeviceType(mockState)).toBe(DeviceType.DESKTOP);
}); });
}); });
@@ -60,4 +109,12 @@ describe('deviceSlice', () => {
expect(action.type).toBe('device/updateScreenSize'); expect(action.type).toBe('device/updateScreenSize');
}); });
}); });
describe('DeviceType constants', () => {
it('should have correct values', () => {
expect(DeviceType.MOBILE).toBe('mobile');
expect(DeviceType.TABLET).toBe('tablet');
expect(DeviceType.DESKTOP).toBe('desktop');
});
});
}); });

View File

@@ -1,190 +0,0 @@
/**
* deviceSlice 使用示例
*
* 本文件展示如何在 React 组件中使用 deviceSlice 来实现响应式设计
*/
import React, { useEffect } from 'react';
import { useSelector, useDispatch } from 'react-redux';
import { selectIsMobile, updateScreenSize } from '@/store/slices/deviceSlice';
import { Box, Text, VStack } from '@chakra-ui/react';
/**
* 示例 1: 基础使用 - 根据设备类型渲染不同内容
*/
export const BasicUsageExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box>
{isMobile ? (
<Text>📱 移动端视图</Text>
) : (
<Text>💻 桌面端视图</Text>
)}
</Box>
);
};
/**
* 示例 2: 监听窗口尺寸变化 - 动态更新设备状态
*/
export const ResizeListenerExample = () => {
const isMobile = useSelector(selectIsMobile);
const dispatch = useDispatch();
useEffect(() => {
// 监听窗口尺寸变化
const handleResize = () => {
dispatch(updateScreenSize());
};
// 监听屏幕方向变化(移动设备)
const handleOrientationChange = () => {
dispatch(updateScreenSize());
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleOrientationChange);
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleOrientationChange);
};
}, [dispatch]);
return (
<VStack>
<Text>当前设备: {isMobile ? '移动设备' : '桌面设备'}</Text>
<Text fontSize="sm" color="gray.500">
试试调整浏览器窗口大小
</Text>
</VStack>
);
};
/**
* 示例 3: 响应式布局 - 根据设备类型调整样式
*/
export const ResponsiveLayoutExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box
p={isMobile ? 4 : 8}
bg={isMobile ? 'blue.50' : 'gray.50'}
borderRadius={isMobile ? 'md' : 'xl'}
maxW={isMobile ? '100%' : '800px'}
mx="auto"
>
<Text fontSize={isMobile ? 'md' : 'lg'}>
响应式内容区域
</Text>
<Text fontSize="sm" color="gray.600" mt={2}>
Padding: {isMobile ? '16px' : '32px'}
</Text>
</Box>
);
};
/**
* 示例 4: 条件渲染组件 - 移动端显示简化版
*/
export const ConditionalRenderExample = () => {
const isMobile = useSelector(selectIsMobile);
return (
<Box>
{isMobile ? (
// 移动端:简化版导航栏
<Box bg="blue.500" p={2}>
<Text color="white" fontSize="sm"> 菜单</Text>
</Box>
) : (
// 桌面端:完整导航栏
<Box bg="blue.500" p={4}>
<Text color="white" fontSize="lg">
首页 | 产品 | 关于我们 | 联系方式
</Text>
</Box>
)}
</Box>
);
};
/**
* 示例 5: 在 App.js 中全局监听(推荐方式)
*
* 将以下代码添加到 src/App.js 中:
*/
export const AppLevelResizeListenerExample = () => {
const dispatch = useDispatch();
useEffect(() => {
const handleResize = () => {
dispatch(updateScreenSize());
};
window.addEventListener('resize', handleResize);
window.addEventListener('orientationchange', handleResize);
// 初始化时也调用一次(可选)
handleResize();
return () => {
window.removeEventListener('resize', handleResize);
window.removeEventListener('orientationchange', handleResize);
};
}, [dispatch]);
// 返回 null 或组件内容
return null;
};
/**
* 示例 6: 自定义 Hook 封装(推荐)
*
* 在 src/hooks/useDevice.js 中创建自定义 Hook
*/
// import { useSelector } from 'react-redux';
// import { selectIsMobile } from '@/store/slices/deviceSlice';
//
// export const useDevice = () => {
// const isMobile = useSelector(selectIsMobile);
//
// return {
// isMobile,
// isDesktop: !isMobile,
// };
// };
/**
* 使用自定义 Hook
*/
export const CustomHookUsageExample = () => {
// const { isMobile, isDesktop } = useDevice();
return (
<Box>
{/* <Text>移动设备: {isMobile ? '是' : '否'}</Text> */}
{/* <Text>桌面设备: {isDesktop ? '是' : '否'}</Text> */}
</Box>
);
};
/**
* 推荐实践:
*
* 1. 在 App.js 中添加全局 resize 监听器
* 2. 创建自定义 Hook (useDevice) 简化使用
* 3. 结合 Chakra UI 的响应式 Props优先使用 Chakra 内置响应式)
* 4. 仅在需要 JS 逻辑判断时使用 Redux如条件渲染、动态导入
*
* Chakra UI 响应式示例(推荐优先使用):
* <Box
* fontSize={{ base: 'sm', md: 'md', lg: 'lg' }} // Chakra 内置响应式
* p={{ base: 4, md: 6, lg: 8 }}
* >
* 内容
* </Box>
*/

View File

@@ -12,6 +12,19 @@ import {
} from '../../lib/posthog'; } from '../../lib/posthog';
import { logger } from '../../utils/logger'; import { logger } from '../../utils/logger';
// ⚡ 模块级缓存:存储 reducer 注入前的事件(避免丢失)
let pendingEventsBeforeInjection = [];
/**
* 获取并清空注入前缓存的事件
* 在 App.js 中 reducer 注入后调用
*/
export const flushPendingEventsBeforeInjection = () => {
const events = [...pendingEventsBeforeInjection];
pendingEventsBeforeInjection = [];
return events;
};
// ==================== Initial State ==================== // ==================== Initial State ====================
const initialState = { const initialState = {
@@ -51,7 +64,15 @@ export const initializePostHog = createAsyncThunk(
'posthog/initialize', 'posthog/initialize',
async (_, { getState, rejectWithValue }) => { async (_, { getState, rejectWithValue }) => {
try { try {
const { config } = getState().posthog; const posthogState = getState().posthog;
// ⚡ 防御性检查reducer 尚未注入
if (!posthogState) {
logger.warn('PostHog', 'PostHog reducer 尚未注入,跳过初始化');
return { isInitialized: false, skipped: true };
}
const { config } = posthogState;
if (!config.apiKey) { if (!config.apiKey) {
logger.warn('PostHog', '未配置 API Key分析功能将被禁用'); logger.warn('PostHog', '未配置 API Key分析功能将被禁用');
@@ -112,7 +133,20 @@ export const trackEvent = createAsyncThunk(
'posthog/trackEvent', 'posthog/trackEvent',
async ({ eventName, properties = {} }, { getState, rejectWithValue }) => { async ({ eventName, properties = {} }, { getState, rejectWithValue }) => {
try { try {
const { isInitialized } = getState().posthog; const posthogState = getState().posthog;
// ⚡ reducer 尚未注入:缓存到模块级队列(不丢弃)
if (!posthogState) {
logger.debug('PostHog', 'PostHog reducer 尚未注入,事件已缓存', { eventName });
pendingEventsBeforeInjection.push({
eventName,
properties,
timestamp: new Date().toISOString()
});
return { eventName, properties, pendingInjection: true };
}
const { isInitialized } = posthogState;
if (!isInitialized) { if (!isInitialized) {
logger.warn('PostHog', 'PostHog 未初始化,事件将被缓存', { eventName }); logger.warn('PostHog', 'PostHog 未初始化,事件将被缓存', { eventName });
@@ -160,7 +194,14 @@ export const flushCachedEvents = createAsyncThunk(
'posthog/flushCachedEvents', 'posthog/flushCachedEvents',
async (_, { getState, dispatch }) => { async (_, { getState, dispatch }) => {
try { try {
const { eventQueue, isInitialized } = getState().posthog; const posthogState = getState().posthog;
// ⚡ 防御性检查reducer 尚未注入
if (!posthogState) {
return { flushed: 0, skipped: true };
}
const { eventQueue, isInitialized } = posthogState;
if (!isInitialized || eventQueue.length === 0) { if (!isInitialized || eventQueue.length === 0) {
return { flushed: 0 }; return { flushed: 0 };
@@ -281,15 +322,16 @@ export const {
// ==================== Selectors ==================== // ==================== Selectors ====================
export const selectPostHog = (state) => state.posthog; // ⚡ 安全的 selectors支持 reducer 未注入的情况)
export const selectIsInitialized = (state) => state.posthog.isInitialized; export const selectPostHog = (state) => state.posthog || initialState;
export const selectUser = (state) => state.posthog.user; export const selectIsInitialized = (state) => state.posthog?.isInitialized ?? false;
export const selectFeatureFlags = (state) => state.posthog.featureFlags; export const selectUser = (state) => state.posthog?.user ?? null;
export const selectEventQueue = (state) => state.posthog.eventQueue; export const selectFeatureFlags = (state) => state.posthog?.featureFlags ?? {};
export const selectStats = (state) => state.posthog.stats; export const selectEventQueue = (state) => state.posthog?.eventQueue ?? [];
export const selectStats = (state) => state.posthog?.stats ?? initialState.stats;
export const selectFeatureFlag = (flagKey) => (state) => { export const selectFeatureFlag = (flagKey) => (state) => {
return state.posthog.featureFlags[flagKey] || posthogGetFeatureFlag(flagKey); return state.posthog?.featureFlags?.[flagKey] || posthogGetFeatureFlag(flagKey);
}; };
export const selectIsOptedOut = () => posthogHasOptedOut(); export const selectIsOptedOut = () => posthogHasOptedOut();

View File

@@ -7,6 +7,7 @@ import { getApiBase } from '../../utils/apiConfig';
/** /**
* 异步 Thunk: 获取用户订阅信息 * 异步 Thunk: 获取用户订阅信息
* 使用 condition 选项防止同一时刻多个组件重复发起请求
*/ */
export const fetchSubscriptionInfo = createAsyncThunk( export const fetchSubscriptionInfo = createAsyncThunk(
'subscription/fetchInfo', 'subscription/fetchInfo',
@@ -51,6 +52,21 @@ export const fetchSubscriptionInfo = createAsyncThunk(
logger.error('subscriptionSlice', '加载订阅信息失败', error); logger.error('subscriptionSlice', '加载订阅信息失败', error);
return rejectWithValue(error.message); return rejectWithValue(error.message);
} }
},
{
// 防止重复请求:如果已加载或正在加载中,则跳过本次请求
condition: (_, { getState }) => {
const { subscription } = getState();
// 如果正在加载或已加载完成,返回 false 阻止请求
if (subscription.loading || subscription.loaded) {
logger.debug('subscriptionSlice', '跳过重复请求', {
loading: subscription.loading,
loaded: subscription.loaded
});
return false;
}
return true;
}
} }
); );
@@ -71,6 +87,7 @@ const subscriptionSlice = createSlice({
}, },
// 加载状态 // 加载状态
loading: false, loading: false,
loaded: false, // 是否已加载过(用于防止重复请求)
error: null, error: null,
// 订阅 Modal 状态 // 订阅 Modal 状态
isModalOpen: false, isModalOpen: false,
@@ -104,8 +121,8 @@ const subscriptionSlice = createSlice({
end_date: null end_date: null
}; };
state.loading = false; state.loading = false;
state.loaded = false; // 重置已加载标记,下次登录时重新获取
state.error = null; state.error = null;
logger.debug('subscriptionSlice', '重置订阅信息为免费版');
}, },
}, },
extraReducers: (builder) => { extraReducers: (builder) => {
@@ -118,6 +135,7 @@ const subscriptionSlice = createSlice({
// fetchSubscriptionInfo - fulfilled // fetchSubscriptionInfo - fulfilled
.addCase(fetchSubscriptionInfo.fulfilled, (state, action) => { .addCase(fetchSubscriptionInfo.fulfilled, (state, action) => {
state.loading = false; state.loading = false;
state.loaded = true; // 标记已加载
state.info = action.payload; state.info = action.payload;
state.error = null; state.error = null;
}) })
@@ -136,6 +154,7 @@ export const { openModal, closeModal, resetToFree } = subscriptionSlice.actions;
// 导出 selectors // 导出 selectors
export const selectSubscriptionInfo = (state) => state.subscription.info; export const selectSubscriptionInfo = (state) => state.subscription.info;
export const selectSubscriptionLoading = (state) => state.subscription.loading; export const selectSubscriptionLoading = (state) => state.subscription.loading;
export const selectSubscriptionLoaded = (state) => state.subscription.loaded;
export const selectSubscriptionError = (state) => state.subscription.error; export const selectSubscriptionError = (state) => state.subscription.error;
export const selectSubscriptionModalOpen = (state) => state.subscription.isModalOpen; export const selectSubscriptionModalOpen = (state) => state.subscription.isModalOpen;

View File

@@ -1,12 +0,0 @@
/* Tailwind CSS 入口文件 */
@tailwind base;
@tailwind components;
@tailwind utilities;
/* 自定义工具类 */
@layer utilities {
/* 毛玻璃效果 */
.backdrop-blur-xl {
backdrop-filter: blur(24px);
}
}

View File

@@ -36,3 +36,37 @@ iframe[src*="/visitor/"] {
[class*="bytedesk-badge"] { [class*="bytedesk-badge"] {
z-index: 1000000 !important; z-index: 1000000 !important;
} }
/* ========== H5 端客服组件整体缩小 ========== */
@media (max-width: 768px) {
/* 整个客服容器缩小(包括按钮和提示框) */
[class*="bytedesk"],
[id*="bytedesk"],
[class*="BytedeskWeb"] {
transform: scale(0.7) !important;
transform-origin: bottom right !important;
}
}
/* ========== 提示框 3 秒后自动消失 ========== */
/* 提示框("在线客服 点击咨询"气泡)- 扩展选择器 */
[class*="bytedesk-bubble"],
[class*="bytedesk-tooltip"],
[class*="BytedeskWeb"] [class*="bubble"],
[class*="BytedeskWeb"] [class*="tooltip"],
[class*="bytedesk"] > div:not(button):not(iframe),
[class*="BytedeskWeb"] > div:not(button):not(iframe),
[id*="bytedesk"] > div:not(button):not(iframe) {
animation: bytedeskFadeOut 0.3s ease-out 3s forwards !important;
}
@keyframes bytedeskFadeOut {
from {
opacity: 1;
visibility: visible;
}
to {
opacity: 0;
visibility: hidden;
}
}

44
src/types/static-assets.d.ts vendored Normal file
View File

@@ -0,0 +1,44 @@
/**
* 静态资源模块声明
* 允许 TypeScript 正确处理静态资源导入
*/
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
declare module '*.jpeg' {
const content: string;
export default content;
}
declare module '*.gif' {
const content: string;
export default content;
}
declare module '*.svg' {
const content: string;
export default content;
}
declare module '*.webp' {
const content: string;
export default content;
}
declare module '*.ico' {
const content: string;
export default content;
}
declare module '*.bmp' {
const content: string;
export default content;
}

View File

@@ -7,6 +7,34 @@ const isDevelopment =
process.env.NODE_ENV === 'development' || process.env.NODE_ENV === 'development' ||
process.env.REACT_APP_ENABLE_DEBUG === 'true'; process.env.REACT_APP_ENABLE_DEBUG === 'true';
// ========== 日志级别配置 ==========
// 日志级别error < warn < info < debug
// 默认级别warn只显示警告和错误
// 可通过 localStorage.setItem('LOG_LEVEL', 'debug') 开启详细日志
const LOG_LEVELS = {
error: 0,
warn: 1,
info: 2,
debug: 3,
};
// 从 localStorage 读取日志级别(允许用户临时开启详细日志)
const getLogLevel = () => {
if (typeof window !== 'undefined' && window.localStorage) {
const level = localStorage.getItem('LOG_LEVEL');
if (level && LOG_LEVELS[level] !== undefined) {
return LOG_LEVELS[level];
}
}
// 默认只显示 warn 和 error
return LOG_LEVELS.warn;
};
// 检查是否应该输出指定级别的日志
const shouldLogLevel = (level) => {
return LOG_LEVELS[level] <= getLogLevel();
};
// ========== 日志限流配置 ========== // ========== 日志限流配置 ==========
const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次 const LOG_THROTTLE_TIME = 1000; // 1秒内相同日志只输出一次
const recentLogs = new Map(); // 日志缓存,用于去重 const recentLogs = new Map(); // 日志缓存,用于去重
@@ -148,13 +176,13 @@ export const logger = {
}, },
/** /**
* 调试日志(仅开发环境) * 调试日志(仅开发环境 + LOG_LEVEL=debug
* @param {string} component - 组件名称 * @param {string} component - 组件名称
* @param {string} message - 调试信息 * @param {string} message - 调试信息
* @param {object} data - 相关数据(可选) * @param {object} data - 相关数据(可选)
*/ */
debug: (component, message, data = {}) => { debug: (component, message, data = {}) => {
if (isDevelopment && shouldLog(component, message)) { if (isDevelopment && shouldLogLevel('debug') && shouldLog(component, message)) {
console.group(`🐛 Debug: ${component}`); console.group(`🐛 Debug: ${component}`);
console.log('Message:', message); console.log('Message:', message);
if (Object.keys(data).length > 0) { if (Object.keys(data).length > 0) {
@@ -166,13 +194,13 @@ export const logger = {
}, },
/** /**
* 信息日志(仅开发环境) * 信息日志(仅开发环境 + LOG_LEVEL>=info
* @param {string} component - 组件名称 * @param {string} component - 组件名称
* @param {string} message - 信息内容 * @param {string} message - 信息内容
* @param {object} data - 相关数据(可选) * @param {object} data - 相关数据(可选)
*/ */
info: (component, message, data = {}) => { info: (component, message, data = {}) => {
if (isDevelopment && shouldLog(component, message)) { if (isDevelopment && shouldLogLevel('info') && shouldLog(component, message)) {
console.group(` Info: ${component}`); console.group(` Info: ${component}`);
console.log('Message:', message); console.log('Message:', message);
if (Object.keys(data).length > 0) { if (Object.keys(data).length > 0) {
@@ -181,6 +209,28 @@ export const logger = {
console.log('Timestamp:', new Date().toISOString()); console.log('Timestamp:', new Date().toISOString());
console.groupEnd(); console.groupEnd();
} }
},
/**
* 设置日志级别(方便调试)
* @param {string} level - 日志级别 ('error' | 'warn' | 'info' | 'debug')
*/
setLevel: (level) => {
if (LOG_LEVELS[level] !== undefined) {
localStorage.setItem('LOG_LEVEL', level);
console.log(`[Logger] 日志级别已设置为: ${level}`);
console.log(`[Logger] 可用级别: error < warn < info < debug`);
} else {
console.error(`[Logger] 无效的日志级别: ${level}`);
}
},
/**
* 获取当前日志级别
*/
getLevel: () => {
const levelNum = getLogLevel();
return Object.keys(LOG_LEVELS).find(key => LOG_LEVELS[key] === levelNum) || 'warn';
} }
}; };

View File

@@ -58,11 +58,6 @@ const performanceMeasures: Array<{ name: string; duration: number; startMark: st
*/ */
class PerformanceMonitor { class PerformanceMonitor {
private metrics: PerformanceMetrics = {}; private metrics: PerformanceMetrics = {};
private isProduction: boolean;
constructor() {
this.isProduction = process.env.NODE_ENV === 'production';
}
/** /**
* 标记性能时间点 * 标记性能时间点
@@ -70,12 +65,6 @@ class PerformanceMonitor {
mark(name: string): void { mark(name: string): void {
const timestamp = performance.now(); const timestamp = performance.now();
performanceMarks.set(name, timestamp); performanceMarks.set(name, timestamp);
if (!this.isProduction) {
logger.debug('PerformanceMonitor', `⏱️ Mark: ${name}`, {
time: `${timestamp.toFixed(2)}ms`
});
}
} }
/** /**
@@ -106,12 +95,6 @@ class PerformanceMonitor {
endMark endMark
}); });
if (!this.isProduction) {
logger.debug('PerformanceMonitor', `📊 Measure: ${measureName}`, {
duration: `${duration.toFixed(2)}ms`
});
}
return duration; return duration;
} }

View File

@@ -4,7 +4,6 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import type { Dispatch, SetStateAction, KeyboardEvent } from 'react'; import type { Dispatch, SetStateAction, KeyboardEvent } from 'react';
import axios from 'axios'; import axios from 'axios';
import { logger } from '@utils/logger';
import { MessageTypes, type Message } from '../constants/messageTypes'; import { MessageTypes, type Message } from '../constants/messageTypes';
import type { UploadedFile } from './useFileUpload'; import type { UploadedFile } from './useFileUpload';
import type { User } from './useAgentSessions'; import type { User } from './useAgentSessions';
@@ -221,7 +220,7 @@ export const useAgentChat = ({
loadSessions(); loadSessions();
} }
} catch (error: any) { } catch (error: any) {
logger.error('Agent chat error', error); console.error('Agent chat error:', error);
// 移除 "思考中" 和 "执行中" 消息 // 移除 "思考中" 和 "执行中" 消息
setMessages((prev) => setMessages((prev) =>

View File

@@ -4,7 +4,6 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import type { Dispatch, SetStateAction } from 'react'; import type { Dispatch, SetStateAction } from 'react';
import axios from 'axios'; import axios from 'axios';
import { logger } from '@utils/logger';
import { MessageTypes, type Message } from '../constants/messageTypes'; import { MessageTypes, type Message } from '../constants/messageTypes';
/** /**
@@ -103,7 +102,7 @@ export const useAgentSessions = ({
setSessions(response.data.data); setSessions(response.data.data);
} }
} catch (error) { } catch (error) {
logger.error('加载会话列表失败', error); console.error('加载会话列表失败:', error);
} finally { } finally {
setIsLoadingSessions(false); setIsLoadingSessions(false);
} }
@@ -135,7 +134,7 @@ export const useAgentSessions = ({
setMessages(formattedMessages); setMessages(formattedMessages);
} }
} catch (error) { } catch (error) {
logger.error('加载会话历史失败', error); console.error('加载会话历史失败:', error);
} }
}, },
[setMessages] [setMessages]

View File

@@ -0,0 +1,80 @@
/* CompactSearchBox.css */
/* 紧凑版搜索和筛选组件样式 */
/* 搜索框 placeholder 白色 - 全覆盖选择器 */
.gold-placeholder input::placeholder,
.gold-placeholder input[type="text"]::placeholder,
.gold-placeholder .ant-input::placeholder,
.gold-placeholder .ant-input-affix-wrapper input::placeholder,
.gold-placeholder .ant-select-selection-search-input::placeholder,
.gold-placeholder .ant-input-affix-wrapper .ant-input::placeholder {
color: #FFFFFF !important;
opacity: 0.8 !important;
}
/* AutoComplete placeholder - 关键选择器 */
.gold-placeholder .ant-select-selection-placeholder {
color: #FFFFFF !important;
opacity: 0.8 !important;
}
.gold-placeholder .ant-input-affix-wrapper .ant-input,
.gold-placeholder .ant-input {
color: #FFFFFF !important;
}
.gold-placeholder .ant-input-affix-wrapper {
background: transparent !important;
}
/* 透明下拉框样式 */
.transparent-select .ant-select-selector,
.transparent-cascader .ant-select-selector {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
/* 行业筛选宽度自适应,减少间距 */
.transparent-cascader {
width: auto !important;
}
.transparent-cascader .ant-select-selector {
padding-right: 8px !important;
min-width: unset !important;
}
/* 行业筛选 Cascader placeholder 白色 */
.transparent-select .ant-select-selection-placeholder,
.transparent-cascader .ant-select-selection-placeholder,
.transparent-cascader input::placeholder,
.transparent-cascader .ant-cascader-input::placeholder {
color: #FFFFFF !important;
}
.transparent-cascader .ant-cascader-input {
background: transparent !important;
}
/* 行业筛选 Cascader 选中值白色 */
.transparent-cascader .ant-select-selection-item,
.transparent-cascader .ant-cascader-picker-label {
color: #FFFFFF !important;
}
/* 方括号样式下拉框 - 无边框 */
.bracket-select .ant-select-selector {
background: transparent !important;
border: none !important;
box-shadow: none !important;
}
.bracket-select .ant-select-selection-item,
.bracket-select .ant-select-selection-placeholder {
color: #FFFFFF !important;
}
.bracket-select .ant-select-arrow {
color: rgba(255, 255, 255, 0.65) !important;
}

View File

@@ -4,30 +4,49 @@
import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react'; import React, { useState, useMemo, useEffect, useCallback, useRef } from 'react';
import { import {
Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect, Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect,
Tooltip Tooltip, Divider, Flex
} from 'antd'; } from 'antd';
import { import {
SearchOutlined, CloseCircleOutlined, StockOutlined, FilterOutlined, SearchOutlined, CloseCircleOutlined, StockOutlined, FilterOutlined,
CalendarOutlined, SortAscendingOutlined CalendarOutlined, SortAscendingOutlined, ReloadOutlined, ThunderboltOutlined
} from '@ant-design/icons'; } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import debounce from 'lodash/debounce'; import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice'; import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice';
import { stockService } from '../../../services/stockService'; import { stockService } from '@services/stockService';
import { logger } from '../../../utils/logger'; import { logger } from '@utils/logger';
import TradingTimeFilter from './TradingTimeFilter'; import TradingTimeFilter from './TradingTimeFilter';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import './CompactSearchBox.css';
const { Option } = AntSelect; const { Option } = AntSelect;
// 排序选项常量
const SORT_OPTIONS = [
{ value: 'new', label: '最新排序', mobileLabel: '最新' },
{ value: 'hot', label: '最热排序', mobileLabel: '热门' },
{ value: 'importance', label: '重要性排序', mobileLabel: '重要' },
{ value: 'returns_avg', label: '平均收益', mobileLabel: '均收' },
{ value: 'returns_week', label: '周收益', mobileLabel: '周收' },
];
// 重要性等级常量
const IMPORTANCE_OPTIONS = [
{ value: 'S', label: 'S级' },
{ value: 'A', label: 'A级' },
{ value: 'B', label: 'B级' },
{ value: 'C', label: 'C级' },
];
const CompactSearchBox = ({ const CompactSearchBox = ({
onSearch, onSearch,
onSearchFocus, onSearchFocus,
filters = {}, filters = {},
mode, mode,
pageSize, pageSize,
trackingFunctions = {} trackingFunctions = {},
isMobile = false
}) => { }) => {
// 状态 // 状态
const [stockOptions, setStockOptions] = useState([]); const [stockOptions, setStockOptions] = useState([]);
@@ -420,19 +439,21 @@ const CompactSearchBox = ({
dispatch(fetchIndustryData()); dispatch(fetchIndustryData());
} }
}; };
return ( return (
<div style={{ <div style={{ padding: 0, background: 'transparent' }}>
padding: window.innerWidth < 768 ? '12px 16px' : '16px 20px', {/* 第一行:搜索框 + 日期筛选 */}
background: PROFESSIONAL_COLORS.background.card, <Flex
borderRadius: '12px', align="center"
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3), 0 0 20px rgba(255, 195, 0, 0.1)', gap={isMobile ? 8 : 12}
border: `1px solid ${PROFESSIONAL_COLORS.border.default}`, style={{
backdropFilter: 'blur(10px)' background: 'rgba(255, 255, 255, 0.03)',
}}> border: `1px solid ${PROFESSIONAL_COLORS.border.light}`,
{/* 单行紧凑布局 - 移动端自动换行 */} borderRadius: '24px',
<Space wrap style={{ width: '100%' }} size={window.innerWidth < 768 ? 'small' : 'medium'}> padding: isMobile ? '2px 4px' : '8px 16px',
{/* 搜索框 */} marginBottom: isMobile ? 8 : 12
}}
>
{/* 搜索框 - flex: 1 占满剩余空间 */}
<AutoComplete <AutoComplete
value={inputValue} value={inputValue}
onChange={handleInputChange} onChange={handleInputChange}
@@ -440,46 +461,57 @@ const CompactSearchBox = ({
onSelect={handleStockSelect} onSelect={handleStockSelect}
onFocus={onSearchFocus} onFocus={onSearchFocus}
options={stockOptions} options={stockOptions}
placeholder="搜索股票/话题..."
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter') { if (e.key === 'Enter') {
handleMainSearch(); handleMainSearch();
} }
}} }}
style={{ width: window.innerWidth < 768 ? '100%' : 240, minWidth: window.innerWidth < 768 ? 0 : 240 }} style={{ flex: 1, minWidth: isMobile ? 100 : 200 }}
className="gold-placeholder"
> >
<Input <Input
prefix={<SearchOutlined style={{ color: PROFESSIONAL_COLORS.gold[500] }} />} prefix={<SearchOutlined style={{ color: PROFESSIONAL_COLORS.gold[500] }} />}
placeholder="搜索股票/话题..."
style={{ style={{
borderRadius: '8px', border: 'none',
border: `1px solid ${PROFESSIONAL_COLORS.border.default}`, background: 'transparent',
boxShadow: `0 2px 8px rgba(255, 195, 0, 0.1)`, color: PROFESSIONAL_COLORS.text.primary,
background: PROFESSIONAL_COLORS.background.secondary, boxShadow: 'none'
color: PROFESSIONAL_COLORS.text.primary
}} }}
/> />
</AutoComplete> </AutoComplete>
{/* 时间筛选 */} {/* 分隔线 - H5 时隐藏 */}
<Tooltip title="时间筛选"> {!isMobile && <Divider type="vertical" style={{ height: 24, margin: '0 8px', borderColor: 'rgba(255,255,255,0.15)' }} />}
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<CalendarOutlined style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 12 }} /> {/* 日期筛选按钮组 */}
<div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
<CalendarOutlined style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 14, marginRight: 8 }} />
<TradingTimeFilter <TradingTimeFilter
value={tradingTimeRange?.key || null} value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange} onChange={handleTradingTimeChange}
compact compact={!isMobile}
mobile={isMobile}
/> />
</div> </div>
</Tooltip> </Flex>
{/* 第二行:筛选条件 */}
<Flex justify="space-between" align="center">
{/* 左侧筛选 */}
<Space size={isMobile ? 4 : 8}>
{/* 行业筛选 */} {/* 行业筛选 */}
<Tooltip title="行业分类">
<Cascader <Cascader
value={industryValue} value={industryValue}
onChange={handleIndustryChange} onChange={handleIndustryChange}
onFocus={handleCascaderFocus} onFocus={handleCascaderFocus}
options={industryData || []} options={industryData || []}
placeholder="行业" placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<FilterOutlined style={{ fontSize: 12 }} />
{isMobile ? '行业' : '行业筛选'}
</span>
}
changeOnSelect changeOnSelect
showSearch={{ showSearch={{
filter: (inputValue, path) => filter: (inputValue, path) =>
@@ -489,145 +521,65 @@ const CompactSearchBox = ({
}} }}
allowClear allowClear
expandTrigger="hover" expandTrigger="hover"
displayRender={(labels) => labels[labels.length - 1] || '行业'} displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')}
disabled={industryLoading} disabled={industryLoading}
style={{ style={{ minWidth: isMobile ? 70 : 80 }}
width: window.innerWidth < 768 ? '100%' : 120, suffixIcon={null}
minWidth: window.innerWidth < 768 ? 0 : 120, className="transparent-cascader"
borderRadius: '8px'
}}
suffixIcon={<FilterOutlined style={{ fontSize: 14, color: PROFESSIONAL_COLORS.gold[500] }} />}
/> />
</Tooltip>
{/* 重要性筛选 */} {/* 事件等级 */}
<Tooltip title="事件等级筛选">
<AntSelect <AntSelect
mode="multiple" mode="multiple"
value={importance} value={importance}
onChange={handleImportanceChange} onChange={handleImportanceChange}
style={{ style={{ minWidth: isMobile ? 100 : 120 }}
width: window.innerWidth < 768 ? '100%' : 120, placeholder={
minWidth: window.innerWidth < 768 ? 0 : 120, <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
borderRadius: '8px' <ThunderboltOutlined style={{ fontSize: 12 }} />
}} {isMobile ? '等级' : '事件等级'}
placeholder="事件等级" </span>
}
maxTagCount={0} maxTagCount={0}
maxTagPlaceholder={(omittedValues) => `已选 ${omittedValues.length}`} maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}` : `已选 ${omittedValues.length}`}
className="bracket-select"
> >
<Option value="S">S级</Option> {IMPORTANCE_OPTIONS.map(opt => (
<Option value="A">A级</Option> <Option key={opt.value} value={opt.value}>{opt.label}</Option>
<Option value="B">B级</Option> ))}
<Option value="C">C级</Option>
</AntSelect> </AntSelect>
</Tooltip> </Space>
{/* 右侧排序和重置 */}
<Space size={isMobile ? 4 : 8}>
{/* 排序 */} {/* 排序 */}
<Tooltip title="排序方式">
<AntSelect <AntSelect
value={sort} value={sort}
onChange={handleSortChange} onChange={handleSortChange}
style={{ style={{ minWidth: isMobile ? 55 : 120 }}
width: window.innerWidth < 768 ? '100%' : 130, className="bracket-select"
minWidth: window.innerWidth < 768 ? 0 : 130,
borderRadius: '8px'
}}
suffixIcon={<SortAscendingOutlined style={{ fontSize: 14, color: PROFESSIONAL_COLORS.gold[500] }} />}
> >
<Option value="new"> 最新</Option> {SORT_OPTIONS.map(opt => (
<Option value="hot">🔥 最热</Option> <Option key={opt.value} value={opt.value}>
<Option value="importance"> 重要性</Option> <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<Option value="returns_avg">📊 平均收益</Option> <SortAscendingOutlined style={{ fontSize: 12 }} />
<Option value="returns_week">📈 周收益</Option> {isMobile ? opt.mobileLabel : opt.label}
</span>
</Option>
))}
</AntSelect> </AntSelect>
</Tooltip>
{/* 重置按钮 */} {/* 重置按钮 */}
<Tooltip title="重置所有筛选">
<Button <Button
icon={<CloseCircleOutlined />} icon={<ReloadOutlined />}
onClick={handleReset} onClick={handleReset}
danger type="text"
type="primary" style={{ color: PROFESSIONAL_COLORS.text.secondary }}
style={{
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.2)'
}}
> >
重置 {!isMobile && '重置筛选'}
</Button> </Button>
</Tooltip>
</Space> </Space>
</Flex>
{/* 激活的筛选标签(如果有的话) */}
{(inputValue || industryValue.length > 0 || importance.length > 0 || tradingTimeRange || sort !== 'new') && (
<div style={{ marginTop: 8, display: 'flex', flexWrap: 'wrap', gap: 4 }}>
{inputValue && (
<Tag closable onClose={() => {
setInputValue('');
const params = buildFilterParams({ q: '' });
triggerSearch(params);
}} color="blue">
搜索: {inputValue}
</Tag>
)}
{tradingTimeRange && (
<Tag closable onClose={() => {
setTradingTimeRange(null);
const params = buildFilterParams({
start_date: '',
end_date: '',
recent_days: ''
});
triggerSearch(params);
}} color="green">
{tradingTimeRange.label}
</Tag>
)}
{industryValue.length > 0 && industryData && (
<Tag closable onClose={() => {
setIndustryValue([]);
const params = buildFilterParams({ industry_code: '' });
triggerSearch(params);
}} color="orange">
行业: {(() => {
const findLabel = (code, data) => {
for (const item of data) {
if (code.startsWith(item.value)) {
if (item.value === code) {
return item.label;
} else {
return findLabel(code, item.children);
}
}
}
return null;
};
const lastLevelCode = industryValue[industryValue.length - 1];
return findLabel(lastLevelCode, industryData);
})()}
</Tag>
)}
{importance.length > 0 && (
<Tag closable onClose={() => {
setImportance([]);
const params = buildFilterParams({ importance: 'all' });
triggerSearch(params);
}} color="purple">
重要性: {importance.map(imp => ({ 'S': '极高', 'A': '高', 'B': '中', 'C': '低' }[imp] || imp)).join(', ')}
</Tag>
)}
{sort && sort !== 'new' && (
<Tag closable onClose={() => {
setSort('new');
const params = buildFilterParams({ sort: 'new' });
triggerSearch(params);
}} color="cyan">
排序: {({ 'hot': '最热', 'importance': '重要性', 'returns_avg': '平均收益', 'returns_week': '周收益' }[sort] || sort)}
</Tag>
)}
</div>
)}
</div> </div>
); );
}; };

View File

@@ -47,6 +47,7 @@ import { usePagination } from './DynamicNewsCard/hooks/usePagination';
import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './DynamicNewsCard/constants'; import { PAGINATION_CONFIG, DISPLAY_MODES, REFRESH_DEBOUNCE_DELAY } from './DynamicNewsCard/constants';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
import { debounce } from '../../../utils/debounce'; import { debounce } from '../../../utils/debounce';
import { useDevice } from '@hooks/useDevice';
// 🔍 调试:渲染计数器 // 🔍 调试:渲染计数器
let dynamicNewsCardRenderCount = 0; let dynamicNewsCardRenderCount = 0;
@@ -81,6 +82,7 @@ const DynamicNewsCardComponent = forwardRef(({
// 通知权限相关 // 通知权限相关
const { browserPermission, requestBrowserPermission } = useNotification(); const { browserPermission, requestBrowserPermission } = useNotification();
const { isMobile } = useDevice();
// Refs // Refs
const cardHeaderRef = useRef(null); const cardHeaderRef = useRef(null);
@@ -534,73 +536,53 @@ const [currentMode, setCurrentMode] = useState('vertical');
position="relative" position="relative"
zIndex={1} zIndex={1}
pb={3} pb={3}
px={isMobile ? 2 : undefined}
> >
<VStack spacing={3} align="stretch"> <VStack spacing={3} align="stretch">
{/* 第一行:标题 + 通知开关 + 更新时间 */} {/* 第一行:标题 + 模式切换 + 通知开关 + 更新时间 */}
<Flex justify="space-between" align="center"> <Flex justify="space-between" align="center">
{/* 左侧:标题 */} {/* 左侧:标题 + 模式切换按钮 */}
<Heading size="md" color={PROFESSIONAL_COLORS.text.primary}> <HStack spacing={4}>
<Heading size={isMobile ? "sm" : "md"} color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}> <HStack spacing={2}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} /> <TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text> <Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack> </HStack>
</Heading> </Heading>
{/* 模式切换按钮(移动端隐藏) */}
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}
</HStack>
{/* 右侧:通知开关 + 更新时间 */} {/* 右侧:通知开关 + 更新时间 */}
<HStack spacing={3}> <HStack spacing={3}>
{/* 通知开关 */} {/* 通知开关 - 移动端隐藏 */}
<Tooltip {!isMobile && (
label={browserPermission === 'granted'
? '浏览器通知已开启'
: '开启实时推送通知'}
placement="left"
hasArrow
>
<HStack <HStack
spacing={2} spacing={2}
px={3}
py={1.5}
borderRadius="md"
bg={browserPermission === 'granted'
? useColorModeValue('green.50', 'green.900')
: useColorModeValue('gray.50', 'gray.700')}
borderWidth="1px"
borderColor={browserPermission === 'granted'
? useColorModeValue('green.200', 'green.700')
: useColorModeValue('gray.200', 'gray.600')}
cursor="pointer" cursor="pointer"
_hover={{
borderColor: browserPermission === 'granted'
? useColorModeValue('green.300', 'green.600')
: useColorModeValue('blue.300', 'blue.600'),
}}
transition="all 0.2s"
onClick={handleNotificationToggle} onClick={handleNotificationToggle}
_hover={{ opacity: 0.8 }}
transition="opacity 0.2s"
> >
<Icon <Icon
as={BellIcon} as={BellIcon}
boxSize={3.5} boxSize={3.5}
color={browserPermission === 'granted' color={PROFESSIONAL_COLORS.gold[500]}
? useColorModeValue('green.600', 'green.300')
: useColorModeValue('gray.500', 'gray.400')}
/> />
<Text <Text
fontSize="sm" fontSize="sm"
fontWeight="medium" color={PROFESSIONAL_COLORS.text.secondary}
color={browserPermission === 'granted'
? useColorModeValue('green.700', 'green.200')
: useColorModeValue('gray.600', 'gray.300')}
> >
{browserPermission === 'granted' ? '已开启' : '开启通知'} 实时消息推送{browserPermission === 'granted' ? '已开启' : '开启'}
</Text> </Text>
<Switch <Switch
size="sm" size="sm"
isChecked={browserPermission === 'granted'} isChecked={browserPermission === 'granted'}
pointerEvents="none" pointerEvents="none"
colorScheme="green" colorScheme="yellow"
/> />
</HStack> </HStack>
</Tooltip> )}
{/* 更新时间 */} {/* 更新时间 */}
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap"> <Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">
@@ -618,6 +600,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
mode={mode} mode={mode}
pageSize={pageSize} pageSize={pageSize}
trackingFunctions={trackingFunctions} trackingFunctions={trackingFunctions}
isMobile={isMobile}
/> />
</Box> </Box>
</VStack> </VStack>
@@ -627,41 +610,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
<CardBody <CardBody
ref={cardBodyRef} ref={cardBodyRef}
position="relative" position="relative"
pt={4} pt={0}
px={0}
mx={0}
display="flex" display="flex"
flexDirection="column" flexDirection="column"
overflow="visible" overflow="visible"
zIndex={1} zIndex={1}
> >
{/* 顶部控制栏:模式切换按钮 + 分页控制器(滚动时固定在顶部) */}
<Box
position="sticky"
top="0"
zIndex={10}
bg={cardBg}
py={2}
mb={2}
borderBottom="1px solid"
borderColor={borderColor}
mx={-6}
px={6}
boxShadow="sm"
>
<Flex justify="space-between" align="center">
{/* 左侧:模式切换按钮 */}
<ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />
{/* 右侧:分页控制器(仅在纵向模式显示) */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Flex>
</Box>
{/* 内容区域 - 撑满剩余高度 */} {/* 内容区域 - 撑满剩余高度 */}
<Box flex="1" minH={0} position="relative"> <Box flex="1" minH={0} position="relative">
{/* Loading 蒙层 - 数据请求时显示 */} {/* Loading 蒙层 - 数据请求时显示 */}
@@ -727,6 +683,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
</ModalContent> </ModalContent>
</Modal> </Modal>
)} )}
{/* 右侧分页控制器仅在纵向模式显示H5 放不下时折行 */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Card> </Card>
); );
}); });

View File

@@ -87,7 +87,7 @@ const EventScrollList = React.memo(({
h="100%" h="100%"
pt={0} pt={0}
pb={4} pb={4}
px={mode === 'four-row' ? 0 : 2} px={mode === 'four-row' ? 0 : { base: 0, md: 2 }}
position="relative" position="relative"
data-scroll-container="true" data-scroll-container="true"
css={{ css={{

View File

@@ -17,7 +17,7 @@ const ModeToggleButtons = React.memo(({ mode, onModeChange }) => {
colorScheme="blue" colorScheme="blue"
variant={mode === 'vertical' ? 'solid' : 'outline'} variant={mode === 'vertical' ? 'solid' : 'outline'}
> >
纵向 列表
</Button> </Button>
<Button <Button
onClick={() => onModeChange('four-row')} onClick={() => onModeChange('four-row')}

View File

@@ -9,18 +9,12 @@ import {
Center, Center,
Text, Text,
useBreakpointValue, useBreakpointValue,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure useDisclosure
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { InfoIcon } from '@chakra-ui/icons'; import { InfoIcon } from '@chakra-ui/icons';
import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard'; import HorizontalDynamicNewsEventCard from '../EventCard/HorizontalDynamicNewsEventCard';
import EventDetailScrollPanel from './EventDetailScrollPanel'; import EventDetailScrollPanel from './EventDetailScrollPanel';
import DynamicNewsDetailPanel from '../DynamicNewsDetail/DynamicNewsDetailPanel'; import EventDetailModal from '../EventDetailModal';
/** /**
* 纵向分栏模式布局 * 纵向分栏模式布局
@@ -165,20 +159,11 @@ const VerticalModeLayout = React.memo(({
{/* 移动端详情弹窗 */} {/* 移动端详情弹窗 */}
{isMobile && ( {isMobile && (
<Modal isOpen={isMobileModalOpen} onClose={onMobileModalClose} size="full" scrollBehavior="inside"> <EventDetailModal
<ModalOverlay bg="blackAlpha.800" backdropFilter="blur(10px)" /> open={isMobileModalOpen}
<ModalContent maxW="100vw" m={0} borderRadius={0}> onClose={onMobileModalClose}
<ModalHeader bg="gray.900" color="white" borderBottom="1px solid" borderColor="gray.700"> event={mobileSelectedEvent}
{mobileSelectedEvent?.title || '事件详情'} />
</ModalHeader>
<ModalCloseButton color="white" />
<ModalBody p={0} bg="gray.900">
{mobileSelectedEvent && (
<DynamicNewsDetailPanel event={mobileSelectedEvent} showHeader={false} />
)}
</ModalBody>
</ModalContent>
</Modal>
)} )}
</Flex> </Flex>
); );

View File

@@ -47,7 +47,7 @@ const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggl
spacing={3} spacing={3}
zIndex={1} zIndex={1}
> >
{/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 */} {/* 重要性徽章 - 与 EventHeaderInfo 样式一致,尺寸略小 - H5 隐藏 */}
<Badge <Badge
px={3} px={3}
py={1.5} py={1.5}
@@ -62,7 +62,7 @@ const CompactMetaBar = ({ event, importance, isFollowing, followerCount, onToggl
} }
color="white" color="white"
boxShadow="lg" boxShadow="lg"
display="flex" display={{ base: 'none', lg: 'flex' }}
alignItems="center" alignItems="center"
gap={1} gap={1}
> >

View File

@@ -2,13 +2,15 @@
// 精简模式股票卡片组件(浮动卡片样式) // 精简模式股票卡片组件(浮动卡片样式)
import React from 'react'; import React from 'react';
import { useSelector } from 'react-redux';
import { import {
Box, Box,
Text, Text,
Tooltip, Tooltip,
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '../../../../utils/colorUtils'; import { selectIsMobile } from '@store/slices/deviceSlice';
import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } from '@utils/colorUtils';
/** /**
* 精简模式股票卡片组件 * 精简模式股票卡片组件
@@ -17,6 +19,7 @@ import { getChangeColor, getChangeBackgroundGradient, getChangeBorderColor } fro
* @param {Object} props.quote - 股票行情数据(可选) * @param {Object} props.quote - 股票行情数据(可选)
*/ */
const CompactStockItem = ({ stock, quote = null }) => { const CompactStockItem = ({ stock, quote = null }) => {
const isMobile = useSelector(selectIsMobile);
const nameColor = useColorModeValue('gray.700', 'gray.300'); const nameColor = useColorModeValue('gray.700', 'gray.300');
const handleViewDetail = () => { const handleViewDetail = () => {
@@ -45,10 +48,10 @@ const CompactStockItem = ({ stock, quote = null }) => {
> >
<Box <Box
bgGradient={getChangeBackgroundGradient(change)} bgGradient={getChangeBackgroundGradient(change)}
borderWidth="3px" borderWidth="1px"
borderColor={getChangeBorderColor(change)} borderColor={getChangeBorderColor(change)}
borderRadius="2xl" borderRadius="xl"
p={4} p={2}
onClick={handleViewDetail} onClick={handleViewDetail}
cursor="pointer" cursor="pointer"
boxShadow="lg" boxShadow="lg"
@@ -69,14 +72,14 @@ const CompactStockItem = ({ stock, quote = null }) => {
}} }}
transition="all 0.3s ease-in-out" transition="all 0.3s ease-in-out"
display="inline-block" display="inline-block"
minW="150px" minW="100px"
> >
{/* 股票代码 */} {/* 股票代码 */}
<Text <Text
fontSize="md" fontSize={isMobile ? "sm" : "md"}
fontWeight="bold" fontWeight="bold"
color={getChangeColor(change)} color={getChangeColor(change)}
mb={2} mb={isMobile ? 1 : 2}
textAlign="center" textAlign="center"
> >
{stock.stock_code} {stock.stock_code}
@@ -84,7 +87,7 @@ const CompactStockItem = ({ stock, quote = null }) => {
{/* 涨跌幅 - 超大号显示 */} {/* 涨跌幅 - 超大号显示 */}
<Text <Text
fontSize="3xl" fontSize={isMobile ? "xl" : "3xl"}
fontWeight="black" fontWeight="black"
color={getChangeColor(change)} color={getChangeColor(change)}
textAlign="center" textAlign="center"
@@ -96,9 +99,9 @@ const CompactStockItem = ({ stock, quote = null }) => {
{/* 股票名称(小字) */} {/* 股票名称(小字) */}
<Text <Text
fontSize="xs" fontSize={isMobile ? "2xs" : "xs"}
color={nameColor} color={nameColor}
mt={2} mt={isMobile ? 1 : 2}
textAlign="center" textAlign="center"
noOfLines={1} noOfLines={1}
fontWeight="medium" fontWeight="medium"

View File

@@ -418,7 +418,7 @@ const DynamicNewsDetailPanel = ({ event, showHeader = true }) => {
</CollapsibleSection> </CollapsibleSection>
{/* 讨论区(评论区) - 所有登录用户可用 */} {/* 讨论区(评论区) - 所有登录用户可用 */}
<Box mt={4}> <Box>
<EventCommentSection eventId={event.id} /> <EventCommentSection eventId={event.id} />
</Box> </Box>
</VStack> </VStack>

View File

@@ -37,16 +37,16 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
return ( return (
<VStack <VStack
align="stretch" align="stretch"
spacing={2} spacing={1}
bg={cardBg} bg={cardBg}
borderWidth="1px" borderWidth="1px"
borderColor={borderColor} borderColor={borderColor}
borderRadius="md" borderRadius="md"
px={4} px={2}
py={2} py={1}
cursor="pointer" cursor="pointer"
transition="all 0.2s" transition="all 0.2s"
minW="200px" minW="100px"
_hover={{ _hover={{
transform: 'translateY(-1px)', transform: 'translateY(-1px)',
boxShadow: 'md', boxShadow: 'md',
@@ -68,17 +68,17 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
</Text> </Text>
{/* 第二行:相关度 + 涨跌幅 */} {/* 第二行:相关度 + 涨跌幅 */}
<Flex justify="space-between" align="center" gap={2} flexWrap="wrap"> <Flex justify="space-between" align="center" gap={1} flexWrap="wrap">
{/* 相关度标签 */} {/* 相关度标签 */}
<Box <Box
bg={relevanceColors.bg} bg={relevanceColors.bg}
color={relevanceColors.color} color={relevanceColors.color}
px={2} px={1.5}
py={0.5} py={0.5}
borderRadius="sm" borderRadius="sm"
flexShrink={0} flexShrink={0}
> >
<Text fontSize="xs" fontWeight="medium" whiteSpace="nowrap"> <Text fontSize="10px" fontWeight="medium" whiteSpace="nowrap">
相关度: {relevanceScore}% 相关度: {relevanceScore}%
</Text> </Text>
</Box> </Box>
@@ -87,8 +87,8 @@ const SimpleConceptCard = ({ concept, onClick, getRelevanceColor }) => {
{changePct !== null && ( {changePct !== null && (
<Badge <Badge
colorScheme={changeColor} colorScheme={changeColor}
fontSize="xs" fontSize="10px"
px={2} px={1.5}
py={0.5} py={0.5}
flexShrink={0} flexShrink={0}
> >

View File

@@ -243,7 +243,7 @@ const RelatedConceptsSection = ({
} }
}} }}
> >
{isExpanded ? '收起' : '查看详细描述'} {isExpanded ? '收起' : '查看详细'}
</Button> </Button>
</Flex> </Flex>
{/* 第二行:交易日期信息 */} {/* 第二行:交易日期信息 */}

View File

@@ -2,6 +2,7 @@
// 股票卡片组件(融合表格功能的卡片样式) // 股票卡片组件(融合表格功能的卡片样式)
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useSelector } from 'react-redux';
import { import {
Box, Box,
Flex, Flex,
@@ -16,13 +17,15 @@ import {
useColorModeValue, useColorModeValue,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { StarIcon } from '@chakra-ui/icons'; import { StarIcon } from '@chakra-ui/icons';
import { Tag } from 'antd';
import { RobotOutlined } from '@ant-design/icons';
import { selectIsMobile } from '@store/slices/deviceSlice';
import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart'; import MiniTimelineChart from '../StockDetailPanel/components/MiniTimelineChart';
import MiniKLineChart from './MiniKLineChart'; import MiniKLineChart from './MiniKLineChart';
import TimelineChartModal from '../../../../components/StockChart/TimelineChartModal'; import TimelineChartModal from '@components/StockChart/TimelineChartModal';
import KLineChartModal from '../../../../components/StockChart/KLineChartModal'; import KLineChartModal from '@components/StockChart/KLineChartModal';
import CitedContent from '../../../../components/Citation/CitedContent'; import { getChangeColor } from '@utils/colorUtils';
import { getChangeColor } from '../../../../utils/colorUtils'; import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
/** /**
* 股票卡片组件 * 股票卡片组件
@@ -44,6 +47,7 @@ const StockListItem = ({
isInWatchlist = false, isInWatchlist = false,
onWatchlistToggle onWatchlistToggle
}) => { }) => {
const isMobile = useSelector(selectIsMobile);
const cardBg = PROFESSIONAL_COLORS.background.card; const cardBg = PROFESSIONAL_COLORS.background.card;
const borderColor = PROFESSIONAL_COLORS.border.default; const borderColor = PROFESSIONAL_COLORS.border.default;
const codeColor = '#3B82F6'; const codeColor = '#3B82F6';
@@ -128,9 +132,9 @@ const StockListItem = ({
transition="all 0.2s" transition="all 0.2s"
> >
{/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */} {/* 单行紧凑布局:名称+涨跌幅 | 分时图 | K线图 | 关联描述 */}
<HStack spacing={2} align="center" flexWrap="wrap"> <HStack spacing={2} align="center" flexWrap={isMobile ? 'wrap' : 'nowrap'}>
{/* 左侧:股票信息区 */} {/* 左侧:股票信息区 */}
<HStack spacing={2} minW="360px" maxW="380px" flexShrink={0}> <HStack spacing={2} overflow="hidden">
{/* 股票代码 + 名称 + 涨跌幅 */} {/* 股票代码 + 名称 + 涨跌幅 */}
<VStack <VStack
align="stretch" align="stretch"
@@ -194,24 +198,24 @@ const StockListItem = ({
</HStack> </HStack>
</VStack> </VStack>
{/* 分时图 - 更紧凑 */} {/* 分时图 - 自适应 */}
<VStack <VStack
w="115px" flex={1}
minW="80px"
maxW="150px"
borderWidth="1px" borderWidth="1px"
borderColor="rgba(59, 130, 246, 0.3)" borderColor="rgba(59, 130, 246, 0.3)"
borderRadius="md" borderRadius="md"
px={1.5} px={2}
py={1} py={1.5}
bg="rgba(59, 130, 246, 0.1)" bg="rgba(59, 130, 246, 0.1)"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsTimelineModalOpen(true); setIsTimelineModalOpen(true);
}} }}
cursor="pointer" cursor="pointer"
flexShrink={0}
align="stretch" align="stretch"
spacing={0} spacing={0}
h="fit-content"
_hover={{ _hover={{
borderColor: '#3B82F6', borderColor: '#3B82F6',
boxShadow: '0 0 10px rgba(59, 130, 246, 0.3)', boxShadow: '0 0 10px rgba(59, 130, 246, 0.3)',
@@ -228,7 +232,7 @@ const StockListItem = ({
> >
📈 分时 📈 分时
</Text> </Text>
<Box h="32px"> <Box h="28px">
<MiniTimelineChart <MiniTimelineChart
stockCode={stock.stock_code} stockCode={stock.stock_code}
eventTime={eventTime} eventTime={eventTime}
@@ -236,24 +240,24 @@ const StockListItem = ({
</Box> </Box>
</VStack> </VStack>
{/* K线图 - 更紧凑 */} {/* K线图 - 自适应 */}
<VStack <VStack
w="115px" flex={1}
minW="80px"
maxW="150px"
borderWidth="1px" borderWidth="1px"
borderColor="rgba(168, 85, 247, 0.3)" borderColor="rgba(168, 85, 247, 0.3)"
borderRadius="md" borderRadius="md"
px={1.5} px={2}
py={1} py={1.5}
bg="rgba(168, 85, 247, 0.1)" bg="rgba(168, 85, 247, 0.1)"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setIsKLineModalOpen(true); setIsKLineModalOpen(true);
}} }}
cursor="pointer" cursor="pointer"
flexShrink={0}
align="stretch" align="stretch"
spacing={0} spacing={0}
h="fit-content"
_hover={{ _hover={{
borderColor: '#A855F7', borderColor: '#A855F7',
boxShadow: '0 0 10px rgba(168, 85, 247, 0.3)', boxShadow: '0 0 10px rgba(168, 85, 247, 0.3)',
@@ -270,7 +274,7 @@ const StockListItem = ({
> >
📊 日线 📊 日线
</Text> </Text>
<Box h="32px"> <Box h="28px">
<MiniKLineChart <MiniKLineChart
stockCode={stock.stock_code} stockCode={stock.stock_code}
eventTime={eventTime} eventTime={eventTime}
@@ -281,7 +285,7 @@ const StockListItem = ({
{/* 关联描述 - 升级和降级处理 */} {/* 关联描述 - 升级和降级处理 */}
{stock.relation_desc && ( {stock.relation_desc && (
<Box flex={1} minW={0}> <Box flex={1} minW={0} flexBasis={isMobile ? '100%' : ''}>
{stock.relation_desc?.data ? ( {stock.relation_desc?.data ? (
// 升级:带引用来源的版本 - 添加折叠功能 // 升级:带引用来源的版本 - 添加折叠功能
<Tooltip <Tooltip
@@ -298,8 +302,6 @@ const StockListItem = ({
setIsDescExpanded(!isDescExpanded); setIsDescExpanded(!isDescExpanded);
}} }}
cursor="pointer" cursor="pointer"
px={3}
py={2}
bg={PROFESSIONAL_COLORS.background.secondary} bg={PROFESSIONAL_COLORS.background.secondary}
borderRadius="md" borderRadius="md"
_hover={{ _hover={{
@@ -308,18 +310,30 @@ const StockListItem = ({
transition="background 0.2s" transition="background 0.2s"
position="relative" position="relative"
> >
<Collapse in={isDescExpanded} startingHeight={40}> <Collapse in={isDescExpanded} startingHeight={56}>
<CitedContent {/* AI 标识 - 行内显示在文字前面 */}
data={stock.relation_desc} <Tag
title="" icon={<RobotOutlined />}
showAIBadge={true} color="purple"
textColor={PROFESSIONAL_COLORS.text.primary} style={{
containerStyle={{ fontSize: 12,
backgroundColor: 'transparent', padding: '2px 8px',
borderRadius: '0', marginRight: 8,
padding: '0', verticalAlign: 'middle',
display: 'inline-flex',
}} }}
/> >
AI合成
</Tag>
{/* 直接渲染文字内容 */}
<Text
as="span"
fontSize="sm"
color={PROFESSIONAL_COLORS.text.primary}
lineHeight="1.8"
>
{stock.relation_desc?.data?.map(item => item.sentences || item.query_part).filter(Boolean).join('')}
</Text>
</Collapse> </Collapse>
</Box> </Box>
</Tooltip> </Tooltip>
@@ -339,8 +353,6 @@ const StockListItem = ({
setIsDescExpanded(!isDescExpanded); setIsDescExpanded(!isDescExpanded);
}} }}
cursor="pointer" cursor="pointer"
px={3}
py={2}
bg={PROFESSIONAL_COLORS.background.secondary} bg={PROFESSIONAL_COLORS.background.secondary}
borderRadius="md" borderRadius="md"
_hover={{ _hover={{
@@ -350,7 +362,7 @@ const StockListItem = ({
position="relative" position="relative"
> >
{/* 去掉"关联描述"标题 */} {/* 去掉"关联描述"标题 */}
<Collapse in={isDescExpanded} startingHeight={36}> <Collapse in={isDescExpanded} startingHeight={56}>
<Text <Text
fontSize="xs" fontSize="xs"
color={nameColor} color={nameColor}

View File

@@ -32,10 +32,10 @@ const EventFollowButton = ({
size={size} size={size}
colorScheme="yellow" colorScheme="yellow"
variant="ghost" variant="ghost"
bg="whiteAlpha.500" bg="rgba(113, 128, 150, 0.6)"
boxShadow="sm" boxShadow="sm"
_hover={{ _hover={{
bg: 'whiteAlpha.800', bg: 'rgba(113, 128, 150, 0.8)',
boxShadow: 'md' boxShadow: 'md'
}} }}
icon={ icon={
@@ -47,8 +47,7 @@ const EventFollowButton = ({
) : ( ) : (
<AiOutlineStar <AiOutlineStar
size={iconSize} size={iconSize}
color="#718096" color="gold"
strokeWidth="1"
/> />
) )
} }

View File

@@ -15,6 +15,8 @@ import {
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { getImportanceConfig } from '../../../../constants/importanceLevels'; import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme'; import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
import { useDevice } from '@hooks/useDevice';
import dayjs from 'dayjs';
// 导入子组件 // 导入子组件
import ImportanceStamp from './ImportanceStamp'; import ImportanceStamp from './ImportanceStamp';
@@ -54,6 +56,7 @@ const HorizontalDynamicNewsEventCard = React.memo(({
layout = 'vertical', layout = 'vertical',
}) => { }) => {
const importance = getImportanceConfig(event.importance); const importance = getImportanceConfig(event.importance);
const { isMobile } = useDevice();
// 专业配色 - 黑色、灰色、金色主题 // 专业配色 - 黑色、灰色、金色主题
const cardBg = PROFESSIONAL_COLORS.background.card; const cardBg = PROFESSIONAL_COLORS.background.card;
@@ -67,8 +70,8 @@ const HorizontalDynamicNewsEventCard = React.memo(({
const showTimeline = useBreakpointValue({ base: false, md: true }); // 移动端隐藏时间轴 const showTimeline = useBreakpointValue({ base: false, md: true }); // 移动端隐藏时间轴
const cardPadding = useBreakpointValue({ base: 2, md: 3 }); // 移动端减小内边距 const cardPadding = useBreakpointValue({ base: 2, md: 3 }); // 移动端减小内边距
const titleFontSize = useBreakpointValue({ base: 'sm', md: 'md' }); // 移动端减小标题字体 const titleFontSize = useBreakpointValue({ base: 'sm', md: 'md' }); // 移动端减小标题字体
const titlePaddingRight = useBreakpointValue({ base: '80px', md: '120px' }); // 为关键词留空间 const titlePaddingRight = useBreakpointValue({ base: '16px', md: '120px' }); // 桌面端为关键词留空间,移动端不显示关键词
const spacing = useBreakpointValue({ base: 2, md: 3 }); // 间距 const spacing = useBreakpointValue({ base: 1, md: 3 }); // 间距(移动端更紧凑)
/** /**
* 根据平均涨幅计算背景色(专业配色 - 深色主题) * 根据平均涨幅计算背景色(专业配色 - 深色主题)
@@ -159,6 +162,33 @@ const HorizontalDynamicNewsEventCard = React.memo(({
onClick={() => onEventClick?.(event)} onClick={() => onEventClick?.(event)}
> >
<CardBody p={cardPadding} pb={2}> <CardBody p={cardPadding} pb={2}>
{/* 左上角:移动端时间显示 */}
{isMobile && (
<Box
position="absolute"
top={1}
left={1}
zIndex={2}
{...(timelineStyle.bgGradient ? { bgGradient: timelineStyle.bgGradient } : { bg: timelineStyle.bg })}
borderWidth={timelineStyle.borderWidth}
borderColor={timelineStyle.borderColor}
borderRadius="md"
px={1.5}
py={1}
textAlign="center"
boxShadow={timelineStyle.boxShadow}
>
<Text
fontSize="9px"
fontWeight="bold"
color={timelineStyle.textColor}
lineHeight="1.2"
>
{dayjs(event.created_at).format('MM-DD HH:mm')}
</Text>
</Box>
)}
{/* 右上角:关注按钮 */} {/* 右上角:关注按钮 */}
<Box position="absolute" top={{ base: 1, md: 2 }} right={{ base: 1, md: 2 }} zIndex={2}> <Box position="absolute" top={{ base: 1, md: 2 }} right={{ base: 1, md: 2 }} zIndex={2}>
<EventFollowButton <EventFollowButton
@@ -170,8 +200,8 @@ const HorizontalDynamicNewsEventCard = React.memo(({
/> />
</Box> </Box>
{/* Keywords梦幻轮播 - 绝对定位在卡片右侧空白处 */} {/* Keywords梦幻轮播 - 绝对定位在卡片右侧空白处(移动端隐藏) */}
{event.keywords && event.keywords.length > 0 && ( {!isMobile && event.keywords && event.keywords.length > 0 && (
<KeywordsCarousel <KeywordsCarousel
keywords={event.keywords} keywords={event.keywords}
interval={4000} interval={4000}
@@ -200,6 +230,7 @@ const HorizontalDynamicNewsEventCard = React.memo(({
onClick={(e) => onTitleClick?.(e, event)} onClick={(e) => onTitleClick?.(e, event)}
mt={1} mt={1}
paddingRight={titlePaddingRight} paddingRight={titlePaddingRight}
paddingLeft={isMobile ? '70px' : undefined}
> >
<Text <Text
fontSize={titleFontSize} fontSize={titleFontSize}

View File

@@ -0,0 +1,36 @@
.event-detail-modal {
top: 20% !important;
margin: 0 auto !important;
padding-bottom: 0 !important;
.ant-modal-content {
border-radius: 24px !important;
background: transparent;
}
// 标题样式 - 深色文字(白色背景)
.ant-modal-title {
color: #1A202C;
}
// 关闭按钮样式 - 深色(白色背景)
.ant-modal-close {
color: #4A5568;
&:hover {
color: #1A202C;
}
}
}
// 自底向上滑入动画
@keyframes slideUp {
from {
transform: translateY(100%);
opacity: 0;
}
to {
transform: translateY(0);
opacity: 1;
}
}

View File

@@ -0,0 +1,48 @@
import React from 'react';
import { useSelector } from 'react-redux';
import { Modal } from 'antd';
import { selectIsMobile } from '@store/slices/deviceSlice';
import DynamicNewsDetailPanel from './DynamicNewsDetail/DynamicNewsDetailPanel';
import './EventDetailModal.less';
interface EventDetailModalProps {
/** 是否打开弹窗 */
open: boolean;
/** 关闭弹窗回调 */
onClose: () => void;
/** 事件对象 */
event: any; // TODO: 后续可替换为具体的 Event 类型
}
/**
* 事件详情弹窗组件
*/
const EventDetailModal: React.FC<EventDetailModalProps> = ({
open,
onClose,
event,
}) => {
const isMobile = useSelector(selectIsMobile);
return (
<Modal
open={open}
onCancel={onClose}
footer={null}
title={event?.title || '事件详情'}
width='100vw'
destroyOnClose
className="event-detail-modal"
styles={{
mask: { background: 'transparent' },
content: { borderRadius: 24, padding: 0, maxWidth: 1400, background: 'transparent', margin: '0 auto' },
header: { background: '#FFFFFF', borderBottom: '1px solid #E2E8F0', padding: '16px 24px', borderRadius: '24px 24px 0 0', margin: 0 },
body: { padding: 0 },
}}
>
{event && <DynamicNewsDetailPanel event={event} showHeader={false} />}
</Modal>
);
};
export default EventDetailModal;

View File

@@ -37,7 +37,7 @@
} }
.hot-events-carousel { .hot-events-carousel {
padding: 0 40px; /* 增加左右padding箭头留出空间 */ padding: 0; /* 移除左右padding箭头使用绝对定位 */
position: relative; position: relative;
} }
@@ -65,13 +65,20 @@
color: #096dd9 !important; color: #096dd9 !important;
} }
/* 箭头位置 */ /* 箭头位置 - 绝对定位,悬浮在卡片边缘 */
.hot-events-carousel .slick-prev.custom-carousel-arrow { .hot-events-carousel .slick-prev.custom-carousel-arrow {
left: 0 !important; left: 8px !important;
position: absolute;
} }
.hot-events-carousel .slick-next.custom-carousel-arrow { .hot-events-carousel .slick-next.custom-carousel-arrow {
right: 0 !important; right: 8px !important;
position: absolute;
}
/* 隐藏可能重复的默认箭头 */
.hot-events-carousel .slick-arrow:not(.custom-carousel-arrow) {
display: none !important;
} }
/* 禁用状态 */ /* 禁用状态 */

View File

@@ -2,19 +2,11 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd'; import { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons'; import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import { import { useDisclosure } from '@chakra-ui/react';
Modal, import EventDetailModal from './EventDetailModal';
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import './HotEvents.css'; import './HotEvents.css';
import defaultEventImage from '../../../assets/img/default-event.jpg'; import defaultEventImage from '../../../assets/img/default-event.jpg';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
// 自定义箭头组件 // 自定义箭头组件
const CustomArrow = ({ className, style, onClick, direction }) => { const CustomArrow = ({ className, style, onClick, direction }) => {
@@ -196,21 +188,12 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
</Card> </Card>
)} )}
{/* 事件详情弹窗 - 使用 Chakra UI Modal与平铺模式一致 */} {/* 事件详情弹窗 */}
{isModalOpen ? ( <EventDetailModal
<Modal isOpen={isModalOpen} onClose={onModalClose} size="6xl" scrollBehavior="inside"> open={isModalOpen}
<ModalOverlay /> onClose={onModalClose}
<ModalContent> event={modalEvent}
<ModalHeader> />
{modalEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
</ModalBody>
</ModalContent>
</Modal>
): null}
</div> </div>
); );
}; };

View File

@@ -1,20 +1,25 @@
// src/views/Community/components/TradingTimeFilter.js // src/views/Community/components/TradingTimeFilter.js
// 交易时段智能筛选组件 // 交易时段智能筛选组件
import React, { useState, useMemo, useEffect } from 'react'; import React, { useState, useMemo, useEffect } from 'react';
import { Space, Button, Tag, Tooltip, DatePicker, Popover } from 'antd'; import { Space, Button, Tag, Tooltip, DatePicker, Popover, Select } from 'antd';
import { ClockCircleOutlined, CalendarOutlined } from '@ant-design/icons'; import { ClockCircleOutlined, CalendarOutlined, FilterOutlined } from '@ant-design/icons';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import locale from 'antd/es/date-picker/locale/zh_CN'; import locale from 'antd/es/date-picker/locale/zh_CN';
import { logger } from '../../../utils/logger'; import { logger } from '@utils/logger';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
const { RangePicker } = DatePicker; const { RangePicker } = DatePicker;
const { Option } = Select;
/** /**
* 交易时段筛选组件 * 交易时段筛选组件
* @param {string} value - 当前选中的 key受控 * @param {string} value - 当前选中的 key受控
* @param {Function} onChange - 时间范围变化回调 (timeConfig) => void * @param {Function} onChange - 时间范围变化回调 (timeConfig) => void
* @param {boolean} compact - 是否使用紧凑模式PC 端搜索栏内使用)
* @param {boolean} mobile - 是否使用移动端模式(下拉选择)
*/ */
const TradingTimeFilter = ({ value, onChange }) => { const TradingTimeFilter = ({ value, onChange, compact = false, mobile = false }) => {
const [selectedKey, setSelectedKey] = useState(null); const [selectedKey, setSelectedKey] = useState(null);
const [customRangeVisible, setCustomRangeVisible] = useState(false); const [customRangeVisible, setCustomRangeVisible] = useState(false);
const [customRange, setCustomRange] = useState(null); const [customRange, setCustomRange] = useState(null);
@@ -266,7 +271,39 @@ const TradingTimeFilter = ({ value, onChange }) => {
} }
}; };
// 渲染按钮 // 渲染紧凑模式按钮PC 端搜索栏内使用,文字按钮 + | 分隔符)
const renderCompactButton = (config, showDivider = true) => {
const isSelected = selectedKey === config.key;
const fullTooltip = config.timeHint ? `${config.tooltip} · ${config.timeHint}` : config.tooltip;
return (
<React.Fragment key={config.key}>
<Tooltip title={fullTooltip}>
<span
onClick={() => handleButtonClick(config)}
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '13px',
fontWeight: isSelected ? 600 : 400,
color: isSelected ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.text.secondary,
background: isSelected ? 'rgba(255, 195, 0, 0.15)' : 'transparent',
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
}}
>
{config.label}
</span>
</Tooltip>
{showDivider && (
<span style={{ color: 'rgba(255, 255, 255, 0.2)', margin: '0 2px' }}>|</span>
)}
</React.Fragment>
);
};
// 渲染按钮(默认模式)
const renderButton = (config) => { const renderButton = (config) => {
const isSelected = selectedKey === config.key; const isSelected = selectedKey === config.key;
@@ -321,6 +358,98 @@ const TradingTimeFilter = ({ value, onChange }) => {
</div> </div>
); );
// 移动端模式:下拉选择器
if (mobile) {
const allButtons = [...timeRangeConfig.dynamic, ...timeRangeConfig.fixed];
const handleMobileSelect = (key) => {
if (key === selectedKey) {
// 取消选中
setSelectedKey(null);
onChange(null);
} else {
const config = allButtons.find(b => b.key === key);
if (config) {
handleButtonClick(config);
}
}
};
return (
<Select
value={selectedKey}
onChange={handleMobileSelect}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<FilterOutlined style={{ fontSize: 12 }} />
筛选
</span>
}
allowClear
onClear={() => {
setSelectedKey(null);
onChange(null);
}}
style={{ minWidth: 80 }}
className="transparent-select"
popupMatchSelectWidth={false}
>
{allButtons.map(config => (
<Option key={config.key} value={config.key}>
{config.label}
</Option>
))}
</Select>
);
}
// 紧凑模式PC 端搜索栏内的样式
if (compact) {
// 合并所有按钮配置
const allButtons = [...timeRangeConfig.dynamic, ...timeRangeConfig.fixed];
return (
<div style={{ display: 'flex', alignItems: 'center', flexWrap: 'nowrap' }}>
{allButtons.map((config, index) =>
renderCompactButton(config, index < allButtons.length - 1)
)}
{/* 更多时间 */}
<span style={{ color: 'rgba(255, 255, 255, 0.2)', margin: '0 2px' }}>|</span>
<Popover
content={customRangeContent}
title="选择自定义时间范围"
trigger="click"
open={customRangeVisible}
onOpenChange={setCustomRangeVisible}
placement="bottomLeft"
>
<Tooltip title="自定义时间范围">
<span
style={{
cursor: 'pointer',
padding: '4px 8px',
borderRadius: '4px',
fontSize: '13px',
fontWeight: selectedKey === 'custom' ? 600 : 400,
color: selectedKey === 'custom' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.text.secondary,
background: selectedKey === 'custom' ? 'rgba(255, 195, 0, 0.15)' : 'transparent',
transition: 'all 0.2s ease',
whiteSpace: 'nowrap',
display: 'flex',
alignItems: 'center',
gap: '4px',
}}
>
<CalendarOutlined style={{ fontSize: 12 }} />
更多
</span>
</Tooltip>
</Popover>
</div>
);
}
// 默认模式:移动端/独立使用
return ( return (
<Space wrap size={[8, 8]} style={{ display: 'flex', alignItems: 'flex-start' }}> <Space wrap size={[8, 8]} style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* 动态按钮(根据时段显示多个) */} {/* 动态按钮(根据时段显示多个) */}

View File

@@ -1,5 +1,5 @@
// src/views/Community/index.js // src/views/Community/index.js
import React, { useEffect, useRef, useState } from 'react'; import React, { useEffect, useRef, lazy, Suspense } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { import {
@@ -10,22 +10,15 @@ import {
Box, Box,
Container, Container,
useColorModeValue, useColorModeValue,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
CloseButton,
HStack,
VStack,
Text,
useBreakpointValue, useBreakpointValue,
Skeleton,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
// 导入组件 // 导入组件
import DynamicNewsCard from './components/DynamicNewsCard'; import DynamicNewsCard from './components/DynamicNewsCard';
import HotEventsSection from './components/HotEventsSection'; import HotEventsSection from './components/HotEventsSection';
import HeroPanel from './components/HeroPanel'; // ⚡ HeroPanel 懒加载:包含 ECharts (~600KB),首屏不需要立即渲染
const HeroPanel = lazy(() => import('./components/HeroPanel'));
// 导入自定义 Hooks // 导入自定义 Hooks
import { useEventData } from './hooks/useEventData'; import { useEventData } from './hooks/useEventData';
@@ -49,8 +42,6 @@ const Community = () => {
// 专业配色 - 深色主题 // 专业配色 - 深色主题
const bgColor = PROFESSIONAL_COLORS.background.primary; const bgColor = PROFESSIONAL_COLORS.background.primary;
const alertBgColor = 'rgba(59, 130, 246, 0.1)';
const alertBorderColor = PROFESSIONAL_COLORS.border.default;
// Ref用于首次滚动到内容区域 // Ref用于首次滚动到内容区域
const containerRef = useRef(null); const containerRef = useRef(null);
@@ -78,9 +69,6 @@ const Community = () => {
// ⚡ DynamicNewsCard 的 ref用于触发刷新 // ⚡ DynamicNewsCard 的 ref用于触发刷新
const dynamicNewsCardRef = useRef(null); const dynamicNewsCardRef = useRef(null);
// 通知横幅显示状态
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
// 🎯 初始化Community埋点Hook // 🎯 初始化Community埋点Hook
const communityEvents = useCommunityEvents({ navigate }); const communityEvents = useCommunityEvents({ navigate });
@@ -119,39 +107,6 @@ const Community = () => {
} }
}, [events, loading, pagination, filters]); }, [events, loading, pagination, filters]);
// ⚡ 检查通知权限状态,显示横幅提示
useEffect(() => {
// 延迟3秒显示让用户先浏览页面
const timer = setTimeout(() => {
// 如果未授权或未请求过权限,显示横幅
if (browserPermission !== 'granted') {
const hasClosedBanner = localStorage.getItem('notification_banner_closed');
if (!hasClosedBanner) {
setShowNotificationBanner(true);
logger.info('Community', '显示通知权限横幅');
}
}
}, 3000);
return () => clearTimeout(timer);
}, [browserPermission]);
// 处理开启通知
const handleEnableNotifications = async () => {
const permission = await requestBrowserPermission();
if (permission === 'granted') {
setShowNotificationBanner(false);
logger.info('Community', '通知权限已授予');
}
};
// 处理关闭横幅
const handleCloseBanner = () => {
setShowNotificationBanner(false);
localStorage.setItem('notification_banner_closed', 'true');
logger.info('Community', '通知横幅已关闭');
};
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度) // ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
const hasScrolled = useRef(false); const hasScrolled = useRef(false);
useEffect(() => { useEffect(() => {
@@ -235,45 +190,14 @@ const Community = () => {
<Box minH="100vh" bg={bgColor}> <Box minH="100vh" bg={bgColor}>
{/* 主内容区域 */} {/* 主内容区域 */}
<Container ref={containerRef} maxW={containerMaxW} px={containerPx} pt={{ base: 3, md: 6 }} pb={{ base: 4, md: 8 }}> <Container ref={containerRef} maxW={containerMaxW} px={containerPx} pt={{ base: 3, md: 6 }} pb={{ base: 4, md: 8 }}>
{/* 通知权限提示横幅 */} {/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
{showNotificationBanner && ( <Suspense fallback={
<Alert <Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">
status="info" <Skeleton height="200px" borderRadius="lg" startColor="gray.800" endColor="gray.700" />
variant="subtle"
borderRadius="lg"
mb={4}
boxShadow="md"
bg={alertBgColor}
borderWidth="1px"
borderColor={alertBorderColor}
>
<AlertIcon />
<Box flex="1">
<AlertTitle fontSize="md" mb={1}>
开启桌面通知不错过重要事件
</AlertTitle>
<AlertDescription fontSize="sm">
即使浏览器最小化也能第一时间接收新事件推送通知
</AlertDescription>
</Box> </Box>
<HStack spacing={2} ml={4}> }>
<Button
size="sm"
colorScheme="blue"
onClick={handleEnableNotifications}
>
立即开启
</Button>
<CloseButton
onClick={handleCloseBanner}
position="relative"
/>
</HStack>
</Alert>
)}
{/* 顶部说明面板:产品介绍 + 沪深指数 + 热门概念词云 */}
<HeroPanel /> <HeroPanel />
</Suspense>
{/* 实时要闻·动态追踪 - 横向滚动 */} {/* 实时要闻·动态追踪 - 横向滚动 */}
<DynamicNewsCard <DynamicNewsCard

View File

@@ -44,8 +44,8 @@ import {
import FullCalendar from '@fullcalendar/react'; import FullCalendar from '@fullcalendar/react';
import dayGridPlugin from '@fullcalendar/daygrid'; import dayGridPlugin from '@fullcalendar/daygrid';
import interactionPlugin from '@fullcalendar/interaction'; import interactionPlugin from '@fullcalendar/interaction';
import { DateClickArg } from '@fullcalendar/interaction'; import type { DateClickArg } from '@fullcalendar/interaction';
import { EventClickArg } from '@fullcalendar/common'; import type { EventClickArg } from '@fullcalendar/core';
import dayjs, { Dayjs } from 'dayjs'; import dayjs, { Dayjs } from 'dayjs';
import 'dayjs/locale/zh-cn'; import 'dayjs/locale/zh-cn';

View File

@@ -247,7 +247,7 @@ export const PlansPanel: React.FC = () => {
}; };
// 渲染单个卡片 // 渲染单个卡片
const renderCard = (item: InvestmentEvent): JSX.Element => { const renderCard = (item: InvestmentEvent): React.ReactElement => {
const statusInfo = getStatusInfo(item.status); const statusInfo = getStatusInfo(item.status);
return ( return (

View File

@@ -247,7 +247,7 @@ export const ReviewsPanel: React.FC = () => {
}; };
// 渲染单个卡片 // 渲染单个卡片
const renderCard = (item: InvestmentEvent): JSX.Element => { const renderCard = (item: InvestmentEvent): React.ReactElement => {
const statusInfo = getStatusInfo(item.status); const statusInfo = getStatusInfo(item.status);
return ( return (

View File

@@ -78,14 +78,13 @@ const KLineChartView: React.FC<KLineChartViewProps> = ({
}, },
}, },
candle: { candle: {
type: 'line', // 使用折线图模式 type: 'area' as const, // 使用面积图模式
line: { area: {
upColor: themeColors.primary.gold, lineColor: themeColors.primary.gold,
downColor: themeColors.primary.gold, lineSize: 2,
style: 'solid', backgroundColor: [`${themeColors.primary.gold}30`, `${themeColors.primary.gold}05`],
size: 2,
},
}, },
} as any,
crosshair: { crosshair: {
horizontal: { horizontal: {
line: { line: {
@@ -148,7 +147,7 @@ const KLineChartView: React.FC<KLineChartViewProps> = ({
.sort((a, b) => a.timestamp - b.timestamp); .sort((a, b) => a.timestamp - b.timestamp);
// 设置数据 // 设置数据
chart?.applyNewData(chartData); (chart as any)?.applyNewData(chartData);
chartRef.current = chart; chartRef.current = chart;

View File

@@ -1,508 +0,0 @@
import React, { useEffect, useRef, useState } from 'react';
import {
Box,
VStack,
HStack,
Text,
Button,
ButtonGroup,
Flex,
Icon,
useColorMode,
Tooltip,
} from '@chakra-ui/react';
import { createChart, LineSeries } from 'lightweight-charts';
import type { IChartApi, ISeriesApi, LineData, Time } from 'lightweight-charts';
import {
FaExpand,
FaCompress,
FaCamera,
FaRedo,
FaCog,
} from 'react-icons/fa';
import { MetricDataPoint } from '@services/categoryService';
// 黑金主题配色
const themeColors = {
bg: {
primary: '#0a0a0a',
secondary: '#1a1a1a',
card: '#1e1e1e',
},
text: {
primary: '#ffffff',
secondary: '#b8b8b8',
muted: '#808080',
gold: '#D4AF37',
},
border: {
default: 'rgba(255, 255, 255, 0.1)',
gold: 'rgba(212, 175, 55, 0.3)',
},
primary: {
gold: '#D4AF37',
goldLight: '#F4E3A7',
},
};
interface TradingViewChartProps {
data: MetricDataPoint[];
metricName: string;
unit: string;
frequency: string;
}
type TimeRange = '1M' | '3M' | '6M' | '1Y' | 'YTD' | 'ALL';
const TradingViewChart: React.FC<TradingViewChartProps> = ({
data,
metricName,
unit,
frequency,
}) => {
const chartContainerRef = useRef<HTMLDivElement>(null);
const chartRef = useRef<IChartApi | null>(null);
const lineSeriesRef = useRef<ISeriesApi<'Line'> | null>(null);
const [isFullscreen, setIsFullscreen] = useState(false);
const [selectedRange, setSelectedRange] = useState<TimeRange>('ALL');
const { colorMode } = useColorMode();
// 初始化图表
useEffect(() => {
if (!chartContainerRef.current || data.length === 0) return;
try {
// 创建图表 (lightweight-charts 5.0 标准 API)
const chart = createChart(chartContainerRef.current, {
width: chartContainerRef.current.clientWidth,
height: 500,
layout: {
background: { type: 'solid', color: themeColors.bg.card },
textColor: themeColors.text.secondary,
},
grid: {
vertLines: {
color: 'rgba(255, 255, 255, 0.05)',
},
horzLines: {
color: 'rgba(255, 255, 255, 0.05)',
},
},
crosshair: {
vertLine: {
color: themeColors.primary.gold,
width: 1,
style: 3, // 虚线
labelBackgroundColor: themeColors.primary.gold,
},
horzLine: {
color: themeColors.primary.gold,
width: 1,
style: 3,
labelBackgroundColor: themeColors.primary.gold,
},
},
rightPriceScale: {
borderColor: themeColors.border.default,
},
timeScale: {
borderColor: themeColors.border.default,
timeVisible: true,
secondsVisible: false,
rightOffset: 12,
barSpacing: 6, // 增加条形间距,减少拥挤
fixLeftEdge: false,
lockVisibleTimeRangeOnResize: true,
rightBarStaysOnScroll: true,
borderVisible: true,
visible: true,
// 控制时间标签的最小间距(像素)
tickMarkMaxCharacterLength: 8,
},
localization: {
locale: 'en-US',
// 使用 ISO 日期格式,强制显示 YYYY-MM-DD
dateFormat: 'dd MMM \'yy', // 这会被我们的自定义格式化器覆盖
},
handleScroll: {
mouseWheel: true,
pressedMouseMove: true,
},
handleScale: {
axisPressedMouseMove: true,
mouseWheel: true,
pinch: true,
},
});
// 设置时间轴的自定义格式化器(强制显示 YYYY-MM-DD
chart.applyOptions({
localization: {
timeFormatter: (time) => {
// time 可能是字符串 'YYYY-MM-DD' 或时间戳
if (typeof time === 'string') {
return time; // 直接返回 YYYY-MM-DD 字符串
}
// 如果是时间戳,转换为 YYYY-MM-DD
const date = new Date(time * 1000);
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
},
},
});
// 创建折线系列 (lightweight-charts 5.0 使用 addSeries 方法)
// 第一个参数是 series 类本身(不是实例)
const lineSeries = chart.addSeries(LineSeries, {
color: themeColors.primary.gold,
lineWidth: 2,
crosshairMarkerVisible: true,
crosshairMarkerRadius: 6,
crosshairMarkerBorderColor: themeColors.primary.goldLight,
crosshairMarkerBackgroundColor: themeColors.primary.gold,
lastValueVisible: true,
priceLineVisible: true,
priceLineColor: themeColors.primary.gold,
priceLineWidth: 1,
priceLineStyle: 3, // 虚线
title: metricName,
});
// 转换数据格式
// lightweight-charts 5.0 需要 YYYY-MM-DD 格式的字符串作为 time
const chartData: LineData[] = data
.filter((item) => item.value !== null)
.map((item) => {
// 确保日期格式为 YYYY-MM-DD
const dateStr = item.date.trim();
return {
time: dateStr as Time,
value: item.value as number,
};
})
.sort((a, b) => {
// 确保时间从左到右递增
const timeA = new Date(a.time as string).getTime();
const timeB = new Date(b.time as string).getTime();
return timeA - timeB;
});
// 设置数据
lineSeries.setData(chartData);
// 自动缩放到合适的视图
chart.timeScale().fitContent();
chartRef.current = chart;
lineSeriesRef.current = lineSeries;
// 响应式调整
const handleResize = () => {
if (chartContainerRef.current && chart) {
chart.applyOptions({
width: chartContainerRef.current.clientWidth,
});
}
};
window.addEventListener('resize', handleResize);
return () => {
window.removeEventListener('resize', handleResize);
chart.remove();
};
} catch (error) {
console.error('❌ TradingView Chart 初始化失败:', error);
console.error('Error details:', {
message: error.message,
stack: error.stack,
createChartType: typeof createChart,
LineSeriesType: typeof LineSeries,
});
// 重新抛出错误让 ErrorBoundary 捕获
throw error;
}
}, [data, metricName]);
// 时间范围筛选
const handleTimeRangeChange = (range: TimeRange) => {
setSelectedRange(range);
if (!chartRef.current || data.length === 0) return;
const now = new Date();
let startDate: Date;
switch (range) {
case '1M':
startDate = new Date(now.getFullYear(), now.getMonth() - 1, now.getDate());
break;
case '3M':
startDate = new Date(now.getFullYear(), now.getMonth() - 3, now.getDate());
break;
case '6M':
startDate = new Date(now.getFullYear(), now.getMonth() - 6, now.getDate());
break;
case '1Y':
startDate = new Date(now.getFullYear() - 1, now.getMonth(), now.getDate());
break;
case 'YTD':
startDate = new Date(now.getFullYear(), 0, 1); // 当年1月1日
break;
case 'ALL':
default:
chartRef.current.timeScale().fitContent();
return;
}
// 设置可见范围
const startTimestamp = startDate.getTime() / 1000;
const endTimestamp = now.getTime() / 1000;
chartRef.current.timeScale().setVisibleRange({
from: startTimestamp as Time,
to: endTimestamp as Time,
});
};
// 重置缩放
const handleReset = () => {
if (chartRef.current) {
chartRef.current.timeScale().fitContent();
setSelectedRange('ALL');
}
};
// 截图功能
const handleScreenshot = () => {
if (!chartRef.current) return;
const canvas = chartContainerRef.current?.querySelector('canvas');
if (!canvas) return;
canvas.toBlob((blob) => {
if (!blob) return;
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${metricName}_${new Date().toISOString().split('T')[0]}.png`;
link.click();
URL.revokeObjectURL(url);
});
};
// 全屏切换
const toggleFullscreen = () => {
if (!chartContainerRef.current) return;
if (!isFullscreen) {
if (chartContainerRef.current.requestFullscreen) {
chartContainerRef.current.requestFullscreen();
}
} else {
if (document.exitFullscreen) {
document.exitFullscreen();
}
}
setIsFullscreen(!isFullscreen);
};
// 计算统计数据
const stats = React.useMemo(() => {
const values = data.filter((item) => item.value !== null).map((item) => item.value as number);
if (values.length === 0) {
return { min: 0, max: 0, avg: 0, latest: 0, change: 0, changePercent: 0 };
}
const min = Math.min(...values);
const max = Math.max(...values);
const avg = values.reduce((sum, val) => sum + val, 0) / values.length;
const latest = values[values.length - 1];
const first = values[0];
const change = latest - first;
const changePercent = first !== 0 ? (change / first) * 100 : 0;
return { min, max, avg, latest, change, changePercent };
}, [data]);
// 格式化数字
const formatNumber = (num: number) => {
if (Math.abs(num) >= 1e9) {
return (num / 1e9).toFixed(2) + 'B';
}
if (Math.abs(num) >= 1e6) {
return (num / 1e6).toFixed(2) + 'M';
}
if (Math.abs(num) >= 1e3) {
return (num / 1e3).toFixed(2) + 'K';
}
return num.toFixed(2);
};
return (
<VStack align="stretch" spacing={4} w="100%">
{/* 工具栏 */}
<Flex justify="space-between" align="center" wrap="wrap" gap={4}>
{/* 时间范围选择 */}
<ButtonGroup size="sm" isAttached variant="outline">
{(['1M', '3M', '6M', '1Y', 'YTD', 'ALL'] as TimeRange[]).map((range) => (
<Button
key={range}
onClick={() => handleTimeRangeChange(range)}
bg={selectedRange === range ? themeColors.primary.gold : 'transparent'}
color={
selectedRange === range ? themeColors.bg.primary : themeColors.text.secondary
}
borderColor={themeColors.border.gold}
_hover={{
bg: selectedRange === range ? themeColors.primary.goldLight : themeColors.bg.card,
}}
>
{range}
</Button>
))}
</ButtonGroup>
{/* 图表操作 */}
<HStack spacing={2}>
<Tooltip label="重置视图">
<Button
size="sm"
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={handleReset}
>
<Icon as={FaRedo} />
</Button>
</Tooltip>
<Tooltip label="截图">
<Button
size="sm"
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={handleScreenshot}
>
<Icon as={FaCamera} />
</Button>
</Tooltip>
<Tooltip label={isFullscreen ? '退出全屏' : '全屏'}>
<Button
size="sm"
variant="ghost"
color={themeColors.text.secondary}
_hover={{ color: themeColors.primary.gold }}
onClick={toggleFullscreen}
>
<Icon as={isFullscreen ? FaCompress : FaExpand} />
</Button>
</Tooltip>
</HStack>
</Flex>
{/* 统计数据 */}
<Flex
justify="space-around"
align="center"
bg={themeColors.bg.secondary}
p={3}
borderRadius="md"
borderWidth="1px"
borderColor={themeColors.border.default}
wrap="wrap"
gap={4}
>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.gold} fontSize="lg" fontWeight="bold">
{formatNumber(stats.latest)} {unit}
</Text>
<Text
color={stats.change >= 0 ? '#00ff88' : '#ff4444'}
fontSize="xs"
fontWeight="bold"
>
{stats.change >= 0 ? '+' : ''}
{formatNumber(stats.change)} ({stats.changePercent.toFixed(2)}%)
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{formatNumber(stats.avg)} {unit}
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{formatNumber(stats.max)} {unit}
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{formatNumber(stats.min)} {unit}
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{data.filter((item) => item.value !== null).length}
</Text>
</VStack>
<VStack spacing={0}>
<Text color={themeColors.text.muted} fontSize="xs">
</Text>
<Text color={themeColors.text.primary} fontSize="md" fontWeight="bold">
{frequency}
</Text>
</VStack>
</Flex>
{/* 图表容器 */}
<Box
ref={chartContainerRef}
w="100%"
h="500px"
borderRadius="md"
borderWidth="1px"
borderColor={themeColors.border.gold}
overflow="hidden"
position="relative"
bg={themeColors.bg.card}
/>
{/* 提示信息 */}
<Flex justify="space-between" align="center" fontSize="xs" color={themeColors.text.muted}>
<HStack spacing={4}>
<Text>💡 </Text>
</HStack>
<Text>: {metricName}</Text>
</Flex>
</VStack>
);
};
export default TradingViewChart;

View File

@@ -696,18 +696,20 @@ const DataBrowser: React.FC = () => {
p={3} p={3}
cursor="pointer" cursor="pointer"
bg="transparent" bg="transparent"
_hover={{ bg: themeColors.bg.cardHover }} _hover={{
bg: themeColors.bg.cardHover,
borderLeftColor: themeColors.primary.gold,
}}
borderRadius="md" borderRadius="md"
borderLeftWidth="3px" borderLeftWidth="3px"
borderLeftColor="transparent" borderLeftColor="transparent"
_hover={{ borderLeftColor: themeColors.primary.gold }}
transition="all 0.2s" transition="all 0.2s"
onClick={() => { onClick={() => {
// 转换搜索结果为 TreeMetric 格式 // 转换搜索结果为 TreeMetric 格式
const metric: TreeMetric = { const metric: TreeMetric = {
metric_id: result.metric_id, metric_id: result.metric_id,
metric_name: result.metric_name, metric_name: result.metric_name,
source: result.source, source: result.source as 'SMM' | 'Mysteel',
frequency: result.frequency, frequency: result.frequency,
unit: result.unit, unit: result.unit,
description: result.description, description: result.description,

View File

@@ -29,10 +29,12 @@ import {
FaChartLine, FaChartLine,
FaInfoCircle FaInfoCircle
} from 'react-icons/fa'; } from 'react-icons/fa';
import { stockService } from '../../../services/eventService'; import { Tag } from 'antd';
import { logger } from '../../../utils/logger'; import { RobotOutlined } from '@ant-design/icons';
import CitedContent from '../../../components/Citation/CitedContent'; import { stockService } from '@services/eventService';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme'; import { logger } from '@utils/logger';
import CitedContent from '@components/Citation/CitedContent';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
const HistoricalEvents = ({ const HistoricalEvents = ({
events = [], events = [],
@@ -244,7 +246,7 @@ const HistoricalEvents = ({
key={event.id} key={event.id}
bg={cardBg} bg={cardBg}
borderWidth="1px" borderWidth="1px"
borderColor={borderColor} borderColor="gray.500"
borderRadius="lg" borderRadius="lg"
position="relative" position="relative"
overflow="visible" overflow="visible"
@@ -267,16 +269,16 @@ const HistoricalEvents = ({
}} }}
transition="all 0.2s" transition="all 0.2s"
> >
<VStack align="stretch" spacing={2} p={3}> <VStack align="stretch" spacing={3} p={4}>
{/* 顶部区域:左侧(标题+时间) + 右侧(按钮) */} {/* 顶部区域:左侧(标题+时间) + 右侧(按钮) */}
<HStack align="flex-start" spacing={3}> <HStack align="flex-start" spacing={3}>
{/* 左侧:标题 + 时间信息(允许折行) */} {/* 左侧:标题 + 时间信息(允许折行) */}
<VStack flex="1" align="flex-start" spacing={1}> <VStack flex="1" align="flex-start" spacing={2}>
{/* 标题 */} {/* 标题 */}
<Text <Text
fontSize="md" fontSize="lg"
fontWeight="bold" fontWeight="bold"
color={useColorModeValue('blue.600', 'blue.400')} color={useColorModeValue('blue.500', 'blue.300')}
lineHeight="1.4" lineHeight="1.4"
cursor="pointer" cursor="pointer"
onClick={(e) => { onClick={(e) => {
@@ -290,27 +292,28 @@ const HistoricalEvents = ({
{/* 时间 + Badges允许折行 */} {/* 时间 + Badges允许折行 */}
<HStack spacing={2} flexWrap="wrap"> <HStack spacing={2} flexWrap="wrap">
<Text fontSize="sm" color={textSecondary}> <Text fontSize="sm" color="gray.300" fontWeight="medium">
{formatDate(getEventDate(event))} {formatDate(getEventDate(event))}
</Text> </Text>
<Text fontSize="sm" color={textSecondary}> <Text fontSize="sm" color="gray.400">
({getRelativeTime(getEventDate(event))}) ({getRelativeTime(getEventDate(event))})
</Text> </Text>
{event.importance && ( {event.importance && (
<Badge colorScheme={importanceColor} size="sm"> <Badge colorScheme={importanceColor} fontSize="xs" px={2}>
重要性: {event.importance} 重要性: {event.importance}
</Badge> </Badge>
)} )}
{event.avg_change_pct !== undefined && event.avg_change_pct !== null && ( {event.avg_change_pct !== undefined && event.avg_change_pct !== null && (
<Badge <Badge
colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'} colorScheme={event.avg_change_pct > 0 ? 'red' : event.avg_change_pct < 0 ? 'green' : 'gray'}
size="sm" fontSize="xs"
px={2}
> >
涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}% 涨幅: {event.avg_change_pct > 0 ? '+' : ''}{event.avg_change_pct.toFixed(2)}%
</Badge> </Badge>
)} )}
{event.similarity !== undefined && event.similarity !== null && ( {event.similarity !== undefined && event.similarity !== null && (
<Badge colorScheme={getSimilarityColor(event.similarity)} size="sm"> <Badge colorScheme={getSimilarityColor(event.similarity)} fontSize="xs" px={2}>
相关度: {event.similarity} 相关度: {event.similarity}
</Badge> </Badge>
)} )}
@@ -344,10 +347,9 @@ const HistoricalEvents = ({
data={content} data={content}
title="" title=""
showAIBadge={true} showAIBadge={true}
textColor={PROFESSIONAL_COLORS.text.primary} textColor="#E2E8F0"
containerStyle={{ containerStyle={{
backgroundColor: useColorModeValue('#f7fafc', 'rgba(45, 55, 72, 0.6)'), backgroundColor: 'transparent',
borderRadius: '8px',
padding: '0', padding: '0',
}} }}
/> />

View File

@@ -28,9 +28,11 @@ import {
ModalCloseButton, ModalCloseButton,
Icon, Icon,
useColorModeValue, useColorModeValue,
Tooltip Tooltip,
Center
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { InfoIcon, ViewIcon } from '@chakra-ui/icons'; import { InfoIcon, ViewIcon } from '@chakra-ui/icons';
import { Share2, GitBranch, Inbox } from 'lucide-react';
import ReactECharts from 'echarts-for-react'; import ReactECharts from 'echarts-for-react';
import { eventService } from '../../../services/eventService'; import { eventService } from '../../../services/eventService';
import CitedContent from '../../../components/Citation/CitedContent'; import CitedContent from '../../../components/Citation/CitedContent';
@@ -637,7 +639,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
}; };
return ( return (
<Box p={6}> <Box>
{/* 统计信息条 */} {/* 统计信息条 */}
<Box <Box
mb={4} mb={4}
@@ -647,56 +649,57 @@ const TransmissionChainAnalysis = ({ eventId }) => {
borderColor={PROFESSIONAL_COLORS.border.default} borderColor={PROFESSIONAL_COLORS.border.default}
bg={PROFESSIONAL_COLORS.background.secondary} bg={PROFESSIONAL_COLORS.background.secondary}
> >
<HStack spacing={6} wrap="wrap"> <Flex wrap="wrap" gap={{ base: 3, md: 6 }}>
<Stat> <Stat minW="fit-content">
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>总节点数</StatLabel> <StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">总节点数</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.totalNodes}</StatNumber> <StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.totalNodes}</StatNumber>
</Stat> </Stat>
<Stat> <Stat minW="fit-content">
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>涉及行业</StatLabel> <StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">涉及行业</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.involvedIndustries}</StatNumber> <StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.involvedIndustries}</StatNumber>
</Stat> </Stat>
<Stat> <Stat minW="fit-content">
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>相关公司</StatLabel> <StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">相关公司</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.relatedCompanies}</StatNumber> <StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.relatedCompanies}</StatNumber>
</Stat> </Stat>
<Stat> <Stat minW="fit-content">
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>正向影响</StatLabel> <StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">正向影响</StatLabel>
<StatNumber color="#10B981">{stats.positiveImpact}</StatNumber> <StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#10B981">{stats.positiveImpact}</StatNumber>
</Stat> </Stat>
<Stat> <Stat minW="fit-content">
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>负向影响</StatLabel> <StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">负向影响</StatLabel>
<StatNumber color="#EF4444">{stats.negativeImpact}</StatNumber> <StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#EF4444">{stats.negativeImpact}</StatNumber>
</Stat> </Stat>
<Stat> <Stat minW="fit-content">
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>循环效应</StatLabel> <StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">循环效应</StatLabel>
<StatNumber color="#A855F7">{stats.circularEffect}</StatNumber> <StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#A855F7">{stats.circularEffect}</StatNumber>
</Stat> </Stat>
</HStack> </Flex>
</Box> </Box>
{/* 自定义图例 */} {/* 自定义图例 */}
<Box mb={4}> <Flex mb={4} wrap="wrap" gap={2}>
<HStack spacing={4} wrap="wrap">
{Object.entries(NODE_STYLES).map(([type, style]) => ( {Object.entries(NODE_STYLES).map(([type, style]) => (
<Tag <Tag
key={type} key={type}
size="md" size="sm"
px={2}
py={1}
bg={PROFESSIONAL_COLORS.background.secondary} bg={PROFESSIONAL_COLORS.background.secondary}
color={PROFESSIONAL_COLORS.text.primary} color={PROFESSIONAL_COLORS.text.primary}
borderWidth="1px" borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default} borderColor={PROFESSIONAL_COLORS.border.default}
> >
<Box w={3} h={3} bg={style.color} borderRadius="sm" mr={2} /> <Box w={2.5} h={2.5} bg={style.color} borderRadius="sm" mr={1.5} />
{NODE_TYPE_LABELS[type] || type} {NODE_TYPE_LABELS[type] || type}
</Tag> </Tag>
))} ))}
</HStack> </Flex>
</Box>
{/* 视图切换按钮 */} {/* 视图切换按钮 */}
<Flex mb={4} gap={2}> <Flex mb={4} gap={2}>
<Button <Button
leftIcon={<Icon as={Share2} boxSize={4} />}
bg={viewMode === 'graph' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary} bg={viewMode === 'graph' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary}
color={viewMode === 'graph' ? 'black' : PROFESSIONAL_COLORS.text.primary} color={viewMode === 'graph' ? 'black' : PROFESSIONAL_COLORS.text.primary}
_hover={{ _hover={{
@@ -710,6 +713,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
力导向图 力导向图
</Button> </Button>
<Button <Button
leftIcon={<Icon as={GitBranch} boxSize={4} />}
bg={viewMode === 'sankey' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary} bg={viewMode === 'sankey' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary}
color={viewMode === 'sankey' ? 'black' : PROFESSIONAL_COLORS.text.primary} color={viewMode === 'sankey' ? 'black' : PROFESSIONAL_COLORS.text.primary}
_hover={{ _hover={{
@@ -722,7 +726,6 @@ const TransmissionChainAnalysis = ({ eventId }) => {
> >
桑基图 桑基图
</Button> </Button>
</Flex> </Flex>
{loading && ( {loading && (
@@ -748,35 +751,54 @@ const TransmissionChainAnalysis = ({ eventId }) => {
{!loading && !error && ( {!loading && !error && (
<Box> <Box>
{/* 提示信息 */} {/* 图表容器 - 宽高比 2:1H5 自适应 */}
<Alert
status="info"
mb={4}
borderRadius="md"
bg="rgba(59, 130, 246, 0.1)"
color="#3B82F6"
borderWidth="1px"
borderColor="#3B82F6"
>
<AlertIcon />
<Text fontSize="sm" color={PROFESSIONAL_COLORS.text.secondary}>
<Icon as={ViewIcon} mr={2} />
点击图表中的节点可以查看详细信息
</Text>
</Alert>
{/* 图表容器 */}
<Box <Box
h={viewMode === 'sankey' ? "600px" : "700px"} position="relative"
w="100%"
pb={{ base: "75%", md: "50%" }}
border="1px solid" border="1px solid"
borderColor={PROFESSIONAL_COLORS.border.default} borderColor={PROFESSIONAL_COLORS.border.default}
borderRadius="lg" borderRadius="lg"
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)" boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
bg={PROFESSIONAL_COLORS.background.card} bg={PROFESSIONAL_COLORS.background.card}
p={4}
ref={containerRef} ref={containerRef}
> >
<Box
position="absolute"
top={0}
left={0}
right={0}
bottom={0}
p={4}
>
{/* 提示信息 - 固定在左上角 */}
<Text
position="absolute"
top={2}
left={3}
fontSize="xs"
color={PROFESSIONAL_COLORS.text.muted}
zIndex={1}
bg="rgba(0, 0, 0, 0.5)"
px={2}
py={1}
borderRadius="md"
>
<Icon as={ViewIcon} mr={1} boxSize={3} />
点击节点查看详情
</Text>
{chartReady && ( {chartReady && (
<>
{/* 空状态提示 */}
{(viewMode === 'graph' && (!graphData || !graphData.nodes || graphData.nodes.length === 0)) ||
(viewMode === 'sankey' && (!sankeyData || !sankeyData.nodes || sankeyData.nodes.length === 0)) ? (
<Center h="100%" flexDirection="column">
<Icon as={Inbox} boxSize={12} color={PROFESSIONAL_COLORS.text.muted} />
<Text mt={4} color={PROFESSIONAL_COLORS.text.muted} fontSize="sm">
暂无传导链数据
</Text>
</Center>
) : (
<> <>
{viewMode === 'graph' ? ( {viewMode === 'graph' ? (
<ReactECharts <ReactECharts
@@ -817,6 +839,9 @@ const TransmissionChainAnalysis = ({ eventId }) => {
)} )}
</> </>
)} )}
</>
)}
</Box>
</Box> </Box>
</Box> </Box>
)} )}
@@ -826,8 +851,8 @@ const TransmissionChainAnalysis = ({ eventId }) => {
<Modal isOpen={isModalOpen} onClose={handleCloseModal} size="xl"> <Modal isOpen={isModalOpen} onClose={handleCloseModal} size="xl">
<ModalOverlay /> <ModalOverlay />
<ModalContent maxH="80vh" bg={modalBgColor}> <ModalContent maxH="80vh" bg={modalBgColor}>
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor}> <ModalHeader borderBottom="1px solid" borderColor={modalBorderColor} pr={12}>
<HStack justify="space-between"> <HStack justify="space-between" pr={2}>
<Text color={PROFESSIONAL_COLORS.text.primary}>{selectedNode ? '节点详情' : '传导链分析'}</Text> <Text color={PROFESSIONAL_COLORS.text.primary}>{selectedNode ? '节点详情' : '传导链分析'}</Text>
{selectedNode && ( {selectedNode && (
<Badge <Badge
@@ -841,7 +866,10 @@ const TransmissionChainAnalysis = ({ eventId }) => {
)} )}
</HStack> </HStack>
</ModalHeader> </ModalHeader>
<ModalCloseButton /> <ModalCloseButton
color={PROFESSIONAL_COLORS.text.secondary}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
/>
<ModalBody overflowY="auto"> <ModalBody overflowY="auto">
{selectedNode ? ( {selectedNode ? (
@@ -1084,11 +1112,15 @@ const TransmissionChainAnalysis = ({ eventId }) => {
prefix="机制:" prefix="机制:"
prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }} prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }}
textColor={PROFESSIONAL_COLORS.text.primary} textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{ marginTop: 8 }} containerStyle={{
marginTop: 8,
backgroundColor: 'transparent',
padding: 0,
}}
showAIBadge={false} showAIBadge={false}
/> />
) : parent.transmission_mechanism ? ( ) : parent.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600"> <Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary}>
机制: {parent.transmission_mechanism}AI合成 机制: {parent.transmission_mechanism}AI合成
</Text> </Text>
) : null} ) : null}
@@ -1109,19 +1141,38 @@ const TransmissionChainAnalysis = ({ eventId }) => {
if (targetsFromAPI) { if (targetsFromAPI) {
return ( return (
<Box> <Box>
<Text fontWeight="bold" mb={2} color="blue.600"> <Text fontWeight="bold" mb={2} color={PROFESSIONAL_COLORS.gold[500]}>
影响输出 ({nodeDetail.children.length}) 影响输出 ({nodeDetail.children.length})AI合成
</Text> </Text>
<List spacing={2}> <List spacing={2}>
{nodeDetail.children.map((child, index) => ( {nodeDetail.children.map((child, index) => (
<ListItem key={index} p={2} bg="gray.50" borderRadius="md" borderLeft="3px solid" borderColor="orange.300" position="relative"> <ListItem
key={index}
p={2}
bg={PROFESSIONAL_COLORS.background.secondary}
borderRadius="md"
borderLeft="3px solid"
borderColor="#FB923C"
position="relative"
>
{child.direction && ( {child.direction && (
<Box position="absolute" top={2} right={2} zIndex={1}> <Box position="absolute" top={2} right={2} zIndex={1}>
<Badge <Badge
colorScheme={ bg={
child.direction === 'positive' ? 'green' : child.direction === 'positive' ? 'rgba(16, 185, 129, 0.15)' :
child.direction === 'negative' ? 'red' : child.direction === 'negative' ? 'rgba(239, 68, 68, 0.15)' :
'gray' 'rgba(107, 114, 128, 0.15)'
}
color={
child.direction === 'positive' ? '#10B981' :
child.direction === 'negative' ? '#EF4444' :
'#6B7280'
}
borderWidth="1px"
borderColor={
child.direction === 'positive' ? '#10B981' :
child.direction === 'negative' ? '#EF4444' :
'#6B7280'
} }
size="sm" size="sm"
> >
@@ -1132,7 +1183,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</Box> </Box>
)} )}
<VStack align="stretch" spacing={1}> <VStack align="stretch" spacing={1}>
<Text fontWeight="bold" fontSize="sm" pr={child.direction ? 20 : 0}>{child.name}</Text> <Text fontWeight="bold" fontSize="sm" color={PROFESSIONAL_COLORS.text.primary} pr={child.direction ? 20 : 0}>{child.name}</Text>
{child.transmission_mechanism?.data ? ( {child.transmission_mechanism?.data ? (
<CitedContent <CitedContent
data={child.transmission_mechanism} data={child.transmission_mechanism}
@@ -1140,11 +1191,15 @@ const TransmissionChainAnalysis = ({ eventId }) => {
prefix="机制:" prefix="机制:"
prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }} prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }}
textColor={PROFESSIONAL_COLORS.text.primary} textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{ marginTop: 8 }} containerStyle={{
marginTop: 8,
backgroundColor: 'transparent',
padding: 0,
}}
showAIBadge={false} showAIBadge={false}
/> />
) : child.transmission_mechanism ? ( ) : child.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600"> <Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary}>
机制: {child.transmission_mechanism}AI合成 机制: {child.transmission_mechanism}AI合成
</Text> </Text>
) : null} ) : null}
@@ -1169,7 +1224,14 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</ModalBody> </ModalBody>
<ModalFooter borderTop="1px solid" borderColor={modalBorderColor}> <ModalFooter borderTop="1px solid" borderColor={modalBorderColor}>
<Button onClick={handleCloseModal}>关闭</Button> <Button
onClick={handleCloseModal}
variant="ghost"
color={PROFESSIONAL_COLORS.text.secondary}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
>
关闭
</Button>
</ModalFooter> </ModalFooter>
</ModalContent> </ModalContent>
</Modal> </Modal>

View File

@@ -9,6 +9,7 @@ import { usePostHogTrack } from '@/hooks/usePostHogRedux';
import { useHomeResponsive } from '@/hooks/useHomeResponsive'; import { useHomeResponsive } from '@/hooks/useHomeResponsive';
import { ACQUISITION_EVENTS } from '@/lib/constants'; import { ACQUISITION_EVENTS } from '@/lib/constants';
import { CORE_FEATURES } from '@/constants/homeFeatures'; import { CORE_FEATURES } from '@/constants/homeFeatures';
import { performanceMonitor } from '@/utils/performanceMonitor';
import type { Feature } from '@/types/home'; import type { Feature } from '@/types/home';
import { HeroBackground } from './components/HeroBackground'; import { HeroBackground } from './components/HeroBackground';
import { HeroHeader } from './components/HeroHeader'; import { HeroHeader } from './components/HeroHeader';
@@ -36,6 +37,11 @@ const HomePage: React.FC = () => {
showDecorations showDecorations
} = useHomeResponsive(); } = useHomeResponsive();
// ⚡ 性能标记:首页组件挂载 = 渲染开始
useEffect(() => {
performanceMonitor.mark('homepage-render-start');
}, []);
// PostHog 追踪:页面浏览 // PostHog 追踪:页面浏览
useEffect(() => { useEffect(() => {
track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, { track(ACQUISITION_EVENTS.LANDING_PAGE_VIEWED, {
@@ -67,6 +73,8 @@ const HomePage: React.FC = () => {
// 背景图片加载完成回调 // 背景图片加载完成回调
const handleImageLoad = useCallback(() => { const handleImageLoad = useCallback(() => {
setImageLoaded(true); setImageLoaded(true);
// ⚡ 性能标记:首页渲染完成(背景图片加载完成 = 首屏视觉完整)
performanceMonitor.mark('homepage-render-end');
}, []); }, []);
// 特色功能(第一个) // 特色功能(第一个)

View File

@@ -1,194 +0,0 @@
/**
* 首页骨架屏组件
* 模拟首页的 6 个功能卡片布局,减少白屏感知时间
*
* 使用 Chakra UI 的 Skeleton 组件
*
* @module views/Home/components/HomePageSkeleton
*/
import React from 'react';
import {
Box,
Container,
SimpleGrid,
Skeleton,
SkeletonText,
VStack,
HStack,
useColorModeValue,
} from '@chakra-ui/react';
// ============================================================
// 类型定义
// ============================================================
interface HomePageSkeletonProps {
/** 是否显示动画效果 */
isAnimated?: boolean;
/** 骨架屏速度(秒) */
speed?: number;
}
// ============================================================
// 单个卡片骨架
// ============================================================
const FeatureCardSkeleton: React.FC<{ isFeatured?: boolean }> = ({ isFeatured = false }) => {
const bg = useColorModeValue('white', 'gray.800');
const borderColor = useColorModeValue('gray.200', 'gray.700');
return (
<Box
bg={bg}
borderRadius="xl"
borderWidth="1px"
borderColor={borderColor}
p={isFeatured ? 8 : 6}
h={isFeatured ? '350px' : '280px'}
boxShadow={isFeatured ? 'xl' : 'md'}
position="relative"
>
<VStack align="start" spacing={4} h="full">
{/* 图标骨架 */}
<Skeleton
height={isFeatured ? '60px' : '48px'}
width={isFeatured ? '60px' : '48px'}
borderRadius="lg"
startColor={isFeatured ? 'blue.100' : 'gray.100'}
endColor={isFeatured ? 'blue.200' : 'gray.200'}
/>
{/* 标题骨架 */}
<Skeleton height="28px" width="70%" borderRadius="md" />
{/* 描述骨架 */}
<SkeletonText
mt="2"
noOfLines={isFeatured ? 4 : 3}
spacing="3"
skeletonHeight="2"
width="100%"
/>
{/* 按钮骨架 */}
<Skeleton
height="40px"
width={isFeatured ? '140px' : '100px'}
borderRadius="md"
mt="auto"
/>
</VStack>
{/* Featured 徽章骨架 */}
{isFeatured && (
<Skeleton
position="absolute"
top="4"
right="4"
height="24px"
width="80px"
borderRadius="full"
/>
)}
</Box>
);
};
// ============================================================
// 主骨架组件
// ============================================================
export const HomePageSkeleton: React.FC<HomePageSkeletonProps> = ({
isAnimated = true,
speed = 0.8,
}) => {
const containerBg = useColorModeValue('gray.50', 'gray.900');
return (
<Box
w="full"
minH="100vh"
bg={containerBg}
pt={{ base: '120px', md: '140px' }}
pb={{ base: '60px', md: '80px' }}
>
<Container maxW="container.xl">
<VStack spacing={{ base: 8, md: 12 }} align="stretch">
{/* 顶部标题区域骨架 */}
<VStack spacing={4} textAlign="center">
{/* 主标题 */}
<Skeleton
height={{ base: '40px', md: '56px' }}
width={{ base: '80%', md: '500px' }}
borderRadius="md"
speed={speed}
/>
{/* 副标题 */}
<Skeleton
height={{ base: '20px', md: '24px' }}
width={{ base: '90%', md: '600px' }}
borderRadius="md"
speed={speed}
/>
{/* CTA 按钮 */}
<HStack spacing={4} mt={4}>
<Skeleton
height="48px"
width="140px"
borderRadius="lg"
speed={speed}
/>
<Skeleton
height="48px"
width="140px"
borderRadius="lg"
speed={speed}
/>
</HStack>
</VStack>
{/* 功能卡片网格骨架 */}
<SimpleGrid
columns={{ base: 1, md: 2, lg: 3 }}
spacing={{ base: 6, md: 8 }}
mt={8}
>
{/* 第一张卡片 - Featured (新闻中心) */}
<Box gridColumn={{ base: 'span 1', lg: 'span 2' }}>
<FeatureCardSkeleton isFeatured />
</Box>
{/* 其余 5 张卡片 */}
{[1, 2, 3, 4, 5].map((index) => (
<FeatureCardSkeleton key={index} />
))}
</SimpleGrid>
{/* 底部装饰元素骨架 */}
<HStack justify="center" spacing={8} mt={12}>
{[1, 2, 3].map((index) => (
<VStack key={index} spacing={2} align="center">
<Skeleton
height="40px"
width="40px"
borderRadius="full"
speed={speed}
/>
<Skeleton height="16px" width="60px" borderRadius="md" speed={speed} />
</VStack>
))}
</HStack>
</VStack>
</Container>
</Box>
);
};
// ============================================================
// 默认导出
// ============================================================
export default HomePageSkeleton;

View File

@@ -37,20 +37,40 @@ export default function WechatCallback() {
// 1. 获取URL参数 // 1. 获取URL参数
const code = searchParams.get("code"); const code = searchParams.get("code");
const state = searchParams.get("state"); const state = searchParams.get("state");
const wechatLogin = searchParams.get("wechat_login");
// 2. 参数验证 // 2. 检查是否是 H5 模式登录成功回调
// 后端已经完成登录,只需要刷新前端 session 状态
if (wechatLogin === "success") {
logger.info('WechatCallback', 'H5 模式登录成功', { state });
// 刷新 session 状态
await checkSession();
// 显示成功状态
setStatus("success");
setMessage("登录成功!正在跳转...");
// 延迟跳转到首页
setTimeout(() => {
navigate("/home", { replace: true });
}, 1000);
return;
}
// 3. 原有的 code 模式处理(微信直接回调前端的情况)
if (!code) { if (!code) {
throw new Error("授权失败:缺少授权码"); throw new Error("授权失败:缺少授权码");
} }
// 3. 调用后端处理回调 // 4. 调用后端处理回调
const response = await authService.handleWechatH5Callback(code, state); const response = await authService.handleWechatH5Callback(code, state);
if (!response || !response.success) { if (!response || !response.success) {
throw new Error(response?.error || "授权失败,请重试"); throw new Error(response?.error || "授权失败,请重试");
} }
// 4. 存储用户信息如果有返回token // 5. 存储用户信息如果有返回token
if (response.token) { if (response.token) {
localStorage.setItem("token", response.token); localStorage.setItem("token", response.token);
} }
@@ -58,14 +78,14 @@ export default function WechatCallback() {
localStorage.setItem("user", JSON.stringify(response.user)); localStorage.setItem("user", JSON.stringify(response.user));
} }
// 5. 更新session // 6. 更新session
await checkSession(); await checkSession();
// 6. 显示成功状态 // 7. 显示成功状态
setStatus("success"); setStatus("success");
setMessage("登录成功!正在跳转..."); setMessage("登录成功!正在跳转...");
// 7. 延迟跳转到首页 // 8. 延迟跳转到首页
setTimeout(() => { setTimeout(() => {
navigate("/home", { replace: true }); navigate("/home", { replace: true });
}, 1500); }, 1500);
@@ -73,6 +93,7 @@ export default function WechatCallback() {
logger.error('WechatCallback', 'handleCallback', error, { logger.error('WechatCallback', 'handleCallback', error, {
code: searchParams.get("code"), code: searchParams.get("code"),
state: searchParams.get("state"), state: searchParams.get("state"),
wechat_login: searchParams.get("wechat_login"),
errorMessage: error.message errorMessage: error.message
}); });
setStatus("error"); setStatus("error");

View File

@@ -1,5 +1,5 @@
import { motion } from "framer-motion"; import { motion } from "framer-motion";
import Button from "@/components/Button2"; import Button from "@/components/Button";
import { pricing } from "./content"; import { pricing } from "./content";