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