init: 初始化

This commit is contained in:
richarjiang
2026-04-04 10:03:06 +08:00
parent 7a06b5e336
commit 817d5a85c5
14 changed files with 478 additions and 394 deletions

View File

@@ -1,33 +1,42 @@
<template>
<view class="brand-banner" :style="bannerStyle">
<view class="brand-banner">
<!-- Background image layer -->
<image
v-if="studioInfo?.bannerUrl"
class="banner-bg"
:src="studioInfo.bannerUrl"
mode="aspectFill"
/>
<!-- Dark overlay for readability -->
<view class="banner-overlay" />
<!-- Status bar spacer -->
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }" />
<!-- Nav area -->
<view class="nav-bar">
<view class="studio-name-row">
<!-- Centered content -->
<view class="banner-content">
<!-- Circular logo -->
<view class="logo-circle">
<image
v-if="studioInfo?.logo"
class="logo"
class="logo-img"
:src="studioInfo.logo"
mode="aspectFit"
/>
<text class="studio-name">{{ studioInfo?.name || '普拉提工作室' }}</text>
<text v-else class="logo-placeholder">FC</text>
</view>
<text class="studio-slogan">专业 · 精致 · 健康</text>
</view>
<!-- Decorative circles -->
<view class="deco-circle deco-circle--1" />
<view class="deco-circle deco-circle--2" />
<!-- Studio name -->
<text class="studio-name">{{ studioInfo?.name || 'Focus Core' }}</text>
</view>
</view>
</template>
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import { onMounted, ref } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
const props = defineProps<{
defineProps<{
studioInfo: StudioConfig | null
}>()
@@ -37,82 +46,79 @@ onMounted(() => {
const sysInfo = uni.getSystemInfoSync()
statusBarHeight.value = sysInfo.statusBarHeight ?? 20
})
const bannerStyle = computed(() => {
if (props.studioInfo?.bannerUrl) {
return {
backgroundImage: `url(${props.studioInfo.bannerUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
}
}
return {}
})
</script>
<style lang="scss" scoped>
.brand-banner {
position: relative;
width: 100%;
min-height: 300rpx;
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 50%, #1a1a2e 100%);
height: 580rpx;
overflow: hidden;
padding-bottom: 40rpx;
background: #2a2a2a;
}
.nav-bar {
.banner-bg {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
}
.banner-overlay {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0, 0, 0, 0.3);
}
.status-bar {
position: relative;
z-index: 2;
padding: 16rpx 40rpx 0;
}
.studio-name-row {
.banner-content {
position: relative;
z-index: 2;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40rpx;
}
.logo-circle {
width: 200rpx;
height: 200rpx;
border-radius: 50%;
background: #ffffff;
display: flex;
align-items: center;
gap: 16rpx;
justify-content: center;
overflow: hidden;
box-shadow: 0 8rpx 40rpx rgba(0, 0, 0, 0.2);
}
.logo {
width: 64rpx;
height: 64rpx;
border-radius: 12rpx;
.logo-img {
width: 160rpx;
height: 160rpx;
}
.logo-placeholder {
font-size: 64rpx;
font-weight: 800;
color: #333;
letter-spacing: 4rpx;
}
.studio-name {
font-size: 44rpx;
margin-top: 28rpx;
font-size: 40rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
.studio-slogan {
display: block;
margin-top: 10rpx;
font-size: 24rpx;
color: #c9a87c;
letter-spacing: 6rpx;
}
/* Decorative blurred circles */
.deco-circle {
position: absolute;
border-radius: 50%;
opacity: 0.12;
background: #c9a87c;
}
.deco-circle--1 {
width: 300rpx;
height: 300rpx;
top: -80rpx;
right: -60rpx;
}
.deco-circle--2 {
width: 180rpx;
height: 180rpx;
bottom: -40rpx;
right: 120rpx;
opacity: 0.08;
text-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.3);
}
</style>

View File

@@ -1,84 +1,61 @@
<template>
<view class="card-shop">
<!-- Section header -->
<view class="section-header">
<text class="section-title">会员卡</text>
<text class="section-subtitle">选择适合您的套餐</text>
<text class="section-action" @tap="goToAllCards">全部</text>
</view>
<!-- Loading skeleton -->
<scroll-view
v-if="loading"
scroll-x
class="cards-scroll"
:show-scrollbar="false"
>
<view class="cards-row">
<view
v-for="i in 3"
:key="i"
class="card-item skeleton-card"
/>
<view v-if="loading" class="card-list">
<view
v-for="i in 3"
:key="i"
class="card-row skeleton-row"
>
<view class="skeleton-thumb" />
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--title" />
<view class="skeleton-line skeleton-line--sub" />
<view class="skeleton-line skeleton-line--price" />
</view>
</view>
</scroll-view>
</view>
<!-- Card list -->
<scroll-view
v-else-if="cardTypes.length"
scroll-x
class="cards-scroll"
:show-scrollbar="false"
>
<view class="cards-row">
<view
v-for="card in cardTypes"
:key="card.id"
class="card-item"
:class="cardItemClass(card)"
@tap="goToDetail(card.id)"
>
<!-- Card header band -->
<view class="card-header" :class="cardHeaderClass(card)">
<text class="card-type-label">{{ typeLabel(card) }}</text>
<view v-else-if="cardTypes.length" class="card-list">
<view
v-for="card in cardTypes"
:key="card.id"
class="card-row"
@tap="goToDetail(card.id)"
>
<!-- Thumbnail -->
<view class="card-thumb" :class="thumbClass(card)">
<view class="thumb-fallback">
<text class="thumb-name">{{ truncate(card.name, 8) }}</text>
<text class="thumb-price">¥{{ formatPrice(card.price) }}</text>
</view>
</view>
<!-- Card name -->
<!-- Card info -->
<view class="card-info">
<text class="card-name">{{ card.name }}</text>
<!-- Pricing -->
<text class="card-validity">有效期:{{ card.durationDays }} </text>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
<text class="price-label">价格:</text>
<text class="price-symbol">¥</text>
<text class="price-current">{{ formatPrice(card.price) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
>
¥{{ formatPrice(card.originalPrice) }}
原价:¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
<!-- Description -->
<text v-if="card.description" class="card-desc">
{{ truncate(card.description, 40) }}
</text>
<!-- Duration / Times -->
<view class="card-meta">
<view v-if="card.totalTimes" class="meta-item">
<text class="meta-value">{{ card.totalTimes }}</text>
<text class="meta-label"></text>
</view>
<view class="meta-item">
<text class="meta-value">{{ card.durationDays }}</text>
<text class="meta-label">天有效</text>
</view>
</view>
<!-- Buy button -->
<view class="buy-btn">
<text class="buy-btn-text">立即购买</text>
</view>
</view>
</view>
</scroll-view>
</view>
<!-- Empty -->
<view v-else class="empty-state">
@@ -120,25 +97,15 @@ function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
}
function typeLabel(card: CardType): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验',
}
return map[card.type] ?? '会员卡'
function goToAllCards() {
// Navigate to all cards page or scroll behavior
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
}
function cardItemClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'card-item--trial'
if (card.type === CardTypeCategory.DURATION) return 'card-item--duration'
return ''
}
function cardHeaderClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'header--trial'
if (card.type === CardTypeCategory.DURATION) return 'header--duration'
return 'header--times'
function thumbClass(card: CardType): string {
if (card.type === CardTypeCategory.TRIAL) return 'thumb--trial'
if (card.type === CardTypeCategory.DURATION) return 'thumb--duration'
return 'thumb--times'
}
function truncate(str: string, maxLen: number): string {
@@ -148,166 +115,208 @@ function truncate(str: string, maxLen: number): string {
<style lang="scss" scoped>
.card-shop {
margin: 24rpx 0 0;
padding-bottom: 40rpx;
background: #ffffff;
margin-top: 16rpx;
padding-bottom: 20rpx;
}
/* ── Section header ── */
.section-header {
display: flex;
align-items: baseline;
gap: 16rpx;
padding: 0 24rpx;
margin-bottom: 20rpx;
align-items: center;
justify-content: space-between;
padding: 32rpx 32rpx 20rpx;
}
.section-title {
font-size: 32rpx;
font-size: 36rpx;
font-weight: 700;
color: #1a1a2e;
color: #222;
}
.section-subtitle {
font-size: 24rpx;
.section-action {
font-size: 26rpx;
color: #999;
padding: 8rpx 24rpx;
border: 1rpx solid #ddd;
border-radius: 24rpx;
}
.cards-scroll {
width: 100%;
/* ── Card list ── */
.card-list {
padding: 0 32rpx;
}
.cards-row {
.card-row {
display: flex;
flex-direction: row;
gap: 20rpx;
padding: 8rpx 24rpx 16rpx;
width: max-content;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
/* --- Individual Card --- */
.card-item {
width: 280rpx;
background: #ffffff;
border-radius: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.10);
/* ── Thumbnail ── */
.card-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-shrink: 0;
&--trial {
border: 2rpx solid #c9a87c;
}
&--duration {
border: 2rpx solid #9b59b6;
}
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
}
.skeleton-card {
height: 360rpx;
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #7d6608, #c9a87c);
}
.thumb-name {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
/* ── Card info ── */
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
}
.price-row {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.price-label {
font-size: 24rpx;
color: #e53935;
}
.price-symbol {
font-size: 24rpx;
color: #e53935;
font-weight: 600;
}
.price-current {
font-size: 40rpx;
font-weight: 800;
color: #e53935;
}
.price-original {
font-size: 22rpx;
color: #bbb;
text-decoration: line-through;
margin-left: 12rpx;
}
/* ── Skeleton ── */
.skeleton-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
}
.skeleton-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
flex-shrink: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
.skeleton-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 16rpx;
}
.skeleton-line {
height: 24rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--title {
width: 70%;
height: 30rpx;
}
&--sub {
width: 40%;
}
&--price {
width: 50%;
height: 36rpx;
}
}
@keyframes shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
.card-header {
padding: 12rpx 20rpx;
}
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
.card-type-label {
font-size: 22rpx;
font-weight: 600;
color: #ffffff;
letter-spacing: 2rpx;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
padding: 20rpx 20rpx 8rpx;
display: block;
}
.price-row {
display: flex;
align-items: baseline;
gap: 12rpx;
padding: 0 20rpx 12rpx;
}
.price-current {
font-size: 40rpx;
font-weight: 800;
color: #c9a87c;
}
.price-original {
font-size: 24rpx;
color: #bbb;
text-decoration: line-through;
}
.card-desc {
font-size: 22rpx;
color: #888;
padding: 0 20rpx 16rpx;
line-height: 1.5;
display: block;
}
.card-meta {
display: flex;
gap: 20rpx;
padding: 0 20rpx 20rpx;
flex: 1;
align-items: flex-end;
}
.meta-item {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.meta-value {
font-size: 28rpx;
font-weight: 700;
color: #1a1a2e;
}
.meta-label {
font-size: 22rpx;
color: #999;
}
.buy-btn {
margin: 0 20rpx 24rpx;
background: #1a1a2e;
border-radius: 40rpx;
padding: 16rpx 0;
display: flex;
align-items: center;
justify-content: center;
}
.buy-btn-text {
font-size: 26rpx;
font-weight: 600;
color: #c9a87c;
}
/* ── Empty state ── */
.empty-state {
padding: 60rpx;
padding: 80rpx;
display: flex;
align-items: center;
justify-content: center;

View File

@@ -1,57 +1,34 @@
<template>
<view class="studio-info card">
<!-- Photo Swiper -->
<swiper
<view class="studio-info">
<!-- Horizontal photo strip -->
<scroll-view
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
scroll-x
class="photo-strip"
:show-scrollbar="false"
>
<swiper-item
v-for="(photo, idx) in studioInfo.photos"
:key="idx"
>
<view class="photo-strip-inner">
<image
class="photo"
v-for="(photo, idx) in studioInfo.photos"
:key="idx"
class="strip-photo"
:src="photo"
mode="aspectFill"
@tap="previewPhoto(idx)"
/>
</swiper-item>
</swiper>
</view>
</scroll-view>
<!-- 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">
<!-- Address + Phone row -->
<view class="location-row">
<view class="location-left" @tap="handleAddressTap">
<text class="location-icon">📍</text>
<text class="location-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 class="phone-btn" @tap="handlePhoneTap">
<text class="phone-icon">📞</text>
</view>
</view>
</view>
@@ -64,6 +41,14 @@ const props = defineProps<{
studioInfo: StudioConfig | null
}>()
function previewPhoto(index: number) {
if (!props.studioInfo?.photos?.length) return
uni.previewImage({
current: index,
urls: props.studioInfo.photos,
})
}
function handleAddressTap() {
if (!props.studioInfo) return
@@ -73,8 +58,8 @@ function handleAddressTap() {
uni.openLocation({
latitude,
longitude,
name: name || '普拉提工作室',
address: address,
name: name || 'Focus Core',
address,
fail() {
copyAddress()
},
@@ -109,95 +94,73 @@ function handlePhoneTap() {
<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 {
/* ── Photo strip ── */
.photo-strip {
width: 100%;
height: 360rpx;
border-radius: 16rpx 16rpx 0 0;
overflow: hidden;
padding: 24rpx 0;
}
.photo {
width: 100%;
height: 100%;
}
.photo-placeholder {
width: 100%;
height: 280rpx;
background: linear-gradient(135deg, #f0f0f0, #e8e8e8);
.photo-strip-inner {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
flex-direction: row;
gap: 16rpx;
border-radius: 16rpx 16rpx 0 0;
padding: 0 24rpx;
width: max-content;
}
.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;
.strip-photo {
width: 240rpx;
height: 160rpx;
border-radius: 12rpx;
flex-shrink: 0;
}
.iconfont {
font-size: 36rpx;
/* ── Location row ── */
.location-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 24rpx 32rpx 28rpx;
gap: 24rpx;
}
.info-text {
.location-left {
display: flex;
align-items: flex-start;
gap: 12rpx;
flex: 1;
min-width: 0;
}
.location-icon {
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;
margin-top: 4rpx;
}
.location-text {
font-size: 26rpx;
color: #666;
line-height: 1.5;
word-break: break-all;
}
.phone-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #f5f5f5;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.phone-icon {
font-size: 36rpx;
color: #4CAF50;
}
</style>

View File

@@ -100,19 +100,19 @@
"list": [
{
"pagePath": "pages/home/index",
"text": "首页",
"text": "场馆首页",
"iconPath": "static/tab/home.png",
"selectedIconPath": "static/tab/home-active.png"
},
{
"pagePath": "pages/booking/index",
"text": "预约",
"text": "课程预约",
"iconPath": "static/tab/booking.png",
"selectedIconPath": "static/tab/booking-active.png"
},
{
"pagePath": "pages/profile/index",
"text": "我的",
"text": "个人中心",
"iconPath": "static/tab/profile.png",
"selectedIconPath": "static/tab/profile-active.png"
}

View File

@@ -9,19 +9,22 @@
@refresherrefresh="handleRefresh"
@refresherrestore="refreshing = false"
>
<!-- Brand Banner (custom nav) -->
<!-- Brand Banner (hero with bg image + centered logo) -->
<BrandBanner :studio-info="studioStore.studioInfo" />
<!-- Studio Info (swiper + address + phone) -->
<!-- Studio Info (photo strip + address/phone) -->
<StudioInfo :studio-info="studioStore.studioInfo" />
<!-- Divider -->
<view class="section-divider" />
<!-- Quick Entry (login / trial / book / renew) -->
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
<!-- Upcoming Bookings -->
<UpcomingBooking />
<!-- Card Shop (horizontal scroll) -->
<!-- Card Shop (vertical list) -->
<view :id="cardShopAnchorId">
<CardShop ref="cardShopRef" />
</view>
@@ -102,6 +105,11 @@ function scrollToCardShop() {
height: 100vh;
}
.section-divider {
height: 16rpx;
background: #f5f5f5;
}
.bottom-padding {
height: 120rpx;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 738 B

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 679 B

After

Width:  |  Height:  |  Size: 371 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 722 B

After

Width:  |  Height:  |  Size: 387 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 573 B

After

Width:  |  Height:  |  Size: 382 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 728 B

After

Width:  |  Height:  |  Size: 460 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 622 B

After

Width:  |  Height:  |  Size: 455 B