campus-errand/miniapp/pages/message/chat.vue
2026-03-12 18:12:10 +08:00

928 lines
20 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="order-bar" v-if="orderInfo.id" @click="goOrderDetail">
<text class="order-bar-text">
{{ formatOrderType(orderInfo.orderType) }}订单 #{{ orderInfo.orderNo }}
</text>
<text class="order-bar-link">查看详情 </text>
</view>
<!-- 聊天记录区域 -->
<scroll-view
class="chat-body"
scroll-y
:scroll-top="scrollTop"
scroll-with-animation
>
<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"
>
<!-- 系统提示消息(居中显示) -->
<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 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'
export default {
data() {
return {
orderId: null,
targetUserId: null,
orderInfo: {},
chatMessages: [],
inputText: '',
scrollTop: 0,
showMoreMenu: false,
showPhonePopup: false,
targetPhone: '',
// 改价弹窗状态
showPriceChangePopup: false,
priceChangeType: '', // 'Commission' 或 'GoodsAmount'
newPriceInput: '',
submittingPriceChange: false
}
},
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
}
},
onLoad(options) {
this.orderId = options.orderId
this.targetUserId = options.targetUserId
this.loadOrderInfo()
this.loadChatMessages()
},
methods: {
/** 加载订单信息 */
async loadOrderInfo() {
if (!this.orderId) return
try {
const res = await getOrderDetail(this.orderId)
this.orderInfo = res || {}
} catch (e) {
// 静默处理
}
},
/** 加载聊天记录(腾讯 IM SDK 集成后替换) */
loadChatMessages() {
// TODO: 集成腾讯 IM SDK 后,从 SDK 获取历史消息
},
/** 发送文本消息 */
sendMessage() {
if (!this.inputText.trim()) return
// TODO: 通过腾讯 IM SDK 发送消息
this.chatMessages.push({
type: 'text',
content: this.inputText,
isSelf: true,
avatar: ''
})
this.inputText = ''
this.scrollToBottom()
},
/** 滚动到底部 */
scrollToBottom() {
this.$nextTick(() => {
this.scrollTop = 99999
})
},
/** 切换更多菜单 */
toggleMoreMenu() {
this.showMoreMenu = !this.showMoreMenu
},
/** 发送图片 */
onSendImage() {
this.showMoreMenu = false
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: ''
})
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) {
// TODO: 调用微信支付接口支付补缴金额
// 支付成功后再发送改价申请
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' ? '跑腿佣金' : '商品总额'
this.chatMessages.push({
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({
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({
type: 'system',
content: `已退还您${refundAmount}元,请在微信中查看`
})
}
// 刷新订单信息以获取最新金额
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}`
})
},
/** 显示电话弹窗 */
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}`
})
},
/** 预览图片 */
previewImage(url) {
uni.previewImage({ urls: [url] })
},
/** 联系客服 */
contactService() {
// 微信小程序自带客服功能通过 button open-type="contact" 实现
},
/** 格式化订单类型 */
formatOrderType(type) {
const map = {
Pickup: '代取',
Delivery: '代送',
Help: '万能帮',
Purchase: '代购',
Food: '美食街'
}
return map[type] || type
}
}
}
</script>
<style scoped>
.chat-page {
display: flex;
flex-direction: column;
height: 100vh;
}
.order-bar {
display: flex;
justify-content: space-between;
align-items: center;
background-color: #f0f7ff;
padding: 20rpx 30rpx;
flex-shrink: 0;
}
.order-bar-text {
font-size: 26rpx;
color: #333333;
}
.order-bar-link {
font-size: 26rpx;
color: #007AFF;
}
.chat-body {
flex: 1;
padding: 20rpx 24rpx;
overflow-y: auto;
}
.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: #007AFF;
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;
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: #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;
}
/* 弹窗通用样式 */
.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: #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;
}
</style>