Files
template-10584/src/user/ticket/index.tsx
赵忠林 4a45bc5242 feat(ticket): 添加支付后自动刷新水票列表功能
- 在订单确认页面跳转到水票列表时添加时间戳参数
- 在水票列表页面添加支付后自动刷新逻辑
- 使用 ref 防止重复执行自动刷新
- 添加缓存键避免重复处理同一支付请求
- 支付后自动重试刷新水票列表三次,确保数据同步
- 实现了防抖机制防止并发刷新操作
2026-03-11 18:51:23 +08:00

929 lines
34 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 { useRef, 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 PAY_REFRESH_HANDLED_KEY = 'user_ticket_from_pay_at_handled';
const UserTicketList = () => {
const [ticketList, setTicketList] = useState<GltUserTicket[]>([]);
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<GltTicketOrder[]>([]);
const [orderLoading, setOrderLoading] = useState(false);
const [orderHasMore, setOrderHasMore] = useState(true);
const [orderPage, setOrderPage] = useState(1);
const [orderTotal, setOrderTotal] = useState(0);
const [orderCancelLoadingById, setOrderCancelLoadingById] = useState<Record<number, boolean>>({});
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<GltUserTicket | null>(null);
const [qrImageUrl, setQrImageUrl] = useState('');
const payAutoRefreshRunningRef = useRef(false);
const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms));
const parsePositiveNumberParam = (v: unknown) => {
const n = Number(v);
return Number.isFinite(n) && n > 0 ? n : undefined;
};
const getFromPayAtParam = () => {
const params = Taro.getCurrentInstance().router?.params;
return parsePositiveNumberParam((params as any)?.fromPayAt);
};
const shouldAutoRefreshAfterPay = (fromPayAt?: number) => {
if (!fromPayAt) return false;
const handled = parsePositiveNumberParam(Taro.getStorageSync(PAY_REFRESH_HANDLED_KEY)) || 0;
return handled !== fromPayAt;
};
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<GltUserTicket> | 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<GltUserTicket> | 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: 'none' });
} 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(() => {
void (async () => {
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') {
await reloadTickets(true);
const fromPayAt = getFromPayAtParam();
if (shouldAutoRefreshAfterPay(fromPayAt) && !payAutoRefreshRunningRef.current) {
payAutoRefreshRunningRef.current = true;
try {
Taro.setStorageSync(PAY_REFRESH_HANDLED_KEY, fromPayAt);
// 支付后水票可能异步入账:自动再刷新几次,避免用户手动下拉刷新。
for (const delayMs of [800, 1500, 2500]) {
await sleep(delayMs);
await reloadTickets(true);
}
} finally {
payAutoRefreshRunningRef.current = false;
}
}
} else {
await reloadOrders(true);
}
})();
})
return (
<ConfigProvider>
{/* 搜索栏 */}
<View className="bg-white px-4 py-3 hidden">
<SearchBar
placeholder="搜索水票"
value={searchValue}
onChange={setSearchValue}
onSearch={handleSearch}
/>
</View>
{/* Tab切换 */}
<View className="bg-white">
<Tabs value={activeTab} onChange={handleTabChange}>
<TabPane title="我的水票" value="ticket"></TabPane>
<TabPane title="送水订单" value="order"></TabPane>
</Tabs>
</View>
{activeTab === 'ticket' && ticketTotal > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50 hidden">
{ticketTotal}
</View>
)}
{activeTab === 'order' && orderTotal > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50 hidden">
{orderTotal}
</View>
)}
{/* 列表 */}
<PullToRefresh onRefresh={handleRefresh} headHeight={60}>
<View style={{ height: 'calc(100vh)', overflowY: 'auto' }} id="ticket-scroll">
{activeTab === 'ticket' && ticketList.length === 0 && !ticketLoading ? (
<View
className="flex flex-col justify-center items-center"
style={{ height: 'calc(100vh - 160px)' }}
>
<Empty
description="暂无水票"
style={{ backgroundColor: 'transparent' }}
/>
</View>
) : activeTab === 'order' && orderList.length === 0 && !orderLoading ? (
<View
className="flex flex-col justify-center items-center"
style={{ height: 'calc(100vh - 160px)' }}
>
<Empty description="暂无送水订单" style={{ backgroundColor: 'transparent' }} />
</View>
) : activeTab === 'ticket' ? (
<InfiniteLoading
target="ticket-scroll"
hasMore={ticketHasMore}
onLoadMore={loadMoreTickets}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{ticketList.length === 0 ? '暂无数据' : '没有更多了'}
</View>
}
>
<View className="px-4 py-3">
{ticketList.map((item, index) => (
<View
key={String(item.id ?? `${item.templateId ?? 't'}-${index}`)}
className="bg-white rounded-xl p-4 mb-3"
onClick={() => showTicketDetail(item)}
>
<View className="flex items-start justify-between">
<View className="flex-1 pr-3">
<Text className="text-base font-semibold text-gray-900">
{item.id}
</Text>
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.templateName}</Text>
</View>
{item.orderNo && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text>
</View>
)}
{item.createTime && (
<View className="mt-1">
<Text className="text-xs text-gray-400">{dayjs(item.createTime).format('YYYY年MM月DD日 HH:mm:ss')}</Text>
</View>
)}
</View>
<View className="flex flex-col items-end gap-2">
<Button
size="small"
type="primary"
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => {
e.stopPropagation();
void goSendWater(item);
}}
>
</Button>
{/*<Tag type={item.status === 1 ? 'danger' : 'success'}>*/}
{/* {item.status === 1 ? '冻结' : '正常'}*/}
{/*</Tag>*/}
<Button
size="small"
type="primary"
style={{ display: 'none'}}
disabled={(item.availableQty ?? 0) <= 0 || item.status === 1}
onClick={(e) => {
// Avoid triggering card click.
e.stopPropagation();
openTicketQr(item);
}}
>
</Button>
</View>
</View>
<View className="mt-3 flex justify-between">
<View className="flex flex-col items-center">
<Text className="text-lg font-bold text-blue-600 text-center">{item.availableQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View className="flex flex-col items-center">
<Text className="text-lg text-gray-900 text-center">{item.usedQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
<View
className="flex flex-col items-center"
hoverClass="opacity-70"
onClick={(e) => {
e.stopPropagation();
void goReleasePlanDetail(item);
}}
>
<Text className="text-lg text-gray-900 text-center">{item.frozenQty ?? 0}</Text>
<Text className="text-xs text-gray-500"></Text>
</View>
</View>
</View>
))}
</View>
</InfiniteLoading>
) : (
<InfiniteLoading
target="ticket-scroll"
hasMore={orderHasMore}
onLoadMore={loadMoreOrders}
loadingText={
<View className="flex justify-center items-center py-4">
<Loading />
<View className="ml-2">...</View>
</View>
}
loadMoreText={
<View className="text-center py-4 text-gray-500">
{orderList.length === 0 ? '暂无数据' : '没有更多了'}
</View>
}
>
<View className="px-4 py-3">
{orderList.map((item, index) => (
<View
key={String(item.id ?? `order-${index}`)}
className="bg-white rounded-xl p-4 mb-3"
>
<View className="flex items-start justify-between">
<View className="flex-1 pr-3">
<Text className="text-base font-semibold text-gray-900">
{item.userTicketId ?? '-'}
</Text>
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.totalNum ?? 0}</Text>
</View>
<View className="mt-1">
<Text className="text-xs text-gray-500">{formatDate(item.sendTime)}</Text>
</View>
</View>
{(() => {
const meta = getTicketOrderStatusMeta(item);
return <Tag type={meta.type}>{meta.text}</Tag>;
})()}
</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>
<View className="mt-1">
<Text className="text-xs text-gray-500">{formatDateTime(item.createTime)}</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}
{item.id ? (
<View className="mt-3 flex justify-end gap-2">
<Button
size="small"
disabled={
!isTicketOrderPendingDelivery(item) ||
!!orderCancelLoadingById[item.id as number]
}
onClick={(e) => {
e.stopPropagation();
void handleOrderModify(item);
}}
>
</Button>
<Button
size="small"
type="danger"
disabled={
!isTicketOrderPendingDelivery(item) ||
!!orderCancelLoadingById[item.id as number]
}
onClick={(e) => {
e.stopPropagation();
void handleOrderCancel(item);
}}
>
</Button>
</View>
) : null}
</View>
))}
</View>
</InfiniteLoading>
)}
</View>
</PullToRefresh>
{/* 核销二维码 */}
<Popup
visible={qrVisible}
position="center"
closeable
onClose={() => setQrVisible(false)}
style={{ width: '90%' }}
>
<View className="p-6">
<View className="mb-4">
<Text className="text-lg font-bold"></Text>
</View>
{qrTicket && (
<View className="bg-gray-50 rounded-lg p-3 mb-4">
<View className="flex justify-between mb-2">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-sm text-gray-900">{qrTicket.id}</Text>
</View>
<View className="flex justify-between">
<Text className="text-sm text-gray-600"></Text>
<Text className="text-sm text-gray-900">{qrTicket.availableQty ?? 0}</Text>
</View>
</View>
)}
<View className="text-center mb-4">
<View className="p-4 bg-white border border-gray-200 rounded-lg">
{qrImageUrl ? (
<View className="flex flex-col justify-center items-center">
<Image
src={qrImageUrl}
mode="aspectFit"
style={{ width: '200px', height: '200px' }}
/>
<Text className="text-sm text-gray-400 mt-2 px-2">
</Text>
</View>
) : (
<View
className="bg-gray-100 rounded flex items-center justify-center mx-auto"
style={{ width: '200px', height: '200px' }}
>
<Text className="text-gray-500 text-sm">...</Text>
</View>
)}
</View>
</View>
<Button
type="primary"
block
onClick={() => {
if (!qrTicket) return;
const content = buildTicketQrContent(qrTicket);
setQrImageUrl(buildEncryptedQrImageUrl('ticket', content));
}}
>
</Button>
</View>
</Popup>
</ConfigProvider>
);
};
export default UserTicketList;