This commit is contained in:
parent
70c466951b
commit
c543ebaf8b
|
|
@ -58,6 +58,10 @@
|
||||||
<el-icon><Money /></el-icon>
|
<el-icon><Money /></el-icon>
|
||||||
<template #title>提现管理</template>
|
<template #title>提现管理</template>
|
||||||
</el-menu-item>
|
</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-menu-item index="/config">
|
||||||
<el-icon><Setting /></el-icon>
|
<el-icon><Setting /></el-icon>
|
||||||
<template #title>配置管理</template>
|
<template #title>配置管理</template>
|
||||||
|
|
@ -99,7 +103,7 @@
|
||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { ElMessageBox } from 'element-plus'
|
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 router = useRouter()
|
||||||
const isCollapse = ref(false)
|
const isCollapse = ref(false)
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,12 @@ const routes = [
|
||||||
component: () => import('../views/Withdrawals.vue'),
|
component: () => import('../views/Withdrawals.vue'),
|
||||||
meta: { title: '提现管理' }
|
meta: { title: '提现管理' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'chat-records',
|
||||||
|
name: 'ChatRecords',
|
||||||
|
component: () => import('../views/ChatRecords.vue'),
|
||||||
|
meta: { title: '聊天记录' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'config',
|
path: 'config',
|
||||||
name: '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>
|
</div>
|
||||||
|
|
||||||
<el-table :data="list" v-loading="loading" stripe style="width: 100%">
|
<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">
|
<el-table-column label="头像" width="70" align="center">
|
||||||
<template #default="{ row }">
|
<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>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column prop="nickname" label="昵称" min-width="120" show-overflow-tooltip />
|
<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">
|
<el-table-column label="角色" width="100" align="center">
|
||||||
<template #default="{ row }">
|
<template #default="{ row }">
|
||||||
<el-tag :type="getRoleType(row.role)" size="small" round>{{ getRoleLabel(row.role) }}</el-tag>
|
<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) {
|
function getRoleLabel(role) {
|
||||||
const map = { User: '普通用户', Admin: '管理员' }
|
const map = { User: '普通用户', Runner: '跑腿', Admin: '管理员' }
|
||||||
return map[role] || role
|
return map[role] || role
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRoleType(role) {
|
function getRoleType(role) {
|
||||||
return role === 'Admin' ? 'warning' : ''
|
const map = { Admin: 'warning', Runner: 'success' }
|
||||||
|
return map[role] || ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatTime(str) {
|
function formatTime(str) {
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ export default {
|
||||||
})
|
})
|
||||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||||
uni.showToast({ title: '下单成功', icon: 'success' })
|
uni.showToast({ title: '下单成功', icon: 'success' })
|
||||||
setTimeout(() => { uni.navigateBack() }, 1500)
|
setTimeout(() => { uni.switchTab({ url: '/pages/index/index' }) }, 1500)
|
||||||
} catch (e) {} finally { this.submitting = false }
|
} catch (e) {} finally { this.submitting = false }
|
||||||
},
|
},
|
||||||
wxPay(params) {
|
wxPay(params) {
|
||||||
|
|
|
||||||
|
|
@ -182,7 +182,7 @@ export default {
|
||||||
this.cartStore.clearCart()
|
this.cartStore.clearCart()
|
||||||
uni.showToast({ title: '下单成功', icon: 'success' })
|
uni.showToast({ title: '下单成功', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.navigateBack({ delta: 2 })
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// 错误已在 request 中处理
|
// 错误已在 request 中处理
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@
|
||||||
<view class="form-group">
|
<view class="form-group">
|
||||||
<text class="form-label">4.跑腿佣金</text>
|
<text class="form-label">4.跑腿佣金</text>
|
||||||
<view class="input-box">
|
<view class="input-box">
|
||||||
<input v-model="form.commission" type="digit" placeholder="请输入跑腿佣金,若涉及购买,需包含商品金额"
|
<input v-model="form.commission" type="digit" placeholder="请输入跑腿佣金"
|
||||||
placeholder-class="placeholder" />
|
placeholder-class="placeholder" />
|
||||||
</view>
|
</view>
|
||||||
<text class="form-tip">佣金先由平台保管,接单方完成订单后才会收到佣金</text>
|
<text class="form-tip">佣金先由平台保管,接单方完成订单后才会收到佣金</text>
|
||||||
|
|
@ -217,14 +217,14 @@
|
||||||
commission,
|
commission,
|
||||||
totalAmount: goodsAmount + commission
|
totalAmount: goodsAmount + commission
|
||||||
})
|
})
|
||||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '下单成功',
|
title: '下单成功',
|
||||||
icon: 'success'
|
icon: 'success'
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.navigateBack()
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (e) {} finally {
|
} catch (e) {} finally {
|
||||||
this.submitting = false
|
this.submitting = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -224,6 +224,7 @@
|
||||||
return {
|
return {
|
||||||
orderId: null,
|
orderId: null,
|
||||||
orderInfo: {},
|
orderInfo: {},
|
||||||
|
lastOrderStatus: null,
|
||||||
chatMessages: [],
|
chatMessages: [],
|
||||||
inputText: '',
|
inputText: '',
|
||||||
scrollTop: 0,
|
scrollTop: 0,
|
||||||
|
|
@ -282,9 +283,10 @@
|
||||||
offNewMessage()
|
offNewMessage()
|
||||||
},
|
},
|
||||||
onShow() {
|
onShow() {
|
||||||
// 如果 orderId 存在但 orderInfo 为空,重新加载
|
|
||||||
if (this.orderId && !this.orderInfo.id) {
|
if (this.orderId && !this.orderInfo.id) {
|
||||||
this.loadOrderInfo()
|
this.loadOrderInfo()
|
||||||
|
} else if (this.orderId && this.lastOrderStatus) {
|
||||||
|
this.checkOrderStatusChange()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -297,6 +299,9 @@
|
||||||
const res = await getOrderDetail(this.orderId);
|
const res = await getOrderDetail(this.orderId);
|
||||||
console.log('[聊天] 订单详情:', res)
|
console.log('[聊天] 订单详情:', res)
|
||||||
this.orderInfo = res || {}
|
this.orderInfo = res || {}
|
||||||
|
if (!this.lastOrderStatus) {
|
||||||
|
this.lastOrderStatus = this.orderInfo.status
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('[聊天] 加载订单详情失败:', 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() {
|
onCompleteOrder() {
|
||||||
this.showMorePanel = false
|
this.showMorePanel = false
|
||||||
uni.navigateTo({
|
uni.navigateTo({
|
||||||
|
|
|
||||||
|
|
@ -55,7 +55,7 @@
|
||||||
<image class="chat-avatar" :src="item.targetAvatar || '/static/logo.png'" mode="aspectFill"></image>
|
<image class="chat-avatar" :src="item.targetAvatar || '/static/logo.png'" mode="aspectFill"></image>
|
||||||
<view class="chat-info">
|
<view class="chat-info">
|
||||||
<view class="chat-top">
|
<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>
|
<text class="chat-time">{{ formatTime(item.lastTime) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="chat-msg">{{ getOrderLabel(item) }}</text>
|
<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) {
|
getOrderLabel(item) {
|
||||||
const typeMap = { Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }
|
const typeMap = { Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }
|
||||||
|
|
|
||||||
|
|
@ -25,12 +25,12 @@
|
||||||
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
|
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-stats">
|
<view class="card-stats">
|
||||||
<view class="stat-item">
|
<view class="stat-item" @click.stop="goMyOrders('InProgress')">
|
||||||
<text class="stat-label">进行中</text>
|
<text class="stat-label">进行中</text>
|
||||||
<text class="stat-num">{{ stats.orderOngoing }}</text>
|
<text class="stat-num">{{ stats.orderOngoing }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-divider"></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-label">已完成</text>
|
||||||
<text class="stat-num">{{ stats.orderCompleted }}</text>
|
<text class="stat-num">{{ stats.orderCompleted }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
@ -44,12 +44,12 @@
|
||||||
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
|
<image class="arrow-icon" src="/static/ic_arrow.png" mode="aspectFit"></image>
|
||||||
</view>
|
</view>
|
||||||
<view class="card-stats">
|
<view class="card-stats">
|
||||||
<view class="stat-item">
|
<view class="stat-item" @click.stop="goMyTaken('InProgress')">
|
||||||
<text class="stat-label">进行中</text>
|
<text class="stat-label">进行中</text>
|
||||||
<text class="stat-num">{{ stats.takenOngoing }}</text>
|
<text class="stat-num">{{ stats.takenOngoing }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="stat-divider"></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-label">已完成</text>
|
||||||
<text class="stat-num">{{ stats.takenCompleted }}</text>
|
<text class="stat-num">{{ stats.takenCompleted }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
@ -149,8 +149,14 @@ export default {
|
||||||
uni.navigateTo({ url: '/pages/login/login' })
|
uni.navigateTo({ url: '/pages/login/login' })
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
goMyOrders() { uni.navigateTo({ url: '/pages/order/my-orders' }) },
|
goMyOrders(status) {
|
||||||
goMyTaken() { uni.navigateTo({ url: '/pages/order/my-taken' }) },
|
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' }) },
|
goQrcode() { uni.navigateTo({ url: '/pages/config/qrcode' }) },
|
||||||
goCertification() { uni.navigateTo({ url: '/pages/runner/certification' }) },
|
goCertification() { uni.navigateTo({ url: '/pages/runner/certification' }) },
|
||||||
goEarnings() { uni.navigateTo({ url: '/pages/mine/earnings' }) },
|
goEarnings() { uni.navigateTo({ url: '/pages/mine/earnings' }) },
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { getMyOrders, cancelOrder, submitReview, confirmOrder, rejectOrder } from '../../utils/api'
|
import { getMyOrders, cancelOrder, submitReview, confirmOrder, rejectOrder } from '../../utils/api'
|
||||||
|
import { initIM, sendCustomMessage } from '../../utils/im'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
data() {
|
data() {
|
||||||
|
|
@ -214,9 +215,14 @@ export default {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onLoad(options) {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
const sysInfo = uni.getSystemInfoSync()
|
||||||
this.statusBarHeight = sysInfo.statusBarHeight || 0
|
this.statusBarHeight = sysInfo.statusBarHeight || 0
|
||||||
|
if (options.status) {
|
||||||
|
this.currentStatus = options.status
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
this.loadOrders()
|
this.loadOrders()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
@ -310,6 +316,7 @@ export default {
|
||||||
await confirmOrder(order.id)
|
await confirmOrder(order.id)
|
||||||
uni.showToast({ title: '订单已完成', icon: 'success' })
|
uni.showToast({ title: '订单已完成', icon: 'success' })
|
||||||
this.loadOrders()
|
this.loadOrders()
|
||||||
|
this.sendOrderStatusIM(order.runnerId, order.id, '单主已确认,订单已完成')
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
@ -326,11 +333,26 @@ export default {
|
||||||
await rejectOrder(order.id)
|
await rejectOrder(order.id)
|
||||||
uni.showToast({ title: '已拒绝,订单继续进行', icon: 'none' })
|
uni.showToast({ title: '已拒绝,订单继续进行', icon: 'none' })
|
||||||
this.loadOrders()
|
this.loadOrders()
|
||||||
|
this.sendOrderStatusIM(order.runnerId, order.id, '单主已拒绝完成,订单继续进行')
|
||||||
} catch (e) {}
|
} 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) {
|
openReview(order) {
|
||||||
this.reviewingOrder = order
|
this.reviewingOrder = order
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,14 @@ export default {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onShow() {
|
onLoad(options) {
|
||||||
const sysInfo = uni.getSystemInfoSync()
|
const sysInfo = uni.getSystemInfoSync()
|
||||||
this.statusBarHeight = sysInfo.statusBarHeight || 0
|
this.statusBarHeight = sysInfo.statusBarHeight || 0
|
||||||
|
if (options.status) {
|
||||||
|
this.currentStatus = options.status
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onShow() {
|
||||||
this.loadOrders()
|
this.loadOrders()
|
||||||
},
|
},
|
||||||
methods: {
|
methods: {
|
||||||
|
|
|
||||||
|
|
@ -185,7 +185,7 @@ export default {
|
||||||
})
|
})
|
||||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||||
uni.showToast({ title: '下单成功', icon: 'success' })
|
uni.showToast({ title: '下单成功', icon: 'success' })
|
||||||
setTimeout(() => { uni.navigateBack() }, 1500)
|
setTimeout(() => { uni.switchTab({ url: '/pages/index/index' }) }, 1500)
|
||||||
} catch (e) {} finally { this.submitting = false }
|
} catch (e) {} finally { this.submitting = false }
|
||||||
},
|
},
|
||||||
wxPay(params) {
|
wxPay(params) {
|
||||||
|
|
|
||||||
|
|
@ -243,14 +243,14 @@
|
||||||
commission,
|
commission,
|
||||||
totalAmount: goodsAmount + commission
|
totalAmount: goodsAmount + commission
|
||||||
})
|
})
|
||||||
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
if (result.paymentParams) await this.wxPay(result.paymentParams)
|
||||||
uni.showToast({
|
uni.showToast({
|
||||||
title: '下单成功',
|
title: '下单成功',
|
||||||
icon: 'success'
|
icon: 'success'
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.navigateBack()
|
uni.switchTab({ url: '/pages/index/index' })
|
||||||
}, 1500)
|
}, 1500)
|
||||||
} catch (e) {} finally {
|
} catch (e) {} finally {
|
||||||
this.submitting = false
|
this.submitting = false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,6 +54,21 @@ export async function initIM() {
|
||||||
// 登录
|
// 登录
|
||||||
await chat.login({ userID: userId, userSig })
|
await chat.login({ userID: userId, userSig })
|
||||||
console.log('[IM] 登录成功:', userId)
|
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 }
|
return { sdkAppId, userId }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -255,7 +270,7 @@ export function formatIMMessage(msg, currentImUserId) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 自定义消息(改价等)
|
// 自定义消息(改价、订单状态等)
|
||||||
if (msg.type === TencentCloudChat.TYPES.MSG_CUSTOM) {
|
if (msg.type === TencentCloudChat.TYPES.MSG_CUSTOM) {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(msg.payload.data)
|
const data = JSON.parse(msg.payload.data)
|
||||||
|
|
@ -274,6 +289,15 @@ export function formatIMMessage(msg, currentImUserId) {
|
||||||
orderId: msgOrderId
|
orderId: msgOrderId
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (data.bizType === 'order-status') {
|
||||||
|
return {
|
||||||
|
id: msg.ID,
|
||||||
|
type: 'system',
|
||||||
|
content: data.description || '[订单状态变更]',
|
||||||
|
time: msg.time * 1000,
|
||||||
|
orderId: msgOrderId
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
return {
|
return {
|
||||||
id: msg.ID,
|
id: msg.ID,
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,7 @@ builder.Services.AddSingleton<JwtService>();
|
||||||
builder.Services.AddHttpClient<IWeChatService, WeChatService>();
|
builder.Services.AddHttpClient<IWeChatService, WeChatService>();
|
||||||
|
|
||||||
// 注册腾讯 IM 服务
|
// 注册腾讯 IM 服务
|
||||||
|
builder.Services.AddHttpClient();
|
||||||
builder.Services.AddSingleton<TencentIMService>();
|
builder.Services.AddSingleton<TencentIMService>();
|
||||||
|
|
||||||
// OpenAPI 文档(.NET 10 内置)
|
// OpenAPI 文档(.NET 10 内置)
|
||||||
|
|
@ -678,9 +679,7 @@ app.MapGet("/api/orders/chat-list", async (HttpContext httpContext, AppDbContext
|
||||||
if (userIdClaim == null) return Results.Unauthorized();
|
if (userIdClaim == null) return Results.Unauthorized();
|
||||||
var currentUserId = int.Parse(userIdClaim.Value);
|
var currentUserId = int.Parse(userIdClaim.Value);
|
||||||
|
|
||||||
var orders = await db.Orders
|
var rawOrders = await db.Orders
|
||||||
.Include(o => o.Owner)
|
|
||||||
.Include(o => o.Runner)
|
|
||||||
.Where(o => o.RunnerId != null &&
|
.Where(o => o.RunnerId != null &&
|
||||||
(o.OwnerId == currentUserId || o.RunnerId == currentUserId) &&
|
(o.OwnerId == currentUserId || o.RunnerId == currentUserId) &&
|
||||||
o.Status != OrderStatus.Cancelled)
|
o.Status != OrderStatus.Cancelled)
|
||||||
|
|
@ -693,17 +692,39 @@ app.MapGet("/api/orders/chat-list", async (HttpContext httpContext, AppDbContext
|
||||||
o.ItemName,
|
o.ItemName,
|
||||||
Status = o.Status.ToString(),
|
Status = o.Status.ToString(),
|
||||||
o.Commission,
|
o.Commission,
|
||||||
TargetUserId = o.OwnerId == currentUserId ? o.RunnerId : (int?)o.OwnerId,
|
o.OwnerId,
|
||||||
TargetNickname = o.OwnerId == currentUserId
|
o.RunnerId,
|
||||||
? (o.Runner != null ? o.Runner.Nickname : "用户")
|
OwnerNickname = o.Owner!.Nickname,
|
||||||
: (o.Owner != null ? o.Owner.Nickname : "用户"),
|
OwnerAvatar = o.Owner!.AvatarUrl,
|
||||||
TargetAvatar = o.OwnerId == currentUserId
|
RunnerNickname = o.Runner!.Nickname,
|
||||||
? (o.Runner != null ? o.Runner.AvatarUrl : "")
|
RunnerAvatar = o.Runner!.AvatarUrl,
|
||||||
: (o.Owner != null ? o.Owner.AvatarUrl : ""),
|
|
||||||
LastTime = o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt
|
LastTime = o.CompletedAt ?? o.AcceptedAt ?? o.CreatedAt
|
||||||
})
|
})
|
||||||
.ToListAsync();
|
.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);
|
return Results.Ok(orders);
|
||||||
}).RequireAuthorization();
|
}).RequireAuthorization();
|
||||||
|
|
||||||
|
|
@ -2972,6 +2993,67 @@ app.MapPut("/api/admin/config/{key}", async (string key, UpdateConfigRequest req
|
||||||
});
|
});
|
||||||
}).RequireAuthorization("AdminOnly");
|
}).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 (
|
app.MapPost("/api/admin/auth/login", async (
|
||||||
AdminLoginRequest request,
|
AdminLoginRequest request,
|
||||||
|
|
|
||||||
|
|
@ -5,18 +5,21 @@ using System.Text.Json;
|
||||||
namespace CampusErrand.Services;
|
namespace CampusErrand.Services;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 腾讯 IM 服务,负责生成 UserSig
|
/// 腾讯 IM 服务,负责生成 UserSig 和调用服务端 REST API
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public class TencentIMService
|
public class TencentIMService
|
||||||
{
|
{
|
||||||
private readonly long _sdkAppId;
|
private readonly long _sdkAppId;
|
||||||
private readonly string _secretKey;
|
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");
|
var config = configuration.GetSection("TencentIM");
|
||||||
_sdkAppId = config.GetValue<long>("SDKAppId");
|
_sdkAppId = config.GetValue<long>("SDKAppId");
|
||||||
_secretKey = config["SecretKey"]!;
|
_secretKey = config["SecretKey"]!;
|
||||||
|
_httpClient = httpClientFactory.CreateClient();
|
||||||
}
|
}
|
||||||
|
|
||||||
public long SDKAppId => _sdkAppId;
|
public long SDKAppId => _sdkAppId;
|
||||||
|
|
@ -24,8 +27,6 @@ public class TencentIMService
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// 生成 UserSig
|
/// 生成 UserSig
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="userId">用户标识</param>
|
|
||||||
/// <param name="expireSeconds">有效期(秒),默认7天</param>
|
|
||||||
public string GenerateUserSig(string userId, int expireSeconds = 604800)
|
public string GenerateUserSig(string userId, int expireSeconds = 604800)
|
||||||
{
|
{
|
||||||
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
var now = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
|
||||||
|
|
@ -45,17 +46,39 @@ public class TencentIMService
|
||||||
|
|
||||||
var jsonBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(obj));
|
var jsonBytes = Encoding.UTF8.GetBytes(JsonSerializer.Serialize(obj));
|
||||||
|
|
||||||
// zlib 压缩
|
|
||||||
using var output = new MemoryStream();
|
using var output = new MemoryStream();
|
||||||
using (var zlib = new System.IO.Compression.ZLibStream(output, System.IO.Compression.CompressionLevel.Optimal))
|
using (var zlib = new System.IO.Compression.ZLibStream(output, System.IO.Compression.CompressionLevel.Optimal))
|
||||||
{
|
{
|
||||||
zlib.Write(jsonBytes, 0, jsonBytes.Length);
|
zlib.Write(jsonBytes, 0, jsonBytes.Length);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Base64 URL 安全编码
|
|
||||||
return Convert.ToBase64String(output.ToArray())
|
return Convert.ToBase64String(output.ToArray())
|
||||||
.Replace('+', '*')
|
.Replace('+', '*')
|
||||||
.Replace('/', '-')
|
.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