功能优化

This commit is contained in:
18631081161 2026-03-19 00:38:37 +08:00
parent d0a63a38af
commit 365116c2f8
19 changed files with 582 additions and 400 deletions

View File

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

View File

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

View File

@ -50,7 +50,7 @@
"quickapp" : {},
/* */
"mp-weixin" : {
"appid" : "wx626fd4a47944e3ea",
"appid" : "wxd62aec23fcb79bc6",
"setting" : {
"urlCheck" : false
},

View File

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

View File

@ -11,6 +11,7 @@
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@tencentcloud/chat": "^3.6.6",
"pinia": "^3.0.4"
},
"devDependencies": {

View File

@ -122,12 +122,12 @@
.banner-swiper {
width: 100%;
height: 400rpx;
height: 374rpx;
}
.banner-image {
width: 100%;
height: 400rpx;
height: 374rpx;
}
/* 服务入口卡片列表 */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -227,6 +227,8 @@
onShow() {
const sysInfo = uni.getSystemInfoSync()
this.statusBarHeight = sysInfo.statusBarHeight || 0
//
this.certStatus = null
this.loadBanner()
this.loadOrders()
},

View File

@ -231,7 +231,7 @@ export default {
}
.submit-btn {
background-color: #007AFF;
background-color: #FAD146;
color: #ffffff;
text-align: center;
padding: 24rpx 0;

View File

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

View File

@ -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
View 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
}
/**
* 获取会话 IDC2C 单聊
* @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
}
}

View File

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

View 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('=', '_');
}
}

View File

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