feat(ticket): 添加水票功能支持

- 在订单模型中增加formId字段用于标识商品ID
- 更新统一扫码组件以支持水票和礼品卡核销
- 实现水票列表页面,包含我的水票和核销记录两个标签页
- 添加水票核销二维码生成功能
- 支持水票的分页加载和搜索功能
- 实现水票核销记录的展示
- 添加水票状态变更历史追踪
- 更新订单状态判断逻辑以支持特定商品完成状态
- 扩展扫码验证功能以处理水票业务类型
This commit is contained in:
2026-02-04 11:00:54 +08:00
parent a3c952d092
commit f96918bf86
7 changed files with 484 additions and 90 deletions

View File

@@ -93,6 +93,8 @@ export interface ShopOrder {
totalNum?: number; totalNum?: number;
// 教练id // 教练id
coachId?: number; coachId?: number;
// 商品ID
formId?: number;
// 支付的用户id // 支付的用户id
payUserId?: number; payUserId?: number;
// 0余额支付, 1微信支付102微信Native2会员卡支付3支付宝4现金5POS机6VIP月卡7VIP年卡8VIP次卡9IC月卡10IC年卡11IC次卡12免费13VIP充值卡14IC充值卡15积分支付16VIP季卡17IC季卡18代付 // 0余额支付, 1微信支付102微信Native2会员卡支付3支付宝4现金5POS机6VIP月卡7VIP年卡8VIP次卡9IC月卡10IC年卡11IC次卡12免费13VIP充值卡14IC充值卡15积分支付16VIP季卡17IC季卡18代付

View File

@@ -68,7 +68,7 @@ const UnifiedQRButton: React.FC<UnifiedQRButtonProps> = ({
setTimeout(() => { setTimeout(() => {
Taro.showModal({ Taro.showModal({
title: '核销成功', title: '核销成功',
content: '是否继续扫码核销其他礼品卡?', content: '是否继续扫码核销其他水票/礼品卡?',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
handleClick(); // 递归调用继续扫码 handleClick(); // 递归调用继续扫码

View File

@@ -5,6 +5,7 @@ import {
parseQRContent parseQRContent
} from '@/api/passport/qr-login'; } from '@/api/passport/qr-login';
import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift"; import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift";
import { getGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket';
import { useUser } from "@/hooks/useUser"; import { useUser } from "@/hooks/useUser";
import { isValidJSON } from "@/utils/jsonUtils"; import { isValidJSON } from "@/utils/jsonUtils";
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -29,6 +30,15 @@ export enum ScanType {
UNKNOWN = 'unknown' // 未知类型 UNKNOWN = 'unknown' // 未知类型
} }
type VerificationBusinessType = 'gift' | 'ticket';
interface TicketVerificationPayload {
userTicketId: number;
qty?: number;
userId?: number;
t?: number;
}
/** /**
* 统一扫码结果 * 统一扫码结果
*/ */
@@ -73,7 +83,11 @@ export function useUnifiedQRScan() {
// 1. 检查是否为JSON格式核销二维码 // 1. 检查是否为JSON格式核销二维码
if (isValidJSON(scanResult)) { if (isValidJSON(scanResult)) {
const json = JSON.parse(scanResult); const json = JSON.parse(scanResult);
if (json.businessType === 'gift' && json.token && json.data) { if ((json.businessType === 'gift' || json.businessType === 'ticket') && json.token && json.data) {
return ScanType.VERIFICATION;
}
// Allow plaintext (non-encrypted) ticket verification payload for debugging/internal use.
if (json.userTicketId) {
return ScanType.VERIFICATION; return ScanType.VERIFICATION;
} }
} }
@@ -130,35 +144,79 @@ export function useUnifiedQRScan() {
throw new Error('您没有核销权限'); throw new Error('您没有核销权限');
} }
let code = ''; let businessType: VerificationBusinessType = 'gift';
let decryptedOrRaw = '';
// 判断是否为加密的JSON格式 // 判断是否为加密的JSON格式
if (isValidJSON(scanResult)) { if (isValidJSON(scanResult)) {
const json = JSON.parse(scanResult); const json = JSON.parse(scanResult);
if (json.businessType === 'gift' && json.token && json.data) { if ((json.businessType === 'gift' || json.businessType === 'ticket') && json.token && json.data) {
// 解密获取核销码 businessType = json.businessType;
// 解密获取核销内容
const decryptedData = await decryptQrData({ const decryptedData = await decryptQrData({
token: json.token, token: json.token,
encryptedData: json.data encryptedData: json.data
}); });
if (decryptedData) { if (decryptedData) {
code = decryptedData.toString(); decryptedOrRaw = decryptedData.toString();
} else { } else {
throw new Error('解密失败'); throw new Error('解密失败');
} }
} else if (json.userTicketId) {
businessType = 'ticket';
decryptedOrRaw = scanResult.trim();
} }
} else { } else {
// 直接使用扫码结果作为核销 // 直接使用扫码结果作为核销内容
code = scanResult.trim(); decryptedOrRaw = scanResult.trim();
} }
if (!code) { if (!decryptedOrRaw) {
throw new Error('无法获取有效的核销码'); throw new Error('无法获取有效的核销码');
} }
// 验证核销码 if (businessType === 'ticket') {
const gift = await getShopGiftByCode(code); if (!isValidJSON(decryptedOrRaw)) {
throw new Error('水票核销信息格式错误');
}
const payload = JSON.parse(decryptedOrRaw) as TicketVerificationPayload;
const userTicketId = Number(payload.userTicketId);
const qty = Math.max(1, Number(payload.qty || 1));
if (!Number.isFinite(userTicketId) || userTicketId <= 0) {
throw new Error('水票核销信息无效');
}
const ticket = await getGltUserTicket(userTicketId);
if (!ticket) throw new Error('水票不存在');
if (ticket.status === 1) throw new Error('该水票已冻结');
const available = Number(ticket.availableQty || 0);
const used = Number(ticket.usedQty || 0);
if (available < qty) throw new Error('水票可用次数不足');
await updateGltUserTicket({
...ticket,
availableQty: available - qty,
usedQty: used + qty
});
return {
type: ScanType.VERIFICATION,
data: {
businessType: 'ticket',
ticket: {
...ticket,
availableQty: available - qty,
usedQty: used + qty
},
qty
},
message: `核销成功(已使用${qty}次)`
};
}
// 验证礼品卡核销码
const gift = await getShopGiftByCode(decryptedOrRaw);
if (!gift) { if (!gift) {
throw new Error('核销码无效'); throw new Error('核销码无效');
@@ -187,7 +245,7 @@ export function useUnifiedQRScan() {
return { return {
type: ScanType.VERIFICATION, type: ScanType.VERIFICATION,
data: gift, data: { businessType: 'gift', gift },
message: '核销成功' message: '核销成功'
}; };
}, [isAdmin]); }, [isAdmin]);

View File

@@ -233,10 +233,27 @@ const UserCard = forwardRef<any, any>((_, ref) => {
console.log('统一扫码成功:', result); console.log('统一扫码成功:', result);
// 根据扫码类型给出不同的提示 // 根据扫码类型给出不同的提示
if (result.type === 'verification') { if (result.type === 'verification') {
// 核销成功,可以显示更多信息或跳转到详情页 const businessType = result?.data?.businessType;
if (businessType === 'gift' && result?.data?.gift) {
const gift = result.data.gift;
Taro.showModal({ Taro.showModal({
title: '核销成功', title: '核销成功',
content: `已成功核销的品类${result.data.goodsName || '水票'},面值¥${result.data.faceValue}` content: `已成功核销:${gift.goodsName || gift.name || '礼品'},面值¥${gift.faceValue}`
});
return;
}
if (businessType === 'ticket' && result?.data?.ticket) {
const ticket = result.data.ticket;
const qty = result.data.qty || 1;
Taro.showModal({
title: '核销成功',
content: `已成功核销:${ticket.templateName || '水票'},本次使用${qty}次,剩余可用${ticket.availableQty ?? 0}`
});
return;
}
Taro.showModal({
title: '核销成功',
content: '已成功核销'
}); });
} }
}} }}

View File

@@ -43,7 +43,7 @@ const UnifiedQRPage: React.FC = () => {
setTimeout(() => { setTimeout(() => {
Taro.showModal({ Taro.showModal({
title: '核销成功', title: '核销成功',
content: '是否继续扫码核销其他礼品卡?', content: '是否继续扫码核销其他水票/礼品卡?',
success: (res) => { success: (res) => {
if (res.confirm) { if (res.confirm) {
handleStartScan(); handleStartScan();
@@ -179,7 +179,7 @@ const UnifiedQRPage: React.FC = () => {
</Text> </Text>
<Text className="text-gray-600 mb-6 block"> <Text className="text-gray-600 mb-6 block">
{scanType === ScanType.LOGIN ? '正在确认登录' : {scanType === ScanType.LOGIN ? '正在确认登录' :
scanType === ScanType.VERIFICATION ? '正在核销礼品卡' : '正在处理'} scanType === ScanType.VERIFICATION ? '正在核销' : '正在处理'}
</Text> </Text>
</> </>
)} )}
@@ -192,12 +192,29 @@ const UnifiedQRPage: React.FC = () => {
</Text> </Text>
{result.type === ScanType.VERIFICATION && result.data && ( {result.type === ScanType.VERIFICATION && result.data && (
<View className="bg-green-50 rounded-lg p-3 mb-4"> <View className="bg-green-50 rounded-lg p-3 mb-4">
{result.data.businessType === 'gift' && result.data.gift && (
<>
<Text className="text-sm text-green-800 block"> <Text className="text-sm text-green-800 block">
{result.data.goodsName || '未知商品'} {result.data.gift.goodsName || result.data.gift.name || '未知'}
</Text> </Text>
<Text className="text-sm text-green-800 block"> <Text className="text-sm text-green-800 block">
¥{result.data.faceValue} ¥{result.data.gift.faceValue}
</Text> </Text>
</>
)}
{result.data.businessType === 'ticket' && result.data.ticket && (
<>
<Text className="text-sm text-green-800 block">
{result.data.ticket.templateName || '水票'}
</Text>
<Text className="text-sm text-green-800 block">
{result.data.qty || 1}
</Text>
<Text className="text-sm text-green-800 block">
{result.data.ticket.availableQty ?? 0}
</Text>
</>
)}
</View> </View>
)} )}
<View className="mt-2"> <View className="mt-2">
@@ -278,9 +295,14 @@ const UnifiedQRPage: React.FC = () => {
<Text className="text-sm text-gray-800"> <Text className="text-sm text-gray-800">
{record.success ? record.message : record.error} {record.success ? record.message : record.error}
</Text> </Text>
{record.success && record.type === ScanType.VERIFICATION && record.data && ( {record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'gift' && record.data?.gift && (
<Text className="text-xs text-gray-500"> <Text className="text-xs text-gray-500">
{record.data.goodsName} - ¥{record.data.faceValue} {record.data.gift.goodsName || record.data.gift.name} - ¥{record.data.gift.faceValue}
</Text>
)}
{record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'ticket' && record.data?.ticket && (
<Text className="text-xs text-gray-500">
{record.data.ticket.templateName || '水票'} - {record.data.qty || 1}
</Text> </Text>
)} )}
</View> </View>
@@ -304,7 +326,7 @@ const UnifiedQRPage: React.FC = () => {
</Text> </Text>
<Text className="text-xs text-blue-700 block mb-1"> <Text className="text-xs text-blue-700 block mb-1">
/
</Text> </Text>
<Text className="text-xs text-blue-700 block"> <Text className="text-xs text-blue-700 block">

View File

@@ -131,6 +131,7 @@ function OrderList(props: OrderListProps) {
// 已付款后检查发货状态 // 已付款后检查发货状态
if (order.deliveryStatus === 10) return '待发货'; if (order.deliveryStatus === 10) return '待发货';
if (order.formId === 10074) return '已完成';
if (order.deliveryStatus === 20) { if (order.deliveryStatus === 20) {
// 若订单没有配送员,沿用原“待收货”语义 // 若订单没有配送员,沿用原“待收货”语义
if (!order.riderId) return '待收货'; if (!order.riderId) return '待收货';

View File

@@ -1,31 +1,46 @@
import { useState } from 'react'; import { useState } from 'react';
import Taro, { useDidShow } from '@tarojs/taro'; import Taro, { useDidShow } from '@tarojs/taro';
import { import {
Button,
ConfigProvider, ConfigProvider,
Empty, Empty,
InfiniteLoading, InfiniteLoading,
Loading, Loading,
Popup,
PullToRefresh, PullToRefresh,
SearchBar, SearchBar,
Tabs, Tabs,
TabPane, TabPane,
Tag Tag
} from '@nutui/nutui-react-taro'; } from '@nutui/nutui-react-taro';
import { View, Text } 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 { pageGltUserTicketLog } from '@/api/glt/gltUserTicketLog';
import type { GltUserTicketLog } from '@/api/glt/gltUserTicketLog/model';
import { BaseUrl } from '@/config/app';
const PAGE_SIZE = 10; const PAGE_SIZE = 10;
const UserTicketList = () => { const UserTicketList = () => {
const [list, setList] = useState<GltUserTicket[]>([]); const [ticketList, setTicketList] = useState<GltUserTicket[]>([]);
const [loading, setLoading] = useState(false); const [ticketLoading, setTicketLoading] = useState(false);
const [hasMore, setHasMore] = useState(true); const [ticketHasMore, setTicketHasMore] = useState(true);
const [searchValue, setSearchValue] = useState(''); const [searchValue, setSearchValue] = useState('');
const [page, setPage] = useState(1); const [ticketPage, setTicketPage] = useState(1);
const [total, setTotal] = useState(0); const [ticketTotal, setTicketTotal] = useState(0);
// 0-正常 1-冻结
const [activeTab, setActiveTab] = useState<string>('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 getUserId = () => {
const raw = Taro.getStorageSync('UserId'); const raw = Taro.getStorageSync('UserId');
@@ -33,6 +48,48 @@ const UserTicketList = () => {
return Number.isFinite(id) && id > 0 ? id : undefined; 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 showTicketDetail = (ticket: GltUserTicket) => {
const lines: string[] = []; const lines: string[] = [];
if (ticket.templateName) lines.push(`水票:${ticket.templateName}`); if (ticket.templateName) lines.push(`水票:${ticket.templateName}`);
@@ -49,83 +106,149 @@ const UserTicketList = () => {
}); });
}; };
const reloadWithTab = async (tab: string, isRefresh = true, keywords?: string) => { const reloadTickets = async (isRefresh = true, keywords?: string) => {
if (loading) return; if (ticketLoading) return;
const userId = getUserId(); const userId = getUserId();
if (!userId) { if (!userId) {
setList([]); setTicketList([]);
setTotal(0); setTicketTotal(0);
setHasMore(false); setTicketHasMore(false);
return; return;
} }
if (isRefresh) { if (isRefresh) {
setPage(1); setTicketPage(1);
setList([]); setTicketList([]);
setHasMore(true); setTicketHasMore(true);
} }
setLoading(true); setTicketLoading(true);
try { try {
const currentPage = isRefresh ? 1 : page; const currentPage = isRefresh ? 1 : ticketPage;
const status = Number(tab); // 0正常1冻结
const res = await pageGltUserTicket({ const res = await pageGltUserTicket({
page: currentPage, page: currentPage,
limit: PAGE_SIZE, limit: PAGE_SIZE,
userId, userId,
status,
keywords: (keywords ?? searchValue) || undefined keywords: (keywords ?? searchValue) || undefined
}); });
const nextList = isRefresh ? res.list : [...list, ...res.list]; const nextList = isRefresh ? res.list : [...ticketList, ...res.list];
setList(nextList); setTicketList(nextList);
const count = typeof res.count === 'number' ? res.count : nextList.length; const count = typeof res.count === 'number' ? res.count : nextList.length;
setTotal(count); setTicketTotal(count);
setHasMore(nextList.length < count); setTicketHasMore(nextList.length < count);
if (res.list.length > 0) { if (res.list.length > 0) {
setPage(currentPage + 1); setTicketPage(currentPage + 1);
} else { } else {
setHasMore(false); setTicketHasMore(false);
} }
} catch (error) { } catch (error) {
console.error('获取水票列表失败:', error); console.error('获取水票列表失败:', error);
Taro.showToast({ title: '获取水票失败', icon: 'error' }); Taro.showToast({ title: '获取水票失败', icon: 'error' });
setHasMore(false); setTicketHasMore(false);
} finally { } finally {
setLoading(false); setTicketLoading(false);
} }
}; };
const reload = async (isRefresh = true) => reloadWithTab(activeTab, isRefresh); 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) => { const handleSearch = (value: string) => {
setSearchValue(value); setSearchValue(value);
reloadWithTab(activeTab, true, value); if (activeTab === 'ticket') {
}; reloadTickets(true, value);
} else {
const handleRefresh = async () => { reloadLogs(true, value);
await reload(true);
};
const handleTabChange = (value: string | number) => {
const tab = String(value);
setActiveTab(tab);
setPage(1);
setList([]);
setHasMore(true);
reloadWithTab(tab, true);
};
const loadMore = async () => {
if (!loading && hasMore) {
await reload(false);
} }
}; };
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(() => { useDidShow(() => {
reloadWithTab(activeTab, true).then(); if (activeTab === 'ticket') {
reloadTickets(true).then();
} else {
reloadLogs(true).then();
}
}); });
return ( return (
@@ -143,35 +266,48 @@ const UserTicketList = () => {
{/* Tab切换 */} {/* Tab切换 */}
<View className="bg-white"> <View className="bg-white">
<Tabs value={activeTab} onChange={handleTabChange}> <Tabs value={activeTab} onChange={handleTabChange}>
<TabPane title="正常" value="0"></TabPane> <TabPane title="我的水票" value="ticket"></TabPane>
<TabPane title="冻结" value="1"></TabPane> <TabPane title="核销记录" value="log"></TabPane>
</Tabs> </Tabs>
</View> </View>
{total > 0 && ( {activeTab === 'ticket' && ticketTotal > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50 hidden"> <View className="px-4 py-2 text-sm text-gray-500 bg-gray-50 hidden">
{total} {ticketTotal}
</View>
)}
{activeTab === 'log' && logTotal > 0 && (
<View className="px-4 py-2 text-sm text-gray-500 bg-gray-50 hidden">
{logTotal}
</View> </View>
)} )}
{/* 列表 */} {/* 列表 */}
<PullToRefresh onRefresh={handleRefresh} headHeight={60}> <PullToRefresh onRefresh={handleRefresh} headHeight={60}>
<View style={{ height: 'calc(100vh - 200px)', overflowY: 'auto' }} id="ticket-scroll"> <View style={{ height: 'calc(100vh - 200px)', overflowY: 'auto' }} id="ticket-scroll">
{list.length === 0 && !loading ? ( {activeTab === 'ticket' && ticketList.length === 0 && !ticketLoading ? (
<View <View
className="flex flex-col justify-center items-center" className="flex flex-col justify-center items-center"
style={{ height: 'calc(100vh - 260px)' }} style={{ height: 'calc(100vh - 260px)' }}
> >
<Empty <Empty
description={activeTab === '0' ? '暂无正常水票' : '暂无冻结水票'} description="暂无水票"
style={{ backgroundColor: 'transparent' }} style={{ backgroundColor: 'transparent' }}
/> />
</View> </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 <InfiniteLoading
target="ticket-scroll" target="ticket-scroll"
hasMore={hasMore} hasMore={ticketHasMore}
onLoadMore={loadMore} onLoadMore={loadMoreTickets}
loadingText={ loadingText={
<View className="flex justify-center items-center py-4"> <View className="flex justify-center items-center py-4">
<Loading /> <Loading />
@@ -180,12 +316,12 @@ const UserTicketList = () => {
} }
loadMoreText={ loadMoreText={
<View className="text-center py-4 text-gray-500"> <View className="text-center py-4 text-gray-500">
{list.length === 0 ? '暂无数据' : '没有更多了'} {ticketList.length === 0 ? '暂无数据' : '没有更多了'}
</View> </View>
} }
> >
<View className="px-4 py-3"> <View className="px-4 py-3">
{list.map((item, index) => ( {ticketList.map((item, index) => (
<View <View
key={String(item.id ?? `${item.templateId ?? 't'}-${index}`)} key={String(item.id ?? `${item.templateId ?? 't'}-${index}`)}
className="bg-white rounded-xl p-4 mb-3" className="bg-white rounded-xl p-4 mb-3"
@@ -198,18 +334,32 @@ const UserTicketList = () => {
</Text> </Text>
{item.orderNo && ( {item.orderNo && (
<View className="mt-1"> <View className="mt-1">
<Text className="text-xs text-gray-500">{item.orderNo}</Text> <Text className="text-xs text-gray-500">{item.orderNo}</Text>
</View> </View>
)} )}
{item.createTime && ( {item.createTime && (
<View className="mt-1"> <View className="mt-1">
<Text className="text-xs text-gray-400">{item.createTime}</Text> <Text className="text-xs text-gray-400">{item.createTime}</Text>
</View> </View>
)} )}
</View> </View>
<View className="flex flex-col items-end gap-2">
<Tag type={item.status === 1 ? 'danger' : 'success'}> <Tag type={item.status === 1 ? 'danger' : 'success'}>
{item.status === 1 ? '冻结' : '正常'} {item.status === 1 ? '冻结' : '正常'}
</Tag> </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>
<View className="mt-3 flex justify-between"> <View className="mt-3 flex justify-between">
@@ -241,9 +391,153 @@ const UserTicketList = () => {
))} ))}
</View> </View>
</InfiniteLoading> </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> </View>
</PullToRefresh> </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> </ConfigProvider>
); );
}; };