diff --git a/src/pages-service/tickets/booking.vue b/src/pages-service/tickets/booking.vue
index a68fbd7..3a8f14d 100644
--- a/src/pages-service/tickets/booking.vue
+++ b/src/pages-service/tickets/booking.vue
@@ -2,7 +2,7 @@
diff --git a/src/pages-service/tickets/components/BookingFlow.vue b/src/pages-service/tickets/components/BookingFlow.vue
index ddc0345..d6b5eb0 100644
--- a/src/pages-service/tickets/components/BookingFlow.vue
+++ b/src/pages-service/tickets/components/BookingFlow.vue
@@ -1,116 +1,171 @@
-
-
-
-
-
-
-
- {{ product.name }}
-
- {{ highlight }}
+
+
+
+
+
+
+
+
+ {{ product.name }}
+
+ {{ ageRuleText }}
+ {{ highlight }}
+
+ ¥{{ product.salePrice }}
- ¥{{ product.salePrice }} 起
-
- 使用日期
+
+ {{ bookingHighlights }}
+ 请先选择使用日期和场次,再填写游客信息。
+
+
+
+
+
+
+ 年龄限制:{{ ageRuleText }}
+ 提交订单时仅校验门票游客,观光车和园内项目不校验年龄。
+
+
+
+
+
+ 使用日期
-
+
-
- 使用场次
+
+ {{ slotTitle }}
+ 请先选择使用日期,再查看各场次剩余库存。
-
-
-
-
-
- 购买数量
- 每单最多{{ maxQuantity }}张
-
-
-
- {{ quantity }}
-
-
-
-
-
-
-
- 出行人
- 实名制门票需填写出行人信息
-
- 已选{{ selectedTravelers.length }}/{{ quantity }}人
-
-
-
-
-
- 联系人
-
- 姓名
-
-
-
- 手机号
-
+
+
+
+ 购买数量
+ 最多购买 {{ maxQuantity }} 份
+
+
+
+ {{ quantity }}
+
+
-
+
+
+ 需填 {{ quantity }} 位游客
+
+
+ 可直接选择常用出行人,也可以进入常用出行人页面新增
+
+
+
+
+
+
+
+
+
+
+
+
+ 游客 {{ index + 1 }}
+ {{ traveler.name }} · {{ traveler.idType }} {{ traveler.idNumber }}
+
+
+
+
+
+ 联系手机*
+
+
+
+
+
+
+ 联系人
+
+
+
+
- {{ insurance.name }}
- {{ insurance.description }}
- ¥{{ insurancePrice }}/人
+
+ {{ insurance.name }}
+ ¥{{ insurancePrice }}/份
+
+ 默认购买,按游客人数计算;如不需要可取消勾选。
+ {{ insurance.description }}
+ {{ draft.insuranceSelected ? `已选择 ${quantity} 份,共 ¥${totals.insuranceAmount}` : '未购买保险' }}
-
- 提交订单前将校验实名制门票所需的出行人信息,请确认姓名及证件号码与有效证件一致。
-
-
- 商品¥{{ totals.productAmount }}{{ draft.insuranceSelected ? ` + 保险¥${totals.insuranceAmount}` : '' }}
- 合计 ¥{{ totals.paidAmount }}
+
+
+ 商品 ¥{{ totals.productAmount }}{{ draft.insuranceSelected ? ` + 保险 ¥${totals.insuranceAmount}` : '' }}
+ ¥{{ totals.paidAmount }}
-
+
@@ -137,29 +192,69 @@ const selectedTravelers = computed(() => {
const selectedIds = new Set(props.draft.travelerIds || [])
return props.travelers.filter((traveler) => selectedIds.has(traveler.id))
})
+const bookingHighlights = computed(() => {
+ const highlights = (props.product.highlights || []).filter((item) => item !== '官方')
+ return highlights.slice(0, 3).join(' ') || '官方预订 便捷入园'
+})
+const ageRuleText = computed(() => {
+ const { min, max } = props.product.ageRule || {}
+ if (Number(max) >= 999) return `${min}周岁及以上`
+ return `${min}—${max}周岁`
+})
+const isScenicAdmission = computed(() => ['景区门票', '组合套餐'].includes(props.product.category))
+const slotTitle = computed(() => isScenicAdmission.value ? '入园场次' : '预约场次')
const patchDraft = (patch) => emit('update-draft', { ...props.draft, ...patch })
const isSoldOut = (item) => Number(item.stock) === 0
const dateValue = (item) => item.date || item.value || ''
+const datePrice = (item) => Number(item.price ?? props.product.salePrice ?? 0).toLocaleString('zh-CN')
const dateClass = (item) => {
- if (isSoldOut(item)) return 'border-slate-100 bg-slate-50 text-slate-400'
+ if (isSoldOut(item)) return 'border-slate-100 bg-slate-50 text-slate-300'
return props.draft.visitDate === dateValue(item)
- ? 'border-emerald-500 bg-emerald-50 text-emerald-700'
- : 'border-slate-200 bg-white text-slate-700'
+ ? 'border-blue-500 bg-blue-50 ring-2 ring-blue-100 text-blue-700'
+ : 'border-slate-200 bg-slate-50 text-slate-700'
+}
+const slotClass = (slot) => {
+ if (props.draft.timeSlot === slot) return 'border-blue-500 bg-blue-50 ring-2 ring-blue-100 text-blue-700'
+ if (!props.draft.visitDate) return 'border-slate-100 bg-slate-50 text-slate-300'
+ return 'border-slate-200 bg-slate-50 text-slate-700'
}
const selectDate = (item) => {
if (!isSoldOut(item)) patchDraft({ visitDate: dateValue(item), timeSlot: '' })
}
const changeQuantity = (offset) => {
const nextQuantity = Math.min(Math.max(quantity.value + offset, 1), maxQuantity.value)
- patchDraft({ quantity: nextQuantity })
+ const travelerIds = (props.draft.travelerIds || []).slice(0, nextQuantity)
+ patchDraft({ quantity: nextQuantity, travelerIds })
}
+const isTravelerSelected = (id) => (props.draft.travelerIds || []).includes(id)
+const travelerClass = (traveler) => isTravelerSelected(traveler.id)
+ ? 'border-blue-500 bg-blue-50 text-blue-700 ring-1 ring-blue-100'
+ : 'border-slate-200 bg-slate-50 text-slate-700'
const toggleTraveler = (id) => {
- const ids = new Set(props.draft.travelerIds || [])
- ids.has(id) ? ids.delete(id) : ids.add(id)
- patchDraft({ travelerIds: [...ids] })
+ const ids = [...new Set(props.draft.travelerIds || [])]
+ const isSelected = ids.includes(id)
+ if (!isSelected && ids.length >= quantity.value) {
+ uni.showToast({ title: `最多选择${quantity.value}位游客`, icon: 'none' })
+ return
+ }
+
+ const travelerIds = isSelected ? ids.filter((value) => value !== id) : [...ids, id]
+ const firstTraveler = props.travelers.find((traveler) => travelerIds.includes(traveler.id))
+ patchDraft({ travelerIds, contactName: firstTraveler?.name || props.draft.contactName || '' })
+}
+const updatePhone = (event) => {
+ const firstTraveler = selectedTravelers.value[0]
+ patchDraft({
+ phone: event.detail.value,
+ contactName: props.draft.contactName || firstTraveler?.name || '游客',
+ })
}
-const updateContactName = (event) => patchDraft({ contactName: event.detail.value })
-const updatePhone = (event) => patchDraft({ phone: event.detail.value })
const toggleInsurance = () => patchDraft({ insuranceSelected: !props.draft.insuranceSelected, insurancePrice: insurancePrice.value })
+
+
diff --git a/src/pages-service/tickets/components/PaymentResult.vue b/src/pages-service/tickets/components/PaymentResult.vue
index 3467134..f7b9fd7 100644
--- a/src/pages-service/tickets/components/PaymentResult.vue
+++ b/src/pages-service/tickets/components/PaymentResult.vue
@@ -29,8 +29,8 @@
-
-
+
+
diff --git a/src/pages-service/tickets/view-contract.test.mjs b/src/pages-service/tickets/view-contract.test.mjs
index 3a68ace..a9788ac 100644
--- a/src/pages-service/tickets/view-contract.test.mjs
+++ b/src/pages-service/tickets/view-contract.test.mjs
@@ -62,6 +62,57 @@ test("storefront synchronizes the left category from right-column scrolling", as
assert.match(source, /TopNavBar/);
});
+test("booking flow matches the visitor prototype while preserving requested controls", async () => {
+ const component = await readTicketFile("components/BookingFlow.vue");
+ const page = await readTicketFile("booking.vue");
+
+ assert.match(component, /flex rounded-2xl bg-white p-3 shadow-sm/);
+ assert.match(component, /product\.images\?\.\[0\]/);
+ assert.match(component, /mt-\[5px\] flex flex-wrap gap-\[4px\]/);
+ assert.match(component, /rounded-\[4px\] bg-\[#f2ebff\] px-\[5px\] py-\[2px\] text-\[10px\] text-\[#7a52b3\]/);
+ assert.match(component, /rounded-\[4px\] bg-\[#e8f2ff\] px-\[5px\] py-\[2px\] text-\[10px\] text-\[#3474c7\]/);
+ assert.doesNotMatch(component, /rounded bg-orange-50 px-1\.5 py-0\.5 text-xs text-orange-600/);
+ assert.match(component, /border-b border-orange-100 bg-orange-50 p-4/);
+ assert.match(component, /请先选择使用日期和场次,再填写游客信息。/);
+ assert.match(component, /product\.ageRule/);
+ assert.match(component, /年龄限制/);
+ assert.match(component, /border-blue-500 bg-blue-50 ring-2 ring-blue-100/);
+ assert.match(component, /min-w-\[90px\][^"]*pb-3 pt-6 text-center/);
+ assert.match(component, //);
+ assert.match(component, //);
+ assert.doesNotMatch(component, /v-if="index < 2"[^>]*-top-/);
+ assert.match(component, /datePrice/);
+ assert.match(component, /余 \{\{ item\.stock \}\}/);
+ assert.match(component, /slotTitle/);
+ assert.match(component, /购买数量/);
+ assert.match(component, /rounded-full border border-slate-200 p-1/);
+ assert.match(component, /bg-blue-600[^"]*text-white/);
+ assert.match(component, /需填/);
+ assert.match(component, /选择\/新增/);
+ assert.match(component, /h-5 w-5 items-center justify-center rounded-\[4px\] bg-blue-600/);
+ assert.doesNotMatch(component, /h-5 w-5 items-center justify-center rounded-full bg-blue-600/);
+ assert.match(component, /联系手机/);
+ assert.match(component, /用于接收出票通知/);
+ assert.match(component, //);
+ assert.match(component, /默认购买,按游客人数计算;如不需要可取消勾选。/);
+ assert.match(component, /ml-3 flex h-6 w-11 items-center rounded-full p-0\.5/);
+ assert.match(component, /draft\.insuranceSelected \? 'bg-emerald-500' : 'bg-slate-300'/);
+ assert.match(component, /absolute bottom-0 left-0 right-0 z-20/);
+ assert.match(component, /ml-auto h-12 w-\[\d+rpx\] flex-none rounded-full/);
+ assert.doesNotMatch(component, /ml-auto h-12 min-w-0 flex-1 rounded-full/);
+ assert.match(component, /bg-orange-500/);
+ assert.match(component, /text-orange-600/);
+ assert.match(page, /paid \? '订单已支付,可查看凭证' : '可选日期'/);
+});
+
+test("payment result uses blue primary actions", async () => {
+ const component = await readTicketFile("components/PaymentResult.vue");
+
+ assert.match(component, /border border-\[#2563eb\][^"]*text-\[#2563eb\]" @click="emit\('detail'\)">查看订单详情/);
+ assert.match(component, /bg-\[#2563eb\][^"]*text-white" @click="emit\('credentials'\)">查看凭证/);
+ assert.doesNotMatch(component, /#7c3aed|#6d28d9/);
+});
+
test("order content uses bounded box-model wrappers", async () => {
const componentNames = [
"OrderList",