优化Widget:localStorage持久化会话、消息历史加载、默认展开预览
This commit is contained in:
@@ -1,15 +1,23 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
import VisitorChat from '@/widgets/VisitorChat'
|
import VisitorChat from '@/widgets/VisitorChat'
|
||||||
|
|
||||||
const WidgetPreview = () => {
|
const WidgetPreview = () => {
|
||||||
|
const [showWidget, setShowWidget] = useState(true)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-neutral-100 flex flex-col">
|
<div className="min-h-screen bg-neutral-100 flex flex-col">
|
||||||
<div className="flex-1 flex items-center justify-center">
|
<div className="flex-1 flex items-center justify-center">
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-2xl font-bold text-neutral-800 mb-2">示例网站页面</h1>
|
<h1 className="text-2xl font-bold text-neutral-800 mb-2">示例网站页面</h1>
|
||||||
<p className="text-neutral-400">访客聊天 Widget 已嵌入右下角,点击气泡按钮开始对话</p>
|
<p className="text-neutral-400 mb-4">Widget 在右下角,发送消息会写入数据库</p>
|
||||||
|
{!showWidget && (
|
||||||
|
<button onClick={() => setShowWidget(true)} className="text-sm text-blue-500 hover:text-blue-600 underline">
|
||||||
|
重新显示 Widget
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<VisitorChat />
|
{showWidget && <VisitorChat />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,70 +9,73 @@ interface Message {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
||||||
|
const STORAGE_KEY = 'kefu_widget_session'
|
||||||
|
|
||||||
const VisitorChat = () => {
|
const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(defaultOpen)
|
||||||
const [sessionId, setSessionId] = useState<number | null>(null)
|
const [sessionId, setSessionId] = useState<number | null>(() => {
|
||||||
const [messages, setMessages] = useState<Message[]>([
|
const saved = localStorage.getItem(STORAGE_KEY)
|
||||||
{ id: 0, sender: 'agent', content: '您好!欢迎咨询客服云,请问有什么可以帮您的?', time: '' },
|
return saved ? Number(saved) : null
|
||||||
])
|
})
|
||||||
|
const [messages, setMessages] = useState<Message[]>(() => {
|
||||||
|
const saved = localStorage.getItem(STORAGE_KEY + '_msgs')
|
||||||
|
return saved ? JSON.parse(saved) : []
|
||||||
|
})
|
||||||
const [input, setInput] = useState('')
|
const [input, setInput] = useState('')
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
const [showRating, setShowRating] = useState(false)
|
const [showRating, setShowRating] = useState(false)
|
||||||
const [rated, setRated] = useState(false)
|
const [rated, setRated] = useState(false)
|
||||||
const pollRef = useRef<number | null>(null)
|
const pollRef = useRef<number | null>(null)
|
||||||
|
const initRef = useRef(false)
|
||||||
|
|
||||||
const initSession = async () => {
|
const initSession = async () => {
|
||||||
|
if (initRef.current && sessionId) return
|
||||||
|
initRef.current = true
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=测试访客`, { method: 'POST' })
|
const res = await fetch(`/api/widget/init?channel_key=WK_8a3f2e&visitor_name=客服云访客`, { method: 'POST' })
|
||||||
const json = await res.json()
|
const json = await res.json()
|
||||||
if (json.code === 0) {
|
if (json.code === 0) {
|
||||||
setSessionId(json.data.session_id)
|
const sid = json.data.session_id
|
||||||
|
setSessionId(sid)
|
||||||
|
localStorage.setItem(STORAGE_KEY, String(sid))
|
||||||
|
await loadMessages(sid)
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Init session failed:', e)
|
console.error('Init failed:', e)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
const loadMessages = async (sid?: number) => {
|
||||||
if (open && !sessionId) {
|
const s = sid || sessionId
|
||||||
initSession()
|
if (!s) return
|
||||||
}
|
try {
|
||||||
if (!open) {
|
const res = await fetch(`/api/widget/messages?session_id=${s}`)
|
||||||
setSessionId(null)
|
const json = await res.json()
|
||||||
if (pollRef.current) clearInterval(pollRef.current)
|
if (json.code === 0 && json.data && json.data.length > 0) {
|
||||||
}
|
const msgs: Message[] = json.data.map((m: any) => ({
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
id: m.id,
|
||||||
}, [open])
|
sender: m.sender_type === 'agent' ? 'agent' : 'visitor',
|
||||||
|
content: m.content,
|
||||||
|
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
}))
|
||||||
|
setMessages(msgs)
|
||||||
|
localStorage.setItem(STORAGE_KEY + '_msgs', JSON.stringify(msgs))
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (sessionId) {
|
if (open && !initRef.current) {
|
||||||
pollRef.current = window.setInterval(async () => {
|
initSession()
|
||||||
try {
|
}
|
||||||
const res = await fetch(`/api/widget/messages?session_id=${sessionId}`)
|
}, [open, sessionId])
|
||||||
const json = await res.json()
|
|
||||||
if (json.code === 0 && json.data) {
|
useEffect(() => {
|
||||||
const serverMsgs: Message[] = json.data.map((m: any) => ({
|
if (sessionId && open) {
|
||||||
id: m.id,
|
pollRef.current = window.setInterval(() => loadMessages(), 3000)
|
||||||
sender: m.sender_type === 'agent' ? 'agent' : 'visitor',
|
|
||||||
content: m.content,
|
|
||||||
time: new Date(m.sent_at).toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
|
||||||
}))
|
|
||||||
setMessages(prev => {
|
|
||||||
const existing = new Set(prev.map(m => m.id))
|
|
||||||
const welcome = prev[0]
|
|
||||||
// Filter out server messages that we already have; keep welcome
|
|
||||||
const serverOnly = serverMsgs.filter(m => !existing.has(m.id) && m.id > 0)
|
|
||||||
// Merge sorted
|
|
||||||
const merged = [welcome, ...serverOnly].sort((a, b) => a.id - b.id)
|
|
||||||
return merged.length > 1 ? merged : prev
|
|
||||||
})
|
|
||||||
}
|
|
||||||
} catch { /* ignore */ }
|
|
||||||
}, 2000)
|
|
||||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||||
}
|
}
|
||||||
}, [sessionId])
|
}, [sessionId, open])
|
||||||
|
|
||||||
const sendMessage = async (text: string) => {
|
const sendMessage = async (text: string) => {
|
||||||
if (!text.trim() || sending) return
|
if (!text.trim() || sending) return
|
||||||
@@ -80,24 +83,21 @@ const VisitorChat = () => {
|
|||||||
setInput('')
|
setInput('')
|
||||||
setSending(true)
|
setSending(true)
|
||||||
|
|
||||||
// Add locally immediately
|
const localMsg: Message = {
|
||||||
const localMsg: Message = { id: -Date.now(), sender: 'visitor', content, time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }
|
id: -Date.now(), sender: 'visitor', content,
|
||||||
|
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||||
|
}
|
||||||
setMessages(prev => [...prev, localMsg])
|
setMessages(prev => [...prev, localMsg])
|
||||||
|
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/widget/message', {
|
await fetch('/api/widget/message', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { 'Content-Type': 'application/json' },
|
headers: { 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ session_id: sessionId, content, type: 'text' }),
|
body: JSON.stringify({ session_id: sessionId, content, type: 'text' }),
|
||||||
})
|
})
|
||||||
const json = await res.json()
|
loadMessages(sessionId)
|
||||||
if (json.code !== 0) {
|
} catch { /* ignore */ }
|
||||||
console.error('Send failed:', json.message)
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
console.error('Send error:', e)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
@@ -108,6 +108,11 @@ const VisitorChat = () => {
|
|||||||
setRated(false)
|
setRated(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleClose = () => {
|
||||||
|
setOpen(false)
|
||||||
|
if (!rated) setShowRating(true)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!open && (
|
{!open && (
|
||||||
@@ -125,17 +130,23 @@ const VisitorChat = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<div className="text-sm font-medium text-white">客服云</div>
|
<div className="text-sm font-medium text-white">客服云</div>
|
||||||
<div className="text-xs text-white/70">在线</div>
|
<div className="text-xs text-white/70">在线 · 会话{sessionId || '...'}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<CloseOutlined className="text-white cursor-pointer hover:text-white/80" onClick={() => { setOpen(false); if (!rated) setShowRating(true) }} />
|
<CloseOutlined className="text-white cursor-pointer hover:text-white/80" onClick={handleClose} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 overflow-auto p-4 space-y-3 bg-neutral-50">
|
<div className="flex-1 overflow-auto p-4 space-y-3 bg-neutral-50">
|
||||||
|
{messages.length === 0 && (
|
||||||
|
<div className="flex flex-col items-center justify-center h-full text-neutral-400 gap-3">
|
||||||
|
<MessageOutlined className="text-3xl" />
|
||||||
|
<div className="text-sm">欢迎咨询,请描述您的问题</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
{messages.map(msg => (
|
{messages.map(msg => (
|
||||||
<div key={msg.id} className={`flex ${msg.sender === 'visitor' ? 'justify-end' : 'justify-start'}`}>
|
<div key={msg.id} className={`flex ${msg.sender === 'visitor' ? 'justify-end' : 'justify-start'}`}>
|
||||||
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${msg.sender === 'visitor' ? 'bg-blue-500 text-white' : 'bg-white text-neutral-700 border border-neutral-200'}`}>
|
<div className={`max-w-[75%] rounded-lg px-3 py-2 text-sm ${msg.sender === 'visitor' ? 'bg-blue-500 text-white' : 'bg-white text-neutral-700 border border-neutral-200'}`}>
|
||||||
{msg.content}
|
<div>{msg.sender === 'agent' && <span className="block text-xs text-blue-500 font-medium mb-0.5">客服</span>}{msg.content}</div>
|
||||||
{msg.time && <div className={`text-xs mt-1 ${msg.sender === 'visitor' ? 'text-white/60' : 'text-neutral-400'}`}>{msg.time}</div>}
|
{msg.time && <div className={`text-xs mt-1 ${msg.sender === 'visitor' ? 'text-white/60' : 'text-neutral-400'}`}>{msg.time}</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -144,6 +155,7 @@ const VisitorChat = () => {
|
|||||||
|
|
||||||
{messages.length <= 1 && (
|
{messages.length <= 1 && (
|
||||||
<div className="px-4 py-2 border-t border-neutral-100 flex-shrink-0">
|
<div className="px-4 py-2 border-t border-neutral-100 flex-shrink-0">
|
||||||
|
<div className="text-xs text-neutral-400 mb-1.5">快捷问题:</div>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{quickQuestions.map((q, i) => (
|
{quickQuestions.map((q, i) => (
|
||||||
<button key={i} onClick={() => sendMessage(q)} className="text-xs px-2.5 py-1 rounded-full bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors border border-blue-100">{q}</button>
|
<button key={i} onClick={() => sendMessage(q)} className="text-xs px-2.5 py-1 rounded-full bg-blue-50 text-blue-600 hover:bg-blue-100 transition-colors border border-blue-100">{q}</button>
|
||||||
@@ -157,13 +169,13 @@ const VisitorChat = () => {
|
|||||||
<SmileOutlined className="text-neutral-300" />
|
<SmileOutlined className="text-neutral-300" />
|
||||||
<input
|
<input
|
||||||
className="flex-1 bg-transparent outline-none text-sm text-neutral-700 placeholder:text-neutral-400"
|
className="flex-1 bg-transparent outline-none text-sm text-neutral-700 placeholder:text-neutral-400"
|
||||||
placeholder="输入您的问题..."
|
placeholder={sessionId ? '输入消息... Enter 发送' : '正在连接...'}
|
||||||
value={input}
|
value={input}
|
||||||
onChange={e => setInput(e.target.value)}
|
onChange={e => setInput(e.target.value)}
|
||||||
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
||||||
disabled={sending}
|
disabled={sending || !sessionId}
|
||||||
/>
|
/>
|
||||||
<SendOutlined className="text-blue-500 cursor-pointer hover:text-blue-600" onClick={() => sendMessage(input)} />
|
<SendOutlined className={`cursor-pointer ${sessionId ? 'text-blue-500 hover:text-blue-600' : 'text-neutral-300'}`} onClick={() => sendMessage(input)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user