Remove widget
This commit is contained in:
@@ -75,13 +75,6 @@ func NewServer() (*iris.Application, error) {
|
||||
ShowList: false,
|
||||
})
|
||||
|
||||
// 注册widget静态资源服务
|
||||
app.HandleDir("/widget", iris.Dir("widget/out"), iris.DirOptions{
|
||||
IndexName: "index.html",
|
||||
Compress: true,
|
||||
ShowList: false,
|
||||
})
|
||||
|
||||
return app, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://127.0.0.1:8083
|
||||
@@ -1,42 +0,0 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
!.env.example
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
@@ -1,5 +0,0 @@
|
||||
import { ChatShell } from "@/components/im/chat-shell";
|
||||
|
||||
export default function FramePage() {
|
||||
return <ChatShell />;
|
||||
}
|
||||
@@ -1,157 +0,0 @@
|
||||
@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-x: hidden;
|
||||
background:
|
||||
radial-gradient(circle at top, rgba(37, 99, 235, 0.08), transparent 30%),
|
||||
linear-gradient(180deg, #fbfdff 0%, #f4f8fc 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;
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -1,686 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import dynamic from "next/dynamic";
|
||||
import Image from "next/image";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
ArrowRight,
|
||||
Bot,
|
||||
ChartColumn,
|
||||
Cpu,
|
||||
DatabaseZap,
|
||||
GitBranch,
|
||||
MessageSquareText,
|
||||
ShieldCheck,
|
||||
} from "lucide-react";
|
||||
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
import type { WidgetHostConfig } from "@/lib/widget/config";
|
||||
|
||||
const STORAGE_KEY = "cs-agent-widget-test-config";
|
||||
const GITHUB_URL = "https://github.com/huabeitech/cs-ai-agent";
|
||||
const GITEE_URL = "https://gitee.com/huabeitech/cs-ai-agent";
|
||||
const OFFICIAL_SITE_URL = "https://aiagent.huabei.pro";
|
||||
|
||||
type TestConfig = WidgetHostConfig;
|
||||
|
||||
const featureCards = [
|
||||
{
|
||||
title: "先让 AI 处理重复路径",
|
||||
description: "把常见问题和标准流程优先交给 Agent,降低人工压力。",
|
||||
icon: MessageSquareText,
|
||||
},
|
||||
{
|
||||
title: "人工接管不脱离上下文",
|
||||
description: "在低置信度、命中规则或用户要求时,带着会话上下文平滑转人工。",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
title: "知识、人工和工单形成闭环",
|
||||
description: "把知识检索、人工接管、外部系统调用和工单流程放进同一条客服链路。",
|
||||
icon: GitBranch,
|
||||
},
|
||||
{
|
||||
title: "面向客服运营而不是 Demo",
|
||||
description: "强调稳定运行、可观测流程和结构化升级,不把项目做成聊天壳子。",
|
||||
icon: ShieldCheck,
|
||||
},
|
||||
];
|
||||
|
||||
const scopeCards = [
|
||||
{
|
||||
title: "会话运行时",
|
||||
description: "接收用户消息、维护会话状态并触发回复执行。",
|
||||
icon: MessageSquareText,
|
||||
},
|
||||
{
|
||||
title: "AI 知识检索",
|
||||
description: "基于 RAG 的企业知识库,提升答案准确性。",
|
||||
icon: DatabaseZap,
|
||||
},
|
||||
{
|
||||
title: "AI Agent 运行时",
|
||||
description: "自定义 Skill,通过 MCP 协议对接企业业务系统。",
|
||||
icon: Cpu,
|
||||
},
|
||||
{
|
||||
title: "人工协同",
|
||||
description: "在 AI 不该继续时,把会话转给人工客服或指定客服组。",
|
||||
icon: Bot,
|
||||
},
|
||||
{
|
||||
title: "工单闭环",
|
||||
description: "从会话创建工单,跟踪处理过程,直到最终关闭。",
|
||||
icon: ChartColumn,
|
||||
},
|
||||
];
|
||||
|
||||
const flowSteps = [
|
||||
{
|
||||
step: "01",
|
||||
title: "用户发起咨询",
|
||||
description: "用户通过 Web Widget 或开放接口进入系统,创建新会话或匹配已有会话。",
|
||||
},
|
||||
{
|
||||
step: "02",
|
||||
title: "系统装载上下文",
|
||||
description: "加载会话历史、AI 配置、可用 Skill、知识库和工具能力,为当前问题准备执行上下文。",
|
||||
},
|
||||
{
|
||||
step: "03",
|
||||
title: "AI 优先回复",
|
||||
description: "AI Agent 结合历史消息、知识检索结果和可用工具,优先处理常见问题与标准流程。",
|
||||
},
|
||||
{
|
||||
step: "04",
|
||||
title: "按需调用能力",
|
||||
description: "在回复过程中执行 Skill、通过 MCP 调用外部系统,或触发特定业务流程。",
|
||||
},
|
||||
{
|
||||
step: "05",
|
||||
title: "转交人工处理",
|
||||
description: "当 AI 无法继续有效处理,或用户希望由人工接待时,会话带着上下文转交客服。",
|
||||
},
|
||||
{
|
||||
step: "06",
|
||||
title: "转入工单流程",
|
||||
description: "当问题需要跨时段、跨角色持续跟进时,可以从会话直接转入工单流程。",
|
||||
},
|
||||
];
|
||||
|
||||
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: "IoT 智能客服",
|
||||
subtitle: "设备告警、工单协同与在线服务统一入口",
|
||||
position: "right",
|
||||
themeColor: "#2563eb",
|
||||
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 WidgetHomePageInner() {
|
||||
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]);
|
||||
|
||||
function updateField<K extends keyof TestConfig>(
|
||||
key: K,
|
||||
value: TestConfig[K],
|
||||
) {
|
||||
setConfig((current) => ({ ...current, [key]: value }));
|
||||
}
|
||||
|
||||
function handleApply() {
|
||||
const nextConfig = {
|
||||
...config,
|
||||
channelId: config.channelId.trim(),
|
||||
baseUrl: config.baseUrl.trim() || window.location.origin,
|
||||
apiBaseUrl:
|
||||
config.apiBaseUrl?.trim() ||
|
||||
config.baseUrl.trim() ||
|
||||
window.location.origin,
|
||||
title: config.title?.trim() || "IoT 智能客服",
|
||||
subtitle: config.subtitle?.trim() || "设备告警、工单协同与在线服务统一入口",
|
||||
themeColor: config.themeColor?.trim() || "#2563eb",
|
||||
width: config.width?.trim() || "680px",
|
||||
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 已挂载");
|
||||
}
|
||||
|
||||
const sdkUrl = getWidgetSdkUrl(config.baseUrl, window.location.pathname);
|
||||
const snippet = `<script>
|
||||
window.CSAgentConfig = {
|
||||
channelId: "${config.channelId || ""}",
|
||||
baseUrl: "${config.baseUrl}",
|
||||
apiBaseUrl: "${config.apiBaseUrl || config.baseUrl}",
|
||||
title: "${config.title || "IoT 智能客服"}",
|
||||
subtitle: "${config.subtitle || "设备告警、工单协同与在线服务统一入口"}",
|
||||
position: "${config.position || "right"}",
|
||||
themeColor: "${config.themeColor || "#2563eb"}",
|
||||
width: "${config.width || "680px"}",
|
||||
subject: "${config.subject || ""}",
|
||||
};
|
||||
</script>
|
||||
<script async src="${sdkUrl}"></script>`;
|
||||
|
||||
return (
|
||||
<main className="min-h-screen overflow-y-auto overflow-x-hidden scroll-smooth">
|
||||
<div className="relative">
|
||||
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[24rem] bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.14),transparent_46%)]" />
|
||||
|
||||
<header className="sticky top-0 z-20 border-b border-slate-200/80 bg-white/90 backdrop-blur-xl">
|
||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-4 md:px-6">
|
||||
<a
|
||||
href={OFFICIAL_SITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex items-center gap-3 text-slate-950"
|
||||
>
|
||||
<Image
|
||||
src="/images/logo.png"
|
||||
alt="贝壳 AI 客服"
|
||||
width={40}
|
||||
height={40}
|
||||
className="h-10 w-10 rounded-2xl object-contain ring-1 ring-slate-200"
|
||||
/>
|
||||
<span>
|
||||
<span className="block text-base font-semibold">
|
||||
贝壳 AI 客服
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
|
||||
<nav className="hidden items-center gap-8 text-sm text-slate-600 md:flex">
|
||||
<a href="#features" className="transition hover:text-slate-950">
|
||||
产品能力
|
||||
</a>
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-slate-950"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_SITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-slate-950"
|
||||
>
|
||||
官网
|
||||
</a>
|
||||
</nav>
|
||||
|
||||
<a
|
||||
href="#integration"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-blue-600 px-4 py-2 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
>
|
||||
立即体验
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<section className="px-4 pb-18 pt-16 md:px-6 md:pb-24 md:pt-20">
|
||||
<div className="mx-auto flex max-w-4xl flex-col items-center text-center">
|
||||
<h1 className="mt-6 text-4xl font-semibold tracking-[-0.05em] text-slate-950 md:text-6xl">
|
||||
AI 优先接待,人工无缝协同
|
||||
</h1>
|
||||
|
||||
<p className="mt-6 max-w-2xl text-lg leading-8 text-slate-600">
|
||||
贝壳 AI 客服是一个以 AI Agent 为核心的智能客服系统,把在线会话、
|
||||
知识库检索、工单流转和人工接管串成一条完整链路。
|
||||
</p>
|
||||
|
||||
<div className="mt-8 flex flex-wrap items-center justify-center gap-3">
|
||||
<a
|
||||
href="#integration"
|
||||
className="inline-flex items-center gap-2 rounded-full bg-blue-600 px-5 py-3 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
>
|
||||
开始接入
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_SITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="rounded-full border border-slate-200 bg-white px-5 py-3 text-sm font-medium text-slate-700 transition hover:border-slate-300 hover:text-slate-950"
|
||||
>
|
||||
查看官网
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="mt-12 grid w-full gap-4 md:grid-cols-3">
|
||||
{[
|
||||
{ label: "接待模式", value: "AI + 人工协同" },
|
||||
{ label: "回答方式", value: "知识库 RAG 驱动" },
|
||||
{ label: "运行能力", value: "Skills + MCP + 工单流程" },
|
||||
].map((item) => (
|
||||
<div
|
||||
key={item.label}
|
||||
className="rounded-lg border border-white/80 bg-white/90 p-6 text-left shadow-[0_18px_40px_rgba(15,23,42,0.05)]"
|
||||
>
|
||||
<div className="text-sm text-slate-500">{item.label}</div>
|
||||
<div className="mt-2 text-xl font-semibold text-slate-950">
|
||||
{item.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="features" className="px-4 py-8 md:px-6 md:py-10">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-sm font-medium text-blue-700">产品能力</div>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-slate-950">
|
||||
面向真实客服场景的开源系统
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-8 text-slate-600">
|
||||
用一套系统处理用户咨询、AI 回复、知识检索、人工接管和后续工单。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
||||
{featureCards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<article
|
||||
key={item.title}
|
||||
className="rounded-lg border border-slate-200 bg-white p-6 shadow-[0_18px_40px_rgba(15,23,42,0.04)]"
|
||||
>
|
||||
<div className="inline-flex rounded-2xl bg-blue-50 p-3 text-blue-700">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<h3 className="mt-4 text-lg font-semibold text-slate-950">
|
||||
{item.title}
|
||||
</h3>
|
||||
<p className="mt-3 text-sm leading-7 text-slate-600">
|
||||
{item.description}
|
||||
</p>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="px-4 py-16 md:px-6">
|
||||
<div className="mx-auto grid max-w-6xl gap-6 lg:grid-cols-[0.95fr_1.05fr]">
|
||||
<div className="rounded-lg border border-slate-200 bg-white p-7 shadow-[0_24px_60px_rgba(15,23,42,0.06)]">
|
||||
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em] text-slate-950">
|
||||
覆盖完整客服链路
|
||||
</div>
|
||||
<p className="mt-4 text-base leading-8 text-slate-600">
|
||||
从第一条消息到问题关闭,关键环节都在同一套系统里。
|
||||
</p>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-1">
|
||||
{scopeCards.map((item) => {
|
||||
const Icon = item.icon;
|
||||
return (
|
||||
<div
|
||||
key={item.title}
|
||||
className="rounded-lg border border-slate-200 bg-slate-50 p-5"
|
||||
>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="rounded-2xl bg-blue-50 p-3 text-blue-700">
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-slate-950">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-7 text-slate-600">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-slate-200 bg-gradient-to-br from-slate-950 via-slate-900 to-blue-950 p-7 text-white shadow-[0_24px_60px_rgba(15,23,42,0.12)]">
|
||||
<div className="mt-2 text-2xl font-semibold tracking-[-0.03em]">
|
||||
客服处理闭环
|
||||
</div>
|
||||
<p className="mt-4 text-sm leading-7 text-slate-300">
|
||||
从用户发起咨询,到 AI 回复、人工接管、工单跟踪,核心流程都在同一套系统里完成。
|
||||
</p>
|
||||
|
||||
<div className="mt-6 space-y-4">
|
||||
{flowSteps.map((item) => (
|
||||
<div
|
||||
key={item.step}
|
||||
className="flex items-start gap-4 rounded-lg border border-white/10 bg-white/5 p-5"
|
||||
>
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-white text-sm font-semibold text-slate-950">
|
||||
{item.step}
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-white">
|
||||
{item.title}
|
||||
</div>
|
||||
<div className="mt-2 text-sm leading-7 text-slate-200">
|
||||
{item.description}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="integration" className="px-4 py-16 md:px-6 md:py-20">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div className="max-w-2xl">
|
||||
<div className="text-sm font-medium text-blue-700">接入方式</div>
|
||||
<h2 className="mt-2 text-3xl font-semibold tracking-[-0.03em] text-slate-950">
|
||||
保留在线配置与接入脚本,直接拿来演示和集成
|
||||
</h2>
|
||||
<p className="mt-4 text-base leading-8 text-slate-600">
|
||||
这里可以直接填写 `channelId`、调整宿主参数并挂载 Widget,也可以复制脚本到业务系统完成基础接入。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 grid gap-6 xl:grid-cols-[0.94fr_1.06fr]">
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-6 shadow-[0_24px_60px_rgba(15,23,42,0.06)]">
|
||||
<div className="text-lg font-semibold text-slate-950">Widget 配置</div>
|
||||
<div className="mt-1 text-sm text-slate-500">
|
||||
宿主页面接入时需要的基础参数。
|
||||
</div>
|
||||
|
||||
<div className="mt-6 grid gap-4 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
channelId
|
||||
</div>
|
||||
<input
|
||||
value={config.channelId}
|
||||
onChange={(event) => updateField("channelId", event.target.value)}
|
||||
placeholder="请输入后台渠道 channelId"
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
baseUrl
|
||||
</div>
|
||||
<input
|
||||
value={config.baseUrl}
|
||||
onChange={(event) => updateField("baseUrl", event.target.value)}
|
||||
placeholder="Widget 地址,例如 http://localhost:3001"
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
apiBaseUrl
|
||||
</div>
|
||||
<input
|
||||
value={config.apiBaseUrl ?? ""}
|
||||
onChange={(event) => updateField("apiBaseUrl", event.target.value)}
|
||||
placeholder="后端地址,例如 http://localhost:8080"
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
标题
|
||||
</div>
|
||||
<input
|
||||
value={config.title ?? ""}
|
||||
onChange={(event) => updateField("title", event.target.value)}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
副标题
|
||||
</div>
|
||||
<input
|
||||
value={config.subtitle ?? ""}
|
||||
onChange={(event) => updateField("subtitle", event.target.value)}
|
||||
placeholder="例如:设备告警、工单协同与在线服务统一入口"
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
主题色
|
||||
</div>
|
||||
<input
|
||||
value={config.themeColor ?? ""}
|
||||
onChange={(event) => updateField("themeColor", event.target.value)}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
宽度
|
||||
</div>
|
||||
<input
|
||||
value={config.width ?? ""}
|
||||
onChange={(event) => updateField("width", event.target.value)}
|
||||
placeholder="例如 680px、50vw"
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
会话主题
|
||||
</div>
|
||||
<input
|
||||
value={config.subject ?? ""}
|
||||
onChange={(event) => updateField("subject", event.target.value)}
|
||||
placeholder="例如:站点A-储能柜-001"
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<div className="mb-2 text-xs font-medium tracking-[0.18em] text-slate-500">
|
||||
悬浮位置
|
||||
</div>
|
||||
<select
|
||||
value={config.position ?? "right"}
|
||||
onChange={(event) =>
|
||||
updateField(
|
||||
"position",
|
||||
event.target.value as "left" | "right",
|
||||
)
|
||||
}
|
||||
className="w-full rounded-2xl border border-slate-200 bg-white px-4 py-3 text-sm text-slate-900 outline-none transition focus:border-blue-400"
|
||||
>
|
||||
<option value="right">右侧</option>
|
||||
<option value="left">左侧</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex flex-wrap gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleApply}
|
||||
className="rounded-full bg-blue-600 px-5 py-3 text-sm font-medium text-white transition hover:bg-blue-700"
|
||||
>
|
||||
挂载 Widget
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
const nextConfig = {
|
||||
...buildDefaultConfig(window.location.origin),
|
||||
subject: generateRandomSubject(),
|
||||
};
|
||||
setConfig(nextConfig);
|
||||
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(nextConfig));
|
||||
window.localStorage.removeItem("cs-agent:external-id");
|
||||
removeMountedWidget();
|
||||
setStatus("已重置");
|
||||
}}
|
||||
className="rounded-full border border-slate-200 bg-white px-5 py-3 text-sm font-medium text-slate-700 transition hover:border-slate-300"
|
||||
>
|
||||
重置配置
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="rounded-lg border border-slate-200 bg-white p-6 shadow-[0_24px_60px_rgba(15,23,42,0.06)]">
|
||||
<div className="text-lg font-semibold text-slate-950">接入脚本</div>
|
||||
<div className="mt-1 text-sm text-slate-500">
|
||||
复制到宿主页面即可完成基础接入。
|
||||
</div>
|
||||
<pre className="cs-agent-scrollbar mt-6 overflow-x-auto rounded-lg border border-slate-200 bg-slate-950 p-5 text-xs leading-6 text-slate-100">
|
||||
<code>{snippet}</code>
|
||||
</pre>
|
||||
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<footer className="border-t border-slate-200 bg-white px-4 py-8 md:px-6">
|
||||
<div className="mx-auto flex max-w-6xl flex-col gap-4 text-sm text-slate-500 md:flex-row md:items-center md:justify-between">
|
||||
<div>贝壳 AI 客服</div>
|
||||
<div className="flex flex-wrap items-center gap-5">
|
||||
<a
|
||||
href={GITHUB_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-slate-900"
|
||||
>
|
||||
GitHub
|
||||
</a>
|
||||
<a
|
||||
href={GITEE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-slate-900"
|
||||
>
|
||||
Gitee
|
||||
</a>
|
||||
<a
|
||||
href={OFFICIAL_SITE_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="transition hover:text-slate-900"
|
||||
>
|
||||
官网
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
const WidgetHomePage = dynamic(async () => WidgetHomePageInner, {
|
||||
ssr: false,
|
||||
});
|
||||
|
||||
export default WidgetHomePage;
|
||||
@@ -1,25 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "base-nova",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "neutral",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"iconLibrary": "lucide",
|
||||
"rtl": false,
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
},
|
||||
"menuColor": "default",
|
||||
"menuAccent": "subtle",
|
||||
"registries": {}
|
||||
}
|
||||
@@ -1,323 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
Maximize2Icon,
|
||||
Minimize2Icon,
|
||||
MinusIcon,
|
||||
RotateCwIcon,
|
||||
XIcon
|
||||
} from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useShallow } from "zustand/react/shallow";
|
||||
|
||||
import { ConnectionStatus } from "@/components/im/connection-status";
|
||||
import { MessageEditor } from "@/components/im/message-editor";
|
||||
import {
|
||||
MessageList,
|
||||
type MessageListHandle,
|
||||
} from "@/components/im/message-list";
|
||||
import { closeConversation } from "@/lib/services/conversation";
|
||||
import { useChatStore } from "@/lib/store/chat-store";
|
||||
import {
|
||||
bindHostBridge,
|
||||
requestHostClose,
|
||||
requestHostMinimize,
|
||||
requestHostToggleMaximize,
|
||||
} from "@/lib/widget/host-bridge";
|
||||
|
||||
export function ChatShell() {
|
||||
const messageListRef = useRef<MessageListHandle | null>(null);
|
||||
const [isMaximized, setIsMaximized] = useState(false);
|
||||
const [isCloseDialogOpen, setIsCloseDialogOpen] = useState(false);
|
||||
const [isClosingConversation, setIsClosingConversation] = useState(false);
|
||||
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
themeColor,
|
||||
conversation,
|
||||
messages,
|
||||
messagesHasMore,
|
||||
messagesLoadingMore,
|
||||
loadOlderMessages,
|
||||
status,
|
||||
error,
|
||||
isOpen,
|
||||
isVisible,
|
||||
setIsOpen,
|
||||
setIsVisible,
|
||||
bootstrap,
|
||||
handleSendMessage,
|
||||
uploadMessageImage,
|
||||
sendAttachment,
|
||||
retry,
|
||||
disconnectSocket,
|
||||
markConversationRead,
|
||||
} = useChatStore(
|
||||
useShallow((state) => ({
|
||||
title: state.title,
|
||||
subtitle: state.subtitle,
|
||||
themeColor: state.themeColor,
|
||||
conversation: state.conversation,
|
||||
messages: state.messages,
|
||||
messagesHasMore: state.messagesHasMore,
|
||||
messagesLoadingMore: state.messagesLoadingMore,
|
||||
loadOlderMessages: state.loadOlderMessages,
|
||||
status: state.status,
|
||||
error: state.error,
|
||||
isOpen: state.isOpen,
|
||||
isVisible: state.isVisible,
|
||||
setIsOpen: state.setIsOpen,
|
||||
setIsVisible: state.setIsVisible,
|
||||
bootstrap: state.bootstrap,
|
||||
handleSendMessage: state.handleSendMessage,
|
||||
uploadMessageImage: state.uploadMessageImage,
|
||||
sendAttachment: state.sendAttachment,
|
||||
retry: state.retry,
|
||||
disconnectSocket: state.disconnectSocket,
|
||||
markConversationRead: state.markConversationRead,
|
||||
})),
|
||||
);
|
||||
|
||||
const maybeMarkConversationRead = useCallback(() => {
|
||||
if (!isVisible || !conversation || typeof document === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (document.visibilityState !== "visible") {
|
||||
return;
|
||||
}
|
||||
void markConversationRead().catch((error) => {
|
||||
console.error("Failed to mark widget conversation read", error);
|
||||
});
|
||||
}, [conversation, isVisible, markConversationRead]);
|
||||
|
||||
useEffect(() => {
|
||||
return bindHostBridge({
|
||||
onOpen: () => {
|
||||
setIsOpen(true);
|
||||
setIsVisible(true);
|
||||
},
|
||||
onMinimize: () => {
|
||||
setIsVisible(false);
|
||||
},
|
||||
onMaximizedChange: (nextIsMaximized) => {
|
||||
setIsMaximized(nextIsMaximized);
|
||||
},
|
||||
});
|
||||
}, [setIsOpen, setIsVisible]);
|
||||
|
||||
useEffect(() => {
|
||||
bootstrap();
|
||||
|
||||
return () => {
|
||||
if (!isOpen) {
|
||||
disconnectSocket();
|
||||
}
|
||||
};
|
||||
}, [isOpen, bootstrap, disconnectSocket]);
|
||||
|
||||
useEffect(() => {
|
||||
maybeMarkConversationRead();
|
||||
}, [maybeMarkConversationRead, messages.length]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === "visible") {
|
||||
maybeMarkConversationRead();
|
||||
}
|
||||
};
|
||||
const handleFocus = () => {
|
||||
maybeMarkConversationRead();
|
||||
};
|
||||
|
||||
document.addEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.addEventListener("focus", handleFocus);
|
||||
return () => {
|
||||
document.removeEventListener("visibilitychange", handleVisibilityChange);
|
||||
window.removeEventListener("focus", handleFocus);
|
||||
};
|
||||
}, [maybeMarkConversationRead]);
|
||||
|
||||
async function handleSend(content: string) {
|
||||
await handleSendMessage(content);
|
||||
messageListRef.current?.scrollToBottom();
|
||||
}
|
||||
|
||||
function handleMinimize() {
|
||||
setIsVisible(false);
|
||||
requestHostMinimize();
|
||||
}
|
||||
|
||||
function handleToggleMaximize() {
|
||||
requestHostToggleMaximize();
|
||||
}
|
||||
|
||||
async function confirmCloseConversation() {
|
||||
if (isClosingConversation) {
|
||||
return;
|
||||
}
|
||||
setIsClosingConversation(true);
|
||||
try {
|
||||
if (conversation?.id) {
|
||||
await closeConversation(conversation.id);
|
||||
}
|
||||
setIsCloseDialogOpen(false);
|
||||
requestHostClose();
|
||||
} catch (closeError) {
|
||||
window.alert(
|
||||
closeError instanceof Error ? closeError.message : "关闭会话失败",
|
||||
);
|
||||
} finally {
|
||||
setIsClosingConversation(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!isCloseDialogOpen) {
|
||||
return;
|
||||
}
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === "Escape" && !isClosingConversation) {
|
||||
setIsCloseDialogOpen(false);
|
||||
}
|
||||
};
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, [isCloseDialogOpen, isClosingConversation]);
|
||||
|
||||
return (
|
||||
<main
|
||||
className="cs-agent-shell relative flex h-screen overflow-hidden bg-(--background)"
|
||||
style={{ "--primary": themeColor } as React.CSSProperties}
|
||||
>
|
||||
<section className="cs-agent-panel flex h-full w-full flex-col overflow-hidden border border-white/70">
|
||||
<header className="relative shrink-0 overflow-hidden border-b border-white/60 px-4 pb-3 pt-3 shadow-[0_10px_24px_rgba(15,23,42,0.05)]">
|
||||
<div className="absolute inset-x-0 top-0 h-20 bg-[radial-gradient(circle_at_top_right,rgba(37,99,235,0.14),transparent_52%)]" />
|
||||
<div className="relative flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate text-[16px] font-semibold tracking-[0.01em] text-slate-950">
|
||||
{title}
|
||||
</div>
|
||||
<div className="mt-1 text-[12px] text-slate-500">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* <div className="inline-flex items-center gap-1.5 rounded-full border border-white/70 bg-white/65 px-2.5 py-1 text-[11px] text-slate-500 shadow-[0_6px_18px_rgba(15,23,42,0.04)]">
|
||||
<ShieldCheckIcon className="size-3.5 text-emerald-600" />
|
||||
会话加密传输
|
||||
</div> */}
|
||||
{status !== "connected" ? (
|
||||
<ConnectionStatus status={status} />
|
||||
) : null}
|
||||
<div className="inline-flex items-center gap-1 rounded-[18px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.88),rgba(241,245,249,0.82))] p-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.9),0_12px_28px_rgba(15,23,42,0.07)] backdrop-blur-xl">
|
||||
<button
|
||||
type="button"
|
||||
onClick={retry}
|
||||
aria-label="重新连接"
|
||||
title="重新连接"
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-[14px] text-slate-400 transition duration-200 hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(239,246,255,0.96))] hover:text-sky-600 hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_18px_rgba(56,189,248,0.16)]"
|
||||
>
|
||||
<RotateCwIcon className="size-3.75 transition duration-200 group-hover:rotate-[-20deg]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMinimize}
|
||||
aria-label="收起聊天窗口"
|
||||
title="收起聊天窗口"
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-[14px] text-slate-400 transition duration-200 hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(248,250,252,0.96))] hover:text-slate-700 hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_18px_rgba(15,23,42,0.10)]"
|
||||
>
|
||||
<MinusIcon className="size-3.75 transition duration-200 group-hover:scale-x-[0.88]" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleToggleMaximize}
|
||||
aria-label={isMaximized ? "取消最大化" : "最大化聊天窗口"}
|
||||
title={isMaximized ? "取消最大化" : "最大化聊天窗口"}
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-[14px] text-slate-400 transition duration-200 hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(238,249,244,0.96))] hover:text-emerald-700 hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_18px_rgba(16,185,129,0.14)]"
|
||||
>
|
||||
{isMaximized ? (
|
||||
<Minimize2Icon className="size-3.75 transition duration-200 group-hover:scale-[0.94]" />
|
||||
) : (
|
||||
<Maximize2Icon className="size-3.75 transition duration-200 group-hover:scale-[1.04]" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsCloseDialogOpen(true)}
|
||||
aria-label="关闭聊天窗口"
|
||||
title="关闭聊天窗口"
|
||||
className="group inline-flex h-6 w-6 items-center justify-center rounded-[14px] text-rose-400 transition duration-200 hover:-translate-y-0.5 hover:bg-[linear-gradient(180deg,rgba(255,255,255,0.98),rgba(255,241,242,0.98))] hover:text-rose-600 hover:shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_10px_18px_rgba(244,63,94,0.16)]"
|
||||
>
|
||||
<XIcon className="size-3.75 transition duration-200 group-hover:scale-[0.92]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className=".cs-agent-grid-bg grid min-h-0 flex-1 overflow-hidden grid-rows-[minmax(0,1fr)_auto]">
|
||||
<MessageList
|
||||
ref={messageListRef}
|
||||
messages={messages}
|
||||
onNearBottomVisible={maybeMarkConversationRead}
|
||||
hasMoreOlder={messagesHasMore}
|
||||
loadingOlder={messagesLoadingMore}
|
||||
onLoadOlder={loadOlderMessages}
|
||||
/>
|
||||
<MessageEditor
|
||||
disabled={!conversation}
|
||||
onSend={handleSend}
|
||||
onUploadImage={uploadMessageImage}
|
||||
onSendAttachment={sendAttachment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="border-t border-rose-200 bg-rose-50 px-4 py-3 text-sm text-rose-700">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
|
||||
{isCloseDialogOpen ? (
|
||||
<div className="absolute inset-0 z-50 flex items-center justify-center bg-[radial-gradient(circle_at_top,rgba(37,99,235,0.16),transparent_38%),rgba(15,23,42,0.24)] px-5 backdrop-blur-sm">
|
||||
<div className="cs-agent-fade-up w-full max-w-[320px] rounded-[26px] border border-white/70 bg-[linear-gradient(180deg,rgba(255,255,255,0.96),rgba(241,245,249,0.94))] p-5 shadow-[0_28px_80px_rgba(15,23,42,0.18),inset_0_1px_0_rgba(255,255,255,0.92)]">
|
||||
<div className="flex items-start gap-3">
|
||||
{/* <div className="mt-0.5 inline-flex h-10 w-10 shrink-0 items-center justify-center rounded-2xl bg-linear-to-b from-rose-50 to-rose-100 text-rose-500 shadow-[inset_0_1px_0_rgba(255,255,255,0.95),0_12px_22px_rgba(244,63,94,0.12)]">
|
||||
<XIcon className="size-4" />
|
||||
</div> */}
|
||||
<div className="min-w-0">
|
||||
<div className="text-[15px] font-semibold tracking-[0.01em] text-slate-950">
|
||||
结束当前对话?
|
||||
</div>
|
||||
<div className="mt-1.5 text-[12px] leading-5 text-slate-500">
|
||||
结束会话,客服将无法再查看您的消息记录,如需再次联系请重新发起对话。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={isClosingConversation}
|
||||
onClick={() => setIsCloseDialogOpen(false)}
|
||||
className="inline-flex h-9 items-center justify-center rounded-2xl border border-slate-200/80 bg-white/80 px-4 text-[12px] font-medium text-slate-600 shadow-[0_10px_20px_rgba(15,23,42,0.05)] transition hover:-translate-y-0.5 hover:border-slate-300 hover:bg-white hover:text-slate-800 disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
继续对话
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isClosingConversation}
|
||||
onClick={() => void confirmCloseConversation()}
|
||||
className="inline-flex h-9 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,#f43f5e,#fb7185)] px-4 text-[12px] font-semibold text-white shadow-[0_14px_28px_rgba(244,63,94,0.24)] transition hover:-translate-y-0.5 hover:opacity-92 disabled:translate-y-0 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{isClosingConversation ? "结束中..." : "确认结束"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ConnectionStatusProps = {
|
||||
status: "connecting" | "connected" | "disconnected";
|
||||
};
|
||||
|
||||
const statusText: Record<ConnectionStatusProps["status"], string> = {
|
||||
connecting: "连接中",
|
||||
connected: "在线服务",
|
||||
disconnected: "连接已断开",
|
||||
};
|
||||
|
||||
export function ConnectionStatus({ status }: ConnectionStatusProps) {
|
||||
const toneClass =
|
||||
status === "connected"
|
||||
? "border-emerald-200/80 bg-emerald-50 text-emerald-700"
|
||||
: status === "connecting"
|
||||
? "border-amber-200/80 bg-amber-50 text-amber-700"
|
||||
: "border-slate-200/80 bg-slate-100 text-slate-600";
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 rounded-full border px-2.5 py-1 text-[11px] font-medium tracking-[0.02em] shadow-[0_6px_16px_rgba(15,23,42,0.06)]",
|
||||
toneClass,
|
||||
)}
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"cs-agent-status-dot inline-block size-2 rounded-full",
|
||||
status === "connected"
|
||||
? "bg-emerald-500 shadow-[0_0_0_4px_rgba(16,185,129,0.14)]"
|
||||
: status === "connecting"
|
||||
? "bg-amber-500 shadow-[0_0_0_4px_rgba(245,158,11,0.16)]"
|
||||
: "bg-slate-400 shadow-[0_0_0_4px_rgba(148,163,184,0.14)]",
|
||||
)}
|
||||
/>
|
||||
<span>{statusText[status]}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,365 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { EditorContent, useEditor } from "@tiptap/react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Image from "@tiptap/extension-image";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import { ImageIcon, PaperclipIcon, SendHorizonalIcon } from "lucide-react";
|
||||
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
type UploadedImage = {
|
||||
assetId: string;
|
||||
provider: string;
|
||||
storageKey: string;
|
||||
url: string;
|
||||
filename?: string;
|
||||
};
|
||||
|
||||
const MessageImage = Image.extend({
|
||||
addAttributes() {
|
||||
return {
|
||||
...this.parent?.(),
|
||||
dataAssetId: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-asset-id"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataAssetId ? { "data-asset-id": attributes.dataAssetId } : {},
|
||||
},
|
||||
dataProvider: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-provider"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataProvider ? { "data-provider": attributes.dataProvider } : {},
|
||||
},
|
||||
dataStorageKey: {
|
||||
default: null,
|
||||
parseHTML: (element) => element.getAttribute("data-storage-key"),
|
||||
renderHTML: (attributes) =>
|
||||
attributes.dataStorageKey ? { "data-storage-key": attributes.dataStorageKey } : {},
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
type MessageEditorProps = {
|
||||
disabled?: boolean;
|
||||
uploadingAsset?: boolean;
|
||||
onSend: (html: string) => Promise<void>;
|
||||
onUploadImage: (file: File) => Promise<UploadedImage | null>;
|
||||
onSendAttachment: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
export function MessageEditor({
|
||||
disabled = false,
|
||||
uploadingAsset = false,
|
||||
onSend,
|
||||
onUploadImage,
|
||||
onSendAttachment,
|
||||
}: MessageEditorProps) {
|
||||
const [localUploading, setLocalUploading] = useState(false);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const onSendRef = useRef(onSend);
|
||||
const onUploadImageRef = useRef(onUploadImage);
|
||||
const onSendAttachmentRef = useRef(onSendAttachment);
|
||||
const shouldRestoreFocusRef = useRef(false);
|
||||
const isUploading = uploadingAsset || localUploading;
|
||||
|
||||
useEffect(() => {
|
||||
onSendRef.current = onSend;
|
||||
}, [onSend]);
|
||||
|
||||
useEffect(() => {
|
||||
onUploadImageRef.current = onUploadImage;
|
||||
}, [onUploadImage]);
|
||||
|
||||
useEffect(() => {
|
||||
onSendAttachmentRef.current = onSendAttachment;
|
||||
}, [onSendAttachment]);
|
||||
|
||||
const editor = useEditor({
|
||||
immediatelyRender: false,
|
||||
extensions: [
|
||||
StarterKit.configure({
|
||||
heading: false,
|
||||
blockquote: false,
|
||||
codeBlock: false,
|
||||
bulletList: false,
|
||||
orderedList: false,
|
||||
horizontalRule: false,
|
||||
}),
|
||||
MessageImage,
|
||||
Placeholder.configure({
|
||||
placeholder: "输入消息,Enter 发送,Shift + Enter 换行",
|
||||
}),
|
||||
],
|
||||
content: "",
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"cs-agent-scrollbar min-h-12 max-h-40 overflow-y-auto px-1.5 py-1 text-[13px] leading-6 text-slate-900 outline-none [&_p]:m-0 [&_p+*]:mt-2 [&_img]:my-2 [&_img]:max-h-64 [&_img]:rounded-xl [&_img]:object-contain",
|
||||
},
|
||||
handleKeyDown: (_view, event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSend();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handlePaste: (_view, event) => {
|
||||
if (disabled || isUploading) {
|
||||
return false;
|
||||
}
|
||||
const imageFile = getClipboardImageFile(event.clipboardData);
|
||||
if (!imageFile) {
|
||||
return false;
|
||||
}
|
||||
event.preventDefault();
|
||||
void insertUploadedImage(imageFile);
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor) {
|
||||
return;
|
||||
}
|
||||
editor.setEditable(!disabled && !isUploading);
|
||||
}, [disabled, editor, isUploading]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!editor || disabled || isUploading || !shouldRestoreFocusRef.current) {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus();
|
||||
});
|
||||
}, [disabled, editor, isUploading]);
|
||||
|
||||
async function handleSend() {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return;
|
||||
}
|
||||
const html = editor.getHTML();
|
||||
if (!isMeaningfulHTML(html)) {
|
||||
return;
|
||||
}
|
||||
await onSendRef.current(html);
|
||||
editor.commands.clearContent(true);
|
||||
}
|
||||
|
||||
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file || !editor || disabled || isUploading) {
|
||||
if (editor && shouldRestoreFocusRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
await insertUploadedImage(file);
|
||||
}
|
||||
|
||||
async function insertUploadedImage(file: File) {
|
||||
if (!editor || disabled || isUploading) {
|
||||
return;
|
||||
}
|
||||
shouldRestoreFocusRef.current = true;
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
const placeholderId = `uploading-${generateUUID()}`;
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setImage({
|
||||
src: objectUrl,
|
||||
alt: file.name || "uploading-image",
|
||||
title: placeholderId,
|
||||
})
|
||||
.run();
|
||||
|
||||
try {
|
||||
setLocalUploading(true);
|
||||
const uploaded = await onUploadImageRef.current(file);
|
||||
if (!uploaded?.url) {
|
||||
removeImageByTitle(editor, placeholderId);
|
||||
return;
|
||||
}
|
||||
replaceImageSourceByTitle(editor, placeholderId, uploaded);
|
||||
} finally {
|
||||
setLocalUploading(false);
|
||||
URL.revokeObjectURL(objectUrl);
|
||||
requestAnimationFrame(() => {
|
||||
if (!disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectAttachment(
|
||||
event: React.ChangeEvent<HTMLInputElement>,
|
||||
) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file || disabled || isUploading) {
|
||||
if (editor && shouldRestoreFocusRef.current) {
|
||||
requestAnimationFrame(() => {
|
||||
editor.commands.focus();
|
||||
});
|
||||
}
|
||||
return;
|
||||
}
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true;
|
||||
setLocalUploading(true);
|
||||
try {
|
||||
await onSendAttachmentRef.current(file);
|
||||
} finally {
|
||||
setLocalUploading(false);
|
||||
requestAnimationFrame(() => {
|
||||
if (editor && !disabled && shouldRestoreFocusRef.current) {
|
||||
editor.commands.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3 pt-2">
|
||||
<div className="rounded-xl border border-white/60 bg-white/78 p-2 shadow-[0_10px_24px_rgba(15,23,42,0.05)] backdrop-blur">
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleSelectImage}
|
||||
/>
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
className="hidden"
|
||||
onChange={handleSelectAttachment}
|
||||
/>
|
||||
<div className="min-h-10">
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
<div className="mt-1.5 flex items-center justify-between">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true;
|
||||
imageInputRef.current?.click();
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
aria-label={isUploading ? "图片上传中" : "发送图片"}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-xl border border-slate-200/80 bg-white/90 text-slate-500 shadow-[0_8px_18px_rgba(15,23,42,0.05)] transition duration-200 hover:-translate-y-0.5 hover:text-slate-700 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-300"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onMouseDown={(event) => event.preventDefault()}
|
||||
onClick={() => {
|
||||
shouldRestoreFocusRef.current = editor?.isFocused ?? true;
|
||||
attachmentInputRef.current?.click();
|
||||
}}
|
||||
disabled={disabled || isUploading}
|
||||
aria-label={isUploading ? "附件上传中" : "发送附件"}
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-xl border border-slate-200/80 bg-white/90 text-slate-500 shadow-[0_8px_18px_rgba(15,23,42,0.05)] transition duration-200 hover:-translate-y-0.5 hover:text-slate-700 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-300"
|
||||
>
|
||||
<PaperclipIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="text-[10px] text-slate-400">Enter 发送</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void handleSend()}
|
||||
disabled={disabled || isUploading}
|
||||
aria-label="发送"
|
||||
className="inline-flex size-8 shrink-0 items-center justify-center rounded-xl bg-[linear-gradient(135deg,var(--primary),color-mix(in_srgb,var(--primary)_75%,white_25%))] text-white shadow-[0_10px_20px_color-mix(in_srgb,var(--primary)_28%,transparent)] transition duration-200 hover:-translate-y-0.5 hover:brightness-105 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
|
||||
>
|
||||
<SendHorizonalIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function isMeaningfulHTML(html: string) {
|
||||
const normalized = html
|
||||
.replace(/<p><\/p>/g, "")
|
||||
.replace(/<p><br><\/p>/g, "")
|
||||
.replace(/\s+/g, "");
|
||||
if (/<img[\s\S]*?>/i.test(normalized)) {
|
||||
return true;
|
||||
}
|
||||
const plainText = normalized.replace(/<[^>]+>/g, "").trim();
|
||||
return plainText !== "";
|
||||
}
|
||||
|
||||
function getClipboardImageFile(clipboardData: DataTransfer | null) {
|
||||
if (!clipboardData) {
|
||||
return null;
|
||||
}
|
||||
for (const item of Array.from(clipboardData.items)) {
|
||||
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||
return item.getAsFile();
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function removeImageByTitle(editor: NonNullable<ReturnType<typeof useEditor>>, title: string) {
|
||||
const { state } = editor;
|
||||
let targetPos: number | null = null;
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "image" && node.attrs.title === title) {
|
||||
targetPos = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (targetPos === null) {
|
||||
return;
|
||||
}
|
||||
editor.chain().focus().deleteRange({ from: targetPos, to: targetPos + 1 }).run();
|
||||
}
|
||||
|
||||
function replaceImageSourceByTitle(
|
||||
editor: NonNullable<ReturnType<typeof useEditor>>,
|
||||
title: string,
|
||||
uploaded: UploadedImage,
|
||||
) {
|
||||
const { state, view } = editor;
|
||||
let targetPos: number | null = null;
|
||||
state.doc.descendants((node, pos) => {
|
||||
if (node.type.name === "image" && node.attrs.title === title) {
|
||||
targetPos = pos;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (targetPos === null) {
|
||||
return;
|
||||
}
|
||||
const transaction = view.state.tr.setNodeMarkup(targetPos, undefined, {
|
||||
...view.state.doc.nodeAt(targetPos)?.attrs,
|
||||
src: uploaded.url,
|
||||
alt: uploaded.filename || "image",
|
||||
dataAssetId: uploaded.assetId,
|
||||
dataProvider: uploaded.provider,
|
||||
dataStorageKey: uploaded.storageKey,
|
||||
title: "",
|
||||
});
|
||||
view.dispatch(transaction);
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { memo, useEffect, useRef } from "react";
|
||||
|
||||
type MessageHTMLProps = {
|
||||
html: string;
|
||||
className?: string;
|
||||
onImageSettled?: () => void;
|
||||
onImageClick?: (src: string, alt?: string) => void;
|
||||
};
|
||||
|
||||
function MessageHTMLComponent({
|
||||
html,
|
||||
className = "",
|
||||
onImageSettled,
|
||||
onImageClick,
|
||||
}: MessageHTMLProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onImageSettledRef = useRef(onImageSettled);
|
||||
const onImageClickRef = useRef(onImageClick);
|
||||
|
||||
useEffect(() => {
|
||||
onImageSettledRef.current = onImageSettled;
|
||||
}, [onImageSettled]);
|
||||
|
||||
useEffect(() => {
|
||||
onImageClickRef.current = onImageClick;
|
||||
}, [onImageClick]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const images = Array.from(container.querySelectorAll("img"));
|
||||
if (images.length === 0) {
|
||||
return;
|
||||
}
|
||||
const cleanups = images.map((image) => {
|
||||
const handleSettled = () => onImageSettledRef.current?.();
|
||||
const handleClick = () => {
|
||||
const src = image.getAttribute("src");
|
||||
if (src) {
|
||||
const alt = image.getAttribute("alt") ?? undefined;
|
||||
onImageClickRef.current?.(src, alt);
|
||||
}
|
||||
};
|
||||
image.addEventListener("load", handleSettled);
|
||||
image.addEventListener("error", handleSettled);
|
||||
image.addEventListener("click", handleClick);
|
||||
if (image.complete) {
|
||||
onImageSettledRef.current?.();
|
||||
}
|
||||
image.classList.add("cursor-zoom-in");
|
||||
return () => {
|
||||
image.removeEventListener("load", handleSettled);
|
||||
image.removeEventListener("error", handleSettled);
|
||||
image.removeEventListener("click", handleClick);
|
||||
};
|
||||
});
|
||||
return () => {
|
||||
cleanups.forEach((cleanup) => cleanup());
|
||||
};
|
||||
}, [html, onImageSettled, onImageClick]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className={`break-words [&_p]:m-0 [&_p+*]:mt-2 [&_h1]:m-0 [&_h1]:text-base [&_h1]:font-semibold [&_h1+*]:mt-2 [&_h2]:m-0 [&_h2]:text-[15px] [&_h2]:font-semibold [&_h2+*]:mt-2 [&_h3]:m-0 [&_h3]:font-semibold [&_h3+*]:mt-2 [&_h4]:m-0 [&_h4]:font-medium [&_h4+*]:mt-2 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_li]:my-1 [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-current/20 [&_blockquote]:pl-3 [&_blockquote]:opacity-90 [&_pre]:my-2 [&_pre]:max-w-full [&_pre]:overflow-x-auto [&_pre]:rounded-xl [&_pre]:bg-black/6 [&_pre]:px-3 [&_pre]:py-2 [&_pre]:text-[13px] [&_pre]:leading-6 [&_code]:rounded [&_code]:bg-black/6 [&_code]:px-1 [&_code]:py-0.5 [&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_hr]:my-3 [&_hr]:border-current/10 [&_table]:my-2 [&_table]:w-full [&_table]:border-collapse [&_th]:border [&_th]:border-current/10 [&_th]:px-2 [&_th]:py-1 [&_th]:text-left [&_th]:font-medium [&_td]:border [&_td]:border-current/10 [&_td]:px-2 [&_td]:py-1 [&_img]:my-2 [&_img]:max-h-64 [&_img]:max-w-full [&_img]:rounded-xl [&_img]:object-contain [&_.im-attachment]:min-w-0 [&_.im-attachment-link]:flex [&_.im-attachment-link]:min-w-0 [&_.im-attachment-link]:items-center [&_.im-attachment-link]:gap-3 [&_.im-attachment-link]:rounded-2xl [&_.im-attachment-link]:no-underline [&_.im-attachment-link]:transition-colors hover:[&_.im-attachment-link]:bg-black/5 [&_.im-attachment-icon]:flex [&_.im-attachment-icon]:size-10 [&_.im-attachment-icon]:shrink-0 [&_.im-attachment-icon]:items-center [&_.im-attachment-icon]:justify-center [&_.im-attachment-icon]:rounded-2xl [&_.im-attachment-icon]:bg-black/5 [&_.im-attachment-icon_svg]:size-5 [&_.im-attachment-content]:flex [&_.im-attachment-content]:min-w-0 [&_.im-attachment-content]:flex-col [&_.im-attachment-title]:truncate [&_.im-attachment-title]:font-medium [&_.im-attachment-meta]:text-xs [&_.im-attachment-meta]:opacity-70 ${className}`}
|
||||
dangerouslySetInnerHTML={{ __html: html }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export const MessageHTML = memo(
|
||||
MessageHTMLComponent,
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.html === nextProps.html &&
|
||||
prevProps.className === nextProps.className &&
|
||||
prevProps.onImageSettled === nextProps.onImageSettled &&
|
||||
prevProps.onImageClick === nextProps.onImageClick,
|
||||
);
|
||||
@@ -1,123 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useLayoutEffect, useRef, useState } from "react";
|
||||
import { ImageIcon, SendHorizonalIcon } from "lucide-react";
|
||||
|
||||
type MessageInputProps = {
|
||||
disabled?: boolean;
|
||||
uploadingImage?: boolean;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
onSendImage: (file: File) => Promise<void>;
|
||||
};
|
||||
|
||||
export function MessageInput({
|
||||
disabled,
|
||||
uploadingImage = false,
|
||||
onSend,
|
||||
onSendImage,
|
||||
}: MessageInputProps) {
|
||||
const [value, setValue] = useState("");
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement | null>(null);
|
||||
const imageInputRef = useRef<HTMLInputElement | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const textarea = textareaRef.current;
|
||||
if (!textarea) {
|
||||
return;
|
||||
}
|
||||
const lineHeight = 20;
|
||||
const maxHeight = lineHeight * 8;
|
||||
textarea.style.height = "0px";
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, maxHeight)}px`;
|
||||
textarea.style.overflowY =
|
||||
textarea.scrollHeight > maxHeight ? "auto" : "hidden";
|
||||
}, [value]);
|
||||
|
||||
async function handleSubmit() {
|
||||
const content = value.trim();
|
||||
if (!content || disabled || submitting) {
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
try {
|
||||
await onSend(content);
|
||||
setValue("");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelectImage(event: React.ChangeEvent<HTMLInputElement>) {
|
||||
const file = event.target.files?.[0];
|
||||
event.target.value = "";
|
||||
if (!file || disabled || uploadingImage || submitting) {
|
||||
return;
|
||||
}
|
||||
if (!file.type.startsWith("image/")) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await onSendImage(file);
|
||||
} finally {
|
||||
requestAnimationFrame(() => {
|
||||
textareaRef.current?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-3 pb-3 pt-2">
|
||||
<div className="rounded-3xl border border-white/60 bg-white/78 p-2.5 shadow-[0_10px_24px_rgba(15,23,42,0.05)] backdrop-blur">
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
onChange={handleSelectImage}
|
||||
/>
|
||||
<div className="flex items-start gap-3">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
onChange={(event) => setValue(event.target.value)}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
void handleSubmit();
|
||||
}
|
||||
}}
|
||||
placeholder="输入消息,Enter 发送,Shift + Enter 换行"
|
||||
disabled={disabled || uploadingImage}
|
||||
rows={2}
|
||||
className="cs-agent-scrollbar min-h-12 flex-1 resize-none bg-transparent px-1.5 pt-1 text-[13px] leading-6 text-slate-900 outline-none placeholder:text-slate-400 disabled:cursor-not-allowed"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => imageInputRef.current?.click()}
|
||||
disabled={disabled || uploadingImage || submitting}
|
||||
aria-label={uploadingImage ? "图片上传中" : "发送图片"}
|
||||
className="mt-1 inline-flex size-11 shrink-0 items-center justify-center rounded-2xl border border-slate-200/80 bg-white/90 text-slate-500 shadow-[0_10px_24px_rgba(15,23,42,0.05)] transition duration-200 hover:-translate-y-0.5 hover:text-slate-700 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-100 disabled:text-slate-300"
|
||||
>
|
||||
<ImageIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSubmit}
|
||||
disabled={disabled || submitting || uploadingImage}
|
||||
aria-label={submitting ? "发送中" : "发送"}
|
||||
className="mt-1 inline-flex size-11 shrink-0 items-center justify-center rounded-2xl bg-[linear-gradient(135deg,var(--primary),color-mix(in_srgb,var(--primary)_75%,white_25%))] text-white shadow-[0_12px_26px_color-mix(in_srgb,var(--primary)_28%,transparent)] transition duration-200 hover:-translate-y-0.5 hover:brightness-105 disabled:translate-y-0 disabled:cursor-not-allowed disabled:bg-slate-300 disabled:shadow-none"
|
||||
>
|
||||
<SendHorizonalIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="mt-2 px-1.5 text-[11px] text-slate-400">
|
||||
Enter 发送,支持图片上传
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
forwardRef,
|
||||
memo,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
} from "react";
|
||||
import Image from "next/image";
|
||||
|
||||
import { MessageHTML } from "@/components/im/message-html";
|
||||
import { useImageLightbox } from "@/components/image-lightbox";
|
||||
import { renderMessageHTML } from "@/lib/services/message-asset";
|
||||
import type { WidgetMessage } from "@/lib/services/types";
|
||||
import { cn, formatDateTime } from "@/lib/utils";
|
||||
|
||||
type MessageListProps = {
|
||||
messages: WidgetMessage[];
|
||||
onNearBottomVisible?: () => void;
|
||||
hasMoreOlder?: boolean;
|
||||
loadingOlder?: boolean;
|
||||
onLoadOlder?: () => Promise<void>;
|
||||
};
|
||||
|
||||
export type MessageListHandle = {
|
||||
scrollToBottom: () => void;
|
||||
};
|
||||
|
||||
function getDayKey(value?: string) {
|
||||
if (!value) {
|
||||
return "unknown";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value.slice(0, 10);
|
||||
}
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}-${String(
|
||||
date.getDate(),
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function getTimelineLabel(value?: string) {
|
||||
if (!value) {
|
||||
return "刚刚";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
const now = new Date();
|
||||
const currentDayKey = getDayKey(value);
|
||||
const todayDayKey = getDayKey(now.toISOString());
|
||||
const timeText = `${String(date.getHours()).padStart(2, "0")}:${String(date.getMinutes()).padStart(2, "0")}`;
|
||||
if (currentDayKey === todayDayKey) {
|
||||
return `今天 ${timeText}`;
|
||||
}
|
||||
return `${currentDayKey} ${timeText}`;
|
||||
}
|
||||
|
||||
export const MessageList = forwardRef<MessageListHandle, MessageListProps>(
|
||||
function MessageList(
|
||||
{
|
||||
messages,
|
||||
onNearBottomVisible,
|
||||
hasMoreOlder = false,
|
||||
loadingOlder = false,
|
||||
onLoadOlder,
|
||||
},
|
||||
ref,
|
||||
) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
const frameRef = useRef<number | null>(null);
|
||||
const shouldStickToBottomRef = useRef(true);
|
||||
const lastMessageId = messages.at(-1)?.id;
|
||||
|
||||
const isNearBottom = useCallback(
|
||||
(element: HTMLElement, threshold = 80) =>
|
||||
element.scrollHeight - element.scrollTop - element.clientHeight <=
|
||||
threshold,
|
||||
[],
|
||||
);
|
||||
|
||||
const scrollToBottom = useCallback(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
container.scrollTop = container.scrollHeight;
|
||||
}, []);
|
||||
|
||||
const scheduleScrollToBottom = useCallback(
|
||||
(attempts = 4) => {
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
}
|
||||
|
||||
const run = (remaining: number, previousHeight = -1) => {
|
||||
frameRef.current = requestAnimationFrame(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
frameRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const currentHeight = container.scrollHeight;
|
||||
scrollToBottom();
|
||||
if (remaining > 1 && currentHeight !== previousHeight) {
|
||||
run(remaining - 1, currentHeight);
|
||||
return;
|
||||
}
|
||||
frameRef.current = null;
|
||||
});
|
||||
};
|
||||
|
||||
run(attempts);
|
||||
},
|
||||
[scrollToBottom],
|
||||
);
|
||||
|
||||
const handleImageSettled = useCallback(() => {
|
||||
if (shouldStickToBottomRef.current) {
|
||||
scheduleScrollToBottom();
|
||||
onNearBottomVisible?.();
|
||||
}
|
||||
}, [onNearBottomVisible, scheduleScrollToBottom]);
|
||||
|
||||
useImperativeHandle(ref, () => ({
|
||||
scrollToBottom,
|
||||
}));
|
||||
|
||||
useLayoutEffect(() => {
|
||||
shouldStickToBottomRef.current = true;
|
||||
scheduleScrollToBottom();
|
||||
return () => {
|
||||
if (frameRef.current !== null) {
|
||||
cancelAnimationFrame(frameRef.current);
|
||||
frameRef.current = null;
|
||||
}
|
||||
};
|
||||
}, [lastMessageId, scheduleScrollToBottom]);
|
||||
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const content = contentRef.current;
|
||||
if (!container || !content) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
shouldStickToBottomRef.current = isNearBottom(container);
|
||||
if (shouldStickToBottomRef.current) {
|
||||
onNearBottomVisible?.();
|
||||
}
|
||||
};
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
if (shouldStickToBottomRef.current) {
|
||||
scheduleScrollToBottom();
|
||||
}
|
||||
});
|
||||
|
||||
handleScroll();
|
||||
container.addEventListener("scroll", handleScroll);
|
||||
resizeObserver.observe(content);
|
||||
scrollToBottom();
|
||||
|
||||
return () => {
|
||||
container.removeEventListener("scroll", handleScroll);
|
||||
resizeObserver.disconnect();
|
||||
};
|
||||
}, [
|
||||
isNearBottom,
|
||||
onNearBottomVisible,
|
||||
scheduleScrollToBottom,
|
||||
scrollToBottom,
|
||||
]);
|
||||
|
||||
const handleLoadOlder = useCallback(async () => {
|
||||
if (!onLoadOlder || loadingOlder || !hasMoreOlder) {
|
||||
return;
|
||||
}
|
||||
const container = containerRef.current;
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
const anchor = {
|
||||
height: container.scrollHeight,
|
||||
top: container.scrollTop,
|
||||
};
|
||||
try {
|
||||
await onLoadOlder();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
requestAnimationFrame(() => {
|
||||
requestAnimationFrame(() => {
|
||||
const c = containerRef.current;
|
||||
if (!c) {
|
||||
return;
|
||||
}
|
||||
c.scrollTop = c.scrollHeight - anchor.height + anchor.top;
|
||||
});
|
||||
});
|
||||
}, [hasMoreOlder, loadingOlder, onLoadOlder]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex min-h-0 flex-1 flex-col gap-4 overflow-y-auto px-4 py-4 cs-agent-scrollbar"
|
||||
>
|
||||
<div ref={contentRef} className="flex flex-col gap-4">
|
||||
{hasMoreOlder && onLoadOlder ? (
|
||||
<div className="flex justify-center py-1">
|
||||
<button
|
||||
type="button"
|
||||
disabled={loadingOlder}
|
||||
onClick={() => void handleLoadOlder()}
|
||||
className="rounded-full border border-white/70 bg-white/75 px-3 py-1 text-[11px] font-medium text-slate-500 shadow-[0_8px_18px_rgba(15,23,42,0.04)] backdrop-blur transition hover:-translate-y-0.5 hover:border-sky-200 hover:text-sky-700 disabled:translate-y-0 disabled:opacity-60"
|
||||
>
|
||||
{loadingOlder ? "加载中…" : "加载更早的消息"}
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
{/* <WelcomePanel title={title} welcomeText={welcomeText} /> */}
|
||||
|
||||
{/* {messages.length === 0 ? (
|
||||
<div className="cs-agent-fade-up rounded-3xl border border-dashed border-slate-200 bg-white/72 px-4 py-5 text-sm leading-6 text-slate-500 shadow-[0_10px_22px_rgba(15,23,42,0.04)] backdrop-blur">
|
||||
开始发送第一条消息后,会在这里保留完整会话记录。
|
||||
</div>
|
||||
) : null} */}
|
||||
|
||||
{messages.map((message, index) => {
|
||||
const previousMessage = index > 0 ? messages[index - 1] : null;
|
||||
const showTimeline =
|
||||
index === 0 ||
|
||||
getDayKey(previousMessage?.sentAt) !== getDayKey(message.sentAt);
|
||||
|
||||
return (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
message={message}
|
||||
showTimeline={showTimeline}
|
||||
onImageSettled={handleImageSettled}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
type MessageItemProps = {
|
||||
message: WidgetMessage;
|
||||
showTimeline: boolean;
|
||||
onImageSettled: () => void;
|
||||
};
|
||||
|
||||
const MessageItem = memo(
|
||||
function MessageItem({
|
||||
message,
|
||||
showTimeline,
|
||||
onImageSettled,
|
||||
}: MessageItemProps) {
|
||||
const { open: openImageLightbox } = useImageLightbox();
|
||||
const isCustomer = message.senderType === "customer";
|
||||
const senderName = isCustomer ? "我" : message.senderName?.trim() || "客服";
|
||||
const agentAvatarSrc =
|
||||
!isCustomer && message.senderAvatar?.trim()
|
||||
? message.senderAvatar.trim()
|
||||
: undefined;
|
||||
const htmlContent = buildMessageHTML(message);
|
||||
|
||||
return (
|
||||
<div className="cs-agent-fade-up">
|
||||
{showTimeline ? (
|
||||
<div className="mb-3 flex items-center justify-center">
|
||||
<div className="rounded-full border border-white/70 bg-white/80 px-3 py-1 text-[11px] font-medium text-slate-500 shadow-[0_8px_18px_rgba(15,23,42,0.04)] backdrop-blur">
|
||||
{getTimelineLabel(message.sentAt)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-2",
|
||||
isCustomer ? "justify-end" : "justify-start",
|
||||
)}
|
||||
>
|
||||
{!isCustomer && agentAvatarSrc ? (
|
||||
<Image
|
||||
src={agentAvatarSrc}
|
||||
alt=""
|
||||
width={32}
|
||||
height={32}
|
||||
className="size-8 shrink-0 rounded-full object-cover ring-1 ring-white/80"
|
||||
/>
|
||||
) : null}
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-w-[86%] flex-col gap-1",
|
||||
isCustomer ? "items-end" : "items-start",
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-2 px-1 text-[11px] text-slate-400">
|
||||
<span className="font-medium">{senderName}</span>
|
||||
<span>{formatDateTime(message.sentAt)}</span>
|
||||
{isCustomer ? (
|
||||
<span>{message.agentRead ? "客服已读" : "客服未读"}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm leading-normal shadow-[0_14px_28px_rgba(15,23,42,0.08)]",
|
||||
isCustomer
|
||||
? "bg-[#A9EA7A] text-[#161616]"
|
||||
: "border border-white/80 bg-white/94 text-slate-900",
|
||||
)}
|
||||
>
|
||||
<MessageHTML
|
||||
html={htmlContent}
|
||||
className={cn(
|
||||
isCustomer
|
||||
? "[&_p]:text-[#161616] [&_a]:text-[#161616] [&_a]:underline [&_img]:cursor-zoom-in"
|
||||
: "[&_a]:text-slate-900 [&_a]:underline [&_img]:cursor-zoom-in",
|
||||
)}
|
||||
onImageSettled={onImageSettled}
|
||||
onImageClick={openImageLightbox}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
(prevProps, nextProps) =>
|
||||
prevProps.message === nextProps.message &&
|
||||
prevProps.showTimeline === nextProps.showTimeline &&
|
||||
prevProps.onImageSettled === nextProps.onImageSettled,
|
||||
);
|
||||
|
||||
function buildMessageHTML(message: WidgetMessage) {
|
||||
return renderMessageHTML(message);
|
||||
}
|
||||
@@ -1,376 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
ExternalLinkIcon,
|
||||
RefreshCwIcon,
|
||||
RotateCcwIcon,
|
||||
RotateCwIcon,
|
||||
XIcon,
|
||||
ZoomInIcon,
|
||||
ZoomOutIcon,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from "react";
|
||||
import type { ReactZoomPanPinchContentRef } from "react-zoom-pan-pinch";
|
||||
import {
|
||||
TransformComponent,
|
||||
TransformWrapper,
|
||||
} from "react-zoom-pan-pinch";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ImageLightboxItem = {
|
||||
src: string;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
type ImageLightboxContextValue = {
|
||||
open: (src: string, alt?: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
const ImageLightboxContext = createContext<ImageLightboxContextValue | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const iconBtnClass =
|
||||
"inline-flex size-7 shrink-0 items-center justify-center rounded-md text-white outline-none transition-colors hover:bg-white/10 focus-visible:ring-2 focus-visible:ring-white/40";
|
||||
|
||||
export function useImageLightbox(): ImageLightboxContextValue {
|
||||
const ctx = useContext(ImageLightboxContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useImageLightbox 必须在 ImageLightboxProvider 内使用");
|
||||
}
|
||||
return ctx;
|
||||
}
|
||||
|
||||
export function useImageLightboxOptional(): ImageLightboxContextValue | null {
|
||||
return useContext(ImageLightboxContext);
|
||||
}
|
||||
|
||||
export type ImageLightboxProps = {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
src: string | null;
|
||||
alt?: string;
|
||||
};
|
||||
|
||||
function canOpenInNewTab(url: string): boolean {
|
||||
if (!url) {
|
||||
return false;
|
||||
}
|
||||
if (url.startsWith("/")) {
|
||||
return true;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return (
|
||||
parsed.protocol === "http:" ||
|
||||
parsed.protocol === "https:" ||
|
||||
parsed.protocol === "blob:"
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function LightboxImageBody({
|
||||
src,
|
||||
alt,
|
||||
pinchRef,
|
||||
rotationDeg,
|
||||
}: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
pinchRef: React.RefObject<ReactZoomPanPinchContentRef | null>;
|
||||
rotationDeg: number;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const showOpenTab = canOpenInNewTab(src);
|
||||
|
||||
useEffect(() => {
|
||||
requestAnimationFrame(() => {
|
||||
pinchRef.current?.centerView(1, 0);
|
||||
});
|
||||
}, [rotationDeg, pinchRef]);
|
||||
|
||||
return (
|
||||
<div className="relative h-full min-h-0 w-full min-w-0 flex-1">
|
||||
{loading && !error ? (
|
||||
<div
|
||||
className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center"
|
||||
aria-hidden
|
||||
>
|
||||
<div className="size-10 animate-pulse rounded-full bg-white/25" />
|
||||
</div>
|
||||
) : null}
|
||||
{error ? (
|
||||
<div className="flex min-h-[min(50vh,320px)] flex-col items-center justify-center gap-4 px-6 py-12 text-center text-sm text-white/90">
|
||||
<p>图片加载失败</p>
|
||||
{showOpenTab ? (
|
||||
<a
|
||||
href={src}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="inline-flex h-7 items-center justify-center rounded-md bg-white/90 px-2.5 text-xs font-medium text-slate-900"
|
||||
>
|
||||
在新标签页打开
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<TransformWrapper
|
||||
ref={pinchRef}
|
||||
initialScale={1}
|
||||
minScale={0.35}
|
||||
maxScale={8}
|
||||
centerOnInit
|
||||
centerZoomedOut
|
||||
limitToBounds
|
||||
wheel={{ step: 0.12 }}
|
||||
pinch={{ step: 5 }}
|
||||
panning={{ velocityDisabled: false }}
|
||||
doubleClick={{ mode: "reset", step: 0.7 }}
|
||||
>
|
||||
<TransformComponent
|
||||
wrapperClass="!h-full !w-full !max-h-full !max-w-full"
|
||||
contentClass="!flex !h-full !min-h-0 !w-full !min-w-0 !items-center !justify-center !p-4 sm:!p-6"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element -- 外链与任意尺寸大图预览 */}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt || "预览图片"}
|
||||
draggable={false}
|
||||
style={{ transform: `rotate(${rotationDeg}deg)` }}
|
||||
className={cn(
|
||||
"max-h-[min(85vh,calc(100dvh-3rem))] max-w-full origin-center object-contain transition-transform duration-200 ease-out select-none",
|
||||
loading ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
onLoad={() => {
|
||||
setLoading(false);
|
||||
setError(false);
|
||||
requestAnimationFrame(() => {
|
||||
pinchRef.current?.centerView(1, 0);
|
||||
});
|
||||
}}
|
||||
onError={() => {
|
||||
setLoading(false);
|
||||
setError(true);
|
||||
}}
|
||||
/>
|
||||
</TransformComponent>
|
||||
</TransformWrapper>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ImageLightboxDialogContent({
|
||||
src,
|
||||
alt,
|
||||
onRequestClose,
|
||||
}: {
|
||||
src: string;
|
||||
alt?: string;
|
||||
onRequestClose: () => void;
|
||||
}) {
|
||||
const pinchRef = useRef<ReactZoomPanPinchContentRef | null>(null);
|
||||
const [rotationDeg, setRotationDeg] = useState(0);
|
||||
const showOpenTab = canOpenInNewTab(src);
|
||||
const titleText = alt?.trim() || "图片预览";
|
||||
|
||||
const rotateLeft = useCallback(() => {
|
||||
setRotationDeg((d) => (d - 90 + 360) % 360);
|
||||
}, []);
|
||||
|
||||
const rotateRight = useCallback(() => {
|
||||
setRotationDeg((d) => (d + 90) % 360);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="relative flex h-full min-h-0 w-full flex-col">
|
||||
<div
|
||||
className="absolute inset-0 z-0 bg-black/85"
|
||||
aria-hidden
|
||||
onClick={onRequestClose}
|
||||
/>
|
||||
<div className="relative z-10 flex h-full min-h-0 flex-col">
|
||||
<div className="flex h-12 shrink-0 items-center gap-2 border-b border-white/10 bg-black/55 px-2 py-2 text-white sm:gap-3 sm:px-4">
|
||||
<h2
|
||||
id="widget-image-lightbox-title"
|
||||
className="min-w-0 flex-1 truncate text-left text-sm font-medium leading-snug text-white"
|
||||
>
|
||||
{titleText}
|
||||
</h2>
|
||||
<div className="flex shrink-0 items-center gap-0.5 sm:gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtnClass}
|
||||
aria-label="放大"
|
||||
onClick={() => pinchRef.current?.zoomIn(0.25)}
|
||||
>
|
||||
<ZoomInIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtnClass}
|
||||
aria-label="缩小"
|
||||
onClick={() => pinchRef.current?.zoomOut(0.25)}
|
||||
>
|
||||
<ZoomOutIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtnClass}
|
||||
aria-label="向左旋转"
|
||||
onClick={rotateLeft}
|
||||
>
|
||||
<RotateCcwIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtnClass}
|
||||
aria-label="向右旋转"
|
||||
onClick={rotateRight}
|
||||
>
|
||||
<RotateCwIcon className="size-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtnClass}
|
||||
aria-label="重置缩放、位置与旋转"
|
||||
onClick={() => {
|
||||
setRotationDeg(0);
|
||||
pinchRef.current?.resetTransform(200);
|
||||
}}
|
||||
>
|
||||
<RefreshCwIcon className="size-4" />
|
||||
</button>
|
||||
{showOpenTab ? (
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtnClass}
|
||||
aria-label="在新标签页打开"
|
||||
onClick={() => {
|
||||
window.open(src, "_blank", "noopener,noreferrer");
|
||||
}}
|
||||
>
|
||||
<ExternalLinkIcon className="size-4" />
|
||||
</button>
|
||||
) : null}
|
||||
<button
|
||||
type="button"
|
||||
className={iconBtnClass}
|
||||
aria-label="关闭"
|
||||
onClick={onRequestClose}
|
||||
>
|
||||
<XIcon className="size-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative flex min-h-0 min-w-0 flex-1 flex-col overflow-hidden">
|
||||
<LightboxImageBody
|
||||
pinchRef={pinchRef}
|
||||
rotationDeg={rotationDeg}
|
||||
src={src}
|
||||
alt={alt}
|
||||
/>
|
||||
</div>
|
||||
<p className="sr-only">
|
||||
使用滚轮或双指缩放,按住拖拽可平移图片;工具栏可向左或向右旋转。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageLightboxView({
|
||||
open,
|
||||
onOpenChange,
|
||||
src,
|
||||
alt,
|
||||
}: ImageLightboxProps) {
|
||||
const dialogRef = useRef<HTMLDialogElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const el = dialogRef.current;
|
||||
if (!el) {
|
||||
return;
|
||||
}
|
||||
if (open && src) {
|
||||
if (!el.open) {
|
||||
el.showModal();
|
||||
}
|
||||
} else if (el.open) {
|
||||
el.close();
|
||||
}
|
||||
}, [open, src]);
|
||||
|
||||
return (
|
||||
<dialog
|
||||
ref={dialogRef}
|
||||
className="cs-agent-image-lightbox fixed inset-0 z-[2147483646] m-0 flex h-full max-h-none w-full max-w-none flex-col border-0 bg-transparent p-0 [&:not([open])]:hidden"
|
||||
aria-modal="true"
|
||||
aria-labelledby="widget-image-lightbox-title"
|
||||
onClose={() => onOpenChange(false)}
|
||||
>
|
||||
{src ? (
|
||||
<ImageLightboxDialogContent
|
||||
key={src}
|
||||
alt={alt}
|
||||
src={src}
|
||||
onRequestClose={() => onOpenChange(false)}
|
||||
/>
|
||||
) : null}
|
||||
</dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageLightboxProvider({ children }: { children: ReactNode }) {
|
||||
const [state, setState] = useState<ImageLightboxItem | null>(null);
|
||||
|
||||
const open = useCallback((src: string, alt?: string) => {
|
||||
const trimmed = src?.trim();
|
||||
if (!trimmed) {
|
||||
return;
|
||||
}
|
||||
setState({ src: trimmed, alt });
|
||||
}, []);
|
||||
|
||||
const close = useCallback(() => {
|
||||
setState(null);
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
open,
|
||||
close,
|
||||
}),
|
||||
[open, close],
|
||||
);
|
||||
|
||||
return (
|
||||
<ImageLightboxContext.Provider value={contextValue}>
|
||||
{children}
|
||||
<ImageLightboxView
|
||||
open={state !== null}
|
||||
onOpenChange={(next) => {
|
||||
if (!next) {
|
||||
setState(null);
|
||||
}
|
||||
}}
|
||||
src={state?.src ?? null}
|
||||
alt={state?.alt}
|
||||
/>
|
||||
</ImageLightboxContext.Provider>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
|
||||
export default defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
globalIgnores([".next/**", "out/**", "build/**"]),
|
||||
]);
|
||||
@@ -1,29 +0,0 @@
|
||||
import { requestJson } from "@/lib/services/http";
|
||||
import type { JsonResult, WidgetConversation } from "@/lib/services/types";
|
||||
|
||||
/** 身份由请求头 X-External-Source / X-External-Id(及可选 X-External-Name)提供,与 GetExternalInfo 一致 */
|
||||
export async function createOrMatchConversation() {
|
||||
const result = await requestJson<JsonResult<WidgetConversation>>(
|
||||
"/api/open/im/conversation/create_or_match",
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
);
|
||||
if (!result.data) {
|
||||
throw new Error(result.message || "conversation init failed");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function closeConversation(conversationId: number) {
|
||||
const result = await requestJson<JsonResult<null>>(
|
||||
"/api/open/im/conversation/close",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId }),
|
||||
},
|
||||
);
|
||||
if (result.success === false) {
|
||||
throw new Error(result.message || "conversation close failed");
|
||||
}
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
import { readWidgetConfig } from "@/lib/widget/config";
|
||||
import { getOrCreateExternalId } from "@/lib/widget/visitor";
|
||||
|
||||
export async function requestJson<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const config = readWidgetConfig();
|
||||
const baseUrl = (config.apiBaseUrl || config.baseUrl).replace(/\/$/, "");
|
||||
const visitorId = getOrCreateExternalId();
|
||||
const externalSource = (config.externalSource ?? "web_chat").trim() || "web_chat";
|
||||
const headers = new Headers(init?.headers ?? {});
|
||||
if (
|
||||
!headers.has("Content-Type") &&
|
||||
init?.body &&
|
||||
!(typeof FormData !== "undefined" && init.body instanceof FormData)
|
||||
) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
headers.set("X-External-Source", externalSource);
|
||||
headers.set("X-External-Id", visitorId);
|
||||
const externalName = (config.subject ?? "").trim();
|
||||
if (externalName) {
|
||||
headers.set("X-External-Name", encodeURIComponent(externalName));
|
||||
}
|
||||
headers.set("X-Channel-Id", config.channelId);
|
||||
const response = await fetch(`${baseUrl}${path}`, {
|
||||
...init,
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed: ${response.status}`);
|
||||
}
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
@@ -1,149 +0,0 @@
|
||||
import MarkdownIt from "markdown-it";
|
||||
|
||||
export type WidgetMessageAssetPayload = {
|
||||
assetId: string;
|
||||
filename?: string;
|
||||
fileSize?: number;
|
||||
mimeType?: string;
|
||||
url?: string;
|
||||
};
|
||||
|
||||
const messageMarkdown = new MarkdownIt({
|
||||
html: false,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
});
|
||||
|
||||
export function parseMessageAssetPayload(
|
||||
payload?: string,
|
||||
): WidgetMessageAssetPayload | null {
|
||||
if (!payload?.trim()) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(payload) as WidgetMessageAssetPayload;
|
||||
if (!parsed?.assetId?.trim()) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMessageHTML(message: {
|
||||
messageType: string;
|
||||
content: string;
|
||||
payload?: string;
|
||||
}) {
|
||||
if (message.messageType === "html") {
|
||||
return message.content;
|
||||
}
|
||||
|
||||
const asset = parseMessageAssetPayload(message.payload);
|
||||
if (message.messageType === "image") {
|
||||
if (asset?.url) {
|
||||
return `<p><img src="${escapeHTMLAttr(asset.url)}" alt="${escapeHTMLAttr(
|
||||
asset.filename || "image",
|
||||
)}"></p>`;
|
||||
}
|
||||
return "<p>[图片]</p>";
|
||||
}
|
||||
|
||||
if (message.messageType === "attachment") {
|
||||
if (asset?.url) {
|
||||
const title = escapeHTML(asset.filename || message.content || "附件");
|
||||
const meta = formatFileSize(asset.fileSize ?? 0);
|
||||
const metaHTML = meta
|
||||
? `<div class="im-attachment-meta">${escapeHTML(meta)}</div>`
|
||||
: "";
|
||||
return `<div class="im-attachment"><a href="${escapeHTMLAttr(
|
||||
asset.url,
|
||||
)}" target="_blank" rel="noreferrer" download="${escapeHTMLAttr(
|
||||
asset.filename || "",
|
||||
)}" class="im-attachment-link"><span class="im-attachment-icon" aria-hidden="true">${getAttachmentIconSVG()}</span><span class="im-attachment-content"><span class="im-attachment-title">${title}</span>${metaHTML}</span></a></div>`;
|
||||
}
|
||||
return `<p>${escapeHTML(message.content || "[附件]")}</p>`;
|
||||
}
|
||||
|
||||
return renderTextMessageHTML(message.content || "");
|
||||
}
|
||||
|
||||
export function summarizeMessage(message: {
|
||||
messageType: string;
|
||||
content: string;
|
||||
payload?: string;
|
||||
}) {
|
||||
if (message.messageType === "image") {
|
||||
return "[图片]";
|
||||
}
|
||||
if (message.messageType === "attachment") {
|
||||
const asset = parseMessageAssetPayload(message.payload);
|
||||
return asset?.filename?.trim() ? `[附件] ${asset.filename.trim()}` : "[附件]";
|
||||
}
|
||||
if (message.messageType === "html") {
|
||||
const text = extractTextFromHTML(message.content);
|
||||
if (text.trim()) {
|
||||
return text.substring(0, 100);
|
||||
}
|
||||
if (message.content.includes("<img")) {
|
||||
return "[图片]";
|
||||
}
|
||||
return "[消息]";
|
||||
}
|
||||
return message.content?.substring(0, 100) || "[消息]";
|
||||
}
|
||||
|
||||
function formatFileSize(size: number) {
|
||||
if (!Number.isFinite(size) || size <= 0) {
|
||||
return "";
|
||||
}
|
||||
const units = ["B", "KB", "MB", "GB"];
|
||||
let value = size;
|
||||
let index = 0;
|
||||
while (value >= 1024 && index < units.length - 1) {
|
||||
value /= 1024;
|
||||
index += 1;
|
||||
}
|
||||
const digits = value >= 10 || index === 0 ? 0 : 1;
|
||||
return `${value.toFixed(digits)} ${units[index]}`;
|
||||
}
|
||||
|
||||
function extractTextFromHTML(html: string): string {
|
||||
if (typeof document === "undefined") {
|
||||
return "";
|
||||
}
|
||||
const div = document.createElement("div");
|
||||
div.innerHTML = html;
|
||||
return div.textContent || div.innerText || "";
|
||||
}
|
||||
|
||||
function renderTextMessageHTML(content: string) {
|
||||
const value = content.trim();
|
||||
if (!value) {
|
||||
return "<p></p>";
|
||||
}
|
||||
return messageMarkdown.render(value);
|
||||
}
|
||||
|
||||
function escapeHTML(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("'", "'")
|
||||
.replaceAll("\n", "<br>");
|
||||
}
|
||||
|
||||
function escapeHTMLAttr(value: string) {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll('"', """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
}
|
||||
|
||||
function getAttachmentIconSVG() {
|
||||
return `<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path><path d="M14 2v6h6"></path><path d="M9 15h6"></path><path d="M9 11h2"></path></svg>`;
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { requestJson } from "@/lib/services/http";
|
||||
import type {
|
||||
CursorResult,
|
||||
JsonResult,
|
||||
WidgetAsset,
|
||||
WidgetMessage,
|
||||
} from "@/lib/services/types";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const DEFAULT_PAGE_LIMIT = 50;
|
||||
|
||||
function buildListQuery(
|
||||
conversationId: number,
|
||||
options?: { cursor?: number; limit?: number },
|
||||
) {
|
||||
const params = new URLSearchParams({
|
||||
conversationId: String(conversationId),
|
||||
});
|
||||
const cursor = options?.cursor;
|
||||
if (cursor !== undefined && cursor > 0) {
|
||||
params.set("cursor", String(cursor));
|
||||
}
|
||||
const limit = options?.limit ?? DEFAULT_PAGE_LIMIT;
|
||||
if (limit > 0) {
|
||||
params.set("limit", String(limit));
|
||||
}
|
||||
return params.toString();
|
||||
}
|
||||
|
||||
export async function fetchMessagesPage(
|
||||
conversationId: number,
|
||||
options?: { cursor?: number; limit?: number },
|
||||
): Promise<CursorResult<WidgetMessage>> {
|
||||
const qs = buildListQuery(conversationId, options);
|
||||
const result = await requestJson<
|
||||
JsonResult<CursorResult<WidgetMessage>>
|
||||
>(`/api/open/im/message/list?${qs}`);
|
||||
if (result.success === false) {
|
||||
throw new Error(result.message || "加载消息失败");
|
||||
}
|
||||
const data = result.data;
|
||||
return {
|
||||
results: data?.results ?? [],
|
||||
cursor: data?.cursor ?? "",
|
||||
hasMore: Boolean(data?.hasMore),
|
||||
};
|
||||
}
|
||||
|
||||
/** @deprecated 使用 fetchMessagesPage;保留别名供渐进迁移 */
|
||||
export async function fetchMessages(conversationId: number) {
|
||||
const page = await fetchMessagesPage(conversationId);
|
||||
return page.results;
|
||||
}
|
||||
|
||||
export async function sendMessageWithPayload(
|
||||
conversationId: number,
|
||||
payload: {
|
||||
messageType: string;
|
||||
content: string;
|
||||
payload?: string;
|
||||
clientMsgId?: string;
|
||||
},
|
||||
) {
|
||||
const result = await requestJson<JsonResult<WidgetMessage>>("/api/open/im/message/send", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
conversationId,
|
||||
clientMsgId: payload.clientMsgId || `client_${generateUUID()}`,
|
||||
messageType: payload.messageType,
|
||||
content: payload.content,
|
||||
payload: payload.payload || "",
|
||||
}),
|
||||
});
|
||||
if (!result.data) {
|
||||
throw new Error(result.message || "send message failed");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function sendMessage(conversationId: number, content: string) {
|
||||
return sendMessageWithPayload(conversationId, {
|
||||
messageType: "html",
|
||||
content,
|
||||
payload: "",
|
||||
});
|
||||
}
|
||||
|
||||
export async function markMessageRead(conversationId: number, messageId = 0) {
|
||||
await requestJson<JsonResult<void>>("/api/open/im/message/read", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ conversationId, messageId }),
|
||||
});
|
||||
}
|
||||
|
||||
export async function uploadImage(conversationId: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.set("conversationId", String(conversationId));
|
||||
formData.set("file", file);
|
||||
const result = await requestJson<JsonResult<WidgetAsset>>(
|
||||
"/api/open/im/message/upload_image",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
if (!result.data) {
|
||||
throw new Error(result.message || "upload image failed");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
export async function uploadAttachment(conversationId: number, file: File) {
|
||||
const formData = new FormData();
|
||||
formData.set("conversationId", String(conversationId));
|
||||
formData.set("file", file);
|
||||
const result = await requestJson<JsonResult<WidgetAsset>>(
|
||||
"/api/open/im/message/upload_attachment",
|
||||
{
|
||||
method: "POST",
|
||||
body: formData,
|
||||
},
|
||||
);
|
||||
if (!result.data) {
|
||||
throw new Error(result.message || "upload attachment failed");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { WidgetMessage } from "./types";
|
||||
import { summarizeMessage } from "./message-asset";
|
||||
|
||||
export function getNotificationBody(message: WidgetMessage): string {
|
||||
return summarizeMessage(message);
|
||||
}
|
||||
|
||||
export function showNotification(title: string, body: string, onClick?: () => void) {
|
||||
if (typeof Notification === "undefined") {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Notification.permission === "granted") {
|
||||
const notification = new Notification(title, {
|
||||
body,
|
||||
icon: "/favicon.ico",
|
||||
badge: "/favicon.ico",
|
||||
});
|
||||
|
||||
if (onClick) {
|
||||
notification.onclick = () => {
|
||||
onClick();
|
||||
notification.close();
|
||||
};
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
notification.close();
|
||||
}, 5000);
|
||||
} else if (Notification.permission === "default") {
|
||||
Notification.requestPermission().then((permission) => {
|
||||
if (permission === "granted") {
|
||||
showNotification(title, body, onClick);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import { readWidgetConfig } from "@/lib/widget/config";
|
||||
import { getOrCreateExternalId } from "@/lib/widget/visitor";
|
||||
|
||||
export type RealtimeEnvelope = {
|
||||
type: string;
|
||||
topic?: string;
|
||||
data?: {
|
||||
conversationId?: number;
|
||||
messageId?: number;
|
||||
};
|
||||
payload?: {
|
||||
conversationId?: number;
|
||||
messageId?: number;
|
||||
};
|
||||
};
|
||||
|
||||
export function createRealtimeConnection(onEvent: (event: RealtimeEnvelope) => void) {
|
||||
const config = readWidgetConfig();
|
||||
const baseUrl = (config.apiBaseUrl || config.baseUrl)
|
||||
.replace(/^http/, "ws")
|
||||
.replace(/\/$/, "");
|
||||
const externalId = encodeURIComponent(getOrCreateExternalId());
|
||||
const externalSource = encodeURIComponent(
|
||||
(config.externalSource ?? "web_chat").trim() || "web_chat",
|
||||
);
|
||||
const externalName = (config.subject ?? "").trim();
|
||||
const nameQuery =
|
||||
externalName !== ""
|
||||
? `&externalName=${encodeURIComponent(externalName)}`
|
||||
: "";
|
||||
const socket = new WebSocket(
|
||||
`${baseUrl}/api/open/im/ws?externalId=${externalId}&externalSource=${externalSource}&channelId=${encodeURIComponent(config.channelId)}${nameQuery}`,
|
||||
);
|
||||
socket.addEventListener("message", (event) => {
|
||||
try {
|
||||
onEvent(JSON.parse(event.data) as RealtimeEnvelope);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
return socket;
|
||||
}
|
||||
@@ -1,82 +0,0 @@
|
||||
export type PageResult<T> = {
|
||||
results: T[];
|
||||
page?: {
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
};
|
||||
};
|
||||
|
||||
export type CursorResult<T> = {
|
||||
results: T[];
|
||||
cursor: string;
|
||||
hasMore: boolean;
|
||||
};
|
||||
|
||||
export type JsonResult<T> = {
|
||||
success?: boolean;
|
||||
errorCode?: number;
|
||||
code?: number;
|
||||
message?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export type WidgetConversation = {
|
||||
id: number;
|
||||
subject?: string;
|
||||
status?: number;
|
||||
serviceMode?: number;
|
||||
currentAssigneeId?: number;
|
||||
lastMessageAt?: string;
|
||||
lastMessageSummary?: string;
|
||||
customerUnreadCount?: number;
|
||||
agentUnreadCount?: number;
|
||||
customerLastReadMessageId?: number;
|
||||
customerLastReadSeqNo?: number;
|
||||
customerLastReadAt?: string;
|
||||
agentLastReadMessageId?: number;
|
||||
agentLastReadSeqNo?: number;
|
||||
agentLastReadAt?: string;
|
||||
};
|
||||
|
||||
export type WidgetMessage = {
|
||||
id: number;
|
||||
conversationId: number;
|
||||
senderType: string;
|
||||
senderName?: string;
|
||||
senderAvatar?: string;
|
||||
messageType: string;
|
||||
content: string;
|
||||
payload?: string;
|
||||
seqNo?: number;
|
||||
sentAt?: string;
|
||||
customerRead?: boolean;
|
||||
customerReadAt?: string;
|
||||
agentRead?: boolean;
|
||||
agentReadAt?: string;
|
||||
};
|
||||
|
||||
export type WidgetAsset = {
|
||||
id: number;
|
||||
assetId: string;
|
||||
provider: string;
|
||||
storageKey: string;
|
||||
filename: string;
|
||||
fileSize: number;
|
||||
mimeType: string;
|
||||
status: number;
|
||||
url: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createUserId: number;
|
||||
createUserName: string;
|
||||
updateUserId: number;
|
||||
updateUserName: string;
|
||||
};
|
||||
|
||||
export type WidgetConfigResponse = {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
welcomeText?: string;
|
||||
themeColor?: string;
|
||||
};
|
||||
@@ -1,13 +0,0 @@
|
||||
import { requestJson } from "@/lib/services/http";
|
||||
import type { JsonResult, WidgetConfigResponse } from "@/lib/services/types";
|
||||
import { readWidgetConfig } from "@/lib/widget/config";
|
||||
|
||||
export async function fetchWidgetConfig() {
|
||||
const config = readWidgetConfig();
|
||||
const result = await requestJson<JsonResult<WidgetConfigResponse>>(
|
||||
`/api/open/im/widget/config?channelId=${encodeURIComponent(config.channelId)}`,
|
||||
);
|
||||
return result.data ?? {};
|
||||
}
|
||||
|
||||
export { readWidgetConfig };
|
||||
@@ -1,568 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { create } from "zustand";
|
||||
|
||||
import { createOrMatchConversation } from "@/lib/services/conversation";
|
||||
import {
|
||||
fetchMessagesPage,
|
||||
markMessageRead,
|
||||
sendMessage,
|
||||
sendMessageWithPayload,
|
||||
uploadAttachment,
|
||||
uploadImage,
|
||||
} from "@/lib/services/message";
|
||||
import {
|
||||
getNotificationBody,
|
||||
showNotification,
|
||||
} from "@/lib/services/notification";
|
||||
import { summarizeMessage } from "@/lib/services/message-asset";
|
||||
import {
|
||||
createRealtimeConnection,
|
||||
type RealtimeEnvelope,
|
||||
} from "@/lib/services/realtime";
|
||||
import type {
|
||||
WidgetAsset,
|
||||
WidgetConfigResponse,
|
||||
WidgetConversation,
|
||||
WidgetMessage,
|
||||
} from "@/lib/services/types";
|
||||
import {
|
||||
fetchWidgetConfig,
|
||||
readWidgetConfig,
|
||||
} from "@/lib/services/widget-config";
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
type ChatStatus = "connecting" | "connected" | "disconnected";
|
||||
|
||||
const RECONNECT_BASE_DELAY = 2000;
|
||||
const RECONNECT_MAX_DELAY = 30000;
|
||||
|
||||
function mergeMessagesByIdAsc(
|
||||
a: WidgetMessage[],
|
||||
b: WidgetMessage[],
|
||||
): WidgetMessage[] {
|
||||
const byId = new Map<number, WidgetMessage>();
|
||||
for (const m of a) {
|
||||
byId.set(m.id, m);
|
||||
}
|
||||
for (const m of b) {
|
||||
byId.set(m.id, m);
|
||||
}
|
||||
return Array.from(byId.values()).sort((x, y) => x.id - y.id);
|
||||
}
|
||||
|
||||
function parseCursorId(cursor: string): number {
|
||||
const n = Number.parseInt(cursor, 10);
|
||||
return Number.isFinite(n) && n > 0 ? n : 0;
|
||||
}
|
||||
|
||||
function cursorFromLoadedMessages(messages: WidgetMessage[]): string {
|
||||
if (messages.length === 0) {
|
||||
return "";
|
||||
}
|
||||
return String(Math.min(...messages.map((m) => m.id)));
|
||||
}
|
||||
|
||||
function minWidgetMessageId(messages: WidgetMessage[]): number | null {
|
||||
if (messages.length === 0) {
|
||||
return null;
|
||||
}
|
||||
return Math.min(...messages.map((m) => m.id));
|
||||
}
|
||||
|
||||
function hasMoreAfterLatestSyncMerge(args: {
|
||||
previousMessages: WidgetMessage[];
|
||||
previousHasMore: boolean;
|
||||
merged: WidgetMessage[];
|
||||
apiHasMore: boolean;
|
||||
}): boolean {
|
||||
const prevMin = minWidgetMessageId(args.previousMessages);
|
||||
const mergedMin = minWidgetMessageId(args.merged);
|
||||
|
||||
if (mergedMin === null) {
|
||||
return Boolean(args.apiHasMore);
|
||||
}
|
||||
|
||||
if (!args.previousHasMore && prevMin !== null && mergedMin >= prevMin) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return args.previousHasMore || Boolean(args.apiHasMore);
|
||||
}
|
||||
|
||||
export interface ChatStore {
|
||||
title: string;
|
||||
subtitle: string;
|
||||
welcomeText: string;
|
||||
themeColor: string;
|
||||
conversation: WidgetConversation | null;
|
||||
messages: WidgetMessage[];
|
||||
messagesCursor: string;
|
||||
messagesHasMore: boolean;
|
||||
messagesLoadingMore: boolean;
|
||||
status: ChatStatus;
|
||||
error: string;
|
||||
isOpen: boolean;
|
||||
isVisible: boolean;
|
||||
initialized: boolean;
|
||||
socket: WebSocket | null;
|
||||
readingMessageId: number;
|
||||
|
||||
setIsOpen: (isOpen: boolean) => void;
|
||||
setIsVisible: (isVisible: boolean) => void;
|
||||
bootstrap: () => void;
|
||||
handleSendMessage: (html: string) => Promise<void>;
|
||||
uploadMessageImage: (file: File) => Promise<WidgetAsset | null>;
|
||||
sendAttachment: (file: File) => Promise<void>;
|
||||
retry: () => void;
|
||||
disconnectSocket: () => void;
|
||||
refreshMessages: () => Promise<void>;
|
||||
syncLatestMessages: () => Promise<void>;
|
||||
loadOlderMessages: () => Promise<void>;
|
||||
markConversationRead: () => Promise<void>;
|
||||
}
|
||||
|
||||
let bootstrapToken = 0;
|
||||
|
||||
export const useChatStore = create<ChatStore>((set, get) => {
|
||||
let reconnectTimer: number | null = null;
|
||||
let pingTimer: number | null = null;
|
||||
let reconnectAttempt = 0;
|
||||
let shouldReconnect = false;
|
||||
|
||||
const clearRealtimeTimers = () => {
|
||||
if (reconnectTimer !== null) {
|
||||
window.clearTimeout(reconnectTimer);
|
||||
reconnectTimer = null;
|
||||
}
|
||||
if (pingTimer !== null) {
|
||||
window.clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
};
|
||||
|
||||
const scheduleReconnect = () => {
|
||||
if (!shouldReconnect || reconnectTimer !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const delay = Math.min(
|
||||
RECONNECT_BASE_DELAY * 2 ** reconnectAttempt,
|
||||
RECONNECT_MAX_DELAY,
|
||||
);
|
||||
set({ status: "connecting" });
|
||||
reconnectTimer = window.setTimeout(() => {
|
||||
reconnectTimer = null;
|
||||
reconnectAttempt += 1;
|
||||
if (!shouldReconnect || !get().isOpen) {
|
||||
return;
|
||||
}
|
||||
connectSocket();
|
||||
}, delay);
|
||||
};
|
||||
|
||||
const closeSocket = (options?: { reconnect?: boolean }) => {
|
||||
shouldReconnect = options?.reconnect ?? false;
|
||||
clearRealtimeTimers();
|
||||
if (!shouldReconnect) {
|
||||
reconnectAttempt = 0;
|
||||
}
|
||||
|
||||
const socket = get().socket;
|
||||
if (
|
||||
socket &&
|
||||
(socket.readyState === WebSocket.OPEN ||
|
||||
socket.readyState === WebSocket.CONNECTING)
|
||||
) {
|
||||
socket.close();
|
||||
}
|
||||
|
||||
set({ socket: null });
|
||||
};
|
||||
|
||||
const connectSocket = () => {
|
||||
const conversationId = get().conversation?.id;
|
||||
if (!conversationId) {
|
||||
return;
|
||||
}
|
||||
closeSocket({ reconnect: false });
|
||||
shouldReconnect = true;
|
||||
|
||||
const handleRealtimeEvent = (event: RealtimeEnvelope) => {
|
||||
const payload = event.data ?? event.payload;
|
||||
const needsRefresh =
|
||||
event.type === "message.created" ||
|
||||
event.type?.startsWith("conversation.");
|
||||
|
||||
if (needsRefresh && payload?.conversationId === conversationId) {
|
||||
void get()
|
||||
.syncLatestMessages()
|
||||
.then(() => {
|
||||
if (event.type === "message.created") {
|
||||
const state = get();
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (
|
||||
lastMessage &&
|
||||
lastMessage.senderType !== "customer" &&
|
||||
typeof document !== "undefined" &&
|
||||
document.visibilityState !== "visible"
|
||||
) {
|
||||
showNotification(
|
||||
"新消息",
|
||||
getNotificationBody(lastMessage),
|
||||
() => {
|
||||
state.setIsOpen(true);
|
||||
state.setIsVisible(true);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const socket = createRealtimeConnection(handleRealtimeEvent);
|
||||
set({ socket });
|
||||
|
||||
socket.addEventListener("open", () => {
|
||||
clearRealtimeTimers();
|
||||
reconnectAttempt = 0;
|
||||
pingTimer = window.setInterval(() => {
|
||||
if (socket.readyState === WebSocket.OPEN) {
|
||||
socket.send(JSON.stringify({ type: "ping" }));
|
||||
}
|
||||
}, 20000);
|
||||
if (get().isOpen && get().socket === socket) {
|
||||
set({ status: "connected" });
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("error", () => {
|
||||
if (get().socket === socket) {
|
||||
scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
socket.addEventListener("close", () => {
|
||||
if (pingTimer !== null) {
|
||||
window.clearInterval(pingTimer);
|
||||
pingTimer = null;
|
||||
}
|
||||
if (get().socket === socket) {
|
||||
set({ socket: null });
|
||||
}
|
||||
if (get().isOpen) {
|
||||
if (shouldReconnect) {
|
||||
scheduleReconnect();
|
||||
} else {
|
||||
set({ status: "disconnected" });
|
||||
}
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
return {
|
||||
title: "在线客服",
|
||||
subtitle: "",
|
||||
welcomeText: "",
|
||||
themeColor: "#2563eb",
|
||||
conversation: null,
|
||||
messages: [],
|
||||
messagesCursor: "",
|
||||
messagesHasMore: false,
|
||||
messagesLoadingMore: false,
|
||||
status: "connecting",
|
||||
error: "",
|
||||
isOpen: typeof window !== "undefined" ? window.self === window.top : false,
|
||||
isVisible:
|
||||
typeof window !== "undefined" ? window.self === window.top : false,
|
||||
initialized: false,
|
||||
socket: null,
|
||||
readingMessageId: 0,
|
||||
|
||||
setIsOpen: (isOpen: boolean) => {
|
||||
set({ isOpen });
|
||||
},
|
||||
|
||||
setIsVisible: (isVisible: boolean) => {
|
||||
set({ isVisible });
|
||||
},
|
||||
|
||||
disconnectSocket: () => {
|
||||
closeSocket({ reconnect: false });
|
||||
},
|
||||
|
||||
refreshMessages: async () => {
|
||||
const conversationId = get().conversation?.id;
|
||||
if (!conversationId) return;
|
||||
|
||||
try {
|
||||
const page = await fetchMessagesPage(conversationId);
|
||||
const currentConversation = get().conversation;
|
||||
set({
|
||||
messages: page.results,
|
||||
messagesCursor: cursorFromLoadedMessages(page.results) || page.cursor,
|
||||
messagesHasMore: page.hasMore,
|
||||
conversation: currentConversation,
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to refresh messages", e);
|
||||
}
|
||||
},
|
||||
|
||||
syncLatestMessages: async () => {
|
||||
const conversationId = get().conversation?.id;
|
||||
if (!conversationId) return;
|
||||
|
||||
try {
|
||||
const page = await fetchMessagesPage(conversationId);
|
||||
const batch = page.results;
|
||||
if (batch.length === 0) {
|
||||
return;
|
||||
}
|
||||
const firstId = batch[0]!.id;
|
||||
const currentConversation = get().conversation;
|
||||
set((state) => {
|
||||
const preserved = state.messages.filter((m) => m.id < firstId);
|
||||
const merged = mergeMessagesByIdAsc(preserved, batch);
|
||||
return {
|
||||
messages: merged,
|
||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor,
|
||||
messagesHasMore: hasMoreAfterLatestSyncMerge({
|
||||
previousMessages: state.messages,
|
||||
previousHasMore: state.messagesHasMore,
|
||||
merged,
|
||||
apiHasMore: Boolean(page.hasMore),
|
||||
}),
|
||||
conversation: currentConversation,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
console.error("Failed to sync messages", e);
|
||||
}
|
||||
},
|
||||
|
||||
loadOlderMessages: async () => {
|
||||
const conversationId = get().conversation?.id;
|
||||
if (
|
||||
!conversationId ||
|
||||
get().messagesLoadingMore ||
|
||||
!get().messagesHasMore
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const cursorId = parseCursorId(get().messagesCursor);
|
||||
if (cursorId <= 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ messagesLoadingMore: true });
|
||||
try {
|
||||
const page = await fetchMessagesPage(conversationId, {
|
||||
cursor: cursorId,
|
||||
});
|
||||
const currentConversation = get().conversation;
|
||||
set((state) => {
|
||||
const merged = mergeMessagesByIdAsc(page.results, state.messages);
|
||||
return {
|
||||
messages: merged,
|
||||
messagesCursor: cursorFromLoadedMessages(merged) || page.cursor,
|
||||
messagesHasMore: page.hasMore,
|
||||
messagesLoadingMore: false,
|
||||
conversation: currentConversation,
|
||||
};
|
||||
});
|
||||
} catch (e) {
|
||||
set({ messagesLoadingMore: false });
|
||||
console.error("Failed to load older messages", e);
|
||||
}
|
||||
},
|
||||
|
||||
markConversationRead: async () => {
|
||||
const state = get();
|
||||
const conversation = state.conversation;
|
||||
const lastMessage = state.messages.at(-1);
|
||||
if (!conversation?.id || !lastMessage) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
(conversation.customerUnreadCount ?? 0) <= 0 &&
|
||||
(conversation.customerLastReadMessageId ?? 0) >= lastMessage.id
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (state.readingMessageId === lastMessage.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
set({ readingMessageId: lastMessage.id });
|
||||
try {
|
||||
await markMessageRead(conversation.id, lastMessage.id);
|
||||
set((current) => ({
|
||||
readingMessageId: 0,
|
||||
messages: current.messages.map((item) =>
|
||||
(item.seqNo ?? 0) <= (lastMessage.seqNo ?? 0)
|
||||
? { ...item, customerRead: true }
|
||||
: item,
|
||||
),
|
||||
conversation: current.conversation
|
||||
? {
|
||||
...current.conversation,
|
||||
customerUnreadCount: 0,
|
||||
customerLastReadMessageId: lastMessage.id,
|
||||
customerLastReadSeqNo: lastMessage.seqNo,
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
} catch (error) {
|
||||
set({ readingMessageId: 0 });
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
|
||||
bootstrap: () => {
|
||||
const token = ++bootstrapToken;
|
||||
|
||||
if (!get().isOpen) {
|
||||
closeSocket({ reconnect: false });
|
||||
set({ status: "disconnected" });
|
||||
return;
|
||||
}
|
||||
|
||||
const activateChat = async () => {
|
||||
try {
|
||||
set({ error: "", status: "connecting" });
|
||||
|
||||
const hostConfig = readWidgetConfig();
|
||||
const widgetConfig: WidgetConfigResponse =
|
||||
await fetchWidgetConfig().catch(() => ({}));
|
||||
if (bootstrapToken !== token || !get().isOpen) return;
|
||||
|
||||
set({
|
||||
title: hostConfig.title || widgetConfig.title || "在线客服",
|
||||
subtitle: hostConfig.subtitle || widgetConfig.subtitle || "",
|
||||
welcomeText: widgetConfig.welcomeText || "",
|
||||
themeColor:
|
||||
hostConfig.themeColor || widgetConfig.themeColor || "#2563eb",
|
||||
});
|
||||
|
||||
let currentConversation = get().conversation;
|
||||
if (!get().initialized || !currentConversation) {
|
||||
currentConversation = await createOrMatchConversation();
|
||||
if (bootstrapToken !== token || !get().isOpen) return;
|
||||
set({ initialized: true, conversation: currentConversation });
|
||||
}
|
||||
|
||||
await get().refreshMessages();
|
||||
if (bootstrapToken !== token || !get().isOpen) return;
|
||||
|
||||
connectSocket();
|
||||
} catch (bootstrapError) {
|
||||
if (bootstrapToken !== token || !get().isOpen) return;
|
||||
set({
|
||||
status: "disconnected",
|
||||
error:
|
||||
bootstrapError instanceof Error
|
||||
? bootstrapError.message
|
||||
: "初始化失败",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
void activateChat();
|
||||
},
|
||||
|
||||
handleSendMessage: async (content: string) => {
|
||||
const conversationId = get().conversation?.id;
|
||||
if (!conversationId) return;
|
||||
set({ error: "" });
|
||||
try {
|
||||
const nextMessage = await sendMessage(conversationId, content);
|
||||
set((state) => ({
|
||||
messages: state.messages.some((m) => m.id === nextMessage.id)
|
||||
? state.messages.map((m) =>
|
||||
m.id === nextMessage.id ? nextMessage : m,
|
||||
)
|
||||
: [...state.messages, nextMessage],
|
||||
conversation: state.conversation
|
||||
? {
|
||||
...state.conversation,
|
||||
customerLastReadMessageId: nextMessage.id,
|
||||
customerLastReadSeqNo: nextMessage.seqNo,
|
||||
customerUnreadCount: 0,
|
||||
lastMessageAt: nextMessage.sentAt,
|
||||
lastMessageSummary: summarizeMessage(nextMessage),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : "发送消息失败" });
|
||||
}
|
||||
},
|
||||
|
||||
uploadMessageImage: async (file: File) => {
|
||||
const conversationId = get().conversation?.id;
|
||||
if (!conversationId) return null;
|
||||
set({ error: "" });
|
||||
try {
|
||||
const asset = await uploadImage(conversationId, file);
|
||||
return asset;
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : "发送图片失败" });
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
sendAttachment: async (file: File) => {
|
||||
const conversationId = get().conversation?.id;
|
||||
if (!conversationId) return;
|
||||
set({ error: "" });
|
||||
try {
|
||||
const asset = await uploadAttachment(conversationId, file);
|
||||
const nextMessage = await sendMessageWithPayload(conversationId, {
|
||||
messageType: "attachment",
|
||||
content: asset.filename,
|
||||
payload: JSON.stringify({ assetId: asset.assetId }),
|
||||
clientMsgId: `widget_attachment_${generateUUID()}`,
|
||||
});
|
||||
set((state) => ({
|
||||
messages: state.messages.some((m) => m.id === nextMessage.id)
|
||||
? state.messages.map((m) =>
|
||||
m.id === nextMessage.id ? nextMessage : m,
|
||||
)
|
||||
: [...state.messages, nextMessage],
|
||||
conversation: state.conversation
|
||||
? {
|
||||
...state.conversation,
|
||||
customerLastReadMessageId: nextMessage.id,
|
||||
customerLastReadSeqNo: nextMessage.seqNo,
|
||||
customerUnreadCount: 0,
|
||||
lastMessageAt: nextMessage.sentAt,
|
||||
lastMessageSummary: summarizeMessage(nextMessage),
|
||||
}
|
||||
: null,
|
||||
}));
|
||||
} catch (e) {
|
||||
set({ error: e instanceof Error ? e.message : "发送附件失败" });
|
||||
}
|
||||
},
|
||||
|
||||
retry: async () => {
|
||||
if (!get().conversation?.id) return;
|
||||
|
||||
set({ error: "", status: "connecting" });
|
||||
try {
|
||||
await get().refreshMessages();
|
||||
if (get().isOpen) {
|
||||
shouldReconnect = true;
|
||||
connectSocket();
|
||||
}
|
||||
} catch (retryError) {
|
||||
set({
|
||||
status: "disconnected",
|
||||
error: retryError instanceof Error ? retryError.message : "刷新失败",
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
});
|
||||
@@ -1,45 +0,0 @@
|
||||
import { clsx, type ClassValue } from "clsx";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
export function generateUUID() {
|
||||
if (typeof globalThis.crypto?.randomUUID === "function") {
|
||||
return globalThis.crypto.randomUUID();
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(16);
|
||||
if (typeof globalThis.crypto?.getRandomValues === "function") {
|
||||
globalThis.crypto.getRandomValues(bytes);
|
||||
} else {
|
||||
for (let i = 0; i < bytes.length; i += 1) {
|
||||
bytes[i] = Math.floor(Math.random() * 256);
|
||||
}
|
||||
}
|
||||
|
||||
bytes[6] = (bytes[6] & 0x0f) | 0x40;
|
||||
bytes[8] = (bytes[8] & 0x3f) | 0x80;
|
||||
|
||||
const hex = Array.from(bytes, (byte) => byte.toString(16).padStart(2, "0"));
|
||||
return [
|
||||
hex.slice(0, 4).join(""),
|
||||
hex.slice(4, 6).join(""),
|
||||
hex.slice(6, 8).join(""),
|
||||
hex.slice(8, 10).join(""),
|
||||
hex.slice(10, 16).join(""),
|
||||
].join("-");
|
||||
}
|
||||
|
||||
export function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return value;
|
||||
}
|
||||
const pad = (num: number) => String(num).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
export type WidgetHostConfig = {
|
||||
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 传给后端作 ExternalName */
|
||||
subject?: string;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
CSAgentConfig?: WidgetHostConfig;
|
||||
__CS_AGENT_WIDGET_CONFIG__?: WidgetHostConfig;
|
||||
}
|
||||
}
|
||||
|
||||
export function readWidgetConfig(): WidgetHostConfig {
|
||||
if (typeof window === "undefined") {
|
||||
return {
|
||||
channelId: "",
|
||||
baseUrl: "",
|
||||
apiBaseUrl: "",
|
||||
};
|
||||
}
|
||||
const query = new URLSearchParams(window.location.search);
|
||||
const fallback = {
|
||||
channelId: query.get("channelId") ?? "",
|
||||
baseUrl: query.get("baseUrl") ?? "",
|
||||
apiBaseUrl: query.get("apiBaseUrl") ?? undefined,
|
||||
externalSource: query.get("externalSource") ?? 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 setWidgetConfig(config: WidgetHostConfig) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
window.__CS_AGENT_WIDGET_CONFIG__ = config;
|
||||
}
|
||||
@@ -1,85 +0,0 @@
|
||||
import { setWidgetConfig, type WidgetHostConfig } from "@/lib/widget/config";
|
||||
|
||||
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";
|
||||
const READY_MESSAGE_TYPE = "cs-agent:ready";
|
||||
const REQUEST_MINIMIZE_MESSAGE_TYPE = "cs-agent:request-minimize";
|
||||
const REQUEST_CLOSE_MESSAGE_TYPE = "cs-agent:request-close";
|
||||
const REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE = "cs-agent:request-toggle-maximize";
|
||||
|
||||
type HostBridgeOptions = {
|
||||
onOpen?: () => void;
|
||||
onMinimize?: () => void;
|
||||
onMaximizedChange?: (isMaximized: boolean) => void;
|
||||
};
|
||||
|
||||
export function bindHostBridge(options: HostBridgeOptions = {}) {
|
||||
if (typeof window === "undefined") {
|
||||
return () => undefined;
|
||||
}
|
||||
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({ type: READY_MESSAGE_TYPE }, "*");
|
||||
}
|
||||
|
||||
const handleMessage = (
|
||||
event: MessageEvent,
|
||||
) => {
|
||||
const data = event.data as
|
||||
| {
|
||||
type?: string;
|
||||
payload?: WidgetHostConfig | { isMaximized?: boolean };
|
||||
}
|
||||
| undefined;
|
||||
if (!data?.type) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === INIT_MESSAGE_TYPE && data.payload) {
|
||||
setWidgetConfig(data.payload as WidgetHostConfig);
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === OPEN_MESSAGE_TYPE) {
|
||||
options.onOpen?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === MINIMIZE_MESSAGE_TYPE) {
|
||||
options.onMinimize?.();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === MAXIMIZED_MESSAGE_TYPE) {
|
||||
options.onMaximizedChange?.(
|
||||
Boolean((data.payload as { isMaximized?: boolean } | undefined)?.isMaximized),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
window.addEventListener("message", handleMessage);
|
||||
return () => window.removeEventListener("message", handleMessage);
|
||||
}
|
||||
|
||||
function postToParent(type: string) {
|
||||
if (typeof window === "undefined") {
|
||||
return;
|
||||
}
|
||||
if (window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({ type }, "*");
|
||||
}
|
||||
}
|
||||
|
||||
export function requestHostMinimize() {
|
||||
postToParent(REQUEST_MINIMIZE_MESSAGE_TYPE);
|
||||
}
|
||||
|
||||
export function requestHostClose() {
|
||||
postToParent(REQUEST_CLOSE_MESSAGE_TYPE);
|
||||
}
|
||||
|
||||
export function requestHostToggleMaximize() {
|
||||
postToParent(REQUEST_TOGGLE_MAXIMIZE_MESSAGE_TYPE);
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
(function () {
|
||||
if (window.__CS_AGENT_WIDGET_LOADED__) {
|
||||
return;
|
||||
}
|
||||
window.__CS_AGENT_WIDGET_LOADED__ = true;
|
||||
|
||||
var config = window.CSAgentConfig || {};
|
||||
var baseUrl = String(config.baseUrl || "").replace(/\/$/, "");
|
||||
if (!config.channelId || !baseUrl) {
|
||||
console.error("[cs-agent-widget] channelId and baseUrl are required");
|
||||
return;
|
||||
}
|
||||
|
||||
function resolveWidgetBaseUrl() {
|
||||
var currentScript = document.currentScript;
|
||||
if (currentScript && currentScript.src) {
|
||||
return currentScript.src.replace(/\/sdk\/cs-agent-widget\.js(?:\?.*)?$/, "");
|
||||
}
|
||||
if (/\/widget$/.test(baseUrl)) {
|
||||
return baseUrl;
|
||||
}
|
||||
return baseUrl + "/widget";
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
var widgetBaseUrl = resolveWidgetBaseUrl();
|
||||
var frameUrl = new URL(widgetBaseUrl + "/frame/");
|
||||
frameUrl.searchParams.set("channelId", config.channelId);
|
||||
frameUrl.searchParams.set("baseUrl", baseUrl);
|
||||
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl);
|
||||
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);
|
||||
var frame = null;
|
||||
var frameLoaded = false;
|
||||
var frameReady = false;
|
||||
var initSent = false;
|
||||
var isOpen = false;
|
||||
var isMaximized = false;
|
||||
var frameHideTimer = null;
|
||||
var frameDestroyTimer = null;
|
||||
var animationDuration = 260;
|
||||
|
||||
function clearFrameTimers() {
|
||||
if (frameHideTimer) {
|
||||
window.clearTimeout(frameHideTimer);
|
||||
frameHideTimer = null;
|
||||
}
|
||||
if (frameDestroyTimer) {
|
||||
window.clearTimeout(frameDestroyTimer);
|
||||
frameDestroyTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFrameLayout() {
|
||||
if (!frame) {
|
||||
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 (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 flushFrameState() {
|
||||
if (!frame || !frameLoaded || !frameReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initSent) {
|
||||
initSent = true;
|
||||
postToFrame({
|
||||
type: "cs-agent:init",
|
||||
payload: config,
|
||||
});
|
||||
}
|
||||
|
||||
postToFrame({ type: isOpen ? "cs-agent:open" : "cs-agent:minimize" });
|
||||
postToFrame({
|
||||
type: "cs-agent:maximized",
|
||||
payload: { isMaximized: isMaximized },
|
||||
});
|
||||
}
|
||||
|
||||
function postToFrame(message) {
|
||||
if (!frame || !frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
frame.contentWindow.postMessage(message, frameUrl.origin);
|
||||
} catch (error) {
|
||||
console.error("[cs-agent-widget] postMessage failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
function syncFrameVisibility() {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
clearFrameTimers();
|
||||
applyFrameLayout();
|
||||
frame.style.display = "block";
|
||||
|
||||
if (isOpen) {
|
||||
frame.style.visibility = "visible";
|
||||
frame.style.pointerEvents = "auto";
|
||||
frameHideTimer = window.setTimeout(function () {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
frame.style.opacity = "1";
|
||||
frame.style.transform = "translate3d(0, 0, 0) scale(1)";
|
||||
}, 16);
|
||||
flushFrameState();
|
||||
return;
|
||||
}
|
||||
|
||||
frame.style.pointerEvents = "none";
|
||||
frame.style.opacity = "0";
|
||||
frame.style.transform = isMaximized
|
||||
? "translate3d(0, 10px, 0) scale(0.985)"
|
||||
: "translate3d(0, 16px, 0) scale(0.96)";
|
||||
frameHideTimer = window.setTimeout(function () {
|
||||
if (!frame || isOpen) {
|
||||
return;
|
||||
}
|
||||
frame.style.visibility = "hidden";
|
||||
}, animationDuration);
|
||||
flushFrameState();
|
||||
}
|
||||
|
||||
function handleWindowMessage(event) {
|
||||
if (!frame || event.source !== frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
var data = event.data || {};
|
||||
if (data.type === "cs-agent:ready") {
|
||||
frameReady = true;
|
||||
flushFrameState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "cs-agent:request-minimize") {
|
||||
isOpen = false;
|
||||
syncFrameVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "cs-agent:request-close") {
|
||||
destroyFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "cs-agent:request-toggle-maximize") {
|
||||
isMaximized = !isMaximized;
|
||||
syncFrameVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
function destroyFrame() {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
clearFrameTimers();
|
||||
frame.style.pointerEvents = "none";
|
||||
frame.style.opacity = "0";
|
||||
frame.style.transform = "translate3d(0, 18px, 0) scale(0.94)";
|
||||
frame.style.visibility = "hidden";
|
||||
frameDestroyTimer = window.setTimeout(function () {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
if (frame.parentNode) {
|
||||
frame.parentNode.removeChild(frame);
|
||||
}
|
||||
frame = null;
|
||||
frameLoaded = false;
|
||||
frameReady = false;
|
||||
initSent = false;
|
||||
isOpen = false;
|
||||
isMaximized = false;
|
||||
clearFrameTimers();
|
||||
}, animationDuration);
|
||||
}
|
||||
|
||||
function createFrame() {
|
||||
if (frame) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
frame = document.createElement("iframe");
|
||||
frame.dataset.csAgentWidget = "frame";
|
||||
frame.title = config.title || "在线客服";
|
||||
frame.src = frameUrl.toString();
|
||||
applyFrameLayout();
|
||||
frame.style.display = "block";
|
||||
frame.style.visibility = "hidden";
|
||||
frame.style.pointerEvents = "none";
|
||||
frame.style.opacity = "0";
|
||||
frame.style.transform = "translate3d(0, 18px, 0) scale(0.96)";
|
||||
frame.addEventListener("load", function () {
|
||||
frameLoaded = true;
|
||||
syncFrameVisibility();
|
||||
});
|
||||
|
||||
document.body.appendChild(frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
button.addEventListener("click", function () {
|
||||
if (!frame) {
|
||||
createFrame();
|
||||
}
|
||||
isOpen = !isOpen;
|
||||
syncFrameVisibility();
|
||||
});
|
||||
|
||||
window.addEventListener("message", handleWindowMessage);
|
||||
document.body.appendChild(button);
|
||||
})();
|
||||
@@ -1,16 +0,0 @@
|
||||
import { generateUUID } from "@/lib/utils";
|
||||
|
||||
const EXTERNAL_ID_KEY = "cs-agent:external-id";
|
||||
|
||||
export function getOrCreateExternalId() {
|
||||
if (typeof window === "undefined") {
|
||||
return "";
|
||||
}
|
||||
const current = window.localStorage.getItem(EXTERNAL_ID_KEY);
|
||||
if (current) {
|
||||
return current;
|
||||
}
|
||||
const visitorId = `visitor_${generateUUID()}`;
|
||||
window.localStorage.setItem(EXTERNAL_ID_KEY, visitorId);
|
||||
return visitorId;
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { NextConfig } from "next"
|
||||
import { PHASE_DEVELOPMENT_SERVER } from "next/constants"
|
||||
|
||||
const backendBaseUrl =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL?.trim() || "http://127.0.0.1:8083"
|
||||
const productionBasePath = "/widget"
|
||||
|
||||
export default function nextConfig(phase: string): NextConfig {
|
||||
if (phase === PHASE_DEVELOPMENT_SERVER) {
|
||||
return {
|
||||
reactStrictMode: true,
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
async rewrites() {
|
||||
return [
|
||||
{
|
||||
source: "/api/:path*",
|
||||
destination: `${backendBaseUrl}/api/:path*`,
|
||||
basePath: false,
|
||||
},
|
||||
{
|
||||
source: "/storage/:path*",
|
||||
destination: `${backendBaseUrl}/storage/:path*`,
|
||||
basePath: false,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
reactStrictMode: true,
|
||||
output: "export",
|
||||
basePath: productionBasePath,
|
||||
assetPrefix: `${productionBasePath}/`,
|
||||
trailingSlash: true,
|
||||
images: {
|
||||
unoptimized: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -1,39 +0,0 @@
|
||||
{
|
||||
"name": "widget",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "PORT=4000 next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:sdk": "node ./scripts/build-sdk.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/extension-image": "^3.20.2",
|
||||
"@tiptap/extension-placeholder": "^3.20.2",
|
||||
"@tiptap/react": "^3.20.2",
|
||||
"@tiptap/starter-kit": "^3.20.2",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.577.0",
|
||||
"markdown-it": "^14.1.1",
|
||||
"next": "16.1.6",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"react-zoom-pan-pinch": "^3.7.0",
|
||||
"tailwind-merge": "^3.5.0",
|
||||
"zustand": "^5.0.12"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/markdown-it": "^14.1.2",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.1.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
Generated
-4765
File diff suppressed because it is too large
Load Diff
@@ -1,3 +0,0 @@
|
||||
onlyBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
@@ -1,7 +0,0 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 60 KiB |
@@ -1,270 +0,0 @@
|
||||
(function () {
|
||||
if (window.__CS_AGENT_WIDGET_LOADED__) {
|
||||
return;
|
||||
}
|
||||
window.__CS_AGENT_WIDGET_LOADED__ = true;
|
||||
|
||||
var config = window.CSAgentConfig || {};
|
||||
var baseUrl = String(config.baseUrl || "").replace(/\/$/, "");
|
||||
if (!config.channelId || !baseUrl) {
|
||||
console.error("[cs-agent-widget] channelId and baseUrl are required");
|
||||
return;
|
||||
}
|
||||
|
||||
function resolveWidgetBaseUrl() {
|
||||
var currentScript = document.currentScript;
|
||||
if (currentScript && currentScript.src) {
|
||||
return currentScript.src.replace(/\/sdk\/cs-agent-widget\.js(?:\?.*)?$/, "");
|
||||
}
|
||||
if (/\/widget$/.test(baseUrl)) {
|
||||
return baseUrl;
|
||||
}
|
||||
return baseUrl + "/widget";
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
var widgetBaseUrl = resolveWidgetBaseUrl();
|
||||
var frameUrl = new URL(widgetBaseUrl + "/frame/");
|
||||
frameUrl.searchParams.set("channelId", config.channelId);
|
||||
frameUrl.searchParams.set("baseUrl", baseUrl);
|
||||
if (config.apiBaseUrl) frameUrl.searchParams.set("apiBaseUrl", config.apiBaseUrl);
|
||||
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);
|
||||
var frame = null;
|
||||
var frameLoaded = false;
|
||||
var frameReady = false;
|
||||
var initSent = false;
|
||||
var isOpen = false;
|
||||
var isMaximized = false;
|
||||
var frameHideTimer = null;
|
||||
var frameDestroyTimer = null;
|
||||
var animationDuration = 260;
|
||||
|
||||
function clearFrameTimers() {
|
||||
if (frameHideTimer) {
|
||||
window.clearTimeout(frameHideTimer);
|
||||
frameHideTimer = null;
|
||||
}
|
||||
if (frameDestroyTimer) {
|
||||
window.clearTimeout(frameDestroyTimer);
|
||||
frameDestroyTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
function applyFrameLayout() {
|
||||
if (!frame) {
|
||||
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 (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 flushFrameState() {
|
||||
if (!frame || !frameLoaded || !frameReady) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!initSent) {
|
||||
initSent = true;
|
||||
postToFrame({
|
||||
type: "cs-agent:init",
|
||||
payload: config,
|
||||
});
|
||||
}
|
||||
|
||||
postToFrame({ type: isOpen ? "cs-agent:open" : "cs-agent:minimize" });
|
||||
postToFrame({
|
||||
type: "cs-agent:maximized",
|
||||
payload: { isMaximized: isMaximized },
|
||||
});
|
||||
}
|
||||
|
||||
function postToFrame(message) {
|
||||
if (!frame || !frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
frame.contentWindow.postMessage(message, frameUrl.origin);
|
||||
} catch (error) {
|
||||
console.error("[cs-agent-widget] postMessage failed", error);
|
||||
}
|
||||
}
|
||||
|
||||
function syncFrameVisibility() {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
clearFrameTimers();
|
||||
applyFrameLayout();
|
||||
frame.style.display = "block";
|
||||
|
||||
if (isOpen) {
|
||||
frame.style.visibility = "visible";
|
||||
frame.style.pointerEvents = "auto";
|
||||
frameHideTimer = window.setTimeout(function () {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
frame.style.opacity = "1";
|
||||
frame.style.transform = "translate3d(0, 0, 0) scale(1)";
|
||||
}, 16);
|
||||
flushFrameState();
|
||||
return;
|
||||
}
|
||||
|
||||
frame.style.pointerEvents = "none";
|
||||
frame.style.opacity = "0";
|
||||
frame.style.transform = isMaximized
|
||||
? "translate3d(0, 10px, 0) scale(0.985)"
|
||||
: "translate3d(0, 16px, 0) scale(0.96)";
|
||||
frameHideTimer = window.setTimeout(function () {
|
||||
if (!frame || isOpen) {
|
||||
return;
|
||||
}
|
||||
frame.style.visibility = "hidden";
|
||||
}, animationDuration);
|
||||
flushFrameState();
|
||||
}
|
||||
|
||||
function handleWindowMessage(event) {
|
||||
if (!frame || event.source !== frame.contentWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
var data = event.data || {};
|
||||
if (data.type === "cs-agent:ready") {
|
||||
frameReady = true;
|
||||
flushFrameState();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "cs-agent:request-minimize") {
|
||||
isOpen = false;
|
||||
syncFrameVisibility();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "cs-agent:request-close") {
|
||||
destroyFrame();
|
||||
return;
|
||||
}
|
||||
|
||||
if (data.type === "cs-agent:request-toggle-maximize") {
|
||||
isMaximized = !isMaximized;
|
||||
syncFrameVisibility();
|
||||
}
|
||||
}
|
||||
|
||||
function destroyFrame() {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
clearFrameTimers();
|
||||
frame.style.pointerEvents = "none";
|
||||
frame.style.opacity = "0";
|
||||
frame.style.transform = "translate3d(0, 18px, 0) scale(0.94)";
|
||||
frame.style.visibility = "hidden";
|
||||
frameDestroyTimer = window.setTimeout(function () {
|
||||
if (!frame) {
|
||||
return;
|
||||
}
|
||||
if (frame.parentNode) {
|
||||
frame.parentNode.removeChild(frame);
|
||||
}
|
||||
frame = null;
|
||||
frameLoaded = false;
|
||||
frameReady = false;
|
||||
initSent = false;
|
||||
isOpen = false;
|
||||
isMaximized = false;
|
||||
clearFrameTimers();
|
||||
}, animationDuration);
|
||||
}
|
||||
|
||||
function createFrame() {
|
||||
if (frame) {
|
||||
return frame;
|
||||
}
|
||||
|
||||
frame = document.createElement("iframe");
|
||||
frame.dataset.csAgentWidget = "frame";
|
||||
frame.title = config.title || "在线客服";
|
||||
frame.src = frameUrl.toString();
|
||||
applyFrameLayout();
|
||||
frame.style.display = "block";
|
||||
frame.style.visibility = "hidden";
|
||||
frame.style.pointerEvents = "none";
|
||||
frame.style.opacity = "0";
|
||||
frame.style.transform = "translate3d(0, 18px, 0) scale(0.96)";
|
||||
frame.addEventListener("load", function () {
|
||||
frameLoaded = true;
|
||||
syncFrameVisibility();
|
||||
});
|
||||
|
||||
document.body.appendChild(frame);
|
||||
return frame;
|
||||
}
|
||||
|
||||
button.addEventListener("click", function () {
|
||||
if (!frame) {
|
||||
createFrame();
|
||||
}
|
||||
isOpen = !isOpen;
|
||||
syncFrameVisibility();
|
||||
});
|
||||
|
||||
window.addEventListener("message", handleWindowMessage);
|
||||
document.body.appendChild(button);
|
||||
})();
|
||||
@@ -1,14 +0,0 @@
|
||||
import { cp, mkdir } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const rootDir = path.resolve(currentDir, "..");
|
||||
const source = path.join(rootDir, "lib", "widget", "sdk-template.js");
|
||||
const targetDir = path.join(rootDir, "public", "sdk");
|
||||
const target = path.join(targetDir, "cs-agent-widget.js");
|
||||
|
||||
await mkdir(targetDir, { recursive: true });
|
||||
await cp(source, target);
|
||||
|
||||
console.log(`sdk written to ${target}`);
|
||||
@@ -1,34 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": [
|
||||
"next-env.d.ts",
|
||||
"**/*.ts",
|
||||
"**/*.tsx",
|
||||
".next/types/**/*.ts",
|
||||
".next/dev/types/**/*.ts",
|
||||
"**/*.mts"
|
||||
],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user