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>
|
||||
@@ -1,15 +1,635 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>卡种管理 - 待实现</text>
|
||||
<!-- Add button -->
|
||||
<view class="toolbar">
|
||||
<text class="toolbar-hint">共 {{ cardTypes.length }} 个卡种</text>
|
||||
<view class="add-btn" @tap="openAdd">
|
||||
<text class="add-btn-text">+ 新增卡种</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading" class="skeleton-list">
|
||||
<view v-for="i in 3" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="!cardTypes.length" class="empty-state">
|
||||
<text class="empty-icon">💳</text>
|
||||
<text class="empty-text">暂无卡种,点击右上角新增</text>
|
||||
</view>
|
||||
|
||||
<!-- Card type list -->
|
||||
<view v-else class="ct-list">
|
||||
<view
|
||||
v-for="ct in cardTypes"
|
||||
:key="ct.id"
|
||||
class="ct-card"
|
||||
:class="{ 'ct-card--inactive': !ct.isActive }"
|
||||
>
|
||||
<!-- Header band -->
|
||||
<view class="ct-header" :class="headerClass(ct)">
|
||||
<text class="ct-type-label">{{ typeLabel(ct) }}</text>
|
||||
<view class="ct-status-tag" :class="ct.isActive ? 'tag--on' : 'tag--off'">
|
||||
<text class="ct-status-text">{{ ct.isActive ? '销售中' : '已下架' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Body -->
|
||||
<view class="ct-body">
|
||||
<text class="ct-name">{{ ct.name }}</text>
|
||||
<view class="ct-price-row">
|
||||
<text class="ct-price">¥{{ formatPrice(ct.price) }}</text>
|
||||
<text v-if="ct.originalPrice && ct.originalPrice > ct.price" class="ct-original">
|
||||
¥{{ formatPrice(ct.originalPrice) }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="ct.description" class="ct-desc">{{ ct.description }}</text>
|
||||
|
||||
<view class="ct-meta">
|
||||
<view v-if="ct.totalTimes" class="meta-item">
|
||||
<text class="meta-value">{{ ct.totalTimes }}</text>
|
||||
<text class="meta-label">次</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-value">{{ ct.durationDays }}</text>
|
||||
<text class="meta-label">天有效</text>
|
||||
</view>
|
||||
<view class="meta-item">
|
||||
<text class="meta-label">排序</text>
|
||||
<text class="meta-value">{{ ct.sortOrder }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Actions -->
|
||||
<view class="ct-actions">
|
||||
<view class="ct-action-btn edit-btn" @tap="openEdit(ct)">
|
||||
<text class="ct-action-text">编辑</text>
|
||||
</view>
|
||||
<view
|
||||
class="ct-action-btn toggle-btn"
|
||||
:class="ct.isActive ? 'toggle-off' : 'toggle-on'"
|
||||
@tap="toggleActive(ct)"
|
||||
>
|
||||
<text class="ct-action-text">{{ ct.isActive ? '下架' : '上架' }}</text>
|
||||
</view>
|
||||
<view class="ct-action-btn delete-btn" @tap="confirmDelete(ct)">
|
||||
<text class="ct-action-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Add / Edit modal -->
|
||||
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
||||
<scroll-view scroll-y class="modal">
|
||||
<text class="modal-title">{{ editTarget ? '编辑卡种' : '新增卡种' }}</text>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">卡种名称</text>
|
||||
<input class="modal-input" v-model="form.name" placeholder="如:10次课套餐" placeholder-style="color:#bbb" />
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">类型</text>
|
||||
<picker mode="selector" :range="typeOptions" range-key="label" :value="form.typeIdx" @change="(e: any) => form.typeIdx = Number(e.detail.value)">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ typeOptions[form.typeIdx].label }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">现价(元)</text>
|
||||
<input class="modal-input" type="digit" v-model="form.priceStr" placeholder="如:980" placeholder-style="color:#bbb" />
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">原价(元)</text>
|
||||
<input class="modal-input" type="digit" v-model="form.originalPriceStr" placeholder="可选,用于展示划线价" placeholder-style="color:#bbb" />
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">次数</text>
|
||||
<input class="modal-input" type="number" v-model="form.totalTimesStr" placeholder="次卡必填,月卡留空" placeholder-style="color:#bbb" />
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">有效天数</text>
|
||||
<input class="modal-input" type="number" v-model="form.durationDaysStr" placeholder="如:90" placeholder-style="color:#bbb" />
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">排序值</text>
|
||||
<input class="modal-input" type="number" v-model="form.sortOrderStr" placeholder="数字越小越靠前" placeholder-style="color:#bbb" />
|
||||
</view>
|
||||
|
||||
<view class="modal-field modal-field--last">
|
||||
<text class="modal-label">描述</text>
|
||||
<textarea
|
||||
class="modal-textarea"
|
||||
v-model="form.description"
|
||||
placeholder="可选"
|
||||
placeholder-style="color:#bbb"
|
||||
:maxlength="200"
|
||||
auto-height
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="modal-actions">
|
||||
<view class="modal-cancel" @tap="closeModal">
|
||||
<text class="modal-cancel-text">取消</text>
|
||||
</view>
|
||||
<view class="modal-confirm" :class="{ 'modal-confirm--loading': submitting }" @tap="submitForm">
|
||||
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put, del } from '../../utils/request'
|
||||
import { formatPrice } from '../../utils/format'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import type { CardType } from '@mp-pilates/shared'
|
||||
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editTarget = ref<CardType | null>(null)
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '次卡', value: CardTypeCategory.TIMES },
|
||||
{ label: '月卡', value: CardTypeCategory.DURATION },
|
||||
{ label: '体验卡', value: CardTypeCategory.TRIAL },
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
typeIdx: 0,
|
||||
priceStr: '',
|
||||
originalPriceStr: '',
|
||||
totalTimesStr: '',
|
||||
durationDaysStr: '90',
|
||||
sortOrderStr: '0',
|
||||
description: '',
|
||||
})
|
||||
|
||||
async function fetchCardTypes() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await get<CardType[]>('/admin/card-types')
|
||||
cardTypes.value = data.sort((a, b) => a.sortOrder - b.sortOrder)
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editTarget.value = null
|
||||
form.value = {
|
||||
name: '',
|
||||
typeIdx: 0,
|
||||
priceStr: '',
|
||||
originalPriceStr: '',
|
||||
totalTimesStr: '',
|
||||
durationDaysStr: '90',
|
||||
sortOrderStr: '0',
|
||||
description: '',
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(ct: CardType) {
|
||||
editTarget.value = ct
|
||||
form.value = {
|
||||
name: ct.name,
|
||||
typeIdx: typeOptions.findIndex((t) => t.value === ct.type),
|
||||
priceStr: String(ct.price),
|
||||
originalPriceStr: ct.originalPrice ? String(ct.originalPrice) : '',
|
||||
totalTimesStr: ct.totalTimes ? String(ct.totalTimes) : '',
|
||||
durationDaysStr: String(ct.durationDays),
|
||||
sortOrderStr: String(ct.sortOrder),
|
||||
description: ct.description ?? '',
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editTarget.value = null
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
if (submitting.value) return
|
||||
if (!form.value.name.trim()) {
|
||||
uni.showToast({ title: '请填写卡种名称', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const price = parseFloat(form.value.priceStr)
|
||||
if (isNaN(price) || price <= 0) {
|
||||
uni.showToast({ title: '请填写有效价格', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const durationDays = parseInt(form.value.durationDaysStr, 10)
|
||||
if (isNaN(durationDays) || durationDays < 1) {
|
||||
uni.showToast({ title: '请填写有效天数', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.value.name.trim(),
|
||||
type: typeOptions[form.value.typeIdx].value,
|
||||
price,
|
||||
durationDays,
|
||||
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
|
||||
}
|
||||
|
||||
if (form.value.originalPriceStr) {
|
||||
payload.originalPrice = parseFloat(form.value.originalPriceStr)
|
||||
}
|
||||
if (form.value.totalTimesStr) {
|
||||
payload.totalTimes = parseInt(form.value.totalTimesStr, 10)
|
||||
}
|
||||
if (form.value.description.trim()) {
|
||||
payload.description = form.value.description.trim()
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (editTarget.value) {
|
||||
await put(`/admin/card-types/${editTarget.value.id}`, payload)
|
||||
} else {
|
||||
await post('/admin/card-types', payload)
|
||||
}
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
closeModal()
|
||||
await fetchCardTypes()
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(ct: CardType) {
|
||||
try {
|
||||
await put(`/admin/card-types/${ct.id}`, { isActive: !ct.isActive })
|
||||
await fetchCardTypes()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(ct: CardType) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: `删除卡种「${ct.name}」?此操作不可恢复。`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await del(`/admin/card-types/${ct.id}`)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
await fetchCardTypes()
|
||||
} catch {
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function typeLabel(ct: CardType): string {
|
||||
const map: Record<CardTypeCategory, string> = {
|
||||
[CardTypeCategory.TIMES]: '次卡',
|
||||
[CardTypeCategory.DURATION]: '月卡',
|
||||
[CardTypeCategory.TRIAL]: '体验卡',
|
||||
}
|
||||
return map[ct.type] ?? '会员卡'
|
||||
}
|
||||
|
||||
function headerClass(ct: CardType): string {
|
||||
if (ct.type === CardTypeCategory.TRIAL) return 'header--trial'
|
||||
if (ct.type === CardTypeCategory.DURATION) return 'header--duration'
|
||||
return 'header--times'
|
||||
}
|
||||
|
||||
onMounted(fetchCardTypes)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
/* ── Toolbar ─────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 24rpx 16rpx;
|
||||
}
|
||||
|
||||
.toolbar-hint {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #1a1a2e;
|
||||
border-radius: 32rpx;
|
||||
padding: 12rpx 28rpx;
|
||||
}
|
||||
|
||||
.add-btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
/* ── Skeleton ────────────────────────────── */
|
||||
.skeleton-list {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 260rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 80rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||
|
||||
/* ── Card type list ──────────────────────── */
|
||||
.ct-list {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.ct-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
&--inactive {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.ct-header {
|
||||
padding: 16rpx 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.header--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
|
||||
.header--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
||||
.header--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
|
||||
|
||||
.ct-type-label {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.ct-status-tag {
|
||||
border-radius: 20rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
}
|
||||
|
||||
.tag--on { background: rgba(255,255,255,0.2); }
|
||||
.tag--off { background: rgba(0,0,0,0.2); }
|
||||
|
||||
.ct-status-text {
|
||||
font-size: 20rpx;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.ct-body {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.ct-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
display: block;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.ct-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.ct-price {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.ct-original {
|
||||
font-size: 24rpx;
|
||||
color: #bbb;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.ct-desc {
|
||||
font-size: 22rpx;
|
||||
color: #888;
|
||||
line-height: 1.5;
|
||||
display: block;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.ct-meta {
|
||||
display: flex;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ── Actions ─────────────────────────────── */
|
||||
.ct-actions {
|
||||
display: flex;
|
||||
border-top: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.ct-action-btn {
|
||||
flex: 1;
|
||||
padding: 20rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-right: 1rpx solid #f5f5f5;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.ct-action-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-btn .ct-action-text { color: #1a1a2e; }
|
||||
.toggle-on .ct-action-text { color: #27ae60; }
|
||||
.toggle-off .ct-action-text { color: #e67e22; }
|
||||
.delete-btn .ct-action-text { color: #c0392b; }
|
||||
|
||||
/* ── Modal ───────────────────────────────── */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 40rpx 32rpx 60rpx;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
gap: 16rpx;
|
||||
|
||||
&--last {
|
||||
border-bottom: none;
|
||||
align-items: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
width: 140rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 26rpx;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.picker-text { font-size: 26rpx; color: #222; }
|
||||
.picker-arrow { font-size: 26rpx; color: #bbb; }
|
||||
|
||||
.modal-textarea {
|
||||
flex: 1;
|
||||
font-size: 26rpx;
|
||||
color: #222;
|
||||
min-height: 80rpx;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 32rpx;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
background: #f0f0f0;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
||||
|
||||
.modal-confirm {
|
||||
flex: 2;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--loading { opacity: 0.6; }
|
||||
}
|
||||
|
||||
.modal-confirm-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,237 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>管理中心 - 待实现</text>
|
||||
<view class="admin-page">
|
||||
<!-- Header -->
|
||||
<view class="admin-header">
|
||||
<view class="header-top">
|
||||
<text class="header-title">管理中心</text>
|
||||
<view class="admin-badge">
|
||||
<text class="admin-badge-text">管理员</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="header-sub">欢迎回来,{{ userStore.user?.nickname }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Stats row -->
|
||||
<view class="stats-row">
|
||||
<view v-for="stat in stats" :key="stat.label" class="stat-cell">
|
||||
<view v-if="loadingStats" class="stat-skeleton" />
|
||||
<template v-else>
|
||||
<text class="stat-value">{{ stat.value }}</text>
|
||||
<text class="stat-label">{{ stat.label }}</text>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Nav grid -->
|
||||
<view class="grid">
|
||||
<view
|
||||
v-for="item in navItems"
|
||||
:key="item.path"
|
||||
class="grid-item"
|
||||
@tap="navigate(item.path)"
|
||||
>
|
||||
<text class="grid-icon">{{ item.icon }}</text>
|
||||
<text class="grid-label">{{ item.label }}</text>
|
||||
<text class="grid-desc">{{ item.desc }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { get } from '../../utils/request'
|
||||
import type { PaginatedData, OrderWithDetails, BookingWithDetails } from '@mp-pilates/shared'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const loadingStats = ref(true)
|
||||
|
||||
interface Stat {
|
||||
label: string
|
||||
value: string | number
|
||||
}
|
||||
|
||||
const stats = ref<Stat[]>([
|
||||
{ label: '今日预约', value: '-' },
|
||||
{ label: '总订单', value: '-' },
|
||||
{ label: '总预约', value: '-' },
|
||||
])
|
||||
|
||||
const navItems = [
|
||||
{ path: '/pages/admin/week-template', icon: '📅', label: '排课设置', desc: '管理周课模板' },
|
||||
{ path: '/pages/admin/slot-adjust', icon: '🗓️', label: '时段调整', desc: '手动添加/关闭时段' },
|
||||
{ path: '/pages/admin/members', icon: '👥', label: '会员管理', desc: '查看会员活跃度' },
|
||||
{ path: '/pages/admin/orders', icon: '📋', label: '订单管理', desc: '查看购卡订单' },
|
||||
{ path: '/pages/admin/card-types', icon: '💳', label: '卡种管理', desc: '配置会员卡套餐' },
|
||||
{ path: '/pages/admin/studio', icon: '🏢', label: '工作室设置', desc: '基本信息配置' },
|
||||
]
|
||||
|
||||
async function loadStats() {
|
||||
loadingStats.value = true
|
||||
try {
|
||||
const today = new Date().toISOString().slice(0, 10)
|
||||
const [bookingsRes, ordersRes] = await Promise.all([
|
||||
get<PaginatedData<BookingWithDetails>>('/admin/bookings?page=1&limit=1'),
|
||||
get<PaginatedData<OrderWithDetails>>('/admin/orders?page=1&limit=1'),
|
||||
])
|
||||
|
||||
// Today's bookings — fetch with date filter
|
||||
const todayRes = await get<PaginatedData<BookingWithDetails>>(
|
||||
`/admin/bookings?page=1&limit=1&date=${today}`,
|
||||
)
|
||||
|
||||
stats.value = [
|
||||
{ label: '今日预约', value: todayRes.total ?? 0 },
|
||||
{ label: '总订单', value: ordersRes.total ?? 0 },
|
||||
{ label: '总预约', value: bookingsRes.total ?? 0 },
|
||||
]
|
||||
} catch {
|
||||
stats.value = [
|
||||
{ label: '今日预约', value: '--' },
|
||||
{ label: '总订单', value: '--' },
|
||||
{ label: '总预约', value: '--' },
|
||||
]
|
||||
} finally {
|
||||
loadingStats.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function navigate(path: string) {
|
||||
uni.navigateTo({ url: path })
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.admin-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
}
|
||||
|
||||
/* ── Header ─────────────────────────────────────── */
|
||||
.admin-header {
|
||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
||||
padding: 80rpx 32rpx 48rpx;
|
||||
}
|
||||
|
||||
.header-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.admin-badge {
|
||||
background: #c9a87c;
|
||||
border-radius: 20rpx;
|
||||
padding: 4rpx 16rpx;
|
||||
}
|
||||
|
||||
.admin-badge-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.header-sub {
|
||||
font-size: 26rpx;
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
|
||||
/* ── Stats row ───────────────────────────────────── */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
margin: -24rpx 24rpx 0;
|
||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.stat-cell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 36rpx 0;
|
||||
border-right: 1rpx solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: #1a1a2e;
|
||||
line-height: 1;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.stat-skeleton {
|
||||
width: 80rpx;
|
||||
height: 60rpx;
|
||||
border-radius: 8rpx;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Nav grid ────────────────────────────────────── */
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20rpx;
|
||||
margin: 32rpx 24rpx 40rpx;
|
||||
}
|
||||
|
||||
.grid-item {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 36rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&:active {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-icon {
|
||||
font-size: 52rpx;
|
||||
margin-bottom: 4rpx;
|
||||
}
|
||||
|
||||
.grid-label {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.grid-desc {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
line-height: 1.4;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,354 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>会员管理 - 待实现</text>
|
||||
<!-- Search / filter bar -->
|
||||
<view class="filter-bar">
|
||||
<input
|
||||
class="search-input"
|
||||
v-model="searchQuery"
|
||||
placeholder="搜索昵称或手机号"
|
||||
placeholder-style="color:#bbb"
|
||||
@input="onSearch"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Stats summary -->
|
||||
<view class="stats-row">
|
||||
<view class="stat-cell">
|
||||
<text class="stat-value">{{ totalMembers }}</text>
|
||||
<text class="stat-label">活跃会员</text>
|
||||
</view>
|
||||
<view class="stat-cell">
|
||||
<text class="stat-value">{{ totalBookings }}</text>
|
||||
<text class="stat-label">总预约次数</text>
|
||||
</view>
|
||||
<view class="stat-cell">
|
||||
<text class="stat-value">{{ confirmedBookings }}</text>
|
||||
<text class="stat-label">待上课</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading" class="skeleton-list">
|
||||
<view v-for="i in 6" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="!filteredMembers.length" class="empty-state">
|
||||
<text class="empty-icon">👥</text>
|
||||
<text class="empty-text">{{ searchQuery ? '未找到匹配会员' : '暂无预约记录' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Member list -->
|
||||
<view v-else class="member-list">
|
||||
<view
|
||||
v-for="member in filteredMembers"
|
||||
:key="member.userId"
|
||||
class="member-card"
|
||||
>
|
||||
<view class="member-avatar">
|
||||
<text class="member-avatar-text">{{ member.nickname.slice(0, 1).toUpperCase() }}</text>
|
||||
</view>
|
||||
<view class="member-info">
|
||||
<text class="member-name">{{ member.nickname }}</text>
|
||||
<text v-if="member.phone" class="member-phone">{{ maskPhone(member.phone) }}</text>
|
||||
</view>
|
||||
<view class="member-stats">
|
||||
<view class="member-stat">
|
||||
<text class="member-stat-value">{{ member.totalBookings }}</text>
|
||||
<text class="member-stat-label">次预约</text>
|
||||
</view>
|
||||
<view class="member-stat">
|
||||
<text class="member-stat-value confirmed-count">{{ member.confirmedBookings }}</text>
|
||||
<text class="member-stat-label">待上课</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Load more -->
|
||||
<view v-if="hasMore && !loading" class="load-more" @tap="loadMore">
|
||||
<text class="load-more-text">加载更多</text>
|
||||
</view>
|
||||
<view v-if="loadingMore" class="load-more">
|
||||
<text class="load-more-text">加载中...</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get } from '../../utils/request'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import type { BookingWithDetails, PaginatedData } from '@mp-pilates/shared'
|
||||
|
||||
interface MemberSummary {
|
||||
userId: string
|
||||
nickname: string
|
||||
phone?: string
|
||||
totalBookings: number
|
||||
confirmedBookings: number
|
||||
}
|
||||
|
||||
const allBookings = ref<BookingWithDetails[]>([])
|
||||
const page = ref(1)
|
||||
const limit = 50
|
||||
const hasMore = ref(true)
|
||||
const loading = ref(false)
|
||||
const loadingMore = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
const members = computed<MemberSummary[]>(() => {
|
||||
const map = new Map<string, MemberSummary>()
|
||||
for (const b of allBookings.value) {
|
||||
const userId = b.userId
|
||||
if (!userId) continue
|
||||
if (!map.has(userId)) {
|
||||
map.set(userId, {
|
||||
userId,
|
||||
nickname: userId.slice(0, 8),
|
||||
totalBookings: 0,
|
||||
confirmedBookings: 0,
|
||||
})
|
||||
}
|
||||
const m = map.get(userId)!
|
||||
m.totalBookings++
|
||||
if (b.status === BookingStatus.CONFIRMED) {
|
||||
m.confirmedBookings++
|
||||
}
|
||||
}
|
||||
return Array.from(map.values()).sort((a, b) => b.totalBookings - a.totalBookings)
|
||||
})
|
||||
|
||||
const filteredMembers = computed(() => {
|
||||
if (!searchQuery.value.trim()) return members.value
|
||||
const q = searchQuery.value.toLowerCase()
|
||||
return members.value.filter(
|
||||
(m) =>
|
||||
m.nickname.toLowerCase().includes(q) ||
|
||||
(m.phone && m.phone.includes(q)),
|
||||
)
|
||||
})
|
||||
|
||||
const totalMembers = computed(() => members.value.length)
|
||||
const totalBookings = computed(() => members.value.reduce((s, m) => s + m.totalBookings, 0))
|
||||
const confirmedBookings = computed(() => members.value.reduce((s, m) => s + m.confirmedBookings, 0))
|
||||
|
||||
async function fetchBookings(isLoadMore = false) {
|
||||
if (isLoadMore) {
|
||||
loadingMore.value = true
|
||||
} else {
|
||||
loading.value = true
|
||||
allBookings.value = []
|
||||
page.value = 1
|
||||
hasMore.value = true
|
||||
}
|
||||
|
||||
try {
|
||||
const data = await get<PaginatedData<BookingWithDetails>>(
|
||||
`/admin/bookings?page=${page.value}&limit=${limit}`,
|
||||
)
|
||||
allBookings.value = [...allBookings.value, ...(data.items ?? [])]
|
||||
hasMore.value = allBookings.value.length < data.total
|
||||
page.value++
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadMore() {
|
||||
if (loadingMore.value || !hasMore.value) return
|
||||
await fetchBookings(true)
|
||||
}
|
||||
|
||||
function onSearch() {
|
||||
// Reactive filtering via computed — no action needed
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
}
|
||||
|
||||
onMounted(() => fetchBookings())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
/* ── Filter bar ──────────────────────────── */
|
||||
.filter-bar {
|
||||
padding: 20rpx 24rpx;
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.search-input {
|
||||
background: #f5f3f0;
|
||||
border-radius: 32rpx;
|
||||
padding: 16rpx 28rpx;
|
||||
font-size: 26rpx;
|
||||
color: #222;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Stats row ───────────────────────────── */
|
||||
.stats-row {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.stat-cell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 28rpx 0;
|
||||
border-right: 1rpx solid #f0f0f0;
|
||||
|
||||
&:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #1a1a2e;
|
||||
line-height: 1;
|
||||
margin-bottom: 6rpx;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* ── Skeleton ────────────────────────────── */
|
||||
.skeleton-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 120rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 80rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||
|
||||
/* ── Member list ─────────────────────────── */
|
||||
.member-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.member-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 20rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.member-avatar {
|
||||
width: 80rpx;
|
||||
height: 80rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.member-avatar-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.member-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.member-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.member-phone {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.member-stats {
|
||||
display: flex;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.member-stat {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.member-stat-value {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.confirmed-count {
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.member-stat-label {
|
||||
font-size: 20rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* ── Load more ───────────────────────────── */
|
||||
.load-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28rpx 0;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: 26rpx;
|
||||
color: #c9a87c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,349 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>订单管理 - 待实现</text>
|
||||
<!-- Status filter tabs -->
|
||||
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
|
||||
<view class="filter-row">
|
||||
<view
|
||||
v-for="f in filters"
|
||||
:key="f.key"
|
||||
class="filter-chip"
|
||||
:class="{ 'filter-chip--active': statusFilter === f.key }"
|
||||
@tap="selectFilter(f.key)"
|
||||
>
|
||||
<text class="filter-chip-text">{{ f.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading" class="skeleton-list">
|
||||
<view v-for="i in 5" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="!orders.length" class="empty-state">
|
||||
<text class="empty-icon">📋</text>
|
||||
<text class="empty-text">暂无订单</text>
|
||||
</view>
|
||||
|
||||
<!-- Order list -->
|
||||
<view v-else class="order-list">
|
||||
<view
|
||||
v-for="order in orders"
|
||||
:key="order.id"
|
||||
class="order-card"
|
||||
>
|
||||
<!-- Header: card name + status badge -->
|
||||
<view class="order-header">
|
||||
<text class="order-card-name">{{ order.cardType?.name ?? '未知卡种' }}</text>
|
||||
<view class="status-badge" :class="statusBadgeClass(order.status)">
|
||||
<text class="status-badge-text">{{ statusLabel(order.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- User info -->
|
||||
<view v-if="order.user" class="order-user">
|
||||
<text class="order-user-icon">👤</text>
|
||||
<text class="order-user-text">
|
||||
{{ order.user.nickname }}
|
||||
<text v-if="order.user.phone"> · {{ maskPhone(order.user.phone) }}</text>
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Amount + date row -->
|
||||
<view class="order-footer">
|
||||
<text class="order-amount">¥{{ formatPrice(order.amount) }}</text>
|
||||
<text class="order-date">{{ formatOrderDate(order.createdAt) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Order id -->
|
||||
<text class="order-id">订单号:{{ order.id.slice(0, 16) }}...</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Pagination -->
|
||||
<view v-if="totalPages > 1" class="pagination">
|
||||
<view
|
||||
class="page-btn"
|
||||
:class="{ 'page-btn--disabled': currentPage === 1 }"
|
||||
@tap="goPage(currentPage - 1)"
|
||||
>
|
||||
<text class="page-btn-text">‹ 上一页</text>
|
||||
</view>
|
||||
<text class="page-info">{{ currentPage }} / {{ totalPages }}</text>
|
||||
<view
|
||||
class="page-btn"
|
||||
:class="{ 'page-btn--disabled': currentPage === totalPages }"
|
||||
@tap="goPage(currentPage + 1)"
|
||||
>
|
||||
<text class="page-btn-text">下一页 ›</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get } from '../../utils/request'
|
||||
import { formatPrice } from '../../utils/format'
|
||||
import type { OrderWithDetails, PaginatedData } from '@mp-pilates/shared'
|
||||
|
||||
const filters = [
|
||||
{ key: '', label: '全部' },
|
||||
{ key: 'PAID', label: '已支付' },
|
||||
{ key: 'PENDING', label: '待支付' },
|
||||
{ key: 'REFUNDED', label: '已退款' },
|
||||
{ key: 'CANCELLED', label: '已取消' },
|
||||
]
|
||||
|
||||
const statusFilter = ref('')
|
||||
const orders = ref<OrderWithDetails[]>([])
|
||||
const loading = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const total = ref(0)
|
||||
const limit = 10
|
||||
|
||||
const totalPages = computed(() => Math.max(1, Math.ceil(total.value / limit)))
|
||||
|
||||
async function fetchOrders() {
|
||||
loading.value = true
|
||||
try {
|
||||
const statusParam = statusFilter.value ? `&status=${statusFilter.value}` : ''
|
||||
const data = await get<PaginatedData<OrderWithDetails>>(
|
||||
`/admin/orders?page=${currentPage.value}&limit=${limit}${statusParam}`,
|
||||
)
|
||||
orders.value = [...(data.items ?? [])]
|
||||
total.value = data.total ?? 0
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
orders.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function selectFilter(key: string) {
|
||||
statusFilter.value = key
|
||||
currentPage.value = 1
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
function goPage(p: number) {
|
||||
if (p < 1 || p > totalPages.value) return
|
||||
currentPage.value = p
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
function statusLabel(status: string): string {
|
||||
const map: Record<string, string> = {
|
||||
PAID: '已支付',
|
||||
PENDING: '待支付',
|
||||
REFUNDED: '已退款',
|
||||
CANCELLED: '已取消',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: string): string {
|
||||
if (status === 'PAID') return 'badge--paid'
|
||||
if (status === 'PENDING') return 'badge--pending'
|
||||
if (status === 'REFUNDED') return 'badge--refunded'
|
||||
if (status === 'CANCELLED') return 'badge--cancelled'
|
||||
return ''
|
||||
}
|
||||
|
||||
function maskPhone(phone: string): string {
|
||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
}
|
||||
|
||||
function formatOrderDate(iso: string): string {
|
||||
const d = new Date(iso)
|
||||
return `${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
onMounted(fetchOrders)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
padding-bottom: 40rpx;
|
||||
}
|
||||
|
||||
/* ── Filter scroll ───────────────────────── */
|
||||
.filter-scroll {
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 12rpx;
|
||||
padding: 16rpx 24rpx;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
padding: 12rpx 28rpx;
|
||||
border-radius: 32rpx;
|
||||
background: #f0f0f0;
|
||||
|
||||
&--active {
|
||||
background: #1a1a2e;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-chip-text {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
|
||||
.filter-chip--active & {
|
||||
color: #c9a87c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Skeleton ────────────────────────────── */
|
||||
.skeleton-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 180rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 100rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 80rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||
|
||||
/* ── Order list ──────────────────────────── */
|
||||
.order-list {
|
||||
padding: 16rpx 24rpx 0;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
padding: 28rpx;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.order-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.order-card-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
border-radius: 20rpx;
|
||||
padding: 6rpx 16rpx;
|
||||
}
|
||||
|
||||
.status-badge-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.badge--paid { background: #d4edda; .status-badge-text { color: #155724; } }
|
||||
.badge--pending { background: #fff3cd; .status-badge-text { color: #856404; } }
|
||||
.badge--refunded { background: #cce5ff; .status-badge-text { color: #004085; } }
|
||||
.badge--cancelled { background: #f8d7da; .status-badge-text { color: #721c24; } }
|
||||
|
||||
.order-user {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.order-user-icon { font-size: 24rpx; }
|
||||
|
||||
.order-user-text {
|
||||
font-size: 24rpx;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.order-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.order-amount {
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.order-date {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.order-id {
|
||||
font-size: 20rpx;
|
||||
color: #bbb;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Pagination ──────────────────────────── */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 32rpx;
|
||||
padding: 32rpx 0;
|
||||
}
|
||||
|
||||
.page-btn {
|
||||
padding: 12rpx 32rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
||||
|
||||
&--disabled {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.page-btn-text {
|
||||
font-size: 26rpx;
|
||||
color: #1a1a2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,512 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>时段调整 - 待实现</text>
|
||||
<!-- Tabs -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab"
|
||||
:class="{ 'tab--active': activeTab === tab.key }"
|
||||
@tap="activeTab = tab.key"
|
||||
>
|
||||
<text class="tab-text">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ── Tab: Manual add ───────────────────── -->
|
||||
<view v-if="activeTab === 'add'" class="section">
|
||||
<text class="section-title">手动新增时段</text>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-row">
|
||||
<text class="form-label">日期</text>
|
||||
<picker mode="date" :value="addForm.date" @change="(e: any) => addForm.date = e.detail.value">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ addForm.date }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<text class="form-label">开始时间</text>
|
||||
<picker mode="time" :value="addForm.startTime" @change="(e: any) => addForm.startTime = e.detail.value">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ addForm.startTime }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<text class="form-label">结束时间</text>
|
||||
<picker mode="time" :value="addForm.endTime" @change="(e: any) => addForm.endTime = e.detail.value">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ addForm.endTime }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="form-row form-row--last">
|
||||
<text class="form-label">容量(人)</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="number"
|
||||
v-model="addForm.capacityStr"
|
||||
placeholder="默认10"
|
||||
placeholder-style="color:#bbb"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="action-btn primary-btn"
|
||||
:class="{ 'primary-btn--loading': addingSlot }"
|
||||
@tap="handleAddSlot"
|
||||
>
|
||||
<text class="primary-btn-text">{{ addingSlot ? '添加中...' : '添加时段' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ── Tab: Close slots ──────────────────── -->
|
||||
<view v-else-if="activeTab === 'close'" class="section">
|
||||
<view class="search-row">
|
||||
<picker mode="date" :value="closeDateFilter" @change="(e: any) => { closeDateFilter = e.detail.value; fetchSlotsForClose() }">
|
||||
<view class="date-filter">
|
||||
<text class="date-filter-text">{{ closeDateFilter }}</text>
|
||||
<text class="date-filter-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view v-if="loadingClose" class="skeleton-list">
|
||||
<view v-for="i in 3" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<view v-else-if="!closeSlots.length" class="empty-state">
|
||||
<text class="empty-icon">🗓️</text>
|
||||
<text class="empty-text">该日暂无时段</text>
|
||||
</view>
|
||||
|
||||
<view v-else class="slot-list">
|
||||
<view
|
||||
v-for="slot in closeSlots"
|
||||
:key="slot.id"
|
||||
class="slot-card"
|
||||
:class="{ 'slot-card--closed': slot.status === 'CLOSED' }"
|
||||
>
|
||||
<view class="slot-info">
|
||||
<text class="slot-time">{{ slot.startTime.slice(0, 5) }}–{{ slot.endTime.slice(0, 5) }}</text>
|
||||
<text class="slot-cap">容量 {{ slot.capacity }} · 已预约 {{ slot.bookedCount }}</text>
|
||||
</view>
|
||||
<view
|
||||
v-if="slot.status !== 'CLOSED'"
|
||||
class="close-btn"
|
||||
@tap="confirmClose(slot)"
|
||||
>
|
||||
<text class="close-btn-text">关闭</text>
|
||||
</view>
|
||||
<view v-else class="closed-tag">
|
||||
<text class="closed-tag-text">已关闭</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- ── Tab: Generate ─────────────────────── -->
|
||||
<view v-else-if="activeTab === 'generate'" class="section">
|
||||
<text class="section-title">按模板生成时段</text>
|
||||
<text class="section-sub">将依据当前排课模板,生成未来指定天数的课程时段(已存在的时段不会重复生成)。</text>
|
||||
|
||||
<view class="form-card">
|
||||
<view class="form-row form-row--last">
|
||||
<text class="form-label">生成天数</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="number"
|
||||
v-model="generateDaysStr"
|
||||
placeholder="如:14"
|
||||
placeholder-style="color:#bbb"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view
|
||||
class="action-btn primary-btn"
|
||||
:class="{ 'primary-btn--loading': generating }"
|
||||
@tap="handleGenerate"
|
||||
>
|
||||
<text class="primary-btn-text">{{ generating ? '生成中...' : '生成时段' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { get, post, put } from '../../utils/request'
|
||||
import type { TimeSlot } from '@mp-pilates/shared'
|
||||
|
||||
const tabs = [
|
||||
{ key: 'add', label: '新增时段' },
|
||||
{ key: 'close', label: '关闭时段' },
|
||||
{ key: 'generate', label: '批量生成' },
|
||||
]
|
||||
|
||||
const activeTab = ref<string>('add')
|
||||
|
||||
// ── Add slot form ─────────────────────────────────
|
||||
const todayStr = new Date().toISOString().slice(0, 10)
|
||||
const addForm = ref({
|
||||
date: todayStr,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
capacityStr: '10',
|
||||
})
|
||||
const addingSlot = ref(false)
|
||||
|
||||
async function handleAddSlot() {
|
||||
if (addingSlot.value) return
|
||||
const capacity = parseInt(addForm.value.capacityStr, 10)
|
||||
if (!addForm.value.date || !addForm.value.startTime || !addForm.value.endTime) {
|
||||
uni.showToast({ title: '请完整填写信息', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
addingSlot.value = true
|
||||
try {
|
||||
await post('/admin/time-slot/manual', {
|
||||
date: addForm.value.date,
|
||||
startTime: addForm.value.startTime,
|
||||
endTime: addForm.value.endTime,
|
||||
capacity: isNaN(capacity) ? undefined : capacity,
|
||||
})
|
||||
uni.showToast({ title: '时段已添加', icon: 'success' })
|
||||
addForm.value = { date: todayStr, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message ?? '添加失败', icon: 'none' })
|
||||
} finally {
|
||||
addingSlot.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ── Close slots ────────────────────────────────────
|
||||
interface SlotRow extends TimeSlot {
|
||||
bookedCount: number
|
||||
}
|
||||
|
||||
const closeDateFilter = ref(todayStr)
|
||||
const closeSlots = ref<SlotRow[]>([])
|
||||
const loadingClose = ref(false)
|
||||
|
||||
async function fetchSlotsForClose() {
|
||||
loadingClose.value = true
|
||||
try {
|
||||
const data = await get<SlotRow[]>(`/admin/time-slots?date=${closeDateFilter.value}`)
|
||||
closeSlots.value = data
|
||||
} catch {
|
||||
closeSlots.value = []
|
||||
} finally {
|
||||
loadingClose.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function confirmClose(slot: SlotRow) {
|
||||
uni.showModal({
|
||||
title: '关闭时段',
|
||||
content: `确认关闭 ${slot.startTime.slice(0, 5)}–${slot.endTime.slice(0, 5)} 的时段?`,
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await put(`/admin/time-slot/${slot.id}/close`, {})
|
||||
uni.showToast({ title: '已关闭', icon: 'success' })
|
||||
await fetchSlotsForClose()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ── Generate slots ─────────────────────────────────
|
||||
const generateDaysStr = ref('14')
|
||||
const generating = ref(false)
|
||||
|
||||
async function handleGenerate() {
|
||||
if (generating.value) return
|
||||
const days = parseInt(generateDaysStr.value, 10)
|
||||
if (isNaN(days) || days < 1 || days > 90) {
|
||||
uni.showToast({ title: '请输入 1–90 天', icon: 'none' })
|
||||
return
|
||||
}
|
||||
generating.value = true
|
||||
try {
|
||||
await post('/admin/generate-slots', { days })
|
||||
uni.showToast({ title: '生成成功', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message ?? '生成失败', icon: 'none' })
|
||||
} finally {
|
||||
generating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSlotsForClose()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
}
|
||||
|
||||
/* ── Tabs ────────────────────────────────── */
|
||||
.tabs {
|
||||
display: flex;
|
||||
background: #ffffff;
|
||||
border-bottom: 1rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.tab {
|
||||
flex: 1;
|
||||
padding: 28rpx 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
|
||||
&--active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 20%;
|
||||
right: 20%;
|
||||
height: 4rpx;
|
||||
background: #1a1a2e;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.tab-text {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
|
||||
.tab--active & {
|
||||
color: #1a1a2e;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Section ─────────────────────────────── */
|
||||
.section {
|
||||
padding: 24rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
display: block;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.section-sub {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
line-height: 1.6;
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
/* ── Form card ───────────────────────────── */
|
||||
.form-card {
|
||||
background: #ffffff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 28rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
|
||||
&--last {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
width: 160rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
font-size: 28rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
/* ── Buttons ─────────────────────────────── */
|
||||
.action-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.primary-btn {
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
|
||||
&--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.primary-btn-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
/* ── Close tab ───────────────────────────── */
|
||||
.search-row {
|
||||
margin-bottom: 20rpx;
|
||||
}
|
||||
|
||||
.date-filter {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
background: #ffffff;
|
||||
border-radius: 32rpx;
|
||||
padding: 12rpx 24rpx;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.date-filter-text {
|
||||
font-size: 26rpx;
|
||||
color: #1a1a2e;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.date-filter-arrow {
|
||||
font-size: 26rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.skeleton-list {
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 100rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 12rpx;
|
||||
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; }
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 80rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon { font-size: 80rpx; }
|
||||
.empty-text { font-size: 28rpx; color: #bbb; }
|
||||
|
||||
.slot-list {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
.slot-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx 28rpx;
|
||||
margin-bottom: 12rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&--closed {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
.slot-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.slot-time {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.slot-cap {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: #fde8e8;
|
||||
border-radius: 8rpx;
|
||||
padding: 12rpx 28rpx;
|
||||
}
|
||||
|
||||
.close-btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.closed-tag {
|
||||
background: #f0f0f0;
|
||||
border-radius: 8rpx;
|
||||
padding: 12rpx 28rpx;
|
||||
}
|
||||
|
||||
.closed-tag-text {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,417 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>工作室设置 - 待实现</text>
|
||||
<!-- Loading state -->
|
||||
<view v-if="loading" class="skeleton-page">
|
||||
<view class="skeleton-section" />
|
||||
<view class="skeleton-section" />
|
||||
<view class="skeleton-section" />
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- Banner preview -->
|
||||
<view class="banner-preview" :style="bannerStyle">
|
||||
<view class="banner-overlay">
|
||||
<view class="banner-logo-wrap">
|
||||
<image v-if="form.logo" class="banner-logo" :src="form.logo" mode="aspectFill" />
|
||||
<view v-else class="banner-logo-placeholder">
|
||||
<text class="banner-logo-text">{{ form.name.slice(0, 1) || '🏢' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="banner-name">{{ form.name || '工作室名称' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Form card -->
|
||||
<view class="form-card">
|
||||
<text class="form-card-title">基本信息</text>
|
||||
|
||||
<view class="form-row">
|
||||
<text class="form-label">工作室名称</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="form.name"
|
||||
placeholder="请输入名称"
|
||||
placeholder-style="color:#bbb"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<text class="form-label">地址</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="form.address"
|
||||
placeholder="请输入地址"
|
||||
placeholder-style="color:#bbb"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row">
|
||||
<text class="form-label">联系电话</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="form.phone"
|
||||
type="tel"
|
||||
placeholder="请输入电话"
|
||||
placeholder-style="color:#bbb"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row form-row--last">
|
||||
<text class="form-label">Logo URL</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="form.logo"
|
||||
placeholder="图片链接(可选)"
|
||||
placeholder-style="color:#bbb"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Settings card -->
|
||||
<view class="form-card">
|
||||
<text class="form-card-title">预约设置</text>
|
||||
|
||||
<view class="form-row">
|
||||
<view class="label-group">
|
||||
<text class="form-label">取消限制(小时)</text>
|
||||
<text class="form-label-sub">课前多少小时内不允许取消</text>
|
||||
</view>
|
||||
<input
|
||||
class="form-input form-input--short"
|
||||
type="number"
|
||||
v-model="form.cancelHoursLimitStr"
|
||||
placeholder="如:2"
|
||||
placeholder-style="color:#bbb"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row form-row--last">
|
||||
<view class="label-group">
|
||||
<text class="form-label">宣传图 URL</text>
|
||||
<text class="form-label-sub">首页横幅图片链接</text>
|
||||
</view>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="form.bannerUrl"
|
||||
placeholder="图片链接(可选)"
|
||||
placeholder-style="color:#bbb"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Location card -->
|
||||
<view class="form-card">
|
||||
<text class="form-card-title">位置坐标(可选)</text>
|
||||
|
||||
<view class="form-row">
|
||||
<text class="form-label">纬度</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="digit"
|
||||
v-model="form.latitudeStr"
|
||||
placeholder="如:31.2304"
|
||||
placeholder-style="color:#bbb"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="form-row form-row--last">
|
||||
<text class="form-label">经度</text>
|
||||
<input
|
||||
class="form-input"
|
||||
type="digit"
|
||||
v-model="form.longitudeStr"
|
||||
placeholder="如:121.4737"
|
||||
placeholder-style="color:#bbb"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Save button -->
|
||||
<view class="save-wrap">
|
||||
<view
|
||||
class="save-btn"
|
||||
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty }"
|
||||
@tap="handleSave"
|
||||
>
|
||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, put } from '../../utils/request'
|
||||
import type { StudioConfig } from '@mp-pilates/shared'
|
||||
|
||||
// Form state
|
||||
const form = ref({
|
||||
name: '',
|
||||
address: '',
|
||||
phone: '',
|
||||
logo: '',
|
||||
bannerUrl: '',
|
||||
cancelHoursLimitStr: '2',
|
||||
latitudeStr: '',
|
||||
longitudeStr: '',
|
||||
})
|
||||
|
||||
const original = ref({ ...form.value })
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
|
||||
const isDirty = computed(() =>
|
||||
JSON.stringify(form.value) !== JSON.stringify(original.value),
|
||||
)
|
||||
|
||||
const bannerStyle = computed(() => {
|
||||
if (form.value.bannerUrl) {
|
||||
return `background-image: url(${form.value.bannerUrl}); background-size: cover; background-position: center;`
|
||||
}
|
||||
return 'background: linear-gradient(135deg, #1a1a2e, #2d2d5e);'
|
||||
})
|
||||
|
||||
async function fetchStudioInfo() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await get<StudioConfig>('/studio/info')
|
||||
const initial = {
|
||||
name: data.name ?? '',
|
||||
address: data.address ?? '',
|
||||
phone: data.phone ?? '',
|
||||
logo: data.logo ?? '',
|
||||
bannerUrl: data.bannerUrl ?? '',
|
||||
cancelHoursLimitStr: String(data.cancelHoursLimit ?? 2),
|
||||
latitudeStr: data.latitude != null ? String(data.latitude) : '',
|
||||
longitudeStr: data.longitude != null ? String(data.longitude) : '',
|
||||
}
|
||||
form.value = { ...initial }
|
||||
original.value = { ...initial }
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!isDirty.value || saving.value) return
|
||||
|
||||
const cancelHoursLimit = parseInt(form.value.cancelHoursLimitStr, 10)
|
||||
if (isNaN(cancelHoursLimit) || cancelHoursLimit < 0) {
|
||||
uni.showToast({ title: '取消限制小时数无效', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.value.name.trim() || undefined,
|
||||
address: form.value.address.trim() || undefined,
|
||||
phone: form.value.phone.trim() || undefined,
|
||||
logo: form.value.logo.trim() || undefined,
|
||||
bannerUrl: form.value.bannerUrl.trim() || undefined,
|
||||
cancelHoursLimit,
|
||||
}
|
||||
|
||||
const lat = parseFloat(form.value.latitudeStr)
|
||||
const lng = parseFloat(form.value.longitudeStr)
|
||||
if (!isNaN(lat)) payload.latitude = lat
|
||||
if (!isNaN(lng)) payload.longitude = lng
|
||||
|
||||
await put('/admin/studio/info', payload)
|
||||
original.value = { ...form.value }
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchStudioInfo)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
padding-bottom: 60rpx;
|
||||
}
|
||||
|
||||
/* ── Skeleton ────────────────────────────── */
|
||||
.skeleton-page {
|
||||
padding: 0 24rpx;
|
||||
padding-top: 280rpx;
|
||||
}
|
||||
|
||||
.skeleton-section {
|
||||
height: 200rpx;
|
||||
border-radius: 20rpx;
|
||||
margin-bottom: 24rpx;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Banner preview ──────────────────────── */
|
||||
.banner-preview {
|
||||
height: 260rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.35);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.banner-logo-wrap {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
border: 4rpx solid rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.banner-logo {
|
||||
width: 96rpx;
|
||||
height: 96rpx;
|
||||
}
|
||||
|
||||
.banner-logo-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #c9a87c;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.banner-logo-text {
|
||||
font-size: 40rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.banner-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
/* ── Form card ───────────────────────────── */
|
||||
.form-card {
|
||||
background: #ffffff;
|
||||
border-radius: 20rpx;
|
||||
margin: 24rpx 24rpx 0;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.form-card-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: #999;
|
||||
display: block;
|
||||
padding: 24rpx 28rpx 0;
|
||||
letter-spacing: 1rpx;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 28rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
|
||||
&--last {
|
||||
border-bottom: none;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
width: 180rpx;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-label-sub {
|
||||
font-size: 20rpx;
|
||||
color: #bbb;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.label-group {
|
||||
width: 240rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
text-align: right;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.form-input--short {
|
||||
width: 100rpx;
|
||||
flex: none;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Save button ─────────────────────────── */
|
||||
.save-wrap {
|
||||
padding: 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
border-radius: 48rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
|
||||
|
||||
&:active { opacity: 0.85; }
|
||||
|
||||
&--loading,
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.save-btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,624 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>排课设置 - 待实现</text>
|
||||
<!-- Top toolbar -->
|
||||
<view class="toolbar">
|
||||
<text class="toolbar-hint">共 {{ templates.length }} 条模板</text>
|
||||
<view class="add-btn" @tap="openAdd">
|
||||
<text class="add-btn-text">+ 新增</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading" class="skeleton-list">
|
||||
<view v-for="i in 4" :key="i" class="skeleton-item" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="!templates.length" class="empty-state">
|
||||
<text class="empty-icon">📅</text>
|
||||
<text class="empty-text">暂无排课模板,点击右上角新增</text>
|
||||
</view>
|
||||
|
||||
<!-- Template list grouped by weekday -->
|
||||
<template v-else>
|
||||
<view v-for="day in weekDays" :key="day.value" class="day-group">
|
||||
<view class="day-header">
|
||||
<text class="day-label">{{ day.label }}</text>
|
||||
<text class="day-count">{{ dayTemplates(day.value).length }} 节</text>
|
||||
</view>
|
||||
<view v-if="!dayTemplates(day.value).length" class="day-empty">
|
||||
<text class="day-empty-text">该天无课</text>
|
||||
</view>
|
||||
<view
|
||||
v-for="tpl in dayTemplates(day.value)"
|
||||
:key="tpl.id"
|
||||
class="tpl-card"
|
||||
:class="{ 'tpl-card--inactive': !tpl.isActive }"
|
||||
>
|
||||
<view class="tpl-main">
|
||||
<view class="tpl-time-block">
|
||||
<text class="tpl-time">{{ tpl.startTime.slice(0, 5) }}–{{ tpl.endTime.slice(0, 5) }}</text>
|
||||
<view class="tpl-status-dot" :class="tpl.isActive ? 'dot--active' : 'dot--inactive'" />
|
||||
</view>
|
||||
<view class="tpl-meta">
|
||||
<text class="tpl-capacity">容量 {{ tpl.capacity }} 人</text>
|
||||
<text class="tpl-active-label">{{ tpl.isActive ? '启用中' : '已停用' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="tpl-actions">
|
||||
<view class="action-btn edit-btn" @tap="openEdit(tpl)">
|
||||
<text class="action-btn-text">编辑</text>
|
||||
</view>
|
||||
<view
|
||||
class="action-btn toggle-btn"
|
||||
:class="tpl.isActive ? 'toggle-btn--off' : 'toggle-btn--on'"
|
||||
@tap="toggleActive(tpl)"
|
||||
>
|
||||
<text class="action-btn-text">{{ tpl.isActive ? '停用' : '启用' }}</text>
|
||||
</view>
|
||||
<view class="action-btn delete-btn" @tap="confirmDelete(tpl)">
|
||||
<text class="action-btn-text">删除</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- Save all button -->
|
||||
<view v-if="dirty" class="save-bar">
|
||||
<view class="save-bar-btn" :class="{ 'save-bar-btn--loading': saving }" @tap="saveAll">
|
||||
<text class="save-bar-text">{{ saving ? '保存中...' : '保存全部更改' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Add / Edit modal -->
|
||||
<view v-if="showModal" class="modal-mask" @tap.self="closeModal">
|
||||
<view class="modal">
|
||||
<text class="modal-title">{{ editTarget ? '编辑模板' : '新增模板' }}</text>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">星期</text>
|
||||
<picker mode="selector" :range="weekDays" range-key="label" :value="form.dayOfWeek" @change="onDayChange">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ weekDays[form.dayOfWeek].label }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">开始时间</text>
|
||||
<picker mode="time" :value="form.startTime" @change="(e: any) => form.startTime = e.detail.value">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ form.startTime }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">结束时间</text>
|
||||
<picker mode="time" :value="form.endTime" @change="(e: any) => form.endTime = e.detail.value">
|
||||
<view class="picker-display">
|
||||
<text class="picker-text">{{ form.endTime }}</text>
|
||||
<text class="picker-arrow">›</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="modal-field">
|
||||
<text class="modal-label">容量(人)</text>
|
||||
<input
|
||||
class="modal-input"
|
||||
type="number"
|
||||
v-model="form.capacityStr"
|
||||
placeholder="如:10"
|
||||
placeholder-style="color:#bbb"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="modal-actions">
|
||||
<view class="modal-cancel" @tap="closeModal">
|
||||
<text class="modal-cancel-text">取消</text>
|
||||
</view>
|
||||
<view class="modal-confirm" :class="{ 'modal-confirm--loading': submitting }" @tap="submitForm">
|
||||
<text class="modal-confirm-text">{{ submitting ? '保存中...' : '确认' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { get, put } from '../../utils/request'
|
||||
import type { WeekTemplate, WeekTemplateInput } from '@mp-pilates/shared'
|
||||
|
||||
const templates = ref<WeekTemplate[]>([])
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const dirty = ref(false)
|
||||
const showModal = ref(false)
|
||||
const submitting = ref(false)
|
||||
const editTarget = ref<WeekTemplate | null>(null)
|
||||
|
||||
const weekDays = [
|
||||
{ label: '周一', value: 1 },
|
||||
{ label: '周二', value: 2 },
|
||||
{ label: '周三', value: 3 },
|
||||
{ label: '周四', value: 4 },
|
||||
{ label: '周五', value: 5 },
|
||||
{ label: '周六', value: 6 },
|
||||
{ label: '周日', value: 0 },
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
dayOfWeek: 0,
|
||||
startTime: '09:00',
|
||||
endTime: '10:00',
|
||||
capacityStr: '10',
|
||||
})
|
||||
|
||||
function dayTemplates(dayVal: number) {
|
||||
return templates.value.filter((t) => t.dayOfWeek === dayVal)
|
||||
}
|
||||
|
||||
async function fetchTemplates() {
|
||||
loading.value = true
|
||||
try {
|
||||
const data = await get<WeekTemplate[]>('/admin/week-template')
|
||||
templates.value = data
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
editTarget.value = null
|
||||
form.value = { dayOfWeek: 0, startTime: '09:00', endTime: '10:00', capacityStr: '10' }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function openEdit(tpl: WeekTemplate) {
|
||||
editTarget.value = tpl
|
||||
form.value = {
|
||||
dayOfWeek: weekDays.findIndex((d) => d.value === tpl.dayOfWeek),
|
||||
startTime: tpl.startTime.slice(0, 5),
|
||||
endTime: tpl.endTime.slice(0, 5),
|
||||
capacityStr: String(tpl.capacity),
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
function closeModal() {
|
||||
showModal.value = false
|
||||
editTarget.value = null
|
||||
}
|
||||
|
||||
function onDayChange(e: any) {
|
||||
form.value.dayOfWeek = Number(e.detail.value)
|
||||
}
|
||||
|
||||
async function submitForm() {
|
||||
const capacity = parseInt(form.value.capacityStr, 10)
|
||||
if (isNaN(capacity) || capacity < 1) {
|
||||
uni.showToast({ title: '请输入有效容量', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const dayVal = weekDays[form.value.dayOfWeek].value
|
||||
|
||||
if (editTarget.value) {
|
||||
// Update in local list
|
||||
const idx = templates.value.findIndex((t) => t.id === editTarget.value!.id)
|
||||
if (idx !== -1) {
|
||||
templates.value[idx] = {
|
||||
...templates.value[idx],
|
||||
dayOfWeek: dayVal,
|
||||
startTime: form.value.startTime,
|
||||
endTime: form.value.endTime,
|
||||
capacity,
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Add locally with a temp id
|
||||
templates.value.push({
|
||||
id: `tmp_${Date.now()}`,
|
||||
dayOfWeek: dayVal,
|
||||
startTime: form.value.startTime,
|
||||
endTime: form.value.endTime,
|
||||
capacity,
|
||||
isActive: true,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
} as unknown as WeekTemplate)
|
||||
}
|
||||
|
||||
dirty.value = true
|
||||
closeModal()
|
||||
}
|
||||
|
||||
function toggleActive(tpl: WeekTemplate) {
|
||||
const idx = templates.value.findIndex((t) => t.id === tpl.id)
|
||||
if (idx !== -1) {
|
||||
templates.value[idx] = { ...templates.value[idx], isActive: !templates.value[idx].isActive }
|
||||
dirty.value = true
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(tpl: WeekTemplate) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: `删除 ${weekDays.find((d) => d.value === tpl.dayOfWeek)?.label} ${tpl.startTime.slice(0, 5)} 的模板?`,
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
templates.value = templates.value.filter((t) => t.id !== tpl.id)
|
||||
dirty.value = true
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function saveAll() {
|
||||
if (saving.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const payload: WeekTemplateInput[] = templates.value.map((t) => ({
|
||||
dayOfWeek: t.dayOfWeek,
|
||||
startTime: t.startTime,
|
||||
endTime: t.endTime,
|
||||
capacity: t.capacity,
|
||||
isActive: t.isActive,
|
||||
}))
|
||||
await put('/admin/week-template', { templates: payload })
|
||||
dirty.value = false
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
await fetchTemplates()
|
||||
} catch {
|
||||
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTemplates)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
padding-bottom: 160rpx;
|
||||
}
|
||||
|
||||
/* ── Toolbar ────────────────────────────── */
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24rpx 24rpx 16rpx;
|
||||
}
|
||||
|
||||
.toolbar-hint {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.add-btn {
|
||||
background: #1a1a2e;
|
||||
border-radius: 32rpx;
|
||||
padding: 12rpx 32rpx;
|
||||
}
|
||||
|
||||
.add-btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
/* ── Skeleton ───────────────────────────── */
|
||||
.skeleton-list {
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-item {
|
||||
height: 120rpx;
|
||||
border-radius: 12rpx;
|
||||
margin-bottom: 16rpx;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Empty ──────────────────────────────── */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 120rpx 0;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* ── Day group ──────────────────────────── */
|
||||
.day-group {
|
||||
margin: 0 24rpx 24rpx;
|
||||
}
|
||||
|
||||
.day-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16rpx 0 12rpx;
|
||||
}
|
||||
|
||||
.day-label {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.day-count {
|
||||
font-size: 22rpx;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.day-empty {
|
||||
padding: 20rpx 0;
|
||||
}
|
||||
|
||||
.day-empty-text {
|
||||
font-size: 24rpx;
|
||||
color: #ccc;
|
||||
}
|
||||
|
||||
/* ── Template card ──────────────────────── */
|
||||
.tpl-card {
|
||||
background: #ffffff;
|
||||
border-radius: 12rpx;
|
||||
padding: 24rpx;
|
||||
margin-bottom: 12rpx;
|
||||
box-shadow: 0 2rpx 10rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&--inactive {
|
||||
opacity: 0.55;
|
||||
}
|
||||
}
|
||||
|
||||
.tpl-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.tpl-time-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.tpl-time {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
}
|
||||
|
||||
.tpl-status-dot {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.dot--active { background: #27ae60; }
|
||||
.dot--inactive { background: #ccc; }
|
||||
|
||||
.tpl-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.tpl-capacity {
|
||||
font-size: 24rpx;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.tpl-active-label {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.tpl-actions {
|
||||
display: flex;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
padding: 12rpx 0;
|
||||
border-radius: 8rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-btn-text {
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-btn {
|
||||
background: #f0f0f0;
|
||||
.action-btn-text { color: #1a1a2e; }
|
||||
}
|
||||
|
||||
.toggle-btn--off {
|
||||
background: #fff3cd;
|
||||
.action-btn-text { color: #a07000; }
|
||||
}
|
||||
|
||||
.toggle-btn--on {
|
||||
background: #d4edda;
|
||||
.action-btn-text { color: #155724; }
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
background: #fde8e8;
|
||||
.action-btn-text { color: #c0392b; }
|
||||
}
|
||||
|
||||
/* ── Save bar ───────────────────────────── */
|
||||
.save-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 24rpx;
|
||||
background: #ffffff;
|
||||
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.save-bar-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.save-bar-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
/* ── Modal ──────────────────────────────── */
|
||||
.modal-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 40rpx 32rpx 60rpx;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
display: block;
|
||||
margin-bottom: 32rpx;
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 28rpx 0;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
}
|
||||
|
||||
.modal-label {
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
width: 160rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.picker-display {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.picker-text {
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
font-size: 28rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.modal-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 40rpx;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
background: #f0f0f0;
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.modal-cancel-text {
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
}
|
||||
|
||||
.modal-confirm {
|
||||
flex: 2;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
border-radius: 44rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.modal-confirm-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,310 @@
|
||||
<template>
|
||||
<view class="booking-page">
|
||||
<view class="placeholder">
|
||||
<text>预约课程 - 待实现</text>
|
||||
<!-- ──────────── Sticky header area ──────────── -->
|
||||
<view class="sticky-header">
|
||||
<!-- Date selector -->
|
||||
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
||||
|
||||
<!-- Time period filter -->
|
||||
<TimePeriodFilter v-model="selectedPeriod" @change="onPeriodChange" />
|
||||
</view>
|
||||
|
||||
<!-- ──────────── Slot list ──────────── -->
|
||||
<scroll-view
|
||||
class="slot-scroll"
|
||||
scroll-y
|
||||
:style="{ height: scrollHeight }"
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="bookingStore.loadingSlots && !refreshing" class="loading-wrap">
|
||||
<view v-for="i in 4" :key="i" class="skeleton-card" />
|
||||
</view>
|
||||
|
||||
<!-- Empty state -->
|
||||
<view v-else-if="filteredSlots.length === 0" class="empty-wrap">
|
||||
<image class="empty-img" src="/static/images/empty-calendar.png" mode="aspectFit" />
|
||||
<text class="empty-text">当日暂无可约时段</text>
|
||||
<text class="empty-sub">请选择其他日期或时段</text>
|
||||
</view>
|
||||
|
||||
<!-- Slot cards -->
|
||||
<view v-else class="slot-list">
|
||||
<SlotCard
|
||||
v-for="slot in filteredSlots"
|
||||
:key="slot.id"
|
||||
:slot="slot"
|
||||
@book="onBookTap"
|
||||
@cancel="onCancelTap"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Bottom padding spacer -->
|
||||
<view class="scroll-bottom-spacer" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- ──────────── Confirm popup ──────────── -->
|
||||
<BookingConfirmPopup
|
||||
:visible="showConfirmPopup"
|
||||
:slot="pendingSlot"
|
||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||
@confirm="onConfirmBooking"
|
||||
@cancel="showConfirmPopup = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||
import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { formatDate, getDateRange } from '../../utils/format'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
import TimePeriodFilter from '../../components/TimePeriodFilter.vue'
|
||||
import SlotCard from '../../components/SlotCard.vue'
|
||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||
|
||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||
|
||||
// ─── Stores ───────────────────────────────────────────────
|
||||
const bookingStore = useBookingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── State ────────────────────────────────────────────────
|
||||
const selectedDate = ref<string>(formatDate(new Date()))
|
||||
const selectedPeriod = ref<PeriodKey>(null)
|
||||
const showConfirmPopup = ref(false)
|
||||
const pendingSlot = ref<TimeSlotWithBookingStatus | null>(null)
|
||||
const refreshing = ref(false)
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────
|
||||
// Approximate scroll area height (vh minus sticky header ~220rpx + tabbar ~100rpx)
|
||||
const scrollHeight = computed(() => {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
const headerPx = 220 * (sysInfo.windowWidth / 750)
|
||||
const tabbarPx = 100 * (sysInfo.windowWidth / 750)
|
||||
return `${sysInfo.windowHeight - headerPx - tabbarPx}px`
|
||||
})
|
||||
|
||||
// ─── Filtered slots ───────────────────────────────────────
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||
if (!selectedPeriod.value) return [...slots]
|
||||
|
||||
const period = TIME_PERIODS[selectedPeriod.value]
|
||||
return slots.filter((slot) => {
|
||||
const t = slot.startTime
|
||||
return t >= period.start && t < period.end
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────
|
||||
async function loadSlots(date: string) {
|
||||
await bookingStore.fetchSlots(date)
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
refreshing.value = true
|
||||
await loadSlots(selectedDate.value)
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
// ─── Event handlers ───────────────────────────────────────
|
||||
function onDateSelect(date: string) {
|
||||
selectedDate.value = date
|
||||
loadSlots(date)
|
||||
}
|
||||
|
||||
function onPeriodChange(_period: PeriodKey) {
|
||||
// Filtering is done client-side via computed property
|
||||
}
|
||||
|
||||
// ─── Book flow ────────────────────────────────────────────
|
||||
async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
||||
// 1. Ensure logged in
|
||||
if (!userStore.loggedIn) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录后再预约课程',
|
||||
confirmText: '去登录',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await userStore.login()
|
||||
await userStore.fetchMemberships()
|
||||
// Retry booking flow after login
|
||||
onBookTap(slot)
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 2. Ensure has valid membership
|
||||
if (!userStore.hasValidMembership) {
|
||||
uni.showModal({
|
||||
title: '暂无可用会员卡',
|
||||
content: '您当前没有有效的会员卡,购买后即可预约课程',
|
||||
confirmText: '去购买',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/store/index' })
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// 3. Show confirm popup
|
||||
pendingSlot.value = slot
|
||||
showConfirmPopup.value = true
|
||||
}
|
||||
|
||||
async function onConfirmBooking(payload: { timeSlotId: string; membershipId: string }) {
|
||||
showConfirmPopup.value = false
|
||||
|
||||
uni.showLoading({ title: '预约中...' })
|
||||
try {
|
||||
await bookingStore.createBooking(payload)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '预约成功!', icon: 'success' })
|
||||
// Refresh slots to reflect new booking status
|
||||
await loadSlots(selectedDate.value)
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const message = err instanceof Error ? err.message : '预约失败,请重试'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
async function onCancelTap(slot: TimeSlotWithBookingStatus) {
|
||||
if (!slot.myBookingId) return
|
||||
|
||||
uni.showModal({
|
||||
title: '取消预约',
|
||||
content: '确定要取消这个预约吗?',
|
||||
confirmText: '确定取消',
|
||||
confirmColor: '#ef4444',
|
||||
cancelText: '再想想',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showLoading({ title: '取消中...' })
|
||||
try {
|
||||
await bookingStore.cancelBooking(slot.myBookingId!)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||||
await loadSlots(selectedDate.value)
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const message = err instanceof Error ? err.message : '取消失败,请重试'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
// Load memberships if logged in but not yet fetched
|
||||
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
|
||||
await userStore.fetchMemberships()
|
||||
}
|
||||
// Load today's slots
|
||||
await loadSlots(selectedDate.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.booking-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
.placeholder {
|
||||
background: #f5f3f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Sticky header ─────────────────────────────────── */
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
/* ── Scroll container ──────────────────────────────── */
|
||||
.slot-scroll {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* ── Slot list ─────────────────────────────────────── */
|
||||
.slot-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 24rpx 0;
|
||||
}
|
||||
|
||||
/* ── Loading skeleton ──────────────────────────────── */
|
||||
.loading-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 140rpx;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* ── Empty state ───────────────────────────────────── */
|
||||
.empty-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400rpx;
|
||||
color: #999;
|
||||
padding: 120rpx 40rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.empty-img {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 30rpx;
|
||||
color: #666;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* ── Bottom spacer ─────────────────────────────────── */
|
||||
.scroll-bottom-spacer {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,561 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>购买会员卡 - 待实现</text>
|
||||
<view class="card-detail-page">
|
||||
<!-- Loading state -->
|
||||
<view v-if="loading" class="loading-wrap">
|
||||
<view class="skeleton-header" />
|
||||
<view class="skeleton-body">
|
||||
<view class="skeleton-line w80" />
|
||||
<view class="skeleton-line w60" />
|
||||
<view class="skeleton-line w40" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Error state -->
|
||||
<view v-else-if="!card" class="error-wrap">
|
||||
<text class="error-icon">😕</text>
|
||||
<text class="error-text">会员卡信息加载失败</text>
|
||||
<view class="retry-btn" @tap="loadCard">
|
||||
<text class="retry-text">点击重试</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Card content -->
|
||||
<template v-else>
|
||||
<!-- Hero section -->
|
||||
<view class="card-hero" :class="heroClass">
|
||||
<view class="hero-badge">
|
||||
<text class="hero-badge-text">{{ typeLabel }}</text>
|
||||
</view>
|
||||
<text class="hero-name">{{ card.name }}</text>
|
||||
<view class="hero-price-row">
|
||||
<text class="hero-price">¥{{ formatPrice(card.price) }}</text>
|
||||
<text
|
||||
v-if="card.originalPrice && card.originalPrice > card.price"
|
||||
class="hero-original"
|
||||
>
|
||||
¥{{ formatPrice(card.originalPrice) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Detail cards -->
|
||||
<view class="detail-section">
|
||||
<!-- Key info grid -->
|
||||
<view class="info-card">
|
||||
<view class="info-grid">
|
||||
<view class="info-cell" v-if="card.totalTimes">
|
||||
<text class="cell-value">{{ card.totalTimes }}</text>
|
||||
<text class="cell-label">课时次数</text>
|
||||
</view>
|
||||
<view class="info-cell">
|
||||
<text class="cell-value">{{ card.durationDays }}</text>
|
||||
<text class="cell-label">有效天数</text>
|
||||
</view>
|
||||
<view class="info-cell">
|
||||
<text class="cell-value">{{ unitPrice }}</text>
|
||||
<text class="cell-label">{{ card.totalTimes ? '每次单价' : '按天均价' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Description -->
|
||||
<view v-if="card.description" class="desc-card">
|
||||
<text class="desc-title">课程说明</text>
|
||||
<text class="desc-content">{{ card.description }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Features list -->
|
||||
<view class="features-card">
|
||||
<text class="features-title">购买须知</text>
|
||||
<view class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">购买后立即生效,有效期 {{ card.durationDays }} 天</text>
|
||||
</view>
|
||||
<view v-if="card.totalTimes" class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">共 {{ card.totalTimes }} 次课时,可灵活安排</text>
|
||||
</view>
|
||||
<view class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">每次预约扣除 1 次课时</text>
|
||||
</view>
|
||||
<view class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">到期或课时用完后自动失效</text>
|
||||
</view>
|
||||
<view class="feature-item">
|
||||
<text class="feature-dot">•</text>
|
||||
<text class="feature-text">支持微信支付,安全便捷</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Bottom action bar -->
|
||||
<view class="bottom-bar">
|
||||
<view class="price-summary">
|
||||
<text class="summary-label">实付金额</text>
|
||||
<text class="summary-price">¥{{ formatPrice(card.price) }}</text>
|
||||
</view>
|
||||
<view
|
||||
class="buy-btn"
|
||||
:class="{ 'buy-btn--loading': buying }"
|
||||
@tap="handleBuy"
|
||||
>
|
||||
<text class="buy-btn-text">{{ buying ? '支付中...' : '立即购买' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
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 { useUserStore } from '../../stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Route param ──────────────────────────────────────────
|
||||
const cardId = ref<string>('')
|
||||
|
||||
// ─── State ────────────────────────────────────────────────
|
||||
const card = ref<CardType | null>(null)
|
||||
const loading = ref(false)
|
||||
const buying = ref(false)
|
||||
|
||||
// ─── Computed ─────────────────────────────────────────────
|
||||
const typeLabel = computed(() => {
|
||||
if (!card.value) return ''
|
||||
const map: Record<CardTypeCategory, string> = {
|
||||
[CardTypeCategory.TIMES]: '次卡',
|
||||
[CardTypeCategory.DURATION]: '月卡',
|
||||
[CardTypeCategory.TRIAL]: '体验卡',
|
||||
}
|
||||
return map[card.value.type] ?? '会员卡'
|
||||
})
|
||||
|
||||
const heroClass = computed(() => {
|
||||
if (!card.value) return ''
|
||||
if (card.value.type === CardTypeCategory.TRIAL) return 'hero--trial'
|
||||
if (card.value.type === CardTypeCategory.DURATION) return 'hero--duration'
|
||||
return 'hero--times'
|
||||
})
|
||||
|
||||
const unitPrice = computed(() => {
|
||||
if (!card.value) return '-'
|
||||
if (card.value.totalTimes) {
|
||||
const price = card.value.price / card.value.totalTimes
|
||||
return `¥${(price / 100).toFixed(0)}`
|
||||
}
|
||||
const price = card.value.price / card.value.durationDays
|
||||
return `¥${(price / 100).toFixed(0)}`
|
||||
})
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────
|
||||
async function loadCard() {
|
||||
if (!cardId.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const types = await get<CardType[]>('/membership/card-types')
|
||||
card.value = types.find((c) => c.id === cardId.value) ?? null
|
||||
} catch {
|
||||
card.value = null
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Buy flow ─────────────────────────────────────────────
|
||||
async function handleBuy() {
|
||||
if (buying.value || !card.value) return
|
||||
|
||||
// Ensure logged in
|
||||
if (!userStore.loggedIn) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录后再购买会员卡',
|
||||
confirmText: '去登录',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
await userStore.login()
|
||||
handleBuy()
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
uni.showModal({
|
||||
title: '确认购买',
|
||||
content: `确认购买「${card.value.name}」,实付 ¥${formatPrice(card.value.price)}?`,
|
||||
confirmText: '确认支付',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
await doPurchase()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function doPurchase() {
|
||||
if (!card.value) return
|
||||
buying.value = true
|
||||
uni.showLoading({ title: '创建订单...' })
|
||||
|
||||
try {
|
||||
const result = await post<CreateOrderResponse>('/payment/create-order', {
|
||||
cardTypeId: card.value.id,
|
||||
})
|
||||
|
||||
uni.hideLoading()
|
||||
|
||||
// Launch WeChat Pay
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
timeStamp: result.paymentParams.timeStamp,
|
||||
nonceStr: result.paymentParams.nonceStr,
|
||||
package: result.paymentParams.package,
|
||||
signType: result.paymentParams.signType as 'MD5' | 'HMAC-SHA256',
|
||||
paySign: result.paymentParams.paySign,
|
||||
success: () => resolve(),
|
||||
fail: (err: { errMsg?: string }) => reject(new Error(err.errMsg ?? '支付取消')),
|
||||
})
|
||||
})
|
||||
|
||||
// Payment succeeded
|
||||
uni.showToast({ title: '购买成功!', icon: 'success' })
|
||||
// Refresh memberships in background
|
||||
await userStore.fetchMemberships()
|
||||
// Navigate back after a moment
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
}, 1500)
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const msg = err instanceof Error ? err.message : '支付失败,请重试'
|
||||
if (!msg.includes('取消') && !msg.includes('cancel')) {
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
} finally {
|
||||
buying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
// Get id from page options
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
const options = (current as { options?: Record<string, string> }).options ?? {}
|
||||
cardId.value = options.id ?? ''
|
||||
loadCard()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.card-detail-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
/* ── Loading ─────────────────────────────────────────── */
|
||||
.loading-wrap {
|
||||
padding: 0 0 32rpx;
|
||||
}
|
||||
|
||||
.skeleton-header {
|
||||
height: 360rpx;
|
||||
background: linear-gradient(90deg, #e8e8e8 25%, #d8d8d8 50%, #e8e8e8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
padding: 32rpx 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-line {
|
||||
height: 28rpx;
|
||||
border-radius: 14rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
|
||||
&.w80 { width: 80%; }
|
||||
&.w60 { width: 60%; }
|
||||
&.w40 { width: 40%; }
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* ── Error ───────────────────────────────────────────── */
|
||||
.error-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 40rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
|
||||
.error-text {
|
||||
font-size: 30rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
padding: 20rpx 48rpx;
|
||||
border-radius: 40rpx;
|
||||
background: #c9a87c;
|
||||
}
|
||||
|
||||
.retry-text {
|
||||
font-size: 28rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── Hero ────────────────────────────────────────────── */
|
||||
.card-hero {
|
||||
padding: 60rpx 32rpx 52rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
|
||||
&.hero--times {
|
||||
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
|
||||
}
|
||||
|
||||
&.hero--duration {
|
||||
background: linear-gradient(135deg, #6c3483 0%, #9b59b6 100%);
|
||||
}
|
||||
|
||||
&.hero--trial {
|
||||
background: linear-gradient(135deg, #7d6608 0%, #c9a87c 100%);
|
||||
}
|
||||
}
|
||||
|
||||
.hero-badge {
|
||||
align-self: flex-start;
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
background: rgba(255, 255, 255, 0.18);
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
|
||||
.hero-badge-text {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
.hero-price-row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.hero-price {
|
||||
font-size: 56rpx;
|
||||
font-weight: 800;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.hero-original {
|
||||
font-size: 28rpx;
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
/* ── Detail section ──────────────────────────────────── */
|
||||
.detail-section {
|
||||
padding: 24rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
/* ── Info grid card ──────────────────────────────────── */
|
||||
.info-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 32rpx 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-around;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-cell {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
flex: 1;
|
||||
position: relative;
|
||||
|
||||
& + & {
|
||||
border-left: 1rpx solid #f0f0f0;
|
||||
}
|
||||
}
|
||||
|
||||
.cell-value {
|
||||
font-size: 44rpx;
|
||||
font-weight: 800;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.cell-label {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
/* ── Description card ────────────────────────────────── */
|
||||
.desc-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.desc-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.desc-content {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ── Features card ───────────────────────────────────── */
|
||||
.features-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
padding: 28rpx 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.features-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.feature-item {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.feature-dot {
|
||||
font-size: 26rpx;
|
||||
color: #c9a87c;
|
||||
line-height: 1.6;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.feature-text {
|
||||
font-size: 26rpx;
|
||||
color: #555;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* ── Bottom action bar ───────────────────────────────── */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: #fff;
|
||||
border-top: 1rpx solid #f0ece8;
|
||||
padding: 20rpx 32rpx calc(20rpx + env(safe-area-inset-bottom));
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 24rpx;
|
||||
box-shadow: 0 -4rpx 20rpx rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
.price-summary {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.summary-price {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #c9a87c;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
border-radius: 44rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&--loading {
|
||||
opacity: 0.6;
|
||||
}
|
||||
}
|
||||
|
||||
.buy-btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,108 @@
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<view class="placeholder">
|
||||
<text>首页 - 待实现</text>
|
||||
<!-- Pull-to-refresh wrapper -->
|
||||
<scroll-view
|
||||
class="page-scroll"
|
||||
scroll-y
|
||||
:refresher-enabled="true"
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="handleRefresh"
|
||||
@refresherrestore="refreshing = false"
|
||||
>
|
||||
<!-- ① Brand Banner (custom nav) -->
|
||||
<BrandBanner :studio-info="studioStore.studioInfo" />
|
||||
|
||||
<!-- ② Studio Info (swiper + address + phone) -->
|
||||
<StudioInfo :studio-info="studioStore.studioInfo" />
|
||||
|
||||
<!-- ③ Quick Entry (login / trial / book / renew) -->
|
||||
<QuickEntry @scroll-to-card-shop="scrollToCardShop" />
|
||||
|
||||
<!-- ④ Upcoming Bookings -->
|
||||
<UpcomingBooking />
|
||||
|
||||
<!-- ⑤ Card Shop (horizontal scroll) -->
|
||||
<view :id="cardShopAnchorId">
|
||||
<CardShop ref="cardShopRef" />
|
||||
</view>
|
||||
|
||||
<!-- Bottom padding for tab bar -->
|
||||
<view class="bottom-padding" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import BrandBanner from '../../components/BrandBanner.vue'
|
||||
import StudioInfo from '../../components/StudioInfo.vue'
|
||||
import QuickEntry from '../../components/QuickEntry.vue'
|
||||
import UpcomingBooking from '../../components/UpcomingBooking.vue'
|
||||
import CardShop from '../../components/CardShop.vue'
|
||||
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { useStudioStore } from '../../stores/studio'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const studioStore = useStudioStore()
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
const refreshing = ref(false)
|
||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||
const cardShopAnchorId = 'card-shop-anchor'
|
||||
|
||||
// Refresh all data on every show
|
||||
onShow(async () => {
|
||||
await refreshData()
|
||||
})
|
||||
|
||||
async function refreshData() {
|
||||
const tasks: Promise<unknown>[] = [studioStore.fetchStudioInfo()]
|
||||
|
||||
if (userStore.loggedIn) {
|
||||
tasks.push(
|
||||
userStore.fetchMemberships(),
|
||||
bookingStore.fetchUpcomingBookings(),
|
||||
)
|
||||
}
|
||||
|
||||
await Promise.allSettled(tasks)
|
||||
|
||||
// Also refresh card shop
|
||||
cardShopRef.value?.fetchCardTypes()
|
||||
}
|
||||
|
||||
async function handleRefresh() {
|
||||
refreshing.value = true
|
||||
try {
|
||||
await refreshData()
|
||||
} finally {
|
||||
refreshing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scrollToCardShop() {
|
||||
uni.pageScrollTo({
|
||||
selector: `#${cardShopAnchorId}`,
|
||||
duration: 300,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400rpx;
|
||||
color: #999;
|
||||
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
.bottom-padding {
|
||||
height: 120rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,443 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>我的预约 - 待实现</text>
|
||||
<view class="bookings-page">
|
||||
<!-- Tab filter -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === tab.key }"
|
||||
@tap="selectTab(tab.key)"
|
||||
>
|
||||
<text class="tab-label">{{ tab.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Content -->
|
||||
<scroll-view
|
||||
class="scroll"
|
||||
scroll-y
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Loading -->
|
||||
<view v-if="bookingStore.loadingBookings && !refreshing" class="loading-wrap">
|
||||
<view v-for="i in 3" :key="i" class="skeleton-card" />
|
||||
</view>
|
||||
|
||||
<!-- Empty -->
|
||||
<view v-else-if="filteredBookings.length === 0" class="empty-wrap">
|
||||
<text class="empty-icon">📅</text>
|
||||
<text class="empty-title">暂无预约记录</text>
|
||||
<text class="empty-sub">去预约一节课吧</text>
|
||||
<view class="empty-btn" @tap="goBooking">
|
||||
<text class="empty-btn-text">去预约</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Booking list -->
|
||||
<view v-else class="list">
|
||||
<view
|
||||
v-for="booking in filteredBookings"
|
||||
:key="booking.id"
|
||||
class="booking-card"
|
||||
>
|
||||
<!-- Date header stripe -->
|
||||
<view class="booking-stripe" :class="stripeClass(booking.status)" />
|
||||
|
||||
<!-- Card content -->
|
||||
<view class="booking-content">
|
||||
<view class="booking-main">
|
||||
<!-- Date + time -->
|
||||
<view class="booking-datetime">
|
||||
<text class="booking-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
||||
<text class="booking-time">
|
||||
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Status badge -->
|
||||
<view class="status-badge" :class="statusBadgeClass(booking.status)">
|
||||
<text class="status-text">{{ statusLabel(booking.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Membership used -->
|
||||
<view class="booking-meta">
|
||||
<text class="meta-label">💳 {{ booking.membership.cardType.name }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Cancel button for confirmed upcoming bookings -->
|
||||
<view
|
||||
v-if="booking.status === BookingStatus.CONFIRMED && isUpcoming(booking.timeSlot.date)"
|
||||
class="cancel-row"
|
||||
>
|
||||
<view class="cancel-btn" @tap="handleCancel(booking)">
|
||||
<text class="cancel-text">取消预约</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scroll-bottom-spacer" />
|
||||
</scroll-view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { BookingWithDetails } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { formatDate } from '../../utils/format'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
// ─── Tab state ────────────────────────────────────────────
|
||||
type TabKey = 'upcoming' | 'all'
|
||||
|
||||
const tabs = [
|
||||
{ key: 'upcoming' as TabKey, label: '即将上课' },
|
||||
{ key: 'all' as TabKey, label: '全部记录' },
|
||||
]
|
||||
|
||||
const activeTab = ref<TabKey>('upcoming')
|
||||
const refreshing = ref(false)
|
||||
|
||||
// ─── Filtered bookings ────────────────────────────────────
|
||||
const filteredBookings = computed<BookingWithDetails[]>(() => {
|
||||
const all = bookingStore.myBookings as BookingWithDetails[]
|
||||
if (activeTab.value === 'upcoming') {
|
||||
const today = formatDate(new Date())
|
||||
return all.filter(
|
||||
(b) => b.status === BookingStatus.CONFIRMED && b.timeSlot.date >= today,
|
||||
).sort((a, b) => a.timeSlot.date.localeCompare(b.timeSlot.date))
|
||||
}
|
||||
return [...all].sort((a, b) => {
|
||||
// Most recent first
|
||||
if (b.timeSlot.date !== a.timeSlot.date) {
|
||||
return b.timeSlot.date.localeCompare(a.timeSlot.date)
|
||||
}
|
||||
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
function isUpcoming(date: string): boolean {
|
||||
return date >= formatDate(new Date())
|
||||
}
|
||||
|
||||
function statusLabel(status: BookingStatus): string {
|
||||
const map: Record<BookingStatus, string> = {
|
||||
[BookingStatus.CONFIRMED]: '已预约',
|
||||
[BookingStatus.CANCELLED]: '已取消',
|
||||
[BookingStatus.COMPLETED]: '已完成',
|
||||
[BookingStatus.NO_SHOW]: '未出席',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: BookingStatus): string {
|
||||
const map: Record<BookingStatus, string> = {
|
||||
[BookingStatus.CONFIRMED]: 'badge--confirmed',
|
||||
[BookingStatus.CANCELLED]: 'badge--cancelled',
|
||||
[BookingStatus.COMPLETED]: 'badge--completed',
|
||||
[BookingStatus.NO_SHOW]: 'badge--noshow',
|
||||
}
|
||||
return map[status] ?? ''
|
||||
}
|
||||
|
||||
function stripeClass(status: BookingStatus): string {
|
||||
const map: Record<BookingStatus, string> = {
|
||||
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
|
||||
[BookingStatus.CANCELLED]: 'stripe--cancelled',
|
||||
[BookingStatus.COMPLETED]: 'stripe--completed',
|
||||
[BookingStatus.NO_SHOW]: 'stripe--noshow',
|
||||
}
|
||||
return map[status] ?? ''
|
||||
}
|
||||
|
||||
function formatDateDisplay(dateStr: string): string {
|
||||
// e.g. "2024-03-15" → "3月15日 周五"
|
||||
const d = new Date(dateStr)
|
||||
const month = d.getMonth() + 1
|
||||
const day = d.getDate()
|
||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
const weekday = weekdays[d.getDay()]
|
||||
return `${month}月${day}日 ${weekday}`
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────
|
||||
function selectTab(key: TabKey) {
|
||||
activeTab.value = key
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
refreshing.value = true
|
||||
await bookingStore.fetchMyBookings()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
function goBooking() {
|
||||
uni.switchTab({ url: '/pages/booking/index' })
|
||||
}
|
||||
|
||||
async function handleCancel(booking: BookingWithDetails) {
|
||||
uni.showModal({
|
||||
title: '取消预约',
|
||||
content: `确定要取消 ${formatDateDisplay(booking.timeSlot.date)} ${booking.timeSlot.startTime.slice(0, 5)} 的课程吗?`,
|
||||
confirmText: '确定取消',
|
||||
confirmColor: '#ef4444',
|
||||
cancelText: '再想想',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showLoading({ title: '取消中...' })
|
||||
try {
|
||||
await bookingStore.cancelBooking(booking.id)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||||
await bookingStore.fetchMyBookings()
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const msg = err instanceof Error ? err.message : '取消失败,请重试'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(() => bookingStore.fetchMyBookings())
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.bookings-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ── Tab bar ─────────────────────────────────────────── */
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid #f0ece8;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 28rpx 0;
|
||||
position: relative;
|
||||
|
||||
&.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tab-label {
|
||||
font-size: 28rpx;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Scroll ──────────────────────────────────────────── */
|
||||
.scroll {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Loading ─────────────────────────────────────────── */
|
||||
.loading-wrap {
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 160rpx;
|
||||
border-radius: 16rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────────────────── */
|
||||
.empty-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
margin-top: 12rpx;
|
||||
padding: 20rpx 56rpx;
|
||||
border-radius: 44rpx;
|
||||
background: #c9a87c;
|
||||
}
|
||||
|
||||
.empty-btn-text {
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── List ────────────────────────────────────────────── */
|
||||
.list {
|
||||
padding: 24rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
/* ── Booking card ────────────────────────────────────── */
|
||||
.booking-card {
|
||||
background: #fff;
|
||||
border-radius: 16rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.booking-stripe {
|
||||
width: 8rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
&--confirmed { background: #c9a87c; }
|
||||
&--completed { background: #4caf50; }
|
||||
&--cancelled { background: #e0e0e0; }
|
||||
&--noshow { background: #ef4444; }
|
||||
}
|
||||
|
||||
.booking-content {
|
||||
flex: 1;
|
||||
padding: 24rpx 24rpx 24rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.booking-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.booking-datetime {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.booking-date {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.booking-time {
|
||||
font-size: 24rpx;
|
||||
color: #888;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
padding: 8rpx 18rpx;
|
||||
border-radius: 20rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.badge--confirmed { background: #fff8ee; }
|
||||
&.badge--completed { background: #f0faf3; }
|
||||
&.badge--cancelled { background: #f5f5f5; }
|
||||
&.badge--noshow { background: #fef0f0; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 600;
|
||||
|
||||
.badge--confirmed & { color: #c9a87c; }
|
||||
.badge--completed & { color: #4caf50; }
|
||||
.badge--cancelled & { color: #bbb; }
|
||||
.badge--noshow & { color: #ef4444; }
|
||||
}
|
||||
|
||||
.booking-meta {
|
||||
.meta-label {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Cancel row ──────────────────────────────────────── */
|
||||
.cancel-row {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
padding: 8rpx 24rpx;
|
||||
}
|
||||
|
||||
.cancel-text {
|
||||
font-size: 24rpx;
|
||||
color: #ef4444;
|
||||
text-decoration: underline;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ── Spacer ──────────────────────────────────────────── */
|
||||
.scroll-bottom-spacer {
|
||||
height: 48rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,23 +1,105 @@
|
||||
<template>
|
||||
<view class="profile-page">
|
||||
<view class="placeholder">
|
||||
<text>我的 - 待实现</text>
|
||||
<!-- User card: always visible -->
|
||||
<UserCard
|
||||
:logged-in="loggedIn"
|
||||
:user="user"
|
||||
:loading="loginLoading"
|
||||
@login="handleLogin"
|
||||
/>
|
||||
|
||||
<!-- Logged-in content -->
|
||||
<template v-if="loggedIn">
|
||||
<!-- Training stats: overlaps bottom of UserCard -->
|
||||
<TrainingStats :stats="stats" />
|
||||
|
||||
<!-- Menu section -->
|
||||
<ProfileMenu :is-admin="isAdmin" />
|
||||
|
||||
<!-- Logout button -->
|
||||
<view class="profile-page__logout-wrap">
|
||||
<button class="profile-page__logout-btn" @tap="handleLogout">退出登录</button>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import UserCard from '../../components/UserCard.vue'
|
||||
import TrainingStats from '../../components/TrainingStats.vue'
|
||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const { loggedIn, user, stats, isAdmin } = userStore
|
||||
|
||||
const loginLoading = ref(false)
|
||||
|
||||
onShow(async () => {
|
||||
if (loggedIn) {
|
||||
await Promise.all([userStore.fetchProfile(), userStore.fetchStats()])
|
||||
}
|
||||
})
|
||||
|
||||
async function handleLogin() {
|
||||
if (loginLoading.value) return
|
||||
loginLoading.value = true
|
||||
try {
|
||||
await userStore.login()
|
||||
// After login, fetch stats immediately
|
||||
await Promise.all([userStore.fetchProfile(), userStore.fetchStats()])
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
loginLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleLogout() {
|
||||
uni.showModal({
|
||||
title: '退出登录',
|
||||
content: '确定要退出登录吗?',
|
||||
confirmText: '退出',
|
||||
confirmColor: '#ff4d4f',
|
||||
success(res) {
|
||||
if (res.confirm) {
|
||||
userStore.logout()
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
|
||||
// Content area below the dark header card
|
||||
// UserCard has its own dark bg, content sits on $bg-page
|
||||
|
||||
&__logout-wrap {
|
||||
margin: $spacing-xl $spacing-lg $spacing-lg;
|
||||
}
|
||||
|
||||
&__logout-btn {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
line-height: 88rpx;
|
||||
background: $bg-card;
|
||||
color: $error-color;
|
||||
font-size: 30rpx;
|
||||
font-weight: 500;
|
||||
border: none;
|
||||
border-radius: $radius-lg;
|
||||
text-align: center;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
}
|
||||
.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 400rpx;
|
||||
color: #999;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,348 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>个人信息 - 待实现</text>
|
||||
<view class="info-page">
|
||||
<!-- Avatar section -->
|
||||
<view class="avatar-section">
|
||||
<view class="avatar-wrap" @tap="chooseAvatar">
|
||||
<image
|
||||
v-if="form.avatarUrl"
|
||||
class="avatar"
|
||||
:src="form.avatarUrl"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
<view v-else class="avatar-placeholder">
|
||||
<text class="avatar-placeholder-text">{{ nicknameInitial }}</text>
|
||||
</view>
|
||||
<view class="avatar-edit-badge">
|
||||
<text class="avatar-edit-icon">📷</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="avatar-hint">点击更换头像</text>
|
||||
</view>
|
||||
|
||||
<!-- Form -->
|
||||
<view class="form-card">
|
||||
<!-- Nickname -->
|
||||
<view class="form-row">
|
||||
<text class="form-label">昵称</text>
|
||||
<input
|
||||
class="form-input"
|
||||
v-model="form.nickname"
|
||||
placeholder="请输入昵称"
|
||||
placeholder-style="color: #bbb"
|
||||
maxlength="20"
|
||||
:disabled="saving"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<!-- Phone (read-only) -->
|
||||
<view class="form-row form-row--readonly">
|
||||
<text class="form-label">手机号</text>
|
||||
<text class="form-value">{{ phoneDisplay }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Member since (read-only) -->
|
||||
<view class="form-row form-row--readonly">
|
||||
<text class="form-label">注册时间</text>
|
||||
<text class="form-value">{{ joinDateDisplay }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Save button -->
|
||||
<view class="save-wrap">
|
||||
<view
|
||||
class="save-btn"
|
||||
:class="{ 'save-btn--loading': saving, 'save-btn--disabled': !isDirty }"
|
||||
@tap="handleSave"
|
||||
>
|
||||
<text class="save-btn-text">{{ saving ? '保存中...' : '保存修改' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Form state ───────────────────────────────────────────
|
||||
const form = ref({
|
||||
nickname: '',
|
||||
avatarUrl: '',
|
||||
})
|
||||
|
||||
const originalForm = ref({
|
||||
nickname: '',
|
||||
avatarUrl: '',
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
// ─── Computed ─────────────────────────────────────────────
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
form.value.nickname !== originalForm.value.nickname ||
|
||||
form.value.avatarUrl !== originalForm.value.avatarUrl,
|
||||
)
|
||||
|
||||
const nicknameInitial = computed(() => {
|
||||
const nick = form.value.nickname || '?'
|
||||
return nick.slice(0, 1).toUpperCase()
|
||||
})
|
||||
|
||||
const phoneDisplay = computed(() => {
|
||||
const phone = userStore.user?.phone
|
||||
if (!phone) return '未绑定'
|
||||
// Mask middle digits: 138****1234
|
||||
return phone.slice(0, 3) + '****' + phone.slice(-4)
|
||||
})
|
||||
|
||||
const joinDateDisplay = computed(() => {
|
||||
const createdAt = userStore.user?.createdAt
|
||||
if (!createdAt) return '-'
|
||||
const d = new Date(createdAt)
|
||||
return `${d.getFullYear()}年${d.getMonth() + 1}月${d.getDate()}日`
|
||||
})
|
||||
|
||||
// ─── Avatar picker ────────────────────────────────────────
|
||||
function chooseAvatar() {
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
sizeType: ['compressed'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => {
|
||||
const tempPath = res.tempFilePaths[0]
|
||||
// Upload to server
|
||||
uploadAvatar(tempPath)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function uploadAvatar(tempPath: string) {
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
const token = uni.getStorageSync('token') as string
|
||||
|
||||
uni.uploadFile({
|
||||
url: 'http://localhost:3000/api/user/avatar',
|
||||
filePath: tempPath,
|
||||
name: 'file',
|
||||
header: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
success: (res) => {
|
||||
uni.hideLoading()
|
||||
try {
|
||||
interface UploadResponse {
|
||||
success: boolean
|
||||
data: { url: string }
|
||||
}
|
||||
const result = JSON.parse(res.data) as UploadResponse
|
||||
if (result.success && result.data?.url) {
|
||||
form.value = { ...form.value, avatarUrl: result.data.url }
|
||||
} else {
|
||||
throw new Error('上传失败')
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '头像上传失败', icon: 'none' })
|
||||
}
|
||||
},
|
||||
fail: () => {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '头像上传失败', icon: 'none' })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Save ─────────────────────────────────────────────────
|
||||
async function handleSave() {
|
||||
if (!isDirty.value || saving.value) return
|
||||
|
||||
const nickname = form.value.nickname.trim()
|
||||
if (!nickname) {
|
||||
uni.showToast({ title: '昵称不能为空', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await userStore.updateProfile({
|
||||
nickname,
|
||||
avatarUrl: form.value.avatarUrl || undefined,
|
||||
})
|
||||
// Update original to reflect saved state
|
||||
originalForm.value = { ...form.value }
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
} catch (err: unknown) {
|
||||
const msg = err instanceof Error ? err.message : '保存失败,请重试'
|
||||
uni.showToast({ title: msg, icon: 'none' })
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
// Ensure we have fresh profile data
|
||||
await userStore.fetchProfile()
|
||||
if (userStore.user) {
|
||||
const initial = {
|
||||
nickname: userStore.user.nickname,
|
||||
avatarUrl: userStore.user.avatarUrl ?? '',
|
||||
}
|
||||
form.value = { ...initial }
|
||||
originalForm.value = { ...initial }
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.info-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
}
|
||||
|
||||
/* ── Avatar section ──────────────────────────────────── */
|
||||
.avatar-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
padding: 60rpx 0 48rpx;
|
||||
background: #fff;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.avatar-wrap {
|
||||
position: relative;
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid #f0f0f0;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #c9a87c, #e8c88a);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-placeholder-text {
|
||||
font-size: 60rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.avatar-edit-badge {
|
||||
position: absolute;
|
||||
bottom: 4rpx;
|
||||
right: 4rpx;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.15);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-edit-icon {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.avatar-hint {
|
||||
margin-top: 16rpx;
|
||||
font-size: 24rpx;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
/* ── Form card ───────────────────────────────────────── */
|
||||
.form-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
margin: 0 24rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 32rpx 28rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
&--readonly {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.form-label {
|
||||
font-size: 28rpx;
|
||||
color: #555;
|
||||
width: 120rpx;
|
||||
flex-shrink: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-input {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #222;
|
||||
text-align: right;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.form-value {
|
||||
flex: 1;
|
||||
font-size: 28rpx;
|
||||
color: #888;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Save button ─────────────────────────────────────── */
|
||||
.save-wrap {
|
||||
padding: 40rpx 24rpx;
|
||||
}
|
||||
|
||||
.save-btn {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
border-radius: 48rpx;
|
||||
background: linear-gradient(90deg, #1a1a2e, #2d2d5e);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
box-shadow: 0 4rpx 20rpx rgba(26, 26, 46, 0.3);
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&--loading,
|
||||
&--disabled {
|
||||
opacity: 0.5;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.save-btn-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,437 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="placeholder">
|
||||
<text>我的会员卡 - 待实现</text>
|
||||
<view class="membership-page">
|
||||
<!-- Pull-to-refresh scroll view -->
|
||||
<scroll-view
|
||||
class="scroll"
|
||||
scroll-y
|
||||
refresher-enabled
|
||||
:refresher-triggered="refreshing"
|
||||
@refresherrefresh="onRefresh"
|
||||
>
|
||||
<!-- Loading skeleton -->
|
||||
<view v-if="loading && !refreshing" class="loading-wrap">
|
||||
<view v-for="i in 2" :key="i" class="skeleton-card" />
|
||||
</view>
|
||||
|
||||
<!-- Empty state -->
|
||||
<view v-else-if="memberships.length === 0" class="empty-wrap">
|
||||
<text class="empty-icon">💳</text>
|
||||
<text class="empty-title">暂无会员卡</text>
|
||||
<text class="empty-sub">购买会员卡后即可预约课程</text>
|
||||
<view class="empty-btn" @tap="goStore">
|
||||
<text class="empty-btn-text">去购买</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Membership list -->
|
||||
<view v-else class="list">
|
||||
<!-- Active cards -->
|
||||
<view v-if="activeMemberships.length > 0">
|
||||
<text class="group-title">有效会员卡</text>
|
||||
<view
|
||||
v-for="m in activeMemberships"
|
||||
:key="m.id"
|
||||
class="card-item card-item--active"
|
||||
>
|
||||
<view class="card-top" :class="cardTopClass(m)">
|
||||
<view>
|
||||
<text class="card-name">{{ m.cardType.name }}</text>
|
||||
<text class="card-type-tag">{{ typeLabel(m.cardType.type) }}</text>
|
||||
</view>
|
||||
<view class="card-badge card-badge--active">
|
||||
<text class="badge-text">有效</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<view class="info-row" v-if="m.remainingTimes !== null">
|
||||
<text class="info-label">剩余课时</text>
|
||||
<text class="info-value info-value--highlight">{{ m.remainingTimes }} 次</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">有效期至</text>
|
||||
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">开始日期</text>
|
||||
<text class="info-value">{{ m.startDate.slice(0, 10) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<!-- Progress bar for time-based cards -->
|
||||
<view v-if="m.remainingTimes !== null && m.cardType.totalTimes" class="progress-wrap">
|
||||
<view class="progress-bar">
|
||||
<view
|
||||
class="progress-fill"
|
||||
:style="{ width: progressWidth(m) }"
|
||||
/>
|
||||
</view>
|
||||
<text class="progress-label">
|
||||
已使用 {{ m.cardType.totalTimes - m.remainingTimes }}/{{ m.cardType.totalTimes }} 次
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Expired / used up cards -->
|
||||
<view v-if="inactiveMemberships.length > 0" class="inactive-section">
|
||||
<text class="group-title">历史记录</text>
|
||||
<view
|
||||
v-for="m in inactiveMemberships"
|
||||
:key="m.id"
|
||||
class="card-item card-item--inactive"
|
||||
>
|
||||
<view class="card-top card-top--inactive">
|
||||
<view>
|
||||
<text class="card-name card-name--dim">{{ m.cardType.name }}</text>
|
||||
<text class="card-type-tag card-type-tag--dim">{{ typeLabel(m.cardType.type) }}</text>
|
||||
</view>
|
||||
<view class="card-badge" :class="statusBadgeClass(m.status)">
|
||||
<text class="badge-text">{{ statusLabel(m.status) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<view class="info-row" v-if="m.remainingTimes !== null">
|
||||
<text class="info-label">剩余课时</text>
|
||||
<text class="info-value">{{ m.remainingTimes }} 次</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">有效期至</text>
|
||||
<text class="info-value">{{ m.expireDate.slice(0, 10) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="scroll-bottom-spacer" />
|
||||
</scroll-view>
|
||||
|
||||
<!-- Buy more FAB -->
|
||||
<view class="fab" @tap="goStore">
|
||||
<text class="fab-text">+ 购买会员卡</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { get } from '../../utils/request'
|
||||
|
||||
// ─── State ────────────────────────────────────────────────
|
||||
const memberships = ref<MembershipWithCardType[]>([])
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
|
||||
// ─── Computed ─────────────────────────────────────────────
|
||||
const activeMemberships = computed(() =>
|
||||
memberships.value.filter((m) => m.status === MembershipStatus.ACTIVE),
|
||||
)
|
||||
|
||||
const inactiveMemberships = computed(() =>
|
||||
memberships.value.filter((m) => m.status !== MembershipStatus.ACTIVE),
|
||||
)
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────
|
||||
function typeLabel(type: CardTypeCategory): string {
|
||||
const map: Record<CardTypeCategory, string> = {
|
||||
[CardTypeCategory.TIMES]: '次卡',
|
||||
[CardTypeCategory.DURATION]: '月卡',
|
||||
[CardTypeCategory.TRIAL]: '体验卡',
|
||||
}
|
||||
return map[type] ?? '会员卡'
|
||||
}
|
||||
|
||||
function statusLabel(status: MembershipStatus): string {
|
||||
const map: Record<MembershipStatus, string> = {
|
||||
[MembershipStatus.ACTIVE]: '有效',
|
||||
[MembershipStatus.EXPIRED]: '已过期',
|
||||
[MembershipStatus.USED_UP]: '已用完',
|
||||
}
|
||||
return map[status] ?? status
|
||||
}
|
||||
|
||||
function statusBadgeClass(status: MembershipStatus): string {
|
||||
if (status === MembershipStatus.EXPIRED) return 'card-badge--expired'
|
||||
if (status === MembershipStatus.USED_UP) return 'card-badge--used'
|
||||
return ''
|
||||
}
|
||||
|
||||
function cardTopClass(m: MembershipWithCardType): string {
|
||||
if (m.cardType.type === CardTypeCategory.TRIAL) return 'card-top--trial'
|
||||
if (m.cardType.type === CardTypeCategory.DURATION) return 'card-top--duration'
|
||||
return 'card-top--times'
|
||||
}
|
||||
|
||||
function progressWidth(m: MembershipWithCardType): string {
|
||||
if (m.remainingTimes === null || !m.cardType.totalTimes) return '0%'
|
||||
const pct = (m.remainingTimes / m.cardType.totalTimes) * 100
|
||||
return `${Math.max(0, Math.min(100, pct))}%`
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────
|
||||
async function loadMemberships() {
|
||||
loading.value = true
|
||||
try {
|
||||
memberships.value = await get<MembershipWithCardType[]>('/membership/my')
|
||||
} catch {
|
||||
uni.showToast({ title: '加载失败,请下拉刷新', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
refreshing.value = true
|
||||
await loadMemberships()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
function goStore() {
|
||||
uni.navigateBack({ delta: 10 })
|
||||
// Navigate to store tab
|
||||
uni.switchTab({ url: '/pages/home/index' })
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(loadMemberships)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page { min-height: 100vh; background: #f5f5f5; }
|
||||
.placeholder { display: flex; align-items: center; justify-content: center; height: 400rpx; color: #999; }
|
||||
.membership-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
}
|
||||
|
||||
.scroll {
|
||||
height: 100vh;
|
||||
}
|
||||
|
||||
/* ── Loading ─────────────────────────────────────────── */
|
||||
.loading-wrap {
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 200rpx;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 100% 0; }
|
||||
100% { background-position: -100% 0; }
|
||||
}
|
||||
|
||||
/* ── Empty ───────────────────────────────────────────── */
|
||||
.empty-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
gap: 20rpx;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
|
||||
.empty-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.empty-btn {
|
||||
margin-top: 12rpx;
|
||||
padding: 20rpx 56rpx;
|
||||
border-radius: 44rpx;
|
||||
background: #c9a87c;
|
||||
}
|
||||
|
||||
.empty-btn-text {
|
||||
font-size: 30rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── List ────────────────────────────────────────────── */
|
||||
.list {
|
||||
padding: 24rpx 24rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.group-title {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
padding: 8rpx 4rpx 12rpx;
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* ── Card item ───────────────────────────────────────── */
|
||||
.card-item {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
margin-bottom: 16rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
|
||||
&--inactive {
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
.card-top {
|
||||
padding: 24rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
|
||||
&--times { background: linear-gradient(90deg, #1a1a2e, #2d2d5e); }
|
||||
&--duration { background: linear-gradient(90deg, #6c3483, #9b59b6); }
|
||||
&--trial { background: linear-gradient(90deg, #7d6608, #c9a87c); }
|
||||
&--inactive { background: #888; }
|
||||
}
|
||||
|
||||
.card-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #fff;
|
||||
display: block;
|
||||
margin-bottom: 6rpx;
|
||||
|
||||
&--dim { color: #ddd; }
|
||||
}
|
||||
|
||||
.card-type-tag {
|
||||
font-size: 20rpx;
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
font-weight: 400;
|
||||
display: block;
|
||||
|
||||
&--dim { color: rgba(255, 255, 255, 0.5); }
|
||||
}
|
||||
|
||||
.card-badge {
|
||||
padding: 8rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
border: 1rpx solid rgba(255, 255, 255, 0.4);
|
||||
|
||||
&--active { background: rgba(76, 175, 80, 0.25); }
|
||||
&--expired { background: rgba(0, 0, 0, 0.2); }
|
||||
&--used { background: rgba(0, 0, 0, 0.2); }
|
||||
}
|
||||
|
||||
.badge-text {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 20rpx 28rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 26rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
font-size: 26rpx;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
|
||||
&--highlight {
|
||||
color: #c9a87c;
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Progress bar ────────────────────────────────────── */
|
||||
.progress-wrap {
|
||||
padding: 0 28rpx 20rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 8rpx;
|
||||
background: #f0f0f0;
|
||||
border-radius: 4rpx;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #c9a87c, #e8c88a);
|
||||
border-radius: 4rpx;
|
||||
transition: width 0.4s ease;
|
||||
}
|
||||
|
||||
.progress-label {
|
||||
font-size: 22rpx;
|
||||
color: #bbb;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
/* ── Inactive section ────────────────────────────────── */
|
||||
.inactive-section {
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
/* ── FAB ─────────────────────────────────────────────── */
|
||||
.fab {
|
||||
position: fixed;
|
||||
bottom: calc(32rpx + env(safe-area-inset-bottom));
|
||||
right: 32rpx;
|
||||
background: #1a1a2e;
|
||||
border-radius: 44rpx;
|
||||
padding: 22rpx 36rpx;
|
||||
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.2);
|
||||
z-index: 100;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.fab-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: #c9a87c;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* ── Spacer ──────────────────────────────────────────── */
|
||||
.scroll-bottom-spacer {
|
||||
height: 100rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user