优化Widget:localStorage持久化会话、消息历史加载、默认展开预览
This commit is contained in:
@@ -1,15 +1,23 @@
|
||||
import { useState } from 'react'
|
||||
import VisitorChat from '@/widgets/VisitorChat'
|
||||
|
||||
const WidgetPreview = () => {
|
||||
const [showWidget, setShowWidget] = useState(true)
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-neutral-100 flex flex-col">
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="text-center">
|
||||
<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>
|
||||
<VisitorChat />
|
||||
{showWidget && <VisitorChat />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,70 +9,73 @@ interface Message {
|
||||
}
|
||||
|
||||
const quickQuestions = ['产品功能介绍', '价格咨询', '售后服务', '合作咨询']
|
||||
const STORAGE_KEY = 'kefu_widget_session'
|
||||
|
||||
const VisitorChat = () => {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [sessionId, setSessionId] = useState<number | null>(null)
|
||||
const [messages, setMessages] = useState<Message[]>([
|
||||
{ id: 0, sender: 'agent', content: '您好!欢迎咨询客服云,请问有什么可以帮您的?', time: '' },
|
||||
])
|
||||
const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
const [sessionId, setSessionId] = useState<number | null>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
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 [sending, setSending] = useState(false)
|
||||
const [showRating, setShowRating] = useState(false)
|
||||
const [rated, setRated] = useState(false)
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const initRef = useRef(false)
|
||||
|
||||
const initSession = async () => {
|
||||
if (initRef.current && sessionId) return
|
||||
initRef.current = true
|
||||
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()
|
||||
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) {
|
||||
console.error('Init session failed:', e)
|
||||
console.error('Init failed:', e)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !sessionId) {
|
||||
initSession()
|
||||
}
|
||||
if (!open) {
|
||||
setSessionId(null)
|
||||
if (pollRef.current) clearInterval(pollRef.current)
|
||||
}
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId) {
|
||||
pollRef.current = window.setInterval(async () => {
|
||||
const loadMessages = async (sid?: number) => {
|
||||
const s = sid || sessionId
|
||||
if (!s) return
|
||||
try {
|
||||
const res = await fetch(`/api/widget/messages?session_id=${sessionId}`)
|
||||
const res = await fetch(`/api/widget/messages?session_id=${s}`)
|
||||
const json = await res.json()
|
||||
if (json.code === 0 && json.data) {
|
||||
const serverMsgs: Message[] = json.data.map((m: any) => ({
|
||||
if (json.code === 0 && json.data && json.data.length > 0) {
|
||||
const msgs: Message[] = json.data.map((m: any) => ({
|
||||
id: m.id,
|
||||
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
|
||||
})
|
||||
setMessages(msgs)
|
||||
localStorage.setItem(STORAGE_KEY + '_msgs', JSON.stringify(msgs))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !initRef.current) {
|
||||
initSession()
|
||||
}
|
||||
}, [open, sessionId])
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionId && open) {
|
||||
pollRef.current = window.setInterval(() => loadMessages(), 3000)
|
||||
return () => { if (pollRef.current) clearInterval(pollRef.current) }
|
||||
}
|
||||
}, [sessionId])
|
||||
}, [sessionId, open])
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || sending) return
|
||||
@@ -80,24 +83,21 @@ const VisitorChat = () => {
|
||||
setInput('')
|
||||
setSending(true)
|
||||
|
||||
// Add locally immediately
|
||||
const localMsg: Message = { id: -Date.now(), sender: 'visitor', content, time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }) }
|
||||
const localMsg: Message = {
|
||||
id: -Date.now(), sender: 'visitor', content,
|
||||
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
setMessages(prev => [...prev, localMsg])
|
||||
|
||||
if (sessionId) {
|
||||
try {
|
||||
const res = await fetch('/api/widget/message', {
|
||||
await fetch('/api/widget/message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ session_id: sessionId, content, type: 'text' }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== 0) {
|
||||
console.error('Send failed:', json.message)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Send error:', e)
|
||||
}
|
||||
loadMessages(sessionId)
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
setSending(false)
|
||||
}
|
||||
@@ -108,6 +108,11 @@ const VisitorChat = () => {
|
||||
setRated(false)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
if (!rated) setShowRating(true)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open && (
|
||||
@@ -125,17 +130,23 @@ const VisitorChat = () => {
|
||||
</div>
|
||||
<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>
|
||||
<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 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 => (
|
||||
<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'}`}>
|
||||
{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>}
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,6 +155,7 @@ const VisitorChat = () => {
|
||||
|
||||
{messages.length <= 1 && (
|
||||
<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">
|
||||
{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>
|
||||
@@ -157,13 +169,13 @@ const VisitorChat = () => {
|
||||
<SmileOutlined className="text-neutral-300" />
|
||||
<input
|
||||
className="flex-1 bg-transparent outline-none text-sm text-neutral-700 placeholder:text-neutral-400"
|
||||
placeholder="输入您的问题..."
|
||||
placeholder={sessionId ? '输入消息... Enter 发送' : '正在连接...'}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user