Files
ai-agent/internal/web/supportchat/assets/chat.js
T

1456 lines
53 KiB
JavaScript
Raw Normal View History

(() => {
"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();
})();