Files
lilishop-uniapp/pages/order/appointment/appointmentCheckout.vue
pikachu1995@126.com 2107e3f03a feat: 补齐预约下单售后链路,并接入礼品卡结算
立即购买先选时段再结算,免物流地址,售后按数量退款;同步接入礼品卡入口。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-20 17:07:27 +08:00

564 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="page">
<view class="section">
<view class="section-title">预约日期</view>
<picker mode="date" :value="slotDate" :start="minDate" :end="maxDate" @change="onDateChange">
<view class="picker-row">
<text>{{ slotDate || '请选择日期' }}</text>
<u-icon name="arrow-right" color="#ccc" size="16" />
</view>
</picker>
</view>
<view class="section">
<view class="section-title">预约时段</view>
<view v-if="loadingSlots" class="tip">加载时段中...</view>
<view v-else-if="!slots.length" class="tip">该日期暂无可预约时段</view>
<view v-else class="slot-list">
<view
v-for="slot in slots"
:key="slot.id"
class="slot-item"
:class="{ active: selectedSlot && selectedSlot.id === slot.id }"
@click="selectSlot(slot)"
>
{{ slot.slotStartTime }}-{{ slot.slotEndTime }}
<text v-if="slot.showRemainingQty && slot.availableQty != null" class="slot-qty">
{{ slot.availableQty }}
</text>
</view>
</view>
</view>
<view class="section" v-if="serviceModes.length > 1">
<view class="section-title">服务方式</view>
<view class="mode-list">
<view
v-for="mode in serviceModes"
:key="mode"
class="mode-item"
:class="{ active: serviceMode === mode }"
@click="serviceMode = mode"
>
{{ SERVICE_MODE_LABEL[mode] || mode }}
</view>
</view>
</view>
<view class="section">
<view class="section-title">联系人</view>
<u-input v-model="contactName" placeholder="请输入联系人姓名" border="surround" />
<view class="input-gap" />
<u-input v-model="contactPhone" placeholder="请输入联系电话" type="number" border="surround" />
</view>
<view class="section" v-if="serviceMode === SERVICE_MODE.ON_SITE">
<view class="section-title">服务地址</view>
<u-input
v-model="serviceAddress"
type="textarea"
placeholder="请输入上门服务地址"
border="surround"
height="100"
/>
</view>
<view class="section" v-for="(group, gi) in formAnswers" :key="gi" v-show="formFields.length">
<view class="section-title">
{{ formAnswers.length > 1 ? `补充信息(${gi + 1}/${formAnswers.length}` : '补充信息' }}
</view>
<view v-for="field in formFields" :key="field.fieldKey" class="form-field">
<view v-if="field.richText" class="rich-text" v-html="field.richTextContent" />
<template v-else>
<view class="field-label">
{{ field.fieldLabel }}
<text v-if="field.required" class="required">*</text>
</view>
<view v-if="field.fieldType === 'CHECKBOX'" class="opt-list">
<view
v-for="opt in field.options"
:key="opt.value"
class="opt-item"
@click="toggleCheckbox(gi, field, opt.value)"
>
<text class="opt-mark">{{ isChecked(group[field.fieldKey], opt.value) ? '☑' : '☐' }}</text>
{{ opt.label }}
</view>
</view>
<view v-else-if="field.fieldType === 'RADIO'" class="opt-list">
<view
v-for="opt in field.options"
:key="opt.value"
class="opt-item"
@click="setFormAnswer(gi, field.fieldKey, opt.value)"
>
<text class="opt-mark">{{ group[field.fieldKey] === opt.value ? '●' : '○' }}</text>
{{ opt.label }}
</view>
</view>
<picker
v-else-if="field.fieldType === 'SELECT'"
:range="field.optionLabels"
@change="(e) => onSelectChange(gi, field, e)"
>
<view class="picker-row">
<text>{{ optionLabel(field, group[field.fieldKey]) || field.hint }}</text>
<text class="arrow"></text>
</view>
</picker>
<picker
v-else-if="field.fieldType === 'DATE'"
mode="date"
:value="group[field.fieldKey]"
@change="(e) => setFormAnswer(gi, field.fieldKey, e.detail.value)"
>
<view class="picker-row">
<text>{{ group[field.fieldKey] || field.hint }}</text>
<text class="arrow"></text>
</view>
</picker>
<view v-else-if="field.fieldType === 'DATE_RANGE'" class="range-row">
<picker mode="date" :value="rangePart(group[field.fieldKey], 0)" @change="(e) => setRangePart(gi, field.fieldKey, 0, e.detail.value)">
<view class="picker-row"><text>{{ rangePart(group[field.fieldKey], 0) || '开始日期' }}</text></view>
</picker>
<text class="range-sep"></text>
<picker mode="date" :value="rangePart(group[field.fieldKey], 1)" @change="(e) => setRangePart(gi, field.fieldKey, 1, e.detail.value)">
<view class="picker-row"><text>{{ rangePart(group[field.fieldKey], 1) || '结束日期' }}</text></view>
</picker>
</view>
<picker
v-else-if="field.fieldType === 'TIME'"
mode="time"
:value="group[field.fieldKey]"
@change="(e) => setFormAnswer(gi, field.fieldKey, e.detail.value)"
>
<view class="picker-row">
<text>{{ group[field.fieldKey] || field.hint }}</text>
<text class="arrow"></text>
</view>
</picker>
<view v-else-if="field.fieldType === 'TIME_RANGE'" class="range-row">
<picker mode="time" :value="rangePart(group[field.fieldKey], 0)" @change="(e) => setRangePart(gi, field.fieldKey, 0, e.detail.value)">
<view class="picker-row"><text>{{ rangePart(group[field.fieldKey], 0) || '开始时间' }}</text></view>
</picker>
<text class="range-sep"></text>
<picker mode="time" :value="rangePart(group[field.fieldKey], 1)" @change="(e) => setRangePart(gi, field.fieldKey, 1, e.detail.value)">
<view class="picker-row"><text>{{ rangePart(group[field.fieldKey], 1) || '结束时间' }}</text></view>
</picker>
</view>
<picker
v-else-if="field.fieldType === 'CITY'"
mode="region"
@change="(e) => onCityChange(gi, field, e)"
>
<view class="picker-row">
<text>{{ group[field.fieldKey] || field.hint }}</text>
<text class="arrow"></text>
</view>
</picker>
<view v-else-if="field.fieldType === 'IMAGE'" class="image-row">
<view
v-for="(url, ui) in group[field.fieldKey] || []"
:key="ui"
class="image-thumb"
@click="previewImage(group[field.fieldKey], ui)"
>
<image :src="url" mode="aspectFill" />
</view>
<view
v-if="(group[field.fieldKey] || []).length < field.maxUploadCount"
class="image-add"
@click="chooseImages(gi, field)"
>+</view>
</view>
<u-input
v-else
:model-value="group[field.fieldKey]"
:placeholder="field.hint"
border="surround"
:type="field.contentType === 'NUMBER' ? 'number' : 'text'"
@update:model-value="(val) => setFormAnswer(gi, field.fieldKey, val)"
/>
</template>
</view>
</view>
<view class="section notice">
<view class="section-title">预约须知</view>
<text class="notice-text">{{ cancelNotice }}</text>
</view>
<view class="footer">
<u-button type="primary" shape="circle" :loading="submitting" @click="submit">
确认并去支付
</u-button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { listSlots, bindCheckout, getGoodsExt } from '@/api/appointment.js'
import {
SERVICE_MODE,
SERVICE_MODE_LABEL,
parseServiceModes,
buildBookableDates,
buildCancelNotice,
formatDate,
parseFormFields,
buildFormGroups,
isBlankFormValue,
validateTextContent,
} from '@/utils/appointmentGoods.js'
const skuId = ref('')
const goodsId = ref('')
const num = ref(1)
const goodsExt = ref<any>(null)
const slotDate = ref('')
const slots = ref<any[]>([])
const selectedSlot = ref<any>(null)
const serviceModes = ref<string[]>([SERVICE_MODE.IN_STORE])
const serviceMode = ref(SERVICE_MODE.IN_STORE)
const contactName = ref('')
const contactPhone = ref('')
const serviceAddress = ref('')
const loadingSlots = ref(false)
const submitting = ref(false)
const bookableDates = ref<string[]>([])
const formFields = ref<any[]>([])
const formAnswers = ref<Record<string, any>[]>([])
const cancelNotice = computed(() => buildCancelNotice(goodsExt.value))
const minDate = computed(() => bookableDates.value[0] || formatDate(new Date()))
const maxDate = computed(() => {
const list = bookableDates.value
return list.length ? list[list.length - 1] : minDate.value
})
onLoad((options) => {
skuId.value = options?.skuId || ''
goodsId.value = options?.goodsId || ''
num.value = Number(options?.num) || 1
})
onMounted(async () => {
if (!goodsId.value) return
try {
const res = await getGoodsExt(goodsId.value)
goodsExt.value = res.data?.result || null
serviceModes.value = parseServiceModes(goodsExt.value)
serviceMode.value = serviceModes.value[0] || SERVICE_MODE.IN_STORE
bookableDates.value = buildBookableDates(goodsExt.value)
slotDate.value = bookableDates.value[0] || formatDate(new Date())
formFields.value = parseFormFields(goodsExt.value?.systemFormSnapshot)
formAnswers.value = buildFormGroups(goodsExt.value, num.value)
await loadSlots()
} catch {
bookableDates.value = buildBookableDates(null)
slotDate.value = bookableDates.value[0] || formatDate(new Date())
}
})
watch(slotDate, () => {
selectedSlot.value = null
loadSlots()
})
async function loadSlots() {
if (!skuId.value || !slotDate.value) return
loadingSlots.value = true
try {
const res = await listSlots(skuId.value, slotDate.value)
slots.value = res.data?.result || []
} catch {
slots.value = []
} finally {
loadingSlots.value = false
}
}
function onDateChange(e: any) {
slotDate.value = e.detail?.value || ''
}
function selectSlot(slot: any) {
if (!slot?.availableQty || slot.availableQty <= 0) {
uni.showToast({ title: '该时段已满', icon: 'none' })
return
}
selectedSlot.value = slot
}
function setFormAnswer(groupIndex: number, fieldKey: string, value: any) {
const next = [...formAnswers.value]
next[groupIndex] = { ...next[groupIndex], [fieldKey]: value }
formAnswers.value = next
}
function isChecked(value: any, opt: string) {
return Array.isArray(value) && value.includes(opt)
}
function toggleCheckbox(groupIndex: number, field: any, opt: string) {
const current = formAnswers.value[groupIndex]?.[field.fieldKey]
const list = Array.isArray(current) ? [...current] : []
const idx = list.indexOf(opt)
if (idx >= 0) list.splice(idx, 1)
else list.push(opt)
setFormAnswer(groupIndex, field.fieldKey, list)
}
function optionLabel(field: any, value: string) {
return field.options?.find((item: any) => item.value === value)?.label || value
}
function onSelectChange(groupIndex: number, field: any, e: any) {
const idx = Number(e.detail?.value)
const opt = field.options?.[idx]
setFormAnswer(groupIndex, field.fieldKey, opt?.value || '')
}
function rangePart(value: string, index: number) {
return String(value || '').split('~')[index] || ''
}
function setRangePart(groupIndex: number, fieldKey: string, index: number, part: string) {
const current = String(formAnswers.value[groupIndex]?.[fieldKey] || '').split('~')
current[index] = part
if (!current[0]) current[0] = ''
if (!current[1]) current[1] = ''
setFormAnswer(groupIndex, fieldKey, `${current[0]}~${current[1]}`)
}
function onCityChange(groupIndex: number, field: any, e: any) {
const parts = e.detail?.value || []
let depth = 3
if (field.cityLevel === 'PROVINCE_CITY') depth = 2
setFormAnswer(groupIndex, field.fieldKey, parts.slice(0, depth).join(' '))
}
function previewImage(list: string[], index: number) {
uni.previewImage({ urls: list, current: list[index] })
}
function chooseImages(groupIndex: number, field: any) {
const current = Array.isArray(formAnswers.value[groupIndex]?.[field.fieldKey])
? [...formAnswers.value[groupIndex][field.fieldKey]]
: []
const remain = Math.max((field.maxUploadCount || 1) - current.length, 0)
if (!remain) return
uni.chooseImage({
count: remain,
success: (res) => {
setFormAnswer(groupIndex, field.fieldKey, current.concat(res.tempFilePaths || []))
},
})
}
function validate() {
if (!selectedSlot.value) {
uni.showToast({ title: '请选择预约时段', icon: 'none' })
return false
}
if (!contactName.value.trim()) {
uni.showToast({ title: '请填写联系人', icon: 'none' })
return false
}
if (!contactPhone.value.trim()) {
uni.showToast({ title: '请填写联系电话', icon: 'none' })
return false
}
if (serviceMode.value === SERVICE_MODE.ON_SITE && !serviceAddress.value.trim()) {
uni.showToast({ title: '请填写服务地址', icon: 'none' })
return false
}
for (const field of formFields.value) {
if (field.richText) continue
for (const group of formAnswers.value) {
const val = group?.[field.fieldKey]
if (field.required && isBlankFormValue(val)) {
uni.showToast({ title: `请填写${field.fieldLabel}`, icon: 'none' })
return false
}
if (field.fieldType === 'TEXT' && !validateTextContent(field, val)) {
uni.showToast({ title: `请填写正确的${field.fieldLabel}`, icon: 'none' })
return false
}
}
}
return true
}
async function submit() {
if (!validate()) return
submitting.value = true
try {
const slot = selectedSlot.value
const checkoutPayload = {
skuId: skuId.value,
slotStockId: slot.id,
serviceMode: serviceMode.value,
slotDate: slotDate.value,
slotStartTime: slot.slotStartTime,
slotEndTime: slot.slotEndTime,
contactName: contactName.value.trim(),
contactPhone: contactPhone.value.trim(),
serviceAddressSnapshot:
serviceMode.value === SERVICE_MODE.ON_SITE ? serviceAddress.value.trim() : '',
formAnswerJson: formAnswers.value.length
? JSON.stringify({ answers: formAnswers.value })
: '',
}
await bindCheckout(checkoutPayload)
uni.setStorageSync('appointment_checkout_context', checkoutPayload)
uni.navigateTo({ url: '/pages/order/fillorder?way=BUY_NOW' })
} catch (e: any) {
uni.showToast({
title: e?.data?.message || e?.message || '预约信息提交失败',
icon: 'none',
})
} finally {
submitting.value = false
}
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: #f5f5f5;
padding: 24rpx 24rpx 160rpx;
}
.section {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 20rpx;
}
.section-title {
font-size: 28rpx;
font-weight: 600;
margin-bottom: 20rpx;
}
.picker-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 28rpx;
color: #333;
}
.tip {
color: #999;
font-size: 26rpx;
}
.slot-list,
.mode-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.slot-item,
.mode-item {
padding: 16rpx 28rpx;
background: #f2f2f2;
border-radius: 30rpx;
font-size: 24rpx;
color: #333;
border: 2rpx solid transparent;
}
.slot-item.active,
.mode-item.active {
background: #fff5f5;
border-color: #f2270c;
color: #f2270c;
}
.slot-qty {
margin-left: 8rpx;
color: #999;
}
.input-gap {
height: 16rpx;
}
.arrow {
color: #ccc;
font-size: 32rpx;
}
.opt-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.opt-item {
font-size: 26rpx;
color: #333;
}
.opt-mark {
margin-right: 8rpx;
}
.range-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.range-sep {
color: #999;
font-size: 24rpx;
}
.image-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
}
.image-thumb,
.image-add {
width: 120rpx;
height: 120rpx;
border-radius: 8rpx;
overflow: hidden;
background: #f5f5f5;
}
.image-thumb image {
width: 100%;
height: 100%;
}
.image-add {
display: flex;
align-items: center;
justify-content: center;
color: #999;
font-size: 48rpx;
border: 1rpx dashed #ddd;
}
.field-label {
font-size: 26rpx;
color: #333;
margin-bottom: 8rpx;
}
.required {
color: #f2270c;
margin-left: 4rpx;
}
.rich-text {
font-size: 24rpx;
color: #666;
line-height: 1.6;
}
.notice-text {
font-size: 24rpx;
color: #666;
line-height: 1.6;
}
.footer {
position: fixed;
left: 0;
right: 0;
bottom: 0;
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
background: #fff;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
}
</style>