855 lines
27 KiB
Vue
855 lines
27 KiB
Vue
<template>
|
||
<view class="page-shell cust-appt-create">
|
||
<view class="create-nav nav-gradient" :style="navSafeStyle">
|
||
<view class="nav-back" @click="goBack"><AppIcon name="back" :size="18" color="#ffffff" /></view>
|
||
<text class="nav-title">创建预约</text>
|
||
<view style="width:32px"></view>
|
||
</view>
|
||
|
||
<view class="c-hero">
|
||
<text class="c-hero-title">预约宠物服务</text>
|
||
<text class="c-hero-sub">在线预约,专业服务</text>
|
||
</view>
|
||
|
||
<view class="c-form-card">
|
||
<view v-if="useCompactStoreRow" class="c-field">
|
||
<text class="app-form-label">预约门店</text>
|
||
<view class="c-store-summary-row">
|
||
<text class="c-store-summary-name">{{ currentStoreDisplayName }}</text>
|
||
<text
|
||
v-if="orderedStores.length > 1 && storeLockedCompact"
|
||
class="c-store-change-link"
|
||
@click="expandStorePicker"
|
||
>更换门店</text>
|
||
</view>
|
||
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
|
||
</view>
|
||
<view v-else class="c-field">
|
||
<text class="app-form-label">预约门店</text>
|
||
<picker
|
||
mode="selector"
|
||
:range="orderedStores"
|
||
range-key="pickerLabel"
|
||
:value="selectedStoreIndex"
|
||
:disabled="!orderedStores.length"
|
||
@change="onStorePickerChange"
|
||
>
|
||
<view class="app-form-input app-form-picker">
|
||
<text :class="{ 'app-form-placeholder': !orderedStores.length }">{{ storePickerDisplay }}</text>
|
||
<text class="app-form-arrow">›</text>
|
||
</view>
|
||
</picker>
|
||
<text
|
||
v-if="showCollapseStoreLink"
|
||
class="c-store-collapse"
|
||
@click="onCollapseStorePicker"
|
||
>收起</text>
|
||
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
|
||
</view>
|
||
<view class="c-field">
|
||
<text class="app-form-label">宠物名字</text>
|
||
<view class="pet-name-row">
|
||
<input
|
||
v-model="newAppt.petName"
|
||
class="app-form-input pet-name-input"
|
||
placeholder="请输入宠物名字"
|
||
@input="onPetNameInput"
|
||
/>
|
||
<button
|
||
v-if="myPets.length > 1"
|
||
class="app-btn-sm app-btn-soft pet-switch-btn"
|
||
@click="openPetSwitcher"
|
||
>切换宠物</button>
|
||
</view>
|
||
</view>
|
||
<view class="c-field">
|
||
<text class="app-form-label">宠物类型</text>
|
||
<picker mode="selector" :range="petTypes" range-key="label" @change="onPetTypeChange">
|
||
<view class="app-form-input app-form-picker">
|
||
<text :class="{ 'app-form-placeholder': !newAppt.petType }">{{ newAppt.petType || '请选择' }}</text>
|
||
<text class="app-form-arrow">›</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
<view class="c-field">
|
||
<text class="app-form-label">服务类型</text>
|
||
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
|
||
<view class="app-form-input app-form-picker">
|
||
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
|
||
<text class="app-form-arrow">›</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
<view class="c-field">
|
||
<text class="app-form-label">预约时段</text>
|
||
<view class="c-time-row">
|
||
<picker mode="date" :value="selectedDateStr" @change="onAppointmentDateChange" class="c-time-half">
|
||
<view class="app-form-input app-form-picker">
|
||
<text :class="{ 'app-form-placeholder': !selectedDateStr }">{{ selectedDateStr || '日期' }}</text>
|
||
</view>
|
||
</picker>
|
||
<picker
|
||
mode="selector"
|
||
:range="availableSlotLabels"
|
||
:value="slotPickerIndex"
|
||
:disabled="!availableTimes.length"
|
||
@change="onAppointmentTimeOnlyChange"
|
||
class="c-time-half c-time-half--slot"
|
||
>
|
||
<view class="app-form-input app-form-picker">
|
||
<text class="slot-range-text" :class="{ 'app-form-placeholder': !appointmentTime && !loadingSlots }">{{ loadingSlots ? '加载中…' : (selectedSlotRangeLabel || '选择时段') }}</text>
|
||
</view>
|
||
</picker>
|
||
</view>
|
||
<text class="c-store-hint">{{ bookingWindowHint }}</text>
|
||
<button
|
||
v-if="showTryTomorrow"
|
||
class="app-btn-sm app-btn-soft c-try-tomorrow"
|
||
@click="tryPickTomorrow"
|
||
>试选明天</button>
|
||
</view>
|
||
<view class="c-field">
|
||
<text class="app-form-label">备注(可选)</text>
|
||
<input v-model="newAppt.remark" class="app-form-input" placeholder="可选" />
|
||
</view>
|
||
<button class="app-btn-main app-btn-primary" style="margin-top:20px" :loading="creatingAppt" @click="confirmNewAppt">提交预约</button>
|
||
</view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted, watch } from 'vue'
|
||
import { onLoad, onUnload } from '@dcloudio/uni-app'
|
||
import {
|
||
createAppointment,
|
||
getServiceTypeList,
|
||
getPetList,
|
||
getStoreList,
|
||
createPet,
|
||
getAppointmentAvailableSlots
|
||
} from '../../api/index.js'
|
||
import AppIcon from '../../components/AppIcon.vue'
|
||
import { getUserSession, getStoreSession, setStoreSession, getGuestApptDraft, saveGuestApptDraft, clearGuestApptDraft, apptDraftUserKey } from '../../utils/session.js'
|
||
import { haversineKm } from '../../utils/geo.js'
|
||
import { normalizeToHalfHour, ymdLocal, formatHalfHourSlotRange } from '../../utils/halfHourTime.js'
|
||
|
||
const userInfo = getUserSession()
|
||
const storeInfo = getStoreSession()
|
||
const LAST_SUBMITTED_PET_KEY = `petstore_last_submitted_pet_${userInfo.id || 'guest'}`
|
||
|
||
const BOOKING_STORE_ID_KEY = 'petstore_booking_store_id'
|
||
|
||
const lastServiceStorageKey = (storeId) =>
|
||
`petstore_last_service_${userInfo.id || 'guest'}_${storeId}`
|
||
|
||
/** 扫码/深链带入的门店 id,用于预填并支持折叠展示 */
|
||
const routeStoreId = ref('')
|
||
const storeExpanded = ref(false)
|
||
|
||
/**
|
||
* 解析小程序码 scene 字段中的 storeId。
|
||
* 支持形如 `storeId=1`、`sid=1`、`?storeId=1` 的 query 串;也兼容被 encodeURIComponent 编码过的字符串。
|
||
*/
|
||
function parseSceneStoreId(scene) {
|
||
if (!scene) return ''
|
||
let decoded = String(scene)
|
||
try { decoded = decodeURIComponent(decoded) } catch (_) {}
|
||
const params = new URLSearchParams(decoded.replace(/^\?/, ''))
|
||
return params.get('storeId') || params.get('sid') || ''
|
||
}
|
||
|
||
const orderedStores = ref([])
|
||
const selectedStoreIndex = ref(0)
|
||
const hasUserLocation = ref(false)
|
||
|
||
onLoad((options) => {
|
||
// 读取顺序:普通 storeId query > 小程序码 scene 解码
|
||
const directStoreId = options?.storeId != null && String(options.storeId).trim() !== ''
|
||
? String(options.storeId).trim()
|
||
: ''
|
||
routeStoreId.value = directStoreId || parseSceneStoreId(options?.scene)
|
||
})
|
||
|
||
const storeLockedCompact = computed(() => {
|
||
if (!routeStoreId.value || !orderedStores.value.length) return false
|
||
const cur = orderedStores.value[selectedStoreIndex.value]
|
||
return !!(cur && String(cur.id) === String(routeStoreId.value))
|
||
})
|
||
|
||
const useCompactStoreRow = computed(() => {
|
||
if (!orderedStores.value.length) return false
|
||
if (orderedStores.value.length === 1) return true
|
||
return storeLockedCompact.value && !storeExpanded.value
|
||
})
|
||
|
||
const showCollapseStoreLink = computed(
|
||
() =>
|
||
!!routeStoreId.value &&
|
||
orderedStores.value.length > 1 &&
|
||
storeLockedCompact.value &&
|
||
storeExpanded.value
|
||
)
|
||
|
||
const currentStoreDisplayName = computed(() => {
|
||
const row = orderedStores.value[selectedStoreIndex.value]
|
||
if (!row) return orderedStores.value.length ? '请选择' : '暂无可选门店'
|
||
return row.pickerLabel || row.name || '—'
|
||
})
|
||
|
||
const expandStorePicker = () => {
|
||
storeExpanded.value = true
|
||
}
|
||
|
||
const onCollapseStorePicker = () => {
|
||
storeExpanded.value = false
|
||
}
|
||
|
||
const navSafeStyle = (() => {
|
||
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
|
||
let navHeight = statusBarHeight + 44
|
||
const menuRect = uni.getMenuButtonBoundingClientRect?.()
|
||
if (menuRect && menuRect.top && menuRect.height) {
|
||
const verticalGap = Math.max(menuRect.top - statusBarHeight, 4)
|
||
navHeight = statusBarHeight + verticalGap * 2 + menuRect.height
|
||
}
|
||
return `padding-top:${statusBarHeight}px;height:${navHeight}px;`
|
||
})()
|
||
|
||
const petTypes = [
|
||
{ label: '猫', value: '猫' },
|
||
{ label: '狗', value: '狗' },
|
||
{ label: '其他', value: '其他' }
|
||
]
|
||
|
||
const serviceTypes = ref([])
|
||
|
||
const storePickerDisplay = computed(() => {
|
||
const row = orderedStores.value[selectedStoreIndex.value]
|
||
if (!row) return orderedStores.value.length ? '请选择' : '暂无可选门店'
|
||
return row.pickerLabel
|
||
})
|
||
|
||
const storeHintLine = computed(() => {
|
||
if (!orderedStores.value.length) return ''
|
||
if (hasUserLocation.value) return '已根据定位优先展示最近门店,可点击切换'
|
||
return '未获取到定位时按列表排序,可手动选择门店'
|
||
})
|
||
|
||
const buildOrderedStores = (raw, lat, lng) => {
|
||
const withDist = raw.map((s) => {
|
||
const d =
|
||
lat != null && lng != null ? haversineKm(lat, lng, s.latitude, s.longitude) : null
|
||
return { ...s, distKm: d }
|
||
})
|
||
withDist.sort((a, b) => {
|
||
if (a.distKm == null && b.distKm == null) return (a.name || '').localeCompare(b.name || '', 'zh')
|
||
if (a.distKm == null) return 1
|
||
if (b.distKm == null) return -1
|
||
return a.distKm - b.distKm
|
||
})
|
||
return withDist.map((s) => ({
|
||
...s,
|
||
pickerLabel:
|
||
s.distKm != null
|
||
? `${s.name} · 约${s.distKm < 10 ? s.distKm.toFixed(1) : Math.round(s.distKm)}km`
|
||
: s.name
|
||
}))
|
||
}
|
||
|
||
const applySelectedStoreToSession = () => {
|
||
const s = orderedStores.value[selectedStoreIndex.value]
|
||
if (!s?.id) return
|
||
uni.setStorageSync(BOOKING_STORE_ID_KEY, s.id)
|
||
setStoreSession({
|
||
id: s.id,
|
||
name: s.name,
|
||
address: s.address,
|
||
phone: s.phone,
|
||
latitude: s.latitude,
|
||
longitude: s.longitude,
|
||
logo: s.logo
|
||
})
|
||
}
|
||
|
||
const initStoresAndLocation = async () => {
|
||
const res = await getStoreList()
|
||
if (res.code !== 200 || !Array.isArray(res.data) || !res.data.length) {
|
||
uni.showToast({ title: '暂无可预约门店', icon: 'none' })
|
||
return
|
||
}
|
||
let lat
|
||
let lng
|
||
try {
|
||
const loc = await new Promise((resolve, reject) => {
|
||
uni.getLocation({
|
||
type: 'gcj02',
|
||
success: resolve,
|
||
fail: reject
|
||
})
|
||
})
|
||
lat = loc.latitude
|
||
lng = loc.longitude
|
||
hasUserLocation.value = true
|
||
} catch (_) {
|
||
hasUserLocation.value = false
|
||
}
|
||
|
||
orderedStores.value = buildOrderedStores(res.data, lat, lng)
|
||
|
||
const savedId = uni.getStorageSync(BOOKING_STORE_ID_KEY)
|
||
const sid = storeInfo?.id ?? userInfo?.storeId
|
||
let pickIdx = 0
|
||
const qrIdx =
|
||
routeStoreId.value && orderedStores.value.length
|
||
? orderedStores.value.findIndex((x) => String(x.id) === String(routeStoreId.value))
|
||
: -1
|
||
if (qrIdx >= 0) {
|
||
pickIdx = qrIdx
|
||
storeExpanded.value = false
|
||
} else if (savedId && orderedStores.value.some((x) => String(x.id) === String(savedId))) {
|
||
pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(savedId))
|
||
} else if (sid && orderedStores.value.some((x) => String(x.id) === String(sid))) {
|
||
pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(sid))
|
||
}
|
||
selectedStoreIndex.value = Math.max(0, pickIdx)
|
||
applySelectedStoreToSession()
|
||
}
|
||
|
||
const onStorePickerChange = async (e) => {
|
||
selectedStoreIndex.value = Number(e.detail.value)
|
||
applySelectedStoreToSession()
|
||
newAppt.value.serviceType = ''
|
||
await loadServiceTypesForSelected()
|
||
await loadAvailableSlots()
|
||
}
|
||
const myPets = ref([])
|
||
const selectedPetId = ref(null)
|
||
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
|
||
const creatingAppt = ref(false)
|
||
|
||
/** 与日期选择器同步,避免无号源时丢失已选日期 */
|
||
const selectedDateStr = ref(ymdLocal())
|
||
const availableTimes = ref([])
|
||
const loadingSlots = ref(false)
|
||
const bookingWindowHint = ref('请选择半小时时间段;同一时段仅接待一单,已满或已过期不可选')
|
||
|
||
const normalizeText = (s) => (s || '').toString().trim().toLowerCase()
|
||
|
||
const findMatchedPet = (name, petType) =>
|
||
myPets.value.find(
|
||
(p) => normalizeText(p.name) === normalizeText(name) && normalizeText(p.petType) === normalizeText(petType)
|
||
)
|
||
|
||
const applyPetToForm = (pet) => {
|
||
if (!pet) return
|
||
selectedPetId.value = pet.id || null
|
||
newAppt.value.petName = pet.name || ''
|
||
newAppt.value.petType = pet.petType || ''
|
||
}
|
||
|
||
const openPetSwitcher = () => {
|
||
if (myPets.value.length <= 1) return
|
||
uni.showActionSheet({
|
||
itemList: myPets.value.map((p) => `${p.name}(${p.petType})`),
|
||
success: (res) => {
|
||
const pet = myPets.value[res.tapIndex]
|
||
if (pet) applyPetToForm(pet)
|
||
}
|
||
})
|
||
}
|
||
|
||
const onPetNameInput = (e) => {
|
||
const currentName = e?.detail?.value ?? newAppt.value.petName
|
||
if (!selectedPetId.value) return
|
||
const cur = myPets.value.find((p) => p.id === selectedPetId.value)
|
||
if (!cur) {
|
||
selectedPetId.value = null
|
||
return
|
||
}
|
||
if (normalizeText(currentName) !== normalizeText(cur.name)) {
|
||
selectedPetId.value = null
|
||
}
|
||
}
|
||
|
||
const onPetTypeChange = (e) => {
|
||
const i = Number(e.detail.value)
|
||
const nextType = petTypes[i]?.value || ''
|
||
newAppt.value.petType = nextType
|
||
if (!selectedPetId.value) return
|
||
const cur = myPets.value.find((p) => p.id === selectedPetId.value)
|
||
if (!cur || normalizeText(nextType) !== normalizeText(cur.petType)) {
|
||
selectedPetId.value = null
|
||
}
|
||
}
|
||
|
||
const appointmentTime = computed(() => {
|
||
const raw = newAppt.value.appointmentTime || ''
|
||
if (!raw.includes('T')) return ''
|
||
const part = (raw.split('T')[1] || '').slice(0, 5)
|
||
return normalizeToHalfHour(part)
|
||
})
|
||
|
||
const slotPickerIndex = computed(() => {
|
||
const t = appointmentTime.value
|
||
const i = availableTimes.value.indexOf(t)
|
||
return i >= 0 ? i : 0
|
||
})
|
||
|
||
/** 滚轮选项展示为「09:00–09:30」 */
|
||
const availableSlotLabels = computed(() =>
|
||
availableTimes.value.map((t) => formatHalfHourSlotRange(t))
|
||
)
|
||
|
||
const selectedSlotRangeLabel = computed(() =>
|
||
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : ''
|
||
)
|
||
|
||
const showTryTomorrow = computed(
|
||
() =>
|
||
!loadingSlots.value &&
|
||
!!selectedDateStr.value &&
|
||
availableTimes.value.length === 0
|
||
)
|
||
|
||
const tryPickTomorrow = () => {
|
||
const cur = selectedDateStr.value
|
||
if (!cur) return
|
||
const parts = cur.split('-').map((x) => parseInt(x, 10))
|
||
if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return
|
||
const d = new Date(parts[0], parts[1] - 1, parts[2])
|
||
if (Number.isNaN(d.getTime())) return
|
||
d.setDate(d.getDate() + 1)
|
||
selectedDateStr.value = ymdLocal(d)
|
||
loadAvailableSlots()
|
||
}
|
||
|
||
const loadAvailableSlots = async () => {
|
||
const row = orderedStores.value[selectedStoreIndex.value]
|
||
const storeId = row?.id
|
||
const date = selectedDateStr.value
|
||
if (!storeId || !date) {
|
||
availableTimes.value = []
|
||
return
|
||
}
|
||
loadingSlots.value = true
|
||
try {
|
||
const res = await getAppointmentAvailableSlots(storeId, date)
|
||
loadingSlots.value = false
|
||
if (res.code !== 200 || !res.data?.slots) {
|
||
availableTimes.value = []
|
||
uni.showToast({ title: res.message || '号源加载失败', icon: 'none' })
|
||
return
|
||
}
|
||
const ds = res.data.dayStart
|
||
const le = res.data.lastSlotStart
|
||
if (ds != null && le != null) {
|
||
const a = String(ds).slice(0, 5)
|
||
const b = String(le).slice(0, 5)
|
||
bookingWindowHint.value = `本店可约 ${a}~${b};以下为半小时时段,同一时段仅一单,已满或已过不可选`
|
||
}
|
||
availableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
|
||
if (!availableTimes.value.length) {
|
||
newAppt.value.appointmentTime = ''
|
||
uni.showToast({ title: '当日已无号源,请换一天', icon: 'none' })
|
||
return
|
||
}
|
||
const raw = newAppt.value.appointmentTime || ''
|
||
const datePart = raw.includes('T') ? raw.split('T')[0] : ''
|
||
const timePart = raw.includes('T')
|
||
? normalizeToHalfHour((raw.split('T')[1] || '').slice(0, 5))
|
||
: ''
|
||
if (datePart === date && timePart && availableTimes.value.includes(timePart)) {
|
||
newAppt.value.appointmentTime = `${date}T${timePart}`
|
||
} else {
|
||
newAppt.value.appointmentTime = `${date}T${availableTimes.value[0]}`
|
||
}
|
||
} catch (_) {
|
||
loadingSlots.value = false
|
||
availableTimes.value = []
|
||
uni.showToast({ title: '号源加载失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const onAppointmentDateChange = (e) => {
|
||
const date = e?.detail?.value || ''
|
||
if (!date) {
|
||
selectedDateStr.value = ''
|
||
newAppt.value.appointmentTime = ''
|
||
availableTimes.value = []
|
||
return
|
||
}
|
||
selectedDateStr.value = date
|
||
loadAvailableSlots()
|
||
}
|
||
|
||
const onAppointmentTimeOnlyChange = (e) => {
|
||
const idx = Number(e.detail.value)
|
||
const time = availableTimes.value[idx]
|
||
const date = selectedDateStr.value
|
||
if (!date) {
|
||
uni.showToast({ title: '请先选择日期', icon: 'none' })
|
||
return
|
||
}
|
||
if (!time) return
|
||
newAppt.value.appointmentTime = `${date}T${time}`
|
||
}
|
||
|
||
const loadServiceTypesForSelected = async () => {
|
||
const row = orderedStores.value[selectedStoreIndex.value]
|
||
const sid = row?.id
|
||
const res = await getServiceTypeList(sid ?? undefined)
|
||
if (res.code === 200 && Array.isArray(res.data)) {
|
||
serviceTypes.value = res.data.map((s) => ({ label: s.name, value: s.name }))
|
||
if (sid) {
|
||
try {
|
||
const last = uni.getStorageSync(lastServiceStorageKey(sid))
|
||
if (last && serviceTypes.value.some((s) => s.value === last)) {
|
||
newAppt.value.serviceType = last
|
||
}
|
||
} catch (_) {}
|
||
}
|
||
}
|
||
}
|
||
|
||
const hydrateDefaultPet = () => {
|
||
if (!myPets.value.length) return
|
||
let picked = null
|
||
try {
|
||
const saved = JSON.parse(uni.getStorageSync(LAST_SUBMITTED_PET_KEY) || 'null')
|
||
if (saved) {
|
||
if (saved.petId) picked = myPets.value.find((p) => String(p.id) === String(saved.petId)) || null
|
||
if (!picked && saved.name && saved.petType) picked = findMatchedPet(saved.name, saved.petType) || null
|
||
}
|
||
} catch (_) {}
|
||
if (!picked) picked = myPets.value[0]
|
||
applyPetToForm(picked)
|
||
}
|
||
|
||
const rememberLastSubmittedPet = (pet) => {
|
||
if (!pet) return
|
||
uni.setStorageSync(
|
||
LAST_SUBMITTED_PET_KEY,
|
||
JSON.stringify({ petId: pet.id || null, name: pet.name || '', petType: pet.petType || '' })
|
||
)
|
||
}
|
||
|
||
const ensurePetProfile = async (name, petType) => {
|
||
const current = myPets.value.find((p) => String(p.id) === String(selectedPetId.value))
|
||
if (
|
||
current &&
|
||
normalizeText(current.name) === normalizeText(name) &&
|
||
normalizeText(current.petType) === normalizeText(petType)
|
||
) {
|
||
return current
|
||
}
|
||
const matched = findMatchedPet(name, petType)
|
||
if (matched) {
|
||
selectedPetId.value = matched.id || null
|
||
return matched
|
||
}
|
||
const res = await createPet({
|
||
ownerUserId: userInfo.id,
|
||
name: name.trim(),
|
||
petType: petType
|
||
})
|
||
if (res.code !== 200 || !res.data) {
|
||
return null
|
||
}
|
||
const created = res.data
|
||
myPets.value.unshift(created)
|
||
selectedPetId.value = created.id || null
|
||
return created
|
||
}
|
||
|
||
const confirmNewAppt = async () => {
|
||
const a = newAppt.value
|
||
if (!a.petName) { uni.showToast({ title: '请输入宠物名字', icon: 'none' }); return }
|
||
if (!a.petType) { uni.showToast({ title: '请选择宠物类型', icon: 'none' }); return }
|
||
if (!a.serviceType) { uni.showToast({ title: '请选择服务类型', icon: 'none' }); return }
|
||
if (!a.appointmentTime) { uni.showToast({ title: '请选择预约时间', icon: 'none' }); return }
|
||
if (!availableTimes.value.includes(appointmentTime.value)) {
|
||
uni.showToast({ title: '该时段不可预约,请重新选择', icon: 'none' })
|
||
await loadAvailableSlots()
|
||
return
|
||
}
|
||
const row = orderedStores.value[selectedStoreIndex.value]
|
||
const storeId = row?.id
|
||
if (!storeId) {
|
||
uni.showToast({ title: '请先选择预约门店', icon: 'none' })
|
||
return
|
||
}
|
||
// 提交时登录策略:未登录先保存 guest 草稿,跳登录页,登录后回到本页并恢复草稿。
|
||
if (!userInfo?.id) {
|
||
saveApptDraft()
|
||
const target = `/pages/appointment/CustAppointmentCreate?storeId=${encodeURIComponent(storeId)}`
|
||
const redirect = encodeURIComponent(target)
|
||
uni.navigateTo({ url: `/pages/login/Login?redirect=${redirect}` })
|
||
return
|
||
}
|
||
creatingAppt.value = true
|
||
const ensuredPet = await ensurePetProfile(a.petName, a.petType)
|
||
if (!ensuredPet?.id) {
|
||
creatingAppt.value = false
|
||
uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' })
|
||
return
|
||
}
|
||
const payload = { ...a, storeId }
|
||
payload.petId = ensuredPet.id
|
||
const res = await createAppointment(payload)
|
||
creatingAppt.value = false
|
||
if (res.code === 200) {
|
||
rememberLastSubmittedPet(ensuredPet)
|
||
try {
|
||
uni.setStorageSync(lastServiceStorageKey(storeId), a.serviceType)
|
||
} catch (_) {}
|
||
try {
|
||
const key = apptDraftStorageKey()
|
||
if (key) uni.removeStorageSync(key)
|
||
clearGuestApptDraft()
|
||
} catch (_) {}
|
||
uni.showToast({ title: '预约成功', icon: 'success' })
|
||
setTimeout(() => uni.navigateBack(), 600)
|
||
} else {
|
||
uni.showToast({ title: res.message || '创建失败', icon: 'none' })
|
||
}
|
||
}
|
||
|
||
const goBack = () => {
|
||
const pages = getCurrentPages()
|
||
if (pages.length > 1) uni.navigateBack()
|
||
else uni.reLaunch({ url: '/pages/home/Home' })
|
||
}
|
||
|
||
const loadMyPets = async () => {
|
||
// 未登录时跳过宠物列表请求;提交时登录后会重新加载本页并恢复草稿。
|
||
if (!userInfo?.id) {
|
||
myPets.value = []
|
||
return
|
||
}
|
||
const res = await getPetList({ ownerUserId: userInfo.id })
|
||
if (res.code === 200) {
|
||
myPets.value = res.data || []
|
||
hydrateDefaultPet()
|
||
}
|
||
}
|
||
|
||
/**
|
||
* P0 C3:草稿策略冻结为「提交时登录 + guest 草稿恢复」。
|
||
* - 未登录:写入 guest 草稿 key(不依赖 userId)。
|
||
* - 登录后:优先读 guest 草稿恢复,恢复成功后删除 guest 草稿;同时按 userId 维度保存草稿。
|
||
* 草稿有效期 7 天。
|
||
*/
|
||
const apptDraftStorageKey = () => {
|
||
const uid = userInfo?.id
|
||
return uid ? apptDraftUserKey(uid) : ''
|
||
}
|
||
|
||
const buildDraftPayload = () => {
|
||
const row = orderedStores.value[selectedStoreIndex.value]
|
||
return {
|
||
v: 1,
|
||
at: Date.now(),
|
||
petName: newAppt.value.petName,
|
||
petType: newAppt.value.petType,
|
||
serviceType: newAppt.value.serviceType,
|
||
appointmentTime: newAppt.value.appointmentTime,
|
||
remark: newAppt.value.remark,
|
||
selectedDateStr: selectedDateStr.value,
|
||
storeIndex: selectedStoreIndex.value,
|
||
storeId: row?.id
|
||
}
|
||
}
|
||
|
||
const applyDraft = (d) => {
|
||
if (!d || d.v !== 1) return
|
||
if (typeof d.remark === 'string') newAppt.value.remark = d.remark
|
||
if (d.serviceType) newAppt.value.serviceType = d.serviceType
|
||
if (d.appointmentTime) newAppt.value.appointmentTime = d.appointmentTime
|
||
if (d.petName) newAppt.value.petName = d.petName
|
||
if (d.petType) newAppt.value.petType = d.petType
|
||
if (typeof d.selectedDateStr === 'string' && d.selectedDateStr) selectedDateStr.value = d.selectedDateStr
|
||
if (typeof d.storeIndex === 'number' && orderedStores.value[d.storeIndex]) {
|
||
selectedStoreIndex.value = d.storeIndex
|
||
applySelectedStoreToSession()
|
||
} else if (d.storeId && orderedStores.value.some((x) => String(x.id) === String(d.storeId))) {
|
||
selectedStoreIndex.value = orderedStores.value.findIndex((x) => String(x.id) === String(d.storeId))
|
||
applySelectedStoreToSession()
|
||
}
|
||
selectedPetId.value = null
|
||
}
|
||
|
||
const saveApptDraft = () => {
|
||
try {
|
||
const payload = buildDraftPayload()
|
||
// 未登录:只写 guest 草稿;登录态:按 userId 维度保存。
|
||
if (!userInfo?.id) {
|
||
saveGuestApptDraft(payload)
|
||
return
|
||
}
|
||
const key = apptDraftStorageKey()
|
||
if (key) uni.setStorageSync(key, JSON.stringify(payload))
|
||
} catch (_) {}
|
||
}
|
||
|
||
const restoreApptDraft = () => {
|
||
try {
|
||
// 登录后优先恢复 guest 草稿(提交时登录场景),恢复后清理 guest 草稿。
|
||
const guest = getGuestApptDraft()
|
||
if (guest) {
|
||
if (Date.now() - (guest.at || 0) <= 7 * 86400000) {
|
||
applyDraft(guest)
|
||
}
|
||
clearGuestApptDraft()
|
||
return
|
||
}
|
||
if (!userInfo?.id) return
|
||
const key = apptDraftStorageKey()
|
||
if (!key) return
|
||
const raw = uni.getStorageSync(key)
|
||
if (!raw) return
|
||
const d = JSON.parse(raw)
|
||
if (Date.now() - (d.at || 0) > 7 * 86400000) {
|
||
uni.removeStorageSync(key)
|
||
return
|
||
}
|
||
applyDraft(d)
|
||
} catch (_) {}
|
||
}
|
||
|
||
let apptDraftTimer = null
|
||
watch(
|
||
() =>
|
||
JSON.stringify({
|
||
n: newAppt.value,
|
||
si: selectedStoreIndex.value,
|
||
ds: selectedDateStr.value
|
||
}),
|
||
() => {
|
||
if (apptDraftTimer) clearTimeout(apptDraftTimer)
|
||
apptDraftTimer = setTimeout(saveApptDraft, 650)
|
||
}
|
||
)
|
||
|
||
onUnload(() => {
|
||
saveApptDraft()
|
||
})
|
||
|
||
onMounted(async () => {
|
||
selectedDateStr.value = ymdLocal()
|
||
await initStoresAndLocation()
|
||
if (orderedStores.value.length) {
|
||
await loadServiceTypesForSelected()
|
||
}
|
||
await loadMyPets()
|
||
restoreApptDraft()
|
||
if (orderedStores.value.length) {
|
||
await loadServiceTypesForSelected()
|
||
}
|
||
await loadAvailableSlots()
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.cust-appt-create {
|
||
background: #fafaf8;
|
||
min-height: 100vh;
|
||
padding-bottom: env(safe-area-inset-bottom);
|
||
}
|
||
.create-nav {
|
||
padding: 14px 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 10;
|
||
}
|
||
.nav-back { font-size: 20px; color: #fff; }
|
||
.nav-title { font-size: 18px; font-weight: 700; color: #fff; }
|
||
|
||
.c-hero { padding: 24px 20px 20px; }
|
||
.c-hero-title {
|
||
display: block;
|
||
font-size: 26px;
|
||
font-weight: 800;
|
||
color: #1a1a1a;
|
||
letter-spacing: -0.5px;
|
||
}
|
||
.c-hero-sub {
|
||
display: block;
|
||
font-size: 14px;
|
||
color: var(--c-text-3);
|
||
margin-top: 4px;
|
||
}
|
||
.c-form-card {
|
||
margin: 0 16px;
|
||
background: #fff;
|
||
border-radius: 16px;
|
||
padding: 24px 20px;
|
||
box-shadow: 0 2px 8px rgba(0,0,0,0.04);
|
||
}
|
||
.c-field { margin-bottom: 16px; }
|
||
.c-time-row { display: flex; gap: 10px; }
|
||
.c-time-half { flex: 1; min-width: 0; }
|
||
.c-time-half--slot .slot-range-text {
|
||
font-size: 15px;
|
||
letter-spacing: 0;
|
||
}
|
||
.pet-name-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
}
|
||
.pet-name-input {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
.pet-switch-btn {
|
||
width: 68px !important;
|
||
padding: 0 !important;
|
||
color: var(--c-brand) !important;
|
||
flex-shrink: 0;
|
||
}
|
||
.c-store-hint {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 13px;
|
||
color: #666660;
|
||
line-height: 1.5;
|
||
}
|
||
.c-store-summary-row {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 12px;
|
||
padding: 12px 14px;
|
||
background: #f5f5f2;
|
||
border-radius: 12px;
|
||
}
|
||
.c-store-summary-name {
|
||
flex: 1;
|
||
min-width: 0;
|
||
font-size: 15px;
|
||
font-weight: 600;
|
||
color: #1a1a1a;
|
||
}
|
||
.c-store-change-link {
|
||
flex-shrink: 0;
|
||
font-size: 14px;
|
||
font-weight: 600;
|
||
color: var(--c-brand);
|
||
padding: 6px 0 6px 8px;
|
||
}
|
||
.c-store-collapse {
|
||
display: block;
|
||
margin-top: 8px;
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--c-brand);
|
||
}
|
||
.c-try-tomorrow {
|
||
margin-top: 12px;
|
||
width: 100%;
|
||
}
|
||
</style>
|