feat: 页面调整
This commit is contained in:
@@ -12,13 +12,21 @@
|
||||
|
||||
<!-- 中间标题区域 -->
|
||||
<view :class="[
|
||||
'nav-bar-center flex items-center justify-center',
|
||||
'nav-bar-center flex min-h-[30px] items-center justify-center',
|
||||
`nav-bar-center--${titleAlign}`,
|
||||
]">
|
||||
<slot name="title">
|
||||
<text class="text-[17px] font-[500] text-[#000000]" :style="{ color: titleColor }">
|
||||
{{ title }}
|
||||
</text>
|
||||
<view
|
||||
class="flex w-full min-w-0 flex-col justify-center"
|
||||
:class="titleContentClass"
|
||||
>
|
||||
<text class="max-w-full truncate text-[17px] font-[500] leading-[21px] text-[#000000]" :style="{ color: titleColor }">
|
||||
{{ title }}
|
||||
</text>
|
||||
<text v-if="subtitle" class="mt-[1px] max-w-full truncate text-[11px] leading-[14px]" :style="{ color: subtitleColor }">
|
||||
{{ subtitle }}
|
||||
</text>
|
||||
</view>
|
||||
</slot>
|
||||
</view>
|
||||
|
||||
@@ -40,6 +48,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
subtitle: {
|
||||
type: String,
|
||||
default: "",
|
||||
},
|
||||
// 是否固定在顶部
|
||||
fixed: {
|
||||
type: Boolean,
|
||||
@@ -60,6 +72,10 @@ const props = defineProps({
|
||||
type: String,
|
||||
default: "#000",
|
||||
},
|
||||
subtitleColor: {
|
||||
type: String,
|
||||
default: "#667085",
|
||||
},
|
||||
// 返回按钮图标颜色
|
||||
backIconColor: {
|
||||
type: String,
|
||||
@@ -141,10 +157,16 @@ const navBarStyle = computed(() => {
|
||||
|
||||
const navContentStyle = computed(() => {
|
||||
return {
|
||||
height: navBarHeight.value + "px",
|
||||
height: navBarHeight.value + (props.subtitle ? 12 : 0) + "px",
|
||||
};
|
||||
});
|
||||
|
||||
const titleContentClass = computed(() =>
|
||||
props.titleAlign === "left"
|
||||
? "items-start text-left"
|
||||
: "items-center text-center"
|
||||
);
|
||||
|
||||
const hasBackListener = computed(() => {
|
||||
const vnodeProps = instance?.vnode?.props || {};
|
||||
return Boolean(vnodeProps.onBack);
|
||||
|
||||
@@ -20,7 +20,6 @@
|
||||
|
||||
.nav-bar-center {
|
||||
flex: 1;
|
||||
height: 30px;
|
||||
padding: 0 20px; // 为左右按钮留出空间
|
||||
|
||||
// 居中对齐(默认)
|
||||
|
||||
72
src/pages-service/tickets/booking.vue
Normal file
72
src/pages-service/tickets/booking.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-slate-100">
|
||||
<TopNavBar
|
||||
:title="paid ? '支付结果' : '预订选择'"
|
||||
:subtitle="paid ? '订单已支付,可查看凭证' : '确认日期、出行人与数量'"
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
/>
|
||||
<PaymentResult
|
||||
v-if="paid"
|
||||
class="min-h-0 flex-1"
|
||||
:order="ticketStore.currentOrder"
|
||||
@home="goHome"
|
||||
@orders="goOrders"
|
||||
@detail="goOrderDetail"
|
||||
@credentials="goCredentials"
|
||||
/>
|
||||
<BookingFlow
|
||||
v-else
|
||||
class="min-h-0 flex-1"
|
||||
:product="ticketStore.currentProduct"
|
||||
:draft="ticketStore.bookingDraft"
|
||||
:travelers="ticketStore.travelers"
|
||||
:available-dates="AVAILABLE_DATES"
|
||||
:insurance="INSURANCE"
|
||||
@update-draft="ticketStore.updateBookingDraft"
|
||||
@open-travelers="openTravelers"
|
||||
@submit="submitBooking"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import BookingFlow from "./components/BookingFlow.vue";
|
||||
import PaymentResult from "./components/PaymentResult.vue";
|
||||
import { AVAILABLE_DATES, INSURANCE } from "./data.js";
|
||||
import { useTicketStore } from "./store.js";
|
||||
|
||||
const ticketStore = useTicketStore();
|
||||
const paid = ref(false);
|
||||
|
||||
onLoad((options) => {
|
||||
ticketStore.startBooking(options?.productId);
|
||||
});
|
||||
|
||||
const submitBooking = () => {
|
||||
const result = ticketStore.submitBooking();
|
||||
if (result.error) {
|
||||
uni.showToast({ title: result.error, icon: "none" });
|
||||
return;
|
||||
}
|
||||
paid.value = true;
|
||||
};
|
||||
|
||||
const openTravelers = () =>
|
||||
uni.navigateTo({ url: "/pages-service/tickets/travelers?select=1" });
|
||||
const goHome = () =>
|
||||
uni.reLaunch({ url: "/pages-service/tickets/index" });
|
||||
const goOrders = () =>
|
||||
uni.redirectTo({ url: "/pages-service/tickets/orders" });
|
||||
const goOrderDetail = () =>
|
||||
uni.redirectTo({
|
||||
url: `/pages-service/tickets/order-detail?orderId=${ticketStore.currentOrderId}`,
|
||||
});
|
||||
const goCredentials = () =>
|
||||
uni.redirectTo({
|
||||
url: `/pages-service/tickets/credentials?orderId=${ticketStore.currentOrderId}`,
|
||||
});
|
||||
</script>
|
||||
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<view class="flex h-screen flex-col bg-slate-100 text-slate-900">
|
||||
<view class="flex shrink-0 items-center bg-white px-4 pb-3 pt-10">
|
||||
<button class="m-0 mr-3 flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 p-0 text-lg leading-none" @tap="emit('back')">‹</button>
|
||||
<text class="text-lg font-semibold">预订选择</text>
|
||||
</view>
|
||||
|
||||
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100 text-slate-900">
|
||||
<scroll-view scroll-y class="min-h-0 flex-1">
|
||||
<view class="space-y-3 px-3 py-3 pb-36">
|
||||
<view class="box-border w-full min-w-0 space-y-3 px-3 py-3 pb-36">
|
||||
<view class="flex rounded-2xl bg-white p-3 shadow-sm">
|
||||
<image class="h-20 w-20 shrink-0 rounded-xl bg-slate-200" :src="product.images?.[0]" mode="aspectFill" />
|
||||
<view class="ml-3 flex min-w-0 flex-1 flex-col justify-between">
|
||||
@@ -110,7 +105,7 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="flex shrink-0 items-center bg-white px-4 py-3 shadow-[0_-2px_10px_rgba(15,23,42,0.08)]">
|
||||
<view class="box-border flex w-full min-w-0 shrink-0 items-center bg-white px-4 py-3 shadow-[0_-2px_10px_rgba(15,23,42,0.08)]">
|
||||
<view class="min-w-0 flex-1">
|
||||
<text class="block text-xs text-slate-500">商品¥{{ totals.productAmount }}{{ draft.insuranceSelected ? ` + 保险¥${totals.insuranceAmount}` : '' }}</text>
|
||||
<text class="mt-1 block text-lg font-semibold text-rose-500">合计 ¥{{ totals.paidAmount }}</text>
|
||||
@@ -132,7 +127,7 @@ const props = defineProps({
|
||||
insurance: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'update-draft', 'open-travelers', 'submit'])
|
||||
const emit = defineEmits(['update-draft', 'open-travelers', 'submit'])
|
||||
|
||||
const maxQuantity = computed(() => Number(props.product.reservation?.maxQuantity) || 1)
|
||||
const quantity = computed(() => Math.min(Math.max(Number(props.draft.quantity) || 1, 1), maxQuantity.value))
|
||||
|
||||
@@ -1,24 +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 class="flex h-full min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
|
||||
<scroll-view scroll-y class="min-h-0 min-w-0 flex-1">
|
||||
<view class="box-border w-full min-w-0 px-[16px] py-[16px]">
|
||||
<view class="box-border w-full min-w-0 overflow-hidden rounded-[16px] bg-white p-[16px]">
|
||||
<text class="block break-words text-[16px] font-medium text-[#171717]">{{ order.productName || '景区产品' }}</text>
|
||||
<view class="mt-[10px] flex min-w-0 text-[13px] text-[#667085]"><text class="shrink-0">订单号</text><text class="ml-[8px] min-w-0 flex-1 break-all text-right">{{ order.orderNo || order.id || '-' }}</text></view>
|
||||
<text class="mt-[8px] block break-words text-[14px] text-[#475467]">{{ order.visitDate || '-' }} {{ order.timeSlot || '全天' }}</text>
|
||||
<text class="mt-[6px] block break-words 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.gateCredential" class="mt-[12px] box-border w-full min-w-0 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] box-border flex w-full min-w-0 flex-col items-center rounded-[12px] bg-white p-[14px]">
|
||||
<view class="grid h-[176px] w-[176px] max-w-full 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="mt-[12px] flex min-w-0 items-center justify-center"><text class="text-[13px] text-[#667085]">核验状态</text><text class="ml-[8px] text-[16px] font-medium text-[#15803d]">{{ order.gateCredential.status === 'issued' ? '可使用' : '处理中' }}</text></view>
|
||||
</view>
|
||||
<view class="mt-[16px] min-w-0 border-t border-[#7c3aed] pt-[12px]"><text v-for="right in gateRights" :key="right.id" class="mb-[6px] block break-words 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 v-if="order.selfCredential" class="mt-[12px] box-border w-full min-w-0 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] box-border flex w-full min-w-0 flex-col items-center rounded-[12px] bg-white p-[14px]">
|
||||
<view class="grid h-[176px] w-[176px] max-w-full 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="mt-[12px] flex min-w-0 items-center justify-center"><text class="text-[13px] text-[#667085]">核验状态</text><text class="ml-[8px] text-[16px] font-medium text-[#15803d]">{{ order.selfCredential.status === 'issued' ? '可使用' : '处理中' }}</text></view>
|
||||
</view>
|
||||
<view class="mt-[16px] min-w-0 border-t border-[#bfdbfe] pt-[12px]"><text v-for="right in selfRights" :key="right.id" class="mb-[6px] block break-words 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] min-w-0 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-[24px]" />
|
||||
</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 class="box-border w-full min-w-0 bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]"><button class="box-border w-full min-w-0 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('detail')">查看订单详情</button></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -26,7 +41,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({ order: { type: Object, required: true } })
|
||||
const emit = defineEmits(['back', 'detail'])
|
||||
const emit = defineEmits(['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'))
|
||||
|
||||
@@ -1,62 +1,64 @@
|
||||
<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 class="flex h-full min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
|
||||
<scroll-view scroll-y class="min-h-0 min-w-0 flex-1">
|
||||
<view class="box-border w-full min-w-0 px-[16px] py-[16px]">
|
||||
<view class="box-border w-full min-w-0 overflow-hidden rounded-[16px] bg-white p-[16px]">
|
||||
<view class="flex min-w-0 items-start justify-between gap-[10px]">
|
||||
<text class="min-w-0 flex-1 break-words text-[16px] font-medium leading-[22px] text-[#171717]">{{ order.productName || '景区产品' }}</text>
|
||||
<text class="shrink-0 rounded-[12px] bg-[#ecfdf3] px-[8px] py-[3px] text-[12px] text-[#15803d]">{{ statusText }}</text>
|
||||
</view>
|
||||
<view class="mt-[12px] flex min-w-0 items-start text-[13px] text-[#667085]">
|
||||
<text class="shrink-0">订单号</text>
|
||||
<text class="ml-[8px] min-w-0 flex-1 break-all text-right">{{ order.orderNo || order.id || '-' }}</text>
|
||||
</view>
|
||||
</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 class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
|
||||
<text class="text-[16px] font-medium text-[#171717]">使用信息</text>
|
||||
<view class="mt-[14px] flex min-w-0 justify-between gap-[12px] text-[14px]"><text class="shrink-0 text-[#667085]">使用日期</text><text class="min-w-0 break-words text-right text-[#344054]">{{ order.visitDate || '-' }}</text></view>
|
||||
<view class="mt-[12px] flex min-w-0 justify-between gap-[12px] text-[14px]"><text class="shrink-0 text-[#667085]">使用时段</text><text class="min-w-0 break-words text-right text-[#344054]">{{ order.timeSlot || '全天' }}</text></view>
|
||||
</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 class="mt-[12px] box-border w-full min-w-0 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 min-w-0 justify-between gap-[12px] text-[14px]">
|
||||
<text class="min-w-0 flex-1 break-words text-[#344054]">{{ traveler.name || '-' }}</text>
|
||||
<text class="shrink-0 text-[#667085]">{{ traveler.idType || '游客' }}</text>
|
||||
</view>
|
||||
<view v-if="!(order.travelers || []).length" class="mt-[14px] text-[14px] text-[#98a2b3]">暂无出行人信息</view>
|
||||
<view class="mt-[14px] flex min-w-0 border-t border-[#f2f4f7] pt-[14px] text-[14px]">
|
||||
<text class="shrink-0 text-[#667085]">联系人手机</text>
|
||||
<text class="ml-[12px] min-w-0 flex-1 break-all text-right text-[#344054]">{{ order.phone || '-' }}</text>
|
||||
</view>
|
||||
</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] box-border w-full min-w-0 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 min-w-0 items-center justify-between gap-[12px] border-b border-[#f2f4f7] pb-[12px] last:border-b-0 last:pb-0">
|
||||
<view class="min-w-0 flex-1"><text class="block break-words text-[14px] text-[#344054]">{{ right.name }}</text><text class="mt-[4px] block text-[12px] text-[#98a2b3]">{{ fulfillmentText(right.fulfillment) }}</text></view>
|
||||
<text class="shrink-0 text-right 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>
|
||||
<text class="mt-[10px] block text-[13px] leading-[20px] text-[#667085]">未使用的权益可按产品退款规则申请退款;已核销权益不支持退款,实际退款以审核结果为准。</text>
|
||||
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
|
||||
<text class="text-[16px] font-medium text-[#171717]">费用信息</text>
|
||||
<view class="mt-[14px] flex justify-between gap-[12px] text-[14px]"><text class="text-[#667085]">产品金额</text><text class="text-right text-[#344054]">¥{{ money(order.productAmount) }}</text></view>
|
||||
<view class="mt-[12px] flex justify-between gap-[12px] text-[14px]"><text class="text-[#667085]">保险金额</text><text class="text-right text-[#344054]">¥{{ money(order.insuranceAmount) }}</text></view>
|
||||
<view class="mt-[12px] flex justify-between gap-[12px] 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 min-w-0 justify-between gap-[12px] text-[13px]"><text class="shrink-0 text-[#667085]">支付时间</text><text class="min-w-0 break-words text-right text-[#344054]">{{ order.paidAt || '-' }}</text></view>
|
||||
</view>
|
||||
|
||||
<view class="mt-[12px] box-border w-full min-w-0 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-[24px]" />
|
||||
</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 class="box-border flex w-full min-w-0 gap-[12px] bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]">
|
||||
<button v-if="hasCredential" class="m-0 min-w-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 min-w-0 flex-1 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('refund')">申请退款</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -65,7 +67,7 @@
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({ order: { type: Object, required: true } })
|
||||
const emit = defineEmits(['back', 'credentials', 'refund'])
|
||||
const emit = defineEmits(['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)
|
||||
|
||||
@@ -1,43 +1,35 @@
|
||||
<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>
|
||||
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
|
||||
<scroll-view scroll-y class="min-h-0 min-w-0 flex-1">
|
||||
<view class="box-border w-full min-w-0 px-[16px] py-[16px]">
|
||||
<view v-if="orders.length" class="flex min-w-0 flex-col gap-[12px]">
|
||||
<view v-for="order in orders" :key="order.id || order.orderNo" class="box-border w-full min-w-0 overflow-hidden rounded-[16px] bg-white p-[16px]" @click="emit('open', order.id)">
|
||||
<view class="flex min-w-0 items-start justify-between gap-[12px]">
|
||||
<text class="min-w-0 flex-1 break-words 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>
|
||||
<text class="mt-[12px] block text-[14px] text-[#475467]">{{ order.visitDate || '-' }} {{ order.timeSlot || '全天' }}</text>
|
||||
<view class="mt-[10px] flex min-w-0 flex-wrap 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 min-w-0 items-center justify-between gap-[10px] border-t border-[#f2f4f7] pt-[12px]">
|
||||
<text class="min-w-0 flex-1 truncate text-[12px] text-[#98a2b3]">订单号 {{ order.orderNo || order.id || '-' }}</text>
|
||||
<text class="shrink-0 text-[17px] font-semibold text-[#ef4444]">¥{{ money(order.paidAmount) }}</text>
|
||||
</view>
|
||||
</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 v-else class="flex min-h-[420px] min-w-0 flex-col items-center justify-center text-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>
|
||||
<text class="mt-[16px] text-[16px] font-medium text-[#344054]">暂无订单</text>
|
||||
<text class="mt-[8px] text-[13px] text-[#98a2b3]">购买后可在这里查看凭证和退款进度</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -45,7 +37,7 @@
|
||||
|
||||
<script setup>
|
||||
defineProps({ orders: { type: Array, default: () => [] } })
|
||||
const emit = defineEmits(['back', 'open'])
|
||||
const emit = defineEmits(['open'])
|
||||
|
||||
const money = (value) => Number(value || 0).toFixed(2)
|
||||
const firstTraveler = (order) => order?.travelers?.[0]?.name || '未填写出行人'
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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="box-border flex h-full min-w-0 flex-col bg-white px-[20px] pb-[calc(env(safe-area-inset-bottom)+20px)] pt-[24px]">
|
||||
<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" />
|
||||
@@ -7,7 +7,7 @@
|
||||
<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]">
|
||||
<view class="mt-[28px] box-border w-full min-w-0 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>
|
||||
@@ -19,7 +19,7 @@
|
||||
</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>
|
||||
<text class="ml-[12px] min-w-0 break-all text-right 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>
|
||||
@@ -28,7 +28,7 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mt-[16px] grid w-full grid-cols-2 gap-[12px]">
|
||||
<view class="mt-[16px] grid w-full min-w-0 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>
|
||||
|
||||
@@ -1,14 +1,12 @@
|
||||
<template>
|
||||
<view class="h-screen overflow-hidden bg-slate-50 flex flex-col">
|
||||
<view class="shrink-0 bg-white px-[16px] pb-[12px] pt-[calc(env(safe-area-inset-top)+12px)]">
|
||||
<view class="flex h-[32px] items-center justify-between">
|
||||
<view class="flex h-[32px] w-[32px] items-center justify-center" @click="emit('back')">
|
||||
<uni-icons type="left" size="22" color="#1f2937" />
|
||||
</view>
|
||||
<text class="text-[17px] font-semibold text-slate-800">商品详情</text>
|
||||
<view class="h-[32px] w-[32px]" />
|
||||
</view>
|
||||
</view>
|
||||
<TopNavBar
|
||||
title="商品详情"
|
||||
subtitle="费用说明与购买须知"
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
@back="emit('back')"
|
||||
/>
|
||||
|
||||
<scroll-view class="min-h-0 flex-1" scroll-y>
|
||||
<image class="h-[280px] w-full bg-slate-200" :src="product.images && product.images[0]" mode="aspectFill" />
|
||||
@@ -66,6 +64,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue"
|
||||
|
||||
defineProps({
|
||||
product: { type: Object, required: true },
|
||||
})
|
||||
|
||||
@@ -1,33 +1,44 @@
|
||||
<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 v-if="submittedRefund" class="box-border flex h-full min-w-0 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-[24px] box-border w-full min-w-0 rounded-[16px] bg-[#f8fafc] p-[16px] text-[14px]">
|
||||
<view class="flex min-w-0 justify-between gap-[12px]"><text class="shrink-0 text-[#667085]">订单号</text><text class="min-w-0 flex-1 break-all text-right 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>
|
||||
<button class="mt-[28px] box-border w-full min-w-0 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="emit('detail')">查看订单详情</button>
|
||||
<button class="mt-[12px] box-border w-full min-w-0 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 v-else class="flex h-full min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
|
||||
<scroll-view scroll-y class="min-h-0 min-w-0 flex-1">
|
||||
<view class="box-border w-full min-w-0 px-[16px] py-[16px]">
|
||||
<view class="box-border w-full min-w-0 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] box-border flex w-full min-w-0 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] shrink-0 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] min-w-0 flex-1"><text class="block break-words text-[14px] text-[#344054]">{{ right.name }}</text><text class="mt-[3px] block break-words 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>
|
||||
<text v-if="!pendingRights.length" class="mt-[14px] block text-[14px] text-[#98a2b3]">当前没有可申请退款的权益</text>
|
||||
|
||||
<view class="mt-[12px] box-border w-full min-w-0 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 min-w-0 items-start" @click="selectedReason = reason">
|
||||
<view class="mt-[1px] flex h-[20px] w-[20px] shrink-0 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] min-w-0 flex-1 break-words text-[14px] leading-[21px] text-[#344054]">{{ reason }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="mt-[12px] box-border w-full min-w-0 rounded-[16px] bg-white p-[16px]">
|
||||
<view class="flex min-w-0 items-center justify-between gap-[12px]"><text class="text-[15px] text-[#667085]">预计退款金额</text><text class="shrink-0 text-[22px] font-semibold text-[#ef4444]">¥{{ money(refundAmount) }}</text></view>
|
||||
<text class="mt-[8px] block text-[12px] text-[#98a2b3]">实际到账金额以审核结果为准</text>
|
||||
</view>
|
||||
<view class="h-[24px]" />
|
||||
</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 class="box-border w-full min-w-0 bg-white px-[16px] py-[12px] pb-[calc(env(safe-area-inset-bottom)+12px)]"><button class="box-border w-full min-w-0 rounded-[24px] bg-[#7c3aed] p-0 text-[15px] leading-[48px] text-white" @click="submitRefund">提交退款申请</button></view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -40,7 +51,7 @@ const props = defineProps({
|
||||
reasons: { type: Array, default: () => [] },
|
||||
submittedRefund: { type: Object, default: null },
|
||||
})
|
||||
const emit = defineEmits(['back', 'submit', 'payload', 'detail', 'home'])
|
||||
const emit = defineEmits(['submit', 'detail', 'home'])
|
||||
const selectedIds = ref([])
|
||||
const selectedReason = ref('')
|
||||
const pendingRights = computed(() => (props.order?.entitlements || []).filter((right) => right.status === 'pending'))
|
||||
|
||||
@@ -4,17 +4,21 @@
|
||||
<image class="absolute inset-0 h-full w-full" :src="scenicSpot.heroImage" mode="aspectFill" />
|
||||
<view class="absolute inset-0 bg-[linear-gradient(180deg,rgba(6,17,31,0.15)_18%,rgba(6,17,31,0.1)_45%,rgba(6,17,31,0.92)_100%)]" />
|
||||
|
||||
<view class="absolute left-[16px] right-[16px] top-[calc(env(safe-area-inset-top)+12px)] flex items-center justify-between">
|
||||
<view class="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-black/30" @click="emit('back')">
|
||||
<uni-icons type="left" size="20" color="#FFFFFF" />
|
||||
<view class="absolute inset-x-0 top-0 z-20">
|
||||
<TopNavBar
|
||||
title=""
|
||||
background="transparent"
|
||||
title-color="#ffffff"
|
||||
back-icon-color="#ffffff"
|
||||
@back="emit('back')"
|
||||
/>
|
||||
</view>
|
||||
<view class="absolute right-[100px] top-[calc(env(safe-area-inset-top)+58px)] z-30 flex items-center gap-[10px]">
|
||||
<view class="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-black/35 shadow-sm" @tap.stop="emit('open-travelers')">
|
||||
<uni-icons type="contact" size="21" color="#FFFFFF" />
|
||||
</view>
|
||||
<view class="flex items-center gap-[10px]">
|
||||
<view class="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-black/30" @click="emit('open-travelers')">
|
||||
<uni-icons type="contact" size="20" color="#FFFFFF" />
|
||||
</view>
|
||||
<view class="flex h-[38px] w-[38px] items-center justify-center rounded-full bg-black/30" @click="emit('open-orders')">
|
||||
<uni-icons type="list" size="20" color="#FFFFFF" />
|
||||
</view>
|
||||
<view class="flex h-[40px] w-[40px] items-center justify-center rounded-full bg-black/35 shadow-sm" @tap.stop="emit('open-orders')">
|
||||
<uni-icons type="list" size="21" color="#FFFFFF" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -40,9 +44,15 @@
|
||||
|
||||
<view class="-mt-[16px] min-h-0 flex-1 overflow-hidden rounded-t-[22px] bg-white pt-[14px]">
|
||||
<view class="flex h-full min-h-0">
|
||||
<scroll-view class="h-full w-[96px] shrink-0 bg-white" scroll-y>
|
||||
<scroll-view
|
||||
class="h-full w-[96px] shrink-0 bg-white"
|
||||
scroll-y
|
||||
:scroll-into-view="leftScrollIntoViewId"
|
||||
scroll-with-animation
|
||||
>
|
||||
<view
|
||||
v-for="group in groups"
|
||||
:id="`ticket-nav-${group.id}`"
|
||||
:key="group.id"
|
||||
class="relative flex min-h-[68px] items-center justify-center px-[10px] py-[12px] text-center"
|
||||
:class="group.id === activeGroupId ? 'bg-[#ecf8f2] text-[#11795c]' : 'bg-white text-[#5f6875]'"
|
||||
@@ -53,7 +63,14 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<scroll-view class="h-full min-w-0 flex-1 bg-[#f4f6f8]" scroll-y :scroll-into-view="scrollIntoViewId" scroll-with-animation>
|
||||
<scroll-view
|
||||
id="ticket-product-scroll"
|
||||
class="h-full min-w-0 flex-1 bg-[#f4f6f8]"
|
||||
scroll-y
|
||||
:scroll-into-view="scrollIntoViewId"
|
||||
scroll-with-animation
|
||||
@scroll="handleProductScroll"
|
||||
>
|
||||
<view class="px-[12px] pb-[24px]">
|
||||
<view v-for="group in groups" :id="`ticket-group-${group.id}`" :key="group.id" class="pt-[14px]">
|
||||
<view class="flex items-center">
|
||||
@@ -95,7 +112,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { computed, getCurrentInstance, nextTick, onMounted, ref, watch } from 'vue'
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue"
|
||||
|
||||
const props = defineProps({
|
||||
scenicSpot: { type: Object, required: true },
|
||||
@@ -106,6 +124,10 @@ const props = defineProps({
|
||||
const emit = defineEmits(['back', 'open-travelers', 'open-orders', 'open-detail', 'buy'])
|
||||
const activeGroupId = ref(props.groups[0]?.id || '')
|
||||
const scrollIntoViewId = ref('')
|
||||
const leftScrollIntoViewId = computed(() => `ticket-nav-${activeGroupId.value}`)
|
||||
const groupOffsets = ref([])
|
||||
const rightScrollTop = ref(0)
|
||||
const instance = getCurrentInstance()
|
||||
const productsById = computed(() => new Map(props.products.map((product) => [product.id, product])))
|
||||
|
||||
const productsForGroup = (group) => (group.productIds || [])
|
||||
@@ -116,9 +138,57 @@ const ageRuleText = (rule) => `${rule.min}—${rule.max}周岁`
|
||||
|
||||
const selectGroup = (groupId) => {
|
||||
activeGroupId.value = groupId
|
||||
scrollIntoViewId.value = `ticket-group-${groupId}`
|
||||
scrollIntoViewId.value = ''
|
||||
nextTick(() => {
|
||||
scrollIntoViewId.value = `ticket-group-${groupId}`
|
||||
})
|
||||
}
|
||||
|
||||
const measureGroupOffsets = () => {
|
||||
if (!props.groups.length) return
|
||||
|
||||
nextTick(() => {
|
||||
const query = uni.createSelectorQuery().in(instance?.proxy)
|
||||
query.select('#ticket-product-scroll').boundingClientRect()
|
||||
props.groups.forEach((group) => {
|
||||
query.select(`#ticket-group-${group.id}`).boundingClientRect()
|
||||
})
|
||||
query.exec((rects = []) => {
|
||||
const containerRect = rects[0]
|
||||
if (!containerRect) return
|
||||
groupOffsets.value = props.groups
|
||||
.map((group, index) => ({
|
||||
id: group.id,
|
||||
top: Math.max(
|
||||
0,
|
||||
Number(rects[index + 1]?.top || 0) -
|
||||
Number(containerRect.top || 0) +
|
||||
rightScrollTop.value
|
||||
),
|
||||
}))
|
||||
.sort((left, right) => left.top - right.top)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
const handleProductScroll = (event) => {
|
||||
rightScrollTop.value = Number(event.detail?.scrollTop || 0)
|
||||
if (!groupOffsets.value.length) {
|
||||
measureGroupOffsets()
|
||||
return
|
||||
}
|
||||
|
||||
const activationLine = rightScrollTop.value + 24
|
||||
const activeGroup = groupOffsets.value.reduce(
|
||||
(current, group) => (group.top <= activationLine ? group : current),
|
||||
groupOffsets.value[0]
|
||||
)
|
||||
if (activeGroup?.id) activeGroupId.value = activeGroup.id
|
||||
}
|
||||
|
||||
onMounted(measureGroupOffsets)
|
||||
watch(() => props.groups.map((group) => group.id).join(','), measureGroupOffsets)
|
||||
|
||||
const makePhoneCall = () => {
|
||||
if (!props.scenicSpot.phone) return
|
||||
uni.makePhoneCall({ phoneNumber: props.scenicSpot.phone })
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
<template>
|
||||
<view class="flex h-screen flex-col bg-slate-100 text-slate-900">
|
||||
<view class="flex shrink-0 items-center bg-white px-4 pb-3 pt-10">
|
||||
<button class="m-0 mr-3 flex h-8 w-8 items-center justify-center rounded-full bg-slate-100 p-0 text-lg leading-none" @tap="emit('back')">‹</button>
|
||||
<text class="text-lg font-semibold">常用出行人</text>
|
||||
</view>
|
||||
|
||||
<view class="flex h-full min-w-0 flex-col overflow-hidden bg-slate-100 text-slate-900">
|
||||
<scroll-view scroll-y class="min-h-0 flex-1">
|
||||
<view class="space-y-3 px-3 py-3 pb-28">
|
||||
<view class="box-border w-full min-w-0 space-y-3 px-3 py-3 pb-28">
|
||||
<view v-if="travelers.length" class="space-y-3">
|
||||
<button v-for="traveler in travelers" :key="traveler.id" class="m-0 flex w-full items-center rounded-2xl bg-white px-4 py-4 text-left shadow-sm" @tap="selectTraveler(traveler.id)">
|
||||
<view v-if="selectionMode" class="mr-3 flex h-5 w-5 shrink-0 items-center justify-center rounded-full border" :class="isSelected(traveler.id) ? 'border-emerald-500 bg-emerald-500 text-white' : 'border-slate-300 bg-white text-transparent'">✓</view>
|
||||
@@ -39,7 +34,7 @@
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="shrink-0 bg-white px-4 py-3">
|
||||
<view class="box-border w-full min-w-0 shrink-0 bg-white px-4 py-3">
|
||||
<button class="m-0 w-full rounded-full bg-emerald-600 py-3 text-sm font-semibold text-white" @tap="showForm = true">新增出行人</button>
|
||||
</view>
|
||||
</view>
|
||||
@@ -54,7 +49,7 @@ const props = defineProps({
|
||||
selectionMode: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'toggle', 'save'])
|
||||
const emit = defineEmits(['toggle', 'save'])
|
||||
const idTypes = ['身份证', '护照']
|
||||
const showForm = ref(false)
|
||||
const formError = ref('')
|
||||
|
||||
32
src/pages-service/tickets/credentials.vue
Normal file
32
src/pages-service/tickets/credentials.vue
Normal file
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
|
||||
<TopNavBar
|
||||
title="查看凭证"
|
||||
subtitle="入园及园内权益核验"
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
/>
|
||||
<CredentialsView
|
||||
v-if="ticketStore.currentOrder"
|
||||
class="min-h-0 flex-1"
|
||||
:order="ticketStore.currentOrder"
|
||||
@detail="goDetail"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import CredentialsView from "./components/CredentialsView.vue";
|
||||
import { useTicketStore } from "./store.js";
|
||||
|
||||
const ticketStore = useTicketStore();
|
||||
|
||||
onLoad((options) => ticketStore.openOrder(options?.orderId));
|
||||
|
||||
const goDetail = () =>
|
||||
uni.redirectTo({
|
||||
url: `/pages-service/tickets/order-detail?orderId=${ticketStore.currentOrderId}`,
|
||||
});
|
||||
</script>
|
||||
@@ -1,88 +1,22 @@
|
||||
<template>
|
||||
<view class="h-screen overflow-hidden bg-[#f5f7fa] text-[#172033]">
|
||||
<view class="h-screen min-w-0 overflow-hidden bg-[#f5f7fa] text-[#172033]">
|
||||
<TicketHome
|
||||
v-if="currentView === TICKET_VIEWS.HOME"
|
||||
v-if="!detailProduct"
|
||||
:scenic-spot="SCENIC_SPOT"
|
||||
:groups="visibleGroups"
|
||||
:products="products"
|
||||
:products="ticketStore.products"
|
||||
@back="goBack"
|
||||
@open-travelers="openTravelerManager(false)"
|
||||
@open-orders="navigate(TICKET_VIEWS.ORDERS)"
|
||||
@open-travelers="openTravelers"
|
||||
@open-orders="openOrders"
|
||||
@open-detail="openProductDetail"
|
||||
@buy="startBooking"
|
||||
/>
|
||||
|
||||
<ProductDetail
|
||||
v-else-if="currentView === TICKET_VIEWS.DETAIL"
|
||||
:product="currentProduct"
|
||||
@back="goBack"
|
||||
v-else
|
||||
:product="detailProduct"
|
||||
@back="detailProductId = ''"
|
||||
@buy="startBooking"
|
||||
/>
|
||||
|
||||
<BookingFlow
|
||||
v-else-if="currentView === TICKET_VIEWS.BOOKING"
|
||||
:product="currentProduct"
|
||||
:draft="bookingDraft"
|
||||
:travelers="travelers"
|
||||
:available-dates="AVAILABLE_DATES"
|
||||
:insurance="INSURANCE"
|
||||
@back="goBack"
|
||||
@update-draft="updateBookingDraft"
|
||||
@open-travelers="openTravelerManager(true)"
|
||||
@submit="submitBooking"
|
||||
/>
|
||||
|
||||
<TravelerManager
|
||||
v-else-if="currentView === TICKET_VIEWS.TRAVELERS"
|
||||
:travelers="travelers"
|
||||
:selected-ids="bookingDraft.travelerIds"
|
||||
:selection-mode="travelerSelectionMode"
|
||||
@back="goBack"
|
||||
@toggle="toggleTraveler"
|
||||
@save="saveTraveler"
|
||||
/>
|
||||
|
||||
<PaymentResult
|
||||
v-else-if="currentView === TICKET_VIEWS.PAID"
|
||||
:order="currentOrder"
|
||||
@home="returnHome"
|
||||
@orders="navigate(TICKET_VIEWS.ORDERS)"
|
||||
@detail="showCurrentOrderDetail"
|
||||
@credentials="navigate(TICKET_VIEWS.CREDENTIALS)"
|
||||
/>
|
||||
|
||||
<OrderList
|
||||
v-else-if="currentView === TICKET_VIEWS.ORDERS"
|
||||
:orders="orders"
|
||||
@back="goBack"
|
||||
@open="openOrder"
|
||||
/>
|
||||
|
||||
<OrderDetail
|
||||
v-else-if="currentView === TICKET_VIEWS.ORDER_DETAIL"
|
||||
:order="currentOrder"
|
||||
@back="goBack"
|
||||
@credentials="navigate(TICKET_VIEWS.CREDENTIALS)"
|
||||
@refund="openRefund"
|
||||
/>
|
||||
|
||||
<RefundFlow
|
||||
v-else-if="[TICKET_VIEWS.REFUND, TICKET_VIEWS.REFUND_RESULT].includes(currentView)"
|
||||
:order="currentOrder"
|
||||
:reasons="REFUND_REASONS"
|
||||
:submitted-refund="currentView === TICKET_VIEWS.REFUND_RESULT ? submittedRefund : null"
|
||||
@back="goBack"
|
||||
@submit="submitRefund"
|
||||
@detail="replaceWithOrderDetail"
|
||||
@home="returnHome"
|
||||
/>
|
||||
|
||||
<CredentialsView
|
||||
v-else-if="currentView === TICKET_VIEWS.CREDENTIALS"
|
||||
:order="currentOrder"
|
||||
@back="goBack"
|
||||
@detail="replaceWithOrderDetail"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -90,203 +24,31 @@
|
||||
import { computed, ref } from "vue";
|
||||
import TicketHome from "./components/TicketHome.vue";
|
||||
import ProductDetail from "./components/ProductDetail.vue";
|
||||
import BookingFlow from "./components/BookingFlow.vue";
|
||||
import TravelerManager from "./components/TravelerManager.vue";
|
||||
import PaymentResult from "./components/PaymentResult.vue";
|
||||
import OrderList from "./components/OrderList.vue";
|
||||
import OrderDetail from "./components/OrderDetail.vue";
|
||||
import RefundFlow from "./components/RefundFlow.vue";
|
||||
import CredentialsView from "./components/CredentialsView.vue";
|
||||
import {
|
||||
AVAILABLE_DATES,
|
||||
GROUPS,
|
||||
INITIAL_ORDERS,
|
||||
INITIAL_TRAVELERS,
|
||||
INSURANCE,
|
||||
PRODUCTS,
|
||||
REFUND_REASONS,
|
||||
SCENIC_SPOT,
|
||||
} from "./data.js";
|
||||
import {
|
||||
TICKET_VIEWS,
|
||||
applyRefund,
|
||||
createBookingDraft,
|
||||
createPaidOrder,
|
||||
popViewHistory,
|
||||
validateBooking,
|
||||
} from "./model.mjs";
|
||||
import { GROUPS, SCENIC_SPOT } from "./data.js";
|
||||
import { useTicketStore } from "./store.js";
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
const currentView = ref(TICKET_VIEWS.HOME);
|
||||
const viewHistory = ref([]);
|
||||
const products = ref(clone(PRODUCTS));
|
||||
const travelers = ref(clone(INITIAL_TRAVELERS));
|
||||
const orders = ref(clone(INITIAL_ORDERS));
|
||||
const refunds = ref([]);
|
||||
const currentProductId = ref(PRODUCTS[0]?.id || "");
|
||||
const currentOrderId = ref(INITIAL_ORDERS[0]?.id || "");
|
||||
const bookingDraft = ref(createBookingDraft(PRODUCTS[0]));
|
||||
const travelerSelectionMode = ref(false);
|
||||
const submittedRefund = ref(null);
|
||||
const ticketStore = useTicketStore();
|
||||
const detailProductId = ref("");
|
||||
|
||||
const visibleGroups = computed(() =>
|
||||
[...GROUPS]
|
||||
.filter((group) => group.enabled)
|
||||
.sort((left, right) => left.sortOrder - right.sortOrder)
|
||||
);
|
||||
|
||||
const currentProduct = computed(() =>
|
||||
products.value.find((product) => product.id === currentProductId.value) ||
|
||||
products.value[0]
|
||||
const detailProduct = computed(() =>
|
||||
ticketStore.products.find((product) => product.id === detailProductId.value)
|
||||
);
|
||||
|
||||
const currentOrder = computed(() =>
|
||||
orders.value.find((order) => order.id === currentOrderId.value) ||
|
||||
orders.value[0]
|
||||
);
|
||||
|
||||
const navigate = (view) => {
|
||||
viewHistory.value = [...viewHistory.value, currentView.value];
|
||||
currentView.value = view;
|
||||
};
|
||||
|
||||
const replaceView = (view) => {
|
||||
currentView.value = view;
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
if (!viewHistory.value.length) {
|
||||
uni.navigateBack();
|
||||
return;
|
||||
}
|
||||
|
||||
const previous = popViewHistory(viewHistory.value);
|
||||
viewHistory.value = previous.history;
|
||||
currentView.value = previous.view;
|
||||
};
|
||||
|
||||
const returnHome = () => {
|
||||
currentView.value = TICKET_VIEWS.HOME;
|
||||
viewHistory.value = [];
|
||||
submittedRefund.value = null;
|
||||
};
|
||||
|
||||
const goBack = () => uni.navigateBack();
|
||||
const openTravelers = () =>
|
||||
uni.navigateTo({ url: "/pages-service/tickets/travelers" });
|
||||
const openOrders = () =>
|
||||
uni.navigateTo({ url: "/pages-service/tickets/orders" });
|
||||
const openProductDetail = (productId) => {
|
||||
currentProductId.value = productId;
|
||||
navigate(TICKET_VIEWS.DETAIL);
|
||||
detailProductId.value = productId;
|
||||
};
|
||||
|
||||
const startBooking = (productId) => {
|
||||
currentProductId.value = productId;
|
||||
bookingDraft.value = createBookingDraft(currentProduct.value);
|
||||
navigate(TICKET_VIEWS.BOOKING);
|
||||
};
|
||||
|
||||
const updateBookingDraft = (draft) => {
|
||||
bookingDraft.value = draft;
|
||||
};
|
||||
|
||||
const openTravelerManager = (selectionMode) => {
|
||||
travelerSelectionMode.value = selectionMode;
|
||||
navigate(TICKET_VIEWS.TRAVELERS);
|
||||
};
|
||||
|
||||
const toggleTraveler = (travelerId) => {
|
||||
const selectedIds = bookingDraft.value.travelerIds || [];
|
||||
bookingDraft.value = {
|
||||
...bookingDraft.value,
|
||||
travelerIds: selectedIds.includes(travelerId)
|
||||
? selectedIds.filter((id) => id !== travelerId)
|
||||
: [...selectedIds, travelerId],
|
||||
};
|
||||
};
|
||||
|
||||
const saveTraveler = (traveler) => {
|
||||
travelers.value = [...travelers.value, traveler];
|
||||
if (travelerSelectionMode.value) {
|
||||
bookingDraft.value = {
|
||||
...bookingDraft.value,
|
||||
travelerIds: [...new Set([...bookingDraft.value.travelerIds, traveler.id])],
|
||||
};
|
||||
}
|
||||
uni.showToast({ title: "出行人已保存", icon: "success" });
|
||||
};
|
||||
|
||||
const submitBooking = () => {
|
||||
const product = currentProduct.value;
|
||||
const draft = bookingDraft.value;
|
||||
const error = validateBooking({
|
||||
product,
|
||||
draft,
|
||||
travelers: travelers.value,
|
||||
const startBooking = (productId) =>
|
||||
uni.navigateTo({
|
||||
url: `/pages-service/tickets/booking?productId=${productId}`,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
uni.showToast({ title: error, icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (product.stock < draft.quantity) {
|
||||
uni.showToast({ title: "当前库存不足,请减少购买数量", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
const order = createPaidOrder({
|
||||
product,
|
||||
draft,
|
||||
travelers: travelers.value,
|
||||
});
|
||||
|
||||
orders.value = [order, ...orders.value];
|
||||
products.value = products.value.map((item) =>
|
||||
item.id === product.id
|
||||
? {
|
||||
...item,
|
||||
stock: Math.max(0, item.stock - draft.quantity),
|
||||
sold: item.sold + draft.quantity,
|
||||
}
|
||||
: item
|
||||
);
|
||||
currentOrderId.value = order.id;
|
||||
navigate(TICKET_VIEWS.PAID);
|
||||
};
|
||||
|
||||
const openOrder = (orderId) => {
|
||||
currentOrderId.value = orderId;
|
||||
navigate(TICKET_VIEWS.ORDER_DETAIL);
|
||||
};
|
||||
|
||||
const showCurrentOrderDetail = () => {
|
||||
navigate(TICKET_VIEWS.ORDER_DETAIL);
|
||||
};
|
||||
|
||||
const replaceWithOrderDetail = () => {
|
||||
replaceView(TICKET_VIEWS.ORDER_DETAIL);
|
||||
};
|
||||
|
||||
const openRefund = () => {
|
||||
submittedRefund.value = null;
|
||||
navigate(TICKET_VIEWS.REFUND);
|
||||
};
|
||||
|
||||
const submitRefund = ({ entitlementIds, reason }) => {
|
||||
const result = applyRefund(
|
||||
currentOrder.value,
|
||||
entitlementIds,
|
||||
reason
|
||||
);
|
||||
|
||||
if (!result.refund) {
|
||||
uni.showToast({ title: "所选权益暂不可退款", icon: "none" });
|
||||
return;
|
||||
}
|
||||
|
||||
orders.value = orders.value.map((order) =>
|
||||
order.id === result.order.id ? result.order : order
|
||||
);
|
||||
refunds.value = [result.refund, ...refunds.value];
|
||||
submittedRefund.value = result.refund;
|
||||
navigate(TICKET_VIEWS.REFUND_RESULT);
|
||||
};
|
||||
</script>
|
||||
|
||||
37
src/pages-service/tickets/order-detail.vue
Normal file
37
src/pages-service/tickets/order-detail.vue
Normal file
@@ -0,0 +1,37 @@
|
||||
<template>
|
||||
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
|
||||
<TopNavBar
|
||||
title="订单详情"
|
||||
subtitle="查看使用信息与订单权益"
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
/>
|
||||
<OrderDetail
|
||||
v-if="ticketStore.currentOrder"
|
||||
class="min-h-0 flex-1"
|
||||
:order="ticketStore.currentOrder"
|
||||
@credentials="goCredentials"
|
||||
@refund="goRefund"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import OrderDetail from "./components/OrderDetail.vue";
|
||||
import { useTicketStore } from "./store.js";
|
||||
|
||||
const ticketStore = useTicketStore();
|
||||
|
||||
onLoad((options) => ticketStore.openOrder(options?.orderId));
|
||||
|
||||
const goCredentials = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages-service/tickets/credentials?orderId=${ticketStore.currentOrderId}`,
|
||||
});
|
||||
const goRefund = () =>
|
||||
uni.navigateTo({
|
||||
url: `/pages-service/tickets/refund?orderId=${ticketStore.currentOrderId}`,
|
||||
});
|
||||
</script>
|
||||
30
src/pages-service/tickets/orders.vue
Normal file
30
src/pages-service/tickets/orders.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
|
||||
<TopNavBar
|
||||
title="我的订单"
|
||||
subtitle="购买、凭证与退款进度"
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
/>
|
||||
<OrderList
|
||||
class="min-h-0 flex-1"
|
||||
:orders="ticketStore.orders"
|
||||
@open="openOrder"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import OrderList from "./components/OrderList.vue";
|
||||
import { useTicketStore } from "./store.js";
|
||||
|
||||
const ticketStore = useTicketStore();
|
||||
|
||||
const openOrder = (orderId) => {
|
||||
ticketStore.openOrder(orderId);
|
||||
uni.navigateTo({
|
||||
url: `/pages-service/tickets/order-detail?orderId=${orderId}`,
|
||||
});
|
||||
};
|
||||
</script>
|
||||
46
src/pages-service/tickets/refund.vue
Normal file
46
src/pages-service/tickets/refund.vue
Normal file
@@ -0,0 +1,46 @@
|
||||
<template>
|
||||
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-[#f8fafc]">
|
||||
<TopNavBar
|
||||
title="申请退款"
|
||||
subtitle="选择未使用权益提交申请"
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
/>
|
||||
<RefundFlow
|
||||
v-if="ticketStore.currentOrder"
|
||||
class="min-h-0 flex-1"
|
||||
:order="ticketStore.currentOrder"
|
||||
:reasons="REFUND_REASONS"
|
||||
:submitted-refund="ticketStore.submittedRefund"
|
||||
@submit="submitRefund"
|
||||
@detail="goDetail"
|
||||
@home="goHome"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import RefundFlow from "./components/RefundFlow.vue";
|
||||
import { REFUND_REASONS } from "./data.js";
|
||||
import { useTicketStore } from "./store.js";
|
||||
|
||||
const ticketStore = useTicketStore();
|
||||
|
||||
onLoad((options) => {
|
||||
ticketStore.openOrder(options?.orderId);
|
||||
ticketStore.resetRefund();
|
||||
});
|
||||
|
||||
const submitRefund = (payload) => {
|
||||
const result = ticketStore.submitRefund(payload);
|
||||
if (result.error) uni.showToast({ title: result.error, icon: "none" });
|
||||
};
|
||||
const goDetail = () =>
|
||||
uni.redirectTo({
|
||||
url: `/pages-service/tickets/order-detail?orderId=${ticketStore.currentOrderId}`,
|
||||
});
|
||||
const goHome = () =>
|
||||
uni.reLaunch({ url: "/pages-service/tickets/index" });
|
||||
</script>
|
||||
131
src/pages-service/tickets/store.js
Normal file
131
src/pages-service/tickets/store.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import { defineStore } from "pinia";
|
||||
import {
|
||||
INITIAL_ORDERS,
|
||||
INITIAL_TRAVELERS,
|
||||
PRODUCTS,
|
||||
} from "./data.js";
|
||||
import {
|
||||
applyRefund,
|
||||
createBookingDraft,
|
||||
createPaidOrder,
|
||||
validateBooking,
|
||||
} from "./model.mjs";
|
||||
|
||||
const clone = (value) => JSON.parse(JSON.stringify(value));
|
||||
|
||||
export const useTicketStore = defineStore("ticket-client", {
|
||||
state: () => ({
|
||||
products: clone(PRODUCTS),
|
||||
travelers: clone(INITIAL_TRAVELERS),
|
||||
orders: clone(INITIAL_ORDERS),
|
||||
refunds: [],
|
||||
currentProductId: PRODUCTS[0]?.id || "",
|
||||
currentOrderId: INITIAL_ORDERS[0]?.id || "",
|
||||
bookingDraft: createBookingDraft(PRODUCTS[0]),
|
||||
submittedRefund: null,
|
||||
}),
|
||||
|
||||
getters: {
|
||||
currentProduct(state) {
|
||||
return (
|
||||
state.products.find((product) => product.id === state.currentProductId) ||
|
||||
state.products[0]
|
||||
);
|
||||
},
|
||||
currentOrder(state) {
|
||||
return (
|
||||
state.orders.find((order) => order.id === state.currentOrderId) ||
|
||||
state.orders[0]
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
actions: {
|
||||
startBooking(productId) {
|
||||
const product =
|
||||
this.products.find((item) => item.id === productId) || this.products[0];
|
||||
this.currentProductId = product?.id || "";
|
||||
this.bookingDraft = createBookingDraft(product);
|
||||
},
|
||||
updateBookingDraft(draft) {
|
||||
this.bookingDraft = draft;
|
||||
},
|
||||
toggleTraveler(travelerId) {
|
||||
const selectedIds = this.bookingDraft.travelerIds || [];
|
||||
this.bookingDraft = {
|
||||
...this.bookingDraft,
|
||||
travelerIds: selectedIds.includes(travelerId)
|
||||
? selectedIds.filter((id) => id !== travelerId)
|
||||
: [...selectedIds, travelerId],
|
||||
};
|
||||
},
|
||||
saveTraveler(traveler, selectAfterSave = false) {
|
||||
this.travelers = [...this.travelers, traveler];
|
||||
if (selectAfterSave) {
|
||||
this.bookingDraft = {
|
||||
...this.bookingDraft,
|
||||
travelerIds: [
|
||||
...new Set([...(this.bookingDraft.travelerIds || []), traveler.id]),
|
||||
],
|
||||
};
|
||||
}
|
||||
},
|
||||
submitBooking() {
|
||||
const product = this.currentProduct;
|
||||
const draft = this.bookingDraft;
|
||||
const error = validateBooking({
|
||||
product,
|
||||
draft,
|
||||
travelers: this.travelers,
|
||||
});
|
||||
|
||||
if (error) return { error, order: null };
|
||||
if (product.stock < draft.quantity) {
|
||||
return { error: "当前库存不足,请减少购买数量", order: null };
|
||||
}
|
||||
|
||||
const order = createPaidOrder({
|
||||
product,
|
||||
draft,
|
||||
travelers: this.travelers,
|
||||
});
|
||||
this.orders = [order, ...this.orders];
|
||||
this.products = this.products.map((item) =>
|
||||
item.id === product.id
|
||||
? {
|
||||
...item,
|
||||
stock: Math.max(0, item.stock - draft.quantity),
|
||||
sold: item.sold + draft.quantity,
|
||||
}
|
||||
: item
|
||||
);
|
||||
this.currentOrderId = order.id;
|
||||
|
||||
return { error: "", order };
|
||||
},
|
||||
openOrder(orderId) {
|
||||
if (this.orders.some((order) => order.id === orderId)) {
|
||||
this.currentOrderId = orderId;
|
||||
}
|
||||
},
|
||||
resetRefund() {
|
||||
this.submittedRefund = null;
|
||||
},
|
||||
submitRefund({ entitlementIds, reason }) {
|
||||
const order = this.currentOrder;
|
||||
const result = applyRefund(order, entitlementIds, reason);
|
||||
|
||||
if (!result.refund) {
|
||||
return { error: "当前没有可退款的权益", refund: null };
|
||||
}
|
||||
|
||||
this.orders = this.orders.map((item) =>
|
||||
item.id === result.order.id ? result.order : item
|
||||
);
|
||||
this.refunds = [result.refund, ...this.refunds];
|
||||
this.submittedRefund = result.refund;
|
||||
|
||||
return { error: "", refund: result.refund };
|
||||
},
|
||||
},
|
||||
});
|
||||
38
src/pages-service/tickets/travelers.vue
Normal file
38
src/pages-service/tickets/travelers.vue
Normal file
@@ -0,0 +1,38 @@
|
||||
<template>
|
||||
<view class="flex h-screen min-w-0 flex-col overflow-hidden bg-slate-100">
|
||||
<TopNavBar
|
||||
title="常用出行人"
|
||||
subtitle="快速管理实名预约信息"
|
||||
title-align="left"
|
||||
background="#ffffff"
|
||||
/>
|
||||
<TravelerManager
|
||||
class="min-h-0 flex-1"
|
||||
:travelers="ticketStore.travelers"
|
||||
:selected-ids="ticketStore.bookingDraft.travelerIds"
|
||||
:selection-mode="selectionMode"
|
||||
@toggle="ticketStore.toggleTraveler"
|
||||
@save="saveTraveler"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from "vue";
|
||||
import { onLoad } from "@dcloudio/uni-app";
|
||||
import TopNavBar from "@/components/TopNavBar/index.vue";
|
||||
import TravelerManager from "./components/TravelerManager.vue";
|
||||
import { useTicketStore } from "./store.js";
|
||||
|
||||
const ticketStore = useTicketStore();
|
||||
const selectionMode = ref(false);
|
||||
|
||||
onLoad((options) => {
|
||||
selectionMode.value = options?.select === "1";
|
||||
});
|
||||
|
||||
const saveTraveler = (traveler) => {
|
||||
ticketStore.saveTraveler(traveler, selectionMode.value);
|
||||
uni.showToast({ title: "出行人已保存", icon: "success" });
|
||||
};
|
||||
</script>
|
||||
80
src/pages-service/tickets/view-contract.test.mjs
Normal file
80
src/pages-service/tickets/view-contract.test.mjs
Normal file
@@ -0,0 +1,80 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const ticketRoot = new URL("./", import.meta.url);
|
||||
const readTicketFile = (path) => readFile(new URL(path, ticketRoot), "utf8");
|
||||
|
||||
const pageNames = [
|
||||
"booking",
|
||||
"travelers",
|
||||
"orders",
|
||||
"order-detail",
|
||||
"credentials",
|
||||
"refund",
|
||||
];
|
||||
|
||||
test("registers every visitor ticket flow as an independent page", async () => {
|
||||
const pagesJson = JSON.parse(
|
||||
await readFile(new URL("../../pages.json", import.meta.url), "utf8")
|
||||
);
|
||||
const ticketPackage = pagesJson.subPackages.find(
|
||||
(item) => item.root === "pages-service"
|
||||
);
|
||||
const paths = ticketPackage.pages.map((item) => item.path);
|
||||
|
||||
for (const pageName of pageNames) {
|
||||
assert.ok(paths.includes(`tickets/${pageName}`), `${pageName} route missing`);
|
||||
}
|
||||
});
|
||||
|
||||
test("every ticket flow page uses TopNavBar", async () => {
|
||||
for (const pageName of pageNames) {
|
||||
const source = await readTicketFile(`${pageName}.vue`);
|
||||
assert.match(source, /TopNavBar/);
|
||||
assert.match(source, /subtitle=/);
|
||||
assert.match(source, /title-align="left"/);
|
||||
}
|
||||
});
|
||||
|
||||
test("TopNavBar exposes an optional subtitle", async () => {
|
||||
const source = await readFile(
|
||||
new URL("../../components/TopNavBar/index.vue", import.meta.url),
|
||||
"utf8"
|
||||
);
|
||||
|
||||
assert.match(source, /subtitle/);
|
||||
assert.match(source, /\{\{\s*subtitle\s*\}\}/);
|
||||
assert.match(source, /titleAlign === "left"/);
|
||||
assert.match(source, /items-start text-left/);
|
||||
assert.match(source, /items-center text-center/);
|
||||
});
|
||||
|
||||
test("storefront synchronizes the left category from right-column scrolling", async () => {
|
||||
const source = await readTicketFile("components/TicketHome.vue");
|
||||
|
||||
assert.match(source, /right-\[100px\]/);
|
||||
assert.match(source, /z-30/);
|
||||
assert.match(source, /@tap\.stop="emit\('open-travelers'\)"/);
|
||||
assert.match(source, /@tap\.stop="emit\('open-orders'\)"/);
|
||||
assert.match(source, /@scroll="handleProductScroll"/);
|
||||
assert.match(source, /groupOffsets/);
|
||||
assert.match(source, /scrollTop/);
|
||||
assert.match(source, /leftScrollIntoViewId/);
|
||||
assert.match(source, /TopNavBar/);
|
||||
});
|
||||
|
||||
test("order content uses bounded box-model wrappers", async () => {
|
||||
const componentNames = [
|
||||
"OrderList",
|
||||
"OrderDetail",
|
||||
"RefundFlow",
|
||||
"CredentialsView",
|
||||
];
|
||||
|
||||
for (const componentName of componentNames) {
|
||||
const source = await readTicketFile(`components/${componentName}.vue`);
|
||||
assert.match(source, /box-border/);
|
||||
assert.match(source, /min-w-0/);
|
||||
}
|
||||
});
|
||||
@@ -148,6 +148,42 @@
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "tickets/booking",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "tickets/travelers",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "tickets/orders",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "tickets/order-detail",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "tickets/credentials",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "tickets/refund",
|
||||
"style": {
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -60,7 +60,8 @@ const sendReply = (item) => {
|
||||
// 快速预定
|
||||
if (item.type === Command.quickBooking) {
|
||||
checkToken().then(() => {
|
||||
uni.navigateTo({ url: "/pages-quick/list" });
|
||||
// uni.navigateTo({ url: "/pages-quick/list" });
|
||||
uni.navigateTo({ url: "/pages-service/tickets/index" });
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user