campus-errand/miniapp/pages/message/chat.vue
2026-03-20 18:09:42 +08:00

858 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="chat-page">
<!-- 自定义导航栏 -->
<view class="custom-navbar" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="navbar-content">
<view class="nav-back" @click="goBack">
<image class="back-icon" src="/static/ic_back.png" mode="aspectFit"></image>
</view>
<text class="navbar-title">聊天</text>
<view class="nav-placeholder"></view>
</view>
</view>
<view :style="{ height: (statusBarHeight + 44) + 'px' }"></view>
<!-- 顶部订单信息栏 -->
<view class="order-bar" v-if="orderInfo.id">
<view class="order-bar-info" @click="goOrderDetail">
<text class="order-bar-text">
{{ formatOrderType(orderInfo.orderType) }}订单 #{{ orderInfo.orderNo }}
</text>
<text class="order-bar-link">查看详情 </text>
</view>
<view class="order-bar-actions">
<view class="bar-action-btn" @click="onCallPhone">
<text>📞 拨打电话</text>
</view>
<view class="bar-action-btn" @click="onContactService">
<text>💬 联系客服</text>
</view>
</view>
</view>
<!-- 聊天记录区域 -->
<scroll-view
class="chat-body"
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="msg.id || index"
>
<!-- 系统提示消息(居中显示) -->
<view v-if="msg.type === 'system'" class="msg-system">
<text>{{ msg.content }}</text>
</view>
<!-- 改价申请消息 -->
<view v-else-if="msg.type === 'price-change'" class="price-change-card">
<view class="pc-header">
<text class="pc-title">{{ msg.isSelf ? '您' : '对方' }}发起了{{ msg.changeTypeLabel }}改价申请</text>
</view>
<view class="pc-body">
<view class="pc-row">
<text class="pc-label">原价</text>
<text class="pc-value">¥{{ msg.originalPrice }}</text>
</view>
<view class="pc-row">
<text class="pc-label">新价</text>
<text class="pc-value pc-new-price">¥{{ msg.newPrice }}</text>
</view>
<view class="pc-row" v-if="msg.difference !== 0">
<text class="pc-label">{{ msg.difference > 0 ? '需补缴' : '将退款' }}</text>
<text class="pc-value" :class="msg.difference > 0 ? 'pc-pay' : 'pc-refund'">
¥{{ Math.abs(msg.difference).toFixed(2) }}
</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>
</view>
<view class="pc-btn pc-reject" @click="respondPriceChange(msg.priceChangeId, 'Rejected')">
<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' ? '已同意' : '已拒绝' }}
</text>
</view>
</view>
<!-- 普通消息(文本/图片) -->
<template v-else>
<image
class="msg-avatar"
:src="msg.avatar || '/static/logo.png'"
mode="aspectFill"
></image>
<view class="msg-bubble">
<image
v-if="msg.type === 'image'"
class="msg-image"
:src="msg.content"
mode="widthFix"
@click="previewImage(msg.content)"
></image>
<text v-else class="msg-text">{{ msg.content }}</text>
</view>
</template>
</view>
</scroll-view>
<!-- 底部输入区域 -->
<view class="chat-footer">
<input
class="chat-input"
v-model="inputText"
placeholder="输入消息..."
confirm-type="send"
@confirm="sendMessage"
/>
<view class="more-btn" @click="toggleMoreMenu">
<text class="more-icon">+</text>
</view>
</view>
<!-- 更多功能菜单 -->
<view v-if="showMoreMenu" class="more-menu-mask" @click="showMoreMenu = false">
<view class="more-menu" @click.stop>
<view class="menu-item" @click="onSendImage">
<text class="menu-icon">🖼️</text>
<text class="menu-label">发送图片</text>
</view>
<view class="menu-item" @click="onChangeCommission">
<text class="menu-icon">💰</text>
<text class="menu-label">更改跑腿价格</text>
</view>
<view
class="menu-item"
v-if="showGoodsPriceBtn"
@click="onChangeGoodsPrice"
>
<text class="menu-icon">🏷️</text>
<text class="menu-label">更改商品价格</text>
</view>
<view
class="menu-item"
v-if="isRunner"
@click="onCompleteOrder"
>
<text class="menu-icon">✅</text>
<text class="menu-label">完成订单</text>
</view>
</view>
</view>
<!-- 改价弹窗 -->
<view v-if="showPriceChangePopup" class="popup-mask" @click="showPriceChangePopup = false">
<view class="popup-content" @click.stop>
<text class="popup-title">{{ priceChangeTypeLabel }}</text>
<view class="popup-field">
<text class="popup-label">当前价格</text>
<text class="popup-current-price">¥{{ currentPriceForChange }}</text>
</view>
<view class="popup-field">
<text class="popup-label">新价格</text>
<input
class="popup-input"
type="digit"
v-model="newPriceInput"
placeholder="请输入新价格"
/>
</view>
<view v-if="priceDifference !== 0" class="popup-diff">
<text v-if="priceDifference > 0 && isOwner" class="pc-pay">
需补缴 ¥{{ priceDifference.toFixed(2) }}(将跳转支付)
</text>
<text v-else-if="priceDifference < 0" class="pc-refund">
将退款 ¥{{ Math.abs(priceDifference).toFixed(2) }}
</text>
<text v-else-if="priceDifference > 0 && !isOwner" class="pc-pay">
对方需补缴 ¥{{ priceDifference.toFixed(2) }}
</text>
</view>
<view class="popup-actions">
<view class="popup-btn popup-cancel" @click="showPriceChangePopup = false">
<text>取消</text>
</view>
<view class="popup-btn popup-confirm" @click="submitPriceChange">
<text>发起修改申请</text>
</view>
</view>
</view>
</view>
</view>
</template>
<script>
import { getOrderDetail, createPriceChange, respondPriceChange as respondPriceChangeApi } from '../../utils/api'
import { useUserStore } from '../../stores/user'
import {
initIM, logoutIM, onNewMessage, offNewMessage,
getMessageList, sendTextMessage, sendImageMessage,
sendCustomMessage, formatIMMessage
} from '../../utils/im'
export default {
data() {
return {
orderId: null,
orderInfo: {},
chatMessages: [],
inputText: '',
scrollTop: 0,
showMoreMenu: false,
// IM 相关
imUserId: '',
targetImUserId: '',
imReady: false,
nextReqMessageID: '',
historyCompleted: false,
loadingHistory: false,
// 改价弹窗
showPriceChangePopup: false,
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
const original = this.priceChangeType === 'Commission'
? (this.orderInfo.commission || 0)
: (this.orderInfo.goodsAmount || 0)
return newPrice - original
}
},
async onLoad(options) {
const sysInfo = uni.getSystemInfoSync()
this.statusBarHeight = sysInfo.statusBarHeight || 0
this.orderId = options.orderId || null
// 从消息列表跳转时可能只有 targetUserId
this.targetImUserIdFromParam = options.targetUserId || null
if (this.orderId) {
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
if (this.targetImUserIdFromParam) {
// 从消息列表跳转,直接使用传入的 targetUserId
this.targetImUserId = this.targetImUserIdFromParam
} else if (this.orderInfo.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' })
}
},
/** 拉取历史消息 */
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()
},
/** 发送文本消息 */
async sendMessage() {
if (!this.inputText.trim()) return
if (!this.imReady) {
uni.showToast({ title: 'IM未就绪', icon: 'none' })
return
}
const text = this.inputText
this.inputText = ''
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 = this.scrollTop === 99999 ? 99998 : 99999
})
},
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
/** 发送图片 */
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 msg = await sendImageMessage(this.targetImUserId, tempPath)
this.chatMessages.push(formatIMMessage(msg, this.imUserId))
this.scrollToBottom()
} catch (e) {
uni.showToast({ title: '图片发送失败', icon: 'none' })
}
}
})
},
onChangeCommission() {
this.showMoreMenu = false
this.priceChangeType = 'Commission'
this.newPriceInput = ''
this.showPriceChangePopup = true
},
onChangeGoodsPrice() {
this.showMoreMenu = false
this.priceChangeType = 'GoodsAmount'
this.newPriceInput = ''
this.showPriceChangePopup = true
},
/** 提交改价申请 */
async submitPriceChange() {
const newPrice = parseFloat(this.newPriceInput)
if (isNaN(newPrice) || newPrice < 0) {
uni.showToast({ title: '请输入有效的价格', icon: 'none' })
return
}
if (this.submittingPriceChange) return
this.submittingPriceChange = true
const difference = this.priceDifference
if (this.isOwner && difference > 0) {
uni.showModal({
title: '补缴支付',
content: `需补缴 ¥${difference.toFixed(2)},确认支付后将发送改价申请`,
success: async (modalRes) => {
if (modalRes.confirm) {
await this.doSubmitPriceChange(newPrice)
}
this.submittingPriceChange = false
}
})
return
}
await this.doSubmitPriceChange(newPrice)
this.submittingPriceChange = false
},
async doSubmitPriceChange(newPrice) {
try {
const res = await createPriceChange(this.orderId, {
changeType: this.priceChangeType,
newPrice
})
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,
changeTypeLabel,
originalPrice: res.originalPrice.toFixed(2),
newPrice: res.newPrice.toFixed(2),
difference: res.difference,
status: res.status
})
this.scrollToBottom()
uni.showToast({ title: '改价申请已发送', icon: 'none' })
} catch (e) {
uni.showToast({ title: e.message || '改价申请失败', icon: 'none' })
}
},
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
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) {
this.chatMessages.push({
id: `sys_refund_${Date.now()}`,
type: 'system',
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}` })
},
goOrderDetail() {
uni.navigateTo({ url: `/pages/order/order-detail?id=${this.orderId}` })
},
/** 拨打电话(显示对方手机号) */
onCallPhone() {
// 单主看跑腿手机号,跑腿看单主手机号
const phone = this.isOwner ? this.orderInfo.runnerPhone : this.orderInfo.phone
if (!phone) {
uni.showToast({ title: '暂无对方手机号', icon: 'none' })
return
}
uni.showModal({
title: '对方手机号',
content: phone,
confirmText: '复制电话',
success: (res) => {
if (res.confirm) {
uni.setClipboardData({
data: phone,
success: () => {
uni.showToast({ title: '手机号已复制', icon: 'success' })
}
})
}
}
})
},
/** 联系客服 */
onContactService() {
// 跳转微信小程序自带客服页(需在小程序后台配置客服)
// 小程序中使用 button open-type="contact" 才能打开客服
// 这里用 navigateTo 跳转客服二维码页作为替代
uni.navigateTo({ url: '/pages/config/qrcode' })
},
previewImage(url) {
uni.previewImage({ urls: [url] })
},
formatOrderType(type) {
const map = { Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }
return map[type] || type
}
}
}
</script>
<style scoped>
/* 自定义导航栏 */
.custom-navbar {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 999;
background: #FFB700;
}
.navbar-content {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 20rpx;
}
.nav-back {
width: 60rpx;
height: 60rpx;
display: flex;
align-items: center;
justify-content: center;
}
.back-icon {
width: 40rpx;
height: 40rpx;
}
.navbar-title {
font-size: 34rpx;
font-weight: bold;
color: #363636;
}
.nav-placeholder {
width: 60rpx;
}
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
}
.order-bar {
background-color: #f0f7ff;
flex-shrink: 0;
}
.order-bar-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 30rpx 10rpx;
}
.order-bar-text {
font-size: 26rpx;
color: #333333;
}
.order-bar-link {
font-size: 26rpx;
color: #FFB700;
}
.order-bar-actions {
display: flex;
gap: 16rpx;
padding: 10rpx 30rpx 20rpx;
}
.bar-action-btn {
flex: 1;
text-align: center;
padding: 12rpx 0;
background-color: #ffffff;
border-radius: 8rpx;
border: 1rpx solid #e0e0e0;
}
.bar-action-btn text {
font-size: 24rpx;
color: #666666;
}
.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;
border-radius: 16rpx;
font-size: 28rpx;
color: #333333;
word-break: break-all;
display: block;
}
.msg-self .msg-text {
background-color: #FFB700;
color: #ffffff;
}
.msg-image {
max-width: 100%;
border-radius: 12rpx;
}
.msg-system {
background-color: #f5f5f5;
padding: 12rpx 20rpx;
border-radius: 8rpx;
font-size: 24rpx;
color: #999999;
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;
background-color: #f5f5f5;
border-radius: 36rpx;
padding: 0 30rpx;
font-size: 28rpx;
}
.more-btn {
width: 72rpx;
height: 72rpx;
display: flex;
align-items: center;
justify-content: center;
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;
background-color: rgba(0, 0, 0, 0.3);
z-index: 100;
display: flex;
align-items: flex-end;
}
.more-menu {
width: 100%;
background-color: #ffffff;
border-radius: 24rpx 24rpx 0 0;
padding: 30rpx;
display: flex;
flex-wrap: wrap;
}
.menu-item {
width: 25%;
display: flex;
flex-direction: column;
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;
border-radius: 16rpx;
padding: 24rpx;
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: #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;
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;
font-weight: 500;
display: block;
text-align: center;
margin-bottom: 30rpx;
}
.popup-field {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.popup-label { font-size: 28rpx; color: #666666; }
.popup-current-price { font-size: 28rpx; color: #333333; }
.popup-input {
width: 50%;
height: 64rpx;
border: 1rpx solid #dddddd;
border-radius: 8rpx;
padding: 0 16rpx;
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: #FAD146; color: #ffffff; }
</style>