perf: 优化 UI

This commit is contained in:
richarjiang
2026-04-06 11:15:10 +08:00
parent 3a9982209f
commit 66d47ec162
24 changed files with 822 additions and 7129 deletions

View File

@@ -1,18 +1,14 @@
<template>
<view class="brand-banner">
<!-- Background image layer -->
<!-- Background image layer with blur -->
<image
v-if="studioInfo?.bannerUrl"
class="banner-bg"
:src="studioInfo.bannerUrl"
:src="bannerImage"
mode="aspectFill"
/>
<!-- Dark overlay for readability -->
<view class="banner-overlay" />
<!-- Status bar spacer -->
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }" />
<!-- Centered content -->
<view class="banner-content">
<!-- Circular logo -->
@@ -33,19 +29,13 @@
</template>
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import type { StudioConfig } from '@mp-pilates/shared'
import { getSystemLayout } from '../utils/system'
defineProps<{
studioInfo: StudioConfig | null
}>()
const statusBarHeight = ref(0)
onMounted(() => {
statusBarHeight.value = getSystemLayout().statusBarHeight
})
const bannerImage = 'https://plates-1251306435.cos.ap-guangzhou.myqcloud.com/mp/images/bannerBg.jpg'
</script>
<style lang="scss" scoped>
@@ -63,6 +53,8 @@ onMounted(() => {
left: 0;
width: 100%;
height: 100%;
filter: blur(2px);
transform: scale(1.05);
}
.banner-overlay {
@@ -74,11 +66,6 @@ onMounted(() => {
background: rgba($primary-dark, 0.25);
}
.status-bar {
position: relative;
z-index: 2;
}
.banner-content {
position: relative;
z-index: 2;
@@ -86,7 +73,7 @@ onMounted(() => {
flex-direction: column;
align-items: center;
justify-content: center;
padding-top: 40rpx;
padding-top: 120rpx;
}
.logo-circle {

View File

@@ -13,7 +13,7 @@
:key="i"
class="card-row skeleton-row"
>
<view class="skeleton-thumb" />
<view class="skeleton-card-cover" />
<view class="skeleton-info">
<view class="skeleton-line skeleton-line--title" />
<view class="skeleton-line skeleton-line--sub" />
@@ -30,30 +30,62 @@
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 Cover horizontal premium design -->
<view class="card-cover" :class="getCardCoverClass(card.type)">
<!-- Left accent bar -->
<view class="cover-accent-bar" />
<!-- Card info -->
<view class="card-info">
<text class="card-name">{{ card.name }}</text>
<text class="card-validity">有效期:{{ card.durationDays }} </text>
<view class="price-row">
<text class="price-label">价格:</text>
<text class="price-symbol">¥</text>
<text class="price-current">{{ formatPrice(card.price) }}</text>
<!-- Decorative circles -->
<view class="cover-deco cover-deco--tl" />
<view class="cover-deco cover-deco--br" />
<!-- CSS Icon -->
<view class="cover-icon" :class="`cover-icon--${card.type}`" />
<!-- Right side: text content -->
<view class="cover-content">
<view class="cover-badge">
<text class="cover-badge-text">{{ getCardTypeLabel(card.type) }}</text>
</view>
<text class="cover-name">{{ card.name }}</text>
<view class="cover-price-row">
<text class="cover-currency">¥</text>
<text class="cover-price">{{ formatPrice(card.price) }}</text>
</view>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
class="cover-original"
>
原价:¥{{ formatPrice(card.originalPrice) }}
¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
</view>
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ card.name }}</text>
<text class="card-validity">有效期 {{ card.durationDays }} </text>
</view>
<view class="info-bottom">
<view v-if="card.totalTimes" class="card-times">
<text class="card-times-value">{{ card.totalTimes }}</text>
<text class="card-times-unit">课时</text>
</view>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(card.price) }}</text>
<text
v-if="card.originalPrice && card.originalPrice > card.price"
class="price-original"
>
¥{{ formatPrice(card.originalPrice) }}
</text>
</view>
</view>
</view>
<!-- Arrow -->
<text class="card-arrow"></text>
</view>
</view>
@@ -67,9 +99,8 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { get } from '../utils/request'
import { formatPrice } from '../utils/format'
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../utils/format'
const cardTypes = ref<CardType[]>([])
const loading = ref(false)
@@ -98,19 +129,8 @@ function goToDetail(id: string) {
}
function goToAllCards() {
// Navigate to all cards page or scroll behavior
uni.navigateTo({ url: '/pages/card/detail?showAll=1' })
}
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 {
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
}
</script>
<style lang="scss" scoped>
@@ -150,132 +170,363 @@ function truncate(str: string, maxLen: number): string {
.card-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
gap: 20rpx;
padding: 16rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
&:last-child {
border-bottom: none;
}
}
/* ── Thumbnail ── */
.card-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
/* ══════════════════════════════════════════════════════════
CARD COVER — Horizontal premium card design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
gap: 0;
/* Glow effect behind */
&::before {
content: '';
position: absolute;
top: -20rpx;
left: -20rpx;
right: -20rpx;
bottom: -20rpx;
background: inherit;
filter: blur(24rpx) brightness(0.8);
z-index: 0;
opacity: 0.4;
}
}
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
/* Left accent stripe */
.cover-accent-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: rgba(255, 255, 255, 0.4);
z-index: 1;
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
/* Decorative circles */
.cover-deco {
position: absolute;
border-radius: 50%;
z-index: 0;
pointer-events: none;
&--tl {
width: 60rpx;
height: 60rpx;
top: -16rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.1);
}
&--br {
width: 80rpx;
height: 80rpx;
bottom: -24rpx;
left: -16rpx;
background: rgba(255, 255, 255, 0.07);
}
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
/* CSS-drawn icons */
.cover-icon {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 2;
flex-shrink: 0;
margin-left: 20rpx;
}
.thumb-name {
font-size: 22rpx;
/* 次卡 — stacked cards */
.cover-icon--TIMES {
&::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.85);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
}
&::after {
content: '';
position: absolute;
bottom: 10rpx;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.2);
}
}
/* 月卡 — calendar */
.cover-icon--DURATION {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36rpx;
height: 30rpx;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 9rpx;
left: 50%;
transform: translateX(-50%);
width: 24rpx;
height: 0;
border-top: 2rpx solid rgba(255, 255, 255, 1);
box-shadow:
-6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
}
}
/* 体验卡 — star */
.cover-icon--TRIAL {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16rpx;
height: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 50%;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.25);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2rpx;
height: 42rpx;
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
0 12rpx 0 0 rgba(255, 255, 255, 0.8),
-12rpx 0 0 0 rgba(255, 255, 255, 0.8),
12rpx 0 0 0 rgba(255, 255, 255, 0.8),
-8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
-8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
}
}
/* Card cover backgrounds */
.cover--times {
background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
}
.cover--duration {
background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
}
.cover--trial {
background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
}
/* Right side text content */
.cover-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 0 16rpx 0 12rpx;
gap: 4rpx;
z-index: 2;
}
.cover-badge {
padding: 3rpx 10rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
}
.cover-badge-text {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
.cover-name {
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;
letter-spacing: 0.5rpx;
line-height: 1.2;
max-width: 130rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
.cover-price-row {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.price-row {
.cover-currency {
font-size: 18rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.cover-price {
font-size: 28rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.cover-original {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
}
/* ── Card info — matches card-cover height ── */
.card-info {
flex: 1;
min-width: 0;
height: 130rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-top {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
}
.info-bottom {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5rpx;
line-height: 1.2;
}
.card-validity {
font-size: 23rpx;
color: $text-secondary;
line-height: 1.2;
}
.card-times {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.price-label {
font-size: 24rpx;
color: #e53935;
.card-times-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.price-symbol {
font-size: 24rpx;
color: #e53935;
font-weight: 600;
.card-times-unit {
font-size: 20rpx;
color: $text-secondary;
font-weight: 500;
}
.price-row {
display: flex;
align-items: baseline;
gap: 6rpx;
}
.price-current {
font-size: 40rpx;
font-size: 32rpx;
font-weight: 800;
color: #e53935;
color: $brand-color;
line-height: 1;
}
.price-original {
font-size: 22rpx;
color: #bbb;
font-size: 20rpx;
color: $text-hint;
text-decoration: line-through;
margin-left: 12rpx;
}
/* Arrow */
.card-arrow {
font-size: 44rpx;
color: $text-hint;
flex-shrink: 0;
transform: scaleX(0.5);
transform-origin: center;
}
/* ── Skeleton ── */
.skeleton-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx 0;
border-bottom: 1rpx solid #f0f0f0;
gap: 20rpx;
padding: 20rpx 0;
border-bottom: 1rpx solid rgba($brand-color, 0.08);
}
.skeleton-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
.skeleton-card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
flex-shrink: 0;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
}
@@ -290,12 +541,12 @@ function truncate(str: string, maxLen: number): string {
.skeleton-line {
height: 24rpx;
border-radius: 6rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
background-size: 400% 100%;
animation: shimmer 1.4s infinite;
&--title {
width: 70%;
width: 60%;
height: 30rpx;
}
@@ -304,7 +555,7 @@ function truncate(str: string, maxLen: number): string {
}
&--price {
width: 50%;
width: 45%;
height: 36rpx;
}
}
@@ -319,6 +570,6 @@ function truncate(str: string, maxLen: number): string {
.empty-text {
font-size: 28rpx;
color: #bbb;
color: $text-hint;
}
</style>

View File

@@ -8,17 +8,17 @@
</view>
</scroll-view>
<!-- Address + Phone row -->
<!-- Address + Chat row -->
<view class="location-row">
<view class="location-left" @tap="handleAddressTap">
<text class="location-icon">📍</text>
<view class="location-icon" />
<text class="location-text">
{{ studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D' }}
</text>
</view>
<view class="phone-btn" @tap="handlePhoneTap">
<text class="phone-icon">📞</text>
</view>
<button class="chat-btn" open-type="contact">
<view class="chat-icon" />
</button>
</view>
</view>
</template>
@@ -39,23 +39,20 @@ function previewPhoto(index: number) {
}
function handleAddressTap() {
if (!props.studioInfo) return
const latitude = props.studioInfo?.latitude ?? 22.567048
const longitude = props.studioInfo?.longitude ?? 113.867227
const address = props.studioInfo?.address || '深圳市宝安区西乡街道财富港 D 座 1203D'
const name = props.studioInfo?.name || 'Focus Core'
const { latitude, longitude, address, name } = props.studioInfo
if (latitude && longitude) {
uni.openLocation({
latitude,
longitude,
name: name || 'Focus Core',
address,
fail() {
copyAddress()
},
})
} else {
copyAddress()
}
uni.openLocation({
latitude,
longitude,
name,
address,
fail() {
copyAddress()
},
})
}
function copyAddress() {
@@ -68,17 +65,6 @@ function copyAddress() {
},
})
}
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>
@@ -124,11 +110,6 @@ function handlePhoneTap() {
min-width: 0;
}
.location-icon {
font-size: 28rpx;
flex-shrink: 0;
}
.location-text {
font-size: 26rpx;
color: #666;
@@ -136,19 +117,94 @@ function handlePhoneTap() {
word-break: break-all;
}
.phone-btn {
/* ── Icons ── */
.location-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
flex-shrink: 0;
// 定位图标 — 圆头 + 尖尾
&::before {
content: '';
position: absolute;
top: 14rpx;
left: 50%;
transform: translateX(-50%);
width: 18rpx;
height: 18rpx;
border: 2.5rpx solid $brand-color;
border-radius: 50% 50% 50% 0;
transform: translateX(-50%) rotate(-45deg);
box-sizing: border-box;
}
// 中心白点
&::after {
content: '';
position: absolute;
top: 21rpx;
left: 50%;
transform: translateX(-50%);
width: 6rpx;
height: 6rpx;
background: $brand-color;
border-radius: 50%;
box-sizing: border-box;
}
}
.chat-btn {
width: 72rpx;
height: 72rpx;
border-radius: 50%;
background: #f5f5f5;
background: rgba($brand-color, 0.06);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: 0;
margin: 0;
border: none;
}
.phone-icon {
font-size: 36rpx;
color: #4CAF50;
.chat-btn::after {
border: none;
}
.chat-icon {
width: 56rpx;
height: 56rpx;
border-radius: 50%;
background: rgba($brand-color, 0.06);
position: relative;
// 消息气泡
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 26rpx;
height: 20rpx;
border: 2.5rpx solid $brand-color;
border-radius: 6rpx;
box-sizing: border-box;
}
// 气泡尾巴
&::after {
content: '';
position: absolute;
bottom: 12rpx;
left: 50%;
transform: translateX(-50%);
width: 0;
height: 0;
border-left: 5rpx solid transparent;
border-right: 5rpx solid transparent;
border-top: 7rpx solid $brand-color;
}
}
</style>

View File

@@ -37,25 +37,54 @@
class="card-row"
@tap="goToDetail(c.id)"
>
<view class="card-thumb" :class="thumbClass(c)">
<view class="thumb-fallback">
<text class="thumb-name">{{ truncate(c.name, 8) }}</text>
<text class="thumb-price">¥{{ formatPrice(c.price) }}</text>
</view>
</view>
<view class="card-info">
<text class="card-name">{{ c.name }}</text>
<text class="card-validity">有效期:{{ c.durationDays }} </text>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
<!-- Card Cover horizontal -->
<view class="card-cover" :class="getCardCoverClass(c.type)">
<view class="cover-accent-bar" />
<view class="cover-deco cover-deco--tl" />
<view class="cover-deco cover-deco--br" />
<view class="cover-icon" :class="`cover-icon--${c.type}`" />
<view class="cover-content">
<view class="cover-badge">
<text class="cover-badge-text">{{ getCardTypeLabel(c.type) }}</text>
</view>
<text class="cover-name">{{ c.name }}</text>
<view class="cover-price-row">
<text class="cover-currency">¥</text>
<text class="cover-price">{{ formatPrice(c.price) }}</text>
</view>
<text
v-if="c.originalPrice && c.originalPrice > c.price"
class="price-original"
class="cover-original"
>
原价:¥{{ formatPrice(c.originalPrice) }}
¥{{ formatPrice(c.originalPrice) }}
</text>
</view>
</view>
<!-- Card info aligns with card-cover height -->
<view class="card-info">
<view class="info-top">
<text class="card-name">{{ c.name }}</text>
<text class="card-validity">有效期 {{ c.durationDays }} </text>
</view>
<view class="info-bottom">
<view v-if="c.totalTimes" class="card-times">
<text class="card-times-value">{{ c.totalTimes }}</text>
<text class="card-times-unit">课时</text>
</view>
<view class="price-row">
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
<text
v-if="c.originalPrice && c.originalPrice > c.price"
class="price-original"
>
¥{{ formatPrice(c.originalPrice) }}
</text>
</view>
</view>
</view>
<text class="card-arrow"></text>
</view>
</view>
<view v-else class="empty-state">
@@ -172,7 +201,7 @@ import { ref, computed, onMounted } from 'vue'
import type { CardType, CreateOrderResponse } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
import { get, post } from '../../utils/request'
import { formatPrice } from '../../utils/format'
import { formatPrice, getCardTypeLabel, getCardCoverClass } from '../../utils/format'
import { getSystemLayout } from '../../utils/system'
import { useUserStore } from '../../stores/user'
import CustomNavBar from '../../components/CustomNavBar.vue'
@@ -257,16 +286,6 @@ function goToDetail(id: string) {
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
}
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 {
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
}
// ─── Buy flow ─────────────────────────────────────────────
async function handleBuy() {
if (buying.value || !card.value) return
@@ -710,101 +729,335 @@ onMounted(() => {
.card-row {
display: flex;
align-items: center;
gap: 24rpx;
padding: 24rpx;
gap: 20rpx;
padding: 20rpx;
background: #fff;
border-radius: 20rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
}
.card-thumb {
width: 200rpx;
height: 140rpx;
border-radius: 12rpx;
/* ══════════════════════════════════════════════════════════
CARD COVER — Horizontal premium card design
══════════════════════════════════════════════════════════ */
.card-cover {
width: 240rpx;
height: 130rpx;
border-radius: 16rpx;
overflow: hidden;
flex-shrink: 0;
position: relative;
}
.thumb-fallback {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
flex-direction: row;
align-items: center;
justify-content: center;
gap: 8rpx;
padding: 12rpx;
&::before {
content: '';
position: absolute;
top: -20rpx;
left: -20rpx;
right: -20rpx;
bottom: -20rpx;
background: inherit;
filter: blur(24rpx) brightness(0.8);
z-index: 0;
opacity: 0.4;
}
}
.thumb--times .thumb-fallback {
background: linear-gradient(135deg, #3a3a3a, #555);
.cover-accent-bar {
position: absolute;
left: 0;
top: 0;
bottom: 0;
width: 6rpx;
background: rgba(255, 255, 255, 0.4);
z-index: 1;
}
.thumb--duration .thumb-fallback {
background: linear-gradient(135deg, #6c3483, #9b59b6);
.cover-deco {
position: absolute;
border-radius: 50%;
z-index: 0;
pointer-events: none;
&--tl {
width: 60rpx;
height: 60rpx;
top: -16rpx;
right: 20rpx;
background: rgba(255, 255, 255, 0.1);
}
&--br {
width: 80rpx;
height: 80rpx;
bottom: -24rpx;
left: -16rpx;
background: rgba(255, 255, 255, 0.07);
}
}
.thumb--trial .thumb-fallback {
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
.cover-icon {
width: 52rpx;
height: 52rpx;
position: relative;
z-index: 2;
flex-shrink: 0;
margin-left: 20rpx;
}
.thumb-name {
font-size: 22rpx;
.cover-icon--TIMES {
&::before {
content: '';
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 0.85);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.12);
}
&::after {
content: '';
position: absolute;
bottom: 10rpx;
left: 50%;
transform: translateX(-50%);
width: 36rpx;
height: 24rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 5rpx;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.2);
}
}
.cover-icon--DURATION {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 36rpx;
height: 30rpx;
border: 2rpx solid rgba(255, 255, 255, 0.9);
border-radius: 5rpx;
box-sizing: border-box;
}
&::after {
content: '';
position: absolute;
top: 9rpx;
left: 50%;
transform: translateX(-50%);
width: 24rpx;
height: 0;
border-top: 2rpx solid rgba(255, 255, 255, 1);
box-shadow:
-6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9),
6rpx 5rpx 0 0 rgba(255, 255, 255, 0.9);
}
}
.cover-icon--TRIAL {
&::before {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 16rpx;
height: 16rpx;
border: 2rpx solid rgba(255, 255, 255, 1);
border-radius: 50%;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.25);
}
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 2rpx;
height: 42rpx;
background: rgba(255, 255, 255, 0.8);
box-shadow:
0 -12rpx 0 0 rgba(255, 255, 255, 0.8),
0 12rpx 0 0 rgba(255, 255, 255, 0.8),
-12rpx 0 0 0 rgba(255, 255, 255, 0.8),
12rpx 0 0 0 rgba(255, 255, 255, 0.8),
-8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx -8rpx 0 0 rgba(255, 255, 255, 0.8),
-8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8),
8rpx 8rpx 0 0 rgba(255, 255, 255, 0.8);
}
}
.cover--times {
background: linear-gradient(135deg, #1e2340 0%, #2d2d5e 50%, #3a3a7a 100%);
}
.cover--duration {
background: linear-gradient(135deg, #4a1a6b 0%, #6c3483 50%, #8e4aaf 100%);
}
.cover--trial {
background: linear-gradient(135deg, #14527a 0%, #1a6fa0 50%, #48a9a6 100%);
}
.cover-content {
flex: 1;
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 0 16rpx 0 12rpx;
gap: 4rpx;
z-index: 2;
}
.cover-badge {
padding: 3rpx 10rpx;
border-radius: 8rpx;
background: rgba(255, 255, 255, 0.18);
border: 1rpx solid rgba(255, 255, 255, 0.28);
}
.cover-badge-text {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
color: #ffffff;
text-align: center;
line-height: 1.3;
word-break: break-all;
}
.thumb-price {
.cover-name {
font-size: 24rpx;
font-weight: 700;
color: #ffffff;
}
.card-info {
flex: 1;
min-width: 0;
}
.card-name {
display: block;
font-size: 30rpx;
font-weight: 600;
color: #222;
margin-bottom: 8rpx;
letter-spacing: 0.5rpx;
line-height: 1.2;
max-width: 130rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.cover-price-row {
display: flex;
align-items: baseline;
gap: 2rpx;
}
.cover-currency {
font-size: 18rpx;
font-weight: 600;
color: rgba(255, 255, 255, 0.85);
}
.cover-price {
font-size: 28rpx;
font-weight: 800;
color: #ffffff;
line-height: 1;
}
.cover-original {
font-size: 16rpx;
color: rgba(255, 255, 255, 0.5);
text-decoration: line-through;
}
/* ── Card info — matches card-cover height ── */
.card-info {
flex: 1;
min-width: 0;
height: 130rpx;
display: flex;
flex-direction: column;
justify-content: space-between;
}
.info-top {
display: flex;
flex-direction: column;
justify-content: center;
gap: 4rpx;
}
.info-bottom {
display: flex;
align-items: baseline;
gap: 16rpx;
}
.card-name {
font-size: 30rpx;
font-weight: 700;
color: $text-primary;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
letter-spacing: 0.5rpx;
line-height: 1.2;
}
.card-validity {
display: block;
font-size: 24rpx;
color: #999;
margin-bottom: 12rpx;
font-size: 23rpx;
color: $text-secondary;
line-height: 1.2;
}
.card-times {
display: flex;
align-items: baseline;
gap: 4rpx;
}
.card-times-value {
font-size: 34rpx;
font-weight: 800;
color: $brand-color;
line-height: 1;
}
.card-times-unit {
font-size: 20rpx;
color: $text-secondary;
font-weight: 500;
}
.price-row {
display: flex;
align-items: baseline;
gap: 8rpx;
gap: 6rpx;
}
.price-current {
font-size: 40rpx;
font-size: 32rpx;
font-weight: 800;
color: #e53935;
color: $brand-color;
line-height: 1;
}
.price-original {
font-size: 22rpx;
color: #bbb;
font-size: 20rpx;
color: $text-hint;
text-decoration: line-through;
}
.card-arrow {
font-size: 44rpx;
color: $text-hint;
flex-shrink: 0;
transform: scaleX(0.5);
transform-origin: center;
}
/* ── Empty state ─────────────────────────────────────── */
.empty-state {
padding: 160rpx 40rpx;

View File

@@ -1,12 +1,11 @@
<template>
<view class="home-page" :style="pageStyle">
<!-- Custom nav bar -->
<CustomNavBar title="场馆首页" />
<view class="home-page">
<!-- Pull-to-refresh wrapper -->
<scroll-view
class="page-scroll"
scroll-y
:scroll-top="scrollTop"
:scroll-with-animation="true"
:refresher-enabled="true"
:refresher-triggered="refreshing"
@refresherrefresh="handleRefresh"
@@ -42,7 +41,6 @@
import { ref, computed } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import CustomNavBar from '../../components/CustomNavBar.vue'
import BrandBanner from '../../components/BrandBanner.vue'
import StudioInfo from '../../components/StudioInfo.vue'
import QuickEntry from '../../components/QuickEntry.vue'
@@ -52,7 +50,6 @@ import CardShop from '../../components/CardShop.vue'
import { useUserStore } from '../../stores/user'
import { useStudioStore } from '../../stores/studio'
import { useBookingStore } from '../../stores/booking'
import { getSystemLayout } from '../../utils/system'
const userStore = useUserStore()
const studioStore = useStudioStore()
@@ -75,24 +72,10 @@ onShareTimeline(() => {
})
// ─── Layout ───────────────────────────────────────────────
const navBarHeight = ref('64px')
function updateLayout() {
const { statusBarHeight: statusBarPx, windowWidth, navBarHeight: navBarPx } = getSystemLayout()
const ratio = windowWidth / 750
const navTitlePx = 88 * ratio
navBarHeight.value = `${navBarPx}px`
}
updateLayout()
const pageStyle = computed(() => ({
'--nav-bar-height': navBarHeight.value,
}))
const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0)
// Refresh all data on every show
onShow(async () => {
@@ -125,22 +108,28 @@ async function handleRefresh() {
}
function scrollToCardShop() {
uni.pageScrollTo({
selector: `#${cardShopAnchorId}`,
duration: 300,
})
uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`)
.boundingClientRect((rect) => {
if (rect) {
scrollTop.value = rect.top
}
})
.exec()
}
</script>
<style lang="scss" scoped>
.home-page {
min-height: 100vh;
display: flex;
flex-direction: column;
height: 100vh;
background: #FAF8F5;
padding-top: var(--nav-bar-height);
}
.page-scroll {
height: calc(100vh - var(--nav-bar-height));
flex: 1;
overflow-y: auto;
}
.section-divider {

View File

@@ -1,3 +1,6 @@
import type { CardType } from '@mp-pilates/shared'
import { CardTypeCategory } from '@mp-pilates/shared'
/** 格式化金额:分 → 元 */
export function formatPrice(cents: number): string {
return (cents / 100).toFixed(2)
@@ -44,3 +47,20 @@ export function getDateRange(days: number): ReadonlyArray<{ readonly date: strin
}
return result
}
/** 会员卡类型标签 */
export function getCardTypeLabel(type: CardTypeCategory): string {
const map: Record<CardTypeCategory, string> = {
[CardTypeCategory.TIMES]: '次卡',
[CardTypeCategory.DURATION]: '月卡',
[CardTypeCategory.TRIAL]: '体验',
}
return map[type] ?? '会员'
}
/** 会员卡封面 CSS 类名 */
export function getCardCoverClass(type: CardTypeCategory): string {
if (type === CardTypeCategory.TRIAL) return 'cover--trial'
if (type === CardTypeCategory.DURATION) return 'cover--duration'
return 'cover--times'
}