Files
ai-agent/widget/app/page.tsx
T
mlogclub efe801b8bf Init
2026-04-09 10:01:23 +08:00

340 lines
12 KiB
TypeScript

"use client";
import { useEffect, useState } from "react";
import dynamic from "next/dynamic";
import type { WidgetHostConfig } from "@/lib/widget/config";
import { generateUUID } from "@/lib/utils";
const STORAGE_KEY = "cs-agent-widget-test-config";
type TestConfig = WidgetHostConfig;
function getWidgetRootPath(pathname: string): string {
return pathname.startsWith("/widget") ? "/widget" : "";
}
function getWidgetSdkUrl(baseUrl: string, pathname: string): string {
return `${baseUrl.replace(/\/$/, "")}${getWidgetRootPath(pathname)}/sdk/cs-agent-widget.js`;
}
function generateRandomSubject(): string {
return `用户${generateUUID().replace(/-/g, "").slice(0, 8)}`;
}
function buildDefaultConfig(baseUrl: string): TestConfig {
return {
channelId: "",
baseUrl,
apiBaseUrl: baseUrl,
title: "在线客服",
subtitle: "贝壳AI客服为您服务",
position: "right",
themeColor: "#0f6cbd",
width: "680px",
subject: generateRandomSubject(),
};
}
function getInitialConfig(): TestConfig {
if (typeof window === "undefined") {
return buildDefaultConfig("");
}
const origin = window.location.origin;
const query = new URLSearchParams(window.location.search);
const savedText = window.localStorage.getItem(STORAGE_KEY);
const savedConfig = savedText
? (JSON.parse(savedText) as Partial<TestConfig>)
: {};
return {
...buildDefaultConfig(origin),
...savedConfig,
channelId: query.get("channelId") ?? savedConfig.channelId ?? "",
baseUrl: query.get("baseUrl") ?? savedConfig.baseUrl ?? origin,
apiBaseUrl:
query.get("apiBaseUrl") ??
savedConfig.apiBaseUrl ??
savedConfig.baseUrl ??
origin,
width: query.get("width") ?? savedConfig.width ?? "680px",
subject:
query.get("subject") ?? savedConfig.subject ?? generateRandomSubject(),
};
}
function removeMountedWidget() {
if (typeof window === "undefined") {
return;
}
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 as Window & { __CS_AGENT_WIDGET_LOADED__?: boolean })
.__CS_AGENT_WIDGET_LOADED__;
}
function injectWidget(config: TestConfig) {
removeMountedWidget();
window.CSAgentConfig = config;
const script = document.createElement("script");
script.async = true;
script.src = getWidgetSdkUrl(window.location.origin, window.location.pathname);
script.dataset.csAgentWidget = "script";
document.body.appendChild(script);
}
function WidgetTestPageInner() {
const [config, setConfig] = useState<TestConfig>(getInitialConfig);
const [status, setStatus] = useState(() =>
getInitialConfig().channelId ? "Widget 已挂载" : "请先填写 channelId",
);
useEffect(() => {
if (config.channelId) {
injectWidget(config);
} else {
removeMountedWidget();
}
return () => {
removeMountedWidget();
};
}, [config]);
const currentConfig = config;
function updateField<K extends keyof TestConfig>(
key: K,
value: TestConfig[K],
) {
setConfig((current) => (current ? { ...current, [key]: value } : current));
}
function handleApply() {
const nextConfig = {
...currentConfig,
channelId: currentConfig.channelId.trim(),
baseUrl: currentConfig.baseUrl.trim() || window.location.origin,
apiBaseUrl:
currentConfig.apiBaseUrl?.trim() ||
currentConfig.baseUrl.trim() ||
window.location.origin,
title: currentConfig.title?.trim() || "在线客服",
subtitle:
currentConfig.subtitle?.trim() || "",
themeColor: currentConfig.themeColor?.trim() || "#0f6cbd",
width: currentConfig.width?.trim() || "680px",
};
setConfig(nextConfig);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig));
if (!nextConfig.channelId) {
removeMountedWidget();
setStatus("请先填写 channelId");
return;
}
injectWidget(nextConfig);
setStatus("Widget 已挂载");
}
const sdkUrl = getWidgetSdkUrl(currentConfig.baseUrl, window.location.pathname);
const snippet = `<script>
window.CSAgentConfig = {
channelId: "${currentConfig.channelId || ""}",
baseUrl: "${currentConfig.baseUrl}",
apiBaseUrl: "${currentConfig.apiBaseUrl || currentConfig.baseUrl}",
title: "${currentConfig.title || "在线客服"}",
subtitle: "${currentConfig.subtitle || ""}",
position: "${currentConfig.position || "right"}",
themeColor: "${currentConfig.themeColor || "#0f6cbd"}",
width: "${currentConfig.width || "680px"}",
subject: "${currentConfig.subject || ""}",
};
</script>
<script async src="${sdkUrl}"></script>`;
return (
<main className="min-h-screen px-4 py-6 md:px-6 md:py-7">
<div className="mx-auto grid w-full max-w-6xl gap-4 lg:grid-cols-[1.15fr_0.85fr]">
<section className="rounded-lg border border-white/70 bg-white/80 p-4 backdrop-blur md:p-5">
<div className="mb-4 flex flex-wrap items-start justify-between gap-3">
<div className="rounded-full border border-slate-200 bg-slate-50 px-3 py-1 text-xs text-slate-600">
{status}
</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
channelId
</div>
<input
value={currentConfig.channelId}
onChange={(event) => updateField("channelId", event.target.value)}
placeholder="请输入后台渠道 channelId"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
/>
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
baseUrl
</div>
<input
value={currentConfig.baseUrl}
onChange={(event) => updateField("baseUrl", event.target.value)}
placeholder="Widget 地址,例如 http://localhost:3001"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
/>
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
apiBaseUrl
</div>
<input
value={currentConfig.apiBaseUrl ?? ""}
onChange={(event) =>
updateField("apiBaseUrl", event.target.value)
}
placeholder="后端地址,例如 http://localhost:8080"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
/>
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
标题
</div>
<input
value={currentConfig.title ?? ""}
onChange={(event) => updateField("title", event.target.value)}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
/>
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
副标题
</div>
<input
value={currentConfig.subtitle ?? ""}
onChange={(event) =>
updateField("subtitle", event.target.value)
}
placeholder="例如:通常几分钟内回复,支持连续会话记录"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
/>
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
主题色
</div>
<input
value={currentConfig.themeColor ?? ""}
onChange={(event) =>
updateField("themeColor", event.target.value)
}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
/>
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
宽度
</div>
<input
value={currentConfig.width ?? ""}
onChange={(event) =>
updateField("width", event.target.value)
}
placeholder="例如 680px、50vw"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
/>
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
会话主题
</div>
<input
value={currentConfig.subject ?? ""}
onChange={(event) =>
updateField("subject", event.target.value)
}
placeholder="可选,例如:张三的咨询、订单#12345"
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
/>
</label>
<label className="block">
<div className="mb-1.5 text-xs font-medium text-slate-700">
悬浮位置
</div>
<select
value={currentConfig.position ?? "right"}
onChange={(event) =>
updateField(
"position",
event.target.value as "left" | "right",
)
}
className="w-full rounded-lg border border-slate-200 bg-white px-3 py-2.5 text-sm text-slate-900 outline-none transition focus:border-sky-400"
>
<option value="right">右侧</option>
<option value="left">左侧</option>
</select>
</label>
</div>
<div className="mt-3 flex flex-wrap gap-2.5">
<button
type="button"
onClick={handleApply}
className="rounded-md bg-(--primary) px-4 py-2 text-sm text-white transition hover:opacity-92"
>
挂载 Widget
</button>
<button
type="button"
onClick={() => {
const nextConfig = {
...currentConfig,
subject: generateRandomSubject(),
};
setConfig(nextConfig);
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig));
window.localStorage.removeItem("cs-agent:external-id");
removeMountedWidget();
setStatus("已重置");
}}
className="rounded-md border border-slate-200 bg-white px-4 py-2 text-sm text-slate-700 transition hover:border-slate-300"
>
重置
</button>
</div>
</section>
<section className="rounded-lg border border-slate-200 bg-slate-50/95 p-4 text-slate-800 md:p-5">
<p className="mt-1 text-sm leading-6 text-slate-600">
这段脚本就是外部业务系统接入时需要放到宿主页面里的最小配置。
</p>
<pre className="mt-3 overflow-x-auto rounded-md border border-slate-200 bg-white p-3 text-xs leading-5 text-slate-700">
<code>{snippet}</code>
</pre>
</section>
</div>
</main>
);
}
const WidgetTestPage = dynamic(async () => WidgetTestPageInner, {
ssr: false,
});
export default WidgetTestPage;