From 8679b26f74edcf3d0b85ef680299b4524e3ae174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=B5=E5=BF=A0=E6=9E=97?= <170083662@qq.com> Date: Thu, 5 Feb 2026 01:08:37 +0800 Subject: [PATCH] =?UTF-8?q?feat(rider):=20=E6=96=B0=E5=A2=9E=E6=B0=B4?= =?UTF-8?q?=E7=A5=A8=E6=A0=B8=E9=94=80=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加水票核销扫码页面,支持扫描加密和明文二维码 - 实现水票验证逻辑,包括余额检查和核销确认 - 添加核销记录展示,最多保留最近10条记录 - 在骑手端界面增加水票核销入口 - 新增获取用户水票总数的API接口 - 优化首页轮播图加载,增加缓存和懒加载机制 - 添加门店选择功能,支持订单确认页切换门店 - 修复物流信息类型安全问题 - 更新用户中心门店相关文案显示 --- src/api/glt/gltUserTicket/index.ts | 16 ++ src/app.config.ts | 3 +- src/pages/index/Banner.tsx | 44 +-- src/pages/index/index.tsx | 24 +- src/pages/user/components/IsDealer.tsx | 4 +- src/rider/index.tsx | 11 +- src/rider/ticket/verification/index.config.ts | 4 + src/rider/ticket/verification/index.tsx | 253 ++++++++++++++++++ src/shop/orderConfirm/index.tsx | 112 +++++++- src/user/order/components/OrderList.tsx | 20 +- src/user/order/logistics/index.tsx | 2 +- 11 files changed, 454 insertions(+), 39 deletions(-) create mode 100644 src/rider/ticket/verification/index.config.ts create mode 100644 src/rider/ticket/verification/index.tsx diff --git a/src/api/glt/gltUserTicket/index.ts b/src/api/glt/gltUserTicket/index.ts index d338780..8ac9b2e 100644 --- a/src/api/glt/gltUserTicket/index.ts +++ b/src/api/glt/gltUserTicket/index.ts @@ -103,3 +103,19 @@ export async function getGltUserTicket(id: number) { } return Promise.reject(new Error(res.message)); } + +/** + * 获取我的水票总数 + */ +export async function getMyGltUserTicketTotal() { + const res = await request.get>( + '/glt/glt-user-ticket/my-total' + ); + if (res.code === 0) { + const data: any = res.data; + if (typeof data === 'number') return data; + if (data && typeof data.total === 'number') return data.total; + return 0; + } + return Promise.reject(new Error(res.message)); +} diff --git a/src/app.config.ts b/src/app.config.ts index c8c3250..e861ab7 100644 --- a/src/app.config.ts +++ b/src/app.config.ts @@ -105,7 +105,8 @@ export default { "root": "rider", "pages": [ "index", - "orders/index" + "orders/index", + "ticket/verification/index" ] }, { diff --git a/src/pages/index/Banner.tsx b/src/pages/index/Banner.tsx index f3b04d3..bcf7f11 100644 --- a/src/pages/index/Banner.tsx +++ b/src/pages/index/Banner.tsx @@ -6,6 +6,7 @@ import {Image} from '@nutui/nutui-react-taro' import {getCmsAdByCode} from "@/api/cms/cmsAd"; import navTo from "@/utils/common"; import {pageCmsArticle} from "@/api/cms/cmsArticle"; +import Taro from '@tarojs/taro' type AdImage = { url?: string @@ -41,41 +42,45 @@ const MyPage = () => { const [loading, setLoading] = useState(true) // const [disableSwiper, setDisableSwiper] = useState(false) + const CACHE_KEY = 'home_banner_mp-ad' + // 用于记录触摸开始位置 // const touchStartRef = useRef({x: 0, y: 0}) // 加载数据 - const loadData = async () => { - setLoading(true) + const loadData = async (opts?: {silent?: boolean}) => { + if (!opts?.silent) setLoading(true) try { - const [flashRes] = await Promise.allSettled([ - getCmsAdByCode('mp-ad'), - getCmsAdByCode('hot_today'), - pageCmsArticle({ limit: 1, recommend: 1 }), - ]) - - if (flashRes.status === 'fulfilled') { - console.log('flashflashflash', flashRes.value) - setCarouselData(flashRes.value) - } else { - console.error('Failed to fetch flash:', flashRes.reason) - } - + // 只阻塞 banner 自己的数据;其他数据预热不应影响首屏展示速度 + const flash = await getCmsAdByCode('mp-ad') + setCarouselData(flash) + void Taro.setStorage({ key: CACHE_KEY, data: flash }).catch(() => {}) } catch (error) { console.error('Banner数据加载失败:', error) } finally { - setLoading(false) + if (!opts?.silent) setLoading(false) } + + // 后台预热(不阻塞 banner 渲染) + void getCmsAdByCode('hot_today').catch(() => {}) + void pageCmsArticle({ limit: 1, recommend: 1 }).catch(() => {}) } useEffect(() => { - loadData().then() + const cached = Taro.getStorageSync(CACHE_KEY) as CmsAd | undefined + // 有缓存则先渲染缓存,避免首屏等待;再静默刷新 + if (cached && normalizeAdImages(cached).length) { + setCarouselData(cached) + setLoading(false) + void loadData({ silent: true }) + return + } + void loadData() }, []) // 轮播图高度,默认300px const carouselHeight = toNumberPx(carouselData?.height, 300) const carouselImages = normalizeAdImages(carouselData) - console.log(carouselImages,'carouselImages') // 骨架屏组件 const BannerSkeleton = () => ( @@ -149,7 +154,8 @@ const MyPage = () => { src={src} mode={'scaleToFill'} onClick={() => (img.path ? navTo(`${img.path}`) : undefined)} - lazyLoad={false} + // 首张图优先加载,其余按需懒加载,避免并发图片请求拖慢首屏可见 + lazyLoad={index !== 0} style={{ height: `${carouselHeight}px`, borderRadius: '4px', diff --git a/src/pages/index/index.tsx b/src/pages/index/index.tsx index 61d23bb..98a3403 100644 --- a/src/pages/index/index.tsx +++ b/src/pages/index/index.tsx @@ -1,6 +1,6 @@ import Header from './Header' import Banner from './Banner' -import Taro, { useShareAppMessage } from '@tarojs/taro' +import Taro, { useDidShow, useShareAppMessage } from '@tarojs/taro' import { View, Text, Image, ScrollView } from '@tarojs/components' import { useEffect, useMemo, useState, type ReactNode } from 'react' import { Cart, Coupon, Gift, Ticket } from '@nutui/icons-react-taro' @@ -8,11 +8,13 @@ import { getShopInfo } from '@/api/layout' import { checkAndHandleInviteRelation, hasPendingInvite } from '@/utils/invite' import { pageShopGoods } from '@/api/shop/shopGoods' import type { ShopGoods, ShopGoodsParam } from '@/api/shop/shopGoods/model' +import { getMyGltUserTicketTotal } from '@/api/glt/gltUserTicket' import './index.scss' function Home() { const [activeTabKey, setActiveTabKey] = useState('recommend') const [goodsList, setGoodsList] = useState([]) + const [ticketTotal, setTicketTotal] = useState(0) useShareAppMessage(() => { // 获取当前用户ID,用于生成邀请链接 @@ -87,9 +89,24 @@ function Home() { // const handleTabsStickyChange = (isSticky: boolean) => {} const reload = () => { - + const token = Taro.getStorageSync('access_token') + if (!token) { + setTicketTotal(0) + return + } + getMyGltUserTicketTotal() + .then((total) => setTicketTotal(typeof total === 'number' ? total : 0)) + .catch((err) => { + console.error('首页水票总数加载失败:', err) + setTicketTotal(0) + }) }; + // 回到首页/首次进入时都刷新一次(避免依赖 scope.userInfo 导致不触发 reload) + useDidShow(() => { + reload() + }) + useEffect(() => { // 获取站点信息 getShopInfo().then(() => { @@ -132,7 +149,6 @@ function Home() { if (res.authSetting['scope.userInfo']) { // 用户已经授权过,可以直接获取用户信息 console.log('用户已经授权过,可以直接获取用户信息') - reload(); } else { // 用户未授权,需要弹出授权窗口 console.log('用户未授权,需要弹出授权窗口') @@ -227,7 +243,7 @@ function Home() { 电子水票 - 您还有 0 张水票 + 您还有 {ticketTotal} 张水票 diff --git a/src/pages/user/components/IsDealer.tsx b/src/pages/user/components/IsDealer.tsx index 6c001f0..0a33df8 100644 --- a/src/pages/user/components/IsDealer.tsx +++ b/src/pages/user/components/IsDealer.tsx @@ -51,7 +51,7 @@ const IsDealer = () => { {config?.vipText || '门店入驻'} + className={'pl-3 text-orange-100 font-medium'}>{config?.vipText || '门店中心'} {/*门店核销*/} } @@ -75,7 +75,7 @@ const IsDealer = () => { title={ - {config?.vipText || '门店入驻'} + {config?.vipText || '门店中心'} {config?.vipComments || ''} } diff --git a/src/rider/index.tsx b/src/rider/index.tsx index 0a023fe..f737458 100644 --- a/src/rider/index.tsx +++ b/src/rider/index.tsx @@ -7,7 +7,8 @@ import { Dongdong, ArrowRight, Purse, - People + People, + Scan } from '@nutui/icons-react-taro' import {useDealerUser} from '@/hooks/useDealerUser' import { useThemeStyles } from '@/hooks/useTheme' @@ -240,6 +241,14 @@ const DealerIndex: React.FC = () => { + + navigateToPage('/rider/ticket/verification/index')}> + + + + + + {/* 第二行功能 */} diff --git a/src/rider/ticket/verification/index.config.ts b/src/rider/ticket/verification/index.config.ts new file mode 100644 index 0000000..afd29c5 --- /dev/null +++ b/src/rider/ticket/verification/index.config.ts @@ -0,0 +1,4 @@ +export default definePageConfig({ + navigationBarTitleText: '水票核销' +}) + diff --git a/src/rider/ticket/verification/index.tsx b/src/rider/ticket/verification/index.tsx new file mode 100644 index 0000000..f07a537 --- /dev/null +++ b/src/rider/ticket/verification/index.tsx @@ -0,0 +1,253 @@ +import React, { useMemo, useState } from 'react' +import { View, Text } from '@tarojs/components' +import Taro from '@tarojs/taro' +import { Button, Card, ConfigProvider } from '@nutui/nutui-react-taro' +import { Scan, Success, Failure, Tips } from '@nutui/icons-react-taro' + +import { decryptQrData } from '@/api/shop/shopGift' +import { getGltUserTicket, updateGltUserTicket } from '@/api/glt/gltUserTicket' +import type { GltUserTicket } from '@/api/glt/gltUserTicket/model' +import { isValidJSON } from '@/utils/jsonUtils' +import { useUser } from '@/hooks/useUser' + +type TicketPayload = { + userTicketId: number + qty?: number + userId?: number + t?: number +} + +type VerifyRecord = { + id: number + time: string + success: boolean + message: string + ticketName?: string + userInfo?: string + qty?: number +} + +const RiderTicketVerificationPage: React.FC = () => { + const { hasRole, isAdmin } = useUser() + const [loading, setLoading] = useState(false) + const [lastTicket, setLastTicket] = useState(null) + const [lastQty, setLastQty] = useState(1) + const [records, setRecords] = useState([]) + + const canVerify = useMemo(() => { + return ( + hasRole('rider') || + hasRole('store') || + hasRole('staff') || + hasRole('admin') || + isAdmin() + ) + }, [hasRole, isAdmin]) + + const addRecord = (rec: Omit) => { + const item: VerifyRecord = { + id: Date.now(), + time: new Date().toLocaleString(), + ...rec + } + setRecords(prev => [item, ...prev].slice(0, 10)) + } + + const parsePayload = (raw: string): TicketPayload => { + const trimmed = raw.trim() + if (!isValidJSON(trimmed)) throw new Error('无效的水票核销信息') + const payload = JSON.parse(trimmed) as TicketPayload + const userTicketId = Number(payload.userTicketId) + const qty = Math.max(1, Number(payload.qty || 1)) + if (!Number.isFinite(userTicketId) || userTicketId <= 0) { + throw new Error('水票核销信息无效') + } + return { ...payload, userTicketId, qty } + } + + const extractPayloadFromScanResult = async (scanResult: string): Promise => { + const trimmed = scanResult.trim() + + // 1) 加密二维码:{ businessType, token, data } + if (isValidJSON(trimmed)) { + const json = JSON.parse(trimmed) as any + if (json?.businessType && json?.token && json?.data) { + if (json.businessType !== 'ticket') { + throw new Error('请扫描水票核销码') + } + const decrypted = await decryptQrData({ + token: String(json.token), + encryptedData: String(json.data) + }) + return parsePayload(String(decrypted || '')) + } + + // 2) 明文 payload(内部调试/非加密二维码) + if (json?.userTicketId) { + return parsePayload(trimmed) + } + } + + throw new Error('无效的水票核销码') + } + + const verifyTicket = async (payload: TicketPayload) => { + const userTicketId = Number(payload.userTicketId) + const qty = Math.max(1, Number(payload.qty || 1)) + + 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('水票可用次数不足') + + const lines: string[] = [] + lines.push(`水票:${ticket.templateName || '水票'}`) + lines.push(`本次核销:${qty} 次`) + lines.push(`剩余可用:${available - qty} 次`) + if (ticket.phone) lines.push(`用户手机号:${ticket.phone}`) + if (ticket.nickname) lines.push(`用户昵称:${ticket.nickname}`) + + const modalRes = await Taro.showModal({ + title: '确认核销', + content: lines.join('\n') + }) + if (!modalRes.confirm) return + + await updateGltUserTicket({ + ...ticket, + availableQty: available - qty, + usedQty: used + qty + }) + + setLastTicket({ + ...ticket, + availableQty: available - qty, + usedQty: used + qty + }) + setLastQty(qty) + + addRecord({ + success: true, + message: `核销成功(${qty}次)`, + ticketName: ticket.templateName || '水票', + userInfo: [ticket.nickname, ticket.phone].filter(Boolean).join(' / ') || undefined, + qty + }) + Taro.showToast({ title: '核销成功', icon: 'success' }) + } + + const handleScan = async () => { + if (loading) return + if (!canVerify) { + Taro.showToast({ title: '您没有核销权限', icon: 'none' }) + return + } + + try { + setLoading(true) + const res = await Taro.scanCode({}) + const scanResult = res?.result + if (!scanResult) throw new Error('未识别到二维码内容') + + const payload = await extractPayloadFromScanResult(scanResult) + await verifyTicket(payload) + } catch (e: any) { + const msg = e?.message || '核销失败' + addRecord({ success: false, message: msg }) + Taro.showToast({ title: msg, icon: 'none' }) + } finally { + setLoading(false) + } + } + + return ( + + + + + + 水票核销 + + + 扫描用户出示的“水票核销码”完成核销 + + + + + + + + + + + {lastTicket && ( + + + 最近一次核销 + 使用 {lastQty} 次 + + + + {lastTicket.templateName || '水票'}(剩余 {lastTicket.availableQty ?? 0} 次) + + + + )} + + + + + 核销记录 + 仅保留最近10条 + + {records.length === 0 ? ( + + 暂无记录 + + ) : ( + + {records.map(r => ( + + + + {r.success ? ( + + ) : ( + + )} + {r.message} + + + + {r.time} + {r.ticketName ? ` · ${r.ticketName}` : ''} + {typeof r.qty === 'number' ? ` · ${r.qty}次` : ''} + + + {r.userInfo && ( + + {r.userInfo} + + )} + + + ))} + + )} + + + + ) +} + +export default RiderTicketVerificationPage diff --git a/src/shop/orderConfirm/index.tsx b/src/shop/orderConfirm/index.tsx index 9b8caf1..9d97580 100644 --- a/src/shop/orderConfirm/index.tsx +++ b/src/shop/orderConfirm/index.tsx @@ -11,7 +11,7 @@ import { InputNumber, ConfigProvider } from '@nutui/nutui-react-taro' -import {Location, ArrowRight} from '@nutui/icons-react-taro' +import {Location, ArrowRight, Shop} from '@nutui/icons-react-taro' import Taro, {useDidShow} from '@tarojs/taro' import {ShopGoods} from "@/api/shop/shopGoods/model"; import {getShopGoods} from "@/api/shop/shopGoods"; @@ -37,6 +37,9 @@ import { filterUnusableCoupons } from "@/utils/couponUtils"; import navTo from "@/utils/common"; +import type {ShopStore} from "@/api/shop/shopStore/model"; +import {getShopStore, listShopStore} from "@/api/shop/shopStore"; +import {getSelectedStoreFromStorage, saveSelectedStoreToStorage} from "@/utils/storeSelection"; const OrderConfirm = () => { @@ -67,9 +70,37 @@ const OrderConfirm = () => { const [availableCoupons, setAvailableCoupons] = useState([]) const [couponLoading, setCouponLoading] = useState(false) + // 门店选择:用于在下单页展示当前“已选门店”,并允许用户切换(写入 SelectedStore Storage) + const [storePopupVisible, setStorePopupVisible] = useState(false) + const [stores, setStores] = useState([]) + const [storeLoading, setStoreLoading] = useState(false) + const [selectedStore, setSelectedStore] = useState(getSelectedStoreFromStorage()) + const router = Taro.getCurrentInstance().router; const goodsId = router?.params?.goodsId; + const loadStores = async () => { + if (storeLoading) return + try { + setStoreLoading(true) + const list = await listShopStore() + setStores((list || []).filter(s => s?.isDelete !== 1)) + } catch (e) { + console.error('获取门店列表失败:', e) + setStores([]) + Taro.showToast({title: '获取门店列表失败', icon: 'none'}) + } finally { + setStoreLoading(false) + } + } + + const openStorePopup = async () => { + setStorePopupVisible(true) + if (!stores.length) { + await loadStores() + } + } + // 计算商品总价 const getGoodsTotal = () => { if (!goods) return 0 @@ -561,6 +592,8 @@ const OrderConfirm = () => { } useDidShow(() => { + // 返回/切换到该页面时,刷新一下当前已选门店 + setSelectedStore(getSelectedStoreFromStorage()) loadAllData() }) @@ -623,6 +656,26 @@ const OrderConfirm = () => { )} + + + + 门店 + + )} + extra={( + + + {selectedStore?.name || '请选择门店'} + + + + )} + onClick={openStorePopup} + /> + + @@ -816,6 +869,63 @@ const OrderConfirm = () => { + {/* 门店选择弹窗 */} + setStorePopupVisible(false)} + > + + + 选择门店 + setStorePopupVisible(false)} + > + 关闭 + + + + {storeLoading ? ( + + 加载中... + + ) : ( + + {stores.map((s) => { + const isActive = !!selectedStore?.id && selectedStore.id === s.id + return ( + {s.name || `门店${s.id}`}} + description={s.address || ''} + onClick={async () => { + let storeToSave: ShopStore = s + if (s?.id) { + try { + const full = await getShopStore(s.id) + if (full) storeToSave = full + } catch (_e) { + // keep base item + } + } + setSelectedStore(storeToSave) + saveSelectedStoreToStorage(storeToSave) + setStorePopupVisible(false) + Taro.showToast({title: '门店已切换', icon: 'success'}) + }} + /> + ) + })} + {!stores.length && ( + 暂无门店数据} /> + )} + + )} + + +
diff --git a/src/user/order/components/OrderList.tsx b/src/user/order/components/OrderList.tsx index 3c37ca4..e5110aa 100644 --- a/src/user/order/components/OrderList.tsx +++ b/src/user/order/components/OrderList.tsx @@ -337,12 +337,12 @@ function OrderList(props: OrderListProps) { }; // 查看物流 (待收货状态) - const viewLogistics = (order: ShopOrder) => { - // 跳转到物流查询页面 - Taro.navigateTo({ - url: `/user/order/logistics/index?orderId=${order.orderId}&orderNo=${order.orderNo}&expressNo=${order.transactionId || ''}&expressCompany=SF` - }); - }; + // const viewLogistics = (order: ShopOrder) => { + // // 跳转到物流查询页面 + // Taro.navigateTo({ + // url: `/user/order/logistics/index?orderId=${order.orderId}&orderNo=${order.orderNo}&expressNo=${order.transactionId || ''}&expressCompany=SF` + // }); + // }; // 再次购买 (已完成状态) const buyAgain = (order: ShopOrder) => { @@ -822,10 +822,10 @@ function OrderList(props: OrderListProps) { {/* 待收货状态:显示查看物流和确认收货 */} {item.deliveryStatus === 20 && (!item.riderId || !!item.sendEndTime) && item.orderStatus !== 2 && item.orderStatus !== 6 && ( - + {/**/}