Files
hfb_sys/frontend/src/features/admin/views/AdminChatsView.vue
T
yml2213 98174c0e33 feat(quality-owner): 增加优质号主、极品专区与无忧租号保险
- 支持保证金余额支付、在线支付开通及后台手动设置审计
- 新增极品专区发布权限、赔付方案与保险结算费率快照
- 优化首页、商品卡和个人中心的优质号主身份展示
2026-11-04 15:04:35 +08:00

1008 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: '客服关闭退款',
quality_owner_pay: '优质号主保证金',
}
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>