Files
glt-taro/src/user/ticket/orders/index.tsx
赵忠林 25177d724e feat(ticket): 完善水票配送订单功能
- 优化导入路径,修复 PageParam 类型引用
- 新增 DeliverConfirmMode 类型定义,支持拍照完成和等待客户确认两种模式
- 实现配送确认的双模式功能,支持直接完成和等待确认流程
- 重构订单状态判断逻辑,完善配送流程状态管理
- 新增用户端确认收货功能,支持手动确认收货操作
- 优化订单列表展示,增加票号、取货点、门店电话等详细信息
- 添加地址复制和联系门店功能按钮
- 实现补传照片完成订单功能
- 更新订单流程状态显示,提供更准确的状态标识
- 添加配送确认模式切换的单选框界面
- 优化下单成功后的页面跳转逻辑
- 新增水票配送订单后端接口设计文档
2026-02-06 20:33:56 +08:00

604 lines
24 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 { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import {
Tabs,
TabPane,
Cell,
Space,
Button,
Dialog,
Radio,
RadioGroup,
Image,
Empty,
InfiniteLoading,
PullToRefresh,
Loading
} from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { pageGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder'
import type { GltTicketOrder, GltTicketOrderParam } from '@/api/glt/gltTicketOrder/model'
import { uploadFile } from '@/api/system/file'
const PAGE_SIZE = 10
type DeliverConfirmMode = 'photoComplete' | 'waitCustomerConfirm'
export default function TicketOrdersPage() {
const riderId = useMemo(() => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
}, [])
const pageRef = useRef(1)
const listRef = useRef<GltTicketOrder[]>([])
const [tabIndex, setTabIndex] = useState(0)
const [list, setList] = useState<GltTicketOrder[]>([])
const [hasMore, setHasMore] = useState(true)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [deliverDialogVisible, setDeliverDialogVisible] = useState(false)
const [deliverSubmitting, setDeliverSubmitting] = useState(false)
const [deliverOrder, setDeliverOrder] = useState<GltTicketOrder | null>(null)
const [deliverImg, setDeliverImg] = useState<string | undefined>(undefined)
const [deliverConfirmMode, setDeliverConfirmMode] = useState<DeliverConfirmMode>('photoComplete')
const riderTabs = useMemo(
() => [
{ index: 0, title: '全部' },
{ index: 1, title: '待配送', deliveryStatus: 10 },
{ index: 2, title: '配送中', deliveryStatus: 20 },
{ index: 3, title: '待确认', deliveryStatus: 30 },
{ index: 4, title: '已完成', deliveryStatus: 40 }
],
[]
)
const getOrderStatusText = (order: GltTicketOrder) => {
if (order.status === 1) return '已冻结'
const deliveryStatus = order.deliveryStatus
if (deliveryStatus === 40) return '已完成'
if (deliveryStatus === 30) return '待客户确认'
if (deliveryStatus === 20) return '配送中'
if (deliveryStatus === 10) return '待配送'
// 兼容:如果后端暂未下发 deliveryStatus就用时间字段推断
if (order.receiveConfirmTime) return '已完成'
if (order.sendEndTime) return '待客户确认'
if (order.sendStartTime) return '配送中'
if (order.riderId) return '待配送'
return '待派单'
}
const getOrderStatusColor = (order: GltTicketOrder) => {
const text = getOrderStatusText(order)
if (text === '已完成') return 'text-green-600'
if (text === '待客户确认') return 'text-purple-600'
if (text === '配送中') return 'text-blue-600'
if (text === '待配送') return 'text-amber-600'
if (text === '已冻结') return 'text-orange-600'
return 'text-gray-500'
}
const canStartDeliver = (order: GltTicketOrder) => {
if (!order.id) return false
if (order.status === 1) return false
if (!riderId || order.riderId !== riderId) return false
if (order.deliveryStatus && order.deliveryStatus !== 10) return false
return !order.sendStartTime && !order.sendEndTime
}
const canConfirmDelivered = (order: GltTicketOrder) => {
if (!order.id) return false
if (order.status === 1) return false
if (!riderId || order.riderId !== riderId) return false
if (order.receiveConfirmTime) return false
if (order.deliveryStatus === 40) return false
if (order.sendEndTime) return false
// 只允许在“配送中”阶段确认送达
if (typeof order.deliveryStatus === 'number') return order.deliveryStatus === 20
return !!order.sendStartTime
}
const canCompleteByPhoto = (order: GltTicketOrder) => {
if (!order.id) return false
if (order.status === 1) return false
if (!riderId || order.riderId !== riderId) return false
if (order.receiveConfirmTime) return false
if (order.deliveryStatus === 40) return false
// 已送达但未完成:允许补传照片并直接完成
return !!order.sendEndTime
}
const filterByTab = useCallback(
(orders: GltTicketOrder[]) => {
if (tabIndex === 0) return orders
const current = riderTabs.find(t => t.index === tabIndex)
const status = current?.deliveryStatus
if (!status) return orders
// 如果后端已实现 deliveryStatus 筛选,这里基本不会再过滤;否则用兼容逻辑兜底。
return orders.filter(o => {
const ds = o.deliveryStatus
if (typeof ds === 'number') return ds === status
if (status === 10) return !!o.riderId && !o.sendStartTime && !o.sendEndTime
if (status === 20) return !!o.sendStartTime && !o.sendEndTime
if (status === 30) return !!o.sendEndTime && !o.receiveConfirmTime
if (status === 40) return !!o.receiveConfirmTime
return true
})
},
[riderTabs, tabIndex]
)
const reload = useCallback(
async (resetPage = false) => {
if (!riderId) return
if (loading) return
setLoading(true)
setError(null)
const currentPage = resetPage ? 1 : pageRef.current
const currentTab = riderTabs.find(t => t.index === tabIndex)
const params: GltTicketOrderParam = {
page: currentPage,
limit: PAGE_SIZE,
riderId,
deliveryStatus: currentTab?.deliveryStatus
}
try {
const res = await pageGltTicketOrder(params as any)
const incomingAll = (res?.list || []) as GltTicketOrder[]
// 兼容:后端若暂未实现 riderId 过滤,前端兜底过滤掉非本人的订单
const incoming = incomingAll.filter(o => o?.deleted !== 1 && o?.riderId === riderId)
const prev = resetPage ? [] : listRef.current
const next = resetPage ? incoming : prev.concat(incoming)
listRef.current = next
setList(next)
const total = typeof res?.count === 'number' ? res.count : undefined
const filteredOut = incomingAll.length - incoming.length
if (typeof total === 'number' && filteredOut === 0) {
setHasMore(next.length < total)
} else {
setHasMore(incomingAll.length >= PAGE_SIZE)
}
pageRef.current = currentPage + 1
} catch (e) {
console.error('加载配送订单失败:', e)
setError('加载失败,请重试')
setHasMore(false)
} finally {
setLoading(false)
}
},
[loading, riderId, riderTabs, tabIndex]
)
const reloadMore = useCallback(async () => {
if (loading || !hasMore) return
await reload(false)
}, [hasMore, loading, reload])
const openDeliverDialog = (order: GltTicketOrder, opts?: { mode?: DeliverConfirmMode }) => {
setDeliverOrder(order)
setDeliverImg(order.sendEndImg)
setDeliverConfirmMode(opts?.mode || (order.sendEndImg ? 'photoComplete' : 'waitCustomerConfirm'))
setDeliverDialogVisible(true)
}
const handleChooseDeliverImg = async () => {
try {
const file = await uploadFile()
setDeliverImg(file?.url)
} catch (e) {
console.error('上传送达照片失败:', e)
Taro.showToast({ title: '上传失败,请重试', icon: 'none' })
}
}
const handleStartDeliver = async (order: GltTicketOrder) => {
if (!order?.id) return
if (!canStartDeliver(order)) return
try {
await updateGltTicketOrder({
id: order.id,
deliveryStatus: 20,
sendStartTime: dayjs().format('YYYY-MM-DD HH:mm:ss')
})
Taro.showToast({ title: '已开始配送', icon: 'success' })
pageRef.current = 1
await reload(true)
} catch (e) {
console.error('开始配送失败:', e)
Taro.showToast({ title: '开始配送失败', icon: 'none' })
}
}
const handleConfirmDelivered = async () => {
if (!deliverOrder?.id) return
if (deliverSubmitting) return
if (deliverConfirmMode === 'photoComplete' && !deliverImg) {
Taro.showToast({ title: '请先拍照/上传送达照片', icon: 'none' })
return
}
setDeliverSubmitting(true)
try {
const now = dayjs().format('YYYY-MM-DD HH:mm:ss')
// 送达时间:首次“确认送达”写入;补传照片时不要覆盖原送达时间
const deliveredAt = deliverOrder.sendEndTime || now
// 说明:
// - waitCustomerConfirm只标记“已送达”进入待客户确认客户点击确认收货后完成
// - photoComplete拍照留档后可直接完成由后端策略决定是否允许
const payload: GltTicketOrder =
deliverConfirmMode === 'photoComplete'
? {
id: deliverOrder.id,
deliveryStatus: 40,
sendEndTime: deliveredAt,
sendEndImg: deliverImg,
receiveConfirmTime: now,
receiveConfirmType: 20
}
: {
id: deliverOrder.id,
deliveryStatus: 30,
sendEndTime: deliveredAt,
sendEndImg: deliverImg
}
await updateGltTicketOrder(payload)
Taro.showToast({ title: '已确认送达', icon: 'success' })
setDeliverDialogVisible(false)
setDeliverOrder(null)
setDeliverImg(undefined)
setDeliverConfirmMode('photoComplete')
pageRef.current = 1
await reload(true)
} catch (e) {
console.error('确认送达失败:', e)
Taro.showToast({ title: '确认送达失败', icon: 'none' })
} finally {
setDeliverSubmitting(false)
}
}
useEffect(() => {
listRef.current = list
}, [list])
useDidShow(() => {
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
void reload(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
})
useEffect(() => {
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
void reload(true)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tabIndex, riderId])
if (!riderId) {
return (
<View className="bg-gray-50 min-h-screen p-4">
<Text></Text>
</View>
)
}
const displayList = filterByTab(list)
return (
<View className="bg-gray-50 min-h-screen">
<Tabs value={tabIndex} onChange={paneKey => setTabIndex(Number(paneKey))} align="left">
{riderTabs.map(t => (
<TabPane key={t.index} title={loading && tabIndex === t.index ? `${t.title}...` : t.title} />
))}
</Tabs>
<View className="px-3 pb-4">
<PullToRefresh
onRefresh={async () => {
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
await reload(true)
}}
>
{error ? (
<View className="bg-white rounded-lg p-6">
<View className="flex flex-col items-center justify-center">
<Text className="text-gray-500 mb-3">{error}</Text>
<Button size="small" type="primary" onClick={() => reload(true)}>
</Button>
</View>
</View>
) : (
<InfiniteLoading
hasMore={hasMore}
onLoadMore={reloadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
displayList.length === 0 ? (
<View className="bg-white rounded-lg p-6">
<Empty description="暂无配送订单" />
</View>
) : (
<View className="text-center py-4 text-gray-500"></View>
)
}
>
{displayList.map(o => {
const qty = Number(o.totalNum || 0)
const timeText = o.createTime ? dayjs(o.createTime).format('YYYY-MM-DD HH:mm') : '-'
const addr = o.address || (o.addressId ? `地址ID${o.addressId}` : '-')
const remark = o.buyerRemarks || o.comments || ''
const ticketNo = o.userTicketId || '-'
const flow1Done = !!o.riderId
const flow2Done =
!!o.sendStartTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 20)
const flow3Done =
!!o.sendEndTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 30)
const flow4Done = !!o.receiveConfirmTime || o.deliveryStatus === 40
const phoneToCall = o.phone
const storePhone = o.storePhone
const pickupName = o.warehouseName || o.storeName
const pickupAddr = o.warehouseAddress || o.storeAddress
return (
<Cell key={String(o.id)} style={{ padding: '16px' }}>
<View className="w-full">
<View className="flex justify-between items-center">
<Text className="text-gray-800 font-bold text-sm">{`订单#${o.id}`}</Text>
<Text className={`${getOrderStatusColor(o)} text-sm font-medium`}>{getOrderStatusText(o)}</Text>
</View>
<View className="text-gray-400 text-xs mt-1">{timeText}</View>
<View className="text-gray-400 text-xs mt-1">{ticketNo}</View>
<View className="mt-3 bg-white rounded-lg">
<View className="text-sm text-gray-700">
<Text className="text-gray-500"></Text>
<Text>{addr}</Text>
</View>
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>
{o.nickname || '-'} {o.phone ? `(${o.phone})` : ''}
</Text>
</View>
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{pickupName || '-'}</Text>
</View>
{pickupAddr ? (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{pickupAddr}</Text>
</View>
) : null}
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{o.sendTime ? dayjs(o.sendTime).format('YYYY-MM-DD HH:mm') : '-'}</Text>
</View>
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{qty || '-'}</Text>
<Text className="text-gray-500 ml-3"></Text>
<Text>{o.storeName || '-'}</Text>
</View>
{o.storePhone ? (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{o.storePhone}</Text>
</View>
) : null}
{remark ? (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{remark}</Text>
</View>
) : null}
{o.sendStartTime ? (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{dayjs(o.sendStartTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
) : null}
{o.sendEndTime ? (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{dayjs(o.sendEndTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
) : null}
{o.receiveConfirmTime ? (
<View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text>
<Text>{dayjs(o.receiveConfirmTime).format('YYYY-MM-DD HH:mm')}</Text>
</View>
) : null}
{o.sendEndImg ? (
<View className="text-sm text-gray-700 mt-2">
<Text className="text-gray-500"></Text>
<View className="mt-2">
<Image src={o.sendEndImg} width="100%" height="120" />
</View>
</View>
) : null}
</View>
{/* 配送流程 */}
<View className="mt-3 bg-gray-50 rounded-lg p-2 text-xs">
<Text className="text-gray-600"></Text>
<Text className={flow1Done ? 'text-green-600 font-medium' : 'text-gray-400'}>1 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow2Done ? 'text-blue-600 font-medium' : 'text-gray-400'}>2 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow3Done ? 'text-purple-600 font-medium' : 'text-gray-400'}>3 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow4Done ? 'text-green-600 font-medium' : 'text-gray-400'}>4 </Text>
</View>
<View className="mt-3 flex justify-end">
<Space>
{!!phoneToCall && (
<Button
size="small"
onClick={e => {
e.stopPropagation()
Taro.makePhoneCall({ phoneNumber: phoneToCall })
}}
>
</Button>
)}
{!!addr && addr !== '-' && (
<Button
size="small"
onClick={e => {
e.stopPropagation()
void Taro.setClipboardData({ data: addr })
Taro.showToast({ title: '地址已复制', icon: 'none' })
}}
>
</Button>
)}
{!!storePhone && (
<Button
size="small"
onClick={e => {
e.stopPropagation()
Taro.makePhoneCall({ phoneNumber: storePhone })
}}
>
</Button>
)}
{canStartDeliver(o) && (
<Button
size="small"
onClick={e => {
e.stopPropagation()
void handleStartDeliver(o)
}}
>
</Button>
)}
{canConfirmDelivered(o) && (
<Button
size="small"
type="primary"
onClick={e => {
e.stopPropagation()
openDeliverDialog(o, { mode: 'waitCustomerConfirm' })
}}
>
</Button>
)}
{canCompleteByPhoto(o) && (
<Button
size="small"
type="primary"
onClick={e => {
e.stopPropagation()
openDeliverDialog(o, { mode: 'photoComplete' })
}}
>
</Button>
)}
</Space>
</View>
</View>
</Cell>
)
})}
</InfiniteLoading>
)}
</PullToRefresh>
</View>
<Dialog
title="确认送达"
visible={deliverDialogVisible}
confirmText={
deliverSubmitting
? '提交中...'
: deliverConfirmMode === 'photoComplete'
? '拍照完成'
: '确认送达'
}
cancelText="取消"
onConfirm={handleConfirmDelivered}
onCancel={() => {
if (deliverSubmitting) return
setDeliverDialogVisible(false)
setDeliverOrder(null)
setDeliverImg(undefined)
setDeliverConfirmMode('photoComplete')
}}
>
<View className="text-sm text-gray-700">
<View></View>
<View className="mt-3">
<RadioGroup value={deliverConfirmMode} onChange={v => setDeliverConfirmMode(v as DeliverConfirmMode)}>
<Radio value="photoComplete"></Radio>
<Radio value="waitCustomerConfirm"></Radio>
</RadioGroup>
</View>
<View className="mt-3">
<Button size="small" onClick={handleChooseDeliverImg}>
{deliverImg ? '重新拍照/上传' : '拍照/上传'}
</Button>
</View>
{deliverImg && (
<View className="mt-3">
<Image src={deliverImg} width="100%" height="120" />
<View className="mt-2 flex justify-end">
<Button size="small" onClick={() => setDeliverImg(undefined)}>
</Button>
</View>
</View>
)}
<View className="mt-3 text-xs text-gray-500">
</View>
</View>
</Dialog>
</View>
)
}