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

- 会话结束后支持重新咨询,评价改为选星+快捷文案再提交
- 转接/分配事件写入操作人与目标坐席,工作台时间线与备注可见
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
+52 -5
View File
@@ -594,11 +594,39 @@ func (h *SessionHandler) Assign(c *gin.Context) {
return return
} }
model.DB.Create(&model.SessionEvent{SessionID: session.ID, OperatorID: middleware.GetUserID(c), Action: "assign", Detail: "会话分配"}) operatorID := middleware.GetUserID(c)
operatorName := userDisplayName(operatorID)
targetName := userDisplayName(req.AgentID)
detail := "会话分配给 " + targetName
if operatorID == req.AgentID {
detail = operatorName + " 领取了会话"
} else {
detail = operatorName + " 将会话分配给 " + targetName
}
model.DB.Create(&model.SessionEvent{
SessionID: session.ID,
OperatorID: operatorID,
Action: "assign",
Detail: detail,
})
session.AgentID = &req.AgentID session.AgentID = &req.AgentID
session.Status = "active" session.Status = "active"
broadcastSessionUpdate(session) broadcastSessionUpdate(session)
middleware.JSON(c, gin.H{"message": "分配成功"}) middleware.JSON(c, gin.H{"message": "分配成功", "detail": detail})
}
func userDisplayName(userID uint) string {
var user model.User
if err := model.DB.Select("id", "nickname", "username").First(&user, userID).Error; err != nil {
return "未知坐席"
}
if strings.TrimSpace(user.Nickname) != "" {
return user.Nickname
}
if strings.TrimSpace(user.Username) != "" {
return user.Username
}
return "未知坐席"
} }
func (h *SessionHandler) Transfer(c *gin.Context) { func (h *SessionHandler) Transfer(c *gin.Context) {
@@ -621,6 +649,19 @@ func (h *SessionHandler) Transfer(c *gin.Context) {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"}) c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"})
return return
} }
if session.AgentID != nil && *session.AgentID == req.AgentID {
c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "不能转接给当前接待坐席"})
return
}
operatorID := middleware.GetUserID(c)
operatorName := userDisplayName(operatorID)
targetName := userDisplayName(req.AgentID)
fromName := ""
if session.AgentID != nil {
fromName = userDisplayName(*session.AgentID)
}
result := model.DB.Model(&model.Session{}). result := model.DB.Model(&model.Session{}).
Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active"). Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active").
Updates(map[string]interface{}{"agent_id": req.AgentID, "last_read_seq": 0}) Updates(map[string]interface{}{"agent_id": req.AgentID, "last_read_seq": 0})
@@ -633,16 +674,22 @@ func (h *SessionHandler) Transfer(c *gin.Context) {
return return
} }
// 详情写明「谁 → 谁」,便于接手坐席在备注/时间线中一眼看清
detail := operatorName + " 转接给 " + targetName
if fromName != "" && fromName != operatorName {
detail = operatorName + " 将会话由 " + fromName + " 转接给 " + targetName
}
model.DB.Create(&model.SessionEvent{ model.DB.Create(&model.SessionEvent{
SessionID: session.ID, SessionID: session.ID,
OperatorID: middleware.GetUserID(c), OperatorID: operatorID,
Action: "transfer", Action: "transfer",
Detail: "会话转接", Detail: detail,
}) })
session.AgentID = &req.AgentID session.AgentID = &req.AgentID
broadcastSessionUpdate(session) broadcastSessionUpdate(session)
middleware.JSON(c, gin.H{"message": "转接成功"}) middleware.JSON(c, gin.H{"message": "转接成功", "detail": detail})
} }
func (h *SessionHandler) End(c *gin.Context) { func (h *SessionHandler) End(c *gin.Context) {
+99 -13
View File
@@ -422,7 +422,8 @@ const Dashboard = () => {
if (payload.type === 'session_created' || payload.type === 'session_updated') { if (payload.type === 'session_created' || payload.type === 'session_updated') {
void loadAllRef.current({ silent: true }) 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 { } catch {
// ignore malformed frames // ignore malformed frames
@@ -524,7 +525,51 @@ const Dashboard = () => {
return tb - ta 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 = () => { const emitTyping = () => {
if (!selectedId || !canOperate) return 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' })} {new Date(selected.created_at).toLocaleString('zh-CN', { month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit' })}
</span> </span>
</div> </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 isAgent = message.sender_type === 'agent'
const visitorInitial = selectedCustomer.name.slice(0, 1) const visitorInitial = selectedCustomer.name.slice(0, 1)
return ( return (
<div <div
key={message.id} key={`msg-${message.id}`}
className={`flex items-start gap-2.5 mb-4 ${isAgent ? 'flex-row-reverse' : ''}`} className={`flex items-start gap-2.5 mb-4 ${isAgent ? 'flex-row-reverse' : ''}`}
> >
<div <div
@@ -976,7 +1038,7 @@ const Dashboard = () => {
</div> </div>
) )
})} })}
{detail?.messages.length === 0 && ( {chatTimeline.length === 0 && (
<div className="text-center text-sm text-neutral-400 py-10"></div> <div className="text-center text-sm text-neutral-400 py-10"></div>
)} )}
{visitorTyping && selectedCustomer && ( {visitorTyping && selectedCustomer && (
@@ -1207,18 +1269,42 @@ const Dashboard = () => {
<div className="text-xs text-neutral-400"></div> <div className="text-xs text-neutral-400"></div>
) : notes.map(note => { ) : notes.map(note => {
const isLeave = note.action === 'offline_leave' 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 const box = isLeave
? 'bg-orange-50 text-orange-900 border-orange-100' ? 'bg-orange-50 text-orange-900 border-orange-100'
: isAssign : isTransfer
? 'bg-blue-50 text-blue-900 border-blue-100' ? 'bg-violet-50 text-violet-900 border-violet-100'
: 'bg-amber-50 text-amber-900 border-amber-100' : (isAuto || isAssign)
const timeCls = isLeave ? 'text-orange-600/70' : isAssign ? 'text-blue-600/70' : 'text-amber-600/70' ? '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 ( return (
<div key={note.id} className={`rounded-lg p-2 text-xs whitespace-pre-wrap border ${box}`}> <div key={note.id} className={`rounded-lg p-2 text-xs whitespace-pre-wrap border ${box}`}>
{(isLeave || isAssign) && ( {title && <div className="font-medium mb-0.5">{title}</div>}
<div className="font-medium mb-0.5">{isLeave ? '离线留言' : '自动分配'}</div>
)}
{note.detail} {note.detail}
<div className={`mt-1 ${timeCls}`}>{new Date(note.created_at).toLocaleString('zh-CN')}</div> <div className={`mt-1 ${timeCls}`}>{new Date(note.created_at).toLocaleString('zh-CN')}</div>
</div> </div>
+271 -107
View File
@@ -54,8 +54,24 @@ const VisitorChat = ({
const [rated, setRated] = useState(false) const [rated, setRated] = useState(false)
const [sessionEnded, setSessionEnded] = useState(false) const [sessionEnded, setSessionEnded] = useState(false)
const [agentTyping, setAgentTyping] = useState(false) const [agentTyping, setAgentTyping] = useState(false)
const [ratingScore, setRatingScore] = useState(0)
const [ratingText, setRatingText] = useState('') const [ratingText, setRatingText] = useState('')
const [hoverStar, setHoverStar] = useState(0) 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 [pendingImage, setPendingImage] = useState<{ file: File; preview: string } | null>(null)
const [sendError, setSendError] = useState('') const [sendError, setSendError] = useState('')
const [agentsOnline, setAgentsOnline] = useState(true) const [agentsOnline, setAgentsOnline] = useState(true)
@@ -256,9 +272,96 @@ const VisitorChat = ({
return true 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 () => { const initSession = useCallback(async () => {
if (sessionId && visitorToken) { if (sessionId && visitorToken) {
// 本地有 seq 游标时增量补齐,否则全量 // 本地有 seq 游标时增量补齐,否则全量(若已结束会由 loadMessages 带回状态)
const after = lastSeqRef.current const after = lastSeqRef.current
await loadMessages(sessionId, visitorToken, after > 0 ? { afterSeq: after } : { full: true }) await loadMessages(sessionId, visitorToken, after > 0 ? { afterSeq: after } : { full: true })
return return
@@ -266,38 +369,13 @@ const VisitorChat = ({
if (initRef.current) return if (initRef.current) return
initRef.current = true initRef.current = true
try { try {
const res = await fetch(`/api/widget/init?channel_key=${encodeURIComponent(channelKey)}&visitor_name=${encodeURIComponent('访客')}`, { await bootstrapNewSession()
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 || '初始化会话失败')
}
} catch (e) { } catch (e) {
console.error('Init failed:', 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(() => { useEffect(() => {
sessionIdRef.current = sessionId sessionIdRef.current = sessionId
@@ -622,24 +700,38 @@ const VisitorChat = ({
notifyHost('kefu-widget-minimize') notifyHost('kefu-widget-minimize')
} }
const submitRating = async (score: number) => { const submitRating = async () => {
if (!sessionId || !visitorToken || rated) return if (!sessionId || !visitorToken || rated || ratingSubmitting) return
if (ratingScore < 1 || ratingScore > 5) {
setSendError('请先点击星星选择评分')
return
}
setRatingSubmitting(true)
setSendError('')
try { try {
const res = await fetch('/api/widget/rating', { const res = await fetch('/api/widget/rating', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'X-Visitor-Token': visitorToken }, 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() const json = await res.json()
if (json.code === 0) { if (json.code === 0) {
setRated(true) setRated(true)
setShowRating(false) setShowRating(false)
setRatingScore(0)
setRatingText('')
setSendError('') setSendError('')
} else { } else {
setSendError(json.message || '评价提交失败') setSendError(json.message || '评价提交失败')
} }
} catch { } catch {
setSendError('评价提交失败,请稍后重试') 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"> <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>} {sendError && <div className="mb-2 text-xs text-red-500">{sendError}</div>}
{sessionEnded && !rated && ( {sessionEnded && (
<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"> <div className="mb-3 space-y-2">
<span className="text-xs text-amber-800"></span> {!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 <button
type="button" type="button"
onClick={() => setShowRating(true)} onClick={() => { void startNewSession() }}
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" 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> </button>
<p className="m-0 text-center text-[11px] text-neutral-400"></p>
</div> </div>
)} )}
@@ -820,7 +925,7 @@ const VisitorChat = ({
</div> </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"> <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" /> <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> <div className="flex-1 min-w-0 text-xs text-neutral-500"> WebP</div>
@@ -838,20 +943,20 @@ const VisitorChat = ({
</div> </div>
)} )}
{agentsOnline && ( {!sessionEnded && agentsOnline && (
<div className="flex items-center gap-2 mb-2"> <div className="flex items-center gap-2 mb-2">
<button <button
type="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" 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="发送图片" aria-label="发送图片"
onClick={() => fileInputRef.current?.click()} onClick={() => fileInputRef.current?.click()}
disabled={sessionEnded || !sessionId || sending} disabled={!sessionId || sending}
> >
<PictureOutlined className="text-base" /> <PictureOutlined className="text-base" />
</button> </button>
<EmojiPicker <EmojiPicker
onSelect={insertEmoji} 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" 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" placement="topLeft"
/> />
@@ -865,87 +970,146 @@ const VisitorChat = ({
</div> </div>
)} )}
<div className="flex items-center gap-2"> {!sessionEnded && (
<input <div className="flex items-center gap-2">
ref={textInputRef} <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]" ref={textInputRef}
placeholder={ 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]"
sessionEnded placeholder={
? '会话已结束' !sessionId
: !sessionId
? '正在连接...' ? '正在连接...'
: agentsOnline : 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())
} }
}} value={input}
onKeyDown={e => { onChange={e => { setInput(e.target.value); if (agentsOnline) emitTyping() }}
if (e.key === 'Enter') { onPaste={e => {
if (agentsOnline) sendMessage(input) if (!agentsOnline) return
} const item = Array.from(e.clipboardData.items).find(i => i.type.startsWith('image/'))
}} if (item) {
disabled={sending || !sessionId || sessionEnded} e.preventDefault()
/> handleImageFile(item.getAsFile())
{agentsOnline ? ( }
<button }}
type="button" onKeyDown={e => {
onClick={() => sendMessage(input)} if (e.key === 'Enter') {
disabled={!input.trim() || sending || !sessionId || sessionEnded} if (agentsOnline) sendMessage(input)
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="发送" }}
> disabled={sending || !sessionId}
<SendOutlined className="text-white text-sm" /> />
</button> {agentsOnline ? (
) : ( <button
<button type="button"
type="button" onClick={() => sendMessage(input)}
onClick={submitLeaveMessage} disabled={!input.trim() || sending || !sessionId}
disabled={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"
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" aria-label="发送"
> >
{leaveSent ? '再留言' : '提交留言'} <SendOutlined className="text-white text-sm" />
</button> </button>
)} ) : (
</div> <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> </footer>
{showRating && ( {showRating && (
<div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10"> <div className="absolute inset-0 bg-black/40 flex items-center justify-center z-10 p-4">
<div className="bg-white rounded-xl p-6 mx-8 w-full max-w-xs text-center shadow-lg"> <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-lg font-semibold text-neutral-800 mb-1"></div>
<div className="text-sm text-neutral-400 mb-4"></div> <div className="text-sm text-neutral-400 mb-3"></div>
<div className="flex justify-center gap-1.5 mb-4"> <div className="flex justify-center gap-1.5 mb-1">
{[1, 2, 3, 4, 5].map(star => ( {[1, 2, 3, 4, 5].map(star => {
<StarFilled const active = star <= (hoverStar || ratingScore)
key={star} return (
className="text-2xl cursor-pointer transition-colors" <StarFilled
style={{ color: star <= hoverStar ? '#facc15' : '#e2e8f0' }} key={star}
onMouseEnter={() => setHoverStar(star)} className="text-2xl cursor-pointer transition-colors"
onMouseLeave={() => setHoverStar(0)} style={{ color: active ? '#facc15' : '#e2e8f0' }}
onClick={() => submitRating(star)} 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> </div>
<textarea <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} rows={3}
maxLength={500} maxLength={500}
value={ratingText} value={ratingText}
onChange={e => setRatingText(e.target.value)} 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> </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>
</div> </div>
)} )}