feat(app): implement home, booking, and profile pages

Home: brand banner, studio info swiper, smart quick entries based on
membership status, upcoming bookings, card shop horizontal scroll
Booking: 7-day date selector, time period filter, slot cards with
status, booking confirm popup with membership picker
Profile: user card with login, training stats, menu with admin entry
8 reusable components: BrandBanner, StudioInfo, QuickEntry,
UpcomingBooking, CardShop, DateSelector, SlotCard, BookingConfirmPopup,
TimePeriodFilter, UserCard, TrainingStats, ProfileMenu
This commit is contained in:
richarjiang
2026-04-02 14:35:17 +08:00
parent 554fc30954
commit 3a29aca0db
26 changed files with 7766 additions and 74 deletions

View File

@@ -0,0 +1,203 @@
<template>
<view class="studio-info card">
<!-- Photo Swiper -->
<swiper
v-if="studioInfo?.photos?.length"
class="photo-swiper"
:indicator-dots="studioInfo.photos.length > 1"
:autoplay="true"
:interval="4000"
:duration="500"
indicator-color="rgba(255,255,255,0.5)"
indicator-active-color="#c9a87c"
circular
>
<swiper-item
v-for="(photo, idx) in studioInfo.photos"
:key="idx"
>
<image
class="photo"
:src="photo"
mode="aspectFill"
/>
</swiper-item>
</swiper>
<!-- Placeholder when no photos -->
<view v-else class="photo-placeholder">
<text class="placeholder-icon">🏃</text>
<text class="placeholder-text">专业普拉提工作室</text>
</view>
<!-- Info rows -->
<view class="info-rows">
<!-- Address -->
<view class="info-row" @tap="handleAddressTap">
<view class="icon-wrap">
<text class="iconfont">📍</text>
</view>
<text class="info-text address-text">
{{ studioInfo?.address || '地址加载中…' }}
</text>
<text class="info-action">
{{ studioInfo?.latitude ? '导航' : '复制' }}
</text>
</view>
<!-- Phone -->
<view class="info-row" @tap="handlePhoneTap">
<view class="icon-wrap">
<text class="iconfont">📞</text>
</view>
<text class="info-text">{{ studioInfo?.phone || '—' }}</text>
<text class="info-action">拨打</text>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import type { StudioConfig } from '@mp-pilates/shared'
const props = defineProps<{
studioInfo: StudioConfig | null
}>()
function handleAddressTap() {
if (!props.studioInfo) return
const { latitude, longitude, address, name } = props.studioInfo
if (latitude && longitude) {
uni.openLocation({
latitude,
longitude,
name: name || '普拉提工作室',
address: address,
fail() {
copyAddress()
},
})
} else {
copyAddress()
}
}
function copyAddress() {
const address = props.studioInfo?.address
if (!address) return
uni.setClipboardData({
data: address,
success() {
uni.showToast({ title: '地址已复制', icon: 'none' })
},
})
}
function handlePhoneTap() {
const phone = props.studioInfo?.phone
if (!phone) return
uni.makePhoneCall({
phoneNumber: phone,
fail() {
uni.showToast({ title: '拨号失败', icon: 'none' })
},
})
}
</script>
<style lang="scss" scoped>
.studio-info {
margin: 24rpx 24rpx 0;
overflow: hidden;
}
.card {
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
}
.photo-swiper {
width: 100%;
height: 360rpx;
border-radius: 16rpx 16rpx 0 0;
overflow: hidden;
}
.photo {
width: 100%;
height: 100%;
}
.photo-placeholder {
width: 100%;
height: 280rpx;
background: linear-gradient(135deg, #f0f0f0, #e8e8e8);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 16rpx;
border-radius: 16rpx 16rpx 0 0;
}
.placeholder-icon {
font-size: 80rpx;
}
.placeholder-text {
font-size: 28rpx;
color: #999;
}
.info-rows {
padding: 16rpx 32rpx;
}
.info-row {
display: flex;
align-items: center;
padding: 20rpx 0;
border-bottom: 1rpx solid #f0f0f0;
gap: 16rpx;
&:last-child {
border-bottom: none;
}
}
.icon-wrap {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.iconfont {
font-size: 36rpx;
}
.info-text {
flex: 1;
font-size: 28rpx;
color: #333;
line-height: 1.4;
}
.address-text {
font-size: 26rpx;
}
.info-action {
font-size: 24rpx;
color: #c9a87c;
padding: 6rpx 16rpx;
border: 1rpx solid #c9a87c;
border-radius: 24rpx;
flex-shrink: 0;
}
</style>