feat: add ticket order and credential views

This commit is contained in:
2026-07-30 16:46:55 +08:00
parent 145e096b3e
commit 7a2133dc99
5 changed files with 278 additions and 0 deletions

View File

@@ -0,0 +1,39 @@
<template>
<view class="flex h-screen flex-col bg-[#f8fafc] pt-[env(safe-area-inset-top)]">
<view class="flex items-center bg-white px-[16px] py-[14px]"><view class="flex h-[36px] w-[36px] items-center justify-center" @click="emit('back')"><uni-icons type="left" size="20" color="#171717" /></view><text class="ml-[4px] text-[20px] font-semibold text-[#171717]">我的凭证</text></view>
<scroll-view scroll-y class="min-h-0 flex-1 px-[16px] py-[16px]">
<view class="rounded-[16px] bg-white p-[16px]"><text class="block text-[16px] font-medium text-[#171717]">{{ order.productName || '景区产品' }}</text><text class="mt-[10px] block text-[13px] text-[#667085]">订单号 {{ order.orderNo || order.id || '-' }}</text><text class="mt-[8px] block text-[14px] text-[#475467]">{{ order.visitDate || '-' }} {{ order.timeSlot || '全天' }}</text><text class="mt-[6px] block text-[14px] text-[#475467]">出行人 {{ firstTraveler }}</text></view>
<view v-if="order.gateCredential" class="mt-[12px] overflow-hidden rounded-[18px] bg-[#5b21b6] p-[18px]">
<text class="text-[18px] font-semibold text-white">第三方票务凭证</text><text class="mt-[6px] block text-[13px] text-[#ddd6fe]">请向检票人员出示此二维码</text>
<view class="mt-[16px] flex rounded-[12px] bg-white p-[14px]"><view class="grid h-[162px] w-[162px] grid-cols-9 grid-rows-9 border border-[#5b21b6] p-[4px]"><view v-for="(cell, index) in gateGrid" :key="index" class="h-full w-full" :class="cell ? 'bg-[#5b21b6]' : 'bg-white'" /></view><view class="ml-[16px] flex flex-1 flex-col justify-center"><text class="text-[13px] text-[#667085]">核验状态</text><text class="mt-[5px] text-[16px] font-medium text-[#15803d]">{{ order.gateCredential.status === 'issued' ? '可使用' : '处理中' }}</text></view></view>
<view class="mt-[16px] border-t border-[#7c3aed] pt-[12px]"><text v-for="right in gateRights" :key="right.id" class="mb-[6px] block text-[13px] text-[#ede9fe]">{{ right.name }} × {{ right.quantity || 0 }}</text><text class="mt-[6px] block text-[12px] leading-[18px] text-[#ddd6fe]">请在预约日期和时段内完成核验截图可能无法正常使用</text></view>
</view>
<view v-if="order.selfCredential" class="mt-[12px] overflow-hidden rounded-[18px] border border-[#bfdbfe] bg-[#eff6ff] p-[18px]">
<text class="text-[18px] font-semibold text-[#1e3a8a]">自营园内权益</text><text class="mt-[6px] block text-[13px] text-[#3b82f6]">请向服务人员出示此二维码</text>
<view class="mt-[16px] flex rounded-[12px] bg-white p-[14px]"><view class="grid h-[162px] w-[162px] grid-cols-9 grid-rows-9 border border-[#1d4ed8] p-[4px]"><view v-for="(cell, index) in selfGrid" :key="index" class="h-full w-full" :class="cell ? 'bg-[#1d4ed8]' : 'bg-white'" /></view><view class="ml-[16px] flex flex-1 flex-col justify-center"><text class="text-[13px] text-[#667085]">核验状态</text><text class="mt-[5px] text-[16px] font-medium text-[#15803d]">{{ order.selfCredential.status === 'issued' ? '可使用' : '处理中' }}</text></view></view>
<view class="mt-[16px] border-t border-[#bfdbfe] pt-[12px]"><text v-for="right in selfRights" :key="right.id" class="mb-[6px] block text-[13px] text-[#1e3a8a]">{{ right.name }} × {{ right.quantity || 0 }}</text><text class="mt-[6px] block text-[12px] leading-[18px] text-[#3b82f6]">请在服务点出示二维码权益核销后不可再次使用</text></view>
</view>
<view v-if="!order.gateCredential && !order.selfCredential" class="flex min-h-[260px] flex-col items-center justify-center"><uni-icons type="info" size="34" color="#98a2b3" /><text class="mt-[12px] text-[14px] text-[#98a2b3]">暂无可用凭证</text></view>
<view class="h-[80px]" />
</scroll-view>
<view class="bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]"><button class="w-full rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('detail')">查看订单详情</button></view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ order: { type: Object, required: true } })
const emit = defineEmits(['back', 'detail'])
const firstTraveler = computed(() => props.order?.travelers?.[0]?.name || '未填写出行人')
const gateRights = computed(() => (props.order?.entitlements || []).filter((right) => right.fulfillment === 'third_party'))
const selfRights = computed(() => (props.order?.entitlements || []).filter((right) => right.fulfillment === 'self'))
const makeGrid = (token) => {
const source = String(token || 'credential')
return Array.from({ length: 81 }, (_, index) => ((source.charCodeAt(index % source.length) + index * 17 + Math.floor(index / 9) * 7) % 5) < 3)
}
const gateGrid = computed(() => makeGrid(props.order?.gateCredential?.qrToken))
const selfGrid = computed(() => makeGrid(props.order?.selfCredential?.qrToken))
</script>

View File

@@ -0,0 +1,75 @@
<template>
<view class="flex h-screen flex-col bg-[#f8fafc] pt-[env(safe-area-inset-top)]">
<view class="flex items-center bg-white px-[16px] py-[14px]">
<view class="flex h-[36px] w-[36px] items-center justify-center" @click="emit('back')">
<uni-icons type="left" size="20" color="#171717" />
</view>
<text class="ml-[4px] text-[20px] font-semibold text-[#171717]">订单详情</text>
</view>
<scroll-view scroll-y class="min-h-0 flex-1 px-[16px] py-[16px]">
<view class="rounded-[16px] bg-white p-[16px]">
<view class="flex items-center justify-between">
<text class="text-[16px] font-medium text-[#171717]">{{ order.productName || '景区产品' }}</text>
<text class="rounded-[12px] bg-[#ecfdf3] px-[8px] py-[3px] text-[12px] text-[#15803d]">{{ statusText }}</text>
</view>
<text class="mt-[12px] block text-[13px] text-[#667085]">订单号 {{ order.orderNo || order.id || '-' }}</text>
</view>
<view class="mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">使用信息</text>
<view class="mt-[14px] flex justify-between text-[14px]"><text class="text-[#667085]">使用日期</text><text class="text-[#344054]">{{ order.visitDate || '-' }}</text></view>
<view class="mt-[12px] flex justify-between text-[14px]"><text class="text-[#667085]">使用时段</text><text class="text-[#344054]">{{ order.timeSlot || '全天' }}</text></view>
</view>
<view class="mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">出行人</text>
<view v-for="traveler in order.travelers || []" :key="traveler.id || traveler.name" class="mt-[14px] flex justify-between text-[14px]">
<text class="text-[#344054]">{{ traveler.name || '-' }}</text><text class="text-[#667085]">{{ traveler.idType || '游客' }}</text>
</view>
<view v-if="!(order.travelers || []).length" class="mt-[14px] text-[14px] text-[#98a2b3]">暂无出行人信息</view>
<view class="mt-[14px] border-t border-[#f2f4f7] pt-[14px] text-[14px]"><text class="text-[#667085]">联系人手机 </text><text class="text-[#344054]">{{ order.phone || '-' }}</text></view>
</view>
<view class="mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">权益与使用状态</text>
<view v-for="right in order.entitlements || []" :key="right.id" class="mt-[14px] flex items-center justify-between border-b border-[#f2f4f7] pb-[12px] last:border-b-0 last:pb-0">
<view><text class="block text-[14px] text-[#344054]">{{ right.name }}</text><text class="mt-[4px] block text-[12px] text-[#98a2b3]">{{ fulfillmentText(right.fulfillment) }}</text></view>
<text class="text-[13px] text-[#667085]">{{ right.quantity || 0 }} · {{ rightStatus(right.status) }}</text>
</view>
</view>
<view class="mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">费用信息</text>
<view class="mt-[14px] flex justify-between text-[14px]"><text class="text-[#667085]">产品金额</text><text class="text-[#344054]">¥{{ money(order.productAmount) }}</text></view>
<view class="mt-[12px] flex justify-between text-[14px]"><text class="text-[#667085]">保险金额</text><text class="text-[#344054]">¥{{ money(order.insuranceAmount) }}</text></view>
<view class="mt-[12px] flex justify-between border-t border-[#f2f4f7] pt-[12px] text-[14px]"><text class="text-[#667085]">实付金额</text><text class="font-semibold text-[#ef4444]">¥{{ money(order.paidAmount) }}</text></view>
<view class="mt-[12px] flex justify-between text-[13px]"><text class="text-[#667085]">支付时间</text><text class="text-[#344054]">{{ order.paidAt || '-' }}</text></view>
</view>
<view class="mt-[12px] rounded-[16px] bg-white p-[16px]">
<text class="text-[16px] font-medium text-[#171717]">退款规则</text>
<text class="mt-[10px] block text-[13px] leading-[20px] text-[#667085]">未使用的权益可按产品退款规则申请退款已核销权益不支持退款实际退款以审核结果为准</text>
</view>
<view class="h-[92px]" />
</scroll-view>
<view class="flex gap-[12px] bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]">
<button v-if="hasCredential" class="m-0 flex-1 rounded-[24px] border border-[#7c3aed] bg-white p-0 text-[15px] leading-[48px] text-[#6d28d9]" @click="emit('credentials')">查看凭证</button>
<button v-if="canRefund" class="m-0 flex-1 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('refund')">申请退款</button>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({ order: { type: Object, required: true } })
const emit = defineEmits(['back', 'credentials', 'refund'])
const canRefund = computed(() => (props.order?.entitlements || []).some((right) => right.status === 'pending'))
const hasCredential = computed(() => Boolean(props.order?.gateCredential || props.order?.selfCredential))
const money = (value) => Number(value || 0).toFixed(2)
const fulfillmentText = (value) => value === 'third_party' ? '第三方票务核验' : '景区自营权益'
const rightStatus = (value) => ({ pending: '待使用', refunding: '退款中', used: '已使用', refunded: '已退款' }[value] || '待使用')
const statusText = computed(() => ({ paid: '待使用', refunding: '退款中', completed: '已完成', refunded: '已退款' }[props.order?.status] || '待使用'))
</script>

View File

@@ -0,0 +1,58 @@
<template>
<view class="flex h-screen flex-col bg-[#f8fafc] pt-[env(safe-area-inset-top)]">
<view class="flex items-center bg-white px-[16px] py-[14px]">
<view class="flex h-[36px] w-[36px] items-center justify-center" @click="emit('back')">
<uni-icons type="left" size="20" color="#171717" />
</view>
<view class="ml-[4px]">
<text class="block text-[20px] font-semibold text-[#171717]">我的订单</text>
<text class="mt-[2px] block text-[12px] text-[#667085]">购买凭证与退款进度</text>
</view>
</view>
<scroll-view scroll-y class="min-h-0 flex-1 px-[16px] py-[16px]">
<view v-if="orders.length" class="flex flex-col gap-[12px]">
<view v-for="order in orders" :key="order.id || order.orderNo" class="rounded-[16px] bg-white p-[16px]" @click="emit('open', order.id)">
<view class="flex items-start justify-between gap-[12px]">
<text class="flex-1 text-[16px] font-medium leading-[23px] text-[#171717]">{{ order.productName || '景区产品' }}</text>
<text v-if="orderStatus(order) === 'paid'" class="shrink-0 rounded-[12px] bg-[#ecfdf3] px-[8px] py-[3px] text-[12px] text-[#15803d]">待使用</text>
<text v-else-if="orderStatus(order) === 'refunding'" class="shrink-0 rounded-[12px] bg-[#fff7ed] px-[8px] py-[3px] text-[12px] text-[#c2410c]">退款中</text>
<text v-else-if="orderStatus(order) === 'completed'" class="shrink-0 rounded-[12px] bg-[#eff6ff] px-[8px] py-[3px] text-[12px] text-[#2563eb]">已完成</text>
<text v-else class="shrink-0 rounded-[12px] bg-[#f2f4f7] px-[8px] py-[3px] text-[12px] text-[#667085]">已退款</text>
</view>
<text class="mt-[12px] block text-[14px] text-[#475467]">{{ order.visitDate || '-' }} {{ order.timeSlot || '全天' }}</text>
<view class="mt-[10px] flex items-center gap-[8px] text-[13px] text-[#667085]">
<text>{{ order.quantity || 0 }}</text>
<text>{{ firstTraveler(order) }}</text>
<text v-if="order.insurance" class="rounded-[4px] bg-[#eff6ff] px-[6px] py-[2px] text-[11px] text-[#2563eb]">含保险</text>
</view>
<view class="mt-[14px] flex items-center justify-between border-t border-[#f2f4f7] pt-[12px]">
<text class="max-w-[200px] truncate text-[12px] text-[#98a2b3]">订单号 {{ order.orderNo || order.id || '-' }}</text>
<text class="text-[17px] font-semibold text-[#ef4444]">¥{{ money(order.paidAmount) }}</text>
</view>
</view>
</view>
<view v-else class="flex min-h-[420px] flex-col items-center justify-center">
<view class="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-[#eef2ff]">
<uni-icons type="list" size="34" color="#7c3aed" />
</view>
<text class="mt-[16px] text-[16px] font-medium text-[#344054]">暂无订单</text>
<text class="mt-[8px] text-[13px] text-[#98a2b3]">购买后可在这里查看凭证和退款进度</text>
</view>
</scroll-view>
</view>
</template>
<script setup>
defineProps({ orders: { type: Array, default: () => [] } })
const emit = defineEmits(['back', 'open'])
const money = (value) => Number(value || 0).toFixed(2)
const firstTraveler = (order) => order?.travelers?.[0]?.name || '未填写出行人'
const orderStatus = (order) => {
if (order?.status === 'refunded' || order?.refundStatus === 'approved') return 'refunded'
if (order?.status === 'refunding' || order?.refundStatus === 'pending') return 'refunding'
if (order?.status === 'completed') return 'completed'
return 'paid'
}
</script>

View File

@@ -0,0 +1,51 @@
<template>
<view class="flex h-screen flex-col bg-white px-[20px] pt-[calc(env(safe-area-inset-top)+32px)] pb-[calc(env(safe-area-inset-bottom)+20px)]">
<view class="flex flex-1 flex-col items-center overflow-y-auto">
<view class="flex h-[72px] w-[72px] items-center justify-center rounded-full bg-[#dcfce7]">
<uni-icons type="checkmarkempty" size="46" color="#16a34a" />
</view>
<text class="mt-[16px] text-[24px] font-semibold text-[#171717]">支付成功</text>
<text class="mt-[8px] text-[14px] text-[#667085]">请按预约时间使用您的权益</text>
<view class="mt-[28px] w-full rounded-[16px] bg-[#f8fafc] p-[16px]">
<text class="block text-[17px] font-medium text-[#171717]">{{ order.productName || '景区产品' }}</text>
<view class="mt-[16px] flex items-center justify-between text-[14px]">
<text class="text-[#667085]">使用日期</text>
<text class="text-[#344054]">{{ order.visitDate || '-' }}</text>
</view>
<view class="mt-[12px] flex items-center justify-between text-[14px]">
<text class="text-[#667085]">使用时段</text>
<text class="text-[#344054]">{{ order.timeSlot || '-' }}</text>
</view>
<view class="mt-[12px] flex items-center justify-between text-[14px]">
<text class="text-[#667085]">订单号</text>
<text class="max-w-[220px] truncate text-[#344054]">{{ order.orderNo || order.id || '-' }}</text>
</view>
<view class="mt-[16px] flex items-end justify-between border-t border-[#e4e7ec] pt-[16px]">
<text class="text-[14px] text-[#667085]">实付金额</text>
<text class="text-[24px] font-semibold text-[#ef4444]">¥{{ money(order.paidAmount) }}</text>
</view>
</view>
</view>
<view class="mt-[16px] grid w-full grid-cols-2 gap-[12px]">
<button class="m-0 flex h-[48px] items-center justify-center rounded-[24px] border border-[#7c3aed] bg-white p-0 text-[15px] leading-[48px] text-[#6d28d9]" @click="emit('detail')">查看订单详情</button>
<button v-if="hasCredential" class="m-0 flex h-[48px] items-center justify-center rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('credentials')">查看凭证</button>
<button class="m-0 flex h-[48px] items-center justify-center rounded-[24px] border border-[#d0d5dd] bg-white p-0 text-[15px] leading-[48px] text-[#344054]" @click="emit('orders')">我的订单</button>
<button class="m-0 flex h-[48px] items-center justify-center rounded-[24px] border border-[#d0d5dd] bg-white p-0 text-[15px] leading-[48px] text-[#344054]" @click="emit('home')">返回景区首页</button>
</view>
</view>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
order: { type: Object, required: true },
})
const emit = defineEmits(['home', 'orders', 'detail', 'credentials'])
const hasCredential = computed(() => Boolean(props.order?.gateCredential || props.order?.selfCredential))
const money = (value) => Number(value || 0).toFixed(2)
</script>

View File

@@ -0,0 +1,55 @@
<template>
<view v-if="submittedRefund" class="flex h-screen flex-col items-center justify-center bg-white px-[28px] pb-[env(safe-area-inset-bottom)]">
<view class="flex h-[76px] w-[76px] items-center justify-center rounded-full bg-[#dcfce7]"><uni-icons type="checkmarkempty" size="48" color="#16a34a" /></view>
<text class="mt-[18px] text-[23px] font-semibold text-[#171717]">退款申请已提交</text>
<text class="mt-[10px] text-[14px] text-[#667085]">退款金额 ¥{{ money(submittedRefund.amount) }}</text>
<view class="mt-[24px] w-full rounded-[16px] bg-[#f8fafc] p-[16px] text-[14px]">
<view class="flex justify-between"><text class="text-[#667085]">订单号</text><text class="max-w-[210px] truncate text-[#344054]">{{ order.orderNo || order.id || '-' }}</text></view>
<view class="mt-[12px] flex justify-between"><text class="text-[#667085]">退款状态</text><text class="text-[#d97706]">待审核</text></view>
</view>
<button class="mt-[28px] w-full rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('detail')">查看订单详情</button>
<button class="mt-[12px] w-full rounded-[24px] border border-[#d0d5dd] bg-white p-0 text-[15px] leading-[48px] text-[#344054]" @click="emit('home')">返回景区首页</button>
</view>
<view v-else class="flex h-screen flex-col bg-[#f8fafc] pt-[env(safe-area-inset-top)]">
<view class="flex items-center bg-white px-[16px] py-[14px]"><view class="flex h-[36px] w-[36px] items-center justify-center" @click="emit('back')"><uni-icons type="left" size="20" color="#171717" /></view><text class="ml-[4px] text-[20px] font-semibold text-[#171717]">申请退款</text></view>
<scroll-view scroll-y class="min-h-0 flex-1 px-[16px] py-[16px]">
<view class="rounded-[16px] bg-white p-[16px]"><text class="text-[16px] font-medium text-[#171717]">选择退款权益</text>
<view v-for="right in pendingRights" :key="right.id" class="mt-[12px] flex items-center rounded-[12px] border p-[12px]" :class="selectedIds.includes(right.id) ? 'border-[#7c3aed] bg-[#faf5ff]' : 'border-[#eaecf0] bg-white'" @click="toggleRight(right.id)">
<view class="flex h-[20px] w-[20px] items-center justify-center rounded-full border" :class="selectedIds.includes(right.id) ? 'border-[#7c3aed] bg-[#7c3aed]' : 'border-[#d0d5dd] bg-white'"><uni-icons v-if="selectedIds.includes(right.id)" type="checkmarkempty" size="14" color="#ffffff" /></view>
<view class="ml-[10px] flex-1"><text class="block text-[14px] text-[#344054]">{{ right.name }}</text><text class="mt-[3px] block text-[12px] text-[#98a2b3]">{{ right.quantity || 0 }}可退 ¥{{ money((right.refundAllocation || 0) * (right.quantity || 0)) }}</text></view>
</view>
<text v-if="!pendingRights.length" class="mt-[14px] block text-[14px] text-[#98a2b3]">当前没有可申请退款的权益</text>
</view>
<view class="mt-[12px] rounded-[16px] bg-white p-[16px]"><text class="text-[16px] font-medium text-[#171717]">退款原因</text>
<view v-for="reason in reasons" :key="reason" class="mt-[12px] flex items-center" @click="selectedReason = reason"><view class="flex h-[20px] w-[20px] items-center justify-center rounded-full border" :class="selectedReason === reason ? 'border-[#7c3aed] bg-[#7c3aed]' : 'border-[#d0d5dd] bg-white'"><view v-if="selectedReason === reason" class="h-[7px] w-[7px] rounded-full bg-white" /></view><text class="ml-[10px] text-[14px] text-[#344054]">{{ reason }}</text></view>
</view>
<view class="mt-[12px] rounded-[16px] bg-white p-[16px]"><view class="flex items-center justify-between"><text class="text-[15px] text-[#667085]">预计退款金额</text><text class="text-[22px] font-semibold text-[#ef4444]">¥{{ money(refundAmount) }}</text></view><text class="mt-[8px] block text-[12px] text-[#98a2b3]">实际到账金额以审核结果为准</text></view>
<view class="h-[80px]" />
</scroll-view>
<view class="bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]"><button class="w-full rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="submitRefund">提交退款申请</button></view>
</view>
</template>
<script setup>
import { computed, ref } from 'vue'
import { calculateRefundAmount } from '../model.mjs'
const props = defineProps({
order: { type: Object, required: true },
reasons: { type: Array, default: () => [] },
submittedRefund: { type: Object, default: null },
})
const emit = defineEmits(['back', 'submit', 'payload', 'detail', 'home'])
const selectedIds = ref([])
const selectedReason = ref('')
const pendingRights = computed(() => (props.order?.entitlements || []).filter((right) => right.status === 'pending'))
const refundAmount = computed(() => calculateRefundAmount(props.order, selectedIds.value))
const money = (value) => Number(value || 0).toFixed(2)
const toggleRight = (id) => { selectedIds.value = selectedIds.value.includes(id) ? selectedIds.value.filter((value) => value !== id) : [...selectedIds.value, id] }
const submitRefund = () => {
if (!selectedIds.value.length) return uni.showToast({ title: '请选择可退款权益', icon: 'none' })
if (!selectedReason.value) return uni.showToast({ title: '请选择退款原因', icon: 'none' })
emit('submit', { entitlementIds: selectedIds.value, reason: selectedReason.value })
}
</script>