feat(ticket): 完善水票配送订单功能

- 优化导入路径,修复 PageParam 类型引用
- 新增 DeliverConfirmMode 类型定义,支持拍照完成和等待客户确认两种模式
- 实现配送确认的双模式功能,支持直接完成和等待确认流程
- 重构订单状态判断逻辑,完善配送流程状态管理
- 新增用户端确认收货功能,支持手动确认收货操作
- 优化订单列表展示,增加票号、取货点、门店电话等详细信息
- 添加地址复制和联系门店功能按钮
- 实现补传照片完成订单功能
- 更新订单流程状态显示,提供更准确的状态标识
- 添加配送确认模式切换的单选框界面
- 优化下单成功后的页面跳转逻辑
- 新增水票配送订单后端接口设计文档
This commit is contained in:
2026-02-06 20:33:56 +08:00
parent 661e7574ef
commit 25177d724e
5 changed files with 268 additions and 28 deletions

View File

@@ -0,0 +1,41 @@
# 水票配送订单:后端提示词(可直接发给后端)
## 1) 订单查询(配送员端)
请在 `GET /glt/glt-ticket-order/page` 支持以下筛选,并保证权限隔离:
- `riderId`:只返回该配送员的订单(必要)
- `deliveryStatus`10待配送、20配送中、30待客户确认、40已完成必要
- 排序:建议 `sendTime asc` + `createTime desc`(或给前端一个可控排序字段)
## 2) 配送流程字段(建议后端落库并回传)
订单表建议确保有以下字段(当前前端已按这些字段做流程判断/展示):
- `riderId/riderName/riderPhone`:配送员信息
- `deliveryStatus`10/20/30/40
- `sendStartTime`:配送员点击“开始配送”的时间
- `sendEndTime`:配送员点击“确认送达”的时间
- `sendEndImg`:送达拍照留档图片 URL可选/必填由后端策略决定)
- `receiveConfirmTime`:客户确认收货时间
- `receiveConfirmType`10客户手动确认、20配送照片自动确认、30超时自动确认
## 3) 状态流转与校验(强烈建议在后端做)
请在更新订单时做状态机校验,避免前端绕过流程:
- `10 -> 20`:仅允许订单属于当前配送员,且未开始/未送达
- `20 -> 30`:配送员确认送达(可带 `sendEndImg`
- `20/30 -> 40`:完成;来源可能是
- 客户手动确认(写 `receiveConfirmTime` + `receiveConfirmType=10`
- 配送照片直接完成(写 `receiveConfirmTime` + `receiveConfirmType=20`,并要求 `sendEndImg`
- 超时自动确认(写 `receiveConfirmTime` + `receiveConfirmType=30`,建议由定时任务执行)
## 4) 建议新增/明确的接口能力
为了避免并发抢单/越权更新,建议新增更语义化的接口(或在 update 内做等价校验):
- 接单(抢单/派单):`POST /glt/glt-ticket-order/{id}/accept`
- 后端原子校验:仅当 `riderId is null` 才能写入当前 rider 信息
- 开始配送:`POST /glt/glt-ticket-order/{id}/start`(写 `sendStartTime` + `deliveryStatus=20`
- 确认送达:`POST /glt/glt-ticket-order/{id}/delivered`(写 `sendEndTime` + `deliveryStatus=30` + 可选 `sendEndImg`
- 客户确认收货:`POST /glt/glt-ticket-order/{id}/confirm-receive`
- 校验:只能本人 `userId` 操作,且必须已送达
## 5) 为了“导航到收货地址/取货点”的字段补充(建议)
当前仅有 `address` 字符串,无法在小程序内 `openLocation` 精准导航;建议补充:
- 收货地址:`receiverName``receiverPhone``province/city/district/detail``latitude/longitude`
- 取货点(门店/仓库):`storeLatitude/storeLongitude``warehouseLatitude/warehouseLongitude`

View File

@@ -1,4 +1,4 @@
import type { PageParam } from '@/api/index'; import type { PageParam } from '@/api';
/** /**
* 送水订单 * 送水订单

View File

@@ -16,7 +16,7 @@ import {
import { View, Text, Image } from '@tarojs/components'; import { View, Text, Image } from '@tarojs/components';
import { pageGltUserTicket } from '@/api/glt/gltUserTicket'; import { pageGltUserTicket } from '@/api/glt/gltUserTicket';
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'; import type { GltUserTicket } from '@/api/glt/gltUserTicket/model';
import { pageGltTicketOrder } from '@/api/glt/gltTicketOrder'; import { pageGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder';
import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model'; import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model';
import { BaseUrl } from '@/config/app'; import { BaseUrl } from '@/config/app';
import dayjs from "dayjs"; import dayjs from "dayjs";
@@ -37,7 +37,10 @@ const UserTicketList = () => {
const [orderPage, setOrderPage] = useState(1); const [orderPage, setOrderPage] = useState(1);
const [orderTotal, setOrderTotal] = useState(0); const [orderTotal, setOrderTotal] = useState(0);
const [activeTab, setActiveTab] = useState<'ticket' | 'order'>('ticket'); const [activeTab, setActiveTab] = useState<'ticket' | 'order'>(() => {
const tab = Taro.getCurrentInstance().router?.params?.tab
return tab === 'order' ? 'order' : 'ticket'
});
const [qrVisible, setQrVisible] = useState(false); const [qrVisible, setQrVisible] = useState(false);
const [qrTicket, setQrTicket] = useState<GltUserTicket | null>(null); const [qrTicket, setQrTicket] = useState<GltUserTicket | null>(null);
@@ -253,13 +256,56 @@ const UserTicketList = () => {
return d.isValid() ? d.format('YYYY年MM月DD日 HH:mm:ss') : v; return d.isValid() ? d.format('YYYY年MM月DD日 HH:mm:ss') : v;
}; };
const getOrderStatusText = (status?: number) => { const getTicketOrderStatusMeta = (order: GltTicketOrder) => {
// Backend field meaning may vary; page asks for "是否送达". if (order.status === 1) return { text: '已冻结', type: 'warning' as const };
if (status === 1) return { text: '已送达', type: 'success' as const };
if (status === 0) return { text: '未送达', type: 'warning' as const }; const ds = order.deliveryStatus
return { text: status == null ? '-' : String(status), type: 'primary' as const }; if (ds === 40 || order.receiveConfirmTime) return { text: '已完成', type: 'success' as const };
if (ds === 30 || order.sendEndTime) return { text: '待确认收货', type: 'primary' as const };
if (ds === 20 || order.sendStartTime) return { text: '配送中', type: 'primary' as const };
if (ds === 10 || order.riderId) return { text: '待配送', type: 'warning' as const };
return { text: '待派单', type: 'primary' as const };
}; };
const canUserConfirmReceive = (order: GltTicketOrder) => {
if (!order?.id) return false
if (order.status === 1) return false
if (order.deliveryStatus === 40) return false
if (order.receiveConfirmTime) return false
// 必须是“已送达”后才能确认收货
return !!order.sendEndTime || order.deliveryStatus === 30
}
const handleUserConfirmReceive = async (order: GltTicketOrder) => {
if (!order?.id) return
if (!canUserConfirmReceive(order)) return
const modal = await Taro.showModal({
title: '确认收货',
content: '请确认已收到本次送水,确认后将无法撤销。',
confirmText: '确认收货'
})
if (!modal.confirm) return
try {
Taro.showLoading({ title: '提交中...' })
const now = dayjs().format('YYYY-MM-DD HH:mm:ss')
await updateGltTicketOrder({
id: order.id,
deliveryStatus: 40,
receiveConfirmTime: now,
receiveConfirmType: 10
})
Taro.showToast({ title: '已确认收货', icon: 'success' })
await reloadOrders(true)
} catch (e) {
console.error('确认收货失败:', e)
Taro.showToast({ title: '确认失败,请重试', icon: 'none' })
} finally {
Taro.hideLoading()
}
}
useDidShow(() => { useDidShow(() => {
if (activeTab === 'ticket') { if (activeTab === 'ticket') {
reloadTickets(true).then(); reloadTickets(true).then();
@@ -436,10 +482,52 @@ const UserTicketList = () => {
</View> </View>
</View> </View>
{(() => { {(() => {
const meta = getOrderStatusText(item.status); const meta = getTicketOrderStatusMeta(item);
return <Tag type={meta.type}>{meta.text}</Tag>; return <Tag type={meta.type}>{meta.text}</Tag>;
})()} })()}
</View> </View>
<View className="mt-2 text-xs text-gray-500">
<Text>{item.id ?? '-'}</Text>
</View>
<View className="mt-1 text-xs text-gray-500">
<Text>{item.address || '-'}</Text>
</View>
{item.storeName ? (
<View className="mt-1 text-xs text-gray-500">
<Text>{item.storeName}</Text>
</View>
) : null}
{item.sendStartTime ? (
<View className="mt-1 text-xs text-gray-500">
<Text>{formatDateTime(item.sendStartTime)}</Text>
</View>
) : null}
{item.sendEndTime ? (
<View className="mt-1 text-xs text-gray-500">
<Text>{formatDateTime(item.sendEndTime)}</Text>
</View>
) : null}
{item.receiveConfirmTime ? (
<View className="mt-1 text-xs text-gray-500">
<Text>{formatDateTime(item.receiveConfirmTime)}</Text>
</View>
) : null}
{item.sendEndImg ? (
<View className="mt-3">
<Image src={item.sendEndImg} mode="aspectFill" style={{ width: '100%', height: '160px', borderRadius: '8px' }} />
</View>
) : null}
{canUserConfirmReceive(item) ? (
<View className="mt-3 flex justify-end">
<Button
type="primary"
size="small"
onClick={() => handleUserConfirmReceive(item)}
>
</Button>
</View>
) : null}
</View> </View>
))} ))}
</View> </View>

View File

@@ -8,6 +8,8 @@ import {
Space, Space,
Button, Button,
Dialog, Dialog,
Radio,
RadioGroup,
Image, Image,
Empty, Empty,
InfiniteLoading, InfiniteLoading,
@@ -22,6 +24,8 @@ import { uploadFile } from '@/api/system/file'
const PAGE_SIZE = 10 const PAGE_SIZE = 10
type DeliverConfirmMode = 'photoComplete' | 'waitCustomerConfirm'
export default function TicketOrdersPage() { export default function TicketOrdersPage() {
const riderId = useMemo(() => { const riderId = useMemo(() => {
const raw = Taro.getStorageSync('UserId') const raw = Taro.getStorageSync('UserId')
@@ -42,6 +46,7 @@ export default function TicketOrdersPage() {
const [deliverSubmitting, setDeliverSubmitting] = useState(false) const [deliverSubmitting, setDeliverSubmitting] = useState(false)
const [deliverOrder, setDeliverOrder] = useState<GltTicketOrder | null>(null) const [deliverOrder, setDeliverOrder] = useState<GltTicketOrder | null>(null)
const [deliverImg, setDeliverImg] = useState<string | undefined>(undefined) const [deliverImg, setDeliverImg] = useState<string | undefined>(undefined)
const [deliverConfirmMode, setDeliverConfirmMode] = useState<DeliverConfirmMode>('photoComplete')
const riderTabs = useMemo( const riderTabs = useMemo(
() => [ () => [
@@ -95,7 +100,21 @@ export default function TicketOrdersPage() {
if (!riderId || order.riderId !== riderId) return false if (!riderId || order.riderId !== riderId) return false
if (order.receiveConfirmTime) return false if (order.receiveConfirmTime) return false
if (order.deliveryStatus === 40) return false if (order.deliveryStatus === 40) return false
return !order.sendEndTime 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( const filterByTab = useCallback(
@@ -173,9 +192,10 @@ export default function TicketOrdersPage() {
await reload(false) await reload(false)
}, [hasMore, loading, reload]) }, [hasMore, loading, reload])
const openDeliverDialog = (order: GltTicketOrder) => { const openDeliverDialog = (order: GltTicketOrder, opts?: { mode?: DeliverConfirmMode }) => {
setDeliverOrder(order) setDeliverOrder(order)
setDeliverImg(order.sendEndImg) setDeliverImg(order.sendEndImg)
setDeliverConfirmMode(opts?.mode || (order.sendEndImg ? 'photoComplete' : 'waitCustomerConfirm'))
setDeliverDialogVisible(true) setDeliverDialogVisible(true)
} }
@@ -210,22 +230,41 @@ export default function TicketOrdersPage() {
const handleConfirmDelivered = async () => { const handleConfirmDelivered = async () => {
if (!deliverOrder?.id) return if (!deliverOrder?.id) return
if (deliverSubmitting) return if (deliverSubmitting) return
if (deliverConfirmMode === 'photoComplete' && !deliverImg) {
Taro.showToast({ title: '请先拍照/上传送达照片', icon: 'none' })
return
}
setDeliverSubmitting(true) setDeliverSubmitting(true)
try { try {
const now = dayjs().format('YYYY-MM-DD HH:mm:ss')
// 送达时间:首次“确认送达”写入;补传照片时不要覆盖原送达时间
const deliveredAt = deliverOrder.sendEndTime || now
// 说明: // 说明:
// - sendEndImg送达照片留档可选/必填由后端策略决定 // - waitCustomerConfirm只标记“已送达”进入待客户确认客户点击确认收货后完成
// - sendEndTime配送员确认送达时间 // - photoComplete拍照留档后可直接完成由后端策略决定是否允许
// - deliveryStatus建议后端设置为 30待客户确认 const payload: GltTicketOrder =
await updateGltTicketOrder({ deliverConfirmMode === 'photoComplete'
id: deliverOrder.id, ? {
deliveryStatus: 30, id: deliverOrder.id,
sendEndTime: dayjs().format('YYYY-MM-DD HH:mm:ss'), deliveryStatus: 40,
sendEndImg: deliverImg 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' }) Taro.showToast({ title: '已确认送达', icon: 'success' })
setDeliverDialogVisible(false) setDeliverDialogVisible(false)
setDeliverOrder(null) setDeliverOrder(null)
setDeliverImg(undefined) setDeliverImg(undefined)
setDeliverConfirmMode('photoComplete')
pageRef.current = 1 pageRef.current = 1
await reload(true) await reload(true)
} catch (e) { } catch (e) {
@@ -320,13 +359,19 @@ export default function TicketOrdersPage() {
const timeText = o.createTime ? dayjs(o.createTime).format('YYYY-MM-DD HH:mm') : '-' const timeText = o.createTime ? dayjs(o.createTime).format('YYYY-MM-DD HH:mm') : '-'
const addr = o.address || (o.addressId ? `地址ID${o.addressId}` : '-') const addr = o.address || (o.addressId ? `地址ID${o.addressId}` : '-')
const remark = o.buyerRemarks || o.comments || '' const remark = o.buyerRemarks || o.comments || ''
const ticketNo = o.userTicketId || '-'
const flow1Done = !!o.riderId const flow1Done = !!o.riderId
const flow2Done = !!o.sendStartTime || (typeof o.deliveryStatus === 'number' && o.deliveryStatus >= 20) const flow2Done =
const flow3Done = !!o.sendEndTime !!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 flow4Done = !!o.receiveConfirmTime || o.deliveryStatus === 40
const phoneToCall = o.phone const phoneToCall = o.phone
const storePhone = o.storePhone
const pickupName = o.warehouseName || o.storeName
const pickupAddr = o.warehouseAddress || o.storeAddress
return ( return (
<Cell key={String(o.id)} style={{ padding: '16px' }}> <Cell key={String(o.id)} style={{ padding: '16px' }}>
@@ -337,6 +382,7 @@ export default function TicketOrdersPage() {
</View> </View>
<View className="text-gray-400 text-xs mt-1">{timeText}</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="mt-3 bg-white rounded-lg">
<View className="text-sm text-gray-700"> <View className="text-sm text-gray-700">
@@ -349,6 +395,16 @@ export default function TicketOrdersPage() {
{o.nickname || '-'} {o.phone ? `(${o.phone})` : ''} {o.nickname || '-'} {o.phone ? `(${o.phone})` : ''}
</Text> </Text>
</View> </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"> <View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text> <Text className="text-gray-500"></Text>
<Text>{o.sendTime ? dayjs(o.sendTime).format('YYYY-MM-DD HH:mm') : '-'}</Text> <Text>{o.sendTime ? dayjs(o.sendTime).format('YYYY-MM-DD HH:mm') : '-'}</Text>
@@ -359,6 +415,12 @@ export default function TicketOrdersPage() {
<Text className="text-gray-500 ml-3"></Text> <Text className="text-gray-500 ml-3"></Text>
<Text>{o.storeName || '-'}</Text> <Text>{o.storeName || '-'}</Text>
</View> </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 ? ( {remark ? (
<View className="text-sm text-gray-700 mt-1"> <View className="text-sm text-gray-700 mt-1">
<Text className="text-gray-500"></Text> <Text className="text-gray-500"></Text>
@@ -402,7 +464,7 @@ export default function TicketOrdersPage() {
<Text className="mx-1 text-gray-400">{'>'}</Text> <Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow3Done ? 'text-purple-600 font-medium' : 'text-gray-400'}>3 </Text> <Text className={flow3Done ? 'text-purple-600 font-medium' : 'text-gray-400'}>3 </Text>
<Text className="mx-1 text-gray-400">{'>'}</Text> <Text className="mx-1 text-gray-400">{'>'}</Text>
<Text className={flow4Done ? 'text-green-600 font-medium' : 'text-gray-400'}>4 </Text> <Text className={flow4Done ? 'text-green-600 font-medium' : 'text-gray-400'}>4 </Text>
</View> </View>
<View className="mt-3 flex justify-end"> <View className="mt-3 flex justify-end">
@@ -418,6 +480,29 @@ export default function TicketOrdersPage() {
</Button> </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) && ( {canStartDeliver(o) && (
<Button <Button
size="small" size="small"
@@ -435,12 +520,24 @@ export default function TicketOrdersPage() {
type="primary" type="primary"
onClick={e => { onClick={e => {
e.stopPropagation() e.stopPropagation()
openDeliverDialog(o) openDeliverDialog(o, { mode: 'waitCustomerConfirm' })
}} }}
> >
</Button> </Button>
)} )}
{canCompleteByPhoto(o) && (
<Button
size="small"
type="primary"
onClick={e => {
e.stopPropagation()
openDeliverDialog(o, { mode: 'photoComplete' })
}}
>
</Button>
)}
</Space> </Space>
</View> </View>
</View> </View>
@@ -455,7 +552,13 @@ export default function TicketOrdersPage() {
<Dialog <Dialog
title="确认送达" title="确认送达"
visible={deliverDialogVisible} visible={deliverDialogVisible}
confirmText={deliverSubmitting ? '提交中...' : '确认送达'} confirmText={
deliverSubmitting
? '提交中...'
: deliverConfirmMode === 'photoComplete'
? '拍照完成'
: '确认送达'
}
cancelText="取消" cancelText="取消"
onConfirm={handleConfirmDelivered} onConfirm={handleConfirmDelivered}
onCancel={() => { onCancel={() => {
@@ -463,10 +566,18 @@ export default function TicketOrdersPage() {
setDeliverDialogVisible(false) setDeliverDialogVisible(false)
setDeliverOrder(null) setDeliverOrder(null)
setDeliverImg(undefined) setDeliverImg(undefined)
setDeliverConfirmMode('photoComplete')
}} }}
> >
<View className="text-sm text-gray-700"> <View className="text-sm text-gray-700">
<View>/</View> <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"> <View className="mt-3">
<Button size="small" onClick={handleChooseDeliverImg}> <Button size="small" onClick={handleChooseDeliverImg}>
{deliverImg ? '重新拍照/上传' : '拍照/上传'} {deliverImg ? '重新拍照/上传' : '拍照/上传'}
@@ -483,7 +594,7 @@ export default function TicketOrdersPage() {
</View> </View>
)} )}
<View className="mt-3 text-xs text-gray-500"> <View className="mt-3 text-xs text-gray-500">
</View> </View>
</View> </View>
</Dialog> </Dialog>

View File

@@ -250,7 +250,7 @@ const OrderConfirm = () => {
Taro.showToast({ title: '下单成功', icon: 'success' }) Taro.showToast({ title: '下单成功', icon: 'success' })
setTimeout(() => { setTimeout(() => {
// 跳转到“我的送水订单” // 跳转到“我的送水订单”
Taro.redirectTo({ url: '/user/ticket/orders/index' }) Taro.redirectTo({ url: '/user/ticket/index?tab=order' })
}, 800) }, 800)
} catch (e: any) { } catch (e: any) {
console.error('水票下单失败:', e) console.error('水票下单失败:', e)