(() => { "use strict"; const params = new URLSearchParams(window.location.search); const byId = (id) => document.getElementById(id); const dom = { app: byId("support-app"), title: byId("channel-title"), subtitle: byId("channel-subtitle"), statusDot: byId("header-status-dot"), connectionBadge: byId("connection-badge"), connectionLabel: byId("connection-label"), retry: byId("retry-button"), close: byId("close-button"), scroller: byId("message-scroller"), loadMore: byId("load-more"), loading: byId("loading-state"), empty: byId("empty-state"), list: byId("message-list"), status: byId("status-bar"), queueStatus: byId("queue-status"), queueTitle: byId("queue-title"), queuePosition: byId("queue-position"), queueDetail: byId("queue-detail"), queueWait: byId("queue-wait"), queueEta: byId("queue-eta"), quickSection: byId("quick-section"), quickButton: byId("quick-button"), input: byId("message-input"), imageInput: byId("image-input"), fileInput: byId("file-input"), imageButton: byId("image-button"), fileButton: byId("file-button"), pendingUploads: byId("pending-uploads"), send: byId("send-button"), quickOverlay: byId("quick-overlay"), quickClose: byId("quick-close"), quickList: byId("quick-list"), quickEmpty: byId("quick-empty"), closeOverlay: byId("close-overlay"), closeDialogX: byId("close-dialog-x"), continueButton: byId("continue-button"), confirmClose: byId("confirm-close-button"), toasts: byId("toast-region"), }; const channelId = (params.get("channel_id") || "").trim(); const apiBase = getApiBase(); const accessTarget = getAccessTarget(); const identity = getIdentity(); const state = { conversation: null, messages: [], cursor: "", hasMore: false, loadingOlder: false, sending: false, closing: false, initializing: false, initialized: false, quickActions: [], socket: null, reconnectTimer: null, pingTimer: null, reconnectAttempt: 0, allowReconnect: true, pendingUploads: [], queueRefreshTimer: null, queueTickTimer: null, queueSyncedAt: 0, initialScrollSettling: false, chatBinding: accessTarget ? loadChatBinding(accessTarget) : "", }; class CustomerSessionRefreshError extends Error {} function getApiBase() { const explicit = (params.get("api_base_url") || "").trim(); if (explicit) return explicit.replace(/\/$/, ""); const marker = "/support/chat"; const index = window.location.pathname.indexOf(marker); const prefix = index >= 0 ? window.location.pathname.slice(0, index) : ""; return `${prefix}/api`; } function getIdentity() { let external_id = (params.get("external_id") || "").trim(); let external_name = (params.get("external_name") || "").trim(); const userId = (params.get("user_id") || "").trim(); if (!external_id && userId) { external_id = `mall_user:${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); if (!external_name) external_name = `访客${external_id.slice(-8)}`; } return { external_id, external_name: external_name || "访客", userId, }; } function getAccessTarget() { const fragment = new URLSearchParams(window.location.hash.replace(/^#/, "")); const fragmentType = (fragment.get("identity_type") || "").trim().toLowerCase(); const fragmentNumber = (fragment.get("identity") || "").trim(); const legacyCard = (params.get("card_no") || "").trim(); const legacyDevice = (params.get("device_no") || "").trim(); let target = null; if ((fragmentType === "card" || fragmentType === "device") && validAccessNumber(fragmentNumber)) { target = { type: fragmentType, number: fragmentNumber }; } else if (validAccessNumber(legacyCard)) { target = { type: "card", number: legacyCard }; } else if (validAccessNumber(legacyDevice)) { target = { type: "device", number: legacyDevice }; } if (target) { writeSessionStorage(accessTargetStorageKey(), JSON.stringify(target)); return target; } try { const stored = JSON.parse(readSessionStorage(accessTargetStorageKey()) || "null"); if (stored && (stored.type === "card" || stored.type === "device") && validAccessNumber(stored.number)) { return { type: stored.type, number: String(stored.number).trim() }; } } catch (_) { // Invalid per-tab state is ignored and cannot become an identity proof. } return null; } function validAccessNumber(value) { const text = String(value || "").trim(); return text.length > 0 && text.length <= 128 && !/[\u0000-\u001f\u007f]/.test(text); } function accessTargetStorageKey() { return `agent_desk_support_target:${window.location.pathname}:${channelId}`; } function chatBindingStorageKey(target) { return `agent_desk_support_binding:${window.location.pathname}:${channelId}:${target.type}:${target.number}`; } function loadChatBinding(target) { return String(readSessionStorage(chatBindingStorageKey(target)) || "").trim(); } function readSessionStorage(key) { try { return window.sessionStorage.getItem(key); } catch (_) { return null; } } function writeSessionStorage(key, value) { try { window.sessionStorage.setItem(key, value); } catch (_) { // Private browsing may disable session storage; the chat entry can be // established again on the next navigation. } } function removeSessionStorage(key) { try { window.sessionStorage.removeItem(key); } catch (_) { // Ignore inaccessible browser storage. } } function randomId() { 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)}`; } async function request(path, options = {}) { const headers = new Headers(options.headers || {}); headers.set("X-Channel-Id", channelId); headers.set("X-External-Id", identity.external_id); headers.set("X-External-Name", encodeURIComponent(identity.external_name)); headers.set("Accept", "application/json"); if (accessTarget) { headers.set("X-H5-Chat-Session", "required"); if (state.chatBinding) headers.set("X-H5-Chat-Binding", state.chatBinding); } if (options.body && !(options.body instanceof FormData)) { headers.set("Content-Type", "application/json"); } let response; try { response = await fetch(`${apiBase}${path}`, Object.assign({}, options, { headers, cache: "no-store", credentials: "include", })); } catch (_) { throw new Error("网络连接失败,请检查网络后重试"); } let payload; try { payload = await response.json(); } catch (_) { throw new Error("客服服务暂时不可用,请稍后重试"); } 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(); throw new CustomerSessionRefreshError("正在重新连接客服"); } throw new Error(chineseError(value)); } return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload; } function isCustomerSessionError(value, status) { const text = String(value || ""); return status === 401 || /unauthorized|身份验证|二次验证|重新验证|验证已失效/i.test(text); } function targetQuery(target = accessTarget) { const query = new URLSearchParams(); if (target && target.type === "card") query.set("card_no", target.number); if (target && target.type === "device") query.set("device_no", target.number); return query; } function h5AccessURL(path) { const url = new URL(`/h5api${path}`, window.location.origin); const query = targetQuery(); query.forEach((value, key) => url.searchParams.set(key, value)); return url; } async function h5AccessRequest(path, options = {}) { let response; try { response = await fetch(h5AccessURL(path), Object.assign({}, options, { cache: "no-store", credentials: "include", headers: Object.assign({ Accept: "application/json" }, options.headers || {}), })); } catch (_) { throw new Error("网络连接失败,请检查网络后重试"); } let payload; try { payload = await response.json(); } catch (_) { throw new Error("客服入口暂时不可用,请稍后重试"); } 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; } async function establishCustomerChatSession() { if (!accessTarget) return false; try { const entry = await h5AccessRequest("/access/chat-entry", { method: "POST", body: "{}", headers: { "Content-Type": "application/json" }, }); 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); const destination = new URL(window.location.href); destination.searchParams.delete("card_no"); destination.searchParams.delete("device_no"); destination.searchParams.delete("external_id"); destination.searchParams.delete("external_name"); destination.searchParams.set("access_ticket", ticket); destination.hash = ""; window.location.replace(destination.toString()); return true; } catch (error) { clearChatBinding(); fatal(error instanceof Error ? error.message : "客服入口建立失败,请重试"); return false; } } function clearChatBinding() { state.chatBinding = ""; if (accessTarget) removeSessionStorage(chatBindingStorageKey(accessTarget)); } function clearCustomerAccessState() { clearChatBinding(); removeSessionStorage(accessTargetStorageKey()); } function chineseError(value) { const text = String(value || ""); if (/forbidden|unauthorized|auth/i.test(text)) return "身份验证失败,请重新进入客服页面"; if (/channel/i.test(text)) return "客服渠道无效或已停用"; if (/conversation/i.test(text)) return "当前会话不可用,请刷新后重试"; if (/file|upload|asset/i.test(text)) return "文件上传失败,请稍后重试"; if (/timeout/i.test(text)) return "请求超时,请稍后重试"; if (/^[\u4e00-\u9fa5\s,。!?、:;()【】“”‘’_-]+$/.test(text) && text.length < 80) return text; return "操作未完成,请稍后重试或联系人工客服"; } async function init() { if (state.initializing) return; if (!channelId) { fatal("缺少客服渠道参数 channel_id,请从正确的客服入口进入"); return; } if (accessTarget && !state.chatBinding) { state.initializing = true; updateConnectionStatus("connecting"); await establishCustomerChatSession(); state.initializing = false; return; } state.initializing = true; updateConnectionStatus("connecting"); try { const config = await request(`/channel/config?channel_id=${encodeURIComponent(channelId)}`); applyConfig(config || {}); state.conversation = await request("/conversation/create_or_match", { method: "POST", body: "{}" }); state.queueSyncedAt = Date.now(); await Promise.all([loadMessages(true), loadQuickActions()]); dom.loading.hidden = true; dom.app.setAttribute("aria-busy", "false"); state.initialized = true; state.allowReconnect = true; updateAvailability(); connectSocket(); } catch (error) { if (error instanceof CustomerSessionRefreshError) { state.initialized = false; return; } fatal(error instanceof Error ? error.message : "客服服务暂时不可用,请稍后重试"); } finally { state.initializing = false; } } function applyConfig(config) { const title = config.title || "在线客服"; const subtitle = config.subtitle || "欢迎咨询"; dom.title.textContent = title; dom.subtitle.textContent = subtitle; document.title = title; const color = /^#[0-9a-f]{6}$/i.test(config.theme_color || "") ? config.theme_color : "#2475fc"; document.documentElement.style.setProperty("--theme", color); const rgb = color.match(/[0-9a-f]{2}/gi).map((item) => parseInt(item, 16)).join(", "); document.documentElement.style.setProperty("--theme-rgb", rgb); document.querySelector('meta[name="theme-color"]').setAttribute("content", color); } async function loadMessages(initial = false) { if (!state.conversation || !state.conversation.id) return; const result = await request(`/message/list?conversation_id=${state.conversation.id}&limit=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(); dom.loading.hidden = true; dom.empty.hidden = state.messages.length > 0; dom.loadMore.hidden = !state.hasMore; if (initial) settleInitialScroll(); await markLatestRead(); } async function loadOlder() { 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 && 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; }); } catch (error) { toast(error instanceof Error ? error.message : "加载更早的消息失败", true); } finally { state.loadingOlder = false; dom.loadMore.disabled = false; dom.loadMore.textContent = "加载更早的消息"; dom.loadMore.hidden = !state.hasMore; } } function mergeMessages(first, second) { const map = new Map(); [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() { const fragment = document.createDocumentFragment(); state.messages.forEach((message, index) => { const previous = index > 0 ? state.messages[index - 1] : null; if (!previous || dayKey(previous.sent_at) !== dayKey(message.sent_at)) { const timeline = document.createElement("div"); timeline.className = "timeline"; const label = document.createElement("span"); label.textContent = timelineLabel(message.sent_at); timeline.append(label); fragment.append(timeline); } fragment.append(createMessageElement(message)); }); replaceChildrenCompat(dom.list, [fragment]); dom.empty.hidden = state.messages.length > 0; } function createMessageElement(message) { const sender = String(message.sender_type || "customer"); const mine = sender === "customer"; const system = sender === "system"; const row = document.createElement("article"); row.className = `message-row ${sender}${mine ? " mine" : ""}${system ? " system" : ""}`; const copy = document.createElement("div"); copy.className = "message-copy"; const meta = document.createElement("div"); meta.className = "message-meta"; const name = document.createElement("span"); name.className = "message-sender"; name.textContent = senderName(message); const time = document.createElement("time"); time.textContent = formatDateTime(message.sent_at); meta.append(name, time); if (mine && !system) { const read = document.createElement("span"); read.textContent = message.agent_read ? "客服已读" : "客服未读"; meta.append(read); } const bubble = document.createElement("div"); bubble.className = "message-bubble"; renderMessageBody(bubble, message); copy.append(meta, bubble); if (system) row.append(copy); else if (mine) row.append(copy); else row.append(avatar(message), copy); return row; } 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; } node.textContent = fallback; return node; } function senderName(message) { if (message.sender_type === "customer") return "我"; if (message.sender_name) return message.sender_name; if (message.sender_type === "ai") return "AI 客服"; if (message.sender_type === "agent") return "客服"; if (message.sender_type === "system") return "系统消息"; return "客服"; } function renderMessageBody(container, message) { if (message.recalled_at || Number(message.send_status) === 6) { container.textContent = "该消息已撤回"; return; } const type = String(message.message_type || "text").toLowerCase(); const payload = parsePayload(message.payload); if (type === "image") { const assets = Array.isArray(payload.assets) && payload.assets.length ? payload.assets : [payload]; const gallery = document.createElement("div"); 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 && asset.url) || ""; image.alt = (asset && asset.filename) || "聊天图片"; image.loading = "eager"; image.addEventListener("load", () => { if (state.initialScrollSettling) scrollToBottom(false); }); image.addEventListener("error", () => { if (state.initialScrollSettling) scrollToBottom(false); }); image.addEventListener("click", () => window.open(image.src, "_blank", "noopener")); gallery.append(image); }); container.append(gallery); if (String(message.content || "").trim()) { const caption = document.createElement("div"); caption.className = "message-image-caption"; caption.append(sanitizeHtml(message.content || "")); container.append(caption); } return; } if (type === "attachment") { const link = document.createElement("a"); link.className = "attachment-card"; link.href = payload.url || message.content || "#"; link.target = "_blank"; link.rel = "noopener"; link.innerHTML = ''; const text = document.createElement("span"); text.className = "attachment-copy"; const strong = document.createElement("strong"); strong.textContent = payload.filename || message.content || "查看附件"; const small = document.createElement("small"); small.textContent = formatBytes(payload.file_size); text.append(strong, small); link.append(text); container.append(link); return; } if (type === "html") { container.append(sanitizeHtml(message.content || "")); return; } container.append(sanitizeHtml(toSafeHtml(message.content || ""))); } function sanitizeHtml(html) { const parsed = new DOMParser().parseFromString(`
${html}
`, "text/html"); const root = parsed.body.firstElementChild; const allowedTags = new Set([ "A", "B", "BLOCKQUOTE", "BR", "CODE", "EM", "I", "LI", "OL", "P", "PRE", "S", "SPAN", "STRONG", "U", "UL", ]); const blockedTags = new Set([ "EMBED", "FORM", "IFRAME", "INPUT", "LINK", "META", "OBJECT", "SCRIPT", "STYLE", "TEMPLATE", ]); Array.from(root.querySelectorAll("*")).forEach((node) => { if (blockedTags.has(node.tagName)) { if (node.parentNode) node.parentNode.removeChild(node); return; } if (!allowedTags.has(node.tagName)) { const parent = node.parentNode; if (parent) { while (node.firstChild) parent.insertBefore(node.firstChild, node); parent.removeChild(node); } return; } 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 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"); } if (node.hasAttribute("href")) { node.setAttribute("target", "_blank"); node.setAttribute("rel", "noopener noreferrer nofollow"); } } }); const fragment = document.createDocumentFragment(); Array.from(root.childNodes).forEach((node) => fragment.appendChild(document.importNode(node, true))); return fragment; } function parsePayload(value) { if (value && typeof value === "object") return value; try { return JSON.parse(value || "{}"); } catch (_) { return {}; } } function parseDate(value) { if (!value) return null; const date = new Date(String(value).replace(" ", "T")); return Number.isNaN(date.getTime()) ? null : date; } function dayKey(value) { const date = parseDate(value); if (!date) return String(value || "unknown").slice(0, 10); return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(date.getDate()).padStart(2, "0")}`; } function timelineLabel(value) { const date = parseDate(value); if (!date) return "刚刚"; const time = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`; if (dayKey(value) === dayKey(new Date().toISOString())) return `今天 ${time}`; return `${dayKey(value)} ${time}`; } function formatDateTime(value) { const date = parseDate(value); if (!date) return String(value || ""); return new Intl.DateTimeFormat("zh-CN", { year: "numeric", month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit", hour12: false, }).format(date); } function formatBytes(value) { const size = Number(value || 0); if (!size) return "附件"; if (size < 1024) return `${size} B`; if (size < 1048576) return `${(size / 1024).toFixed(1)} KB`; return `${(size / 1048576).toFixed(1)} MB`; } function toSafeHtml(text) { const div = document.createElement("div"); div.textContent = String(text || "").replace(/\r\n?/g, "\n"); const paragraphs = div.innerHTML .split(/\n{2,}/) .map((paragraph) => `

${paragraph.replace(/\n/g, "
")}

`); return paragraphs.join(""); } function stageFiles(files, kind) { if (!files.length || !canSend() || state.sending) return; const accepted = files.filter((file) => { if (file.size > 20 * 1024 * 1024) { toast(`${file.name || "文件"}不能超过 20MB`, true); return false; } if (kind === "image" && !String(file.type || "").startsWith("image/")) { toast(`${file.name || "所选文件"}不是图片`, true); return false; } return true; }); if (!accepted.length) return; if (kind !== "image" || state.pendingUploads.some((pending) => pending.kind !== "image")) { clearPendingUploads(); } const remaining = kind === "image" ? Math.max(0, 9 - state.pendingUploads.length) : 1; if (accepted.length > remaining) toast("每次最多发送 9 张图片", true); accepted.slice(0, remaining).forEach((file) => { state.pendingUploads.push({ file, kind, previewUrl: kind === "image" ? URL.createObjectURL(file) : "", }); }); renderPendingUploads(); updateAvailability(); } function renderPendingUploads() { replaceChildrenCompat(dom.pendingUploads, []); dom.pendingUploads.hidden = state.pendingUploads.length === 0; state.pendingUploads.forEach((pending) => { const image = pending.kind === "image"; const item = document.createElement("div"); item.className = `pending-upload${image ? " pending-upload-image" : ""}`; item.dataset.kind = pending.kind; const preview = document.createElement("div"); preview.className = "pending-upload-preview"; preview.setAttribute("aria-hidden", "true"); if (image) { const img = document.createElement("img"); img.alt = ""; img.src = pending.previewUrl; preview.append(img); } else { const icon = document.createElement("span"); icon.className = "pending-upload-file-icon"; icon.innerHTML = ''; preview.append(icon); } let copy = null; if (!image) { copy = document.createElement("div"); copy.className = "pending-upload-copy"; const name = document.createElement("strong"); name.textContent = pending.file.name || "附件"; const meta = document.createElement("span"); meta.textContent = `附件 · ${formatBytes(pending.file.size)}`; copy.append(name, meta); } const remove = document.createElement("button"); remove.className = "pending-upload-remove"; remove.type = "button"; remove.disabled = state.sending; remove.setAttribute("aria-label", "移除待发送文件"); remove.title = "移除"; remove.innerHTML = ''; remove.addEventListener("click", () => removePendingUpload(pending)); item.append(preview); if (copy) item.append(copy); item.append(remove); dom.pendingUploads.append(item); }); } function removePendingUpload(pending) { const index = state.pendingUploads.indexOf(pending); if (index < 0) return; if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl); state.pendingUploads.splice(index, 1); renderPendingUploads(); updateAvailability(); } function clearPendingUploads() { state.pendingUploads.forEach((pending) => { if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl); }); state.pendingUploads = []; replaceChildrenCompat(dom.pendingUploads, []); dom.pendingUploads.hidden = true; dom.imageInput.value = ""; dom.fileInput.value = ""; updateAvailability(); } async function createMessage(messageType, content, payload) { const message = await request("/message/send", { method: "POST", body: JSON.stringify({ conversation_id: state.conversation.id, message_type: messageType, content, payload, client_msg_id: clientMessageId(messageType === "html" ? "support_chat_html" : "support_chat_asset"), }), }); state.messages = mergeMessages(state.messages, [message]); renderMessages(); scrollToBottom(); } async function uploadPendingFile(pending) { const form = new FormData(); form.append("conversation_id", String(state.conversation.id)); form.append("file", pending.file); const asset = await request(`/message/${pending.kind === "image" ? "upload_image" : "upload_attachment"}`, { method: "POST", body: form }); return { asset_id: asset.asset_id, filename: asset.filename, file_size: asset.file_size, mime_type: asset.mime_type, url: asset.url, }; } async function sendComposer() { const content = dom.input.value.trim(); const pending = state.pendingUploads.slice(); if ((!content && !pending.length) || state.sending || !canSend()) return; state.sending = true; updateAvailability(); try { if (pending.length && pending.every((item) => item.kind === "image")) { const assets = []; for (const item of pending) assets.push(await uploadPendingFile(item)); await createMessage("image", content ? toSafeHtml(content) : "", JSON.stringify({ assets })); dom.input.value = ""; clearPendingUploads(); autoResizeInput(); } else { for (const item of pending) { const asset = await uploadPendingFile(item); await createMessage(item.kind, asset.filename || item.file.name, JSON.stringify(asset)); removePendingUpload(item); } } if (content && (!pending.length || pending.some((item) => item.kind !== "image"))) { await createMessage("html", toSafeHtml(content), ""); dom.input.value = ""; autoResizeInput(); } } catch (error) { toast(error instanceof Error ? error.message : "发送消息失败", true); } finally { state.sending = false; updateAvailability(); } } async function loadQuickActions() { try { const actions = await request(`/conversation/quick_actions?conversation_id=${state.conversation.id}`); state.quickActions = Array.isArray(actions) ? actions : []; } catch (_) { state.quickActions = []; } renderQuickActions(); } function renderQuickActions() { replaceChildrenCompat(dom.quickList, state.quickActions.map((action) => { const button = document.createElement("button"); button.type = "button"; button.className = "quick-item"; button.textContent = action.title || action.code; button.title = action.description || action.title || action.code; button.addEventListener("click", () => executeQuickAction(action)); return button; })); dom.quickEmpty.hidden = state.quickActions.length > 0; dom.quickSection.hidden = state.quickActions.length === 0; } async function executeQuickAction(action) { if (state.sending || !canSend()) return; closeQuickDialog(); state.sending = true; updateAvailability(); try { const result = await request("/conversation/quick_action", { method: "POST", body: JSON.stringify({ conversation_id: state.conversation.id, code: action.code, client_msg_id: clientMessageId("support_chat_quick"), }), }); state.messages = mergeMessages(state.messages, [result && result.customer_message, result && result.reply_message].filter(Boolean)); renderMessages(); scrollToBottom(); } catch (error) { toast(error instanceof Error ? error.message : "快捷服务执行失败", true); } finally { state.sending = false; updateAvailability(); } } async function markLatestRead() { const latest = lastItem(state.messages); if (!latest || !state.conversation || !state.conversation.id) return; try { await request("/message/read", { method: "POST", body: JSON.stringify({ conversation_id: state.conversation.id, message_id: Number(latest.id) }), }); } catch (_) { // Read receipt failure must not interrupt chatting. } } function canSend() { return Boolean(state.conversation && state.conversation.id) && Number(state.conversation.status) !== 4; } function updateAvailability() { const closed = state.conversation && Number(state.conversation.status) === 4; const disabled = !state.conversation || closed || state.sending; dom.input.disabled = disabled; dom.send.disabled = disabled || (!dom.input.value.trim() && !state.pendingUploads.length); dom.imageButton.disabled = disabled; dom.fileButton.disabled = disabled; dom.pendingUploads.querySelectorAll(".pending-upload-remove").forEach((button) => { button.disabled = disabled; }); dom.quickButton.disabled = disabled; dom.status.hidden = !closed; if (closed) dom.status.textContent = "当前客服会话已关闭,感谢您的使用。"; updateQueueStatus(); syncQueueTimers(); } function updateQueueStatus() { const conversation = state.conversation; const queued = Number(conversation && conversation.status) === 2 && Number((conversation && conversation.current_assignee_id) || 0) === 0; dom.queueStatus.hidden = !queued; if (!queued) return; const position = Math.max(1, Number(conversation.queue_position || 1)); const ahead = Math.max(0, Number(conversation.queue_ahead_count || position - 1)); const waiting = Math.max(position, Number(conversation.queue_waiting_count || position)); const online = Boolean(conversation.queue_service_online); const waitSeconds = currentQueueWaitSeconds(); const etaSeconds = Math.max(0, Number(conversation.queue_estimated_wait_seconds || 0)); dom.queueTitle.textContent = online ? "人工客服排队中" : "已进入人工客服队列"; dom.queuePosition.textContent = `第 ${position} 位`; if (!online) { dom.queueDetail.textContent = "当前为非服务时段,排队顺序已保留。你可以继续留言,客服上线后会查看并优先接入。"; } else if (ahead > 0) { dom.queueDetail.textContent = `前方 ${ahead} 人,当前队列共 ${waiting} 人。等待期间可以继续留言,客服接入后会看到。`; } else { dom.queueDetail.textContent = "已排在队首,正在为你匹配客服。你可以继续留言,客服接入后会看到。"; } dom.queueWait.textContent = `已等待 ${formatQueueDuration(waitSeconds)}`; dom.queueEta.textContent = online ? (etaSeconds > 0 ? `预计约 ${formatQueueDuration(etaSeconds)}` : "预计很快接入") : "等待客服上线"; } function currentQueueWaitSeconds() { 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)); } function formatQueueDuration(seconds) { const value = Math.max(0, Number(seconds || 0)); if (value < 60) return "不到 1 分钟"; if (value < 3600) return `${Math.max(1, Math.ceil(value / 60))} 分钟`; const hours = Math.floor(value / 3600); const minutes = Math.ceil((value % 3600) / 60); return minutes > 0 ? `${hours} 小时 ${minutes} 分钟` : `${hours} 小时`; } function syncQueueTimers() { const queued = Number(state.conversation && state.conversation.status) === 2 && Number((state.conversation && state.conversation.current_assignee_id) || 0) === 0; if (!queued) { clearQueueTimers(); return; } if (!state.queueTickTimer) { state.queueTickTimer = window.setInterval(updateQueueStatus, 1000); } if (!state.queueRefreshTimer) { state.queueRefreshTimer = window.setInterval(refreshConversationQueue, 15000); } } function clearQueueTimers() { if (state.queueTickTimer) window.clearInterval(state.queueTickTimer); if (state.queueRefreshTimer) window.clearInterval(state.queueRefreshTimer); state.queueTickTimer = null; state.queueRefreshTimer = null; } async function refreshConversationQueue() { if (!state.conversation || !state.conversation.id || Number(state.conversation.status) !== 2) return; try { const latest = await request(`/conversation/${state.conversation.id}`); state.conversation = Object.assign({}, state.conversation, latest); state.queueSyncedAt = Date.now(); updateAvailability(); } catch (_) { // WebSocket remains the primary update path; polling is only a recovery path. } } function updateConnectionStatus(status) { const labels = { connecting: "连接中", connected: "在线服务", disconnected: "连接已断开" }; dom.statusDot.className = `header-status-dot ${status}`; dom.connectionBadge.className = `connection-badge ${status}`; dom.connectionLabel.textContent = labels[status] || labels.disconnected; } function websocketUrl() { 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(); query.set("channel_id", channelId); if (accessTarget) { query.set("h5_chat_session", "required"); query.set("h5_chat_binding", state.chatBinding); } else { query.set("external_id", identity.external_id); } return `${base.replace(/\/$/, "")}/ws/open?${query.toString()}`; } function connectSocket() { if (!state.conversation || !state.conversation.id || Number(state.conversation.status) === 4) return; clearRealtimeTimers(); if (state.socket) { const oldSocket = state.socket; state.socket = null; oldSocket.close(); } state.allowReconnect = true; updateConnectionStatus("connecting"); let socket; try { socket = new WebSocket(websocketUrl()); } catch (_) { updateConnectionStatus("disconnected"); scheduleReconnect(); return; } state.socket = socket; socket.addEventListener("open", () => { if (state.socket !== socket) return; state.reconnectAttempt = 0; updateConnectionStatus("connected"); state.pingTimer = window.setInterval(() => { if (socket.readyState === WebSocket.OPEN) socket.send(JSON.stringify({ type: "ping" })); }, 20000); }); socket.addEventListener("message", (event) => { if (state.socket !== socket) return; handleRealtimeMessage(event.data); }); socket.addEventListener("error", () => { if (state.socket !== socket) return; updateConnectionStatus("disconnected"); }); socket.addEventListener("close", () => { if (state.socket !== socket) return; state.socket = null; clearPingTimer(); updateConnectionStatus("disconnected"); scheduleReconnect(); }); } function handleRealtimeMessage(raw) { let event; try { event = JSON.parse(raw); } catch (_) { return; } const payload = event && event.data; if (event && event.type === "resyncRequired") { loadMessages(false).catch(() => {}); 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 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(); const currentLast = lastItem(state.messages); if ((currentLast && currentLast.id) !== previousLastId) { scrollToBottom(); markLatestRead(); } return; } if (String(event.type || "").startsWith("conversation.")) { const patch = Object.assign({}, payload); delete patch.conversation_id; state.conversation = Object.assign({}, state.conversation, patch); state.queueSyncedAt = Date.now(); applyReadState(payload); renderMessages(); updateAvailability(); if (Number(state.conversation.status) === 4) { clearPendingUploads(); disconnectSocket(false); } } } function normalizeRealtimeMessage(payload) { 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; return { id, conversation_id: conversationId, sender_type: payload.sender_type || "", sender_id: Number(payload.sender_id || 0), sender_name: payload.sender_name, sender_avatar: payload.sender_avatar, message_type: payload.message_type || "text", content: payload.content || "", payload: payload.payload, send_status: Number(payload.send_status || 0), sent_at: payload.sent_at, customer_read: false, agent_read: false, }; } 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) => 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 || !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; state.reconnectAttempt += 1; connectSocket(); }, delay); } function clearPingTimer() { if (state.pingTimer) window.clearInterval(state.pingTimer); state.pingTimer = null; } function clearRealtimeTimers() { clearPingTimer(); if (state.reconnectTimer) window.clearTimeout(state.reconnectTimer); state.reconnectTimer = null; } function disconnectSocket(allowReconnect = false) { state.allowReconnect = allowReconnect; clearRealtimeTimers(); const socket = state.socket; state.socket = null; if (socket && (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CONNECTING)) socket.close(); updateConnectionStatus("disconnected"); } async function retry() { if (!state.initialized || !state.conversation || !state.conversation.id) { dom.loading.hidden = false; dom.empty.hidden = true; await init(); return; } try { await loadMessages(false); } catch (_) { // The socket can still reconnect when a one-time resync fails. } state.reconnectAttempt = 0; connectSocket(); } async function closeConversation() { if (state.closing) return; if (!state.conversation || !state.conversation.id) { closeCloseDialog(); closePage(); return; } state.closing = true; dom.confirmClose.disabled = true; dom.continueButton.disabled = true; dom.confirmClose.textContent = "正在结束…"; try { await request("/conversation/close", { method: "POST", body: JSON.stringify({ conversation_id: state.conversation.id }), }); state.conversation = Object.assign({}, state.conversation, { status: 4 }); disconnectSocket(false); clearCustomerAccessState(); clearPendingUploads(); clearQueueTimers(); updateAvailability(); closeCloseDialog(); if (isEmbedded()) { window.parent.postMessage({ type: "agent-desk:request-close" }, "*"); } } catch (error) { toast(error instanceof Error ? error.message : "结束会话失败", true); } finally { state.closing = false; dom.confirmClose.disabled = false; dom.continueButton.disabled = false; dom.confirmClose.textContent = "结束对话"; } } function isEmbedded() { try { return window.parent !== window; } catch (_) { return false; } } function closePage() { if (isEmbedded()) { window.parent.postMessage({ type: "agent-desk:request-close" }, "*"); } else if (history.length > 1) { history.back(); } else { window.close(); } } function scrollToBottom(smooth = true) { requestAnimationFrame(() => dom.scroller.scrollTo({ top: dom.scroller.scrollHeight, behavior: smooth ? "smooth" : "auto", })); } function settleInitialScroll() { const startedAt = Date.now(); let lastHeight = -1; let stableTicks = 0; const apply = () => { dom.scroller.scrollTop = dom.scroller.scrollHeight; const height = dom.scroller.scrollHeight; stableTicks = height === lastHeight ? stableTicks + 1 : 0; lastHeight = height; 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; return; } window.setTimeout(apply, 80); }; requestAnimationFrame(() => requestAnimationFrame(apply)); } function autoResizeInput() { dom.input.style.height = "auto"; dom.input.style.height = `${Math.min(dom.input.scrollHeight, 160)}px`; updateAvailability(); } async function openQuickDialog() { dom.quickOverlay.hidden = false; state.quickActions = []; renderQuickActions(); await loadQuickActions(); } function closeQuickDialog() { dom.quickOverlay.hidden = true; } function openCloseDialog() { dom.closeOverlay.hidden = false; } function closeCloseDialog() { if (!state.closing) dom.closeOverlay.hidden = true; } function toast(message, error = false) { const node = document.createElement("div"); node.className = `toast${error ? " error" : ""}`; node.textContent = message; dom.toasts.append(node); window.setTimeout(() => node.remove(), 3200); } function fatal(message) { disconnectSocket(false); dom.loading.hidden = true; dom.empty.hidden = false; dom.empty.querySelector("p").textContent = message; dom.input.disabled = true; dom.send.disabled = true; dom.imageButton.disabled = true; dom.fileButton.disabled = true; dom.quickSection.hidden = true; dom.app.setAttribute("aria-busy", "false"); } function bindEvents() { dom.retry.addEventListener("click", retry); dom.close.addEventListener("click", openCloseDialog); dom.loadMore.addEventListener("click", loadOlder); dom.send.addEventListener("click", sendComposer); dom.input.addEventListener("input", autoResizeInput); dom.imageButton.addEventListener("click", () => dom.imageInput.click()); dom.fileButton.addEventListener("click", () => dom.fileInput.click()); dom.imageInput.addEventListener("change", () => { stageFiles(Array.from(dom.imageInput.files || []), "image"); dom.imageInput.value = ""; }); dom.fileInput.addEventListener("change", () => { stageFiles(Array.from(dom.fileInput.files || []).slice(0, 1), "attachment"); dom.fileInput.value = ""; }); dom.quickButton.addEventListener("click", openQuickDialog); dom.quickClose.addEventListener("click", closeQuickDialog); dom.quickOverlay.addEventListener("click", (event) => { if (event.target === dom.quickOverlay) closeQuickDialog(); }); dom.closeDialogX.addEventListener("click", closeCloseDialog); dom.continueButton.addEventListener("click", closeCloseDialog); dom.confirmClose.addEventListener("click", closeConversation); dom.closeOverlay.addEventListener("click", (event) => { if (event.target === dom.closeOverlay) closeCloseDialog(); }); dom.scroller.addEventListener("scroll", () => { if (!state.initialScrollSettling && dom.scroller.scrollTop < 24) loadOlder(); }); document.addEventListener("visibilitychange", () => { if (!document.hidden && state.initialized) { loadMessages(false).catch(() => {}); if (!state.socket) connectSocket(); } }); window.addEventListener("message", (event) => { const type = event.data && event.data.type; if (type === "agent-desk:open" && state.initialized && !state.socket) connectSocket(); if (type === "agent-desk:minimize") disconnectSocket(false); }); window.addEventListener("beforeunload", () => { state.pendingUploads.forEach((pending) => { if (pending.previewUrl) URL.revokeObjectURL(pending.previewUrl); }); disconnectSocket(false); clearQueueTimers(); }); document.addEventListener("keydown", (event) => { if (event.key !== "Escape") return; if (!dom.quickOverlay.hidden) closeQuickDialog(); else if (!dom.closeOverlay.hidden) closeCloseDialog(); }); } bindEvents(); if (isEmbedded()) window.parent.postMessage({ type: "agent-desk:ready" }, "*"); init(); })();