diff --git a/server/internal/handler/session.go b/server/internal/handler/session.go index d9b8562..d20f2e3 100644 --- a/server/internal/handler/session.go +++ b/server/internal/handler/session.go @@ -594,11 +594,39 @@ func (h *SessionHandler) Assign(c *gin.Context) { 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.Status = "active" 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) { @@ -621,6 +649,19 @@ func (h *SessionHandler) Transfer(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"code": 400, "message": "目标客服不存在或不可用"}) 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{}). Where("id = ? AND tenant_id = ? AND status = ?", session.ID, session.TenantID, "active"). Updates(map[string]interface{}{"agent_id": req.AgentID, "last_read_seq": 0}) @@ -633,16 +674,22 @@ func (h *SessionHandler) Transfer(c *gin.Context) { return } + // 详情写明「谁 → 谁」,便于接手坐席在备注/时间线中一眼看清 + detail := operatorName + " 转接给 " + targetName + if fromName != "" && fromName != operatorName { + detail = operatorName + " 将会话由 " + fromName + " 转接给 " + targetName + } + model.DB.Create(&model.SessionEvent{ SessionID: session.ID, - OperatorID: middleware.GetUserID(c), + OperatorID: operatorID, Action: "transfer", - Detail: "会话转接", + Detail: detail, }) session.AgentID = &req.AgentID broadcastSessionUpdate(session) - middleware.JSON(c, gin.H{"message": "转接成功"}) + middleware.JSON(c, gin.H{"message": "转接成功", "detail": detail}) } func (h *SessionHandler) End(c *gin.Context) { diff --git a/web/src/pages/agent/Dashboard.tsx b/web/src/pages/agent/Dashboard.tsx index f520d1d..e320a4d 100644 --- a/web/src/pages/agent/Dashboard.tsx +++ b/web/src/pages/agent/Dashboard.tsx @@ -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' })} - {(detail?.messages || []).map(message => { + {chatTimeline.map(item => { + if (item.kind === 'event') { + const { event } = item + return ( +
+ + {eventLabel(event.action)} + {event.detail || eventLabel(event.action)} + + {new Date(event.created_at).toLocaleString('zh-CN', { + month: 'numeric', day: 'numeric', hour: '2-digit', minute: '2-digit', second: '2-digit', + })} + + +
+ ) + } + const message = item.message const isAgent = message.sender_type === 'agent' const visitorInitial = selectedCustomer.name.slice(0, 1) return (
{
) })} - {detail?.messages.length === 0 && ( + {chatTimeline.length === 0 && (
暂无消息,开始对话吧
)} {visitorTyping && selectedCustomer && ( @@ -1207,18 +1269,42 @@ const Dashboard = () => {
暂无内部备注
) : 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 (
- {(isLeave || isAssign) && ( -
{isLeave ? '离线留言' : '自动分配'}
- )} + {title &&
{title}
} {note.detail}
{new Date(note.created_at).toLocaleString('zh-CN')}
diff --git a/web/src/widgets/VisitorChat.tsx b/web/src/widgets/VisitorChat.tsx index 5587fbc..a186f96 100644 --- a/web/src/widgets/VisitorChat.tsx +++ b/web/src/widgets/VisitorChat.tsx @@ -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 = ({
+ )} {showRating && ( -
-
+
+
本次服务如何?
-
请对我们的服务进行评价
-
- {[1, 2, 3, 4, 5].map(star => ( - setHoverStar(star)} - onMouseLeave={() => setHoverStar(0)} - onClick={() => submitRating(star)} - /> - ))} +
先点亮星级,可补充感受后再提交
+
+ {[1, 2, 3, 4, 5].map(star => { + const active = star <= (hoverStar || ratingScore) + return ( + setHoverStar(star)} + onMouseLeave={() => setHoverStar(0)} + onClick={() => { + setRatingScore(star) + setSendError('') + }} + /> + ) + })} +
+
+ {ratingScore > 0 ? `${ratingScore} 星` : '请选择 1~5 星'} +
+
+ {ratingPresets.map(preset => { + const selected = ratingText === preset + return ( + + ) + })}