diff --git a/src/api/index.js b/src/api/index.js index 37ea090..5a64af1 100644 --- a/src/api/index.js +++ b/src/api/index.js @@ -86,9 +86,9 @@ export const getAppointmentDetail = (id) => get('/appointment/detail', { id }) // 创建预约:登录人身份始终由后端派生;customer 传选中门店,门店代客预约传 customerUserId 或 customerPhone export const createAppointment = (data) => post('/appointment/create', data) -/** 门店某日可预约半小时档(可约 / 已满 / 已过);公开接口 */ -export const getAppointmentAvailableSlots = (storeId, date) => - get('/appointment/available-slots', { storeId, date }) +/** 门店某日可预约开始时段;按服务时长与门店容量计算,公开接口。 */ +export const getAppointmentAvailableSlots = (storeId, date, serviceType) => + get('/appointment/available-slots', { storeId, date, serviceType }) // 开始服务(staffUserId 由后端从上下文派生;保留旧签名兼容,不传 staffUserId) export const startAppointment = (appointmentId/*, staffUserId */) => @@ -147,8 +147,9 @@ export const getServiceTypeList = (storeId) => { return get('/service-type/list', params) } -// 创建服务类型 -export const createServiceType = (storeId, name) => post('/service-type/create', { storeId, name }) +// 创建服务类型(storeId 仅保留旧签名兼容,后端从登录态派生) +export const createServiceType = (storeId, name, durationMinutes) => + post('/service-type/create', { storeId, name, durationMinutes }) // 删除服务类型 export const deleteServiceType = (id) => del(`/service-type/delete?id=${id}`) diff --git a/src/pages/appointment/AppointmentDetail.vue b/src/pages/appointment/AppointmentDetail.vue index df5916f..0ad716f 100644 --- a/src/pages/appointment/AppointmentDetail.vue +++ b/src/pages/appointment/AppointmentDetail.vue @@ -38,6 +38,10 @@ 预约时间 {{ timeDisplay }} + + 预计结束 + {{ endTimeDisplay }}(约 {{ detail.durationMinutes || 60 }} 分钟) + 备注 {{ detail.remark }} @@ -84,6 +88,7 @@ const navSafeStyle = (() => { const statusText = computed(() => (detail.value ? getAppointmentStatusText(detail.value.status) : '')) const timeDisplay = computed(() => formatDateTimeCN(detail.value?.appointmentTime)) +const endTimeDisplay = computed(() => formatDateTimeCN(detail.value?.endTime)) const tagClass = (status) => getAppointmentTagClass(status) diff --git a/src/pages/appointment/CustAppointmentCreate.vue b/src/pages/appointment/CustAppointmentCreate.vue index 8dd9cb8..3fef07c 100644 --- a/src/pages/appointment/CustAppointmentCreate.vue +++ b/src/pages/appointment/CustAppointmentCreate.vue @@ -73,7 +73,7 @@ 服务类型 - + {{ newAppt.serviceType || '请选择' }} @@ -330,8 +330,9 @@ const creatingAppt = ref(false) /** 与日期选择器同步,避免无号源时丢失已选日期 */ const selectedDateStr = ref(ymdLocal()) const availableTimes = ref([]) +const availableSlotEnds = ref({}) const loadingSlots = ref(false) -const bookingWindowHint = ref('请选择半小时时间段;同一时段仅接待一单,已满或已过期不可选') +const bookingWindowHint = ref('请选择服务后查看可预约开始时间;系统会按服务时长校验完整容量') const normalizeText = (s) => (s || '').toString().trim().toLowerCase() @@ -397,11 +398,13 @@ const slotPickerIndex = computed(() => { /** 滚轮选项展示为「09:00–09:30」 */ const availableSlotLabels = computed(() => - availableTimes.value.map((t) => formatHalfHourSlotRange(t)) + availableTimes.value.map((t) => `${t}–${availableSlotEnds.value[t] || formatHalfHourSlotRange(t).slice(6)}`) ) const selectedSlotRangeLabel = computed(() => - appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : '' + appointmentTime.value + ? `${appointmentTime.value}–${availableSlotEnds.value[appointmentTime.value] || formatHalfHourSlotRange(appointmentTime.value).slice(6)}` + : '' ) const showTryTomorrow = computed( @@ -433,20 +436,24 @@ const loadAvailableSlots = async () => { } loadingSlots.value = true try { - const res = await getAppointmentAvailableSlots(storeId, date) + const res = await getAppointmentAvailableSlots(storeId, date, newAppt.value.serviceType || undefined) loadingSlots.value = false if (res.code !== 200 || !res.data?.slots) { availableTimes.value = [] + availableSlotEnds.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 closing = res.data.closingTime + if (ds != null && closing != null) { const a = String(ds).slice(0, 5) - const b = String(le).slice(0, 5) - bookingWindowHint.value = `本店可约 ${a}~${b};以下为半小时时段,同一时段仅一单,已满或已过不可选` + const b = String(closing).slice(0, 5) + 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) if (!availableTimes.value.length) { newAppt.value.appointmentTime = '' @@ -466,6 +473,7 @@ const loadAvailableSlots = async () => { } catch (_) { loadingSlots.value = false availableTimes.value = [] + availableSlotEnds.value = {} uni.showToast({ title: '号源加载失败', icon: 'none' }) } } @@ -494,12 +502,25 @@ const onAppointmentTimeOnlyChange = (e) => { 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 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 })) + 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) { try { const last = uni.getStorageSync(lastServiceStorageKey(sid)) diff --git a/src/pages/home/Home.vue b/src/pages/home/Home.vue index 72043fb..6de988c 100644 --- a/src/pages/home/Home.vue +++ b/src/pages/home/Home.vue @@ -228,7 +228,7 @@ 服务类型 - + {{ newAppt.serviceType || '请选择' }} @@ -256,7 +256,7 @@ - 每行半小时时段,约满不可选 + 按服务时长与门店并发容量计算,约满不可选 备注(可选) @@ -421,6 +421,7 @@ const appointmentTime = computed(() => { /** B 端弹窗:按半小时档选可约时段 */ const staffApptDateStr = ref('') const staffAvailableTimes = ref([]) +const staffAvailableSlotEnds = ref({}) const staffLoadingSlots = ref(false) const staffSlotPickerIndex = computed(() => { @@ -430,11 +431,13 @@ const staffSlotPickerIndex = 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(() => - 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 => { @@ -525,12 +528,14 @@ const loadStaffAvailableSlots = async () => { staffApptDateStr.value = date staffLoadingSlots.value = true try { - const res = await getAppointmentAvailableSlots(storeInfo.id, date) + const res = await getAppointmentAvailableSlots(storeInfo.id, date, newAppt.value.serviceType || undefined) staffLoadingSlots.value = false if (res.code !== 200 || !res.data?.slots) { staffAvailableTimes.value = [] + staffAvailableSlotEnds.value = {} 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) if (!staffAvailableTimes.value.length) { newAppt.value.appointmentTime = '' @@ -550,6 +555,7 @@ const loadStaffAvailableSlots = async () => { } catch (_) { staffLoadingSlots.value = false staffAvailableTimes.value = [] + staffAvailableSlotEnds.value = {} } } @@ -559,6 +565,12 @@ const openStaffNewApptDialog = async () => { await loadStaffAvailableSlots() } +const onStaffServiceTypeChange = (e) => { + const selected = serviceTypes.value[Number(e.detail.value)] + newAppt.value.serviceType = selected?.value || '' + loadStaffAvailableSlots() +} + const onStaffApptDateChange = (e) => { const date = e?.detail?.value || '' if (!date) { @@ -654,7 +666,11 @@ const loadServiceTypes = async () => { if (!storeInfo.id) return const res = await getServiceTypeList(storeInfo.id) 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) + })) } } diff --git a/src/pages/mine/Schedule.vue b/src/pages/mine/Schedule.vue index 7e37693..910eb47 100644 --- a/src/pages/mine/Schedule.vue +++ b/src/pages/mine/Schedule.vue @@ -17,7 +17,7 @@ 时段一览 - 绿色为客户预约;橙色为到店占用;灰色为暂停线上预约。点空白格可手动占用,点占用可取消。 + 绿色为客户预约;橙色为到店占用;灰色为暂停线上预约。每行显示已用/总容量。 @@ -48,7 +48,7 @@ @@ -212,6 +212,7 @@ function onRowTap(row) { const res = await createScheduleBlock({ storeId: storeInfo.id, slotStart, + durationMinutes: 30, blockType, note: '', createdByUserId: userInfo.id diff --git a/src/pages/mine/ServiceType.vue b/src/pages/mine/ServiceType.vue index 50ff31a..50ed53d 100644 --- a/src/pages/mine/ServiceType.vue +++ b/src/pages/mine/ServiceType.vue @@ -37,6 +37,7 @@ {{ s.name }} + 约 {{ s.durationMinutes || 60 }} 分钟 系统默认 @@ -58,6 +59,13 @@ 建议使用简洁明确的命名,方便前台下单与报告展示。 服务类型名称 + 预计服务时长 + + + {{ newDuration }} 分钟 + + + @@ -71,7 +79,7 @@