Files
replypier/frontend/src/components/conversations/ConversationsList.vue
T

815 lines
20 KiB
Vue
Raw Normal View History

<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import 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>