聊天
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
18631081161 2026-03-28 17:16:01 +08:00
parent 70c466951b
commit c543ebaf8b
17 changed files with 636 additions and 55 deletions

View File

@ -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)

View File

@ -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',

View 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>

View File

@ -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) {

View File

@ -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) {

View File

@ -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

View File

@ -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
}

View File

@ -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({

View File

@ -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: '美食街' }

View File

@ -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' }) },

View File

@ -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

View File

@ -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: {

View File

@ -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) {

View File

@ -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
}

View File

@ -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,

View File

@ -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,

View File

@ -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);
}
}