feat(chat): 访客与客服聊天界面、历史拉取与断线重连

聊天页支持发送确认、历史补拉和重连;刷新后不重复展示已确认消息。
This commit is contained in:
WangShen007 committed 2026-09-27 21:10:00 +08:00
1 parent db48cac9e0
commit 4388e1d203
19 files changed
+6252

No files matched your search

@@ -0,0 +1,53 @@
/*
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.
*/
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import HandlerBadge from '@/components/conversations/HandlerBadge.vue'
const mountBadge = (props: Record<string, unknown>) =>
mount(HandlerBadge, {
props,
global: { stubs: { 'font-awesome-icon': true } },
})
describe('HandlerBadge', () => {
it('labels an AI-handled chat', () => {
const badge = mountBadge({ chat: { status: 'open', user_id: null } })
expect(badge.text()).toBe('AI')
expect(badge.find('.handler-ai').exists()).toBe(true)
})
it('labels a chat queued for a human', () => {
const badge = mountBadge({ chat: { status: 'transferred', user_id: null } })
expect(badge.text()).toBe('等待人工客服')
expect(badge.find('.handler-waiting').exists()).toBe(true)
})
it('names the human holding the chat', () => {
const badge = mountBadge({
chat: { status: 'open', user_id: 'u1', user_name: 'Priya' },
currentUserId: 'u2',
})
expect(badge.text()).toBe('Priya')
expect(badge.find('.handler-human').exists()).toBe(true)
})
it('renders nothing for a closed chat, which the status pill already states', () => {
const badge = mountBadge({ chat: { status: 'closed', user_id: 'u1' } })
expect(badge.find('.handler-badge').exists()).toBe(false)
})
})
@@ -0,0 +1,48 @@
/*
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.
*/
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import MessageStatusTicks from '@/components/conversations/MessageStatusTicks.vue'
const stubs = { global: { stubs: { 'font-awesome-icon': true } } }
describe('MessageStatusTicks', () => {
it.each([
['sent', '已发送'],
['delivered', '已送达'],
['read', '已读'],
])('labels a %s message', (status, label) => {
const ticks = mount(MessageStatusTicks, { props: { status }, ...stubs })
expect(ticks.find('.status-ticks').attributes('aria-label')).toBe(label)
expect(ticks.find(`.status-ticks--${status}`).exists()).toBe(true)
})
it.each([null, undefined, 'failed', 'unknown'])(
'shows nothing for %s — a failure has its own notice',
(status) => {
const ticks = mount(MessageStatusTicks, { props: { status }, ...stubs })
expect(ticks.find('.status-ticks').exists()).toBe(false)
},
)
})
describe('MessageStatusTicks on an accent bubble', () => {
it('marks read with the inverted badge', () => {
const ticks = mount(MessageStatusTicks, { props: { status: 'read', onAccent: true }, ...stubs })
expect(ticks.find('.status-ticks--on-accent.status-ticks--read').exists()).toBe(true)
})
})
@@ -0,0 +1,54 @@
/*
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.
*/
import { describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import QuickReplyChips from '@/components/conversations/QuickReplyChips.vue'
import MessageTag from '@/components/conversations/MessageTag.vue'
const sent = (asText = false) => ({
as_text: asText,
choices: [{ id: 'qr_0', text: 'Today' }, { id: 'qr_1', text: 'Tomorrow' }]
})
describe('QuickReplyChips', () => {
it('lists the choices that were sent, in order', () => {
const chips = mount(QuickReplyChips, { props: { sent: sent() } })
expect(chips.findAll('.quick-reply-chip').map((c) => c.text())).toEqual(['Today', 'Tomorrow'])
expect(chips.find('.quick-reply-chips').attributes('aria-label')).toBe('已发送的选项')
})
it('says when the choices went out as a numbered list', () => {
const chips = mount(QuickReplyChips, { props: { sent: sent(true) } })
expect(chips.find('.quick-reply-chips').attributes('aria-label')).toContain('编号列表')
})
it('renders nothing without a record', () => {
expect(mount(QuickReplyChips, { props: { sent: null } }).find('.quick-reply-chips').exists()).toBe(false)
expect(mount(QuickReplyChips, { props: { sent: { choices: [] } } }).find('.quick-reply-chips').exists()).toBe(false)
})
})
describe('MessageTag', () => {
it('shows its icon and label', () => {
const tag = mount(MessageTag, {
props: { icon: 'fa-solid fa-plug', label: 'Sent by CoachIQ' },
global: { stubs: { 'font-awesome-icon': true } }
})
expect(tag.text()).toBe('Sent by CoachIQ')
expect(tag.find('font-awesome-icon-stub').attributes('icon')).toBe('fa-solid fa-plug')
})
})
@@ -0,0 +1,59 @@
/*
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.
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { routeChatToHuman } from '@/utils/chatActions'
import { chatService } from '@/services/chat'
vi.mock('@/services/chat', () => ({
chatService: { routeToHuman: vi.fn() },
}))
const toastSuccess = vi.fn()
const toastError = vi.fn()
vi.mock('vue-sonner', () => ({
toast: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}))
describe('routeChatToHuman', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('returns the updated chat the endpoint sends back', async () => {
const updated = { session_id: 's1', status: 'transferred', user_id: null }
vi.mocked(chatService.routeToHuman).mockResolvedValue(updated as never)
await expect(routeChatToHuman('s1')).resolves.toBe(updated)
expect(toastSuccess).toHaveBeenCalled()
expect(toastError).not.toHaveBeenCalled()
})
it('surfaces the server reason and returns null on failure', async () => {
vi.mocked(chatService.routeToHuman).mockRejectedValue({
response: { data: { detail: 'A human is already handling this chat' } },
})
await expect(routeChatToHuman('s1')).resolves.toBeNull()
expect(toastError).toHaveBeenCalledWith(
'转交会话失败',
expect.objectContaining({ description: 'A human is already handling this chat' })
)
})
})
@@ -0,0 +1,56 @@
/*
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.
*/
import { describe, expect, it } from 'vitest'
import { presenceLine } from '@/utils/widgetPresence'
describe('presenceLine', () => {
it('promises instant replies only while the AI is answering', () => {
const line = presenceLine({ mode: 'ai', available: true })
expect(line).toEqual({ text: '在线 · 立即回复', online: true })
})
it('ignores business hours for an AI agent — it answers at 3am too', () => {
expect(presenceLine({ mode: 'ai', available: false }).online).toBe(true)
})
it('sets a softer expectation when a person has to reply', () => {
const line = presenceLine({ mode: 'human', available: true })
expect(line.text).toBe('在线 · 通常几分钟内回复')
expect(line.online).toBe(true)
})
it('says away outside business hours instead of claiming to be online', () => {
const line = presenceLine({ mode: 'human', available: false })
expect(line.text).toContain('离开')
expect(line.online).toBe(false)
})
it('switches to the human wording once someone takes the chat over', () => {
// The agent still has AI enabled, but a person owns this conversation now.
expect(presenceLine({ mode: 'ai', available: true }, true).text).toBe(
'在线 · 通常几分钟内回复'
)
expect(presenceLine({ mode: 'ai', available: false }, true).online).toBe(false)
})
it('keeps the old wording when the payload predates presence', () => {
// An older cached widget page sends nothing — do not declare the team away
// on no evidence.
expect(presenceLine(undefined).text).toBe('在线 · 立即回复')
expect(presenceLine({}).text).toBe('在线 · 立即回复')
})
})
@@ -0,0 +1,189 @@
/*
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.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { mount, VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import ConversationsView from '../../views/ConversationsView.vue'
import { createPinia, setActivePinia } from 'pinia'
// Mock vue-router (component uses useRoute for deep-link query params)
vi.mock('vue-router', () => ({
useRoute: () => ({ query: {} }),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() })
}))
// Mock the chat service
vi.mock('@/services/chat', () => ({
chatService: {
getRecentChats: vi.fn(),
getChatDetail: vi.fn(),
takeoverChat: vi.fn(),
reassignChat: vi.fn()
}
}))
// Mock the components
vi.mock('@/layouts/DashboardLayout.vue', () => ({
default: {
name: 'DashboardLayout',
template: '<div class="dashboard-layout"><slot /></div>'
}
}))
vi.mock('@/components/conversations/ConversationsList.vue', () => ({
default: {
name: 'ConversationsList',
template: '<div class="conversations-list"></div>',
props: ['conversations', 'loading', 'error', 'loadedCount', 'totalCount', 'hasMore', 'statusFilter', 'showChatInfo'],
emits: ['refresh', 'update-filter', 'load-more', 'chat-updated', 'chat-selected', 'clear-unread']
}
}))
vi.mock('@/components/conversations/ConversationFilters.vue', () => ({
default: {
name: 'ConversationFilters',
template: '<div class="conversation-filters"></div>',
props: ['showFilters', 'filterValues', 'users', 'agents', 'loadingUsers', 'loadingAgents'],
emits: ['toggle', 'apply', 'clear', 'update:filterValues']
}
}))
vi.mock('@/components/conversations/ChatInfoPanel.vue', () => ({
default: {
name: 'ChatInfoPanel',
template: '<div class="chat-info-panel"></div>',
props: ['chatInfo', 'users'],
emits: ['close', 'refresh', 'chatUpdated', 'chatClosed']
}
}))
// Mock API service
vi.mock('@/services/api', () => ({
default: {
get: vi.fn().mockResolvedValue({ data: [] }),
post: vi.fn().mockResolvedValue({ data: {} }),
put: vi.fn().mockResolvedValue({ data: {} }),
delete: vi.fn().mockResolvedValue({ data: {} })
}
}))
// Mock agent service
vi.mock('@/services/agent', () => ({
agentService: {
getOrganizationAgents: vi.fn().mockResolvedValue([])
}
}))
// Import the mocked modules
import { chatService } from '@/services/chat'
describe('ConversationsView', () => {
let wrapper: VueWrapper
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
it('renders properly', () => {
wrapper = mount(ConversationsView)
expect(wrapper.find('.page-header').exists()).toBe(true)
expect(wrapper.find('h1').text()).toBe('会话')
})
it('shows loading state initially', () => {
wrapper = mount(ConversationsView)
const conversationsList = wrapper.findComponent({ name: 'ConversationsList' })
expect(conversationsList.props('loading')).toBe(true)
})
it('loads conversations on mount', async () => {
const mockConversations = [
{ id: '1', title: 'Chat 1' },
{ id: '2', title: 'Chat 2' }
]
;(chatService.getRecentChats as any).mockResolvedValue(mockConversations)
wrapper = mount(ConversationsView)
// Wait for the mounted hook and async operations to complete
await vi.waitFor(async () => {
await nextTick()
const conversationsList = wrapper.findComponent({ name: 'ConversationsList' })
return conversationsList.props('loading') === false
})
const conversationsList = wrapper.findComponent({ name: 'ConversationsList' })
expect(chatService.getRecentChats).toHaveBeenCalledWith({
status: 'open,transferred',
skip: 0,
limit: 20
})
expect(conversationsList.props('conversations')).toEqual(mockConversations)
expect(conversationsList.props('loading')).toBe(false)
})
it('handles loading error', async () => {
const errorMessage = '加载会话失败'
;(chatService.getRecentChats as any).mockRejectedValue(new Error('API Error'))
wrapper = mount(ConversationsView)
// Wait for the mounted hook and async operations to complete
await vi.waitFor(async () => {
await nextTick()
const conversationsList = wrapper.findComponent({ name: 'ConversationsList' })
return conversationsList.props('loading') === false
})
const conversationsList = wrapper.findComponent({ name: 'ConversationsList' })
expect(conversationsList.props('error')).toBe(errorMessage)
expect(conversationsList.props('loading')).toBe(false)
})
it('refreshes conversations when refresh event is emitted', async () => {
const mockConversations = [
{ id: '1', title: 'Chat 1' },
{ id: '2', title: 'Chat 2' }
]
;(chatService.getRecentChats as any).mockResolvedValue(mockConversations)
wrapper = mount(ConversationsView)
await vi.waitFor(async () => {
await nextTick()
const conversationsList = wrapper.findComponent({ name: 'ConversationsList' })
return conversationsList.props('loading') === false
})
// Clear the first call to getRecentChats from mounted
vi.clearAllMocks()
// Trigger refresh
const conversationsList = wrapper.findComponent({ name: 'ConversationsList' })
await conversationsList.vm.$emit('refresh')
// Wait for the refresh to complete
await vi.waitFor(async () => {
await nextTick()
return chatService.getRecentChats.mock.calls.length > 0
})
expect(chatService.getRecentChats).toHaveBeenCalled()
})
})
@@ -0,0 +1,826 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import type { ChatDetail } from '@/types/chat'
import { chatService } from '@/services/chat'
import { userService } from '@/services/user'
import { socketService } from '@/services/socket'
import { toast } from 'vue-sonner'
import LinkedTicketCard from '@/components/tickets/LinkedTicketCard.vue'
import { permissionChecks } from '@/utils/permissions'
import { canRequestRating, endChatMessage as endChatMessageFor } from '@/utils/endChat'
import { getInitials } from '@/utils/text'
import type { Teammate } from '@/services/users'
import { canTakeOverChat, chatAssignee, chatHandler } from '@/utils/chatState'
import { routeChatToHuman } from '@/utils/chatActions'
const canViewTickets = permissionChecks.canViewTickets()
interface Props {
chatInfo: ChatDetail | null
users: Teammate[]
isLoading?: boolean
}
interface Emits {
(e: 'close'): void
(e: 'refresh'): void
(e: 'chatUpdated', chatInfo: ChatDetail): void
(e: 'chatClosed', sessionId: string): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const showEndChatConfirm = ref(false)
const actionLoading = ref(false)
const reassigning = ref(false)
const showReassign = ref(false)
const selectedUserId = ref('')
// Reassigning is a manage-chat action; the parent supplies the teammate list.
const canReassign = permissionChecks.canTakeOverChats()
// Chat action functions
const currentUserId = userService.getUserId()
// Only the web widget can render a rating; on external channels (WhatsApp,
// Messenger, ...) the ask is dropped, so the confirmation must not promise it.
const askRating = computed(() => canRequestRating(props.chatInfo?.channel))
// Shared with the chat pane so both surfaces offer takeover on the same rule.
// This drops main's `status === 'transferred' && user_id !== currentUserId`
// arm deliberately: takeover_session returns False once user_id is set, so
// that arm only ever rendered a button that failed on click.
const canTakeOver = computed(
() => canTakeOverChat(props.chatInfo) && permissionChecks.canTakeOverChats()
)
// Only worth offering while the AI still has it: once a chat is queued or
// claimed there is no AI left to stop.
const canRouteToHuman = computed(
() => chatHandler(props.chatInfo, currentUserId).kind === 'ai'
&& permissionChecks.canTakeOverChats()
)
const canEndChat = computed(() => {
if (!props.chatInfo) return false
return (
props.chatInfo.status !== 'closed' &&
props.chatInfo.user_id === currentUserId
)
})
const handleRouteToHuman = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
const updated = await routeChatToHuman(props.chatInfo.session_id)
if (!updated) return
emit('chatUpdated', updated)
emit('refresh')
} finally {
actionLoading.value = false
}
}
const handleTakeover = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
await chatService.takeoverChat(props.chatInfo.session_id)
toast.success('已接管会话', {
description: '现在可以在本会话中发送消息',
duration: 4000,
closeButton: true
})
const userName = userService.getUserName()
const userId = userService.getUserId()
// Update local chat info state
const updatedChatInfo = {
...props.chatInfo,
status: 'open' as const,
user_id: userId,
user_name: userName
}
socketService.emit('taken_over', {
session_id: props.chatInfo.session_id,
user_name: userName,
profile_picture: userService.getCurrentUser()?.profile_pic || ''
})
// Emit updated chat info to parent components
emit('chatUpdated', updatedChatInfo)
// Refresh conversations list
emit('refresh')
} catch (err: any) {
console.error('Failed to takeover chat:', err)
toast.error('接管会话失败', {
description: err.response?.data?.detail || '请重试',
duration: 4000,
closeButton: true
})
} finally {
actionLoading.value = false
}
}
const handleEndChatRequest = () => {
showEndChatConfirm.value = true
}
const confirmEndChat = async () => {
if (!props.chatInfo) return
try {
actionLoading.value = true
const timestamp = new Date().toISOString()
const endChatMessage = {
message: endChatMessageFor(props.chatInfo.channel),
message_type: "system",
created_at: timestamp,
session_id: props.chatInfo.session_id,
end_chat: true,
request_rating: askRating.value,
end_chat_reason: "AGENT_REQUEST",
end_chat_description: "Agent manually ended the chat"
}
// Emit message through socket to end chat
socketService.emit('agent_message', {
message: endChatMessage.message,
session_id: props.chatInfo.session_id,
message_type: endChatMessage.message_type,
created_at: timestamp,
end_chat: true,
request_rating: askRating.value,
end_chat_reason: "AGENT_REQUEST",
end_chat_description: "Agent manually ended the chat"
})
toast.success('会话已结束', {
description: askRating.value ? '将邀请客户进行评价' : '会话已关闭',
duration: 4000,
closeButton: true
})
showEndChatConfirm.value = false
// Refresh conversations list
emit('refresh')
// Inform parent to close chat views
emit('chatClosed', props.chatInfo.session_id)
} catch (err: any) {
console.error('Failed to end chat:', err)
toast.error('结束会话失败', {
description: err.response?.data?.detail || '请重试',
duration: 4000,
closeButton: true
})
} finally {
actionLoading.value = false
}
}
const cancelEndChat = () => {
showEndChatConfirm.value = false
}
// Who holds the chat, sharing chatAssignee with the inbox badge and the chat
// pane so all three agree. Only the unassigned wording is panel-specific: it
// names the AI agent rather than saying a flat "AI".
const assignedTo = computed(
() => chatAssignee(props.chatInfo, currentUserId) || props.chatInfo?.agent?.name || 'AI 客服'
)
// Helper function to format date
const formatDate = (dateString: string): string => {
const date = new Date(dateString)
return date.toLocaleDateString() + ' ' + date.toLocaleTimeString()
}
// Backend status enum stays English in the API; only the label is localised.
const statusLabels: Record<string, string> = {
open: '进行中',
closed: '已结束',
transferred: '已转交',
pending: '等待中'
}
const statusLabel = (status: string) => statusLabels[status] || status
// Integrator-supplied fields (e.g. student_name, center_name) passed via
// POST /generate-token's `custom_data`. Shown as a label/value list below the
// customer's name and email; snake_case keys are prettified for display.
const metaDataEntries = computed(() => {
const metaData = props.chatInfo?.customer?.meta_data
if (!metaData || typeof metaData !== 'object') return []
return Object.entries(metaData)
.filter(([, value]) => value !== null && value !== undefined && value !== '')
.map(([key, value]) => ({
key,
label: key.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()),
value: typeof value === 'object' ? JSON.stringify(value) : String(value)
}))
})
// Initials for the mobile hero avatar
const customerInitials = computed(() =>
getInitials(props.chatInfo?.customer?.full_name || props.chatInfo?.customer?.email)
)
// The teammate list arrives as a prop (ConversationsView loads it once from
// /users/teammates). This component used to fetch /users itself into a local
// ref that shadowed the prop — which needed manage_users, so for an agent the
// dropdown was always empty even though the API would have allowed the
// reassign.
const openReassign = () => {
showReassign.value = true
}
const cancelReassign = () => {
showReassign.value = false
selectedUserId.value = ''
}
const confirmReassign = async () => {
if (!props.chatInfo || !selectedUserId.value) return
try {
reassigning.value = true
const updated = await chatService.reassignChat(props.chatInfo.session_id, selectedUserId.value)
toast.success('会话已重新分配')
emit('chatUpdated', updated)
emit('refresh')
showReassign.value = false
} catch (err: any) {
console.error('Failed to reassign chat:', err)
toast.error('重新分配失败', { description: err.response?.data?.detail || '请重试' })
} finally {
reassigning.value = false
}
}
</script>
<template>
<div v-if="chatInfo" class="chat-info-sidebar">
<div class="chat-info-header">
<h3>会话信息</h3>
<button @click="emit('close')" class="close-btn" aria-label="关闭会话信息">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18"/>
<line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<div class="chat-info-content">
<!-- Mobile-only hero per the app design; hidden on desktop -->
<div class="customer-hero">
<div class="customer-avatar">{{ customerInitials }}</div>
<div class="customer-hero-name">{{ chatInfo.customer.full_name || chatInfo.customer.email || '客户' }}</div>
<div v-if="chatInfo.customer.email" class="customer-hero-email">{{ chatInfo.customer.email }}</div>
</div>
<div class="info-section">
<h4>客户</h4>
<div class="info-item">
<span class="label">姓名:</span>
<span class="value">{{ chatInfo.customer.full_name || '暂无' }}</span>
</div>
<div class="info-item">
<span class="label">邮箱:</span>
<span class="value">{{ chatInfo.customer.email || '暂无' }}</span>
</div>
<div v-for="entry in metaDataEntries" :key="entry.key" class="info-item">
<span class="label">{{ entry.label }}:</span>
<span class="value">{{ entry.value }}</span>
</div>
</div>
<div v-if="canViewTickets && chatInfo.session_id" class="info-section">
<LinkedTicketCard :session-id="chatInfo.session_id" />
</div>
<div class="info-section">
<h4>分配</h4>
<div class="info-item">
<span class="label">分配给:</span>
<span class="value">{{ assignedTo }}</span>
</div>
</div>
<div class="info-section">
<h4>时间线</h4>
<div class="info-item">
<span class="label">创建时间:</span>
<span class="value">{{ formatDate(chatInfo.created_at) }}</span>
</div>
<div class="info-item">
<span class="label">更新时间:</span>
<span class="value">{{ formatDate(chatInfo.updated_at) }}</span>
</div>
</div>
<div class="info-section">
<h4>状态</h4>
<div class="info-item">
<span class="label">当前状态:</span>
<span class="value status-badge" :class="chatInfo.status">
{{ statusLabel(chatInfo.status) }}
</span>
</div>
<div class="info-item">
<span class="label">消息数:</span>
<span class="value">{{ chatInfo.messages.length }}</span>
</div>
</div>
<!-- Chat Actions Section -->
<div class="info-section">
<h4>操作</h4>
<div class="chat-actions">
<button
v-if="canTakeOver"
class="action-btn takeover-btn"
:disabled="actionLoading"
@click="handleTakeover"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/>
<path d="M13.73 21a2 2 0 0 1-3.46 0"/>
</svg>
{{ actionLoading ? '接管中…' : '接管会话' }}
</button>
<!-- Stops the AI without making the chat yours. -->
<button
v-if="canRouteToHuman"
class="action-btn"
:disabled="actionLoading"
@click="handleRouteToHuman"
>
<font-awesome-icon icon="fa-solid fa-user-group" />
转给团队
</button>
<button
v-if="canEndChat"
class="action-btn end-chat-btn"
:disabled="actionLoading"
@click="handleEndChatRequest"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>
<polyline points="16,17 21,12 16,7"/>
<line x1="21" y1="12" x2="9" y2="12"/>
</svg>
结束会话
</button>
<button
v-if="canReassign && chatInfo.status === 'open' && chatInfo.user_id && users.length"
class="action-btn"
:disabled="reassigning"
@click="openReassign"
>
重新分配
</button>
</div>
</div>
</div>
<!-- End Chat Confirmation Modal -->
<div v-if="showEndChatConfirm" class="end-chat-modal">
<div class="end-chat-modal-content">
<h3>结束会话</h3>
<p v-if="askRating">确定要结束此会话并邀请客户评价吗?</p>
<p v-else>确定要结束此会话吗?</p>
<div class="end-chat-modal-actions">
<button class="cancel-btn" @click="cancelEndChat">取消</button>
<button class="confirm-btn" @click="confirmEndChat" :disabled="actionLoading">
{{ actionLoading ? '结束中…' : (askRating ? '结束会话并请求评价' : '结束会话') }}
</button>
</div>
</div>
</div>
<!-- Reassign Modal -->
<div v-if="showReassign" class="end-chat-modal">
<div class="end-chat-modal-content">
<h3>重新分配会话</h3>
<p>选择要分配此会话的用户。</p>
<div style="margin-bottom: 12px;">
<select v-model="selectedUserId" class="filter-input" style="width: 100%;">
<option value="" disabled>选择用户</option>
<option v-for="u in users" :key="u.id" :value="u.id">{{ u.full_name }} ({{ u.email }})</option>
</select>
</div>
<div class="end-chat-modal-actions">
<button class="cancel-btn" @click="cancelReassign">取消</button>
<button class="confirm-btn" :disabled="!selectedUserId || reassigning" @click="confirmReassign">
{{ reassigning ? '分配中…' : '确认分配' }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.chat-info-sidebar {
background: var(--bg2);
border-left: 1px solid var(--o08);
overflow-y: auto;
animation: slideInRight 0.3s ease-out;
}
@keyframes slideInRight {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.chat-info-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: var(--space-md) var(--space-lg);
border-bottom: 1px solid var(--o07);
background: var(--bg2);
}
.chat-info-header h3 {
margin: 0;
font-family: var(--font-display);
font-size: 16px;
font-weight: 600;
color: var(--text);
}
.close-btn {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid var(--o10);
border-radius: 8px;
background: var(--o05);
color: var(--muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.close-btn:hover {
background: var(--o10);
color: var(--text);
}
.chat-info-content {
padding: var(--space-lg);
}
/* Mobile-only customer hero (hidden on desktop) */
.customer-hero {
display: none;
flex-direction: column;
align-items: center;
text-align: center;
padding: var(--space-sm) 0 var(--space-lg);
border-bottom: 1px solid var(--o07);
margin-bottom: var(--space-lg);
}
.customer-avatar {
width: 84px;
height: 84px;
border-radius: 50%;
background: var(--grad-purple-teal);
color: var(--on-light);
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-size: 30px;
font-weight: var(--font-weight-bold);
margin-bottom: 14px;
}
.customer-hero-name {
font-family: var(--font-display);
font-size: 22px;
font-weight: var(--font-weight-bold);
letter-spacing: var(--tracking-display);
color: var(--text);
}
.customer-hero-email {
font-size: 14px;
color: var(--muted);
margin-top: 4px;
}
.info-section {
margin-bottom: var(--space-lg);
}
.info-section:last-child {
margin-bottom: 0;
}
.info-section h4 {
margin: 0 0 var(--space-md) 0;
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
color: var(--faint);
text-transform: uppercase;
letter-spacing: 0.08em;
border-bottom: 1px solid var(--o07);
padding-bottom: var(--space-xs);
}
.info-item {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: var(--space-sm);
gap: var(--space-sm);
}
.info-item:last-child {
margin-bottom: 0;
}
.info-item .label {
font-size: 12px;
font-weight: 500;
color: var(--muted);
min-width: 80px;
flex-shrink: 0;
}
.info-item .value {
font-size: 13px;
color: var(--text);
text-align: right;
word-break: break-word;
flex: 1;
}
.status-badge {
padding: 4px 8px;
border-radius: var(--radius-full);
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
}
/* Higher specificity than ".info-item .value" so the pill hugs the status text
instead of inheriting .value's flex:1 and stretching its background across
the rest of the row. */
.info-item .value.status-badge {
flex: none;
}
.status-badge.open {
background: rgba(201,242,78,.12);
color: var(--accent-ink);
}
.status-badge.closed {
background: var(--o06);
color: var(--muted);
}
.status-badge.transferred {
background: var(--warning-bg);
color: var(--warning-color);
}
/* Chat Actions */
.chat-actions {
display: flex;
flex-direction: column;
gap: var(--space-sm);
}
.action-btn {
display: flex;
align-items: center;
gap: var(--space-xs);
padding: var(--space-sm) var(--space-md);
border: 1px solid var(--o10);
border-radius: 10px;
background: var(--o05);
color: var(--text3);
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
text-align: left;
width: 100%;
}
.action-btn:hover:not(:disabled) {
background: var(--o10);
border-color: var(--o14);
color: var(--text);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.takeover-btn {
border-color: rgba(201,242,78,.3);
color: var(--accent-ink);
}
.takeover-btn:hover:not(:disabled) {
background: rgba(201,242,78,.1);
}
.end-chat-btn {
border-color: rgba(255,138,115,.3);
color: var(--c-coral);
}
.end-chat-btn:hover:not(:disabled) {
background: rgba(255,138,115,.1);
}
/* End Chat Modal */
.end-chat-modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
animation: fadeIn 0.2s ease-out;
}
@keyframes fadeIn {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.end-chat-modal-content {
background: var(--surface);
border: 1px solid var(--o10);
border-radius: 20px;
padding: var(--space-xl);
max-width: 400px;
width: 90%;
box-shadow: var(--shadow-lg);
animation: slideInUp 0.3s ease-out;
}
@keyframes slideInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.end-chat-modal-content h3 {
margin: 0 0 var(--space-md) 0;
font-family: var(--font-display);
font-size: 18px;
font-weight: 600;
color: var(--text);
}
.end-chat-modal-content p {
margin: 0 0 var(--space-lg) 0;
color: var(--muted);
line-height: 1.5;
}
.end-chat-modal-actions {
display: flex;
gap: var(--space-sm);
justify-content: flex-end;
}
.cancel-btn, .confirm-btn {
padding: var(--space-sm) var(--space-lg);
border-radius: var(--radius-sm);
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all var(--transition-fast);
border: 1px solid transparent;
min-width: 80px;
}
.cancel-btn {
background: var(--o06);
color: var(--text3);
border-color: var(--o10);
}
.cancel-btn:hover {
background: var(--o10);
color: var(--text);
}
/* Mobile: full-screen "Details" page (class applied by ConversationsView) */
@media (max-width: 768px) {
.chat-info-sidebar.mobile-fullscreen {
position: fixed;
inset: 0;
z-index: var(--z-fullscreen-page);
height: 100vh;
height: 100dvh;
border-left: none;
background: var(--bg);
display: flex;
flex-direction: column;
}
.chat-info-sidebar.mobile-fullscreen .chat-info-header {
padding-top: calc(var(--space-md) + var(--safe-top));
}
.chat-info-sidebar.mobile-fullscreen .chat-info-content {
flex: 1;
overflow-y: auto;
padding-bottom: calc(var(--space-lg) + var(--safe-bottom));
}
.chat-info-sidebar.mobile-fullscreen .customer-hero {
display: flex;
}
.chat-info-sidebar.mobile-fullscreen .close-btn {
width: 44px;
height: 44px;
}
.chat-info-sidebar.mobile-fullscreen .action-btn {
min-height: 48px;
font-size: 15px;
}
}
.confirm-btn {
background: var(--error-color);
color: white;
border-color: transparent;
}
.confirm-btn:hover:not(:disabled) {
filter: brightness(1.1);
transform: translateY(-1px);
}
.confirm-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
</style>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,466 @@
<!--
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 { 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 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>
@@ -0,0 +1,82 @@
<!--
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 { 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 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">
// 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 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">
/**
* 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 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { 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 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">
/**
* 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>
@@ -0,0 +1,449 @@
/*
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.
*/
import { ref, computed, watch, nextTick, onMounted, onBeforeUnmount } from 'vue'
import type { ChatDetail, Message } from '@/types/chat'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { chatService } from '@/services/chat'
import { userService } from '@/services/user'
import { socketService } from '@/services/socket'
import { toast } from 'vue-sonner'
import { canRequestRating, endChatMessage as endChatMessageFor } from '@/utils/endChat'
import { canTakeOverChat, chatHandler } from '@/utils/chatState'
import { routeChatToHuman } from '@/utils/chatActions'
import { permissionChecks } from '@/utils/permissions'
import { findMessage, newClientId } from '@/utils/messageMatch'
import { useOrgInboxRoom } from '@/composables/useOrgInboxRoom'
// Define valid chat statuses
type ChatStatus = 'open' | 'closed' | 'transferred'
/** Emitted by the backend when a reply was saved but never reached the customer. */
interface DeliveryErrorEvent {
error?: string
type?: string
session_id?: string
can_template?: boolean
client_id?: string | null
message_id?: number
}
/** Tells the sender which stored row their message became. */
interface AgentMessageStoredEvent {
session_id?: string
client_id?: string | null
message_id?: number
}
/** A channel's delivery receipt for a stored message (sent → delivered → read). */
interface MessageStatusEvent {
session_id?: string
message_id?: number
external_message_id?: string | null
channel_status?: Message['channel_status']
delivery_status?: string | null
}
export function useConversationChat(
initialChat: ChatDetail,
emit: {
(event: 'refresh'): void
(event: 'chatUpdated', data: ChatDetail): void
(event: 'clearUnread', sessionId: string): void
}
) {
const chat = ref<ChatDetail>(initialChat)
// Delivery receipts for chats no agent owns arrive through the org room
useOrgInboxRoom()
const newMessage = ref('')
const messagesContainer = ref<HTMLElement | null>(null)
const isLoading = ref(false)
const currentUserId = userService.getUserId()
// Set once a send is refused because the messaging window closed and a
// template would reopen it. The frontend can't tell the window has expired on
// its own — it never sees the customer's last inbound time — so this is only
// known after the backend refuses a send.
const templateCanReopen = ref(false)
// Claimable and allowed to claim. Shared with ChatInfoPanel via
// canTakeOverChat so the chat screen and the info panel can't disagree —
// this pane used to require status 'transferred', which left an AI-handled
// chat claimable only from the info panel.
const showTakeoverButton = computed(
() => canTakeOverChat(chat.value) && permissionChecks.canTakeOverChats()
)
// Who is answering the customer. Derived once, shared with the inbox list and
// the info panel through chatHandler, so the three surfaces cannot disagree.
const handler = computed(() => chatHandler(chat.value, currentUserId))
const handledByAI = computed(() => handler.value.kind === 'ai')
// Nobody is answering yet: the AI queued it, or this agent never uses AI.
const isWaitingForHuman = computed(() => handler.value.kind === 'waiting')
const isChatClosed = computed(() => handler.value.kind === 'closed')
// A colleague holds it: we can watch, but not reply.
const showTakenOverStatus = computed(
() => handler.value.kind === 'human' && chat.value.user_id !== currentUserId
)
/** One sentence explaining why the composer is unavailable, per handler. */
const handlerCaption = computed(() => {
switch (handler.value.kind) {
case 'ai':
return '此会话正由 AI 客服处理'
case 'waiting':
// Deliberately not "the AI handed this over": with AI switched off on
// the agent there was never an AI in the conversation to hand it on.
return '此会话正在等待客服接手'
case 'closed':
return '此会话已结束'
default:
return `此会话正由 ${handler.value.label} 处理`
}
})
const canSendMessage = computed(() => {
// Cannot send if chat is closed
if (isChatClosed.value) return false
// Cannot send if needs takeover or taken by another user
return !showTakeoverButton.value && !showTakenOverStatus.value
})
const scrollToBottom = async () => {
// Wait for the new message to render, otherwise scrollHeight is still the
// pre-append height and the list stops short of the bottom.
await nextTick()
if (messagesContainer.value) {
messagesContainer.value.scrollTop = messagesContainer.value.scrollHeight
}
}
// Watch for changes in messages and scroll to bottom
watch(() => chat.value.messages, (messages) => {
scrollToBottom()
// A customer message reopens the messaging window, so the closed-window
// notice must go — otherwise it sits above a composer that now works.
if (messages?.[messages.length - 1]?.message_type === 'user') {
templateCanReopen.value = false
}
}, { deep: true })
// The same composable instance serves whichever chat is selected, so a
// closed window learned about in one conversation must not follow us to the
// next. Sending a template reopens the window, which also clears it.
watch(() => chat.value.session_id, () => {
templateCanReopen.value = false
})
const clearTemplateSuggestion = () => {
templateCanReopen.value = false
}
const updateChat = (newChat: ChatDetail) => {
chat.value = { ...newChat }
// Emit event to clear unreads
emit('clearUnread', newChat.session_id)
// Emit chatUpdated event to ensure the parent component updates
emit('chatUpdated', chat.value)
}
// Replace chat state from parent props without emitting events
const replaceChatFromProps = (newChat: ChatDetail) => {
chat.value = { ...newChat }
}
const sendMessage = async () => {
if (!newMessage.value.trim() || !canSendMessage.value) return
try {
const messageText = newMessage.value
// Clear input immediately for better UX
newMessage.value = ''
// Add message locally first
const timestamp = new Date().toISOString()
// Ties this optimistic message to the row the backend stores, so its
// delivery receipts and errors find it exactly (agent_message_stored)
const clientId = newClientId()
const localMessage: Message = {
message: messageText,
message_type: 'agent',
created_at: timestamp,
session_id: chat.value.session_id,
client_id: clientId
}
// Update chat with new message
chat.value.messages.push(localMessage)
chat.value.updated_at = timestamp
// Emit message through socket
socketService.emit('agent_message', {
message: messageText,
session_id: chat.value.session_id,
message_type: 'agent',
created_at: timestamp,
client_id: clientId
})
scrollToBottom()
} catch (err) {
console.error('Failed to send message:', err)
toast.error('消息发送失败', {
description: '请重试',
duration: 4000,
closeButton: true
})
}
}
const handleTakeover = async () => {
try {
isLoading.value = true
await chatService.takeoverChat(chat.value.session_id)
// Show success toast
toast.success('已接管会话', {
description: '现在可以在本会话中发送消息',
duration: 4000,
closeButton: true
})
const userName = userService.getUserName()
const userId = userService.getUserId()
// Update local chat state
chat.value = {
...chat.value,
status: 'open',
user_id: userId,
user_name: userName
}
socketService.emit('taken_over', { session_id: chat.value.session_id, user_name: userName, profile_picture: userService.getCurrentUser()?.profile_pic ? userService.getCurrentUser()?.profile_pic : '' })
// Emit refresh event to update chat status
emit('refresh')
// Also emit chatUpdated event to ensure the parent component updates
emit('chatUpdated', chat.value)
} catch (err: any) {
console.error('Failed to takeover chat:', err)
// Show error toast with specific message if available
toast.error('接管会话失败', {
description: err.response?.data?.detail || '请重试',
duration: 4000,
closeButton: true
})
} finally {
isLoading.value = false
}
}
const handleRouteToHuman = async () => {
try {
isLoading.value = true
const updated = await routeChatToHuman(chat.value.session_id)
if (!updated) return
// The endpoint returns the whole thread back, so take its state rather
// than guessing at a local patch.
chat.value = updated
emit('refresh')
emit('chatUpdated', chat.value)
} finally {
isLoading.value = false
}
}
// Add endChat function
const endChat = async (requestRating = true) => {
try {
isLoading.value = true
// Only the web widget can render a rating; on external channels the ask
// is dropped and the closing message says nothing about rating.
const askRating = requestRating && canRequestRating(chat.value.channel)
// Create end chat message
const timestamp = new Date().toISOString()
const clientId = newClientId()
const endChatMessage = {
message: endChatMessageFor(chat.value.channel),
message_type: "system",
created_at: timestamp,
session_id: chat.value.session_id,
client_id: clientId,
end_chat: true,
request_rating: askRating,
end_chat_reason: "AGENT_REQUEST",
end_chat_description: "Agent manually ended the chat"
}
// Add message locally
chat.value.messages.push(endChatMessage)
// Update chat status locally
chat.value.status = 'closed'
chat.value.updated_at = timestamp
// Emit message through socket to end chat
socketService.emit('agent_message', {
message: endChatMessage.message,
session_id: chat.value.session_id,
message_type: endChatMessage.message_type,
created_at: timestamp,
client_id: clientId,
end_chat: true,
request_rating: askRating,
end_chat_reason: "AGENT_REQUEST",
end_chat_description: "Agent manually ended the chat"
})
// Show success toast
toast.success('会话已结束', {
description: askRating ? '将邀请客户进行评价' : '会话已关闭',
duration: 4000,
closeButton: true
})
// Emit refresh event to update chat status
emit('refresh')
emit('chatUpdated', chat.value)
scrollToBottom()
} catch (err) {
console.error('Failed to end chat:', err)
toast.error('结束会话失败', {
description: '请重试',
duration: 4000,
closeButton: true
})
} finally {
isLoading.value = false
}
}
// On external channels a send can fail after the message is already stored
// (typically once WhatsApp's 24h window closes). Without this the agent sees
// their message sitting in the thread and assumes it arrived. The event names
// the exact message; one not on screen is left for the next refetch.
const handleDeliveryError = (data: DeliveryErrorEvent) => {
if (data?.type !== 'delivery_error' || data.session_id !== chat.value.session_id) return
const failed = findMessage(chat.value.messages, { messageId: data.message_id, clientId: data.client_id })
if (failed) {
failed.attributes = { ...failed.attributes, delivery_status: 'failed' }
}
if (data.can_template) templateCanReopen.value = true
toast.error('消息未送达', {
description: data.error,
duration: 6000,
closeButton: true
})
}
// An optimistic message learns its row id, which receipts are keyed by
const handleAgentMessageStored = (data: AgentMessageStoredEvent) => {
if (data?.session_id !== chat.value.session_id || data.message_id == null) return
const message = findMessage(chat.value.messages, { clientId: data.client_id })
if (message) message.id = data.message_id
}
const handleMessageStatus = (data: MessageStatusEvent) => {
if (data?.session_id !== chat.value.session_id) return
const message = findMessage(chat.value.messages, {
messageId: data.message_id,
externalId: data.external_message_id
})
if (!message) return
message.external_message_id = data.external_message_id ?? message.external_message_id
message.channel_status = data.channel_status
if (data.delivery_status) {
message.attributes = { ...message.attributes, delivery_status: data.delivery_status }
}
}
const setupSocketListeners = () => {
socketService.on('error', handleDeliveryError)
socketService.on('agent_message_stored', handleAgentMessageStored)
socketService.on('message_status', handleMessageStatus)
}
const cleanupSocketListeners = () => {
socketService.off('error', handleDeliveryError)
socketService.off('agent_message_stored', handleAgentMessageStored)
socketService.off('message_status', handleMessageStatus)
}
const handleSocketReconnect = () => {
cleanupSocketListeners()
setupSocketListeners()
}
onMounted(() => {
setupSocketListeners()
socketService.onReconnect(handleSocketReconnect)
})
onBeforeUnmount(() => {
cleanupSocketListeners()
socketService.offReconnect(handleSocketReconnect)
})
const formattedMessages = computed(() => {
const formatted = chat.value.messages.map(msg => ({
...msg,
timeAgo: formatDistanceToNow(new Date(msg.created_at), { addSuffix: true, locale: zhCN })
}))
// Debug: Log messages with attachments
formatted.forEach((msg, idx) => {
if (msg.attachments && msg.attachments.length > 0) {
console.log(`[Message ${idx}] Has ${msg.attachments.length} attachments:`, msg.attachments)
}
})
return formatted
})
return {
newMessage,
messagesContainer,
formattedMessages,
isLoading,
showTakeoverButton,
showTakenOverStatus,
isChatClosed,
canSendMessage,
scrollToBottom,
sendMessage,
handleTakeover,
handleRouteToHuman,
isWaitingForHuman,
handlerCaption,
updateChat,
replaceChatFromProps,
handledByAI,
endChat,
templateCanReopen,
clearTemplateSuggestion
}
}
@@ -0,0 +1,253 @@
/*
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.
*/
import { ref, computed, watch, onMounted, onBeforeUnmount, reactive } from 'vue'
import type { Conversation, ChatDetail, Message } from '@/types/chat'
import { formatDistanceToNow } from 'date-fns'
import { zhCN } from 'date-fns/locale'
import { chatService } from '@/services/chat'
import { socketService } from '@/services/socket'
import { userService } from '@/services/user'
export function useConversationsList(props: {
conversations: Conversation[]
loading: boolean
error: string
hasMore?: boolean
loadingMore?: boolean
}, emit: {
(e: 'refresh'): void
(e: 'chatUpdated', data: ChatDetail): void
(e: 'clearUnread', sessionId: string): void
(e: 'updateFilter', status: 'open' | 'closed'): void
(e: 'loadMore'): void
}) {
const selectedChat = ref<ChatDetail | null>(null)
const selectedId = ref<string | null>(null)
const chatLoading = ref<boolean>(false)
const currentUserId = ref(userService.getUserId())
const unreadMessages = reactive<Record<string, number>>({})
const processedMessages = reactive<Set<string>>(new Set())
// Socket event handlers
const handleChatReply = (data: {
message: string
type: string
agent_name?: string
created_at: string
session_id: string
attributes?: any
attachments?: any[]
}) => {
// Create unique message identifier
const messageKey = `${data.session_id}-${data.created_at}`;
// Skip if already processed
if (processedMessages.has(messageKey)) return;
processedMessages.add(messageKey);
// Add unread counter if not current chat
if (selectedId.value !== data.session_id) {
if (!unreadMessages[data.session_id]) {
unreadMessages[data.session_id] = 0
}
unreadMessages[data.session_id]++
}
if (selectedChat.value && selectedId.value === data.session_id) {
// Ensure created_at is a valid ISO string
const created_at = new Date(data.created_at).toISOString()
// Base message structure
const newMessage: Message = {
message: data.message,
message_type: data.type === 'agent_message' ? 'agent' : data.type,
created_at,
session_id: data.session_id,
attributes: data.attributes || {},
agent_name: data.agent_name,
attachments: data.attachments || undefined
}
// Check if message has Shopify data in attributes
if (data.attributes?.shopify_output && typeof data.attributes.shopify_output === 'object') {
newMessage.message_type = 'product'
newMessage.shopify_output = data.attributes.shopify_output
}
// Create a completely new chat object with all properties
const updatedChat: ChatDetail = {
...selectedChat.value,
messages: [...(selectedChat.value.messages || []), newMessage],
updated_at: created_at,
customer: { ...selectedChat.value.customer },
agent: { ...selectedChat.value.agent },
status: selectedChat.value.status,
user_id: selectedChat.value.user_id,
user_name: selectedChat.value.user_name,
session_id: selectedChat.value.session_id,
}
// Use Promise.resolve().then() to break the reactive chain
// This prevents the recursive update error by deferring the state update
Promise.resolve().then(() => {
// Update the selected chat with the new object
selectedChat.value = updatedChat
// Emit chat update event in a separate tick
Promise.resolve().then(() => {
emit('chatUpdated', updatedChat)
})
})
}
}
const handleRoomEvent = (data: unknown) => {
console.log('Room event received:', data)
// You can handle join/leave events here if needed
}
const setupSocketListeners = () => {
socketService.on('chat_reply', handleChatReply)
socketService.on('room_event', handleRoomEvent)
}
const cleanupSocketListeners = () => {
socketService.off('chat_reply', handleChatReply)
socketService.off('room_event', handleRoomEvent)
}
// Handle socket reconnection
const handleSocketReconnect = () => {
console.log('Socket reconnected, re-establishing listeners and room')
cleanupSocketListeners()
setupSocketListeners()
// Rejoin room if necessary
const userId = userService.getUserId()
if (userId) {
socketService.emit('join_room', { session_id: `user_${userId}` })
}
}
/**
* @param force refetch even when this chat is already open — the Refresh
* button. Live socket updates only arrive once a human takes a chat over;
* while the AI is answering, this is how an agent pulls the latest turns.
*/
const loadChatDetail = async (sessionId: string, force = false) => {
try {
// Only load full chat detail if it's a new chat or not loaded yet
if (force || !selectedChat.value || selectedChat.value.session_id !== sessionId) {
chatLoading.value = true
selectedId.value = sessionId
const detail = await chatService.getChatDetail(sessionId)
// Debug: Log if messages have attachments
if (detail && detail.messages) {
detail.messages.forEach((msg, idx) => {
if (msg.attachments && msg.attachments.length > 0) {
console.log(`[API Response] Message ${idx} has ${msg.attachments.length} attachments`)
}
})
}
selectedChat.value = detail
// Clear unread messages for this chat
if (unreadMessages[sessionId]) {
delete unreadMessages[sessionId]
emit('clearUnread', sessionId)
}
}
} catch (err) {
console.error('Failed to load chat:', err)
} finally {
chatLoading.value = false
}
}
// Watch for conversations changes and load first conversation
watch(() => props.conversations, (newConversations) => {
if (newConversations.length > 0 && !selectedId.value) {
// Use Promise.resolve().then() to break the reactive chain
// This prevents the recursive update error
Promise.resolve().then(() => {
if (!selectedId.value) {
// loadChatDetail(newConversations[0].session_id)
}
})
}
}, { immediate: true })
const formattedConversations = computed(() => {
// Ensure conversations is an array before mapping
if (!Array.isArray(props.conversations)) {
return []
}
return props.conversations.map(conv => ({
...conv,
timeAgo: formatDistanceToNow(new Date(conv.updated_at), { addSuffix: true, locale: zhCN }),
message_type: conv.attributes?.message_type || 'text',
shopify_output: conv.attributes?.shopify_output
}))
})
// Connect to socket and setup listeners on mount
onMounted(() => {
console.log('Connecting to socket')
socketService.connect()
setupSocketListeners()
socketService.onReconnect(handleSocketReconnect)
// Join user-specific room
const userId = userService.getUserId()
if (userId) {
socketService.emit('join_room', { session_id: `user_${userId}` })
}
})
// Cleanup on unmount
onBeforeUnmount(() => {
console.log('Disconnecting from socket')
const userId = userService.getUserId()
if (userId) {
socketService.emit('leave_room', { session_id: `user_${userId}` })
}
cleanupSocketListeners()
socketService.offReconnect(handleSocketReconnect)
})
// Add method to clear unreads
const clearUnread = (sessionId: string) => {
delete unreadMessages[sessionId]
}
return {
selectedChat,
selectedId,
chatLoading,
formattedConversations,
loadChatDetail,
unreadMessages,
clearUnread
}
}
@@ -0,0 +1,46 @@
/*
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.
Joins the per-org inbox room on the /agent namespace, where live updates
about any of the org's conversations arrive (delivery receipts on
AI-handled chats have no agent of their own to address). Only for people who
can see chats nobody owns — the backend refuses everyone else.
*/
import { onBeforeUnmount, onMounted } from 'vue'
import { socketService } from '@/services/socket'
import { userService } from '@/services/user'
import { permissionChecks } from '@/utils/permissions'
export function useOrgInboxRoom() {
const orgId = userService.getCurrentUser()?.organization_id
const room = orgId && permissionChecks.canViewUnassignedChats() ? `org_inbox_${orgId}` : null
const joinRoom = () => {
if (room) socketService.emit('join_room', { session_id: room })
}
onMounted(() => {
if (!room) return
joinRoom()
socketService.onReconnect(joinRoom)
})
onBeforeUnmount(() => {
if (!room) return
socketService.emit('leave_room', { session_id: room })
socketService.offReconnect(joinRoom)
})
}
+606
View File
@@ -0,0 +1,606 @@
<!--
Copyright 2024-2026 ChatterMate
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { ref, onMounted, computed, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import DashboardLayout from '@/layouts/DashboardLayout.vue'
import ConversationsList from '@/components/conversations/ConversationsList.vue'
import ConversationFilters from '@/components/conversations/ConversationFilters.vue'
import ChatInfoPanel from '@/components/conversations/ChatInfoPanel.vue'
import type { Conversation, ChatDetail } from '@/types/chat'
import { chatService } from '@/services/chat'
import { agentService } from '@/services/agent'
import { listTeammates, type Teammate } from '@/services/users'
import channelsService, { type ChannelAccount } from '@/services/channels'
import NewWhatsAppConversation from '@/components/conversations/NewWhatsAppConversation.vue'
import { useBreakpoint } from '@/composables/useBreakpoint'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
const route = useRoute()
const router = useRouter()
const { isMobile } = useBreakpoint()
const { hasEnterpriseModule, enterpriseComponent, moduleImports } = useEnterpriseFeatures()
const PromoInboxCard = enterpriseComponent(moduleImports.promoUsageCard)
// Deep-link target session (e.g. from analytics "Sessions Needing Attention")
const initialSessionId = ref<string | null>(
typeof route.query.session === 'string' ? route.query.session : null
)
// On mobile the ?session= query drives which pane is visible (list vs chat) so
// the hardware back button walks chat → list. It's also kept reactive for push
// deep-links into an already-open app.
watch(
() => route.query.session,
(session) => {
if (typeof session === 'string' && session) {
initialSessionId.value = session
} else {
initialSessionId.value = null
if (isMobile.value) {
conversationsListRef.value?.clearSelectedChat()
}
}
}
)
// Mobile pane state (list vs full-screen chat vs full-screen info)
const mobileChatOpen = computed(() => isMobile.value && !!route.query.session)
const mobileInfoOpen = computed(() => mobileChatOpen.value && route.query.info === '1')
// Open a session: on mobile through the URL (history-backed), on desktop locally
const openSession = (sessionId: string) => {
if (isMobile.value) {
router.push({ query: { ...route.query, session: sessionId } })
} else {
initialSessionId.value = sessionId
}
}
const conversations = ref<Conversation[]>([])
const loading = ref(true)
const error = ref('')
// Outbound exists only when it can work: at least one active WhatsApp number.
// (The server additionally requires an agent routed to it.)
const whatsappAccounts = ref<ChannelAccount[]>([])
const showNewConversation = ref(false)
const loadWhatsAppAccounts = async () => {
// Resolves to [] on failure — a hidden button, not a broken page.
whatsappAccounts.value = await channelsService.listActiveWhatsAppAccounts()
}
const onConversationStarted = (sessionId: string) => {
showNewConversation.value = false
openSession(sessionId)
loadConversations(1)
}
// Select the matching tab when deep-linking (closed sessions live under the "Closed" tab)
const statusFilter = ref<'open' | 'closed'>(
route.query.status === 'closed' ? 'closed' : 'open'
)
const currentPage = ref(1)
const pageSize = ref(20)
const hasMore = ref(true)
const totalCount = ref<number | null>(null)
// Filter states
const filterValues = ref({
customerEmailFilter: '',
dateFromFilter: '',
dateToFilter: '',
agentFilter: '',
userFilter: ''
})
const showFilters = ref(false)
const users = ref<Teammate[]>([])
const loadingUsers = ref(false)
const agents = ref<Array<{id: string, name: string, display_name: string | null}>>([])
const loadingAgents = ref(false)
// Chat info states
const selectedChatInfo = ref<ChatDetail | null>(null)
const showChatInfo = ref(false)
// Ref for ConversationsList component
const conversationsListRef = ref<InstanceType<typeof ConversationsList> | null>(null)
// Computed property to show how many conversations are loaded
const loadedCount = computed(() => conversations.value?.length || 0)
const totalItems = computed(() => totalCount.value || loadedCount.value)
const loadConversations = async (page = 1, loadMore = false) => {
error.value = ''
if (page === 1 || !loadMore) {
loading.value = true
}
const skip = (page - 1) * pageSize.value
let newConversations: Conversation[] = []
try {
const params: any = {
skip,
limit: pageSize.value,
status: statusFilter.value === 'open' ? 'open,transferred' : statusFilter.value
}
// Add filters if they have values
if (filterValues.value.customerEmailFilter.trim()) {
params.customer_email = filterValues.value.customerEmailFilter.trim()
}
if (filterValues.value.agentFilter.trim()) {
params.agent_id = filterValues.value.agentFilter.trim()
}
if (filterValues.value.userFilter.trim()) {
params.user_id = filterValues.value.userFilter.trim()
}
if (filterValues.value.dateFromFilter) {
params.date_from = new Date(filterValues.value.dateFromFilter).toISOString()
}
if (filterValues.value.dateToFilter) {
params.date_to = new Date(filterValues.value.dateToFilter).toISOString()
}
newConversations = await chatService.getRecentChats(params)
// If we're loading more, append to existing conversations
if (loadMore && page > 1) {
conversations.value = [...(conversations.value || []), ...newConversations]
} else {
conversations.value = newConversations
}
// Check if there might be more conversations to load
hasMore.value = newConversations?.length === pageSize.value
currentPage.value = page
// If we received fewer items than the page size, we can calculate the total
if (newConversations?.length < pageSize.value) {
totalCount.value = skip + (newConversations?.length || 0)
}
} catch (err) {
error.value = '加载会话失败'
console.error(err)
} finally {
loading.value = false
}
}
const loadMoreConversations = () => {
if (!loading.value && hasMore.value) {
loadConversations(currentPage.value + 1, true)
}
}
const updateFilter = (status: 'open' | 'closed') => {
statusFilter.value = status
currentPage.value = 1
hasMore.value = true
totalCount.value = null
loadConversations(1)
}
// Filter handlers
const handleApplyFilters = () => {
currentPage.value = 1
hasMore.value = true
totalCount.value = null
loadConversations(1)
// Auto-close filter panel after applying
showFilters.value = false
}
const handleClearFilters = () => {
filterValues.value = {
customerEmailFilter: '',
dateFromFilter: '',
dateToFilter: '',
agentFilter: '',
userFilter: ''
}
handleApplyFilters()
}
const toggleFilters = () => {
showFilters.value = !showFilters.value
}
const loadUsers = async () => {
if (loadingUsers.value) return
loadingUsers.value = true
try {
// /users/teammates, not /users: the latter needs manage_users, so this
// 403'd on every inbox mount for an agent and left the assignee filter and
// the Reassign dropdown empty.
users.value = await listTeammates()
} catch (error) {
console.error('Failed to load teammates:', error)
} finally {
loadingUsers.value = false
}
}
const loadAgents = async () => {
if (loadingAgents.value) return
loadingAgents.value = true
try {
// The roster, not the full agent list: filtering needs names, and
// getOrganizationAgents needs manage_agents.
agents.value = await agentService.getAgentRoster()
} catch (error) {
console.error('Failed to load agents:', error)
} finally {
loadingAgents.value = false
}
}
onMounted(() => {
loadWhatsAppAccounts()
loadConversations(1)
loadUsers()
loadAgents()
})
const handleChatUpdated = (chatDetail: ChatDetail) => {
// Update the conversation in the list if it exists
const index = conversations.value.findIndex(c => c.session_id === chatDetail.session_id)
if (index !== -1) {
// Create a new conversation object with updated data
const updatedConversation: Conversation = {
...conversations.value[index],
last_message: chatDetail.messages[chatDetail.messages.length - 1]?.message || '',
updated_at: chatDetail.updated_at,
message_count: chatDetail.messages.length,
status: chatDetail.status,
// Who holds the chat drives the row's handler badge. Leaving it out kept
// the row saying "AI" after a takeover until the next full refresh.
user_id: chatDetail.user_id,
user_name: chatDetail.user_name,
group_id: chatDetail.group_id
}
// Create a new array with the updated conversation
const updatedConversations = [...conversations.value]
updatedConversations[index] = updatedConversation
conversations.value = updatedConversations
}
// Update chat info panel if this is the selected chat
if (selectedChatInfo.value && selectedChatInfo.value.session_id === chatDetail.session_id) {
selectedChatInfo.value = chatDetail
}
// Update the selected chat in ConversationsList to refresh ConversationChat
if (conversationsListRef.value) {
conversationsListRef.value.updateSelectedChat(chatDetail)
}
}
const handleChatSelected = (chatDetail: ChatDetail) => {
selectedChatInfo.value = chatDetail
// Only auto-show if not already visible
if (!showChatInfo.value) {
showChatInfo.value = true
}
}
const closeChatInfo = () => {
if (mobileInfoOpen.value) {
router.back()
return
}
showChatInfo.value = false
// Don't clear selectedChatInfo so we can reopen it
}
const toggleChatInfo = () => {
if (!selectedChatInfo.value) return
if (isMobile.value) {
openMobileInfo()
} else {
showChatInfo.value = !showChatInfo.value
}
}
// Full-screen info page on mobile, pushed so the back button closes it
const openMobileInfo = () => {
if (selectedChatInfo.value && !mobileInfoOpen.value) {
router.push({ query: { ...route.query, info: '1' } })
}
}
// Back from the full-screen chat pane — always lands on the conversations
// list. router.back() only when the previous entry IS the list (normal tap
// flow); otherwise (push deep link, analytics link, external entry) clear the
// query in place so the chevron doesn't leave the Inbox.
const handleChatBack = () => {
const previous = window.history.state?.back
if (typeof previous === 'string' && previous.startsWith('/conversations') && !previous.includes('session=')) {
router.back()
} else {
router.replace({ query: { ...route.query, session: undefined, info: undefined } })
}
}
// Handle chat closed from ChatInfoPanel
const handleChatClosed = (_sessionId?: string) => {
showChatInfo.value = false
selectedChatInfo.value = null
if (conversationsListRef.value) {
conversationsListRef.value.clearSelectedChat()
}
if (mobileChatOpen.value) {
router.replace({ query: { ...route.query, session: undefined, info: undefined } })
}
}
</script>
<template>
<DashboardLayout :hideHeader="true">
<div class="conversations-page" :class="{ 'mobile-chat-open': mobileChatOpen }">
<header class="page-header">
<div class="header-content">
<h1>会话</h1>
<div class="header-actions">
<button
v-if="whatsappAccounts.length"
class="new-conversation-btn"
@click="showNewConversation = true"
>
<font-awesome-icon :icon="['fab', 'whatsapp']" />
新建会话
</button>
<ConversationFilters
:showFilters="showFilters"
:filterValues="filterValues"
:users="users"
:agents="agents"
:loadingUsers="loadingUsers"
:loadingAgents="loadingAgents"
@toggle="toggleFilters"
@apply="handleApplyFilters"
@clear="handleClearFilters"
@update:filterValues="filterValues = $event"
/>
<button
@click="toggleChatInfo"
class="info-toggle-btn"
:class="{ active: showChatInfo }"
aria-label="切换会话信息"
:disabled="!selectedChatInfo"
>
<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">
<circle cx="12" cy="12" r="10"/>
<line x1="12" y1="16" x2="12" y2="12"/>
<line x1="12" y1="8" x2="12.01" y2="8"/>
</svg>
</button>
</div>
</div>
</header>
<NewWhatsAppConversation
v-if="showNewConversation"
:accounts="whatsappAccounts"
@close="showNewConversation = false"
@started="onConversationStarted"
/>
<!-- Promo usage nudge for the workspace owner (phones, list pane only).
Above the grid: a grid child would take its single bounded row and
push the list into an unbounded one (see .main-content). -->
<PromoInboxCard
v-if="isMobile && !mobileChatOpen && hasEnterpriseModule"
compact
class="promo-inbox-card"
/>
<div class="main-content">
<ConversationsList
ref="conversationsListRef"
:conversations="conversations"
:loading="loading"
:error="error"
:status-filter="statusFilter"
:has-more="hasMore"
:loading-more="loading && currentPage > 1"
:loaded-count="loadedCount"
:total-count="totalItems"
:show-chat-info="showChatInfo && !!selectedChatInfo"
:initial-session-id="initialSessionId"
:mobile-pane="mobileChatOpen ? 'chat' : 'list'"
@refresh="loadConversations(1)"
@update-filter="updateFilter"
@load-more="loadMoreConversations"
@chat-updated="handleChatUpdated"
@chat-selected="handleChatSelected"
@select="openSession"
@back="handleChatBack"
@info="openMobileInfo"
@clear-unread="() => {}"
/>
<ChatInfoPanel
v-if="isMobile ? mobileInfoOpen : showChatInfo"
:class="{ 'mobile-fullscreen': mobileInfoOpen }"
:chatInfo="selectedChatInfo"
:users="users"
@close="closeChatInfo"
@refresh="loadConversations(1)"
@chatUpdated="handleChatUpdated"
@chatClosed="handleChatClosed"
/>
</div>
</div>
</DashboardLayout>
</template>
<style scoped>
.promo-inbox-card {
flex-shrink: 0;
margin: 0 16px 8px;
}
.conversations-page {
display: flex;
flex-direction: column;
height: 100%;
}
.main-content {
display: grid;
grid-template-columns: 1fr 350px;
/* Bound the implicit row: an auto row is content-sized, so the list/chat
panes inherit no height limit and their inner overflow never scrolls. */
grid-template-rows: minmax(0, 1fr);
flex: 1;
min-height: 0;
width: 100%;
overflow: hidden;
position: relative;
background: var(--bg);
transition: all 0.3s ease;
}
.main-content:not(:has(.chat-info-sidebar)) {
grid-template-columns: 1fr;
}
.page-header {
padding: calc(14px + var(--safe-top)) var(--space-lg) 14px;
border-bottom: 1px solid var(--o08);
background: var(--bg2);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
}
.header-content h1 {
margin: 0;
font-family: var(--font-display);
font-size: 22px;
font-weight: 700;
letter-spacing: -0.02em;
color: var(--text);
}
.new-conversation-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 14px;
border-radius: var(--radius-btn, 8px);
border: none;
background: var(--accent-solid);
color: var(--on-accent-solid);
font-size: 13.5px;
font-weight: 600;
cursor: pointer;
}
.header-actions {
display: flex;
gap: var(--space-sm);
align-items: center;
}
.info-toggle-btn {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
border: 1px solid var(--o10);
border-radius: 10px;
background: var(--o05);
color: var(--muted);
cursor: pointer;
transition: all var(--transition-fast);
}
.info-toggle-btn:hover {
background: var(--o10);
color: var(--text);
}
.info-toggle-btn.active {
background: rgba(201,242,78,.12);
color: var(--accent-ink);
border-color: rgba(201,242,78,.3);
}
.info-toggle-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.info-toggle-btn:disabled:hover {
background: var(--o05);
color: var(--muted);
border-color: var(--o10);
}
/* Responsive design */
@media (max-width: 768px) {
.header-content {
flex-direction: column;
gap: var(--space-sm);
align-items: flex-start;
}
.header-actions {
align-self: flex-end;
}
.main-content {
grid-template-columns: 1fr !important;
}
/* Full-screen chat pane: the chat's own header replaces the page header */
.conversations-page.mobile-chat-open .page-header {
display: none;
}
/* The desktop info toggle lives in the page header; on mobile it's in the chat */
.info-toggle-btn {
display: none;
}
}
@media (max-width: 480px) {
.page-header {
padding: var(--space-md);
}
.header-content h1 {
font-size: 20px;
}
}
</style>