218 lines
7.3 KiB
JavaScript
218 lines
7.3 KiB
JavaScript
/**
|
||
* Bytedesk客服Widget组件
|
||
* 用于vf_react项目集成
|
||
*
|
||
* 使用方法:
|
||
* import BytedeskWidget from './components/BytedeskWidget';
|
||
* import { getBytedeskConfig } from './config/bytedesk.config';
|
||
*
|
||
* <BytedeskWidget
|
||
* config={getBytedeskConfig()}
|
||
* autoLoad={true}
|
||
* />
|
||
*/
|
||
|
||
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 <div id="bytedesk-widget-container" style={{ display: 'none' }} />;
|
||
};
|
||
|
||
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;
|