/** * Bytedesk客服Widget组件 * 用于vf_react项目集成 * * 使用方法: * import BytedeskWidget from './components/BytedeskWidget'; * import { getBytedeskConfig } from './config/bytedesk.config'; * * */ import { useEffect, useRef } from 'react'; import PropTypes from 'prop-types'; // ⚡ 模块级变量:防止 React StrictMode 双重初始化 let widgetInitialized = false; let idleCallbackId = null; const BytedeskWidget = ({ config, autoLoad = true, onLoad, onError }) => { const scriptRef = useRef(null); const widgetRef = useRef(null); useEffect(() => { // 如果不自动加载或配置未设置,跳过 if (!autoLoad || !config) { return; } // ⚡ 防止重复初始化(React StrictMode 会双重调用 useEffect) if (widgetInitialized) { return; } // ⚡ 使用 requestIdleCallback 延迟加载,不阻塞首屏 const loadWidget = () => { // 再次检查,防止竞态条件 if (widgetInitialized) return; widgetInitialized = true; // 检查脚本是否已存在 if (document.getElementById('bytedesk-web-script')) { return; } // 加载Bytedesk Widget脚本 const script = document.createElement('script'); script.src = 'https://www.weiyuai.cn/embed/bytedesk-web.js'; script.async = true; script.id = 'bytedesk-web-script'; script.onload = () => { try { if (window.BytedeskWeb) { const bytedesk = new window.BytedeskWeb(config); bytedesk.init(); widgetRef.current = bytedesk; // ⚡ H5 端样式适配:使用 MutationObserver 立即应用样式(避免闪烁) const isMobile = window.innerWidth <= 768; const applyBytedeskStyles = () => { const allElements = document.querySelectorAll('body > div'); allElements.forEach(el => { const style = window.getComputedStyle(el); // 检查是否是右下角固定定位的元素(Bytedesk 按钮) if (style.position === 'fixed' && style.right && style.bottom) { const rightVal = parseInt(style.right); const bottomVal = parseInt(style.bottom); if (rightVal >= 0 && rightVal < 100 && bottomVal >= 0 && bottomVal < 100) { // H5 端设置按钮尺寸为 48x48(只执行一次) if (isMobile && !el.dataset.bytedeskStyled) { el.dataset.bytedeskStyled = 'true'; const button = el.querySelector('button'); if (button) { button.style.width = '48px'; button.style.height = '48px'; button.style.minWidth = '48px'; button.style.minHeight = '48px'; } } // 提示框 3 秒后隐藏(查找白色气泡框) const children = el.querySelectorAll('div'); children.forEach(child => { if (child.dataset.bytedeskTooltip) return; // 已处理过 const childStyle = window.getComputedStyle(child); // 白色背景的提示框 if (childStyle.backgroundColor === 'rgb(255, 255, 255)') { child.dataset.bytedeskTooltip = 'true'; setTimeout(() => { child.style.transition = 'opacity 0.3s'; child.style.opacity = '0'; setTimeout(() => child.remove(), 300); }, 3000); } }); } } }); }; // 立即执行一次 applyBytedeskStyles(); // 监听 DOM 变化,新元素出现时立即应用样式 const observer = new MutationObserver(applyBytedeskStyles); observer.observe(document.body, { childList: true, subtree: true }); // 5 秒后停止监听(避免性能问题) setTimeout(() => observer.disconnect(), 5000); // ⚡ 屏蔽 STOMP WebSocket 错误日志(不影响功能) const originalConsoleError = console.error; console.error = function(...args) { const errorMsg = args.join(' '); if (errorMsg.includes('/stomp') || errorMsg.includes('stomp onWebSocketError') || (errorMsg.includes('WebSocket connection to') && errorMsg.includes('/stomp'))) { return; } originalConsoleError.apply(console, args); }; if (onLoad) { onLoad(bytedesk); } } else { throw new Error('BytedeskWeb对象未定义'); } } catch (error) { console.error('[Bytedesk] 初始化失败:', error); if (onError) { onError(error); } } }; script.onerror = (error) => { console.error('[Bytedesk] 脚本加载失败:', error); widgetInitialized = false; // 允许重试 if (onError) { onError(error); } }; document.body.appendChild(script); scriptRef.current = script; }; // ⚡ 使用 requestIdleCallback 在浏览器空闲时加载 if ('requestIdleCallback' in window) { idleCallbackId = requestIdleCallback(loadWidget, { timeout: 3000 }); } else { // 降级:使用 setTimeout idleCallbackId = setTimeout(loadWidget, 100); } // 清理函数 return () => { // 取消待执行的 idle callback if (idleCallbackId) { if ('cancelIdleCallback' in window) { cancelIdleCallback(idleCallbackId); } else { clearTimeout(idleCallbackId); } idleCallbackId = null; } // ⚠️ 不重置 widgetInitialized,保持单例 // 不清理 DOM,因为客服 Widget 应该持久存在 }; }, [config, autoLoad, onLoad, onError]); // 不渲染任何可见元素(Widget会自动插入到body) return
; }; BytedeskWidget.propTypes = { config: PropTypes.shape({ apiUrl: PropTypes.string.isRequired, htmlUrl: PropTypes.string.isRequired, placement: PropTypes.oneOf(['bottom-right', 'bottom-left', 'top-right', 'top-left']), marginBottom: PropTypes.number, marginSide: PropTypes.number, autoPopup: PropTypes.bool, locale: PropTypes.string, bubbleConfig: PropTypes.shape({ show: PropTypes.bool, icon: PropTypes.string, title: PropTypes.string, subtitle: PropTypes.string, }), theme: PropTypes.shape({ mode: PropTypes.oneOf(['light', 'dark', 'system']), backgroundColor: PropTypes.string, textColor: PropTypes.string, }), chatConfig: PropTypes.shape({ org: PropTypes.string.isRequired, t: PropTypes.string.isRequired, sid: PropTypes.string.isRequired, }).isRequired, }), autoLoad: PropTypes.bool, onLoad: PropTypes.func, onError: PropTypes.func, }; export default BytedeskWidget;