perf: 优化页面
This commit is contained in:
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="booking-page">
|
||||
<view class="booking-page" :style="pageStyle">
|
||||
<!-- ──────────── Custom nav bar ──────────── -->
|
||||
<CustomNavBar title="预约课程" />
|
||||
|
||||
<!-- ──────────── Sticky header area ──────────── -->
|
||||
<view class="sticky-header">
|
||||
<!-- Date selector -->
|
||||
@@ -13,29 +16,45 @@
|
||||
<scroll-view
|
||||
class="slot-scroll"
|
||||
scroll-y
|
||||
:style="{ height: scrollHeight }"
|
||||
:style="{ height: scrollHeight, paddingTop: stickyHeaderHeight }"
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
|
||||
<view v-for="i in 4" :key="i" class="skeleton-card" />
|
||||
<view v-for="i in 3" :key="i" class="skeleton-card">
|
||||
<view class="skeleton-time" />
|
||||
<view class="skeleton-body">
|
||||
<view class="skeleton-title" />
|
||||
<view class="skeleton-sub" />
|
||||
</view>
|
||||
<view class="skeleton-btn" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Empty state -->
|
||||
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
||||
<image class="empty-img" src="/static/images/empty-calendar.png" mode="aspectFit" />
|
||||
<view class="empty-icon-circle">
|
||||
<text class="empty-icon-text">📅</text>
|
||||
</view>
|
||||
<text class="empty-text">当日暂无可约时段</text>
|
||||
<text class="empty-sub">请选择其他日期或时段</text>
|
||||
<text class="empty-sub">请选择其他日期或时段查看</text>
|
||||
</view>
|
||||
|
||||
<!-- Slot cards -->
|
||||
<view v-else class="slot-list">
|
||||
<!-- Date summary -->
|
||||
<view class="date-summary">
|
||||
<text class="date-summary-text">
|
||||
共 {{ filteredSlots.length }} 个可选时段
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<SlotCard
|
||||
v-for="slot in filteredSlots"
|
||||
:key="slot.id"
|
||||
:slot="slot"
|
||||
v-for="item in filteredSlots"
|
||||
:key="item.id"
|
||||
:time-slot="item"
|
||||
@book="onBookTap"
|
||||
@cancel="onCancelTap"
|
||||
/>
|
||||
@@ -48,7 +67,7 @@
|
||||
<!-- ──────────── Confirm popup ──────────── -->
|
||||
<BookingConfirmPopup
|
||||
:visible="showConfirmPopup"
|
||||
:slot="pendingSlot"
|
||||
:time-slot="pendingSlot"
|
||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||
@confirm="onConfirmBooking"
|
||||
@cancel="showConfirmPopup = false"
|
||||
@@ -62,11 +81,12 @@ import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pila
|
||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { formatDate, getDateRange } from '../../utils/format'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||
import SlotCard from '../../components/SlotCard.vue'
|
||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||
|
||||
@@ -82,13 +102,34 @@ const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
||||
const refreshing = ref(false)
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────
|
||||
// Approximate scroll area height (vh minus sticky header ~220rpx + tabbar ~100rpx)
|
||||
const scrollHeight = computed(() => {
|
||||
// Default: statusBar ~20px + 88rpx ≈ 64px; avoid empty string on first render
|
||||
const navBarHeight = ref('64px')
|
||||
const scrollHeight = ref('500px')
|
||||
const stickyHeaderHeight = ref('240rpx')
|
||||
|
||||
function updateLayout() {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
const headerPx = 220 * (sysInfo.windowWidth / 750)
|
||||
const tabbarPx = 100 * (sysInfo.windowWidth / 750)
|
||||
return `${sysInfo.windowHeight - headerPx - tabbarPx}px`
|
||||
})
|
||||
const ratio = sysInfo.windowWidth / 750
|
||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||
const navTitlePx = 88 * ratio
|
||||
const navBarPx = Math.round(statusBarPx + navTitlePx)
|
||||
navBarHeight.value = `${navBarPx}px`
|
||||
|
||||
// Measure sticky header: DateSelector (~160rpx) + TimePeriodFilter (~76rpx) + borders
|
||||
const stickyPx = Math.round(240 * ratio)
|
||||
stickyHeaderHeight.value = `${stickyPx}px`
|
||||
|
||||
// scrollHeight: from below nav bar to above tabbar
|
||||
const tabbarPx = Math.round(100 * ratio)
|
||||
scrollHeight.value = `${sysInfo.windowHeight - navBarPx - tabbarPx}px`
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
|
||||
// CSS variable for sticky header offset
|
||||
const pageStyle = computed(() => ({
|
||||
'--nav-bar-height': navBarHeight.value,
|
||||
}))
|
||||
|
||||
// ─── Filtered slots ───────────────────────────────────────
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
@@ -226,24 +267,29 @@ onMounted(async () => {
|
||||
<style lang="scss" scoped>
|
||||
.booking-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
background: #f7f4f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--nav-bar-height: v-bind(navBarHeight);
|
||||
padding-top: var(--nav-bar-height);
|
||||
}
|
||||
|
||||
/* ── Sticky header ─────────────────────────────────── */
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
position: fixed;
|
||||
top: var(--nav-bar-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ── Scroll container ──────────────────────────────── */
|
||||
.slot-scroll {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Slot list ─────────────────────────────────────── */
|
||||
@@ -251,7 +297,18 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 24rpx 0;
|
||||
padding: 24rpx 24rpx 0;
|
||||
}
|
||||
|
||||
/* ── Date summary ──────────────────────────────────── */
|
||||
.date-summary {
|
||||
padding: 0 8rpx 4rpx;
|
||||
}
|
||||
|
||||
.date-summary-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Loading skeleton ──────────────────────────────── */
|
||||
@@ -264,10 +321,59 @@ onMounted(async () => {
|
||||
|
||||
.skeleton-card {
|
||||
height: 140rpx;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
||||
gap: 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.skeleton-time {
|
||||
width: 80rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 12rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
width: 60%;
|
||||
height: 28rpx;
|
||||
border-radius: 8rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-sub {
|
||||
width: 40%;
|
||||
height: 20rpx;
|
||||
border-radius: 6rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
width: 140rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
@@ -281,15 +387,23 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
padding: 140rpx 40rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.empty-img {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8rpx;
|
||||
.empty-icon-circle {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 50%;
|
||||
background: #f0ece8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.empty-icon-text {
|
||||
font-size: 56rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
|
||||
Reference in New Issue
Block a user