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 (
+
+ )
+ })}
)}