feat: add ticket booking views
This commit is contained in:
170
src/pages-service/tickets/components/BookingFlow.vue
Normal file
170
src/pages-service/tickets/components/BookingFlow.vue
Normal file
@@ -0,0 +1,170 @@
|
||||
<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>
|
||||
|
||||
<scroll-view scroll-y class="min-h-0 flex-1">
|
||||
<view class="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>
|
||||
</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>
|
||||
<scroll-view scroll-x class="mt-3 whitespace-nowrap">
|
||||
<view class="flex gap-2">
|
||||
<button
|
||||
v-for="item 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="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>
|
||||
</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-semibold">使用场次</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'"
|
||||
@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>
|
||||
</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>
|
||||
<input 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-20 text-sm text-slate-700">手机号</text>
|
||||
<input class="min-w-0 flex-1 text-right text-sm" type="number" maxlength="11" :value="draft.phone || ''" placeholder="用于接收订单通知" @input="updatePhone" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="flex items-center rounded-2xl bg-white px-4 py-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-500">{{ insurance.description }}</text>
|
||||
<text class="mt-1 block text-xs text-rose-500">¥{{ insurancePrice }}/人</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="flex 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>
|
||||
</view>
|
||||
<button class="m-0 rounded-full bg-emerald-600 px-4 py-3 text-sm font-semibold text-white" @tap="emit('submit')">提交订单并模拟支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { calculateBookingTotals } from '../model.mjs'
|
||||
|
||||
const props = defineProps({
|
||||
product: { type: Object, required: true },
|
||||
draft: { type: Object, required: true },
|
||||
travelers: { type: Array, default: () => [] },
|
||||
availableDates: { type: Array, default: () => [] },
|
||||
insurance: { type: Object, required: true },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', '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))
|
||||
const insurancePrice = computed(() => Number(props.insurance.price ?? props.draft.insurancePrice ?? 0))
|
||||
const totals = computed(() => calculateBookingTotals(props.product, quantity.value, props.draft.insuranceSelected, insurancePrice.value))
|
||||
const selectedTravelers = computed(() => {
|
||||
const selectedIds = new Set(props.draft.travelerIds || [])
|
||||
return props.travelers.filter((traveler) => selectedIds.has(traveler.id))
|
||||
})
|
||||
|
||||
const patchDraft = (patch) => emit('update-draft', { ...props.draft, ...patch })
|
||||
const isSoldOut = (item) => Number(item.stock) === 0
|
||||
const dateValue = (item) => item.date || item.value || ''
|
||||
const dateClass = (item) => {
|
||||
if (isSoldOut(item)) return 'border-slate-100 bg-slate-50 text-slate-400'
|
||||
return props.draft.visitDate === dateValue(item)
|
||||
? 'border-emerald-500 bg-emerald-50 text-emerald-700'
|
||||
: 'border-slate-200 bg-white 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 toggleTraveler = (id) => {
|
||||
const ids = new Set(props.draft.travelerIds || [])
|
||||
ids.has(id) ? ids.delete(id) : ids.add(id)
|
||||
patchDraft({ travelerIds: [...ids] })
|
||||
}
|
||||
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>
|
||||
100
src/pages-service/tickets/components/TravelerManager.vue
Normal file
100
src/pages-service/tickets/components/TravelerManager.vue
Normal file
@@ -0,0 +1,100 @@
|
||||
<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>
|
||||
|
||||
<scroll-view scroll-y class="min-h-0 flex-1">
|
||||
<view class="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>
|
||||
<view class="min-w-0 flex-1">
|
||||
<view class="flex items-center"><text class="text-base font-semibold">{{ traveler.name }}</text><text class="ml-2 rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-500">{{ traveler.idType }}</text></view>
|
||||
<text class="mt-2 block text-sm text-slate-600">{{ maskId(traveler.idNumber) }}</text>
|
||||
<text v-if="traveler.phone" class="mt-1 block text-xs text-slate-500">{{ maskPhone(traveler.phone) }}</text>
|
||||
</view>
|
||||
<text v-if="!selectionMode" class="text-slate-300">›</text>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-else class="flex flex-col items-center rounded-2xl bg-white px-4 py-14 text-center">
|
||||
<text class="text-4xl">🧳</text>
|
||||
<text class="mt-4 text-base font-semibold">还没有常用出行人</text>
|
||||
<text class="mt-2 text-sm text-slate-500">添加出行人后,可在实名制订单中快速选择</text>
|
||||
</view>
|
||||
|
||||
<view v-if="showForm" class="rounded-2xl bg-white px-4 py-4">
|
||||
<view class="flex items-center justify-between"><text class="text-base font-semibold">新增出行人</text><button class="m-0 p-0 text-sm text-slate-500" @tap="resetForm">取消</button></view>
|
||||
<view class="mt-3 flex items-center border-b border-slate-100 py-3"><text class="w-20 text-sm">姓名</text><input class="min-w-0 flex-1 text-right text-sm" :value="form.name" placeholder="请填写真实姓名" @input="setFormField('name', $event.detail.value)" /></view>
|
||||
<view class="flex items-center border-b border-slate-100 py-3"><text class="w-20 text-sm">证件类型</text><view class="flex flex-1 justify-end gap-2"><button v-for="type in idTypes" :key="type" class="m-0 rounded-full border px-3 py-1 text-xs" :class="form.idType === type ? 'border-emerald-500 bg-emerald-50 text-emerald-700' : 'border-slate-200 bg-white text-slate-600'" @tap="setFormField('idType', type)">{{ type }}</button></view></view>
|
||||
<view class="flex items-center border-b border-slate-100 py-3"><text class="w-20 text-sm">证件号码</text><input class="min-w-0 flex-1 text-right text-sm" :value="form.idNumber" placeholder="请填写证件号码" @input="setFormField('idNumber', $event.detail.value)" /></view>
|
||||
<view class="flex items-center py-3"><text class="w-20 text-sm">手机号</text><input class="min-w-0 flex-1 text-right text-sm" type="number" maxlength="11" :value="form.phone" placeholder="选填" @input="setFormField('phone', $event.detail.value)" /></view>
|
||||
<text v-if="formError" class="mt-1 block text-xs text-rose-500">{{ formError }}</text>
|
||||
<button class="mt-3 w-full rounded-xl bg-emerald-600 py-3 text-sm font-semibold text-white" @tap="saveTraveler">保存出行人</button>
|
||||
</view>
|
||||
|
||||
<view class="rounded-2xl bg-slate-200/60 px-4 py-3 text-xs leading-5 text-slate-500">我们会严格保护您的身份与联系方式,仅用于实名制预约、出票及出行服务。</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="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>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { reactive, ref } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
travelers: { type: Array, default: () => [] },
|
||||
selectedIds: { type: Array, default: () => [] },
|
||||
selectionMode: { type: Boolean, default: false },
|
||||
})
|
||||
|
||||
const emit = defineEmits(['back', 'toggle', 'save'])
|
||||
const idTypes = ['身份证', '护照']
|
||||
const showForm = ref(false)
|
||||
const formError = ref('')
|
||||
const form = reactive({ name: '', idType: '身份证', idNumber: '', phone: '' })
|
||||
|
||||
const isSelected = (id) => props.selectedIds.includes(id)
|
||||
const selectTraveler = (id) => {
|
||||
if (props.selectionMode) emit('toggle', id)
|
||||
}
|
||||
const maskId = (value) => {
|
||||
const id = String(value || '')
|
||||
if (id.length < 8) return id
|
||||
return `${id.slice(0, 3)}********${id.slice(-4)}`
|
||||
}
|
||||
const maskPhone = (value) => {
|
||||
const phone = String(value || '')
|
||||
return phone.length === 11 ? `${phone.slice(0, 3)}****${phone.slice(-4)}` : phone
|
||||
}
|
||||
const setFormField = (key, value) => {
|
||||
form[key] = value
|
||||
formError.value = ''
|
||||
}
|
||||
const resetForm = () => {
|
||||
form.name = ''
|
||||
form.idType = '身份证'
|
||||
form.idNumber = ''
|
||||
form.phone = ''
|
||||
formError.value = ''
|
||||
showForm.value = false
|
||||
}
|
||||
const saveTraveler = () => {
|
||||
if (!form.name.trim() || !form.idNumber.trim()) {
|
||||
formError.value = '请填写姓名和证件号码'
|
||||
return
|
||||
}
|
||||
if (form.phone && !/^1[3-9]\d{9}$/.test(form.phone)) {
|
||||
formError.value = '请填写正确的手机号'
|
||||
return
|
||||
}
|
||||
emit('save', { id: `traveler-${Date.now()}`, name: form.name.trim(), idType: form.idType, idNumber: form.idNumber.trim(), phone: form.phone })
|
||||
resetForm()
|
||||
}
|
||||
</script>
|
||||
Reference in New Issue
Block a user