Compare commits
41 Commits
1d1d6c8169
...
feature_20
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf4fdf6a68 | ||
|
|
34338373cd | ||
|
|
589e1c20f9 | ||
|
|
60e9a40a1f | ||
|
|
b8b24643fe | ||
|
|
e9e9ec9051 | ||
|
|
5b0e420770 | ||
|
|
93f43054fd | ||
|
|
101d042b0e | ||
|
|
a1aa6718e6 | ||
|
|
753727c1c0 | ||
|
|
afc92ee583 | ||
|
|
d825e4fe59 | ||
|
|
62cf0a6c7d | ||
|
|
805d446775 | ||
|
|
24ddfcd4b5 | ||
|
|
a90158239b | ||
|
|
a8d4245595 | ||
|
|
5aedde7528 | ||
|
|
f5f89a1c72 | ||
|
|
e0b7f8c59d | ||
|
|
d22d75e761 | ||
|
|
30fc156474 | ||
|
|
572665199a | ||
|
|
a2831c82a8 | ||
|
|
217551b6ab | ||
|
|
022271947a | ||
|
|
cd6ffdbe68 | ||
|
|
9df725b748 | ||
|
|
64f8914951 | ||
|
|
506e5a448c | ||
|
|
e277352133 | ||
|
|
87437ed229 | ||
|
|
037471d880 | ||
|
|
0c482bc72c | ||
|
|
4aebb3bf4b | ||
|
|
ed241bd9c5 | ||
|
|
e6ede81c78 | ||
|
|
a0b688da80 | ||
|
|
6bd09b797d | ||
|
|
9c532b5f18 |
57
app.py
57
app.py
@@ -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:
|
||||||
|
|||||||
54
src/App.js
54
src/App.js
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 && (
|
||||||
<>
|
<>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|
||||||
// ==================== 登录/注册结果 ====================
|
// ==================== 登录/注册结果 ====================
|
||||||
|
|||||||
115
src/index.js
115
src/index.js
@@ -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();
|
||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 返回取消订阅的清理函数
|
// 返回取消订阅的清理函数
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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';
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
80
src/views/Community/components/CompactSearchBox.css
Normal file
80
src/views/Community/components/CompactSearchBox.css
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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={{
|
||||||
|
|||||||
@@ -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')}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -243,7 +243,7 @@ const RelatedConceptsSection = ({
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isExpanded ? '收起' : '查看详细描述'}
|
{isExpanded ? '收起' : '查看详细'}
|
||||||
</Button>
|
</Button>
|
||||||
</Flex>
|
</Flex>
|
||||||
{/* 第二行:交易日期信息 */}
|
{/* 第二行:交易日期信息 */}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
36
src/views/Community/components/EventDetailModal.less
Normal file
36
src/views/Community/components/EventDetailModal.less
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
48
src/views/Community/components/EventDetailModal.tsx
Normal file
48
src/views/Community/components/EventDetailModal.tsx
Normal 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;
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 禁用状态 */
|
/* 禁用状态 */
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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' }}>
|
||||||
{/* 动态按钮(根据时段显示多个) */}
|
{/* 动态按钮(根据时段显示多个) */}
|
||||||
|
|||||||
@@ -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)">
|
||||||
|
|||||||
@@ -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',
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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:1,H5 自适应 */}
|
||||||
<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>
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
Reference in New Issue
Block a user