forked from gxwebsoft/mp-10550
- 移除30天地址修改冷却限制功能 - 删除相关的历史订单查询和地址锁定逻辑 - 将订单状态检查逻辑简化为统一的待配送检查函数 - 在编辑模式下验证订单是否可修改 - 调整按钮文本从"去购买水票"改为"确定下单" - 优化订单操作按钮的位置和显示逻辑 - 移除地址修改限制相关的UI提示和状态管理
895 lines
33 KiB
TypeScript
895 lines
33 KiB
TypeScript
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<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 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: '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 (
|
||
<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;
|