完善访客结束流程与转接可见性
- 会话结束后支持重新咨询,评价改为选星+快捷文案再提交 - 转接/分配事件写入操作人与目标坐席,工作台时间线与备注可见
This commit is contained in:
@@ -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) {
|
||||||
|
|||||||
@@ -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
@@ -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} 星` : '请选择 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>
|
</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>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user