1419 lines
37 KiB
Vue
1419 lines
37 KiB
Vue
<!--
|
||||
|
|
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 { onMounted, watch, nextTick, ref, computed, onBeforeUnmount } from 'vue'
|
|||
|
|
import type { ChatDetail } from '@/types/chat'
|
|||
|
|
import { useConversationChat } from '@/composables/useConversationChat'
|
|||
|
|
import { useConversationFiles } from '@/composables/useConversationFiles'
|
|||
|
|
import { useVisualViewport } from '@/composables/useVisualViewport'
|
|||
|
|
import { useJiraTicket } from '@/composables/useJiraTicket'
|
|||
|
|
import JiraTicketModal from '@/components/jira/JiraTicketModal.vue'
|
|||
|
|
import TicketCreateModal from '@/components/tickets/TicketCreateModal.vue'
|
|||
|
|
import { ticketService } from '@/services/tickets'
|
|||
|
|
import { permissionChecks } from '@/utils/permissions'
|
|||
|
|
import FileUpload from '@/components/common/FileUpload.vue'
|
|||
|
|
import { userService } from '@/services/user'
|
|||
|
|
import ChannelBadge from '@/components/common/ChannelBadge.vue'
|
|||
|
|
import WhatsAppTemplatePicker from '@/components/conversations/WhatsAppTemplatePicker.vue'
|
|||
|
|
import MessageStatusTicks from '@/components/conversations/MessageStatusTicks.vue'
|
|||
|
|
import QuickReplyChips from '@/components/conversations/QuickReplyChips.vue'
|
|||
|
|
import MessageTag from '@/components/conversations/MessageTag.vue'
|
|||
|
|
import { marked } from 'marked'
|
|||
|
|
import { sanitizeHtml } from '@/utils/sanitize'
|
|||
|
|
import type { Renderer } from 'marked'
|
|||
|
|
|
|||
|
|
const props = defineProps<{
|
|||
|
|
chat: ChatDetail
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
const emit = defineEmits<{
|
|||
|
|
(e: 'refresh'): void
|
|||
|
|
(e: 'chatUpdated', data: ChatDetail): void
|
|||
|
|
(e: 'clearUnread', sessionId: string): void
|
|||
|
|
(e: 'view-product', productId: string): void
|
|||
|
|
(e: 'back'): void
|
|||
|
|
(e: 'info'): void
|
|||
|
|
}>()
|
|||
|
|
|
|||
|
|
// Create a local ref to track the current chat state
|
|||
|
|
const currentChat = ref(props.chat)
|
|||
|
|
|
|||
|
|
const {
|
|||
|
|
newMessage,
|
|||
|
|
messagesContainer,
|
|||
|
|
formattedMessages,
|
|||
|
|
isLoading,
|
|||
|
|
showTakeoverButton,
|
|||
|
|
showTakenOverStatus,
|
|||
|
|
isChatClosed,
|
|||
|
|
canSendMessage,
|
|||
|
|
scrollToBottom,
|
|||
|
|
sendMessage,
|
|||
|
|
handleTakeover,
|
|||
|
|
handleRouteToHuman,
|
|||
|
|
isWaitingForHuman,
|
|||
|
|
handlerCaption,
|
|||
|
|
updateChat,
|
|||
|
|
replaceChatFromProps,
|
|||
|
|
handledByAI,
|
|||
|
|
endChat,
|
|||
|
|
templateCanReopen,
|
|||
|
|
clearTemplateSuggestion
|
|||
|
|
} = useConversationChat(props.chat, emit)
|
|||
|
|
|
|||
|
|
// Templates are a WhatsApp-only way back into a conversation whose 24h window
|
|||
|
|
// closed, and they need the account the conversation arrived on.
|
|||
|
|
const showTemplatePicker = ref(false)
|
|||
|
|
const canUseTemplates = computed(
|
|||
|
|
() => currentChat.value.channel === 'whatsapp' && !!currentChat.value.channel_account_id
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
const handleTemplateSent = () => {
|
|||
|
|
showTemplatePicker.value = false
|
|||
|
|
clearTemplateSuggestion()
|
|||
|
|
emit('refresh')
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Add file handling functionality
|
|||
|
|
const {
|
|||
|
|
fileUploadRef,
|
|||
|
|
uploadedFiles,
|
|||
|
|
handleFilesUploaded,
|
|||
|
|
handleFileUploadError,
|
|||
|
|
handleChatPaste,
|
|||
|
|
handleSendMessageWithAttachments,
|
|||
|
|
formatFileSize,
|
|||
|
|
isImageAttachment,
|
|||
|
|
getDownloadUrl,
|
|||
|
|
getImageUrl
|
|||
|
|
} = useConversationFiles(currentChat, newMessage, canSendMessage, scrollToBottom)
|
|||
|
|
|
|||
|
|
// Add Jira ticket functionality
|
|||
|
|
const {
|
|||
|
|
jiraConnected,
|
|||
|
|
checkJiraStatus
|
|||
|
|
} = useJiraTicket()
|
|||
|
|
|
|||
|
|
// State for Jira ticket modal
|
|||
|
|
const showJiraTicketModal = ref(false)
|
|||
|
|
const ticketSummary = ref('')
|
|||
|
|
|
|||
|
|
// Native ticketing: preferred over Jira when the user can manage tickets.
|
|||
|
|
// One native ticket per session — the button flips to "View ticket".
|
|||
|
|
const showTicketModal = ref(false)
|
|||
|
|
const canUseNativeTickets = permissionChecks.canManageTickets()
|
|||
|
|
const linkedTicketId = ref<string | null>(null)
|
|||
|
|
const showTicketMenu = ref(false)
|
|||
|
|
|
|||
|
|
async function refreshLinkedTicket() {
|
|||
|
|
if (!canUseNativeTickets || !currentChat.value?.session_id) return
|
|||
|
|
const ticket = await ticketService.getTicketBySession(currentChat.value.session_id)
|
|||
|
|
linkedTicketId.value = ticket?.id || null
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Add marked configuration
|
|||
|
|
marked.setOptions({
|
|||
|
|
renderer: new marked.Renderer(),
|
|||
|
|
gfm: true,
|
|||
|
|
breaks: true
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
// Configure marked renderer to add target="_blank" to links
|
|||
|
|
const renderer = new marked.Renderer() as Renderer
|
|||
|
|
renderer.link = function({ href, title, text }) {
|
|||
|
|
if (!href) return text || ''
|
|||
|
|
const link = `<a href="${href}"${title ? ` title="${title}"` : ''}>${text}</a>`
|
|||
|
|
return link.replace(/^<a /, '<a target="_blank" rel="nofollow" ')
|
|||
|
|
}
|
|||
|
|
marked.use({ renderer })
|
|||
|
|
|
|||
|
|
// Create helper function to render and sanitize markdown
|
|||
|
|
const renderMarkdown = (text: string) => {
|
|||
|
|
return sanitizeHtml(marked.parse(text, { async: false }) as string)
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Computed property to determine if the current user can create a ticket
|
|||
|
|
const canCreateTicket = computed(() => {
|
|||
|
|
// Can only create ticket if:
|
|||
|
|
// 1. User can send messages (already handled by canSendMessage)
|
|||
|
|
// 2. Chat is not closed
|
|||
|
|
// 3. Current user is the one who took over the chat
|
|||
|
|
return canSendMessage.value &&
|
|||
|
|
!isChatClosed.value &&
|
|||
|
|
currentChat.value.user_id === userService.getUserId();
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Function to handle create ticket
|
|||
|
|
const handleCreateTicket = async () => {
|
|||
|
|
// Get a summary from the last few messages
|
|||
|
|
const lastMessages = formattedMessages.value.slice(-3)
|
|||
|
|
const summary = lastMessages.map(m => m.message).join(' ').substring(0, 100) + '...'
|
|||
|
|
|
|||
|
|
ticketSummary.value = summary
|
|||
|
|
showJiraTicketModal.value = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// Function to handle ticket created
|
|||
|
|
const handleTicketCreated = (ticketKey: string) => {
|
|||
|
|
// Add ticket key to message input
|
|||
|
|
newMessage.value = `Jira 工单已创建:${ticketKey}`
|
|||
|
|
// Close the modal
|
|||
|
|
showJiraTicketModal.value = false
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
// Keep the composer above the on-screen keyboard on installed iOS PWAs, where
|
|||
|
|
// the keyboard overlays the viewport instead of resizing it
|
|||
|
|
useVisualViewport(() => scrollToBottom())
|
|||
|
|
|
|||
|
|
// Watch for chat changes and update the internal state
|
|||
|
|
watch(() => props.chat, (newChat) => {
|
|||
|
|
if (newChat) {
|
|||
|
|
// Only sync local state to avoid emitting events back to parent
|
|||
|
|
currentChat.value = newChat
|
|||
|
|
replaceChatFromProps(newChat)
|
|||
|
|
}
|
|||
|
|
}, { immediate: true })
|
|||
|
|
|
|||
|
|
// Check Jira status on mount
|
|||
|
|
onMounted(async () => {
|
|||
|
|
scrollToBottom()
|
|||
|
|
await checkJiraStatus()
|
|||
|
|
await refreshLinkedTicket()
|
|||
|
|
})
|
|||
|
|
|
|||
|
|
watch(() => currentChat.value?.session_id, () => refreshLinkedTicket())
|
|||
|
|
</script>
|
|||
|
|
|
|||
|
|
<template>
|
|||
|
|
<div class="chat-layout">
|
|||
|
|
<header class="chat-header">
|
|||
|
|
<button class="back-btn" aria-label="返回会话列表" @click="emit('back')">
|
|||
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M15 5l-7 7 7 7"/></svg>
|
|||
|
|
</button>
|
|||
|
|
<div class="user-info">
|
|||
|
|
<div class="user-info-top">
|
|||
|
|
<h2>{{ chat.customer.full_name || chat.customer.email }}</h2>
|
|||
|
|
<ChannelBadge :channel="chat.channel" />
|
|||
|
|
</div>
|
|||
|
|
<div v-if="handledByAI" class="chat-closed-status">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-lock" />
|
|||
|
|
AI 客服处理中
|
|||
|
|
</div>
|
|||
|
|
<div v-if="isWaitingForHuman" class="chat-closed-status">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-user-clock" />
|
|||
|
|
等待人工客服
|
|||
|
|
</div>
|
|||
|
|
<div v-if="showTakenOverStatus" class="taken-over-status">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-user-clock" />
|
|||
|
|
<span class="status-text" :title="`由 ${chat.user_name || '其他客服'} 接管`">
|
|||
|
|
由 {{ chat.user_name || '其他客服' }} 接管
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="isChatClosed" class="chat-closed-status">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-lock" />
|
|||
|
|
会话已结束
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="header-actions">
|
|||
|
|
<!-- The thread only streams once a human takes the chat over; while the
|
|||
|
|
AI is answering this is how you pull the latest turns. -->
|
|||
|
|
<button class="refresh-chat-btn" title="刷新会话" @click="emit('refresh')">
|
|||
|
|
<!-- font-awesome-icon, not <i class="fas">: the FA CSS isn't loaded
|
|||
|
|
and dom.watch() isn't on, so bare <i> tags render nothing. -->
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-rotate-right" />
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
v-if="canUseTemplates && !isChatClosed"
|
|||
|
|
class="create-ticket-btn"
|
|||
|
|
title="发送模板"
|
|||
|
|
aria-label="发送模板"
|
|||
|
|
@click="showTemplatePicker = true"
|
|||
|
|
>
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-comment-dots" />
|
|||
|
|
<span class="btn-label">发送模板</span>
|
|||
|
|
</button>
|
|||
|
|
<template v-if="canCreateTicket">
|
|||
|
|
<!-- A native ticket already exists for this conversation -->
|
|||
|
|
<button
|
|||
|
|
v-if="linkedTicketId"
|
|||
|
|
class="create-ticket-btn"
|
|||
|
|
@click="$router.push(`/tickets/${linkedTicketId}`)"
|
|||
|
|
>
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-ticket-alt" />
|
|||
|
|
<span class="btn-label">查看工单</span>
|
|||
|
|
</button>
|
|||
|
|
<!-- Jira connected: keep the familiar Jira flow primary, native in
|
|||
|
|
the dropdown (orgs that configured Jira keep their board). -->
|
|||
|
|
<div v-else-if="jiraConnected" class="ticket-btn-group">
|
|||
|
|
<button class="create-ticket-btn" @click="handleCreateTicket">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-ticket-alt" />
|
|||
|
|
<span class="btn-label">创建工单</span>
|
|||
|
|
</button>
|
|||
|
|
<button
|
|||
|
|
v-if="canUseNativeTickets"
|
|||
|
|
class="ticket-menu-toggle"
|
|||
|
|
title="更多工单选项"
|
|||
|
|
@click="showTicketMenu = !showTicketMenu"
|
|||
|
|
>
|
|||
|
|
▾
|
|||
|
|
</button>
|
|||
|
|
<div v-if="showTicketMenu" class="ticket-menu">
|
|||
|
|
<button
|
|||
|
|
class="ticket-menu-item"
|
|||
|
|
@click="showTicketMenu = false; showTicketModal = true"
|
|||
|
|
>
|
|||
|
|
创建 ReplyPier 工单
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<!-- No Jira: native ticketing is the flow -->
|
|||
|
|
<button
|
|||
|
|
v-else-if="canUseNativeTickets"
|
|||
|
|
class="create-ticket-btn"
|
|||
|
|
@click="showTicketModal = true"
|
|||
|
|
>
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-ticket-alt" />
|
|||
|
|
<span class="btn-label">创建工单</span>
|
|||
|
|
</button>
|
|||
|
|
</template>
|
|||
|
|
<button class="info-btn" aria-label="会话详情" @click="emit('info')">
|
|||
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><circle cx="12" cy="7.8" r="1.1" fill="currentColor" stroke="none"/></svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</header>
|
|||
|
|
|
|||
|
|
<main class="chat-content">
|
|||
|
|
<div class="messages" ref="messagesContainer">
|
|||
|
|
<div
|
|||
|
|
v-for="(message, idx) in formattedMessages"
|
|||
|
|
:key="idx"
|
|||
|
|
class="message"
|
|||
|
|
:class="message.message_type === 'agent' ? 'agent' : ((message.message_type === 'bot' || message.message_type === 'product') ? 'bot' : 'user')"
|
|||
|
|
>
|
|||
|
|
<div class="message-content">
|
|||
|
|
<div class="message-bubble">
|
|||
|
|
<!-- Product message -->
|
|||
|
|
<template v-if="message.message_type === 'product' && message.attributes?.shopify_output?.products?.length">
|
|||
|
|
<div class="products-carousel">
|
|||
|
|
<div v-html="renderMarkdown(message.message || '')" class="product-message-text"></div>
|
|||
|
|
<div class="carousel-items">
|
|||
|
|
<div
|
|||
|
|
v-for="product in message.attributes.shopify_output.products"
|
|||
|
|
:key="product.id"
|
|||
|
|
class="product-card-compact"
|
|||
|
|
>
|
|||
|
|
<div class="product-image-compact" v-if="product.image?.src">
|
|||
|
|
<img :src="product.image.src" :alt="product.title || ''" class="product-thumbnail">
|
|||
|
|
</div>
|
|||
|
|
<div class="product-info-compact">
|
|||
|
|
<div class="product-text-area">
|
|||
|
|
<div class="product-title-compact">{{ product.title }}</div>
|
|||
|
|
<div class="product-variant-compact" v-if="product.variant_title">{{ product.variant_title }}</div>
|
|||
|
|
<div class="product-price-compact">{{ product.price }}</div>
|
|||
|
|
</div>
|
|||
|
|
<div class="product-actions-compact">
|
|||
|
|
<button
|
|||
|
|
class="view-details-button-compact"
|
|||
|
|
@click="$emit('view-product', String(product?.id || 'unknown'))"
|
|||
|
|
>
|
|||
|
|
查看商品 <span class="external-link-icon">↗</span>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
<!-- Regular message with markdown -->
|
|||
|
|
<template v-else>
|
|||
|
|
<MessageTag
|
|||
|
|
v-if="message.attributes?.interactive_reply"
|
|||
|
|
icon="fa-solid fa-hand-pointer"
|
|||
|
|
label="按钮回复"
|
|||
|
|
/>
|
|||
|
|
<MessageTag
|
|||
|
|
v-if="message.attributes?.source === 'app'"
|
|||
|
|
icon="fa-solid fa-plug"
|
|||
|
|
:label="`由 ${message.attributes?.app_name || '应用'} 发送`"
|
|||
|
|
/>
|
|||
|
|
<div v-html="renderMarkdown(message.message || '')"></div>
|
|||
|
|
<QuickReplyChips :sent="message.attributes?.interactive_sent" />
|
|||
|
|
|
|||
|
|
<!-- Display attachments if present -->
|
|||
|
|
<div v-if="message.attachments && message.attachments.length > 0" class="message-attachments">
|
|||
|
|
<div
|
|||
|
|
v-for="attachment in message.attachments"
|
|||
|
|
:key="attachment.id"
|
|||
|
|
class="attachment-item"
|
|||
|
|
>
|
|||
|
|
<!-- Image attachment - render as image -->
|
|||
|
|
<template v-if="isImageAttachment(attachment.content_type)">
|
|||
|
|
<div class="attachment-image-container">
|
|||
|
|
<img
|
|||
|
|
:src="getImageUrl(attachment.file_url)"
|
|||
|
|
:alt="attachment.filename"
|
|||
|
|
class="attachment-image"
|
|||
|
|
/>
|
|||
|
|
<div class="attachment-image-info">
|
|||
|
|
<a
|
|||
|
|
:href="getDownloadUrl(attachment.file_url)"
|
|||
|
|
target="_blank"
|
|||
|
|
class="attachment-link"
|
|||
|
|
>
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-download" />
|
|||
|
|
{{ attachment.filename }}
|
|||
|
|
<span class="attachment-size">({{ formatFileSize(attachment.file_size) }})</span>
|
|||
|
|
</a>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
<!-- Other file types - render as download link -->
|
|||
|
|
<template v-else>
|
|||
|
|
<a
|
|||
|
|
:href="getDownloadUrl(attachment.file_url)"
|
|||
|
|
target="_blank"
|
|||
|
|
class="attachment-link"
|
|||
|
|
>
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-paperclip" />
|
|||
|
|
{{ attachment.filename }}
|
|||
|
|
<span class="attachment-size">({{ formatFileSize(attachment.file_size) }})</span>
|
|||
|
|
</a>
|
|||
|
|
</template>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
<span class="message-time">
|
|||
|
|
{{ message.timeAgo }}<MessageStatusTicks
|
|||
|
|
:status="message.channel_status"
|
|||
|
|
:on-accent="message.message_type === 'agent'"
|
|||
|
|
/>
|
|||
|
|
</span>
|
|||
|
|
<span v-if="message.attributes?.delivery_status" class="delivery-failed">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-circle-exclamation" /> 未送达
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
<span v-if="message.message_type === 'bot' || message.message_type === 'agent'" class="agent-name">
|
|||
|
|
{{ message.message_type === 'bot' ? (message.agent_name || chat.agent.name || 'AI 客服') : (message.user_name || '客服') }}
|
|||
|
|
</span>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</main>
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
<!-- Jira Ticket Modal -->
|
|||
|
|
<JiraTicketModal
|
|||
|
|
v-if="showJiraTicketModal"
|
|||
|
|
:chat-id="currentChat.session_id"
|
|||
|
|
:initial-summary="ticketSummary"
|
|||
|
|
@close="showJiraTicketModal = false"
|
|||
|
|
@ticket-created="handleTicketCreated"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- Native ticket modal (AI-drafted from this conversation) -->
|
|||
|
|
<TicketCreateModal
|
|||
|
|
:open="showTicketModal"
|
|||
|
|
:session-id="currentChat.session_id"
|
|||
|
|
:session-label="chat.customer.full_name || chat.customer.email || '此会话'"
|
|||
|
|
@close="showTicketModal = false"
|
|||
|
|
@created="refreshLinkedTicket"
|
|||
|
|
/>
|
|||
|
|
|
|||
|
|
<!-- Unclaimed: the composer is replaced by the primary "Take over chat"
|
|||
|
|
action. Covers both an AI-handled chat and one the AI has queued for a
|
|||
|
|
human, so claiming never requires opening the details panel. -->
|
|||
|
|
<footer v-if="showTakeoverButton" class="chat-input takeover-footer">
|
|||
|
|
<div class="takeover-caption">
|
|||
|
|
{{ handlerCaption }}
|
|||
|
|
</div>
|
|||
|
|
<button class="takeover-button" :disabled="isLoading" @click="handleTakeover">
|
|||
|
|
<svg viewBox="0 0 24 24" width="20" height="20" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 12h11m0 0l-4-4m4 4l-4 4"/><path d="M5 5v14"/></svg>
|
|||
|
|
接管会话
|
|||
|
|
</button>
|
|||
|
|
<!-- Claiming it yourself was the only way to silence the AI. This queues
|
|||
|
|
it for whoever is free instead. -->
|
|||
|
|
<button
|
|||
|
|
v-if="handledByAI"
|
|||
|
|
class="hand-over-button"
|
|||
|
|
:disabled="isLoading"
|
|||
|
|
@click="handleRouteToHuman"
|
|||
|
|
>
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-user-group" />
|
|||
|
|
转给团队
|
|||
|
|
</button>
|
|||
|
|
</footer>
|
|||
|
|
|
|||
|
|
<footer class="chat-input" v-else-if="!isChatClosed && !handledByAI">
|
|||
|
|
<!-- The agent has just been told a reply couldn't be delivered; offer the
|
|||
|
|
one action that gets the conversation back rather than leaving them
|
|||
|
|
to retype into a composer that will fail again. -->
|
|||
|
|
<div v-if="templateCanReopen && canUseTemplates" class="window-closed-notice">
|
|||
|
|
<div class="window-closed-text">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-clock" />
|
|||
|
|
<span>该客户的 24 小时窗口已关闭,发送已审核的模板可重新开启。</span>
|
|||
|
|
</div>
|
|||
|
|
<button class="window-closed-btn" @click="showTemplatePicker = true">
|
|||
|
|
发送模板
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
<div class="input-container" :class="{ disabled: !canSendMessage }" @paste="handleChatPaste">
|
|||
|
|
<FileUpload
|
|||
|
|
ref="fileUploadRef"
|
|||
|
|
:max-files="3"
|
|||
|
|
@filesUploaded="handleFilesUploaded"
|
|||
|
|
@error="handleFileUploadError"
|
|||
|
|
/>
|
|||
|
|
<div class="message-input-wrapper">
|
|||
|
|
<input
|
|||
|
|
v-model="newMessage"
|
|||
|
|
type="text"
|
|||
|
|
placeholder="输入消息或粘贴截图"
|
|||
|
|
class="message-input"
|
|||
|
|
@keyup.enter="handleSendMessageWithAttachments"
|
|||
|
|
:disabled="!canSendMessage"
|
|||
|
|
>
|
|||
|
|
<button
|
|||
|
|
class="send-button"
|
|||
|
|
@click="handleSendMessageWithAttachments"
|
|||
|
|
:disabled="(!newMessage.trim() && uploadedFiles.length === 0) || !canSendMessage"
|
|||
|
|
aria-label="发送"
|
|||
|
|
>
|
|||
|
|
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 2 11 13"></path><path d="M22 2l-7 20-4-9-9-4 20-7z"></path></svg>
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
<div v-if="!canSendMessage" class="input-message">
|
|||
|
|
{{ handlerCaption }}
|
|||
|
|
</div>
|
|||
|
|
</footer>
|
|||
|
|
|
|||
|
|
<footer v-else-if="handledByAI" class="chat-closed-footer">
|
|||
|
|
<div class="chat-closed-message">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-lock" />
|
|||
|
|
此会话正由 AI 客服处理
|
|||
|
|
</div>
|
|||
|
|
</footer>
|
|||
|
|
<footer v-else class="chat-closed-footer">
|
|||
|
|
<div class="chat-closed-message">
|
|||
|
|
<font-awesome-icon icon="fa-solid fa-lock" />
|
|||
|
|
此会话已结束
|
|||
|
|
</div>
|
|||
|
|
</footer>
|
|||
|
|
|
|||
|
|
<WhatsAppTemplatePicker
|
|||
|
|
v-if="showTemplatePicker && currentChat.channel_account_id"
|
|||
|
|
:account-id="currentChat.channel_account_id"
|
|||
|
|
:session-id="currentChat.session_id"
|
|||
|
|
@close="showTemplatePicker = false"
|
|||
|
|
@sent="handleTemplateSent"
|
|||
|
|
/>
|
|||
|
|
</div>
|
|||
|
|
</template>
|
|||
|
|
|
|||
|
|
<style scoped>
|
|||
|
|
.chat-layout {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
/* Fill .chat-view rather than a fixed viewport fraction — the pane is already
|
|||
|
|
sized by the grid, and 70vh left a dead strip below the message input. */
|
|||
|
|
height: 100%;
|
|||
|
|
flex: 1;
|
|||
|
|
min-height: 0;
|
|||
|
|
width: 100%;
|
|||
|
|
background: var(--bg);
|
|||
|
|
position: relative;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-header {
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
padding: 14px 20px;
|
|||
|
|
border-bottom: 1px solid var(--o08);
|
|||
|
|
background: var(--bg2);
|
|||
|
|
display: flex;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 10px;
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Mobile-only affordances (back chevron, info button) */
|
|||
|
|
.back-btn,
|
|||
|
|
.info-btn {
|
|||
|
|
display: none;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
color: var(--text);
|
|||
|
|
cursor: pointer;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
padding: 0;
|
|||
|
|
-webkit-tap-highlight-color: transparent;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.info-btn {
|
|||
|
|
width: 38px;
|
|||
|
|
height: 38px;
|
|||
|
|
border-radius: 11px;
|
|||
|
|
background: var(--o05);
|
|||
|
|
border: 1px solid var(--o10);
|
|||
|
|
color: var(--text3);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.takeover-footer {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 11px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.takeover-caption {
|
|||
|
|
text-align: center;
|
|||
|
|
font-size: 12.5px;
|
|||
|
|
color: var(--muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.takeover-button {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 9px;
|
|||
|
|
height: 50px;
|
|||
|
|
border: none;
|
|||
|
|
border-radius: 13px;
|
|||
|
|
background: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
font-family: var(--font-sans);
|
|||
|
|
font-size: 15px;
|
|||
|
|
font-weight: var(--font-weight-bold);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: filter var(--transition-fast), opacity var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.takeover-button:hover:not(:disabled) {
|
|||
|
|
filter: brightness(1.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.takeover-button:disabled {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Secondary to "Take over chat": same shape, no fill. */
|
|||
|
|
.hand-over-button {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 9px;
|
|||
|
|
height: 44px;
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 13px;
|
|||
|
|
background: transparent;
|
|||
|
|
color: var(--text-color);
|
|||
|
|
font-family: var(--font-sans);
|
|||
|
|
font-size: 14px;
|
|||
|
|
font-weight: var(--font-weight-medium);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: background var(--transition-fast), opacity var(--transition-fast);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hand-over-button:hover:not(:disabled) {
|
|||
|
|
background: var(--o06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.hand-over-button:disabled {
|
|||
|
|
opacity: 0.6;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-content {
|
|||
|
|
flex: 1 1 auto;
|
|||
|
|
min-height: 0;
|
|||
|
|
position: relative;
|
|||
|
|
width: 100%;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.messages {
|
|||
|
|
position: absolute;
|
|||
|
|
top: 0;
|
|||
|
|
left: 0;
|
|||
|
|
right: 0;
|
|||
|
|
bottom: 0;
|
|||
|
|
overflow-y: auto;
|
|||
|
|
padding: 16px;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 16px;
|
|||
|
|
scroll-behavior: smooth;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-input {
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
padding: 14px 20px;
|
|||
|
|
border-top: 1px solid var(--o08);
|
|||
|
|
background: var(--bg2);
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Name + channel badge on one row, status line beneath — without this the
|
|||
|
|
block-level h2, the badge and the status each claim their own line and the
|
|||
|
|
status text wraps, making the header several rows tall. */
|
|||
|
|
.user-info {
|
|||
|
|
flex: 1;
|
|||
|
|
min-width: 0;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.user-info-top {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.user-info h2 {
|
|||
|
|
font-size: 15px;
|
|||
|
|
font-family: var(--font-display);
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text);
|
|||
|
|
margin: 0;
|
|||
|
|
min-width: 0;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.status {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-actions {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 12px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
color: var(--muted);
|
|||
|
|
cursor: pointer;
|
|||
|
|
padding: 8px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.action-btn:hover {
|
|||
|
|
background: var(--o08);
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message {
|
|||
|
|
max-width: 70%;
|
|||
|
|
display: flex;
|
|||
|
|
margin-bottom: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-content {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 4px;
|
|||
|
|
max-width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Customer (incoming) — left */
|
|||
|
|
.message.user {
|
|||
|
|
margin-right: auto;
|
|||
|
|
justify-content: flex-start;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* AI + human agent (outbound) — right */
|
|||
|
|
.message.bot,
|
|||
|
|
.message.agent {
|
|||
|
|
margin-left: auto;
|
|||
|
|
justify-content: flex-end;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-bubble {
|
|||
|
|
padding: 11px 15px;
|
|||
|
|
position: relative;
|
|||
|
|
max-width: 100%;
|
|||
|
|
word-wrap: break-word;
|
|||
|
|
font-size: 14px;
|
|||
|
|
line-height: 1.55;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Customer bubble — neutral, sharp top-left */
|
|||
|
|
.message.user .message-bubble {
|
|||
|
|
background: var(--bubble-customer-bg);
|
|||
|
|
color: var(--bubble-customer-fg);
|
|||
|
|
border-radius: 4px 15px 15px 15px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* AI bubble — teal tint, sharp top-right */
|
|||
|
|
.message.bot .message-bubble {
|
|||
|
|
background: var(--bubble-ai-bg);
|
|||
|
|
border: 1px solid var(--bubble-ai-border);
|
|||
|
|
color: var(--bubble-ai-fg);
|
|||
|
|
border-radius: 15px 15px 4px 15px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Human agent bubble — lime, sharp top-right */
|
|||
|
|
.message.agent .message-bubble {
|
|||
|
|
background: var(--bubble-agent-bg);
|
|||
|
|
color: var(--bubble-agent-fg);
|
|||
|
|
border-radius: 15px 15px 4px 15px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.agent-name {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
padding-left: 4px;
|
|||
|
|
margin-top: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-time {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--muted);
|
|||
|
|
margin-top: 4px;
|
|||
|
|
display: block;
|
|||
|
|
text-align: right;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.user .message-time,
|
|||
|
|
.message.bot .message-time {
|
|||
|
|
color: var(--muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.agent .message-time {
|
|||
|
|
color: color-mix(in srgb, var(--on-accent) 55%, transparent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.delivery-failed {
|
|||
|
|
font-size: 11px;
|
|||
|
|
color: var(--c-danger);
|
|||
|
|
margin-top: 2px;
|
|||
|
|
display: block;
|
|||
|
|
text-align: right;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
/* The agent bubble is accent-filled: lift --c-danger toward the bubble's own
|
|||
|
|
foreground for contrast while keeping it readably red, not just a timestamp */
|
|||
|
|
.message.agent .delivery-failed {
|
|||
|
|
color: color-mix(in srgb, var(--c-danger) 65%, var(--on-accent));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.window-closed-notice {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: space-between;
|
|||
|
|
gap: 12px;
|
|||
|
|
flex-wrap: wrap;
|
|||
|
|
padding: 10px 14px;
|
|||
|
|
margin-bottom: 8px;
|
|||
|
|
border-radius: var(--radius-btn, 8px);
|
|||
|
|
background: var(--warning-light);
|
|||
|
|
border: 1px solid color-mix(in srgb, var(--warning) 35%, transparent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.window-closed-text {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
font-size: 13px;
|
|||
|
|
line-height: 1.5;
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.window-closed-text svg {
|
|||
|
|
color: var(--warning);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.window-closed-btn {
|
|||
|
|
padding: 7px 12px;
|
|||
|
|
border-radius: var(--radius-btn, 8px);
|
|||
|
|
border: none;
|
|||
|
|
background: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
font-size: 13px;
|
|||
|
|
font-weight: 600;
|
|||
|
|
cursor: pointer;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-container {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: flex-end;
|
|||
|
|
gap: 12px;
|
|||
|
|
background: var(--surface);
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
border-radius: 14px;
|
|||
|
|
border: 1px solid var(--o10);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-container.disabled {
|
|||
|
|
opacity: 0.7;
|
|||
|
|
background: var(--background-mute);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-input-wrapper {
|
|||
|
|
flex: 1;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-input {
|
|||
|
|
flex: 1;
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
font-size: 14px;
|
|||
|
|
padding: 8px 0;
|
|||
|
|
outline: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-input::placeholder {
|
|||
|
|
color: var(--text-placeholder);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.emoji-btn,
|
|||
|
|
.attach-btn,
|
|||
|
|
.send-button {
|
|||
|
|
background: none;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
padding: 8px;
|
|||
|
|
font-size: 18px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.send-button {
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
width: 38px;
|
|||
|
|
height: 38px;
|
|||
|
|
padding: 0;
|
|||
|
|
border: none;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
border-radius: var(--radius-input);
|
|||
|
|
background: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
transition: filter 0.2s, opacity 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.send-button:hover:not(:disabled) {
|
|||
|
|
filter: brightness(1.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.send-button:disabled {
|
|||
|
|
opacity: 0.4;
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.send-button svg {
|
|||
|
|
display: block;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
.taken-over-status {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--primary-color);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
min-width: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* text-overflow can't act on a flex item, so the label carries it */
|
|||
|
|
.status-text {
|
|||
|
|
white-space: nowrap;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.taken-over-status svg {
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.input-message {
|
|||
|
|
text-align: center;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
font-size: 12px;
|
|||
|
|
margin-top: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-input:disabled {
|
|||
|
|
cursor: not-allowed;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-closed-status {
|
|||
|
|
font-size: 12px;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
white-space: nowrap;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-closed-status svg {
|
|||
|
|
font-size: 14px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-closed-footer {
|
|||
|
|
flex: 0 0 auto;
|
|||
|
|
padding: 14px 20px;
|
|||
|
|
border-top: 1px solid var(--o08);
|
|||
|
|
background: var(--bg2);
|
|||
|
|
width: 100%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-closed-message {
|
|||
|
|
text-align: center;
|
|||
|
|
color: var(--muted);
|
|||
|
|
font-size: 13px;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-closed-message svg {
|
|||
|
|
font-size: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
|
|||
|
|
.refresh-chat-btn {
|
|||
|
|
display: inline-flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
width: 32px;
|
|||
|
|
height: 32px;
|
|||
|
|
border-radius: var(--radius-btn, 8px);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
background: transparent;
|
|||
|
|
color: var(--muted);
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.refresh-chat-btn:hover {
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.create-ticket-btn {
|
|||
|
|
background: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
border: none;
|
|||
|
|
border-radius: var(--radius-sm);
|
|||
|
|
padding: 8px 16px;
|
|||
|
|
font-size: 14px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 8px;
|
|||
|
|
transition: background-color 0.2s;
|
|||
|
|
margin-right: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.create-ticket-btn:hover {
|
|||
|
|
transform: translateY(-1px);
|
|||
|
|
filter: brightness(1.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ticket-btn-group {
|
|||
|
|
position: relative;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ticket-btn-group .create-ticket-btn {
|
|||
|
|
margin-right: 0;
|
|||
|
|
border-radius: var(--radius-sm) 0 0 var(--radius-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ticket-menu-toggle {
|
|||
|
|
background: var(--accent-solid);
|
|||
|
|
color: var(--on-accent-solid);
|
|||
|
|
border: none;
|
|||
|
|
border-left: 1px solid color-mix(in srgb, var(--on-accent-solid) 20%, transparent);
|
|||
|
|
border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
|
|||
|
|
padding: 8px 10px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
margin-right: 16px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ticket-menu {
|
|||
|
|
position: absolute;
|
|||
|
|
top: calc(100% + 6px);
|
|||
|
|
right: 16px;
|
|||
|
|
z-index: 30;
|
|||
|
|
background: var(--bg2);
|
|||
|
|
border: 1px solid var(--o12);
|
|||
|
|
border-radius: 10px;
|
|||
|
|
overflow: hidden;
|
|||
|
|
min-width: 170px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ticket-menu-item {
|
|||
|
|
display: block;
|
|||
|
|
width: 100%;
|
|||
|
|
text-align: left;
|
|||
|
|
padding: 9px 13px;
|
|||
|
|
background: transparent;
|
|||
|
|
border: none;
|
|||
|
|
color: var(--text3);
|
|||
|
|
font-size: 12.5px;
|
|||
|
|
cursor: pointer;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.ticket-menu-item:hover {
|
|||
|
|
background: var(--o05);
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Add product carousel styles */
|
|||
|
|
.products-carousel {
|
|||
|
|
margin: var(--space-xs) 0;
|
|||
|
|
width: 100%;
|
|||
|
|
padding: var(--space-xs);
|
|||
|
|
background: var(--o03);
|
|||
|
|
border-radius: 20px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-message-text {
|
|||
|
|
margin-bottom: var(--space-sm);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.carousel-items {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: row;
|
|||
|
|
gap: var(--space-sm);
|
|||
|
|
margin-top: var(--space-xs);
|
|||
|
|
overflow-x: auto;
|
|||
|
|
padding: var(--space-xs);
|
|||
|
|
padding-bottom: var(--space-md);
|
|||
|
|
scroll-snap-type: x mandatory;
|
|||
|
|
-webkit-overflow-scrolling: touch;
|
|||
|
|
scrollbar-width: thin;
|
|||
|
|
scrollbar-color: var(--o25) var(--o08);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-card-compact {
|
|||
|
|
background-color: var(--surface);
|
|||
|
|
border: 1px solid var(--o10);
|
|||
|
|
border-radius: 20px;
|
|||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06),
|
|||
|
|
0 1px 2px rgba(0, 0, 0, 0.04);
|
|||
|
|
overflow: hidden;
|
|||
|
|
width: 180px;
|
|||
|
|
flex-shrink: 0;
|
|||
|
|
transition: all 0.2s ease;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-card-compact:hover {
|
|||
|
|
transform: translateY(-2px);
|
|||
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08),
|
|||
|
|
0 2px 4px rgba(0, 0, 0, 0.06);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-image-compact {
|
|||
|
|
width: 100%;
|
|||
|
|
aspect-ratio: 1;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-thumbnail {
|
|||
|
|
width: 100%;
|
|||
|
|
height: 100%;
|
|||
|
|
object-fit: cover;
|
|||
|
|
transition: transform 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-thumbnail:hover {
|
|||
|
|
transform: scale(1.05);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-info-compact {
|
|||
|
|
padding: var(--space-sm);
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: var(--space-xs);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-title-compact {
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
font-weight: 500;
|
|||
|
|
line-height: 1.4;
|
|||
|
|
overflow: hidden;
|
|||
|
|
text-overflow: ellipsis;
|
|||
|
|
display: -webkit-box;
|
|||
|
|
-webkit-line-clamp: 2;
|
|||
|
|
line-clamp: 2;
|
|||
|
|
-webkit-box-orient: vertical;
|
|||
|
|
min-height: 2.8em;
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-variant-compact {
|
|||
|
|
font-size: var(--text-xs);
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.product-price-compact {
|
|||
|
|
font-size: var(--text-sm);
|
|||
|
|
font-weight: 600;
|
|||
|
|
color: var(--text);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.view-details-button-compact {
|
|||
|
|
width: 100%;
|
|||
|
|
padding: 8px 12px;
|
|||
|
|
background-color: var(--bg2);
|
|||
|
|
color: var(--text);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 16px;
|
|||
|
|
font-size: var(--text-xs);
|
|||
|
|
font-weight: 500;
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
justify-content: center;
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.2s;
|
|||
|
|
gap: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.view-details-button-compact:hover {
|
|||
|
|
background-color: var(--background-soft);
|
|||
|
|
border-color: var(--border-color-hover);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.external-link-icon {
|
|||
|
|
font-size: 1em;
|
|||
|
|
line-height: 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Add styles for markdown content */
|
|||
|
|
.message-bubble :deep(img) {
|
|||
|
|
max-width: 100%;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
margin: 8px 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-bubble :deep(p) {
|
|||
|
|
margin: 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message-bubble :deep(a) {
|
|||
|
|
color: var(--primary-color);
|
|||
|
|
/* Always underlined: colour alone is not a reliable link affordance, and it
|
|||
|
|
keeps links findable on the accent-filled bubbles below. */
|
|||
|
|
text-decoration: underline;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* The human-agent bubble is filled with --accent-ink, and --primary-color IS
|
|||
|
|
--accent-ink in both themes — so a link inherited the bubble's own
|
|||
|
|
background colour and rendered invisible. Links there take the bubble's
|
|||
|
|
foreground instead. */
|
|||
|
|
.message.agent .message-bubble :deep(a) {
|
|||
|
|
color: var(--bubble-agent-fg);
|
|||
|
|
text-decoration: underline;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.agent .message-bubble :deep(a:hover) {
|
|||
|
|
opacity: 0.8;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Attachment styles */
|
|||
|
|
.message-attachments {
|
|||
|
|
margin-top: 8px;
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 6px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-item {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-link {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
gap: 6px;
|
|||
|
|
padding: 6px 10px;
|
|||
|
|
background: var(--background-color);
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
border-radius: 8px;
|
|||
|
|
color: var(--text-primary);
|
|||
|
|
text-decoration: none;
|
|||
|
|
font-size: 13px;
|
|||
|
|
transition: all 0.2s;
|
|||
|
|
max-width: 100%;
|
|||
|
|
overflow: hidden;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-link:hover {
|
|||
|
|
background: var(--background-mute);
|
|||
|
|
border-color: var(--primary-color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-link svg {
|
|||
|
|
font-size: 14px;
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-size {
|
|||
|
|
color: var(--text-muted);
|
|||
|
|
font-size: 11px;
|
|||
|
|
margin-left: 4px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.agent .attachment-link {
|
|||
|
|
background: color-mix(in srgb, var(--on-accent) 12%, transparent);
|
|||
|
|
border-color: color-mix(in srgb, var(--on-accent) 22%, transparent);
|
|||
|
|
color: var(--on-accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.agent .attachment-link:hover {
|
|||
|
|
background: color-mix(in srgb, var(--on-accent) 18%, transparent);
|
|||
|
|
border-color: color-mix(in srgb, var(--on-accent) 32%, transparent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.agent .attachment-link svg,
|
|||
|
|
.message.agent .attachment-size {
|
|||
|
|
color: var(--on-accent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Image attachment styles */
|
|||
|
|
.attachment-image-container {
|
|||
|
|
display: flex;
|
|||
|
|
flex-direction: column;
|
|||
|
|
gap: 6px;
|
|||
|
|
max-width: 350px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-image {
|
|||
|
|
width: 100%;
|
|||
|
|
max-height: 400px;
|
|||
|
|
border-radius: 8px;
|
|||
|
|
object-fit: contain;
|
|||
|
|
border: 1px solid var(--border-color);
|
|||
|
|
cursor: pointer;
|
|||
|
|
transition: all 0.2s;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-image:hover {
|
|||
|
|
border-color: var(--primary-color);
|
|||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.agent .attachment-image {
|
|||
|
|
border-color: color-mix(in srgb, var(--on-accent) 22%, transparent);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message.agent .attachment-image:hover {
|
|||
|
|
border-color: color-mix(in srgb, var(--on-accent) 40%, transparent);
|
|||
|
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-image-info {
|
|||
|
|
display: flex;
|
|||
|
|
align-items: center;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.attachment-image-info .attachment-link {
|
|||
|
|
margin: 0;
|
|||
|
|
padding: 4px 8px;
|
|||
|
|
font-size: 12px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Mobile: full-screen chat pane */
|
|||
|
|
@media (max-width: 768px) {
|
|||
|
|
.back-btn,
|
|||
|
|
.info-btn {
|
|||
|
|
display: flex;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.back-btn {
|
|||
|
|
margin-left: -8px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.chat-header {
|
|||
|
|
padding: calc(4px + var(--safe-top)) 6px 4px;
|
|||
|
|
gap: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.header-actions {
|
|||
|
|
gap: 2px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Icon-only actions on phones — labels would squeeze the customer name */
|
|||
|
|
.btn-label {
|
|||
|
|
display: none;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* 44px minimum touch target (Apple HIG / WCAG 2.5.8) — the header pads
|
|||
|
|
tightly instead of shrinking the controls */
|
|||
|
|
.create-ticket-btn {
|
|||
|
|
margin-right: 0;
|
|||
|
|
width: 44px;
|
|||
|
|
height: 44px;
|
|||
|
|
padding: 0;
|
|||
|
|
justify-content: center;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.refresh-chat-btn,
|
|||
|
|
.info-btn,
|
|||
|
|
.back-btn {
|
|||
|
|
width: 44px;
|
|||
|
|
height: 44px;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
.message {
|
|||
|
|
max-width: 85%;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
/* Keyboard-aware, safe-area-aware composer */
|
|||
|
|
.chat-input,
|
|||
|
|
.chat-closed-footer {
|
|||
|
|
padding: 12px 14px calc(12px + var(--safe-bottom) + var(--kb-offset, 0px));
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
</style>
|