优化客服会话筛选
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
markAdminChatRead,
|
||||
sendAdminChatMessage,
|
||||
updateChatRemark,
|
||||
type AdminChatCounts,
|
||||
type ChatConversation,
|
||||
type ChatMessage,
|
||||
type QuickReply,
|
||||
@@ -55,11 +56,32 @@ const attachments = ref<string[]>([])
|
||||
const listRef = ref<HTMLElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const filter = ref<'all' | 'mine' | 'unassigned'>('mine')
|
||||
const stage = ref<'all' | 'pending' | 'unjoined' | 'handoff' | 'renting' | 'after_sale' | 'ended'>(
|
||||
'pending'
|
||||
)
|
||||
const keyword = ref('')
|
||||
const chatCounts = ref<AdminChatCounts>({})
|
||||
const transferVisible = ref(false)
|
||||
const quickReplyVisible = ref(false)
|
||||
const quickReplies = ref<QuickReply[]>([])
|
||||
const remarkEditing = ref(false)
|
||||
const remarkValue = ref('')
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const ownershipTabs = [
|
||||
{ key: 'mine', label: '我的' },
|
||||
{ key: 'all', label: '全部' },
|
||||
{ key: 'unassigned', label: '未分配' },
|
||||
] as const
|
||||
const stageTabs = [
|
||||
{ key: 'pending', label: '待处理' },
|
||||
{ key: 'unjoined', label: '无订单' },
|
||||
{ key: 'handoff', label: '待交接' },
|
||||
{ key: 'renting', label: '使用中' },
|
||||
{ key: 'after_sale', label: '售后中' },
|
||||
{ key: 'ended', label: '已结束' },
|
||||
{ key: 'all', label: '全部阶段' },
|
||||
] as const
|
||||
|
||||
const activeMembers = computed(() => {
|
||||
const participants = active.value?.participants || []
|
||||
@@ -88,6 +110,8 @@ const orderDetailLink = computed(() =>
|
||||
from: 'chat',
|
||||
chat_id: String(active.value?.id || ''),
|
||||
chat_filter: filter.value,
|
||||
chat_stage: stage.value,
|
||||
...(keyword.value.trim() ? { chat_keyword: keyword.value.trim() } : {}),
|
||||
},
|
||||
}
|
||||
: ''
|
||||
@@ -167,14 +191,24 @@ onMounted(async () => {
|
||||
if (['all', 'mine', 'unassigned'].includes(routeFilter)) {
|
||||
filter.value = routeFilter as typeof filter.value
|
||||
}
|
||||
const routeStage = firstQueryValue(route.query.chat_stage)
|
||||
if (stageTabs.some(item => item.key === routeStage)) {
|
||||
stage.value = routeStage as typeof stage.value
|
||||
}
|
||||
keyword.value = firstQueryValue(route.query.chat_keyword)
|
||||
await Promise.all([loadConversations(), loadQuickReplies()])
|
||||
})
|
||||
|
||||
async function loadConversations(showLoading = true) {
|
||||
if (showLoading) loading.value = true
|
||||
try {
|
||||
const res = await fetchAdminChats(1, 100, filter.value)
|
||||
const res = await fetchAdminChats(1, 100, {
|
||||
filter: filter.value,
|
||||
stage: stage.value,
|
||||
keyword: keyword.value.trim(),
|
||||
})
|
||||
conversations.value = res.items
|
||||
chatCounts.value = res.counts || {}
|
||||
const first = conversations.value[0]
|
||||
if (!active.value) {
|
||||
const routeChatID = Number(firstQueryValue(route.query.chat_id) || 0)
|
||||
@@ -358,9 +392,36 @@ function handleQuickReplySelect(reply: QuickReply) {
|
||||
|
||||
function handleFilterChange(val: string) {
|
||||
filter.value = val as typeof filter.value
|
||||
resetActiveConversation()
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
function handleStageChange(val: string) {
|
||||
stage.value = val as typeof stage.value
|
||||
resetActiveConversation()
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
function handleKeywordInput() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => {
|
||||
resetActiveConversation()
|
||||
loadConversations()
|
||||
}, 320)
|
||||
}
|
||||
|
||||
function handleKeywordSearch() {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
resetActiveConversation()
|
||||
loadConversations()
|
||||
}
|
||||
|
||||
function resetActiveConversation() {
|
||||
active.value = null
|
||||
messages.value = []
|
||||
loadConversations()
|
||||
activeOrder.value = null
|
||||
activeHandoffRecords.value = []
|
||||
activePaymentRecords.value = []
|
||||
}
|
||||
|
||||
function handleTransferSuccess() {
|
||||
@@ -433,6 +494,61 @@ function getSupportName(item: ChatConversation) {
|
||||
return support?.display_name || '未分配'
|
||||
}
|
||||
|
||||
function ownershipCount(key: string) {
|
||||
return Number(chatCounts.value.ownership?.[key] || 0)
|
||||
}
|
||||
|
||||
function stageCount(key: string) {
|
||||
return Number(chatCounts.value.stages?.[key] || 0)
|
||||
}
|
||||
|
||||
function conversationStageLabel(item: ChatConversation) {
|
||||
if (item.unread_count > 0 || item.last_sender_type === 'user') return '待处理'
|
||||
if (!item.latest_order_id) return '无订单'
|
||||
if (item.latest_order_status === 'pending_handoff') return '待交接'
|
||||
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return '使用中'
|
||||
if (
|
||||
[
|
||||
'pending_checkout_confirm',
|
||||
'pending_checkout_accept',
|
||||
'checkout_disputing',
|
||||
'abnormal',
|
||||
].includes(item.latest_order_status || '') ||
|
||||
(item.latest_refund_status && item.latest_refund_status !== 'none')
|
||||
) {
|
||||
return '售后中'
|
||||
}
|
||||
if (['completed', 'cancelled', 'closed'].includes(item.latest_order_status || '')) return '已结束'
|
||||
return '跟进中'
|
||||
}
|
||||
|
||||
function conversationStageClass(item: ChatConversation) {
|
||||
if (item.unread_count > 0 || item.last_sender_type === 'user') 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'
|
||||
if (
|
||||
[
|
||||
'pending_checkout_confirm',
|
||||
'pending_checkout_accept',
|
||||
'checkout_disputing',
|
||||
'abnormal',
|
||||
].includes(item.latest_order_status || '') ||
|
||||
(item.latest_refund_status && item.latest_refund_status !== 'none')
|
||||
) {
|
||||
return 'after-sale'
|
||||
}
|
||||
if (['completed', 'cancelled', 'closed'].includes(item.latest_order_status || '')) return 'ended'
|
||||
return 'normal'
|
||||
}
|
||||
|
||||
function conversationOrderText(item: ChatConversation) {
|
||||
if (item.latest_order_no) return item.latest_order_no
|
||||
if (item.latest_order_id) return `订单 ${item.latest_order_id}`
|
||||
if (item.listing_id) return `发布 ${formatListingNo('', item.listing_id)}`
|
||||
return '暂无订单'
|
||||
}
|
||||
|
||||
function amountYuan(cent: unknown) {
|
||||
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
|
||||
return 0
|
||||
@@ -507,10 +623,32 @@ function firstQueryValue(value: unknown) {
|
||||
<aside class="conversation-pane" v-loading="loading">
|
||||
<div class="filter-tabs">
|
||||
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
|
||||
<el-radio-button value="mine">我的会话</el-radio-button>
|
||||
<el-radio-button value="all">全部</el-radio-button>
|
||||
<el-radio-button value="unassigned">未分配</el-radio-button>
|
||||
<el-radio-button v-for="item in ownershipTabs" :key="item.key" :value="item.key">
|
||||
{{ item.label }} {{ ownershipCount(item.key) }}
|
||||
</el-radio-button>
|
||||
</el-radio-group>
|
||||
<div class="stage-tabs">
|
||||
<button
|
||||
v-for="item in stageTabs"
|
||||
:key="item.key"
|
||||
type="button"
|
||||
:class="{ active: stage === item.key }"
|
||||
@click="handleStageChange(item.key)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<em>{{ stageCount(item.key) }}</em>
|
||||
</button>
|
||||
</div>
|
||||
<el-input
|
||||
v-model="keyword"
|
||||
class="conversation-search"
|
||||
clearable
|
||||
size="small"
|
||||
placeholder="搜索群名 / 订单 / 手机号 / 备注"
|
||||
@input="handleKeywordInput"
|
||||
@clear="handleKeywordSearch"
|
||||
@keyup.enter="handleKeywordSearch"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
v-for="item in conversations"
|
||||
@@ -530,6 +668,12 @@ function firstQueryValue(value: unknown) {
|
||||
(item.type === 'general_support' ? '客服会话已创建' : '订单群聊已创建')
|
||||
}}
|
||||
</p>
|
||||
<div class="row-tags">
|
||||
<span class="stage-tag" :class="conversationStageClass(item)">
|
||||
{{ conversationStageLabel(item) }}
|
||||
</span>
|
||||
<span>{{ conversationOrderText(item) }}</span>
|
||||
</div>
|
||||
<div class="row-meta">
|
||||
<span class="support-name">{{ getSupportName(item) }}</span>
|
||||
<em v-if="item.unread_count > 0">{{ item.unread_count }}</em>
|
||||
@@ -810,10 +954,67 @@ function firstQueryValue(value: unknown) {
|
||||
}
|
||||
|
||||
.filter-tabs {
|
||||
padding: 12px;
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 10px 12px 12px;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.filter-tabs :deep(.el-radio-group) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-tabs :deep(.el-radio-button) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.filter-tabs :deep(.el-radio-button__inner) {
|
||||
width: 100%;
|
||||
padding: 7px 8px;
|
||||
}
|
||||
|
||||
.stage-tabs {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.stage-tabs button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid #d8dee8;
|
||||
border-radius: 999px;
|
||||
background: #fff;
|
||||
color: #4b5563;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.stage-tabs button.active {
|
||||
border-color: #3b82f6;
|
||||
background: #eff6ff;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.stage-tabs em {
|
||||
color: #94a3b8;
|
||||
font-style: normal;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.stage-tabs button.active em {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.conversation-search {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.conversation-row {
|
||||
position: relative;
|
||||
display: block;
|
||||
@@ -859,6 +1060,63 @@ function firstQueryValue(value: unknown) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-tags {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.row-tags span {
|
||||
overflow: hidden;
|
||||
max-width: 160px;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
background: #eef2f7;
|
||||
color: #64748b;
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.row-tags .stage-tag {
|
||||
flex: none;
|
||||
max-width: none;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.stage-tag.pending {
|
||||
background: #fee2e2;
|
||||
color: #dc2626;
|
||||
}
|
||||
|
||||
.stage-tag.unjoined {
|
||||
background: #fef3c7;
|
||||
color: #b45309;
|
||||
}
|
||||
|
||||
.stage-tag.handoff {
|
||||
background: #dbeafe;
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.stage-tag.renting {
|
||||
background: #dcfce7;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
.stage-tag.after-sale {
|
||||
background: #f3e8ff;
|
||||
color: #7e22ce;
|
||||
}
|
||||
|
||||
.stage-tag.ended {
|
||||
background: #e5e7eb;
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.row-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -39,12 +39,16 @@ const returnTarget = computed(() => {
|
||||
const from = firstQueryValue(route.query.from)
|
||||
const chatID = firstQueryValue(route.query.chat_id)
|
||||
const chatFilter = firstQueryValue(route.query.chat_filter)
|
||||
const chatStage = firstQueryValue(route.query.chat_stage)
|
||||
const chatKeyword = firstQueryValue(route.query.chat_keyword)
|
||||
if (from === 'chat' && chatID) {
|
||||
return {
|
||||
path: adminPath('chats'),
|
||||
query: {
|
||||
chat_id: chatID,
|
||||
...(chatFilter ? { chat_filter: chatFilter } : {}),
|
||||
...(chatStage ? { chat_stage: chatStage } : {}),
|
||||
...(chatKeyword ? { chat_keyword: chatKeyword } : {}),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,11 @@ export interface ChatConversation {
|
||||
id: number
|
||||
order_id: number | null
|
||||
listing_id?: number | null
|
||||
latest_order_id?: number | null
|
||||
latest_order_no?: string
|
||||
latest_order_status?: string
|
||||
latest_handoff_status?: string
|
||||
latest_refund_status?: string
|
||||
type: string
|
||||
title: string
|
||||
status: string
|
||||
@@ -26,11 +31,29 @@ export interface ChatConversation {
|
||||
last_message_id?: number
|
||||
last_message_preview: string
|
||||
last_message_at?: string
|
||||
last_sender_type?: 'user' | 'admin' | 'system'
|
||||
last_sender_id?: number
|
||||
last_sender_role?: string
|
||||
unread_count: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminChatCounts {
|
||||
ownership?: Record<string, number>
|
||||
stages?: Record<string, number>
|
||||
}
|
||||
|
||||
export interface AdminChatQuery {
|
||||
filter?: string
|
||||
stage?: string
|
||||
keyword?: string
|
||||
}
|
||||
|
||||
export type AdminChatResult = PaginatedResult<ChatConversation> & {
|
||||
counts?: AdminChatCounts
|
||||
}
|
||||
|
||||
export interface ChatMessage {
|
||||
id: number
|
||||
conversation_id: number
|
||||
@@ -102,13 +125,15 @@ export async function markChatRead(id: number) {
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminChats(page = 1, pageSize = 50, filter = 'all') {
|
||||
const { data } = await apiClient.get<ApiResponse<PaginatedResult<ChatConversation>>>(
|
||||
'/admin/chats',
|
||||
{
|
||||
params: { page, page_size: pageSize, filter },
|
||||
}
|
||||
export async function fetchAdminChats(page = 1, pageSize = 50, query: AdminChatQuery = {}) {
|
||||
const params = Object.fromEntries(
|
||||
Object.entries({ page, page_size: pageSize, ...query }).filter(
|
||||
([, value]) => value !== '' && value !== undefined
|
||||
)
|
||||
)
|
||||
const { data } = await apiClient.get<ApiResponse<AdminChatResult>>('/admin/chats', {
|
||||
params,
|
||||
})
|
||||
return data.data
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user