Files
lilishop-uniapp/pages/order/appointment/appointmentCheckout.vue
pikachu1995@126.com 0bffe81c6b feat: 更新预约功能与礼品卡支持
- 新增礼品卡页面,支持礼品卡的选择与使用。
- 调整预约下单流程,增强预约信息展示与用户交互。
- 更新支付成功页面的导航逻辑,确保用户体验流畅。
- 优化搜索页面的历史关键词加载逻辑,提升性能。
2026-09-28 14:37:26 +08:00

1132 lines
33 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" :style="themeStyle">
<view class="section product-section" v-if="goodsDetail.id">
<view class="section-title">商品信息</view>
<view class="product-row">
<image class="product-thumb" :src="goodsDetail.thumbnail" mode="aspectFill" />
<view class="product-info">
<view class="product-name">{{ goodsDetail.goodsName }}</view>
<view class="product-price">
<text class="price-symbol">¥</text>
<text class="price-int">{{ goodsFormatPrice(goodsDetail.price)[0] }}</text>
<text class="price-dec">.{{ goodsFormatPrice(goodsDetail.price)[1] }}</text>
</view>
</view>
</view>
<view
v-for="(spec, specIndex) in formatList"
:key="spec.name"
class="spec-block"
>
<view class="spec-block-title">{{ spec.name }}</view>
<view class="spec-list">
<view
v-for="(specVal, specValIndex) in spec.values"
:key="`${specVal.value}-${specValIndex}`"
class="spec-item"
:class="{ active: specVal.value === currentSelected[specIndex] }"
@click="handleClickSpec(specIndex, specVal)"
>
{{ specVal.value }}
</view>
</view>
</view>
<view class="quantity-row">
<text class="quantity-label">购买数量</text>
<uni-number-box v-model="num" :min="1" :max="maxBuyNum" />
</view>
</view>
<view class="section" v-if="serviceModes.length">
<view class="section-title">服务方式</view>
<view class="mode-list" :class="{ 'mode-list-split': serviceModes.length > 1 }">
<view
v-for="mode in serviceModes"
:key="mode"
class="mode-item"
:class="{ active: serviceMode === mode, disabled: serviceModes.length === 1 }"
@click="selectServiceMode(mode)"
>
{{ SERVICE_MODE_LABEL[mode] || mode }}
</view>
</view>
<text v-if="serviceModes.length > 1" class="mode-hint">
商品同时支持两种模式时须先选服务方式
</text>
</view>
<view class="section booking-time-section">
<view class="section-title">预约时间</view>
<view class="booking-time-body">
<view class="calendar-header">
<view class="month-nav" @click="shiftMonth(-1)">
<u-icon name="arrow-left" size="16" color="#666" />
</view>
<text class="month-title">{{ monthTitle }}</text>
<view class="month-nav" @click="shiftMonth(1)">
<u-icon name="arrow-right" size="16" color="#666" />
</view>
</view>
<view class="calendar-grid">
<view class="weekday-row">
<text v-for="label in WEEKDAY_LABELS" :key="label" class="weekday-cell">{{ label }}</text>
</view>
<view v-for="(week, weekIndex) in monthWeeks" :key="weekIndex" class="date-row">
<view
v-for="day in week"
:key="day.value"
class="date-cell"
:class="{
active: slotDate === day.value,
disabled: !day.bookable,
'out-month': !day.inMonth,
today: day.isToday,
}"
@click="selectCalendarDate(day)"
>
<view class="today-tag">{{ day.isToday ? '今' : '' }}</view>
<view class="date-num" :class="{ 'date-num-active': slotDate === day.value }">
{{ day.day }}
</view>
</view>
</view>
</view>
<view v-if="loadingSlots" class="tip slot-tip">加载时段中...</view>
<view v-else-if="!visibleSlots.length" class="tip slot-tip">该日期暂无可预约时段</view>
<view v-else class="slot-panel-list">
<view
v-for="slot in visibleSlots"
:key="slot.id"
class="slot-panel-item"
:class="{
active: selectedSlot && selectedSlot.id === slot.id,
full: isSlotFull(slot),
}"
@click="selectSlot(slot)"
>
<text class="slot-time">{{ slot.slotStartTime }} - {{ slot.slotEndTime }}</text>
<text class="slot-status">{{ slotStatusText(slot) }}</text>
</view>
</view>
</view>
</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"
:class="{ 'form-field-block': isBlockFormField(field) }"
>
<view v-if="field.richText" class="rich-text" v-html="field.richTextContent" />
<template v-else>
<view class="field-label">
<text v-if="field.required" class="required">*</text>
<text>{{ field.fieldLabel }}</text>
</view>
<view class="field-body">
<view v-if="field.fieldType === 'CHECKBOX'" class="opt-list">
<view
v-for="opt in field.options"
:key="opt.value"
class="opt-item"
:class="{ active: isChecked(group[field.fieldKey], opt.value) }"
@click="toggleCheckbox(gi, field, opt.value)"
>
{{ 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"
:class="{ active: group[field.fieldKey] === opt.value }"
@click="setFormAnswer(gi, field.fieldKey, opt.value)"
>
{{ 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 :class="{ 'field-placeholder': !group[field.fieldKey] }">{{ 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 :class="{ 'field-placeholder': !group[field.fieldKey] }">{{ 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 :class="{ 'field-placeholder': !rangePart(group[field.fieldKey], 0) }">{{ 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 :class="{ 'field-placeholder': !rangePart(group[field.fieldKey], 1) }">{{ 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 :class="{ 'field-placeholder': !group[field.fieldKey] }">{{ 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 :class="{ 'field-placeholder': !rangePart(group[field.fieldKey], 0) }">{{ 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 :class="{ 'field-placeholder': !rangePart(group[field.fieldKey], 1) }">{{ 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 :class="{ 'field-placeholder': !group[field.fieldKey] }">{{ 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="none"
placeholder-style="color:#c0c4cc;font-size:28rpx"
:custom-style="{ padding: '0', background: 'transparent' }"
:type="field.contentType === 'NUMBER' || field.contentType === 'MOBILE' ? 'number' : 'text'"
@update:model-value="(val) => setFormAnswer(gi, field.fieldKey, val)"
/>
</view>
</template>
</view>
</view>
<view class="section" v-if="serviceMode === SERVICE_MODE.ON_SITE">
<view class="section-title">服务地址</view>
<view v-if="serviceAreaRequired" class="loc-btn" @click="chooseServiceLocation">地图选点</view>
<u-input
v-model="serviceAddress"
type="textarea"
:disabled="serviceAreaRequired"
:placeholder="serviceAreaRequired ? '请在地图上选择上门地址' : '请输入上门服务地址'"
border="surround"
height="100"
/>
<text v-if="serviceAreaRequired" class="loc-hint">商家已设置上门范围,请在地图上选择服务地址</text>
</view>
<view class="section notice">
<view class="section-title">预约须知</view>
<view v-if="bookableDateNotice" class="notice-text">{{ bookableDateNotice }}</view>
<view v-if="bookingRangeNotice" class="notice-text">{{ bookingRangeNotice }}</view>
<view v-if="advanceNotice" class="notice-text">{{ advanceNotice }}</view>
<view class="notice-text">{{ cancelNotice }}</view>
</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 { getGoods } from '@/api/goods.js'
import * as API_trade from '@/api/trade.js'
import { listSlots, bindCheckout, getGoodsExt } from '@/api/appointment.js'
import uniNumberBox from '@/components/uni-number-box.vue'
import { goodsFormatPrice, parseGoodsImageUrl } from '@/utils/filters.js'
import { getAppointmentMaxBuyNum } from '@/utils/goodsType.js'
import { useStore } from '@/store'
import { getThemeStyle } from '@/utils/theme'
import {
SERVICE_MODE,
SERVICE_MODE_LABEL,
parseServiceModes,
buildBookableDates,
buildBookableDateNotice,
buildBookingRangeNotice,
buildAdvanceNotice,
buildCancelNotice,
formatDate,
parseFormFields,
buildFormGroups,
isBlankFormValue,
validateTextContent,
pickContactFromForm,
buildFormDisplayRows,
buildSpecFormatList,
findSkuIdBySpecSelection,
getSpecSelectionForSku,
WEEKDAY_LABELS,
buildMonthCalendar,
formatMonthTitle,
monthStartDate,
shiftMonthDate,
isSlotFull,
isSlotStartAfterNow,
getSlotAvailableQty,
slotStatusText,
} from '@/utils/appointmentGoods.js'
const store = useStore()
const themeStyle = computed(() => getThemeStyle(store.state.theme))
const skuId = ref('')
const goodsId = ref('')
const num = ref(1)
const goodsDetail = ref<any>({})
const goodsSpec = ref<any[]>([])
const formatList = ref<any[]>([])
const currentSelected = ref<string[]>([])
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 serviceAddress = ref('')
const serviceLongitude = ref<number | null>(null)
const serviceLatitude = ref<number | null>(null)
const loadingSlots = ref(false)
const submitting = ref(false)
const bookableDates = ref<string[]>([])
const formFields = ref<any[]>([])
const formAnswers = ref<Record<string, any>[]>([])
const calendarMonth = ref('')
const bookableDateNotice = computed(() => buildBookableDateNotice(goodsExt.value))
const bookingRangeNotice = computed(() => buildBookingRangeNotice(goodsExt.value))
const advanceNotice = computed(() => buildAdvanceNotice(goodsExt.value))
const cancelNotice = computed(() => buildCancelNotice(goodsExt.value))
const serviceAreaRequired = computed(() => !!goodsExt.value?.serviceAreaRequired)
const bookableDateSet = computed(() => new Set(bookableDates.value))
const monthWeeks = computed(() =>
buildMonthCalendar(
calendarMonth.value || slotDate.value || formatDate(new Date()),
bookableDateSet.value
)
)
const monthTitle = computed(() =>
formatMonthTitle(calendarMonth.value || slotDate.value || formatDate(new Date()))
)
const maxBuyNum = computed(() => {
const goodsMax = getAppointmentMaxBuyNum(goodsDetail.value)
const slotLimit = getSlotAvailableQty(selectedSlot.value)
if (slotLimit == null) return goodsMax
return Math.min(goodsMax, slotLimit)
})
const visibleSlots = computed(() =>
(slots.value || []).filter((slot) => isSlotStartAfterNow(slot, slotDate.value))
)
onLoad((options) => {
skuId.value = options?.skuId || ''
goodsId.value = options?.goodsId || ''
num.value = Number(options?.num) || 1
})
onMounted(async () => {
if (!goodsId.value) return
await loadGoodsDetail()
initSpecSelection()
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] || ''
calendarMonth.value = monthStartDate(slotDate.value || formatDate(new Date()))
formFields.value = parseFormFields(goodsExt.value?.systemFormSnapshot)
formAnswers.value = buildFormGroups(goodsExt.value, num.value)
} catch {
bookableDates.value = buildBookableDates(null)
slotDate.value = bookableDates.value[0] || ''
calendarMonth.value = monthStartDate(slotDate.value || formatDate(new Date()))
}
})
watch(slotDate, () => {
selectedSlot.value = null
loadSlots()
})
watch(num, (val) => {
const limit = maxBuyNum.value
if (limit > 0 && val > limit) {
num.value = limit
return
}
formAnswers.value = buildFormGroups(goodsExt.value, val)
})
watch(selectedSlot, (slot) => {
const limit = getSlotAvailableQty(slot)
if (limit == null || num.value <= limit) return
num.value = Math.max(limit, 1)
if (limit > 0) {
uni.showToast({ title: `该时段最多可预约${limit}个`, icon: 'none' })
}
})
async function loadGoodsDetail() {
if (!goodsId.value) return
const res = await getGoods(skuId.value || 'undefined', goodsId.value)
if (!res?.data?.success) {
uni.showToast({ title: res?.data?.message || '商品加载失败', icon: 'none' })
return
}
goodsDetail.value = res.data.result?.data || {}
goodsDetail.value.thumbnail = parseGoodsImageUrl(goodsDetail.value.thumbnail)
goodsSpec.value = res.data.result?.specs || []
if (!skuId.value && goodsSpec.value.length) {
skuId.value = goodsSpec.value[0].skuId
}
}
function initSpecSelection() {
formatList.value = buildSpecFormatList(goodsSpec.value)
if (skuId.value) {
currentSelected.value = getSpecSelectionForSku(goodsSpec.value, skuId.value)
return
}
if (goodsSpec.value.length) {
skuId.value = goodsSpec.value[0].skuId
currentSelected.value = getSpecSelectionForSku(goodsSpec.value, skuId.value)
}
}
async function handleClickSpec(specIndex: number, specVal: any) {
currentSelected.value[specIndex] = specVal.value
const nextSkuId = findSkuIdBySpecSelection(goodsSpec.value, currentSelected.value)
if (!nextSkuId) {
uni.showToast({ title: '暂无该规格', icon: 'none' })
return
}
if (nextSkuId === skuId.value) return
skuId.value = nextSkuId
selectedSlot.value = null
await loadGoodsDetail()
initSpecSelection()
await 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 shiftMonth(delta: number) {
const anchor = calendarMonth.value || slotDate.value || formatDate(new Date())
calendarMonth.value = shiftMonthDate(anchor, delta)
}
function selectCalendarDate(day: { value: string; bookable: boolean; inMonth?: boolean }) {
if (!day?.bookable) return
slotDate.value = day.value
if (day.inMonth === false) {
calendarMonth.value = monthStartDate(day.value)
}
}
function isBlockFormField(field: any) {
const type = field?.fieldType
return type === 'CHECKBOX' || type === 'RADIO' || type === 'IMAGE' || type === 'DATE_RANGE' || type === 'TIME_RANGE' || field?.richText
}
function selectServiceMode(mode: string) {
if (!serviceModes.value.includes(mode)) return
serviceMode.value = mode
}
function selectSlot(slot: any) {
if (!isSlotStartAfterNow(slot, slotDate.value)) {
uni.showToast({ title: '请选择当前时间之后的时段', icon: 'none' })
return
}
if (isSlotFull(slot)) {
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 chooseServiceLocation() {
const open = () => {
uni.chooseLocation({
success(res) {
const address = [res.address, res.name].filter(Boolean).join(' ')
serviceAddress.value = address
serviceLongitude.value = res.longitude
serviceLatitude.value = res.latitude
},
})
}
// #ifdef MP-WEIXIN
uni.authorize({
scope: 'scope.userLocation',
success: open,
fail() {
uni.showModal({
content: '需要定位权限才能选择上门地址',
confirmText: '去设置',
success: (res) => {
if (res.confirm) {
uni.openSetting({ success: open })
}
},
})
},
})
// #endif
// #ifndef MP-WEIXIN
open()
// #endif
}
function validate() {
if (!selectedSlot.value) {
uni.showToast({ title: '请选择预约时段', icon: 'none' })
return false
}
if (!isSlotStartAfterNow(selectedSlot.value, slotDate.value)) {
uni.showToast({ title: '请选择当前时间之后的时段', icon: 'none' })
return false
}
const slotLimit = getSlotAvailableQty(selectedSlot.value)
if (slotLimit != null && num.value > slotLimit) {
uni.showToast({ title: `该时段最多可预约${slotLimit}个`, icon: 'none' })
return false
}
if (serviceMode.value === SERVICE_MODE.ON_SITE && !serviceAddress.value.trim()) {
uni.showToast({ title: serviceAreaRequired.value ? '请在地图上选择服务地址' : '请填写服务地址', icon: 'none' })
return false
}
if (
serviceMode.value === SERVICE_MODE.ON_SITE
&& serviceAreaRequired.value
&& (serviceLongitude.value == null || serviceLatitude.value == null)
) {
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 cartRes = await API_trade.addToCart({
skuId: skuId.value,
num: num.value,
cartType: 'BUY_NOW',
})
if (!cartRes.data?.success) {
uni.showToast({ title: cartRes.data?.message || '下单失败', icon: 'none' })
return
}
const slot = selectedSlot.value
const contact = pickContactFromForm(formFields.value, formAnswers.value)
const checkoutPayload = {
skuId: skuId.value,
slotStockId: slot.id,
serviceMode: serviceMode.value,
slotDate: slotDate.value,
slotStartTime: slot.slotStartTime,
slotEndTime: slot.slotEndTime,
contactName: contact.contactName,
contactPhone: contact.contactPhone,
serviceAddressSnapshot:
serviceMode.value === SERVICE_MODE.ON_SITE ? serviceAddress.value.trim() : '',
serviceLongitude:
serviceMode.value === SERVICE_MODE.ON_SITE ? serviceLongitude.value : null,
serviceLatitude:
serviceMode.value === SERVICE_MODE.ON_SITE ? serviceLatitude.value : null,
formAnswerJson: formAnswers.value.length
? JSON.stringify({ answers: formAnswers.value })
: '',
formRows: buildFormDisplayRows(formFields.value, 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;
}
.loc-btn {
display: inline-block;
margin-bottom: 16rpx;
padding: 8rpx 24rpx;
border: 1rpx solid var(--theme-primary, #ff3c2a);
border-radius: 8rpx;
color: var(--theme-primary, #ff3c2a);
font-size: 26rpx;
}
.loc-hint {
display: block;
margin-top: 12rpx;
color: #999;
font-size: 24rpx;
}
.product-row {
display: flex;
gap: 20rpx;
}
.product-thumb {
width: 140rpx;
height: 140rpx;
border-radius: 12rpx;
flex-shrink: 0;
background: #f5f5f5;
}
.product-info {
flex: 1;
min-width: 0;
}
.product-name {
font-size: 28rpx;
font-weight: 600;
color: #333;
line-height: 1.4;
}
.product-price {
margin-top: 12rpx;
color: #f2270c;
font-weight: 600;
}
.price-symbol,
.price-dec {
font-size: 24rpx;
}
.price-int {
font-size: 36rpx;
}
.spec-block {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #f0f0f0;
}
.spec-block-title {
font-size: 26rpx;
color: #333;
font-weight: 600;
margin-bottom: 16rpx;
}
.quantity-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid #f0f0f0;
}
.quantity-label {
font-size: 28rpx;
color: #333;
}
.spec-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.spec-item {
padding: 16rpx 28rpx;
background: #f2f2f2;
border-radius: 30rpx;
font-size: 24rpx;
color: #333;
border: 2rpx solid transparent;
}
.spec-item.active {
background: #fff5f5;
border-color: #f2270c;
color: #f2270c;
}
.form-field {
display: flex;
align-items: center;
gap: 24rpx;
min-height: 96rpx;
padding: 8rpx 0;
border-bottom: 1rpx solid #f2f2f2;
}
.form-field:last-child {
border-bottom: none;
padding-bottom: 0;
}
.form-field-block {
flex-direction: column;
align-items: stretch;
gap: 16rpx;
padding: 20rpx 0;
}
.field-body {
flex: 1;
min-width: 0;
}
.field-body picker {
display: block;
width: 100%;
}
.field-body :deep(.u-input) {
padding: 0 !important;
background: transparent !important;
}
.field-placeholder {
color: #c0c4cc;
}
.picker-row {
display: flex;
justify-content: space-between;
align-items: center;
width: 100%;
min-height: 44rpx;
font-size: 28rpx;
color: #333;
}
.tip {
color: #999;
font-size: 26rpx;
}
.mode-list {
display: flex;
gap: 16rpx;
}
.mode-list-split .mode-item {
flex: 1;
width: 0;
}
.booking-time-body {
margin-top: 0;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: center;
gap: 32rpx;
margin-bottom: 20rpx;
}
.month-title {
font-size: 28rpx;
font-weight: 600;
color: #333;
min-width: 180rpx;
text-align: center;
}
.month-nav {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
}
.calendar-grid {
margin-top: 4rpx;
}
.weekday-row,
.date-row {
display: flex;
align-items: flex-start;
}
.date-row + .date-row {
margin-top: 8rpx;
}
.weekday-cell,
.date-cell {
flex: 1;
text-align: center;
}
.weekday-cell {
font-size: 22rpx;
color: #999;
padding-bottom: 12rpx;
}
.date-cell {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: 84rpx;
padding: 0;
box-sizing: border-box;
}
.date-cell.disabled {
opacity: 0.45;
}
.date-cell.disabled .date-num,
.date-cell.disabled .today-tag {
color: #ccc;
}
.date-cell.out-month .date-num {
color: #ccc;
}
.date-cell.out-month:not(.disabled) .date-num {
color: #999;
}
.today-tag {
height: 22rpx;
line-height: 22rpx;
font-size: 18rpx;
color: var(--theme-primary, #ff3c2a);
}
.date-num {
width: 52rpx;
height: 52rpx;
line-height: 52rpx;
margin-top: 2rpx;
border-radius: 50%;
font-size: 28rpx;
color: #333;
}
.date-num-active,
.date-cell.active .date-num {
background: var(--theme-primary, #ff3c2a);
color: #fff;
font-weight: 600;
}
.slot-tip {
margin-top: 24rpx;
}
.slot-panel-list {
margin-top: 24rpx;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.slot-panel-item {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 28rpx;
background: #fff;
border: 2rpx solid #e8e8e8;
border-radius: 12rpx;
box-sizing: border-box;
}
.slot-panel-item.active {
background: var(--theme-primary-10, rgba(255, 60, 42, 0.1));
border-color: var(--theme-primary, #ff3c2a);
}
.slot-panel-item.full {
background: #fafafa;
border-color: #eee;
}
.slot-panel-item.full .slot-time,
.slot-panel-item.full .slot-status {
color: #bbb;
}
.slot-time {
font-size: 28rpx;
color: #333;
}
.slot-panel-item.active .slot-time {
color: var(--theme-primary, #ff3c2a);
font-weight: 600;
}
.slot-status {
font-size: 24rpx;
color: #666;
flex-shrink: 0;
margin-left: 16rpx;
}
.mode-item {
padding: 20rpx 16rpx;
background: #fff;
border-radius: 12rpx;
font-size: 26rpx;
color: #333;
border: 2rpx solid #e5e5e5;
box-sizing: border-box;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
}
.mode-item.active {
background: var(--theme-primary-10, rgba(255, 60, 42, 0.1));
border-color: var(--theme-primary, #ff3c2a);
color: var(--theme-primary, #ff3c2a);
font-weight: 600;
}
.mode-hint {
display: block;
margin-top: 16rpx;
font-size: 24rpx;
color: #999;
line-height: 1.5;
}
.input-gap {
height: 16rpx;
}
.arrow {
color: #ccc;
font-size: 32rpx;
}
.opt-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
gap: 16rpx;
}
.opt-item {
padding: 12rpx 28rpx;
background: #f6f7f9;
border-radius: 999rpx;
border: 2rpx solid transparent;
font-size: 26rpx;
color: #333;
line-height: 1.4;
}
.opt-item.active {
background: var(--theme-primary-10, rgba(255, 60, 42, 0.1));
border-color: var(--theme-primary, #ff3c2a);
color: var(--theme-primary, #ff3c2a);
}
.range-row {
display: flex;
align-items: center;
gap: 12rpx;
}
.range-row picker {
flex: 1;
min-width: 0;
}
.range-row .picker-row {
background: #f6f7f9;
border-radius: 12rpx;
padding: 16rpx 20rpx;
box-sizing: border-box;
}
.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 {
flex: 0 0 168rpx;
width: 168rpx;
font-size: 28rpx;
color: #333;
line-height: 1.4;
}
.form-field-block .field-label {
flex: none;
width: auto;
}
.required {
color: #f2270c;
margin-right: 4rpx;
}
.rich-text {
font-size: 24rpx;
color: #666;
line-height: 1.6;
}
.notice-text {
display: block;
font-size: 24rpx;
color: #666;
line-height: 1.6;
}
.notice-text + .notice-text {
margin-top: 8rpx;
}
.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>