This commit is contained in:
parent
70c466951b
commit
c543ebaf8b
|
|
@ -58,6 +58,10 @@
|
|||
<el-icon><Money /></el-icon>
|
||||
<template #title>提现管理</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/chat-records">
|
||||
<el-icon><ChatDotSquare /></el-icon>
|
||||
<template #title>聊天记录</template>
|
||||
</el-menu-item>
|
||||
<el-menu-item index="/config">
|
||||
<el-icon><Setting /></el-icon>
|
||||
<template #title>配置管理</template>
|
||||
|
|
@ -99,7 +103,7 @@
|
|||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { ElMessageBox } from 'element-plus'
|
||||
import { Monitor, Fold, Expand, ArrowDown, Picture, Grid, Shop, Stamp, User, UserFilled, Star, Bell, List, Setting, Money } from '@element-plus/icons-vue'
|
||||
import { Monitor, Fold, Expand, ArrowDown, Picture, Grid, Shop, Stamp, User, UserFilled, Star, Bell, List, Setting, Money, ChatDotSquare } from '@element-plus/icons-vue'
|
||||
|
||||
const router = useRouter()
|
||||
const isCollapse = ref(false)
|
||||
|
|
|
|||
|
|
@ -84,6 +84,12 @@ const routes = [
|
|||
component: () => import('../views/Withdrawals.vue'),
|
||||
meta: { title: '提现管理' }
|
||||
},
|
||||
{
|
||||
path: 'chat-records',
|
||||
name: 'ChatRecords',
|
||||
component: () => import('../views/ChatRecords.vue'),
|
||||
meta: { title: '聊天记录' }
|
||||
},
|
||||
{
|
||||
path: 'config',
|
||||
name: 'Config',
|
||||
|
|
|
|||
354
admin/src/views/ChatRecords.vue
Normal file
354
admin/src/views/ChatRecords.vue
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
<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>
|
||||
|
|
@ -20,16 +20,17 @@
|
|||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
|
||||
<el-table-column prop="id" label="ID" width="80" align="center" />
|
||||
<el-table-column prop="id" label="UID" width="80" align="center" />
|
||||
<el-table-column label="头像" width="70" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-avatar :size="36" :src="row.avatarUrl" />
|
||||
<el-avatar :size="36" :src="row.avatarUrl || undefined">
|
||||
<template #default>
|
||||
<span style="font-size: 14px">{{ (row.nickname || '?')[0] }}</span>
|
||||
</template>
|
||||
</el-avatar>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="nickname" label="昵称" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="手机号" min-width="130">
|
||||
<template #default="{ row }">{{ row.phone || '-' }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="角色" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="getRoleType(row.role)" size="small" round>{{ getRoleLabel(row.role) }}</el-tag>
|
||||
|
|
@ -103,12 +104,13 @@ async function toggleBan(row, isBanned) {
|
|||
}
|
||||
|
||||
function getRoleLabel(role) {
|
||||
const map = { User: '普通用户', Admin: '管理员' }
|
||||
const map = { User: '普通用户', Runner: '跑腿', Admin: '管理员' }
|
||||
return map[role] || role
|
||||
}
|
||||
|
||||
function getRoleType(role) {
|
||||
return role === 'Admin' ? 'warning' : ''
|
||||
const map = { Admin: 'warning', Runner: 'success' }
|
||||
return map[role] || ''
|
||||
}
|
||||
|
||||
function formatTime(str) {
|
||||
|
|
|
|||
|
|
@ -178,7 +178,7 @@ export default {
|
|||
})
|
||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||
uni.showToast({ title: '下单成功', icon: 'success' })
|
||||
setTimeout(() => { uni.navigateBack() }, 1500)
|
||||
setTimeout(() => { uni.switchTab({ url: '/pages/index/index' }) }, 1500)
|
||||
} catch (e) {} finally { this.submitting = false }
|
||||
},
|
||||
wxPay(params) {
|
||||
|
|
|
|||
|
|
@ -182,7 +182,7 @@ export default {
|
|||
this.cartStore.clearCart()
|
||||
uni.showToast({ title: '下单成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.navigateBack({ delta: 2 })
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
// 错误已在 request 中处理
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
<view class="form-group">
|
||||
<text class="form-label">4.跑腿佣金</text>
|
||||
<view class="input-box">
|
||||
<input v-model="form.commission" type="digit" placeholder="请输入跑腿佣金,若涉及购买,需包含商品金额"
|
||||
<input v-model="form.commission" type="digit" placeholder="请输入跑腿佣金"
|
||||
placeholder-class="placeholder" />
|
||||
</view>
|
||||
<text class="form-tip">佣金先由平台保管,接单方完成订单后才会收到佣金</text>
|
||||
|
|
@ -217,14 +217,14 @@
|
|||
commission,
|
||||
totalAmount: goodsAmount + commission
|
||||
})
|
||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||
uni.showToast({
|
||||
title: '下单成功',
|
||||
icon: 'success'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||
uni.showToast({
|
||||
title: '下单成功',
|
||||
icon: 'success'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1500)
|
||||
} catch (e) {} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -224,6 +224,7 @@
|
|||
return {
|
||||
orderId: null,
|
||||
orderInfo: {},
|
||||
lastOrderStatus: null,
|
||||
chatMessages: [],
|
||||
inputText: '',
|
||||
scrollTop: 0,
|
||||
|
|
@ -282,9 +283,10 @@
|
|||
offNewMessage()
|
||||
},
|
||||
onShow() {
|
||||
// 如果 orderId 存在但 orderInfo 为空,重新加载
|
||||
if (this.orderId && !this.orderInfo.id) {
|
||||
this.loadOrderInfo()
|
||||
} else if (this.orderId && this.lastOrderStatus) {
|
||||
this.checkOrderStatusChange()
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -297,6 +299,9 @@
|
|||
const res = await getOrderDetail(this.orderId);
|
||||
console.log('[聊天] 订单详情:', res)
|
||||
this.orderInfo = res || {}
|
||||
if (!this.lastOrderStatus) {
|
||||
this.lastOrderStatus = this.orderInfo.status
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[聊天] 加载订单详情失败:', e)
|
||||
}
|
||||
|
|
@ -540,6 +545,45 @@
|
|||
})
|
||||
}
|
||||
},
|
||||
async checkOrderStatusChange() {
|
||||
const oldStatus = this.lastOrderStatus
|
||||
try {
|
||||
await this.loadOrderInfo()
|
||||
} catch (e) {
|
||||
return
|
||||
}
|
||||
const newStatus = this.orderInfo.status
|
||||
if (!oldStatus || oldStatus === newStatus) return
|
||||
this.lastOrderStatus = newStatus
|
||||
|
||||
const statusMessages = {
|
||||
'InProgress→WaitConfirm': '跑腿已提交完成,等待单主确认',
|
||||
'WaitConfirm→Completed': '单主已确认,订单已完成',
|
||||
'WaitConfirm→InProgress': '单主已拒绝完成,订单继续进行'
|
||||
}
|
||||
const key = `${oldStatus}→${newStatus}`
|
||||
const msg = statusMessages[key]
|
||||
if (!msg) return
|
||||
|
||||
this.chatMessages.push({
|
||||
id: `sys_status_${Date.now()}`,
|
||||
type: 'system',
|
||||
content: msg
|
||||
})
|
||||
this.scrollToBottom()
|
||||
|
||||
if (this.imReady && this.targetImUserId) {
|
||||
try {
|
||||
await sendCustomMessage(this.targetImUserId, {
|
||||
bizType: 'order-status',
|
||||
action: key,
|
||||
description: msg
|
||||
}, this.orderId)
|
||||
} catch (e) {
|
||||
console.error('[聊天] 发送状态通知失败:', e)
|
||||
}
|
||||
}
|
||||
},
|
||||
onCompleteOrder() {
|
||||
this.showMorePanel = false
|
||||
uni.navigateTo({
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@
|
|||
<image class="chat-avatar" :src="item.targetAvatar || '/static/logo.png'" mode="aspectFill"></image>
|
||||
<view class="chat-info">
|
||||
<view class="chat-top">
|
||||
<text class="chat-name">{{ item.targetNickname || '用户' }}</text>
|
||||
<text class="chat-name">{{ displayNickname(item) }}</text>
|
||||
<text class="chat-time">{{ formatTime(item.lastTime) }}</text>
|
||||
</view>
|
||||
<text class="chat-msg">{{ getOrderLabel(item) }}</text>
|
||||
|
|
@ -152,6 +152,15 @@ export default {
|
|||
})
|
||||
},
|
||||
|
||||
/** 显示昵称,过滤掉 IM 用户ID 格式 */
|
||||
displayNickname(item) {
|
||||
const nick = item.targetNickname
|
||||
if (!nick || /^user_\d+$/.test(nick)) {
|
||||
return item.targetUserId ? `用户${item.targetUserId}` : '用户'
|
||||
}
|
||||
return nick
|
||||
},
|
||||
|
||||
/** 获取订单摘要标签 */
|
||||
getOrderLabel(item) {
|
||||
const typeMap = { Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }
|
||||
|
|
|
|||
|
|
@ -25,12 +25,12 @@
|
|||
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
|
||||
</view>
|
||||
<view class="card-stats">
|
||||
<view class="stat-item">
|
||||
<view class="stat-item" @click.stop="goMyOrders('InProgress')">
|
||||
<text class="stat-label">进行中</text>
|
||||
<text class="stat-num">{{ stats.orderOngoing }}</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<view class="stat-item" @click.stop="goMyOrders('Completed')">
|
||||
<text class="stat-label">已完成</text>
|
||||
<text class="stat-num">{{ stats.orderCompleted }}</text>
|
||||
</view>
|
||||
|
|
@ -44,12 +44,12 @@
|
|||
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
|
||||
</view>
|
||||
<view class="card-stats">
|
||||
<view class="stat-item">
|
||||
<view class="stat-item" @click.stop="goMyTaken('InProgress')">
|
||||
<text class="stat-label">进行中</text>
|
||||
<text class="stat-num">{{ stats.takenOngoing }}</text>
|
||||
</view>
|
||||
<view class="stat-divider"></view>
|
||||
<view class="stat-item">
|
||||
<view class="stat-item" @click.stop="goMyTaken('Completed')">
|
||||
<text class="stat-label">已完成</text>
|
||||
<text class="stat-num">{{ stats.takenCompleted }}</text>
|
||||
</view>
|
||||
|
|
@ -149,8 +149,14 @@ export default {
|
|||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
}
|
||||
},
|
||||
goMyOrders() { uni.navigateTo({ url: '/pages/order/my-orders' }) },
|
||||
goMyTaken() { uni.navigateTo({ url: '/pages/order/my-taken' }) },
|
||||
goMyOrders(status) {
|
||||
const query = status ? `?status=${status}` : ''
|
||||
uni.navigateTo({ url: '/pages/order/my-orders' + query })
|
||||
},
|
||||
goMyTaken(status) {
|
||||
const query = status ? `?status=${status}` : ''
|
||||
uni.navigateTo({ url: '/pages/order/my-taken' + query })
|
||||
},
|
||||
goQrcode() { uni.navigateTo({ url: '/pages/config/qrcode' }) },
|
||||
goCertification() { uni.navigateTo({ url: '/pages/runner/certification' }) },
|
||||
goEarnings() { uni.navigateTo({ url: '/pages/mine/earnings' }) },
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@
|
|||
|
||||
<script>
|
||||
import { getMyOrders, cancelOrder, submitReview, confirmOrder, rejectOrder } from '../../utils/api'
|
||||
import { initIM, sendCustomMessage } from '../../utils/im'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
|
|
@ -214,9 +215,14 @@ export default {
|
|||
})
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
onLoad(options) {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
this.statusBarHeight = sysInfo.statusBarHeight || 0
|
||||
if (options.status) {
|
||||
this.currentStatus = options.status
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadOrders()
|
||||
},
|
||||
methods: {
|
||||
|
|
@ -310,6 +316,7 @@ export default {
|
|||
await confirmOrder(order.id)
|
||||
uni.showToast({ title: '订单已完成', icon: 'success' })
|
||||
this.loadOrders()
|
||||
this.sendOrderStatusIM(order.runnerId, order.id, '单主已确认,订单已完成')
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
|
|
@ -326,11 +333,26 @@ export default {
|
|||
await rejectOrder(order.id)
|
||||
uni.showToast({ title: '已拒绝,订单继续进行', icon: 'none' })
|
||||
this.loadOrders()
|
||||
this.sendOrderStatusIM(order.runnerId, order.id, '单主已拒绝完成,订单继续进行')
|
||||
} catch (e) {}
|
||||
}
|
||||
})
|
||||
},
|
||||
|
||||
/** 发送订单状态变更 IM 通知(异步,不阻塞主流程) */
|
||||
async sendOrderStatusIM(targetUserId, orderId, description) {
|
||||
if (!targetUserId) return
|
||||
try {
|
||||
await initIM()
|
||||
await sendCustomMessage(`user_${targetUserId}`, {
|
||||
bizType: 'order-status',
|
||||
description
|
||||
}, orderId)
|
||||
} catch (e) {
|
||||
console.error('[IM] 发送状态通知失败:', e)
|
||||
}
|
||||
},
|
||||
|
||||
/** 打开评价弹窗 */
|
||||
openReview(order) {
|
||||
this.reviewingOrder = order
|
||||
|
|
|
|||
|
|
@ -151,9 +151,14 @@ export default {
|
|||
})
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
onLoad(options) {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
this.statusBarHeight = sysInfo.statusBarHeight || 0
|
||||
if (options.status) {
|
||||
this.currentStatus = options.status
|
||||
}
|
||||
},
|
||||
onShow() {
|
||||
this.loadOrders()
|
||||
},
|
||||
methods: {
|
||||
|
|
|
|||
|
|
@ -185,7 +185,7 @@ export default {
|
|||
})
|
||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||
uni.showToast({ title: '下单成功', icon: 'success' })
|
||||
setTimeout(() => { uni.navigateBack() }, 1500)
|
||||
setTimeout(() => { uni.switchTab({ url: '/pages/index/index' }) }, 1500)
|
||||
} catch (e) {} finally { this.submitting = false }
|
||||
},
|
||||
wxPay(params) {
|
||||
|
|
|
|||
|
|
@ -243,14 +243,14 @@
|
|||
commission,
|
||||
totalAmount: goodsAmount + commission
|
||||
})
|
||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||
uni.showToast({
|
||||
title: '下单成功',
|
||||
icon: 'success'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||
uni.showToast({
|
||||
title: '下单成功',
|
||||
icon: 'success'
|
||||
})
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1500)
|
||||
} catch (e) {} finally {
|
||||
this.submitting = false
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,21 @@ export async function initIM() {
|
|||
// 登录
|
||||
await chat.login({ userID: userId, userSig })
|
||||
console.log('[IM] 登录成功:', userId)
|
||||
|
||||
// 同步用户资料(昵称+头像)到 IM
|
||||
try {
|
||||
const userInfo = JSON.parse(uni.getStorageSync('userInfo') || '{}')
|
||||
if (userInfo.nickname || userInfo.avatarUrl) {
|
||||
const profileData = {}
|
||||
if (userInfo.nickname) profileData.nick = userInfo.nickname
|
||||
if (userInfo.avatarUrl) profileData.avatar = userInfo.avatarUrl
|
||||
await chat.updateMyProfile(profileData)
|
||||
console.log('[IM] 用户资料已同步')
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('[IM] 同步用户资料失败:', e)
|
||||
}
|
||||
|
||||
return { sdkAppId, userId }
|
||||
}
|
||||
|
||||
|
|
@ -255,7 +270,7 @@ export function formatIMMessage(msg, currentImUserId) {
|
|||
}
|
||||
}
|
||||
|
||||
// 自定义消息(改价等)
|
||||
// 自定义消息(改价、订单状态等)
|
||||
if (msg.type === TencentCloudChat.TYPES.MSG_CUSTOM) {
|
||||
try {
|
||||
const data = JSON.parse(msg.payload.data)
|
||||
|
|
@ -274,6 +289,15 @@ export function formatIMMessage(msg, currentImUserId) {
|
|||
orderId: msgOrderId
|
||||
}
|
||||
}
|
||||
if (data.bizType === 'order-status') {
|
||||
return {
|
||||
id: msg.ID,
|
||||
type: 'system',
|
||||
content: data.description || '[订单状态变更]',
|
||||
time: msg.time * 1000,
|
||||
orderId: msgOrderId
|
||||
}
|
||||
}
|
||||
} catch (e) {}
|
||||
return {
|
||||
id: msg.ID,
|
||||
|
|
|
|||
|
|
@ -68,6 +68,7 @@ builder.Services.AddSingleton<JwtService>();
|
|||
builder.Services.AddHttpClient<IWeChatService, WeChatService>();
|
||||
|
||||
// 注册腾讯 IM 服务
|
||||
builder.Services.AddHttpClient();
|
||||
builder.Services.AddSingleton<TencentIMService>();
|
||||
|
||||
// OpenAPI 文档(.NET 10 内置)
|
||||
|
|
@ -678,9 +679,7 @@ app.MapGet("/api/orders/chat-list", async (HttpContext httpContext, AppDbContext
|
|||
if (userIdClaim == null) return Results.Unauthorized();
|
||||
var currentUserId = int.Parse(userIdClaim.Value);
|
||||
|
||||
var orders = await db.Orders
|
||||
.Include(o => o.Owner)
|
||||
.Include(o => o.Runner)
|
||||
var rawOrders = await db.Orders
|
||||
.Where(o => o.RunnerId != null &&
|
||||
(o.OwnerId == currentUserId || o.RunnerId == currentUserId) &&
|
||||
o.Status != OrderStatus.Cancelled)
|
||||
|
|
@ -693,17 +692,39 @@ app.MapGet("/api/orders/chat-list", async (HttpContext httpContext, AppDbContext
|
|||
o.ItemName,
|
||||
Status = o.Status.ToString(),
|
||||
o.Commission,
|
||||
TargetUserId = o.OwnerId == currentUserId ? o.RunnerId : (int?)o.OwnerId,
|
||||
TargetNickname = o.OwnerId == currentUserId
|
||||
? (o.Runner != null ? o.Runner.Nickname : "用户")
|
||||
: (o.Owner != null ? o.Owner.Nickname : "用户"),
|
||||
TargetAvatar = o.OwnerId == currentUserId
|
||||
? (o.Runner != null ? o.Runner.AvatarUrl : "")
|
||||
: (o.Owner != null ? o.Owner.AvatarUrl : ""),
|
||||
o.OwnerId,
|
||||
o.RunnerId,
|
||||
OwnerNickname = o.Owner!.Nickname,
|
||||
OwnerAvatar = o.Owner!.AvatarUrl,
|
||||
RunnerNickname = o.Runner!.Nickname,
|
||||
RunnerAvatar = o.Runner!.AvatarUrl,
|
||||
LastTime = o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var orders = rawOrders.Select(o =>
|
||||
{
|
||||
var isOwner = o.OwnerId == currentUserId;
|
||||
var targetId = isOwner ? o.RunnerId : (int?)o.OwnerId;
|
||||
var nickname = isOwner ? o.RunnerNickname : o.OwnerNickname;
|
||||
var avatar = isOwner ? o.RunnerAvatar : o.OwnerAvatar;
|
||||
if (string.IsNullOrWhiteSpace(nickname))
|
||||
nickname = $"用户{targetId}";
|
||||
return new
|
||||
{
|
||||
o.OrderId,
|
||||
o.OrderNo,
|
||||
o.OrderType,
|
||||
o.ItemName,
|
||||
o.Status,
|
||||
o.Commission,
|
||||
TargetUserId = targetId,
|
||||
TargetNickname = nickname,
|
||||
TargetAvatar = avatar ?? "",
|
||||
o.LastTime
|
||||
};
|
||||
});
|
||||
|
||||
return Results.Ok(orders);
|
||||
}).RequireAuthorization();
|
||||
|
||||
|
|
@ -2972,6 +2993,67 @@ app.MapPut("/api/admin/config/{key}", async (string key, UpdateConfigRequest req
|
|||
});
|
||||
}).RequireAuthorization("AdminOnly");
|
||||
|
||||
// ========== 聊天记录管理接口 ==========
|
||||
|
||||
app.MapGet("/api/admin/chat-list", async (AppDbContext db) =>
|
||||
{
|
||||
var orders = await db.Orders
|
||||
.Where(o => o.RunnerId != null && o.Status != OrderStatus.Cancelled)
|
||||
.OrderByDescending(o => o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt)
|
||||
.Select(o => new
|
||||
{
|
||||
o.Id,
|
||||
o.OrderNo,
|
||||
OrderType = o.OrderType.ToString(),
|
||||
o.ItemName,
|
||||
Status = o.Status.ToString(),
|
||||
o.Commission,
|
||||
OwnerId = o.OwnerId,
|
||||
OwnerNickname = o.Owner!.Nickname,
|
||||
OwnerAvatar = o.Owner!.AvatarUrl,
|
||||
RunnerId = o.RunnerId,
|
||||
RunnerNickname = o.Runner!.Nickname,
|
||||
RunnerAvatar = o.Runner!.AvatarUrl,
|
||||
CreatedAt = o.CreatedAt
|
||||
})
|
||||
.ToListAsync();
|
||||
|
||||
var result = orders.Select(o => new
|
||||
{
|
||||
o.Id,
|
||||
o.OrderNo,
|
||||
o.OrderType,
|
||||
o.ItemName,
|
||||
o.Status,
|
||||
o.Commission,
|
||||
o.OwnerId,
|
||||
OwnerNickname = string.IsNullOrWhiteSpace(o.OwnerNickname) ? $"用户{o.OwnerId}" : o.OwnerNickname,
|
||||
o.OwnerAvatar,
|
||||
o.RunnerId,
|
||||
RunnerNickname = string.IsNullOrWhiteSpace(o.RunnerNickname) ? $"用户{o.RunnerId}" : o.RunnerNickname,
|
||||
o.RunnerAvatar,
|
||||
o.CreatedAt
|
||||
});
|
||||
|
||||
return Results.Ok(result);
|
||||
}).RequireAuthorization("AdminOnly");
|
||||
|
||||
app.MapGet("/api/admin/chat-messages", async (int ownerUserId, int runnerUserId, TencentIMService imService) =>
|
||||
{
|
||||
var fromImId = $"user_{ownerUserId}";
|
||||
var toImId = $"user_{runnerUserId}";
|
||||
|
||||
try
|
||||
{
|
||||
var result = await imService.GetRoamMessagesAsync(fromImId, toImId);
|
||||
return Results.Ok(result);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return Results.BadRequest(new { code = 400, message = $"拉取聊天记录失败: {ex.Message}" });
|
||||
}
|
||||
}).RequireAuthorization("AdminOnly");
|
||||
|
||||
// 管理员账号密码登录接口
|
||||
app.MapPost("/api/admin/auth/login", async (
|
||||
AdminLoginRequest request,
|
||||
|
|
|
|||
|
|
@ -5,18 +5,21 @@ using System.Text.Json;
|
|||
namespace CampusErrand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯 IM 服务,负责生成 UserSig
|
||||
/// 腾讯 IM 服务,负责生成 UserSig 和调用服务端 REST API
|
||||
/// </summary>
|
||||
public class TencentIMService
|
||||
{
|
||||
private readonly long _sdkAppId;
|
||||
private readonly string _secretKey;
|
||||
private readonly HttpClient _httpClient;
|
||||
private const string AdminAccount = "administrator";
|
||||
|
||||
public TencentIMService(IConfiguration configuration)
|
||||
public TencentIMService(IConfiguration configuration, IHttpClientFactory httpClientFactory)
|
||||
{
|
||||
var config = configuration.GetSection("TencentIM");
|
||||
_sdkAppId = config.GetValue<long>("SDKAppId");
|
||||
_secretKey = config["SecretKey"]!;
|
||||
_httpClient = httpClientFactory.CreateClient();
|
||||
}
|
||||
|
||||
public long SDKAppId => _sdkAppId;
|
||||
|
|
@ -24,8 +27,6 @@ public class TencentIMService
|
|||
/// <summary>
|
||||
/// 生成 UserSig
|
||||
/// </summary>
|
||||
/// <param name="userId">用户标识</param>
|
||||
/// <param name="expireSeconds">有效期(秒),默认7天</param>
|
||||
public string GenerateUserSig(string userId, int expireSeconds = 604800)
|
||||
{
|
||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||
|
|
@ -45,17 +46,39 @@ public class TencentIMService
|
|||
|
||||
var jsonBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(obj));
|
||||
|
||||
// zlib 压缩
|
||||
using var output = new MemoryStream();
|
||||
using (var zlib = new System.IO.Compression.ZLibStream(output, System.IO.Compression.CompressionLevel.Optimal))
|
||||
{
|
||||
zlib.Write(jsonBytes, 0, jsonBytes.Length);
|
||||
}
|
||||
|
||||
// Base64 URL 安全编码
|
||||
return Convert.ToBase64String(output.ToArray())
|
||||
.Replace('+', '*')
|
||||
.Replace('/', '-')
|
||||
.Replace('=', '_');
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 通过服务端 REST API 拉取两个用户之间的漫游消息
|
||||
/// </summary>
|
||||
public async Task<JsonElement> GetRoamMessagesAsync(string fromUserId, string toUserId, int maxCnt = 100, long minTime = 0, long maxTime = 0)
|
||||
{
|
||||
var adminSig = GenerateUserSig(AdminAccount);
|
||||
var random = Random.Shared.Next(100000, 999999);
|
||||
var url = $"https://console.tim.qq.com/v4/openim/admin_getroammsg?sdkappid={_sdkAppId}&identifier={AdminAccount}&usersig={adminSig}&random={random}&contenttype=json";
|
||||
|
||||
var body = new
|
||||
{
|
||||
Operator_Account = fromUserId,
|
||||
Peer_Account = toUserId,
|
||||
MaxCnt = maxCnt,
|
||||
MinTime = minTime,
|
||||
MaxTime = maxTime
|
||||
};
|
||||
|
||||
var content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
|
||||
var response = await _httpClient.PostAsync(url, content);
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
return JsonSerializer.Deserialize<JsonElement>(json);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user