campus-errand/admin/src/views/ChatRecords.vue
18631081161 c543ebaf8b
All checks were successful
continuous-integration/drone/push Build is passing
聊天
2026-03-28 17:16:01 +08:00

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>