完善访客结束流程与转接可见性

- 会话结束后支持重新咨询,评价改为选星+快捷文案再提交
- 转接/分配事件写入操作人与目标坐席,工作台时间线与备注可见
This commit is contained in:
yml2213
2026-07-15 14:49:55 +08:00
parent 1359bfe597
commit a1336c630b
3 changed files with 422 additions and 125 deletions
+99 -13
View File
@@ -422,7 +422,8 @@ const Dashboard = () => {
if (payload.type === 'session_created' || payload.type === 'session_updated') {
void loadAllRef.current({ silent: true })
if (sameSession) void syncAfterSeqRef.current(sid, { markRead: false })
// 转接/分配/结束等会写 SessionEvent,需全量刷新详情(含 events)
if (sameSession) void loadDetailRef.current(sid, false, { silent: true })
}
} catch {
// ignore malformed frames
@@ -524,7 +525,51 @@ const Dashboard = () => {
return tb - ta
})
const notes = detail?.events.filter(event => event.action === 'note' || event.action === 'offline_leave' || event.action === 'auto_assign').slice().reverse() || []
const timelineEvents = (detail?.events || []).filter(event =>
event.action === 'transfer'
|| event.action === 'assign'
|| event.action === 'auto_assign'
|| event.action === 'end'
|| event.action === 'offline_leave',
)
const notes = detail?.events
.filter(event =>
event.action === 'note'
|| event.action === 'offline_leave'
|| event.action === 'auto_assign'
|| event.action === 'assign'
|| event.action === 'transfer'
|| event.action === 'end',
)
.slice()
.reverse() || []
type ChatTimelineItem =
| { kind: 'message'; at: number; message: Message }
| { kind: 'event'; at: number; event: SessionEvent }
const chatTimeline: ChatTimelineItem[] = (() => {
const items: ChatTimelineItem[] = []
for (const message of detail?.messages || []) {
items.push({ kind: 'message', at: new Date(message.sent_at).getTime(), message })
}
for (const event of timelineEvents) {
items.push({ kind: 'event', at: new Date(event.created_at).getTime(), event })
}
items.sort((a, b) => a.at - b.at || (a.kind === 'event' ? -1 : 1))
return items
})()
const eventLabel = (action: string) => {
switch (action) {
case 'transfer': return '会话转接'
case 'assign': return '人工分配'
case 'auto_assign': return '自动分配'
case 'end': return '结束会话'
case 'offline_leave': return '离线留言'
default: return '系统记录'
}
}
const emitTyping = () => {
if (!selectedId || !canOperate) return
@@ -939,12 +984,29 @@ const Dashboard = () => {
{new Date(selected.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
{(detail?.messages || []).map(message => {
{chatTimeline.map(item => {
if (item.kind === 'event') {
const { event } = item
return (
<div key={`ev-${event.id}`} className="flex justify-center mb-4">
<span className="inline-flex flex-col items-center max-w-[90%] px-3 py-1.5 rounded-lg text-xs text-violet-700 bg-violet-50 border border-violet-100">
<span className="font-medium text-violet-800">{eventLabel(event.action)}</span>
<span className="text-center leading-relaxed mt-0.5">{event.detail || eventLabel(event.action)}</span>
<span className="text-[11px] text-violet-500/80 mt-0.5">
{new Date(event.created_at).toLocaleString('zh-CN', {
month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit',
})}
</span>
</span>
</div>
)
}
const message = item.message
const isAgent = message.sender_type === 'agent'
const visitorInitial = selectedCustomer.name.slice(0, 1)
return (
<div
key={message.id}
key={`msg-${message.id}`}
className={`flex items-start gap-2.5 mb-4 ${isAgent ? 'flex-row-reverse' : ''}`}
>
<div
@@ -976,7 +1038,7 @@ const Dashboard = () => {
</div>
)
})}
{detail?.messages.length === 0 && (
{chatTimeline.length === 0 && (
<div className="text-center text-sm text-neutral-400 py-10"></div>
)}
{visitorTyping && selectedCustomer && (
@@ -1207,18 +1269,42 @@ const Dashboard = () => {
<div className="text-xs text-neutral-400"></div>
) : notes.map(note => {
const isLeave = note.action === 'offline_leave'
const isAssign = note.action === 'auto_assign'
const isAuto = note.action === 'auto_assign'
const isTransfer = note.action === 'transfer'
const isAssign = note.action === 'assign'
const isEnd = note.action === 'end'
const box = isLeave
? 'bg-orange-50 text-orange-900 border-orange-100'
: isAssign
? 'bg-blue-50 text-blue-900 border-blue-100'
: 'bg-amber-50 text-amber-900 border-amber-100'
const timeCls = isLeave ? 'text-orange-600/70' : isAssign ? 'text-blue-600/70' : 'text-amber-600/70'
: isTransfer
? 'bg-violet-50 text-violet-900 border-violet-100'
: (isAuto || isAssign)
? 'bg-blue-50 text-blue-900 border-blue-100'
: isEnd
? 'bg-neutral-100 text-neutral-700 border-neutral-200'
: 'bg-amber-50 text-amber-900 border-amber-100'
const timeCls = isLeave
? 'text-orange-600/70'
: isTransfer
? 'text-violet-600/70'
: (isAuto || isAssign)
? 'text-blue-600/70'
: isEnd
? 'text-neutral-500'
: 'text-amber-600/70'
const title = isLeave
? '离线留言'
: isTransfer
? '会话转接'
: isAuto
? '自动分配'
: isAssign
? '人工分配'
: isEnd
? '结束会话'
: null
return (
<div key={note.id} className={`rounded-lg p-2 text-xs whitespace-pre-wrap border ${box}`}>
{(isLeave || isAssign) && (
<div className="font-medium mb-0.5">{isLeave ? '离线留言' : '自动分配'}</div>
)}
{title && <div className="font-medium mb-0.5">{title}</div>}
{note.detail}
<div className={`mt-1 ${timeCls}`}>{new Date(note.created_at).toLocaleString('zh-CN')}</div>
</div>
+271 -107
View File
@@ -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}` : '请选择 15 星'}
</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>
)}