init push
This commit is contained in:
950
web/app/public/widget-bot.js
Normal file
950
web/app/public/widget-bot.js
Normal file
@@ -0,0 +1,950 @@
|
||||
(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();
|
||||
})();
|
||||
|
||||
Reference in New Issue
Block a user