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:
429
packages/app/src/components/BookingConfirmPopup.vue
Normal file
429
packages/app/src/components/BookingConfirmPopup.vue
Normal file
@@ -0,0 +1,429 @@
|
||||
<template>
|
||||
<!-- Overlay mask -->
|
||||
<view v-if="visible" class="popup-mask" @tap="handleMaskTap">
|
||||
<!-- Popup panel – stop propagation so tapping inside doesn't close -->
|
||||
<view class="popup-panel" @tap.stop>
|
||||
<!-- Header -->
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">确认预约</text>
|
||||
<view class="close-btn" @tap="handleCancel">
|
||||
<text class="close-icon">✕</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Course info -->
|
||||
<view class="info-section">
|
||||
<view class="info-row">
|
||||
<text class="info-label">日期</text>
|
||||
<text class="info-value">{{ slot?.date }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">时间</text>
|
||||
<text class="info-value" v-if="slot">
|
||||
{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">剩余</text>
|
||||
<text class="info-value" v-if="slot">
|
||||
{{ slot.capacity - slot.bookedCount }} 个名额
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="divider" />
|
||||
|
||||
<!-- Membership card selection -->
|
||||
<view class="card-section">
|
||||
<view class="section-label-row">
|
||||
<text class="section-label">选择扣课会员卡</text>
|
||||
</view>
|
||||
|
||||
<!-- Single membership -->
|
||||
<view v-if="memberships.length === 1" class="card-single">
|
||||
<view class="card-item selected">
|
||||
<view class="card-icon-wrap">
|
||||
<text class="card-icon">💳</text>
|
||||
</view>
|
||||
<view class="card-info">
|
||||
<text class="card-name">{{ memberships[0].cardType.name }}</text>
|
||||
<text class="card-remain" v-if="memberships[0].remainingTimes !== null">
|
||||
剩余 {{ memberships[0].remainingTimes }} 次
|
||||
</text>
|
||||
<text class="card-remain" v-else>
|
||||
有效期至 {{ memberships[0].expireDate.slice(0, 10) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="check-mark">
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Multiple memberships picker -->
|
||||
<view v-else-if="memberships.length > 1" class="card-picker-wrap">
|
||||
<view
|
||||
v-for="m in memberships"
|
||||
:key="m.id"
|
||||
class="card-item"
|
||||
:class="{ selected: selectedMembershipId === m.id }"
|
||||
@tap="selectedMembershipId = m.id"
|
||||
>
|
||||
<view class="card-icon-wrap">
|
||||
<text class="card-icon">💳</text>
|
||||
</view>
|
||||
<view class="card-info">
|
||||
<text class="card-name">{{ m.cardType.name }}</text>
|
||||
<text class="card-remain" v-if="m.remainingTimes !== null">
|
||||
剩余 {{ m.remainingTimes }} 次
|
||||
</text>
|
||||
<text class="card-remain" v-else>
|
||||
有效期至 {{ m.expireDate.slice(0, 10) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="check-mark" v-if="selectedMembershipId === m.id">
|
||||
<text class="check-icon">✓</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- No memberships fallback (should not normally appear) -->
|
||||
<view v-else class="no-card-tip">
|
||||
<text class="no-card-text">暂无可用会员卡</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Deduction tip -->
|
||||
<view class="deduction-tip" v-if="selectedMembership">
|
||||
<text class="deduction-text">
|
||||
确认后将从「{{ selectedMembership.cardType.name }}」扣除 1 次课时
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<view class="action-row">
|
||||
<view class="btn-outline" @tap="handleCancel">
|
||||
<text class="btn-outline-text">取消</text>
|
||||
</view>
|
||||
<view
|
||||
class="btn-confirm"
|
||||
:class="{ disabled: !selectedMembershipId }"
|
||||
@tap="handleConfirm"
|
||||
>
|
||||
<text class="btn-confirm-text">确认预约</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
slot: TimeSlotWithBookingStatus | null
|
||||
memberships: MembershipWithCardType[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'confirm', payload: { timeSlotId: string; membershipId: string }): void
|
||||
(e: 'cancel'): void
|
||||
(e: 'update:visible', val: boolean): void
|
||||
}>()
|
||||
|
||||
const selectedMembershipId = ref<string>('')
|
||||
|
||||
// Auto-select the first membership when popup opens or memberships list changes
|
||||
watch(
|
||||
[() => props.visible, () => props.memberships],
|
||||
([visible, memberships]) => {
|
||||
if (visible && memberships.length > 0) {
|
||||
selectedMembershipId.value = memberships[0].id
|
||||
}
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
const selectedMembership = computed(() =>
|
||||
props.memberships.find((m) => m.id === selectedMembershipId.value) ?? null,
|
||||
)
|
||||
|
||||
function handleConfirm() {
|
||||
if (!props.slot || !selectedMembershipId.value) return
|
||||
emit('confirm', {
|
||||
timeSlotId: props.slot.id,
|
||||
membershipId: selectedMembershipId.value,
|
||||
})
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel')
|
||||
emit('update:visible', false)
|
||||
}
|
||||
|
||||
function handleMaskTap() {
|
||||
handleCancel()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.popup-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.popup-panel {
|
||||
width: 100%;
|
||||
background: #fff;
|
||||
border-radius: 32rpx 32rpx 0 0;
|
||||
padding: 32rpx 32rpx calc(32rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.popup-header {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.popup-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.close-icon {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* Info rows */
|
||||
.info-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
width: 80rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1rpx;
|
||||
background: #f0f0f0;
|
||||
margin: 8rpx 0 28rpx;
|
||||
}
|
||||
|
||||
/* Card selection */
|
||||
.card-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.section-label-row {
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.section-label {
|
||||
font-size: 28rpx;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.card-single,
|
||||
.card-picker-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.card-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 24rpx 20rpx;
|
||||
border-radius: 16rpx;
|
||||
border: 2rpx solid #f0f0f0;
|
||||
background: #fafafa;
|
||||
gap: 20rpx;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
|
||||
&.selected {
|
||||
border-color: #c9a87c;
|
||||
background: #fffbf5;
|
||||
}
|
||||
}
|
||||
|
||||
.card-icon-wrap {
|
||||
width: 60rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 14rpx;
|
||||
background: linear-gradient(135deg, #d4b896, #c9a87c);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
font-size: 32rpx;
|
||||
}
|
||||
|
||||
.card-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.card-remain {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.check-mark {
|
||||
width: 44rpx;
|
||||
height: 44rpx;
|
||||
border-radius: 50%;
|
||||
background: #c9a87c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.check-icon {
|
||||
font-size: 24rpx;
|
||||
color: #fff;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.no-card-tip {
|
||||
padding: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-card-text {
|
||||
font-size: 26rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* Deduction tip */
|
||||
.deduction-tip {
|
||||
background: #fffbf0;
|
||||
border-radius: 12rpx;
|
||||
padding: 16rpx 20rpx;
|
||||
margin-bottom: 28rpx;
|
||||
}
|
||||
|
||||
.deduction-text {
|
||||
font-size: 24rpx;
|
||||
color: #c9a87c;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Action buttons */
|
||||
.action-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20rpx;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
border: 2rpx solid #e0e0e0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
background: #f5f5f5;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-outline-text {
|
||||
font-size: 30rpx;
|
||||
color: #666;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.btn-confirm {
|
||||
flex: 2;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
background: #c9a87c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.disabled {
|
||||
background: #e0e0e0;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-confirm-text {
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
|
||||
.disabled & {
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
118
packages/app/src/components/BrandBanner.vue
Normal file
118
packages/app/src/components/BrandBanner.vue
Normal file
@@ -0,0 +1,118 @@
|
||||
<template>
|
||||
<view class="brand-banner" :style="bannerStyle">
|
||||
<!-- Status bar spacer -->
|
||||
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }" />
|
||||
|
||||
<!-- Nav area -->
|
||||
<view class="nav-bar">
|
||||
<view class="studio-name-row">
|
||||
<image
|
||||
v-if="studioInfo?.logo"
|
||||
class="logo"
|
||||
:src="studioInfo.logo"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="studio-name">{{ studioInfo?.name || '普拉提工作室' }}</text>
|
||||
</view>
|
||||
<text class="studio-slogan">专业 · 精致 · 健康</text>
|
||||
</view>
|
||||
|
||||
<!-- Decorative circles -->
|
||||
<view class="deco-circle deco-circle--1" />
|
||||
<view class="deco-circle deco-circle--2" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { StudioConfig } from '@mp-pilates/shared'
|
||||
|
||||
const props = defineProps<{
|
||||
studioInfo: StudioConfig | null
|
||||
}>()
|
||||
|
||||
const statusBarHeight = ref(0)
|
||||
|
||||
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%);
|
||||
overflow: hidden;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.nav-bar {
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
padding: 16rpx 40rpx 0;
|
||||
}
|
||||
|
||||
.studio-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.logo {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 12rpx;
|
||||
}
|
||||
|
||||
.studio-name {
|
||||
font-size: 44rpx;
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
320
packages/app/src/components/CardShop.vue
Normal file
320
packages/app/src/components/CardShop.vue
Normal file
@@ -0,0 +1,320 @@
|
||||
<template>
|
||||
<view class="card-shop">
|
||||
<view class="section-header">
|
||||
<text class="section-title">会员卡</text>
|
||||
<text class="section-subtitle">选择适合您的套餐</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>
|
||||
</scroll-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>
|
||||
|
||||
<!-- Card name -->
|
||||
<text class="card-name">{{ card.name }}</text>
|
||||
|
||||
<!-- Pricing -->
|
||||
<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>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else class="empty-state">
|
||||
<text class="empty-text">暂无可购买的会员卡</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<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'
|
||||
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
const loading = ref(false)
|
||||
|
||||
async function fetchCardTypes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const result = await get<CardType[]>('/membership/card-types')
|
||||
cardTypes.value = result
|
||||
.filter((c) => c.isActive)
|
||||
.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
} catch {
|
||||
uni.showToast({ title: '加载会员卡失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchCardTypes)
|
||||
|
||||
// Expose refresh method for parent page
|
||||
defineExpose({ fetchCardTypes })
|
||||
|
||||
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 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 truncate(str: string, maxLen: number): string {
|
||||
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-shop {
|
||||
margin: 24rpx 0 0;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
padding: 0 24rpx;
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.section-subtitle {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.cards-scroll {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.cards-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 20rpx;
|
||||
padding: 8rpx 24rpx 16rpx;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
/* --- Individual Card --- */
|
||||
.card-item {
|
||||
width: 280rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.10);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--trial {
|
||||
border: 2rpx solid #c9a87c;
|
||||
}
|
||||
|
||||
&--duration {
|
||||
border: 2rpx solid #9b59b6;
|
||||
}
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 360rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@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 {
|
||||
padding: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
</style>
|
||||
126
packages/app/src/components/DateSelector.vue
Normal file
126
packages/app/src/components/DateSelector.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<view class="date-selector">
|
||||
<scroll-view class="scroll" scroll-x enhanced :show-scrollbar="false">
|
||||
<view class="track">
|
||||
<view
|
||||
v-for="item in dateRange"
|
||||
:key="item.date"
|
||||
class="date-item"
|
||||
:class="{ active: item.date === modelValue, today: item.isToday }"
|
||||
@tap="handleSelect(item.date)"
|
||||
>
|
||||
<text class="weekday">{{ item.isToday ? '今天' : item.weekday }}</text>
|
||||
<text class="day">{{ getDayNumber(item.date) }}</text>
|
||||
<text class="month">{{ getMonthNumber(item.date) }}月</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { DATE_SELECTOR_DAYS } from '@mp-pilates/shared'
|
||||
import { getDateRange } from '../utils/format'
|
||||
|
||||
interface Props {
|
||||
modelValue: string
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
select: [date: string]
|
||||
'update:modelValue': [date: string]
|
||||
}>()
|
||||
|
||||
const dateRange = computed(() => getDateRange(DATE_SELECTOR_DAYS))
|
||||
|
||||
function getDayNumber(date: string): string {
|
||||
return String(parseInt(date.split('-')[2], 10))
|
||||
}
|
||||
|
||||
function getMonthNumber(date: string): string {
|
||||
return String(parseInt(date.split('-')[1], 10))
|
||||
}
|
||||
|
||||
function handleSelect(date: string) {
|
||||
emit('update:modelValue', date)
|
||||
emit('select', date)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.date-selector {
|
||||
background: #fff;
|
||||
padding: 16rpx 0 20rpx;
|
||||
border-bottom: 1rpx solid #f0ece8;
|
||||
|
||||
.scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.track {
|
||||
display: inline-flex;
|
||||
flex-direction: row;
|
||||
padding: 0 24rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.date-item {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 88rpx;
|
||||
padding: 16rpx 12rpx;
|
||||
border-radius: 16rpx;
|
||||
background: #f7f4f0;
|
||||
gap: 4rpx;
|
||||
transition: background 0.2s;
|
||||
flex-shrink: 0;
|
||||
|
||||
.weekday {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.day {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #333;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.month {
|
||||
font-size: 20rpx;
|
||||
color: #bbb;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: #c9a87c;
|
||||
|
||||
.weekday {
|
||||
color: rgba(255, 255, 255, 0.85);
|
||||
}
|
||||
|
||||
.day {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.month {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
}
|
||||
|
||||
&.today:not(.active) {
|
||||
.weekday {
|
||||
color: #c9a87c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
163
packages/app/src/components/ProfileMenu.vue
Normal file
163
packages/app/src/components/ProfileMenu.vue
Normal file
@@ -0,0 +1,163 @@
|
||||
<template>
|
||||
<view class="profile-menu">
|
||||
<template v-for="item in menuItems" :key="item.key">
|
||||
<!-- Separator -->
|
||||
<view v-if="item.type === 'separator'" class="profile-menu__separator" />
|
||||
|
||||
<!-- Menu item -->
|
||||
<view
|
||||
v-else
|
||||
class="profile-menu__item"
|
||||
:class="{ 'profile-menu__item--admin': item.isAdmin }"
|
||||
hover-class="profile-menu__item--hover"
|
||||
hover-stay-time="150"
|
||||
@tap="navigate(item.path!)"
|
||||
>
|
||||
<view class="profile-menu__icon-wrap" :class="{ 'profile-menu__icon-wrap--admin': item.isAdmin }">
|
||||
<text class="profile-menu__icon">{{ item.icon }}</text>
|
||||
</view>
|
||||
<text class="profile-menu__title" :class="{ 'profile-menu__title--admin': item.isAdmin }">
|
||||
{{ item.title }}
|
||||
</text>
|
||||
<text class="profile-menu__arrow">›</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface MenuItem {
|
||||
key: string
|
||||
type: 'item' | 'separator'
|
||||
icon?: string
|
||||
title?: string
|
||||
path?: string
|
||||
isAdmin?: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<{
|
||||
isAdmin: boolean
|
||||
}>()
|
||||
|
||||
const menuItems = computed<MenuItem[]>(() => {
|
||||
const items: MenuItem[] = [
|
||||
{
|
||||
key: 'membership',
|
||||
type: 'item',
|
||||
icon: '💳',
|
||||
title: '我的会员卡',
|
||||
path: '/pages/profile/membership',
|
||||
},
|
||||
{
|
||||
key: 'bookings',
|
||||
type: 'item',
|
||||
icon: '📅',
|
||||
title: '我的预约',
|
||||
path: '/pages/profile/bookings',
|
||||
},
|
||||
{
|
||||
key: 'info',
|
||||
type: 'item',
|
||||
icon: '👤',
|
||||
title: '个人信息',
|
||||
path: '/pages/profile/info',
|
||||
},
|
||||
]
|
||||
|
||||
if (props.isAdmin) {
|
||||
items.push({ key: 'sep', type: 'separator' })
|
||||
items.push({
|
||||
key: 'admin',
|
||||
type: 'item',
|
||||
icon: '⚙️',
|
||||
title: '管理中心',
|
||||
path: '/pages/admin/index',
|
||||
isAdmin: true,
|
||||
})
|
||||
}
|
||||
|
||||
return items
|
||||
})
|
||||
|
||||
function navigate(path: string) {
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-menu {
|
||||
background: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
margin: $spacing-md $spacing-lg 0;
|
||||
overflow: hidden;
|
||||
|
||||
&__separator {
|
||||
height: $spacing-sm;
|
||||
background: $bg-page;
|
||||
}
|
||||
|
||||
&__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
background: $bg-card;
|
||||
transition: background 0.15s;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--hover {
|
||||
background: #f9f9f9;
|
||||
}
|
||||
|
||||
&--admin {
|
||||
// Admin row gets a subtle accent tint
|
||||
}
|
||||
}
|
||||
|
||||
&__icon-wrap {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: $radius-sm;
|
||||
background: rgba($brand-color, 0.06);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-right: $spacing-md;
|
||||
|
||||
&--admin {
|
||||
background: rgba($accent-color, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
&__icon {
|
||||
font-size: 32rpx;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
font-size: 30rpx;
|
||||
color: $text-primary;
|
||||
font-weight: 400;
|
||||
|
||||
&--admin {
|
||||
color: $accent-color;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
&__arrow {
|
||||
font-size: 36rpx;
|
||||
color: $text-hint;
|
||||
line-height: 1;
|
||||
transform: scaleX(0.6);
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
318
packages/app/src/components/QuickEntry.vue
Normal file
318
packages/app/src/components/QuickEntry.vue
Normal file
@@ -0,0 +1,318 @@
|
||||
<template>
|
||||
<view class="quick-entry">
|
||||
<!-- ① Not logged in -->
|
||||
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin">
|
||||
<view class="entry-content">
|
||||
<view class="entry-left">
|
||||
<text class="entry-icon">👋</text>
|
||||
<view>
|
||||
<text class="entry-title">欢迎来到工作室</text>
|
||||
<text class="entry-subtitle">登录后即可预约课程</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="entry-btn login-btn">
|
||||
<text class="entry-btn-text">微信登录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ② Logged in, no memberships at all → new user -->
|
||||
<view
|
||||
v-else-if="userStore.loggedIn && userStore.memberships.length === 0"
|
||||
class="entry-card trial-card"
|
||||
@tap="handleTrialEntry"
|
||||
>
|
||||
<view class="entry-content">
|
||||
<view class="entry-left">
|
||||
<text class="entry-icon">✨</text>
|
||||
<view>
|
||||
<text class="entry-title">初次体验</text>
|
||||
<text class="entry-subtitle">专属体验课,了解普拉提</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="entry-btn trial-btn">
|
||||
<text class="entry-btn-text">预约体验课</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-badge trial-badge">新会员专享</view>
|
||||
</view>
|
||||
|
||||
<!-- ③ Has valid active card + running low warning -->
|
||||
<template v-else-if="userStore.hasValidMembership">
|
||||
<view class="entry-card active-card" @tap="handleBooking">
|
||||
<view class="entry-content">
|
||||
<view class="entry-left">
|
||||
<text class="entry-icon">🧘</text>
|
||||
<view>
|
||||
<text class="entry-title">一键约课</text>
|
||||
<text class="entry-subtitle">{{ activeMembershipLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="entry-btn book-btn">
|
||||
<text class="entry-btn-text">立即预约</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- Running low badge -->
|
||||
<view v-if="isRunningLow" class="card-badge low-badge">
|
||||
仅剩 {{ lowestRemainingTimes }} 次
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Renew reminder if running low -->
|
||||
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop">
|
||||
<text class="renew-tip-icon">⚠️</text>
|
||||
<text class="renew-tip-text">课次即将用完,点击续卡保持练习节奏</text>
|
||||
<text class="renew-tip-action">续卡 ›</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- ④ Has memberships but none active → buy card -->
|
||||
<view
|
||||
v-else
|
||||
class="entry-card expired-card"
|
||||
@tap="scrollToCardShop"
|
||||
>
|
||||
<view class="entry-content">
|
||||
<view class="entry-left">
|
||||
<text class="entry-icon">💳</text>
|
||||
<view>
|
||||
<text class="entry-title">续费会员卡</text>
|
||||
<text class="entry-subtitle">您的卡已到期,续卡继续练习</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="entry-btn renew-btn">
|
||||
<text class="entry-btn-text">购买会员卡</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useUserStore } from '../stores/user'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'scroll-to-card-shop'): void
|
||||
}>()
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loading = ref(false)
|
||||
|
||||
async function handleLogin() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await userStore.login()
|
||||
await userStore.fetchMemberships()
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleTrialEntry() {
|
||||
// Navigate to the first TRIAL card detail page
|
||||
uni.navigateTo({ url: '/pages/card/detail?trial=1' })
|
||||
}
|
||||
|
||||
function handleBooking() {
|
||||
uni.switchTab({ url: '/pages/booking/index' })
|
||||
}
|
||||
|
||||
function scrollToCardShop() {
|
||||
emit('scroll-to-card-shop')
|
||||
}
|
||||
|
||||
// Computed: label for the active membership
|
||||
const activeMembershipLabel = computed(() => {
|
||||
const active = userStore.activeMemberships
|
||||
if (!active.length) return ''
|
||||
const m = active[0]
|
||||
const cardName = m.cardType.name
|
||||
if (m.cardType.type === CardTypeCategory.TIMES && m.remainingTimes !== null) {
|
||||
return `${cardName} · 剩余 ${m.remainingTimes} 次`
|
||||
}
|
||||
const expire = new Date(m.expireDate)
|
||||
const today = new Date()
|
||||
const daysLeft = Math.ceil((expire.getTime() - today.getTime()) / 86400000)
|
||||
return `${cardName} · 剩余 ${daysLeft} 天`
|
||||
})
|
||||
|
||||
// Check if any TIMES card has ≤ 2 remaining
|
||||
const isRunningLow = computed(() => {
|
||||
return userStore.activeMemberships.some(
|
||||
(m) =>
|
||||
m.cardType.type === CardTypeCategory.TIMES &&
|
||||
m.remainingTimes !== null &&
|
||||
m.remainingTimes <= 2,
|
||||
)
|
||||
})
|
||||
|
||||
const lowestRemainingTimes = computed(() => {
|
||||
const timesCards = userStore.activeMemberships.filter(
|
||||
(m) =>
|
||||
m.cardType.type === CardTypeCategory.TIMES &&
|
||||
m.remainingTimes !== null &&
|
||||
m.remainingTimes <= 2,
|
||||
)
|
||||
if (!timesCards.length) return 0
|
||||
return Math.min(...timesCards.map((m) => m.remainingTimes as number))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.quick-entry {
|
||||
margin: 24rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
position: relative;
|
||||
border-radius: 16rpx;
|
||||
padding: 36rpx 32rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.10);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.login-card {
|
||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
||||
}
|
||||
|
||||
.trial-card {
|
||||
background: linear-gradient(135deg, #2d2d5e, #4a3f7a);
|
||||
}
|
||||
|
||||
.active-card {
|
||||
background: linear-gradient(135deg, #1a1a2e, #3a2a1a);
|
||||
}
|
||||
|
||||
.expired-card {
|
||||
background: linear-gradient(135deg, #4a4a4a, #2a2a2a);
|
||||
}
|
||||
|
||||
.entry-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.entry-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.entry-icon {
|
||||
font-size: 56rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
display: block;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.entry-subtitle {
|
||||
display: block;
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.entry-btn {
|
||||
flex-shrink: 0;
|
||||
padding: 16rpx 32rpx;
|
||||
border-radius: 40rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.entry-btn-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
background: #c9a87c;
|
||||
}
|
||||
|
||||
.trial-btn {
|
||||
background: #c9a87c;
|
||||
}
|
||||
|
||||
.book-btn {
|
||||
background: #c9a87c;
|
||||
}
|
||||
|
||||
.renew-btn {
|
||||
background: #888;
|
||||
}
|
||||
|
||||
.login-btn .entry-btn-text,
|
||||
.trial-btn .entry-btn-text,
|
||||
.book-btn .entry-btn-text,
|
||||
.renew-btn .entry-btn-text {
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
/* Corner badge */
|
||||
.card-badge {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
padding: 8rpx 20rpx;
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
border-radius: 0 16rpx 0 16rpx;
|
||||
}
|
||||
|
||||
.trial-badge {
|
||||
background: #c9a87c;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.low-badge {
|
||||
background: #e74c3c;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* Renew tip bar */
|
||||
.renew-tip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
margin-top: 16rpx;
|
||||
padding: 20rpx 24rpx;
|
||||
background: #fff8f0;
|
||||
border-radius: 12rpx;
|
||||
border: 1rpx solid #f0d9bc;
|
||||
}
|
||||
|
||||
.renew-tip-icon {
|
||||
font-size: 28rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.renew-tip-text {
|
||||
flex: 1;
|
||||
font-size: 24rpx;
|
||||
color: #a0622a;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.renew-tip-action {
|
||||
font-size: 24rpx;
|
||||
color: #c9a87c;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
228
packages/app/src/components/SlotCard.vue
Normal file
228
packages/app/src/components/SlotCard.vue
Normal file
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<view class="slot-card">
|
||||
<!-- Time & capacity info -->
|
||||
<view class="slot-main">
|
||||
<view class="slot-time-block">
|
||||
<text class="slot-time">{{ slot.startTime.slice(0, 5) }} - {{ slot.endTime.slice(0, 5) }}</text>
|
||||
<view class="slot-capacity" :class="capacityClass">
|
||||
<text class="capacity-text">{{ capacityLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Action area -->
|
||||
<view class="slot-action">
|
||||
<!-- OPEN + not booked by me -->
|
||||
<template v-if="slot.status === TimeSlotStatus.OPEN && !slot.isBookedByMe">
|
||||
<view class="btn btn-book" @tap.stop="emit('book', slot)">
|
||||
<text class="btn-text">可预约</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- OPEN + booked by me -->
|
||||
<template v-else-if="slot.status === TimeSlotStatus.OPEN && slot.isBookedByMe">
|
||||
<view class="booked-row">
|
||||
<view class="badge-booked">
|
||||
<text class="badge-text">已预约</text>
|
||||
</view>
|
||||
<view class="btn-cancel" @tap.stop="emit('cancel', slot)">
|
||||
<text class="btn-cancel-text">取消</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- FULL -->
|
||||
<template v-else-if="slot.status === TimeSlotStatus.FULL">
|
||||
<view class="btn btn-disabled">
|
||||
<text class="btn-text">已约满</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- CLOSED -->
|
||||
<template v-else>
|
||||
<view class="btn btn-disabled">
|
||||
<text class="btn-text">已关闭</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Booked indicator bar -->
|
||||
<view v-if="slot.isBookedByMe" class="booked-bar" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { TimeSlotWithBookingStatus } from '@mp-pilates/shared'
|
||||
import { TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
slot: TimeSlotWithBookingStatus
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
book: [slot: TimeSlotWithBookingStatus]
|
||||
cancel: [slot: TimeSlotWithBookingStatus]
|
||||
}>()
|
||||
|
||||
const capacityLabel = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.slot
|
||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||
return `${bookedCount}/${capacity} 人`
|
||||
})
|
||||
|
||||
const capacityClass = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.slot
|
||||
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
|
||||
if (status === TimeSlotStatus.FULL) return 'cap-full'
|
||||
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
||||
return 'cap-open'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.slot-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
position: relative;
|
||||
|
||||
.booked-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6rpx;
|
||||
background: #c9a87c;
|
||||
border-radius: 20rpx 0 0 20rpx;
|
||||
}
|
||||
|
||||
.slot-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.slot-time-block {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.slot-time {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.slot-capacity {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
|
||||
.capacity-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
|
||||
&.cap-open .capacity-text {
|
||||
background: #f0faf3;
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.cap-almost .capacity-text {
|
||||
background: #fff8ed;
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&.cap-full .capacity-text {
|
||||
background: #fef0f0;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
&.cap-closed .capacity-text {
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
.slot-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-width: 140rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 34rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 28rpx;
|
||||
|
||||
.btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.btn-book {
|
||||
background: #c9a87c;
|
||||
|
||||
.btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-disabled {
|
||||
background: #f0f0f0;
|
||||
|
||||
.btn-text {
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.booked-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.badge-booked {
|
||||
height: 52rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #fff8ee;
|
||||
border-radius: 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.badge-text {
|
||||
font-size: 24rpx;
|
||||
color: #c9a87c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-cancel {
|
||||
height: 52rpx;
|
||||
padding: 0 16rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.btn-cancel-text {
|
||||
font-size: 24rpx;
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
203
packages/app/src/components/StudioInfo.vue
Normal file
203
packages/app/src/components/StudioInfo.vue
Normal 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>
|
||||
92
packages/app/src/components/TimePeriodFilter.vue
Normal file
92
packages/app/src/components/TimePeriodFilter.vue
Normal file
@@ -0,0 +1,92 @@
|
||||
<template>
|
||||
<view class="time-period-filter">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key ?? 'all'"
|
||||
class="tab-item"
|
||||
:class="{ active: modelValue === tab.key }"
|
||||
@tap="handleChange(tab.key)"
|
||||
>
|
||||
<text class="tab-label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
||||
|
||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||
|
||||
interface Tab {
|
||||
key: PeriodKey
|
||||
label: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
modelValue: PeriodKey
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
change: [period: PeriodKey]
|
||||
'update:modelValue': [period: PeriodKey]
|
||||
}>()
|
||||
|
||||
const tabs = computed<Tab[]>(() => [
|
||||
{ key: null, label: '全部' },
|
||||
...Object.entries(TIME_PERIODS).map(([key, val]) => ({
|
||||
key: key as keyof typeof TIME_PERIODS,
|
||||
label: val.label,
|
||||
})),
|
||||
])
|
||||
|
||||
function handleChange(key: PeriodKey) {
|
||||
emit('update:modelValue', key)
|
||||
emit('change', key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.time-period-filter {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #fff;
|
||||
padding: 0 24rpx;
|
||||
border-bottom: 1rpx solid #f0ece8;
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24rpx 0;
|
||||
position: relative;
|
||||
|
||||
.tab-label {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.tab-label {
|
||||
color: #c9a87c;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 40rpx;
|
||||
height: 4rpx;
|
||||
background: #c9a87c;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
84
packages/app/src/components/TrainingStats.vue
Normal file
84
packages/app/src/components/TrainingStats.vue
Normal file
@@ -0,0 +1,84 @@
|
||||
<template>
|
||||
<view class="training-stats">
|
||||
<view class="training-stats__item">
|
||||
<text class="training-stats__value">{{ stats?.monthBookings ?? 0 }}</text>
|
||||
<text class="training-stats__unit">次</text>
|
||||
<text class="training-stats__label">本月训练</text>
|
||||
</view>
|
||||
|
||||
<view class="training-stats__divider" />
|
||||
|
||||
<view class="training-stats__item">
|
||||
<text class="training-stats__value">{{ stats?.monthDays ?? 0 }}</text>
|
||||
<text class="training-stats__unit">天</text>
|
||||
<text class="training-stats__label">训练天数</text>
|
||||
</view>
|
||||
|
||||
<view class="training-stats__divider" />
|
||||
|
||||
<view class="training-stats__item">
|
||||
<text class="training-stats__value">{{ stats?.monthHours ?? 0 }}</text>
|
||||
<text class="training-stats__unit">小时</text>
|
||||
<text class="training-stats__label">训练时长</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { UserStatsResponse } from '@mp-pilates/shared'
|
||||
|
||||
defineProps<{
|
||||
stats: UserStatsResponse | null
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.training-stats {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
background: $bg-card;
|
||||
border-radius: $radius-lg;
|
||||
margin: 0 $spacing-lg;
|
||||
padding: $spacing-md 0;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
// Pull card up to overlap the dark header
|
||||
margin-top: -$spacing-xl;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&__item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
|
||||
&__divider {
|
||||
width: 1rpx;
|
||||
background: $border-color;
|
||||
margin: $spacing-xs 0;
|
||||
}
|
||||
|
||||
&__value {
|
||||
font-size: 48rpx;
|
||||
font-weight: 700;
|
||||
color: $brand-color;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
&__unit {
|
||||
font-size: 22rpx;
|
||||
color: $text-secondary;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
&__label {
|
||||
font-size: 24rpx;
|
||||
color: $text-hint;
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
229
packages/app/src/components/UpcomingBooking.vue
Normal file
229
packages/app/src/components/UpcomingBooking.vue
Normal file
@@ -0,0 +1,229 @@
|
||||
<template>
|
||||
<view v-if="bookingStore.upcomingBookings.length" class="upcoming-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">即将上课</text>
|
||||
<text class="section-more" @tap="goToBookings">全部预约 ›</text>
|
||||
</view>
|
||||
|
||||
<view
|
||||
v-for="booking in displayedBookings"
|
||||
:key="booking.id"
|
||||
class="booking-card"
|
||||
>
|
||||
<!-- Date column -->
|
||||
<view class="date-col">
|
||||
<text class="date-day">{{ getDayNumber(booking.timeSlot.date) }}</text>
|
||||
<text class="date-month">{{ getMonthLabel(booking.timeSlot.date) }}</text>
|
||||
<text class="date-weekday">{{ getWeekdayLabel(booking.timeSlot.date) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Divider -->
|
||||
<view class="booking-divider" />
|
||||
|
||||
<!-- Details column -->
|
||||
<view class="details-col">
|
||||
<text class="card-name">{{ booking.membership.cardType.name }}</text>
|
||||
<view class="time-row">
|
||||
<text class="time-icon">🕐</text>
|
||||
<text class="time-text">
|
||||
{{ formatTime(booking.timeSlot.startTime) }} – {{ formatTime(booking.timeSlot.endTime) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="status-row">
|
||||
<view class="status-dot" :class="statusDotClass(booking.status)" />
|
||||
<text class="status-text" :class="statusTextClass(booking.status)">
|
||||
{{ statusLabel(booking.status) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Right arrow -->
|
||||
<text class="booking-arrow">›</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useBookingStore } from '../stores/booking'
|
||||
import { getWeekdayLabel } from '../utils/format'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
// Show at most 2 upcoming bookings
|
||||
const displayedBookings = computed(() =>
|
||||
bookingStore.upcomingBookings.slice(0, 2),
|
||||
)
|
||||
|
||||
function getDayNumber(dateStr: string): string {
|
||||
return new Date(dateStr).getDate().toString()
|
||||
}
|
||||
|
||||
function getMonthLabel(dateStr: string): string {
|
||||
const months = ['1月', '2月', '3月', '4月', '5月', '6月', '7月', '8月', '9月', '10月', '11月', '12月']
|
||||
return months[new Date(dateStr).getMonth()]
|
||||
}
|
||||
|
||||
function formatTime(timeStr: string): string {
|
||||
// timeStr might be "HH:mm:ss" or "HH:mm"
|
||||
return timeStr.slice(0, 5)
|
||||
}
|
||||
|
||||
function statusLabel(status: BookingStatus): string {
|
||||
const map: Record<BookingStatus, string> = {
|
||||
[BookingStatus.CONFIRMED]: '已确认',
|
||||
[BookingStatus.CANCELLED]: '已取消',
|
||||
[BookingStatus.COMPLETED]: '已完成',
|
||||
[BookingStatus.NO_SHOW]: '未出席',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function statusDotClass(status: BookingStatus): string {
|
||||
if (status === BookingStatus.CONFIRMED) return 'dot--confirmed'
|
||||
if (status === BookingStatus.COMPLETED) return 'dot--completed'
|
||||
if (status === BookingStatus.CANCELLED) return 'dot--cancelled'
|
||||
return 'dot--default'
|
||||
}
|
||||
|
||||
function statusTextClass(status: BookingStatus): string {
|
||||
if (status === BookingStatus.CONFIRMED) return 'text--confirmed'
|
||||
if (status === BookingStatus.COMPLETED) return 'text--completed'
|
||||
if (status === BookingStatus.CANCELLED) return 'text--cancelled'
|
||||
return ''
|
||||
}
|
||||
|
||||
function goToBookings() {
|
||||
uni.navigateTo({ url: '/pages/profile/bookings' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.upcoming-section {
|
||||
margin: 24rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 26rpx;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.booking-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
|
||||
padding: 28rpx 28rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.date-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
width: 88rpx;
|
||||
}
|
||||
|
||||
.date-day {
|
||||
font-size: 52rpx;
|
||||
font-weight: 800;
|
||||
color: #1a1a2e;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.date-month {
|
||||
font-size: 22rpx;
|
||||
color: #c9a87c;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.date-weekday {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.booking-divider {
|
||||
width: 2rpx;
|
||||
height: 80rpx;
|
||||
background: #f0f0f0;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.details-col {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-name {
|
||||
display: block;
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.time-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 10rpx;
|
||||
}
|
||||
|
||||
.time-icon {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.time-text {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot--confirmed { background: #27ae60; }
|
||||
.dot--completed { background: #3498db; }
|
||||
.dot--cancelled { background: #e74c3c; }
|
||||
.dot--default { background: #999; }
|
||||
|
||||
.status-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.text--confirmed { color: #27ae60; }
|
||||
.text--completed { color: #3498db; }
|
||||
.text--cancelled { color: #e74c3c; }
|
||||
|
||||
.booking-arrow {
|
||||
font-size: 36rpx;
|
||||
color: #ccc;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
176
packages/app/src/components/UserCard.vue
Normal file
176
packages/app/src/components/UserCard.vue
Normal file
@@ -0,0 +1,176 @@
|
||||
<template>
|
||||
<view class="user-card">
|
||||
<!-- Not logged in state -->
|
||||
<view v-if="!loggedIn" class="user-card__guest">
|
||||
<view class="user-card__guest-avatar">
|
||||
<image class="user-card__avatar-img" src="/static/default-avatar.png" mode="aspectFill" />
|
||||
</view>
|
||||
<view class="user-card__guest-info">
|
||||
<text class="user-card__guest-title">Hi,欢迎来到普拉提</text>
|
||||
<text class="user-card__guest-sub">登录后查看个人数据</text>
|
||||
</view>
|
||||
<button class="user-card__login-btn" :loading="loading" @tap="handleLogin">
|
||||
微信登录
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<!-- Logged in state -->
|
||||
<view v-else class="user-card__user">
|
||||
<view class="user-card__avatar-wrap">
|
||||
<image
|
||||
class="user-card__avatar-img"
|
||||
:src="avatarSrc"
|
||||
mode="aspectFill"
|
||||
@error="onAvatarError"
|
||||
/>
|
||||
</view>
|
||||
<view class="user-card__info">
|
||||
<text class="user-card__nickname">{{ user!.nickname }}</text>
|
||||
<text v-if="maskedPhone" class="user-card__phone">{{ maskedPhone }}</text>
|
||||
<text class="user-card__joined">注册于 {{ joinedDate }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import type { UserProfileResponse } from '@mp-pilates/shared'
|
||||
import { formatDate } from '../utils/format'
|
||||
|
||||
const props = defineProps<{
|
||||
loggedIn: boolean
|
||||
user: UserProfileResponse | null
|
||||
loading?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'login'): void
|
||||
}>()
|
||||
|
||||
const avatarFailed = ref(false)
|
||||
|
||||
const avatarSrc = computed(() => {
|
||||
if (avatarFailed.value || !props.user?.avatarUrl) {
|
||||
return '/static/default-avatar.png'
|
||||
}
|
||||
return props.user.avatarUrl
|
||||
})
|
||||
|
||||
const maskedPhone = computed(() => {
|
||||
const phone = props.user?.phone
|
||||
if (!phone) return null
|
||||
// Mask middle 4 digits: 138****8888
|
||||
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
|
||||
})
|
||||
|
||||
const joinedDate = computed(() => {
|
||||
if (!props.user?.createdAt) return ''
|
||||
return formatDate(props.user.createdAt)
|
||||
})
|
||||
|
||||
function onAvatarError() {
|
||||
avatarFailed.value = true
|
||||
}
|
||||
|
||||
function handleLogin() {
|
||||
emit('login')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.user-card {
|
||||
background: $brand-color;
|
||||
padding: 80rpx $spacing-lg $spacing-xl;
|
||||
|
||||
&__guest {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
&__guest-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__guest-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
&__guest-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&__guest-sub {
|
||||
font-size: 24rpx;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
}
|
||||
|
||||
&__login-btn {
|
||||
flex-shrink: 0;
|
||||
background: $accent-color;
|
||||
color: #ffffff;
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: $radius-lg;
|
||||
padding: 0 $spacing-md;
|
||||
height: 64rpx;
|
||||
line-height: 64rpx;
|
||||
min-width: 160rpx;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
|
||||
&__user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
&__avatar-wrap {
|
||||
flex-shrink: 0;
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
&__avatar-img {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
&__info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
&__nickname {
|
||||
font-size: 36rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
&__phone {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.75);
|
||||
}
|
||||
|
||||
&__joined {
|
||||
font-size: 22rpx;
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user