实现 P0 自动分配、离线留言与可嵌入 Widget SDK
- 会话创建时按负载自动分配在线客服,无客服则进入离线模式 - 新增 /api/widget/leave-message 沉淀联系方式与留言事件 - 访客端离线留言表单;工作台展示离线留言/自动分配事件 - 提供 public/widget.js + /widget/embed 嵌入方案
This commit is contained in:
@@ -0,0 +1,23 @@
|
||||
import { useMemo } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import VisitorChat from '@/widgets/VisitorChat'
|
||||
|
||||
/** 供 widget.js iframe 嵌入的无边框页面 */
|
||||
const WidgetEmbed = () => {
|
||||
const [params] = useSearchParams()
|
||||
const channelKey = useMemo(() => params.get('channel_key') || 'WK_8a3f2e', [params])
|
||||
const embedded = params.get('embedded') === '1'
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen overflow-hidden bg-transparent">
|
||||
<VisitorChat
|
||||
defaultOpen
|
||||
channelKey={channelKey}
|
||||
embedded={embedded}
|
||||
layout={embedded ? 'fill' : 'floating'}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default WidgetEmbed
|
||||
@@ -1,9 +1,10 @@
|
||||
import VisitorChat from '@/widgets/VisitorChat'
|
||||
|
||||
const WidgetPreview = () => {
|
||||
const origin = typeof window !== 'undefined' ? window.location.origin : 'https://your-host'
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-100 relative overflow-hidden">
|
||||
{/* 模拟宿主站点骨架,贴近设计稿模糊背景 */}
|
||||
<div className="max-w-[960px] mx-auto px-6 py-10 opacity-40 select-none pointer-events-none">
|
||||
<div className="h-10 w-3/5 bg-neutral-200 rounded mb-4" />
|
||||
<div className="h-4 w-[90%] bg-neutral-200 rounded mb-3" />
|
||||
@@ -15,16 +16,28 @@ const WidgetPreview = () => {
|
||||
</div>
|
||||
<div className="h-4 w-[85%] bg-neutral-200 rounded mb-3" />
|
||||
<div className="h-4 w-[70%] bg-neutral-200 rounded mb-3" />
|
||||
<div className="h-4 w-4/5 bg-neutral-200 rounded mb-6" />
|
||||
<div className="h-[200px] w-full bg-neutral-200 rounded-lg" />
|
||||
</div>
|
||||
<div className="absolute inset-0 flex items-start justify-center pt-16 pointer-events-none">
|
||||
<div className="text-center pointer-events-auto">
|
||||
<h1 className="text-xl font-semibold text-neutral-700 mb-1">访客 Widget 预览</h1>
|
||||
<p className="text-sm text-neutral-400">右下角为可嵌入聊天组件 · 消息会写入数据库</p>
|
||||
|
||||
<div className="absolute top-6 left-1/2 -translate-x-1/2 w-full max-w-xl px-4 pointer-events-auto">
|
||||
<div className="bg-white/95 backdrop-blur border border-neutral-200 rounded-xl shadow-sm p-4">
|
||||
<h1 className="text-base font-semibold text-neutral-800 m-0 mb-1">访客 Widget 预览</h1>
|
||||
<p className="text-xs text-neutral-500 m-0 mb-3">
|
||||
右下角为聊天组件。任意站点可嵌入下方脚本(iframe 版 SDK)。
|
||||
</p>
|
||||
<pre className="m-0 text-[11px] leading-relaxed bg-neutral-50 border border-neutral-100 rounded-lg p-3 overflow-x-auto text-neutral-700 whitespace-pre-wrap">
|
||||
{`<script src="${origin}/widget.js" data-id="WK_8a3f2e"></script>`}
|
||||
</pre>
|
||||
<p className="text-[11px] text-neutral-400 mt-2 mb-0">
|
||||
也可打开独立嵌入页:
|
||||
<a className="text-blue-600 ml-1" href="/widget/embed?channel_key=WK_8a3f2e&embedded=1" target="_blank" rel="noreferrer">
|
||||
/widget/embed
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<VisitorChat defaultOpen />
|
||||
|
||||
<VisitorChat defaultOpen channelKey="WK_8a3f2e" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -253,7 +253,7 @@ const Dashboard = () => {
|
||||
return tb - ta
|
||||
})
|
||||
|
||||
const notes = detail?.events.filter(event => event.action === 'note').slice().reverse() || []
|
||||
const notes = detail?.events.filter(event => event.action === 'note' || event.action === 'offline_leave' || event.action === 'auto_assign').slice().reverse() || []
|
||||
|
||||
const emitTyping = () => {
|
||||
if (!selectedId || !canOperate) return
|
||||
@@ -899,12 +899,25 @@ const Dashboard = () => {
|
||||
<div className="space-y-2 max-h-40 overflow-auto">
|
||||
{notes.length === 0 ? (
|
||||
<div className="text-xs text-neutral-400">暂无内部备注</div>
|
||||
) : notes.map(note => (
|
||||
<div key={note.id} className="bg-amber-50 text-amber-900 rounded-lg p-2 text-xs whitespace-pre-wrap border border-amber-100">
|
||||
{note.detail}
|
||||
<div className="text-amber-600/70 mt-1">{new Date(note.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
))}
|
||||
) : notes.map(note => {
|
||||
const isLeave = note.action === 'offline_leave'
|
||||
const isAssign = note.action === 'auto_assign'
|
||||
const box = isLeave
|
||||
? 'bg-orange-50 text-orange-900 border-orange-100'
|
||||
: isAssign
|
||||
? 'bg-blue-50 text-blue-900 border-blue-100'
|
||||
: 'bg-amber-50 text-amber-900 border-amber-100'
|
||||
const timeCls = isLeave ? 'text-orange-600/70' : isAssign ? 'text-blue-600/70' : 'text-amber-600/70'
|
||||
return (
|
||||
<div key={note.id} className={`rounded-lg p-2 text-xs whitespace-pre-wrap border ${box}`}>
|
||||
{(isLeave || isAssign) && (
|
||||
<div className="font-medium mb-0.5">{isLeave ? '离线留言' : '自动分配'}</div>
|
||||
)}
|
||||
{note.detail}
|
||||
<div className={`mt-1 ${timeCls}`}>{new Date(note.created_at).toLocaleString('zh-CN')}</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
{canOperate && (
|
||||
<div className="mt-2 flex gap-1">
|
||||
|
||||
Reference in New Issue
Block a user