- 结账内容写入分隔标记,展示端拆出「结账备注(不参与结算金额)」与「系统结算信息(参与结算金额)」 - 系统结算信息每件消耗品单独一行并给合计,哈夫币/押金各一行;兼容旧数据(同一行用「;」分隔自动拆行并补合计) - 新增 CheckoutSystemDetail 明细组件,接入订单交接记录、结账明细(PC/移动端)与客服订单详情、聊天侧栏 - 结账提交表单与二次确认补充「备注不参与结算金额」提示
1007 lines
34 KiB
Vue
1007 lines
34 KiB
Vue
<script setup lang="ts">
|
||
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||
import { useRoute } from 'vue-router'
|
||
import { ElMessage, ElMessageBox } from 'element-plus'
|
||
import { Check, Close, Picture } from '@element-plus/icons-vue'
|
||
import {
|
||
fetchAdminChat,
|
||
fetchAdminChatMessages,
|
||
fetchAdminChatCounts,
|
||
fetchAdminChats,
|
||
fetchQuickReplies,
|
||
markAdminChatRead,
|
||
markAllAdminChatsRead,
|
||
sendAdminChatMessage,
|
||
updateChatRemark,
|
||
type AdminChatCounts,
|
||
type ChatConversation,
|
||
type ChatMessage,
|
||
type QuickReply,
|
||
} from '@/features/chats'
|
||
import {
|
||
fetchAdminLatestOrderByListing,
|
||
fetchAdminHandoffRecords,
|
||
fetchAdminOrder,
|
||
splitCheckoutContent,
|
||
type HandoffRecord,
|
||
type Order,
|
||
} from '@/features/orders'
|
||
import CheckoutSystemDetail from '@/features/orders/components/CheckoutSystemDetail.vue'
|
||
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 { centToYuan, formatCentWithSymbol, formatMoney } from '@/shared/utils/money'
|
||
import { formatDateMinute, formatDateTime } from '@/shared/utils/time'
|
||
import { formatListingNo } from '@/shared/utils/listingDisplay'
|
||
import { orderHandoffStatusLabel, 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 conversationPage = ref(1)
|
||
const conversationPageSize = 50
|
||
const conversationTotal = ref(0)
|
||
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)
|
||
const clearingAll = ref(false)
|
||
const uploading = ref(false)
|
||
const content = ref('')
|
||
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
|
||
let countsTimer: ReturnType<typeof setTimeout> | null = null
|
||
let countsCacheKey = ''
|
||
let countsLoadedAt = 0
|
||
|
||
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 || []
|
||
return participants
|
||
.map(item => {
|
||
const name = item.remark ? `${item.remark}(${item.display_name})` : item.display_name
|
||
return `${roleLabel(item.role)}:${name}`
|
||
})
|
||
.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 latestHandoffParts = computed(() => splitCheckoutContent(latestHandoffRecord.value?.content))
|
||
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,
|
||
chat_stage: stage.value,
|
||
...(keyword.value.trim() ? { chat_keyword: keyword.value.trim() } : {}),
|
||
},
|
||
}
|
||
: ''
|
||
)
|
||
let orderLoadToken = 0
|
||
|
||
// 桌面通知
|
||
const desktopNotification = useDesktopNotification('admin')
|
||
|
||
function handleSSEEvent(event: ChatEvent) {
|
||
if (event.type === 'conversation_updated') {
|
||
loadConversations(false)
|
||
scheduleCountsRefresh()
|
||
}
|
||
if (event.type === 'conversation_read') {
|
||
// 对方已读:仅当本端仍有未获回执的自己消息时重载,刷新「已读」状态
|
||
if (active.value && event.conversation_id === active.value.id) {
|
||
const isSelfReader = event.reader_type === 'admin' && event.reader_id === currentAdminId
|
||
if (!isSelfReader && messages.value.some(m => m.is_self && !m.is_read)) {
|
||
loadMessages(active.value.id, false)
|
||
}
|
||
}
|
||
}
|
||
if (event.type === 'new_message') {
|
||
const msg = event.message
|
||
if (!msg) return
|
||
const isSelf = msg.sender_type === 'admin' && msg.sender_id === currentAdminId
|
||
if (
|
||
active.value &&
|
||
event.conversation_id === active.value.id &&
|
||
!messages.value.some(m => m.id === msg.id)
|
||
) {
|
||
messages.value = [
|
||
...messages.value,
|
||
{
|
||
id: msg.id,
|
||
conversation_id: msg.conversation_id,
|
||
sender_type: msg.sender_type as ChatMessage['sender_type'],
|
||
sender_id: msg.sender_id,
|
||
sender_role: msg.sender_role as ChatMessage['sender_role'],
|
||
sender_name: msg.sender_name,
|
||
sender_avatar: '',
|
||
is_self: isSelf,
|
||
is_read: false,
|
||
content_type: msg.content_type as ChatMessage['content_type'],
|
||
content: msg.content,
|
||
attachment_urls: msg.attachment_urls || [],
|
||
created_at: msg.created_at,
|
||
},
|
||
]
|
||
nextTick(() => scrollBottom())
|
||
}
|
||
|
||
loadConversations(false)
|
||
scheduleCountsRefresh()
|
||
|
||
// 不是自己发送的消息才发送通知;当前正在看的会话且页面前台时会在 notify 内部静默。
|
||
if (!isSelf && msg.admin_attention_type) {
|
||
desktopNotification.notify(event, active.value?.id ?? null)
|
||
}
|
||
}
|
||
}
|
||
|
||
const { onEvent } = useChatSSE('admin', '/api/admin/chats/events')
|
||
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
|
||
}
|
||
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(), loadCounts(true), loadQuickReplies()])
|
||
})
|
||
|
||
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(conversationPage.value, conversationPageSize, {
|
||
filter: filter.value,
|
||
stage: stage.value,
|
||
keyword: keyword.value.trim(),
|
||
})
|
||
conversations.value = res.items
|
||
conversationTotal.value = res.total
|
||
const first = conversations.value[0]
|
||
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('会话加载失败')
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
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 clearAllUnread() {
|
||
if (clearingAll.value) return
|
||
try {
|
||
await ElMessageBox.confirm(
|
||
'将把全部客服会话标记为已读,不会删除聊天记录。后续新消息仍会正常提醒。',
|
||
'一键清理未读提醒',
|
||
{ type: 'warning', confirmButtonText: '确认清理', cancelButtonText: '取消' }
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
clearingAll.value = true
|
||
try {
|
||
await markAllAdminChatsRead()
|
||
await Promise.all([loadConversations(true), loadCounts(true)])
|
||
ElMessage.success('未读提醒已清理')
|
||
} catch {
|
||
ElMessage.error('清理失败,请稍后重试')
|
||
} finally {
|
||
clearingAll.value = false
|
||
}
|
||
}
|
||
|
||
async function loadQuickReplies() {
|
||
try {
|
||
quickReplies.value = await fetchQuickReplies()
|
||
} catch {
|
||
/* ignore */
|
||
}
|
||
}
|
||
|
||
async function openConversation(item: ChatConversation) {
|
||
await openConversationById(item.id)
|
||
}
|
||
|
||
async function openConversationById(id: number, refreshList = true) {
|
||
messageLoading.value = true
|
||
try {
|
||
const chat = await fetchAdminChat(id)
|
||
active.value = chat
|
||
void loadOrderContext(chat)
|
||
await loadMessages(id)
|
||
await markAdminChatRead(id)
|
||
scheduleCountsRefresh()
|
||
if (refreshList) await loadConversations(false)
|
||
remarkEditing.value = false
|
||
remarkValue.value = ''
|
||
attachments.value = []
|
||
} catch {
|
||
ElMessage.error('会话详情加载失败')
|
||
} finally {
|
||
messageLoading.value = false
|
||
}
|
||
}
|
||
|
||
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
|
||
if (scroll) {
|
||
await nextTick()
|
||
scrollBottom()
|
||
}
|
||
}
|
||
|
||
async function handleSend() {
|
||
const text = content.value.trim()
|
||
const imageUrls = [...attachments.value]
|
||
if (!active.value || (!text && imageUrls.length === 0) || sending.value || uploading.value) return
|
||
sending.value = true
|
||
try {
|
||
const sent = await sendAdminChatMessage(active.value.id, text, imageUrls)
|
||
appendMessage(sent)
|
||
content.value = ''
|
||
attachments.value = []
|
||
await loadConversations(false)
|
||
} catch {
|
||
ElMessage.error('发送失败')
|
||
} finally {
|
||
sending.value = false
|
||
}
|
||
}
|
||
|
||
function appendMessage(message: ChatMessage) {
|
||
if (messages.value.some(item => item.id === message.id)) return
|
||
messages.value = [...messages.value, message]
|
||
nextTick(() => scrollBottom())
|
||
}
|
||
|
||
function pickImages() {
|
||
if (uploading.value || attachments.value.length >= 9) return
|
||
fileInputRef.value?.click()
|
||
}
|
||
|
||
async function handleImageChange(event: Event) {
|
||
const input = event.target as HTMLInputElement
|
||
const files = Array.from(input.files || [])
|
||
input.value = ''
|
||
if (files.length === 0) return
|
||
await uploadImages(files)
|
||
}
|
||
|
||
async function handlePaste(event: ClipboardEvent) {
|
||
const items = Array.from(event.clipboardData?.items || [])
|
||
const imageFiles = items
|
||
.filter(item => item.type.startsWith('image/'))
|
||
.map(item => item.getAsFile())
|
||
.filter(Boolean) as File[]
|
||
|
||
if (imageFiles.length > 0) {
|
||
event.preventDefault()
|
||
await uploadImages(imageFiles)
|
||
}
|
||
}
|
||
|
||
async function uploadImages(files: File[]) {
|
||
const slots = 9 - attachments.value.length
|
||
if (slots <= 0) {
|
||
ElMessage.warning('每条消息最多发送 9 张图片')
|
||
return
|
||
}
|
||
uploading.value = true
|
||
try {
|
||
for (const file of files.slice(0, slots)) {
|
||
if (
|
||
!['image/jpeg', 'image/png', 'image/webp'].includes(file.type) ||
|
||
file.size > 25 * 1024 * 1024
|
||
) {
|
||
ElMessage.warning(`${file.name} 不符合图片规则`)
|
||
continue
|
||
}
|
||
const uploaded = await uploadAdminFile(file, 'chat')
|
||
attachments.value.push(uploaded.url)
|
||
}
|
||
if (files.length > slots) {
|
||
ElMessage.warning('每条消息最多发送 9 张图片')
|
||
}
|
||
} catch {
|
||
ElMessage.error('图片上传失败')
|
||
} finally {
|
||
uploading.value = false
|
||
}
|
||
}
|
||
|
||
function removeAttachment(index: number) {
|
||
attachments.value.splice(index, 1)
|
||
}
|
||
|
||
function handleQuickReplySelect(reply: QuickReply) {
|
||
content.value = reply.content
|
||
quickReplyVisible.value = false
|
||
}
|
||
|
||
function handleFilterChange(val: string) {
|
||
filter.value = val as typeof filter.value
|
||
resetActiveConversation()
|
||
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||
}
|
||
|
||
function handleStageChange(val: string) {
|
||
stage.value = val as typeof stage.value
|
||
resetActiveConversation()
|
||
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||
}
|
||
|
||
function handleKeywordInput() {
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
searchTimer = setTimeout(() => {
|
||
resetActiveConversation()
|
||
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||
}, 320)
|
||
}
|
||
|
||
function handleKeywordSearch() {
|
||
if (searchTimer) clearTimeout(searchTimer)
|
||
resetActiveConversation()
|
||
void Promise.all([loadConversations(true, true), loadCounts(true)])
|
||
}
|
||
|
||
function resetActiveConversation() {
|
||
active.value = null
|
||
messages.value = []
|
||
activeOrder.value = null
|
||
activeHandoffRecords.value = []
|
||
activePaymentRecords.value = []
|
||
}
|
||
|
||
function handleTransferSuccess() {
|
||
loadConversations(false)
|
||
scheduleCountsRefresh()
|
||
if (active.value) {
|
||
loadMessages(active.value.id, false)
|
||
}
|
||
}
|
||
|
||
async function startEditRemark() {
|
||
if (!active.value) return
|
||
remarkValue.value = active.value.admin_remark || active.value.title
|
||
remarkEditing.value = true
|
||
}
|
||
|
||
async function saveRemark() {
|
||
if (!active.value) return
|
||
try {
|
||
await updateChatRemark(active.value.id, remarkValue.value)
|
||
ElMessage.success('备注已更新')
|
||
remarkEditing.value = false
|
||
await fetchAdminChat(active.value.id).then(chat => {
|
||
active.value = chat
|
||
})
|
||
await loadConversations(false)
|
||
} catch {
|
||
ElMessage.error('更新备注失败')
|
||
}
|
||
}
|
||
|
||
function scrollBottom() {
|
||
const el = listRef.value
|
||
if (!el) return
|
||
el.scrollTop = el.scrollHeight
|
||
}
|
||
|
||
function roleLabel(role: string) {
|
||
const map: Record<string, string> = {
|
||
renter: '租客',
|
||
owner: '号主',
|
||
support: '客服',
|
||
customer: '咨询',
|
||
system: '系统',
|
||
admin: '管理员',
|
||
}
|
||
return map[role] || '成员'
|
||
}
|
||
|
||
function senderLabel(item: ChatMessage) {
|
||
if (item.sender_type === 'system') return '系统'
|
||
// 管理员消息特殊标识
|
||
if (item.sender_role === 'admin') {
|
||
return `管理员 · ${item.sender_name}`
|
||
}
|
||
return `${roleLabel(item.sender_role)} · ${item.sender_name}`
|
||
}
|
||
|
||
function getConversationTitle(item: ChatConversation) {
|
||
return item.admin_remark || item.title
|
||
}
|
||
|
||
function getSupportName(item: ChatConversation) {
|
||
const support = item.participants?.find(p => p.role === 'support')
|
||
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.needs_reply) 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.needs_reply) 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
|
||
}
|
||
|
||
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: '卖家交接',
|
||
platform_handoff: '客服代交接',
|
||
renter_checkout: '买家结账',
|
||
owner_counter_checkout: '卖家反驳结账',
|
||
platform_checkout_counter: '客服修改结账',
|
||
renter_confirm_checkout: '买家确认结账',
|
||
owner_accept_checkout: '卖家接受结账',
|
||
admin_arbitration: '客服仲裁',
|
||
platform_checkout_dispute_opened: '客服发起结账争议',
|
||
}
|
||
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>
|
||
<section class="admin-page">
|
||
<div class="page-head">
|
||
<div>
|
||
<h1>客服会话</h1>
|
||
<p>处理订单三方沟通和平台咨询</p>
|
||
</div>
|
||
<div class="head-right">
|
||
<NotificationSettings scope="admin" />
|
||
<el-button @click="quickReplyVisible = true">快捷回复管理</el-button>
|
||
<el-button :loading="clearingAll" :icon="Check" @click="clearAllUnread">一键清理</el-button>
|
||
<el-button :loading="loading" @click="refreshAll">刷新</el-button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="chat-workbench">
|
||
<aside class="conversation-pane" v-loading="loading">
|
||
<div class="filter-tabs">
|
||
<el-radio-group v-model="filter" size="small" @change="handleFilterChange">
|
||
<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"
|
||
:key="item.id"
|
||
type="button"
|
||
class="conversation-row"
|
||
:class="{ active: active?.id === item.id }"
|
||
@click="openConversation(item)"
|
||
>
|
||
<div class="row-title">
|
||
<strong>{{ getConversationTitle(item) }}</strong>
|
||
<span>{{ formatDateMinute(item.last_message_at || item.created_at) }}</span>
|
||
</div>
|
||
<p>
|
||
{{
|
||
item.last_message_preview ||
|
||
(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>
|
||
</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">
|
||
<template v-if="active">
|
||
<header class="message-head">
|
||
<div class="head-title">
|
||
<template v-if="remarkEditing">
|
||
<el-input
|
||
v-model="remarkValue"
|
||
size="small"
|
||
style="width: 200px"
|
||
placeholder="输入备注"
|
||
@keyup.enter="saveRemark"
|
||
/>
|
||
<el-button size="small" type="primary" @click="saveRemark">保存</el-button>
|
||
<el-button size="small" @click="remarkEditing = false">取消</el-button>
|
||
</template>
|
||
<template v-else>
|
||
<h2>
|
||
{{ getConversationTitle(active) }}
|
||
<el-button link size="small" @click="startEditRemark">编辑备注</el-button>
|
||
</h2>
|
||
<p>{{ activeMembers }}</p>
|
||
</template>
|
||
</div>
|
||
<div class="head-actions">
|
||
<el-button size="small" @click="transferVisible = true">转接</el-button>
|
||
<RouterLink v-if="activeOrder" :to="orderDetailLink">
|
||
<el-button size="small">查看订单</el-button>
|
||
</RouterLink>
|
||
</div>
|
||
</header>
|
||
|
||
<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'">
|
||
<div class="system-block">
|
||
<span>{{ item.content }}</span>
|
||
<div v-if="item.attachment_urls.length > 0" class="system-attachments">
|
||
<ChatAttachmentImage
|
||
v-for="url in item.attachment_urls"
|
||
:key="url"
|
||
:source="url"
|
||
admin
|
||
/>
|
||
</div>
|
||
</div>
|
||
</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>{{ orderHandoffStatusLabel(activeOrder) }}</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>
|
||
<template v-if="latestHandoffParts.systemLines.length">
|
||
<p v-if="latestHandoffParts.remark" class="handoff-remark">
|
||
备注(不参与结算金额):{{ latestHandoffParts.remark }}
|
||
</p>
|
||
<div class="handoff-system">
|
||
<span class="handoff-system-tag">系统结算信息(参与结算金额)</span>
|
||
<CheckoutSystemDetail :lines="latestHandoffParts.systemLines" />
|
||
</div>
|
||
</template>
|
||
<p v-else>{{ 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">
|
||
<div class="composer-tools">
|
||
<input
|
||
ref="fileInputRef"
|
||
class="hidden-file"
|
||
type="file"
|
||
accept="image/jpeg,image/png,image/webp"
|
||
multiple
|
||
@change="handleImageChange"
|
||
/>
|
||
<el-dropdown trigger="click" @command="handleQuickReplySelect">
|
||
<el-button size="small" text>快捷回复</el-button>
|
||
<template #dropdown>
|
||
<el-dropdown-menu>
|
||
<el-dropdown-item
|
||
v-for="reply in quickReplies"
|
||
:key="reply.id"
|
||
:command="reply"
|
||
>
|
||
<span class="reply-title">{{ reply.title }}</span>
|
||
<span class="reply-preview"
|
||
>{{ reply.content.slice(0, 30)
|
||
}}{{ reply.content.length > 30 ? '...' : '' }}</span
|
||
>
|
||
</el-dropdown-item>
|
||
<el-dropdown-item v-if="quickReplies.length === 0" disabled>
|
||
暂无快捷回复
|
||
</el-dropdown-item>
|
||
</el-dropdown-menu>
|
||
</template>
|
||
</el-dropdown>
|
||
<el-button
|
||
size="small"
|
||
text
|
||
:icon="Picture"
|
||
:loading="uploading"
|
||
:disabled="attachments.length >= 9"
|
||
@click="pickImages"
|
||
>
|
||
图片
|
||
</el-button>
|
||
</div>
|
||
<div v-if="attachments.length > 0" class="pending-attachments">
|
||
<div v-for="(url, index) in attachments" :key="url" class="pending-item">
|
||
<ChatAttachmentImage :source="url" admin />
|
||
<button type="button" class="remove-attachment" @click="removeAttachment(index)">
|
||
<el-icon :size="14"><Close /></el-icon>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
<div class="composer-input">
|
||
<el-input
|
||
v-model="content"
|
||
type="textarea"
|
||
:rows="3"
|
||
maxlength="1000"
|
||
show-word-limit
|
||
placeholder="输入客服回复"
|
||
@keydown.enter.exact.prevent="handleSend"
|
||
@paste="handlePaste"
|
||
/>
|
||
<el-button
|
||
type="primary"
|
||
:loading="sending"
|
||
:disabled="!canSend || uploading"
|
||
@click="handleSend"
|
||
>发送</el-button
|
||
>
|
||
</div>
|
||
</footer>
|
||
</template>
|
||
<el-empty v-else description="请选择会话" />
|
||
</main>
|
||
</div>
|
||
|
||
<TransferDialog
|
||
v-if="active"
|
||
v-model="transferVisible"
|
||
:conversation-id="active.id"
|
||
@success="handleTransferSuccess"
|
||
/>
|
||
|
||
<QuickReplyDialog v-model="quickReplyVisible" @success="loadQuickReplies" />
|
||
</section>
|
||
</template>
|
||
|
||
<style scoped src="./AdminChatsView.css"></style>
|