feat(chat): 访客与客服聊天界面、历史拉取与断线重连
聊天页支持发送确认、历史补拉和重连;刷新后不重复展示已确认消息。
This commit is contained in:
1 parent
ef9fee1585
commit
4ff934ad98
19 files changed
+6252
No files matched your search
@@ -0,0 +1,826 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<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>
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,466 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Teammate } from '@/services/users'
|
||||
import { ref, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
|
||||
interface FilterValues {
|
||||
customerEmailFilter: string
|
||||
agentFilter: string
|
||||
userFilter: string
|
||||
dateFromFilter: string
|
||||
dateToFilter: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
showFilters: boolean
|
||||
filterValues: FilterValues
|
||||
users: Teammate[]
|
||||
agents: Array<{id: string, name: string, display_name: string | null}>
|
||||
loadingUsers?: boolean
|
||||
loadingAgents?: boolean
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'toggle'): void
|
||||
(e: 'apply', filters: FilterValues): void
|
||||
(e: 'clear'): void
|
||||
(e: 'update:filterValues', filters: FilterValues): void
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<Emits>()
|
||||
|
||||
// Local filter state
|
||||
const localFilters = ref<FilterValues>({ ...props.filterValues })
|
||||
|
||||
// Update local filters when props change
|
||||
const updateLocalFilters = () => {
|
||||
localFilters.value = { ...props.filterValues }
|
||||
}
|
||||
|
||||
// Watch for prop changes
|
||||
onMounted(() => {
|
||||
updateLocalFilters()
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return localFilters.value.customerEmailFilter.trim() ||
|
||||
localFilters.value.agentFilter.trim() ||
|
||||
localFilters.value.userFilter.trim() ||
|
||||
localFilters.value.dateFromFilter ||
|
||||
localFilters.value.dateToFilter
|
||||
})
|
||||
|
||||
const activeFilterCount = computed(() => {
|
||||
return Object.values({
|
||||
customerEmailFilter: localFilters.value.customerEmailFilter.trim(),
|
||||
agentFilter: localFilters.value.agentFilter.trim(),
|
||||
userFilter: localFilters.value.userFilter.trim(),
|
||||
dateFromFilter: localFilters.value.dateFromFilter,
|
||||
dateToFilter: localFilters.value.dateToFilter
|
||||
}).filter(Boolean).length
|
||||
})
|
||||
|
||||
const applyFilters = () => {
|
||||
emit('update:filterValues', { ...localFilters.value })
|
||||
emit('apply', { ...localFilters.value })
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
localFilters.value = {
|
||||
customerEmailFilter: '',
|
||||
agentFilter: '',
|
||||
userFilter: '',
|
||||
dateFromFilter: '',
|
||||
dateToFilter: ''
|
||||
}
|
||||
emit('update:filterValues', { ...localFilters.value })
|
||||
emit('clear')
|
||||
}
|
||||
|
||||
// Click outside handler
|
||||
const handleClickOutside = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const filtersContainer = document.querySelector('.filters-container')
|
||||
const filterToggleBtn = document.querySelector('.filter-toggle-btn')
|
||||
|
||||
if (props.showFilters &&
|
||||
filtersContainer &&
|
||||
!filtersContainer.contains(target) &&
|
||||
filterToggleBtn &&
|
||||
!filterToggleBtn.contains(target)) {
|
||||
emit('toggle')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
document.addEventListener('click', handleClickOutside)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
document.removeEventListener('click', handleClickOutside)
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="filter-header-section">
|
||||
<!-- Filter Toggle Button -->
|
||||
<button
|
||||
@click="emit('toggle')"
|
||||
class="filter-toggle-btn"
|
||||
:class="{ active: showFilters, 'has-filters': hasActiveFilters }"
|
||||
aria-label="筛选"
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polygon points="22 3 2 3 10 12.46 10 19 14 21 14 12.46 22 3"/>
|
||||
</svg>
|
||||
<span v-if="hasActiveFilters" class="filter-count">{{ activeFilterCount }}</span>
|
||||
</button>
|
||||
|
||||
<!-- Advanced Filters Dropdown -->
|
||||
<div v-if="showFilters" class="filters-container">
|
||||
<div class="filters-dropdown">
|
||||
<div class="filters-header">
|
||||
<h3>筛选会话</h3>
|
||||
<button @click="emit('toggle')" 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="filters-grid">
|
||||
<div class="filter-group">
|
||||
<label for="customer-email">客户邮箱</label>
|
||||
<input
|
||||
id="customer-email"
|
||||
v-model="localFilters.customerEmailFilter"
|
||||
type="email"
|
||||
placeholder="按客户邮箱筛选…"
|
||||
class="filter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="agent-filter">客服</label>
|
||||
<select
|
||||
id="agent-filter"
|
||||
v-model="localFilters.agentFilter"
|
||||
class="filter-input filter-select"
|
||||
:disabled="loadingAgents"
|
||||
>
|
||||
<option value="">全部客服</option>
|
||||
<option v-for="agent in agents" :key="agent.id" :value="agent.id">
|
||||
{{ agent.display_name || agent.name }}
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="user-filter">用户</label>
|
||||
<select
|
||||
id="user-filter"
|
||||
v-model="localFilters.userFilter"
|
||||
class="filter-input filter-select"
|
||||
:disabled="loadingUsers"
|
||||
>
|
||||
<option value="">全部用户</option>
|
||||
<option v-for="user in users" :key="user.id" :value="user.id">
|
||||
{{ user.full_name }} ({{ user.email }})
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="date-from">开始日期</label>
|
||||
<input
|
||||
id="date-from"
|
||||
v-model="localFilters.dateFromFilter"
|
||||
type="date"
|
||||
class="filter-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="filter-group">
|
||||
<label for="date-to">结束日期</label>
|
||||
<input
|
||||
id="date-to"
|
||||
v-model="localFilters.dateToFilter"
|
||||
type="date"
|
||||
class="filter-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-actions">
|
||||
<button @click="applyFilters" class="apply-btn">应用筛选</button>
|
||||
<button @click="clearFilters" class="clear-btn">清除全部</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.filter-header-section {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-toggle-btn {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-color);
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-toggle-btn:hover {
|
||||
background: var(--background-soft);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--primary-color-soft);
|
||||
}
|
||||
|
||||
.filter-toggle-btn.active {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.filter-toggle-btn.has-filters {
|
||||
background: var(--primary-color-soft);
|
||||
color: var(--primary-color);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.filter-count {
|
||||
position: absolute;
|
||||
top: -6px;
|
||||
right: -6px;
|
||||
background: var(--error-color);
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.filters-container {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
right: 0;
|
||||
z-index: 1000;
|
||||
background: var(--background-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
animation: slideDown 0.2s ease-out;
|
||||
min-width: 600px;
|
||||
max-width: 90vw;
|
||||
}
|
||||
|
||||
@keyframes slideDown {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(-10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.filters-dropdown {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.filters-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.filters-header h3 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.filter-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.filter-group label {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.filter-input {
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--background-color);
|
||||
color: var(--text-primary);
|
||||
font-size: 14px;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.filter-select {
|
||||
appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 20 20'%3e%3cpath stroke='%236b7280' stroke-linecap='round' stroke-linejoin='round' stroke-width='1.5' d='M6 8l4 4 4-4'/%3e%3c/svg%3e");
|
||||
background-position: right 8px center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 16px;
|
||||
padding-right: 40px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filter-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-color-soft);
|
||||
}
|
||||
|
||||
.filter-input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
display: flex;
|
||||
gap: var(--space-sm);
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--space-md);
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.apply-btn, .clear-btn {
|
||||
padding: 10px 20px;
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border: 1px solid transparent;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.apply-btn {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.apply-btn:hover {
|
||||
background: var(--primary-color-hover);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.clear-btn {
|
||||
background: var(--background-color);
|
||||
color: var(--text-muted);
|
||||
border-color: var(--border-color);
|
||||
}
|
||||
|
||||
.clear-btn:hover {
|
||||
background: var(--background-soft);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--text-muted);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.filters-container {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
min-width: unset;
|
||||
max-width: unset;
|
||||
border-radius: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.filters-dropdown {
|
||||
background: var(--background-color);
|
||||
border-radius: var(--radius-lg);
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.filters-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filter-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.apply-btn, .clear-btn {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,815 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { Conversation, ChatDetail } from '@/types/chat'
|
||||
import ConversationChat from '@/components/conversations/ConversationChat.vue'
|
||||
import ChannelBadge from '@/components/common/ChannelBadge.vue'
|
||||
import HandlerBadge from '@/components/conversations/HandlerBadge.vue'
|
||||
import { useConversationsList } from '@/composables/useConversationsList'
|
||||
import { useBreakpoint } from '@/composables/useBreakpoint'
|
||||
import { chatHandler } from '@/utils/chatState'
|
||||
import { userService } from '@/services/user'
|
||||
import { ref, onMounted, onBeforeUnmount, watch, nextTick } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
conversations: Conversation[]
|
||||
loading: boolean
|
||||
error: string
|
||||
statusFilter: 'open' | 'closed'
|
||||
hasMore: boolean
|
||||
loadingMore: boolean
|
||||
showChatInfo?: boolean
|
||||
initialSessionId?: string | null
|
||||
mobilePane?: 'list' | 'chat'
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'chatUpdated', data: ChatDetail): void
|
||||
(e: 'chatSelected', data: ChatDetail): void
|
||||
(e: 'clearUnread', sessionId: string): void
|
||||
(e: 'updateFilter', status: 'open' | 'closed'): void
|
||||
(e: 'loadMore'): void
|
||||
(e: 'select', sessionId: string): void
|
||||
(e: 'back'): void
|
||||
(e: 'info'): void
|
||||
}>()
|
||||
|
||||
const { isMobile } = useBreakpoint()
|
||||
const currentUserId = userService.getUserId()
|
||||
|
||||
// The preview used to be prefixed with the AI agent's name unconditionally,
|
||||
// which read as "the bot said this" even on a chat a colleague had taken over.
|
||||
// Anonymous visitors have neither a name nor a real email (the backend hides the
|
||||
// placeholder), so the row would otherwise show nothing at all.
|
||||
const customerName = (conv: Conversation) =>
|
||||
conv.customer?.full_name || conv.customer?.email || '访客'
|
||||
|
||||
// Display labels for the backend status enum, which stays in English in the API.
|
||||
const statusLabels: Record<string, string> = {
|
||||
open: '进行中',
|
||||
closed: '已结束',
|
||||
transferred: '已转交',
|
||||
pending: '等待中'
|
||||
}
|
||||
|
||||
const statusLabel = (status: string) => statusLabels[status] || status
|
||||
|
||||
const previewPrefix = (conv: Conversation) => {
|
||||
const handler = chatHandler(conv, currentUserId)
|
||||
return handler.kind === 'human' ? handler.label : conv.agent.name
|
||||
}
|
||||
|
||||
const {
|
||||
selectedChat,
|
||||
selectedId,
|
||||
chatLoading,
|
||||
formattedConversations,
|
||||
loadChatDetail,
|
||||
unreadMessages,
|
||||
clearUnread
|
||||
} = useConversationsList(props, emit)
|
||||
|
||||
// Enhanced loadChatDetail to also emit chat-selected event
|
||||
const loadChatDetailWithSelection = async (sessionId: string) => {
|
||||
await loadChatDetail(sessionId)
|
||||
// Emit the chat-selected event with the loaded chat detail
|
||||
if (selectedChat.value) {
|
||||
emit('chatSelected', selectedChat.value)
|
||||
}
|
||||
}
|
||||
|
||||
// On mobile a tap routes through the URL (?session=) so back navigation works;
|
||||
// the parent pushes the query, which flows back in via initialSessionId.
|
||||
const handleConversationClick = (sessionId: string) => {
|
||||
if (isMobile.value) {
|
||||
emit('select', sessionId)
|
||||
} else {
|
||||
loadChatDetailWithSelection(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// Function to update the selected chat from parent
|
||||
const updateSelectedChat = (updatedChat: ChatDetail) => {
|
||||
console.log('updateSelectedChat', updatedChat)
|
||||
if (selectedChat.value && selectedChat.value.session_id === updatedChat.session_id) {
|
||||
selectedChat.value = updatedChat
|
||||
}
|
||||
}
|
||||
|
||||
// Function to clear the selected chat from parent
|
||||
const clearSelectedChat = () => {
|
||||
selectedChat.value = null
|
||||
selectedId.value = null
|
||||
}
|
||||
|
||||
// Expose the function to parent component
|
||||
defineExpose({
|
||||
updateSelectedChat,
|
||||
clearSelectedChat
|
||||
})
|
||||
|
||||
// Intersection observer for infinite scrolling
|
||||
const listContainer = ref<HTMLElement | null>(null)
|
||||
const loadMoreTrigger = ref<HTMLElement | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
// Scroll to top functionality
|
||||
const showScrollToTop = ref(false)
|
||||
const scrollThreshold = 300 // Show button after scrolling down 300px
|
||||
|
||||
// Handle scroll event to show/hide scroll-to-top button
|
||||
const handleScroll = () => {
|
||||
if (listContainer.value) {
|
||||
showScrollToTop.value = listContainer.value.scrollTop > scrollThreshold
|
||||
}
|
||||
}
|
||||
|
||||
// Scroll to top function
|
||||
const scrollToTop = () => {
|
||||
if (listContainer.value) {
|
||||
listContainer.value.scrollTo({
|
||||
top: 0,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Flag to track filter changes
|
||||
const filterChanged = ref(false)
|
||||
|
||||
// Watch for filter changes to set the flag
|
||||
watch(() => props.statusFilter, () => {
|
||||
filterChanged.value = true
|
||||
})
|
||||
|
||||
// Watch for loading state changes to select first conversation after filter change
|
||||
watch(() => props.loading, (isLoading, prevLoading) => {
|
||||
// Only proceed if loading has just completed (was true, now false)
|
||||
if (prevLoading === true && isLoading === false && filterChanged.value) {
|
||||
// Reset the flag
|
||||
filterChanged.value = false
|
||||
|
||||
// When loading completes after filter change, select the first conversation if
|
||||
// available (desktop only — on mobile the list pane stays put until a tap)
|
||||
if (props.conversations.length > 0 && !isMobile.value) {
|
||||
loadChatDetailWithSelection(props.conversations[0].session_id)
|
||||
} else {
|
||||
// If no conversations available, clear the selected chat
|
||||
selectedChat.value = null
|
||||
selectedId.value = null
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Deep-link: open a specific session directly (from analytics "Sessions Needing
|
||||
// Attention", mobile ?session= navigation, or a push notification). Fetches the
|
||||
// chat detail by id, so it works even if the session isn't in the currently
|
||||
// loaded/filtered list. Reacts to every change so in-app deep-links keep working.
|
||||
watch(
|
||||
() => props.initialSessionId,
|
||||
(sessionId) => {
|
||||
if (sessionId && sessionId !== selectedId.value) {
|
||||
loadChatDetailWithSelection(sessionId)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Setup intersection observer for infinite scrolling
|
||||
onMounted(() => {
|
||||
setupIntersectionObserver()
|
||||
|
||||
// Add scroll event listener
|
||||
if (listContainer.value) {
|
||||
listContainer.value.addEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for changes in the loadMoreTrigger ref
|
||||
watch(loadMoreTrigger, (newValue) => {
|
||||
if (newValue && observer) {
|
||||
observer.observe(newValue)
|
||||
}
|
||||
})
|
||||
|
||||
// Watch for changes in the listContainer ref to add scroll listener
|
||||
watch(listContainer, (newValue) => {
|
||||
if (newValue) {
|
||||
newValue.addEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
|
||||
// Setup intersection observer
|
||||
const setupIntersectionObserver = () => {
|
||||
if (window.IntersectionObserver) {
|
||||
// Disconnect previous observer if it exists
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
}
|
||||
|
||||
observer = new IntersectionObserver((entries) => {
|
||||
const entry = entries[0]
|
||||
if (entry.isIntersecting && props.hasMore && !props.loadingMore) {
|
||||
emit('loadMore')
|
||||
}
|
||||
}, { threshold: 0.5 })
|
||||
|
||||
if (loadMoreTrigger.value) {
|
||||
observer.observe(loadMoreTrigger.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup intersection observer and event listeners
|
||||
onBeforeUnmount(() => {
|
||||
if (observer) {
|
||||
observer.disconnect()
|
||||
observer = null
|
||||
}
|
||||
|
||||
// Remove scroll event listener
|
||||
if (listContainer.value) {
|
||||
listContainer.value.removeEventListener('scroll', handleScroll)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="conversations-container"
|
||||
:class="{ 'with-chat-info': showChatInfo, 'mobile-show-chat': mobilePane === 'chat' }">
|
||||
<!-- Sidebar with conversation list -->
|
||||
<div class="conversations-sidebar">
|
||||
<!-- Filter controls -->
|
||||
<div class="filter-controls">
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: statusFilter === 'open' }"
|
||||
@click="emit('updateFilter', 'open')"
|
||||
>
|
||||
进行中
|
||||
</button>
|
||||
<button
|
||||
class="filter-btn"
|
||||
:class="{ active: statusFilter === 'closed' }"
|
||||
@click="emit('updateFilter', 'closed')"
|
||||
>
|
||||
已结束
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading && !loadingMore" class="loading-state">
|
||||
加载会话中…
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
{{ error }}
|
||||
<button @click="emit('refresh')" class="refresh-button">重试</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="conversations.length === 0" class="empty-state">
|
||||
暂无会话
|
||||
</div>
|
||||
|
||||
<div v-else class="conversations-list" ref="listContainer" tabindex="0" aria-label="会话列表">
|
||||
<div
|
||||
v-for="conv in formattedConversations"
|
||||
:key="conv.session_id"
|
||||
class="conversation-item"
|
||||
:class="{ active: selectedId === conv.session_id }"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
:aria-label="`与 ${customerName(conv)} 的会话`"
|
||||
:aria-current="selectedId === conv.session_id ? 'true' : undefined"
|
||||
@click="handleConversationClick(conv.session_id)"
|
||||
@keydown.enter.prevent="handleConversationClick(conv.session_id)"
|
||||
@keydown.space.prevent="handleConversationClick(conv.session_id)"
|
||||
>
|
||||
<div class="conversation-item-header">
|
||||
<h3>{{ customerName(conv) }}</h3>
|
||||
<ChannelBadge :channel="conv.channel" />
|
||||
<span class="timestamp">{{ conv.timeAgo }}</span>
|
||||
</div>
|
||||
<div class="conversation-preview">
|
||||
<span class="agent-name">{{ previewPrefix(conv) }}:</span>
|
||||
<!-- Product message preview -->
|
||||
<template v-if="conv.message_type === 'product' && conv.shopify_output?.products?.length">
|
||||
<p class="last-message product-preview">
|
||||
<span class="product-icon">🛍️</span>
|
||||
已分享 {{ conv.shopify_output.products.length }} 件商品
|
||||
</p>
|
||||
</template>
|
||||
<!-- Regular message preview -->
|
||||
<template v-else>
|
||||
<p class="last-message">{{ conv.last_message }}</p>
|
||||
</template>
|
||||
<div v-if="unreadMessages[conv.session_id]" class="unread-bubble">
|
||||
{{ unreadMessages[conv.session_id] }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="message-count">{{ conv.message_count }} 条消息</div>
|
||||
<div class="conversation-footer">
|
||||
<div class="conversation-status" :class="conv.status">
|
||||
{{ statusLabel(conv.status) }}
|
||||
</div>
|
||||
<HandlerBadge :chat="conv" :current-user-id="currentUserId" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading more indicator -->
|
||||
<div
|
||||
v-if="hasMore || loadingMore"
|
||||
class="load-more-trigger"
|
||||
ref="loadMoreTrigger"
|
||||
>
|
||||
<div v-if="loadingMore" class="loading-more">
|
||||
<div class="loading-spinner"></div>
|
||||
<span>加载更多会话中…</span>
|
||||
</div>
|
||||
<div v-else-if="hasMore" class="load-more-container">
|
||||
<span class="load-more-hint">向下滚动加载更多</span>
|
||||
<button
|
||||
class="load-more-button"
|
||||
@click="emit('loadMore')"
|
||||
:disabled="loadingMore"
|
||||
>
|
||||
加载更多
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Scroll to top button -->
|
||||
<button
|
||||
v-if="showScrollToTop"
|
||||
class="scroll-to-top-btn"
|
||||
@click="scrollToTop"
|
||||
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">
|
||||
<path d="M18 15l-6-6-6 6"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Chat view -->
|
||||
<div class="chat-view">
|
||||
<div v-if="chatLoading" class="loading-state">
|
||||
加载会话中…
|
||||
</div>
|
||||
<ConversationChat
|
||||
v-else-if="selectedChat"
|
||||
:chat="selectedChat"
|
||||
@refresh="() => {
|
||||
// force: this chat is already open, and loadChatDetail skips a session
|
||||
// it thinks it has — without it this refetch was a silent no-op.
|
||||
selectedChat && loadChatDetail(selectedChat.session_id, true);
|
||||
emit('refresh');
|
||||
}"
|
||||
@chatUpdated="(updated) => {
|
||||
// Forward as well as store locally. Taking a chat over from the chat
|
||||
// pane changes user_id, and the info panel decides whether to offer
|
||||
// 'End Chat' from its own chatInfo prop — which only the parent can
|
||||
// update. Without this the button stayed hidden until a refresh.
|
||||
selectedChat = updated;
|
||||
emit('chatUpdated', updated);
|
||||
}"
|
||||
@clearUnread="clearUnread"
|
||||
@back="emit('back')"
|
||||
@info="emit('info')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.conversations-container {
|
||||
display: grid;
|
||||
grid-template-columns: 320px 1fr;
|
||||
/* Same as the parent grid: without a bounded row the sidebar's height: 100%
|
||||
resolves against content, so .conversations-list can never scroll. */
|
||||
grid-template-rows: minmax(0, 1fr);
|
||||
gap: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.conversations-container.with-chat-info {
|
||||
grid-template-columns: 320px 1fr;
|
||||
}
|
||||
|
||||
.conversations-sidebar {
|
||||
border-right: 1px solid var(--o08);
|
||||
background: var(--bg2);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.filter-controls {
|
||||
display: flex;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--o07);
|
||||
background: var(--bg2);
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.filter-btn {
|
||||
flex: 1;
|
||||
padding: 7px 12px;
|
||||
background: transparent;
|
||||
border: none;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.filter-btn:hover {
|
||||
background: var(--o05);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.filter-btn.active {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversations-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
scrollbar-width: thin; /* For Firefox */
|
||||
scrollbar-color: var(--border-color) transparent; /* For Firefox */
|
||||
}
|
||||
|
||||
/* Webkit scrollbar styling */
|
||||
.conversations-list::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.conversations-list::-webkit-scrollbar-thumb {
|
||||
background-color: var(--border-color);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
/* Status and handler sit on one line and wrap together on a narrow row. */
|
||||
.conversation-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.conversation-status {
|
||||
font-size: 10px;
|
||||
font-family: var(--font-mono);
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
text-transform: uppercase;
|
||||
display: inline-block;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.conversation-status.open {
|
||||
background: rgba(201,242,78,.12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.conversation-status.closed {
|
||||
background: var(--o06);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.conversation-status.transferred {
|
||||
background-color: var(--warning-color-soft);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.chat-view {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sidebar-header {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.sidebar-header h2 {
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid var(--o06);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
min-height: 72px;
|
||||
}
|
||||
|
||||
.conversation-item:hover {
|
||||
background: var(--o05);
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: var(--accent-bg-06);
|
||||
border-left: 3px solid var(--accent-ink);
|
||||
}
|
||||
|
||||
.conversation-item-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--space-xs);
|
||||
}
|
||||
|
||||
.conversation-item-header h3 {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 170px;
|
||||
}
|
||||
|
||||
.timestamp {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.conversation-preview {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.agent-name {
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.last-message {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
max-width: 140px;
|
||||
}
|
||||
|
||||
.message-count {
|
||||
font-size: 11px;
|
||||
color: var(--faint);
|
||||
}
|
||||
|
||||
.unread-bubble {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 50%;
|
||||
min-width: 18px;
|
||||
height: 18px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.no-chat-selected {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
padding: 16px;
|
||||
text-align: center;
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.refresh-button {
|
||||
padding: 4px 8px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text3);
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.load-more-trigger {
|
||||
height: 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 0;
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.loading-more {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--primary-color);
|
||||
border-radius: 50%;
|
||||
border-top-color: transparent;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.load-more-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.load-more-hint {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.load-more-button {
|
||||
padding: 6px 12px;
|
||||
background-color: var(--background-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-sm);
|
||||
color: var(--text-primary);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-fast);
|
||||
}
|
||||
|
||||
.load-more-button:hover {
|
||||
background-color: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.load-more-button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.scroll-to-top-btn {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
right: 16px;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||
transition: all var(--transition-fast);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.scroll-to-top-btn:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.scroll-to-top-btn:active {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.product-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
color: var(--primary-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.product-icon {
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.last-message.product-preview {
|
||||
color: var(--primary-color);
|
||||
max-width: 160px;
|
||||
}
|
||||
|
||||
/* Mobile: single stacked pane — the list by default, the chat when a session
|
||||
is selected (?session= in the URL, via the mobilePane prop) */
|
||||
@media (max-width: 768px) {
|
||||
.conversations-container,
|
||||
.conversations-container.with-chat-info {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.conversations-container .chat-view {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conversations-container.mobile-show-chat .conversations-sidebar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.conversations-container.mobile-show-chat .chat-view {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.conversations-sidebar {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.conversation-item {
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.conversation-item.active {
|
||||
background: transparent;
|
||||
border-left: none;
|
||||
}
|
||||
|
||||
.conversation-item-header h3 {
|
||||
max-width: none;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.last-message {
|
||||
max-width: none;
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,82 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { chatHandler, type ChatHandlerKind, type HandlerCandidate } from '@/utils/chatState'
|
||||
|
||||
const props = defineProps<{
|
||||
chat?: HandlerCandidate | null
|
||||
currentUserId?: string | null
|
||||
}>()
|
||||
|
||||
const HANDLER_ICONS: Record<ChatHandlerKind, string> = {
|
||||
ai: 'fa-solid fa-robot',
|
||||
waiting: 'fa-solid fa-user-clock',
|
||||
human: 'fa-solid fa-user',
|
||||
closed: 'fa-solid fa-lock',
|
||||
}
|
||||
|
||||
const handler = computed(() => chatHandler(props.chat, props.currentUserId))
|
||||
|
||||
// A closed chat has nobody handling it, and the status pill beside this badge
|
||||
// already says so — saying it twice is noise.
|
||||
const visible = computed(() => handler.value.kind !== 'closed')
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span v-if="visible" class="handler-badge" :class="`handler-${handler.kind}`">
|
||||
<font-awesome-icon :icon="HANDLER_ICONS[handler.kind]" />
|
||||
<span class="handler-label">{{ handler.label }}</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.handler-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
padding: 2px 7px;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 1.6;
|
||||
letter-spacing: 0.02em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The name can be long; the badge shrinks rather than pushing the row wider. */
|
||||
.handler-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.handler-ai {
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.handler-waiting {
|
||||
background: var(--warning-color-soft);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.handler-human {
|
||||
background: var(--success-color-soft);
|
||||
color: var(--success-color);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
// A small icon + label above a message saying where it came from (a tapped
|
||||
// button, an external app).
|
||||
defineProps<{
|
||||
icon: string
|
||||
label: string
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="message-tag">
|
||||
<font-awesome-icon :icon="icon" /> {{ label }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message-tag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-bottom: 4px;
|
||||
font-size: var(--text-xs);
|
||||
color: var(--muted);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,351 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Start a WhatsApp conversation with a phone number.
|
||||
*
|
||||
* One shared modal behind both entry points — the Conversations "New
|
||||
* conversation" button and the People drawer's "Message on WhatsApp" — so
|
||||
* template choice and variable filling can never diverge between them.
|
||||
*/
|
||||
import { ref, computed, watch, onBeforeUnmount } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import channelsService, { type ChannelAccount } from '@/services/channels'
|
||||
import { peopleService } from '@/services/people'
|
||||
import type { PersonListItem } from '@/types/people'
|
||||
import { DEFAULT_LANGUAGE } from '@/utils/whatsappLanguages'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import WhatsAppTemplateSelect, {
|
||||
type TemplateSelection,
|
||||
} from '@/components/conversations/WhatsAppTemplateSelect.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
accounts: ChannelAccount[]
|
||||
/** Pre-selected person (the People-drawer entry point). */
|
||||
person?: { id: string; name?: string | null; phone?: string | null } | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'started', sessionId: string): void
|
||||
}>()
|
||||
|
||||
// Outbound = business-initiated: Meta only reliably delivers Utility and
|
||||
// Authentication templates to someone who hasn't messaged us; Marketing also
|
||||
// carries per-user caps. The server enforces the same rule.
|
||||
const OUTBOUND_CATEGORIES = ['UTILITY', 'AUTHENTICATION']
|
||||
|
||||
const accountId = ref(props.accounts[0]?.id ?? '')
|
||||
const to = ref(props.person?.phone ?? '')
|
||||
const name = ref('')
|
||||
const selection = ref<TemplateSelection | null>(null)
|
||||
const sending = ref(false)
|
||||
|
||||
/** The person this number belongs to, when known — prevents a duplicate row. */
|
||||
const pickedPerson = ref<{ id: string; label: string } | null>(
|
||||
props.person ? { id: props.person.id, label: props.person.name || props.person.phone || '' } : null,
|
||||
)
|
||||
// The phone the pick was based on. Editing away from it unlinks — but a
|
||||
// person picked WITHOUT a stored phone stays linked to whatever is typed:
|
||||
// that's the drawer's "message this person at a number you enter" case.
|
||||
let pickedPhone: string | null = props.person?.phone ?? null
|
||||
|
||||
/**
|
||||
* Autocomplete against People. Degrades silently to a plain phone input where
|
||||
* People isn't available (non-Pro plan or missing permission): the feature is
|
||||
* a convenience, not a requirement, and a 403 must not break outbound.
|
||||
*/
|
||||
const suggestions = ref<PersonListItem[]>([])
|
||||
const peopleAvailable = ref(true)
|
||||
let searchTimer: ReturnType<typeof setTimeout> | undefined
|
||||
/** Guards against out-of-order responses — see searchPeople. */
|
||||
let searchToken = 0
|
||||
|
||||
const searchPeople = async () => {
|
||||
const term = to.value.trim()
|
||||
if (!peopleAvailable.value || pickedPerson.value || term.length < 3) {
|
||||
suggestions.value = []
|
||||
return
|
||||
}
|
||||
// The debounce limits how OFTEN we ask, not what order the answers arrive in:
|
||||
// pause mid-number and two requests are in flight. A broader prefix scans more
|
||||
// rows, so it tends to resolve LAST and overwrite the narrower result — the
|
||||
// agent then picks from suggestions for a number they have already moved past.
|
||||
const token = ++searchToken
|
||||
try {
|
||||
const result = await peopleService.listPeople({ search: term, page_size: 5 })
|
||||
if (token !== searchToken) return
|
||||
suggestions.value = result.items.filter((p) => p.phone || p.name)
|
||||
} catch (error: any) {
|
||||
if (token !== searchToken) return
|
||||
// Only a 403 means People genuinely isn't available to this user (non-Pro
|
||||
// plan or missing permission) and is worth latching off. Latching on ANY
|
||||
// error let a single network blip kill autocomplete for the rest of the
|
||||
// modal's life — and a silently dead autocomplete invites exactly the
|
||||
// duplicate person that picking someone exists to prevent.
|
||||
if (error?.response?.status === 403) peopleAvailable.value = false
|
||||
suggestions.value = []
|
||||
}
|
||||
}
|
||||
|
||||
watch(to, () => {
|
||||
// Typing away from the picked person's own number un-picks them.
|
||||
if (pickedPerson.value && pickedPhone && to.value !== pickedPhone) {
|
||||
pickedPerson.value = null
|
||||
pickedPhone = null
|
||||
}
|
||||
clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(searchPeople, 300)
|
||||
})
|
||||
|
||||
const pick = (person: PersonListItem) => {
|
||||
// Falls back to the phone like the props path does: searchPeople admits
|
||||
// anyone with a phone OR a name, so a phone-only person reaches here and
|
||||
// would otherwise render "Sending to" followed by nothing.
|
||||
pickedPerson.value = {
|
||||
id: person.id,
|
||||
label: person.name || person.email || person.phone || '该联系人',
|
||||
}
|
||||
pickedPhone = person.phone ?? null
|
||||
if (person.phone) to.value = person.phone
|
||||
suggestions.value = []
|
||||
}
|
||||
|
||||
const unpick = () => {
|
||||
pickedPerson.value = null
|
||||
pickedPhone = null
|
||||
}
|
||||
|
||||
// Client-side mirror of the server's normalize_phone: '+' then 8-15 digits,
|
||||
// decoration forgiven. The server remains the authority.
|
||||
const phoneLooksValid = computed(() =>
|
||||
/^\+[1-9]\d{7,14}$/.test(to.value.replace(/[\s\-().]/g, '')),
|
||||
)
|
||||
|
||||
const canSend = computed(
|
||||
() => phoneLooksValid.value && !!selection.value?.complete && !sending.value,
|
||||
)
|
||||
|
||||
const send = async () => {
|
||||
if (!canSend.value || !selection.value) return
|
||||
const { template, components } = selection.value
|
||||
try {
|
||||
sending.value = true
|
||||
const result = await channelsService.startWhatsAppConversation(accountId.value, {
|
||||
to: to.value,
|
||||
template_name: template.name,
|
||||
language: template.language || DEFAULT_LANGUAGE,
|
||||
components,
|
||||
customer_id: pickedPerson.value?.id,
|
||||
customer_name: name.value.trim() || undefined,
|
||||
})
|
||||
toast.success('消息已发送', {
|
||||
description: '会话已进入收件箱,对方回复后将由 AI 客服应答。',
|
||||
})
|
||||
emit('started', result.session_id)
|
||||
} catch (error: any) {
|
||||
toast.error('无法发起会话', {
|
||||
description: error?.response?.data?.detail || '请重试',
|
||||
closeButton: true,
|
||||
})
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(searchTimer))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal title="新建 WhatsApp 会话" width="560px" @close="emit('close')">
|
||||
<!-- Meta's opt-in policy is the operator's obligation; state it, don't police it -->
|
||||
<p class="nwc-intro">
|
||||
只能联系已同意在 WhatsApp 接收消息的人,否则消息被举报后 Meta 会封禁企业账号。
|
||||
</p>
|
||||
|
||||
<label v-if="accounts.length > 1" class="nwc-field">
|
||||
<span class="nwc-label">发送号码</span>
|
||||
<select v-model="accountId" class="nwc-input">
|
||||
<option v-for="account in accounts" :key="account.id" :value="account.id">
|
||||
{{ account.display_name || account.external_account_id }}
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div class="nwc-field">
|
||||
<span class="nwc-label">接收方</span>
|
||||
<input
|
||||
v-model="to"
|
||||
class="nwc-input"
|
||||
placeholder="+91 12345 67890"
|
||||
autocomplete="off"
|
||||
:aria-label="peopleAvailable ? '电话号码或搜索联系人' : '电话号码'"
|
||||
/>
|
||||
<span v-if="pickedPerson" class="nwc-picked">
|
||||
发送至 <strong>{{ pickedPerson.label }}</strong>
|
||||
<button type="button" class="nwc-unpick" aria-label="不是此人" @click="unpick">×</button>
|
||||
</span>
|
||||
<span v-else-if="to && !phoneLooksValid" class="nwc-hint">
|
||||
请使用带国家代码的国际格式,例如 +91…
|
||||
</span>
|
||||
<!-- A plain list of buttons, deliberately NOT role="listbox". The
|
||||
children are real buttons: Tab reaches each one and Enter picks it.
|
||||
Claiming listbox told screen readers to expect role="option"
|
||||
children and announced "listbox, 0 options" over working controls —
|
||||
worse than the honest markup, and the full combobox pattern
|
||||
(aria-expanded/controls/activedescendant plus arrow-key handling)
|
||||
is more than a field that degrades to a plain phone input needs. -->
|
||||
<ul v-if="suggestions.length" class="nwc-suggestions" aria-label="匹配的联系人">
|
||||
<li v-for="person in suggestions" :key="person.id">
|
||||
<button type="button" class="nwc-suggestion" @click="pick(person)">
|
||||
<span class="nwc-suggestion-name">{{ person.name || person.email }}</span>
|
||||
<span v-if="person.phone" class="nwc-suggestion-phone">{{ person.phone }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<label v-if="!pickedPerson" class="nwc-field">
|
||||
<span class="nwc-label">姓名(选填)</span>
|
||||
<input v-model="name" class="nwc-input" placeholder="例如:张伟" autocomplete="off" />
|
||||
<span class="nwc-hint">仅当该号码尚未存在于联系人中时使用。</span>
|
||||
</label>
|
||||
|
||||
<div class="nwc-field">
|
||||
<span class="nwc-label">模板</span>
|
||||
<!-- Key: switching the sending number reloads that account's templates -->
|
||||
<WhatsAppTemplateSelect
|
||||
:key="accountId"
|
||||
v-model:selection="selection"
|
||||
:account-id="accountId"
|
||||
:categories="OUTBOUND_CATEGORIES"
|
||||
empty-hint="主动发起的会话需要已审核的工具类(Utility)或身份验证类(Authentication)模板。可在「设置 → 集成」的 WhatsApp 卡片中创建。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<button class="modal-btn" @click="emit('close')">取消</button>
|
||||
<button
|
||||
class="modal-btn modal-btn-primary"
|
||||
:disabled="!canSend"
|
||||
:aria-busy="sending"
|
||||
@click="send"
|
||||
>
|
||||
<font-awesome-icon v-if="sending" icon="fa-solid fa-spinner" spin />
|
||||
{{ sending ? '发送中…' : '发送并开启会话' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
.nwc-intro {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.nwc-field {
|
||||
display: block;
|
||||
margin-bottom: 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.nwc-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.nwc-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.nwc-hint,
|
||||
.nwc-picked {
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.nwc-suggestions {
|
||||
list-style: none;
|
||||
margin: 4px 0 0;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-color);
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
box-shadow: var(--shadow-md);
|
||||
}
|
||||
|
||||
.nwc-suggestion {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border: none;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.nwc-suggestion:hover {
|
||||
background: var(--o08);
|
||||
}
|
||||
|
||||
.nwc-unpick {
|
||||
border: none;
|
||||
background: none;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
font-size: 13px;
|
||||
padding: 0 2px;
|
||||
}
|
||||
|
||||
.nwc-suggestion-phone {
|
||||
color: var(--muted);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,96 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import channelsService, { type WhatsAppTemplate } from '@/services/channels'
|
||||
import { DEFAULT_LANGUAGE } from '@/utils/whatsappLanguages'
|
||||
import BaseModal from '@/components/common/BaseModal.vue'
|
||||
import WhatsAppTemplateSelect, {
|
||||
type TemplateSelection,
|
||||
} from '@/components/conversations/WhatsAppTemplateSelect.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
accountId: string
|
||||
sessionId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'sent', template: WhatsAppTemplate): void
|
||||
}>()
|
||||
|
||||
const selection = ref<TemplateSelection | null>(null)
|
||||
const sending = ref(false)
|
||||
|
||||
const canSend = computed(() => !!selection.value?.complete && !sending.value)
|
||||
|
||||
const send = async () => {
|
||||
if (!selection.value || !canSend.value) return
|
||||
const { template, components } = selection.value
|
||||
try {
|
||||
sending.value = true
|
||||
await channelsService.sendWhatsAppTemplate(props.accountId, {
|
||||
session_id: props.sessionId,
|
||||
template_name: template.name,
|
||||
language: template.language || DEFAULT_LANGUAGE,
|
||||
components,
|
||||
})
|
||||
toast.success('模板已发送', { description: '客户可在接下来 24 小时内回复。' })
|
||||
emit('sent', template)
|
||||
} catch (error: any) {
|
||||
toast.error('模板发送失败', {
|
||||
description: error?.response?.data?.detail || '请重试',
|
||||
closeButton: true,
|
||||
})
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseModal title="发送模板" @close="emit('close')">
|
||||
<p class="tpl-intro">
|
||||
该会话已超出 WhatsApp 的 24 小时窗口,发送已审核的模板可重新开启会话。
|
||||
</p>
|
||||
|
||||
<WhatsAppTemplateSelect v-model:selection="selection" :account-id="accountId" />
|
||||
|
||||
<template #actions>
|
||||
<button class="modal-btn" @click="emit('close')">取消</button>
|
||||
<button
|
||||
class="modal-btn modal-btn-primary"
|
||||
:disabled="!canSend"
|
||||
:aria-busy="sending"
|
||||
@click="send"
|
||||
>
|
||||
<font-awesome-icon v-if="sending" icon="fa-solid fa-spinner" spin />
|
||||
{{ sending ? '发送中…' : '发送模板' }}
|
||||
</button>
|
||||
</template>
|
||||
</BaseModal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tpl-intro {
|
||||
margin: 0 0 16px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,343 @@
|
||||
<!--
|
||||
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.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* Template choice + variable filling, shared by the session-mode picker
|
||||
* (reopen an expired window) and the phone-mode New-conversation modal.
|
||||
*
|
||||
* One component on purpose: Chatwoot and Intercom each bolted template
|
||||
* variables onto one send path and not the other, and both are still paying
|
||||
* for the split. The parent owns the send; this owns what to send.
|
||||
*/
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import channelsService, {
|
||||
type TemplateComponent,
|
||||
type WhatsAppTemplate,
|
||||
} from '@/services/channels'
|
||||
import {
|
||||
isSendable,
|
||||
isAuthentication,
|
||||
templatePreviewText,
|
||||
templateVariables,
|
||||
previewTemplate,
|
||||
isTemplateComplete,
|
||||
buildTemplateComponents,
|
||||
isSameTemplate,
|
||||
templateKey,
|
||||
} from '@/utils/whatsappTemplates'
|
||||
import { languageLabel } from '@/utils/whatsappLanguages'
|
||||
|
||||
export interface TemplateSelection {
|
||||
template: WhatsAppTemplate
|
||||
components?: TemplateComponent[]
|
||||
complete: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
accountId: string
|
||||
/** Restrict to these categories (e.g. outbound = UTILITY/AUTHENTICATION). */
|
||||
categories?: string[]
|
||||
emptyHint?: string
|
||||
}>()
|
||||
|
||||
/** The parent reads this to send: null until a template is chosen. */
|
||||
const selection = defineModel<TemplateSelection | null>('selection', { default: null })
|
||||
|
||||
const templates = ref<WhatsAppTemplate[]>([])
|
||||
const loading = ref(true)
|
||||
/** Set when the list itself could not load — distinct from "no templates yet". */
|
||||
const loadError = ref('')
|
||||
const selected = ref<WhatsAppTemplate | null>(null)
|
||||
const values = ref<Record<number, string>>({})
|
||||
|
||||
const sendable = computed(() =>
|
||||
templates.value.filter(
|
||||
(template) =>
|
||||
isSendable(template) &&
|
||||
(!props.categories ||
|
||||
props.categories.includes(String(template.category || '').toUpperCase())),
|
||||
),
|
||||
)
|
||||
const variables = computed(() => (selected.value ? templateVariables(selected.value) : []))
|
||||
const preview = computed(() =>
|
||||
selected.value ? previewTemplate(selected.value, values.value) : '',
|
||||
)
|
||||
// An authentication template's single value is the passcode, not an authored
|
||||
// {{n}} — naming it "Variable 1" would tell the agent nothing.
|
||||
const isAuthSelected = computed(() => !!selected.value && isAuthentication(selected.value))
|
||||
const fieldLabel = (index: number) =>
|
||||
isAuthSelected.value ? '验证码' : `变量 ${index}`
|
||||
|
||||
const select = (template: WhatsAppTemplate) => {
|
||||
// Re-clicking the current choice must not wipe what has been typed into it.
|
||||
// Compared by name AND language: a same-named sibling is a DIFFERENT
|
||||
// template, and returning early there silently kept the language already
|
||||
// chosen — sending English to someone the agent picked Spanish for.
|
||||
if (isSameTemplate(selected.value, template)) return
|
||||
selected.value = template
|
||||
values.value = {}
|
||||
}
|
||||
|
||||
watch([selected, values], () => {
|
||||
selection.value = selected.value
|
||||
? {
|
||||
template: selected.value,
|
||||
components: buildTemplateComponents(selected.value, values.value),
|
||||
complete: isTemplateComplete(selected.value, values.value),
|
||||
}
|
||||
: null
|
||||
}, { deep: true })
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
templates.value = await channelsService.listWhatsAppTemplates(props.accountId)
|
||||
// Skip straight past a choice of one — the agent still confirms by sending.
|
||||
if (sendable.value.length === 1) select(sendable.value[0])
|
||||
} catch (error: any) {
|
||||
loadError.value = error?.response?.data?.detail || '模板加载失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="loading" class="tps-skeleton" aria-live="polite" aria-busy="true">
|
||||
<div v-for="n in 3" :key="n" class="tps-skeleton-row"></div>
|
||||
<span class="tps-visually-hidden">加载模板中</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="loadError" class="tps-empty">
|
||||
<font-awesome-icon icon="fa-solid fa-circle-exclamation" />
|
||||
<p>{{ loadError }}</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="sendable.length === 0" class="tps-empty">
|
||||
<p>暂无已审核的模板。</p>
|
||||
<p class="tps-empty-hint">
|
||||
{{ emptyHint || '可在「设置 → 集成」中的 WhatsApp 卡片创建。每个模板都需经 Meta 审核后方可发送。' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="tps-body">
|
||||
<ul class="tps-list" aria-label="已审核的模板">
|
||||
<!-- Keyed and compared on name+language: one name can have a row per
|
||||
language, and keying on the name alone gave Vue duplicate keys and
|
||||
lit every sibling up as selected at once. -->
|
||||
<li v-for="template in sendable" :key="templateKey(template)">
|
||||
<button
|
||||
type="button"
|
||||
class="tps-option"
|
||||
:class="{ selected: isSameTemplate(selected, template) }"
|
||||
:aria-pressed="isSameTemplate(selected, template)"
|
||||
@click="select(template)"
|
||||
>
|
||||
<span class="tps-option-head">
|
||||
<span class="tps-name">{{ template.name }}</span>
|
||||
<span v-if="template.language" class="tps-lang">
|
||||
{{ languageLabel(template.language) }}
|
||||
</span>
|
||||
</span>
|
||||
<span class="tps-preview">{{ templatePreviewText(template) }}</span>
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div v-if="selected && variables.length" class="tps-fields">
|
||||
<label v-for="index in variables" :key="index" class="tps-field">
|
||||
<span class="tps-field-label">{{ fieldLabel(index) }}</span>
|
||||
<input
|
||||
v-model="values[index]"
|
||||
type="text"
|
||||
class="tps-input"
|
||||
:placeholder="isAuthSelected ? '要发送的验证码' : `{{${index}}} 的值`"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="selected" class="tps-result">
|
||||
<span class="tps-field-label">预览</span>
|
||||
<p class="tps-result-text">{{ preview }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.tps-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.tps-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tps-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tps-option:hover {
|
||||
border-color: var(--accent-solid);
|
||||
}
|
||||
|
||||
.tps-option.selected {
|
||||
border-color: var(--accent-solid);
|
||||
background: color-mix(in srgb, var(--accent-solid) 10%, var(--background-soft));
|
||||
}
|
||||
|
||||
.tps-option:focus-visible {
|
||||
outline: 2px solid var(--accent-solid);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.tps-option-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.tps-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tps-lang {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 999px;
|
||||
background: var(--background-color);
|
||||
border: 1px solid var(--border-color);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.tps-preview {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.tps-fields {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.tps-field {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.tps-field-label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.tps-input {
|
||||
width: 100%;
|
||||
padding: 10px 12px;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tps-result {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.tps-result-text {
|
||||
margin: 0;
|
||||
padding: 12px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
background: var(--background-soft);
|
||||
border: 1px solid var(--border-color);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.tps-skeleton {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
/* Like the real row, the border is what makes it legible — --background-soft
|
||||
alone is invisible against the modal in dark theme. */
|
||||
.tps-skeleton-row {
|
||||
height: 56px;
|
||||
border-radius: var(--radius-btn, 8px);
|
||||
border: 1px solid var(--border-color);
|
||||
background: var(--o08);
|
||||
animation: tps-pulse 1.4s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes tps-pulse {
|
||||
50% {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tps-skeleton-row {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
|
||||
.tps-empty {
|
||||
text-align: center;
|
||||
padding: 24px 8px;
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.tps-empty p {
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.tps-empty-hint {
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tps-visually-hidden {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user