perf(chat): 分页客服会话并拆分统计查询
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Close, Picture } from '@element-plus/icons-vue'
|
||||
import {
|
||||
fetchAdminChat,
|
||||
fetchAdminChatMessages,
|
||||
fetchAdminChatCounts,
|
||||
fetchAdminChats,
|
||||
fetchQuickReplies,
|
||||
markAdminChatRead,
|
||||
@@ -41,6 +42,9 @@ const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
||||
const route = useRoute()
|
||||
|
||||
const conversations = ref<ChatConversation[]>([])
|
||||
const conversationPage = ref(1)
|
||||
const conversationPageSize = 50
|
||||
const conversationTotal = ref(0)
|
||||
const active = ref<ChatConversation | null>(null)
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const orderLoading = ref(false)
|
||||
@@ -67,6 +71,9 @@ const quickReplies = ref<QuickReply[]>([])
|
||||
const remarkEditing = ref(false)
|
||||
const remarkValue = ref('')
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let countsTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let countsCacheKey = ''
|
||||
let countsLoadedAt = 0
|
||||
|
||||
const ownershipTabs = [
|
||||
{ key: 'mine', label: '我的' },
|
||||
@@ -123,6 +130,7 @@ const desktopNotification = useDesktopNotification('admin')
|
||||
function handleSSEEvent(event: ChatEvent) {
|
||||
if (event.type === 'conversation_updated') {
|
||||
loadConversations(false)
|
||||
scheduleCountsRefresh()
|
||||
}
|
||||
if (event.type === 'conversation_read') {
|
||||
// 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态
|
||||
@@ -164,9 +172,10 @@ function handleSSEEvent(event: ChatEvent) {
|
||||
}
|
||||
|
||||
loadConversations(false)
|
||||
scheduleCountsRefresh()
|
||||
|
||||
// 不是自己发送的消息才发送通知;当前正在看的会话且页面前台时会在 notify 内部静默。
|
||||
if (!isSelf) {
|
||||
if (!isSelf && msg.admin_attention_type) {
|
||||
desktopNotification.notify(event, active.value?.id ?? null)
|
||||
}
|
||||
}
|
||||
@@ -187,19 +196,25 @@ onMounted(async () => {
|
||||
stage.value = routeStage as typeof stage.value
|
||||
}
|
||||
keyword.value = firstQueryValue(route.query.chat_keyword)
|
||||
await Promise.all([loadConversations(), loadQuickReplies()])
|
||||
await Promise.all([loadConversations(), loadCounts(true), loadQuickReplies()])
|
||||
})
|
||||
|
||||
async function loadConversations(showLoading = true) {
|
||||
onBeforeUnmount(() => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
if (countsTimer) clearTimeout(countsTimer)
|
||||
})
|
||||
|
||||
async function loadConversations(showLoading = true, resetPage = false) {
|
||||
if (resetPage) conversationPage.value = 1
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const res = await fetchAdminChats(1, 100, {
|
||||
const res = await fetchAdminChats(conversationPage.value, conversationPageSize, {
|
||||
filter: filter.value,
|
||||
stage: stage.value,
|
||||
keyword: keyword.value.trim(),
|
||||
})
|
||||
conversations.value = res.items
|
||||
chatCounts.value = res.counts || {}
|
||||
conversationTotal.value = res.total
|
||||
const first = conversations.value[0]
|
||||
if (!active.value) {
|
||||
const routeChatID = Number(firstQueryValue(route.query.chat_id) || 0)
|
||||
@@ -216,6 +231,40 @@ async function loadConversations(showLoading = true) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadCounts(force = false) {
|
||||
const query = {
|
||||
filter: filter.value,
|
||||
stage: stage.value,
|
||||
keyword: keyword.value.trim(),
|
||||
}
|
||||
const cacheKey = JSON.stringify(query)
|
||||
if (!force && cacheKey === countsCacheKey && Date.now() - countsLoadedAt < 5000) return
|
||||
try {
|
||||
chatCounts.value = await fetchAdminChatCounts(query)
|
||||
countsCacheKey = cacheKey
|
||||
countsLoadedAt = Date.now()
|
||||
} catch {
|
||||
/* keep the last successful counts visible */
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleCountsRefresh() {
|
||||
if (countsTimer) clearTimeout(countsTimer)
|
||||
countsTimer = setTimeout(() => {
|
||||
countsTimer = null
|
||||
void loadCounts(true)
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
conversationPage.value = page
|
||||
void loadConversations()
|
||||
}
|
||||
|
||||
function refreshAll() {
|
||||
void Promise.all([loadConversations(true), loadCounts(true)])
|
||||
}
|
||||
|
||||
async function loadQuickReplies() {
|
||||
try {
|
||||
quickReplies.value = await fetchQuickReplies()
|
||||
@@ -236,6 +285,7 @@ async function openConversationById(id: number, refreshList = true) {
|
||||
void loadOrderContext(chat)
|
||||
await loadMessages(id)
|
||||
await markAdminChatRead(id)
|
||||
scheduleCountsRefresh()
|
||||
if (refreshList) await loadConversations(false)
|
||||
remarkEditing.value = false
|
||||
remarkValue.value = ''
|
||||
@@ -384,27 +434,27 @@ function handleQuickReplySelect(reply: QuickReply) {
|
||||
function handleFilterChange(val: string) {
|
||||
filter.value = val as typeof filter.value
|
||||
resetActiveConversation()
|
||||
loadConversations()
|
||||
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||||
}
|
||||
|
||||
function handleStageChange(val: string) {
|
||||
stage.value = val as typeof stage.value
|
||||
resetActiveConversation()
|
||||
loadConversations()
|
||||
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||||
}
|
||||
|
||||
function handleKeywordInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
resetActiveConversation()
|
||||
loadConversations()
|
||||
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||||
}, 320)
|
||||
}
|
||||
|
||||
function handleKeywordSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
resetActiveConversation()
|
||||
loadConversations()
|
||||
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||||
}
|
||||
|
||||
function resetActiveConversation() {
|
||||
@@ -417,6 +467,7 @@ function resetActiveConversation() {
|
||||
|
||||
function handleTransferSuccess() {
|
||||
loadConversations(false)
|
||||
scheduleCountsRefresh()
|
||||
if (active.value) {
|
||||
loadMessages(active.value.id, false)
|
||||
}
|
||||
@@ -488,7 +539,7 @@ function stageCount(key: string) {
|
||||
}
|
||||
|
||||
function conversationStageLabel(item: ChatConversation) {
|
||||
if (item.unread_count > 0 || item.last_sender_type === 'user') return '待处理'
|
||||
if (item.unread_count > 0) return '待处理'
|
||||
if (!item.latest_order_id) return '无订单'
|
||||
if (item.latest_order_status === 'pending_handoff') return '待交接'
|
||||
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return '使用中'
|
||||
@@ -508,7 +559,7 @@ function conversationStageLabel(item: ChatConversation) {
|
||||
}
|
||||
|
||||
function conversationStageClass(item: ChatConversation) {
|
||||
if (item.unread_count > 0 || item.last_sender_type === 'user') return 'pending'
|
||||
if (item.unread_count > 0) return 'pending'
|
||||
if (!item.latest_order_id) return 'unjoined'
|
||||
if (item.latest_order_status === 'pending_handoff') return 'handoff'
|
||||
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return 'renting'
|
||||
@@ -603,7 +654,7 @@ function firstQueryValue(value: unknown) {
|
||||
<div class="head-right">
|
||||
<NotificationSettings scope="admin" />
|
||||
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
||||
<el-button :loading="loading" @click="loadConversations()">刷新</el-button>
|
||||
<el-button :loading="loading" @click="refreshAll">刷新</el-button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -668,6 +719,18 @@ function firstQueryValue(value: unknown) {
|
||||
</div>
|
||||
</button>
|
||||
<el-empty v-if="!loading && conversations.length === 0" description="暂无客服会话" />
|
||||
<div v-if="conversationTotal > conversationPageSize" class="conversation-pagination">
|
||||
<el-pagination
|
||||
small
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
:current-page="conversationPage"
|
||||
:page-size="conversationPageSize"
|
||||
:total="conversationTotal"
|
||||
:disabled="loading"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="message-pane">
|
||||
@@ -1013,6 +1076,14 @@ function firstQueryValue(value: unknown) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.conversation-pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 12px 8px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.conversation-row {
|
||||
position: relative;
|
||||
display: block;
|
||||
|
||||
@@ -160,6 +160,16 @@ export async function fetchAdminChats(page = 1, pageSize = 50, query: AdminChatQ
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminChatCounts(query: AdminChatQuery = {}) {
|
||||
const params = Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value !== undefined)
|
||||
)
|
||||
const { data } = await apiClient.get<ApiResponse<AdminChatCounts>>('/admin/chats/counts', {
|
||||
params,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminChat(id: number) {
|
||||
const { data } = await apiClient.get<ApiResponse<ChatConversation>>(`/admin/chats/${id}`)
|
||||
return data.data
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface SSEMessage {
|
||||
content_type: string
|
||||
content: string
|
||||
attachment_urls: string[]
|
||||
admin_attention_type?: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user