campus-errand/miniapp/pages/order/order-detail.vue
18631081161 b359070a0e
All checks were successful
continuous-integration/drone/push Build is passing
聊天修改
2026-04-02 01:09:02 +08:00

785 lines
19 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="detail-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="page-title">
<text>我的{{ getTypeLabel(order.orderType) }}订单详情</text>
</view>
<!-- 订单状态 -->
<view class="status-bar" :class="getStatusClass(order.status)">
<text class="status-text">{{ getStatusLabel(order.status) }}</text>
</view>
<!-- 订单基本信息 -->
<view class="info-section">
<view class="section-title"><text>订单信息</text></view>
<view class="info-row">
<text class="info-label">订单ID</text>
<text class="info-value">{{ order.orderNo || '-' }}</text>
</view>
<view class="info-row" v-if="order.itemName">
<text class="info-label">物品/事项</text>
<text class="info-value">{{ order.itemName }}</text>
</view>
<view class="info-row" v-if="order.pickupLocation">
<text class="info-label">取货地点</text>
<text class="info-value">{{ order.pickupLocation }}</text>
</view>
<view class="info-row" v-if="order.deliveryLocation">
<text class="info-label">送达地点</text>
<text class="info-value">{{ order.deliveryLocation }}</text>
</view>
<view class="info-row" v-if="order.remark">
<text class="info-label">备注</text>
<text class="info-value">{{ order.remark }}</text>
</view>
<view class="info-row" v-if="order.phone">
<text class="info-label">联系方式</text>
<text class="info-value">{{ order.phone }}</text>
</view>
<view class="info-row" v-if="order.goodsAmount">
<text class="info-label">商品金额</text>
<text class="info-value price">¥{{ order.goodsAmount }}</text>
</view>
<view class="info-row">
<text class="info-label">跑腿佣金</text>
<text class="info-value price">¥{{ order.commission }}</text>
</view>
<view class="info-row" v-if="order.platformFee != null">
<text class="info-label">平台抽成</text>
<text class="info-value">-¥{{ order.platformFee }}</text>
</view>
<view class="info-row" v-if="order.netEarning != null">
<text class="info-label">跑腿实得</text>
<text class="info-value highlight">¥{{ order.netEarning }}</text>
</view>
<view class="info-row">
<text class="info-label">支付总额</text>
<text class="info-value price">¥{{ order.totalAmount }}</text>
</view>
<view class="info-row">
<text class="info-label">下单时间</text>
<text class="info-value">{{ formatTime(order.createdAt) }}</text>
</view>
<!-- 接单后显示 -->
<view class="info-row">
<text class="info-label">接单时间</text>
<text class="info-value">{{ order.acceptedAt ? formatTime(order.acceptedAt) : '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">跑腿昵称</text>
<text class="info-value">{{ order.runnerNickname || '-' }}</text>
</view>
<view class="info-row">
<text class="info-label">跑腿UID</text>
<text class="info-value">{{ order.runnerId || '-' }}</text>
</view>
<!-- 完成后显示 -->
<view class="info-row">
<text class="info-label">完成时间</text>
<text class="info-value">{{ order.completedAt ? formatTime(order.completedAt) : '-' }}</text>
</view>
</view>
<!-- 完成凭证 -->
<view class="info-section" v-if="order.completionProof">
<view class="section-title"><text>完成凭证</text></view>
<image
class="proof-image"
:src="order.completionProof"
mode="aspectFit"
@click="previewImage(order.completionProof)"
></image>
</view>
<!-- 美食街订单额外信息 -->
<view class="info-section" v-if="order.orderType === 'Food' && foodItems.length > 0">
<view class="section-title"><text>美食街订单详情</text></view>
<view class="info-row" v-if="order.packingFee">
<text class="info-label">打包费</text>
<text class="info-value price">¥{{ order.packingFee }}</text>
</view>
<!-- 按门店分组展示菜品 -->
<view v-for="(group, idx) in groupedFoodItems" :key="idx" class="shop-group">
<text class="shop-name">{{ group.shopName }}</text>
<view v-for="item in group.items" :key="item.id" class="dish-item">
<image v-if="item.dishPhoto" class="dish-photo" :src="item.dishPhoto" mode="aspectFill"></image>
<view class="dish-info">
<text class="dish-name">{{ item.dishName }}</text>
<text class="dish-price">¥{{ item.unitPrice }} × {{ item.quantity }}份</text>
</view>
</view>
</view>
<view class="info-row">
<text class="info-label">总份数</text>
<text class="info-value">{{ totalQuantity }}份</text>
</view>
<view class="info-row">
<text class="info-label">垫付总金额</text>
<text class="info-value price">¥{{ order.goodsAmount }}</text>
</view>
</view>
<!-- 申诉处理结果 -->
<view class="info-section" v-if="appeals.length > 0">
<view class="section-title"><text>申诉处理结果</text></view>
<view v-for="appeal in appeals" :key="appeal.id" class="appeal-item">
<text class="appeal-result">{{ appeal.result }}</text>
<text class="appeal-time">{{ formatTime(appeal.createdAt) }}</text>
</view>
</view>
<!-- 底部操作按钮 -->
<view class="bottom-actions">
<!-- 待接单:取消订单 -->
<template v-if="order.status === 'Pending'">
<view class="action-btn btn-cancel" @click="onCancelOrder">
<text>取消订单</text>
</view>
</template>
<!-- 进行中:单主看联系跑腿,跑腿看完成订单+联系单主 -->
<template v-else-if="order.status === 'InProgress'">
<view v-if="!isOwner" class="action-btn btn-primary" @click="goCompleteOrder">
<text>完成订单</text>
</view>
<view class="action-btn btn-secondary" @click="goChat">
<text>{{ isOwner ? '联系跑腿' : '联系单主' }}</text>
</view>
</template>
<!-- 已完成:单主可评价+联系跑腿,跑腿可联系单主 -->
<template v-else-if="order.status === 'Completed'">
<view v-if="isOwner && !order.isReviewed" class="action-btn btn-primary" @click="openReview">
<text>评价跑腿</text>
</view>
<view class="action-btn btn-secondary" @click="goChat">
<text>{{ isOwner ? '联系跑腿' : '联系单主' }}</text>
</view>
</template>
<!-- 待确认:单主看到确认/拒绝按钮,跑腿看到联系单主 -->
<template v-else-if="order.status === 'WaitConfirm'">
<template v-if="isOwner">
<view class="action-btn btn-cancel" @click="onRejectComplete">
<text>订单未完成</text>
</view>
<view class="action-btn btn-primary" @click="onConfirmComplete">
<text>确认订单完成</text>
</view>
</template>
<view class="action-btn btn-secondary" @click="goChat">
<text>{{ isOwner ? '联系跑腿' : '联系单主' }}</text>
</view>
</template>
</view>
<!-- 评价弹窗 -->
<view class="modal-mask" v-if="showReviewModal" @click="showReviewModal = false">
<view class="modal-content" @click.stop>
<text class="modal-title">评价跑腿</text>
<view class="star-row">
<text
v-for="star in 5"
:key="star"
class="star"
:class="{ active: reviewForm.rating >= star }"
@click="reviewForm.rating = star"
>★</text>
</view>
<textarea
class="review-input"
v-model="reviewForm.content"
placeholder="请输入评价内容(选填)"
maxlength="200"
></textarea>
<view class="modal-actions">
<button class="modal-btn cancel" @click="showReviewModal = false">取消</button>
<button class="modal-btn confirm" @click="submitReviewAction" :loading="reviewSubmitting">提交</button>
</view>
</view>
</view>
</view>
</template>
<script>
import { getOrderDetail, cancelOrder, submitReview, confirmOrder, rejectOrder } from '../../utils/api'
import { useUserStore } from '../../stores/user'
import { initIM, sendCustomMessage } from '../../utils/im'
export default {
data() {
return {
orderId: null,
order: {},
foodItems: [],
appeals: [],
// 评价弹窗
showReviewModal: false,
reviewForm: { rating: 5, content: '' },
reviewSubmitting: false,
statusBarHeight: 0
}
},
computed: {
/** 当前用户是否为单主 */
isOwner() {
const userStore = useUserStore()
return this.order.ownerId === userStore.userId
},
/** 按门店分组美食街菜品 */
groupedFoodItems() {
const groups = {}
this.foodItems.forEach(item => {
const key = item.shopId || item.shopName || '未知门店'
if (!groups[key]) {
groups[key] = { shopName: item.shopName || '未知门店', items: [] }
}
groups[key].items.push(item)
})
return Object.values(groups)
},
/** 总份数 */
totalQuantity() {
return this.foodItems.reduce((sum, item) => sum + (item.quantity || 0), 0)
}
},
onLoad(options) {
const sysInfo = uni.getSystemInfoSync()
this.statusBarHeight = sysInfo.statusBarHeight || 0
this.orderId = options.id
this.loadDetail()
},
methods: {
goBack() { uni.navigateBack() },
/** 加载订单详情 */
async loadDetail() {
if (!this.orderId) return
try {
const res = await getOrderDetail(this.orderId)
this.order = res || {}
this.foodItems = res?.foodItems || []
this.appeals = res?.appeals || []
// 设置页面标题
uni.setNavigationBarTitle({
title: `我的${this.getTypeLabel(this.order.orderType)}订单详情`
})
} catch (e) {
uni.showToast({ title: '加载失败', icon: 'none' })
}
},
getTypeLabel(type) {
const map = { Pickup: '代取', Delivery: '代送', Help: '万能帮', Purchase: '代购', Food: '美食街' }
return map[type] || ''
},
getStatusLabel(status) {
const map = {
Unpaid: '待支付', Pending: '待接单', InProgress: '进行中', WaitConfirm: '待确认',
Completed: '已完成', Cancelled: '已取消', Appealing: '申诉中'
}
return map[status] || status
},
getStatusClass(status) {
const map = {
Pending: 'status-pending', InProgress: 'status-progress',
WaitConfirm: 'status-confirm', Completed: 'status-done',
Cancelled: 'status-cancel', Appealing: 'status-appeal'
}
return map[status] || ''
},
formatTime(dateStr) {
if (!dateStr) return '-'
const d = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`
},
previewImage(url) {
if (!url) return
uni.previewImage({ urls: [url] })
},
/** 取消订单 */
onCancelOrder() {
uni.showModal({
title: '取消订单',
content: '确定取消该订单?',
success: async (res) => {
if (!res.confirm) return
try {
await cancelOrder(this.orderId)
uni.showToast({ title: '订单已取消', icon: 'success' })
this.loadDetail()
} catch (e) {
// 错误已在 request 中处理
}
}
})
},
/** 跳转聊天页(如果上一页就是聊天页,直接返回) */
goChat() {
const pages = getCurrentPages()
if (pages.length >= 2) {
const prevPage = pages[pages.length - 2]
if (prevPage.route === 'pages/message/chat') {
uni.navigateBack()
return
}
}
uni.navigateTo({ url: `/pages/message/chat?orderId=${this.orderId}` })
},
/** 跳转完成订单页(跑腿提交完成凭证) */
goCompleteOrder() {
uni.navigateTo({ url: `/pages/order/complete-order?id=${this.orderId}` })
},
/** 单主确认订单完成 */
onConfirmComplete() {
uni.showModal({
title: '确认完成',
content: '确认该订单已完成?',
success: async (res) => {
if (!res.confirm) return
try {
await confirmOrder(this.orderId)
uni.showToast({ title: '订单已完成', icon: 'success' })
// 通知跑腿
try {
await initIM()
const groupId = this.order.imGroupId || `order_${this.orderId}`
await sendCustomMessage(groupId, {
bizType: 'order-status',
description: '订单已完成'
})
} catch (ex) {}
this.loadDetail()
} catch (e) {}
}
})
},
/** 单主拒绝订单完成 */
onRejectComplete() {
uni.showModal({
title: '拒绝完成',
content: '确认该订单未完成?订单将继续进行。',
success: async (res) => {
if (!res.confirm) return
try {
await rejectOrder(this.orderId)
uni.showToast({ title: '已拒绝,订单继续进行', icon: 'none' })
// 通知跑腿
try {
await initIM()
const groupId2 = this.order.imGroupId || `order_${this.orderId}`
await sendCustomMessage(groupId2, {
bizType: 'order-status',
description: '单主已拒绝完成,订单继续进行'
})
} catch (ex) {}
this.loadDetail()
} catch (e) {}
}
})
},
/** 打开评价弹窗 */
openReview() {
this.reviewForm = { rating: 5, content: '' }
this.showReviewModal = true
},
/** 提交评价 */
async submitReviewAction() {
if (this.reviewForm.rating < 1) {
uni.showToast({ title: '请选择评分', icon: 'none' })
return
}
this.reviewSubmitting = true
try {
await submitReview(this.orderId, {
rating: this.reviewForm.rating,
content: this.reviewForm.content
})
uni.showToast({ title: '评价成功', icon: 'success' })
this.showReviewModal = false
this.loadDetail()
} catch (e) {
// 错误已在 request 中处理
} finally {
this.reviewSubmitting = false
}
}
}
}
</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;
}
.detail-page {
min-height: 100vh;
background-color: #f5f5f5;
padding: 0 0 160rpx;
}
/* 页面标题 */
.page-title {
background-color: #ffffff;
padding: 24rpx 30rpx;
}
.page-title text {
font-size: 32rpx;
color: #333333;
font-weight: bold;
}
/* 状态栏 */
.status-bar {
padding: 20rpx 30rpx;
margin-bottom: 16rpx;
}
.status-bar.status-pending { background-color: #fff7e6; }
.status-bar.status-progress { background-color: #e6f7ff; }
.status-bar.status-confirm { background-color: #fff2e8; }
.status-bar.status-done { background-color: #f6ffed; }
.status-bar.status-cancel { background-color: #f5f5f5; }
.status-bar.status-appeal { background-color: #fff1f0; }
.status-text {
font-size: 30rpx;
font-weight: 500;
}
.status-pending .status-text { color: #faad14; }
.status-progress .status-text { color: #FFB700; }
.status-confirm .status-text { color: #ff9900; }
.status-done .status-text { color: #52c41a; }
.status-cancel .status-text { color: #999999; }
.status-appeal .status-text { color: #e64340; }
/* 信息区域 */
.info-section {
background-color: #ffffff;
margin: 0 24rpx 16rpx;
border-radius: 16rpx;
padding: 24rpx 30rpx;
}
.section-title {
padding-bottom: 16rpx;
border-bottom: 1rpx solid #f0f0f0;
margin-bottom: 16rpx;
}
.section-title text {
font-size: 30rpx;
color: #333333;
font-weight: 500;
}
.info-row {
display: flex;
justify-content: space-between;
padding: 10rpx 0;
}
.info-label {
font-size: 26rpx;
color: #999999;
flex-shrink: 0;
margin-right: 20rpx;
}
.info-value {
font-size: 26rpx;
color: #333333;
text-align: right;
flex: 1;
}
.info-value.price {
color: #e64340;
font-weight: bold;
}
.info-value.highlight {
color: #52c41a;
font-weight: bold;
}
/* 完成凭证 */
.proof-image {
width: 100%;
max-height: 400rpx;
border-radius: 8rpx;
}
/* 美食街门店分组 */
.shop-group {
margin: 16rpx 0;
padding: 16rpx;
background-color: #fafafa;
border-radius: 12rpx;
}
.shop-name {
font-size: 28rpx;
color: #333333;
font-weight: 500;
display: block;
margin-bottom: 12rpx;
}
.dish-item {
display: flex;
align-items: center;
padding: 10rpx 0;
}
.dish-photo {
width: 80rpx;
height: 80rpx;
border-radius: 8rpx;
margin-right: 16rpx;
}
.dish-info {
flex: 1;
display: flex;
justify-content: space-between;
align-items: center;
}
.dish-name {
font-size: 26rpx;
color: #333333;
}
.dish-price {
font-size: 24rpx;
color: #e64340;
}
/* 申诉处理结果 */
.appeal-item {
padding: 16rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.appeal-item:last-child {
border-bottom: none;
}
.appeal-result {
font-size: 26rpx;
color: #333333;
display: block;
margin-bottom: 8rpx;
}
.appeal-time {
font-size: 24rpx;
color: #999999;
}
/* 底部操作按钮 */
.bottom-actions {
position: fixed;
bottom: 0;
left: 0;
right: 0;
display: flex;
gap: 20rpx;
padding: 24rpx 30rpx;
padding-bottom: calc(24rpx + env(safe-area-inset-bottom));
background-color: #ffffff;
box-shadow: 0 -2rpx 12rpx rgba(0, 0, 0, 0.06);
}
.action-btn {
flex: 1;
text-align: center;
padding: 24rpx 0;
border-radius: 12rpx;
font-size: 30rpx;
}
.btn-primary {
background-color: #FAD146;
}
.btn-primary text {
color: #333333;
}
.btn-secondary {
border: 1rpx solid #FAD146;
}
.btn-secondary text {
color: #333333;
}
.btn-cancel {
border: 1rpx solid #e64340;
}
.btn-cancel text {
color: #e64340;
}
/* 评价弹窗 */
.modal-mask {
position: fixed;
top: 0; left: 0; right: 0; bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 999;
}
.modal-content {
width: 600rpx;
background-color: #ffffff;
border-radius: 20rpx;
padding: 40rpx;
}
.modal-title {
font-size: 34rpx;
font-weight: bold;
color: #333333;
text-align: center;
display: block;
margin-bottom: 30rpx;
}
.star-row {
display: flex;
justify-content: center;
gap: 16rpx;
margin-bottom: 30rpx;
}
.star {
font-size: 48rpx;
color: #dddddd;
}
.star.active {
color: #faad14;
}
.review-input {
width: 100%;
height: 160rpx;
border: 1rpx solid #e0e0e0;
border-radius: 12rpx;
padding: 16rpx;
font-size: 28rpx;
box-sizing: border-box;
margin-bottom: 30rpx;
}
.modal-actions {
display: flex;
gap: 20rpx;
}
.modal-btn {
flex: 1;
height: 80rpx;
line-height: 80rpx;
font-size: 30rpx;
border-radius: 40rpx;
border: none;
}
.modal-btn::after {
border: none;
}
.modal-btn.cancel {
background-color: #f5f5f5;
color: #666666;
}
.modal-btn.confirm {
background-color: #FAD146;
color: #ffffff;
}
</style>