feat: brand 白名单下发(站点覆盖/租户头像/CORS) + widget/chat 客户端 key 租户化 + 会话站点标

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
浅念
2026-10-12 22:26:15 +08:00
parent 49e2b476f5
commit bbc7bc520e
15 changed files with 570 additions and 93 deletions
+133 -43
View File
@@ -5,12 +5,18 @@
})();
// Save config immediately (before DOM ready)
// Task 10 值管道: data-* 属性(嵌入方显式覆盖,最高优先) > brand 端点
// (租户/站点门面) > 中性兜底。此处只同步读原始属性;有效值在 brand
// 落定后由 boot() 推导(见文件尾),品牌文案不再硬编码于本文件。
var position = script.getAttribute("data-position") || "bottom-right";
var theme = script.getAttribute("data-theme") || "light";
var title = script.getAttribute("data-title") || "HESUPIPE Support";
var titleAttr = script.getAttribute("data-title");
var autoOpen = script.getAttribute("data-auto-open") === "true";
var whatsapp = script.getAttribute("data-whatsapp") || "https://wa.me/+8615937108317";
var email = script.getAttribute("data-email") || "mailto:hesu@hesuu.com";
var whatsappAttr = script.getAttribute("data-whatsapp");
var emailAttr = script.getAttribute("data-email");
var colorAttr = (script.getAttribute("data-color") || "").trim();
var avatarAttr = (script.getAttribute("data-avatar") || "").trim();
var siteAttr = (script.getAttribute("data-site") || "").trim();
var autoInvite = script.getAttribute("data-auto-invite") !== "false";
var isMobile = script.getAttribute("data-mobile") === "true";
var wsBase = new URL(script.src).origin;
@@ -109,14 +115,62 @@
/* ===== 主题派生单元结束 ===== */
var DEFAULT_AVATAR_PATH = "/static/kefu-small.png";
var avatarAttr = (script.getAttribute("data-avatar") || "").trim();
var avatarUrl = avatarAttr
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
: wsBase + DEFAULT_AVATAR_PATH;
var colorAttr = (script.getAttribute("data-color") || "").trim();
var palette = derivePalette(colorAttr || DEFAULT_COLOR);
if (script.getAttribute("data-debug") === "true") {
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
// ---- Task 10: brand 端点取门面 + 有效值推导 ------------------------------
// __brand = /api/public/brand 响应(null = 拉取失败/超时 → 中性兜底,不白屏)
var __brand = null;
var title = "", whatsapp = "", email = "", avatarUrl = "", privacyUrl = "";
var welcome = "", placeholder = "Type a message...", topics = [];
var palette = null;
var brandSlug = "default";
function fetchBrand() {
var url = wsBase + "/api/public/brand" + (siteAttr ? "?site=" + encodeURIComponent(siteAttr) : "");
var p = fetch(url, { cache: "no-store" }).then(function (r) {
return r.ok ? r.json() : null;
}).catch(function () {
return null; // fetch 失败 → data-*/中性兜底,挂件照常渲染
});
// 挂起保护: brand 端点 3s 无响应按失败处理,网络问题不挡挂件挂载
return Promise.race([p, new Promise(function (res) { setTimeout(function () { res(null); }, 3000); })]);
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"']/g, function (ch) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[ch];
});
}
function contactHref(value, kind) {
// brand 存裸值(电话/邮箱),data-* 历史上传完整 URL——两者都接受
if (!value) return "";
if (/^(https?:|mailto:)/i.test(value)) return value;
return kind === "wa" ? "https://wa.me/" + value : "mailto:" + value;
}
// boot: brand 落定(成功或失败)后推导有效值再挂载。只改"默认值从哪来",
// 不触碰上方主题派生单元(调色板算法零改动,只换输入)。
function boot() {
var b = (__brand && typeof __brand === "object") ? __brand : {};
title = titleAttr || b.title || "Chat with us";
whatsapp = contactHref(whatsappAttr || b.whatsapp || "", "wa");
email = contactHref(emailAttr || b.email || "", "mail");
palette = derivePalette(colorAttr || (b.color || "").trim() || DEFAULT_COLOR);
avatarUrl = avatarAttr
? (/^https?:\/\//i.test(avatarAttr) ? avatarAttr : wsBase + "/" + avatarAttr.replace(/^\//, ""))
: (b.avatar ? wsBase + (b.avatar.charAt(0) === "/" ? b.avatar : "/" + b.avatar)
: wsBase + DEFAULT_AVATAR_PATH);
welcome = b.welcome || "";
topics = Array.isArray(b.topics)
? b.topics.filter(function (t) { return typeof t === "string" && t; })
: [];
placeholder = b.placeholder || "Type a message...";
privacyUrl = b.privacy_url || "";
brandSlug = String(b.slug || "default").replace(/[^a-zA-Z0-9_-]/g, "") || "default";
if (script.getAttribute("data-debug") === "true") {
window.__chatWidgetDebug = { derivePalette: derivePalette, palette: palette };
}
init();
}
var positionStyles = {
@@ -146,7 +200,7 @@
function init() {
var host = document.createElement("div");
host.id = "cc-widget-host";
host.id = "cc-widget-host-" + brandSlug;
document.body.appendChild(host);
var shadow = host.attachShadow({ mode: "open" });
@@ -300,32 +354,61 @@
var container = document.createElement("div");
container.className = "widget-container pos-" + position + (isMobile ? " mobile-host" : "");
// Task 10: 品牌/联系/隐私/话题分块——值缺位(data-* 与 brand 都没给)则整块
// 不渲染(隐私行隐藏、联系按钮隐藏、话题区隐藏),不再硬编码任何租户文案
var waBtnHtml = whatsapp
? '<a href="' + esc(whatsapp) + '" target="_blank" rel="noopener" class="call-btn whatsapp-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg><span>WhatsApp</span></a>'
: "";
var emBtnHtml = email
? '<a href="' + esc(email) + '" class="call-btn email-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>Email</span></a>'
: "";
var privacyLineHtml = privacyUrl
? 'By chatting, you agree to our <a href="' + esc(privacyUrl) + '" target="_blank" rel="noopener">privacy policy</a>.'
: "";
var chatFooterHtml = privacyLineHtml
? '<div class="chat-footer"><span class="footer-left">' + privacyLineHtml + '</span></div>'
: "";
var invitePrivacyHtml = privacyLineHtml
? '<div class="invite-privacy">' + privacyLineHtml + '</div>'
: "";
var topicsBtnsHtml = topics.map(function (t) {
return '<button class="popular-topic-btn" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
}).join("");
var inviteTopicsHtml = topics.map(function (t) {
return '<button class="invite-topic-btn" data-topic="' + esc(t) + '">' + esc(t) + '</button>';
}).join("");
var popularTopicsSectionHtml = topics.length
? '<div class="popular-topics-fixed" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid">' + topicsBtnsHtml + '</div></div><div class="chat-separator-topics"></div>'
: "";
var welcomeMsgHtml = welcome
? '<div class="msg-wrapper"><div class="msg ai">' + esc(welcome) + '</div></div>'
: "";
var panel = document.createElement("div");
panel.className = "chat-panel";
panel.innerHTML = [
'<div class="panel-header">',
'<div class="avatar"><span class="avatar-emoji">🤖</span><img class="avatar-img" alt="" src="'+avatarUrl+'"></div>',
'<div class="info"><h3>' + title + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
'<div class="info"><h3>' + esc(title) + '</h3><div class="status">Online now · Usually replies in minutes</div></div>',
'<div class="actions">',
'<button class="action-btn" aria-label="Minimize">−</button>',
// '<button class="close-btn action-btn" aria-label="Close">×</button>',
'</div></div>',
'<div class="ended" id="w-ended">Chat ended. We\'ll be in touch!</div>',
'<div class="panel-top">',
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions"><a href="' + whatsapp + '" target="_blank" class="call-btn whatsapp-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg><span>WhatsApp</span></a><a href="' + email + '" class="call-btn email-btn"><svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg><span>Email</span></a></div></div></div>',
'<div class="help-block"><div style="display:flex;align-items:center;gap:14px;"><div class="help-content"><div class="help-title">How can we help?</div><div class="help-description">Ask about pipe specifications, quotations, MOQ, shipping and delivery.</div></div><div class="help-actions">' + waBtnHtml + emBtnHtml + '</div></div></div>',
'</div>',
'<div class="chat-separator"></div>',
'<div class="panel-messages" id="w-msgs">',
'<div class="msg-wrapper"><div class="msg ai">What kind of piping project are you working on?</div></div>',
welcomeMsgHtml,
'</div>',
'<div class="typing" id="w-typing"><span></span><span></span><span></span></div>',
'<div class="popular-topics-fixed" id="w-popular-topics"><div class="popular-topics-label">POPULAR TOPICS</div><div class="popular-topics-grid"><button class="popular-topic-btn" data-topic="Get Quotation">Get Quotation</button><button class="popular-topic-btn" data-topic="Pipe Specifications">Pipe Specifications</button><button class="popular-topic-btn" data-topic="Delivery &amp; Shipping">Delivery &amp; Shipping</button></div></div>',
'<div class="chat-separator-topics"></div>',
popularTopicsSectionHtml,
'<div class="input-container"><div class="input-wrapper">',
'<input type="text" id="w-input" placeholder="Type a message...">',
'<input type="text" id="w-input" placeholder="' + esc(placeholder) + '">',
'<button id="w-send"><span style="font-size:16px;">➤</span><span>Send</span></button>',
'</div></div>',
'<div class="chat-footer"><span class="footer-left">By chatting, you agree to our <a href="https://hesupipe.com/privacy-policy/" target="_blank" rel="noopener">privacy policy</a>.</span></div>',
chatFooterHtml,
].join("");
// Invite card HTML template
@@ -342,23 +425,23 @@
'<button class="invite-close-btn" aria-label="Close">',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>',
'</button>',
'<a href="' + whatsapp + '" target="_blank" class="invite-action-btn whatsapp-btn">',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>',
'<span>WhatsApp</span>',
'</a>',
'<a href="' + email + '" class="invite-action-btn email-btn">',
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>',
'<span>Email</span>',
'</a>',
'</div>',
'</div>',
'<div class="invite-body">',
'<div class="invite-topics">',
'<button class="invite-topic-btn" data-topic="Get Quotation">Get Quotation</button>',
'<button class="invite-topic-btn" data-topic="Pipe Specifications">Pipe Specifications</button>',
'<button class="invite-topic-btn" data-topic="Delivery &amp; Shipping">Delivery &amp; Shipping</button>',
(whatsapp
? '<a href="' + esc(whatsapp) + '" target="_blank" rel="noopener" class="invite-action-btn whatsapp-btn">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z"></path></svg>'
+ '<span>WhatsApp</span></a>'
: ""),
(email
? '<a href="' + esc(email) + '" class="invite-action-btn email-btn">'
+ '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z"></path><polyline points="22,6 12,13 2,6"></polyline></svg>'
+ '<span>Email</span></a>'
: ""),
'</div>',
'</div>',
(topics.length
? '<div class="invite-body">'
+ '<div class="invite-topics">' + inviteTopicsHtml + '</div>'
+ '</div>'
: ""),
'<div class="chat-separator"></div>',
'<div class="input-container">',
'<div class="invite-actions">',
@@ -367,7 +450,7 @@
'</button>',
'<button class="invite-dismiss-btn">No Thanks</button>',
'</div>',
'<div class="invite-privacy">By chatting, you agree to our <a href="https://hesupipe.com/privacy-policy/" target="_blank" rel="noopener">privacy policy</a>.</div>',
invitePrivacyHtml,
'</div>',
'</div>'
].join("");
@@ -429,7 +512,8 @@
var inviteTimer = null;
var invitePanel = null;
var inviteShown = false;
var inviteStorageKey = "widget_invite_dismissed_" + window.location.pathname;
// Task 10: key 租户化——同浏览器多租户挂件互不踩(不同租户 slug 隔离)
var inviteStorageKey = "widget_invite_dismissed_" + brandSlug + "_" + window.location.pathname;
var isPermanentlyClosed = false;
function checkInviteStorage() {
@@ -597,10 +681,12 @@
wWs = new WebSocket(wsHost + "/ws/chat");
wWs.onopen = function () {
// Task 10: init 带 site(空域名站点如 es 靠 data-site 接入,服务端
// 记入 client_info["site"] 作会话站点标;空值服务端忽略)
if (wSessionId) {
wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken }));
wWs.send(JSON.stringify({ session_id: wSessionId, page_token: pageToken, site: siteAttr }));
} else {
wWs.send(JSON.stringify({ source: "widget", page_token: pageToken }));
wWs.send(JSON.stringify({ source: "widget", page_token: pageToken, site: siteAttr }));
}
// Send any pending message after WebSocket opens
if (pendingMessage) {
@@ -696,10 +782,14 @@
startInviteTimer();
}
// Wait for body if script is in <head>
if (document.body) {
init();
} else {
document.addEventListener("DOMContentLoaded", init);
// Wait for body if script is in <head>; Task 10: 同时等 brand 落定(成功或
// 失败都继续)再挂载——两条件齐备才 init,任一失败路径都不白屏
function domReady(fn) {
if (document.body) fn();
else document.addEventListener("DOMContentLoaded", fn);
}
fetchBrand().then(function (b) {
__brand = b;
domReady(boot);
});
})();