优化客服群聊订单信息展示
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Close, Picture } from '@element-plus/icons-vue'
|
||||
import {
|
||||
@@ -14,21 +15,37 @@ import {
|
||||
type ChatMessage,
|
||||
type QuickReply,
|
||||
} from '@/features/chats'
|
||||
import {
|
||||
fetchAdminLatestOrderByListing,
|
||||
fetchAdminHandoffRecords,
|
||||
fetchAdminOrder,
|
||||
type HandoffRecord,
|
||||
type Order,
|
||||
} from '@/features/orders'
|
||||
import { fetchAdminPayments, type AdminPayment } from '@/features/admin/api/adminPayments'
|
||||
import { uploadAdminFile } from '@/shared/api/files'
|
||||
import ChatAttachmentImage from '@/components/ChatAttachmentImage.vue'
|
||||
import { useChatSSE, type ChatEvent } from '@/features/chats/composables/useChatSSE'
|
||||
import { useDesktopNotification } from '@/features/chats/composables/useDesktopNotification'
|
||||
import NotificationSettings from '@/features/chats/components/NotificationSettings.vue'
|
||||
import { adminPath } from '@/shared/utils/adminPath'
|
||||
import { formatDateMinute } from '@/shared/utils/time'
|
||||
import { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
|
||||
import { formatDateMinute, formatDateTime } from '@/shared/utils/time'
|
||||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||||
import { handoffStatusLabel, orderStatusLabel } from '@/shared/utils/statusLabels'
|
||||
import TransferDialog from '../components/TransferDialog.vue'
|
||||
import QuickReplyDialog from '../components/QuickReplyDialog.vue'
|
||||
|
||||
const currentAdminId = Number(localStorage.getItem('admin_id') || 0)
|
||||
const route = useRoute()
|
||||
|
||||
const conversations = ref<ChatConversation[]>([])
|
||||
const active = ref<ChatConversation | null>(null)
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const orderLoading = ref(false)
|
||||
const activeOrder = ref<Order | null>(null)
|
||||
const activeHandoffRecords = ref<HandoffRecord[]>([])
|
||||
const activePaymentRecords = ref<AdminPayment[]>([])
|
||||
const loading = ref(false)
|
||||
const messageLoading = ref(false)
|
||||
const sending = ref(false)
|
||||
@@ -55,6 +72,27 @@ const activeMembers = computed(() => {
|
||||
.join(' / ')
|
||||
})
|
||||
const canSend = computed(() => content.value.trim() !== '' || attachments.value.length > 0)
|
||||
const activeListingCode = computed(() =>
|
||||
activeOrder.value
|
||||
? formatListingNo(activeOrder.value.listing_no, activeOrder.value.listing_id)
|
||||
: '-'
|
||||
)
|
||||
const latestHandoffRecord = computed(() => activeHandoffRecords.value[0])
|
||||
const latestPaymentRecord = computed(() => activePaymentRecords.value[0])
|
||||
const hasOrderContext = computed(() => Boolean(active.value?.order_id || active.value?.listing_id))
|
||||
const orderDetailLink = computed(() =>
|
||||
activeOrder.value
|
||||
? {
|
||||
path: adminPath(`orders/${activeOrder.value.id}`),
|
||||
query: {
|
||||
from: 'chat',
|
||||
chat_id: String(active.value?.id || ''),
|
||||
chat_filter: filter.value,
|
||||
},
|
||||
}
|
||||
: ''
|
||||
)
|
||||
let orderLoadToken = 0
|
||||
|
||||
// 桌面通知
|
||||
const desktopNotification = useDesktopNotification('admin')
|
||||
@@ -125,6 +163,10 @@ onEvent(handleSSEEvent)
|
||||
onMounted(async () => {
|
||||
// 静默请求通知权限(用户交互后才会弹窗)
|
||||
desktopNotification.requestPermissionSilently()
|
||||
const routeFilter = firstQueryValue(route.query.chat_filter)
|
||||
if (['all', 'mine', 'unassigned'].includes(routeFilter)) {
|
||||
filter.value = routeFilter as typeof filter.value
|
||||
}
|
||||
await Promise.all([loadConversations(), loadQuickReplies()])
|
||||
})
|
||||
|
||||
@@ -134,8 +176,13 @@ async function loadConversations(showLoading = true) {
|
||||
const res = await fetchAdminChats(1, 100, filter.value)
|
||||
conversations.value = res.items
|
||||
const first = conversations.value[0]
|
||||
if (!active.value && first) {
|
||||
await openConversation(first)
|
||||
if (!active.value) {
|
||||
const routeChatID = Number(firstQueryValue(route.query.chat_id) || 0)
|
||||
if (routeChatID) {
|
||||
await openConversationById(routeChatID, false)
|
||||
} else if (first) {
|
||||
await openConversationById(first.id, false)
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
ElMessage.error('会话加载失败')
|
||||
@@ -153,12 +200,18 @@ async function loadQuickReplies() {
|
||||
}
|
||||
|
||||
async function openConversation(item: ChatConversation) {
|
||||
await openConversationById(item.id)
|
||||
}
|
||||
|
||||
async function openConversationById(id: number, refreshList = true) {
|
||||
messageLoading.value = true
|
||||
try {
|
||||
active.value = await fetchAdminChat(item.id)
|
||||
await loadMessages(item.id)
|
||||
await markAdminChatRead(item.id)
|
||||
await loadConversations(false)
|
||||
const chat = await fetchAdminChat(id)
|
||||
active.value = chat
|
||||
void loadOrderContext(chat)
|
||||
await loadMessages(id)
|
||||
await markAdminChatRead(id)
|
||||
if (refreshList) await loadConversations(false)
|
||||
remarkEditing.value = false
|
||||
remarkValue.value = ''
|
||||
attachments.value = []
|
||||
@@ -169,6 +222,43 @@ async function openConversation(item: ChatConversation) {
|
||||
}
|
||||
}
|
||||
|
||||
async function loadOrderContext(chat: ChatConversation) {
|
||||
const token = ++orderLoadToken
|
||||
activeOrder.value = null
|
||||
activeHandoffRecords.value = []
|
||||
activePaymentRecords.value = []
|
||||
if (!chat.order_id && !chat.listing_id) {
|
||||
orderLoading.value = false
|
||||
return
|
||||
}
|
||||
orderLoading.value = true
|
||||
try {
|
||||
const resolvedOrder = chat.order_id
|
||||
? await fetchAdminOrder(chat.order_id)
|
||||
: await fetchAdminLatestOrderByListing(chat.listing_id as number)
|
||||
if (token !== orderLoadToken) return
|
||||
const handoffRecords = await fetchAdminHandoffRecords(resolvedOrder.id)
|
||||
if (token !== orderLoadToken) return
|
||||
activeOrder.value = resolvedOrder
|
||||
activeHandoffRecords.value = handoffRecords
|
||||
fetchAdminPayments({ order_id: String(resolvedOrder.id), page_size: 5, silent: true })
|
||||
.then(paymentRecords => {
|
||||
if (token === orderLoadToken) activePaymentRecords.value = paymentRecords.items
|
||||
})
|
||||
.catch(() => {
|
||||
if (token === orderLoadToken) activePaymentRecords.value = []
|
||||
})
|
||||
} catch {
|
||||
if (token === orderLoadToken) {
|
||||
activeOrder.value = null
|
||||
activeHandoffRecords.value = []
|
||||
activePaymentRecords.value = []
|
||||
}
|
||||
} finally {
|
||||
if (token === orderLoadToken) orderLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMessages(id: number, scroll = true) {
|
||||
const res = await fetchAdminChatMessages(id, 1, 100)
|
||||
messages.value = res.items
|
||||
@@ -342,6 +432,61 @@ function getSupportName(item: ChatConversation) {
|
||||
const support = item.participants?.find(p => p.role === 'support')
|
||||
return support?.display_name || '未分配'
|
||||
}
|
||||
|
||||
function amountYuan(cent: unknown) {
|
||||
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
|
||||
return 0
|
||||
}
|
||||
|
||||
function money(value: unknown) {
|
||||
return formatMoney(Number(value || 0))
|
||||
}
|
||||
|
||||
function moneyCent(value: number) {
|
||||
return formatCentWithSymbol(value)
|
||||
}
|
||||
|
||||
function orderRentedAt() {
|
||||
return activeOrder.value?.rented_at
|
||||
}
|
||||
|
||||
function orderEstimatedEndAt() {
|
||||
if (!activeOrder.value) return undefined
|
||||
if (activeOrder.value.estimated_end_at) return activeOrder.value.estimated_end_at
|
||||
const rentedAt = orderRentedAt()
|
||||
const durationHours = Number(activeOrder.value.estimated_duration_hours || 0)
|
||||
if (!rentedAt || durationHours <= 0) return undefined
|
||||
return new Date(new Date(rentedAt).getTime() + durationHours * 60 * 60 * 1000).toISOString()
|
||||
}
|
||||
|
||||
function paymentBizTypeLabel(type: string) {
|
||||
const map: Record<string, string> = {
|
||||
order_pay: '订单支付',
|
||||
checkout_refund: '结账退款',
|
||||
arbitration_refund: '仲裁退款',
|
||||
admin_refund: '人工退款',
|
||||
cancel_refund: '取消退款',
|
||||
admin_close_refund: '客服关闭退款',
|
||||
}
|
||||
return map[type] || type
|
||||
}
|
||||
|
||||
function formatHandoffRecordType(type: string) {
|
||||
const typeMap: Record<string, string> = {
|
||||
owner_handoff: '卖家交接',
|
||||
renter_checkout: '买家结账',
|
||||
owner_counter_checkout: '卖家反驳结账',
|
||||
renter_confirm_checkout: '买家确认结账',
|
||||
owner_accept_checkout: '卖家接受结账',
|
||||
admin_arbitration: '客服仲裁',
|
||||
}
|
||||
return typeMap[type] || type
|
||||
}
|
||||
|
||||
function firstQueryValue(value: unknown) {
|
||||
if (Array.isArray(value)) return typeof value[0] === 'string' ? value[0] : ''
|
||||
return typeof value === 'string' ? value : ''
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -418,44 +563,114 @@ function getSupportName(item: ChatConversation) {
|
||||
</div>
|
||||
<div class="head-actions">
|
||||
<el-button size="small" @click="transferVisible = true">转接</el-button>
|
||||
<RouterLink v-if="active.order_id" :to="adminPath(`orders/${active.order_id}`)">
|
||||
<RouterLink v-if="activeOrder" :to="orderDetailLink">
|
||||
<el-button size="small">查看订单</el-button>
|
||||
</RouterLink>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div ref="listRef" class="message-list" v-loading="messageLoading">
|
||||
<div
|
||||
v-for="item in messages"
|
||||
:key="item.id"
|
||||
class="message-row"
|
||||
:class="{
|
||||
self: item.is_self,
|
||||
system: item.sender_type === 'system',
|
||||
admin: item.sender_role === 'admin',
|
||||
}"
|
||||
>
|
||||
<template v-if="item.sender_type === 'system'">
|
||||
<span>{{ item.content }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<small :class="{ 'admin-label': item.sender_role === 'admin' }">
|
||||
{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}
|
||||
</small>
|
||||
<p v-if="item.content">{{ item.content }}</p>
|
||||
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||||
<ChatAttachmentImage
|
||||
v-for="url in item.attachment_urls"
|
||||
:key="url"
|
||||
:source="url"
|
||||
admin
|
||||
/>
|
||||
</div>
|
||||
<small v-if="item.is_self" class="read-status" :class="{ read: item.is_read }">
|
||||
{{ item.is_read ? '已读' : '未读' }}
|
||||
</small>
|
||||
</template>
|
||||
<div class="message-body" :class="{ 'has-order': hasOrderContext }">
|
||||
<div ref="listRef" class="message-list" v-loading="messageLoading">
|
||||
<div
|
||||
v-for="item in messages"
|
||||
:key="item.id"
|
||||
class="message-row"
|
||||
:class="{
|
||||
self: item.is_self,
|
||||
system: item.sender_type === 'system',
|
||||
admin: item.sender_role === 'admin',
|
||||
}"
|
||||
>
|
||||
<template v-if="item.sender_type === 'system'">
|
||||
<span>{{ item.content }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<small :class="{ 'admin-label': item.sender_role === 'admin' }">
|
||||
{{ senderLabel(item) }} · {{ formatDateMinute(item.created_at) }}
|
||||
</small>
|
||||
<p v-if="item.content">{{ item.content }}</p>
|
||||
<div v-if="item.attachment_urls.length > 0" class="message-attachments">
|
||||
<ChatAttachmentImage
|
||||
v-for="url in item.attachment_urls"
|
||||
:key="url"
|
||||
:source="url"
|
||||
admin
|
||||
/>
|
||||
</div>
|
||||
<small v-if="item.is_self" class="read-status" :class="{ read: item.is_read }">
|
||||
{{ item.is_read ? '已读' : '未读' }}
|
||||
</small>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<aside v-if="hasOrderContext" class="order-side-panel" v-loading="orderLoading">
|
||||
<template v-if="activeOrder">
|
||||
<div class="order-side-head">
|
||||
<span>{{ activeOrder.order_no }}</span>
|
||||
<strong>{{ activeListingCode }}</strong>
|
||||
<p>
|
||||
{{ activeOrder.title }} · {{ activeOrder.server_region }} /
|
||||
{{ activeOrder.login_platform }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="order-side-metrics">
|
||||
<div>
|
||||
<span>订单状态</span>
|
||||
<strong>{{ orderStatusLabel(activeOrder.status) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>交接状态</span>
|
||||
<strong>{{ handoffStatusLabel(activeOrder.handoff_status) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>订单金额</span>
|
||||
<strong>¥{{ money(amountYuan(activeOrder.rent_amount_cent)) }}</strong>
|
||||
</div>
|
||||
<div>
|
||||
<span>押金</span>
|
||||
<strong>¥{{ money(amountYuan(activeOrder.deposit_amount_cent)) }}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="order-side-section">
|
||||
<h3>用户信息</h3>
|
||||
<p>商品编号:{{ activeListingCode }}</p>
|
||||
<p>租客:{{ activeOrder.renter_phone || activeOrder.renter_id }}</p>
|
||||
<p>号主:{{ activeOrder.owner_phone || activeOrder.owner_id }}</p>
|
||||
<p>开始:{{ formatDateTime(orderRentedAt(), '未开始') }}</p>
|
||||
<p>预计截止:{{ formatDateTime(orderEstimatedEndAt(), '未设置') }}</p>
|
||||
</section>
|
||||
|
||||
<section class="order-side-section">
|
||||
<h3>支付与退款</h3>
|
||||
<template v-if="latestPaymentRecord">
|
||||
<p>
|
||||
{{ paymentBizTypeLabel(latestPaymentRecord.biz_type) }} ·
|
||||
{{ moneyCent(latestPaymentRecord.amount_cent) }}
|
||||
</p>
|
||||
<p>
|
||||
{{ latestPaymentRecord.provider || '-' }} ·
|
||||
{{ latestPaymentRecord.third_order_id }}
|
||||
</p>
|
||||
<p>{{ formatDateTime(latestPaymentRecord.created_at) }}</p>
|
||||
</template>
|
||||
<p v-else>暂无支付流水</p>
|
||||
</section>
|
||||
|
||||
<section class="order-side-section">
|
||||
<h3>交接记录</h3>
|
||||
<template v-if="latestHandoffRecord">
|
||||
<strong>{{ formatHandoffRecordType(latestHandoffRecord.type) }}</strong>
|
||||
<p>{{ latestHandoffRecord.content }}</p>
|
||||
<p>{{ formatDateTime(latestHandoffRecord.created_at) }}</p>
|
||||
</template>
|
||||
<p v-else>暂无交接记录</p>
|
||||
</section>
|
||||
</template>
|
||||
<el-empty v-else description="暂无订单信息" />
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<footer class="composer">
|
||||
@@ -714,6 +929,17 @@ function getSupportName(item: ChatConversation) {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.message-body {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
background: #f3f6fa;
|
||||
}
|
||||
|
||||
.message-body.has-order {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(330px, 38%);
|
||||
}
|
||||
|
||||
.message-list {
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
@@ -721,6 +947,99 @@ function getSupportName(item: ChatConversation) {
|
||||
background: #f3f6fa;
|
||||
}
|
||||
|
||||
.order-side-panel {
|
||||
min-width: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px;
|
||||
border-left: 1px solid #e5e7eb;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
.order-side-head,
|
||||
.order-side-section {
|
||||
padding: 14px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
box-shadow: 0 8px 18px rgba(15, 23, 42, 0.03);
|
||||
}
|
||||
|
||||
.order-side-head {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.order-side-head span {
|
||||
color: #2563eb;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.order-side-head strong {
|
||||
color: #071947;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.order-side-head p,
|
||||
.order-side-section p {
|
||||
margin: 0;
|
||||
color: #7c8aaa;
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.order-side-metrics {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.order-side-metrics div {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
border: 1px solid #edf2f7;
|
||||
border-radius: 8px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.order-side-metrics span {
|
||||
color: #8a94a6;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.order-side-metrics strong {
|
||||
overflow: hidden;
|
||||
color: #071947;
|
||||
font-size: 16px;
|
||||
font-weight: 800;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-side-section {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-side-section + .order-side-section {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.order-side-section h3 {
|
||||
margin: 0;
|
||||
color: #071947;
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.order-side-section strong {
|
||||
color: #071947;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.message-row {
|
||||
max-width: 70%;
|
||||
margin-bottom: 14px;
|
||||
|
||||
Reference in New Issue
Block a user