Compare commits

...

4 Commits

Author SHA1 Message Date
malei
e11ce1aaaa chore: guard frontend production builds 2026-08-01 23:30:32 +08:00
malei
d29ece3a85 feat: book against service duration 2026-08-01 22:56:31 +08:00
malei
9a12ee07b0 docs: update report tracking contract 2026-08-01 22:29:20 +08:00
malei
ec4015aaba feat: collect customer identity for assisted bookings 2026-08-01 20:31:53 +08:00
13 changed files with 236 additions and 54 deletions

View File

@ -1,3 +1,3 @@
# 小程序构建时的 API 域名api.s-good.com 已挪作他用,勿再指向)
# 提审/体验版前请改为宠小它专用域名,并在微信后台配置合法域名
VITE_API_ORIGIN=http://localhost:8080
# 安全占位符:提审/体验版构建前必须用受控环境文件覆盖,并运行发布门禁。
VITE_API_ORIGIN=https://api.petstore.invalid
VITE_REPORT_PUBLIC_ORIGIN=https://report.petstore.invalid

View File

@ -1,3 +1,4 @@
# 宠小它生产 API 域名api.s-good.com 已挪作他用,勿再指向)
# 部署前请改为宠小它专用域名,例如 https://api.chongxiaota.example
VITE_API_ORIGIN=http://localhost:8080
# 安全占位符:正式构建必须通过 PETSTORE_FRONTEND_ENV_FILE 指向服务器/CI 的受控环境文件,
# 并先执行 npm run preflight:release.invalid 会被门禁拒绝。
VITE_API_ORIGIN=https://api.petstore.invalid
VITE_REPORT_PUBLIC_ORIGIN=https://report.petstore.invalid

View File

@ -76,9 +76,7 @@ npm run dev:h5
开发阶段:在微信开发者工具中勾选「不校验合法域名」(设置 → 项目设置)
生产阶段:在微信公众平台后台添加以下合法域名:
- `http://localhost:8080`(开发用)
- 你的实际后端域名
生产阶段:仅在微信公众平台后台添加本项目实际 HTTPS 后端域名localhost 只用于开发,不能进入体验版/正式版。
## API 配置
@ -135,6 +133,10 @@ npm run dev:h5
当前实现:**提交时登录 + guest 草稿恢复**。未登录用户可填写预约表单,提交时跳转登录页(带 `redirect` 回预约页并保留 `storeId`),登录成功后回到预约页并恢复 guest 草稿(草稿 key 不依赖 `userInfo.id`,避免 `userId: undefined`)。登录态用户继续按 `userId` 维度保存草稿。草稿有效期 7 天。
### 门店代客预约身份
老板/员工在首页新建预约时必须填写宠主手机号,可选填宠主称呼。后端按手机号关联或创建 customer并分别记录 `customerUserId``createdByUserId`;前端不得再把当前员工 `userId` 当作宠主提交。预约详情优先读取 `assignedStaffId`,兼容旧字段 `assignedUserId`
## 前端 RC 发布门禁
发布前必须依次执行并全部通过:
@ -143,15 +145,23 @@ npm run dev:h5
# 1. 安装依赖
npm --prefix frontend install
# 2. H5 构建
# 2. 用权限受控的真实生产环境文件做门禁(不会打印变量值)
PETSTORE_FRONTEND_ENV_FILE=/secure/path/petstore-h5.env npm --prefix frontend run preflight:release
# 3. 让 Vite 使用同一份已通过门禁的配置并构建 H5
cp /secure/path/petstore-h5.env frontend/.env.production.local
npm --prefix frontend run build:h5
# 期望DONE Build complete.
# 3. 微信小程序构建
# 4. 小程序配置单独门禁并构建
PETSTORE_FRONTEND_ENV_FILE=/secure/path/petstore-mp.env npm --prefix frontend run preflight:release
cp /secure/path/petstore-mp.env frontend/.env.mp-weixin.local
npm --prefix frontend run build:mp-weixin
# 期望DONE Build complete. + [verify-mp-app-json] OK
```
仓库内 `.env.production` / `.env.mp-weixin` 只含 `.invalid` 安全占位符,会被门禁主动拒绝。真实域名文件不得提交;发布结束后删除临时 `.env.*.local`
### 前端 RC 检查项
- [ ] `VITE_API_ORIGIN` 已配置(宠小它专用后端 API 域名;**勿再用** `api.s-good.com`

View File

@ -4,9 +4,10 @@
"private": true,
"scripts": {
"dev:mp-weixin": "uni -p mp-weixin",
"build:mp-weixin": "uni build -p mp-weixin && node scripts/verify-mp-app-json.cjs",
"build:mp-weixin": "uni build -p mp-weixin --mode mp-weixin && node scripts/verify-mp-app-json.cjs",
"dev:h5": "uni",
"build:h5": "uni build"
"build:h5": "uni build",
"preflight:release": "node scripts/release-preflight.cjs"
},
"dependencies": {
"@dcloudio/uni-app": "3.0.0-4060620250520001",

View File

@ -0,0 +1,59 @@
const fs = require('node:fs')
const path = require('node:path')
const envFile = process.env.PETSTORE_FRONTEND_ENV_FILE || '.env.production'
const absoluteEnvFile = path.resolve(process.cwd(), envFile)
function fail(message) {
console.error(`[release-preflight] FAIL: ${message}`)
process.exit(1)
}
if (!fs.existsSync(absoluteEnvFile)) {
fail('environment file does not exist')
}
const values = {}
for (const rawLine of fs.readFileSync(absoluteEnvFile, 'utf8').split(/\r?\n/)) {
const line = rawLine.trim()
if (!line || line.startsWith('#')) continue
const separator = line.indexOf('=')
if (separator < 1) continue
const key = line.slice(0, separator).trim().replace(/^export\s+/, '')
const value = line.slice(separator + 1).trim().replace(/^(['"])(.*)\1$/, '$2')
values[key] = value
}
function requireProductionOrigin(name, forbiddenHosts) {
const value = values[name]
if (!value) fail(`${name} is required`)
let url
try {
url = new URL(value)
} catch {
fail(`${name} must be a valid URL`)
}
const hostname = url.hostname.toLowerCase()
const isLocal = hostname === 'localhost' || hostname === '127.0.0.1' || hostname === '::1'
const isPlaceholder = hostname.endsWith('.invalid')
|| hostname.endsWith('.test')
|| hostname.endsWith('.example')
|| ['example.com', 'example.net', 'example.org'].some(
(reserved) => hostname === reserved || hostname.endsWith(`.${reserved}`),
)
const hasExtraParts = Boolean(url.username || url.password || url.search || url.hash)
|| (url.pathname !== '' && url.pathname !== '/')
if (url.protocol !== 'https:' || isLocal || isPlaceholder || hasExtraParts) {
fail(`${name} must be an explicit production HTTPS origin`)
}
if (forbiddenHosts.includes(hostname)) {
fail(`${name} uses a domain reserved by another product`)
}
console.log(`[release-preflight] ${name}: PASS`)
}
requireProductionOrigin('VITE_API_ORIGIN', ['api.s-good.com'])
requireProductionOrigin('VITE_REPORT_PUBLIC_ORIGIN', ['www.s-good.com'])
console.log('[release-preflight] frontend release configuration PASS')

View File

@ -83,12 +83,12 @@ export const getAppointmentList = (/* userId, storeId, */ options = {}) =>
// 预约详情
export const getAppointmentDetail = (id) => get('/appointment/detail', { id })
// 创建预约userId/storeId 由后端从上下文派生;前端仍传 storeId 供 customer 选店场景,后端会按 role 决定是否采纳)
// 创建预约登录人身份始终由后端派生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 */) =>
@ -109,7 +109,7 @@ export const postReportHighlightStart = (data) => post('/report/highlight/start'
*/
export const getReportByToken = (token) => get('/report/get', { token })
/** P0报告打开埋点服务端记日志可对接日志平台 */
/** 报告打开埋点(服务端落低敏 BusinessEvent失败不影响公开报告阅读 */
export const postReportOpenTrack = (token, visitType) =>
post('/report/open-track', { token, visitType: visitType || 'unknown' })
@ -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}`)

View File

@ -38,13 +38,17 @@
<text class="label">预约时间</text>
<text class="value time">{{ timeDisplay }}</text>
</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">
<text class="label">备注</text>
<text class="value remark">{{ detail.remark }}</text>
</view>
<view v-if="detail.assignedUserId" class="detail-row">
<view v-if="detail.assignedStaffId || detail.assignedUserId" class="detail-row">
<text class="label">服务技师</text>
<text class="value">#{{ detail.assignedUserId }}</text>
<text class="value">#{{ detail.assignedStaffId || detail.assignedUserId }}</text>
</view>
<view class="detail-meta">
<text v-if="detail.id">预约编号 {{ detail.id }}</text>
@ -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)

View File

@ -73,7 +73,7 @@
</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">
<picker mode="selector" :range="serviceTypes" range-key="label" @change="onServiceTypeChange">
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="app-form-arrow"></text>
@ -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:0009: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))
@ -593,7 +614,7 @@ const confirmNewAppt = async () => {
uni.showToast({ title: '宠物档案保存失败,请重试', icon: 'none' })
return
}
const payload = { ...a, storeId, userId: userInfo.id }
const payload = { ...a, storeId }
payload.petId = ensuredPet.id
const res = await createAppointment(payload)
creatingAppt.value = false

View File

@ -199,6 +199,20 @@
<text class="popup-close" @click="showNewAppt = false"></text>
</view>
<view class="popup-body">
<view class="c-field">
<text class="app-form-label">宠主手机号</text>
<input
v-model="newAppt.customerPhone"
class="app-form-input"
type="number"
maxlength="11"
placeholder="用于关联宠主与服务报告"
/>
</view>
<view class="c-field">
<text class="app-form-label">宠主称呼可选</text>
<input v-model="newAppt.customerName" class="app-form-input" maxlength="64" placeholder="例如:小白妈妈" />
</view>
<view class="c-field">
<text class="app-form-label">宠物名字</text>
<input v-model="newAppt.petName" class="app-form-input" placeholder="请输入" />
@ -214,7 +228,7 @@
</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">
<picker mode="selector" :range="serviceTypes" range-key="label" @change="onStaffServiceTypeChange">
<view class="app-form-input app-form-picker">
<text :class="{ 'app-form-placeholder': !newAppt.serviceType }">{{ newAppt.serviceType || '请选择' }}</text>
<text class="app-form-arrow"></text>
@ -242,7 +256,7 @@
</view>
</picker>
</view>
<text class="c-store-hint home-slot-hint">每行半小时时段约满不可选</text>
<text class="c-store-hint home-slot-hint">按服务时长与门店并发容量计算约满不可选</text>
</view>
<view class="c-field">
<text class="app-form-label">备注可选</text>
@ -387,7 +401,16 @@ const petTypes = [
{ label: '其他', value: '其他' }
]
const newAppt = ref({ petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' })
const emptyStaffAppointment = () => ({
customerPhone: '',
customerName: '',
petName: '',
petType: '',
serviceType: '',
appointmentTime: '',
remark: ''
})
const newAppt = ref(emptyStaffAppointment())
const appointmentTime = computed(() => {
const raw = newAppt.value.appointmentTime || ''
if (!raw.includes('T')) return ''
@ -398,6 +421,7 @@ const appointmentTime = computed(() => {
/** B 端弹窗:按半小时档选可约时段 */
const staffApptDateStr = ref('')
const staffAvailableTimes = ref([])
const staffAvailableSlotEnds = ref({})
const staffLoadingSlots = ref(false)
const staffSlotPickerIndex = computed(() => {
@ -407,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 => {
@ -502,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 = ''
@ -527,6 +555,7 @@ const loadStaffAvailableSlots = async () => {
} catch (_) {
staffLoadingSlots.value = false
staffAvailableTimes.value = []
staffAvailableSlotEnds.value = {}
}
}
@ -536,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) {
@ -631,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)
}))
}
}
@ -671,6 +710,10 @@ const goReport = (item) => {
const confirmNewAppt = async () => {
const a = newAppt.value
if (!/^1[3-9]\d{9}$/.test(a.customerPhone || '')) {
uni.showToast({ title: '请输入正确的宠主手机号', icon: 'none' })
return
}
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 }
@ -681,11 +724,11 @@ const confirmNewAppt = async () => {
return
}
creatingAppt.value = true
const res = await createAppointment({ ...a, storeId: storeInfo.id, userId: userInfo.id })
const res = await createAppointment({ ...a, storeId: storeInfo.id })
creatingAppt.value = false
if (res.code === 200) {
uni.showToast({ title: '预约成功', icon: 'success' })
newAppt.value = { petName: '', petType: '', serviceType: '', appointmentTime: '', remark: '' }
newAppt.value = emptyStaffAppointment()
showNewAppt.value = false
fetchAppointments()
} else {

View File

@ -17,7 +17,7 @@
</view>
<view class="sched-hero-text">
<text class="sched-hero-title">时段一览</text>
<text class="sched-hero-desc">绿色为客户预约橙色为到店占用灰色为暂停线上预约点空白格可手动占用点占用可取消</text>
<text class="sched-hero-desc">绿色为客户预约橙色为到店占用灰色为暂停线上预约每行显示已用/总容量</text>
</view>
</view>
@ -48,7 +48,7 @@
<view class="sched-main">
<template v-if="row.kind === 'appointment' && row.appointment">
<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 v-else-if="row.kind === 'block' && row.block">
<text class="sched-line-title">{{ blockTitle(row.block) }}</text>
@ -61,7 +61,7 @@
</view>
</view>
<view class="sched-foot-hint">营业时间一致每半小时一档占用后客户端该档不可再约</view>
<view class="sched-foot-hint">营业容量时段一致服务和到店占用会跨半小时桶消耗名额暂停预约会关闭全部名额</view>
</view>
</view>
</template>
@ -212,6 +212,7 @@ function onRowTap(row) {
const res = await createScheduleBlock({
storeId: storeInfo.id,
slotStart,
durationMinutes: 30,
blockType,
note: '',
createdByUserId: userInfo.id

View File

@ -37,6 +37,7 @@
<view class="st-name-row">
<span class="st-dot"><AppIcon name="service" :size="14" /></span>
<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>
</view>
@ -58,6 +59,13 @@
<view class="popup-desc">建议使用简洁明确的命名方便前台下单与报告展示</view>
<view class="field-label">服务类型名称</view>
<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 class="popup-footer">
<view class="popup-actions">
@ -71,7 +79,7 @@
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import AppIcon from '../../components/AppIcon.vue'
import { navigateTo } from '../../utils/globalState.js'
import { getServiceTypeList, createServiceType, deleteServiceType } from '../../api/index.js'
@ -102,6 +110,10 @@ const navSafeStyle = (() => {
const serviceTypes = ref([])
const showNew = ref(false)
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 touchStartX = ref(0)
const touchCurrentX = ref(0)
@ -111,12 +123,17 @@ const load = async () => {
if (res.code === 200) serviceTypes.value = res.data
}
const onDurationChange = (e) => {
newDuration.value = DURATION_OPTIONS[Number(e.detail.value)] || 60
}
const confirmAdd = async () => {
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) {
uni.showToast({ title: '添加成功', icon: 'success' })
newName.value = ''
newDuration.value = 60
showNew.value = false
load()
} else {
@ -193,6 +210,8 @@ onMounted(() => load())
background: var(--c-slate-100);
}
.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; }
/* 与员工管理页一致:删除区绝对贴底,主内容盖住;左滑才露出 */
.st-swipe-wrap {

View File

@ -13,7 +13,7 @@
</view>
<view class="store-hero-text">
<text class="store-hero-title">门店资料</text>
<text class="store-hero-desc">在此维护门店名称电话地址与简介并设置营业时间营业时间决定客户线上可预约的时段范围每半小时为一个时段同一时段仅一单</text>
<text class="store-hero-desc">在此维护门店资料营业容量时段与并发接待数系统会按服务时长检查每个半小时容量桶</text>
</view>
</view>
@ -68,7 +68,7 @@
<view class="store-card store-card--hours store-card--after-basic">
<view class="store-card-head">
<text class="store-card-title">营业时间</text>
<text class="store-card-sub">客户可预约时段为开始结束之间的半点档每半小时为一档每档最多一单已满或已过不可选</text>
<text class="store-card-sub">服务必须在开始至结束之间完成线上预约和到店占用共同消耗并发名额</text>
</view>
<view class="store-field">
<view class="store-field-label">
@ -83,12 +83,12 @@
</view>
</picker>
</view>
<view class="store-field store-field-last">
<view class="store-field">
<view class="store-field-label">
<span class="store-ico"><AppIcon name="service" :size="15" :color="SLATE_600" /></span>
<text>结束时间</text>
</view>
<text class="store-field-tip">当日最后一个可预约的半点起始时刻例如 21:30 表示最晚可约 21:0021:30 这一档</text>
<text class="store-field-tip">最后一个容量桶的开始时刻例如 21:30 表示营业容量至 22:00</text>
<picker mode="selector" :range="HALF_HOUR_TIME_SLOTS" :value="bookingLastIdx" @change="onBookingLastChange">
<view class="app-form-input app-form-picker store-picker-like">
<text>{{ form.bookingLastSlotStart || '请选择' }}</text>
@ -97,6 +97,19 @@
</picker>
<view class="booking-hint">结束须不早于开始保存后对新建预约生效</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 class="store-actions">
@ -147,6 +160,7 @@ const navSafeStyle = (() => {
})()
const saving = ref(false)
const pickingLocation = ref(false)
const CAPACITY_OPTIONS = Array.from({ length: 10 }, (_, i) => i + 1)
const form = ref({
name: '',
phone: '',
@ -155,7 +169,8 @@ const form = ref({
latitude: null,
longitude: null,
bookingDayStart: '09:00',
bookingLastSlotStart: '21:30'
bookingLastSlotStart: '21:30',
bookingCapacity: 1
})
const toHHmm = (v) => {
@ -169,6 +184,7 @@ const toHHmm = (v) => {
const bookingStartIdx = computed(() => halfHourPickerIndex(form.value.bookingDayStart))
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 i = Number(e.detail.value)
@ -180,6 +196,10 @@ const onBookingLastChange = (e) => {
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 toCoord = (v) => {
@ -237,7 +257,8 @@ onMounted(async () => {
latitude: toCoord(storeInfo.latitude),
longitude: toCoord(storeInfo.longitude),
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) {
const res = await getStoreDetail(storeInfo.id)
@ -245,6 +266,7 @@ onMounted(async () => {
const d = res.data
base.bookingDayStart = toHHmm(d.bookingDayStart) || base.bookingDayStart
base.bookingLastSlotStart = toHHmm(d.bookingLastSlotStart) || base.bookingLastSlotStart
base.bookingCapacity = Number(d.bookingCapacity || base.bookingCapacity)
if (d.name != null) base.name = d.name
if (d.phone != null) base.phone = d.phone
if (d.address != null) base.address = d.address

View File

@ -700,7 +700,6 @@ const submitReport = async () => {
const payload = {
appointmentId: currentAppointmentId.value || null,
userId: userInfo.id,
petName: report.value.petName,
serviceType: report.value.serviceType,
appointmentTime: report.value.appointmentTime || null,