import { useState } from 'react'; import Taro, { useDidShow } from '@tarojs/taro'; import { Button, ConfigProvider, Empty, InfiniteLoading, Loading, Popup, PullToRefresh, SearchBar, Tabs, TabPane, Tag } from '@nutui/nutui-react-taro'; import { View, Text, Image } from '@tarojs/components'; import { getGltUserTicket, pageGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket'; import type { GltUserTicket } from '@/api/glt/gltUserTicket/model'; import { pageGltTicketOrder, removeGltTicketOrder, updateGltTicketOrder } from '@/api/glt/gltTicketOrder'; import type { GltTicketOrder } from '@/api/glt/gltTicketOrder/model'; import { BaseUrl } from '@/config/app'; import dayjs from "dayjs"; import { ensureLoggedIn } from '@/utils/auth'; const PAGE_SIZE = 10; const UserTicketList = () => { const [ticketList, setTicketList] = useState([]); const [ticketLoading, setTicketLoading] = useState(false); const [ticketHasMore, setTicketHasMore] = useState(true); const [searchValue, setSearchValue] = useState(''); const [ticketPage, setTicketPage] = useState(1); const [ticketTotal, setTicketTotal] = useState(0); const [orderList, setOrderList] = useState([]); const [orderLoading, setOrderLoading] = useState(false); const [orderHasMore, setOrderHasMore] = useState(true); const [orderPage, setOrderPage] = useState(1); const [orderTotal, setOrderTotal] = useState(0); const [orderCancelLoadingById, setOrderCancelLoadingById] = useState>({}); const [activeTab, setActiveTab] = useState<'ticket' | 'order'>(() => { const tab = Taro.getCurrentInstance().router?.params?.tab return tab === 'order' ? 'order' : 'ticket' }); const [qrVisible, setQrVisible] = useState(false); const [qrTicket, setQrTicket] = useState(null); const [qrImageUrl, setQrImageUrl] = useState(''); const getUserId = () => { const raw = Taro.getStorageSync('UserId'); const id = Number(raw); return Number.isFinite(id) && id > 0 ? id : undefined; }; const buildTicketQrContent = (ticket: GltUserTicket) => { // QR will be encrypted by `/qr-code/create-encrypted-qr-image`, // and decrypted on verifier side to get this payload. return JSON.stringify({ userTicketId: ticket.id, qty: 1, userId: ticket.userId, t: Date.now() }); }; const buildEncryptedQrImageUrl = (businessType: string, data: string) => { const size = '300x300'; const expireMinutes = 30; const base = BaseUrl?.replace(/\/+$/, ''); return `${base}/qr-code/create-encrypted-qr-image?size=${encodeURIComponent( size )}&expireMinutes=${encodeURIComponent(String(expireMinutes))}&businessType=${encodeURIComponent( businessType )}&data=${encodeURIComponent(data)}`; }; const openTicketQr = (ticket: GltUserTicket) => { if (!ticket?.id) { Taro.showToast({ title: '水票信息不完整', icon: 'none' }); return; } if (ticket.status === 1) { Taro.showToast({ title: '该水票已冻结,无法核销', icon: 'none' }); return; } if ((ticket.availableQty ?? 0) <= 0) { Taro.showToast({ title: '可用次数不足', icon: 'none' }); return; } const content = buildTicketQrContent(ticket); setQrTicket(ticket); setQrImageUrl(buildEncryptedQrImageUrl('ticket', content)); setQrVisible(true); }; const goSendWater = async (ticket: GltUserTicket) => { if (!ticket?.id) { Taro.showToast({ title: '水票信息不完整', icon: 'none' }); return; } if (Number(ticket.status) === 1) { Taro.showToast({ title: '该水票已冻结,无法下单', icon: 'none' }); return; } const avail = Number(ticket.availableQty ?? 0); if (!Number.isFinite(avail) || avail <= 0) { Taro.showToast({ title: '可用次数不足', icon: 'none' }); return; } const gid = Number(ticket.goodsId); const url = Number.isFinite(gid) && gid > 0 ? `/user/ticket/use?goodsId=${gid}` : '/user/ticket/use'; if (!ensureLoggedIn(url)) return; await Taro.navigateTo({ url }); }; const goReleasePlanDetail = async (ticket: GltUserTicket) => { if (!ticket?.id) { Taro.showToast({ title: '水票信息不完整', icon: 'none' }); return; } const url = `/user/ticket/release/index?userTicketId=${encodeURIComponent(String(ticket.id))}&templateName=${encodeURIComponent( String(ticket.templateName ?? '') )}&frozenQty=${encodeURIComponent(String(ticket.frozenQty ?? 0))}&releasedQty=${encodeURIComponent( String(ticket.releasedQty ?? 0) )}`; if (!ensureLoggedIn(url)) return; await Taro.navigateTo({ url }); }; const showTicketDetail = (ticket: GltUserTicket) => { const lines: string[] = []; if (ticket.templateName) lines.push(`水票:${ticket.templateName}`); lines.push(`可用:${ticket.availableQty ?? 0}`); lines.push(`总量:${ticket.totalQty ?? 0}`); lines.push(`已用:${ticket.usedQty ?? 0}`); lines.push(`冻结:${ticket.frozenQty ?? 0}`); lines.push(`已释放:${ticket.releasedQty ?? 0}`); if (ticket.orderNo) lines.push(`订单号:${ticket.orderNo}`); // Taro.showModal({ // title: '水票详情', // content: lines.join('\n'), // showCancel: false // }); }; const reloadTickets = async (isRefresh = true, keywords?: string) => { if (ticketLoading) return; const userId = getUserId(); if (!userId) { setTicketList([]); setTicketTotal(0); setTicketHasMore(false); return; } if (isRefresh) { setTicketPage(1); setTicketList([]); setTicketHasMore(true); } setTicketLoading(true); try { const currentPage = isRefresh ? 1 : ticketPage; const res = await pageGltUserTicket({ page: currentPage, limit: PAGE_SIZE, userId, keywords: (keywords ?? searchValue) || undefined }); const nextList = isRefresh ? res.list : [...ticketList, ...res.list]; setTicketList(nextList); const count = typeof res.count === 'number' ? res.count : nextList.length; setTicketTotal(count); setTicketHasMore(nextList.length < count); if (res.list.length > 0) { setTicketPage(currentPage + 1); } else { setTicketHasMore(false); } } catch (error) { console.error('获取水票列表失败:', error); Taro.showToast({ title: '获取水票失败', icon: 'error' }); setTicketHasMore(false); } finally { setTicketLoading(false); } }; const reloadOrders = async (isRefresh = true, keywords?: string) => { if (orderLoading) return; const userId = getUserId(); if (!userId) { setOrderList([]); setOrderTotal(0); setOrderHasMore(false); return; } if (isRefresh) { setOrderPage(1); setOrderList([]); setOrderHasMore(true); } setOrderLoading(true); try { const currentPage = isRefresh ? 1 : orderPage; const res = await pageGltTicketOrder({ page: currentPage, limit: PAGE_SIZE, userId, keywords: (keywords ?? searchValue) || undefined }); const resList = res?.list || []; const safeList = resList.filter((o) => Number((o as any)?.deleted) !== 1); const nextList = isRefresh ? safeList : [...orderList, ...safeList]; setOrderList(nextList); const serverCount = typeof res?.count === 'number' ? res.count : undefined; const total = typeof serverCount === 'number' ? serverCount : nextList.length; setOrderTotal(total); setOrderHasMore(typeof serverCount === 'number' ? nextList.length < serverCount : resList.length >= PAGE_SIZE); if (resList.length > 0) setOrderPage(currentPage + 1); else setOrderHasMore(false); } catch (error) { console.error('获取送水订单失败:', error); Taro.showToast({ title: '获取送水订单失败', icon: 'error' }); setOrderHasMore(false); } finally { setOrderLoading(false); } }; const handleSearch = (value: string) => { setSearchValue(value); if (activeTab === 'ticket') { reloadTickets(true, value); } else { reloadOrders(true, value); } }; const handleRefresh = async () => { if (activeTab === 'ticket') { await reloadTickets(true); } else { await reloadOrders(true); } }; const handleTabChange = (value: string | number) => { const tab = String(value) as 'ticket' | 'order'; setActiveTab(tab); if (tab === 'ticket') { setTicketPage(1); setTicketList([]); setTicketHasMore(true); reloadTickets(true); } else { setOrderPage(1); setOrderList([]); setOrderHasMore(true); reloadOrders(true); } }; const loadMoreTickets = async () => { if (!ticketLoading && ticketHasMore) { await reloadTickets(false); } }; const loadMoreOrders = async () => { if (!orderLoading && orderHasMore) { await reloadOrders(false); } }; const formatDateTime = (v?: string) => { if (!v) return '-'; const d = dayjs(v); return d.isValid() ? d.format('YYYY年MM月DD日 HH:mm:ss') : v; }; const formatDate = (v?: string) => { if (!v) return '-'; const d = dayjs(v); return d.isValid() ? d.format('YYYY年MM月DD日') : v; }; const getTicketAvailableQty = (t?: Partial | null) => { if (!t) return 0; const anyT: any = t; const raw = anyT.availableQty ?? anyT.availableNum ?? anyT.availableCount ?? anyT.remainQty ?? anyT.remainNum ?? anyT.remainCount; const n = Number(raw); if (Number.isFinite(n)) return n; const total = Number(anyT.totalQty ?? anyT.totalNum ?? anyT.totalCount ?? 0); const used = Number(anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount ?? 0); const frozen = Number(anyT.frozenQty ?? anyT.frozenNum ?? anyT.frozenCount ?? 0); const computed = (Number.isFinite(total) ? total : 0) - (Number.isFinite(used) ? used : 0) - (Number.isFinite(frozen) ? frozen : 0); return Number.isFinite(computed) ? computed : 0; }; const getTicketUsedQty = (t?: Partial | null) => { if (!t) return 0; const anyT: any = t; const raw = anyT.usedQty ?? anyT.usedNum ?? anyT.usedCount; const n = Number(raw); return Number.isFinite(n) ? n : 0; }; const rollbackUserTicketAfterOrderCancel = async (order: GltTicketOrder, before?: GltUserTicket | null) => { const orderId = Number(order?.id); const ticketId = Number(order?.userTicketId); const qty = Math.max(0, Math.floor(Number((order as any)?.totalNum ?? 0))); if (!Number.isFinite(orderId) || orderId <= 0) return; if (!Number.isFinite(ticketId) || ticketId <= 0) return; if (!Number.isFinite(qty) || qty <= 0) return; const rollbackKey = `glt_ticket_order_rollback:${orderId}`; if (Taro.getStorageSync(rollbackKey)) return; const after = await getGltUserTicket(ticketId); if (!after?.id) return; const beforeAvail = before ? getTicketAvailableQty(before) : undefined; const afterAvail = getTicketAvailableQty(after); const beforeUsed = before ? getTicketUsedQty(before) : undefined; const afterUsed = getTicketUsedQty(after); let needAvail = qty; if (typeof beforeAvail === 'number') { const delta = afterAvail - beforeAvail; if (delta >= qty) { Taro.setStorageSync(rollbackKey, Date.now()); return; // backend already rolled back } if (delta > 0) needAvail = Math.max(0, qty - delta); } let needUsed = qty; if (typeof beforeUsed === 'number') { const delta = beforeUsed - afterUsed; if (delta >= qty) { needUsed = 0; // backend already rolled back used qty } else if (delta > 0) { needUsed = Math.max(0, qty - delta); } } if (needAvail <= 0 && needUsed <= 0) { Taro.setStorageSync(rollbackKey, Date.now()); return; } const currentAvailRaw = Number((after as any)?.availableQty); const baseAvail = Number.isFinite(currentAvailRaw) ? currentAvailRaw : afterAvail; const safeBaseAvail = Number.isFinite(baseAvail) ? baseAvail : 0; const totalRaw = Number((after as any)?.totalQty ?? 0); const total = Number.isFinite(totalRaw) ? totalRaw : undefined; const frozenRaw = Number((after as any)?.frozenQty ?? 0); const frozen = Number.isFinite(frozenRaw) ? frozenRaw : 0; const currentUsedRaw = Number((after as any)?.usedQty); const baseUsed = Number.isFinite(currentUsedRaw) ? currentUsedRaw : afterUsed; const safeBaseUsed = Number.isFinite(baseUsed) ? baseUsed : 0; let nextUsed = safeBaseUsed - needUsed; if (nextUsed < 0) nextUsed = 0; const maxAvail = typeof total === 'number' ? Math.max(0, total - frozen - nextUsed) : undefined; let nextAvail = safeBaseAvail + needAvail; if (typeof maxAvail === 'number' && Number.isFinite(maxAvail) && nextAvail > maxAvail) nextAvail = maxAvail; if (nextAvail < 0) nextAvail = 0; await updateGltUserTicket({ ...after, availableQty: nextAvail, usedQty: nextUsed }); Taro.setStorageSync(rollbackKey, Date.now()); }; // Allow users to modify/cancel before delivery starts (e.g. 待派单 / 待配送). const isTicketOrderPendingDelivery = (order: GltTicketOrder) => { if (!order?.id) return false; if (Number(order.status) === 1) return false; if (Number((order as any)?.deleted) === 1) return false; if (order.receiveConfirmTime || order.sendEndTime || order.sendStartTime) return false; const ds = Number((order as any)?.deliveryStatus); // If backend didn't set deliveryStatus yet, treat it as pending. if (!Number.isFinite(ds)) return true; // 0/10: before delivery starts return ds === 0 || ds === 10; }; const handleOrderModify = async (order: GltTicketOrder) => { if (!order?.id) { Taro.showToast({ title: '订单信息不完整', icon: 'none' }); return; } if (!isTicketOrderPendingDelivery(order)) { Taro.showToast({ title: '仅配送未开始的订单可修改', icon: 'none' }); return; } Taro.navigateTo({ url: `/user/ticket/use?orderId=${order.id}` }); }; const handleOrderCancel = async (order: GltTicketOrder) => { if (!order?.id) { Taro.showToast({ title: '订单信息不完整', icon: 'none' }); return; } if (!isTicketOrderPendingDelivery(order)) { Taro.showToast({ title: '仅配送未开始的订单可取消', icon: 'none' }); return; } if (orderCancelLoadingById[order.id]) return; const modal = await Taro.showModal({ title: '取消订单', content: '确定要取消该订单吗?取消后无法恢复。', confirmText: '确认取消' }); if (!modal.confirm) return; try { setOrderCancelLoadingById((prev) => ({ ...prev, [order.id as number]: true })); Taro.showLoading({ title: '取消中...' }); let beforeTicket: GltUserTicket | null = null; if (order.userTicketId) { beforeTicket = await getGltUserTicket(Number(order.userTicketId)).catch(() => null); } try { await updateGltTicketOrder({ id: order.id, deleted: 1 }); } catch (e) { await removeGltTicketOrder(order.id); } try { await rollbackUserTicketAfterOrderCancel(order, beforeTicket); Taro.showToast({ title: '订单已取消,水票已退回', icon: 'success' }); } catch (e) { console.error('取消订单后退回水票失败:', e); await Taro.showModal({ title: '取消成功', content: '订单已取消,但水票退回失败,请稍后刷新“我的水票”确认,或联系客服处理。', showCancel: false }); } await reloadOrders(true); } catch (e) { console.error('取消送水订单失败:', e); Taro.showToast({ title: '取消失败,请重试', icon: 'none' }); } finally { Taro.hideLoading(); setOrderCancelLoadingById((prev) => ({ ...prev, [order.id as number]: false })); } }; const getTicketOrderStatusMeta = (order: GltTicketOrder) => { if (order.status === 1) return { text: '已冻结', type: 'warning' as const }; const ds = order.deliveryStatus 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(() => { const tabParam = Taro.getCurrentInstance().router?.params?.tab const nextTab = tabParam === 'ticket' || tabParam === 'order' ? tabParam : undefined if (nextTab && nextTab !== activeTab) { setActiveTab(nextTab) } const tabToLoad = nextTab || activeTab if (tabToLoad === 'ticket') { reloadTickets(true).then() } else { reloadOrders(true).then() } }) return ( {/* 搜索栏 */} {/* Tab切换 */} {activeTab === 'ticket' && ticketTotal > 0 && ( 共 {ticketTotal} 条水票记录 )} {activeTab === 'order' && orderTotal > 0 && ( 共 {orderTotal} 条送水订单 )} {/* 列表 */} {activeTab === 'ticket' && ticketList.length === 0 && !ticketLoading ? ( ) : activeTab === 'order' && orderList.length === 0 && !orderLoading ? ( ) : activeTab === 'ticket' ? ( 加载中... } loadMoreText={ {ticketList.length === 0 ? '暂无数据' : '没有更多了'} } > {ticketList.map((item, index) => ( showTicketDetail(item)} > 票号:{item.id} 套票名称:{item.templateName} {item.orderNo && ( 订单编号:{item.orderNo} )} {item.createTime && ( 下单时间:{dayjs(item.createTime).format('YYYY年MM月DD日 HH:mm:ss')} )} {/**/} {/* {item.status === 1 ? '冻结' : '正常'}*/} {/**/} {item.availableQty ?? 0} 可用水票 {item.usedQty ?? 0} 已用水票 { e.stopPropagation(); void goReleasePlanDetail(item); }} > {item.frozenQty ?? 0} 剩余赠票 ))} ) : ( 加载中... } loadMoreText={ {orderList.length === 0 ? '暂无数据' : '没有更多了'} } > {orderList.map((item, index) => ( 票号:{item.userTicketId ?? '-'} 送水数量:{item.totalNum ?? 0} 配送时间:{formatDate(item.sendTime)} {(() => { const meta = getTicketOrderStatusMeta(item); return {meta.text}; })()} 订单号:{item.id ?? '-'} 收货地址:{item.address || '-'} 下单时间:{formatDateTime(item.createTime)} {/*{item.storeName ? (*/} {/* */} {/* 门店:{item.storeName}*/} {/* */} {/*) : null}*/} {item.sendStartTime ? ( 开始配送:{formatDateTime(item.sendStartTime)} ) : null} {item.sendEndTime ? ( 送达时间:{formatDateTime(item.sendEndTime)} ) : null} {item.receiveConfirmTime ? ( 确认收货:{formatDateTime(item.receiveConfirmTime)} ) : null} {item.sendEndImg ? ( ) : null} {canUserConfirmReceive(item) ? ( ) : null} {item.id ? ( ) : null} ))} )} {/* 核销二维码 */} setQrVisible(false)} style={{ width: '90%' }} > 水票核销码 {qrTicket && ( 票号 {qrTicket.id} 可用次数 {qrTicket.availableQty ?? 0} )} {qrImageUrl ? ( 请向配送员出示此二维码 ) : ( 生成中... )} ); }; export default UserTicketList;