feat(chat): 访客与客服聊天界面、历史拉取与断线重连
聊天页支持发送确认、历史补拉和重连;刷新后不重复展示已确认消息。
This commit is contained in:
1 parent
db48cac9e0
commit
4388e1d203
19 files changed
+6252
No files matched your search
@@ -0,0 +1,815 @@
|
||||
<!--
|
||||
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>
|
||||
Reference in new issue
Block a user