Files
kefu_cloud/web/public/widget.js
T
yml2213 93ef9f2040
CI / Backend (go vet + test) (push) Failing after 9m33s
CI / Frontend (typecheck + lint + build) (push) Successful in 11m57s
fix(widget): 优化访客在线续接与历史地区回填
2026-11-06 18:51:44 +08:00

265 lines
8.3 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 客服云访客 Widget 嵌入脚本
* 用法: <script src="https://your-host/widget.js" data-id="WK_xxxx"></script>
*
* 向 iframe 同步宿主页 URL / 标题,并监听 SPA 路由变化,供客服端展示落地页与浏览轨迹。
*/
(function () {
if (window.__KEFU_WIDGET_LOADED__) return;
window.__KEFU_WIDGET_LOADED__ = true;
var script = document.currentScript;
if (!script) {
var scripts = document.getElementsByTagName('script');
script = scripts[scripts.length - 1];
}
var channelKey = (script && script.getAttribute('data-id')) || '';
if (!channelKey) {
console.warn('[kefu-widget] missing data-id on script tag');
return;
}
var autoOpen = !(script && /^(false|0)$/i.test(script.getAttribute('data-auto-open') || ''));
var src = script && script.src ? script.src : '';
var base = '';
try {
var u = new URL(src, window.location.href);
base = u.origin;
} catch (e) {
base = window.location.origin;
}
var open = false;
var iframe = null;
var lastSentKey = '';
var lastSentAt = 0;
var routeTimer = null;
var presenceTimer = null;
var btn = document.createElement('button');
btn.type = 'button';
btn.setAttribute('aria-label', '打开在线客服');
btn.style.cssText = [
'position:fixed', 'right:24px', 'bottom:24px', 'z-index:2147483000',
'width:56px', 'height:56px', 'border:none', 'border-radius:9999px',
'background:#2563eb', 'color:#fff', 'cursor:pointer',
'box-shadow:0 8px 24px rgba(0,0,0,0.12)',
'display:flex', 'align-items:center', 'justify-content:center',
'font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",sans-serif',
].join(';');
btn.innerHTML = '<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>';
var panel = document.createElement('div');
panel.style.cssText = [
'position:fixed', 'right:24px', 'bottom:24px', 'z-index:2147483001',
'width:400px', 'height:600px', 'max-width:calc(100vw - 32px)', 'max-height:calc(100vh - 32px)',
'border-radius:12px', 'overflow:hidden',
'box-shadow:0 8px 24px rgba(0,0,0,0.12)',
'display:none', 'background:#fff',
].join(';');
function hostPageInfo() {
var href = '';
var title = '';
var ref = '';
try {
href = String(window.location.href || '');
title = String(document.title || '');
ref = String(document.referrer || '');
} catch (e) { /* ignore */ }
return { url: href, title: title, referrer: ref };
}
function buildEmbedURL() {
var info = hostPageInfo();
var q =
'channel_key=' + encodeURIComponent(channelKey) +
'&embedded=1' +
'&page_url=' + encodeURIComponent(info.url) +
'&page_title=' + encodeURIComponent(info.title) +
'&referrer=' + encodeURIComponent(info.referrer);
return base + '/widget/embed?' + q;
}
function postPageToIframe(force) {
if (!iframe || !iframe.contentWindow) return;
var info = hostPageInfo();
var now = Date.now();
// url + title 一起去重:SPA 常先改 hash 再改 title,只比 url 会丢掉正确标题
var key = info.url + '\0' + info.title;
if (!force && key === lastSentKey && now - lastSentAt < 500) return;
lastSentKey = key;
lastSentAt = now;
try {
iframe.contentWindow.postMessage({
type: 'kefu-host-page',
url: info.url,
title: info.title,
referrer: info.referrer,
}, '*');
} catch (e) { /* ignore */ }
}
/**
* 路由变化后延迟采集:让宿主 SPA(含 demo-shop)先跑完 render、更新 document.title,
* 避免轨迹标题永远慢一页(URL 已是新页、title 仍是上一页)。
*/
function schedulePostPage(force) {
if (!iframe && !force) return;
if (routeTimer) clearTimeout(routeTimer);
routeTimer = setTimeout(function () {
routeTimer = null;
postPageToIframe(!!force);
// 再补一帧:部分框架 title 在 paint 后才写
setTimeout(function () { postPageToIframe(false); }, 50);
}, 0);
}
function ensureIframe() {
if (iframe) return;
iframe = document.createElement('iframe');
iframe.title = '在线客服';
iframe.allow = 'clipboard-write';
iframe.style.cssText = 'width:100%;height:100%;border:0;display:block;background:#fff;';
iframe.src = buildEmbedURL();
iframe.addEventListener('load', function () {
schedulePostPage(true);
postHostVisibility();
});
panel.appendChild(iframe);
if (presenceTimer == null) {
presenceTimer = window.setInterval(function () {
if (!iframe || !iframe.contentWindow) return;
try {
iframe.contentWindow.postMessage({
type: 'kefu-host-tick',
visible: document.visibilityState === 'visible',
}, '*');
} catch (e) { /* ignore */ }
}, 15000);
}
}
function setOpen(next) {
open = next;
if (open) {
markSessionStarted();
ensureIframe();
panel.style.display = 'block';
btn.style.display = 'none';
schedulePostPage(true);
} else {
panel.style.display = 'none';
btn.style.display = 'flex';
}
}
function postHostVisibility() {
if (!iframe || !iframe.contentWindow) return;
try {
iframe.contentWindow.postMessage({
type: 'kefu-host-visibility',
visible: document.visibilityState === 'visible',
}, '*');
} catch (e) { /* ignore */ }
}
function shouldAutoOpenOnce() {
if (hasStartedSession()) return false;
markSessionStarted();
return true;
}
function hasStartedSession() {
var key = 'kefu_widget_auto_opened_' + channelKey;
try {
return window.sessionStorage.getItem(key) === '1';
} catch (e) {
return false;
}
}
function markSessionStarted() {
var key = 'kefu_widget_auto_opened_' + channelKey;
try {
window.sessionStorage.setItem(key, '1');
} catch (e) { /* ignore */ }
}
btn.addEventListener('click', function () { setOpen(true); });
window.addEventListener('message', function (event) {
if (!event || !event.data) return;
if (event.data.type === 'kefu-widget-close' || event.data.type === 'kefu-widget-minimize') {
setOpen(false);
}
// iframe 就绪后可再次同步宿主页
if (event.data.type === 'kefu-widget-ready') {
postHostVisibility();
schedulePostPage(true);
}
});
// —— SPA / 浏览器导航监听 ——
function onRouteMaybeChanged() {
if (!iframe) return;
schedulePostPage(false);
}
try {
var _push = history.pushState;
var _replace = history.replaceState;
history.pushState = function () {
var r = _push.apply(this, arguments);
onRouteMaybeChanged();
return r;
};
history.replaceState = function () {
var r = _replace.apply(this, arguments);
onRouteMaybeChanged();
return r;
};
} catch (e) { /* ignore */ }
window.addEventListener('popstate', onRouteMaybeChanged);
window.addEventListener('hashchange', onRouteMaybeChanged);
document.addEventListener('visibilitychange', function () {
postHostVisibility();
if (document.visibilityState === 'visible') schedulePostPage(true);
});
window.addEventListener('pagehide', postHostVisibility);
// 标题可能异步更新
try {
var titleEl = document.querySelector('title');
if (titleEl && typeof MutationObserver !== 'undefined') {
new MutationObserver(function () { onRouteMaybeChanged(); }).observe(titleEl, {
subtree: true, characterData: true, childList: true,
});
}
} catch (e) { /* ignore */ }
function mount() {
document.body.appendChild(btn);
document.body.appendChild(panel);
if (hasStartedSession()) {
// 同站后续页面保持隐藏 iframe 心跳和轨迹,不重复自动展开面板。
ensureIframe();
} else if (autoOpen) {
window.setTimeout(function () {
if (!open && shouldAutoOpenOnce()) setOpen(true);
}, 600);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', mount);
} else {
mount();
}
window.KefuWidget = {
open: function () { setOpen(true); },
close: function () { setOpen(false); },
channelKey: channelKey,
};
})();