Files
hfb_sys/frontend/src/features/admin/views/AdminChatsView.vue
T
yml2213 832a0628c6 feat(order): 结账备注与系统结算信息分开展示,系统结算信息改为逐行明细
- 结账内容写入分隔标记,展示端拆出「结账备注(不参与结算金额)」与「系统结算信息(参与结算金额)」
- 系统结算信息每件消耗品单独一行并给合计,哈夫币/押金各一行;兼容旧数据(同一行用「;」分隔自动拆行并补合计)
- 新增 CheckoutSystemDetail 明细组件,接入订单交接记录、结账明细(PC/移动端)与客服订单详情、聊天侧栏
- 结账提交表单与二次确认补充「备注不参与结算金额」提示
2026-10-15 18:14:31 +08:00

1007 lines
34 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>