1456 lines
53 KiB
JavaScript
1456 lines
53 KiB
JavaScript
|
|
(() => {
|
||
|
|
"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"),
|
||
|
|
accessOverlay: byId("access-overlay"),
|
||
|
|
accessTarget: byId("access-target"),
|
||
|
|
accessPasswordTab: byId("access-password-tab"),
|
||
|
|
accessSMSTab: byId("access-sms-tab"),
|
||
|
|
accessPasswordPanel: byId("access-password-panel"),
|
||
|
|
accessSMSPanel: byId("access-sms-panel"),
|
||
|
|
accessPassword: byId("access-password"),
|
||
|
|
accessPhone: byId("access-phone"),
|
||
|
|
accessCode: byId("access-code"),
|
||
|
|
accessSendCode: byId("access-send-code"),
|
||
|
|
accessSMSHelp: byId("access-sms-help"),
|
||
|
|
accessError: byId("access-error"),
|
||
|
|
accessCancel: byId("access-cancel"),
|
||
|
|
accessSubmit: byId("access-submit"),
|
||
|
|
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,
|
||
|
|
accessBusy: false,
|
||
|
|
accessMethod: "password",
|
||
|
|
chatBinding: accessTarget ? loadChatBinding(accessTarget) : "",
|
||
|
|
};
|
||
|
|
|
||
|
|
class CustomerAccessRequiredError 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}`;
|
||
|
|
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)}`;
|
||
|
|
}
|
||
|
|
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?.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; verification still works
|
||
|
|
// for the current navigation and can simply be repeated after reload.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function removeSessionStorage(key) {
|
||
|
|
try {
|
||
|
|
window.sessionStorage.removeItem(key);
|
||
|
|
} catch (_) {
|
||
|
|
// Ignore inaccessible browser storage.
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
function randomId() {
|
||
|
|
if (window.crypto?.randomUUID) return window.crypto.randomUUID().replaceAll("-", "");
|
||
|
|
return `${Date.now().toString(36)}${Math.random().toString(36).slice(2, 12)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
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}`, { ...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?.ok === false || payload?.success === false) {
|
||
|
|
const value = payload?.msg || payload?.message || payload?.code;
|
||
|
|
if (accessTarget && isCustomerAccessError(value, response.status)) {
|
||
|
|
clearChatBinding();
|
||
|
|
showAccessDialog("客服身份验证已失效,请重新验证。");
|
||
|
|
throw new CustomerAccessRequiredError("请重新验证当前卡板或设备");
|
||
|
|
}
|
||
|
|
throw new Error(chineseError(value));
|
||
|
|
}
|
||
|
|
return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload;
|
||
|
|
}
|
||
|
|
|
||
|
|
function isCustomerAccessError(value, status) {
|
||
|
|
const text = String(value || "");
|
||
|
|
return status === 401 || /unauthorized|身份验证|二次验证|重新验证|验证已失效/i.test(text);
|
||
|
|
}
|
||
|
|
|
||
|
|
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);
|
||
|
|
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), {
|
||
|
|
...options,
|
||
|
|
cache: "no-store",
|
||
|
|
credentials: "include",
|
||
|
|
headers: { Accept: "application/json", ...(options.headers || {}) },
|
||
|
|
});
|
||
|
|
} catch (_) {
|
||
|
|
throw new Error("网络连接失败,请检查网络后重试");
|
||
|
|
}
|
||
|
|
let payload;
|
||
|
|
try {
|
||
|
|
payload = await response.json();
|
||
|
|
} catch (_) {
|
||
|
|
throw new Error("验证服务暂时不可用,请稍后重试");
|
||
|
|
}
|
||
|
|
if (!response.ok || payload?.ok === false || payload?.success === false) {
|
||
|
|
throw new Error(chineseError(payload?.msg || payload?.message || payload?.code));
|
||
|
|
}
|
||
|
|
return Object.prototype.hasOwnProperty.call(payload || {}, "data") ? payload.data : payload;
|
||
|
|
}
|
||
|
|
|
||
|
|
function maskAccessNumber(value) {
|
||
|
|
const text = String(value || "").trim();
|
||
|
|
if (text.length <= 4) return text;
|
||
|
|
return `${text.slice(0, 2)}${"•".repeat(Math.min(6, Math.max(2, text.length - 6)))}${text.slice(-4)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
function setAccessMethod(method) {
|
||
|
|
state.accessMethod = method === "sms" ? "sms" : "password";
|
||
|
|
const password = state.accessMethod === "password";
|
||
|
|
dom.accessPasswordTab.classList.toggle("active", password);
|
||
|
|
dom.accessPasswordTab.setAttribute("aria-selected", String(password));
|
||
|
|
dom.accessSMSTab.classList.toggle("active", !password);
|
||
|
|
dom.accessSMSTab.setAttribute("aria-selected", String(!password));
|
||
|
|
dom.accessPasswordPanel.hidden = !password;
|
||
|
|
dom.accessSMSPanel.hidden = password;
|
||
|
|
setAccessError("");
|
||
|
|
window.setTimeout(() => (password ? dom.accessPassword : dom.accessPhone).focus(), 0);
|
||
|
|
}
|
||
|
|
|
||
|
|
function showAccessDialog(message = "") {
|
||
|
|
if (!accessTarget) return;
|
||
|
|
disconnectSocket(false);
|
||
|
|
dom.accessTarget.textContent = `${accessTarget.type === "device" ? "设备" : "卡板"} ${maskAccessNumber(accessTarget.number)} 涉及套餐、WiFi 密码或设备操作,请先完成二次验证。`;
|
||
|
|
dom.accessOverlay.hidden = false;
|
||
|
|
dom.app.setAttribute("aria-hidden", "true");
|
||
|
|
setAccessMethod(state.accessMethod);
|
||
|
|
setAccessError(message);
|
||
|
|
}
|
||
|
|
|
||
|
|
function setAccessError(message) {
|
||
|
|
dom.accessError.textContent = String(message || "");
|
||
|
|
dom.accessError.hidden = !String(message || "").trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
function setAccessBusy(busy) {
|
||
|
|
state.accessBusy = busy;
|
||
|
|
dom.accessSubmit.disabled = busy;
|
||
|
|
dom.accessSendCode.disabled = busy;
|
||
|
|
dom.accessPassword.disabled = busy;
|
||
|
|
dom.accessPhone.disabled = busy;
|
||
|
|
dom.accessCode.disabled = busy;
|
||
|
|
dom.accessSubmit.textContent = busy ? "正在验证…" : "验证并进入客服";
|
||
|
|
}
|
||
|
|
|
||
|
|
async function sendAccessCode() {
|
||
|
|
if (!accessTarget || state.accessBusy) return;
|
||
|
|
setAccessBusy(true);
|
||
|
|
setAccessError("");
|
||
|
|
try {
|
||
|
|
const result = await h5AccessRequest("/access/send-code", { method: "POST", body: "{}", headers: { "Content-Type": "application/json" } });
|
||
|
|
dom.accessSMSHelp.textContent = `验证码已发送到 ${result?.masked_phone || "已绑定手机号"},请输入完整手机号和验证码。`;
|
||
|
|
toast("验证码已发送");
|
||
|
|
dom.accessCode.focus();
|
||
|
|
} catch (error) {
|
||
|
|
setAccessError(error instanceof Error ? error.message : "验证码发送失败");
|
||
|
|
} finally {
|
||
|
|
setAccessBusy(false);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
async function submitCustomerAccess() {
|
||
|
|
if (!accessTarget || state.accessBusy) return;
|
||
|
|
const proof = state.accessMethod === "sms"
|
||
|
|
? { phone: dom.accessPhone.value.trim(), code: dom.accessCode.value.trim() }
|
||
|
|
: { password: dom.accessPassword.value };
|
||
|
|
if (state.accessMethod === "sms" && (!/^1\d{10}$/.test(proof.phone) || !proof.code)) {
|
||
|
|
setAccessError("请输入完整的已绑定手机号和短信验证码。");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (state.accessMethod === "password" && !String(proof.password || "").trim()) {
|
||
|
|
setAccessError("请输入卡板或设备密码。");
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
setAccessBusy(true);
|
||
|
|
setAccessError("");
|
||
|
|
try {
|
||
|
|
const authorized = await h5AccessRequest("/access/authorize", {
|
||
|
|
method: "POST",
|
||
|
|
body: JSON.stringify(proof),
|
||
|
|
headers: { "Content-Type": "application/json" },
|
||
|
|
});
|
||
|
|
const accessToken = String(authorized?.access_token || "").trim();
|
||
|
|
if (!accessToken) throw new Error("验证结果无效,请重试");
|
||
|
|
const entry = await h5AccessRequest("/access/chat-entry", {
|
||
|
|
method: "POST",
|
||
|
|
body: "{}",
|
||
|
|
headers: { "Content-Type": "application/json", "X-H5-Access-Token": accessToken },
|
||
|
|
});
|
||
|
|
const ticket = String(entry?.ticket || "").trim();
|
||
|
|
const binding = String(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());
|
||
|
|
} catch (error) {
|
||
|
|
clearChatBinding();
|
||
|
|
setAccessError(error instanceof Error ? error.message : "身份验证失败,请重试");
|
||
|
|
setAccessBusy(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) {
|
||
|
|
dom.loading.hidden = true;
|
||
|
|
dom.app.setAttribute("aria-busy", "false");
|
||
|
|
showAccessDialog();
|
||
|
|
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 CustomerAccessRequiredError) {
|
||
|
|
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?.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;
|
||
|
|
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?.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);
|
||
|
|
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(...groups) {
|
||
|
|
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));
|
||
|
|
}
|
||
|
|
|
||
|
|
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));
|
||
|
|
});
|
||
|
|
dom.list.replaceChildren(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";
|
||
|
|
if (message.sender_avatar) {
|
||
|
|
const image = document.createElement("img");
|
||
|
|
image.src = message.sender_avatar;
|
||
|
|
image.alt = "";
|
||
|
|
node.append(image);
|
||
|
|
return node;
|
||
|
|
}
|
||
|
|
const name = senderName(message);
|
||
|
|
node.textContent = (name || "客服").slice(0, 1).toUpperCase();
|
||
|
|
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?.url || "";
|
||
|
|
image.alt = 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 = '<span class="attachment-icon"><svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg></span>';
|
||
|
|
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(`<div>${html}</div>`, "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",
|
||
|
|
]);
|
||
|
|
[...root.querySelectorAll("*")].forEach((node) => {
|
||
|
|
if (blockedTags.has(node.tagName)) {
|
||
|
|
node.remove();
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!allowedTags.has(node.tagName)) {
|
||
|
|
node.replaceWith(...node.childNodes);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
[...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();
|
||
|
|
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();
|
||
|
|
[...root.childNodes].forEach((node) => fragment.append(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) => `<p>${paragraph.replace(/\n/g, "<br>")}</p>`);
|
||
|
|
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);
|
||
|
|
state.pendingUploads.push(...accepted.slice(0, remaining).map((file) => ({
|
||
|
|
file,
|
||
|
|
kind,
|
||
|
|
previewUrl: kind === "image" ? URL.createObjectURL(file) : "",
|
||
|
|
})));
|
||
|
|
renderPendingUploads();
|
||
|
|
updateAvailability();
|
||
|
|
}
|
||
|
|
|
||
|
|
function renderPendingUploads() {
|
||
|
|
dom.pendingUploads.replaceChildren();
|
||
|
|
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 = '<svg viewBox="0 0 24 24"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8Z"/><path d="M14 2v6h6"/></svg>';
|
||
|
|
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 = '<svg viewBox="0 0 24 24"><path d="m6 6 12 12M18 6 6 18"/></svg>';
|
||
|
|
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 = [];
|
||
|
|
dom.pendingUploads.replaceChildren();
|
||
|
|
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];
|
||
|
|
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() {
|
||
|
|
dom.quickList.replaceChildren(...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?.customer_message, 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 = state.messages.at(-1);
|
||
|
|
if (!latest || !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?.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?.status) === 2 && Number(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?.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?.status) === 2 && Number(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?.id || Number(state.conversation.status) !== 2) return;
|
||
|
|
try {
|
||
|
|
const latest = await request(`/conversation/${state.conversation.id}`);
|
||
|
|
state.conversation = { ...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({ 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?.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?.data;
|
||
|
|
if (event?.type === "resyncRequired") {
|
||
|
|
loadMessages(false).catch(() => {});
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!payload || Number(payload.conversation_id) !== Number(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;
|
||
|
|
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) {
|
||
|
|
scrollToBottom();
|
||
|
|
markLatestRead();
|
||
|
|
}
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (String(event.type || "").startsWith("conversation.")) {
|
||
|
|
const patch = { ...payload };
|
||
|
|
delete patch.conversation_id;
|
||
|
|
state.conversation = { ...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?.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) => ({
|
||
|
|
...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;
|
||
|
|
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?.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?.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 = { ...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 = [...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.accessPasswordTab.addEventListener("click", () => setAccessMethod("password"));
|
||
|
|
dom.accessSMSTab.addEventListener("click", () => setAccessMethod("sms"));
|
||
|
|
dom.accessSendCode.addEventListener("click", sendAccessCode);
|
||
|
|
dom.accessSubmit.addEventListener("click", submitCustomerAccess);
|
||
|
|
dom.accessCancel.addEventListener("click", () => {
|
||
|
|
clearCustomerAccessState();
|
||
|
|
closePage();
|
||
|
|
});
|
||
|
|
dom.accessOverlay.addEventListener("keydown", (event) => {
|
||
|
|
if (event.key !== "Enter" || event.shiftKey || state.accessBusy) return;
|
||
|
|
submitCustomerAccess();
|
||
|
|
});
|
||
|
|
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?.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.accessOverlay.hidden) return;
|
||
|
|
if (!dom.quickOverlay.hidden) closeQuickDialog();
|
||
|
|
else if (!dom.closeOverlay.hidden) closeCloseDialog();
|
||
|
|
});
|
||
|
|
}
|
||
|
|
|
||
|
|
bindEvents();
|
||
|
|
if (isEmbedded()) window.parent.postMessage({ type: "agent-desk:ready" }, "*");
|
||
|
|
init();
|
||
|
|
})();
|