优化Widget:localStorage持久化会话、消息历史加载、默认展开预览

This commit is contained in:
yml2213
2026-07-14 11:51:43 +08:00
parent dac61c8c56
commit 064af29d3b
2 changed files with 82 additions and 62 deletions
+10 -2
View File
@@ -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>
) )
} }
+72 -60
View File
@@ -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>