feat: 优化首页、个人中心页面 UI

This commit is contained in:
richarjiang
2026-09-08 14:20:43 +08:00
parent 301f9ae385
commit d1f193e13e
16 changed files with 481 additions and 2829 deletions

View File

@@ -1,5 +1,5 @@
<template>
<view class="booking-page">
<view class="booking-page" :style="{ height: pageHeight }">
<!-- Status bar spacing -->
<view class="status-bar" :style="{ height: statusBarHeight }" />
@@ -10,15 +10,18 @@
<!-- Date & period filters -->
<view class="filter-header">
<DateSelector v-model="selectedDate" variant="booking" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="booking" @change="onPeriodChange" />
<view class="calendar-heading">
<text class="calendar-month">{{ selectedMonthLabel }}</text>
<text class="calendar-hint">选择上课日期</text>
</view>
<DateSelector v-model="selectedDate" variant="soft" @select="onDateSelect" />
<TimePeriodFilter v-model="selectedPeriod" variant="soft" @change="onPeriodChange" />
</view>
<!-- Slot list -->
<scroll-view
class="slot-scroll"
scroll-y
:style="{ height: scrollHeight }"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@@ -37,12 +40,7 @@
<!-- Empty state -->
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
<view class="empty-illustration">
<view class="empty-circle outer" />
<view class="empty-circle inner" />
<view class="empty-dot" />
</view>
<text class="empty-text">当日暂无可约时段</text>
<text class="empty-text">这个时段还没有课程</text>
<text class="empty-sub">请选择其他日期或时段查看</text>
</view>
@@ -50,9 +48,8 @@
<view v-else class="slot-list">
<!-- Date summary -->
<view class="date-summary">
<text class="date-summary-text">
{{ filteredSlots.length }} 可选时段
</text>
<text class="date-summary-title">{{ selectedDayLabel }}</text>
<text class="date-summary-text">{{ filteredSlots.length }} 个时段</text>
</view>
<SlotCard
@@ -82,7 +79,7 @@
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
import { BookingStatus, TIME_PERIODS } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
@@ -126,28 +123,26 @@ onShareTimeline(() => {
// ─── Layout ───────────────────────────────────────────────
const statusBarHeight = ref('20px')
const scrollHeight = ref('500px')
// Heights of static elements above scroll-view (in rpx, converted to px)
const PAGE_HEADER_RPX = 88 // title bar height
const FILTER_HEADER_RPX = 240 // DateSelector + TimePeriodFilter
const TABBAR_RPX = 100
const pageHeight = ref('100vh')
function updateLayout() {
const { statusBarHeight: statusBarPx, windowWidth } = getSystemLayout()
const ratio = windowWidth / 750
statusBarHeight.value = `${statusBarPx}px`
const headerPx = Math.round(PAGE_HEADER_RPX * ratio)
const filterPx = Math.round(FILTER_HEADER_RPX * ratio)
const tabbarPx = Math.round(TABBAR_RPX * ratio)
// scroll-view fills remaining space: window - statusBar - pageHeader - filters - tabbar
const { windowHeight } = uni.getWindowInfo()
const remaining = windowHeight - statusBarPx - headerPx - filterPx - tabbarPx
scrollHeight.value = `${remaining}px`
statusBarHeight.value = `${getSystemLayout().statusBarHeight}px`
// The mini-program window already excludes its native tab bar. Flex layout
// gives the remaining height to the list as the filters change size.
pageHeight.value = `${uni.getWindowInfo().windowHeight}px`
}
updateLayout()
onResize(updateLayout)
const selectedMonthLabel = computed(() => {
const [year, month] = selectedDate.value.split('-')
return `${year}${Number(month)}`
})
const selectedDayLabel = computed(() => {
const [, month, day] = selectedDate.value.split('-')
return `${Number(month)}${Number(day)} 日的课程`
})
// ─── Filtered slots ───────────────────────────────────────
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
@@ -310,216 +305,33 @@ onMounted(async () => {
</script>
<style lang="scss" scoped>
.booking-page {
height: 100vh;
background:
radial-gradient(circle at top, rgba(255, 232, 218, 0.36), transparent 34%),
linear-gradient(180deg, #fbf7f3 0%, #f6efea 100%);
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Status bar ───────────────────────────────────── */
.status-bar {
flex-shrink: 0;
background: #fcfaf8;
}
/* ── Page header ──────────────────────────────────── */
.page-header {
flex-shrink: 0;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
background: #fcfaf8;
}
.page-title {
font-size: 34rpx;
font-weight: 700;
color: #3a2e2a;
letter-spacing: 1rpx;
}
/* ── Filter header ────────────────────────────────── */
.filter-header {
flex-shrink: 0;
background: rgba(252, 250, 248, 0.96);
box-shadow: 0 12rpx 30rpx rgba(120, 91, 79, 0.06);
}
/* ── Scroll container ──────────────────────────────── */
.slot-scroll {
flex: 1;
width: 100%;
box-sizing: border-box;
}
/* ── Slot list ─────────────────────────────────────── */
.slot-list {
display: flex;
flex-direction: column;
padding: 28rpx 0 0;
}
/* ── Date summary ──────────────────────────────────── */
.date-summary {
padding: 0 24rpx 16rpx;
}
.date-summary-text {
font-size: 24rpx;
color: #9d8b83;
font-weight: 500;
}
/* ── Loading skeleton ──────────────────────────────── */
.loading-wrap {
display: flex;
flex-direction: column;
gap: 24rpx;
padding: 28rpx 24rpx;
}
.skeleton-card {
height: 220rpx;
border-radius: 26rpx;
background: rgba(255, 255, 255, 0.88);
display: flex;
flex-direction: row;
align-items: center;
padding: 28rpx 48rpx;
gap: 20rpx;
margin: 0 24rpx;
box-shadow: 0 16rpx 36rpx rgba(120, 91, 79, 0.08);
}
.skeleton-time {
width: 90rpx;
height: 80rpx;
border-radius: 12rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 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, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-sub {
width: 40%;
height: 20rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-btn {
width: 100rpx;
height: 60rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, rgba(236, 225, 217, 0.9) 25%, rgba(248, 240, 233, 0.98) 50%, rgba(236, 225, 217, 0.9) 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
flex-shrink: 0;
}
/* ── Empty state ───────────────────────────────────── */
.empty-wrap {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 120rpx 40rpx 80rpx;
gap: 0;
}
/* Zen-inspired geometric illustration */
.empty-illustration {
position: relative;
width: 200rpx;
height: 200rpx;
margin-bottom: 56rpx;
}
.empty-circle {
position: absolute;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
&.outer {
width: 180rpx;
height: 180rpx;
border: 2rpx solid rgba(192, 154, 137, 0.18);
animation: breathe 3s ease-in-out infinite;
}
&.inner {
width: 120rpx;
height: 120rpx;
background: linear-gradient(135deg, #f6e9e1 0%, #ddc1b4 50%, #b98f7d 100%);
opacity: 0.6;
animation: breathe 3s ease-in-out infinite 0.5s;
}
}
.empty-dot {
position: absolute;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
background: #a87d6c;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
animation: pulse 2s ease-in-out infinite;
}
.empty-text {
font-size: 32rpx;
color: #6f605b;
font-weight: 600;
letter-spacing: 2rpx;
margin-bottom: 16rpx;
}
.empty-sub {
font-size: 26rpx;
color: #a18a82;
letter-spacing: 1rpx;
}
@keyframes breathe {
0%, 100% { transform: translate(-50%, -50%) scale(1); opacity: 0.6; }
50% { transform: translate(-50%, -50%) scale(1.05); opacity: 0.4; }
}
@keyframes pulse {
0%, 100% { opacity: 1; transform: translate(-50%, -50%) scale(1); }
50% { opacity: 0.5; transform: translate(-50%, -50%) scale(0.8); }
}
/* ── Bottom spacer ─────────────────────────────────── */
.scroll-bottom-spacer {
height: 48rpx;
.booking-page { height: 100vh; background: #fbf9f6; display: flex; flex-direction: column; overflow: hidden; }
.status-bar { flex-shrink: 0; }
.page-header { flex-shrink: 0; height: 88rpx; display: flex; align-items: center; justify-content: center; }
.page-title { font-size: 34rpx; font-weight: 500; color: #514943; }
.filter-header { flex-shrink: 0; border-bottom: 1rpx solid #eee8e3; }
.calendar-heading { display: flex; align-items: baseline; justify-content: space-between; padding: 24rpx 32rpx 16rpx; gap: 16rpx; }
.calendar-month { font-size: 28rpx; font-weight: 500; color: #514943; }
.calendar-hint { font-size: 22rpx; color: #8b817b; }
.slot-scroll { flex: 1; height: 0; min-height: 0; width: 100%; box-sizing: border-box; }
.slot-list { display: flex; flex-direction: column; padding-top: 28rpx; }
.date-summary { display: flex; justify-content: space-between; align-items: baseline; gap: 16rpx; padding: 0 32rpx 20rpx; }
.date-summary-title { font-size: 25rpx; color: #6f655e; }
.date-summary-text { font-size: 22rpx; color: #8b817b; }
.loading-wrap { display: flex; flex-direction: column; gap: 20rpx; padding: 28rpx 32rpx; }
.skeleton-card { height: 200rpx; box-sizing: border-box; border-radius: 28rpx; background: #fff; display: flex; align-items: center; padding: 28rpx; gap: 20rpx; }
.skeleton-time, .skeleton-title, .skeleton-sub, .skeleton-btn {
border-radius: 10rpx;
background: linear-gradient(90deg, #f0eae5 25%, #faf7f3 50%, #f0eae5 75%);
background-size: 400% 100%; animation: shimmer 1.4s infinite;
}
.skeleton-time { width: 90rpx; height: 70rpx; flex-shrink: 0; }
.skeleton-body { flex: 1; display: flex; flex-direction: column; gap: 12rpx; }
.skeleton-title { width: 80%; height: 28rpx; }
.skeleton-sub { width: 60%; height: 20rpx; }
.skeleton-btn { width: 110rpx; height: 60rpx; border-radius: 999rpx; flex-shrink: 0; }
.empty-wrap { margin: 32rpx; padding: 64rpx 28rpx; border-radius: 28rpx; background: #f3efea; display: flex; flex-direction: column; align-items: center; gap: 14rpx; }
.empty-text { font-size: 28rpx; color: #6f655e; font-weight: 400; }
.empty-sub { font-size: 23rpx; color: #8b817b; text-align: center; line-height: 1.6; }
.scroll-bottom-spacer { height: 28rpx; }
</style>

View File

@@ -1,52 +1,22 @@
<template>
<view class="home-page">
<!-- Brand Banner fixed background layer -->
<view class="home-page" :style="{ height: pageHeight }">
<view class="banner-fixed">
<BrandBanner :studio-info="studioStore.studioInfo" />
</view>
<!-- Pull-to-refresh wrapper scrollable foreground -->
<scroll-view
class="page-scroll"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@refresherrestore="refreshing = false"
>
<!-- Transparent spacer to reveal Banner behind -->
<scroll-view class="page-scroll" scroll-y :scroll-into-view="scrollTarget"
scroll-with-animation refresher-enabled :refresher-triggered="refreshing"
@refresherrefresh="handleRefresh" @refresherrestore="refreshing = false">
<view class="banner-spacer" />
<!-- Floating card with rounded top corners -->
<view class="floating-card">
<!-- Drag indicator -->
<view class="card-handle">
<view class="card-handle-bar" />
</view>
<!-- Studio Info (photo strip + address/phone) -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<!-- Quick Entry (login / trial / book / renew) -->
<view class="card-handle"><view class="card-handle-bar" /></view>
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<!-- Upcoming Bookings -->
<UpcomingBooking />
<!-- .5 Flash Sale Section -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<FlashSaleSection ref="flashSaleRef" />
<!-- Card Shop (vertical list) -->
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
<!-- About (teacher + studio gallery) -->
<AboutSection />
<!-- Bottom padding for tab bar -->
<view class="bottom-padding" />
</view>
</scroll-view>
@@ -55,7 +25,7 @@
<script setup lang="ts">
import { ref, nextTick, onUnmounted } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { onShow, onResize, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import BrandBanner from '../../components/BrandBanner.vue'
import StudioInfo from '../../components/StudioInfo.vue'
@@ -94,7 +64,9 @@ const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const flashSaleRef = ref<InstanceType<typeof FlashSaleSection> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0)
const scrollTarget = ref('')
const pageHeight = ref(`${uni.getWindowInfo().windowHeight}px`)
onResize(() => { pageHeight.value = `${uni.getWindowInfo().windowHeight}px` })
const pendingScrollToCardShop = ref(false)
// Listen for cross-page scroll request (e.g. from booking page "去购买")
@@ -132,8 +104,10 @@ async function refreshData() {
await Promise.allSettled(tasks)
// Also refresh card shop and flash sales
cardShopRef.value?.fetchCardTypes()
flashSaleRef.value?.fetchFlashSales()
await Promise.allSettled([
cardShopRef.value?.fetchCardTypes(),
flashSaleRef.value?.fetchFlashSales(),
])
}
async function handleRefresh() {
@@ -145,78 +119,30 @@ async function handleRefresh() {
}
}
function scrollToCardShop() {
// Reset first so setting the same value still triggers scroll
scrollTop.value = 0
nextTick(() => {
uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`)
.boundingClientRect()
.selectViewport()
.scrollOffset((res) => {
if (res) {
scrollTop.value = (res as UniApp.NodeInfo).scrollTop ?? 0
}
})
.exec()
})
async function scrollToCardShop() {
scrollTarget.value = ''
await nextTick()
scrollTarget.value = cardShopAnchorId
}
</script>
<style lang="scss" scoped>
.home-page {
position: relative;
height: 100vh;
background: #FAF8F5;
}
/* Banner fixed behind everything */
.banner-fixed {
position: fixed;
top: 0;
left: 0;
width: 100%;
z-index: 0;
}
/* Scroll layer sits above banner */
.page-scroll {
position: relative;
z-index: 1;
flex: 1;
height: 100vh;
}
/* Transparent spacer lets banner peek through */
.banner-spacer {
height: 420rpx;
}
/* Floating card that overlaps the banner */
.home-page { position: relative; height: 100vh; background: #fbf9f6; }
.banner-fixed { position: fixed; top: 0; left: 0; width: 100%; z-index: 0; }
.page-scroll { position: relative; z-index: 1; height: 100%; }
.banner-spacer { height: 420rpx; }
.floating-card {
position: relative;
background: #FAF8F5;
border-radius: 32rpx 32rpx 0 0;
min-height: 100vh;
padding-top: 12rpx;
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
}
/* Small drag indicator at top of card */
.card-handle {
display: flex;
justify-content: center;
padding: 16rpx 0 8rpx;
}
.card-handle-bar {
width: 64rpx;
height: 8rpx;
border-radius: 4rpx;
background: rgba(0, 0, 0, 0.1);
}
.bottom-padding {
height: 120rpx;
border-radius: 32rpx 32rpx 0 0;
border-top: 1rpx solid rgba(255, 255, 255, 0.7);
background: linear-gradient(180deg, rgba(251, 249, 246, 0.86), #fbf9f6 260rpx);
-webkit-backdrop-filter: blur(16px);
backdrop-filter: blur(16px);
box-shadow: 0 -4rpx 20rpx rgba(66, 54, 45, 0.06);
}
.card-handle { display: flex; justify-content: center; padding: 16rpx 0 8rpx; }
.card-handle-bar { width: 64rpx; height: 8rpx; border-radius: 4rpx; background: rgba(112, 94, 79, 0.18); }
.bottom-padding { height: 48rpx; }
</style>

View File

@@ -1,13 +1,12 @@
<template>
<view class="profile-page">
<!-- Custom nav bar (transparent, blends with UserCard gradient) -->
<CustomNavBar title="我的" transparent />
<view class="profile-page" :style="{ paddingTop: navBarHeight + 'px' }">
<view class="profile-page__nav" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="profile-page__nav-title">我的</view>
</view>
<!-- User card -->
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
:loading="loginLoading" @login="handleLogin" @edit="goToInfo" />
<!-- Menu section: always visible -->
<ProfileMenu
@@ -18,7 +17,9 @@
:invite-share-eligible="!!user?.inviteShareEligible"
@clear-cache="handleClearCache"
@require-login="handleLogin"
/>
>
<PracticeActivityCard v-if="loggedIn" :key="userStore.token" :refresh-key="activityRefreshKey" />
</ProfileMenu>
<!-- Logout button: only when logged in -->
<view v-if="loggedIn" class="profile-page__logout-wrap">
@@ -38,7 +39,6 @@ import { getErrorMessage } from '../../utils/auth'
import PracticeActivityCard from '../../components/PracticeActivityCard.vue'
import UserCard from '../../components/UserCard.vue'
import ProfileMenu from '../../components/ProfileMenu.vue'
import CustomNavBar from '../../components/CustomNavBar.vue'
const userStore = useUserStore()
const bookingStore = useBookingStore()
@@ -47,7 +47,8 @@ const { upcomingBookings } = storeToRefs(bookingStore)
const activityRefreshKey = ref(0)
const loginLoading = ref(false)
const navBarHeight = ref(64)
const navBarHeight = ref(getSystemLayout().navBarHeight)
const statusBarHeight = getSystemLayout().statusBarHeight
const activeMembershipCount = computed(
() => user.value?.activeMembershipCount ?? userStore.activeMemberships.length,
@@ -107,12 +108,16 @@ async function handleLogin() {
}
}
function goToInfo() {
uni.navigateTo({ url: '/pages/profile/info' })
}
function handleLogout() {
uni.showModal({
title: '退出登录',
content: '确定要退出登录吗?',
confirmText: '退出',
confirmColor: '#ff4d4f',
confirmColor: '#9c7a6e',
success(res) {
if (res.confirm) {
userStore.logout()
@@ -138,27 +143,10 @@ function handleClearCache() {
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
&__logout-wrap {
margin: $spacing-xl $spacing-lg $spacing-xl;
}
&__logout-btn {
width: 100%;
height: 88rpx;
line-height: 88rpx;
background: $bg-card;
color: $error-color;
font-size: 30rpx;
font-weight: 500;
border: none;
border-radius: $radius-lg;
text-align: center;
&::after {
border: none;
}
}
min-height: 100vh; box-sizing: border-box; background: #fbf9f6; padding-bottom: 36rpx;
&__nav { position: fixed; top: 0; left: 0; right: 0; z-index: 101; background: #fbf9f6; }
&__nav-title { height: 88rpx; display: flex; align-items: center; justify-content: center; font-size: 34rpx; font-weight: 500; color: #514943; }
&__logout-wrap { margin: 28rpx 32rpx 0; }
&__logout-btn { width: 100%; height: 80rpx; line-height: 80rpx; padding: 0; margin: 0; border: none; border-radius: 24rpx; background: #f2ede7; color: #91766a; font-size: 25rpx; font-weight: 400; &::after { border: none; } }
}
</style>