feat: configure booking capacity

This commit is contained in:
malei 2026-08-01 22:56:31 +08:00
parent 1dce8e94d0
commit 7e924fa79d
4 changed files with 90 additions and 19 deletions

View File

@ -43,7 +43,7 @@ export const updateStore = (data: Record<string, unknown>) => apiPut('/store/upd
export const getScheduleDay = (date: string) => export const getScheduleDay = (date: string) =>
apiGet('/schedule/day', { date }) apiGet('/schedule/day', { date })
export const createScheduleBlock = (data: { slotStart: string; blockType: string; note?: string }) => export const createScheduleBlock = (data: { slotStart: string; durationMinutes: number; blockType: string; note?: string }) =>
apiPost('/schedule/block', data) apiPost('/schedule/block', data)
export const deleteScheduleBlock = (id: number) => export const deleteScheduleBlock = (id: number) =>
@ -62,11 +62,11 @@ export const deleteStaff = (staffId: number) =>
export const getServiceTypeList = (storeId?: number | null) => export const getServiceTypeList = (storeId?: number | null) =>
apiGet('/service-type/list', storeId != null ? { storeId } : {}) apiGet('/service-type/list', storeId != null ? { storeId } : {})
export const createServiceType = (name: string) => export const createServiceType = (name: string, durationMinutes: number) =>
apiPost('/service-type/create', { name }) apiPost('/service-type/create', { name, durationMinutes })
export const updateServiceType = (id: number, name: string) => export const updateServiceType = (id: number, name: string, durationMinutes: number) =>
apiPut('/service-type/update', { id, name }) apiPut('/service-type/update', { id, name, durationMinutes })
export const deleteServiceType = (id: number) => export const deleteServiceType = (id: number) =>
apiDelete('/service-type/delete', { id }) apiDelete('/service-type/delete', { id })

View File

@ -26,7 +26,9 @@
</div> </div>
<el-table :data="rows" v-loading="loading" stripe> <el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="appointmentTime" label="预约时间" min-width="160" /> <el-table-column label="预约时间" min-width="210">
<template #default="{ row }">{{ appointmentRange(row) }}</template>
</el-table-column>
<el-table-column label="状态" width="100"> <el-table-column label="状态" width="100">
<template #default="{ row }">{{ statusLabel(row.status) }}</template> <template #default="{ row }">{{ statusLabel(row.status) }}</template>
</el-table-column> </el-table-column>
@ -62,7 +64,7 @@
<el-drawer v-model="drawer" title="预约详情" size="420px"> <el-drawer v-model="drawer" title="预约详情" size="420px">
<template v-if="current"> <template v-if="current">
<el-descriptions :column="1" border> <el-descriptions :column="1" border>
<el-descriptions-item label="时间">{{ current.appointmentTime }}</el-descriptions-item> <el-descriptions-item label="时间">{{ appointmentRange(current) }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ statusLabel(current.status) }}</el-descriptions-item> <el-descriptions-item label="状态">{{ statusLabel(current.status) }}</el-descriptions-item>
<el-descriptions-item label="宠主">{{ current.customerName }} {{ current.customerPhoneMasked }}</el-descriptions-item> <el-descriptions-item label="宠主">{{ current.customerName }} {{ current.customerPhoneMasked }}</el-descriptions-item>
<el-descriptions-item label="宠物">{{ current.petName }} / {{ current.petType }}</el-descriptions-item> <el-descriptions-item label="宠物">{{ current.petName }} / {{ current.petType }}</el-descriptions-item>
@ -101,6 +103,8 @@ import {
type Appt = { type Appt = {
id: number id: number
appointmentTime?: string appointmentTime?: string
endTime?: string
durationMinutes?: number
status?: string status?: string
petName?: string petName?: string
petType?: string petType?: string
@ -128,6 +132,13 @@ const dateFilter = ref((route.query.date as string) || '')
const dateRange = ref<[string, string] | ''>('') const dateRange = ref<[string, string] | ''>('')
const staffId = ref<number | undefined>() const staffId = ref<number | undefined>()
const missingReport = ref(route.query.missingReport === '1') const missingReport = ref(route.query.missingReport === '1')
function appointmentRange(row: Appt): string {
const start = String(row.appointmentTime || '').replace('T', ' ').slice(0, 16)
const end = String(row.endTime || '').slice(11, 16)
if (!start) return '—'
return end ? `${start}${end}` : start
}
const staffOptions = ref<Array<{ id: number; name: string }>>([]) const staffOptions = ref<Array<{ id: number; name: string }>>([])
const drawer = ref(false) const drawer = ref(false)
const current = ref<Appt | null>(null) const current = ref<Appt | null>(null)

View File

@ -7,11 +7,12 @@
<el-button @click="openCreate">新增占用</el-button> <el-button @click="openCreate">新增占用</el-button>
</div> </div>
<el-table :data="rows" v-loading="loading" stripe> <el-table :data="rows" v-loading="loading" stripe>
<el-table-column prop="time" label="时段" width="100" /> <el-table-column prop="time" label="容量档" width="100" />
<el-table-column label="类型" width="120"> <el-table-column label="类型" width="120">
<template #default="{ row }">{{ kindLabel(row.kind, row.blockType) }}</template> <template #default="{ row }">{{ kindLabel(row.kind, row.blockType) }}</template>
</el-table-column> </el-table-column>
<el-table-column prop="title" label="内容" min-width="240" /> <el-table-column prop="title" label="内容" min-width="240" />
<el-table-column prop="capacityText" label="已用/总容量" width="130" />
<el-table-column label="操作" width="120"> <el-table-column label="操作" width="120">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
@ -35,15 +36,25 @@
<el-form-item label="时段"> <el-form-item label="时段">
<el-select v-model="createForm.slotStart" filterable style="width: 100%"> <el-select v-model="createForm.slotStart" filterable style="width: 100%">
<el-option <el-option
v-for="opt in emptySlots" v-for="opt in candidateSlots"
:key="opt.slotStart" :key="opt.slotStart"
:label="opt.time" :label="opt.time"
:value="opt.slotStart" :value="opt.slotStart"
/> />
</el-select> </el-select>
</el-form-item> </el-form-item>
<el-form-item label="占用时长">
<el-input-number
v-model="createForm.durationMinutes"
:min="30"
:max="480"
:step="30"
@change="onBlockTypeChange"
/>
<span style="margin-left: 8px">分钟</span>
</el-form-item>
<el-form-item label="类型"> <el-form-item label="类型">
<el-radio-group v-model="createForm.blockType"> <el-radio-group v-model="createForm.blockType" @change="onBlockTypeChange">
<el-radio value="walk_in">到店占用</el-radio> <el-radio value="walk_in">到店占用</el-radio>
<el-radio value="blocked">暂停预约</el-radio> <el-radio value="blocked">暂停预约</el-radio>
</el-radio-group> </el-radio-group>
@ -73,6 +84,10 @@ type Row = {
blockId?: number blockId?: number
title: string title: string
past?: boolean past?: boolean
usedCapacity: number
totalCapacity: number
remainingCapacity: number
capacityText: string
} }
const loading = ref(false) const loading = ref(false)
@ -83,12 +98,21 @@ const dialogVisible = ref(false)
const createForm = reactive({ const createForm = reactive({
slotStart: '', slotStart: '',
blockType: 'blocked', blockType: 'blocked',
durationMinutes: 30,
note: '', note: '',
}) })
const emptySlots = computed(() => const candidateSlots = computed(() => {
rows.value.filter((r) => r.kind === 'empty' && !r.past), const bucketCount = Math.max(1, Number(createForm.durationMinutes || 30) / 30)
) return rows.value.filter((_row, index) => {
const covered = rows.value.slice(index, index + bucketCount)
if (covered.length !== bucketCount || covered.some((r) => r.past)) return false
if (createForm.blockType === 'blocked') {
return covered.every((r) => r.usedCapacity === 0 && r.kind === 'empty')
}
return covered.every((r) => r.remainingCapacity > 0)
})
})
function kindLabel(kind: string, blockType?: string) { function kindLabel(kind: string, blockType?: string) {
if (kind === 'appointment') return '预约' if (kind === 'appointment') return '预约'
@ -111,6 +135,9 @@ async function load() {
const kind = String(r.kind || 'empty') const kind = String(r.kind || 'empty')
const appt = r.appointment as Record<string, unknown> | null | undefined const appt = r.appointment as Record<string, unknown> | null | undefined
const block = r.block as Record<string, unknown> | null | undefined const block = r.block as Record<string, unknown> | null | undefined
const usedCapacity = Number(r.usedCapacity || 0)
const totalCapacity = Number(r.totalCapacity || 1)
const remainingCapacity = Number(r.remainingCapacity || 0)
let title = '—' let title = '—'
let blockType: string | undefined let blockType: string | undefined
let blockId: number | undefined let blockId: number | undefined
@ -131,6 +158,10 @@ async function load() {
blockId, blockId,
title, title,
past: Boolean(r.past), past: Boolean(r.past),
usedCapacity,
totalCapacity,
remainingCapacity,
capacityText: kind === 'block' && blockType === 'blocked' ? '暂停' : `${usedCapacity}/${totalCapacity}`,
} }
}) })
} finally { } finally {
@ -139,15 +170,23 @@ async function load() {
} }
function openCreate() { function openCreate() {
createForm.slotStart = emptySlots.value[0]?.slotStart || ''
createForm.blockType = 'blocked' createForm.blockType = 'blocked'
createForm.durationMinutes = 30
createForm.slotStart = candidateSlots.value[0]?.slotStart || ''
createForm.note = '' createForm.note = ''
dialogVisible.value = true dialogVisible.value = true
} }
function onBlockTypeChange() {
if (!candidateSlots.value.some((r) => r.slotStart === createForm.slotStart)) {
createForm.slotStart = candidateSlots.value[0]?.slotStart || ''
}
}
function quickBlock(row: Row) { function quickBlock(row: Row) {
createForm.slotStart = row.slotStart createForm.slotStart = row.slotStart
createForm.blockType = 'blocked' createForm.blockType = 'blocked'
createForm.durationMinutes = 30
createForm.note = '' createForm.note = ''
dialogVisible.value = true dialogVisible.value = true
} }
@ -161,6 +200,7 @@ async function submitCreate() {
try { try {
const res = await createScheduleBlock({ const res = await createScheduleBlock({
slotStart: createForm.slotStart, slotStart: createForm.slotStart,
durationMinutes: createForm.durationMinutes,
blockType: createForm.blockType, blockType: createForm.blockType,
note: createForm.note || undefined, note: createForm.note || undefined,
}) })

View File

@ -34,6 +34,10 @@
:disabled="!isBoss" :disabled="!isBoss"
/> />
</el-form-item> </el-form-item>
<el-form-item label="并发接待数">
<el-input-number v-model="form.bookingCapacity" :min="1" :max="10" :disabled="!isBoss" />
<span style="margin-left: 10px; color: var(--el-text-color-secondary)">同一时段最多同时服务的顾客数</span>
</el-form-item>
<el-form-item label="邀请码"> <el-form-item label="邀请码">
<div class="invite-row"> <div class="invite-row">
<el-input v-model="form.inviteCode" readonly /> <el-input v-model="form.inviteCode" readonly />
@ -50,6 +54,8 @@
<el-tab-pane label="服务类型" name="service"> <el-tab-pane label="服务类型" name="service">
<div class="filter-row" v-if="isBoss"> <div class="filter-row" v-if="isBoss">
<el-input v-model="newServiceName" placeholder="新服务类型名称" style="width: 220px" /> <el-input v-model="newServiceName" placeholder="新服务类型名称" style="width: 220px" />
<el-input-number v-model="newServiceDuration" :min="30" :max="480" :step="30" />
<span>分钟</span>
<el-button type="primary" @click="addService">新增</el-button> <el-button type="primary" @click="addService">新增</el-button>
</div> </div>
<el-table :data="serviceTypes" v-loading="loadingMeta" stripe> <el-table :data="serviceTypes" v-loading="loadingMeta" stripe>
@ -57,14 +63,17 @@
<el-table-column label="范围" width="120"> <el-table-column label="范围" width="120">
<template #default="{ row }">{{ row.storeId == null ? '系统默认' : '本店' }}</template> <template #default="{ row }">{{ row.storeId == null ? '系统默认' : '本店' }}</template>
</el-table-column> </el-table-column>
<el-table-column label="预计时长" width="140">
<template #default="{ row }">{{ row.durationMinutes }} 分钟</template>
</el-table-column>
<el-table-column v-if="isBoss" label="操作" width="160"> <el-table-column v-if="isBoss" label="操作" width="160">
<template #default="{ row }"> <template #default="{ row }">
<el-button <el-button
v-if="row.storeId != null" v-if="row.storeId != null"
link link
type="primary" type="primary"
@click="renameService(row)" @click="editService(row)"
>改名</el-button> >编辑</el-button>
<el-button <el-button
v-if="row.storeId != null" v-if="row.storeId != null"
link link
@ -134,11 +143,13 @@ const form = reactive({
inviteCode: '', inviteCode: '',
bookingDayStart: '09:00', bookingDayStart: '09:00',
bookingLastSlotStart: '21:30', bookingLastSlotStart: '21:30',
bookingCapacity: 1,
}) })
const serviceTypes = ref<Array<Record<string, unknown>>>([]) const serviceTypes = ref<Array<Record<string, unknown>>>([])
const staff = ref<Array<Record<string, unknown>>>([]) const staff = ref<Array<Record<string, unknown>>>([])
const newServiceName = ref('') const newServiceName = ref('')
const newServiceDuration = ref(60)
const newStaffName = ref('') const newStaffName = ref('')
const newStaffPhone = ref('') const newStaffPhone = ref('')
@ -165,6 +176,7 @@ async function loadStore() {
form.inviteCode = String(d.inviteCode || '') form.inviteCode = String(d.inviteCode || '')
form.bookingDayStart = normTime(d.bookingDayStart) form.bookingDayStart = normTime(d.bookingDayStart)
form.bookingLastSlotStart = normTime(d.bookingLastSlotStart) form.bookingLastSlotStart = normTime(d.bookingLastSlotStart)
form.bookingCapacity = Number(d.bookingCapacity || 1)
const token = getToken() const token = getToken()
const u = getUser() const u = getUser()
if (token && u) { if (token && u) {
@ -204,6 +216,7 @@ async function saveStore() {
intro: form.intro, intro: form.intro,
bookingDayStart: form.bookingDayStart, bookingDayStart: form.bookingDayStart,
bookingLastSlotStart: form.bookingLastSlotStart, bookingLastSlotStart: form.bookingLastSlotStart,
bookingCapacity: form.bookingCapacity,
}) })
if (res.code !== 200) { if (res.code !== 200) {
ElMessage.error(res.message || '保存失败') ElMessage.error(res.message || '保存失败')
@ -225,19 +238,26 @@ async function copyInvite() {
async function addService() { async function addService() {
const name = newServiceName.value.trim() const name = newServiceName.value.trim()
if (!name) return if (!name) return
const res = await createServiceType(name) const duration = Number(newServiceDuration.value)
const res = await createServiceType(name, duration)
if (res.code !== 200) { if (res.code !== 200) {
ElMessage.error(res.message || '新增失败') ElMessage.error(res.message || '新增失败')
return return
} }
newServiceName.value = '' newServiceName.value = ''
newServiceDuration.value = 60
ElMessage.success('已新增') ElMessage.success('已新增')
await loadMeta() await loadMeta()
} }
async function renameService(row: Record<string, unknown>) { async function editService(row: Record<string, unknown>) {
const { value } = await ElMessageBox.prompt('新名称', '改名', { inputValue: String(row.name || '') }) const { value } = await ElMessageBox.prompt('新名称', '改名', { inputValue: String(row.name || '') })
const res = await updateServiceType(Number(row.id), value) const durationPrompt = await ElMessageBox.prompt('请输入 30480 分钟,且为 30 的倍数', '预计服务时长', {
inputValue: String(row.durationMinutes || 60),
inputPattern: /^(30|60|90|120|150|180|210|240|270|300|330|360|390|420|450|480)$/,
inputErrorMessage: '请输入 30480 的 30 分钟倍数',
})
const res = await updateServiceType(Number(row.id), value, Number(durationPrompt.value))
if (res.code !== 200) { if (res.code !== 200) {
ElMessage.error(res.message || '改名失败') ElMessage.error(res.message || '改名失败')
return return