Files
kefu_cloud/web/public/widget.js
T
yml2213 24fac1bcf1
CI / Backend (go vet + test) (push) Failing after 40m21s
CI / Frontend (typecheck + lint + build) (push) Successful in 1h21m3s
fix(widget): 完善自动回复与新会话进线提醒
2026-11-03 19:29:32 +08:00

219 lines
7.0 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 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 (!open && !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);
});
panel.appendChild(iframe);
}
function setOpen(next) {
open = next;
if (open) {
ensureIframe();
panel.style.display = 'block';
btn.style.display = 'none';
schedulePostPage(true);
} else {
panel.style.display = 'none';
btn.style.display = 'flex';
}
}
function shouldAutoOpenOnce() {
var key = 'kefu_widget_auto_opened_' + channelKey;
try {
if (window.sessionStorage.getItem(key) === '1') return false;
window.sessionStorage.setItem(key, '1');
} catch (e) { /* privacy mode: fall back to opening on each page load */ }
return true;
}
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') {
schedulePostPage(true);
}
});
// —— SPA / 浏览器导航监听 ——
function onRouteMaybeChanged() {
if (!open) 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);
// 标题可能异步更新
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 (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,
};
})();