perf(chat): 分页客服会话并拆分统计查询

This commit is contained in:
yml2213
2026-08-25 19:21:38 +08:00
parent d95b95211c
commit 52dcb18c96
16 changed files with 264 additions and 87 deletions
@@ -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;
+10
View File
@@ -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
}