From f96918bf863f897eafa43bb99413eb3826856c59 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Wed, 4 Feb 2026 11:00:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(ticket):=20=E6=B7=BB=E5=8A=A0=E6=B0=B4?= =?UTF-8?q?=E7=A5=A8=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 在订单模型中增加formId字段用于标识商品ID - 更新统一扫码组件以支持水票和礼品卡核销 - 实现水票列表页面,包含我的水票和核销记录两个标签页 - 添加水票核销二维码生成功能 - 支持水票的分页加载和搜索功能 - 实现水票核销记录的展示 - 添加水票状态变更历史追踪 - 更新订单状态判断逻辑以支持特定商品完成状态 - 扩展扫码验证功能以处理水票业务类型 --- src/api/shop/shopOrder/model/index.ts | 2 + src/components/UnifiedQRButton.tsx | 2 +- src/hooks/useUnifiedQRScan.ts | 80 ++++- src/pages/user/components/UserCard.tsx | 21 +- src/passport/unified-qr/index.tsx | 44 ++- src/user/order/components/OrderList.tsx | 1 + src/user/ticket/index.tsx | 424 ++++++++++++++++++++---- 7 files changed, 484 insertions(+), 90 deletions(-) diff --git a/src/api/shop/shopOrder/model/index.ts b/src/api/shop/shopOrder/model/index.ts index b8afaa1..c28e906 100644 --- a/src/api/shop/shopOrder/model/index.ts +++ b/src/api/shop/shopOrder/model/index.ts @@ -93,6 +93,8 @@ export interface ShopOrder { totalNum?: number; // 教练id coachId?: number; + // 商品ID + formId?: number; // 支付的用户id payUserId?: number; // 0余额支付, 1微信支付,102微信Native,2会员卡支付,3支付宝,4现金,5POS机,6VIP月卡,7VIP年卡,8VIP次卡,9IC月卡,10IC年卡,11IC次卡,12免费,13VIP充值卡,14IC充值卡,15积分支付,16VIP季卡,17IC季卡,18代付 diff --git a/src/components/UnifiedQRButton.tsx b/src/components/UnifiedQRButton.tsx index 6c019d5..6519ec9 100644 --- a/src/components/UnifiedQRButton.tsx +++ b/src/components/UnifiedQRButton.tsx @@ -68,7 +68,7 @@ const UnifiedQRButton: React.FC = ({ setTimeout(() => { Taro.showModal({ title: '核销成功', - content: '是否继续扫码核销其他礼品卡?', + content: '是否继续扫码核销其他水票/礼品卡?', success: (res) => { if (res.confirm) { handleClick(); // 递归调用继续扫码 diff --git a/src/hooks/useUnifiedQRScan.ts b/src/hooks/useUnifiedQRScan.ts index 5468046..8e8692b 100644 --- a/src/hooks/useUnifiedQRScan.ts +++ b/src/hooks/useUnifiedQRScan.ts @@ -5,6 +5,7 @@ import { parseQRContent } from '@/api/passport/qr-login'; import { getShopGiftByCode, updateShopGift, decryptQrData } from "@/api/shop/shopGift"; +import { getGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket'; import { useUser } from "@/hooks/useUser"; import { isValidJSON } from "@/utils/jsonUtils"; import dayjs from 'dayjs'; @@ -29,6 +30,15 @@ export enum ScanType { 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格式(核销二维码) if (isValidJSON(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; } } @@ -130,35 +144,79 @@ export function useUnifiedQRScan() { throw new Error('您没有核销权限'); } - let code = ''; + let businessType: VerificationBusinessType = 'gift'; + let decryptedOrRaw = ''; // 判断是否为加密的JSON格式 if (isValidJSON(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({ token: json.token, encryptedData: json.data }); if (decryptedData) { - code = decryptedData.toString(); + decryptedOrRaw = decryptedData.toString(); } else { throw new Error('解密失败'); } + } else if (json.userTicketId) { + businessType = 'ticket'; + decryptedOrRaw = scanResult.trim(); } } else { - // 直接使用扫码结果作为核销码 - code = scanResult.trim(); + // 直接使用扫码结果作为核销内容 + decryptedOrRaw = scanResult.trim(); } - if (!code) { + if (!decryptedOrRaw) { throw new Error('无法获取有效的核销码'); } - // 验证核销码 - const gift = await getShopGiftByCode(code); + if (businessType === 'ticket') { + 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) { throw new Error('核销码无效'); @@ -187,7 +245,7 @@ export function useUnifiedQRScan() { return { type: ScanType.VERIFICATION, - data: gift, + data: { businessType: 'gift', gift }, message: '核销成功' }; }, [isAdmin]); diff --git a/src/pages/user/components/UserCard.tsx b/src/pages/user/components/UserCard.tsx index b7cc16b..dfb6385 100644 --- a/src/pages/user/components/UserCard.tsx +++ b/src/pages/user/components/UserCard.tsx @@ -233,10 +233,27 @@ const UserCard = forwardRef((_, ref) => { console.log('统一扫码成功:', result); // 根据扫码类型给出不同的提示 if (result.type === 'verification') { - // 核销成功,可以显示更多信息或跳转到详情页 + const businessType = result?.data?.businessType; + if (businessType === 'gift' && result?.data?.gift) { + const gift = result.data.gift; + Taro.showModal({ + title: '核销成功', + 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: `已成功核销的品类:${result.data.goodsName || '水票'},面值¥${result.data.faceValue}` + content: '已成功核销' }); } }} diff --git a/src/passport/unified-qr/index.tsx b/src/passport/unified-qr/index.tsx index c229ad6..be5cda1 100644 --- a/src/passport/unified-qr/index.tsx +++ b/src/passport/unified-qr/index.tsx @@ -43,7 +43,7 @@ const UnifiedQRPage: React.FC = () => { setTimeout(() => { Taro.showModal({ title: '核销成功', - content: '是否继续扫码核销其他礼品卡?', + content: '是否继续扫码核销其他水票/礼品卡?', success: (res) => { if (res.confirm) { handleStartScan(); @@ -179,7 +179,7 @@ const UnifiedQRPage: React.FC = () => { {scanType === ScanType.LOGIN ? '正在确认登录' : - scanType === ScanType.VERIFICATION ? '正在核销礼品卡' : '正在处理'} + scanType === ScanType.VERIFICATION ? '正在核销' : '正在处理'} )} @@ -192,12 +192,29 @@ const UnifiedQRPage: React.FC = () => { {result.type === ScanType.VERIFICATION && result.data && ( - - 水票:{result.data.goodsName || '未知商品'} - - - 面值:¥{result.data.faceValue} - + {result.data.businessType === 'gift' && result.data.gift && ( + <> + + 礼品:{result.data.gift.goodsName || result.data.gift.name || '未知'} + + + 面值:¥{result.data.gift.faceValue} + + + )} + {result.data.businessType === 'ticket' && result.data.ticket && ( + <> + + 水票:{result.data.ticket.templateName || '水票'} + + + 本次核销:{result.data.qty || 1} 次 + + + 剩余可用:{result.data.ticket.availableQty ?? 0} 次 + + + )} )} @@ -278,9 +295,14 @@ const UnifiedQRPage: React.FC = () => { {record.success ? record.message : record.error} - {record.success && record.type === ScanType.VERIFICATION && record.data && ( + {record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'gift' && record.data?.gift && ( - {record.data.goodsName} - ¥{record.data.faceValue} + {record.data.gift.goodsName || record.data.gift.name} - ¥{record.data.gift.faceValue} + + )} + {record.success && record.type === ScanType.VERIFICATION && record.data?.businessType === 'ticket' && record.data?.ticket && ( + + {record.data.ticket.templateName || '水票'} - 本次核销 {record.data.qty || 1} 次 )} @@ -304,7 +326,7 @@ const UnifiedQRPage: React.FC = () => { • 登录二维码:自动确认网页端登录 - • 核销二维码:门店核销用户礼品卡 + • 核销二维码:核销用户水票/礼品卡 • 系统会自动识别二维码类型并执行相应操作 diff --git a/src/user/order/components/OrderList.tsx b/src/user/order/components/OrderList.tsx index cdb0985..2f3a741 100644 --- a/src/user/order/components/OrderList.tsx +++ b/src/user/order/components/OrderList.tsx @@ -131,6 +131,7 @@ function OrderList(props: OrderListProps) { // 已付款后检查发货状态 if (order.deliveryStatus === 10) return '待发货'; + if (order.formId === 10074) return '已完成'; if (order.deliveryStatus === 20) { // 若订单没有配送员,沿用原“待收货”语义 if (!order.riderId) return '待收货'; diff --git a/src/user/ticket/index.tsx b/src/user/ticket/index.tsx index 5d73bf3..b97fa11 100644 --- a/src/user/ticket/index.tsx +++ b/src/user/ticket/index.tsx @@ -1,31 +1,46 @@ 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 } from '@tarojs/components'; +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 [list, setList] = useState([]); - const [loading, setLoading] = useState(false); - const [hasMore, setHasMore] = useState(true); + const [ticketList, setTicketList] = useState([]); + const [ticketLoading, setTicketLoading] = useState(false); + const [ticketHasMore, setTicketHasMore] = useState(true); const [searchValue, setSearchValue] = useState(''); - const [page, setPage] = useState(1); - const [total, setTotal] = useState(0); - // 0-正常 1-冻结 - const [activeTab, setActiveTab] = useState('0'); + const [ticketPage, setTicketPage] = useState(1); + const [ticketTotal, setTicketTotal] = useState(0); + + const [logList, setLogList] = useState([]); + 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(null); + const [qrImageUrl, setQrImageUrl] = useState(''); const getUserId = () => { const raw = Taro.getStorageSync('UserId'); @@ -33,6 +48,48 @@ const UserTicketList = () => { 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}`); @@ -49,83 +106,149 @@ const UserTicketList = () => { }); }; - const reloadWithTab = async (tab: string, isRefresh = true, keywords?: string) => { - if (loading) return; + const reloadTickets = async (isRefresh = true, keywords?: string) => { + if (ticketLoading) return; const userId = getUserId(); if (!userId) { - setList([]); - setTotal(0); - setHasMore(false); + setTicketList([]); + setTicketTotal(0); + setTicketHasMore(false); return; } if (isRefresh) { - setPage(1); - setList([]); - setHasMore(true); + setTicketPage(1); + setTicketList([]); + setTicketHasMore(true); } - setLoading(true); + setTicketLoading(true); try { - const currentPage = isRefresh ? 1 : page; - const status = Number(tab); // 0正常,1冻结 + const currentPage = isRefresh ? 1 : ticketPage; const res = await pageGltUserTicket({ page: currentPage, limit: PAGE_SIZE, userId, - status, keywords: (keywords ?? searchValue) || undefined }); - const nextList = isRefresh ? res.list : [...list, ...res.list]; - setList(nextList); + const nextList = isRefresh ? res.list : [...ticketList, ...res.list]; + setTicketList(nextList); const count = typeof res.count === 'number' ? res.count : nextList.length; - setTotal(count); - setHasMore(nextList.length < count); + setTicketTotal(count); + setTicketHasMore(nextList.length < count); if (res.list.length > 0) { - setPage(currentPage + 1); + setTicketPage(currentPage + 1); } else { - setHasMore(false); + setTicketHasMore(false); } } catch (error) { console.error('获取水票列表失败:', error); Taro.showToast({ title: '获取水票失败', icon: 'error' }); - setHasMore(false); + setTicketHasMore(false); } 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) => { setSearchValue(value); - reloadWithTab(activeTab, true, value); - }; - - const handleRefresh = async () => { - 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); + 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(() => { - reloadWithTab(activeTab, true).then(); + if (activeTab === 'ticket') { + reloadTickets(true).then(); + } else { + reloadLogs(true).then(); + } }); return ( @@ -143,35 +266,48 @@ const UserTicketList = () => { {/* Tab切换 */} - - + + - {total > 0 && ( + {activeTab === 'ticket' && ticketTotal > 0 && ( - 共 {total} 条水票记录 + 共 {ticketTotal} 条水票记录 + + )} + + {activeTab === 'log' && logTotal > 0 && ( + + 共 {logTotal} 条核销记录 )} {/* 列表 */} - {list.length === 0 && !loading ? ( + {activeTab === 'ticket' && ticketList.length === 0 && !ticketLoading ? ( - ) : ( + ) : activeTab === 'log' && logList.length === 0 && !logLoading ? ( + + + + ) : activeTab === 'ticket' ? ( @@ -180,12 +316,12 @@ const UserTicketList = () => { } loadMoreText={ - {list.length === 0 ? '暂无数据' : '没有更多了'} + {ticketList.length === 0 ? '暂无数据' : '没有更多了'} } > - {list.map((item, index) => ( + {ticketList.map((item, index) => ( { {item.orderNo && ( - 订单号:{item.orderNo} + 订单编号:{item.orderNo} )} {item.createTime && ( - 创建时间:{item.createTime} + 下单时间:{item.createTime} )} - - {item.status === 1 ? '冻结' : '正常'} - + + + {item.status === 1 ? '冻结' : '正常'} + + + @@ -241,9 +391,153 @@ const UserTicketList = () => { ))} + ) : ( + + + 加载中... + + } + loadMoreText={ + + {logList.length === 0 ? '暂无数据' : '没有更多了'} + + } + > + + {logList.map((item, index) => ( + + + + + 核销记录 + + {item.createTime && ( + + 时间:{item.createTime} + + )} + {item.orderNo && ( + + 订单编号:{item.orderNo} + + )} + {item.comments && ( + + {item.comments} + + )} + + 变更 + + + + + 可用变更 + {formatSigned(item.changeAvailable)} + + + 已用变更 + {formatSigned(item.changeUsed)} + + + 冻结变更 + {formatSigned(item.changeFrozen)} + + + + + + 可用后 + {item.availableAfter ?? '-'} + + + 已用后 + {item.usedAfter ?? '-'} + + + 冻结后 + {item.frozenAfter ?? '-'} + + + + ))} + + )} + + {/* 核销二维码 */} + setQrVisible(false)} + style={{ width: '90%' }} + > + + + 水票核销码 + + + {qrTicket && ( + + + 水票 + {qrTicket.templateName || '水票'} + + + 可用次数 + {qrTicket.availableQty ?? 0} + + + )} + + + + {qrImageUrl ? ( + + + + 请向配送员出示此二维码 + + + ) : ( + + 生成中... + + )} + + + + + + ); };