Compare commits

...

63 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
97 changed files with 2680 additions and 4034 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:

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,110 +31,151 @@ 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;
}
// 加载Bytedesk Widget脚本 // ⚡ 使用 requestIdleCallback 延迟加载,不阻塞首屏
const script = document.createElement('script'); const loadWidget = () => {
script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js'; // 再次检查,防止竞态条件
script.async = true; if (widgetInitialized) return;
script.id = 'bytedesk-web-script'; widgetInitialized = true;
script.onload = () => { // 检查脚本是否已存在
console.log('[Bytedesk] Widget脚本加载成功'); if (document.getElementById('bytedesk-web-script')) {
return;
}
try { // 加载Bytedesk Widget脚本
if (window.BytedeskWeb) { const script = document.createElement('script');
console.log('[Bytedesk] 初始化Widget'); script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js';
const bytedesk = new window.BytedeskWeb(config); script.async = true;
bytedesk.init(); script.id = 'bytedesk-web-script';
widgetRef.current = bytedesk; script.onload = () => {
console.log('[Bytedesk] Widget初始化成功'); try {
if (window.BytedeskWeb) {
const bytedesk = new window.BytedeskWeb(config);
bytedesk.init();
widgetRef.current = bytedesk;
// ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能 // ⚡ H5 端样式适配:使用 MutationObserver 立即应用样式(避免闪烁
// Bytedesk SDK 内部的 /stomp WebSocket 连接失败不影响核心客服功能 const isMobile = window.innerWidth <= 768;
// SDK 会自动降级使用 HTTP 轮询
const originalConsoleError = console.error; const applyBytedeskStyles = () => {
console.error = function(...args) { const allElements = document.querySelectorAll('body > div');
const errorMsg = args.join(' '); allElements.forEach(el => {
// 忽略 /stomp 和 STOMP 相关错误 const style = window.getComputedStyle(el);
if (errorMsg.includes('/stomp') || // 检查是否是右下角固定定位的元素Bytedesk 按钮)
errorMsg.includes('stomp onWebSocketError') || if (style.position === 'fixed' && style.right && style.bottom) {
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) { const rightVal = parseInt(style.right);
return; // 不输出日志 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 错误日志(不影响功能)
const originalConsoleError = console.error;
console.error = function(...args) {
const errorMsg = args.join(' ');
if (errorMsg.includes('/stomp') ||
errorMsg.includes('stomp onWebSocketError') ||
(errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) {
return;
}
originalConsoleError.apply(console, args);
};
if (onLoad) {
onLoad(bytedesk);
} }
originalConsoleError.apply(console, args); } else {
}; throw new Error('BytedeskWeb对象未定义');
}
if (onLoad) { } catch (error) {
onLoad(bytedesk); console.error('[Bytedesk] 初始化失败:', error);
if (onError) {
onError(error);
} }
} else {
throw new Error('BytedeskWeb对象未定义');
} }
} catch (error) { };
console.error('[Bytedesk] Widget初始化失败:', error);
script.onerror = (error) => {
console.error('[Bytedesk] 脚本加载失败:', error);
widgetInitialized = false; // 允许重试
if (onError) { if (onError) {
onError(error); onError(error);
} }
} };
document.body.appendChild(script);
scriptRef.current = script;
}; };
script.onerror = (error) => { // ⚡ 使用 requestIdleCallback 在浏览器空闲时加载
console.error('[Bytedesk] Widget脚本加载失败:', error); if ('requestIdleCallback' in window) {
if (onError) { idleCallbackId = requestIdleCallback(loadWidget, { timeout: 3000 });
onError(error); } else {
} // 降级:使用 setTimeout
}; idleCallbackId = setTimeout(loadWidget, 100);
}
// 添加脚本到页面 // 清理函数
document.body.appendChild(script);
scriptRef.current = script;
// 清理函数 - 增强错误处理,防止 React 18 StrictMode 双重清理报错
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,40 +1,52 @@
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, className?: string;
href, href?: string;
onClick, onClick?: () => void;
children, children?: React.ReactNode;
px, px?: string;
white, white?: boolean;
isPrimary?: boolean;
isSecondary?: boolean;
}
const Button: React.FC<ButtonProps> = ({
className,
href,
onClick,
children,
px,
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}>
<span className={spanClasses}>{children}</span>
{svgs(white)}
</Link>
)
) : ( ) : (
<button className={classes} onClick={onClick}> <Link to={href} className={classes}>
<span className={spanClasses}>{children}</span> <span className={spanClasses}>{children}</span>
{svgs(white)} {svgs(white)}
</button> </Link>
); )
) : (
<button className={classes} onClick={onClick}>
<span className={spanClasses}>{children}</span>
{svgs(white)}
</button>
);
}; };
export default Button; export default Button;

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,20 +1,25 @@
import Image from "../Image"; import React from 'react';
import Image from '../Image';
const Generating = ({ className }) => ( interface GeneratingProps {
<div className?: string;
className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${ }
className || ""
} text-base`} const Generating: React.FC<GeneratingProps> = ({ className }) => (
> <div
<Image className={`flex items-center h-[3.375rem] px-6 bg-n-8/80 rounded-[1.6875rem] ${
className="w-5 h-5 mr-4" className || ''
src="/images/loading.png" } text-base`}
width={20} >
height={20} <Image
alt="Loading" className="w-5 h-5 mr-4"
/> src="/images/loading.png"
AI is generating| width={20}
</div> height={20}
alt="Loading"
/>
AI is generating|
</div>
); );
export default Generating; export default Generating;

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

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,57 +1,67 @@
const Section = ({ import React from 'react';
className,
crosses, interface SectionProps {
crossesOffset, className?: string;
customPaddings, crosses?: boolean;
children, crossesOffset?: string;
customPaddings?: string;
children?: React.ReactNode;
}
const Section: React.FC<SectionProps> = ({
className,
crosses,
crossesOffset,
customPaddings,
children,
}) => ( }) => (
<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>
<div className="hidden absolute top-0 right-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:right-7.5 xl:right-10"></div> <div className="hidden absolute top-0 right-5 w-0.25 h-full bg-stroke-1 pointer-events-none md:block lg:right-7.5 xl:right-10"></div>
{crosses && ( {crosses && (
<> <>
<div <div
className={`hidden absolute top-0 left-7.5 right-7.5 h-0.25 bg-stroke-1 ${ className={`hidden absolute top-0 left-7.5 right-7.5 h-0.25 bg-stroke-1 ${
crossesOffset && crossesOffset crossesOffset && crossesOffset
} pointer-events-none lg:block xl:left-10 right-10`} } pointer-events-none lg:block xl:left-10 right-10`}
></div> ></div>
<svg <svg
className={`hidden absolute -top-[0.3125rem] left-[1.5625rem] ${ className={`hidden absolute -top-[0.3125rem] left-[1.5625rem] ${
crossesOffset && crossesOffset crossesOffset && crossesOffset
} pointer-events-none lg:block xl:left-[2.1875rem]`} } pointer-events-none lg:block xl:left-[2.1875rem]`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="11" width="11"
height="11" height="11"
fill="none" fill="none"
> >
<path <path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z" d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4" fill="#ada8c4"
/> />
</svg> </svg>
<svg <svg
className={`hidden absolute -top-[0.3125rem] right-[1.5625rem] ${ className={`hidden absolute -top-[0.3125rem] right-[1.5625rem] ${
crossesOffset && crossesOffset crossesOffset && crossesOffset
} pointer-events-none lg:block xl:right-[2.1875rem]`} } pointer-events-none lg:block xl:right-[2.1875rem]`}
xmlns="http://www.w3.org/2000/svg" xmlns="http://www.w3.org/2000/svg"
width="11" width="11"
height="11" height="11"
fill="none" fill="none"
> >
<path <path
d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z" d="M7 1a1 1 0 0 0-1-1H5a1 1 0 0 0-1 1v2a1 1 0 0 1-1 1H1a1 1 0 0 0-1 1v1a1 1 0 0 0 1 1h2a1 1 0 0 1 1 1v2a1 1 0 0 0 1 1h1a1 1 0 0 0 1-1V8a1 1 0 0 1 1-1h2a1 1 0 0 0 1-1V5a1 1 0 0 0-1-1H8a1 1 0 0 1-1-1V1z"
fill="#ada8c4" fill="#ada8c4"
/> />
</svg> </svg>
</> </>
)} )}
</div> </div>
); );
export default Section; export default Section;

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',
value: { calcParamsText: '',
text: changeText, features: [] as any[],
color: '#FF9800', legends: [
}, {
title: { text: '均价: ', color: lineColor },
value: {
text: avgValue !== undefined ? avgValue.toFixed(2) : '--',
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,183 +658,223 @@ 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;
socket.on('connect', () => { let idleCallbackId;
setIsConnected(true); let timeoutId;
setReconnectAttempt(0);
// 判断是首次连接还是重连 // ⚡ Socket 初始化函数(将在浏览器空闲时执行)
if (isFirstConnect.current) { const initSocketConnection = () => {
logger.info('NotificationContext', '首次连接成功', { if (cleanupCalled || socketInitialized) return; // 防止组件卸载后执行或重复初始化
socketId: socket.getSocketId?.()
});
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
isFirstConnect.current = false;
} else {
logger.info('NotificationContext', '重连成功');
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
// 清除之前的定时器 socketInitialized = true; // 标记已初始化
if (reconnectedTimerRef.current) { logger.info('NotificationContext', '初始化 Socket 连接(异步执行,不阻塞首屏)');
clearTimeout(reconnectedTimerRef.current);
// ========== 监听连接成功(首次连接 + 重连) ==========
socket.on('connect', () => {
setIsConnected(true);
setReconnectAttempt(0);
// 判断是首次连接还是重连
if (isFirstConnect.current) {
logger.info('NotificationContext', '首次连接成功', {
socketId: socket.getSocketId?.()
});
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
isFirstConnect.current = false;
} else {
logger.info('NotificationContext', '重连成功');
setConnectionStatus(CONNECTION_STATUS.RECONNECTED);
// 清除之前的定时器
if (reconnectedTimerRef.current) {
clearTimeout(reconnectedTimerRef.current);
}
// 2秒后自动变回 CONNECTED
reconnectedTimerRef.current = setTimeout(() => {
setConnectionStatus(CONNECTION_STATUS.CONNECTED);
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status');
}, 2000);
} }
// 2秒后自动变回 CONNECTED // ⚡ 重连后只需重新订阅,不需要重新注册监听器
reconnectedTimerRef.current = setTimeout(() => { // 使用 setTimeout(0) 确保 socketService 内部状态已同步
setConnectionStatus(CONNECTION_STATUS.CONNECTED); setTimeout(() => {
logger.info('NotificationContext', 'Auto-dismissed RECONNECTED status'); logger.info('NotificationContext', '重新订阅事件推送');
}, 2000);
}
// ⚡ 重连后只需重新订阅,不需要重新注册监听器 if (socket.subscribeToEvents) {
logger.info('NotificationContext', '重新订阅事件推送'); socket.subscribeToEvents({
eventType: 'all',
if (socket.subscribeToEvents) { importance: 'all',
socket.subscribeToEvents({ onSubscribed: (data) => {
eventType: 'all', logger.info('NotificationContext', '订阅成功', data);
importance: 'all', },
onSubscribed: (data) => { });
logger.info('NotificationContext', '订阅成功', data); } else {
}, logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
});
} else {
logger.error('NotificationContext', 'socket.subscribeToEvents 方法不可用');
}
});
// ========== 监听断开连接 ==========
socket.on('disconnect', (reason) => {
setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket 已断开', { reason });
});
// ========== 监听连接错误 ==========
socket.on('connect_error', (error) => {
logger.error('NotificationContext', 'Socket connect_error', error);
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
});
// ========== 监听重连失败 ==========
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', '重连失败');
setConnectionStatus(CONNECTION_STATUS.FAILED);
toast({
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
status: 'error',
duration: null,
isClosable: true,
});
});
// ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数) ==========
socket.on('new_event', (data) => {
logger.info('NotificationContext', '收到 new_event 事件', {
id: data?.id,
title: data?.title,
eventType: data?.event_type || data?.type,
importance: data?.importance
});
logger.debug('NotificationContext', '原始事件数据', data);
// ⚠️ 防御性检查:确保 ref 已初始化
if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
addNotificationRef: !!addNotificationRef.current,
adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
});
return;
}
// ========== Socket层去重检查 ==========
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (!data.id) {
logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
eventId,
eventType: data.type,
title: data.title,
});
}
if (processedEventIds.current.has(eventId)) {
logger.warn('NotificationContext', '重复事件已忽略', { eventId });
return;
}
processedEventIds.current.add(eventId);
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
// 限制 Set 大小,避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedEventIds.current);
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
kept: MAX_PROCESSED_IDS,
});
}
// ========== Socket层去重检查结束 ==========
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
logger.debug('NotificationContext', '正在转换事件格式');
const notification = adaptEventToNotificationRef.current(data);
logger.debug('NotificationContext', '转换后的通知对象', notification);
// ✅ 使用 ref.current 访问最新的 addNotification 函数
logger.debug('NotificationContext', '准备添加通知到队列');
addNotificationRef.current(notification);
logger.info('NotificationContext', '通知已添加到队列');
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
if (eventUpdateCallbacks.current.size > 0) {
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
eventUpdateCallbacks.current.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('NotificationContext', '事件更新回调执行失败', error);
} }
}, 0);
});
// ========== 监听断开连接 ==========
socket.on('disconnect', (reason) => {
setIsConnected(false);
setConnectionStatus(CONNECTION_STATUS.DISCONNECTED);
logger.warn('NotificationContext', 'Socket 已断开', { reason });
});
// ========== 监听连接错误 ==========
socket.on('connect_error', (error) => {
logger.error('NotificationContext', 'Socket connect_error', error);
setConnectionStatus(CONNECTION_STATUS.RECONNECTING);
const attempts = socket.getReconnectAttempts?.() || 0;
setReconnectAttempt(attempts);
logger.info('NotificationContext', `重连中... (第 ${attempts} 次尝试)`);
});
// ========== 监听重连失败 ==========
socket.on('reconnect_failed', () => {
logger.error('NotificationContext', '重连失败');
setConnectionStatus(CONNECTION_STATUS.FAILED);
toast({
title: '连接失败',
description: '无法连接到服务器,请检查网络连接',
status: 'error',
duration: null,
isClosable: true,
}); });
logger.debug('NotificationContext', '所有事件更新回调已触发'); });
}
});
// ========== 监听系统通知(兼容性 ========== // ========== 监听新事件推送(⚡ 只注册一次,使用 ref 访问最新函数 ==========
socket.on('system_notification', (data) => { socket.on('new_event', (data) => {
logger.info('NotificationContext', '收到系统通知', data); logger.info('NotificationContext', '收到 new_event 事件', {
id: data?.id,
title: data?.title,
eventType: data?.event_type || data?.type,
importance: data?.importance
});
logger.debug('NotificationContext', '原始事件数据', data);
if (addNotificationRef.current) { // ⚠️ 防御性检查:确保 ref 已初始化
addNotificationRef.current(data); if (!addNotificationRef.current || !adaptEventToNotificationRef.current) {
} else { logger.error('NotificationContext', 'Ref 未初始化,跳过处理', {
logger.error('NotificationContext', 'addNotificationRef 未初始化'); addNotificationRef: !!addNotificationRef.current,
} adaptEventToNotificationRef: !!adaptEventToNotificationRef.current,
}); });
return;
}
logger.info('NotificationContext', '所有监听器已注册(只注册一次)'); // ========== Socket层去重检查 ==========
const eventId = data.id || `${data.type || 'unknown'}_${data.publishTime || Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
// ========== 获取最大重连次数 ========== if (!data.id) {
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity; logger.warn('NotificationContext', 'Event missing ID, generated fallback', {
setMaxReconnectAttempts(maxAttempts); eventId,
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts }); eventType: data.type,
title: data.title,
});
}
// ========== 启动连接 ========== if (processedEventIds.current.has(eventId)) {
logger.info('NotificationContext', '调用 socket.connect()'); logger.warn('NotificationContext', '重复事件已忽略', { eventId });
socket.connect(); return;
}
processedEventIds.current.add(eventId);
logger.debug('NotificationContext', '事件已记录,防止重复处理', { eventId });
// 限制 Set 大小,避免内存泄漏
if (processedEventIds.current.size > MAX_PROCESSED_IDS) {
const idsArray = Array.from(processedEventIds.current);
processedEventIds.current = new Set(idsArray.slice(-MAX_PROCESSED_IDS));
logger.debug('NotificationContext', 'Cleaned up old processed event IDs', {
kept: MAX_PROCESSED_IDS,
});
}
// ========== Socket层去重检查结束 ==========
// ✅ 使用 ref.current 访问最新的适配器函数(避免闭包陷阱)
logger.debug('NotificationContext', '正在转换事件格式');
const notification = adaptEventToNotificationRef.current(data);
logger.debug('NotificationContext', '转换后的通知对象', notification);
// ✅ 使用 ref.current 访问最新的 addNotification 函数
logger.debug('NotificationContext', '准备添加通知到队列');
addNotificationRef.current(notification);
logger.info('NotificationContext', '通知已添加到队列');
// ⚡ 调用所有注册的事件更新回调(用于通知其他组件刷新数据)
if (eventUpdateCallbacks.current.size > 0) {
logger.debug('NotificationContext', `触发 ${eventUpdateCallbacks.current.size} 个事件更新回调`);
eventUpdateCallbacks.current.forEach(callback => {
try {
callback(data);
} catch (error) {
logger.error('NotificationContext', '事件更新回调执行失败', error);
}
});
logger.debug('NotificationContext', '所有事件更新回调已触发');
}
});
// ========== 监听系统通知(兼容性) ==========
socket.on('system_notification', (data) => {
logger.info('NotificationContext', '收到系统通知', data);
if (addNotificationRef.current) {
addNotificationRef.current(data);
} else {
logger.error('NotificationContext', 'addNotificationRef 未初始化');
}
});
logger.info('NotificationContext', '所有监听器已注册');
// ========== 获取最大重连次数 ==========
const maxAttempts = socket.getMaxReconnectAttempts?.() || Infinity;
setMaxReconnectAttempts(maxAttempts);
logger.info('NotificationContext', 'Max reconnect attempts', { maxAttempts });
// ========== 启动连接 ==========
logger.info('NotificationContext', '调用 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) {
// 用户已登录,加载订阅信息 // 只在未加载且未在加载中时才请求,避免多个组件重复调用
dispatch(fetchSubscriptionInfo()); if (!loaded && !loading) {
logger.debug('useSubscription', '加载订阅信息', { userId: user.id }); dispatch(fetchSubscriptionInfo());
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, auth_url: authUrl,
message: '成功', state
data: {
auth_url: authUrl,
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);
this.connect();
if (!isReady) {
logger.debug('socketService', 'Socket 尚未就绪,等待连接后订阅');
if (!this.socket) {
// 自动连接
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
// ⚡ 基础 reducers首屏必需
const staticReducers = {
communityData: communityDataReducer,
industry: industryReducer, // ✅ 行业分类数据管理
stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
};
// ⚡ 动态 reducers 注册表
const asyncReducers = {};
// ⚡ 创建根 reducer 的工厂函数
const createRootReducer = () => combineReducers({
...staticReducers,
...asyncReducers,
});
export const store = configureStore({ export const store = configureStore({
reducer: { reducer: createRootReducer(),
communityData: communityDataReducer,
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
industry: industryReducer, // ✅ 行业分类数据管理
stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
},
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 }} />
<TradingTimeFilter
value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange}
compact
/>
</div>
</Tooltip>
{/* 行业筛选 */} {/* 日期筛选按钮组 */}
<Tooltip title="行业分类"> <div style={{ display: 'flex', alignItems: 'center', gap: 0 }}>
<CalendarOutlined style={{ color: PROFESSIONAL_COLORS.gold[500], fontSize: 14, marginRight: 8 }} />
<TradingTimeFilter
value={tradingTimeRange?.key || null}
onChange={handleTradingTimeChange}
compact={!isMobile}
mobile={isMobile}
/>
</div>
</Flex>
{/* 第二行:筛选条件 */}
<Flex justify="space-between" align="center">
{/* 左侧筛选 */}
<Space size={isMobile ? 4 : 8}>
{/* 行业筛选 */}
<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>
{/* 排序 */} {/* 右侧排序和重置 */}
<Tooltip title="排序方式"> <Space size={isMobile ? 4 : 8}>
{/* 排序 */}
<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}>
<HStack spacing={2}> <Heading size={isMobile ? "sm" : "md"} color={PROFESSIONAL_COLORS.text.primary}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} /> <HStack spacing={2}>
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text> <TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
</HStack> <Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</Heading> </HStack>
</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" </Box>
borderRadius="lg" }>
mb={4} <HeroPanel />
boxShadow="md" </Suspense>
bg={alertBgColor}
borderWidth="1px"
borderColor={alertBorderColor}
>
<AlertIcon />
<Box flex="1">
<AlertTitle fontSize="md" mb={1}>
开启桌面通知不错过重要事件
</AlertTitle>
<AlertDescription fontSize="sm">
即使浏览器最小化也能第一时间接收新事件推送通知
</AlertDescription>
</Box>
<HStack spacing={2} ml={4}>
<Button
size="sm"
colorScheme="blue"
onClick={handleEnableNotifications}
>
立即开启
</Button>
<CloseButton
onClick={handleCloseBanner}
position="relative"
/>
</HStack>
</Alert>
)}
{/* 顶部说明面板:产品介绍 + 沪深指数 + 热门概念词云 */}
<HeroPanel />
{/* 实时要闻·动态追踪 - 横向滚动 */} {/* 实时要闻·动态追踪 - 横向滚动 */}
<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

@@ -1,18 +1,18 @@
import React, { useEffect, useState, useRef } from 'react'; import React, { useEffect, useState, useRef } from 'react';
import { import {
Box, Box,
Button, Button,
Flex, Flex,
Spinner, Spinner,
Alert, Alert,
AlertIcon, AlertIcon,
Text, Text,
Stat, Stat,
StatLabel, StatLabel,
StatNumber, StatNumber,
HStack, HStack,
VStack, VStack,
Tag, Tag,
Badge, Badge,
List, List,
ListItem, ListItem,
@@ -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="sm"
size="md" px={2}
bg={PROFESSIONAL_COLORS.background.secondary} py={1}
color={PROFESSIONAL_COLORS.text.primary} bg={PROFESSIONAL_COLORS.background.secondary}
borderWidth="1px" color={PROFESSIONAL_COLORS.text.primary}
borderColor={PROFESSIONAL_COLORS.border.default} borderWidth="1px"
> borderColor={PROFESSIONAL_COLORS.border.default}
<Box w={3} h={3} bg={style.color} borderRadius="sm" mr={2} /> >
{NODE_TYPE_LABELS[type] || type} <Box w={2.5} h={2.5} bg={style.color} borderRadius="sm" mr={1.5} />
</Tag> {NODE_TYPE_LABELS[type] || type}
))} </Tag>
</HStack> ))}
</Box> </Flex>
{/* 视图切换按钮 */} {/* 视图切换按钮 */}
<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,86 +751,108 @@ 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' ? ( {/* 空状态提示 */}
<ReactECharts {(viewMode === 'graph' && (!graphData || !graphData.nodes || graphData.nodes.length === 0)) ||
option={graphData ? getGraphOption(graphData) : {}} (viewMode === 'sankey' && (!sankeyData || !sankeyData.nodes || sankeyData.nodes.length === 0)) ? (
style={{ height: '100%', width: '100%' }} <Center h="100%" flexDirection="column">
onEvents={{ <Icon as={Inbox} boxSize={12} color={PROFESSIONAL_COLORS.text.muted} />
click: handleGraphNodeClick <Text mt={4} color={PROFESSIONAL_COLORS.text.muted} fontSize="sm">
}} 暂无传导链数据
opts={{ </Text>
renderer: 'canvas', </Center>
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
) : ( ) : (
<ReactECharts <>
option={sankeyData ? getSankeyOption(sankeyData) : {}} {viewMode === 'graph' ? (
style={{ height: '100%', width: '100%' }} <ReactECharts
onEvents={{ option={graphData ? getGraphOption(graphData) : {}}
click: handleSankeyNodeClick style={{ height: '100%', width: '100%' }}
}} onEvents={{
opts={{ click: handleGraphNodeClick
renderer: 'canvas', }}
devicePixelRatio: window.devicePixelRatio || 1 opts={{
}} renderer: 'canvas',
lazyUpdate={true} devicePixelRatio: window.devicePixelRatio || 1
notMerge={false} }}
shouldSetOption={(prevProps, props) => { lazyUpdate={true}
// 减少不必要的重新渲染 notMerge={false}
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option); shouldSetOption={(prevProps, props) => {
}} // 减少不必要的重新渲染
/> return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
) : (
<ReactECharts
option={sankeyData ? getSankeyOption(sankeyData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleSankeyNodeClick
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
)}
</>
)} )}
</> </>
)} )}
</Box>
</Box> </Box>
</Box> </Box>
)} )}
{/* 节点详情弹窗 */} {/* 节点详情弹窗 */}
{isModalOpen && ( {isModalOpen && (
<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}
@@ -1105,23 +1137,42 @@ const TransmissionChainAnalysis = ({ eventId }) => {
{/* 影响输出 */} {/* 影响输出 */}
{(() => { {(() => {
const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.length > 0; const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.length > 0;
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";