Files
replypier/frontend/src/components/conversations/ChatInfoPanel.vue
T
WangShen007 4388e1d203 feat(chat): 访客与客服聊天界面、历史拉取与断线重连
聊天页支持发送确认、历史补拉和重连;刷新后不重复展示已确认消息。
2026-09-27 21:10:00 +08:00

827 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!--
Copyright 2024-2026 ChatterMate
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.
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ChatDetail } from '@/types/chat'
import { chatService } from '@/services/chat'
import { userService } from '@/services/user'
import { socketService } from '@/services/socket'
import { toast } from 'vue-sonner'
import LinkedTicketCard from '@/components/tickets/LinkedTicketCard.vue'
import { permissionChecks } from '@/utils/permissions'
import { canRequestRating, endChatMessage as endChatMessageFor } from '@/utils/endChat'
import { getInitials } from '@/utils/text'
import type { Teammate } from '@/services/users'
import { canTakeOverChat, chatAssignee, chatHandler } from '@/utils/chatState'
import { routeChatToHuman } from '@/utils/chatActions'
const canViewTickets = permissionChecks.canViewTickets()
interface Props {
chatInfo: ChatDetail | null
users: Teammate[]
isLoading?: boolean
}
interface Emits {
(e: 'close'): void
(e: 'refresh'): void
(e: 'chatUpdated', chatInfo: ChatDetail): void
(e: 'chatClosed', sessionId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const showEndChatConfirm = ref(false)
const actionLoading = ref(false)
const reassigning = ref(false)
const showReassign = ref(false)
const selectedUserId = ref('')
// Reassigning is a manage-chat action; the parent supplies the teammate list.
const canReassign = permissionChecks.canTakeOverChats()
// Chat action functions
const currentUserId = userService.getUserId()
// Only the web widget can render a rating; on external channels (WhatsApp,
// Messenger, ...) the ask is dropped, so the confirmation must not promise it.
const askRating = computed(() => canRequestRating(props.chatInfo?.channel))
// Shared with the chat pane so both surfaces offer takeover on the same rule.
// This drops main's `status === 'transferred' && user_id !== currentUserId`
// arm deliberately: takeover_session returns False once user_id is set, so
// that arm only ever rendered a button that failed on click.
const canTakeOver = computed(
() => canTakeOverChat(props.chatInfo) && permissionChecks.canTakeOverChats()
)
// Only worth offering while the AI still has it: once a chat is queued or
// claimed there is no AI left to stop.
const canRouteToHuman = computed(
() => chatHandler(props.chatInfo, currentUserId).kind === 'ai'
&& permissionChecks.canTakeOverChats()
)
const canEndChat = computed(() => {
if (!props.chatInfo) return false
return (
props.chatInfo.status !== 'closed' &&
props.chatInfo.user_id === currentUserId
)
})
const handleRouteToHuman = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
const updated = await routeChatToHuman(props.chatInfo.session_id)
if (!updated) return
emit('chatUpdated', updated)
emit('refresh')
} finally {
actionLoading.value = false
}
}
const handleTakeover = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
await chatService.takeoverChat(props.chatInfo.session_id)
toast.success('已接管会话', {
description: '现在可以在本会话中发送消息',
duration: 4000,
closeButton: true
})
const userName = userService.getUserName()
const userId = userService.getUserId()
// Update local chat info state
const updatedChatInfo = {
...props.chatInfo,
status: 'open' as const,
user_id: userId,
user_name: userName
}
socketService.emit('taken_over', {
session_id: props.chatInfo.session_id,
user_name: userName,
profile_picture: userService.getCurrentUser()?.profile_pic || ''
})
// Emit updated chat info to parent components
emit('chatUpdated', updatedChatInfo)
// Refresh conversations list
emit('refresh')
} catch (err: any) {
console.error('Failed to takeover chat:', err)
toast.error('接管会话失败', {
description: err.response?.data?.detail || '请重试',
duration: 4000,
closeButton: true
})
} finally {
actionLoading.value = false
}
}
const handleEndChatRequest = () => {
showEndChatConfirm.value = true
}
const confirmEndChat = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
const timestamp = new Date().toISOString()
const endChatMessage = {
message: endChatMessageFor(props.chatInfo.channel),
message_type: "system",
created_at: timestamp,
session_id: props.chatInfo.session_id,
end_chat: true,
request_rating: askRating.value,
end_chat_reason: "AGENT_REQUEST",
end_chat_description: "Agent manually ended the chat"
}
// Emit message through socket to end chat
socketService.emit('agent_message', {
message: endChatMessage.message,
session_id: props.chatInfo.session_id,
message_type: endChatMessage.message_type,
created_at: timestamp,
end_chat: true,
request_rating: askRating.value,
end_chat_reason: "AGENT_REQUEST",
end_chat_description: "Agent manually ended the chat"
})
toast.success('会话已结束', {
description: askRating.value ? '将邀请客户进行评价' : '会话已关闭',
duration: 4000,
closeButton: true
})
showEndChatConfirm.value = false
// Refresh conversations list
emit('refresh')
// Inform parent to close chat views
emit('chatClosed', props.chatInfo.session_id)
} catch (err: any) {
console.error('Failed to end chat:', err)
toast.error('结束会话失败', {
description: err.response?.data?.detail || '请重试',
duration: 4000,
closeButton: true
})
} finally {
actionLoading.value = false
}
}
const cancelEndChat = () => {
showEndChatConfirm.value = false
}
// Who holds the chat, sharing chatAssignee with the inbox badge and the chat
// pane so all three agree. Only the unassigned wording is panel-specific: it
// names the AI agent rather than saying a flat "AI".
const assignedTo = computed(
() => chatAssignee(props.chatInfo, currentUserId) || props.chatInfo?.agent?.name || 'AI 客服'
)
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
// Backend status enum stays English in the API; only the label is localised.
const statusLabels: Record<string, string> = {
open: '进行中',
closed: '已结束',
transferred: '已转交',
pending: '等待中'
}
const statusLabel = (status: string) => statusLabels[status] || status
// Integrator-supplied fields (e.g. student_name, center_name) passed via
// POST /generate-token's `custom_data`. Shown as a label/value list below the
// customer's name and email; snake_case keys are prettified for display.
const metaDataEntries = computed(() => {
const metaData = props.chatInfo?.customer?.meta_data
if (!metaData || typeof metaData !== 'object') return []
return Object.entries(metaData)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => ({
key,
label: key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
value: typeof value === 'object' ? JSON.stringify(value) : String(value)
}))
})
// Initials for the mobile hero avatar
const customerInitials = computed(() =>
getInitials(props.chatInfo?.customer?.full_name || props.chatInfo?.customer?.email)
)
// The teammate list arrives as a prop (ConversationsView loads it once from
// /users/teammates). This component used to fetch /users itself into a local
// ref that shadowed the prop — which needed manage_users, so for an agent the
// dropdown was always empty even though the API would have allowed the
// reassign.
const openReassign = () => {
showReassign.value = true
}
const cancelReassign = () => {
showReassign.value = false
selectedUserId.value = ''
}
const confirmReassign = async () => {
if (!props.chatInfo || !selectedUserId.value) return
try {
reassigning.value = true
const updated = await chatService.reassignChat(props.chatInfo.session_id, selectedUserId.value)
toast.success('会话已重新分配')
emit('chatUpdated', updated)
emit('refresh')
showReassign.value = false
} catch (err: any) {
console.error('Failed to reassign chat:', err)
toast.error('重新分配失败', { description: err.response?.data?.detail || '请重试' })
} finally {
reassigning.value = false
}
}
</script>
<template>
<div v-if="chatInfo" class="chat-info-sidebar">
<div class="chat-info-header">
<h3>会话信息</h3>
<button @click="emit('close')" class="close-btn" aria-label="关闭会话信息">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="chat-info-content">
<!-- Mobile-only hero per the app design; hidden on desktop -->
<div class="customer-hero">
<div class="customer-avatar">{{ customerInitials }}</div>
<div class="customer-hero-name">{{ chatInfo.customer.full_name || chatInfo.customer.email || '客户' }}</div>
<div v-if="chatInfo.customer.email" class="customer-hero-email">{{ chatInfo.customer.email }}</div>
</div>
<div class="info-section">
<h4>客户</h4>
<div class="info-item">
<span class="label">姓名:</span>
<span class="value">{{ chatInfo.customer.full_name || '暂无' }}</span>
</div>
<div class="info-item">
<span class="label">邮箱:</span>
<span class="value">{{ chatInfo.customer.email || '暂无' }}</span>
</div>
<div v-for="entry in metaDataEntries" :key="entry.key" class="info-item">
<span class="label">{{ entry.label }}:</span>
<span class="value">{{ entry.value }}</span>
</div>
</div>
<div v-if="canViewTickets && chatInfo.session_id" class="info-section">
<LinkedTicketCard :session-id="chatInfo.session_id" />
</div>
<div class="info-section">
<h4>分配</h4>
<div class="info-item">
<span class="label">分配给:</span>
<span class="value">{{ assignedTo }}</span>
</div>
</div>
<div class="info-section">
<h4>时间线</h4>
<div class="info-item">
<span class="label">创建时间:</span>
<span class="value">{{ formatDate(chatInfo.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">更新时间:</span>
<span class="value">{{ formatDate(chatInfo.updated_at) }}</span>
</div>
</div>
<div class="info-section">
<h4>状态</h4>
<div class="info-item">
<span class="label">当前状态:</span>
<span class="value status-badge" :class="chatInfo.status">
{{ statusLabel(chatInfo.status) }}
</span>
</div>
<div class="info-item">
<span class="label">消息数:</span>
<span class="value">{{ chatInfo.messages.length }}</span>
</div>
</div>
<!-- Chat Actions Section -->
<div class="info-section">
<h4>操作</h4>
<div class="chat-actions">
<button
v-if="canTakeOver"
class="action-btn takeover-btn"
:disabled="actionLoading"
@click="handleTakeover"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
{{ actionLoading ? '接管中…' : '接管会话' }}
</button>
<!-- Stops the AI without making the chat yours. -->
<button
v-if="canRouteToHuman"
class="action-btn"
:disabled="actionLoading"
@click="handleRouteToHuman"
>
<font-awesome-icon icon="fa-solid fa-user-group" />
转给团队
</button>
<button
v-if="canEndChat"
class="action-btn end-chat-btn"
:disabled="actionLoading"
@click="handleEndChatRequest"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16,17 21,12 16,7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
结束会话
</button>
<button
v-if="canReassign && chatInfo.status === 'open' && chatInfo.user_id && users.length"
class="action-btn"
:disabled="reassigning"
@click="openReassign"
>
重新分配
</button>
</div>
</div>
</div>
<!-- End Chat Confirmation Modal -->
<div v-if="showEndChatConfirm" class="end-chat-modal">
<div class="end-chat-modal-content">
<h3>结束会话</h3>
<p v-if="askRating">确定要结束此会话并邀请客户评价吗?</p>
<p v-else>确定要结束此会话吗?</p>
<div class="end-chat-modal-actions">
<button class="cancel-btn" @click="cancelEndChat">取消</button>
<button class="confirm-btn" @click="confirmEndChat" :disabled="actionLoading">
{{ actionLoading ? '结束中…' : (askRating ? '结束会话并请求评价' : '结束会话') }}
</button>
</div>
</div>
</div>
<!-- Reassign Modal -->
<div v-if="showReassign" class="end-chat-modal">
<div class="end-chat-modal-content">
<h3>重新分配会话</h3>
<p>选择要分配此会话的用户。</p>
<div style="margin-bottom: 12px;">
<select v-model="selectedUserId" class="filter-input" style="width: 100%;">
<option value="" disabled>选择用户</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.full_name }} ({{ u.email }})</option>
</select>
</div>
<div class="end-chat-modal-actions">
<button class="cancel-btn" @click="cancelReassign">取消</button>
<button class="confirm-btn" :disabled="!selectedUserId || reassigning" @click="confirmReassign">
{{ reassigning ? '分配中…' : '确认分配' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.chat-info-sidebar {
background: var(--bg2);
border-left: 1px solid var(--o08);
overflow-y: auto;
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.chat-info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--o07);
background: var(--bg2);
}
.chat-info-header h3 {
margin: 0;
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--o10);
border-radius: 8px;
background: var(--o05);
color: var(--muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.close-btn:hover {
background: var(--o10);
color: var(--text);
}
.chat-info-content {
padding: var(--space-lg);
}
/* Mobile-only customer hero (hidden on desktop) */
.customer-hero {
display: none;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-sm) 0 var(--space-lg);
border-bottom: 1px solid var(--o07);
margin-bottom: var(--space-lg);
}
.customer-avatar {
width: 84px;
height: 84px;
border-radius: 50%;
background: var(--grad-purple-teal);
color: var(--on-light);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 30px;
font-weight: var(--font-weight-bold);
margin-bottom: 14px;
}
.customer-hero-name {
font-family: var(--font-display);
font-size: 22px;
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-display);
color: var(--text);
}
.customer-hero-email {
font-size: 14px;
color: var(--muted);
margin-top: 4px;
}
.info-section {
margin-bottom: var(--space-lg);
}
.info-section:last-child {
margin-bottom: 0;
}
.info-section h4 {
margin: 0 0 var(--space-md) 0;
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
color: var(--faint);
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--o07);
padding-bottom: var(--space-xs);
}
.info-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-sm);
gap: var(--space-sm);
}
.info-item:last-child {
margin-bottom: 0;
}
.info-item .label {
font-size: 12px;
font-weight: 500;
color: var(--muted);
min-width: 80px;
flex-shrink: 0;
}
.info-item .value {
font-size: 13px;
color: var(--text);
text-align: right;
word-break: break-word;
flex: 1;
}
.status-badge {
padding: 4px 8px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
/* Higher specificity than ".info-item .value" so the pill hugs the status text
instead of inheriting .value's flex:1 and stretching its background across
the rest of the row. */
.info-item .value.status-badge {
flex: none;
}
.status-badge.open {
background: rgba(201,242,78,.12);
color: var(--accent-ink);
}
.status-badge.closed {
background: var(--o06);
color: var(--muted);
}
.status-badge.transferred {
background: var(--warning-bg);
color: var(--warning-color);
}
/* Chat Actions */
.chat-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.action-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--o10);
border-radius: 10px;
background: var(--o05);
color: var(--text3);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
width: 100%;
}
.action-btn:hover:not(:disabled) {
background: var(--o10);
border-color: var(--o14);
color: var(--text);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.takeover-btn {
border-color: rgba(201,242,78,.3);
color: var(--accent-ink);
}
.takeover-btn:hover:not(:disabled) {
background: rgba(201,242,78,.1);
}
.end-chat-btn {
border-color: rgba(255,138,115,.3);
color: var(--c-coral);
}
.end-chat-btn:hover:not(:disabled) {
background: rgba(255,138,115,.1);
}
/* End Chat Modal */
.end-chat-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.end-chat-modal-content {
background: var(--surface);
border: 1px solid var(--o10);
border-radius: 20px;
padding: var(--space-xl);
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-lg);
animation: slideInUp 0.3s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.end-chat-modal-content h3 {
margin: 0 0 var(--space-md) 0;
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.end-chat-modal-content p {
margin: 0 0 var(--space-lg) 0;
color: var(--muted);
line-height: 1.5;
}
.end-chat-modal-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
.cancel-btn, .confirm-btn {
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid transparent;
min-width: 80px;
}
.cancel-btn {
background: var(--o06);
color: var(--text3);
border-color: var(--o10);
}
.cancel-btn:hover {
background: var(--o10);
color: var(--text);
}
/* Mobile: full-screen "Details" page (class applied by ConversationsView) */
@media (max-width: 768px) {
.chat-info-sidebar.mobile-fullscreen {
position: fixed;
inset: 0;
z-index: var(--z-fullscreen-page);
height: 100vh;
height: 100dvh;
border-left: none;
background: var(--bg);
display: flex;
flex-direction: column;
}
.chat-info-sidebar.mobile-fullscreen .chat-info-header {
padding-top: calc(var(--space-md) + var(--safe-top));
}
.chat-info-sidebar.mobile-fullscreen .chat-info-content {
flex: 1;
overflow-y: auto;
padding-bottom: calc(var(--space-lg) + var(--safe-bottom));
}
.chat-info-sidebar.mobile-fullscreen .customer-hero {
display: flex;
}
.chat-info-sidebar.mobile-fullscreen .close-btn {
width: 44px;
height: 44px;
}
.chat-info-sidebar.mobile-fullscreen .action-btn {
min-height: 48px;
font-size: 15px;
}
}
.confirm-btn {
background: var(--error-color);
color: white;
border-color: transparent;
}
.confirm-btn:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.confirm-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
</style>