优化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
+72 -60
View File
@@ -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])
const loadMessages = async (sid?: number) => {
const s = sid || sessionId
if (!s) return
try {
const res = await fetch(`/api/widget/messages?session_id=${s}`)
const json = await res.json()
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(msgs)
localStorage.setItem(STORAGE_KEY + '_msgs', JSON.stringify(msgs))
}
} catch { /* ignore */ }
}
useEffect(() => {
if (sessionId) {
pollRef.current = window.setInterval(async () => {
try {
const res = await fetch(`/api/widget/messages?session_id=${sessionId}`)
const json = await res.json()
if (json.code === 0 && json.data) {
const serverMsgs: 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
})
}
} catch { /* ignore */ }
}, 2000)
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>