Files
xinlong-shop-taro/src/pages/order/detail.tsx
赵忠林 69862bfc0c feat(order): 拆分“确认完成”为“送达”和“确认完成”按钮
- 新增送达操作,已发货且无送达照片时显示,可上传送达凭证
- 确认完成操作仅限已付款订单,标记为已完成,不需上传凭证
- 取消deliveryStatus=30表示已送达的用法,避免混淆“部分发货”状态
- 统一按钮显示逻辑,支持线下付款场景下同时显示确认收款/送达按钮
- 调整订单状态文案,新增“已送达待收款”与“待确认完成”状态描述
- 优化订单列表及详情中付款和物流状态的判断,去除对deliveryStatus=30的依赖
- 确认证据上传界面调整,确认完成操作不再显示上传凭证区域
2026-07-17 16:27:09 +08:00

579 lines
22 KiB
TypeScript
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.

import React, { useState, useEffect, useCallback } from 'react'
import { View, Text, Image, ScrollView } from '@tarojs/components'
import Taro from '@tarojs/taro'
import { Button } from '@nutui/nutui-react-taro'
import { getShopOrder, prepayShopOrder, updateShopOrder, repairOrder, removeShopOrder } from '@/api/shop/shopOrder'
import { listShopOrderGoodsByOrderId } from '@/api/shop/shopOrderGoods'
import type { ShopOrder } from '@/api/shop/shopOrder/model'
import { OrderStatus, OrderStatusText } from '@/types/order'
import Price from '@/components/common/Price'
import { getCompressedImageUrl } from '@/utils/image'
definePageConfig({
navigationBarTitleText: '订单详情',
})
// 支付方式映射
const PAY_TYPE_MAP: Record<number, string> = {
0: '余额支付',
1: '微信支付',
2: '支付宝',
3: '银联支付',
4: '现金支付',
5: 'POS机',
6: '免费',
7: '积分支付',
8: '货到付款',
9: '线下付款',
}
// 发票状态映射
const INVOICE_STATUS_MAP: Record<number, string> = {
0: '未开票',
1: '已开票',
2: '不可开票',
}
// 发货状态映射
const DELIVERY_STATUS_MAP: Record<number, string> = {
10: '未发货',
20: '已发货',
30: '部分发货',
}
// 配送方式映射: 0=快递配送, 1=无需发货/自提, 2=商家送货
const DELIVERY_TYPE_MAP: Record<number, string> = {
0: '快递配送',
1: '自提',
2: '商家送货',
}
/** 根据订单状态计算展示用的状态标签 */
const getOrderDisplayStatus = (order: ShopOrder): { title: string; bgColor: string; subtitle: string } => {
const { payStatus, deliveryStatus, orderStatus, payType } = order
const isCod = payType === 8 // 货到付款
const isOffline = payType === 9 // 线下付款
// 退款/取消相关状态
if (orderStatus === OrderStatus.Cancelled) {
return { bgColor: '#999', title: '已取消', subtitle: '订单已取消' }
}
if (orderStatus === OrderStatus.Cancelling) {
return { bgColor: '#ff7d00', title: '取消中', subtitle: '退款处理中' }
}
if (orderStatus === OrderStatus.RefundSuccess) {
return { bgColor: '#999', title: '已退款', subtitle: '退款已原路返回' }
}
if (orderStatus === OrderStatus.RefundApply || orderStatus === OrderStatus.ClientRefundApply) {
return { bgColor: '#ee0a24', title: '退款申请中', subtitle: '商家正在处理退款' }
}
if (orderStatus === OrderStatus.RefundRejected) {
return { bgColor: '#ee0a24', title: '退款被拒绝', subtitle: '退款申请已被拒绝' }
}
// 线下付款已发货但未确认收款:显示"已发货待收款"(红色),与门店端一致
if (isOffline && !payStatus && deliveryStatus === 20) {
return { bgColor: '#ee0a24', title: '已发货待收款', subtitle: '商品已发货,请尽快转账付款' }
}
// 物流状态优先于付款状态
if (deliveryStatus === 20) {
return { bgColor: '#4b9cf5', title: '待收货', subtitle: '商品运输中,请注意查收' }
}
// 未发货时按付款状态显示
if (!payStatus && !isCod && !isOffline) {
return { bgColor: '#ff7d00', title: '待付款', subtitle: '请尽快完成支付' }
}
if (isOffline && !payStatus) {
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '请通过微信转账完成付款,商家确认后发货' }
}
if (isCod && !payStatus) {
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '送达时支付' }
}
if (deliveryStatus === 10) {
return { bgColor: '#4b9cf5', title: '待发货', subtitle: '商家正在准备商品' }
}
if (orderStatus === OrderStatus.Completed) {
return { bgColor: '#0e932e', title: '已完成', subtitle: '交易已完成,感谢购买' }
}
if (orderStatus === OrderStatus.Unused) {
return { bgColor: '#0e932e', title: '已付款', subtitle: '订单已支付' }
}
// 默认
return { bgColor: '#999', title: OrderStatusText[orderStatus ?? 0] || '未知', subtitle: '' }
}
/** 根据订单状态判断当前操作阶段 */
type OrderPhase = 'unpaid' | 'offline_pending' | 'unshipped' | 'shipped' | 'completed' | 'cancelled' | 'refund'
const getOrderPhase = (order: ShopOrder): OrderPhase => {
const { payStatus, deliveryStatus, orderStatus, payType } = order
const isCod = payType === 8 // 货到付款
const isOffline = payType === 9 // 线下付款
// 退款/取消
if ([OrderStatus.Cancelled, OrderStatus.Cancelling, OrderStatus.RefundSuccess, OrderStatus.RefundApply, OrderStatus.ClientRefundApply, OrderStatus.RefundRejected].includes(orderStatus as OrderStatus)) {
return orderStatus === OrderStatus.Cancelled || orderStatus === OrderStatus.RefundSuccess ? 'cancelled' : 'refund'
}
// 物流状态优先于付款状态(已发货的订单,即使未付款也进入 shipped 阶段)
if (deliveryStatus === 20) return 'shipped'
// 线下付款且未确认收款:等待商家确认,不显示"立即支付"按钮
if (isOffline && !payStatus) return 'offline_pending'
// 货到付款订单payStatus=true 但还未实际付款,直接进入 unshipped 阶段(不需要"立即支付"按钮)
if (!payStatus && !isCod) return 'unpaid'
if (deliveryStatus === 10) return 'unshipped'
if (orderStatus === OrderStatus.Completed) return 'completed'
return 'unshipped'
}
const OrderDetailPage: React.FC = () => {
const { id } = Taro.getCurrentInstance().router?.params || {}
const [order, setOrder] = useState<ShopOrder | null>(null)
const [loading, setLoading] = useState(false)
const loadOrder = useCallback(async () => {
if (!id) return
try {
const orderData = await getShopOrder(Number(id))
// 如果订单中没有商品清单,则单独加载
if (!orderData.orderGoods || orderData.orderGoods.length === 0) {
try {
const goods = await listShopOrderGoodsByOrderId(Number(id))
orderData.orderGoods = goods
} catch {
// 忽略加载商品失败
}
}
setOrder(orderData)
} catch {
Taro.showToast({ title: '加载失败', icon: 'none' })
}
}, [id])
useEffect(() => {
loadOrder()
}, [loadOrder])
// 格式化时间
const formatTime = (time: string | undefined) => {
if (!time) return '-'
return time.replace('T', ' ').slice(0, 19)
}
// 取消订单
const handleCancelOrder = () => {
Taro.showModal({
title: '确认取消',
content: '确定要取消该订单吗?',
confirmColor: '#ee0a24',
success: async (res) => {
if (res.confirm && order) {
try {
await updateShopOrder({ ...order, orderStatus: OrderStatus.Cancelled })
Taro.showToast({ title: '订单已取消', icon: 'success' })
loadOrder()
} catch {
Taro.showToast({ title: '取消失败', icon: 'none' })
}
}
}
})
}
// 立即支付
const handlePay = async () => {
if (!order || loading) return
setLoading(true)
try {
const payResult = await prepayShopOrder({ orderId: order.orderId!, payType: 1 })
if (payResult) {
await Taro.requestPayment({
timeStamp: payResult.timeStamp,
nonceStr: payResult.nonceStr,
package: payResult.package,
signType: payResult.signType,
paySign: payResult.paySign,
})
Taro.showToast({ title: '支付成功', icon: 'success' })
loadOrder()
}
} catch (err: any) {
if (err?.message !== 'requestPayment:fail cancel') {
Taro.showToast({ title: err?.message || '支付失败', icon: 'none' })
}
} finally {
setLoading(false)
}
}
// 确认收货
const handleConfirmReceive = () => {
Taro.showModal({
title: '确认收货',
content: '确认已收到商品?',
confirmColor: '#0e932e',
success: async (res) => {
if (res.confirm && order) {
try {
await updateShopOrder({ ...order, orderStatus: OrderStatus.Completed })
Taro.showToast({ title: '已确认收货', icon: 'success' })
loadOrder()
} catch {
Taro.showToast({ title: '操作失败', icon: 'none' })
}
}
}
})
}
// 删除订单
const handleDeleteOrder = () => {
Taro.showModal({
title: '删除订单',
content: '确定要删除该订单吗?删除后不可恢复',
confirmColor: '#ee0a24',
success: async (res) => {
if (res.confirm && order) {
try {
await removeShopOrder(order.orderId)
Taro.showToast({ title: '订单已删除', icon: 'success' })
setTimeout(() => Taro.navigateBack(), 1000)
} catch {
Taro.showToast({ title: '删除失败', icon: 'none' })
}
}
}
})
}
if (!order) {
return (
<View className='min-h-screen bg-gray-50 flex items-center justify-center'>
<Text className='text-gray-400 text-sm'>...</Text>
</View>
)
}
const displayStatus = getOrderDisplayStatus(order)
const phase = getOrderPhase(order)
// deliveryType: 0=快递配送, 1=无需发货/自提, 2=商家送货
const isExpress = order.deliveryType === 0 || order.deliveryType === 2 || order.deliveryType === undefined
const goodsCount = order.orderGoods?.reduce((sum, g) => sum + (g.totalNum || 1), 0) || 0
return (
<View className='min-h-screen bg-gray-50 flex flex-col'>
<ScrollView scrollY className='flex-1'>
{/* 状态卡片 */}
<View
className='p-4'
style={{ backgroundColor: displayStatus.bgColor }}
>
<Text className='text-white text-lg font-medium block'>{displayStatus.title}</Text>
<Text className='text-white text-sm opacity-90 mt-1 block'>{displayStatus.subtitle}</Text>
{phase === 'shipped' && (
<View
className='mt-2 px-3 py-2 rounded-lg inline-flex items-center'
style={{ backgroundColor: 'rgba(255,255,255,0.2)' }}
onClick={() => Taro.navigateTo({ url: `/pages/order/logistics?orderId=${order.orderId}` })}
>
<Text className='text-white text-xs'></Text>
<Text className='text-white text-xs ml-1'></Text>
</View>
)}
</View>
{/* 收货信息 */}
{isExpress ? (
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<View className='flex items-start gap-2'>
<Text className='text-base'>{order.deliveryType === 2 ? '🛵' : '📦'}</Text>
<View className='flex-1'>
<View className='flex gap-2 mb-1'>
<Text className='text-sm font-medium text-gray-800'>{order.realName || '未知'}</Text>
<Text className='text-sm text-gray-600'>{order.mobile || order.phone || '-'}</Text>
</View>
<Text className='text-xs text-gray-500'>{order.address || '未填写收货地址'}</Text>
{order.deliveryType === 2 && order.expressMerchantName && (
<Text className='text-xs text-gray-400 mt-1'>: {order.expressMerchantName}</Text>
)}
{(order.sendStartTime || order.sendEndTime) && (
<Text className='text-xs text-gray-400 mt-1'>
: {order.sendStartTime?.slice(0, 10)} ~ {order.sendEndTime?.slice(0, 10)}
</Text>
)}
</View>
</View>
</View>
) : (
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<View className='flex items-start gap-2'>
<Text className='text-base'>🏪</Text>
<View className='flex-1'>
<Text className='text-sm font-medium text-gray-800'>{order.selfTakeMerchantName || '自提点'}</Text>
<Text className='text-xs text-gray-500 mt-1'>{order.address || '-'}</Text>
{order.selfTakeCode && (
<View className='mt-2 px-3 py-2 rounded-lg inline-flex items-center' style={{ backgroundColor: '#fff7e6' }}>
<Text className='text-xs text-orange-600'>: {order.selfTakeCode}</Text>
</View>
)}
</View>
</View>
</View>
)}
{/* 商品列表 */}
<View className='bg-white mx-3 mt-3 rounded-lg overflow-hidden'>
<View className='p-3 border-b border-gray-100'>
<Text className='text-sm font-medium text-gray-800'> ({goodsCount})</Text>
</View>
{order.orderGoods?.map((item, idx) => (
<View
key={idx}
className='flex gap-3 p-3 border-b border-gray-50'
style={idx === (order.orderGoods?.length ?? 0) - 1 ? { borderBottom: 'none' } : undefined}
onClick={() => item.goodsId && Taro.navigateTo({ url: `/pages/shop/product-detail?id=${item.goodsId}` })}
>
<View className='w-16 h-16 rounded-md bg-gray-100 flex-shrink-0 overflow-hidden'>
{item.image ? (
<Image className='w-full h-full' src={getCompressedImageUrl(item.image)} mode='aspectFill' />
) : (
<View className='w-full h-full flex items-center justify-center'>
<Text className='text-xs text-gray-400'></Text>
</View>
)}
</View>
<View className='flex-1 flex flex-col justify-between h-16'>
<Text className='text-sm text-gray-800 line-clamp-1'>{item.goodsName || '未知商品'}</Text>
{item.spec ? (
<Text className='text-xs text-gray-500 mt-1'>: {item.spec}</Text>
) : null}
</View>
<View className='flex flex-col items-end justify-between h-16'>
<Price price={item.price || '0'} size='small' />
<Text className='text-xs text-gray-500'>x{item.totalNum || 1}</Text>
</View>
</View>
))}
</View>
{/* 金额明细 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='space-y-2'>
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-gray-700'>¥{order.totalPrice || '0.00'}</Text>
</View>
{Number(order.reducePrice || 0) > 0 && (
<View className='flex justify-between'>
<Text className='text-sm text-gray-500'></Text>
<Text className='text-sm text-green-600'>-¥{order.reducePrice}</Text>
</View>
)}
<View className='flex justify-between pt-2 border-t border-gray-100'>
<Text className='text-sm font-medium text-gray-800'></Text>
<Price price={order.payPrice || '0'} size='normal' />
</View>
<View className='flex justify-between pt-2 border-t border-gray-100'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-500'>{PAY_TYPE_MAP[order.payType ?? 0] || '未知'}</Text>
</View>
</View>
</View>
{/* 订单信息 */}
<View className='bg-white mx-3 mt-3 p-3 rounded-lg mb-4'>
<Text className='text-sm font-medium text-gray-800 mb-3 block'></Text>
<View className='space-y-2'>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<View className='flex items-center gap-1'>
<Text className='text-xs text-gray-600'>{order.orderNo}</Text>
<Text
className='text-xs text-blue-500'
onClick={() => {
Taro.setClipboardData({ data: order.orderNo || '' })
Taro.showToast({ title: '已复制', icon: 'none' })
}}
>
</Text>
</View>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{DELIVERY_STATUS_MAP[order.deliveryStatus ?? 10]}</Text>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{DELIVERY_TYPE_MAP[order.deliveryType ?? 0] || '未知'}</Text>
</View>
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.createTime)}</Text>
</View>
{order.payTime && (
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.payTime)}</Text>
</View>
)}
{order.deliveryTime && (
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{formatTime(order.deliveryTime)}</Text>
</View>
)}
{order.comments && (
<View className='flex justify-start'>
<Text className='text-xs text-gray-400 mr-2'></Text>
<Text className='text-xs text-gray-600 flex-1'>{order.comments}</Text>
</View>
)}
<View className='flex justify-between'>
<Text className='text-xs text-gray-400'></Text>
<Text className='text-xs text-gray-600'>{INVOICE_STATUS_MAP[order.isInvoice ?? 0]}</Text>
</View>
</View>
</View>
</ScrollView>
{/* 底部操作按钮(根据订单阶段动态显示) */}
<View
className='bg-white border-t border-gray-100 p-3 flex flex-row gap-2'
style={{ paddingBottom: '20px' }}
>
{/* 待付款: 取消 + 支付 */}
{phase === 'unpaid' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ddd', color: '#666' }}
onClick={handleCancelOrder}
>
</Button>
<Button
size='small'
className='flex-1'
style={{ backgroundColor: '#ee0a24' }}
loading={loading}
onClick={handlePay}
>
</Button>
</>
)}
{/* 线下付款·待确认: 取消 + 联系客服 */}
{phase === 'offline_pending' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ddd', color: '#666' }}
onClick={handleCancelOrder}
>
</Button>
<Button
size='small'
className='flex-1'
style={{ backgroundColor: '#4b9cf5' }}
onClick={() => Taro.makePhoneCall({ phoneNumber: '400-000-0000' })}
>
</Button>
</>
)}
{/* 待发货: 退款 + 联系客服 */}
{phase === 'unshipped' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ddd', color: '#666' }}
onClick={() => Taro.navigateTo({ url: `/pages/order/refund?orderId=${order.orderId}` })}
>
退
</Button>
<Button
size='small'
className='flex-1'
style={{ backgroundColor: '#4b9cf5' }}
onClick={() => Taro.makePhoneCall({ phoneNumber: '400-000-0000' })}
>
</Button>
</>
)}
{/* 待收货: 物流 + 确认收货 */}
{phase === 'shipped' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#4b9cf5', color: '#4b9cf5' }}
onClick={() => Taro.navigateTo({ url: `/pages/order/logistics?orderId=${order.orderId}` })}
>
</Button>
<Button
size='small'
className='flex-1'
style={{ backgroundColor: '#0e932e' }}
onClick={handleConfirmReceive}
>
</Button>
</>
)}
{/* 已完成: 售后 + 删除 */}
{phase === 'completed' && (
<>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ddd', color: '#666' }}
onClick={() => Taro.navigateTo({ url: `/pages/order/after-sale-apply?orderId=${order.orderId}&type=refund` })}
>
</Button>
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ee0a24', color: '#ee0a24' }}
onClick={handleDeleteOrder}
>
</Button>
</>
)}
{/* 已取消/已退款: 删除 */}
{phase === 'cancelled' && (
<Button
size='small'
className='flex-1'
style={{ borderColor: '#ee0a24', color: '#ee0a24' }}
onClick={handleDeleteOrder}
>
</Button>
)}
</View>
</View>
)
}
export default OrderDetailPage