@@ -1,116 +1,171 @@
< template >
< 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 = "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" >
< view >
< text class = "block truncate text-base font-semibold" > { { product . name } } < / text >
< view class = "mt-2 flex flex-wrap gap-1" >
< text v-for = "highlight in product.highlights || []" :key="highlight" class="rounded bg-orange-50 px-1.5 py-0.5 text-xs text-orange-600" > {{ highlight }} < / text >
< view class = "relative flex h-full min-w-0 flex-col overflow-hidden bg-slate-100 text-slate-900" >
< scroll-view scroll -y class = "relative z-0 min-h-0 flex-1" >
< view class = "box-border w-full min-w-0 pb-32 " >
< view class = "box-border w-full min-w-0 p-3 " >
< view class = "flex rounded-2 xl 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" >
< view >
< text class = "block truncate text-base font-semibold" > { { product . name } } < / text >
< view class = "mt-[5px] flex flex-wrap gap-[4px]" >
< text v-if = "product.ageRule" class="rounded-[4px] bg-[#f2ebff] px-[5px] py-[2px] text-[10px] text-[#7a52b3]" > {{ ageRuleText }} < / text >
< text v-for = "highlight in product.highlights || []" :key="highlight" class="rounded-[4px] bg-[#e8f2ff] px-[5px] py-[2px] text-[10px] text-[#3474c7]" > {{ highlight }} < / text >
< / view >
< / view >
< text class = "text-sm font-semibold text-rose-500" > ¥ { { product . salePrice } } < / text >
< / view >
< text class = "text-sm font-semibold text-rose-500" > ¥ { { product . salePrice } } 起 < / text >
< / view >
< / view >
< view class = "rounded-2xl bg-white px-4 py-4 " >
< text class = "text-base font-semibold" > 使用日期 < / text >
< view class = "border-b border-orange-100 bg-orange-50 p-4 text-xs leading-5 text-orange-600 " >
< text class = "font-semibold text-blue-600" > { { bookingHighlights } } < / text >
< text class = "block" > 请先选择使用日期和场次 , 再填写游客信息 。 < / text >
< / view >
< view v-if = "product.ageRule" class="border-b border-blue-100 bg-blue-50 p-4" >
< view class = "flex min-w-0 items-start gap-3" >
< uni-icons class = "mt-0.5 shrink-0" type = "info" size = "18" color = "#2563eb" / >
< view class = "min-w-0 flex-1" >
< text class = "block text-sm font-bold text-blue-900" > 年龄限制 : { { ageRuleText } } < / text >
< text class = "mt-1 block text-xs leading-5 text-blue-700" > 提交订单时仅校验门票游客 , 观光车和园内项目不校验年龄 。 < / text >
< / view >
< / view >
< / view >
< view v-if = "product.reservation?.requiresDate !== false" class="bg-white p-4" >
< text class = "font-bold" > 使用日期 < / text >
< scroll-view scroll -x class = "mt-3 whitespace-nowrap" >
< view class = "flex gap-2" >
< view class = "flex gap-2 pb-1 " >
< button
v-for = "item in availableDates"
v-for = "( item, index) in availableDates"
: key = "item.date || item.value"
class = "m-0 flex min-w-20 flex-col items-center rounded-xl border px-3 py-2 text-left "
class = "booking-button relative m-0 min-w-[90px] rounded-xl border px-3 pb-3 pt-6 text-center "
:class = "dateClass(item)"
:disabled = "isSoldOut(item)"
@tap ="selectDate(item)"
>
< text class = "text-xs" > { { item . week || '可预约' } } < / text >
< text class = "mt-1 text-sm font-medium" > { { item . label || item . date || item . value } } < / text >
< text v-if = "isSoldOut(item)" class="mt-1 text-xs" > 已售罄 < / text >
< text v-else class = "mt-1 text-xs" > 余 { { item . stock } } 张 < / text >
< view v-if = "index < 2" class="absolute left-0 top-0 flex h-5 items-center justify-center rounded-br-lg rounded-tl-xl bg-orange-500 px-2" >
< text class = "text-[10px] leading-none text-white" > { { index === 0 ? '明天' : '后天' } } < / text >
< / view >
< text class = "block text-xs" > { { item . week || '可预约' } } < / text >
< text class = "mt-1 block text-sm font-semibold" > { { item . label || item . date || item . value } } < / text >
< text class = "mt-1 block text-sm text-orange-600" > ¥ { { datePrice ( item ) } } < / text >
< text v-if = "isSoldOut(item)" class="mt-1 block text-[10px] text-slate-400" > 已售罄 < / text >
< text v-else class = "mt-1 block text-[10px] text-slate-400" > 余 { { item . stock } } < / text >
< / button >
< / view >
< / scroll-view >
< / view >
< view v-if = "product.reservation?.requiresTimeSlot" class="rounded-2xl bg-white px-4 py -4" >
< text class = "text-base font-semi bold" > 使用场次 < / text >
< view v-if = "product.reservation?.requiresTimeSlot" class="mt-2 bg-white p-4" >
< text class = "font-bold" > { { slotTitle } } < / text >
< text v-if = "!draft.visitDate" class="mt-2 block text-xs text-orange-600" > 请先选择使用日期 , 再查看各场次剩余库存 。 < / text >
< view class = "mt-3 grid grid-cols-2 gap-2" >
< button
v-for = "slot in product.reservation.timeSlots || []"
:key = "slot"
class = "m-0 rounded-xl border px-2 py-2 text-sm"
: class= "draft.timeSlot === slot ? 'border-emerald-500 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-white text-slate-700' "
class = "booking-button m-0 rounded-xl border px-2 py-3 text-sm font-semibold "
: class= "slotClass(slot) "
:disabled = "!draft.visitDate"
@tap ="patchDraft({ timeSlot: slot })"
> { { slot } } < / button >
< / view >
< / view >
< view class = "flex items-center justify-between rounded-2xl bg-white px-4 py-4" >
< view >
< text class = "block text-base font-semibold" > 购买数量 < / text >
< text class = "mt-1 block text-xs text-slate-500" > 每单最多 { { maxQuantity } } 张 < / text >
< / view >
< view class = "flex items-center overflow-hidden rounded-lg border border-slate-200" >
< button class = "m-0 h-8 w-8 rounded-none bg-slate-50 p-0 text-lg text-slate-600" : disabled = "quantity <= 1" @tap ="changeQuantity(-1)" > − < / button >
< text class = "flex h-8 w-9 items-center justify-center text-center text-sm font-semibold" > { { quantity } } < / text >
< button class = "m-0 h-8 w-8 rounded-none bg-slate-50 p-0 text-lg text-slate-600" : disabled = "quantity >= maxQuantity" @tap ="changeQuantity(1)" > + < / button >
< / view >
< / view >
< view v-if = "product.reservation?.realNameRequired" class="rounded-2xl bg-white px-4 py-4" >
< view class = "flex items-center justify-between" >
< view >
< text class = "block text-base font-semibold" > 出行人 < / text >
< text class = "mt-1 block text-xs text-slate-500" > 实名制门票需填写出行人信息 < / text >
< / view >
< text class = "text-sm text-emerald-600" > 已选 { { selectedTravelers . length } } / { { quantity } } 人 < / text >
< / view >
< view v-if = "selectedTravelers.length" class="mt-3 space-y-2" >
< button v-for = "traveler in selectedTravelers" :key="traveler.id" class="m-0 flex w-full items-center justify-between rounded-xl bg-slate-50 px-3 py-2 text-left" @tap="toggleTraveler(traveler.id)" >
< view > < text class = "text-sm font-medium" > { { traveler . name } } < / text > < text class = "ml-2 text-xs text-slate-500" > { { traveler . idType } } < / text > < / view >
< text class = "text-xs text-emerald-600" > 已选择 < / text >
>
< text class = "block" > { { slot } } { { isScenicAdmission ? '入园' : '' } } < / text >
< text class = "mt-1 block text-[10px] font-normal" : class = "draft.visitDate ? 'text-slate-400' : 'text-slate-300'" > { { draft . visitDate ? '库存充足' : '待选日期' } } < / text >
< / button >
< / view >
< button class = "mt-3 flex w-full items-center justify-center rounded-xl border border-dashed border-emerald-300 bg-emerald-50 px-3 py-2 text-sm text-emerald-700" @tap ="emit('open-travelers')" > 管理常用出行人 < / button >
< / view >
< view v-if = "product.reservation?.contactPhoneRequired" class="rounded-2xl bg-white px-4 py -4">
< text class = "text-base font-semibold" > 联系人 < / text >
< view class = "mt-3 flex items-center border-b border-slate-100 py-2" >
< text class = "w-20 text-sm text-slate-700 " > 姓名 < / text >
< inpu t class = "min-w-0 flex-1 text-right text-sm" : value = "draft.contactName || ''" placeholder = "请输入联系人姓名" @input ="updateContactName" / >
< / view >
< view class = "flex items-center py-2 " >
< text class = "w-2 0 text-sm text-slate-7 00" > 手机号 < / text >
< inpu t class = "min-w-0 flex-1 text-right text-sm" type = "number" maxlength = "11" : value = "draft.phone || ''" placeholder = "用于接收订单通知" @input ="updatePhone" / >
< view class = "mt-2 bg-white p-4" >
< view class = "flex items-center justify-between" >
< view >
< text class = "block font-bold " > 购买数量 < / text >
< tex t class = "mt-1 block text-xs text-slate-400" > 最多购买 { { maxQuantity } } 份 < / text >
< / view >
< view class = "flex items-center gap-4 rounded-full border border-slate-200 p-1 " >
< button class = "booking-button m-0 flex h-7 w-7 items-center justify-center rounded-full bg-slate-100 p- 0 text-base text-slate-5 00" : disabled = "quantity <= 1" @tap ="changeQuantity(-1)" > − < / button >
< tex t class = "min-w-4 text-center font-bold" > { { quantity } } < / text >
< button class = "booking-button m-0 flex h-7 w-7 items-center justify-center rounded-full bg-blue-600 p-0 text-base text-white" : disabled = "quantity >= maxQuantity" @tap ="changeQuantity(1)" > + < / button >
< / view >
< / view >
< / view >
< view class = "flex items-center rounded-2xl bg-white px -4 py-4" @tap ="toggleInsurance ">
< view v-if = "product.reservation?.realNameRequired" class="mt-2 bg-white p-4">
< view class = "flex min-w-0 items-center justify-between gap-3" >
< text class = "font-bold" > 需填 < text class = "text-orange-500" > { { quantity } } < / text > 位游客 < / text >
< button class = "booking-button m-0 flex shrink-0 items-center gap-1 bg-transparent p-0 text-xs font-semibold text-blue-600" @tap ="emit('open-travelers')" >
选择 / 新增
< uni -icons type = "right" size = "15" color = "#2563eb" / >
< / button >
< / view >
< text class = "mt-1 block text-xs text-slate-400" > 可直接选择常用出行人 , 也可以进入常用出行人页面新增 < / text >
< scroll-view scroll -x class = "mt-3 whitespace-nowrap" >
< view class = "flex gap-2 pb-1" >
< button
v-for = "traveler in travelers"
:key = "traveler.id"
class = "booking-button relative m-0 min-w-[92px] rounded-xl border px-3 py-3 text-center text-sm font-semibold"
:class = "travelerClass(traveler)"
@tap ="toggleTraveler(traveler.id)"
>
{ { traveler . name } }
< view v-if = "isTravelerSelected(traveler.id)" class="absolute -right-1 -top-1 flex h-5 w-5 items-center justify-center rounded-[4px] bg-blue-600" >
< uni -icons type = "checkmarkempty" size = "12" color = "#ffffff" / >
< / view >
< / button >
< button class = "booking-button m-0 flex min-w-[92px] items-center justify-center gap-1 rounded-xl border border-dashed border-blue-200 bg-blue-50 px-3 py-3 text-xs font-semibold text-blue-600" @tap ="emit('open-travelers')" >
选择 / 新增
< uni -icons type = "right" size = "14" color = "#2563eb" / >
< / button >
< / view >
< / scroll-view >
< view v-if = "selectedTravelers.length" class="mt-4 divide-y divide-slate-100 border-t border-slate-100" >
< view v-for = "(traveler, index) in selectedTravelers" :key="traveler.id" class="flex min-w-0 items-center gap-3 py-3" >
< button class = "booking-button m-0 flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-slate-400 bg-white p-0 text-base leading-none text-slate-500" @tap ="toggleTraveler(traveler.id)" > − < / button >
< view class = "min-w-0 flex-1" >
< text class = "block text-xs font-bold text-slate-900" > 游客 { { index + 1 } } < / text >
< text class = "mt-1 block truncate text-sm text-slate-500" > { { traveler . name } } · { { traveler . idType } } { { traveler . idNumber } } < / text >
< / view >
< / view >
< / view >
< view v-if = "product.reservation?.contactPhoneRequired" class="flex min-w-0 items-center gap-3 border-t border-slate-100 pt-4" >
< text class = "shrink-0 text-sm font-bold" > 联系手机 < text class = "text-rose-500" > * < / text > < / text >
< input class = "h-10 min-w-0 flex-1 border-b border-slate-200 px-1 text-sm" type = "number" maxlength = "11" : value = "draft.phone || ''" placeholder = "用于接收出票通知" @input ="updatePhone" / >
< uni-icons class = "shrink-0" type = "phone" size = "18" color = "#2563eb" / >
< / view >
< / view >
< view v-else-if = "product.reservation?.contactPhoneRequired" class="mt-2 bg-white p-4" >
< text class = "font-bold" > 联系人 < / text >
< input class = "mt-3 h-11 w-full rounded-xl border border-slate-200 px-3 text-sm" type = "number" maxlength = "11" : value = "draft.phone || ''" placeholder = "手机号,用于接收出票通知" @input ="updatePhone" / >
< / view >
< view class = "mt-2 flex items-start bg-white p-4" @tap ="toggleInsurance" >
< view class = "min-w-0 flex-1" >
< text class = "block text-base font-semibold" > { { insurance . name } } < / text >
< text class = "mt-1 block truncate text-xs text-slate-5 00" > { { insurance . description } } < / text >
< text class = "mt-1 block text-xs text-ros e-5 00" > ¥ { { insurancePrice } } / 人 < / text >
< view class = "flex min-w-0 items-center justify-between gap-3" >
< text class = "font-bold text-slate-9 00" > { { insurance . name } } < / text >
< text class = "shrink-0 font-bold text-orang e-6 00" > ¥ { { insurancePrice } } / 份 < / text >
< / view >
< text class = "mt-1 block text-xs leading-5 text-slate-500" > 默认购买 , 按游客人数计算 ; 如不需要可取消勾选 。 < / text >
< text class = "mt-1 block text-xs text-slate-400" > { { insurance . description } } < / text >
< text class = "mt-2 block text-xs font-semibold text-blue-600" > { { draft . insuranceSelected ? ` 已选择 ${ quantity } 份,共 ¥ ${ totals . insuranceAmount } ` : '未购买保险' } } < / text >
< / view >
< view class = "ml-3 flex h-6 w-11 items-center rounded-full p-0.5" : class = "draft.insuranceSelected ? 'bg-emerald-500' : 'bg-slate-300'" >
< view class = "h-5 w-5 rounded-full bg-white shadow transition-transform" : class = "draft.insuranceSelected ? 'translate-x-5' : 'translate-x-0'" > < / view >
< / view >
< / view >
< view class = "rounded-2xl bg-amber-50 px-4 py-3 text-xs leading-5 text-amber-700" > 提交订单前将校验实名制门票所需的出行人信息 , 请确认姓名及证件号码与有效证件一致 。 < / view >
< / view >
< / scroll-view >
< 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-5 00" > 商品 ¥ { { totals . productAmount } } { { draft . insuranceSelected ? ` + 保险¥ ${ totals . insuranceAmount } ` : '' } } < / text >
< text class = "mt-1 block text-lg font-semi bold text-ros e-5 00" > 合计 ¥ { { totals . paidAmount } } < / text >
< view class = "absolute bottom-0 left-0 right-0 z-20 box-border flex w-full min-w-0 items-center gap-3 border-t border-slate-200 bg-white p-4 pb-[calc(env(safe-area-inset-bottom)+8px )]" >
< view class = "shrink-0 " >
< text class = "block text-[10px] text-slate-4 00" > 商品 ¥ { { totals . productAmount } } { { draft . insuranceSelected ? ` + 保险 ¥ ${ totals . insuranceAmount } ` : '' } } < / text >
< text class = "block text-x l font-bold text-orang e-6 00" > ¥ { { totals . paidAmount } } < / text >
< / view >
< button class = "m-0 rounded-full bg-emerald-600 px-4 py-3 text-sm font-semibold text-white" @tap ="emit('submit')" > 提交订单并模拟 支付 < / button >
< button class = "booking-button m-0 ml-auto h-12 w-[270rpx] flex-none rounded-full bg-orange-500 px-3 text-sm font-bold leading-[48px] text-white" @tap ="emit('submit')" > 提交订单并支付 < / button >
< / view >
< / view >
< / template >
@@ -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-4 00'
if ( isSoldOut ( item ) ) return 'border-slate-100 bg-slate-50 text-slate-3 00'
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 . d elete( id ) : ids . add ( id )
patchDraft ( { travelerIds : [ ... ids ] } )
const ids = [ ... new Set ( props . draft . travelerIds || [ ] ) ]
const isS elec ted = 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 } )
< / script >
< style scoped >
. booking - button : : after {
border : 0 ;
}
< / style >