feat: 常用出行人的调整

This commit is contained in:
2026-07-31 09:38:02 +08:00
parent 15c8d5f36d
commit 6a9135770b
4 changed files with 100 additions and 34 deletions

View File

@@ -1,42 +1,67 @@
<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-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 class="box-border w-full min-w-0 px-4 py-4 pb-8">
<view class="rounded-2xl bg-white p-4 shadow-sm">
<view class="flex items-start gap-3">
<view class="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-50">
<uni-icons type="personadd" size="21" color="#2563eb" />
</view>
<text v-if="!selectionMode" class="text-slate-300"></text>
</button>
<view class="min-w-0 flex-1">
<text class="block text-base font-semibold text-slate-900">新增出行人</text>
<text class="mt-1 block text-xs leading-5 text-slate-500">保存后购买实名票时可直接选择并自动填写</text>
</view>
</view>
<view class="mt-4 flex flex-col gap-3">
<input class="box-border h-11 w-full rounded-xl border border-slate-200 px-3 text-sm" :value="form.name" maxlength="20" placeholder="姓名" @input="setFormField('name', $event.detail.value)" />
<view class="box-border flex h-11 w-full items-center rounded-xl border border-slate-200 bg-white px-3">
<text class="mr-3 shrink-0 border-r border-slate-200 pr-3 text-sm text-slate-500">身份证</text>
<input class="min-w-0 flex-1 text-sm" :value="form.idNumber" maxlength="18" placeholder="18位身份证号码" @input="setFormField('idNumber', $event.detail.value.toUpperCase())" />
</view>
<input class="box-border h-11 w-full rounded-xl border border-slate-200 px-3 text-sm" type="number" :value="form.phone" maxlength="11" placeholder="手机号(选填)" @input="setFormField('phone', $event.detail.value)" />
</view>
<text v-if="formError" class="mt-3 block rounded-xl bg-rose-50 px-3 py-2 text-xs leading-5 text-rose-500">{{ formError }}</text>
<button class="m-0 mt-4 h-11 w-full rounded-xl bg-blue-600 p-0 text-sm font-semibold leading-[44px] text-white" @tap="saveTraveler">保存出行人</button>
</view>
<view v-else class="flex flex-col items-center rounded-2xl bg-white px-4 py-14 text-center">
<view class="mt-4 flex items-center justify-between px-1">
<text class="text-sm font-semibold text-slate-900">已保存的出行人</text>
<text class="text-xs text-slate-400">姓名及证件信息</text>
</view>
<view v-if="travelers.length" class="mt-2 flex flex-col gap-[12px]">
<view v-for="traveler in travelers" :key="traveler.id" class="box-border 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">
<text class="text-base font-semibold">{{ traveler.name }}</text>
<view class="mt-2 flex min-w-0 items-center">
<text class="shrink-0 rounded bg-slate-100 px-1.5 py-0.5 text-xs text-slate-500">{{ traveler.idType }}</text>
<text class="ml-2 min-w-0 truncate text-sm text-slate-600">{{ maskId(traveler.idNumber) }}</text>
</view>
<view v-if="traveler.phone" class="mt-2 flex min-w-0 items-center">
<uni-icons type="phone" size="14" color="#64748b" />
<text class="ml-1 min-w-0 truncate text-xs text-slate-500">{{ maskPhone(traveler.phone) }}</text>
</view>
</view>
<view v-if="!selectionMode" class="ml-3 flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-slate-50" @tap.stop="emit('delete', traveler.id)">
<uni-icons type="trash" size="17" color="#94a3b8" />
</view>
</view>
</view>
<view v-else class="mt-2 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 class="mt-4 flex items-start gap-2 rounded-xl bg-blue-50 p-3 text-xs leading-5 text-blue-700">
<uni-icons class="mt-0.5 shrink-0" type="info" size="16" color="#1d4ed8" />
<text class="min-w-0 flex-1">出行人信息仅用于实名购票第三方出票和身份核验</text>
</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="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>
</template>
@@ -49,11 +74,9 @@ const props = defineProps({
selectionMode: { type: Boolean, default: false },
})
const emit = defineEmits(['toggle', 'save'])
const idTypes = ['身份证', '护照']
const showForm = ref(false)
const emit = defineEmits(['toggle', 'save', 'delete'])
const formError = ref('')
const form = reactive({ name: '', idType: '身份证', idNumber: '', phone: '' })
const form = reactive({ name: '', idNumber: '', phone: '' })
const isSelected = (id) => props.selectedIds.includes(id)
const selectTraveler = (id) => {
@@ -74,22 +97,30 @@ const setFormField = (key, 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 (!/^\d{17}[\dX]$/.test(form.idNumber.trim().toUpperCase())) {
formError.value = '请填写正确的18位身份证号码'
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 })
emit('save', {
id: `traveler-${Date.now()}`,
name: form.name.trim(),
idType: '身份证',
idNumber: form.idNumber.trim().toUpperCase(),
phone: form.phone,
})
resetForm()
}
</script>

View File

@@ -70,6 +70,17 @@ export const useTicketStore = defineStore("ticket-client", {
};
}
},
deleteTraveler(travelerId) {
this.travelers = this.travelers.filter(
(traveler) => traveler.id !== travelerId
);
this.bookingDraft = {
...this.bookingDraft,
travelerIds: (this.bookingDraft.travelerIds || []).filter(
(id) => id !== travelerId
),
};
},
submitBooking() {
const product = this.currentProduct;
const draft = this.bookingDraft;

View File

@@ -13,6 +13,7 @@
:selection-mode="selectionMode"
@toggle="ticketStore.toggleTraveler"
@save="saveTraveler"
@delete="ticketStore.deleteTraveler"
/>
</view>
</template>

View File

@@ -53,8 +53,6 @@ test("TopNavBar exposes an optional subtitle", async () => {
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"/);
@@ -78,3 +76,28 @@ test("order content uses bounded box-model wrappers", async () => {
assert.match(source, /min-w-0/);
}
});
test("traveler manager matches the approved prototype details", async () => {
const component = await readTicketFile("components/TravelerManager.vue");
const page = await readTicketFile("travelers.vue");
const store = await readTicketFile("store.js");
assert.match(component, /gap-\[12px\]/);
assert.match(component, /type="trash"/);
assert.match(component, /type="phone"/);
assert.match(component, /@tap\.stop="emit\('delete', traveler\.id\)"/);
assert.match(component, /身份证信息仅用于|出行人信息仅用于实名购票、第三方出票和身份核验。/);
assert.match(component, /18位身份证号码/);
assert.match(component, /placeholder="手机号(选填)"/);
assert.match(component, /请填写正确的手机号/);
assert.match(component, /bg-blue-600/);
assert.match(component, /已保存的出行人/);
assert.doesNotMatch(component, /showForm/);
assert.doesNotMatch(component, /取消/);
assert.equal((component.match(/新增出行人/g) || []).length, 1);
assert.ok(
component.indexOf("新增出行人") < component.indexOf('v-if="travelers.length"')
);
assert.match(page, /@delete="ticketStore\.deleteTraveler"/);
assert.match(store, /deleteTraveler\(travelerId\)/);
});