Files
hfb_sys/frontend/src/features/admin/views/AdminChatsView.vue
T

1473 lines
39 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, onMounted, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Close, Picture } from '@element-plus/icons-vue'
import {
fetchAdminChat,
fetchAdminChatMessages,
fetchAdminChats,
fetchQuickReplies,
markAdminChatRead,
sendAdminChatMessage,
updateChatRemark,
type AdminChatCounts,
type ChatConversation,
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 { 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 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 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
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 remark = getParticipantRemark()
const name = remark ? `${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 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 getParticipantRemark() {
if (!active.value) return ''
const myParticipant = active.value.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
return myParticipant?.remark || ''
}
function handleSSEEvent(event: ChatEvent) {
if (event.type === 'conversation_updated') {
loadConversations(false)
}
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)
// 不是自己发送的消息才发送通知;当前正在看的会话且页面前台时会在 notify 内部静默。
if (!isSelf) {
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(), loadQuickReplies()])
})
async function loadConversations(showLoading = true) {
if (showLoading) loading.value = true
try {
const res = await fetchAdminChats(1, 100, {
filter: filter.value,
stage: stage.value,
keyword: keyword.value.trim(),
})
conversations.value = res.items
chatCounts.value = res.counts || {}
const first = conversations.value[0]
if (!active.value) {
const routeChatID = Number(firstQueryValue(route.query.chat_id) || 0)
if (routeChatID) {
await openConversationById(routeChatID, false)
} else if (first) {
await openConversationById(first.id, false)
}
}
} catch {
ElMessage.error('会话加载失败')
} finally {
loading.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)
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()
loadConversations()
}
function handleStageChange(val: string) {
stage.value = val as typeof stage.value
resetActiveConversation()
loadConversations()
}
function handleKeywordInput() {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
resetActiveConversation()
loadConversations()
}, 320)
}
function handleKeywordSearch() {
if (searchTimer) clearTimeout(searchTimer)
resetActiveConversation()
loadConversations()
}
function resetActiveConversation() {
active.value = null
messages.value = []
activeOrder.value = null
activeHandoffRecords.value = []
activePaymentRecords.value = []
}
function handleTransferSuccess() {
loadConversations(false)
if (active.value) {
loadMessages(active.value.id, false)
}
}
async function startEditRemark() {
if (!active.value) return
const myParticipant = active.value.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
remarkValue.value = myParticipant?.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) {
const myParticipant = item.participants?.find(
p => p.participant_type === 'admin' && p.participant_id === currentAdminId
)
return myParticipant?.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.unread_count > 0 || item.last_sender_type === 'user') return '待处理'
if (!item.latest_order_id) return '无订单'
if (item.latest_order_status === 'pending_handoff') return '待交接'
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return '使用中'
if (
[
'pending_checkout_confirm',
'pending_checkout_accept',
'checkout_disputing',
'abnormal',
].includes(item.latest_order_status || '') ||
(item.latest_refund_status && item.latest_refund_status !== 'none')
) {
return '售后中'
}
if (['completed', 'cancelled', 'closed'].includes(item.latest_order_status || '')) return '已结束'
return '跟进中'
}
function conversationStageClass(item: ChatConversation) {
if (item.unread_count > 0 || item.last_sender_type === 'user') return 'pending'
if (!item.latest_order_id) return 'unjoined'
if (item.latest_order_status === 'pending_handoff') return 'handoff'
if (['renting', 'overdue'].includes(item.latest_order_status || '')) return 'renting'
if (
[
'pending_checkout_confirm',
'pending_checkout_accept',
'checkout_disputing',
'abnormal',
].includes(item.latest_order_status || '') ||
(item.latest_refund_status && item.latest_refund_status !== 'none')
) {
return 'after-sale'
}
if (['completed', 'cancelled', 'closed'].includes(item.latest_order_status || '')) return 'ended'
return 'normal'
}
function conversationOrderText(item: ChatConversation) {
if (item.latest_order_no) return item.latest_order_no
if (item.latest_order_id) return `订单 ${item.latest_order_id}`
if (item.listing_id) return `发布 ${formatListingNo('', item.listing_id)}`
return '暂无订单'
}
function amountYuan(cent: unknown) {
if (cent !== undefined && cent !== null) return centToYuan(Number(cent || 0))
return 0
}
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="loading" @click="loadConversations()">刷新</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="暂无客服会话" />
</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>
<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">
<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>
.admin-page {
display: grid;
height: 100%;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.page-head {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
.page-head h1 {
margin: 0;
font-size: 22px;
}
.page-head p {
margin: 6px 0 0;
color: #6b7280;
}
.head-right {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
}
.chat-workbench {
display: grid;
min-height: 0;
grid-template-columns: 330px minmax(0, 1fr);
overflow: hidden;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
}
.conversation-pane {
min-height: 0;
overflow-y: auto;
border-right: 1px solid #e5e7eb;
background: #f8fafc;
}
.filter-tabs {
display: grid;
gap: 10px;
padding: 10px 12px 12px;
border-bottom: 1px solid #e5e7eb;
}
.filter-tabs :deep(.el-radio-group) {
display: flex;
width: 100%;
}
.filter-tabs :deep(.el-radio-button) {
flex: 1;
}
.filter-tabs :deep(.el-radio-button__inner) {
width: 100%;
padding: 7px 8px;
}
.stage-tabs {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.stage-tabs button {
display: inline-flex;
align-items: center;
gap: 4px;
height: 26px;
padding: 0 8px;
border: 1px solid #d8dee8;
border-radius: 999px;
background: #fff;
color: #4b5563;
font-size: 12px;
font-weight: 700;
cursor: pointer;
}
.stage-tabs button.active {
border-color: #3b82f6;
background: #eff6ff;
color: #2563eb;
}
.stage-tabs em {
color: #94a3b8;
font-style: normal;
font-weight: 800;
}
.stage-tabs button.active em {
color: #2563eb;
}
.conversation-search {
width: 100%;
}
.conversation-row {
position: relative;
display: block;
width: 100%;
padding: 14px;
border: 0;
border-bottom: 1px solid #e5e7eb;
background: transparent;
text-align: left;
}
.conversation-row.active {
background: #eef6ff;
}
.row-title {
display: flex;
gap: 10px;
align-items: center;
}
.row-title strong {
min-width: 0;
flex: 1;
overflow: hidden;
color: #111827;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-title span {
flex: none;
color: #9ca3af;
font-size: 12px;
}
.conversation-row p {
margin: 8px 24px 0 0;
overflow: hidden;
color: #6b7280;
font-size: 13px;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-tags {
display: flex;
min-width: 0;
gap: 6px;
align-items: center;
margin-top: 8px;
}
.row-tags span {
overflow: hidden;
max-width: 160px;
padding: 2px 7px;
border-radius: 999px;
background: #eef2f7;
color: #64748b;
font-size: 11px;
line-height: 18px;
text-overflow: ellipsis;
white-space: nowrap;
}
.row-tags .stage-tag {
flex: none;
max-width: none;
font-weight: 700;
}
.stage-tag.pending {
background: #fee2e2;
color: #dc2626;
}
.stage-tag.unjoined {
background: #fef3c7;
color: #b45309;
}
.stage-tag.handoff {
background: #dbeafe;
color: #2563eb;
}
.stage-tag.renting {
background: #dcfce7;
color: #15803d;
}
.stage-tag.after-sale {
background: #f3e8ff;
color: #7e22ce;
}
.stage-tag.ended {
background: #e5e7eb;
color: #4b5563;
}
.row-meta {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
}
.support-name {
color: #8a94a6;
font-size: 12px;
}
.row-meta em {
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #ef4444;
color: #fff;
font-size: 11px;
font-style: normal;
font-weight: 700;
line-height: 18px;
text-align: center;
}
.message-pane {
display: grid;
min-width: 0;
min-height: 0;
grid-template-rows: auto minmax(0, 1fr) auto;
}
.message-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
padding: 14px 18px;
border-bottom: 1px solid #e5e7eb;
}
.head-title {
flex: 1;
min-width: 0;
}
.head-title h2 {
margin: 0;
font-size: 18px;
display: flex;
align-items: center;
gap: 8px;
}
.head-title p {
margin: 6px 0 0;
color: #6b7280;
font-size: 13px;
}
.head-actions {
display: flex;
gap: 8px;
flex-shrink: 0;
}
.head-actions a {
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;
padding: 18px;
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;
}
.message-row.self {
margin-left: auto;
text-align: right;
}
.message-row.system {
max-width: none;
text-align: center;
}
.system-block {
display: inline-flex;
max-width: min(100%, 320px);
flex-direction: column;
align-items: center;
gap: 8px;
}
.system-attachments {
display: grid;
justify-items: center;
gap: 6px;
}
.message-row small {
display: block;
margin-bottom: 5px;
color: #8a94a6;
}
.read-status {
margin-top: 4px;
margin-bottom: 0;
font-size: 11px;
}
.read-status.read {
color: #67c23a;
}
.message-row p {
display: inline-block;
margin: 0;
padding: 10px 12px;
border-radius: 8px;
background: #fff;
color: #111827;
line-height: 1.5;
text-align: left;
}
.message-row.self p {
background: #dff5eb;
}
.message-row.admin p {
background: #fff4e6;
border-left: 3px solid #ff9800;
}
.admin-label {
color: #ff9800 !important;
font-weight: 600;
}
.message-attachments {
display: grid;
justify-items: start;
gap: 6px;
}
.message-row.self .message-attachments {
justify-items: end;
}
.message-row.system span {
display: inline-block;
padding: 5px 10px;
border-radius: 8px;
background: #e5e7eb;
color: #6b7280;
font-size: 12px;
}
.composer {
border-top: 1px solid #e5e7eb;
}
.composer-tools {
display: flex;
gap: 8px;
align-items: center;
padding: 8px 14px;
border-bottom: 1px solid #f0f0f0;
}
.hidden-file {
display: none;
}
.pending-attachments {
display: flex;
gap: 8px;
padding: 10px 14px 0;
overflow-x: auto;
}
.pending-item {
position: relative;
flex: none;
}
.pending-item :deep(.chat-image-button) {
width: 84px;
height: 84px;
}
.pending-item :deep(.chat-image-button img) {
height: 84px;
}
.remove-attachment {
position: absolute;
top: 4px;
right: 4px;
display: grid;
width: 22px;
height: 22px;
place-items: center;
border: 0;
border-radius: 50%;
background: rgba(17, 24, 39, 0.72);
color: #fff;
cursor: pointer;
}
.composer-input {
display: grid;
grid-template-columns: minmax(0, 1fr) 88px;
gap: 12px;
align-items: end;
padding: 14px;
}
.reply-title {
font-weight: 500;
margin-right: 8px;
}
.reply-preview {
color: #9ca3af;
font-size: 12px;
}
</style>