功能优化
This commit is contained in:
parent
d0a63a38af
commit
365116c2f8
|
|
@ -1,35 +1,35 @@
|
|||
<template>
|
||||
<div>
|
||||
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px;">
|
||||
<h3 style="margin: 0;">跑腿认证审核</h3>
|
||||
<el-select v-model="statusFilter" placeholder="筛选状态" clearable style="width: 150px;" @change="fetchList">
|
||||
<div class="cert-page">
|
||||
<div class="page-header">
|
||||
<h3>跑腿认证审核</h3>
|
||||
<el-select v-model="statusFilter" placeholder="筛选状态" clearable style="width: 160px;" @change="fetchList">
|
||||
<el-option label="待审核" value="Pending" />
|
||||
<el-option label="已通过" value="Approved" />
|
||||
<el-option label="已拒绝" value="Rejected" />
|
||||
</el-select>
|
||||
</div>
|
||||
|
||||
<el-table :data="list" v-loading="loading" border>
|
||||
<el-table-column prop="id" label="ID" width="60" />
|
||||
<el-table-column prop="userId" label="用户ID" width="80" />
|
||||
<el-table-column prop="userNickname" label="用户昵称" width="120" />
|
||||
<el-table-column prop="realName" label="真实姓名" width="120" />
|
||||
<el-table-column prop="phone" label="手机号" width="140" />
|
||||
<el-table-column label="状态" width="100">
|
||||
<el-table :data="list" v-loading="loading" stripe :header-cell-style="{ background: '#fafafa', color: '#333', fontWeight: 600 }" style="width: 100%;">
|
||||
<el-table-column prop="id" label="ID" width="70" align="center" />
|
||||
<el-table-column prop="userId" label="用户ID" width="90" align="center" />
|
||||
<el-table-column prop="userNickname" label="用户昵称" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="realName" label="真实姓名" min-width="120" show-overflow-tooltip />
|
||||
<el-table-column prop="phone" label="手机号" min-width="140" />
|
||||
<el-table-column label="状态" width="110" align="center">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="statusTagType(row.status)">{{ statusLabel(row.status) }}</el-tag>
|
||||
<el-tag :type="statusTagType(row.status)" effect="light" round>{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="createdAt" label="申请时间" width="170">
|
||||
<el-table-column label="申请时间" min-width="180">
|
||||
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="180" fixed="right">
|
||||
<el-table-column label="操作" width="200" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<template v-if="row.status === 'Pending'">
|
||||
<el-button size="small" type="success" @click="handleReview(row, 'Approved')">通过</el-button>
|
||||
<el-button size="small" type="danger" @click="handleReview(row, 'Rejected')">拒绝</el-button>
|
||||
<el-button size="small" type="success" plain @click="handleReview(row, 'Approved')">通过</el-button>
|
||||
<el-button size="small" type="danger" plain @click="handleReview(row, 'Rejected')">拒绝</el-button>
|
||||
</template>
|
||||
<span v-else style="color: #909399;">已处理</span>
|
||||
<el-tag v-else :type="statusTagType(row.status)" effect="plain" size="small">{{ statusLabel(row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
|
|
@ -69,3 +69,20 @@ async function handleReview(row, status) {
|
|||
|
||||
onMounted(fetchList)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.cert-page {
|
||||
padding: 4px;
|
||||
}
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.page-header h3 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #303133;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
<script>
|
||||
import { initIM } from './utils/im'
|
||||
|
||||
export default {
|
||||
onLaunch: function() {
|
||||
// 检查本地登录凭证,过期则静默清除,不跳转登录页
|
||||
|
|
@ -13,13 +15,20 @@
|
|||
// token 已过期,静默清除
|
||||
uni.removeStorageSync('token')
|
||||
uni.removeStorageSync('userInfo')
|
||||
return
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败,清除无效 token
|
||||
uni.removeStorageSync('token')
|
||||
uni.removeStorageSync('userInfo')
|
||||
return
|
||||
}
|
||||
|
||||
// token 有效,后台初始化 IM
|
||||
initIM().catch(e => {
|
||||
console.log('[App] IM 初始化失败(非阻塞):', e.message)
|
||||
})
|
||||
},
|
||||
onShow: function() {},
|
||||
onHide: function() {}
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@
|
|||
"quickapp" : {},
|
||||
/* 小程序特有相关 */
|
||||
"mp-weixin" : {
|
||||
"appid" : "wx626fd4a47944e3ea",
|
||||
"appid" : "wxd62aec23fcb79bc6",
|
||||
"setting" : {
|
||||
"urlCheck" : false
|
||||
},
|
||||
|
|
|
|||
7
miniapp/package-lock.json
generated
7
miniapp/package-lock.json
generated
|
|
@ -9,6 +9,7 @@
|
|||
"version": "1.0.0",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@tencentcloud/chat": "^3.6.6",
|
||||
"pinia": "^3.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
@ -867,6 +868,12 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tencentcloud/chat": {
|
||||
"version": "3.6.6",
|
||||
"resolved": "https://registry.npmjs.org/@tencentcloud/chat/-/chat-3.6.6.tgz",
|
||||
"integrity": "sha512-PYPGUoZHw3GjDgM0N72qr0K/Wjp28HAoGaND077sbUQEwYFaMjZ1tuBQZugFG6v8OS8Ihx71yqNshR3xDHvYaA==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/@types/chai": {
|
||||
"version": "5.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
"license": "ISC",
|
||||
"type": "commonjs",
|
||||
"dependencies": {
|
||||
"@tencentcloud/chat": "^3.6.6",
|
||||
"pinia": "^3.0.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
|
|
@ -122,12 +122,12 @@
|
|||
|
||||
.banner-swiper {
|
||||
width: 100%;
|
||||
height: 400rpx;
|
||||
height: 374rpx;
|
||||
}
|
||||
|
||||
.banner-image {
|
||||
width: 100%;
|
||||
height: 400rpx;
|
||||
height: 374rpx;
|
||||
}
|
||||
|
||||
/* 服务入口卡片列表 */
|
||||
|
|
|
|||
|
|
@ -25,12 +25,16 @@
|
|||
scroll-y
|
||||
:scroll-top="scrollTop"
|
||||
scroll-with-animation
|
||||
@scrolltoupper="loadMoreHistory"
|
||||
>
|
||||
<view v-if="loadingHistory" class="loading-history">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<view
|
||||
class="msg-row"
|
||||
:class="{ 'msg-self': msg.isSelf, 'msg-center': msg.type === 'system' || msg.type === 'price-change' }"
|
||||
v-for="(msg, index) in chatMessages"
|
||||
:key="index"
|
||||
:key="msg.id || index"
|
||||
>
|
||||
<!-- 系统提示消息(居中显示) -->
|
||||
<view v-if="msg.type === 'system'" class="msg-system">
|
||||
|
|
@ -58,7 +62,6 @@
|
|||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 待处理状态:对方可操作 -->
|
||||
<view v-if="msg.status === 'Pending' && !msg.isSelf" class="pc-actions">
|
||||
<view class="pc-btn pc-accept" @click="respondPriceChange(msg.priceChangeId, 'Accepted')">
|
||||
<text>同意</text>
|
||||
|
|
@ -67,11 +70,9 @@
|
|||
<text>拒绝</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- 等待确认提示 -->
|
||||
<view v-else-if="msg.status === 'Pending' && msg.isSelf" class="pc-waiting">
|
||||
<text>等待对方确认中</text>
|
||||
</view>
|
||||
<!-- 已处理状态 -->
|
||||
<view v-else class="pc-result">
|
||||
<text :class="msg.status === 'Accepted' ? 'pc-accepted' : 'pc-rejected'">
|
||||
{{ msg.status === 'Accepted' ? '已同意' : '已拒绝' }}
|
||||
|
|
@ -182,81 +183,64 @@
|
|||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 电话弹窗 -->
|
||||
<view v-if="showPhonePopup" class="popup-mask" @click="showPhonePopup = false">
|
||||
<view class="phone-popup" @click.stop>
|
||||
<text class="phone-title">对方手机号</text>
|
||||
<text class="phone-number">{{ targetPhone }}</text>
|
||||
<view class="phone-actions">
|
||||
<view class="phone-btn call-btn" @click="callPhone">
|
||||
<text>拨打电话</text>
|
||||
</view>
|
||||
<view class="phone-btn copy-btn" @click="copyPhone">
|
||||
<text>复制电话</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="phone-cancel" @click="showPhonePopup = false">
|
||||
<text>取消</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { getOrderDetail, createPriceChange, respondPriceChange as respondPriceChangeApi } from '../../utils/api'
|
||||
import { uploadFile } from '../../utils/request'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import {
|
||||
initIM, logoutIM, onNewMessage, offNewMessage,
|
||||
getMessageList, sendTextMessage, sendImageMessage,
|
||||
sendCustomMessage, formatIMMessage
|
||||
} from '../../utils/im'
|
||||
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
orderId: null,
|
||||
targetUserId: null,
|
||||
orderInfo: {},
|
||||
chatMessages: [],
|
||||
inputText: '',
|
||||
scrollTop: 0,
|
||||
showMoreMenu: false,
|
||||
showPhonePopup: false,
|
||||
targetPhone: '',
|
||||
// 改价弹窗状态
|
||||
// IM 相关
|
||||
imUserId: '',
|
||||
targetImUserId: '',
|
||||
imReady: false,
|
||||
nextReqMessageID: '',
|
||||
historyCompleted: false,
|
||||
loadingHistory: false,
|
||||
// 改价弹窗
|
||||
showPriceChangePopup: false,
|
||||
priceChangeType: '', // 'Commission' 或 'GoodsAmount'
|
||||
priceChangeType: '',
|
||||
newPriceInput: '',
|
||||
submittingPriceChange: false,
|
||||
statusBarHeight: 0
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
/** 当前用户是否为跑腿 */
|
||||
isRunner() {
|
||||
const userStore = useUserStore()
|
||||
return this.orderInfo.runnerId === userStore.userId
|
||||
},
|
||||
/** 当前用户是否为单主 */
|
||||
isOwner() {
|
||||
const userStore = useUserStore()
|
||||
return this.orderInfo.ownerId === userStore.userId
|
||||
},
|
||||
/** 是否显示更改商品价格按钮 */
|
||||
showGoodsPriceBtn() {
|
||||
const type = this.orderInfo.orderType
|
||||
return type === 'Purchase' || type === 'Food'
|
||||
},
|
||||
/** 改价弹窗标题 */
|
||||
priceChangeTypeLabel() {
|
||||
return this.priceChangeType === 'Commission' ? '更改跑腿价格' : '更改商品价格'
|
||||
},
|
||||
/** 当前改价对应的原价 */
|
||||
currentPriceForChange() {
|
||||
if (this.priceChangeType === 'Commission') {
|
||||
return (this.orderInfo.commission || 0).toFixed(2)
|
||||
}
|
||||
return (this.orderInfo.goodsAmount || 0).toFixed(2)
|
||||
},
|
||||
/** 改价差额(正数=补缴,负数=退款) */
|
||||
priceDifference() {
|
||||
const newPrice = parseFloat(this.newPriceInput)
|
||||
if (isNaN(newPrice) || newPrice < 0) return 0
|
||||
|
|
@ -266,54 +250,117 @@ export default {
|
|||
return newPrice - original
|
||||
}
|
||||
},
|
||||
onLoad(options) {
|
||||
async onLoad(options) {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
this.statusBarHeight = sysInfo.statusBarHeight || 0
|
||||
this.orderId = options.orderId
|
||||
this.targetUserId = options.targetUserId
|
||||
this.loadOrderInfo()
|
||||
this.loadChatMessages()
|
||||
await this.loadOrderInfo()
|
||||
await this.loginIM()
|
||||
},
|
||||
onUnload() {
|
||||
// 离开页面时移除消息监听
|
||||
offNewMessage()
|
||||
},
|
||||
methods: {
|
||||
goBack() { uni.navigateBack() },
|
||||
|
||||
/** 加载订单信息 */
|
||||
async loadOrderInfo() {
|
||||
if (!this.orderId) return
|
||||
try {
|
||||
const res = await getOrderDetail(this.orderId)
|
||||
this.orderInfo = res || {}
|
||||
} catch (e) {}
|
||||
},
|
||||
|
||||
/** 登录 IM 并加载历史消息 */
|
||||
async loginIM() {
|
||||
try {
|
||||
const { userId } = await initIM()
|
||||
this.imUserId = userId
|
||||
|
||||
// 确定对方 IM 用户 ID
|
||||
const userStore = useUserStore()
|
||||
if (this.orderInfo.ownerId === userStore.userId) {
|
||||
// 当前用户是单主,对方是跑腿
|
||||
this.targetImUserId = `user_${this.orderInfo.runnerId}`
|
||||
} else {
|
||||
// 当前用户是跑腿,对方是单主
|
||||
this.targetImUserId = `user_${this.orderInfo.ownerId}`
|
||||
}
|
||||
|
||||
this.imReady = true
|
||||
|
||||
// 监听新消息
|
||||
onNewMessage((msgList) => {
|
||||
for (const msg of msgList) {
|
||||
// 只处理当前会话的消息
|
||||
if (msg.from === this.targetImUserId || msg.from === this.imUserId) {
|
||||
const formatted = formatIMMessage(msg, this.imUserId)
|
||||
this.chatMessages.push(formatted)
|
||||
}
|
||||
}
|
||||
this.scrollToBottom()
|
||||
})
|
||||
|
||||
// 拉取历史消息
|
||||
await this.loadHistory()
|
||||
} catch (e) {
|
||||
// 静默处理
|
||||
console.error('[IM] 初始化失败:', e)
|
||||
uni.showToast({ title: 'IM连接失败,消息功能暂不可用', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 加载聊天记录(腾讯 IM SDK 集成后替换) */
|
||||
loadChatMessages() {
|
||||
// TODO: 集成腾讯 IM SDK 后,从 SDK 获取历史消息
|
||||
/** 拉取历史消息 */
|
||||
async loadHistory() {
|
||||
if (!this.imReady || !this.targetImUserId) return
|
||||
this.loadingHistory = true
|
||||
try {
|
||||
const res = await getMessageList(this.targetImUserId, this.nextReqMessageID)
|
||||
const formatted = res.messageList.map(m => formatIMMessage(m, this.imUserId))
|
||||
// 历史消息插入到前面
|
||||
this.chatMessages = [...formatted, ...this.chatMessages]
|
||||
this.nextReqMessageID = res.nextReqMessageID
|
||||
this.historyCompleted = res.isCompleted
|
||||
this.scrollToBottom()
|
||||
} catch (e) {
|
||||
console.error('[IM] 拉取历史消息失败:', e)
|
||||
} finally {
|
||||
this.loadingHistory = false
|
||||
}
|
||||
},
|
||||
|
||||
/** 上拉加载更多历史 */
|
||||
async loadMoreHistory() {
|
||||
if (this.historyCompleted || this.loadingHistory) return
|
||||
await this.loadHistory()
|
||||
},
|
||||
|
||||
/** 发送文本消息 */
|
||||
sendMessage() {
|
||||
async sendMessage() {
|
||||
if (!this.inputText.trim()) return
|
||||
// TODO: 通过腾讯 IM SDK 发送消息
|
||||
this.chatMessages.push({
|
||||
type: 'text',
|
||||
content: this.inputText,
|
||||
isSelf: true,
|
||||
avatar: ''
|
||||
})
|
||||
if (!this.imReady) {
|
||||
uni.showToast({ title: 'IM未就绪', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const text = this.inputText
|
||||
this.inputText = ''
|
||||
this.scrollToBottom()
|
||||
try {
|
||||
const msg = await sendTextMessage(this.targetImUserId, text)
|
||||
this.chatMessages.push(formatIMMessage(msg, this.imUserId))
|
||||
this.scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '发送失败', icon: 'none' })
|
||||
this.inputText = text
|
||||
}
|
||||
},
|
||||
|
||||
/** 滚动到底部 */
|
||||
scrollToBottom() {
|
||||
this.$nextTick(() => {
|
||||
this.scrollTop = 99999
|
||||
this.scrollTop = this.scrollTop === 99999 ? 99998 : 99999
|
||||
})
|
||||
},
|
||||
|
||||
/** 切换更多菜单 */
|
||||
toggleMoreMenu() {
|
||||
this.showMoreMenu = !this.showMoreMenu
|
||||
},
|
||||
|
|
@ -321,20 +368,18 @@ export default {
|
|||
/** 发送图片 */
|
||||
onSendImage() {
|
||||
this.showMoreMenu = false
|
||||
if (!this.imReady) {
|
||||
uni.showToast({ title: 'IM未就绪', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sourceType: ['album', 'camera'],
|
||||
success: async (res) => {
|
||||
const tempPath = res.tempFilePaths[0]
|
||||
try {
|
||||
const uploadRes = await uploadFile(tempPath)
|
||||
// TODO: 通过腾讯 IM SDK 发送图片消息
|
||||
this.chatMessages.push({
|
||||
type: 'image',
|
||||
content: uploadRes.url || tempPath,
|
||||
isSelf: true,
|
||||
avatar: ''
|
||||
})
|
||||
const msg = await sendImageMessage(this.targetImUserId, tempPath)
|
||||
this.chatMessages.push(formatIMMessage(msg, this.imUserId))
|
||||
this.scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: '图片发送失败', icon: 'none' })
|
||||
|
|
@ -343,7 +388,6 @@ export default {
|
|||
})
|
||||
},
|
||||
|
||||
/** 更改跑腿价格 - 打开改价弹窗 */
|
||||
onChangeCommission() {
|
||||
this.showMoreMenu = false
|
||||
this.priceChangeType = 'Commission'
|
||||
|
|
@ -351,7 +395,6 @@ export default {
|
|||
this.showPriceChangePopup = true
|
||||
},
|
||||
|
||||
/** 更改商品价格 - 打开改价弹窗 */
|
||||
onChangeGoodsPrice() {
|
||||
this.showMoreMenu = false
|
||||
this.priceChangeType = 'GoodsAmount'
|
||||
|
|
@ -371,10 +414,7 @@ export default {
|
|||
|
||||
const difference = this.priceDifference
|
||||
|
||||
// 单主发起改价且新价格高于原价时,需先支付补缴金额
|
||||
if (this.isOwner && difference > 0) {
|
||||
// TODO: 调用微信支付接口支付补缴金额
|
||||
// 支付成功后再发送改价申请
|
||||
uni.showModal({
|
||||
title: '补缴支付',
|
||||
content: `需补缴 ¥${difference.toFixed(2)},确认支付后将发送改价申请`,
|
||||
|
|
@ -392,7 +432,6 @@ export default {
|
|||
this.submittingPriceChange = false
|
||||
},
|
||||
|
||||
/** 执行改价申请提交 */
|
||||
async doSubmitPriceChange(newPrice) {
|
||||
try {
|
||||
const res = await createPriceChange(this.orderId, {
|
||||
|
|
@ -401,9 +440,26 @@ export default {
|
|||
})
|
||||
this.showPriceChangePopup = false
|
||||
|
||||
// 在聊天列表中添加改价申请消息
|
||||
const changeTypeLabel = this.priceChangeType === 'Commission' ? '跑腿佣金' : '商品总额'
|
||||
|
||||
// 通过 IM 发送自定义消息通知对方
|
||||
if (this.imReady) {
|
||||
const customData = {
|
||||
bizType: 'price-change',
|
||||
priceChangeId: res.id,
|
||||
changeTypeLabel,
|
||||
originalPrice: res.originalPrice.toFixed(2),
|
||||
newPrice: res.newPrice.toFixed(2),
|
||||
difference: res.difference,
|
||||
status: res.status,
|
||||
description: `发起了${changeTypeLabel}改价申请`
|
||||
}
|
||||
await sendCustomMessage(this.targetImUserId, customData)
|
||||
}
|
||||
|
||||
// 本地显示
|
||||
this.chatMessages.push({
|
||||
id: `pc_${res.id}`,
|
||||
type: 'price-change',
|
||||
isSelf: true,
|
||||
priceChangeId: res.id,
|
||||
|
|
@ -420,110 +476,55 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
/** 响应改价申请(同意/拒绝) */
|
||||
async respondPriceChange(priceChangeId, action) {
|
||||
try {
|
||||
const res = await respondPriceChangeApi(this.orderId, priceChangeId, { action })
|
||||
|
||||
// 更新聊天消息中的改价状态
|
||||
const msg = this.chatMessages.find(
|
||||
m => m.type === 'price-change' && m.priceChangeId === priceChangeId
|
||||
)
|
||||
if (msg) {
|
||||
msg.status = res.status
|
||||
}
|
||||
if (msg) msg.status = res.status
|
||||
|
||||
const actionLabel = action === 'Accepted' ? '同意' : '拒绝'
|
||||
const changeTypeLabel = res.changeType === 'Commission' ? '跑腿佣金' : '商品总额'
|
||||
|
||||
// 系统提示消息
|
||||
this.chatMessages.push({
|
||||
id: `sys_${Date.now()}`,
|
||||
type: 'system',
|
||||
content: `您已${actionLabel}${changeTypeLabel}改价`
|
||||
})
|
||||
|
||||
// 同意改价后处理退款/补缴提示
|
||||
if (action === 'Accepted' && res.difference !== 0) {
|
||||
if (res.difference < 0) {
|
||||
// 退款提示
|
||||
const refundAmount = Math.abs(res.difference).toFixed(2)
|
||||
this.chatMessages.push({
|
||||
id: `sys_refund_${Date.now()}`,
|
||||
type: 'system',
|
||||
content: `已退还您${refundAmount}元,请在微信中查看`
|
||||
content: `已退还您${Math.abs(res.difference).toFixed(2)}元,请在微信中查看`
|
||||
})
|
||||
}
|
||||
// 刷新订单信息以获取最新金额
|
||||
await this.loadOrderInfo()
|
||||
}
|
||||
|
||||
this.scrollToBottom()
|
||||
} catch (e) {
|
||||
uni.showToast({ title: e.message || '操作失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
|
||||
/** 完成订单 */
|
||||
onCompleteOrder() {
|
||||
this.showMoreMenu = false
|
||||
uni.navigateTo({
|
||||
url: `/pages/order/complete-order?id=${this.orderId}`
|
||||
})
|
||||
uni.navigateTo({ url: `/pages/order/complete-order?id=${this.orderId}` })
|
||||
},
|
||||
|
||||
/** 显示电话弹窗 */
|
||||
showPhone() {
|
||||
if (this.isOwner) {
|
||||
this.targetPhone = this.orderInfo.runnerPhone || '暂无'
|
||||
} else {
|
||||
this.targetPhone = this.orderInfo.phone || '暂无'
|
||||
}
|
||||
this.showPhonePopup = true
|
||||
},
|
||||
|
||||
/** 拨打电话 */
|
||||
callPhone() {
|
||||
if (!this.targetPhone || this.targetPhone === '暂无') return
|
||||
uni.makePhoneCall({ phoneNumber: this.targetPhone })
|
||||
},
|
||||
|
||||
/** 复制电话 */
|
||||
copyPhone() {
|
||||
if (!this.targetPhone || this.targetPhone === '暂无') return
|
||||
uni.setClipboardData({
|
||||
data: this.targetPhone,
|
||||
success: () => {
|
||||
uni.showToast({ title: '手机号已复制', icon: 'none' })
|
||||
}
|
||||
})
|
||||
this.showPhonePopup = false
|
||||
},
|
||||
|
||||
/** 跳转订单详情页 */
|
||||
goOrderDetail() {
|
||||
uni.navigateTo({
|
||||
url: `/pages/order/order-detail?id=${this.orderId}`
|
||||
})
|
||||
uni.navigateTo({ url: `/pages/order/order-detail?id=${this.orderId}` })
|
||||
},
|
||||
|
||||
/** 预览图片 */
|
||||
previewImage(url) {
|
||||
uni.previewImage({ urls: [url] })
|
||||
},
|
||||
|
||||
/** 联系客服 */
|
||||
contactService() {
|
||||
// 微信小程序自带客服功能通过 button open-type="contact" 实现
|
||||
},
|
||||
|
||||
/** 格式化订单类型 */
|
||||
formatOrderType(type) {
|
||||
const map = {
|
||||
Pickup: '代取',
|
||||
Delivery: '代送',
|
||||
Help: '万能帮',
|
||||
Purchase: '代购',
|
||||
Food: '美食街'
|
||||
}
|
||||
const map = { Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }
|
||||
return map[type] || type
|
||||
}
|
||||
}
|
||||
|
|
@ -571,7 +572,6 @@ export default {
|
|||
flex-direction: column;
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.order-bar {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -580,49 +580,46 @@ export default {
|
|||
padding: 20rpx 30rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.order-bar-text {
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.order-bar-link {
|
||||
font-size: 26rpx;
|
||||
color: #007AFF;
|
||||
color: #FFB700;
|
||||
}
|
||||
|
||||
.chat-body {
|
||||
flex: 1;
|
||||
padding: 20rpx 24rpx;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.loading-history {
|
||||
text-align: center;
|
||||
padding: 16rpx 0;
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
.msg-row {
|
||||
display: flex;
|
||||
margin-bottom: 24rpx;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.msg-row.msg-self {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.msg-row.msg-center {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.msg-avatar {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.msg-bubble {
|
||||
max-width: 65%;
|
||||
margin: 0 16rpx;
|
||||
}
|
||||
|
||||
.msg-text {
|
||||
background-color: #ffffff;
|
||||
padding: 20rpx 24rpx;
|
||||
|
|
@ -632,17 +629,14 @@ export default {
|
|||
word-break: break-all;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.msg-self .msg-text {
|
||||
background-color: #007AFF;
|
||||
background-color: #FFB700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.msg-image {
|
||||
max-width: 100%;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.msg-system {
|
||||
background-color: #f5f5f5;
|
||||
padding: 12rpx 20rpx;
|
||||
|
|
@ -652,16 +646,15 @@ export default {
|
|||
text-align: center;
|
||||
max-width: 80%;
|
||||
}
|
||||
|
||||
.chat-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: #ffffff;
|
||||
padding: 16rpx 24rpx;
|
||||
padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
|
||||
border-top: 1rpx solid #eeeeee;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.chat-input {
|
||||
flex: 1;
|
||||
height: 72rpx;
|
||||
|
|
@ -670,7 +663,6 @@ export default {
|
|||
padding: 0 30rpx;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.more-btn {
|
||||
width: 72rpx;
|
||||
height: 72rpx;
|
||||
|
|
@ -680,26 +672,20 @@ export default {
|
|||
margin-left: 16rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.more-icon {
|
||||
font-size: 48rpx;
|
||||
color: #666666;
|
||||
font-weight: 300;
|
||||
}
|
||||
|
||||
/* 更多功能菜单 */
|
||||
.more-menu-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.more-menu {
|
||||
width: 100%;
|
||||
background-color: #ffffff;
|
||||
|
|
@ -708,7 +694,6 @@ export default {
|
|||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
width: 25%;
|
||||
display: flex;
|
||||
|
|
@ -716,17 +701,14 @@ export default {
|
|||
align-items: center;
|
||||
padding: 24rpx 0;
|
||||
}
|
||||
|
||||
.menu-icon {
|
||||
font-size: 48rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.menu-label {
|
||||
font-size: 24rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
/* 改价消息卡片 */
|
||||
.price-change-card {
|
||||
background-color: #ffffff;
|
||||
|
|
@ -735,121 +717,40 @@ export default {
|
|||
width: 80%;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.pc-header {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.pc-title {
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pc-body {
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.pc-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8rpx 0;
|
||||
}
|
||||
|
||||
.pc-label {
|
||||
font-size: 24rpx;
|
||||
color: #999999;
|
||||
}
|
||||
|
||||
.pc-value {
|
||||
font-size: 26rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.pc-new-price {
|
||||
color: #007AFF;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.pc-pay {
|
||||
color: #ff6600;
|
||||
}
|
||||
|
||||
.pc-refund {
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.pc-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.pc-btn {
|
||||
flex: 1;
|
||||
padding: 16rpx 0;
|
||||
border-radius: 8rpx;
|
||||
text-align: center;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.pc-accept {
|
||||
background-color: #007AFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.pc-reject {
|
||||
background-color: #f5f5f5;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.pc-waiting {
|
||||
text-align: center;
|
||||
padding: 12rpx 0;
|
||||
}
|
||||
|
||||
.pc-waiting text {
|
||||
font-size: 24rpx;
|
||||
color: #ff9900;
|
||||
}
|
||||
|
||||
.pc-result {
|
||||
text-align: center;
|
||||
padding: 12rpx 0;
|
||||
}
|
||||
|
||||
.pc-accepted {
|
||||
font-size: 24rpx;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.pc-rejected {
|
||||
font-size: 24rpx;
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
/* 弹窗通用样式 */
|
||||
.pc-header { margin-bottom: 16rpx; }
|
||||
.pc-title { font-size: 26rpx; color: #333333; font-weight: 500; }
|
||||
.pc-body { margin-bottom: 16rpx; }
|
||||
.pc-row { display: flex; justify-content: space-between; align-items: center; padding: 8rpx 0; }
|
||||
.pc-label { font-size: 24rpx; color: #999999; }
|
||||
.pc-value { font-size: 26rpx; color: #333333; }
|
||||
.pc-new-price { color: #FFB700; font-weight: 500; }
|
||||
.pc-pay { color: #ff6600; }
|
||||
.pc-refund { color: #52c41a; }
|
||||
.pc-actions { display: flex; gap: 16rpx; }
|
||||
.pc-btn { flex: 1; padding: 16rpx 0; border-radius: 8rpx; text-align: center; font-size: 26rpx; }
|
||||
.pc-accept { background-color: #FFB700; color: #ffffff; }
|
||||
.pc-reject { background-color: #f5f5f5; color: #666666; }
|
||||
.pc-waiting { text-align: center; padding: 12rpx 0; }
|
||||
.pc-waiting text { font-size: 24rpx; color: #ff9900; }
|
||||
.pc-result { text-align: center; padding: 12rpx 0; }
|
||||
.pc-accepted { font-size: 24rpx; color: #52c41a; }
|
||||
.pc-rejected { font-size: 24rpx; color: #ff4d4f; }
|
||||
/* 弹窗 */
|
||||
.popup-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
top: 0; left: 0; right: 0; bottom: 0;
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
z-index: 200;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
/* 改价弹窗 */
|
||||
.popup-content {
|
||||
width: 80%;
|
||||
background-color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 40rpx;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 32rpx;
|
||||
color: #333333;
|
||||
|
|
@ -858,7 +759,6 @@ export default {
|
|||
text-align: center;
|
||||
margin-bottom: 30rpx;
|
||||
}
|
||||
|
||||
.popup-field {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
|
|
@ -866,17 +766,8 @@ export default {
|
|||
padding: 20rpx 0;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.popup-label {
|
||||
font-size: 28rpx;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.popup-current-price {
|
||||
font-size: 28rpx;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.popup-label { font-size: 28rpx; color: #666666; }
|
||||
.popup-current-price { font-size: 28rpx; color: #333333; }
|
||||
.popup-input {
|
||||
width: 50%;
|
||||
height: 64rpx;
|
||||
|
|
@ -886,92 +777,10 @@ export default {
|
|||
font-size: 28rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.popup-diff {
|
||||
padding: 16rpx 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.popup-diff text {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.popup-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-top: 30rpx;
|
||||
}
|
||||
|
||||
.popup-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx 0;
|
||||
border-radius: 12rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.popup-cancel {
|
||||
background-color: #f5f5f5;
|
||||
color: #666666;
|
||||
}
|
||||
|
||||
.popup-confirm {
|
||||
background-color: #007AFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* 电话弹窗 */
|
||||
.phone-popup {
|
||||
width: 70%;
|
||||
background-color: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
padding: 40rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.phone-title {
|
||||
font-size: 30rpx;
|
||||
color: #333333;
|
||||
font-weight: 500;
|
||||
display: block;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.phone-number {
|
||||
font-size: 40rpx;
|
||||
color: #007AFF;
|
||||
font-weight: bold;
|
||||
display: block;
|
||||
margin-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.phone-actions {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.phone-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx 0;
|
||||
border-radius: 12rpx;
|
||||
text-align: center;
|
||||
font-size: 28rpx;
|
||||
}
|
||||
|
||||
.call-btn {
|
||||
background-color: #007AFF;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
background-color: #f5f5f5;
|
||||
color: #333333;
|
||||
}
|
||||
|
||||
.phone-cancel {
|
||||
padding: 16rpx 0;
|
||||
font-size: 28rpx;
|
||||
color: #999999;
|
||||
}
|
||||
.popup-diff { padding: 16rpx 0; text-align: center; }
|
||||
.popup-diff text { font-size: 24rpx; }
|
||||
.popup-actions { display: flex; gap: 20rpx; margin-top: 30rpx; }
|
||||
.popup-btn { flex: 1; padding: 20rpx 0; border-radius: 12rpx; text-align: center; font-size: 28rpx; }
|
||||
.popup-cancel { background-color: #f5f5f5; color: #666666; }
|
||||
.popup-confirm { background-color: #FAD146; color: #ffffff; }
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -193,9 +193,9 @@ export default {
|
|||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: #007AFF;
|
||||
color: #FFB700;
|
||||
font-weight: 500;
|
||||
background-color: #e8f4fd;
|
||||
background-color: #fff8e6;
|
||||
}
|
||||
|
||||
.notify-item {
|
||||
|
|
@ -220,8 +220,8 @@ export default {
|
|||
|
||||
.notify-type {
|
||||
font-size: 24rpx;
|
||||
color: #007AFF;
|
||||
background-color: #e8f4fd;
|
||||
color: #FFB700;
|
||||
background-color: #fff8e6;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
|
@ -255,7 +255,7 @@ export default {
|
|||
}
|
||||
|
||||
.notify-btn {
|
||||
background-color: #007AFF;
|
||||
background-color: #FFB700;
|
||||
border-radius: 8rpx;
|
||||
padding: 8rpx 24rpx;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -162,7 +162,7 @@ export default {
|
|||
.record-type {
|
||||
font-size: 26rpx;
|
||||
color: #ffffff;
|
||||
background-color: #007AFF;
|
||||
background-color: #FFB700;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -333,7 +333,7 @@ export default {
|
|||
|
||||
/* 金额状态区域 */
|
||||
.amount-section {
|
||||
background-color: #007AFF;
|
||||
background-color: #FFB700;
|
||||
padding: 40rpx 24rpx 50rpx;
|
||||
}
|
||||
|
||||
|
|
@ -388,7 +388,7 @@ export default {
|
|||
}
|
||||
|
||||
.action-btn.primary text {
|
||||
color: #007AFF;
|
||||
color: #FFB700;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
|
|
@ -409,7 +409,7 @@ export default {
|
|||
|
||||
.guide-link text {
|
||||
font-size: 26rpx;
|
||||
color: #007AFF;
|
||||
color: #FFB700;
|
||||
}
|
||||
|
||||
/* 提现记录 */
|
||||
|
|
@ -488,7 +488,7 @@ export default {
|
|||
}
|
||||
|
||||
.ws-pending { color: #faad14; }
|
||||
.ws-processing { color: #007AFF; }
|
||||
.ws-processing { color: #FFB700; }
|
||||
.ws-done { color: #52c41a; }
|
||||
|
||||
/* 弹窗通用 */
|
||||
|
|
@ -569,8 +569,8 @@ export default {
|
|||
}
|
||||
|
||||
.payment-option.active {
|
||||
border-color: #007AFF;
|
||||
background-color: rgba(0, 122, 255, 0.05);
|
||||
border-color: #FFB700;
|
||||
background-color: rgba(255, 183, 0, 0.05);
|
||||
}
|
||||
|
||||
.payment-option text {
|
||||
|
|
@ -579,7 +579,7 @@ export default {
|
|||
}
|
||||
|
||||
.payment-option.active text {
|
||||
color: #007AFF;
|
||||
color: #FFB700;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
|
|
@ -623,8 +623,12 @@ export default {
|
|||
color: #666666;
|
||||
}
|
||||
|
||||
.modal-btn.cancel::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.modal-btn.confirm {
|
||||
background-color: #007AFF;
|
||||
background-color: #FAD146;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -183,7 +183,7 @@ export default {
|
|||
|
||||
/* 顶部渐变背景 */
|
||||
.header-bg {
|
||||
background: linear-gradient(180deg, #FFB700 0%, #FFD59B 100%);
|
||||
background: linear-gradient(90deg, #FFB700 0%, #FFD59B 100%);
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
|
|
|
|||
|
|
@ -227,6 +227,8 @@
|
|||
onShow() {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
this.statusBarHeight = sysInfo.statusBarHeight || 0
|
||||
// 重置认证状态缓存,确保每次进入页面重新获取
|
||||
this.certStatus = null
|
||||
this.loadBanner()
|
||||
this.loadOrders()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ export default {
|
|||
}
|
||||
|
||||
.submit-btn {
|
||||
background-color: #007AFF;
|
||||
background-color: #FAD146;
|
||||
color: #ffffff;
|
||||
text-align: center;
|
||||
padding: 24rpx 0;
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import { clearAuth } from '../utils/request'
|
||||
import { logoutIM } from '../utils/im'
|
||||
|
||||
export const useUserStore = defineStore('user', () => {
|
||||
// 状态
|
||||
|
|
@ -34,6 +35,7 @@ export const useUserStore = defineStore('user', () => {
|
|||
token.value = ''
|
||||
userInfo.value = {}
|
||||
clearAuth()
|
||||
logoutIM().catch(() => {})
|
||||
uni.reLaunch({ url: '/pages/index/index' })
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -201,3 +201,10 @@ export function getWithdrawalGuide() {
|
|||
export function getPageBanner(page) {
|
||||
return request({ url: `/api/config/page-banner/${page}` })
|
||||
}
|
||||
|
||||
// ==================== 腾讯 IM ====================
|
||||
|
||||
/** 获取 IM UserSig */
|
||||
export function getIMUserSig() {
|
||||
return request({ url: '/api/im/usersig' })
|
||||
}
|
||||
|
|
|
|||
238
miniapp/utils/im.js
Normal file
238
miniapp/utils/im.js
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
/**
|
||||
* 腾讯 IM SDK 封装
|
||||
* 负责初始化、登录、收发消息
|
||||
*/
|
||||
import TencentCloudChat from '@tencentcloud/chat'
|
||||
import request from './request'
|
||||
|
||||
let chat = null
|
||||
let isReady = false
|
||||
let onMessageCallback = null
|
||||
|
||||
/**
|
||||
* 获取 IM 实例(单例)
|
||||
*/
|
||||
export function getChatInstance() {
|
||||
return chat
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化并登录 IM
|
||||
* @returns {Promise<{sdkAppId, userId}>}
|
||||
*/
|
||||
export async function initIM() {
|
||||
// 从后端获取 UserSig
|
||||
const res = await request({ url: '/api/im/usersig' })
|
||||
const { sdkAppId, userId, userSig } = res
|
||||
|
||||
// 创建 SDK 实例
|
||||
if (!chat) {
|
||||
chat = TencentCloudChat.create({ SDKAppID: sdkAppId })
|
||||
chat.setLogLevel(1) // Release 级别日志
|
||||
|
||||
// 监听 SDK 就绪
|
||||
chat.on(TencentCloudChat.EVENT.SDK_READY, () => {
|
||||
console.log('[IM] SDK 就绪')
|
||||
isReady = true
|
||||
})
|
||||
|
||||
// 监听新消息
|
||||
chat.on(TencentCloudChat.EVENT.MESSAGE_RECEIVED, (event) => {
|
||||
const msgList = event.data
|
||||
if (onMessageCallback) {
|
||||
onMessageCallback(msgList)
|
||||
}
|
||||
})
|
||||
|
||||
// 监听被踢下线
|
||||
chat.on(TencentCloudChat.EVENT.KICKED_OUT, () => {
|
||||
console.log('[IM] 被踢下线')
|
||||
isReady = false
|
||||
})
|
||||
}
|
||||
|
||||
// 登录
|
||||
await chat.login({ userID: userId, userSig })
|
||||
console.log('[IM] 登录成功:', userId)
|
||||
return { sdkAppId, userId }
|
||||
}
|
||||
|
||||
/**
|
||||
* 登出 IM
|
||||
*/
|
||||
export async function logoutIM() {
|
||||
if (chat) {
|
||||
await chat.logout()
|
||||
isReady = false
|
||||
console.log('[IM] 已登出')
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置新消息回调
|
||||
* @param {Function} callback - 接收消息列表的回调
|
||||
*/
|
||||
export function onNewMessage(callback) {
|
||||
onMessageCallback = callback
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除新消息回调
|
||||
*/
|
||||
export function offNewMessage() {
|
||||
onMessageCallback = null
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取会话 ID(C2C 单聊)
|
||||
* @param {string} targetUserId - 对方用户 ID(如 user_123)
|
||||
*/
|
||||
export function getConversationId(targetUserId) {
|
||||
return `C2C${targetUserId}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取历史消息
|
||||
* @param {string} targetUserId - 对方用户 ID
|
||||
* @param {string} [nextReqMessageID] - 分页标记
|
||||
* @returns {Promise<{messageList, isCompleted, nextReqMessageID}>}
|
||||
*/
|
||||
export async function getMessageList(targetUserId, nextReqMessageID) {
|
||||
const conversationID = getConversationId(targetUserId)
|
||||
const res = await chat.getMessageList({
|
||||
conversationID,
|
||||
nextReqMessageID,
|
||||
count: 20
|
||||
})
|
||||
return {
|
||||
messageList: res.data.messageList || [],
|
||||
isCompleted: res.data.isCompleted,
|
||||
nextReqMessageID: res.data.nextReqMessageID
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送文本消息
|
||||
* @param {string} targetUserId - 对方用户 ID
|
||||
* @param {string} text - 文本内容
|
||||
*/
|
||||
export async function sendTextMessage(targetUserId, text) {
|
||||
const message = chat.createTextMessage({
|
||||
to: targetUserId,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: { text }
|
||||
})
|
||||
const res = await chat.sendMessage(message)
|
||||
return res.data.message
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送图片消息
|
||||
* @param {string} targetUserId - 对方用户 ID
|
||||
* @param {string} filePath - 本地图片路径
|
||||
*/
|
||||
export async function sendImageMessage(targetUserId, filePath) {
|
||||
const message = chat.createImageMessage({
|
||||
to: targetUserId,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: { file: { tempFilePaths: [filePath] } }
|
||||
})
|
||||
const res = await chat.sendMessage(message)
|
||||
return res.data.message
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送自定义消息(改价申请等)
|
||||
* @param {string} targetUserId - 对方用户 ID
|
||||
* @param {Object} customData - 自定义数据
|
||||
*/
|
||||
export async function sendCustomMessage(targetUserId, customData) {
|
||||
const message = chat.createCustomMessage({
|
||||
to: targetUserId,
|
||||
conversationType: TencentCloudChat.TYPES.CONV_C2C,
|
||||
payload: {
|
||||
data: JSON.stringify(customData),
|
||||
description: customData.description || '',
|
||||
extension: customData.extension || ''
|
||||
}
|
||||
})
|
||||
const res = await chat.sendMessage(message)
|
||||
return res.data.message
|
||||
}
|
||||
|
||||
/**
|
||||
* 将 IM 消息转换为聊天页展示格式
|
||||
* @param {Object} msg - IM SDK 消息对象
|
||||
* @param {string} currentImUserId - 当前用户 IM ID
|
||||
* @returns {Object} 聊天页消息格式
|
||||
*/
|
||||
export function formatIMMessage(msg, currentImUserId) {
|
||||
const isSelf = msg.from === currentImUserId
|
||||
const avatar = msg.avatar || '/static/logo.png'
|
||||
|
||||
// 文本消息
|
||||
if (msg.type === TencentCloudChat.TYPES.MSG_TEXT) {
|
||||
return {
|
||||
id: msg.ID,
|
||||
type: 'text',
|
||||
content: msg.payload.text,
|
||||
isSelf,
|
||||
avatar,
|
||||
time: msg.time * 1000
|
||||
}
|
||||
}
|
||||
|
||||
// 图片消息
|
||||
if (msg.type === TencentCloudChat.TYPES.MSG_IMAGE) {
|
||||
const imageInfo = msg.payload.imageInfoArray?.[1] || msg.payload.imageInfoArray?.[0]
|
||||
return {
|
||||
id: msg.ID,
|
||||
type: 'image',
|
||||
content: imageInfo?.url || '',
|
||||
isSelf,
|
||||
avatar,
|
||||
time: msg.time * 1000
|
||||
}
|
||||
}
|
||||
|
||||
// 自定义消息(改价等)
|
||||
if (msg.type === TencentCloudChat.TYPES.MSG_CUSTOM) {
|
||||
try {
|
||||
const data = JSON.parse(msg.payload.data)
|
||||
if (data.bizType === 'price-change') {
|
||||
return {
|
||||
id: msg.ID,
|
||||
type: 'price-change',
|
||||
isSelf,
|
||||
priceChangeId: data.priceChangeId,
|
||||
changeTypeLabel: data.changeTypeLabel,
|
||||
originalPrice: data.originalPrice,
|
||||
newPrice: data.newPrice,
|
||||
difference: data.difference,
|
||||
status: data.status,
|
||||
time: msg.time * 1000
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// 解析失败当普通文本处理
|
||||
}
|
||||
return {
|
||||
id: msg.ID,
|
||||
type: 'text',
|
||||
content: msg.payload.description || '[自定义消息]',
|
||||
isSelf,
|
||||
avatar,
|
||||
time: msg.time * 1000
|
||||
}
|
||||
}
|
||||
|
||||
// 其他类型
|
||||
return {
|
||||
id: msg.ID,
|
||||
type: 'text',
|
||||
content: '[暂不支持的消息类型]',
|
||||
isSelf,
|
||||
avatar,
|
||||
time: msg.time * 1000
|
||||
}
|
||||
}
|
||||
|
|
@ -67,6 +67,9 @@ builder.Services.AddSingleton<JwtService>();
|
|||
// 注册微信服务
|
||||
builder.Services.AddHttpClient<IWeChatService, WeChatService>();
|
||||
|
||||
// 注册腾讯 IM 服务
|
||||
builder.Services.AddSingleton<TencentIMService>();
|
||||
|
||||
// OpenAPI 文档(.NET 10 内置)
|
||||
builder.Services.AddOpenApi();
|
||||
|
||||
|
|
@ -205,6 +208,24 @@ app.MapPost("/api/auth/wx-login", async (
|
|||
app.MapGet("/api/protected", () => Results.Ok("ok"))
|
||||
.RequireAuthorization();
|
||||
|
||||
// ========== 腾讯 IM 接口 ==========
|
||||
|
||||
// 获取 IM UserSig(登录后调用)
|
||||
app.MapGet("/api/im/usersig", (
|
||||
HttpContext context,
|
||||
TencentIMService imService) =>
|
||||
{
|
||||
var userId = int.Parse(context.User.FindFirst(System.Security.Claims.ClaimTypes.NameIdentifier)!.Value);
|
||||
var imUserId = $"user_{userId}";
|
||||
var userSig = imService.GenerateUserSig(imUserId);
|
||||
return Results.Ok(new
|
||||
{
|
||||
sdkAppId = imService.SDKAppId,
|
||||
userId = imUserId,
|
||||
userSig
|
||||
});
|
||||
}).RequireAuthorization();
|
||||
|
||||
app.MapGet("/api/admin/protected", () => Results.Ok("admin ok"))
|
||||
.RequireAuthorization("AdminOnly");
|
||||
|
||||
|
|
|
|||
61
server/Services/TencentIMService.cs
Normal file
61
server/Services/TencentIMService.cs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace CampusErrand.Services;
|
||||
|
||||
/// <summary>
|
||||
/// 腾讯 IM 服务,负责生成 UserSig
|
||||
/// </summary>
|
||||
public class TencentIMService
|
||||
{
|
||||
private readonly long _sdkAppId;
|
||||
private readonly string _secretKey;
|
||||
|
||||
public TencentIMService(IConfiguration configuration)
|
||||
{
|
||||
var config = configuration.GetSection("TencentIM");
|
||||
_sdkAppId = config.GetValue<long>("SDKAppId");
|
||||
_secretKey = config["SecretKey"]!;
|
||||
}
|
||||
|
||||
public long SDKAppId => _sdkAppId;
|
||||
|
||||
/// <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();
|
||||
var obj = new Dictionary<string, object>
|
||||
{
|
||||
["TLS.ver"] = "2.0",
|
||||
["TLS.identifier"] = userId,
|
||||
["TLS.sdkappid"] = _sdkAppId,
|
||||
["TLS.expire"] = expireSeconds,
|
||||
["TLS.time"] = now
|
||||
};
|
||||
|
||||
var contentToSign = $"TLS.identifier:{userId}\nTLS.sdkappid:{_sdkAppId}\nTLS.time:{now}\nTLS.expire:{expireSeconds}\n";
|
||||
using var hmac = new HMACSHA256(Encoding.UTF8.GetBytes(_secretKey));
|
||||
var sig = hmac.ComputeHash(Encoding.UTF8.GetBytes(contentToSign));
|
||||
obj["TLS.sig"] = Convert.ToBase64String(sig);
|
||||
|
||||
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('=', '_');
|
||||
}
|
||||
}
|
||||
|
|
@ -19,13 +19,17 @@
|
|||
"ExpireMinutes": 10080
|
||||
},
|
||||
"WeChat": {
|
||||
"AppId": "wx626fd4a47944e3ea",
|
||||
"AppSecret": "352b4f8238e43df92d5131ba31e6f33d"
|
||||
"AppId": "wxd62aec23fcb79bc6",
|
||||
"AppSecret": "2b3b9d15fee1ed3e6204d67c86facfaf"
|
||||
},
|
||||
"Upload": {
|
||||
"MaxFileSizeBytes": 5242880,
|
||||
"AllowedExtensions": [ ".jpg", ".jpeg", ".png", ".gif", ".webp" ]
|
||||
},
|
||||
"TencentIM": {
|
||||
"SDKAppId": 1600132027,
|
||||
"SecretKey": "321c66ac633842b2f9c318739e58508260930c289c0ea790a2d66b544ac83dfa"
|
||||
},
|
||||
"COS": {
|
||||
"SecretId": "AKIDPioO4YovwtfMrwrYJq8CNN9qT4c0IyQd",
|
||||
"SecretKey": "1nLfLp44pOxUsKe1AmS8gFoBLH0Vloco",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user