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 @@ @@ -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",