Compare commits

..

44 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
47 changed files with 1517 additions and 1256 deletions

57
app.py
View File

@@ -3475,6 +3475,46 @@ def get_wechat_qrcode():
}}), 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'])
def get_wechat_bind_qrcode():
"""发起微信绑定二维码,会话标记为绑定模式"""
@@ -3714,14 +3754,23 @@ def wechat_callback():
# 更新微信session状态供前端轮询检测
if state in wechat_qr_sessions:
session_item = wechat_qr_sessions[state]
# 仅处理登录/注册流程,不处理绑定流程
if not session_item.get('mode'):
# 更新状态和用户信息
mode = 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['user_info'] = {'user_id': user.id}
print(f"✅ 微信扫码状态已更新: {session_item['status']}, user_id: {user.id}")
# 直接跳转到首页
# PC 模式直接跳转到首页
return redirect('/home')
except Exception as e:

View File

@@ -27,19 +27,66 @@ import { PerformancePanel } from './components/PerformancePanel';
import { useGlobalErrorHandler } from './hooks/useGlobalErrorHandler';
// 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
import { logger } from './utils/logger';
import { performanceMonitor } from './utils/performanceMonitor';
// PostHog 追踪
import { trackEvent, trackEventAsync } from '@lib/posthog';
// PostHog 延迟加载:移除同步导入
// import { trackEvent, trackEventAsync } from '@lib/posthog';
// Contexts
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 - 应用核心内容
* 负责 PostHog 初始化和渲染路由
@@ -53,15 +100,45 @@ function AppContent() {
const pageEnterTimeRef = useRef(Date.now());
const currentPathRef = useRef(location.pathname);
// 🎯 PostHog Redux 初始化(延迟执行,不阻塞首屏)
// 🎯 PostHog 空闲时加载 + Redux 初始化(首屏不加载 ~180KB
useEffect(() => {
// ⚡ 延迟 2 秒初始化 PostHog确保首屏渲染不被阻塞
const timer = setTimeout(() => {
dispatch(initializePostHog());
logger.info('App', 'PostHog Redux 初始化已触发(延迟 2 秒)');
}, 2000);
const initPostHogRedux = async () => {
try {
const modules = await loadPostHogModules();
if (!modules) return;
return () => clearTimeout(timer);
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]);
// ⚡ 性能监控:标记 React 初始化完成
@@ -90,22 +167,31 @@ function AppContent() {
};
}, [dispatch]);
// ✅ 首次访问追踪
// ✅ 首次访问追踪(🔴 关键事件:立即加载模块,确保数据不丢失)
useEffect(() => {
const hasVisited = localStorage.getItem('has_visited');
if (!hasVisited) {
const urlParams = new URLSearchParams(location.search);
// ⚡ 使用异步追踪,不阻塞页面渲染
trackEventAsync('first_visit', {
const eventData = {
referrer: document.referrer || 'direct',
utm_source: urlParams.get('utm_source'),
utm_medium: urlParams.get('utm_medium'),
utm_campaign: urlParams.get('utm_campaign'),
landing_page: location.pathname,
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');
}
@@ -120,8 +206,8 @@ function AppContent() {
// 只追踪停留时间 > 1 秒的页面(过滤快速跳转)
if (duration > 1) {
// ⚡ 使用异步追踪,不阻塞页面切换
trackEventAsync('page_view_duration', {
// ⚡ 使用延迟加载的异步追踪,不阻塞页面切换
trackEventLazy('page_view_duration', {
path: currentPathRef.current,
duration_seconds: duration,
is_authenticated: isAuthenticated,

View File

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

View File

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

View File

@@ -82,29 +82,9 @@ const CitedContent = ({
...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 && (
<div style={{ marginBottom: 12, paddingRight: 80 }}>
<div style={{ marginBottom: 12 }}>
<Text strong style={{ fontSize: 14, color: finalTitleColor }}>
{title}
</Text>
@@ -112,10 +92,24 @@ const CitedContent = ({
)}
{/* 带引用的文本内容 */}
<div style={{
lineHeight: 1.8,
paddingRight: title ? 0 : (showAIBadge ? 80 : 0)
}}>
<div style={{ lineHeight: 1.8 }}>
{/* AI 标识 - 行内显示在文字前面 */}
{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 && (
<Text style={{

View File

@@ -2,95 +2,48 @@
// 集中管理应用的全局组件
import React, { useMemo } from 'react';
import { useLocation } from 'react-router-dom';
import { useNotification } from '../contexts/NotificationContext';
import { logger } from '../utils/logger';
import { useSelector } from 'react-redux';
import { selectIsMobile } from '@/store/slices/deviceSlice';
// Global Components
import AuthModalManager from './Auth/AuthModalManager';
import NotificationContainer from './NotificationContainer';
import ConnectionStatusBar from './ConnectionStatusBar';
import ScrollToTop from './ScrollToTop';
// Bytedesk客服组件
import BytedeskWidget from '../bytedesk-integration/components/BytedeskWidget';
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 - 全局组件容器
* 集中管理所有全局级别的组件,如弹窗、通知、状态栏等
*
* 包含的组件:
* - ConnectionStatusBarWrapper: Socket 连接状态条
* - ScrollToTop: 路由切换时自动滚动到顶部
* - AuthModalManager: 认证弹窗管理器
* - NotificationContainer: 通知容器
* - BytedeskWidget: Bytedesk在线客服 (条件性显示,在/和/home页隐藏)
* - NotificationContainer: 通知容器(仅桌面端渲染)
* - BytedeskWidget: Bytedesk在线客服
*
* 注意:
* - ConnectionStatusBar 已移除(所有端)
* - NotificationContainer 在移动端不渲染(通知功能已在 NotificationContext 层禁用)
*/
export function GlobalComponents() {
const location = useLocation();
const isMobile = useSelector(selectIsMobile);
// ✅ 缓存 Bytedesk 配置对象,避免每次渲染都创建新引用导致重新加载
const bytedeskConfigMemo = useMemo(() => getBytedeskConfig(), []);
return (
<>
{/* Socket 连接状态条 */}
<ConnectionStatusBarWrapper />
{/* 路由切换时自动滚动到顶部 */}
<ScrollToTop />
{/* 认证弹窗管理器 */}
<AuthModalManager />
{/* 通知容器 */}
<NotificationContainer />
{/* 通知容器(仅桌面端渲染) */}
{!isMobile && <NotificationContainer />}
{/* Bytedesk在线客服 - 使用缓存的配置对象 */}
<BytedeskWidget

View File

@@ -18,10 +18,8 @@ import {
Link,
Divider,
Avatar,
useColorMode,
useColorModeValue
} from '@chakra-ui/react';
import { SunIcon, MoonIcon } from '@chakra-ui/icons';
import { useNavigate, useLocation } from 'react-router-dom';
/**
@@ -46,7 +44,6 @@ const MobileDrawer = memo(({
}) => {
const navigate = useNavigate();
const location = useLocation();
const { colorMode, toggleColorMode } = useColorMode();
const userBgColor = useColorModeValue('gray.50', 'whiteAlpha.100');
const contactTextColor = useColorModeValue('gray.500', 'gray.300');
const emailTextColor = useColorModeValue('gray.500', 'gray.300');
@@ -82,17 +79,6 @@ const MobileDrawer = memo(({
</DrawerHeader>
<DrawerBody>
<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 && (
<>

View File

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

View File

@@ -5,9 +5,58 @@ import { useToast } from '@chakra-ui/react';
import { logger } from '@utils/logger';
import { performanceMonitor } from '@utils/performanceMonitor';
import { useNotification } from '@contexts/NotificationContext';
import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
// ⚡ PostHog 延迟加载:移除同步导入,首屏减少 ~180KB
// import { identifyUser, resetUser, trackEvent } from '@lib/posthog';
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();
@@ -99,8 +148,8 @@ export const AuthProvider = ({ children }) => {
return prevUser;
}
// ✅ 识别用户身份到 PostHog
identifyUser(data.user.id, {
// ✅ 识别用户身份到 PostHog(延迟加载)
identifyUserLazy(data.user.id, {
email: data.user.email,
username: data.user.username,
subscription_tier: data.user.subscription_tier,
@@ -354,8 +403,8 @@ export const AuthProvider = ({ children }) => {
credentials: 'include'
});
// ✅ 追踪登出事件(必须在 resetUser() 之前,否则会丢失用户身份
trackEvent(SPECIAL_EVENTS.USER_LOGGED_OUT, {
// ✅ 追踪登出事件(延迟加载,必须在 resetUser() 之前)
trackEventLazy(SPECIAL_EVENTS.USER_LOGGED_OUT, {
timestamp: new Date().toISOString(),
user_id: user?.id || null,
session_duration_minutes: user?.session_start
@@ -363,8 +412,8 @@ export const AuthProvider = ({ children }) => {
: null,
});
// ✅ 重置 PostHog 用户会话
resetUser();
// ✅ 重置 PostHog 用户会话(延迟加载)
resetUserLazy();
// 清除本地状态
setUser(null);

View File

@@ -9,6 +9,8 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
import { BellIcon } from '@chakra-ui/icons';
import { useSelector } from 'react-redux';
import { selectIsMobile } from '@/store/slices/deviceSlice';
import { logger } from '../utils/logger';
import socket from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav';
@@ -44,6 +46,10 @@ export const useNotification = () => {
// 通知提供者组件
export const NotificationProvider = ({ children }) => {
// ⚡ 移动端检测(使用 Redux 状态)
const isMobile = useSelector(selectIsMobile);
// ========== 所有 Hooks 必须在条件判断之前调用React 规则) ==========
const toast = useToast();
const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false);
@@ -565,8 +571,8 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
await requestBrowserPermission();
}
// 如果权限是denied已拒绝提供设置指引
else if (browserPermission === 'denied') {
// 如果权限是denied已拒绝提供设置指引(仅 PC 端显示)
else if (browserPermission === 'denied' && !isMobile) {
const toastId = 'browser-permission-denied-guide';
if (!toast.isActive(toastId)) {
toast({
@@ -1009,6 +1015,39 @@ export const NotificationProvider = ({ children }) => {
};
}, [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 = {
notifications,
isConnected,

View File

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

View File

@@ -7,8 +7,8 @@ import { BrowserRouter as Router } from 'react-router-dom';
import { performanceMonitor } from './utils/performanceMonitor';
performanceMonitor.mark('app-start');
// 导入 Brainwave 样式(空文件,保留以避免错误
import './styles/brainwave.css';
// ⚡ 已删除 brainwave.css项目未安装 Tailwind CSS该文件无效
// import './styles/brainwave.css';
// 导入 Select 下拉框颜色修复样式
import './styles/select-fix.css';
@@ -40,91 +40,25 @@ if (process.env.REACT_APP_ENABLE_DEBUG === 'true') {
function registerServiceWorker() {
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
console.log(
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
'color: #FF9800; font-weight: bold;'
);
return;
}
// 仅在支持 Service Worker 的浏览器中注册
if ('serviceWorker' in navigator) {
// 在页面加载完成后注册
window.addEventListener('load', () => {
navigator.serviceWorker
.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) => {
console.error('[App] Service Worker 注册失败');
console.error('[App] 错误类型:', error.name);
console.error('[App] 错误信息:', error.message);
console.error('[App] 完整错误:', error);
// 额外检查:验证文件是否可访问
fetch('/service-worker.js', { method: 'HEAD' })
.then(response => {
if (response.ok) {
console.error('[App] Service Worker 文件存在但注册失败');
console.error('[App] 💡 可能的原因:');
console.error('[App] 1. Service Worker 文件有语法错误');
console.error('[App] 2. 浏览器不支持某些 Service Worker 特性');
console.error('[App] 3. HTTPS 证书问题Service Worker 需要 HTTPS');
} else {
console.error('[App] Service Worker 文件不存在HTTP', response.status, '');
}
})
.catch(fetchError => {
console.error('[App] 无法访问 Service Worker 文件:', fetchError.message);
});
console.error('[App] Service Worker 注册失败:', error.message);
});
});
} else {
console.warn('[App] Service Worker is not supported in this browser');
}
}
// 启动应用MSW 异步初始化,不阻塞首屏渲染)
function startApp() {
// Create root
// 渲染应用
function renderApp() {
const root = ReactDOM.createRoot(document.getElementById('root'));
// ✅ 先渲染应用,不等待 MSW
// StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
root.render(
<React.StrictMode>
@@ -139,33 +73,26 @@ function startApp() {
</React.StrictMode>
);
// ✅ 后台异步启动 MSW不阻塞首屏渲染
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
const initMSW = async () => {
try {
const { startMockServiceWorker } = await import('./mocks/browser');
await startMockServiceWorker();
console.log(
'%c[MSW] ✅ Mock Service Worker 已在后台启动',
'color: #4CAF50; font-weight: bold;'
);
} catch (error) {
console.error('[MSW] ❌ Mock Service Worker 启动失败:', error);
}
};
// 使用 requestIdleCallback 在浏览器空闲时初始化,不阻塞首屏
if ('requestIdleCallback' in window) {
requestIdleCallback(() => initMSW(), { timeout: 3000 });
} else {
// 降级:使用 setTimeout(0) 延迟执行
setTimeout(initMSW, 0);
}
}
// 注册 Service Worker
// 注册 Service Worker非 Mock 模式
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();

View File

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

View File

@@ -47,18 +47,8 @@ export async function startMockServiceWorker() {
});
isStarted = true;
console.log(
'%c[MSW] Mock Service Worker 已启动 🎭 (警告模式)',
'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;'
);
// 精简日志:只保留一行启动提示
console.log('%c[MSW] Mock 已启用 🎭', 'color: #4CAF50; font-weight: bold;');
} catch (error) {
console.error('[MSW] 启动失败:', error);
} finally {

View File

@@ -326,25 +326,22 @@ export const authHandlers = [
});
}),
// 6. 获取微信 H5 授权 URL
http.post('/api/auth/wechat/h5-auth-url', async ({ request }) => {
// 6. 获取微信 H5 授权 URL(手机浏览器用)
http.post('/api/auth/wechat/h5-auth', async ({ request }) => {
await delay(NETWORK_DELAY);
const body = await request.json();
const { redirect_url } = body;
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);
return HttpResponse.json({
code: 0,
message: '成功',
data: {
auth_url: authUrl,
state
}
auth_url: authUrl,
state
});
}),
@@ -409,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

@@ -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

@@ -56,17 +56,8 @@ class SocketService {
// 注册所有暂存的事件监听器(保留 pendingListeners不清空
if (this.pendingListeners.length > 0) {
console.log(`[socketService] 📦 注册 ${this.pendingListeners.length} 个暂存的事件监听器`);
this.pendingListeners.forEach(({ event, callback }) => {
// 直接在 Socket.IO 实例上注册(避免递归调用 this.on()
const wrappedCallback = (...args) => {
console.log(`%c[socketService] 🔔 收到原始事件: ${event}`, 'color: #2196F3; font-weight: bold;');
console.log(`[socketService] 事件数据 (${event}):`, ...args);
callback(...args);
};
this.socket.on(event, wrappedCallback);
console.log(`[socketService] ✓ 已注册事件监听器: ${event}`);
this.socket.on(event, callback);
});
// ⚠️ 重要:不清空 pendingListeners保留用于重连
}
@@ -82,15 +73,8 @@ class SocketService {
this.customReconnectTimer = null;
}
logger.info('socketService', 'Socket.IO connected successfully', {
socketId: this.socket.id,
});
console.log(`%c[socketService] ✅ WebSocket 已连接`, 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] Socket ID:', this.socket.id);
logger.info('socketService', 'Socket.IO connected', { socketId: this.socket.id });
// ⚠️ 已移除自动订阅,让 NotificationContext 负责订阅
// this.subscribeToAllEvents();
});
// 监听断开连接
@@ -174,25 +158,12 @@ class SocketService {
);
if (!exists) {
logger.info('socketService', 'Socket not ready, queuing listener', { event });
console.log(`[socketService] 📦 Socket 未初始化,暂存事件监听器: ${event}`);
this.pendingListeners.push({ event, callback });
} else {
console.log(`[socketService] ⚠️ 监听器已存在,跳过: ${event}`);
}
return;
}
// 包装回调函数,添加日志
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}`);
this.socket.on(event, callback);
}
/**
@@ -210,8 +181,6 @@ class SocketService {
} else {
this.socket.off(event);
}
logger.info('socketService', `Event listener removed: ${event}`);
}
/**
@@ -231,8 +200,6 @@ class SocketService {
} else {
this.socket.emit(event, data);
}
logger.info('socketService', `Event emitted: ${event}`, data);
}
/**
@@ -355,65 +322,31 @@ class SocketService {
* 执行订阅操作(内部方法)
*/
_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 = {
event_type: eventType,
importance: importance,
};
console.log('[SocketService DEBUG] 准备发送 subscribe_events:', subscribeData);
this.emit('subscribe_events', subscribeData);
console.log('[SocketService DEBUG] ✓ 已发送 subscribe_events');
// 监听订阅确认
this.socket.once('subscription_confirmed', (data) => {
console.log('\n[SocketService DEBUG] ========== 收到订阅确认 ==========');
console.log('[SocketService DEBUG] 订阅确认数据:', data);
logger.info('socketService', 'Subscription confirmed', data);
if (onSubscribed) {
console.log('[SocketService DEBUG] 调用 onSubscribed 回调');
onSubscribed(data);
}
console.log('[SocketService DEBUG] ========== 订阅确认处理完成 ==========\n');
});
// 监听订阅错误
this.socket.once('subscription_error', (error) => {
console.error('\n[SocketService ERROR] ========== 订阅错误 ==========');
console.error('[SocketService ERROR] 错误信息:', error);
logger.error('socketService', 'Subscription error', error);
console.error('[SocketService ERROR] ========== 订阅错误处理完成 ==========\n');
});
// 监听新事件推送
// ⚠️ 注意:不要移除其他地方注册的 new_event 监听器(如 NotificationContext
// 多个监听器可以共存,都会被触发
if (onNewEvent) {
console.log('[SocketService DEBUG] 设置 new_event 监听器');
// ⚠️ 已移除 this.socket.off('new_event'),允许多个监听器共存
// 添加新的监听器(与其他监听器共存)
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);
console.log('[SocketService DEBUG] ✓ onNewEvent 回调已调用');
console.log('[SocketService DEBUG] ========== 新事件处理完成 ==========\n');
});
console.log('[SocketService DEBUG] ✓ new_event 监听器已设置(与其他监听器共存)');
}
console.log('[SocketService DEBUG] ========== 订阅完成 ==========\n');
}
/**
@@ -440,11 +373,7 @@ class SocketService {
// 监听取消订阅确认
this.socket.once('unsubscription_confirmed', (data) => {
logger.info('socketService', 'Unsubscription confirmed', data);
// 移除新事件监听器
this.socket.off('new_event');
if (onUnsubscribed) {
onUnsubscribed(data);
}
@@ -462,22 +391,10 @@ class SocketService {
* @returns {Function} 取消订阅的函数
*/
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({
eventType: 'all',
importance: 'all',
onNewEvent: onNewEvent || defaultCallback,
onSubscribed: (data) => {
console.log('%c[socketService] ✅ 订阅成功!', 'color: #4CAF50; font-weight: bold;');
console.log('[socketService] 订阅确认:', data);
},
onNewEvent: onNewEvent || (() => {}),
});
// 返回取消订阅的清理函数

View File

@@ -1,26 +1,38 @@
// src/store/index.js
import { configureStore } from '@reduxjs/toolkit';
import { configureStore, combineReducers } from '@reduxjs/toolkit';
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 stockReducer from './slices/stockSlice';
import authModalReducer from './slices/authModalSlice';
import subscriptionReducer from './slices/subscriptionSlice';
import deviceReducer from './slices/deviceSlice'; // ✅ 设备检测状态管理
import posthogMiddleware from './middleware/posthogMiddleware';
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({
reducer: {
communityData: communityDataReducer,
posthog: posthogReducer, // ✅ PostHog Redux 状态管理
industry: industryReducer, // ✅ 行业分类数据管理
stock: stockReducer, // ✅ 股票和事件数据管理
authModal: authModalReducer, // ✅ 认证弹窗状态管理
subscription: subscriptionReducer, // ✅ 订阅信息状态管理
device: deviceReducer, // ✅ 设备检测状态管理(移动端/桌面端)
[eventsApi.reducerPath]: eventsApi.reducer, // ✅ RTK Query 事件 API
},
reducer: createRootReducer(),
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
@@ -28,14 +40,27 @@ export const store = configureStore({
ignoredActions: [
'communityData/fetchPopularKeywords/fulfilled',
'communityData/fetchHotEvents/fulfilled',
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪
'posthog/trackEvent/fulfilled', // ✅ PostHog 事件追踪(延迟加载后仍需)
'stock/fetchEventStocks/fulfilled',
'stock/fetchStockQuotes/fulfilled',
],
},
})
.concat(posthogMiddleware) // PostHog 自动追踪中间件
// PostHog 中间件延迟加载,首屏不再需要
.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;

View File

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

View File

@@ -7,6 +7,7 @@ import { getApiBase } from '../../utils/apiConfig';
/**
* 异步 Thunk: 获取用户订阅信息
* 使用 condition 选项防止同一时刻多个组件重复发起请求
*/
export const fetchSubscriptionInfo = createAsyncThunk(
'subscription/fetchInfo',
@@ -51,6 +52,21 @@ export const fetchSubscriptionInfo = createAsyncThunk(
logger.error('subscriptionSlice', '加载订阅信息失败', error);
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;
}
}
);

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"] {
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;
}
}

View File

@@ -7,6 +7,34 @@ const isDevelopment =
process.env.NODE_ENV === 'development' ||
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 recentLogs = new Map(); // 日志缓存,用于去重
@@ -148,13 +176,13 @@ export const logger = {
},
/**
* 调试日志(仅开发环境)
* 调试日志(仅开发环境 + LOG_LEVEL=debug
* @param {string} component - 组件名称
* @param {string} message - 调试信息
* @param {object} data - 相关数据(可选)
*/
debug: (component, message, data = {}) => {
if (isDevelopment && shouldLog(component, message)) {
if (isDevelopment && shouldLogLevel('debug') && shouldLog(component, message)) {
console.group(`🐛 Debug: ${component}`);
console.log('Message:', message);
if (Object.keys(data).length > 0) {
@@ -166,13 +194,13 @@ export const logger = {
},
/**
* 信息日志(仅开发环境)
* 信息日志(仅开发环境 + LOG_LEVEL>=info
* @param {string} component - 组件名称
* @param {string} message - 信息内容
* @param {object} data - 相关数据(可选)
*/
info: (component, message, data = {}) => {
if (isDevelopment && shouldLog(component, message)) {
if (isDevelopment && shouldLogLevel('info') && shouldLog(component, message)) {
console.group(` Info: ${component}`);
console.log('Message:', message);
if (Object.keys(data).length > 0) {
@@ -181,6 +209,28 @@ export const logger = {
console.log('Timestamp:', new Date().toISOString());
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 {
private metrics: PerformanceMetrics = {};
private isProduction: boolean;
constructor() {
this.isProduction = process.env.NODE_ENV === 'production';
}
/**
* 标记性能时间点
@@ -70,12 +65,6 @@ class PerformanceMonitor {
mark(name: string): void {
const timestamp = performance.now();
performanceMarks.set(name, timestamp);
if (!this.isProduction) {
logger.debug('PerformanceMonitor', `⏱️ Mark: ${name}`, {
time: `${timestamp.toFixed(2)}ms`
});
}
}
/**
@@ -106,12 +95,6 @@ class PerformanceMonitor {
endMark
});
if (!this.isProduction) {
logger.debug('PerformanceMonitor', `📊 Measure: ${measureName}`, {
duration: `${duration.toFixed(2)}ms`
});
}
return duration;
}

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 {
Input, Cascader, Button, Space, Tag, AutoComplete, Select as AntSelect,
Tooltip
Tooltip, Divider, Flex
} from 'antd';
import {
SearchOutlined, CloseCircleOutlined, StockOutlined, FilterOutlined,
CalendarOutlined, SortAscendingOutlined
CalendarOutlined, SortAscendingOutlined, ReloadOutlined, ThunderboltOutlined
} from '@ant-design/icons';
import dayjs from 'dayjs';
import debounce from 'lodash/debounce';
import { useSelector, useDispatch } from 'react-redux';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '../../../store/slices/industrySlice';
import { stockService } from '../../../services/stockService';
import { logger } from '../../../utils/logger';
import { fetchIndustryData, selectIndustryData, selectIndustryLoading } from '@store/slices/industrySlice';
import { stockService } from '@services/stockService';
import { logger } from '@utils/logger';
import TradingTimeFilter from './TradingTimeFilter';
import { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
import { PROFESSIONAL_COLORS } from '@constants/professionalTheme';
import './CompactSearchBox.css';
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 = ({
onSearch,
onSearchFocus,
filters = {},
mode,
pageSize,
trackingFunctions = {}
trackingFunctions = {},
isMobile = false
}) => {
// 状态
const [stockOptions, setStockOptions] = useState([]);
@@ -420,19 +439,21 @@ const CompactSearchBox = ({
dispatch(fetchIndustryData());
}
};
return (
<div style={{
padding: window.innerWidth < 768 ? '12px 16px' : '16px 20px',
background: PROFESSIONAL_COLORS.background.card,
borderRadius: '12px',
boxShadow: '0 4px 12px rgba(0, 0, 0, 0.3), 0 0 20px rgba(255, 195, 0, 0.1)',
border: `1px solid ${PROFESSIONAL_COLORS.border.default}`,
backdropFilter: 'blur(10px)'
}}>
{/* 单行紧凑布局 - 移动端自动换行 */}
<Space wrap style={{ width: '100%' }} size={window.innerWidth < 768 ? 'small' : 'medium'}>
{/* 搜索框 */}
<div style={{ padding: 0, background: 'transparent' }}>
{/* 第一行:搜索框 + 日期筛选 */}
<Flex
align="center"
gap={isMobile ? 8 : 12}
style={{
background: 'rgba(255, 255, 255, 0.03)',
border: `1px solid ${PROFESSIONAL_COLORS.border.light}`,
borderRadius: '24px',
padding: isMobile ? '2px 4px' : '8px 16px',
marginBottom: isMobile ? 8 : 12
}}
>
{/* 搜索框 - flex: 1 占满剩余空间 */}
<AutoComplete
value={inputValue}
onChange={handleInputChange}
@@ -440,46 +461,57 @@ const CompactSearchBox = ({
onSelect={handleStockSelect}
onFocus={onSearchFocus}
options={stockOptions}
placeholder="搜索股票/话题..."
onKeyDown={(e) => {
if (e.key === 'Enter') {
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
prefix={<SearchOutlined style={{ color: PROFESSIONAL_COLORS.gold[500] }} />}
placeholder="搜索股票/话题..."
style={{
borderRadius: '8px',
border: `1px solid ${PROFESSIONAL_COLORS.border.default}`,
boxShadow: `0 2px 8px rgba(255, 195, 0, 0.1)`,
background: PROFESSIONAL_COLORS.background.secondary,
color: PROFESSIONAL_COLORS.text.primary
border: 'none',
background: 'transparent',
color: PROFESSIONAL_COLORS.text.primary,
boxShadow: 'none'
}}
/>
</AutoComplete>
{/* 时间筛选 */}
<Tooltip title="时间筛选">
<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>
{/* 分隔线 - H5 时隐藏 */}
{!isMobile && <Divider type="vertical" style={{ height: 24, margin: '0 8px', borderColor: 'rgba(255,255,255,0.15)' }} />}
{/* 行业筛选 */}
<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
value={industryValue}
onChange={handleIndustryChange}
onFocus={handleCascaderFocus}
options={industryData || []}
placeholder="行业"
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<FilterOutlined style={{ fontSize: 12 }} />
{isMobile ? '行业' : '行业筛选'}
</span>
}
changeOnSelect
showSearch={{
filter: (inputValue, path) =>
@@ -489,145 +521,65 @@ const CompactSearchBox = ({
}}
allowClear
expandTrigger="hover"
displayRender={(labels) => labels[labels.length - 1] || '行业'}
displayRender={(labels) => labels[labels.length - 1] || (isMobile ? '行业' : '行业筛选')}
disabled={industryLoading}
style={{
width: window.innerWidth < 768 ? '100%' : 120,
minWidth: window.innerWidth < 768 ? 0 : 120,
borderRadius: '8px'
}}
suffixIcon={<FilterOutlined style={{ fontSize: 14, color: PROFESSIONAL_COLORS.gold[500] }} />}
style={{ minWidth: isMobile ? 70 : 80 }}
suffixIcon={null}
className="transparent-cascader"
/>
</Tooltip>
{/* 重要性筛选 */}
<Tooltip title="事件等级筛选">
{/* 事件等级 */}
<AntSelect
mode="multiple"
value={importance}
onChange={handleImportanceChange}
style={{
width: window.innerWidth < 768 ? '100%' : 120,
minWidth: window.innerWidth < 768 ? 0 : 120,
borderRadius: '8px'
}}
placeholder="事件等级"
style={{ minWidth: isMobile ? 100 : 120 }}
placeholder={
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<ThunderboltOutlined style={{ fontSize: 12 }} />
{isMobile ? '等级' : '事件等级'}
</span>
}
maxTagCount={0}
maxTagPlaceholder={(omittedValues) => `已选 ${omittedValues.length}`}
maxTagPlaceholder={(omittedValues) => isMobile ? `${omittedValues.length}` : `已选 ${omittedValues.length}`}
className="bracket-select"
>
<Option value="S">S级</Option>
<Option value="A">A级</Option>
<Option value="B">B级</Option>
<Option value="C">C级</Option>
{IMPORTANCE_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>{opt.label}</Option>
))}
</AntSelect>
</Tooltip>
</Space>
{/* 排序 */}
<Tooltip title="排序方式">
{/* 右侧排序和重置 */}
<Space size={isMobile ? 4 : 8}>
{/* 排序 */}
<AntSelect
value={sort}
onChange={handleSortChange}
style={{
width: window.innerWidth < 768 ? '100%' : 130,
minWidth: window.innerWidth < 768 ? 0 : 130,
borderRadius: '8px'
}}
suffixIcon={<SortAscendingOutlined style={{ fontSize: 14, color: PROFESSIONAL_COLORS.gold[500] }} />}
style={{ minWidth: isMobile ? 55 : 120 }}
className="bracket-select"
>
<Option value="new"> 最新</Option>
<Option value="hot">🔥 最热</Option>
<Option value="importance"> 重要性</Option>
<Option value="returns_avg">📊 平均收益</Option>
<Option value="returns_week">📈 周收益</Option>
{SORT_OPTIONS.map(opt => (
<Option key={opt.value} value={opt.value}>
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
<SortAscendingOutlined style={{ fontSize: 12 }} />
{isMobile ? opt.mobileLabel : opt.label}
</span>
</Option>
))}
</AntSelect>
</Tooltip>
{/* 重置按钮 */}
<Tooltip title="重置所有筛选">
{/* 重置按钮 */}
<Button
icon={<CloseCircleOutlined />}
icon={<ReloadOutlined />}
onClick={handleReset}
danger
type="primary"
style={{
borderRadius: '8px',
boxShadow: '0 2px 8px rgba(255, 77, 79, 0.2)'
}}
type="text"
style={{ color: PROFESSIONAL_COLORS.text.secondary }}
>
重置
{!isMobile && '重置筛选'}
</Button>
</Tooltip>
</Space>
{/* 激活的筛选标签(如果有的话) */}
{(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>
)}
</Space>
</Flex>
</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 { PROFESSIONAL_COLORS } from '../../../constants/professionalTheme';
import { debounce } from '../../../utils/debounce';
import { useDevice } from '@hooks/useDevice';
// 🔍 调试:渲染计数器
let dynamicNewsCardRenderCount = 0;
@@ -81,6 +82,7 @@ const DynamicNewsCardComponent = forwardRef(({
// 通知权限相关
const { browserPermission, requestBrowserPermission } = useNotification();
const { isMobile } = useDevice();
// Refs
const cardHeaderRef = useRef(null);
@@ -534,73 +536,53 @@ const [currentMode, setCurrentMode] = useState('vertical');
position="relative"
zIndex={1}
pb={3}
px={isMobile ? 2 : undefined}
>
<VStack spacing={3} align="stretch">
{/* 第一行:标题 + 通知开关 + 更新时间 */}
{/* 第一行:标题 + 模式切换 + 通知开关 + 更新时间 */}
<Flex justify="space-between" align="center">
{/* 左侧:标题 */}
<Heading size="md" color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack>
</Heading>
{/* 左侧:标题 + 模式切换按钮 */}
<HStack spacing={4}>
<Heading size={isMobile ? "sm" : "md"} color={PROFESSIONAL_COLORS.text.primary}>
<HStack spacing={2}>
<TimeIcon color={PROFESSIONAL_COLORS.gold[500]} />
<Text bgGradient={PROFESSIONAL_COLORS.gradients.gold} bgClip="text">实时要闻·动态追踪</Text>
</HStack>
</Heading>
{/* 模式切换按钮(移动端隐藏) */}
{!isMobile && <ModeToggleButtons mode={mode} onModeChange={handleModeToggle} />}
</HStack>
{/* 右侧:通知开关 + 更新时间 */}
<HStack spacing={3}>
{/* 通知开关 */}
<Tooltip
label={browserPermission === 'granted'
? '浏览器通知已开启'
: '开启实时推送通知'}
placement="left"
hasArrow
>
{/* 通知开关 - 移动端隐藏 */}
{!isMobile && (
<HStack
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"
_hover={{
borderColor: browserPermission === 'granted'
? useColorModeValue('green.300', 'green.600')
: useColorModeValue('blue.300', 'blue.600'),
}}
transition="all 0.2s"
onClick={handleNotificationToggle}
_hover={{ opacity: 0.8 }}
transition="opacity 0.2s"
>
<Icon
as={BellIcon}
boxSize={3.5}
color={browserPermission === 'granted'
? useColorModeValue('green.600', 'green.300')
: useColorModeValue('gray.500', 'gray.400')}
color={PROFESSIONAL_COLORS.gold[500]}
/>
<Text
fontSize="sm"
fontWeight="medium"
color={browserPermission === 'granted'
? useColorModeValue('green.700', 'green.200')
: useColorModeValue('gray.600', 'gray.300')}
color={PROFESSIONAL_COLORS.text.secondary}
>
{browserPermission === 'granted' ? '已开启' : '开启通知'}
实时消息推送{browserPermission === 'granted' ? '已开启' : '开启'}
</Text>
<Switch
size="sm"
isChecked={browserPermission === 'granted'}
pointerEvents="none"
colorScheme="green"
colorScheme="yellow"
/>
</HStack>
</Tooltip>
)}
{/* 更新时间 */}
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">
@@ -618,6 +600,7 @@ const [currentMode, setCurrentMode] = useState('vertical');
mode={mode}
pageSize={pageSize}
trackingFunctions={trackingFunctions}
isMobile={isMobile}
/>
</Box>
</VStack>
@@ -627,41 +610,14 @@ const [currentMode, setCurrentMode] = useState('vertical');
<CardBody
ref={cardBodyRef}
position="relative"
pt={4}
pt={0}
px={0}
mx={0}
display="flex"
flexDirection="column"
overflow="visible"
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">
{/* Loading 蒙层 - 数据请求时显示 */}
@@ -727,6 +683,15 @@ const [currentMode, setCurrentMode] = useState('vertical');
</ModalContent>
</Modal>
)}
{/* 右侧分页控制器仅在纵向模式显示H5 放不下时折行 */}
{mode === 'vertical' && totalPages > 1 && (
<PaginationControl
currentPage={currentPage}
totalPages={totalPages}
onPageChange={handlePageChangeWithScroll}
/>
)}
</Card>
);
});

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -15,6 +15,8 @@ import {
} from '@chakra-ui/react';
import { getImportanceConfig } from '../../../../constants/importanceLevels';
import { PROFESSIONAL_COLORS } from '../../../../constants/professionalTheme';
import { useDevice } from '@hooks/useDevice';
import dayjs from 'dayjs';
// 导入子组件
import ImportanceStamp from './ImportanceStamp';
@@ -54,6 +56,7 @@ const HorizontalDynamicNewsEventCard = React.memo(({
layout = 'vertical',
}) => {
const importance = getImportanceConfig(event.importance);
const { isMobile } = useDevice();
// 专业配色 - 黑色、灰色、金色主题
const cardBg = PROFESSIONAL_COLORS.background.card;
@@ -67,8 +70,8 @@ const HorizontalDynamicNewsEventCard = React.memo(({
const showTimeline = useBreakpointValue({ base: false, md: true }); // 移动端隐藏时间轴
const cardPadding = useBreakpointValue({ base: 2, md: 3 }); // 移动端减小内边距
const titleFontSize = useBreakpointValue({ base: 'sm', md: 'md' }); // 移动端减小标题字体
const titlePaddingRight = useBreakpointValue({ base: '80px', md: '120px' }); // 为关键词留空间
const spacing = useBreakpointValue({ base: 2, md: 3 }); // 间距
const titlePaddingRight = useBreakpointValue({ base: '16px', md: '120px' }); // 桌面端为关键词留空间,移动端不显示关键词
const spacing = useBreakpointValue({ base: 1, md: 3 }); // 间距(移动端更紧凑)
/**
* 根据平均涨幅计算背景色(专业配色 - 深色主题)
@@ -159,6 +162,33 @@ const HorizontalDynamicNewsEventCard = React.memo(({
onClick={() => onEventClick?.(event)}
>
<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}>
<EventFollowButton
@@ -170,8 +200,8 @@ const HorizontalDynamicNewsEventCard = React.memo(({
/>
</Box>
{/* Keywords梦幻轮播 - 绝对定位在卡片右侧空白处 */}
{event.keywords && event.keywords.length > 0 && (
{/* Keywords梦幻轮播 - 绝对定位在卡片右侧空白处(移动端隐藏) */}
{!isMobile && event.keywords && event.keywords.length > 0 && (
<KeywordsCarousel
keywords={event.keywords}
interval={4000}
@@ -200,6 +230,7 @@ const HorizontalDynamicNewsEventCard = React.memo(({
onClick={(e) => onTitleClick?.(e, event)}
mt={1}
paddingRight={titlePaddingRight}
paddingLeft={isMobile ? '70px' : undefined}
>
<Text
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 {
padding: 0 40px; /* 增加左右padding箭头留出空间 */
padding: 0; /* 移除左右padding箭头使用绝对定位 */
position: relative;
}
@@ -65,13 +65,20 @@
color: #096dd9 !important;
}
/* 箭头位置 */
/* 箭头位置 - 绝对定位,悬浮在卡片边缘 */
.hot-events-carousel .slick-prev.custom-carousel-arrow {
left: 0 !important;
left: 8px !important;
position: absolute;
}
.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 { Card, Badge, Tag, Empty, Carousel, Tooltip } from 'antd';
import { ArrowUpOutlined, ArrowDownOutlined, LeftOutlined, RightOutlined } from '@ant-design/icons';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalBody,
ModalCloseButton,
useDisclosure
} from '@chakra-ui/react';
import { useDisclosure } from '@chakra-ui/react';
import EventDetailModal from './EventDetailModal';
import dayjs from 'dayjs';
import './HotEvents.css';
import defaultEventImage from '../../../assets/img/default-event.jpg';
import DynamicNewsDetailPanel from './DynamicNewsDetail';
// 自定义箭头组件
const CustomArrow = ({ className, style, onClick, direction }) => {
@@ -196,21 +188,12 @@ const HotEvents = ({ events, onPageChange, onEventClick }) => {
</Card>
)}
{/* 事件详情弹窗 - 使用 Chakra UI Modal与平铺模式一致 */}
{isModalOpen ? (
<Modal isOpen={isModalOpen} onClose={onModalClose} size="6xl" scrollBehavior="inside">
<ModalOverlay />
<ModalContent>
<ModalHeader>
{modalEvent?.title || '事件详情'}
</ModalHeader>
<ModalCloseButton />
<ModalBody pb={6}>
{modalEvent && <DynamicNewsDetailPanel event={modalEvent} />}
</ModalBody>
</ModalContent>
</Modal>
): null}
{/* 事件详情弹窗 */}
<EventDetailModal
open={isModalOpen}
onClose={onModalClose}
event={modalEvent}
/>
</div>
);
};

View File

@@ -1,20 +1,25 @@
// src/views/Community/components/TradingTimeFilter.js
// 交易时段智能筛选组件
import React, { useState, useMemo, useEffect } from 'react';
import { Space, Button, Tag, Tooltip, DatePicker, Popover } from 'antd';
import { ClockCircleOutlined, CalendarOutlined } from '@ant-design/icons';
import { Space, Button, Tag, Tooltip, DatePicker, Popover, Select } from 'antd';
import { ClockCircleOutlined, CalendarOutlined, FilterOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
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 { Option } = Select;
/**
* 交易时段筛选组件
* @param {string} value - 当前选中的 key受控
* @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 [customRangeVisible, setCustomRangeVisible] = useState(false);
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 isSelected = selectedKey === config.key;
@@ -321,6 +358,98 @@ const TradingTimeFilter = ({ value, onChange }) => {
</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 (
<Space wrap size={[8, 8]} style={{ display: 'flex', alignItems: 'flex-start' }}>
{/* 动态按钮(根据时段显示多个) */}

View File

@@ -1,5 +1,5 @@
// 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 { useSelector, useDispatch } from 'react-redux';
import {
@@ -10,22 +10,15 @@ import {
Box,
Container,
useColorModeValue,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
CloseButton,
HStack,
VStack,
Text,
useBreakpointValue,
Skeleton,
} from '@chakra-ui/react';
// 导入组件
import DynamicNewsCard from './components/DynamicNewsCard';
import HotEventsSection from './components/HotEventsSection';
import HeroPanel from './components/HeroPanel';
// ⚡ HeroPanel 懒加载:包含 ECharts (~600KB),首屏不需要立即渲染
const HeroPanel = lazy(() => import('./components/HeroPanel'));
// 导入自定义 Hooks
import { useEventData } from './hooks/useEventData';
@@ -49,8 +42,6 @@ const Community = () => {
// 专业配色 - 深色主题
const bgColor = PROFESSIONAL_COLORS.background.primary;
const alertBgColor = 'rgba(59, 130, 246, 0.1)';
const alertBorderColor = PROFESSIONAL_COLORS.border.default;
// Ref用于首次滚动到内容区域
const containerRef = useRef(null);
@@ -78,9 +69,6 @@ const Community = () => {
// ⚡ DynamicNewsCard 的 ref用于触发刷新
const dynamicNewsCardRef = useRef(null);
// 通知横幅显示状态
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
// 🎯 初始化Community埋点Hook
const communityEvents = useCommunityEvents({ navigate });
@@ -119,39 +107,6 @@ const Community = () => {
}
}, [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);
useEffect(() => {
@@ -235,45 +190,14 @@ const Community = () => {
<Box minH="100vh" bg={bgColor}>
{/* 主内容区域 */}
<Container ref={containerRef} maxW={containerMaxW} px={containerPx} pt={{ base: 3, md: 6 }} pb={{ base: 4, md: 8 }}>
{/* 通知权限提示横幅 */}
{showNotificationBanner && (
<Alert
status="info"
variant="subtle"
borderRadius="lg"
mb={4}
boxShadow="md"
bg={alertBgColor}
borderWidth="1px"
borderColor={alertBorderColor}
>
<AlertIcon />
<Box flex="1">
<AlertTitle fontSize="md" mb={1}>
开启桌面通知不错过重要事件
</AlertTitle>
<AlertDescription fontSize="sm">
即使浏览器最小化也能第一时间接收新事件推送通知
</AlertDescription>
</Box>
<HStack spacing={2} ml={4}>
<Button
size="sm"
colorScheme="blue"
onClick={handleEnableNotifications}
>
立即开启
</Button>
<CloseButton
onClick={handleCloseBanner}
position="relative"
/>
</HStack>
</Alert>
)}
{/* 顶部说明面板:产品介绍 + 沪深指数 + 热门概念词云 */}
<HeroPanel />
{/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
<Suspense fallback={
<Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">
<Skeleton height="200px" borderRadius="lg" startColor="gray.800" endColor="gray.700" />
</Box>
}>
<HeroPanel />
</Suspense>
{/* 实时要闻·动态追踪 - 横向滚动 */}
<DynamicNewsCard

View File

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

View File

@@ -1,18 +1,18 @@
import React, { useEffect, useState, useRef } from 'react';
import {
Box,
Button,
Flex,
Spinner,
Alert,
AlertIcon,
Text,
Stat,
StatLabel,
StatNumber,
HStack,
VStack,
Tag,
import {
Box,
Button,
Flex,
Spinner,
Alert,
AlertIcon,
Text,
Stat,
StatLabel,
StatNumber,
HStack,
VStack,
Tag,
Badge,
List,
ListItem,
@@ -28,9 +28,11 @@ import {
ModalCloseButton,
Icon,
useColorModeValue,
Tooltip
Tooltip,
Center
} from '@chakra-ui/react';
import { InfoIcon, ViewIcon } from '@chakra-ui/icons';
import { Share2, GitBranch, Inbox } from 'lucide-react';
import ReactECharts from 'echarts-for-react';
import { eventService } from '../../../services/eventService';
import CitedContent from '../../../components/Citation/CitedContent';
@@ -637,7 +639,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
};
return (
<Box p={6}>
<Box>
{/* 统计信息条 */}
<Box
mb={4}
@@ -647,56 +649,57 @@ const TransmissionChainAnalysis = ({ eventId }) => {
borderColor={PROFESSIONAL_COLORS.border.default}
bg={PROFESSIONAL_COLORS.background.secondary}
>
<HStack spacing={6} wrap="wrap">
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>总节点数</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.totalNodes}</StatNumber>
<Flex wrap="wrap" gap={{ base: 3, md: 6 }}>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">总节点数</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.totalNodes}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>涉及行业</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.involvedIndustries}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">涉及行业</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.involvedIndustries}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>相关公司</StatLabel>
<StatNumber color={PROFESSIONAL_COLORS.text.primary}>{stats.relatedCompanies}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">相关公司</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color={PROFESSIONAL_COLORS.text.primary}>{stats.relatedCompanies}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>正向影响</StatLabel>
<StatNumber color="#10B981">{stats.positiveImpact}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">正向影响</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#10B981">{stats.positiveImpact}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>负向影响</StatLabel>
<StatNumber color="#EF4444">{stats.negativeImpact}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">负向影响</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#EF4444">{stats.negativeImpact}</StatNumber>
</Stat>
<Stat>
<StatLabel color={PROFESSIONAL_COLORS.text.secondary}>循环效应</StatLabel>
<StatNumber color="#A855F7">{stats.circularEffect}</StatNumber>
<Stat minW="fit-content">
<StatLabel fontSize={{ base: "xs", md: "sm" }} color={PROFESSIONAL_COLORS.text.secondary} whiteSpace="nowrap">循环效应</StatLabel>
<StatNumber fontSize={{ base: "xl", md: "2xl" }} color="#A855F7">{stats.circularEffect}</StatNumber>
</Stat>
</HStack>
</Flex>
</Box>
{/* 自定义图例 */}
<Box mb={4}>
<HStack spacing={4} wrap="wrap">
{Object.entries(NODE_STYLES).map(([type, style]) => (
<Tag
key={type}
size="md"
bg={PROFESSIONAL_COLORS.background.secondary}
color={PROFESSIONAL_COLORS.text.primary}
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
<Box w={3} h={3} bg={style.color} borderRadius="sm" mr={2} />
{NODE_TYPE_LABELS[type] || type}
</Tag>
))}
</HStack>
</Box>
<Flex mb={4} wrap="wrap" gap={2}>
{Object.entries(NODE_STYLES).map(([type, style]) => (
<Tag
key={type}
size="sm"
px={2}
py={1}
bg={PROFESSIONAL_COLORS.background.secondary}
color={PROFESSIONAL_COLORS.text.primary}
borderWidth="1px"
borderColor={PROFESSIONAL_COLORS.border.default}
>
<Box w={2.5} h={2.5} bg={style.color} borderRadius="sm" mr={1.5} />
{NODE_TYPE_LABELS[type] || type}
</Tag>
))}
</Flex>
{/* 视图切换按钮 */}
<Flex mb={4} gap={2}>
<Button
leftIcon={<Icon as={Share2} boxSize={4} />}
bg={viewMode === 'graph' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary}
color={viewMode === 'graph' ? 'black' : PROFESSIONAL_COLORS.text.primary}
_hover={{
@@ -710,6 +713,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
力导向图
</Button>
<Button
leftIcon={<Icon as={GitBranch} boxSize={4} />}
bg={viewMode === 'sankey' ? PROFESSIONAL_COLORS.gold[500] : PROFESSIONAL_COLORS.background.secondary}
color={viewMode === 'sankey' ? 'black' : PROFESSIONAL_COLORS.text.primary}
_hover={{
@@ -722,7 +726,6 @@ const TransmissionChainAnalysis = ({ eventId }) => {
>
桑基图
</Button>
</Flex>
{loading && (
@@ -748,86 +751,108 @@ const TransmissionChainAnalysis = ({ eventId }) => {
{!loading && !error && (
<Box>
{/* 提示信息 */}
<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>
{/* 图表容器 */}
{/* 图表容器 - 宽高比 2:1H5 自适应 */}
<Box
h={viewMode === 'sankey' ? "600px" : "700px"}
position="relative"
w="100%"
pb={{ base: "75%", md: "50%" }}
border="1px solid"
borderColor={PROFESSIONAL_COLORS.border.default}
borderRadius="lg"
boxShadow="0 4px 12px rgba(0, 0, 0, 0.3)"
bg={PROFESSIONAL_COLORS.background.card}
p={4}
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 && (
<>
{viewMode === 'graph' ? (
<ReactECharts
option={graphData ? getGraphOption(graphData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleGraphNodeClick
}}
opts={{
renderer: 'canvas',
devicePixelRatio: window.devicePixelRatio || 1
}}
lazyUpdate={true}
notMerge={false}
shouldSetOption={(prevProps, props) => {
// 减少不必要的重新渲染
return JSON.stringify(prevProps.option) !== JSON.stringify(props.option);
}}
/>
{/* 空状态提示 */}
{(viewMode === 'graph' && (!graphData || !graphData.nodes || graphData.nodes.length === 0)) ||
(viewMode === 'sankey' && (!sankeyData || !sankeyData.nodes || sankeyData.nodes.length === 0)) ? (
<Center h="100%" flexDirection="column">
<Icon as={Inbox} boxSize={12} color={PROFESSIONAL_COLORS.text.muted} />
<Text mt={4} color={PROFESSIONAL_COLORS.text.muted} fontSize="sm">
暂无传导链数据
</Text>
</Center>
) : (
<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);
}}
/>
<>
{viewMode === 'graph' ? (
<ReactECharts
option={graphData ? getGraphOption(graphData) : {}}
style={{ height: '100%', width: '100%' }}
onEvents={{
click: handleGraphNodeClick
}}
opts={{
renderer: 'canvas',
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) : {}}
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>
)}
{/* 节点详情弹窗 */}
{isModalOpen && (
<Modal isOpen={isModalOpen} onClose={handleCloseModal} size="xl">
<ModalOverlay />
<ModalContent maxH="80vh" bg={modalBgColor}>
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor}>
<HStack justify="space-between">
<ModalHeader borderBottom="1px solid" borderColor={modalBorderColor} pr={12}>
<HStack justify="space-between" pr={2}>
<Text color={PROFESSIONAL_COLORS.text.primary}>{selectedNode ? '节点详情' : '传导链分析'}</Text>
{selectedNode && (
<Badge
@@ -841,7 +866,10 @@ const TransmissionChainAnalysis = ({ eventId }) => {
)}
</HStack>
</ModalHeader>
<ModalCloseButton />
<ModalCloseButton
color={PROFESSIONAL_COLORS.text.secondary}
_hover={{ bg: 'rgba(255, 255, 255, 0.1)' }}
/>
<ModalBody overflowY="auto">
{selectedNode ? (
@@ -1084,11 +1112,15 @@ const TransmissionChainAnalysis = ({ eventId }) => {
prefix="机制:"
prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }}
textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{ marginTop: 8 }}
containerStyle={{
marginTop: 8,
backgroundColor: 'transparent',
padding: 0,
}}
showAIBadge={false}
/>
) : parent.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary}>
机制: {parent.transmission_mechanism}AI合成
</Text>
) : null}
@@ -1105,23 +1137,42 @@ const TransmissionChainAnalysis = ({ eventId }) => {
{/* 影响输出 */}
{(() => {
const targetsFromAPI = nodeDetail && nodeDetail.children && nodeDetail.children.length > 0;
if (targetsFromAPI) {
return (
<Box>
<Text fontWeight="bold" mb={2} color="blue.600">
影响输出 ({nodeDetail.children.length})
<Text fontWeight="bold" mb={2} color={PROFESSIONAL_COLORS.gold[500]}>
影响输出 ({nodeDetail.children.length})AI合成
</Text>
<List spacing={2}>
{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 && (
<Box position="absolute" top={2} right={2} zIndex={1}>
<Badge
colorScheme={
child.direction === 'positive' ? 'green' :
child.direction === 'negative' ? 'red' :
'gray'
bg={
child.direction === 'positive' ? 'rgba(16, 185, 129, 0.15)' :
child.direction === 'negative' ? 'rgba(239, 68, 68, 0.15)' :
'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"
>
@@ -1132,7 +1183,7 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</Box>
)}
<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 ? (
<CitedContent
data={child.transmission_mechanism}
@@ -1140,11 +1191,15 @@ const TransmissionChainAnalysis = ({ eventId }) => {
prefix="机制:"
prefixStyle={{ fontSize: 12, color: PROFESSIONAL_COLORS.text.secondary, fontWeight: 'bold' }}
textColor={PROFESSIONAL_COLORS.text.primary}
containerStyle={{ marginTop: 8 }}
containerStyle={{
marginTop: 8,
backgroundColor: 'transparent',
padding: 0,
}}
showAIBadge={false}
/>
) : child.transmission_mechanism ? (
<Text fontSize="xs" color="gray.600">
<Text fontSize="xs" color={PROFESSIONAL_COLORS.text.secondary}>
机制: {child.transmission_mechanism}AI合成
</Text>
) : null}
@@ -1169,7 +1224,14 @@ const TransmissionChainAnalysis = ({ eventId }) => {
</ModalBody>
<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>
</ModalContent>
</Modal>

View File

@@ -37,20 +37,40 @@ export default function WechatCallback() {
// 1. 获取URL参数
const code = searchParams.get("code");
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) {
throw new Error("授权失败:缺少授权码");
}
// 3. 调用后端处理回调
// 4. 调用后端处理回调
const response = await authService.handleWechatH5Callback(code, state);
if (!response || !response.success) {
throw new Error(response?.error || "授权失败,请重试");
}
// 4. 存储用户信息如果有返回token
// 5. 存储用户信息如果有返回token
if (response.token) {
localStorage.setItem("token", response.token);
}
@@ -58,14 +78,14 @@ export default function WechatCallback() {
localStorage.setItem("user", JSON.stringify(response.user));
}
// 5. 更新session
// 6. 更新session
await checkSession();
// 6. 显示成功状态
// 7. 显示成功状态
setStatus("success");
setMessage("登录成功!正在跳转...");
// 7. 延迟跳转到首页
// 8. 延迟跳转到首页
setTimeout(() => {
navigate("/home", { replace: true });
}, 1500);
@@ -73,6 +93,7 @@ export default function WechatCallback() {
logger.error('WechatCallback', 'handleCallback', error, {
code: searchParams.get("code"),
state: searchParams.get("state"),
wechat_login: searchParams.get("wechat_login"),
errorMessage: error.message
});
setStatus("error");