完善访客结束流程与转接可见性
- 会话结束后支持重新咨询,评价改为选星+快捷文案再提交 - 转接/分配事件写入操作人与目标坐席,工作台时间线与备注可见
This commit is contained in:
+271
-107
@@ -54,8 +54,24 @@ const VisitorChat = ({
|
||||
const [rated, setRated] = useState(false)
|
||||
const [sessionEnded, setSessionEnded] = useState(false)
|
||||
const [agentTyping, setAgentTyping] = useState(false)
|
||||
const [ratingScore, setRatingScore] = useState(0)
|
||||
const [ratingText, setRatingText] = useState('')
|
||||
const [hoverStar, setHoverStar] = useState(0)
|
||||
const [ratingSubmitting, setRatingSubmitting] = useState(false)
|
||||
|
||||
/** 评价快捷文案:按星级展示更贴切的选项 */
|
||||
const ratingPresets = useMemo(() => {
|
||||
if (ratingScore >= 5) {
|
||||
return ['服务很棒,非常感谢!', '回复及时,专业耐心', '问题已顺利解决', '下次还会咨询']
|
||||
}
|
||||
if (ratingScore === 4) {
|
||||
return ['整体不错,体验良好', '基本解决了问题', '态度很好,继续加油', '回复比较及时']
|
||||
}
|
||||
if (ratingScore >= 1 && ratingScore <= 3) {
|
||||
return ['等待时间偏长', '问题未完全解决', '希望回复更清晰一些', '需要再跟进一下']
|
||||
}
|
||||
return ['服务很好,感谢!', '回复及时,很专业', '问题已解决', '等待时间稍长']
|
||||
}, [ratingScore])
|
||||
const [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
|
||||
const [sendError, setSendError] = useState('')
|
||||
const [agentsOnline, setAgentsOnline] = useState(true)
|
||||
@@ -256,9 +272,96 @@ const VisitorChat = ({
|
||||
return true
|
||||
}, [])
|
||||
|
||||
/** 调用 Init 创建新会话并写入本地状态(不复用已结束会话) */
|
||||
const bootstrapNewSession = useCallback(async () => {
|
||||
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) {
|
||||
throw new Error(json.message || '初始化会话失败')
|
||||
}
|
||||
const data = json.data
|
||||
const sid = data.session_id as number
|
||||
const token = data.visitor_token as string
|
||||
setSessionId(sid)
|
||||
setVisitorToken(token)
|
||||
sessionIdRef.current = sid
|
||||
visitorTokenRef.current = token
|
||||
setAgentsOnline(Boolean(data.agents_online))
|
||||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
||||
if (data.welcome_message) setWelcomeMessage(data.welcome_message)
|
||||
if (data.display_name) setDisplayName(data.display_name)
|
||||
if (data.agent_name) setAgentName(data.agent_name)
|
||||
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
||||
else setAgentName('')
|
||||
setSessionEnded(false)
|
||||
setRated(false)
|
||||
setShowRating(false)
|
||||
setLeaveSent(false)
|
||||
setMessages([])
|
||||
localStorage.setItem(storageKey, String(sid))
|
||||
localStorage.setItem(tokenKey, token)
|
||||
localStorage.removeItem(msgsKey)
|
||||
lastSeqRef.current = 0
|
||||
await loadMessages(sid, token, { full: true })
|
||||
}, [channelKey, loadMessages, storageKey, tokenKey, msgsKey])
|
||||
|
||||
const closeSocket = useCallback(() => {
|
||||
const sock = socketRef.current
|
||||
if (!sock) return
|
||||
try {
|
||||
sock.onclose = null
|
||||
sock.close()
|
||||
} catch { /* ignore */ }
|
||||
socketRef.current = null
|
||||
}, [])
|
||||
|
||||
/** 会话结束后重新咨询:清本地凭证并 Init 新会话 */
|
||||
const startNewSession = useCallback(async () => {
|
||||
if (sending) return
|
||||
setSending(true)
|
||||
setSendError('')
|
||||
setShowRating(false)
|
||||
closeSocket()
|
||||
localStorage.removeItem(storageKey)
|
||||
localStorage.removeItem(tokenKey)
|
||||
localStorage.removeItem(msgsKey)
|
||||
setSessionId(null)
|
||||
setVisitorToken('')
|
||||
sessionIdRef.current = null
|
||||
visitorTokenRef.current = ''
|
||||
setMessages([])
|
||||
setInput('')
|
||||
setSessionEnded(false)
|
||||
setRated(false)
|
||||
setRatingScore(0)
|
||||
setRatingText('')
|
||||
setHoverStar(0)
|
||||
setAgentTyping(false)
|
||||
setAgentName('')
|
||||
setLeaveSent(false)
|
||||
setPendingImage(prev => {
|
||||
if (prev?.preview) URL.revokeObjectURL(prev.preview)
|
||||
return null
|
||||
})
|
||||
lastSeqRef.current = 0
|
||||
initRef.current = true
|
||||
try {
|
||||
await bootstrapNewSession()
|
||||
} catch (e) {
|
||||
console.error('Start new session failed:', e)
|
||||
setSendError(e instanceof Error ? e.message : '发起新咨询失败,请稍后重试')
|
||||
initRef.current = false
|
||||
} finally {
|
||||
setSending(false)
|
||||
}
|
||||
}, [sending, closeSocket, storageKey, tokenKey, msgsKey, bootstrapNewSession])
|
||||
|
||||
const initSession = useCallback(async () => {
|
||||
if (sessionId && visitorToken) {
|
||||
// 本地有 seq 游标时增量补齐,否则全量
|
||||
// 本地有 seq 游标时增量补齐,否则全量(若已结束会由 loadMessages 带回状态)
|
||||
const after = lastSeqRef.current
|
||||
await loadMessages(sessionId, visitorToken, after > 0 ? { afterSeq: after } : { full: true })
|
||||
return
|
||||
@@ -266,38 +369,13 @@ const VisitorChat = ({
|
||||
if (initRef.current) return
|
||||
initRef.current = true
|
||||
try {
|
||||
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 data = json.data
|
||||
const sid = data.session_id
|
||||
const token = data.visitor_token
|
||||
setSessionId(sid)
|
||||
setVisitorToken(token)
|
||||
setAgentsOnline(Boolean(data.agents_online))
|
||||
if (data.offline_prompt) setOfflinePrompt(data.offline_prompt)
|
||||
if (data.welcome_message) setWelcomeMessage(data.welcome_message)
|
||||
if (data.display_name) setDisplayName(data.display_name)
|
||||
if (data.agent_name) setAgentName(data.agent_name)
|
||||
else if (data.agent_nickname) setAgentName(data.agent_nickname)
|
||||
if (data.session_status === 'ended' || data.session_status === 'archived') {
|
||||
setSessionEnded(true)
|
||||
setShowRating(true)
|
||||
}
|
||||
localStorage.setItem(storageKey, String(sid))
|
||||
localStorage.setItem(tokenKey, token)
|
||||
lastSeqRef.current = 0
|
||||
await loadMessages(sid, token, { full: true })
|
||||
} else {
|
||||
setSendError(json.message || '初始化会话失败')
|
||||
}
|
||||
await bootstrapNewSession()
|
||||
} catch (e) {
|
||||
console.error('Init failed:', e)
|
||||
setSendError('连接客服失败,请稍后重试')
|
||||
setSendError(e instanceof Error ? e.message : '连接客服失败,请稍后重试')
|
||||
initRef.current = false
|
||||
}
|
||||
}, [sessionId, visitorToken, loadMessages, channelKey, storageKey, tokenKey])
|
||||
}, [sessionId, visitorToken, loadMessages, bootstrapNewSession])
|
||||
|
||||
useEffect(() => {
|
||||
sessionIdRef.current = sessionId
|
||||
@@ -622,24 +700,38 @@ const VisitorChat = ({
|
||||
notifyHost('kefu-widget-minimize')
|
||||
}
|
||||
|
||||
const submitRating = async (score: number) => {
|
||||
if (!sessionId || !visitorToken || rated) return
|
||||
const submitRating = async () => {
|
||||
if (!sessionId || !visitorToken || rated || ratingSubmitting) return
|
||||
if (ratingScore < 1 || ratingScore > 5) {
|
||||
setSendError('请先点击星星选择评分')
|
||||
return
|
||||
}
|
||||
setRatingSubmitting(true)
|
||||
setSendError('')
|
||||
try {
|
||||
const res = await fetch('/api/widget/rating', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken },
|
||||
body: JSON.stringify({ session_id: sessionId, score, text: ratingText }),
|
||||
body: JSON.stringify({
|
||||
session_id: sessionId,
|
||||
score: ratingScore,
|
||||
text: ratingText.trim(),
|
||||
}),
|
||||
})
|
||||
const json = await res.json()
|
||||
if (json.code === 0) {
|
||||
setRated(true)
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setRatingText('')
|
||||
setSendError('')
|
||||
} else {
|
||||
setSendError(json.message || '评价提交失败')
|
||||
}
|
||||
} catch {
|
||||
setSendError('评价提交失败,请稍后重试')
|
||||
} finally {
|
||||
setRatingSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -775,16 +867,29 @@ const VisitorChat = ({
|
||||
<footer className="shrink-0 px-4 py-3 bg-white border-t border-neutral-200">
|
||||
{sendError && <div className="mb-2 text-xs text-red-500">{sendError}</div>}
|
||||
|
||||
{sessionEnded && !rated && (
|
||||
<div className="mx-4 mb-2 rounded-lg bg-amber-50 border border-amber-100 px-3 py-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-amber-800">会话已结束,欢迎为本次服务打分</span>
|
||||
{sessionEnded && (
|
||||
<div className="mb-3 space-y-2">
|
||||
{!rated && (
|
||||
<div className="rounded-lg bg-amber-50 border border-amber-100 px-3 py-2 flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-amber-800">会话已结束,欢迎为本次服务打分</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRating(true)}
|
||||
className="shrink-0 text-xs font-medium text-amber-900 bg-amber-100 hover:bg-amber-200 border-0 rounded-md px-2 py-1 cursor-pointer"
|
||||
>
|
||||
去评价
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowRating(true)}
|
||||
className="shrink-0 text-xs font-medium text-amber-900 bg-amber-100 hover:bg-amber-200 border-0 rounded-md px-2 py-1 cursor-pointer"
|
||||
onClick={() => { void startNewSession() }}
|
||||
disabled={sending}
|
||||
className="w-full h-10 rounded-xl border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-50 cursor-pointer text-white text-sm font-medium"
|
||||
>
|
||||
去评价
|
||||
{sending ? '正在接入...' : '重新咨询'}
|
||||
</button>
|
||||
<p className="m-0 text-center text-[11px] text-neutral-400">将开启新会话,历史消息仅保留在本页展示到重新接入前</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -820,7 +925,7 @@ const VisitorChat = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{pendingImage && agentsOnline && (
|
||||
{!sessionEnded && pendingImage && agentsOnline && (
|
||||
<div className="mb-2 p-2 rounded-lg border border-neutral-200 bg-neutral-50 flex items-center gap-2">
|
||||
<img src={pendingImage.preview} alt="预览" className="w-14 h-14 object-cover rounded" />
|
||||
<div className="flex-1 min-w-0 text-xs text-neutral-500">确认上传并发送?(自动转 WebP)</div>
|
||||
@@ -838,20 +943,20 @@ const VisitorChat = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{agentsOnline && (
|
||||
{!sessionEnded && 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}
|
||||
disabled={!sessionId || sending}
|
||||
>
|
||||
<PictureOutlined className="text-base" />
|
||||
</button>
|
||||
<EmojiPicker
|
||||
onSelect={insertEmoji}
|
||||
disabled={sessionEnded || !sessionId || sending}
|
||||
disabled={!sessionId || sending}
|
||||
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"
|
||||
placement="topLeft"
|
||||
/>
|
||||
@@ -865,87 +970,146 @@ const VisitorChat = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={textInputRef}
|
||||
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
|
||||
{!sessionEnded && (
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={textInputRef}
|
||||
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={
|
||||
!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>
|
||||
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}
|
||||
/>
|
||||
{agentsOnline ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => sendMessage(input)}
|
||||
disabled={!input.trim() || sending || !sessionId}
|
||||
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}
|
||||
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="absolute inset-0 bg-black/40 flex items-center justify-center z-10 p-4">
|
||||
<div className="bg-white rounded-xl p-5 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 className="text-sm text-neutral-400 mb-3">先点亮星级,可补充感受后再提交</div>
|
||||
<div className="flex justify-center gap-1.5 mb-1">
|
||||
{[1, 2, 3, 4, 5].map(star => {
|
||||
const active = star <= (hoverStar || ratingScore)
|
||||
return (
|
||||
<StarFilled
|
||||
key={star}
|
||||
className="text-2xl cursor-pointer transition-colors"
|
||||
style={{ color: active ? '#facc15' : '#e2e8f0' }}
|
||||
onMouseEnter={() => setHoverStar(star)}
|
||||
onMouseLeave={() => setHoverStar(0)}
|
||||
onClick={() => {
|
||||
setRatingScore(star)
|
||||
setSendError('')
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
<div className="text-xs text-neutral-400 mb-3 h-4">
|
||||
{ratingScore > 0 ? `${ratingScore} 星` : '请选择 1~5 星'}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5 justify-center mb-3">
|
||||
{ratingPresets.map(preset => {
|
||||
const selected = ratingText === preset
|
||||
return (
|
||||
<button
|
||||
key={preset}
|
||||
type="button"
|
||||
onClick={() => setRatingText(prev => (prev === preset ? '' : preset))}
|
||||
className={`px-2 py-1 rounded-full text-[11px] border cursor-pointer transition-colors ${
|
||||
selected
|
||||
? 'bg-[#2563eb] border-[#2563eb] text-white'
|
||||
: 'bg-neutral-50 border-neutral-200 text-neutral-600 hover:border-blue-300 hover:text-blue-600'
|
||||
}`}
|
||||
>
|
||||
{preset}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</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"
|
||||
className="w-full rounded-lg border border-neutral-200 p-2 text-sm outline-none focus:border-blue-400 mb-3 resize-none text-left"
|
||||
rows={3}
|
||||
maxLength={500}
|
||||
value={ratingText}
|
||||
onChange={e => setRatingText(e.target.value)}
|
||||
placeholder="可选:写下您的服务感受"
|
||||
placeholder="可选:点击上方标签快速填入,或自行输入"
|
||||
/>
|
||||
<button type="button" onClick={() => setShowRating(false)} className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer">
|
||||
跳过
|
||||
<button
|
||||
type="button"
|
||||
disabled={ratingSubmitting || ratingScore < 1}
|
||||
onClick={() => { void submitRating() }}
|
||||
className="w-full h-10 rounded-xl border-0 bg-[#2563eb] hover:bg-[#1d4ed8] disabled:opacity-40 cursor-pointer text-white text-sm font-medium mb-2"
|
||||
>
|
||||
{ratingSubmitting ? '提交中...' : '提交评价'}
|
||||
</button>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setHoverStar(0)
|
||||
}}
|
||||
className="text-sm text-neutral-400 hover:text-neutral-600 border-0 bg-transparent cursor-pointer"
|
||||
>
|
||||
跳过
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
disabled={sending || ratingSubmitting}
|
||||
onClick={() => {
|
||||
setShowRating(false)
|
||||
setRatingScore(0)
|
||||
setHoverStar(0)
|
||||
void startNewSession()
|
||||
}}
|
||||
className="text-sm text-[#2563eb] hover:text-[#1d4ed8] border-0 bg-transparent cursor-pointer disabled:opacity-50"
|
||||
>
|
||||
跳过并重新咨询
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user