feat: book against service duration
This commit is contained in:
parent
9a12ee07b0
commit
d29ece3a85
@ -86,9 +86,9 @@ export const getAppointmentDetail = (id) => get('/appointment/detail', { id })
|
|||||||
// 创建预约:登录人身份始终由后端派生;customer 传选中门店,门店代客预约传 customerUserId 或 customerPhone
|
// 创建预约:登录人身份始终由后端派生;customer 传选中门店,门店代客预约传 customerUserId 或 customerPhone
|
||||||
export const createAppointment = (data) => post('/appointment/create', data)
|
export const createAppointment = (data) => post('/appointment/create', data)
|
||||||
|
|
||||||
/** 门店某日可预约半小时档(可约 / 已满 / 已过);公开接口 */
|
/** 门店某日可预约开始时段;按服务时长与门店容量计算,公开接口。 */
|
||||||
export const getAppointmentAvailableSlots = (storeId, date) =>
|
export const getAppointmentAvailableSlots = (storeId, date, serviceType) =>
|
||||||
get('/appointment/available-slots', { storeId, date })
|
get('/appointment/available-slots', { storeId, date, serviceType })
|
||||||
|
|
||||||
// 开始服务(staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId)
|
// 开始服务(staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId)
|
||||||
export const startAppointment = (appointmentId/*, staffUserId */) =>
|
export const startAppointment = (appointmentId/*, staffUserId */) =>
|
||||||
@ -147,8 +147,9 @@ export const getServiceTypeList = (storeId) => {
|
|||||||
return get('/service-type/list', params)
|
return get('/service-type/list', params)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 创建服务类型
|
// 创建服务类型(storeId 仅保留旧签名兼容,后端从登录态派生)
|
||||||
export const createServiceType = (storeId, name) => post('/service-type/create', { storeId, name })
|
export const createServiceType = (storeId, name, durationMinutes) =>
|
||||||
|
post('/service-type/create', { storeId, name, durationMinutes })
|
||||||
|
|
||||||
// 删除服务类型
|
// 删除服务类型
|
||||||
export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`)
|
export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`)
|
||||||
|
|||||||
@ -38,6 +38,10 @@
|
|||||||
<text class="label">预约时间</text>
|
<text class="label">预约时间</text>
|
||||||
<text class="value time">{{ timeDisplay }}</text>
|
<text class="value time">{{ timeDisplay }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="detail.endTime" class="detail-row">
|
||||||
|
<text class="label">预计结束</text>
|
||||||
|
<text class="value time">{{ endTimeDisplay }}(约 {{ detail.durationMinutes || 60 }} 分钟)</text>
|
||||||
|
</view>
|
||||||
<view v-if="detail.remark" class="detail-row block">
|
<view v-if="detail.remark" class="detail-row block">
|
||||||
<text class="label">备注</text>
|
<text class="label">备注</text>
|
||||||
<text class="value remark">{{ detail.remark }}</text>
|
<text class="value remark">{{ detail.remark }}</text>
|
||||||
@ -84,6 +88,7 @@ const navSafeStyle = (() => {
|
|||||||
const statusText = computed(() => (detail.value ? getAppointmentStatusText(detail.value.status) : ''))
|
const statusText = computed(() => (detail.value ? getAppointmentStatusText(detail.value.status) : ''))
|
||||||
|
|
||||||
const timeDisplay = computed(() => formatDateTimeCN(detail.value?.appointmentTime))
|
const timeDisplay = computed(() => formatDateTimeCN(detail.value?.appointmentTime))
|
||||||
|
const endTimeDisplay = computed(() => formatDateTimeCN(detail.value?.endTime))
|
||||||
|
|
||||||
const tagClass = (status) => getAppointmentTagClass(status)
|
const tagClass = (status) => getAppointmentTagClass(status)
|
||||||
|
|
||||||
|
|||||||
@ -73,7 +73,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="c-field">
|
<view class="c-field">
|
||||||
<text class="app-form-label">服务类型</text>
|
<text class="app-form-label">服务类型</text>
|
||||||
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
|
<picker mode="selector" :range="serviceTypes" range-key="label" @change="onServiceTypeChange">
|
||||||
<view class="app-form-input app-form-picker">
|
<view class="app-form-input app-form-picker">
|
||||||
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
|
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
|
||||||
<text class="app-form-arrow">›</text>
|
<text class="app-form-arrow">›</text>
|
||||||
@ -330,8 +330,9 @@ const creatingAppt = ref(false)
|
|||||||
/** 与日期选择器同步,避免无号源时丢失已选日期 */
|
/** 与日期选择器同步,避免无号源时丢失已选日期 */
|
||||||
const selectedDateStr = ref(ymdLocal())
|
const selectedDateStr = ref(ymdLocal())
|
||||||
const availableTimes = ref([])
|
const availableTimes = ref([])
|
||||||
|
const availableSlotEnds = ref({})
|
||||||
const loadingSlots = ref(false)
|
const loadingSlots = ref(false)
|
||||||
const bookingWindowHint = ref('请选择半小时时间段;同一时段仅接待一单,已满或已过期不可选')
|
const bookingWindowHint = ref('请选择服务后查看可预约开始时间;系统会按服务时长校验完整容量')
|
||||||
|
|
||||||
const normalizeText = (s) => (s || '').toString().trim().toLowerCase()
|
const normalizeText = (s) => (s || '').toString().trim().toLowerCase()
|
||||||
|
|
||||||
@ -397,11 +398,13 @@ const slotPickerIndex = computed(() => {
|
|||||||
|
|
||||||
/** 滚轮选项展示为「09:00–09:30」 */
|
/** 滚轮选项展示为「09:00–09:30」 */
|
||||||
const availableSlotLabels = computed(() =>
|
const availableSlotLabels = computed(() =>
|
||||||
availableTimes.value.map((t) => formatHalfHourSlotRange(t))
|
availableTimes.value.map((t) => `${t}–${availableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
const selectedSlotRangeLabel = computed(() =>
|
const selectedSlotRangeLabel = computed(() =>
|
||||||
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : ''
|
appointmentTime.value
|
||||||
|
? `${appointmentTime.value}–${availableSlotEnds.value[appointmentTime.value] || formatHalfHourSlotRange(appointmentTime.value).slice(6)}`
|
||||||
|
: ''
|
||||||
)
|
)
|
||||||
|
|
||||||
const showTryTomorrow = computed(
|
const showTryTomorrow = computed(
|
||||||
@ -433,20 +436,24 @@ const loadAvailableSlots = async () => {
|
|||||||
}
|
}
|
||||||
loadingSlots.value = true
|
loadingSlots.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getAppointmentAvailableSlots(storeId, date)
|
const res = await getAppointmentAvailableSlots(storeId, date, newAppt.value.serviceType || undefined)
|
||||||
loadingSlots.value = false
|
loadingSlots.value = false
|
||||||
if (res.code !== 200 || !res.data?.slots) {
|
if (res.code !== 200 || !res.data?.slots) {
|
||||||
availableTimes.value = []
|
availableTimes.value = []
|
||||||
|
availableSlotEnds.value = {}
|
||||||
uni.showToast({ title: res.message || '号源加载失败', icon: 'none' })
|
uni.showToast({ title: res.message || '号源加载失败', icon: 'none' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const ds = res.data.dayStart
|
const ds = res.data.dayStart
|
||||||
const le = res.data.lastSlotStart
|
const closing = res.data.closingTime
|
||||||
if (ds != null && le != null) {
|
if (ds != null && closing != null) {
|
||||||
const a = String(ds).slice(0, 5)
|
const a = String(ds).slice(0, 5)
|
||||||
const b = String(le).slice(0, 5)
|
const b = String(closing).slice(0, 5)
|
||||||
bookingWindowHint.value = `本店可约 ${a}~${b};以下为半小时时段,同一时段仅一单,已满或已过不可选`
|
const mins = Number(res.data.durationMinutes || 60)
|
||||||
|
const capacity = Number(res.data.bookingCapacity || 1)
|
||||||
|
bookingWindowHint.value = `营业容量 ${a}~${b};本服务约 ${mins} 分钟,门店并发接待 ${capacity} 位`
|
||||||
}
|
}
|
||||||
|
availableSlotEnds.value = Object.fromEntries(res.data.slots.map((s) => [s.time, s.endTime]))
|
||||||
availableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
|
availableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
|
||||||
if (!availableTimes.value.length) {
|
if (!availableTimes.value.length) {
|
||||||
newAppt.value.appointmentTime = ''
|
newAppt.value.appointmentTime = ''
|
||||||
@ -466,6 +473,7 @@ const loadAvailableSlots = async () => {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
loadingSlots.value = false
|
loadingSlots.value = false
|
||||||
availableTimes.value = []
|
availableTimes.value = []
|
||||||
|
availableSlotEnds.value = {}
|
||||||
uni.showToast({ title: '号源加载失败', icon: 'none' })
|
uni.showToast({ title: '号源加载失败', icon: 'none' })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -494,12 +502,25 @@ const onAppointmentTimeOnlyChange = (e) => {
|
|||||||
newAppt.value.appointmentTime = `${date}T${time}`
|
newAppt.value.appointmentTime = `${date}T${time}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onServiceTypeChange = (e) => {
|
||||||
|
const selected = serviceTypes.value[Number(e.detail.value)]
|
||||||
|
newAppt.value.serviceType = selected?.value || ''
|
||||||
|
loadAvailableSlots()
|
||||||
|
}
|
||||||
|
|
||||||
const loadServiceTypesForSelected = async () => {
|
const loadServiceTypesForSelected = async () => {
|
||||||
const row = orderedStores.value[selectedStoreIndex.value]
|
const row = orderedStores.value[selectedStoreIndex.value]
|
||||||
const sid = row?.id
|
const sid = row?.id
|
||||||
const res = await getServiceTypeList(sid ?? undefined)
|
const res = await getServiceTypeList(sid ?? undefined)
|
||||||
if (res.code === 200 && Array.isArray(res.data)) {
|
if (res.code === 200 && Array.isArray(res.data)) {
|
||||||
serviceTypes.value = res.data.map((s) => ({ label: s.name, value: s.name }))
|
serviceTypes.value = res.data.map((s) => ({
|
||||||
|
label: `${s.name} · 约${Number(s.durationMinutes || 60)}分钟`,
|
||||||
|
value: s.name,
|
||||||
|
durationMinutes: Number(s.durationMinutes || 60)
|
||||||
|
}))
|
||||||
|
if (newAppt.value.serviceType && !serviceTypes.value.some((s) => s.value === newAppt.value.serviceType)) {
|
||||||
|
newAppt.value.serviceType = ''
|
||||||
|
}
|
||||||
if (sid) {
|
if (sid) {
|
||||||
try {
|
try {
|
||||||
const last = uni.getStorageSync(lastServiceStorageKey(sid))
|
const last = uni.getStorageSync(lastServiceStorageKey(sid))
|
||||||
|
|||||||
@ -228,7 +228,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="c-field">
|
<view class="c-field">
|
||||||
<text class="app-form-label">服务类型</text>
|
<text class="app-form-label">服务类型</text>
|
||||||
<picker mode="selector" :range="serviceTypes" range-key="label" @change="e => newAppt.serviceType = serviceTypes[e.detail.value].value">
|
<picker mode="selector" :range="serviceTypes" range-key="label" @change="onStaffServiceTypeChange">
|
||||||
<view class="app-form-input app-form-picker">
|
<view class="app-form-input app-form-picker">
|
||||||
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
|
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
|
||||||
<text class="app-form-arrow">›</text>
|
<text class="app-form-arrow">›</text>
|
||||||
@ -256,7 +256,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
<text class="c-store-hint home-slot-hint">每行半小时时段,约满不可选</text>
|
<text class="c-store-hint home-slot-hint">按服务时长与门店并发容量计算,约满不可选</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="c-field">
|
<view class="c-field">
|
||||||
<text class="app-form-label">备注(可选)</text>
|
<text class="app-form-label">备注(可选)</text>
|
||||||
@ -421,6 +421,7 @@ const appointmentTime = computed(() => {
|
|||||||
/** B 端弹窗:按半小时档选可约时段 */
|
/** B 端弹窗:按半小时档选可约时段 */
|
||||||
const staffApptDateStr = ref('')
|
const staffApptDateStr = ref('')
|
||||||
const staffAvailableTimes = ref([])
|
const staffAvailableTimes = ref([])
|
||||||
|
const staffAvailableSlotEnds = ref({})
|
||||||
const staffLoadingSlots = ref(false)
|
const staffLoadingSlots = ref(false)
|
||||||
|
|
||||||
const staffSlotPickerIndex = computed(() => {
|
const staffSlotPickerIndex = computed(() => {
|
||||||
@ -430,11 +431,13 @@ const staffSlotPickerIndex = computed(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const staffAvailableSlotLabels = computed(() =>
|
const staffAvailableSlotLabels = computed(() =>
|
||||||
staffAvailableTimes.value.map((t) => formatHalfHourSlotRange(t))
|
staffAvailableTimes.value.map((t) => `${t}–${staffAvailableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`)
|
||||||
)
|
)
|
||||||
|
|
||||||
const staffSelectedSlotRangeLabel = computed(() =>
|
const staffSelectedSlotRangeLabel = computed(() =>
|
||||||
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : ''
|
appointmentTime.value
|
||||||
|
? `${appointmentTime.value}–${staffAvailableSlotEnds.value[appointmentTime.value] || formatHalfHourSlotRange(appointmentTime.value).slice(6)}`
|
||||||
|
: ''
|
||||||
)
|
)
|
||||||
|
|
||||||
const filteredOrders = computed(() => orders.value.filter(o => {
|
const filteredOrders = computed(() => orders.value.filter(o => {
|
||||||
@ -525,12 +528,14 @@ const loadStaffAvailableSlots = async () => {
|
|||||||
staffApptDateStr.value = date
|
staffApptDateStr.value = date
|
||||||
staffLoadingSlots.value = true
|
staffLoadingSlots.value = true
|
||||||
try {
|
try {
|
||||||
const res = await getAppointmentAvailableSlots(storeInfo.id, date)
|
const res = await getAppointmentAvailableSlots(storeInfo.id, date, newAppt.value.serviceType || undefined)
|
||||||
staffLoadingSlots.value = false
|
staffLoadingSlots.value = false
|
||||||
if (res.code !== 200 || !res.data?.slots) {
|
if (res.code !== 200 || !res.data?.slots) {
|
||||||
staffAvailableTimes.value = []
|
staffAvailableTimes.value = []
|
||||||
|
staffAvailableSlotEnds.value = {}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
staffAvailableSlotEnds.value = Object.fromEntries(res.data.slots.map((s) => [s.time, s.endTime]))
|
||||||
staffAvailableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
|
staffAvailableTimes.value = res.data.slots.filter((s) => s.available).map((s) => s.time)
|
||||||
if (!staffAvailableTimes.value.length) {
|
if (!staffAvailableTimes.value.length) {
|
||||||
newAppt.value.appointmentTime = ''
|
newAppt.value.appointmentTime = ''
|
||||||
@ -550,6 +555,7 @@ const loadStaffAvailableSlots = async () => {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
staffLoadingSlots.value = false
|
staffLoadingSlots.value = false
|
||||||
staffAvailableTimes.value = []
|
staffAvailableTimes.value = []
|
||||||
|
staffAvailableSlotEnds.value = {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -559,6 +565,12 @@ const openStaffNewApptDialog = async () => {
|
|||||||
await loadStaffAvailableSlots()
|
await loadStaffAvailableSlots()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onStaffServiceTypeChange = (e) => {
|
||||||
|
const selected = serviceTypes.value[Number(e.detail.value)]
|
||||||
|
newAppt.value.serviceType = selected?.value || ''
|
||||||
|
loadStaffAvailableSlots()
|
||||||
|
}
|
||||||
|
|
||||||
const onStaffApptDateChange = (e) => {
|
const onStaffApptDateChange = (e) => {
|
||||||
const date = e?.detail?.value || ''
|
const date = e?.detail?.value || ''
|
||||||
if (!date) {
|
if (!date) {
|
||||||
@ -654,7 +666,11 @@ const loadServiceTypes = async () => {
|
|||||||
if (!storeInfo.id) return
|
if (!storeInfo.id) return
|
||||||
const res = await getServiceTypeList(storeInfo.id)
|
const res = await getServiceTypeList(storeInfo.id)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
serviceTypes.value = res.data.map(s => ({ label: s.name, value: s.name }))
|
serviceTypes.value = res.data.map(s => ({
|
||||||
|
label: `${s.name} · 约${Number(s.durationMinutes || 60)}分钟`,
|
||||||
|
value: s.name,
|
||||||
|
durationMinutes: Number(s.durationMinutes || 60)
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,7 +17,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="sched-hero-text">
|
<view class="sched-hero-text">
|
||||||
<text class="sched-hero-title">时段一览</text>
|
<text class="sched-hero-title">时段一览</text>
|
||||||
<text class="sched-hero-desc">绿色为客户预约;橙色为到店占用;灰色为暂停线上预约。点空白格可手动占用,点占用可取消。</text>
|
<text class="sched-hero-desc">绿色为客户预约;橙色为到店占用;灰色为暂停线上预约。每行显示已用/总容量。</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -48,7 +48,7 @@
|
|||||||
<view class="sched-main">
|
<view class="sched-main">
|
||||||
<template v-if="row.kind === 'appointment' && row.appointment">
|
<template v-if="row.kind === 'appointment' && row.appointment">
|
||||||
<text class="sched-line-title">{{ row.appointment.petName || '宠物' }} · {{ row.appointment.serviceType || '服务' }}</text>
|
<text class="sched-line-title">{{ row.appointment.petName || '宠物' }} · {{ row.appointment.serviceType || '服务' }}</text>
|
||||||
<text class="sched-line-sub">{{ apptStatusText(row.appointment.status) }}</text>
|
<text class="sched-line-sub">{{ apptStatusText(row.appointment.status) }} · 容量 {{ row.usedCapacity || 0 }}/{{ row.totalCapacity || 1 }}</text>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="row.kind === 'block' && row.block">
|
<template v-else-if="row.kind === 'block' && row.block">
|
||||||
<text class="sched-line-title">{{ blockTitle(row.block) }}</text>
|
<text class="sched-line-title">{{ blockTitle(row.block) }}</text>
|
||||||
@ -61,7 +61,7 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="sched-foot-hint">与「营业时间」一致,每半小时一档;占用后客户端该档不可再约。</view>
|
<view class="sched-foot-hint">与营业容量时段一致;服务和到店占用会跨半小时桶消耗名额,暂停预约会关闭全部名额。</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@ -212,6 +212,7 @@ function onRowTap(row) {
|
|||||||
const res = await createScheduleBlock({
|
const res = await createScheduleBlock({
|
||||||
storeId: storeInfo.id,
|
storeId: storeInfo.id,
|
||||||
slotStart,
|
slotStart,
|
||||||
|
durationMinutes: 30,
|
||||||
blockType,
|
blockType,
|
||||||
note: '',
|
note: '',
|
||||||
createdByUserId: userInfo.id
|
createdByUserId: userInfo.id
|
||||||
|
|||||||
@ -37,6 +37,7 @@
|
|||||||
<view class="st-name-row">
|
<view class="st-name-row">
|
||||||
<span class="st-dot"><AppIcon name="service" :size="14" /></span>
|
<span class="st-dot"><AppIcon name="service" :size="14" /></span>
|
||||||
<text class="st-name">{{ s.name }}</text>
|
<text class="st-name">{{ s.name }}</text>
|
||||||
|
<text class="st-duration">约 {{ s.durationMinutes || 60 }} 分钟</text>
|
||||||
<view v-if="!s.storeId" class="van-tag van-tag--success van-tag--small system-tag">系统默认</view>
|
<view v-if="!s.storeId" class="van-tag van-tag--success van-tag--small system-tag">系统默认</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@ -58,6 +59,13 @@
|
|||||||
<view class="popup-desc">建议使用简洁明确的命名,方便前台下单与报告展示。</view>
|
<view class="popup-desc">建议使用简洁明确的命名,方便前台下单与报告展示。</view>
|
||||||
<view class="field-label">服务类型名称</view>
|
<view class="field-label">服务类型名称</view>
|
||||||
<input v-model="newName" class="van-field" placeholder="请输入" />
|
<input v-model="newName" class="van-field" placeholder="请输入" />
|
||||||
|
<view class="field-label st-duration-label">预计服务时长</view>
|
||||||
|
<picker mode="selector" :range="DURATION_LABELS" :value="durationPickerIndex" @change="onDurationChange">
|
||||||
|
<view class="app-form-input app-form-picker">
|
||||||
|
<text>{{ newDuration }} 分钟</text>
|
||||||
|
<text class="app-form-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
<view class="popup-footer">
|
<view class="popup-footer">
|
||||||
<view class="popup-actions">
|
<view class="popup-actions">
|
||||||
@ -71,7 +79,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import AppIcon from '../../components/AppIcon.vue'
|
import AppIcon from '../../components/AppIcon.vue'
|
||||||
import { navigateTo } from '../../utils/globalState.js'
|
import { navigateTo } from '../../utils/globalState.js'
|
||||||
import { getServiceTypeList, createServiceType, deleteServiceType } from '../../api/index.js'
|
import { getServiceTypeList, createServiceType, deleteServiceType } from '../../api/index.js'
|
||||||
@ -102,6 +110,10 @@ const navSafeStyle = (() => {
|
|||||||
const serviceTypes = ref([])
|
const serviceTypes = ref([])
|
||||||
const showNew = ref(false)
|
const showNew = ref(false)
|
||||||
const newName = ref('')
|
const newName = ref('')
|
||||||
|
const DURATION_OPTIONS = Array.from({ length: 16 }, (_, i) => (i + 1) * 30)
|
||||||
|
const DURATION_LABELS = DURATION_OPTIONS.map((minutes) => `${minutes} 分钟`)
|
||||||
|
const newDuration = ref(60)
|
||||||
|
const durationPickerIndex = computed(() => Math.max(0, DURATION_OPTIONS.indexOf(newDuration.value)))
|
||||||
const swipedId = ref(null)
|
const swipedId = ref(null)
|
||||||
const touchStartX = ref(0)
|
const touchStartX = ref(0)
|
||||||
const touchCurrentX = ref(0)
|
const touchCurrentX = ref(0)
|
||||||
@ -111,12 +123,17 @@ const load = async () => {
|
|||||||
if (res.code === 200) serviceTypes.value = res.data
|
if (res.code === 200) serviceTypes.value = res.data
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onDurationChange = (e) => {
|
||||||
|
newDuration.value = DURATION_OPTIONS[Number(e.detail.value)] || 60
|
||||||
|
}
|
||||||
|
|
||||||
const confirmAdd = async () => {
|
const confirmAdd = async () => {
|
||||||
if (!newName.value) { uni.showToast({ title: '请输入服务类型名称', icon: 'none' }); return }
|
if (!newName.value) { uni.showToast({ title: '请输入服务类型名称', icon: 'none' }); return }
|
||||||
const res = await createServiceType(storeInfo.id, newName.value)
|
const res = await createServiceType(storeInfo.id, newName.value, newDuration.value)
|
||||||
if (res.code === 200) {
|
if (res.code === 200) {
|
||||||
uni.showToast({ title: '添加成功', icon: 'success' })
|
uni.showToast({ title: '添加成功', icon: 'success' })
|
||||||
newName.value = ''
|
newName.value = ''
|
||||||
|
newDuration.value = 60
|
||||||
showNew.value = false
|
showNew.value = false
|
||||||
load()
|
load()
|
||||||
} else {
|
} else {
|
||||||
@ -193,6 +210,8 @@ onMounted(() => load())
|
|||||||
background: var(--c-slate-100);
|
background: var(--c-slate-100);
|
||||||
}
|
}
|
||||||
.st-name { font-size: 15px; font-weight: 600; color: var(--c-gray-800); }
|
.st-name { font-size: 15px; font-weight: 600; color: var(--c-gray-800); }
|
||||||
|
.st-duration { margin-left: auto; font-size: 12px; color: var(--c-slate-500); }
|
||||||
|
.st-duration-label { margin-top: 14px; }
|
||||||
.system-tag { margin-left: 8px; }
|
.system-tag { margin-left: 8px; }
|
||||||
/* 与员工管理页一致:删除区绝对贴底,主内容盖住;左滑才露出 */
|
/* 与员工管理页一致:删除区绝对贴底,主内容盖住;左滑才露出 */
|
||||||
.st-swipe-wrap {
|
.st-swipe-wrap {
|
||||||
|
|||||||
@ -13,7 +13,7 @@
|
|||||||
</view>
|
</view>
|
||||||
<view class="store-hero-text">
|
<view class="store-hero-text">
|
||||||
<text class="store-hero-title">门店资料</text>
|
<text class="store-hero-title">门店资料</text>
|
||||||
<text class="store-hero-desc">在此维护门店名称、电话、地址与简介,并设置「营业时间」。营业时间决定客户线上可预约的时段范围(每半小时为一个时段,同一时段仅一单)。</text>
|
<text class="store-hero-desc">在此维护门店资料、营业容量时段与并发接待数。系统会按服务时长检查每个半小时容量桶。</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@ -68,7 +68,7 @@
|
|||||||
<view class="store-card store-card--hours store-card--after-basic">
|
<view class="store-card store-card--hours store-card--after-basic">
|
||||||
<view class="store-card-head">
|
<view class="store-card-head">
|
||||||
<text class="store-card-title">营业时间</text>
|
<text class="store-card-title">营业时间</text>
|
||||||
<text class="store-card-sub">客户可预约时段为「开始」至「结束」之间的半点档;每半小时为一档,每档最多一单,已满或已过不可选。</text>
|
<text class="store-card-sub">服务必须在开始至结束之间完成;线上预约和到店占用共同消耗并发名额。</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="store-field">
|
<view class="store-field">
|
||||||
<view class="store-field-label">
|
<view class="store-field-label">
|
||||||
@ -83,12 +83,12 @@
|
|||||||
</view>
|
</view>
|
||||||
</picker>
|
</picker>
|
||||||
</view>
|
</view>
|
||||||
<view class="store-field store-field-last">
|
<view class="store-field">
|
||||||
<view class="store-field-label">
|
<view class="store-field-label">
|
||||||
<span class="store-ico"><AppIcon name="service" :size="15" :color="SLATE_600" /></span>
|
<span class="store-ico"><AppIcon name="service" :size="15" :color="SLATE_600" /></span>
|
||||||
<text>结束时间</text>
|
<text>结束时间</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="store-field-tip">当日最后一个可预约的半点起始时刻(例如 21:30 表示最晚可约 21:00–21:30 这一档)</text>
|
<text class="store-field-tip">最后一个容量桶的开始时刻(例如 21:30 表示营业容量至 22:00)</text>
|
||||||
<picker mode="selector" :range="HALF_HOUR_TIME_SLOTS" :value="bookingLastIdx" @change="onBookingLastChange">
|
<picker mode="selector" :range="HALF_HOUR_TIME_SLOTS" :value="bookingLastIdx" @change="onBookingLastChange">
|
||||||
<view class="app-form-input app-form-picker store-picker-like">
|
<view class="app-form-input app-form-picker store-picker-like">
|
||||||
<text>{{ form.bookingLastSlotStart || '请选择' }}</text>
|
<text>{{ form.bookingLastSlotStart || '请选择' }}</text>
|
||||||
@ -97,6 +97,19 @@
|
|||||||
</picker>
|
</picker>
|
||||||
<view class="booking-hint">结束须不早于开始。保存后对新建预约生效。</view>
|
<view class="booking-hint">结束须不早于开始。保存后对新建预约生效。</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view class="store-field store-field-last">
|
||||||
|
<view class="store-field-label">
|
||||||
|
<span class="store-ico"><AppIcon name="profile" :size="15" :color="SLATE_600" /></span>
|
||||||
|
<text>并发接待数</text>
|
||||||
|
</view>
|
||||||
|
<text class="store-field-tip">同一半小时最多可同时服务的顾客数</text>
|
||||||
|
<picker mode="selector" :range="CAPACITY_OPTIONS" :value="bookingCapacityIdx" @change="onBookingCapacityChange">
|
||||||
|
<view class="app-form-input app-form-picker store-picker-like">
|
||||||
|
<text>{{ form.bookingCapacity }} 位</text>
|
||||||
|
<text class="app-form-arrow">›</text>
|
||||||
|
</view>
|
||||||
|
</picker>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="store-actions">
|
<view class="store-actions">
|
||||||
@ -147,6 +160,7 @@ const navSafeStyle = (() => {
|
|||||||
})()
|
})()
|
||||||
const saving = ref(false)
|
const saving = ref(false)
|
||||||
const pickingLocation = ref(false)
|
const pickingLocation = ref(false)
|
||||||
|
const CAPACITY_OPTIONS = Array.from({ length: 10 }, (_, i) => i + 1)
|
||||||
const form = ref({
|
const form = ref({
|
||||||
name: '',
|
name: '',
|
||||||
phone: '',
|
phone: '',
|
||||||
@ -155,7 +169,8 @@ const form = ref({
|
|||||||
latitude: null,
|
latitude: null,
|
||||||
longitude: null,
|
longitude: null,
|
||||||
bookingDayStart: '09:00',
|
bookingDayStart: '09:00',
|
||||||
bookingLastSlotStart: '21:30'
|
bookingLastSlotStart: '21:30',
|
||||||
|
bookingCapacity: 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const toHHmm = (v) => {
|
const toHHmm = (v) => {
|
||||||
@ -169,6 +184,7 @@ const toHHmm = (v) => {
|
|||||||
|
|
||||||
const bookingStartIdx = computed(() => halfHourPickerIndex(form.value.bookingDayStart))
|
const bookingStartIdx = computed(() => halfHourPickerIndex(form.value.bookingDayStart))
|
||||||
const bookingLastIdx = computed(() => halfHourPickerIndex(form.value.bookingLastSlotStart))
|
const bookingLastIdx = computed(() => halfHourPickerIndex(form.value.bookingLastSlotStart))
|
||||||
|
const bookingCapacityIdx = computed(() => Math.max(0, CAPACITY_OPTIONS.indexOf(Number(form.value.bookingCapacity || 1))))
|
||||||
|
|
||||||
const onBookingStartChange = (e) => {
|
const onBookingStartChange = (e) => {
|
||||||
const i = Number(e.detail.value)
|
const i = Number(e.detail.value)
|
||||||
@ -180,6 +196,10 @@ const onBookingLastChange = (e) => {
|
|||||||
form.value.bookingLastSlotStart = HALF_HOUR_TIME_SLOTS[i] || '21:30'
|
form.value.bookingLastSlotStart = HALF_HOUR_TIME_SLOTS[i] || '21:30'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const onBookingCapacityChange = (e) => {
|
||||||
|
form.value.bookingCapacity = CAPACITY_OPTIONS[Number(e.detail.value)] || 1
|
||||||
|
}
|
||||||
|
|
||||||
const hasMapPin = computed(() => form.value.latitude != null && form.value.longitude != null)
|
const hasMapPin = computed(() => form.value.latitude != null && form.value.longitude != null)
|
||||||
|
|
||||||
const toCoord = (v) => {
|
const toCoord = (v) => {
|
||||||
@ -237,7 +257,8 @@ onMounted(async () => {
|
|||||||
latitude: toCoord(storeInfo.latitude),
|
latitude: toCoord(storeInfo.latitude),
|
||||||
longitude: toCoord(storeInfo.longitude),
|
longitude: toCoord(storeInfo.longitude),
|
||||||
bookingDayStart: toHHmm(storeInfo.bookingDayStart) || '09:00',
|
bookingDayStart: toHHmm(storeInfo.bookingDayStart) || '09:00',
|
||||||
bookingLastSlotStart: toHHmm(storeInfo.bookingLastSlotStart) || '21:30'
|
bookingLastSlotStart: toHHmm(storeInfo.bookingLastSlotStart) || '21:30',
|
||||||
|
bookingCapacity: Number(storeInfo.bookingCapacity || 1)
|
||||||
}
|
}
|
||||||
if (storeInfo.id) {
|
if (storeInfo.id) {
|
||||||
const res = await getStoreDetail(storeInfo.id)
|
const res = await getStoreDetail(storeInfo.id)
|
||||||
@ -245,6 +266,7 @@ onMounted(async () => {
|
|||||||
const d = res.data
|
const d = res.data
|
||||||
base.bookingDayStart = toHHmm(d.bookingDayStart) || base.bookingDayStart
|
base.bookingDayStart = toHHmm(d.bookingDayStart) || base.bookingDayStart
|
||||||
base.bookingLastSlotStart = toHHmm(d.bookingLastSlotStart) || base.bookingLastSlotStart
|
base.bookingLastSlotStart = toHHmm(d.bookingLastSlotStart) || base.bookingLastSlotStart
|
||||||
|
base.bookingCapacity = Number(d.bookingCapacity || base.bookingCapacity)
|
||||||
if (d.name != null) base.name = d.name
|
if (d.name != null) base.name = d.name
|
||||||
if (d.phone != null) base.phone = d.phone
|
if (d.phone != null) base.phone = d.phone
|
||||||
if (d.address != null) base.address = d.address
|
if (d.address != null) base.address = d.address
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user