补全 P0:筛选、访客图片与双向输入状态
- 工作台会话列表支持状态/紧急筛选 - 访客 Widget 支持 jpg/png/gif 图片预览发送 - WebSocket 支持访客输入中通知客服,客服输入中通知访客 - 工作台聊天区展示「访客正在输入」动画
This commit is contained in:
+128
-22
@@ -35,11 +35,15 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
const [agentTyping, setAgentTyping] = useState(false)
|
||||
const [ratingText, setRatingText] = useState('')
|
||||
const [hoverStar, setHoverStar] = useState(0)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||
const [sendError, setSendError] = useState('')
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const initRef = useRef(false)
|
||||
const typingTimerRef = useRef<number | null>(null)
|
||||
const chatEndRef = useRef<HTMLDivElement>(null)
|
||||
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||
const socketRef = useRef<WebSocket | null>(null)
|
||||
const lastTypingAt = useRef(0)
|
||||
|
||||
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||
const s = sid || sessionId
|
||||
@@ -106,10 +110,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
`${scheme}//${window.location.host}/api/widget/ws?session_id=${sessionId}`,
|
||||
['kefu-visitor-v1', visitorToken],
|
||||
)
|
||||
socketRef.current = socket
|
||||
socket.onmessage = (event) => {
|
||||
try {
|
||||
const payload = JSON.parse(event.data)
|
||||
if (payload.session_id === sessionId && payload.type === 'typing') {
|
||||
// 仅展示客服侧输入状态
|
||||
if (payload.data?.from && payload.data.from !== 'agent') return
|
||||
setAgentTyping(true)
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||
typingTimerRef.current = window.setTimeout(() => setAgentTyping(false), 1800)
|
||||
@@ -129,18 +136,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
}
|
||||
return () => {
|
||||
socket.close()
|
||||
socketRef.current = null
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||
}
|
||||
}, [sessionId, visitorToken, open, loadMessages])
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, agentTyping, open])
|
||||
}, [messages, agentTyping, open, imagePreview])
|
||||
|
||||
const emitTyping = () => {
|
||||
if (!sessionId || sessionEnded) return
|
||||
const now = Date.now()
|
||||
if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return
|
||||
lastTypingAt.current = now
|
||||
socketRef.current.send(JSON.stringify({ type: 'typing', session_id: sessionId }))
|
||||
}
|
||||
|
||||
const postMessage = async (content: string, type: 'text' | 'image') => {
|
||||
if (!sessionId || !visitorToken) throw new Error('会话未就绪')
|
||||
const res = await fetch('/api/widget/message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||||
body: JSON.stringify({ session_id: sessionId, content, type }),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== 0) {
|
||||
throw new Error(json.message || '发送失败')
|
||||
}
|
||||
return json.data
|
||||
}
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || sending || sessionEnded) return
|
||||
const content = text.trim()
|
||||
setInput('')
|
||||
setSendError('')
|
||||
setSending(true)
|
||||
|
||||
const localMsg: Message = {
|
||||
@@ -152,17 +183,55 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
}
|
||||
setMessages(prev => [...prev, localMsg])
|
||||
|
||||
if (sessionId && visitorToken) {
|
||||
try {
|
||||
await fetch('/api/widget/message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||||
body: JSON.stringify({ session_id: sessionId, content, type: 'text' }),
|
||||
})
|
||||
loadMessages(sessionId)
|
||||
} catch { /* ignore */ }
|
||||
try {
|
||||
await postMessage(content, 'text')
|
||||
await loadMessages(sessionId || undefined)
|
||||
} catch (e) {
|
||||
setSendError(e instanceof Error ? e.message : '发送失败')
|
||||
setMessages(prev => prev.filter(m => m.id !== localMsg.id))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageFile = (file?: File | null) => {
|
||||
if (!file || sessionEnded || !sessionId) return
|
||||
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
|
||||
setSendError('仅支持 jpg、png、gif 图片')
|
||||
return
|
||||
}
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
setSendError('图片不能超过 5 MB')
|
||||
return
|
||||
}
|
||||
setSendError('')
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => setImagePreview(String(reader.result))
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const sendImage = async () => {
|
||||
if (!imagePreview || sending || sessionEnded) return
|
||||
setSending(true)
|
||||
setSendError('')
|
||||
const localMsg: Message = {
|
||||
id: -Date.now(),
|
||||
sender: 'visitor',
|
||||
content: imagePreview,
|
||||
type: 'image',
|
||||
time: new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }),
|
||||
}
|
||||
setMessages(prev => [...prev, localMsg])
|
||||
setImagePreview(null)
|
||||
try {
|
||||
await postMessage(localMsg.content, 'image')
|
||||
await loadMessages(sessionId || undefined)
|
||||
} catch (e) {
|
||||
setSendError(e instanceof Error ? e.message : '图片发送失败')
|
||||
setMessages(prev => prev.filter(m => m.id !== localMsg.id))
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
const handleOpen = () => {
|
||||
@@ -219,7 +288,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
className="fixed z-50 right-6 bottom-6 w-[400px] h-[600px] max-w-[calc(100vw-32px)] max-h-[calc(100vh-32px)] flex flex-col rounded-xl overflow-hidden bg-white"
|
||||
style={{ boxShadow: 'var(--shadow-floating)' }}
|
||||
>
|
||||
{/* Header — solid brand blue */}
|
||||
<header className="shrink-0 px-4 py-4 bg-[#2563eb] text-white flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-full bg-white/20 flex items-center justify-center shrink-0">
|
||||
<CustomerServiceOutlined className="text-lg text-white" />
|
||||
@@ -251,7 +319,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Messages */}
|
||||
<section className="flex-1 overflow-y-auto p-4 flex flex-col gap-3 bg-neutral-50 no-scrollbar">
|
||||
{showWelcome && (
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
@@ -282,9 +349,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
{messages.map(msg => (
|
||||
msg.sender === 'visitor' ? (
|
||||
<div key={msg.id} className="flex justify-end max-w-[85%] ml-auto">
|
||||
<div className="px-4 py-3 rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal">
|
||||
<div className={`rounded-xl bg-[#2563eb] text-white text-[13px] leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||||
{msg.type === 'image' ? (
|
||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg" />
|
||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" />
|
||||
) : (
|
||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
@@ -295,9 +362,9 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
<div className="w-8 h-8 rounded-full bg-neutral-200 flex items-center justify-center shrink-0 mt-0.5">
|
||||
<CustomerServiceOutlined className="text-xs text-neutral-500" />
|
||||
</div>
|
||||
<div className="px-4 py-3 rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal">
|
||||
<div className={`rounded-xl bg-white border border-neutral-200 text-[13px] text-neutral-800 leading-normal ${msg.type === 'image' ? 'p-1.5' : 'px-4 py-3'}`}>
|
||||
{msg.type === 'image' ? (
|
||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg" />
|
||||
<img src={msg.content} alt="图片" className="max-w-[180px] rounded-lg block" />
|
||||
) : (
|
||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
@@ -324,19 +391,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
<div ref={chatEndRef} />
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="shrink-0 relative px-4 py-3 bg-white border-t border-neutral-200">
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 h-[3px] pointer-events-none opacity-40"
|
||||
style={{ background: 'linear-gradient(90deg, transparent 0%, #2563eb 50%, transparent 100%)' }}
|
||||
/>
|
||||
{sendError && (
|
||||
<div className="mb-2 text-xs text-red-500">{sendError}</div>
|
||||
)}
|
||||
{imagePreview && (
|
||||
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||||
<img src={imagePreview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认发送图片?</div>
|
||||
<button
|
||||
type="button"
|
||||
className="text-xs text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
||||
onClick={() => setImagePreview(null)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending}
|
||||
className="text-xs px-2 py-1 rounded-md bg-[#2563eb] text-white border-0 cursor-pointer disabled:opacity-50"
|
||||
onClick={sendImage}
|
||||
>
|
||||
发送
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
<button
|
||||
type="button"
|
||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50"
|
||||
className="w-8 h-8 rounded-md border-0 bg-transparent cursor-pointer flex items-center justify-center text-neutral-400 hover:bg-neutral-50 disabled:opacity-40"
|
||||
aria-label="发送图片"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={sessionEnded || !sessionId}
|
||||
disabled={sessionEnded || !sessionId || sending}
|
||||
>
|
||||
<PictureOutlined className="text-base" />
|
||||
</button>
|
||||
@@ -347,14 +437,30 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
>
|
||||
<SmileOutlined className="text-base" />
|
||||
</button>
|
||||
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" />
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif"
|
||||
className="hidden"
|
||||
onChange={e => {
|
||||
handleImageFile(e.target.files?.[0])
|
||||
e.currentTarget.value = ''
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="flex-1 min-w-0 h-10 px-3 rounded-xl border border-neutral-200 bg-neutral-50 text-[13px] text-neutral-800 outline-none focus:border-[#2563eb]"
|
||||
placeholder={sessionEnded ? '会话已结束' : sessionId ? '输入消息...' : '正在连接...'}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onChange={e => { setInput(e.target.value); emitTyping() }}
|
||||
onPaste={e => {
|
||||
const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
|
||||
if (item) {
|
||||
e.preventDefault()
|
||||
handleImageFile(item.getAsFile())
|
||||
}
|
||||
}}
|
||||
onKeyDown={e => { if (e.key === 'Enter') sendMessage(input) }}
|
||||
disabled={sending || !sessionId || sessionEnded}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user