951 lines
31 KiB
JavaScript
951 lines
31 KiB
JavaScript
(function () {
|
||
'use strict';
|
||
|
||
const defaultModalPosition = 'follow';
|
||
const defaultBtnPosition = 'bottom_right';
|
||
const defaultBtnStyle = 'side_sticky';
|
||
|
||
// 获取当前脚本的域名
|
||
const currentScript = document.currentScript || document.querySelector('script[src*="widget-bot.js"]');
|
||
const ulrObj = new URL(currentScript.src)
|
||
const widgetDomain = `${ulrObj.origin}${ulrObj.pathname}`.replace('/widget-bot.js', '')
|
||
|
||
let widgetInfo = null;
|
||
let widgetButton = null;
|
||
let widgetModal = null;
|
||
let isDragging = false;
|
||
let dragOffset = { x: 0, y: 0 };
|
||
let currentTheme = 'light'; // 默认浅色主题
|
||
let customTriggerElement = null; // 自定义触发元素
|
||
let customTriggerHandler = null; // 自定义触发元素的事件处理函数
|
||
let dragAnimationFrame = null; // 拖拽动画帧ID
|
||
let buttonSize = { width: 0, height: 0 }; // 缓存按钮尺寸
|
||
let initialPosition = { left: 0, top: 0 }; // 拖拽开始时的初始位置
|
||
let hasDragged = false; // 标记是否发生了拖拽
|
||
let dragStartPos = { x: 0, y: 0 }; // 拖拽开始时的鼠标位置
|
||
|
||
// 应用主题
|
||
function applyTheme(theme_mode) {
|
||
currentTheme = theme_mode === 'dark' ? 'dark' : 'light';
|
||
updateThemeClasses();
|
||
}
|
||
|
||
// 更新主题类名
|
||
function updateThemeClasses() {
|
||
if (widgetButton) {
|
||
widgetButton.setAttribute('data-theme', currentTheme);
|
||
}
|
||
if (widgetModal) {
|
||
widgetModal.setAttribute('data-theme', currentTheme);
|
||
}
|
||
}
|
||
|
||
// 获取挂件信息
|
||
async function fetchWidgetInfo() {
|
||
if (widgetButton) {
|
||
widgetButton.classList.add('loading');
|
||
}
|
||
|
||
try {
|
||
const response = await fetch(`${widgetDomain}/share/v1/app/widget/info`, {
|
||
method: 'GET',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
},
|
||
credentials: 'same-origin'
|
||
});
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP error! status: ${response.status}`);
|
||
}
|
||
|
||
const data = await response.json();
|
||
widgetInfo = data.data.settings?.widget_bot_settings;
|
||
|
||
// 验证返回的数据结构
|
||
if (!widgetInfo || typeof widgetInfo !== 'object') {
|
||
throw new Error('Invalid widget info response');
|
||
}
|
||
|
||
// 应用主题模式
|
||
if (widgetInfo.theme_mode) {
|
||
applyTheme(widgetInfo.theme_mode);
|
||
}
|
||
|
||
// 根据 btn_style 创建不同的挂件
|
||
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
|
||
if (btnStyle === 'btn_trigger') {
|
||
createCustomTrigger();
|
||
} else {
|
||
createWidget();
|
||
}
|
||
} catch (error) {
|
||
console.error('获取挂件信息失败:', error);
|
||
// 使用默认值
|
||
widgetInfo = {
|
||
btn_text: '在线客服',
|
||
btn_logo: `''`,
|
||
btn_style: defaultBtnStyle,
|
||
btn_position: defaultBtnPosition,
|
||
modal_position: defaultModalPosition,
|
||
theme_mode: 'light'
|
||
};
|
||
applyTheme(widgetInfo.theme_mode);
|
||
createWidget();
|
||
} finally {
|
||
if (widgetButton) {
|
||
widgetButton.classList.remove('loading');
|
||
}
|
||
}
|
||
}
|
||
|
||
// 应用按钮位置
|
||
function applyButtonPosition(button, position) {
|
||
const pos = position || defaultBtnPosition;
|
||
button.style.top = 'auto';
|
||
button.style.right = 'auto';
|
||
button.style.bottom = 'auto';
|
||
button.style.left = 'auto';
|
||
|
||
// 两种模式使用相同的默认位置:距离边缘16px,垂直方向190px
|
||
switch (pos) {
|
||
case 'top_left':
|
||
button.style.top = '190px';
|
||
button.style.left = '16px';
|
||
break;
|
||
case 'top_right':
|
||
button.style.top = '190px';
|
||
button.style.right = '16px';
|
||
break;
|
||
case 'bottom_left':
|
||
button.style.bottom = '190px';
|
||
button.style.left = '16px';
|
||
break;
|
||
case 'bottom_right':
|
||
default:
|
||
button.style.bottom = '190px';
|
||
button.style.right = '16px';
|
||
break;
|
||
}
|
||
}
|
||
|
||
// 创建侧边吸附按钮
|
||
function createSideStickyButton() {
|
||
widgetButton = document.createElement('div');
|
||
widgetButton.className = 'widget-bot-button widget-bot-side-sticky';
|
||
widgetButton.setAttribute('role', 'button');
|
||
widgetButton.setAttribute('tabindex', '0');
|
||
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
|
||
widgetButton.setAttribute('data-theme', currentTheme);
|
||
|
||
const buttonContent = document.createElement('div');
|
||
buttonContent.className = 'widget-bot-button-content';
|
||
|
||
// 侧边吸附显示图标和文字(btn_logo 以及 btn_text)
|
||
const icon = document.createElement('img');
|
||
const defaultIconSrc = widgetDomain + '/favicon.png';
|
||
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
|
||
icon.alt = 'icon';
|
||
icon.className = 'widget-bot-icon';
|
||
icon.onerror = () => {
|
||
// 如果当前不是 favicon.png,尝试使用 favicon.png 作为备用
|
||
if (icon.src !== defaultIconSrc) {
|
||
icon.src = defaultIconSrc;
|
||
} else {
|
||
// 如果 favicon.png 也加载失败,隐藏图标
|
||
icon.style.display = 'none';
|
||
}
|
||
};
|
||
buttonContent.appendChild(icon);
|
||
|
||
// 添加文字
|
||
const textDiv = document.createElement('div');
|
||
textDiv.className = 'widget-bot-text';
|
||
textDiv.textContent = widgetInfo.btn_text || '在线客服';
|
||
// 设置固定宽度、自动换行和居中
|
||
textDiv.style.wordWrap = 'break-word';
|
||
textDiv.style.whiteSpace = 'normal';
|
||
textDiv.style.textAlign = 'center';
|
||
buttonContent.appendChild(textDiv);
|
||
|
||
widgetButton.appendChild(buttonContent);
|
||
|
||
// 应用位置 - 距离边缘16px,垂直方向190px
|
||
const position = widgetInfo.btn_position || defaultBtnPosition;
|
||
applyButtonPosition(widgetButton, position);
|
||
|
||
// 设置 border-radius 为 24px(统一圆角)
|
||
widgetButton.style.borderRadius = '24px';
|
||
|
||
// 添加事件监听器
|
||
widgetButton.addEventListener('click', handleButtonClick);
|
||
widgetButton.addEventListener('mousedown', startDrag);
|
||
widgetButton.addEventListener('keydown', handleKeyDown);
|
||
|
||
// 添加触摸事件支持
|
||
widgetButton.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||
widgetButton.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||
widgetButton.addEventListener('touchend', handleTouchEnd);
|
||
|
||
document.body.appendChild(widgetButton);
|
||
}
|
||
|
||
// 创建悬浮球按钮
|
||
function createHoverBallButton() {
|
||
widgetButton = document.createElement('div');
|
||
widgetButton.className = 'widget-bot-button widget-bot-hover-ball';
|
||
widgetButton.setAttribute('role', 'button');
|
||
widgetButton.setAttribute('tabindex', '0');
|
||
widgetButton.setAttribute('aria-label', `打开${widgetInfo.btn_text || '在线客服'}窗口`);
|
||
widgetButton.setAttribute('data-theme', currentTheme);
|
||
|
||
const buttonContent = document.createElement('div');
|
||
buttonContent.className = 'widget-bot-button-content';
|
||
|
||
// 悬浮球只显示图标(btn_logo)
|
||
const icon = document.createElement('img');
|
||
const defaultIconSrc = widgetDomain + '/favicon.png';
|
||
icon.src = widgetInfo.btn_logo ? (widgetDomain + widgetInfo.btn_logo) : defaultIconSrc;
|
||
icon.alt = 'icon';
|
||
icon.className = 'widget-bot-icon widget-bot-hover-ball-icon';
|
||
icon.onerror = () => {
|
||
// 如果当前不是 favicon.png,尝试使用 favicon.png 作为备用
|
||
if (icon.src !== defaultIconSrc) {
|
||
icon.src = defaultIconSrc;
|
||
} else {
|
||
// 如果 favicon.png 也加载失败,隐藏图标
|
||
icon.style.display = 'none';
|
||
}
|
||
};
|
||
buttonContent.appendChild(icon);
|
||
|
||
widgetButton.appendChild(buttonContent);
|
||
|
||
// 应用位置 - 距离边缘16px,垂直方向190px
|
||
applyButtonPosition(widgetButton, widgetInfo.btn_position || defaultBtnPosition);
|
||
|
||
// 添加事件监听器
|
||
widgetButton.addEventListener('click', handleButtonClick);
|
||
widgetButton.addEventListener('mousedown', startDrag);
|
||
widgetButton.addEventListener('keydown', handleKeyDown);
|
||
|
||
// 添加触摸事件支持
|
||
widgetButton.addEventListener('touchstart', handleTouchStart, { passive: false });
|
||
widgetButton.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||
widgetButton.addEventListener('touchend', handleTouchEnd);
|
||
|
||
document.body.appendChild(widgetButton);
|
||
}
|
||
|
||
// 创建挂件按钮
|
||
function createWidget() {
|
||
// 如果已存在,先删除
|
||
if (widgetButton) {
|
||
widgetButton.remove();
|
||
}
|
||
|
||
const btnStyle = widgetInfo.btn_style || defaultBtnStyle;
|
||
|
||
if (btnStyle === 'hover_ball') {
|
||
createHoverBallButton();
|
||
} else {
|
||
createSideStickyButton();
|
||
}
|
||
|
||
// 创建模态框
|
||
createModal();
|
||
|
||
// 触发显示动画
|
||
setTimeout(() => {
|
||
widgetButton.style.opacity = '1';
|
||
}, 100);
|
||
}
|
||
|
||
// 创建自定义触发按钮
|
||
function createCustomTrigger() {
|
||
const btnId = widgetInfo.btn_id;
|
||
if (!btnId) {
|
||
console.error('btn_trigger 模式需要提供 btn_id');
|
||
return;
|
||
}
|
||
|
||
let retryCount = 0;
|
||
const maxRetries = 50; // 最多重试 50 次(5秒)
|
||
|
||
// 绑定事件到元素
|
||
function attachTrigger(element) {
|
||
if (!element) return;
|
||
|
||
// 避免重复绑定
|
||
if (element.hasAttribute('data-widget-trigger-attached')) {
|
||
return;
|
||
}
|
||
|
||
element.setAttribute('data-widget-trigger-attached', 'true');
|
||
customTriggerElement = element;
|
||
|
||
// 创建事件处理函数并保存引用
|
||
customTriggerHandler = function (e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
showModal();
|
||
};
|
||
|
||
// 绑定点击事件
|
||
element.addEventListener('click', customTriggerHandler);
|
||
}
|
||
|
||
// 尝试查找并绑定元素
|
||
function tryAttachTrigger() {
|
||
const element = document.getElementById(btnId);
|
||
if (element) {
|
||
attachTrigger(element);
|
||
createModal();
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
// 立即尝试一次
|
||
if (tryAttachTrigger()) {
|
||
return;
|
||
}
|
||
|
||
// 如果元素还没加载,使用多种方式监听
|
||
function retryAttach() {
|
||
if (tryAttachTrigger()) {
|
||
return;
|
||
}
|
||
|
||
retryCount++;
|
||
if (retryCount < maxRetries) {
|
||
setTimeout(retryAttach, 100);
|
||
} else {
|
||
console.warn('自定义触发按钮未找到,已停止重试:', btnId);
|
||
}
|
||
}
|
||
|
||
// 使用 MutationObserver 监听 DOM 变化
|
||
const observer = new MutationObserver(function (mutations) {
|
||
if (tryAttachTrigger()) {
|
||
observer.disconnect();
|
||
}
|
||
});
|
||
|
||
// 开始观察 DOM 变化
|
||
observer.observe(document.body, {
|
||
childList: true,
|
||
subtree: true
|
||
});
|
||
|
||
// 如果 DOM 已加载完成,立即开始重试
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', function () {
|
||
setTimeout(retryAttach, 100);
|
||
});
|
||
} else {
|
||
setTimeout(retryAttach, 100);
|
||
}
|
||
|
||
// 延迟断开观察器(避免无限观察)
|
||
setTimeout(function () {
|
||
observer.disconnect();
|
||
}, 10000); // 10秒后断开
|
||
}
|
||
|
||
// 处理按钮点击事件(区分点击和拖拽)
|
||
function handleButtonClick(e) {
|
||
// 如果发生了拖拽,不打开弹框
|
||
if (hasDragged) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
return;
|
||
}
|
||
showModal();
|
||
}
|
||
|
||
// 键盘事件处理
|
||
function handleKeyDown(e) {
|
||
if (e.key === 'Enter' || e.key === ' ') {
|
||
e.preventDefault();
|
||
showModal();
|
||
}
|
||
}
|
||
|
||
// 触摸事件处理
|
||
let touchStartPos = { x: 0, y: 0 };
|
||
|
||
function handleTouchStart(e) {
|
||
const touch = e.touches[0];
|
||
touchStartPos = { x: touch.clientX, y: touch.clientY };
|
||
startDrag(e);
|
||
}
|
||
|
||
function handleTouchMove(e) {
|
||
if (!isDragging) return;
|
||
e.preventDefault()
|
||
const touch = e.touches[0];
|
||
drag({ clientX: touch.clientX, clientY: touch.clientY });
|
||
}
|
||
|
||
function handleTouchEnd(e) {
|
||
const touch = e.changedTouches[0];
|
||
const distance = Math.sqrt(
|
||
Math.pow(touch.clientX - touchStartPos.x, 2) +
|
||
Math.pow(touch.clientY - touchStartPos.y, 2)
|
||
);
|
||
|
||
// 只有在没有拖拽且移动距离很小的情况下才认为是点击
|
||
if (!hasDragged && distance < 10) {
|
||
// 判断为点击事件
|
||
setTimeout(() => showModal(), 100);
|
||
}
|
||
|
||
stopDrag();
|
||
}
|
||
|
||
// 创建模态框
|
||
function createModal() {
|
||
// 如果已存在,先删除
|
||
if (widgetModal) {
|
||
widgetModal.remove();
|
||
}
|
||
|
||
widgetModal = document.createElement('div');
|
||
widgetModal.className = 'widget-bot-modal';
|
||
widgetModal.setAttribute('role', 'dialog');
|
||
widgetModal.setAttribute('aria-modal', 'true');
|
||
widgetModal.setAttribute('aria-labelledby', 'widget-modal-title');
|
||
widgetModal.setAttribute('data-theme', currentTheme);
|
||
|
||
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
|
||
if (modalPosition === 'fixed') {
|
||
widgetModal.classList.add('widget-bot-modal-fixed');
|
||
}
|
||
|
||
const modalContent = document.createElement('div');
|
||
modalContent.className = 'widget-bot-modal-content';
|
||
if (modalPosition === 'fixed') {
|
||
modalContent.classList.add('widget-bot-modal-content-fixed');
|
||
}
|
||
|
||
// 创建关闭按钮(透明框)
|
||
const closeBtn = document.createElement('button');
|
||
closeBtn.className = 'widget-bot-close-btn';
|
||
closeBtn.setAttribute('aria-label', '关闭窗口');
|
||
closeBtn.setAttribute('type', 'button');
|
||
|
||
// 创建一个内部元素来处理实际的点击事件(因为按钮设置了 pointer-events: none)
|
||
const closeBtnArea = document.createElement('div');
|
||
closeBtnArea.style.width = '100%';
|
||
closeBtnArea.style.height = '100%';
|
||
closeBtnArea.style.pointerEvents = 'auto'; // 内部元素可以接收事件
|
||
closeBtnArea.style.cursor = 'pointer';
|
||
closeBtnArea.addEventListener('click', function (e) {
|
||
e.preventDefault();
|
||
e.stopPropagation();
|
||
hideModal();
|
||
});
|
||
closeBtn.appendChild(closeBtnArea);
|
||
|
||
// 创建iframe
|
||
const iframe = document.createElement('iframe');
|
||
iframe.className = 'widget-bot-iframe';
|
||
iframe.src = `${widgetDomain}/widget`;
|
||
iframe.setAttribute('title', `${widgetInfo.btn_text || '在线客服'}服务窗口`);
|
||
iframe.setAttribute('allow', 'camera; microphone; geolocation');
|
||
iframe.setAttribute('sandbox', 'allow-same-origin allow-scripts allow-forms allow-popups allow-presentation');
|
||
|
||
modalContent.appendChild(closeBtn);
|
||
modalContent.appendChild(iframe);
|
||
widgetModal.appendChild(modalContent);
|
||
|
||
document.body.appendChild(widgetModal);
|
||
}
|
||
|
||
// 检测是否为移动端
|
||
function isMobile() {
|
||
return window.innerWidth <= 768 || /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent);
|
||
}
|
||
|
||
// 智能定位弹框(follow模式)
|
||
function positionModalFollow(modalContent) {
|
||
if (!widgetButton || !modalContent) return;
|
||
|
||
// 移动端强制居中显示
|
||
if (isMobile()) {
|
||
modalContent.style.position = 'relative';
|
||
modalContent.style.top = 'auto';
|
||
modalContent.style.left = 'auto';
|
||
modalContent.style.right = 'auto';
|
||
modalContent.style.bottom = 'auto';
|
||
modalContent.style.margin = 'auto';
|
||
modalContent.style.width = 'calc(100% - 32px)';
|
||
modalContent.style.height = 'auto';
|
||
return;
|
||
}
|
||
|
||
requestAnimationFrame(() => {
|
||
const buttonRect = widgetButton.getBoundingClientRect();
|
||
const windowWidth = window.innerWidth;
|
||
const windowHeight = window.innerHeight;
|
||
const margin = 16; // 距离屏幕边缘的最小距离
|
||
const buttonGap = 16; // 弹框和按钮之间的最小距离
|
||
|
||
// 先设置一个临时位置来获取弹框尺寸
|
||
const originalPosition = modalContent.style.position;
|
||
const originalTop = modalContent.style.top;
|
||
const originalLeft = modalContent.style.left;
|
||
const originalVisibility = modalContent.style.visibility;
|
||
const originalDisplay = modalContent.style.display;
|
||
|
||
modalContent.style.position = 'absolute';
|
||
modalContent.style.top = '0';
|
||
modalContent.style.left = '0';
|
||
modalContent.style.visibility = 'hidden';
|
||
modalContent.style.display = 'block';
|
||
|
||
const modalRect = modalContent.getBoundingClientRect();
|
||
const modalWidth = modalRect.width;
|
||
const modalHeight = modalRect.height;
|
||
|
||
modalContent.style.visibility = originalVisibility || 'visible';
|
||
modalContent.style.display = originalDisplay || 'block';
|
||
|
||
// 计算按钮中心点
|
||
const buttonCenterX = buttonRect.left + buttonRect.width / 2;
|
||
const buttonCenterY = buttonRect.top + buttonRect.height / 2;
|
||
|
||
// 判断按钮在屏幕的哪一侧
|
||
const isLeftSide = buttonCenterX < windowWidth / 2;
|
||
const isTopSide = buttonCenterY < windowHeight / 2;
|
||
|
||
// 智能选择弹框位置,确保完整显示
|
||
let finalTop, finalBottom, finalLeft, finalRight;
|
||
|
||
if (isLeftSide) {
|
||
// 按钮在左侧,弹框优先显示在右侧(按钮右侧)
|
||
finalLeft = buttonRect.right + buttonGap;
|
||
finalRight = 'auto';
|
||
|
||
// 如果右侧空间不够,显示在左侧(按钮左侧)
|
||
if (finalLeft + modalWidth > windowWidth - margin) {
|
||
finalLeft = 'auto';
|
||
finalRight = windowWidth - buttonRect.left + buttonGap;
|
||
// 如果左侧空间也不够,则贴左边(但保持与按钮的距离)
|
||
if (buttonRect.left - buttonGap - modalWidth < margin) {
|
||
finalLeft = margin;
|
||
finalRight = 'auto';
|
||
}
|
||
}
|
||
} else {
|
||
// 按钮在右侧,弹框优先显示在左侧(按钮左侧)
|
||
finalLeft = 'auto';
|
||
finalRight = windowWidth - buttonRect.left + buttonGap;
|
||
|
||
// 如果左侧空间不够,显示在右侧(按钮右侧)
|
||
if (buttonRect.left - buttonGap - modalWidth < margin) {
|
||
finalRight = 'auto';
|
||
finalLeft = buttonRect.right + buttonGap;
|
||
// 如果右侧空间也不够,则贴右边(但保持与按钮的距离)
|
||
if (finalLeft + modalWidth > windowWidth - margin) {
|
||
finalLeft = 'auto';
|
||
finalRight = margin;
|
||
}
|
||
}
|
||
}
|
||
|
||
// 垂直方向:优先与按钮顶部对齐
|
||
// 弹框顶部与按钮顶部对齐
|
||
finalTop = buttonRect.top;
|
||
finalBottom = 'auto';
|
||
|
||
// 如果弹框底部超出屏幕,则向上调整,确保弹框完整显示在屏幕内
|
||
if (finalTop + modalHeight > windowHeight - margin) {
|
||
// 计算向上调整后的位置
|
||
const adjustedTop = windowHeight - margin - modalHeight;
|
||
// 如果调整后的位置仍然在按钮上方,则使用调整后的位置
|
||
if (adjustedTop >= margin) {
|
||
finalTop = adjustedTop;
|
||
} else {
|
||
// 如果调整后仍然超出,则贴顶部
|
||
finalTop = margin;
|
||
}
|
||
} else if (finalTop < margin) {
|
||
// 如果弹框顶部超出屏幕,则贴顶部
|
||
finalTop = margin;
|
||
}
|
||
|
||
// 应用最终位置
|
||
modalContent.style.top = finalTop !== undefined ? (typeof finalTop === 'string' ? finalTop : finalTop + 'px') : 'auto';
|
||
modalContent.style.bottom = finalBottom !== undefined ? (typeof finalBottom === 'string' ? finalBottom : finalBottom + 'px') : 'auto';
|
||
modalContent.style.left = finalLeft !== undefined ? (typeof finalLeft === 'string' ? finalLeft : finalLeft + 'px') : 'auto';
|
||
modalContent.style.right = finalRight !== undefined ? (typeof finalRight === 'string' ? finalRight : finalRight + 'px') : 'auto';
|
||
|
||
// 最终检查并修正,确保弹框完全在屏幕内
|
||
requestAnimationFrame(() => {
|
||
const finalModalRect = modalContent.getBoundingClientRect();
|
||
|
||
// 修正左边界
|
||
if (finalModalRect.left < margin) {
|
||
modalContent.style.left = margin + 'px';
|
||
modalContent.style.right = 'auto';
|
||
}
|
||
|
||
// 修正右边界
|
||
if (finalModalRect.right > windowWidth - margin) {
|
||
modalContent.style.right = margin + 'px';
|
||
modalContent.style.left = 'auto';
|
||
}
|
||
|
||
// 修正上边界
|
||
if (finalModalRect.top < margin) {
|
||
modalContent.style.top = margin + 'px';
|
||
modalContent.style.bottom = 'auto';
|
||
}
|
||
|
||
// 修正下边界
|
||
if (finalModalRect.bottom > windowHeight - margin) {
|
||
modalContent.style.bottom = margin + 'px';
|
||
modalContent.style.top = 'auto';
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
// 显示模态框
|
||
function showModal() {
|
||
if (!widgetModal) return;
|
||
|
||
widgetModal.style.display = 'flex';
|
||
document.body.classList.add('widget-bot-modal-open');
|
||
|
||
const modalPosition = widgetInfo.modal_position || defaultModalPosition;
|
||
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
|
||
|
||
// 移动端强制居中显示
|
||
if (isMobile()) {
|
||
modalContent.style.position = 'relative';
|
||
modalContent.style.top = 'auto';
|
||
modalContent.style.left = 'auto';
|
||
modalContent.style.right = 'auto';
|
||
modalContent.style.bottom = 'auto';
|
||
modalContent.style.margin = 'auto';
|
||
modalContent.style.width = 'calc(100% - 32px)';
|
||
modalContent.style.height = 'auto';
|
||
} else if (modalPosition === 'fixed') {
|
||
// 桌面端固定模式:居中展示
|
||
modalContent.style.position = 'relative';
|
||
modalContent.style.top = 'auto';
|
||
modalContent.style.left = 'auto';
|
||
modalContent.style.right = 'auto';
|
||
modalContent.style.bottom = 'auto';
|
||
modalContent.style.margin = 'auto';
|
||
} else {
|
||
// 桌面端跟随模式:跟随按钮位置 - 智能定位,确保弹框完整显示在屏幕内
|
||
positionModalFollow(modalContent);
|
||
}
|
||
|
||
// 添加ESC键关闭功能(先移除避免重复绑定)
|
||
document.removeEventListener('keydown', handleEscKey);
|
||
document.addEventListener('keydown', handleEscKey);
|
||
}
|
||
|
||
// ESC键处理
|
||
function handleEscKey(e) {
|
||
// 只在弹框显示时响应 ESC 键
|
||
if (e.key === 'Escape' && widgetModal && widgetModal.style.display === 'flex') {
|
||
hideModal();
|
||
}
|
||
}
|
||
|
||
// 隐藏模态框
|
||
function hideModal() {
|
||
if (!widgetModal) return;
|
||
|
||
widgetModal.style.display = 'none';
|
||
document.body.classList.remove('widget-bot-modal-open');
|
||
|
||
// 恢复焦点到按钮
|
||
if (widgetButton) {
|
||
widgetButton.focus();
|
||
}
|
||
|
||
// 移除ESC键监听
|
||
document.removeEventListener('keydown', handleEscKey);
|
||
}
|
||
|
||
// 开始拖拽
|
||
function startDrag(e) {
|
||
if (e.preventDefault) {
|
||
e.preventDefault()
|
||
};
|
||
|
||
isDragging = true;
|
||
hasDragged = false; // 重置拖拽标记
|
||
|
||
const rect = widgetButton.getBoundingClientRect();
|
||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||
|
||
// 记录拖拽开始位置
|
||
dragStartPos.x = clientX;
|
||
dragStartPos.y = clientY;
|
||
|
||
// 由于 transform-origin 是 center,scale 不会改变元素中心位置
|
||
// 但 getBoundingClientRect() 返回的尺寸是放大后的,需要计算原始尺寸
|
||
// 假设当前可能有 scale(1.1),计算原始尺寸
|
||
const scale = 1.1; // hover 时的 scale 值
|
||
const originalWidth = rect.width / scale;
|
||
const originalHeight = rect.height / scale;
|
||
|
||
// 缓存按钮原始尺寸(未缩放)
|
||
buttonSize.width = originalWidth;
|
||
buttonSize.height = originalHeight;
|
||
|
||
// 由于 transform-origin 是 center,元素的左上角位置需要考虑 scale 的影响
|
||
// 中心点位置不变,但左上角会向左上移动
|
||
const centerX = rect.left + rect.width / 2;
|
||
const centerY = rect.top + rect.height / 2;
|
||
const originalLeft = centerX - originalWidth / 2;
|
||
const originalTop = centerY - originalHeight / 2;
|
||
|
||
initialPosition.left = originalLeft;
|
||
initialPosition.top = originalTop;
|
||
|
||
// 计算鼠标相对于原始尺寸(未缩放)按钮左上角的偏移
|
||
dragOffset.x = clientX - originalLeft;
|
||
dragOffset.y = clientY - originalTop;
|
||
|
||
widgetButton.style.position = 'fixed';
|
||
widgetButton.style.top = originalTop + 'px';
|
||
widgetButton.style.left = originalLeft + 'px';
|
||
widgetButton.style.right = 'auto';
|
||
widgetButton.style.bottom = 'auto';
|
||
// 保持 scale 效果
|
||
widgetButton.style.transform = 'scale(1.1)';
|
||
|
||
widgetButton.style.transition = 'none';
|
||
widgetButton.style.willChange = 'left, top, transform';
|
||
|
||
document.addEventListener('mousemove', drag, { passive: false });
|
||
document.addEventListener('mouseup', stopDrag);
|
||
|
||
widgetButton.classList.add('dragging');
|
||
widgetButton.style.zIndex = '10001';
|
||
}
|
||
|
||
// 拖拽中 - 直接更新位置,实现丝滑跟随
|
||
function drag(e) {
|
||
if (!isDragging) return;
|
||
|
||
if (e.preventDefault) {
|
||
e.preventDefault();
|
||
}
|
||
|
||
const clientX = e.clientX || (e.touches && e.touches[0].clientX);
|
||
const clientY = e.clientY || (e.touches && e.touches[0].clientY);
|
||
|
||
// 检测是否发生了实际移动(超过5px才认为是拖拽)
|
||
const moveDistance = Math.sqrt(
|
||
Math.pow(clientX - dragStartPos.x, 2) +
|
||
Math.pow(clientY - dragStartPos.y, 2)
|
||
);
|
||
if (moveDistance > 5) {
|
||
hasDragged = true;
|
||
}
|
||
const windowWidth = window.innerWidth;
|
||
const windowHeight = window.innerHeight;
|
||
const buttonWidth = buttonSize.width;
|
||
const buttonHeight = buttonSize.height;
|
||
|
||
// 直接基于鼠标位置计算新位置
|
||
// 鼠标位置减去拖拽偏移量,得到按钮左上角应该的位置
|
||
const newLeft = clientX - dragOffset.x;
|
||
const newTop = clientY - dragOffset.y;
|
||
|
||
// 垂直位置:限制在屏幕范围内,距离顶部和底部最小距离为 24px
|
||
const minTop = 24;
|
||
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
|
||
const constrainedTop = Math.max(minTop, Math.min(newTop, maxTop));
|
||
|
||
// 水平位置:限制在屏幕范围内
|
||
const maxLeft = windowWidth - buttonWidth;
|
||
const constrainedLeft = Math.max(0, Math.min(newLeft, maxLeft));
|
||
|
||
widgetButton.style.left = constrainedLeft + 'px';
|
||
widgetButton.style.top = constrainedTop + 'px';
|
||
widgetButton.style.right = 'auto';
|
||
widgetButton.style.bottom = 'auto';
|
||
// 保持 scale 效果
|
||
widgetButton.style.transform = 'scale(1.1)';
|
||
}
|
||
|
||
// 停止拖拽
|
||
function stopDrag() {
|
||
if (!isDragging) return;
|
||
|
||
isDragging = false;
|
||
|
||
// 取消待执行的动画帧
|
||
if (dragAnimationFrame) {
|
||
cancelAnimationFrame(dragAnimationFrame);
|
||
dragAnimationFrame = null;
|
||
}
|
||
|
||
document.removeEventListener('mousemove', drag);
|
||
document.removeEventListener('mouseup', stopDrag);
|
||
|
||
widgetButton.classList.remove('dragging');
|
||
widgetButton.style.zIndex = '9999';
|
||
|
||
// 恢复过渡效果
|
||
widgetButton.style.transition = '';
|
||
widgetButton.style.willChange = '';
|
||
// 移除 transform,让 CSS hover 效果可以正常工作
|
||
widgetButton.style.transform = '';
|
||
|
||
// 根据按钮类型和当前位置进行最终定位
|
||
requestAnimationFrame(() => {
|
||
const buttonRect = widgetButton.getBoundingClientRect();
|
||
const currentLeft = buttonRect.left;
|
||
const currentTop = buttonRect.top;
|
||
const windowWidth = window.innerWidth;
|
||
const windowHeight = window.innerHeight;
|
||
const buttonWidth = buttonSize.width;
|
||
const buttonHeight = buttonSize.height;
|
||
|
||
// 两种模式使用相同的停止拖拽逻辑:只能左右侧边缘吸附
|
||
// 根据按钮实际位置判断左右,保持当前位置
|
||
const screenCenterX = windowWidth / 2;
|
||
const buttonCenterX = currentLeft + buttonWidth / 2;
|
||
const isLeftSide = buttonCenterX < screenCenterX;
|
||
const sideDistance = 16; // 距离边缘的距离
|
||
|
||
// 垂直位置:保持在当前位置,限制在屏幕范围内,距离顶部和底部最小距离为 24px
|
||
const minTop = 24;
|
||
const maxTop = Math.max(minTop, windowHeight - buttonHeight - 24);
|
||
const finalTop = Math.max(minTop, Math.min(currentTop, maxTop));
|
||
let finalLeft;
|
||
|
||
// 水平位置:距离左右边16px
|
||
if (isLeftSide) {
|
||
finalLeft = sideDistance;
|
||
widgetButton.style.left = sideDistance + 'px';
|
||
widgetButton.style.right = 'auto';
|
||
} else {
|
||
finalLeft = windowWidth - sideDistance - buttonWidth;
|
||
widgetButton.style.right = sideDistance + 'px';
|
||
widgetButton.style.left = 'auto';
|
||
}
|
||
|
||
widgetButton.style.top = finalTop + 'px';
|
||
widgetButton.style.bottom = 'auto';
|
||
|
||
// 更新 border-radius(现在都是24px圆角)
|
||
widgetButton.style.borderRadius = '24px';
|
||
|
||
// 更新初始位置,为下次拖拽做准备
|
||
if (finalLeft !== undefined && finalTop !== undefined) {
|
||
initialPosition.left = finalLeft;
|
||
initialPosition.top = finalTop;
|
||
} else {
|
||
// 如果未定义,使用当前实际位置
|
||
initialPosition.left = buttonRect.left;
|
||
initialPosition.top = buttonRect.top;
|
||
}
|
||
});
|
||
}
|
||
|
||
// 设置按钮状态
|
||
function setButtonState(state) {
|
||
if (!widgetButton) return;
|
||
|
||
widgetButton.classList.remove('success', 'error', 'loading');
|
||
|
||
if (state === 'success') {
|
||
widgetButton.classList.add('success');
|
||
} else if (state === 'error') {
|
||
widgetButton.classList.add('error');
|
||
} else if (state === 'loading') {
|
||
widgetButton.classList.add('loading');
|
||
}
|
||
}
|
||
|
||
// 更新主题模式
|
||
function updateThemeMode(theme_mode) {
|
||
if (theme_mode === 'light' || theme_mode === 'dark') {
|
||
applyTheme(theme_mode);
|
||
}
|
||
}
|
||
|
||
// 全局函数
|
||
window.hideWidgetModal = hideModal;
|
||
window.setWidgetButtonState = setButtonState;
|
||
window.updateWidgetTheme = updateThemeMode;
|
||
|
||
// 点击模态框背景关闭
|
||
document.addEventListener('click', function (e) {
|
||
if (e.target === widgetModal) {
|
||
hideModal();
|
||
}
|
||
});
|
||
|
||
// 窗口大小改变时重新定位
|
||
window.addEventListener('resize', function () {
|
||
if (widgetModal && widgetModal.style.display === 'flex') {
|
||
const modalContent = widgetModal.querySelector('.widget-bot-modal-content');
|
||
if (!modalContent) return;
|
||
|
||
// 移动端强制居中显示
|
||
if (isMobile()) {
|
||
modalContent.style.position = 'relative';
|
||
modalContent.style.top = 'auto';
|
||
modalContent.style.left = 'auto';
|
||
modalContent.style.right = 'auto';
|
||
modalContent.style.bottom = 'auto';
|
||
modalContent.style.margin = 'auto';
|
||
modalContent.style.width = 'calc(100% - 32px)';
|
||
modalContent.style.height = 'auto';
|
||
return;
|
||
}
|
||
|
||
const modalPosition = widgetInfo?.modal_position || defaultModalPosition;
|
||
if (modalPosition === 'fixed') {
|
||
// 固定居中模式不需要重新定位
|
||
return;
|
||
}
|
||
|
||
// 重新计算模态框位置(使用智能定位)
|
||
positionModalFollow(modalContent);
|
||
}
|
||
});
|
||
|
||
// 初始化
|
||
function init() {
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', fetchWidgetInfo);
|
||
} else {
|
||
fetchWidgetInfo();
|
||
}
|
||
}
|
||
|
||
// 页面卸载时清理
|
||
window.addEventListener('beforeunload', function () {
|
||
if (widgetButton) {
|
||
widgetButton.remove();
|
||
}
|
||
if (widgetModal) {
|
||
widgetModal.remove();
|
||
}
|
||
if (customTriggerElement && customTriggerHandler) {
|
||
customTriggerElement.removeEventListener('click', customTriggerHandler);
|
||
customTriggerElement.removeAttribute('data-widget-trigger-attached');
|
||
}
|
||
});
|
||
|
||
// 启动
|
||
init();
|
||
})();
|
||
|