Compare commits
4 Commits
phase-a-rc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e11ce1aaaa | ||
|
|
d29ece3a85 | ||
|
|
9a12ee07b0 | ||
|
|
ec4015aaba |
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
20
README.md
20
README.md
@ -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`)
|
||||
|
||||
@ -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",
|
||||
|
||||
59
scripts/release-preflight.cjs
Normal file
59
scripts/release-preflight.cjs
Normal 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')
|
||||
@ -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}`)
|
||||
|
||||
@ -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)
|
||||
|
||||
|
||||
@ -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: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))
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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: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">
|
||||
<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
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user