实现 P0 自动分配、离线留言与可嵌入 Widget SDK
- 会话创建时按负载自动分配在线客服,无客服则进入离线模式 - 新增 /api/widget/leave-message 沉淀联系方式与留言事件 - 访客端离线留言表单;工作台展示离线留言/自动分配事件 - 提供 public/widget.js + /widget/embed 嵌入方案
This commit is contained in:
+393
-256
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from 'react'
|
||||
import {
|
||||
CloseOutlined, MessageOutlined, SmileOutlined, SendOutlined,
|
||||
StarFilled, MinusOutlined, PictureOutlined, CustomerServiceOutlined,
|
||||
@@ -12,19 +12,36 @@ interface Message {
|
||||
type?: 'text' | 'image'
|
||||
}
|
||||
|
||||
const quickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||||
const STORAGE_KEY = 'kefu_widget_session'
|
||||
const VISITOR_TOKEN_KEY = 'kefu_widget_visitor_token'
|
||||
const defaultQuickQuestions = ['查询订单状态', '退换货政策', '配送时效说明']
|
||||
|
||||
const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
const [open, setOpen] = useState(defaultOpen)
|
||||
type LayoutMode = 'floating' | 'fill'
|
||||
|
||||
interface VisitorChatProps {
|
||||
defaultOpen?: boolean
|
||||
channelKey?: string
|
||||
/** 是否在 iframe 嵌入模式(关闭/最小化会 postMessage 给宿主) */
|
||||
embedded?: boolean
|
||||
layout?: LayoutMode
|
||||
}
|
||||
|
||||
const VisitorChat = ({
|
||||
defaultOpen = false,
|
||||
channelKey = 'WK_8a3f2e',
|
||||
embedded = false,
|
||||
layout = 'floating',
|
||||
}: VisitorChatProps) => {
|
||||
const storageKey = useMemo(() => `kefu_widget_session_${channelKey}`, [channelKey])
|
||||
const tokenKey = useMemo(() => `kefu_widget_visitor_token_${channelKey}`, [channelKey])
|
||||
const msgsKey = useMemo(() => `${storageKey}_msgs`, [storageKey])
|
||||
|
||||
const [open, setOpen] = useState(defaultOpen || layout === 'fill')
|
||||
const [sessionId, setSessionId] = useState<number | null>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
const saved = localStorage.getItem(storageKey)
|
||||
return saved ? Number(saved) : null
|
||||
})
|
||||
const [visitorToken, setVisitorToken] = useState<string>(() => localStorage.getItem(VISITOR_TOKEN_KEY) || '')
|
||||
const [visitorToken, setVisitorToken] = useState<string>(() => localStorage.getItem(tokenKey) || '')
|
||||
const [messages, setMessages] = useState<Message[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY + '_msgs')
|
||||
const saved = localStorage.getItem(msgsKey)
|
||||
return saved ? JSON.parse(saved) : []
|
||||
})
|
||||
const [input, setInput] = useState('')
|
||||
@@ -37,6 +54,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
const [hoverStar, setHoverStar] = useState(0)
|
||||
const [imagePreview, setImagePreview] = useState<string | null>(null)
|
||||
const [sendError, setSendError] = useState('')
|
||||
const [agentsOnline, setAgentsOnline] = useState(true)
|
||||
const [offlinePrompt, setOfflinePrompt] = useState('当前无客服在线,请留言并留下联系方式,我们上线后会尽快回复您。')
|
||||
const [agentName, setAgentName] = useState('')
|
||||
const [leaveName, setLeaveName] = useState('')
|
||||
const [leavePhone, setLeavePhone] = useState('')
|
||||
const [leaveEmail, setLeaveEmail] = useState('')
|
||||
const [leaveSent, setLeaveSent] = useState(false)
|
||||
const pollRef = useRef<number | null>(null)
|
||||
const initRef = useRef(false)
|
||||
const typingTimerRef = useRef<number | null>(null)
|
||||
@@ -47,7 +71,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
|
||||
const loadMessages = useCallback(async (sid?: number, token?: string) => {
|
||||
const s = sid || sessionId
|
||||
const visitorCredential = token || visitorToken || localStorage.getItem(VISITOR_TOKEN_KEY) || ''
|
||||
const visitorCredential = token || visitorToken || localStorage.getItem(tokenKey) || ''
|
||||
if (!s || !visitorCredential) return
|
||||
try {
|
||||
const res = await fetch(`/api/widget/messages?session_id=${s}`, {
|
||||
@@ -63,10 +87,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
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))
|
||||
localStorage.setItem(msgsKey, JSON.stringify(msgs))
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}, [sessionId, visitorToken])
|
||||
}, [sessionId, visitorToken, tokenKey, msgsKey])
|
||||
|
||||
const initSession = useCallback(async () => {
|
||||
if (sessionId && visitorToken) {
|
||||
@@ -76,21 +100,31 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
if (initRef.current) 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=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`, {
|
||||
method: 'POST',
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code === 0) {
|
||||
const sid = json.data.session_id
|
||||
const token = json.data.visitor_token
|
||||
const data = json.data
|
||||
const sid = data.session_id
|
||||
const token = data.visitor_token
|
||||
setSessionId(sid)
|
||||
setVisitorToken(token)
|
||||
localStorage.setItem(STORAGE_KEY, String(sid))
|
||||
localStorage.setItem(VISITOR_TOKEN_KEY, token)
|
||||
setAgentsOnline(Boolean(data.agents_online))
|
||||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
||||
if (data.agent_name) setAgentName(data.agent_name)
|
||||
if (data.session_status === 'ended') setSessionEnded(true)
|
||||
localStorage.setItem(storageKey, String(sid))
|
||||
localStorage.setItem(tokenKey, token)
|
||||
await loadMessages(sid, token)
|
||||
} else {
|
||||
setSendError(json.message || '初始化会话失败')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Init failed:', e)
|
||||
setSendError('连接客服失败,请稍后重试')
|
||||
}
|
||||
}, [sessionId, visitorToken, loadMessages])
|
||||
}, [sessionId, visitorToken, loadMessages, channelKey, storageKey, tokenKey])
|
||||
|
||||
useEffect(() => {
|
||||
if (open) initSession()
|
||||
@@ -115,7 +149,6 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
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)
|
||||
@@ -123,9 +156,15 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
return
|
||||
}
|
||||
if (payload.session_id === sessionId && (payload.type === 'message' || payload.type === 'session_updated')) {
|
||||
if (payload.type === 'session_updated' && payload.data?.status === 'ended') {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
if (payload.type === 'session_updated') {
|
||||
if (payload.data?.status === 'ended') {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
}
|
||||
if (payload.data?.status === 'active') {
|
||||
setAgentsOnline(true)
|
||||
setAgentName(payload.data?.agent_name || agentName)
|
||||
}
|
||||
}
|
||||
setAgentTyping(false)
|
||||
loadMessages(sessionId, visitorToken)
|
||||
@@ -139,14 +178,20 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
socketRef.current = null
|
||||
if (typingTimerRef.current) clearTimeout(typingTimerRef.current)
|
||||
}
|
||||
}, [sessionId, visitorToken, open, loadMessages])
|
||||
}, [sessionId, visitorToken, open, loadMessages, agentName])
|
||||
|
||||
useEffect(() => {
|
||||
chatEndRef.current?.scrollIntoView({ behavior: 'smooth' })
|
||||
}, [messages, agentTyping, open, imagePreview])
|
||||
|
||||
const notifyHost = (type: 'kefu-widget-close' | 'kefu-widget-minimize') => {
|
||||
if (embedded && window.parent && window.parent !== window) {
|
||||
window.parent.postMessage({ type }, '*')
|
||||
}
|
||||
}
|
||||
|
||||
const emitTyping = () => {
|
||||
if (!sessionId || sessionEnded) return
|
||||
if (!sessionId || sessionEnded || !agentsOnline) return
|
||||
const now = Date.now()
|
||||
if (now - lastTypingAt.current < 1200 || socketRef.current?.readyState !== WebSocket.OPEN) return
|
||||
lastTypingAt.current = now
|
||||
@@ -169,6 +214,10 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
|
||||
const sendMessage = async (text: string) => {
|
||||
if (!text.trim() || sending || sessionEnded) return
|
||||
if (!agentsOnline) {
|
||||
setSendError('当前无客服在线,请使用下方留言表单')
|
||||
return
|
||||
}
|
||||
const content = text.trim()
|
||||
setInput('')
|
||||
setSendError('')
|
||||
@@ -194,8 +243,42 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const submitLeaveMessage = async () => {
|
||||
if (!sessionId || !visitorToken || sending || leaveSent) return
|
||||
const content = input.trim() || '请尽快与我联系,谢谢。'
|
||||
if (!leavePhone.trim() && !leaveEmail.trim()) {
|
||||
setSendError('请至少填写手机号或邮箱')
|
||||
return
|
||||
}
|
||||
setSending(true)
|
||||
setSendError('')
|
||||
try {
|
||||
const res = await fetch('/api/widget/leave-message', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
content,
|
||||
name: leaveName.trim(),
|
||||
phone: leavePhone.trim(),
|
||||
email: leaveEmail.trim(),
|
||||
}),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code !== 0) throw new Error(json.message || '留言失败')
|
||||
setLeaveSent(true)
|
||||
setInput('')
|
||||
if (json.data?.agents_online) setAgentsOnline(true)
|
||||
await loadMessages(sessionId, visitorToken)
|
||||
} catch (e) {
|
||||
setSendError(e instanceof Error ? e.message : '留言失败')
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleImageFile = (file?: File | null) => {
|
||||
if (!file || sessionEnded || !sessionId) return
|
||||
if (!file || sessionEnded || !sessionId || !agentsOnline) return
|
||||
if (!['image/jpeg', 'image/png', 'image/gif'].includes(file.type)) {
|
||||
setSendError('仅支持 jpg、png、gif 图片')
|
||||
return
|
||||
@@ -211,7 +294,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
}
|
||||
|
||||
const sendImage = async () => {
|
||||
if (!imagePreview || sending || sessionEnded) return
|
||||
if (!imagePreview || sending || sessionEnded || !agentsOnline) return
|
||||
setSending(true)
|
||||
setSendError('')
|
||||
const localMsg: Message = {
|
||||
@@ -241,11 +324,13 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false)
|
||||
notifyHost('kefu-widget-close')
|
||||
if (sessionEnded && !rated) setShowRating(true)
|
||||
}
|
||||
|
||||
const handleMinimize = () => {
|
||||
setOpen(false)
|
||||
notifyHost('kefu-widget-minimize')
|
||||
}
|
||||
|
||||
const submitRating = async (score: number) => {
|
||||
@@ -266,12 +351,290 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
}
|
||||
}
|
||||
|
||||
const showWelcome = messages.length === 0
|
||||
const showQuick = messages.length <= 1 && !sessionEnded
|
||||
const showWelcome = messages.length === 0 && !leaveSent
|
||||
const showQuick = messages.length <= 1 && !sessionEnded && agentsOnline
|
||||
const isFill = layout === 'fill'
|
||||
const shellClass = isFill
|
||||
? 'relative w-full h-full flex flex-col bg-white overflow-hidden'
|
||||
: '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'
|
||||
|
||||
const panel = open && (
|
||||
<div className={shellClass} style={isFill ? undefined : { boxShadow: 'var(--shadow-floating)' }}>
|
||||
<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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">在线客服</h1>
|
||||
<p className="m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5">
|
||||
<span
|
||||
className="inline-block w-1.5 h-1.5 rounded-full"
|
||||
style={{ background: sessionEnded ? '#94a3b8' : agentsOnline ? '#16a34a' : '#d97706' }}
|
||||
/>
|
||||
{sessionEnded
|
||||
? '会话已结束'
|
||||
: agentsOnline
|
||||
? (agentName ? `${agentName} 为您服务` : '正在为您服务')
|
||||
: '客服离线 · 可留言'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{!isFill && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMinimize}
|
||||
className="w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
|
||||
aria-label="最小化"
|
||||
>
|
||||
<MinusOutlined className="text-xs" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseOutlined className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<div className="px-4 py-2 rounded-xl bg-white border border-neutral-200 max-w-[85%] text-center">
|
||||
<p className="m-0 text-[13px] text-neutral-600 leading-normal">
|
||||
{agentsOnline
|
||||
? '您好!欢迎咨询,请问有什么可以帮您?'
|
||||
: offlinePrompt}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQuick && (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{defaultQuickQuestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => sendMessage(q)}
|
||||
disabled={!sessionId || sending || sessionEnded}
|
||||
className="px-3 py-1 rounded-full border border-[#2563eb] bg-white text-[#2563eb] text-xs cursor-pointer whitespace-nowrap hover:bg-[#eff6ff] disabled:opacity-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(msg => (
|
||||
msg.sender === 'visitor' ? (
|
||||
<div key={msg.id} className="flex justify-end max-w-[85%] ml-auto">
|
||||
<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 block" />
|
||||
) : (
|
||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={msg.id} className="flex gap-2 max-w-[85%]">
|
||||
<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={`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 block" />
|
||||
) : (
|
||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{agentTyping && (
|
||||
<>
|
||||
<div className="flex gap-2 max-w-[85%]">
|
||||
<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 flex items-center gap-1">
|
||||
<span className="typing-dot" style={{ animationDelay: '0s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-left text-xs text-neutral-400 m-0 pl-[42px]">客服正在输入...</p>
|
||||
</>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</section>
|
||||
|
||||
<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>}
|
||||
|
||||
{!agentsOnline && !sessionEnded && (
|
||||
<div className="mb-3 p-3 rounded-lg border border-amber-100 bg-amber-50 space-y-2">
|
||||
{leaveSent ? (
|
||||
<div className="text-xs text-amber-800 leading-relaxed">
|
||||
留言已提交,客服上线后会尽快联系您。您也可继续补充留言内容。
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="text-xs text-amber-800 font-medium">离线留言</div>
|
||||
<input
|
||||
className="w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
|
||||
placeholder="您的姓名(可选)"
|
||||
value={leaveName}
|
||||
onChange={e => setLeaveName(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
|
||||
placeholder="手机号"
|
||||
value={leavePhone}
|
||||
onChange={e => setLeavePhone(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
className="w-full h-8 px-2 rounded-md border border-neutral-200 bg-white text-xs outline-none"
|
||||
placeholder="邮箱"
|
||||
value={leaveEmail}
|
||||
onChange={e => setLeaveEmail(e.target.value)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{imagePreview && agentsOnline && (
|
||||
<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 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>
|
||||
)}
|
||||
|
||||
{agentsOnline && (
|
||||
<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 disabled:opacity-40"
|
||||
aria-label="发送图片"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={sessionEnded || !sessionId || sending}
|
||||
>
|
||||
<PictureOutlined className="text-base" />
|
||||
</button>
|
||||
<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" aria-label="表情">
|
||||
<SmileOutlined className="text-base" />
|
||||
</button>
|
||||
<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
|
||||
? '正在连接...'
|
||||
: agentsOnline
|
||||
? '输入消息...'
|
||||
: '描述您的问题(留言)'
|
||||
}
|
||||
value={input}
|
||||
onChange={e => { setInput(e.target.value); if (agentsOnline) emitTyping() }}
|
||||
onPaste={e => {
|
||||
if (!agentsOnline) return
|
||||
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') {
|
||||
if (agentsOnline) sendMessage(input)
|
||||
}
|
||||
}}
|
||||
disabled={sending || !sessionId || sessionEnded}
|
||||
/>
|
||||
{agentsOnline ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || sending || !sessionId || sessionEnded}
|
||||
className="w-10 h-10 rounded-full border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer flex items-center justify-center shrink-0"
|
||||
aria-label="发送"
|
||||
>
|
||||
<SendOutlined className="text-white text-sm" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={submitLeaveMessage}
|
||||
disabled={sending || !sessionId || sessionEnded}
|
||||
className="h-10 px-3 rounded-full border-0 bg-[#d97706] hover:bg-[#b45309] disabled:opacity-40 cursor-pointer text-white text-xs shrink-0"
|
||||
>
|
||||
{leaveSent ? '再留言' : '提交留言'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{showRating && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
|
||||
<div className="bg-white rounded-xl p-6 mx-8 w-full max-w-xs text-center shadow-lg">
|
||||
<div className="text-lg font-semibold text-neutral-800 mb-1">本次服务如何?</div>
|
||||
<div className="text-sm text-neutral-400 mb-4">请对我们的服务进行评价</div>
|
||||
<div className="flex justify-center gap-1.5 mb-4">
|
||||
{[1, 2, 3, 4, 5].map(star => (
|
||||
<StarFilled
|
||||
key={star}
|
||||
className="text-2xl cursor-pointer transition-colors"
|
||||
style={{ color: star <= hoverStar ? '#facc15' : '#e2e8f0' }}
|
||||
onMouseEnter={() => setHoverStar(star)}
|
||||
onMouseLeave={() => setHoverStar(0)}
|
||||
onClick={() => submitRating(star)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
value={ratingText}
|
||||
onChange={e => setRatingText(e.target.value)}
|
||||
placeholder="可选:写下您的服务感受"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer">
|
||||
跳过
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{!open && (
|
||||
{!open && layout === 'floating' && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleOpen}
|
||||
@@ -282,233 +645,7 @@ const VisitorChat = ({ defaultOpen = false }: { defaultOpen?: boolean }) => {
|
||||
<MessageOutlined className="text-xl" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{open && (
|
||||
<div
|
||||
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 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" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="m-0 text-[15px] font-semibold leading-tight text-white">在线客服</h1>
|
||||
<p className="m-0 text-xs leading-normal text-white/80 flex items-center gap-1 mt-0.5">
|
||||
<span className="inline-block w-1.5 h-1.5 rounded-full bg-[#16a34a]" />
|
||||
{sessionEnded ? '会话已结束' : '正在为您服务'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMinimize}
|
||||
className="w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
|
||||
aria-label="最小化"
|
||||
>
|
||||
<MinusOutlined className="text-xs" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClose}
|
||||
className="w-7 h-7 rounded bg-white/15 hover:bg-white/25 border-0 cursor-pointer flex items-center justify-center text-white"
|
||||
aria-label="关闭"
|
||||
>
|
||||
<CloseOutlined className="text-xs" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<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">
|
||||
<div className="px-4 py-2 rounded-xl bg-white border border-neutral-200 max-w-[85%] text-center">
|
||||
<p className="m-0 text-[13px] text-neutral-600 leading-normal">
|
||||
您好!欢迎咨询,请问有什么可以帮您?
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showQuick && (
|
||||
<div className="flex flex-wrap gap-2 justify-center">
|
||||
{quickQuestions.map((q, i) => (
|
||||
<button
|
||||
key={i}
|
||||
type="button"
|
||||
onClick={() => sendMessage(q)}
|
||||
disabled={!sessionId || sending || sessionEnded}
|
||||
className="px-3 py-1 rounded-full border border-[#2563eb] bg-white text-[#2563eb] text-xs cursor-pointer whitespace-nowrap hover:bg-[#eff6ff] disabled:opacity-50"
|
||||
>
|
||||
{q}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{messages.map(msg => (
|
||||
msg.sender === 'visitor' ? (
|
||||
<div key={msg.id} className="flex justify-end max-w-[85%] ml-auto">
|
||||
<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 block" />
|
||||
) : (
|
||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={msg.id} className="flex gap-2 max-w-[85%]">
|
||||
<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={`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 block" />
|
||||
) : (
|
||||
<p className="m-0 whitespace-pre-wrap break-words">{msg.content}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
))}
|
||||
|
||||
{agentTyping && (
|
||||
<>
|
||||
<div className="flex gap-2 max-w-[85%]">
|
||||
<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 flex items-center gap-1">
|
||||
<span className="typing-dot" style={{ animationDelay: '0s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.2s' }} />
|
||||
<span className="typing-dot" style={{ animationDelay: '0.4s' }} />
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-left text-xs text-neutral-400 m-0 pl-[42px]">客服正在输入...</p>
|
||||
</>
|
||||
)}
|
||||
<div ref={chatEndRef} />
|
||||
</section>
|
||||
|
||||
<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 disabled:opacity-40"
|
||||
aria-label="发送图片"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={sessionEnded || !sessionId || sending}
|
||||
>
|
||||
<PictureOutlined className="text-base" />
|
||||
</button>
|
||||
<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"
|
||||
aria-label="表情"
|
||||
>
|
||||
<SmileOutlined className="text-base" />
|
||||
</button>
|
||||
<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); 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}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || sending || !sessionId || sessionEnded}
|
||||
className="w-10 h-10 rounded-full border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer flex items-center justify-center shrink-0"
|
||||
aria-label="发送"
|
||||
>
|
||||
<SendOutlined className="text-white text-sm" />
|
||||
</button>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
{showRating && (
|
||||
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10">
|
||||
<div className="bg-white rounded-xl p-6 mx-8 w-full max-w-xs text-center shadow-lg">
|
||||
<div className="text-lg font-semibold text-neutral-800 mb-1">本次服务如何?</div>
|
||||
<div className="text-sm text-neutral-400 mb-4">请对我们的服务进行评价</div>
|
||||
<div className="flex justify-center gap-1.5 mb-4">
|
||||
{[1, 2, 3, 4, 5].map(star => (
|
||||
<StarFilled
|
||||
key={star}
|
||||
className="text-2xl cursor-pointer transition-colors"
|
||||
style={{ color: star <= hoverStar ? '#facc15' : '#e2e8f0' }}
|
||||
onMouseEnter={() => setHoverStar(star)}
|
||||
onMouseLeave={() => setHoverStar(0)}
|
||||
onClick={() => submitRating(star)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
value={ratingText}
|
||||
onChange={e => setRatingText(e.target.value)}
|
||||
placeholder="可选:写下您的服务感受"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer">
|
||||
跳过
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{panel}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user