Files
template-10584/src/user/ticket/index.tsx
赵忠林 f96918bf86 feat(ticket): 添加水票功能支持
- 在订单模型中增加formId字段用于标识商品ID
- 更新统一扫码组件以支持水票和礼品卡核销
- 实现水票列表页面,包含我的水票和核销记录两个标签页
- 添加水票核销二维码生成功能
- 支持水票的分页加载和搜索功能
- 实现水票核销记录的展示
- 添加水票状态变更历史追踪
- 更新订单状态判断逻辑以支持特定商品完成状态
- 扩展扫码验证功能以处理水票业务类型
2026-02-04 11:00:54 +08:00

546 lines
20 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 { 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 { pageGltUserTicket } from '@/api/glt/gltUserTicket';
import type { GltUserTicket } from '@/api/glt/gltUserTicket/model';
import { pageGltUserTicketLog } from '@/api/glt/gltUserTicketLog';
import type { GltUserTicketLog } from '@/api/glt/gltUserTicketLog/model';
import { BaseUrl } from '@/config/app';
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 [logList, setLogList] = useState<GltUserTicketLog[]>([]);
const [logLoading, setLogLoading] = useState(false);
const [logHasMore, setLogHasMore] = useState(true);
const [logPage, setLogPage] = useState(1);
const [logTotal, setLogTotal] = useState(0);
const [activeTab, setActiveTab] = useState<'ticket' | 'log'>('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 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 reloadLogs = async (isRefresh = true, keywords?: string) => {
if (logLoading) return;
if (isRefresh) {
setLogPage(1);
setLogList([]);
setLogHasMore(true);
}
setLogLoading(true);
try {
const currentPage = isRefresh ? 1 : logPage;
const res = await pageGltUserTicketLog({
page: currentPage,
limit: PAGE_SIZE,
keywords: (keywords ?? searchValue) || undefined
});
const resList = res?.list || [];
const nextList = isRefresh ? resList : [...logList, ...resList];
setLogList(nextList);
const count = typeof res?.count === 'number' ? res.count : nextList.length;
setLogTotal(count);
setLogHasMore(nextList.length < count);
if (resList.length > 0) {
setLogPage(currentPage + 1);
} else {
setLogHasMore(false);
}
} catch (error) {
console.error('获取核销记录失败:', error);
Taro.showToast({ title: '获取核销记录失败', icon: 'error' });
setLogHasMore(false);
} finally {
setLogLoading(false);
}
};
const handleSearch = (value: string) => {
setSearchValue(value);
if (activeTab === 'ticket') {
reloadTickets(true, value);
} else {
reloadLogs(true, value);
}
};
const handleRefresh = async () => {
if (activeTab === 'ticket') {
await reloadTickets(true);
} else {
await reloadLogs(true);
}
};
const handleTabChange = (value: string | number) => {
const tab = String(value) as 'ticket' | 'log';
setActiveTab(tab);
if (tab === 'ticket') {
setTicketPage(1);
setTicketList([]);
setTicketHasMore(true);
reloadTickets(true);
} else {
setLogPage(1);
setLogList([]);
setLogHasMore(true);
reloadLogs(true);
}
};
const loadMoreTickets = async () => {
if (!ticketLoading && ticketHasMore) {
await reloadTickets(false);
}
};
const loadMoreLogs = async () => {
if (!logLoading && logHasMore) {
await reloadLogs(false);
}
};
const formatSigned = (n?: number) => {
const val = Number(n || 0);
if (val === 0) return '0';
return val > 0 ? `+${val}` : `${val}`;
};
useDidShow(() => {
if (activeTab === 'ticket') {
reloadTickets(true).then();
} else {
reloadLogs(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="log"></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 === 'log' && logTotal > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50 hidden">
{logTotal}
</View>
)}
{/* 列表 */}
<PullToRefresh onRefresh={handleRefresh} headHeight={60}>
<View style={{ height: 'calc(100vh - 200px)', overflowY: 'auto' }} id="ticket-scroll">
{activeTab === 'ticket' && ticketList.length === 0 && !ticketLoading ? (
<View
className="flex flex-col justify-center items-center"
style={{ height: 'calc(100vh - 260px)' }}
>
<Empty
description="暂无水票"
style={{ backgroundColor: 'transparent' }}
/>
</View>
) : activeTab === 'log' && logList.length === 0 && !logLoading ? (
<View
className="flex flex-col justify-center items-center"
style={{ height: 'calc(100vh - 260px)' }}
>
<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.templateName || '水票'}
</Text>
{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">{item.createTime}</Text>
</View>
)}
</View>
<View className="flex flex-col items-end gap-2">
<Tag type={item.status === 1 ? 'danger' : 'success'}>
{item.status === 1 ? '冻结' : '正常'}
</Tag>
<Button
size="small"
type="primary"
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">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-lg font-bold text-blue-600">{item.availableQty ?? 0}</Text>
</View>
<View className="flex flex-col items-center">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.totalQty ?? 0}</Text>
</View>
<View className="flex flex-col items-end">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.usedQty ?? 0}</Text>
</View>
</View>
<View className="mt-2 flex justify-between">
<View className="flex flex-col">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.frozenQty ?? 0}</Text>
</View>
<View className="flex flex-col items-end">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.releasedQty ?? 0}</Text>
</View>
</View>
</View>
))}
</View>
</InfiniteLoading>
) : (
<InfiniteLoading
target="ticket-scroll"
hasMore={logHasMore}
onLoadMore={loadMoreLogs}
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">
{logList.length === 0 ? '暂无数据' : '没有更多了'}
</View>
}
>
<View className="px-4 py-3">
{logList.map((item, index) => (
<View
key={String(item.id ?? `log-${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">
</Text>
{item.createTime && (
<View className="mt-1">
<Text className="text-xs text-gray-400">{item.createTime}</Text>
</View>
)}
{item.orderNo && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text>
</View>
)}
{item.comments && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{item.comments}</Text>
</View>
)}
</View>
<Tag type="warning"></Tag>
</View>
<View className="mt-3 flex justify-between">
<View className="flex flex-col">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{formatSigned(item.changeAvailable)}</Text>
</View>
<View className="flex flex-col items-center">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{formatSigned(item.changeUsed)}</Text>
</View>
<View className="flex flex-col items-end">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{formatSigned(item.changeFrozen)}</Text>
</View>
</View>
<View className="mt-2 flex justify-between">
<View className="flex flex-col">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.availableAfter ?? '-'}</Text>
</View>
<View className="flex flex-col items-center">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.usedAfter ?? '-'}</Text>
</View>
<View className="flex flex-col items-end">
<Text className="text-xs text-gray-500"></Text>
<Text className="text-sm text-gray-900">{item.frozenAfter ?? '-'}</Text>
</View>
</View>
</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.templateName || '水票'}</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;