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 }} 分钟)
+
备注
@@ -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 = serviceTypes[e.detail.value].value">
+
{{ 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 = serviceTypes[e.detail.value].value">
+
{{ 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 @@
{{ row.appointment.petName || '宠物' }} · {{ row.appointment.serviceType || '服务' }}
- {{ apptStatusText(row.appointment.status) }}
+ {{ apptStatusText(row.appointment.status) }} · 容量 {{ row.usedCapacity || 0 }}/{{ row.totalCapacity || 1 }}
{{ blockTitle(row.block) }}
@@ -61,7 +61,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 }} 分钟
+ ›
+
+