feat(ticket): 将送水订单功能重构为配送订单系统

- 修改页面标题从"送水订单"为"配送订单"
- 扩展订单模型增加配送相关字段:配送开始时间、结束时间、送达照片、配送状态、客户确认时间等
- 新增配送员角色相关字段和筛选参数
- 实现完整的配送流程管理:待配送、配送中、待确认、已完成状态流转
- 添加配送订单标签页切换功能,支持按状态分类查看
- 集成配送操作界面,支持开始配送和确认送达功能
- 实现配送照片上传和展示功能
- 优化订单列表显示,增加配送流程进度展示
- 添加配送相关的业务逻辑验证和状态判断方法
This commit is contained in:
2026-02-06 20:00:36 +08:00
parent 56d933ddf8
commit 661e7574ef
4 changed files with 466 additions and 76 deletions

View File

@@ -34,6 +34,18 @@ export interface GltTicketOrder {
address?: string;
// 配送时间
sendTime?: string;
// 配送开始时间(配送员点击“开始配送”)
sendStartTime?: string;
// 配送结束时间(配送员确认送达)
sendEndTime?: string;
// 配送员送达拍照(选填/必填由后端策略决定)
sendEndImg?: string;
// 发货/配送状态建议10待配送 20配送中 30待客户确认 40已完成
deliveryStatus?: number;
// 客户确认收货时间(客户点击确认收货)
receiveConfirmTime?: string;
// 客户确认方式建议10客户手动确认 20配送照片自动确认 30后台超时自动确认
receiveConfirmType?: number;
// 买家留言
buyerRemarks?: string;
// 用于统计
@@ -71,4 +83,10 @@ export interface GltTicketOrderParam extends PageParam {
id?: number;
keywords?: string;
userId?: number;
// 配送员用户ID用于配送员端查询
riderId?: number;
// 发货/配送状态(建议与 GltTicketOrder.deliveryStatus 对齐)
deliveryStatus?: number;
// 兼容 ShopOrderParam 的筛选字段(如后端已实现可直接复用)
statusFilter?: number;
}

View File

@@ -1,6 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '送订单',
navigationBarTitleText: '送订单',
navigationBarTextStyle: 'black',
navigationBarBackgroundColor: '#ffffff'
})

View File

@@ -1,105 +1,304 @@
import { useEffect, useState } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { View, Text } from '@tarojs/components'
import { Cell, CellGroup, InfiniteLoading, PullToRefresh, Empty, Loading } from '@nutui/nutui-react-taro'
import {
Tabs,
TabPane,
Cell,
Space,
Button,
Dialog,
Image,
Empty,
InfiniteLoading,
PullToRefresh,
Loading
} from '@nutui/nutui-react-taro'
import dayjs from 'dayjs'
import { pageGltTicketOrder } from '@/api/glt/gltTicketOrder'
import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model'
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
export default function TicketOrdersPage() {
const [list, setList] = useState<GltTicketOrder[]>([])
const [loading, setLoading] = useState(false)
const [hasMore, setHasMore] = useState(true)
const [page, setPage] = useState(1)
const userId = (() => {
const riderId = useMemo(() => {
const raw = Taro.getStorageSync('UserId')
const id = Number(raw)
return Number.isFinite(id) && id > 0 ? id : undefined
})()
}, [])
const reload = async (isRefresh = true) => {
if (loading) return
if (!userId) {
setList([])
setHasMore(false)
return
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 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
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)
try {
const currentPage = isRefresh ? 1 : page
const res = await pageGltTicketOrder({
setError(null)
const currentPage = resetPage ? 1 : pageRef.current
const currentTab = riderTabs.find(t => t.index === tabIndex)
const params: GltTicketOrderParam = {
page: currentPage,
limit: PAGE_SIZE,
userId
} as any)
riderId,
deliveryStatus: currentTab?.deliveryStatus
}
const resList = res?.list || []
const next = isRefresh ? resList : [...list, ...resList]
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 : next.length
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)
setPage(currentPage + 1)
} else {
setHasMore(incomingAll.length >= PAGE_SIZE)
}
pageRef.current = currentPage + 1
} catch (e) {
console.error('获取送水订单失败:', e)
Taro.showToast({ title: '获取送水订单失败', icon: 'none' })
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) => {
setDeliverOrder(order)
setDeliverImg(order.sendEndImg)
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
setDeliverSubmitting(true)
try {
// 说明:
// - sendEndImg送达照片留档可选/必填由后端策略决定)
// - sendEndTime配送员确认送达时间
// - deliveryStatus建议后端设置为 30待客户确认
await updateGltTicketOrder({
id: deliverOrder.id,
deliveryStatus: 30,
sendEndTime: dayjs().format('YYYY-MM-DD HH:mm:ss'),
sendEndImg: deliverImg
})
Taro.showToast({ title: '已确认送达', icon: 'success' })
setDeliverDialogVisible(false)
setDeliverOrder(null)
setDeliverImg(undefined)
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(() => {
setPage(1)
pageRef.current = 1
listRef.current = []
setList([])
setHasMore(true)
reload(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">
<PullToRefresh onRefresh={() => reload(true)}>
{list.length === 0 && !loading ? (
<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">
<Empty description="暂无送水订单" />
<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>
) : (
<CellGroup>
{list.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 || ''
return (
<Cell
key={o.id}
title={
<View className="flex flex-col">
<Text className="text-sm text-gray-900"> {qty || '-'} </Text>
{addr ? <Text className="text-xs text-gray-500">{addr}</Text> : null}
{remark ? <Text className="text-xs text-gray-500">{remark}</Text> : null}
</View>
}
extra={<Text className="text-xs text-gray-500">{timeText}</Text>}
/>
)
})}
</CellGroup>
)}
<InfiniteLoading
hasMore={hasMore}
onLoadMore={() => reload(false)}
onLoadMore={reloadMore}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
@@ -107,13 +306,187 @@ export default function TicketOrdersPage() {
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{list.length === 0 ? '暂无数据' : '没有更多了'}
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 flow1Done = !!o.riderId
const flow2Done = !!o.sendStartTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 20)
const flow3Done = !!o.sendEndTime
const flow4Done = !!o.receiveConfirmTime || o.deliveryStatus === 40
const phoneToCall = o.phone
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="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>{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>
{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>
)}
{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)
}}
>
</Button>
)}
</Space>
</View>
</View>
</Cell>
)
})}
</InfiniteLoading>
)}
</PullToRefresh>
</View>
<Dialog
title="确认送达"
visible={deliverDialogVisible}
confirmText={deliverSubmitting ? '提交中...' : '确认送达'}
cancelText="取消"
onConfirm={handleConfirmDelivered}
onCancel={() => {
if (deliverSubmitting) return
setDeliverDialogVisible(false)
setDeliverOrder(null)
setDeliverImg(undefined)
}}
>
<View className="text-sm text-gray-700">
<View>/</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>
)
}

View File

@@ -277,13 +277,13 @@ const OrderConfirm = () => {
// 设置商品信息
if (goodsRes) {
setGoods(goodsRes)
hasInitialLoadedRef.current = true
}
// 设置默认收货地址
if (addressRes && addressRes.length > 0) {
setAddress(addressRes[0])
}
hasInitialLoadedRef.current = true
// Tickets are non-blocking for first paint; load in background.
loadUserTickets()
} catch (err) {