diff --git a/web/app/kefu/page.tsx b/web/app/kefu/page.tsx new file mode 100644 index 0000000..5a038af --- /dev/null +++ b/web/app/kefu/page.tsx @@ -0,0 +1,6 @@ +import { KefuWidgetDemo } from "@/components/kefu/widget-demo" + +export default function Page() { + return +} + diff --git a/web/components/kefu/chat-shell.tsx b/web/components/kefu/chat-shell.tsx index 689eaa4..8183fcb 100644 --- a/web/components/kefu/chat-shell.tsx +++ b/web/components/kefu/chat-shell.tsx @@ -79,6 +79,7 @@ export function KefuChatShell() { closeConversation: state.closeConversation, })) ) + const safeMessages = Array.isArray(messages) ? messages : [] const maybeMarkConversationRead = useCallback(() => { if (!isVisible || !conversation || typeof document === "undefined") { @@ -119,7 +120,7 @@ export function KefuChatShell() { useEffect(() => { maybeMarkConversationRead() - }, [maybeMarkConversationRead, messages.length]) + }, [maybeMarkConversationRead, safeMessages.length]) useEffect(() => { const handleVisibilityChange = () => { @@ -252,7 +253,7 @@ export function KefuChatShell() {
) } - diff --git a/web/components/kefu/message-list.tsx b/web/components/kefu/message-list.tsx index f41639b..819caab 100644 --- a/web/components/kefu/message-list.tsx +++ b/web/components/kefu/message-list.tsx @@ -18,7 +18,7 @@ import { renderIMMessageHTML } from "@/lib/im-message" import { cn, formatDateTime } from "@/lib/utils" type KefuMessageListProps = { - messages: ImMessage[] + messages?: ImMessage[] | null onNearBottomVisible?: () => void hasMoreOlder?: boolean loadingOlder?: boolean @@ -76,7 +76,8 @@ export const KefuMessageList = forwardRef(null) const frameRef = useRef(null) const shouldStickToBottomRef = useRef(true) - const lastMessageId = messages.at(-1)?.id + const safeMessages = Array.isArray(messages) ? messages : [] + const lastMessageId = safeMessages.at(-1)?.id const isNearBottom = useCallback( (element: HTMLElement, threshold = 80) => @@ -221,8 +222,8 @@ export const KefuMessageList = forwardRef ) : null} - {messages.map((message, index) => { - const previousMessage = index > 0 ? messages[index - 1] : null + {safeMessages.map((message, index) => { + const previousMessage = index > 0 ? safeMessages[index - 1] : null const showTimeline = index === 0 || getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt) diff --git a/web/components/kefu/widget-demo.tsx b/web/components/kefu/widget-demo.tsx new file mode 100644 index 0000000..d1b8150 --- /dev/null +++ b/web/components/kefu/widget-demo.tsx @@ -0,0 +1,283 @@ +"use client" + +import { useEffect, useMemo, useState } from "react" + +import type { KefuWidgetHostConfig } from "@/lib/kefu-widget-config" +import { generateUUID } from "@/lib/utils" + +const STORAGE_KEY = "cs-agent-web-widget-test-config" +const INITIAL_CONFIG: KefuWidgetHostConfig = { + channelId: "", + baseUrl: "", + apiBaseUrl: "", + externalSource: "web_chat", + title: "在线客服", + subtitle: "欢迎咨询", + position: "right", + themeColor: "#2563eb", + width: "680px", + subject: "", +} + +declare global { + interface Window { + CSAgentWidget?: { + mount: (config: KefuWidgetHostConfig) => void + destroy: () => void + open: () => void + close: () => void + } + } +} + +function generateRandomSubject() { + return `访客-${generateUUID().replace(/-/g, "").slice(0, 8)}` +} + +function getDefaultConfig(): KefuWidgetHostConfig { + if (typeof window === "undefined") { + return INITIAL_CONFIG + } + + const savedText = window.localStorage.getItem(STORAGE_KEY) + const savedConfig = savedText + ? (JSON.parse(savedText) as Partial) + : {} + const query = new URLSearchParams(window.location.search) + const origin = window.location.origin + + return { + channelId: query.get("channelId") ?? savedConfig.channelId ?? "", + baseUrl: query.get("baseUrl") ?? savedConfig.baseUrl ?? origin, + apiBaseUrl: + query.get("apiBaseUrl") ?? + savedConfig.apiBaseUrl ?? + savedConfig.baseUrl ?? + origin, + externalSource: + query.get("externalSource") ?? savedConfig.externalSource ?? "web_chat", + title: query.get("title") ?? savedConfig.title ?? "在线客服", + subtitle: query.get("subtitle") ?? savedConfig.subtitle ?? "欢迎咨询", + position: + (query.get("position") as "left" | "right" | null) ?? + savedConfig.position ?? + "right", + themeColor: query.get("themeColor") ?? savedConfig.themeColor ?? "#2563eb", + width: query.get("width") ?? savedConfig.width ?? "680px", + subject: query.get("subject") ?? savedConfig.subject ?? generateRandomSubject(), + } +} + +function removeMountedWidget() { + if (typeof window === "undefined") { + return + } + + window.CSAgentWidget?.destroy() + document + .querySelectorAll( + '[data-cs-agent-widget="launcher"], [data-cs-agent-widget="frame"], [data-cs-agent-widget="script"]' + ) + .forEach((node) => node.remove()) + + delete window.CSAgentConfig + delete window.__CS_AGENT_WIDGET_CONFIG__ + delete window.__CS_AGENT_WIDGET_STATE__ + delete window.CSAgentWidget +} + +function injectWidget(config: KefuWidgetHostConfig) { + removeMountedWidget() + window.CSAgentConfig = config + + const script = document.createElement("script") + script.async = true + script.src = `${window.location.origin}/sdk/cs-agent-widget.js` + script.dataset.csAgentWidget = "script" + document.body.appendChild(script) +} + +export function KefuWidgetDemo() { + const [config, setConfig] = useState(INITIAL_CONFIG) + const [status, setStatus] = useState("请填写 channelId") + + useEffect(() => { + const initialConfig = getDefaultConfig() + setConfig(initialConfig) + setStatus(initialConfig.channelId ? "Widget 已挂载" : "请填写 channelId") + + if (initialConfig.channelId) { + injectWidget(initialConfig) + } + + return () => { + removeMountedWidget() + } + }, []) + + const snippet = useMemo(() => { + const scriptSrc = config.baseUrl + ? `${config.baseUrl.replace(/\/$/, "")}/sdk/cs-agent-widget.js` + : "/sdk/cs-agent-widget.js" + + return ` +` + }, [config]) + + function updateField( + key: K, + value: KefuWidgetHostConfig[K] + ) { + setConfig((current) => ({ ...current, [key]: value })) + } + + function handleMount() { + const nextConfig: KefuWidgetHostConfig = { + ...config, + channelId: config.channelId.trim(), + baseUrl: config.baseUrl.trim() || window.location.origin, + apiBaseUrl: + config.apiBaseUrl?.trim() || config.baseUrl.trim() || window.location.origin, + externalSource: config.externalSource?.trim() || "web_chat", + title: config.title?.trim() || "在线客服", + subtitle: config.subtitle?.trim() || "", + themeColor: config.themeColor?.trim() || "#2563eb", + width: config.width?.trim() || "380px", + subject: config.subject?.trim() || generateRandomSubject(), + } + + setConfig(nextConfig) + window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig)) + + if (!nextConfig.channelId) { + removeMountedWidget() + setStatus("请填写 channelId") + return + } + + injectWidget(nextConfig) + setStatus("Widget 已挂载") + } + + return ( +
+
+
+
Widget 挂载测试
+
{status}
+ +
+ updateField("channelId", value)} + /> + updateField("baseUrl", value)} + /> + updateField("apiBaseUrl", value)} + /> + updateField("title", value)} + /> + updateField("subtitle", value)} + /> + updateField("themeColor", value)} + /> + updateField("width", value)} + /> + updateField("subject", value)} + /> +
+ +
+ + + +
+
+ +
+
接入代码
+
+            {snippet}
+          
+
+
+
+ ) +} + +function TextField({ + label, + value, + onChange, +}: { + label: string + value?: string + onChange: (value: string) => void +}) { + return ( + + ) +} diff --git a/web/lib/api/client.ts b/web/lib/api/client.ts index 5079c32..aa89953 100644 --- a/web/lib/api/client.ts +++ b/web/lib/api/client.ts @@ -13,6 +13,7 @@ type JsonResult = { type RequestOptions = RequestInit & { skipAuth?: boolean retryOnAuthError?: boolean + baseUrl?: string } async function parseResult(response: Response) { @@ -57,8 +58,9 @@ export async function request( options: RequestOptions = {}, retryOnAuthError = true ): Promise { - const { headers, skipAuth, ...rest } = options + const { headers, skipAuth, baseUrl, ...rest } = options delete (rest as RequestOptions).retryOnAuthError + delete (rest as RequestOptions).baseUrl const session = readSession() const authHeaders = new Headers(headers) @@ -73,7 +75,8 @@ export async function request( authHeaders.set("Content-Type", "application/json") } - const response = await fetch(`${API_BASE_URL}${path}`, { + const requestBaseUrl = baseUrl !== undefined ? baseUrl : API_BASE_URL + const response = await fetch(`${requestBaseUrl}${path}`, { ...rest, headers: authHeaders, cache: "no-store", diff --git a/web/lib/api/im.ts b/web/lib/api/im.ts index 5cfed8a..8d676c2 100644 --- a/web/lib/api/im.ts +++ b/web/lib/api/im.ts @@ -1,4 +1,5 @@ import { request } from "@/lib/api/client" +import { readKefuWidgetConfig } from "@/lib/kefu-widget-config" import { generateUUID } from "@/lib/utils" export type Paging = { @@ -8,7 +9,7 @@ export type Paging = { } export type PageResult = { - results: T[] + results?: T[] | null page: Paging cursor?: string hasMore?: boolean @@ -133,11 +134,46 @@ export function getImVisitorId() { return visitorId } -function createImHeaders() { +function getRuntimeImConfig() { + const widgetConfig = readKefuWidgetConfig() + const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL) + .trim() + .replace(/\/$/, "") return { - "X-External-Source": OPEN_IM_EXTERNAL_SOURCE, + baseUrl, + channelId: widgetConfig.channelId || OPEN_IM_CHANNEL_ID, + externalSource: + (widgetConfig.externalSource || OPEN_IM_EXTERNAL_SOURCE).trim() || "web_chat", + externalName: (widgetConfig.subject || "").trim(), + } +} + +function createImHeaders() { + const config = getRuntimeImConfig() + const headers: Record = { + "X-External-Source": config.externalSource, "X-External-Id": getImVisitorId(), - "X-Channel-Id": OPEN_IM_CHANNEL_ID, + "X-Channel-Id": config.channelId, + } + if (config.externalName) { + headers["X-External-Name"] = encodeURIComponent(config.externalName) + } + return { + ...headers, + } +} + +function createRequestOptions( + init?: RequestInit +): RequestInit & { baseUrl?: string; skipAuth?: boolean } { + return { + ...init, + skipAuth: true, + headers: { + ...createImHeaders(), + ...(init?.headers as Record | undefined), + }, + baseUrl: getRuntimeImConfig().baseUrl, } } @@ -159,7 +195,7 @@ function toQueryString(query?: Record) { export function fetchImConversationDetail(id: number) { return request(`/api/open/im/conversation/${id}`, { - headers: createImHeaders(), + ...createRequestOptions(), }) } @@ -168,34 +204,32 @@ export function fetchImMessages( ) { return request>( `/api/open/im/message/list${toQueryString(query)}`, - { headers: createImHeaders() } + createRequestOptions() ) } /** 外部身份仅通过 createImHeaders()(X-External-*)传递,无 JSON body */ export function createOrMatchImConversation() { return request("/api/open/im/conversation/create_or_match", { - method: "POST", - headers: createImHeaders(), + ...createRequestOptions({ method: "POST" }), }) } export function fetchImWidgetConfig() { return request( `/api/open/im/widget/config${toQueryString({ - channelId: OPEN_IM_CHANNEL_ID, + channelId: getRuntimeImConfig().channelId, })}`, - { - headers: createImHeaders(), - } + createRequestOptions() ) } export function closeImConversation(conversationId: number) { return request("/api/open/im/conversation/close", { - method: "POST", - headers: createImHeaders(), - body: JSON.stringify({ conversationId }), + ...createRequestOptions({ + method: "POST", + body: JSON.stringify({ conversationId }), + }), }) } @@ -207,17 +241,19 @@ export function sendImMessage(payload: { clientMsgId?: string }) { return request("/api/open/im/message/send", { - method: "POST", - headers: createImHeaders(), - body: JSON.stringify(payload), + ...createRequestOptions({ + method: "POST", + body: JSON.stringify(payload), + }), }) } export function markImMessageRead(conversationId: number, messageId = 0) { return request("/api/open/im/message/read", { - method: "POST", - headers: createImHeaders(), - body: JSON.stringify({ conversationId, messageId }), + ...createRequestOptions({ + method: "POST", + body: JSON.stringify({ conversationId, messageId }), + }), }) } @@ -226,9 +262,10 @@ export function uploadImImage(conversationId: number, file: File) { formData.set("conversationId", String(conversationId)) formData.set("file", file) return request("/api/open/im/message/upload_image", { - method: "POST", - headers: createImHeaders(), - body: formData, + ...createRequestOptions({ + method: "POST", + body: formData, + }), }) } @@ -237,8 +274,9 @@ export function uploadImAttachment(conversationId: number, file: File) { formData.set("conversationId", String(conversationId)) formData.set("file", file) return request("/api/open/im/message/upload_attachment", { - method: "POST", - headers: createImHeaders(), - body: formData, + ...createRequestOptions({ + method: "POST", + body: formData, + }), }) } diff --git a/web/lib/im-realtime.ts b/web/lib/im-realtime.ts index b370061..175a0d5 100644 --- a/web/lib/im-realtime.ts +++ b/web/lib/im-realtime.ts @@ -1,10 +1,6 @@ import { createWebSocketBaseUrl } from "@/lib/api/websocket" import { getImVisitorId } from "@/lib/api/im" - -const OPEN_IM_CHANNEL_ID = - process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() || "" -const OPEN_IM_EXTERNAL_SOURCE = - process.env.NEXT_PUBLIC_OPEN_IM_EXTERNAL_SOURCE?.trim() || "web_chat" +import { readKefuWidgetConfig } from "@/lib/kefu-widget-config" export type ImRealtimeEnvelope = { type: string @@ -20,11 +16,22 @@ export type ImRealtimeEnvelope = { } export function createImRealtimeConnection() { - const baseUrl = createWebSocketBaseUrl() + const config = readKefuWidgetConfig() + const apiBaseUrl = (config.apiBaseUrl || config.baseUrl || "").trim() + const baseUrl = apiBaseUrl + ? apiBaseUrl.replace(/^http/, "ws").replace(/\/$/, "") + : createWebSocketBaseUrl() const externalId = encodeURIComponent(getImVisitorId()) - const externalSource = encodeURIComponent(OPEN_IM_EXTERNAL_SOURCE) - const channelId = encodeURIComponent(OPEN_IM_CHANNEL_ID) + const externalSource = encodeURIComponent( + (config.externalSource ?? "web_chat").trim() || "web_chat" + ) + const channelId = encodeURIComponent(config.channelId || "") + const externalName = (config.subject ?? "").trim() + const nameQuery = + externalName !== "" + ? `&externalName=${encodeURIComponent(externalName)}` + : "" return new WebSocket( - `${baseUrl}/api/open/im/ws?externalId=${externalId}&externalSource=${externalSource}&channelId=${channelId}` + `${baseUrl}/api/open/im/ws?externalId=${externalId}&externalSource=${externalSource}&channelId=${channelId}${nameQuery}` ) } diff --git a/web/lib/kefu-host-bridge.ts b/web/lib/kefu-host-bridge.ts index 0fb3478..9586371 100644 --- a/web/lib/kefu-host-bridge.ts +++ b/web/lib/kefu-host-bridge.ts @@ -1,9 +1,15 @@ +import { + setKefuWidgetConfig, + type KefuWidgetHostConfig, +} from "@/lib/kefu-widget-config" + type HostBridgeOptions = { onOpen?: () => void onMinimize?: () => void onMaximizedChange?: (isMaximized: boolean) => void } +const INIT_MESSAGE_TYPE = "cs-agent:init" const OPEN_MESSAGE_TYPE = "cs-agent:open" const MINIMIZE_MESSAGE_TYPE = "cs-agent:minimize" const MAXIMIZED_MESSAGE_TYPE = "cs-agent:maximized" @@ -25,13 +31,18 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) { const data = event.data as | { type?: string - payload?: { isMaximized?: boolean } + payload?: KefuWidgetHostConfig | { isMaximized?: boolean } } | undefined if (!data?.type) { return } + if (data.type === INIT_MESSAGE_TYPE && data.payload) { + setKefuWidgetConfig(data.payload as KefuWidgetHostConfig) + return + } + if (data.type === OPEN_MESSAGE_TYPE) { options.onOpen?.() return @@ -43,7 +54,8 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) { } if (data.type === MAXIMIZED_MESSAGE_TYPE) { - options.onMaximizedChange?.(Boolean(data.payload?.isMaximized)) + const payload = data.payload as { isMaximized?: boolean } | undefined + options.onMaximizedChange?.(Boolean(payload?.isMaximized)) } } @@ -71,4 +83,3 @@ export function requestKefuHostClose() { export function requestKefuHostToggleMaximize() { postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE) } - diff --git a/web/lib/kefu-widget-config.ts b/web/lib/kefu-widget-config.ts new file mode 100644 index 0000000..f80a057 --- /dev/null +++ b/web/lib/kefu-widget-config.ts @@ -0,0 +1,67 @@ +export type KefuWidgetHostConfig = { + channelId: string + baseUrl: string + apiBaseUrl?: string + /** 与后端 enums.ExternalSource 一致,默认 web_chat */ + externalSource?: string + title?: string + subtitle?: string + position?: "left" | "right" + themeColor?: string + width?: string + /** 访客展示名,随请求以 X-External-Name / WS query externalName 传给后端 */ + subject?: string +} + +declare global { + interface Window { + CSAgentConfig?: KefuWidgetHostConfig + __CS_AGENT_WIDGET_CONFIG__?: KefuWidgetHostConfig + __CS_AGENT_WIDGET_STATE__?: unknown + } +} + +export function readKefuWidgetConfig(): KefuWidgetHostConfig { + if (typeof window === "undefined") { + return { + channelId: "", + baseUrl: "", + apiBaseUrl: "", + } + } + + const query = new URLSearchParams(window.location.search) + const fallback: KefuWidgetHostConfig = { + channelId: + query.get("channelId") ?? + process.env.NEXT_PUBLIC_OPEN_IM_CHANNEL_ID?.trim() ?? + "", + baseUrl: + query.get("baseUrl") ?? + process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ?? + window.location.origin, + apiBaseUrl: + query.get("apiBaseUrl") ?? + process.env.NEXT_PUBLIC_API_BASE_URL?.trim() ?? + undefined, + externalSource: + query.get("externalSource") ?? + process.env.NEXT_PUBLIC_OPEN_IM_EXTERNAL_SOURCE?.trim() ?? + undefined, + title: query.get("title") ?? undefined, + subtitle: query.get("subtitle") ?? undefined, + position: (query.get("position") as "left" | "right" | null) ?? undefined, + themeColor: query.get("themeColor") ?? undefined, + width: query.get("width") ?? undefined, + subject: query.get("subject") ?? undefined, + } + + return window.__CS_AGENT_WIDGET_CONFIG__ ?? window.CSAgentConfig ?? fallback +} + +export function setKefuWidgetConfig(config: KefuWidgetHostConfig) { + if (typeof window === "undefined") { + return + } + window.__CS_AGENT_WIDGET_CONFIG__ = config +} diff --git a/web/lib/stores/kefu-chat.ts b/web/lib/stores/kefu-chat.ts index fa6edb6..bc48dae 100644 --- a/web/lib/stores/kefu-chat.ts +++ b/web/lib/stores/kefu-chat.ts @@ -72,6 +72,10 @@ function mergeMessagesByIdAsc(a: ImMessage[], b: ImMessage[]): ImMessage[] { return Array.from(byId.values()).sort((x, y) => x.id - y.id) } +function ensureMessageList(value: ImMessage[] | null | undefined): ImMessage[] { + return Array.isArray(value) ? value : [] +} + function parseCursorId(cursor: string): number { const value = Number.parseInt(cursor, 10) return Number.isFinite(value) && value > 0 ? value : 0 @@ -394,10 +398,11 @@ export const useKefuChatStore = create((set, get) => { conversationId, limit: DEFAULT_PAGE_LIMIT, }) + const results = ensureMessageList(page.results) set({ - messages: page.results, - messagesCursor: cursorFromLoadedMessages(page.results) || page.cursor || "", - messagesHasMore: Boolean(page.hasMore) || page.results.length >= DEFAULT_PAGE_LIMIT, + messages: results, + messagesCursor: cursorFromLoadedMessages(results) || page.cursor || "", + messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT, }) } catch (error) { set({ @@ -418,7 +423,7 @@ export const useKefuChatStore = create((set, get) => { conversationId, limit: DEFAULT_PAGE_LIMIT, }) - const batch = page.results + const batch = ensureMessageList(page.results) if (batch.length === 0) { return } @@ -466,12 +471,13 @@ export const useKefuChatStore = create((set, get) => { cursor: cursorId, limit: DEFAULT_PAGE_LIMIT, }) + const results = ensureMessageList(page.results) set((state) => { - const merged = mergeMessagesByIdAsc(page.results, state.messages) + const merged = mergeMessagesByIdAsc(results, ensureMessageList(state.messages)) return { messages: merged, messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "", - messagesHasMore: Boolean(page.hasMore) || page.results.length >= DEFAULT_PAGE_LIMIT, + messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT, messagesLoadingMore: false, } }) diff --git a/web/public/sdk/cs-agent-widget.js b/web/public/sdk/cs-agent-widget.js new file mode 100644 index 0000000..8dc53b1 --- /dev/null +++ b/web/public/sdk/cs-agent-widget.js @@ -0,0 +1,363 @@ +(function () { + var DEFAULT_CONFIG = { + position: "right", + themeColor: "#0f6cbd", + width: "380px", + externalSource: "web_chat", + }; + + var state = window.__CS_AGENT_WIDGET_STATE__; + if (!state) { + state = { + button: null, + frame: null, + frameLoaded: false, + frameReady: false, + initSent: false, + isOpen: false, + isMaximized: false, + frameHideTimer: null, + frameDestroyTimer: null, + config: null, + frameUrl: null, + animationDuration: 260, + }; + window.__CS_AGENT_WIDGET_STATE__ = state; + } + + function normalizeConfig(config) { + var merged = {}; + var key; + for (key in DEFAULT_CONFIG) { + if (Object.prototype.hasOwnProperty.call(DEFAULT_CONFIG, key)) { + merged[key] = DEFAULT_CONFIG[key]; + } + } + config = config || {}; + for (key in config) { + if (Object.prototype.hasOwnProperty.call(config, key)) { + merged[key] = config[key]; + } + } + merged.baseUrl = String(merged.baseUrl || window.location.origin).replace(/\/$/, ""); + merged.apiBaseUrl = String(merged.apiBaseUrl || merged.baseUrl).replace(/\/$/, ""); + merged.channelId = String(merged.channelId || ""); + merged.externalSource = String(merged.externalSource || "web_chat"); + return merged; + } + + function resolveWidgetBaseUrl(config) { + var currentScript = document.currentScript; + if (currentScript && currentScript.src) { + return currentScript.src.replace(/\/sdk\/cs-agent-widget\.js(?:\?.*)?$/, ""); + } + return String(config.widgetBaseUrl || config.baseUrl || window.location.origin).replace(/\/$/, ""); + } + + function createFrameUrl(config) { + var widgetBaseUrl = resolveWidgetBaseUrl(config); + var frameUrl = new URL(widgetBaseUrl + "/kefu/chat/"); + frameUrl.searchParams.set("channelId", config.channelId); + frameUrl.searchParams.set("baseUrl", config.baseUrl); + if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl); + if (config.externalSource) frameUrl.searchParams.set("externalSource", config.externalSource); + if (config.title) frameUrl.searchParams.set("title", config.title); + if (config.subtitle) frameUrl.searchParams.set("subtitle", config.subtitle); + if (config.position) frameUrl.searchParams.set("position", config.position); + if (config.themeColor) frameUrl.searchParams.set("themeColor", config.themeColor); + if (config.width) frameUrl.searchParams.set("width", config.width); + if (config.subject) frameUrl.searchParams.set("subject", config.subject); + return frameUrl; + } + + function clearFrameTimers() { + if (state.frameHideTimer) { + window.clearTimeout(state.frameHideTimer); + state.frameHideTimer = null; + } + if (state.frameDestroyTimer) { + window.clearTimeout(state.frameDestroyTimer); + state.frameDestroyTimer = null; + } + } + + function applyFrameLayout() { + var frame = state.frame; + var config = state.config; + if (!frame || !config) { + return; + } + + frame.style.position = "fixed"; + frame.style.border = "0"; + frame.style.overflow = "hidden"; + frame.style.background = "#fff"; + frame.style.zIndex = "2147483000"; + frame.style.boxShadow = "0 28px 80px rgba(15, 35, 65, 0.28)"; + frame.style.willChange = "top,right,bottom,left,width,height,opacity,transform,border-radius"; + frame.style.transition = + "top 260ms cubic-bezier(0.22, 1, 0.36, 1), right 260ms cubic-bezier(0.22, 1, 0.36, 1), bottom 260ms cubic-bezier(0.22, 1, 0.36, 1), left 260ms cubic-bezier(0.22, 1, 0.36, 1), width 260ms cubic-bezier(0.22, 1, 0.36, 1), height 260ms cubic-bezier(0.22, 1, 0.36, 1), opacity 220ms ease, transform 260ms cubic-bezier(0.22, 1, 0.36, 1), border-radius 260ms cubic-bezier(0.22, 1, 0.36, 1), box-shadow 260ms ease"; + frame.style.transformOrigin = + config.position === "left" ? "left bottom" : "right bottom"; + + if (state.isMaximized) { + frame.style.top = "20px"; + frame.style.right = "20px"; + frame.style.bottom = "20px"; + frame.style.left = "20px"; + frame.style.width = "calc(100vw - 40px)"; + frame.style.maxWidth = "none"; + frame.style.height = "calc(100vh - 40px)"; + frame.style.borderRadius = "24px"; + return; + } + + frame.style.top = ""; + frame.style.bottom = "88px"; + frame.style.right = config.position === "left" ? "" : "24px"; + frame.style.left = config.position === "left" ? "24px" : ""; + frame.style.width = config.width || "380px"; + frame.style.maxWidth = "calc(100vw - 24px)"; + frame.style.height = "min(760px, calc(100vh - 112px))"; + frame.style.borderRadius = "28px"; + } + + function postToFrame(message) { + if (!state.frame || !state.frame.contentWindow || !state.frameUrl) { + return; + } + try { + state.frame.contentWindow.postMessage(message, state.frameUrl.origin); + } catch (error) { + console.error("[cs-agent-widget] postMessage failed", error); + } + } + + function flushFrameState() { + if (!state.frame || !state.frameLoaded || !state.frameReady) { + return; + } + + if (!state.initSent) { + state.initSent = true; + postToFrame({ + type: "cs-agent:init", + payload: state.config, + }); + } + + postToFrame({ type: state.isOpen ? "cs-agent:open" : "cs-agent:minimize" }); + postToFrame({ + type: "cs-agent:maximized", + payload: { isMaximized: state.isMaximized }, + }); + } + + function syncFrameVisibility() { + var frame = state.frame; + if (!frame) { + return; + } + clearFrameTimers(); + applyFrameLayout(); + frame.style.display = "block"; + + if (state.isOpen) { + frame.style.visibility = "visible"; + frame.style.pointerEvents = "auto"; + state.frameHideTimer = window.setTimeout(function () { + if (!state.frame) { + return; + } + state.frame.style.opacity = "1"; + state.frame.style.transform = "translate3d(0, 0, 0) scale(1)"; + }, 16); + flushFrameState(); + return; + } + + frame.style.pointerEvents = "none"; + frame.style.opacity = "0"; + frame.style.transform = state.isMaximized + ? "translate3d(0, 10px, 0) scale(0.985)" + : "translate3d(0, 16px, 0) scale(0.96)"; + state.frameHideTimer = window.setTimeout(function () { + if (!state.frame || state.isOpen) { + return; + } + state.frame.style.visibility = "hidden"; + }, state.animationDuration); + flushFrameState(); + } + + function destroyFrame() { + if (!state.frame) { + return; + } + clearFrameTimers(); + state.frame.style.pointerEvents = "none"; + state.frame.style.opacity = "0"; + state.frame.style.transform = "translate3d(0, 18px, 0) scale(0.94)"; + state.frame.style.visibility = "hidden"; + state.frameDestroyTimer = window.setTimeout(function () { + if (!state.frame) { + return; + } + if (state.frame.parentNode) { + state.frame.parentNode.removeChild(state.frame); + } + state.frame = null; + state.frameLoaded = false; + state.frameReady = false; + state.initSent = false; + state.isOpen = false; + state.isMaximized = false; + clearFrameTimers(); + }, state.animationDuration); + } + + function createFrame() { + if (state.frame) { + return state.frame; + } + + state.frame = document.createElement("iframe"); + state.frame.dataset.csAgentWidget = "frame"; + state.frame.title = state.config.title || "在线客服"; + state.frame.src = state.frameUrl.toString(); + applyFrameLayout(); + state.frame.style.display = "block"; + state.frame.style.visibility = "hidden"; + state.frame.style.pointerEvents = "none"; + state.frame.style.opacity = "0"; + state.frame.style.transform = "translate3d(0, 18px, 0) scale(0.96)"; + state.frame.addEventListener("load", function () { + state.frameLoaded = true; + syncFrameVisibility(); + }); + + document.body.appendChild(state.frame); + return state.frame; + } + + function handleWindowMessage(event) { + if (!state.frame || event.source !== state.frame.contentWindow) { + return; + } + + var data = event.data || {}; + if (data.type === "cs-agent:ready") { + state.frameReady = true; + flushFrameState(); + return; + } + + if (data.type === "cs-agent:request-minimize") { + state.isOpen = false; + syncFrameVisibility(); + return; + } + + if (data.type === "cs-agent:request-close") { + destroyFrame(); + return; + } + + if (data.type === "cs-agent:request-toggle-maximize") { + state.isMaximized = !state.isMaximized; + syncFrameVisibility(); + } + } + + function createLauncher() { + if (state.button) { + return state.button; + } + + var config = state.config; + var button = document.createElement("button"); + button.type = "button"; + button.dataset.csAgentWidget = "launcher"; + button.setAttribute("aria-label", config.title || "在线客服"); + button.textContent = config.title || "在线客服"; + button.style.position = "fixed"; + button.style.bottom = "24px"; + button.style.right = config.position === "left" ? "" : "24px"; + button.style.left = config.position === "left" ? "24px" : ""; + button.style.zIndex = "2147483000"; + button.style.border = "0"; + button.style.borderRadius = "999px"; + button.style.padding = "14px 18px"; + button.style.background = config.themeColor || "#0f6cbd"; + button.style.color = "#fff"; + button.style.font = "600 14px/1 sans-serif"; + button.style.boxShadow = "0 18px 40px rgba(15, 35, 65, 0.24)"; + button.style.cursor = "pointer"; + + button.addEventListener("click", function () { + if (!state.frame) { + createFrame(); + } + state.isOpen = !state.isOpen; + syncFrameVisibility(); + }); + + document.body.appendChild(button); + state.button = button; + return button; + } + + function mount(config) { + state.config = normalizeConfig(config || window.CSAgentConfig || {}); + if (!state.config.channelId || !state.config.baseUrl) { + console.error("[cs-agent-widget] channelId and baseUrl are required"); + return; + } + + state.frameUrl = createFrameUrl(state.config); + createLauncher(); + } + + function destroy() { + clearFrameTimers(); + if (state.frame && state.frame.parentNode) { + state.frame.parentNode.removeChild(state.frame); + } + if (state.button && state.button.parentNode) { + state.button.parentNode.removeChild(state.button); + } + state.button = null; + state.frame = null; + state.frameLoaded = false; + state.frameReady = false; + state.initSent = false; + state.isOpen = false; + state.isMaximized = false; + } + + window.CSAgentWidget = { + mount: mount, + destroy: destroy, + open: function () { + if (!state.frame) { + createFrame(); + } + state.isOpen = true; + syncFrameVisibility(); + }, + close: function () { + state.isOpen = false; + syncFrameVisibility(); + }, + }; + + if (!state.listenerBound) { + window.addEventListener("message", handleWindowMessage); + state.listenerBound = true; + } + + if (window.CSAgentConfig) { + mount(window.CSAgentConfig); + } +})(); +