feat(rider): 新增水票核销功能

- 添加水票核销扫码页面,支持扫描加密和明文二维码
- 实现水票验证逻辑,包括余额检查和核销确认
- 添加核销记录展示,最多保留最近10条记录
- 在骑手端界面增加水票核销入口
- 新增获取用户水票总数的API接口
- 优化首页轮播图加载,增加缓存和懒加载机制
- 添加门店选择功能,支持订单确认页切换门店
- 修复物流信息类型安全问题
- 更新用户中心门店相关文案显示
This commit is contained in:
2026-02-05 01:08:37 +08:00
parent fcbaa970d0
commit 8679b26f74
11 changed files with 454 additions and 39 deletions

View File

@@ -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 = () => {
</View>
</View>
</Grid.Item>
<Grid.Item text={'水票核销'} onClick={() => navigateToPage('/rider/ticket/verification/index')}>
<View className="text-center">
<View className="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center mx-auto mb-2">
<Scan color="#06b6d4" size="20" />
</View>
</View>
</Grid.Item>
</Grid>
{/* 第二行功能 */}

View File

@@ -0,0 +1,4 @@
export default definePageConfig({
navigationBarTitleText: '水票核销'
})

View File

@@ -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<GltUserTicket | null>(null)
const [lastQty, setLastQty] = useState<number>(1)
const [records, setRecords] = useState<VerifyRecord[]>([])
const canVerify = useMemo(() => {
return (
hasRole('rider') ||
hasRole('store') ||
hasRole('staff') ||
hasRole('admin') ||
isAdmin()
)
}, [hasRole, isAdmin])
const addRecord = (rec: Omit<VerifyRecord, 'id' | 'time'>) => {
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<TicketPayload> => {
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 (
<ConfigProvider>
<View className="min-h-screen bg-gray-50 p-4">
<Card>
<View className="flex items-center justify-between">
<View>
<Text className="text-base font-bold text-gray-800"></Text>
<View className="mt-1">
<Text className="text-xs text-gray-500">
</Text>
</View>
</View>
<Tips className="text-gray-400" size="18" />
</View>
<View className="mt-4">
<Button
type="primary"
block
loading={loading}
icon={<Scan />}
onClick={handleScan}
>
</Button>
</View>
{lastTicket && (
<View className="mt-4 bg-gray-50 rounded-lg p-3">
<View className="flex items-center justify-between">
<Text className="text-sm text-gray-700"></Text>
<Text className="text-xs text-gray-500">使 {lastQty} </Text>
</View>
<View className="mt-2">
<Text className="text-sm text-gray-900">
{lastTicket.templateName || '水票'} {lastTicket.availableQty ?? 0}
</Text>
</View>
</View>
)}
</Card>
<View className="mt-4">
<View className="mb-2">
<Text className="text-sm font-semibold text-gray-800"></Text>
<Text className="text-xs text-gray-500 ml-2">10</Text>
</View>
{records.length === 0 ? (
<View className="bg-white rounded-lg p-4">
<Text className="text-sm text-gray-500"></Text>
</View>
) : (
<View className="space-y-2">
{records.map(r => (
<View key={r.id} className="bg-white rounded-lg p-3 flex items-start justify-between">
<View className="flex-1 pr-3">
<View className="flex items-center">
{r.success ? (
<Success className="text-green-500 mr-2" size="16" />
) : (
<Failure className="text-red-500 mr-2" size="16" />
)}
<Text className="text-sm text-gray-900">{r.message}</Text>
</View>
<View className="mt-1">
<Text className="text-xs text-gray-500">
{r.time}
{r.ticketName ? ` · ${r.ticketName}` : ''}
{typeof r.qty === 'number' ? ` · ${r.qty}` : ''}
</Text>
</View>
{r.userInfo && (
<View className="mt-1">
<Text className="text-xs text-gray-500">{r.userInfo}</Text>
</View>
)}
</View>
</View>
))}
</View>
)}
</View>
</View>
</ConfigProvider>
)
}
export default RiderTicketVerificationPage