All checks were successful
continuous-integration/drone/push Build is passing
355 lines
11 KiB
Vue
355 lines
11 KiB
Vue
<template>
|
|
<div>
|
|
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
|
<h3 style="margin: 0;">聊天记录</h3>
|
|
<div style="display: flex; gap: 8px;">
|
|
<el-input v-model="searchKeyword" placeholder="搜索订单号/昵称" clearable style="width: 200px;"
|
|
@clear="fetchList" @keyup.enter="fetchList" />
|
|
<el-select v-model="statusFilter" placeholder="订单状态" clearable style="width: 140px;" @change="fetchList">
|
|
<el-option label="进行中" value="InProgress" />
|
|
<el-option label="待确认" value="WaitConfirm" />
|
|
<el-option label="已完成" value="Completed" />
|
|
<el-option label="申诉中" value="Appealing" />
|
|
</el-select>
|
|
<el-button type="primary" @click="fetchList">查询</el-button>
|
|
</div>
|
|
</div>
|
|
|
|
<el-table :data="filteredList" v-loading="loading" border>
|
|
<el-table-column prop="orderNo" label="订单编号" width="160" show-overflow-tooltip />
|
|
<el-table-column prop="orderType" label="类型" width="80">
|
|
<template #default="{ row }">{{ typeLabel(row.orderType) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="状态" width="90">
|
|
<template #default="{ row }">
|
|
<el-tag :type="statusTagType(row.status)" size="small">{{ statusLabel(row.status) }}</el-tag>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="单主" min-width="120">
|
|
<template #default="{ row }">
|
|
<div style="display: flex; align-items: center; gap: 6px;">
|
|
<el-avatar :size="28" :src="row.ownerAvatar || undefined">
|
|
<span style="font-size: 12px">{{ (row.ownerNickname || '?')[0] }}</span>
|
|
</el-avatar>
|
|
<span>{{ row.ownerNickname }}</span>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column label="跑腿" min-width="120">
|
|
<template #default="{ row }">
|
|
<div style="display: flex; align-items: center; gap: 6px;">
|
|
<el-avatar :size="28" :src="row.runnerAvatar || undefined">
|
|
<span style="font-size: 12px">{{ (row.runnerNickname || '?')[0] }}</span>
|
|
</el-avatar>
|
|
<span>{{ row.runnerNickname }}</span>
|
|
</div>
|
|
</template>
|
|
</el-table-column>
|
|
<el-table-column prop="itemName" label="内容" show-overflow-tooltip />
|
|
<el-table-column prop="createdAt" label="下单时间" width="170">
|
|
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
|
</el-table-column>
|
|
<el-table-column label="操作" width="120" fixed="right" align="center">
|
|
<template #default="{ row }">
|
|
<el-button size="small" type="primary" @click="viewChat(row)">查看聊天</el-button>
|
|
</template>
|
|
</el-table-column>
|
|
</el-table>
|
|
|
|
<!-- 聊天记录弹窗 -->
|
|
<el-dialog v-model="chatDialogVisible" :title="chatDialogTitle" width="600px" top="5vh" destroy-on-close>
|
|
<div v-loading="chatLoading" class="chat-container">
|
|
<div v-if="chatMessages.length === 0 && !chatLoading" class="chat-empty">暂无聊天记录</div>
|
|
<div v-for="(msg, index) in chatMessages" :key="index" class="chat-msg-wrap">
|
|
<!-- 时间分割 -->
|
|
<div v-if="msg.showTime" class="chat-time-divider">
|
|
<span>{{ msg.timeLabel }}</span>
|
|
</div>
|
|
<!-- 系统消息 -->
|
|
<div v-if="msg.isSystem" class="chat-system-msg">
|
|
<span>{{ msg.text }}</span>
|
|
</div>
|
|
<!-- 普通消息 -->
|
|
<div v-else :class="['chat-bubble-row', msg.isOwner ? 'chat-right' : 'chat-left']">
|
|
<el-avatar :size="32" :src="msg.avatar || undefined" class="chat-avatar-item">
|
|
<span style="font-size: 12px">{{ (msg.nickname || '?')[0] }}</span>
|
|
</el-avatar>
|
|
<div class="chat-bubble-wrap">
|
|
<div class="chat-nickname">{{ msg.nickname }}</div>
|
|
<div :class="['chat-bubble', msg.isOwner ? 'bubble-right' : 'bubble-left']">
|
|
<img v-if="msg.type === 'image'" :src="msg.text" class="chat-img" @click="previewImage(msg.text)" />
|
|
<span v-else>{{ msg.text }}</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</el-dialog>
|
|
|
|
<!-- 图片预览 -->
|
|
<el-image-viewer v-if="previewVisible" :url-list="[previewUrl]" @close="previewVisible = false" />
|
|
</div>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, computed, onMounted } from 'vue'
|
|
import request from '../utils/request'
|
|
|
|
const loading = ref(false)
|
|
const chatLoading = ref(false)
|
|
const list = ref([])
|
|
const searchKeyword = ref('')
|
|
const statusFilter = ref('')
|
|
const chatDialogVisible = ref(false)
|
|
const chatDialogTitle = ref('')
|
|
const chatMessages = ref([])
|
|
const previewVisible = ref(false)
|
|
const previewUrl = ref('')
|
|
|
|
const typeLabel = (t) => ({ Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }[t] || t)
|
|
const statusLabel = (s) => ({ Pending: '待接单', InProgress: '进行中', WaitConfirm: '待确认', Completed: '已完成', Cancelled: '已取消', Appealing: '申诉中' }[s] || s)
|
|
const statusTagType = (s) => ({ Pending: 'info', InProgress: 'primary', WaitConfirm: 'warning', Completed: 'success', Cancelled: 'danger', Appealing: '' }[s] || 'info')
|
|
const formatTime = (t) => t ? new Date(t).toLocaleString('zh-CN') : ''
|
|
|
|
const filteredList = computed(() => {
|
|
let data = list.value
|
|
if (statusFilter.value) {
|
|
data = data.filter(item => item.status === statusFilter.value)
|
|
}
|
|
if (searchKeyword.value) {
|
|
const kw = searchKeyword.value.toLowerCase()
|
|
data = data.filter(item =>
|
|
(item.orderNo || '').toLowerCase().includes(kw) ||
|
|
(item.ownerNickname || '').toLowerCase().includes(kw) ||
|
|
(item.runnerNickname || '').toLowerCase().includes(kw)
|
|
)
|
|
}
|
|
return data
|
|
})
|
|
|
|
async function fetchList() {
|
|
loading.value = true
|
|
try {
|
|
list.value = await request.get('/admin/chat-list')
|
|
} finally {
|
|
loading.value = false
|
|
}
|
|
}
|
|
|
|
async function viewChat(row) {
|
|
chatDialogTitle.value = `聊天记录 - ${row.orderNo} (${row.ownerNickname} ↔ ${row.runnerNickname})`
|
|
chatDialogVisible.value = true
|
|
chatLoading.value = true
|
|
chatMessages.value = []
|
|
|
|
try {
|
|
const res = await request.get('/admin/chat-messages', {
|
|
params: { ownerUserId: row.ownerId, runnerUserId: row.runnerId }
|
|
})
|
|
chatMessages.value = parseIMMessages(res, row)
|
|
} catch (e) {
|
|
console.error('拉取聊天记录失败:', e)
|
|
} finally {
|
|
chatLoading.value = false
|
|
}
|
|
}
|
|
|
|
function parseIMMessages(imResponse, orderInfo) {
|
|
const msgList = imResponse.MsgList || imResponse.msgList || []
|
|
if (!msgList.length) return []
|
|
|
|
const ownerImId = `user_${orderInfo.ownerId}`
|
|
const result = []
|
|
let lastTime = 0
|
|
|
|
const sorted = [...msgList].sort((a, b) => {
|
|
const ta = a.MsgTimeStamp || a.msgTimeStamp || 0
|
|
const tb = b.MsgTimeStamp || b.msgTimeStamp || 0
|
|
return ta - tb
|
|
})
|
|
|
|
for (const msg of sorted) {
|
|
const from = msg.From_Account || msg.from_Account || ''
|
|
const timestamp = (msg.MsgTimeStamp || msg.msgTimeStamp || 0) * 1000
|
|
const isOwner = from === ownerImId
|
|
const nickname = isOwner ? orderInfo.ownerNickname : orderInfo.runnerNickname
|
|
const avatar = isOwner ? orderInfo.ownerAvatar : orderInfo.runnerAvatar
|
|
|
|
const showTime = timestamp - lastTime > 300000
|
|
let timeLabel = ''
|
|
if (showTime && timestamp) {
|
|
timeLabel = new Date(timestamp).toLocaleString('zh-CN')
|
|
lastTime = timestamp
|
|
}
|
|
|
|
const bodies = msg.MsgBody || msg.msgBody || []
|
|
for (const body of bodies) {
|
|
const msgType = body.MsgType || body.msgType || ''
|
|
const content = body.MsgContent || body.msgContent || {}
|
|
|
|
if (msgType === 'TIMTextElem') {
|
|
result.push({
|
|
type: 'text',
|
|
text: content.Text || content.text || '',
|
|
isOwner, nickname, avatar, showTime, timeLabel, isSystem: false
|
|
})
|
|
} else if (msgType === 'TIMImageElem') {
|
|
const imgList = content.ImageInfoArray || content.imageInfoArray || []
|
|
const imgUrl = imgList[1]?.URL || imgList[1]?.url || imgList[0]?.URL || imgList[0]?.url || ''
|
|
result.push({
|
|
type: 'image',
|
|
text: imgUrl,
|
|
isOwner, nickname, avatar, showTime, timeLabel, isSystem: false
|
|
})
|
|
} else if (msgType === 'TIMCustomElem') {
|
|
const desc = content.Desc || content.desc || ''
|
|
const dataStr = content.Data || content.data || ''
|
|
let displayText = desc || '[自定义消息]'
|
|
try {
|
|
const parsed = JSON.parse(dataStr)
|
|
if (parsed.bizType === 'order-status') {
|
|
result.push({
|
|
type: 'system', text: parsed.description || '[订单状态变更]',
|
|
isOwner: false, nickname: '', avatar: '', showTime, timeLabel, isSystem: true
|
|
})
|
|
continue
|
|
}
|
|
if (parsed.bizType === 'price-change') {
|
|
displayText = `${parsed.changeTypeLabel || '改价'}申请: ¥${parsed.originalPrice} → ¥${parsed.newPrice}`
|
|
}
|
|
} catch {}
|
|
result.push({
|
|
type: 'text', text: displayText,
|
|
isOwner, nickname, avatar, showTime, timeLabel, isSystem: false
|
|
})
|
|
} else {
|
|
result.push({
|
|
type: 'text', text: '[暂不支持的消息类型]',
|
|
isOwner, nickname, avatar, showTime, timeLabel, isSystem: false
|
|
})
|
|
}
|
|
}
|
|
}
|
|
|
|
return result
|
|
}
|
|
|
|
function previewImage(url) {
|
|
previewUrl.value = url
|
|
previewVisible.value = true
|
|
}
|
|
|
|
onMounted(fetchList)
|
|
</script>
|
|
|
|
<style scoped>
|
|
.chat-container {
|
|
max-height: 60vh;
|
|
overflow-y: auto;
|
|
padding: 12px 0;
|
|
}
|
|
|
|
.chat-empty {
|
|
text-align: center;
|
|
color: #999;
|
|
padding: 40px 0;
|
|
}
|
|
|
|
.chat-time-divider {
|
|
text-align: center;
|
|
margin: 12px 0 8px;
|
|
}
|
|
|
|
.chat-time-divider span {
|
|
font-size: 12px;
|
|
color: #999;
|
|
background: #f0f0f0;
|
|
padding: 2px 10px;
|
|
border-radius: 10px;
|
|
}
|
|
|
|
.chat-system-msg {
|
|
text-align: center;
|
|
margin: 8px 0;
|
|
}
|
|
|
|
.chat-system-msg span {
|
|
font-size: 12px;
|
|
color: #999;
|
|
background: #f5f5f5;
|
|
padding: 4px 12px;
|
|
border-radius: 4px;
|
|
}
|
|
|
|
.chat-msg-wrap {
|
|
margin-bottom: 4px;
|
|
}
|
|
|
|
.chat-bubble-row {
|
|
display: flex;
|
|
align-items: flex-start;
|
|
gap: 8px;
|
|
margin: 4px 12px;
|
|
}
|
|
|
|
.chat-left {
|
|
flex-direction: row;
|
|
}
|
|
|
|
.chat-right {
|
|
flex-direction: row-reverse;
|
|
}
|
|
|
|
.chat-avatar-item {
|
|
flex-shrink: 0;
|
|
}
|
|
|
|
.chat-bubble-wrap {
|
|
max-width: 70%;
|
|
}
|
|
|
|
.chat-nickname {
|
|
font-size: 12px;
|
|
color: #999;
|
|
margin-bottom: 2px;
|
|
}
|
|
|
|
.chat-right .chat-nickname {
|
|
text-align: right;
|
|
}
|
|
|
|
.chat-bubble {
|
|
display: inline-block;
|
|
padding: 8px 12px;
|
|
border-radius: 8px;
|
|
font-size: 14px;
|
|
line-height: 1.5;
|
|
word-break: break-all;
|
|
}
|
|
|
|
.bubble-left {
|
|
background: #f0f0f0;
|
|
color: #333;
|
|
border-top-left-radius: 2px;
|
|
}
|
|
|
|
.bubble-right {
|
|
background: #409eff;
|
|
color: #fff;
|
|
border-top-right-radius: 2px;
|
|
}
|
|
|
|
.chat-right .chat-bubble-wrap {
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: flex-end;
|
|
}
|
|
|
|
.chat-img {
|
|
max-width: 200px;
|
|
max-height: 200px;
|
|
border-radius: 6px;
|
|
cursor: pointer;
|
|
}
|
|
</style>
|