Compare commits

...

41 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
42 changed files with 1310 additions and 1081 deletions

57
app.py
View File

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

View File

@@ -100,10 +100,9 @@ function AppContent() {
const pageEnterTimeRef = useRef(Date.now()); const pageEnterTimeRef = useRef(Date.now());
const currentPathRef = useRef(location.pathname); const currentPathRef = useRef(location.pathname);
// 🎯 ⚡ PostHog 延迟加载 + Redux 初始化(首屏不加载 ~180KB // 🎯 ⚡ PostHog 空闲时加载 + Redux 初始化(首屏不加载 ~180KB
useEffect(() => { useEffect(() => {
// ⚡ 延迟 2 秒加载 PostHog 模块,确保首屏渲染不被阻塞 const initPostHogRedux = async () => {
const timer = setTimeout(async () => {
try { try {
const modules = await loadPostHogModules(); const modules = await loadPostHogModules();
if (!modules) return; if (!modules) return;
@@ -115,13 +114,31 @@ function AppContent() {
// 初始化 PostHog // 初始化 PostHog
dispatch(posthogSliceModule.initializePostHog()); dispatch(posthogSliceModule.initializePostHog());
logger.info('App', 'PostHog 模块延迟加载完成Redux 初始化已触发');
} catch (error) {
logger.error('App', 'PostHog 延迟加载失败', error);
}
}, 2000);
return () => clearTimeout(timer); // ⚡ 刷新注入前缓存的事件(避免丢失)
const pendingEvents = posthogSliceModule.flushPendingEventsBeforeInjection();
if (pendingEvents.length > 0) {
logger.info('App', `刷新 ${pendingEvents.length} 个注入前缓存的事件`);
pendingEvents.forEach(({ eventName, properties }) => {
posthogModule.trackEventAsync(eventName, properties);
});
}
logger.info('App', 'PostHog 模块空闲时加载完成Redux 初始化已触发');
} catch (error) {
logger.error('App', 'PostHog 加载失败', error);
}
};
// ⚡ 使用 requestIdleCallback 在浏览器空闲时加载,最长等待 3 秒
if ('requestIdleCallback' in window) {
const idleId = requestIdleCallback(initPostHogRedux, { timeout: 3000 });
return () => cancelIdleCallback(idleId);
} else {
// 降级Safari 等不支持 requestIdleCallback 的浏览器使用 setTimeout
const timer = setTimeout(initPostHogRedux, 1000);
return () => clearTimeout(timer);
}
}, [dispatch]); }, [dispatch]);
// ⚡ 性能监控:标记 React 初始化完成 // ⚡ 性能监控:标记 React 初始化完成
@@ -150,22 +167,31 @@ function AppContent() {
}; };
}, [dispatch]); }, [dispatch]);
// ✅ 首次访问追踪 // ✅ 首次访问追踪(🔴 关键事件:立即加载模块,确保数据不丢失)
useEffect(() => { useEffect(() => {
const hasVisited = localStorage.getItem('has_visited'); const hasVisited = localStorage.getItem('has_visited');
if (!hasVisited) { if (!hasVisited) {
const urlParams = new URLSearchParams(location.search); const urlParams = new URLSearchParams(location.search);
const eventData = {
// ⚡ 使用延迟加载的异步追踪,不阻塞页面渲染
trackEventLazy('first_visit', {
referrer: document.referrer || 'direct', referrer: document.referrer || 'direct',
utm_source: urlParams.get('utm_source'), utm_source: urlParams.get('utm_source'),
utm_medium: urlParams.get('utm_medium'), utm_medium: urlParams.get('utm_medium'),
utm_campaign: urlParams.get('utm_campaign'), utm_campaign: urlParams.get('utm_campaign'),
landing_page: location.pathname, landing_page: location.pathname,
timestamp: new Date().toISOString() timestamp: new Date().toISOString()
}); };
// 🔴 关键事件:立即加载 PostHog 模块并同步追踪(不使用 trackEventLazy
// 确保首次访问数据不会因用户快速离开而丢失
(async () => {
const modules = await loadPostHogModules();
if (modules) {
// 使用同步追踪trackEvent而非异步追踪trackEventAsync
modules.posthogModule.trackEvent('first_visit', eventData);
logger.info('App', '首次访问事件已同步追踪', eventData);
}
})();
localStorage.setItem('has_visited', 'true'); localStorage.setItem('has_visited', 'true');
} }

View File

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

View File

@@ -129,12 +129,8 @@ export default function WechatRegister() {
*/ */
const handleLoginSuccess = useCallback(async (sessionId, status) => { const handleLoginSuccess = useCallback(async (sessionId, status) => {
try { try {
logger.info('WechatRegister', '开始调用登录接口', { sessionId: sessionId.substring(0, 8) + '...', status });
const response = await authService.loginWithWechat(sessionId); const response = await authService.loginWithWechat(sessionId);
logger.info('WechatRegister', '登录接口返回', { success: response?.success, hasUser: !!response?.user });
if (response?.success) { if (response?.success) {
// 追踪微信登录成功 // 追踪微信登录成功
authEvents.trackLoginSuccess( authEvents.trackLoginSuccess(
@@ -183,33 +179,20 @@ export default function WechatRegister() {
const checkWechatStatus = useCallback(async () => { const checkWechatStatus = useCallback(async () => {
// 检查组件是否已卸载,使用 ref 获取最新的 sessionId // 检查组件是否已卸载,使用 ref 获取最新的 sessionId
if (!isMountedRef.current || !sessionIdRef.current) { if (!isMountedRef.current || !sessionIdRef.current) {
logger.debug('WechatRegister', 'checkWechatStatus 跳过', {
isMounted: isMountedRef.current,
hasSessionId: !!sessionIdRef.current
});
return; return;
} }
const currentSessionId = sessionIdRef.current; const currentSessionId = sessionIdRef.current;
logger.debug('WechatRegister', '检查微信状态', { sessionId: currentSessionId });
try { try {
const response = await authService.checkWechatStatus(currentSessionId); const response = await authService.checkWechatStatus(currentSessionId);
// 安全检查:确保 response 存在且包含 status // 安全检查:确保 response 存在且包含 status
if (!response || typeof response.status === 'undefined') { if (!response || typeof response.status === 'undefined') {
logger.warn('WechatRegister', '微信状态检查返回无效数据', { response });
return; return;
} }
const { status } = response; const { status } = response;
logger.debug('WechatRegister', '微信状态', { status });
logger.debug('WechatRegister', '检测到微信状态', {
sessionId: wechatSessionId.substring(0, 8) + '...',
status,
userInfo: response.user_info
});
// 组件卸载后不再更新状态 // 组件卸载后不再更新状态
if (!isMountedRef.current) return; if (!isMountedRef.current) return;
@@ -229,7 +212,6 @@ export default function WechatRegister() {
// 处理成功状态 // 处理成功状态
if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) { if (status === WECHAT_STATUS.LOGIN_SUCCESS || status === WECHAT_STATUS.REGISTER_SUCCESS) {
logger.info('WechatRegister', '检测到登录成功状态,停止轮询', { status });
clearTimers(); // 停止轮询 clearTimers(); // 停止轮询
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null; // 清理 sessionId
@@ -281,7 +263,6 @@ export default function WechatRegister() {
} }
// 处理授权成功AUTHORIZED- 用户已在微信端确认授权,调用登录 API // 处理授权成功AUTHORIZED- 用户已在微信端确认授权,调用登录 API
else if (status === WECHAT_STATUS.AUTHORIZED) { else if (status === WECHAT_STATUS.AUTHORIZED) {
logger.info('WechatRegister', '微信授权成功,调用登录 API', { sessionId: currentSessionId.substring(0, 8) + '...' });
clearTimers(); clearTimers();
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null; // 清理 sessionId
await handleLoginSuccess(currentSessionId, status); await handleLoginSuccess(currentSessionId, status);
@@ -310,11 +291,6 @@ export default function WechatRegister() {
* 启动轮询 * 启动轮询
*/ */
const startPolling = useCallback(() => { const startPolling = useCallback(() => {
logger.debug('WechatRegister', '启动轮询', {
sessionId: sessionIdRef.current,
interval: POLL_INTERVAL
});
// 清理旧的定时器 // 清理旧的定时器
clearTimers(); clearTimers();
@@ -325,7 +301,6 @@ export default function WechatRegister() {
// 设置超时 // 设置超时
timeoutRef.current = setTimeout(() => { timeoutRef.current = setTimeout(() => {
logger.debug('WechatRegister', '二维码超时');
clearTimers(); clearTimers();
sessionIdRef.current = null; // 清理 sessionId sessionIdRef.current = null; // 清理 sessionId
setWechatStatus(WECHAT_STATUS.EXPIRED); setWechatStatus(WECHAT_STATUS.EXPIRED);
@@ -377,11 +352,6 @@ export default function WechatRegister() {
setWechatSessionId(response.data.session_id); setWechatSessionId(response.data.session_id);
setWechatStatus(WECHAT_STATUS.WAITING); setWechatStatus(WECHAT_STATUS.WAITING);
logger.debug('WechatRegister', '获取二维码成功', {
sessionId: response.data.session_id,
authUrl: response.data.auth_url
});
// 启动轮询检查扫码状态 // 启动轮询检查扫码状态
startPolling(); startPolling();
} catch (error) { } catch (error) {

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@
import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react'; import React, { createContext, useContext, useState, useEffect, useCallback, useRef } from 'react';
import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react'; import { useToast, Box, HStack, Text, Button, CloseButton, VStack, Icon } from '@chakra-ui/react';
import { BellIcon } from '@chakra-ui/icons'; import { BellIcon } from '@chakra-ui/icons';
import { useSelector } from 'react-redux';
import { selectIsMobile } from '@/store/slices/deviceSlice';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import socket from '../services/socket'; import socket from '../services/socket';
import notificationSound from '../assets/sounds/notification.wav'; import notificationSound from '../assets/sounds/notification.wav';
@@ -44,6 +46,10 @@ export const useNotification = () => {
// 通知提供者组件 // 通知提供者组件
export const NotificationProvider = ({ children }) => { export const NotificationProvider = ({ children }) => {
// ⚡ 移动端检测(使用 Redux 状态)
const isMobile = useSelector(selectIsMobile);
// ========== 所有 Hooks 必须在条件判断之前调用React 规则) ==========
const toast = useToast(); const toast = useToast();
const [notifications, setNotifications] = useState([]); const [notifications, setNotifications] = useState([]);
const [isConnected, setIsConnected] = useState(false); const [isConnected, setIsConnected] = useState(false);
@@ -565,8 +571,8 @@ export const NotificationProvider = ({ children }) => {
logger.info('NotificationContext', 'Auto-requesting browser permission on notification'); logger.info('NotificationContext', 'Auto-requesting browser permission on notification');
await requestBrowserPermission(); await requestBrowserPermission();
} }
// 如果权限是denied已拒绝提供设置指引 // 如果权限是denied已拒绝提供设置指引(仅 PC 端显示)
else if (browserPermission === 'denied') { else if (browserPermission === 'denied' && !isMobile) {
const toastId = 'browser-permission-denied-guide'; const toastId = 'browser-permission-denied-guide';
if (!toast.isActive(toastId)) { if (!toast.isActive(toastId)) {
toast({ toast({
@@ -1009,6 +1015,39 @@ export const NotificationProvider = ({ children }) => {
}; };
}, [browserPermission, toast]); }, [browserPermission, toast]);
// ⚡ 移动端禁用完整通知能力:返回空壳 Provider
// 注意:此判断必须在所有 Hooks 之后React 规则要求 Hooks 调用顺序一致)
if (isMobile) {
const emptyValue = {
notifications: [],
isConnected: false,
soundEnabled: false,
browserPermission: 'default',
connectionStatus: CONNECTION_STATUS.DISCONNECTED,
reconnectAttempt: 0,
maxReconnectAttempts: 0,
addNotification: () => null,
removeNotification: () => {},
clearAllNotifications: () => {},
toggleSound: () => {},
requestBrowserPermission: () => Promise.resolve('default'),
trackNotificationClick: () => {},
retryConnection: () => {},
showWelcomeGuide: () => {},
showCommunityGuide: () => {},
showFirstFollowGuide: () => {},
registerEventUpdateCallback: () => () => {},
unregisterEventUpdateCallback: () => {},
};
return (
<NotificationContext.Provider value={emptyValue}>
{children}
</NotificationContext.Provider>
);
}
// ========== 桌面端:完整通知功能 ==========
const value = { const value = {
notifications, notifications,
isConnected, isConnected,

View File

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

View File

@@ -40,91 +40,25 @@ if (process.env.REACT_APP_ENABLE_DEBUG === 'true') {
function registerServiceWorker() { function registerServiceWorker() {
// ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突) // ⚠️ Mock 模式下跳过 Service Worker 注册(避免与 MSW 冲突)
if (process.env.REACT_APP_ENABLE_MOCK === 'true') { if (process.env.REACT_APP_ENABLE_MOCK === 'true') {
console.log(
'%c[App] Mock 模式已启用,跳过通知 Service Worker 注册(避免与 MSW 冲突)',
'color: #FF9800; font-weight: bold;'
);
return; return;
} }
// 仅在支持 Service Worker 的浏览器中注册 // 仅在支持 Service Worker 的浏览器中注册
if ('serviceWorker' in navigator) { if ('serviceWorker' in navigator) {
// 在页面加载完成后注册
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker navigator.serviceWorker
.register('/service-worker.js') .register('/service-worker.js')
.then((registration) => {
console.log('[App] ✅ Service Worker 注册成功');
console.log('[App] Scope:', registration.scope);
// 检查当前激活状态
if (navigator.serviceWorker.controller) {
console.log('[App] ✅ Service Worker 已激活并控制页面');
} else {
console.log('[App] ⏳ Service Worker 已注册,等待激活...');
console.log('[App] 💡 刷新页面以激活 Service Worker');
// 监听 controller 变化Service Worker 激活后触发)
navigator.serviceWorker.addEventListener('controllerchange', () => {
console.log('[App] ✅ Service Worker 控制器已更新');
});
}
// 监听 Service Worker 更新
registration.addEventListener('updatefound', () => {
const newWorker = registration.installing;
console.log('[App] 🔄 发现 Service Worker 更新');
if (newWorker) {
newWorker.addEventListener('statechange', () => {
console.log(`[App] Service Worker 状态: ${newWorker.state}`);
if (newWorker.state === 'activated') {
console.log('[App] ✅ Service Worker 已激活');
// 如果有旧的 Service Worker 在控制页面,提示用户刷新
if (navigator.serviceWorker.controller) {
console.log('[App] 💡 Service Worker 已更新,建议刷新页面');
}
}
});
}
});
})
.catch((error) => { .catch((error) => {
console.error('[App] Service Worker 注册失败'); console.error('[App] Service Worker 注册失败:', error.message);
console.error('[App] 错误类型:', error.name);
console.error('[App] 错误信息:', error.message);
console.error('[App] 完整错误:', error);
// 额外检查:验证文件是否可访问
fetch('/service-worker.js', { method: 'HEAD' })
.then(response => {
if (response.ok) {
console.error('[App] Service Worker 文件存在但注册失败');
console.error('[App] 💡 可能的原因:');
console.error('[App] 1. Service Worker 文件有语法错误');
console.error('[App] 2. 浏览器不支持某些 Service Worker 特性');
console.error('[App] 3. HTTPS 证书问题Service Worker 需要 HTTPS');
} else {
console.error('[App] Service Worker 文件不存在HTTP', response.status, '');
}
})
.catch(fetchError => {
console.error('[App] 无法访问 Service Worker 文件:', fetchError.message);
});
}); });
}); });
} else {
console.warn('[App] Service Worker is not supported in this browser');
} }
} }
// 启动应用MSW 异步初始化,不阻塞首屏渲染) // 渲染应用
function startApp() { function renderApp() {
// Create root
const root = ReactDOM.createRoot(document.getElementById('root')); const root = ReactDOM.createRoot(document.getElementById('root'));
// ✅ 先渲染应用,不等待 MSW
// StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题) // StrictMode 已启用Chakra UI 2.10.9+ 已修复兼容性问题)
root.render( root.render(
<React.StrictMode> <React.StrictMode>
@@ -139,33 +73,26 @@ function startApp() {
</React.StrictMode> </React.StrictMode>
); );
// ✅ 后台异步启动 MSW不阻塞首屏渲染 // 注册 Service Worker非 Mock 模式
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
registerServiceWorker(); registerServiceWorker();
} }
// 启动应用
async function startApp() {
// ✅ 开发环境 Mock 模式:先启动 MSW再渲染应用
// 确保所有 API 请求(包括 AuthContext.checkSession都被正确拦截
if (process.env.NODE_ENV === 'development' && process.env.REACT_APP_ENABLE_MOCK === 'true') {
try {
const { startMockServiceWorker } = await import('./mocks/browser');
await startMockServiceWorker();
} catch (error) {
console.error('[MSW] 启动失败:', error);
}
}
// 渲染应用
renderApp();
}
// 启动应用 // 启动应用
startApp(); startApp();

View File

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

View File

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

View File

@@ -326,25 +326,22 @@ export const authHandlers = [
}); });
}), }),
// 6. 获取微信 H5 授权 URL // 6. 获取微信 H5 授权 URL(手机浏览器用)
http.post('/api/auth/wechat/h5-auth-url', async ({ request }) => { http.post('/api/auth/wechat/h5-auth', async ({ request }) => {
await delay(NETWORK_DELAY); await delay(NETWORK_DELAY);
const body = await request.json(); const body = await request.json();
const { redirect_url } = body; const { redirect_url } = body;
const state = generateWechatSessionId(); const state = generateWechatSessionId();
const authUrl = `https://open.weixin.qq.com/connect/oauth2/authorize?appid=mock&redirect_uri=${encodeURIComponent(redirect_url)}&response_type=code&scope=snsapi_userinfo&state=${state}#wechat_redirect`; // Mock 模式下直接返回前端回调地址(模拟授权成功)
const authUrl = `${redirect_url}?wechat_login=success&state=${state}`;
console.log('[Mock] 生成微信 H5 授权 URL:', authUrl); console.log('[Mock] 生成微信 H5 授权 URL:', authUrl);
return HttpResponse.json({ return HttpResponse.json({
code: 0, auth_url: authUrl,
message: '成功', state
data: {
auth_url: authUrl,
state
}
}); });
}), }),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,5 @@
// src/views/Community/index.js // src/views/Community/index.js
import React, { useEffect, useRef, useState, lazy, Suspense } from 'react'; import React, { useEffect, useRef, lazy, Suspense } from 'react';
import { useNavigate, useLocation } from 'react-router-dom'; import { useNavigate, useLocation } from 'react-router-dom';
import { useSelector, useDispatch } from 'react-redux'; import { useSelector, useDispatch } from 'react-redux';
import { import {
@@ -10,15 +10,6 @@ import {
Box, Box,
Container, Container,
useColorModeValue, useColorModeValue,
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
CloseButton,
HStack,
VStack,
Text,
useBreakpointValue, useBreakpointValue,
Skeleton, Skeleton,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
@@ -51,8 +42,6 @@ const Community = () => {
// 专业配色 - 深色主题 // 专业配色 - 深色主题
const bgColor = PROFESSIONAL_COLORS.background.primary; const bgColor = PROFESSIONAL_COLORS.background.primary;
const alertBgColor = 'rgba(59, 130, 246, 0.1)';
const alertBorderColor = PROFESSIONAL_COLORS.border.default;
// Ref用于首次滚动到内容区域 // Ref用于首次滚动到内容区域
const containerRef = useRef(null); const containerRef = useRef(null);
@@ -80,9 +69,6 @@ const Community = () => {
// ⚡ DynamicNewsCard 的 ref用于触发刷新 // ⚡ DynamicNewsCard 的 ref用于触发刷新
const dynamicNewsCardRef = useRef(null); const dynamicNewsCardRef = useRef(null);
// 通知横幅显示状态
const [showNotificationBanner, setShowNotificationBanner] = useState(false);
// 🎯 初始化Community埋点Hook // 🎯 初始化Community埋点Hook
const communityEvents = useCommunityEvents({ navigate }); const communityEvents = useCommunityEvents({ navigate });
@@ -121,39 +107,6 @@ const Community = () => {
} }
}, [events, loading, pagination, filters]); }, [events, loading, pagination, filters]);
// ⚡ 检查通知权限状态,显示横幅提示
useEffect(() => {
// 延迟3秒显示让用户先浏览页面
const timer = setTimeout(() => {
// 如果未授权或未请求过权限,显示横幅
if (browserPermission !== 'granted') {
const hasClosedBanner = localStorage.getItem('notification_banner_closed');
if (!hasClosedBanner) {
setShowNotificationBanner(true);
logger.info('Community', '显示通知权限横幅');
}
}
}, 3000);
return () => clearTimeout(timer);
}, [browserPermission]);
// 处理开启通知
const handleEnableNotifications = async () => {
const permission = await requestBrowserPermission();
if (permission === 'granted') {
setShowNotificationBanner(false);
logger.info('Community', '通知权限已授予');
}
};
// 处理关闭横幅
const handleCloseBanner = () => {
setShowNotificationBanner(false);
localStorage.setItem('notification_banner_closed', 'true');
logger.info('Community', '通知横幅已关闭');
};
// ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度) // ⚡ 首次进入页面时滚动到内容区域(考虑导航栏高度)
const hasScrolled = useRef(false); const hasScrolled = useRef(false);
useEffect(() => { useEffect(() => {
@@ -237,43 +190,6 @@ const Community = () => {
<Box minH="100vh" bg={bgColor}> <Box minH="100vh" bg={bgColor}>
{/* 主内容区域 */} {/* 主内容区域 */}
<Container ref={containerRef} maxW={containerMaxW} px={containerPx} pt={{ base: 3, md: 6 }} pb={{ base: 4, md: 8 }}> <Container ref={containerRef} maxW={containerMaxW} px={containerPx} pt={{ base: 3, md: 6 }} pb={{ base: 4, md: 8 }}>
{/* 通知权限提示横幅 */}
{showNotificationBanner && (
<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>
)}
{/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */} {/* ⚡ 顶部说明面板(懒加载):产品介绍 + 沪深指数 + 热门概念词云 */}
<Suspense fallback={ <Suspense fallback={
<Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)"> <Box mb={6} p={4} borderRadius="xl" bg="rgba(255,255,255,0.02)">

View File

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

View File

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

View File

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