fix(support-chat): support legacy mobile browsers

This commit is contained in:
t
2026-08-30 12:29:22 +08:00
parent 12c7060dbe
commit 46144ef63a
8 changed files with 302 additions and 101 deletions
+103 -77
View File
@@ -89,13 +89,13 @@
const userId = (params.get("user_id") || "").trim();
if (!external_id && userId) {
external_id = `mall_user:${userId}`;
external_name ||= `商城用户 ${userId}`;
if (!external_name) external_name = `商城用户 ${userId}`;
}
if (!external_id) {
const storageKey = "agent_desk_guest_id";
external_id = localStorage.getItem(storageKey) || `guest_${randomId()}`;
localStorage.setItem(storageKey, external_id);
external_name ||= `访客${external_id.slice(-8)}`;
if (!external_name) external_name = `访客${external_id.slice(-8)}`;
}
return {
external_id,
@@ -124,7 +124,7 @@
}
try {
const stored = JSON.parse(readSessionStorage(accessTargetStorageKey()) || "null");
if ((stored?.type === "card" || stored?.type === "device") && validAccessNumber(stored?.number)) {
if (stored && (stored.type === "card" || stored.type === "device") && validAccessNumber(stored.number)) {
return { type: stored.type, number: String(stored.number).trim() };
}
} catch (_) {
@@ -176,10 +176,19 @@
}
function randomId() {
if (window.crypto?.randomUUID) return window.crypto.randomUUID().replaceAll("-", "");
if (window.crypto && window.crypto.randomUUID) return window.crypto.randomUUID().replace(/-/g, "");
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
}
function lastItem(items) {
return Array.isArray(items) && items.length ? items[items.length - 1] : undefined;
}
function replaceChildrenCompat(element, children) {
while (element.firstChild) element.removeChild(element.firstChild);
(children || []).forEach((child) => element.appendChild(child));
}
function clientMessageId(prefix = "support_chat") {
return `${prefix}_${Date.now()}_${randomId().slice(0, 8)}`;
}
@@ -199,7 +208,11 @@
}
let response;
try {
response = await fetch(`${apiBase}${path}`, { ...options, headers, cache: "no-store", credentials: "include" });
response = await fetch(`${apiBase}${path}`, Object.assign({}, options, {
headers,
cache: "no-store",
credentials: "include",
}));
} catch (_) {
throw new Error("网络连接失败,请检查网络后重试");
}
@@ -209,8 +222,8 @@
} catch (_) {
throw new Error("客服服务暂时不可用,请稍后重试");
}
if (!response.ok || payload?.ok === false || payload?.success === false) {
const value = payload?.msg || payload?.message || payload?.code;
if (!response.ok || (payload && payload.ok === false) || (payload && payload.success === false)) {
const value = payload && (payload.msg || payload.message || payload.code);
if (accessTarget && isCustomerSessionError(value, response.status)) {
clearChatBinding();
window.location.reload();
@@ -228,8 +241,8 @@
function targetQuery(target = accessTarget) {
const query = new URLSearchParams();
if (target?.type === "card") query.set("card_no", target.number);
if (target?.type === "device") query.set("device_no", target.number);
if (target && target.type === "card") query.set("card_no", target.number);
if (target && target.type === "device") query.set("device_no", target.number);
return query;
}
@@ -243,12 +256,11 @@
async function h5AccessRequest(path, options = {}) {
let response;
try {
response = await fetch(h5AccessURL(path), {
...options,
response = await fetch(h5AccessURL(path), Object.assign({}, options, {
cache: "no-store",
credentials: "include",
headers: { Accept: "application/json", ...(options.headers || {}) },
});
headers: Object.assign({ Accept: "application/json" }, options.headers || {}),
}));
} catch (_) {
throw new Error("网络连接失败,请检查网络后重试");
}
@@ -258,8 +270,8 @@
} catch (_) {
throw new Error("客服入口暂时不可用,请稍后重试");
}
if (!response.ok || payload?.ok === false || payload?.success === false) {
throw new Error(chineseError(payload?.msg || payload?.message || payload?.code));
if (!response.ok || (payload && payload.ok === false) || (payload && payload.success === false)) {
throw new Error(chineseError(payload && (payload.msg || payload.message || payload.code)));
}
return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload;
}
@@ -272,8 +284,8 @@
body: "{}",
headers: { "Content-Type": "application/json" },
});
const ticket = String(entry?.ticket || "").trim();
const binding = String(entry?.session_binding || "").trim();
const ticket = String((entry && entry.ticket) || "").trim();
const binding = String((entry && entry.session_binding) || "").trim();
if (!ticket || !binding) throw new Error("客服入口无效,请重试");
state.chatBinding = binding;
writeSessionStorage(chatBindingStorageKey(accessTarget), binding);
@@ -366,11 +378,11 @@
}
async function loadMessages(initial = false) {
if (!state.conversation?.id) return;
if (!state.conversation || !state.conversation.id) return;
const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50`);
const incoming = Array.isArray(result?.results) ? result.results : [];
state.cursor = result?.cursor || "";
state.hasMore = Boolean(result?.has_more) || incoming.length >= 50;
const incoming = result && Array.isArray(result.results) ? result.results : [];
state.cursor = (result && result.cursor) || "";
state.hasMore = Boolean(result && result.has_more) || incoming.length >= 50;
state.messages = mergeMessages(state.messages, incoming);
if (initial) state.initialScrollSettling = true;
renderMessages();
@@ -382,16 +394,16 @@
}
async function loadOlder() {
if (!state.hasMore || state.loadingOlder || !state.cursor || !state.conversation?.id) return;
if (!state.hasMore || state.loadingOlder || !state.cursor || !state.conversation || !state.conversation.id) return;
state.loadingOlder = true;
dom.loadMore.disabled = true;
dom.loadMore.textContent = "正在加载…";
const oldHeight = dom.scroller.scrollHeight;
try {
const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=50&cursor=${encodeURIComponent(state.cursor)}`);
state.cursor = result?.cursor || "";
state.hasMore = Boolean(result?.has_more);
state.messages = mergeMessages(result?.results || [], state.messages);
state.cursor = (result && result.cursor) || "";
state.hasMore = Boolean(result && result.has_more);
state.messages = mergeMessages((result && result.results) || [], state.messages);
renderMessages();
requestAnimationFrame(() => {
dom.scroller.scrollTop = dom.scroller.scrollHeight - oldHeight;
@@ -406,12 +418,12 @@
}
}
function mergeMessages(...groups) {
function mergeMessages(first, second) {
const map = new Map();
groups.flat().forEach((item) => {
if (item?.id != null) map.set(String(item.id), item);
});
return [...map.values()].sort((a, b) => Number(a.id) - Number(b.id));
[first, second].forEach((group) => (group || []).forEach((item) => {
if (item && item.id != null) map.set(String(item.id), item);
}));
return Array.from(map.values()).sort((a, b) => Number(a.id) - Number(b.id));
}
function renderMessages() {
@@ -428,7 +440,7 @@
}
fragment.append(createMessageElement(message));
});
dom.list.replaceChildren(fragment);
replaceChildrenCompat(dom.list, [fragment]);
dom.empty.hidden = state.messages.length > 0;
}
@@ -466,15 +478,20 @@
function avatar(message) {
const node = document.createElement("span");
node.className = "message-avatar";
const name = senderName(message);
const fallback = (name || "客服").slice(0, 1).toUpperCase();
if (message.sender_avatar) {
const image = document.createElement("img");
image.src = message.sender_avatar;
image.alt = "";
image.addEventListener("error", () => {
image.remove();
node.textContent = fallback;
}, { once: true });
node.append(image);
return node;
}
const name = senderName(message);
node.textContent = (name || "客服").slice(0, 1).toUpperCase();
node.textContent = fallback;
return node;
}
@@ -500,8 +517,8 @@
gallery.className = `message-image-grid count-${Math.min(assets.length, 9)}`;
assets.slice(0, 9).forEach((asset) => {
const image = document.createElement("img");
image.src = asset?.url || "";
image.alt = asset?.filename || "聊天图片";
image.src = (asset && asset.url) || "";
image.alt = (asset && asset.filename) || "聊天图片";
image.loading = "eager";
image.addEventListener("load", () => {
if (state.initialScrollSettling) scrollToBottom(false);
@@ -557,23 +574,28 @@
"EMBED", "FORM", "IFRAME", "INPUT", "LINK", "META", "OBJECT",
"SCRIPT", "STYLE", "TEMPLATE",
]);
[...root.querySelectorAll("*")].forEach((node) => {
Array.from(root.querySelectorAll("*")).forEach((node) => {
if (blockedTags.has(node.tagName)) {
node.remove();
if (node.parentNode) node.parentNode.removeChild(node);
return;
}
if (!allowedTags.has(node.tagName)) {
node.replaceWith(...node.childNodes);
const parent = node.parentNode;
if (parent) {
while (node.firstChild) parent.insertBefore(node.firstChild, node);
parent.removeChild(node);
}
return;
}
[...node.attributes].forEach((attr) => {
Array.from(node.attributes).forEach((attr) => {
if (node.tagName !== "A" || !["href", "title"].includes(attr.name.toLowerCase())) {
node.removeAttribute(attr.name);
}
});
if (node.tagName === "A") {
const href = String(node.getAttribute("href") || "").trim();
const scheme = href.match(/^([a-z][a-z0-9+.-]*):/i)?.[1]?.toLowerCase();
const schemeMatch = href.match(/^([a-z][a-z0-9+.-]*):/i);
const scheme = schemeMatch && schemeMatch[1] ? schemeMatch[1].toLowerCase() : "";
if (scheme && !["http", "https", "mailto", "tel"].includes(scheme)) {
node.removeAttribute("href");
}
@@ -584,7 +606,7 @@
}
});
const fragment = document.createDocumentFragment();
[...root.childNodes].forEach((node) => fragment.append(document.importNode(node, true)));
Array.from(root.childNodes).forEach((node) => fragment.appendChild(document.importNode(node, true)));
return fragment;
}
@@ -667,17 +689,19 @@
}
const remaining = kind === "image" ? Math.max(0, 9 - state.pendingUploads.length) : 1;
if (accepted.length > remaining) toast("每次最多发送 9 张图片", true);
state.pendingUploads.push(...accepted.slice(0, remaining).map((file) => ({
file,
kind,
previewUrl: kind === "image" ? URL.createObjectURL(file) : "",
})));
accepted.slice(0, remaining).forEach((file) => {
state.pendingUploads.push({
file,
kind,
previewUrl: kind === "image" ? URL.createObjectURL(file) : "",
});
});
renderPendingUploads();
updateAvailability();
}
function renderPendingUploads() {
dom.pendingUploads.replaceChildren();
replaceChildrenCompat(dom.pendingUploads, []);
dom.pendingUploads.hidden = state.pendingUploads.length === 0;
state.pendingUploads.forEach((pending) => {
const image = pending.kind === "image";
@@ -740,7 +764,7 @@
if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl);
});
state.pendingUploads = [];
dom.pendingUploads.replaceChildren();
replaceChildrenCompat(dom.pendingUploads, []);
dom.pendingUploads.hidden = true;
dom.imageInput.value = "";
dom.fileInput.value = "";
@@ -779,7 +803,7 @@
async function sendComposer() {
const content = dom.input.value.trim();
const pending = [...state.pendingUploads];
const pending = state.pendingUploads.slice();
if ((!content && !pending.length) || state.sending || !canSend()) return;
state.sending = true;
@@ -823,7 +847,7 @@
}
function renderQuickActions() {
dom.quickList.replaceChildren(...state.quickActions.map((action) => {
replaceChildrenCompat(dom.quickList, state.quickActions.map((action) => {
const button = document.createElement("button");
button.type = "button";
button.className = "quick-item";
@@ -850,7 +874,7 @@
client_msg_id: clientMessageId("support_chat_quick"),
}),
});
state.messages = mergeMessages(state.messages, [result?.customer_message, result?.reply_message].filter(Boolean));
state.messages = mergeMessages(state.messages, [result && result.customer_message, result && result.reply_message].filter(Boolean));
renderMessages();
scrollToBottom();
} catch (error) {
@@ -862,8 +886,8 @@
}
async function markLatestRead() {
const latest = state.messages.at(-1);
if (!latest || !state.conversation?.id) return;
const latest = lastItem(state.messages);
if (!latest || !state.conversation || !state.conversation.id) return;
try {
await request("/message/read", {
method: "POST",
@@ -875,7 +899,7 @@
}
function canSend() {
return Boolean(state.conversation?.id) && Number(state.conversation.status) !== 4;
return Boolean(state.conversation && state.conversation.id) && Number(state.conversation.status) !== 4;
}
function updateAvailability() {
@@ -897,7 +921,7 @@
function updateQueueStatus() {
const conversation = state.conversation;
const queued = Number(conversation?.status) === 2 && Number(conversation?.current_assignee_id || 0) === 0;
const queued = Number(conversation && conversation.status) === 2 && Number((conversation && conversation.current_assignee_id) || 0) === 0;
dom.queueStatus.hidden = !queued;
if (!queued) return;
@@ -924,7 +948,7 @@
}
function currentQueueWaitSeconds() {
const baseline = Math.max(0, Number(state.conversation?.queue_wait_seconds || 0));
const baseline = Math.max(0, Number((state.conversation && state.conversation.queue_wait_seconds) || 0));
if (!state.queueSyncedAt) return baseline;
return baseline + Math.max(0, Math.floor((Date.now() - state.queueSyncedAt) / 1000));
}
@@ -939,7 +963,7 @@
}
function syncQueueTimers() {
const queued = Number(state.conversation?.status) === 2 && Number(state.conversation?.current_assignee_id || 0) === 0;
const queued = Number(state.conversation && state.conversation.status) === 2 && Number((state.conversation && state.conversation.current_assignee_id) || 0) === 0;
if (!queued) {
clearQueueTimers();
return;
@@ -960,10 +984,10 @@
}
async function refreshConversationQueue() {
if (!state.conversation?.id || Number(state.conversation.status) !== 2) return;
if (!state.conversation || !state.conversation.id || Number(state.conversation.status) !== 2) return;
try {
const latest = await request(`/conversation/${state.conversation.id}`);
state.conversation = { ...state.conversation, ...latest };
state.conversation = Object.assign({}, state.conversation, latest);
state.queueSyncedAt = Date.now();
updateAvailability();
} catch (_) {
@@ -982,7 +1006,8 @@
const base = apiBase.startsWith("http://") || apiBase.startsWith("https://")
? apiBase.replace(/^http/, "ws")
: `${window.location.protocol === "https:" ? "wss:" : "ws:"}//${window.location.host}${apiBase.startsWith("/") ? "" : "/"}${apiBase}`;
const query = new URLSearchParams({ channel_id: channelId });
const query = new URLSearchParams();
query.set("channel_id", channelId);
if (accessTarget) {
query.set("h5_chat_session", "required");
query.set("h5_chat_binding", state.chatBinding);
@@ -993,7 +1018,7 @@
}
function connectSocket() {
if (!state.conversation?.id || Number(state.conversation.status) === 4) return;
if (!state.conversation || !state.conversation.id || Number(state.conversation.status) === 4) return;
clearRealtimeTimers();
if (state.socket) {
const oldSocket = state.socket;
@@ -1043,33 +1068,35 @@
} catch (_) {
return;
}
const payload = event?.data;
if (event?.type === "resyncRequired") {
const payload = event && event.data;
if (event && event.type === "resyncRequired") {
loadMessages(false).catch(() => {});
return;
}
if (!payload || Number(payload.conversation_id) !== Number(state.conversation?.id)) return;
if (!payload || Number(payload.conversation_id) !== Number(state.conversation && state.conversation.id)) return;
if (event.type === "message.created") {
const message = normalizeRealtimeMessage(payload);
if (!message) {
loadMessages(false).catch(() => {});
return;
}
const previousLastId = state.messages.at(-1)?.id;
const previousLast = lastItem(state.messages);
const previousLastId = previousLast && previousLast.id;
state.messages = mergeMessages(state.messages, [message]);
state.conversation.last_message_id = message.id;
state.conversation.last_message_at = message.sent_at || state.conversation.last_message_at;
renderMessages();
if (state.messages.at(-1)?.id !== previousLastId) {
const currentLast = lastItem(state.messages);
if ((currentLast && currentLast.id) !== previousLastId) {
scrollToBottom();
markLatestRead();
}
return;
}
if (String(event.type || "").startsWith("conversation.")) {
const patch = { ...payload };
const patch = Object.assign({}, payload);
delete patch.conversation_id;
state.conversation = { ...state.conversation, ...patch };
state.conversation = Object.assign({}, state.conversation, patch);
state.queueSyncedAt = Date.now();
applyReadState(payload);
renderMessages();
@@ -1082,7 +1109,7 @@
}
function normalizeRealtimeMessage(payload) {
if (payload.message?.id) return payload.message;
if (payload.message && payload.message.id) return payload.message;
const id = Number(payload.message_id || 0);
const conversationId = Number(payload.conversation_id || 0);
if (!id || !conversationId) return null;
@@ -1106,15 +1133,14 @@
function applyReadState(payload) {
const agentReadId = Number(payload.agent_last_read_message_id || 0);
const customerReadId = Number(payload.customer_last_read_message_id || 0);
state.messages = state.messages.map((message) => ({
...message,
state.messages = state.messages.map((message) => Object.assign({}, message, {
agent_read: message.agent_read || (agentReadId > 0 && Number(message.id) <= agentReadId),
customer_read: message.customer_read || (customerReadId > 0 && Number(message.id) <= customerReadId),
}));
}
function scheduleReconnect() {
if (!state.allowReconnect || state.reconnectTimer || !state.conversation?.id || Number(state.conversation.status) === 4) return;
if (!state.allowReconnect || state.reconnectTimer || !state.conversation || !state.conversation.id || Number(state.conversation.status) === 4) return;
const delay = Math.min(2000 * 2 ** state.reconnectAttempt, 30000);
state.reconnectTimer = window.setTimeout(() => {
state.reconnectTimer = null;
@@ -1144,7 +1170,7 @@
}
async function retry() {
if (!state.initialized || !state.conversation?.id) {
if (!state.initialized || !state.conversation || !state.conversation.id) {
dom.loading.hidden = false;
dom.empty.hidden = true;
await init();
@@ -1161,7 +1187,7 @@
async function closeConversation() {
if (state.closing) return;
if (!state.conversation?.id) {
if (!state.conversation || !state.conversation.id) {
closeCloseDialog();
closePage();
return;
@@ -1175,7 +1201,7 @@
method: "POST",
body: JSON.stringify({ conversation_id: state.conversation.id }),
});
state.conversation = { ...state.conversation, status: 4 };
state.conversation = Object.assign({}, state.conversation, { status: 4 });
disconnectSocket(false);
clearCustomerAccessState();
clearPendingUploads();
@@ -1229,7 +1255,7 @@
const height = dom.scroller.scrollHeight;
stableTicks = height === lastHeight ? stableTicks + 1 : 0;
lastHeight = height;
const imagesReady = [...dom.list.querySelectorAll("img")].every((image) => image.complete);
const imagesReady = Array.from(dom.list.querySelectorAll("img")).every((image) => image.complete);
if ((imagesReady && stableTicks >= 4) || Date.now() - startedAt >= 6000) {
dom.scroller.scrollTop = dom.scroller.scrollHeight;
state.initialScrollSettling = false;
@@ -1323,7 +1349,7 @@
}
});
window.addEventListener("message", (event) => {
const type = event.data?.type;
const type = event.data && event.data.type;
if (type === "agent-desk:open" && state.initialized && !state.socket) connectSocket();
if (type === "agent-desk:minimize") disconnectSocket(false);
});