feat: add Kefu widget and related components for customer service integration
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
import { KefuWidgetDemo } from "@/components/kefu/widget-demo"
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
return <KefuWidgetDemo />
|
||||||
|
}
|
||||||
|
|
||||||
@@ -79,6 +79,7 @@ export function KefuChatShell() {
|
|||||||
closeConversation: state.closeConversation,
|
closeConversation: state.closeConversation,
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
const safeMessages = Array.isArray(messages) ? messages : []
|
||||||
|
|
||||||
const maybeMarkConversationRead = useCallback(() => {
|
const maybeMarkConversationRead = useCallback(() => {
|
||||||
if (!isVisible || !conversation || typeof document === "undefined") {
|
if (!isVisible || !conversation || typeof document === "undefined") {
|
||||||
@@ -119,7 +120,7 @@ export function KefuChatShell() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
maybeMarkConversationRead()
|
maybeMarkConversationRead()
|
||||||
}, [maybeMarkConversationRead, messages.length])
|
}, [maybeMarkConversationRead, safeMessages.length])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleVisibilityChange = () => {
|
const handleVisibilityChange = () => {
|
||||||
@@ -252,7 +253,7 @@ export function KefuChatShell() {
|
|||||||
<div className=".cs-agent-grid-bg grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden">
|
<div className=".cs-agent-grid-bg grid min-h-0 flex-1 grid-rows-[minmax(0,1fr)_auto] overflow-hidden">
|
||||||
<KefuMessageList
|
<KefuMessageList
|
||||||
ref={messageListRef}
|
ref={messageListRef}
|
||||||
messages={messages}
|
messages={safeMessages}
|
||||||
onNearBottomVisible={maybeMarkConversationRead}
|
onNearBottomVisible={maybeMarkConversationRead}
|
||||||
hasMoreOlder={messagesHasMore}
|
hasMoreOlder={messagesHasMore}
|
||||||
loadingOlder={messagesLoadingMore}
|
loadingOlder={messagesLoadingMore}
|
||||||
@@ -311,4 +312,3 @@ export function KefuChatShell() {
|
|||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { renderIMMessageHTML } from "@/lib/im-message"
|
|||||||
import { cn, formatDateTime } from "@/lib/utils"
|
import { cn, formatDateTime } from "@/lib/utils"
|
||||||
|
|
||||||
type KefuMessageListProps = {
|
type KefuMessageListProps = {
|
||||||
messages: ImMessage[]
|
messages?: ImMessage[] | null
|
||||||
onNearBottomVisible?: () => void
|
onNearBottomVisible?: () => void
|
||||||
hasMoreOlder?: boolean
|
hasMoreOlder?: boolean
|
||||||
loadingOlder?: boolean
|
loadingOlder?: boolean
|
||||||
@@ -76,7 +76,8 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
|
|||||||
const contentRef = useRef<HTMLDivElement>(null)
|
const contentRef = useRef<HTMLDivElement>(null)
|
||||||
const frameRef = useRef<number | null>(null)
|
const frameRef = useRef<number | null>(null)
|
||||||
const shouldStickToBottomRef = useRef(true)
|
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(
|
const isNearBottom = useCallback(
|
||||||
(element: HTMLElement, threshold = 80) =>
|
(element: HTMLElement, threshold = 80) =>
|
||||||
@@ -221,8 +222,8 @@ export const KefuMessageList = forwardRef<KefuMessageListHandle, KefuMessageList
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{messages.map((message, index) => {
|
{safeMessages.map((message, index) => {
|
||||||
const previousMessage = index > 0 ? messages[index - 1] : null
|
const previousMessage = index > 0 ? safeMessages[index - 1] : null
|
||||||
const showTimeline =
|
const showTimeline =
|
||||||
index === 0 ||
|
index === 0 ||
|
||||||
getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt)
|
getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt)
|
||||||
|
|||||||
@@ -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<KefuWidgetHostConfig>)
|
||||||
|
: {}
|
||||||
|
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<KefuWidgetHostConfig>(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 `<script>
|
||||||
|
window.CSAgentConfig = {
|
||||||
|
channelId: "${config.channelId || ""}",
|
||||||
|
baseUrl: "${config.baseUrl || ""}",
|
||||||
|
apiBaseUrl: "${config.apiBaseUrl || config.baseUrl || ""}",
|
||||||
|
externalSource: "${config.externalSource || "web_chat"}",
|
||||||
|
title: "${config.title || "在线客服"}",
|
||||||
|
subtitle: "${config.subtitle || ""}",
|
||||||
|
position: "${config.position || "right"}",
|
||||||
|
themeColor: "${config.themeColor || "#2563eb"}",
|
||||||
|
width: "${config.width || "380px"}",
|
||||||
|
subject: "${config.subject || ""}",
|
||||||
|
};
|
||||||
|
</script>
|
||||||
|
<script async src="${scriptSrc}"></script>`
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
function updateField<K extends keyof KefuWidgetHostConfig>(
|
||||||
|
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 (
|
||||||
|
<main className="min-h-svh bg-slate-50 px-6 py-8 text-slate-950">
|
||||||
|
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[360px_minmax(0,1fr)]">
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-base font-semibold">Widget 挂载测试</div>
|
||||||
|
<div className="mt-1 text-sm text-slate-500">{status}</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid gap-3">
|
||||||
|
<TextField
|
||||||
|
label="channelId"
|
||||||
|
value={config.channelId}
|
||||||
|
onChange={(value) => updateField("channelId", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="baseUrl"
|
||||||
|
value={config.baseUrl}
|
||||||
|
onChange={(value) => updateField("baseUrl", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="apiBaseUrl"
|
||||||
|
value={config.apiBaseUrl || ""}
|
||||||
|
onChange={(value) => updateField("apiBaseUrl", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="title"
|
||||||
|
value={config.title || ""}
|
||||||
|
onChange={(value) => updateField("title", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="subtitle"
|
||||||
|
value={config.subtitle || ""}
|
||||||
|
onChange={(value) => updateField("subtitle", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="themeColor"
|
||||||
|
value={config.themeColor || ""}
|
||||||
|
onChange={(value) => updateField("themeColor", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="width"
|
||||||
|
value={config.width || ""}
|
||||||
|
onChange={(value) => updateField("width", value)}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
label="subject"
|
||||||
|
value={config.subject || ""}
|
||||||
|
onChange={(value) => updateField("subject", value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleMount}
|
||||||
|
className="rounded-md bg-slate-950 px-4 py-2 text-sm font-medium text-white"
|
||||||
|
>
|
||||||
|
挂载
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => window.CSAgentWidget?.open()}
|
||||||
|
className="rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
打开
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
removeMountedWidget()
|
||||||
|
setStatus("Widget 已卸载")
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-slate-200 bg-white px-4 py-2 text-sm font-medium"
|
||||||
|
>
|
||||||
|
卸载
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-slate-200 bg-white p-5 shadow-sm">
|
||||||
|
<div className="text-base font-semibold">接入代码</div>
|
||||||
|
<pre className="mt-4 overflow-x-auto rounded-md bg-slate-950 p-4 text-xs leading-5 text-slate-100">
|
||||||
|
<code>{snippet}</code>
|
||||||
|
</pre>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function TextField({
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
}: {
|
||||||
|
label: string
|
||||||
|
value?: string
|
||||||
|
onChange: (value: string) => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-1.5 text-sm">
|
||||||
|
<span className="font-medium text-slate-700">{label}</span>
|
||||||
|
<input
|
||||||
|
value={value || ""}
|
||||||
|
onChange={(event) => onChange(event.target.value)}
|
||||||
|
className="h-9 rounded-md border border-slate-200 px-3 text-sm outline-none focus:border-slate-400"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@ type JsonResult<T> = {
|
|||||||
type RequestOptions = RequestInit & {
|
type RequestOptions = RequestInit & {
|
||||||
skipAuth?: boolean
|
skipAuth?: boolean
|
||||||
retryOnAuthError?: boolean
|
retryOnAuthError?: boolean
|
||||||
|
baseUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
async function parseResult<T>(response: Response) {
|
async function parseResult<T>(response: Response) {
|
||||||
@@ -57,8 +58,9 @@ export async function request<T>(
|
|||||||
options: RequestOptions = {},
|
options: RequestOptions = {},
|
||||||
retryOnAuthError = true
|
retryOnAuthError = true
|
||||||
): Promise<T> {
|
): Promise<T> {
|
||||||
const { headers, skipAuth, ...rest } = options
|
const { headers, skipAuth, baseUrl, ...rest } = options
|
||||||
delete (rest as RequestOptions).retryOnAuthError
|
delete (rest as RequestOptions).retryOnAuthError
|
||||||
|
delete (rest as RequestOptions).baseUrl
|
||||||
const session = readSession()
|
const session = readSession()
|
||||||
const authHeaders = new Headers(headers)
|
const authHeaders = new Headers(headers)
|
||||||
|
|
||||||
@@ -73,7 +75,8 @@ export async function request<T>(
|
|||||||
authHeaders.set("Content-Type", "application/json")
|
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,
|
...rest,
|
||||||
headers: authHeaders,
|
headers: authHeaders,
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
|
|||||||
+55
-17
@@ -1,4 +1,5 @@
|
|||||||
import { request } from "@/lib/api/client"
|
import { request } from "@/lib/api/client"
|
||||||
|
import { readKefuWidgetConfig } from "@/lib/kefu-widget-config"
|
||||||
import { generateUUID } from "@/lib/utils"
|
import { generateUUID } from "@/lib/utils"
|
||||||
|
|
||||||
export type Paging = {
|
export type Paging = {
|
||||||
@@ -8,7 +9,7 @@ export type Paging = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PageResult<T> = {
|
export type PageResult<T> = {
|
||||||
results: T[]
|
results?: T[] | null
|
||||||
page: Paging
|
page: Paging
|
||||||
cursor?: string
|
cursor?: string
|
||||||
hasMore?: boolean
|
hasMore?: boolean
|
||||||
@@ -133,11 +134,46 @@ export function getImVisitorId() {
|
|||||||
return visitorId
|
return visitorId
|
||||||
}
|
}
|
||||||
|
|
||||||
function createImHeaders() {
|
function getRuntimeImConfig() {
|
||||||
|
const widgetConfig = readKefuWidgetConfig()
|
||||||
|
const baseUrl = (widgetConfig.apiBaseUrl || widgetConfig.baseUrl || API_BASE_URL)
|
||||||
|
.trim()
|
||||||
|
.replace(/\/$/, "")
|
||||||
return {
|
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<string, string> = {
|
||||||
|
"X-External-Source": config.externalSource,
|
||||||
"X-External-Id": getImVisitorId(),
|
"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<string, string> | undefined),
|
||||||
|
},
|
||||||
|
baseUrl: getRuntimeImConfig().baseUrl,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -159,7 +195,7 @@ function toQueryString(query?: Record<string, string | number | undefined>) {
|
|||||||
|
|
||||||
export function fetchImConversationDetail(id: number) {
|
export function fetchImConversationDetail(id: number) {
|
||||||
return request<ImConversationDetail>(`/api/open/im/conversation/${id}`, {
|
return request<ImConversationDetail>(`/api/open/im/conversation/${id}`, {
|
||||||
headers: createImHeaders(),
|
...createRequestOptions(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,34 +204,32 @@ export function fetchImMessages(
|
|||||||
) {
|
) {
|
||||||
return request<PageResult<ImMessage>>(
|
return request<PageResult<ImMessage>>(
|
||||||
`/api/open/im/message/list${toQueryString(query)}`,
|
`/api/open/im/message/list${toQueryString(query)}`,
|
||||||
{ headers: createImHeaders() }
|
createRequestOptions()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 外部身份仅通过 createImHeaders()(X-External-*)传递,无 JSON body */
|
/** 外部身份仅通过 createImHeaders()(X-External-*)传递,无 JSON body */
|
||||||
export function createOrMatchImConversation() {
|
export function createOrMatchImConversation() {
|
||||||
return request<ImConversation>("/api/open/im/conversation/create_or_match", {
|
return request<ImConversation>("/api/open/im/conversation/create_or_match", {
|
||||||
method: "POST",
|
...createRequestOptions({ method: "POST" }),
|
||||||
headers: createImHeaders(),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function fetchImWidgetConfig() {
|
export function fetchImWidgetConfig() {
|
||||||
return request<ImWidgetConfig>(
|
return request<ImWidgetConfig>(
|
||||||
`/api/open/im/widget/config${toQueryString({
|
`/api/open/im/widget/config${toQueryString({
|
||||||
channelId: OPEN_IM_CHANNEL_ID,
|
channelId: getRuntimeImConfig().channelId,
|
||||||
})}`,
|
})}`,
|
||||||
{
|
createRequestOptions()
|
||||||
headers: createImHeaders(),
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function closeImConversation(conversationId: number) {
|
export function closeImConversation(conversationId: number) {
|
||||||
return request<void>("/api/open/im/conversation/close", {
|
return request<void>("/api/open/im/conversation/close", {
|
||||||
|
...createRequestOptions({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: createImHeaders(),
|
|
||||||
body: JSON.stringify({ conversationId }),
|
body: JSON.stringify({ conversationId }),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,17 +241,19 @@ export function sendImMessage(payload: {
|
|||||||
clientMsgId?: string
|
clientMsgId?: string
|
||||||
}) {
|
}) {
|
||||||
return request<ImMessage>("/api/open/im/message/send", {
|
return request<ImMessage>("/api/open/im/message/send", {
|
||||||
|
...createRequestOptions({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: createImHeaders(),
|
|
||||||
body: JSON.stringify(payload),
|
body: JSON.stringify(payload),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function markImMessageRead(conversationId: number, messageId = 0) {
|
export function markImMessageRead(conversationId: number, messageId = 0) {
|
||||||
return request<void>("/api/open/im/message/read", {
|
return request<void>("/api/open/im/message/read", {
|
||||||
|
...createRequestOptions({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: createImHeaders(),
|
|
||||||
body: JSON.stringify({ conversationId, messageId }),
|
body: JSON.stringify({ conversationId, messageId }),
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -226,9 +262,10 @@ export function uploadImImage(conversationId: number, file: File) {
|
|||||||
formData.set("conversationId", String(conversationId))
|
formData.set("conversationId", String(conversationId))
|
||||||
formData.set("file", file)
|
formData.set("file", file)
|
||||||
return request<ImAsset>("/api/open/im/message/upload_image", {
|
return request<ImAsset>("/api/open/im/message/upload_image", {
|
||||||
|
...createRequestOptions({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: createImHeaders(),
|
|
||||||
body: formData,
|
body: formData,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +274,9 @@ export function uploadImAttachment(conversationId: number, file: File) {
|
|||||||
formData.set("conversationId", String(conversationId))
|
formData.set("conversationId", String(conversationId))
|
||||||
formData.set("file", file)
|
formData.set("file", file)
|
||||||
return request<ImAsset>("/api/open/im/message/upload_attachment", {
|
return request<ImAsset>("/api/open/im/message/upload_attachment", {
|
||||||
|
...createRequestOptions({
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: createImHeaders(),
|
|
||||||
body: formData,
|
body: formData,
|
||||||
|
}),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-9
@@ -1,10 +1,6 @@
|
|||||||
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
import { createWebSocketBaseUrl } from "@/lib/api/websocket"
|
||||||
import { getImVisitorId } from "@/lib/api/im"
|
import { getImVisitorId } from "@/lib/api/im"
|
||||||
|
import { readKefuWidgetConfig } from "@/lib/kefu-widget-config"
|
||||||
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"
|
|
||||||
|
|
||||||
export type ImRealtimeEnvelope = {
|
export type ImRealtimeEnvelope = {
|
||||||
type: string
|
type: string
|
||||||
@@ -20,11 +16,22 @@ export type ImRealtimeEnvelope = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function createImRealtimeConnection() {
|
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 externalId = encodeURIComponent(getImVisitorId())
|
||||||
const externalSource = encodeURIComponent(OPEN_IM_EXTERNAL_SOURCE)
|
const externalSource = encodeURIComponent(
|
||||||
const channelId = encodeURIComponent(OPEN_IM_CHANNEL_ID)
|
(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(
|
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}`
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
|
import {
|
||||||
|
setKefuWidgetConfig,
|
||||||
|
type KefuWidgetHostConfig,
|
||||||
|
} from "@/lib/kefu-widget-config"
|
||||||
|
|
||||||
type HostBridgeOptions = {
|
type HostBridgeOptions = {
|
||||||
onOpen?: () => void
|
onOpen?: () => void
|
||||||
onMinimize?: () => void
|
onMinimize?: () => void
|
||||||
onMaximizedChange?: (isMaximized: boolean) => void
|
onMaximizedChange?: (isMaximized: boolean) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const INIT_MESSAGE_TYPE = "cs-agent:init"
|
||||||
const OPEN_MESSAGE_TYPE = "cs-agent:open"
|
const OPEN_MESSAGE_TYPE = "cs-agent:open"
|
||||||
const MINIMIZE_MESSAGE_TYPE = "cs-agent:minimize"
|
const MINIMIZE_MESSAGE_TYPE = "cs-agent:minimize"
|
||||||
const MAXIMIZED_MESSAGE_TYPE = "cs-agent:maximized"
|
const MAXIMIZED_MESSAGE_TYPE = "cs-agent:maximized"
|
||||||
@@ -25,13 +31,18 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
|
|||||||
const data = event.data as
|
const data = event.data as
|
||||||
| {
|
| {
|
||||||
type?: string
|
type?: string
|
||||||
payload?: { isMaximized?: boolean }
|
payload?: KefuWidgetHostConfig | { isMaximized?: boolean }
|
||||||
}
|
}
|
||||||
| undefined
|
| undefined
|
||||||
if (!data?.type) {
|
if (!data?.type) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (data.type === INIT_MESSAGE_TYPE && data.payload) {
|
||||||
|
setKefuWidgetConfig(data.payload as KefuWidgetHostConfig)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (data.type === OPEN_MESSAGE_TYPE) {
|
if (data.type === OPEN_MESSAGE_TYPE) {
|
||||||
options.onOpen?.()
|
options.onOpen?.()
|
||||||
return
|
return
|
||||||
@@ -43,7 +54,8 @@ export function bindKefuHostBridge(options: HostBridgeOptions = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (data.type === MAXIMIZED_MESSAGE_TYPE) {
|
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() {
|
export function requestKefuHostToggleMaximize() {
|
||||||
postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE)
|
postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -72,6 +72,10 @@ function mergeMessagesByIdAsc(a: ImMessage[], b: ImMessage[]): ImMessage[] {
|
|||||||
return Array.from(byId.values()).sort((x, y) => x.id - y.id)
|
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 {
|
function parseCursorId(cursor: string): number {
|
||||||
const value = Number.parseInt(cursor, 10)
|
const value = Number.parseInt(cursor, 10)
|
||||||
return Number.isFinite(value) && value > 0 ? value : 0
|
return Number.isFinite(value) && value > 0 ? value : 0
|
||||||
@@ -394,10 +398,11 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
conversationId,
|
conversationId,
|
||||||
limit: DEFAULT_PAGE_LIMIT,
|
limit: DEFAULT_PAGE_LIMIT,
|
||||||
})
|
})
|
||||||
|
const results = ensureMessageList(page.results)
|
||||||
set({
|
set({
|
||||||
messages: page.results,
|
messages: results,
|
||||||
messagesCursor: cursorFromLoadedMessages(page.results) || page.cursor || "",
|
messagesCursor: cursorFromLoadedMessages(results) || page.cursor || "",
|
||||||
messagesHasMore: Boolean(page.hasMore) || page.results.length >= DEFAULT_PAGE_LIMIT,
|
messagesHasMore: Boolean(page.hasMore) || results.length >= DEFAULT_PAGE_LIMIT,
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
set({
|
set({
|
||||||
@@ -418,7 +423,7 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
conversationId,
|
conversationId,
|
||||||
limit: DEFAULT_PAGE_LIMIT,
|
limit: DEFAULT_PAGE_LIMIT,
|
||||||
})
|
})
|
||||||
const batch = page.results
|
const batch = ensureMessageList(page.results)
|
||||||
if (batch.length === 0) {
|
if (batch.length === 0) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -466,12 +471,13 @@ export const useKefuChatStore = create<KefuChatStore>((set, get) => {
|
|||||||
cursor: cursorId,
|
cursor: cursorId,
|
||||||
limit: DEFAULT_PAGE_LIMIT,
|
limit: DEFAULT_PAGE_LIMIT,
|
||||||
})
|
})
|
||||||
|
const results = ensureMessageList(page.results)
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const merged = mergeMessagesByIdAsc(page.results, state.messages)
|
const merged = mergeMessagesByIdAsc(results, ensureMessageList(state.messages))
|
||||||
return {
|
return {
|
||||||
messages: merged,
|
messages: merged,
|
||||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor || "",
|
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,
|
messagesLoadingMore: false,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
Reference in New Issue
Block a user