feat(chat): 访客与客服聊天界面、历史拉取与断线重连
聊天页支持发送确认、历史补拉和重连;刷新后不重复展示已确认消息。
This commit is contained in:
1 parent
ef9fee1585
commit
4ff934ad98
19 files changed
+6252
No files matched your search
@@ -0,0 +1,449 @@
|
||||
/*
|
||||
Copyright 2024-2026 ReplyPier
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
|
||||
import type { ChatDetail, Message } from '@/types/chat'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
import { chatService } from '@/services/chat'
|
||||
import { userService } from '@/services/user'
|
||||
import { socketService } from '@/services/socket'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { canRequestRating, endChatMessage as endChatMessageFor } from '@/utils/endChat'
|
||||
import { canTakeOverChat, chatHandler } from '@/utils/chatState'
|
||||
import { routeChatToHuman } from '@/utils/chatActions'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
import { findMessage, newClientId } from '@/utils/messageMatch'
|
||||
import { useOrgInboxRoom } from '@/composables/useOrgInboxRoom'
|
||||
|
||||
// Define valid chat statuses
|
||||
type ChatStatus = 'open' | 'closed' | 'transferred'
|
||||
|
||||
/** Emitted by the backend when a reply was saved but never reached the customer. */
|
||||
interface DeliveryErrorEvent {
|
||||
error?: string
|
||||
type?: string
|
||||
session_id?: string
|
||||
can_template?: boolean
|
||||
client_id?: string | null
|
||||
message_id?: number
|
||||
}
|
||||
|
||||
/** Tells the sender which stored row their message became. */
|
||||
interface AgentMessageStoredEvent {
|
||||
session_id?: string
|
||||
client_id?: string | null
|
||||
message_id?: number
|
||||
}
|
||||
|
||||
/** A channel's delivery receipt for a stored message (sent → delivered → read). */
|
||||
interface MessageStatusEvent {
|
||||
session_id?: string
|
||||
message_id?: number
|
||||
external_message_id?: string | null
|
||||
channel_status?: Message['channel_status']
|
||||
delivery_status?: string | null
|
||||
}
|
||||
|
||||
export function useConversationChat(
|
||||
initialChat: ChatDetail,
|
||||
emit: {
|
||||
(event: 'refresh'): void
|
||||
(event: 'chatUpdated', data: ChatDetail): void
|
||||
(event: 'clearUnread', sessionId: string): void
|
||||
}
|
||||
) {
|
||||
const chat = ref<ChatDetail>(initialChat)
|
||||
// Delivery receipts for chats no agent owns arrive through the org room
|
||||
useOrgInboxRoom()
|
||||
const newMessage = ref('')
|
||||
const messagesContainer = ref<HTMLElement | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const currentUserId = userService.getUserId()
|
||||
// Set once a send is refused because the messaging window closed and a
|
||||
// template would reopen it. The frontend can't tell the window has expired on
|
||||
// its own — it never sees the customer's last inbound time — so this is only
|
||||
// known after the backend refuses a send.
|
||||
const templateCanReopen = ref(false)
|
||||
|
||||
// Claimable and allowed to claim. Shared with ChatInfoPanel via
|
||||
// canTakeOverChat so the chat screen and the info panel can't disagree —
|
||||
// this pane used to require status 'transferred', which left an AI-handled
|
||||
// chat claimable only from the info panel.
|
||||
const showTakeoverButton = computed(
|
||||
() => canTakeOverChat(chat.value) && permissionChecks.canTakeOverChats()
|
||||
)
|
||||
|
||||
// Who is answering the customer. Derived once, shared with the inbox list and
|
||||
// the info panel through chatHandler, so the three surfaces cannot disagree.
|
||||
const handler = computed(() => chatHandler(chat.value, currentUserId))
|
||||
|
||||
const handledByAI = computed(() => handler.value.kind === 'ai')
|
||||
|
||||
// Nobody is answering yet: the AI queued it, or this agent never uses AI.
|
||||
const isWaitingForHuman = computed(() => handler.value.kind === 'waiting')
|
||||
|
||||
const isChatClosed = computed(() => handler.value.kind === 'closed')
|
||||
|
||||
// A colleague holds it: we can watch, but not reply.
|
||||
const showTakenOverStatus = computed(
|
||||
() => handler.value.kind === 'human' && chat.value.user_id !== currentUserId
|
||||
)
|
||||
|
||||
/** One sentence explaining why the composer is unavailable, per handler. */
|
||||
const handlerCaption = computed(() => {
|
||||
switch (handler.value.kind) {
|
||||
case 'ai':
|
||||
return '此会话正由 AI 客服处理'
|
||||
case 'waiting':
|
||||
// Deliberately not "the AI handed this over": with AI switched off on
|
||||
// the agent there was never an AI in the conversation to hand it on.
|
||||
return '此会话正在等待客服接手'
|
||||
case 'closed':
|
||||
return '此会话已结束'
|
||||
default:
|
||||
return `此会话正由 ${handler.value.label} 处理`
|
||||
}
|
||||
})
|
||||
|
||||
const canSendMessage = computed(() => {
|
||||
// Cannot send if chat is closed
|
||||
if (isChatClosed.value) return false
|
||||
|
||||
// Cannot send if needs takeover or taken by another user
|
||||
return !showTakeoverButton.value && !showTakenOverStatus.value
|
||||
})
|
||||
|
||||
const scrollToBottom = async () => {
|
||||
// Wait for the new message to render, otherwise scrollHeight is still the
|
||||
// pre-append height and the list stops short of the bottom.
|
||||
await nextTick()
|
||||
if (messagesContainer.value) {
|
||||
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for changes in messages and scroll to bottom
|
||||
watch(() => chat.value.messages, (messages) => {
|
||||
scrollToBottom()
|
||||
// A customer message reopens the messaging window, so the closed-window
|
||||
// notice must go — otherwise it sits above a composer that now works.
|
||||
if (messages?.[messages.length - 1]?.message_type === 'user') {
|
||||
templateCanReopen.value = false
|
||||
}
|
||||
}, { deep: true })
|
||||
|
||||
// The same composable instance serves whichever chat is selected, so a
|
||||
// closed window learned about in one conversation must not follow us to the
|
||||
// next. Sending a template reopens the window, which also clears it.
|
||||
watch(() => chat.value.session_id, () => {
|
||||
templateCanReopen.value = false
|
||||
})
|
||||
|
||||
const clearTemplateSuggestion = () => {
|
||||
templateCanReopen.value = false
|
||||
}
|
||||
|
||||
const updateChat = (newChat: ChatDetail) => {
|
||||
chat.value = { ...newChat }
|
||||
// Emit event to clear unreads
|
||||
emit('clearUnread', newChat.session_id)
|
||||
// Emit chatUpdated event to ensure the parent component updates
|
||||
emit('chatUpdated', chat.value)
|
||||
}
|
||||
|
||||
// Replace chat state from parent props without emitting events
|
||||
const replaceChatFromProps = (newChat: ChatDetail) => {
|
||||
chat.value = { ...newChat }
|
||||
}
|
||||
|
||||
const sendMessage = async () => {
|
||||
if (!newMessage.value.trim() || !canSendMessage.value) return
|
||||
|
||||
try {
|
||||
const messageText = newMessage.value
|
||||
// Clear input immediately for better UX
|
||||
newMessage.value = ''
|
||||
|
||||
// Add message locally first
|
||||
const timestamp = new Date().toISOString()
|
||||
// Ties this optimistic message to the row the backend stores, so its
|
||||
// delivery receipts and errors find it exactly (agent_message_stored)
|
||||
const clientId = newClientId()
|
||||
const localMessage: Message = {
|
||||
message: messageText,
|
||||
message_type: 'agent',
|
||||
created_at: timestamp,
|
||||
session_id: chat.value.session_id,
|
||||
client_id: clientId
|
||||
}
|
||||
|
||||
// Update chat with new message
|
||||
chat.value.messages.push(localMessage)
|
||||
chat.value.updated_at = timestamp
|
||||
|
||||
// Emit message through socket
|
||||
socketService.emit('agent_message', {
|
||||
message: messageText,
|
||||
session_id: chat.value.session_id,
|
||||
message_type: 'agent',
|
||||
created_at: timestamp,
|
||||
client_id: clientId
|
||||
})
|
||||
|
||||
scrollToBottom()
|
||||
} catch (err) {
|
||||
console.error('Failed to send message:', err)
|
||||
toast.error('消息发送失败', {
|
||||
description: '请重试',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleTakeover = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
await chatService.takeoverChat(chat.value.session_id)
|
||||
|
||||
// Show success toast
|
||||
toast.success('已接管会话', {
|
||||
description: '现在可以在本会话中发送消息',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
const userName = userService.getUserName()
|
||||
const userId = userService.getUserId()
|
||||
|
||||
// Update local chat state
|
||||
chat.value = {
|
||||
...chat.value,
|
||||
status: 'open',
|
||||
user_id: userId,
|
||||
user_name: userName
|
||||
}
|
||||
|
||||
socketService.emit('taken_over', { session_id: chat.value.session_id, user_name: userName, profile_picture: userService.getCurrentUser()?.profile_pic ? userService.getCurrentUser()?.profile_pic : '' })
|
||||
|
||||
// Emit refresh event to update chat status
|
||||
emit('refresh')
|
||||
// Also emit chatUpdated event to ensure the parent component updates
|
||||
emit('chatUpdated', chat.value)
|
||||
} catch (err: any) {
|
||||
console.error('Failed to takeover chat:', err)
|
||||
|
||||
// Show error toast with specific message if available
|
||||
toast.error('接管会话失败', {
|
||||
description: err.response?.data?.detail || '请重试',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRouteToHuman = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const updated = await routeChatToHuman(chat.value.session_id)
|
||||
if (!updated) return
|
||||
|
||||
// The endpoint returns the whole thread back, so take its state rather
|
||||
// than guessing at a local patch.
|
||||
chat.value = updated
|
||||
emit('refresh')
|
||||
emit('chatUpdated', chat.value)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Add endChat function
|
||||
const endChat = async (requestRating = true) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
|
||||
// Only the web widget can render a rating; on external channels the ask
|
||||
// is dropped and the closing message says nothing about rating.
|
||||
const askRating = requestRating && canRequestRating(chat.value.channel)
|
||||
|
||||
// Create end chat message
|
||||
const timestamp = new Date().toISOString()
|
||||
const clientId = newClientId()
|
||||
const endChatMessage = {
|
||||
message: endChatMessageFor(chat.value.channel),
|
||||
message_type: "system",
|
||||
created_at: timestamp,
|
||||
session_id: chat.value.session_id,
|
||||
client_id: clientId,
|
||||
end_chat: true,
|
||||
request_rating: askRating,
|
||||
end_chat_reason: "AGENT_REQUEST",
|
||||
end_chat_description: "Agent manually ended the chat"
|
||||
|
||||
}
|
||||
|
||||
// Add message locally
|
||||
chat.value.messages.push(endChatMessage)
|
||||
|
||||
// Update chat status locally
|
||||
chat.value.status = 'closed'
|
||||
chat.value.updated_at = timestamp
|
||||
|
||||
// Emit message through socket to end chat
|
||||
socketService.emit('agent_message', {
|
||||
message: endChatMessage.message,
|
||||
session_id: chat.value.session_id,
|
||||
message_type: endChatMessage.message_type,
|
||||
created_at: timestamp,
|
||||
client_id: clientId,
|
||||
end_chat: true,
|
||||
request_rating: askRating,
|
||||
end_chat_reason: "AGENT_REQUEST",
|
||||
end_chat_description: "Agent manually ended the chat"
|
||||
})
|
||||
|
||||
// Show success toast
|
||||
toast.success('会话已结束', {
|
||||
description: askRating ? '将邀请客户进行评价' : '会话已关闭',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
|
||||
// Emit refresh event to update chat status
|
||||
emit('refresh')
|
||||
emit('chatUpdated', chat.value)
|
||||
|
||||
scrollToBottom()
|
||||
} catch (err) {
|
||||
console.error('Failed to end chat:', err)
|
||||
toast.error('结束会话失败', {
|
||||
description: '请重试',
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// On external channels a send can fail after the message is already stored
|
||||
// (typically once WhatsApp's 24h window closes). Without this the agent sees
|
||||
// their message sitting in the thread and assumes it arrived. The event names
|
||||
// the exact message; one not on screen is left for the next refetch.
|
||||
const handleDeliveryError = (data: DeliveryErrorEvent) => {
|
||||
if (data?.type !== 'delivery_error' || data.session_id !== chat.value.session_id) return
|
||||
const failed = findMessage(chat.value.messages, { messageId: data.message_id, clientId: data.client_id })
|
||||
if (failed) {
|
||||
failed.attributes = { ...failed.attributes, delivery_status: 'failed' }
|
||||
}
|
||||
if (data.can_template) templateCanReopen.value = true
|
||||
toast.error('消息未送达', {
|
||||
description: data.error,
|
||||
duration: 6000,
|
||||
closeButton: true
|
||||
})
|
||||
}
|
||||
|
||||
// An optimistic message learns its row id, which receipts are keyed by
|
||||
const handleAgentMessageStored = (data: AgentMessageStoredEvent) => {
|
||||
if (data?.session_id !== chat.value.session_id || data.message_id == null) return
|
||||
const message = findMessage(chat.value.messages, { clientId: data.client_id })
|
||||
if (message) message.id = data.message_id
|
||||
}
|
||||
|
||||
const handleMessageStatus = (data: MessageStatusEvent) => {
|
||||
if (data?.session_id !== chat.value.session_id) return
|
||||
const message = findMessage(chat.value.messages, {
|
||||
messageId: data.message_id,
|
||||
externalId: data.external_message_id
|
||||
})
|
||||
if (!message) return
|
||||
message.external_message_id = data.external_message_id ?? message.external_message_id
|
||||
message.channel_status = data.channel_status
|
||||
if (data.delivery_status) {
|
||||
message.attributes = { ...message.attributes, delivery_status: data.delivery_status }
|
||||
}
|
||||
}
|
||||
|
||||
const setupSocketListeners = () => {
|
||||
socketService.on('error', handleDeliveryError)
|
||||
socketService.on('agent_message_stored', handleAgentMessageStored)
|
||||
socketService.on('message_status', handleMessageStatus)
|
||||
}
|
||||
|
||||
const cleanupSocketListeners = () => {
|
||||
socketService.off('error', handleDeliveryError)
|
||||
socketService.off('agent_message_stored', handleAgentMessageStored)
|
||||
socketService.off('message_status', handleMessageStatus)
|
||||
}
|
||||
|
||||
const handleSocketReconnect = () => {
|
||||
cleanupSocketListeners()
|
||||
setupSocketListeners()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
setupSocketListeners()
|
||||
socketService.onReconnect(handleSocketReconnect)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanupSocketListeners()
|
||||
socketService.offReconnect(handleSocketReconnect)
|
||||
})
|
||||
|
||||
const formattedMessages = computed(() => {
|
||||
const formatted = chat.value.messages.map(msg => ({
|
||||
...msg,
|
||||
timeAgo: formatDistanceToNow(new Date(msg.created_at), { addSuffix: true, locale: zhCN })
|
||||
}))
|
||||
|
||||
// Debug: Log messages with attachments
|
||||
formatted.forEach((msg, idx) => {
|
||||
if (msg.attachments && msg.attachments.length > 0) {
|
||||
console.log(`[Message ${idx}] Has ${msg.attachments.length} attachments:`, msg.attachments)
|
||||
}
|
||||
})
|
||||
|
||||
return formatted
|
||||
})
|
||||
|
||||
return {
|
||||
newMessage,
|
||||
messagesContainer,
|
||||
formattedMessages,
|
||||
isLoading,
|
||||
showTakeoverButton,
|
||||
showTakenOverStatus,
|
||||
isChatClosed,
|
||||
canSendMessage,
|
||||
scrollToBottom,
|
||||
sendMessage,
|
||||
handleTakeover,
|
||||
handleRouteToHuman,
|
||||
isWaitingForHuman,
|
||||
handlerCaption,
|
||||
updateChat,
|
||||
replaceChatFromProps,
|
||||
handledByAI,
|
||||
endChat,
|
||||
templateCanReopen,
|
||||
clearTemplateSuggestion
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user