Init
This commit is contained in:
@@ -0,0 +1,5 @@
|
||||
import { ChatShell } from "@/components/im/chat-shell";
|
||||
|
||||
export default function FramePage() {
|
||||
return <ChatShell />;
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
:root {
|
||||
--background: #f3f7fb;
|
||||
--foreground: #111827;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #111827;
|
||||
--muted: #f3f4f6;
|
||||
--muted-foreground: #6b7280;
|
||||
--primary: #2563eb;
|
||||
--primary-foreground: #ffffff;
|
||||
--border: #e5e7eb;
|
||||
--danger: #dc2626;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(37, 99, 235, 0.12), transparent 34%),
|
||||
linear-gradient(180deg, #f7fbff 0%, #edf4fb 100%);
|
||||
color: var(--foreground);
|
||||
font-family:
|
||||
"SF Pro Display", "Segoe UI", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
||||
}
|
||||
|
||||
body {
|
||||
overscroll-behavior: none;
|
||||
}
|
||||
|
||||
button,
|
||||
input,
|
||||
textarea {
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
textarea.cs-agent-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
textarea.cs-agent-scrollbar::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
textarea.cs-agent-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
textarea.cs-agent-scrollbar::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
textarea.cs-agent-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.cs-agent-scrollbar {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #cbd5e1 transparent;
|
||||
}
|
||||
|
||||
.cs-agent-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.cs-agent-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cs-agent-scrollbar::-webkit-scrollbar-thumb {
|
||||
border: 2px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: #cbd5e1;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.cs-agent-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
.cs-agent-shell {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.cs-agent-panel {
|
||||
border-radius: 28px;
|
||||
box-shadow:
|
||||
0 24px 80px rgba(15, 23, 42, 0.14),
|
||||
0 10px 24px rgba(15, 23, 42, 0.08);
|
||||
backdrop-filter: blur(16px);
|
||||
}
|
||||
|
||||
.cs-agent-grid-bg {
|
||||
background-image:
|
||||
radial-gradient(circle at top, rgba(255, 255, 255, 0.72), transparent 32%),
|
||||
linear-gradient(180deg, rgba(247, 249, 252, 0.92) 0%, rgba(238, 244, 251, 0.94) 100%),
|
||||
linear-gradient(rgba(148, 163, 184, 0.08) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(148, 163, 184, 0.08) 1px, transparent 1px);
|
||||
background-size:
|
||||
auto,
|
||||
auto,
|
||||
22px 22px,
|
||||
22px 22px;
|
||||
background-position:
|
||||
center top,
|
||||
center,
|
||||
center,
|
||||
center;
|
||||
}
|
||||
|
||||
.cs-agent-fade-up {
|
||||
animation: cs-agent-fade-up 220ms ease-out;
|
||||
}
|
||||
|
||||
.cs-agent-status-dot {
|
||||
animation: cs-agent-pulse 1.8s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes cs-agent-fade-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate3d(0, 8px, 0);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes cs-agent-pulse {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
50% {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
}
|
||||
|
||||
/* 图片预览:遮罩在 dialog 内部绘制,避免与原生 ::backdrop 叠色 */
|
||||
.cs-agent-image-lightbox::backdrop {
|
||||
background: transparent;
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Metadata } from "next";
|
||||
|
||||
import { ImageLightboxProvider } from "@/components/image-lightbox";
|
||||
|
||||
import "./globals.css";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "贝壳AI客服插件",
|
||||
description: "Embedded customer service widget",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="zh-CN">
|
||||
<body>
|
||||
<ImageLightboxProvider>{children}</ImageLightboxProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
"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;
|
||||
Reference in New Issue
Block a user