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
+103 -25
View File
@@ -1,20 +1,25 @@
// ---- 动态主题色 bootstrap(spec §3):?color= → 派生 → :root 变量 ----
// ---- Task 10: brand 拉取(同源 /api/public/brand,下方两个 IIFE 共用) ----
// 失败/超时(3s) → resolve(null) → 中性兜底,页面照常可用不白屏。
var chatBrandReady = (function () {
try {
var p = fetch("/api/public/brand", { cache: "no-store" }).then(function (r) {
return r.ok ? r.json() : null;
}).catch(function () {
return null;
});
return Promise.race([p, new Promise(function (res) { setTimeout(function () { res(null); }, 3000); })]);
} catch (e) {
return Promise.resolve(null); // 无 fetch 的远古环境:中性兜底
}
})();
// ---- 动态主题色 bootstrap(spec §3):派生 → :root 变量 ----
// Task 10 颜色优先级: ?color=(URL 显式覆盖,语义同挂件 data-*) >
// brand.color(租户/站点门面) > DEFAULT_COLOR。
(function () {
if (typeof ChatTheme === "undefined") return; // theme.js 加载失败→style.css :root 品牌蓝兜底
var m = /(?:^|[?&])color=([^&]*)/.exec(location.search);
function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return ""; } }
var color = m ? safeDecode(m[1]).trim() : "";
var palette = ChatTheme.derivePalette(color || ChatTheme.DEFAULT_COLOR);
var root = document.documentElement.style;
root.setProperty("--w-primary", palette.primary);
root.setProperty("--w-primary-rgb", palette.primaryRgb);
root.setProperty("--w-gradient-end", palette.gradientEnd);
root.setProperty("--w-deep-text", palette.deepText);
root.setProperty("--w-caption-text", palette.captionText);
root.setProperty("--w-soft-bg", palette.softBg);
root.setProperty("--w-border-soft", palette.borderSoft);
// 头像兜底(与挂件同机制)
// 头像兜底(与挂件同机制)——与 brand 无关,立即挂
Array.prototype.forEach.call(document.querySelectorAll(".avatar-img"), function (img) {
function hideEmoji() {
var s = img.parentElement && img.parentElement.querySelector(".avatar-emoji");
@@ -24,18 +29,31 @@
img.addEventListener("load", hideEmoji);
img.addEventListener("error", function () { img.remove(); });
});
chatBrandReady.then(function (b) {
var m = /(?:^|[?&])color=([^&]*)/.exec(location.search);
function safeDecode(s) { try { return decodeURIComponent(s); } catch (e) { return ""; } }
var color = m ? safeDecode(m[1]).trim() : (((b && b.color) || "").trim());
var palette = ChatTheme.derivePalette(color || ChatTheme.DEFAULT_COLOR);
var root = document.documentElement.style;
root.setProperty("--w-primary", palette.primary);
root.setProperty("--w-primary-rgb", palette.primaryRgb);
root.setProperty("--w-gradient-end", palette.gradientEnd);
root.setProperty("--w-deep-text", palette.deepText);
root.setProperty("--w-caption-text", palette.captionText);
root.setProperty("--w-soft-bg", palette.softBg);
root.setProperty("--w-border-soft", palette.borderSoft);
});
})();
(function () {
// 联系方式配置(与 widget.js 的 data-* 机制一致)
// 联系方式配置(与 widget.js 的 data-* 机制一致:属性显式覆盖 > brand)
var chatScript = document.currentScript || document.querySelector('script[src*="chat.js"]');
var whatsapp = (chatScript && chatScript.getAttribute("data-whatsapp")) || "https://wa.me/+8615937108317";
var email = (chatScript && chatScript.getAttribute("data-email")) || "mailto:hesu@hesuu.com";
var whatsappAttr = chatScript && chatScript.getAttribute("data-whatsapp");
var emailAttr = chatScript && chatScript.getAttribute("data-email");
var waBtn = document.querySelector('.whatsapp-btn');
var emBtn = document.querySelector('.email-btn');
if (waBtn) waBtn.href = whatsapp;
if (emBtn) emBtn.href = email;
var sessionId = localStorage.getItem("cc_session_id");
var sessionId = null;
var messagesEl = document.getElementById("messages");
var inputEl = document.getElementById("input");
var sendBtn = document.getElementById("sendBtn");
@@ -49,13 +67,63 @@
var streamBubble = null;
var statusTimer = null;
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;
}
function applyBrand(b) {
b = (b && typeof b === "object") ? b : {};
if (b.title) {
document.title = b.title;
var h1 = document.querySelector(".chat-header .info h1");
if (h1) h1.textContent = b.title;
}
var wa = contactHref(whatsappAttr || b.whatsapp || "", "wa");
if (waBtn) { if (wa) waBtn.href = wa; else waBtn.style.display = "none"; }
var em = contactHref(emailAttr || b.email || "", "mail");
if (emBtn) { if (em) emBtn.href = em; else emBtn.style.display = "none"; }
if (b.placeholder) inputEl.placeholder = b.placeholder;
if (b.welcome) {
var bubble = document.querySelector("#messages .message .bubble");
if (bubble) bubble.textContent = b.welcome;
}
if (Array.isArray(b.topics) && b.topics.length) {
var grid = document.querySelector(".popular-topics-grid");
if (grid) {
grid.innerHTML = "";
b.topics.forEach(function (t) {
if (typeof t !== "string" || !t) return;
var btn = document.createElement("button");
btn.className = "popular-topic-btn";
btn.setAttribute("data-topic", t);
btn.textContent = t;
btn.addEventListener("click", function () { sendTopicMessage(t); });
grid.appendChild(btn);
});
}
} else if (popularTopicsEl) {
popularTopicsEl.style.display = "none"; // 无话题配置:整区隐藏
var sep = document.querySelector(".chat-separator-topics");
if (sep) sep.style.display = "none"; // 分隔线随区隐藏,不留孤线
}
var footer = document.querySelector(".chat-footer");
if (footer) {
var link = footer.querySelector("a");
if (b.privacy_url && link) link.href = b.privacy_url;
else footer.style.display = "none"; // GDPR 行:为空即隐藏
}
}
function getWsUrl() {
var loc = window.location;
var proto = loc.protocol === "https:" ? "wss:" : "ws:";
return proto + "//" + loc.host + "/ws/chat";
}
function connect() {
function connect(sessionKey) {
ws = new WebSocket(getWsUrl());
ws.onopen = function () {
@@ -71,7 +139,7 @@
if (data.type === "init" || data.type === "resumed") {
sessionId = data.session_id;
localStorage.setItem("cc_session_id", sessionId);
localStorage.setItem(sessionKey, sessionId);
if (data.type === "resumed") {
loadHistory();
}
@@ -113,7 +181,7 @@
ws.onclose = function () {
if (!ended) {
setTimeout(connect, 3000);
setTimeout(function () { connect(sessionKey); }, 3000);
}
};
@@ -218,7 +286,8 @@
}
});
// Popular topics buttons
// Popular topics buttons(出厂模板的兜底话题;brand 下发后由 applyBrand
// 重建,这里的绑定覆盖"brand 失败"路径)
var topicBtns = document.querySelectorAll(".popular-topic-btn");
topicBtns.forEach(function (btn) {
btn.addEventListener("click", function () {
@@ -227,5 +296,14 @@
});
});
connect();
// Task 10: 会话 key 租户化(cc_session_id_<slug>)——brand 落定(成功或
// 失败,slug 回退 "default")后才读 key/连 WS,保证同浏览器多租户互不串
chatBrandReady.then(function (b) {
applyBrand(b);
var slug = String((b && b.slug) || "default").replace(/[^a-zA-Z0-9_-]/g, "") || "default";
var sessionKey = "cc_session_id_" + slug;
sessionId = null;
try { sessionId = localStorage.getItem(sessionKey); } catch (e) { /* storage 不可用 */ }
connect(sessionKey);
});
})();