更新 2026-04-18 21:39

This commit is contained in:
MaDaLei 2026-04-18 21:39:26 +08:00
parent 07ad42ea3b
commit a6bd72f0d4
6 changed files with 281 additions and 40 deletions

25
package-lock.json generated
View File

@ -3260,7 +3260,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3274,7 +3273,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3288,7 +3286,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3302,7 +3299,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3316,7 +3312,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3330,7 +3325,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3344,7 +3338,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3358,7 +3351,6 @@
"cpu": [ "cpu": [
"arm" "arm"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3372,7 +3364,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3386,7 +3377,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3400,7 +3390,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3414,7 +3403,6 @@
"cpu": [ "cpu": [
"loong64" "loong64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3428,7 +3416,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3442,7 +3429,6 @@
"cpu": [ "cpu": [
"ppc64" "ppc64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3456,7 +3442,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3470,7 +3455,6 @@
"cpu": [ "cpu": [
"riscv64" "riscv64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3484,7 +3468,6 @@
"cpu": [ "cpu": [
"s390x" "s390x"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3498,7 +3481,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3512,7 +3494,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3526,7 +3507,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3540,7 +3520,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3554,7 +3533,6 @@
"cpu": [ "cpu": [
"arm64" "arm64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3568,7 +3546,6 @@
"cpu": [ "cpu": [
"ia32" "ia32"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3582,7 +3559,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [
@ -3596,7 +3572,6 @@
"cpu": [ "cpu": [
"x64" "x64"
], ],
"dev": true,
"license": "MIT", "license": "MIT",
"optional": true, "optional": true,
"os": [ "os": [

View File

@ -9,6 +9,23 @@ const isPublicRoute = (route) => {
return false return false
} }
function buildLoginRedirectPath() {
try {
const pages = getCurrentPages()
const cur = pages[pages.length - 1]
if (!cur) return ''
let path = cur.route || ''
if (!path.startsWith('/')) path = '/' + path
const opts = cur.options || {}
const keys = Object.keys(opts)
if (keys.length === 0) return path
const qs = keys.map((k) => `${encodeURIComponent(k)}=${encodeURIComponent(opts[k])}`).join('&')
return `${path}?${qs}`
} catch (_) {
return ''
}
}
function scheduleAuthGuard() { function scheduleAuthGuard() {
setTimeout(() => { setTimeout(() => {
try { try {
@ -18,7 +35,16 @@ function scheduleAuthGuard() {
const cur = pages[pages.length - 1] const cur = pages[pages.length - 1]
const route = cur && cur.route ? cur.route : '' const route = cur && cur.route ? cur.route : ''
if (isPublicRoute(route)) return if (isPublicRoute(route)) return
uni.reLaunch({ url: '/pages/login/Login' }) const redirect = buildLoginRedirectPath()
const safe =
redirect &&
redirect.startsWith('/pages/') &&
!redirect.includes('..') &&
!redirect.includes('//')
const loginUrl = safe
? `/pages/login/Login?redirect=${encodeURIComponent(redirect)}`
: '/pages/login/Login'
uni.reLaunch({ url: loginUrl })
} catch (_) { } catch (_) {
uni.reLaunch({ url: '/pages/login/Login' }) uni.reLaunch({ url: '/pages/login/Login' })
} }

View File

@ -20,9 +20,11 @@
</view> </view>
<view v-else-if="empty" class="aps-panel aps-empty"> <view v-else-if="empty" class="aps-panel aps-empty">
<text class="aps-emoji">{{ emptyEmoji }}</text> <slot name="empty">
<text class="aps-title">{{ emptyTitle }}</text> <text class="aps-emoji">{{ emptyEmoji }}</text>
<text v-if="emptyHint" class="aps-hint">{{ emptyHint }}</text> <text class="aps-title">{{ emptyTitle }}</text>
<text v-if="emptyHint" class="aps-hint">{{ emptyHint }}</text>
</slot>
</view> </view>
<slot v-else /> <slot v-else />

View File

@ -12,7 +12,19 @@
</view> </view>
<view class="c-form-card"> <view class="c-form-card">
<view class="c-field"> <view v-if="useCompactStoreRow" class="c-field">
<text class="app-form-label">预约门店</text>
<view class="c-store-summary-row">
<text class="c-store-summary-name">{{ currentStoreDisplayName }}</text>
<text
v-if="orderedStores.length > 1 && storeLockedCompact"
class="c-store-change-link"
@click="expandStorePicker"
>更换门店</text>
</view>
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
</view>
<view v-else class="c-field">
<text class="app-form-label">预约门店</text> <text class="app-form-label">预约门店</text>
<picker <picker
mode="selector" mode="selector"
@ -27,6 +39,11 @@
<text class="app-form-arrow"></text> <text class="app-form-arrow"></text>
</view> </view>
</picker> </picker>
<text
v-if="showCollapseStoreLink"
class="c-store-collapse"
@click="onCollapseStorePicker"
>收起</text>
<text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text> <text v-if="storeHintLine" class="c-store-hint">{{ storeHintLine }}</text>
</view> </view>
<view class="c-field"> <view class="c-field">
@ -85,6 +102,11 @@
</picker> </picker>
</view> </view>
<text class="c-store-hint">{{ bookingWindowHint }}</text> <text class="c-store-hint">{{ bookingWindowHint }}</text>
<button
v-if="showTryTomorrow"
class="app-btn-sm app-btn-soft c-try-tomorrow"
@click="tryPickTomorrow"
>试选明天</button>
</view> </view>
<view class="c-field"> <view class="c-field">
<text class="app-form-label">备注可选</text> <text class="app-form-label">备注可选</text>
@ -97,6 +119,7 @@
<script setup> <script setup>
import { ref, computed, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { import {
createAppointment, createAppointment,
getServiceTypeList, getServiceTypeList,
@ -116,10 +139,57 @@ const LAST_SUBMITTED_PET_KEY = `petstore_last_submitted_pet_${userInfo.id || 'gu
const BOOKING_STORE_ID_KEY = 'petstore_booking_store_id' const BOOKING_STORE_ID_KEY = 'petstore_booking_store_id'
const lastServiceStorageKey = (storeId) =>
`petstore_last_service_${userInfo.id || 'guest'}_${storeId}`
/** 扫码/深链带入的门店 id用于预填并支持折叠展示 */
const routeStoreId = ref('')
const storeExpanded = ref(false)
const orderedStores = ref([]) const orderedStores = ref([])
const selectedStoreIndex = ref(0) const selectedStoreIndex = ref(0)
const hasUserLocation = ref(false) const hasUserLocation = ref(false)
onLoad((options) => {
if (options?.storeId != null && String(options.storeId).trim() !== '') {
routeStoreId.value = String(options.storeId).trim()
}
})
const storeLockedCompact = computed(() => {
if (!routeStoreId.value || !orderedStores.value.length) return false
const cur = orderedStores.value[selectedStoreIndex.value]
return !!(cur && String(cur.id) === String(routeStoreId.value))
})
const useCompactStoreRow = computed(() => {
if (!orderedStores.value.length) return false
if (orderedStores.value.length === 1) return true
return storeLockedCompact.value && !storeExpanded.value
})
const showCollapseStoreLink = computed(
() =>
!!routeStoreId.value &&
orderedStores.value.length > 1 &&
storeLockedCompact.value &&
storeExpanded.value
)
const currentStoreDisplayName = computed(() => {
const row = orderedStores.value[selectedStoreIndex.value]
if (!row) return orderedStores.value.length ? '请选择' : '暂无可选门店'
return row.pickerLabel || row.name || '—'
})
const expandStorePicker = () => {
storeExpanded.value = true
}
const onCollapseStorePicker = () => {
storeExpanded.value = false
}
const navSafeStyle = (() => { const navSafeStyle = (() => {
const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20 const statusBarHeight = uni.getSystemInfoSync?.().statusBarHeight || 20
let navHeight = statusBarHeight + 44 let navHeight = statusBarHeight + 44
@ -215,7 +285,14 @@ const initStoresAndLocation = async () => {
const savedId = uni.getStorageSync(BOOKING_STORE_ID_KEY) const savedId = uni.getStorageSync(BOOKING_STORE_ID_KEY)
const sid = storeInfo?.id ?? userInfo?.storeId const sid = storeInfo?.id ?? userInfo?.storeId
let pickIdx = 0 let pickIdx = 0
if (savedId && orderedStores.value.some((x) => String(x.id) === String(savedId))) { const qrIdx =
routeStoreId.value && orderedStores.value.length
? orderedStores.value.findIndex((x) => String(x.id) === String(routeStoreId.value))
: -1
if (qrIdx >= 0) {
pickIdx = qrIdx
storeExpanded.value = false
} else if (savedId && orderedStores.value.some((x) => String(x.id) === String(savedId))) {
pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(savedId)) pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(savedId))
} else if (sid && orderedStores.value.some((x) => String(x.id) === String(sid))) { } else if (sid && orderedStores.value.some((x) => String(x.id) === String(sid))) {
pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(sid)) pickIdx = orderedStores.value.findIndex((x) => String(x.id) === String(sid))
@ -313,6 +390,25 @@ const selectedSlotRangeLabel = computed(() =>
appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : '' appointmentTime.value ? formatHalfHourSlotRange(appointmentTime.value) : ''
) )
const showTryTomorrow = computed(
() =>
!loadingSlots.value &&
!!selectedDateStr.value &&
availableTimes.value.length === 0
)
const tryPickTomorrow = () => {
const cur = selectedDateStr.value
if (!cur) return
const parts = cur.split('-').map((x) => parseInt(x, 10))
if (parts.length !== 3 || parts.some((n) => Number.isNaN(n))) return
const d = new Date(parts[0], parts[1] - 1, parts[2])
if (Number.isNaN(d.getTime())) return
d.setDate(d.getDate() + 1)
selectedDateStr.value = ymdLocal(d)
loadAvailableSlots()
}
const loadAvailableSlots = async () => { const loadAvailableSlots = async () => {
const row = orderedStores.value[selectedStoreIndex.value] const row = orderedStores.value[selectedStoreIndex.value]
const storeId = row?.id const storeId = row?.id
@ -390,6 +486,14 @@ const loadServiceTypesForSelected = async () => {
const res = await getServiceTypeList(sid ?? undefined) const res = await getServiceTypeList(sid ?? undefined)
if (res.code === 200 && Array.isArray(res.data)) { 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, value: s.name }))
if (sid) {
try {
const last = uni.getStorageSync(lastServiceStorageKey(sid))
if (last && serviceTypes.value.some((s) => s.value === last)) {
newAppt.value.serviceType = last
}
} catch (_) {}
}
} }
} }
@ -473,6 +577,9 @@ const confirmNewAppt = async () => {
creatingAppt.value = false creatingAppt.value = false
if (res.code === 200) { if (res.code === 200) {
rememberLastSubmittedPet(ensuredPet) rememberLastSubmittedPet(ensuredPet)
try {
uni.setStorageSync(lastServiceStorageKey(storeId), a.serviceType)
} catch (_) {}
uni.showToast({ title: '预约成功', icon: 'success' }) uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => uni.navigateBack(), 600) setTimeout(() => uni.navigateBack(), 600)
} else { } else {
@ -573,4 +680,38 @@ onMounted(async () => {
color: #666660; color: #666660;
line-height: 1.5; line-height: 1.5;
} }
.c-store-summary-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 14px;
background: #f5f5f2;
border-radius: 12px;
}
.c-store-summary-name {
flex: 1;
min-width: 0;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
}
.c-store-change-link {
flex-shrink: 0;
font-size: 14px;
font-weight: 600;
color: #2db96d;
padding: 6px 0 6px 8px;
}
.c-store-collapse {
display: block;
margin-top: 8px;
font-size: 13px;
font-weight: 600;
color: #2db96d;
}
.c-try-tomorrow {
margin-top: 12px;
width: 100%;
}
</style> </style>

View File

@ -8,7 +8,10 @@
<view class="c-hero"> <view class="c-hero">
<text class="c-hero-title">服务预约</text> <text class="c-hero-title">服务预约</text>
<text class="c-hero-sub">查看预约进度点击右下角加号新建预约</text> <text class="c-hero-sub">一键预约洗美到店与进度随时可查</text>
<button class="app-btn-main app-btn-primary c-hero-cta" hover-class="c-hero-cta--hover" @click="openCustCreateAppt">
预约服务
</button>
</view> </view>
<view class="home-dash"> <view class="home-dash">
@ -64,6 +67,12 @@
:empty-hint="listEmptyHint" :empty-hint="listEmptyHint"
@retry="fetchAppointments" @retry="fetchAppointments"
> >
<template v-if="custEmptyProminent" #empty>
<text class="c-empty-emoji">📅</text>
<text class="c-empty-title">还没有预约</text>
<text class="c-empty-hint">创建预约后待开始与进行中的单会出现在下方列表</text>
<button class="app-btn-main app-btn-primary c-empty-cta" @click="openCustCreateAppt">立即预约</button>
</template>
<view class="order-list"> <view class="order-list">
<view <view
v-for="item in ordersForList" v-for="item in ordersForList"
@ -86,7 +95,7 @@
</view> </view>
</AppPageState> </AppPageState>
<view class="fab-appt" @click="openCustCreateAppt" hover-class="fab-appt--hover"> <view class="fab-appt fab-appt--cust-secondary" @click="openCustCreateAppt" hover-class="fab-appt--hover">
<text class="fab-appt-icon">+</text> <text class="fab-appt-icon">+</text>
</view> </view>
</view> </view>
@ -437,17 +446,26 @@ const showListPlaceholder = computed(
() => !appointmentsLoading.value && !appointmentsError.value && ordersForList.value.length === 0 () => !appointmentsLoading.value && !appointmentsError.value && ordersForList.value.length === 0
) )
/** 宠主 Tab 列表为空:用自定义空态突出主按钮(与首屏 CTA 一致) */
const custEmptyProminent = computed(
() => userInfo.role === 'customer' && listMode.value === 'tab' && showListPlaceholder.value
)
const listEmptyTitle = computed(() => { const listEmptyTitle = computed(() => {
if (listMode.value === 'today') return '今日暂无预约' if (listMode.value === 'today') return '今日暂无预约'
if (listMode.value === 'week') return '本周暂无已完成' if (listMode.value === 'week') return '本周暂无已完成'
return userInfo.role === 'customer' ? '暂无预约' : '暂无数据' return userInfo.role === 'customer' ? '暂无预约' : '暂无数据'
}) })
const listEmptyHint = computed(() => const listEmptyHint = computed(() => {
listMode.value === 'today' || listMode.value === 'week' if (listMode.value === 'today' || listMode.value === 'week') {
? '可返回列表切换日期或 Tab' return '可返回列表切换日期或 Tab'
: '切换上方状态或新建预约' }
) if (userInfo.role === 'customer') {
return '切换上方 Tab 查看不同状态,或新建预约'
}
return '切换上方状态或新建预约'
})
const loadStaffAvailableSlots = async () => { const loadStaffAvailableSlots = async () => {
if (!storeInfo?.id) { if (!storeInfo?.id) {
@ -733,6 +751,51 @@ onShow(() => {
color: #999993; color: #999993;
margin-top: 4px; margin-top: 4px;
} }
.c-hero-cta {
width: 100%;
margin-top: 20px;
border-radius: 14px !important;
font-size: 16px !important;
font-weight: 700 !important;
}
.c-hero-cta--hover {
opacity: 0.94;
}
.c-empty-emoji {
display: block;
font-size: 40px;
text-align: center;
margin-bottom: 12px;
}
.c-empty-title {
display: block;
font-size: 17px;
font-weight: 700;
color: #1a1a1a;
text-align: center;
}
.c-empty-hint {
display: block;
font-size: 13px;
color: #999993;
text-align: center;
margin-top: 8px;
line-height: 1.5;
padding: 0 8px;
}
.c-empty-cta {
width: 100%;
max-width: 280px;
margin: 24px auto 0;
border-radius: 14px !important;
}
.fab-appt--cust-secondary {
background: #e8f8ef;
box-shadow: 0 4px 14px rgba(45, 185, 109, 0.22);
}
.fab-appt--cust-secondary .fab-appt-icon {
color: #2db96d;
}
.c-form-card { .c-form-card {
margin: 0 16px; margin: 0 16px;
background: #fff; background: #fff;

View File

@ -154,6 +154,38 @@ const safeLine = (s, fallback = '—') => {
const isHttpOk = (res) => res && (res.code === 200 || res.code === '200') const isHttpOk = (res) => res && (res.code === 200 || res.code === '200')
function parseRedirectParam(raw) {
if (raw == null || raw === '') return ''
let s = String(raw)
try {
s = decodeURIComponent(s)
if (/%[0-9A-Fa-f]{2}/.test(s)) s = decodeURIComponent(s)
} catch (_) {
return ''
}
return s
}
function isSafeLoginRedirect(path) {
return (
typeof path === 'string' &&
path.startsWith('/pages/') &&
!path.includes('..') &&
!path.includes('//')
)
}
const redirectAfterLogin = ref('')
function navigateAfterLogin() {
const r = redirectAfterLogin.value
if (isSafeLoginRedirect(r)) {
uni.reLaunch({ url: r })
} else {
openAppPage('home')
}
}
onLoad((options) => { onLoad((options) => {
view.value = 'login' view.value = 'login'
const raw = options && (options.phone ?? options.prefillPhone) const raw = options && (options.phone ?? options.prefillPhone)
@ -161,6 +193,8 @@ onLoad((options) => {
if (/^1[3-9]\d{9}$/.test(p)) { if (/^1[3-9]\d{9}$/.test(p)) {
loginForm.phone = p loginForm.phone = p
} }
const red = options && options.redirect != null ? options.redirect : ''
redirectAfterLogin.value = parseRedirectParam(red)
}) })
const backToLoginForm = () => { view.value = 'login' } const backToLoginForm = () => { view.value = 'login' }
@ -235,7 +269,7 @@ const handleLogin = async () => {
if (res.code === 200) { if (res.code === 200) {
setUserSession(res.data.user) setUserSession(res.data.user)
setStoreSession(res.data.store) setStoreSession(res.data.store)
openAppPage('home') navigateAfterLogin()
} else { } else {
showToast(res.message || '登录失败') showToast(res.message || '登录失败')
} }
@ -279,7 +313,7 @@ const onWxGetPhoneNumber = async (e) => {
if (res.code === 200) { if (res.code === 200) {
setUserSession(res.data.user) setUserSession(res.data.user)
setStoreSession(res.data.store) setStoreSession(res.data.store)
openAppPage('home') navigateAfterLogin()
} else { } else {
showToast(res.message || '登录失败') showToast(res.message || '登录失败')
} }