perf: 优化页面
This commit is contained in:
@@ -15,18 +15,18 @@
|
||||
<view class="info-section">
|
||||
<view class="info-row">
|
||||
<text class="info-label">日期</text>
|
||||
<text class="info-value">{{ slot?.date }}</text>
|
||||
<text class="info-value">{{ timeSlot?.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 class="info-value" v-if="timeSlot">
|
||||
{{ timeSlot.startTime.slice(0, 5) }} - {{ timeSlot.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 class="info-value" v-if="timeSlot">
|
||||
{{ timeSlot.capacity - timeSlot.bookedCount }} 个名额
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -123,7 +123,7 @@ import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pila
|
||||
|
||||
const props = defineProps<{
|
||||
visible: boolean
|
||||
slot: TimeSlotWithBookingStatus | null
|
||||
timeSlot: TimeSlotWithBookingStatus | null
|
||||
memberships: MembershipWithCardType[]
|
||||
}>()
|
||||
|
||||
@@ -151,9 +151,9 @@ const selectedMembership = computed(() =>
|
||||
)
|
||||
|
||||
function handleConfirm() {
|
||||
if (!props.slot || !selectedMembershipId.value) return
|
||||
if (!props.timeSlot || !selectedMembershipId.value) return
|
||||
emit('confirm', {
|
||||
timeSlotId: props.slot.id,
|
||||
timeSlotId: props.timeSlot.id,
|
||||
membershipId: selectedMembershipId.value,
|
||||
})
|
||||
}
|
||||
|
||||
112
packages/app/src/components/CustomNavBar.vue
Normal file
112
packages/app/src/components/CustomNavBar.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<view
|
||||
class="nav-bar"
|
||||
:class="{ 'nav-bar--transparent': transparent }"
|
||||
:style="{ paddingTop: statusBarHeight + 'px' }"
|
||||
>
|
||||
<view class="nav-bar__inner">
|
||||
<!-- Back button -->
|
||||
<view v-if="showBack" class="nav-bar__left" @tap="handleBack">
|
||||
<text class="nav-bar__back-icon">‹</text>
|
||||
</view>
|
||||
<view v-else class="nav-bar__left" />
|
||||
|
||||
<!-- Title -->
|
||||
<text class="nav-bar__title">{{ title }}</text>
|
||||
|
||||
<!-- Right placeholder (balances the back button) -->
|
||||
<view class="nav-bar__right" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
|
||||
defineProps<{
|
||||
title: string
|
||||
/** Transparent bg with white text — for pages with colored header */
|
||||
transparent?: boolean
|
||||
/** Show back arrow (for sub-pages navigated via navigateTo) */
|
||||
showBack?: boolean
|
||||
}>()
|
||||
|
||||
const statusBarHeight = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
statusBarHeight.value = sysInfo.statusBarHeight ?? 20
|
||||
})
|
||||
|
||||
function handleBack() {
|
||||
uni.navigateBack({ delta: 1 })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nav-bar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 101;
|
||||
background: #ffffff;
|
||||
|
||||
&--transparent {
|
||||
background: transparent;
|
||||
|
||||
.nav-bar__title {
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.nav-bar__back-icon {
|
||||
color: #ffffff;
|
||||
}
|
||||
}
|
||||
|
||||
&__inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 88rpx;
|
||||
padding: 0 24rpx;
|
||||
}
|
||||
|
||||
&__left,
|
||||
&__right {
|
||||
width: 72rpx;
|
||||
height: 88rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&__left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
&__right {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
&__back-icon {
|
||||
font-size: 52rpx;
|
||||
font-weight: 300;
|
||||
color: #1a1a2e;
|
||||
line-height: 1;
|
||||
margin-top: -4rpx;
|
||||
}
|
||||
|
||||
&__title {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
font-size: 34rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a2e;
|
||||
letter-spacing: 2rpx;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,53 +1,66 @@
|
||||
<template>
|
||||
<view class="slot-card">
|
||||
<!-- Time & capacity info -->
|
||||
<view class="slot-card" :class="{ 'slot-card--booked': timeSlot.isBookedByMe }">
|
||||
<!-- Booked accent bar -->
|
||||
<view v-if="timeSlot.isBookedByMe" class="booked-bar" />
|
||||
|
||||
<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>
|
||||
<!-- Left: Time column -->
|
||||
<view class="slot-time-col">
|
||||
<text class="slot-start">{{ timeSlot.startTime.slice(0, 5) }}</text>
|
||||
<view class="time-divider" />
|
||||
<text class="slot-end">{{ timeSlot.endTime.slice(0, 5) }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Center: Info -->
|
||||
<view class="slot-info">
|
||||
<view class="slot-title-row">
|
||||
<text class="slot-title">普拉提私教</text>
|
||||
<text class="slot-duration">{{ durationMin }}分钟</text>
|
||||
</view>
|
||||
<view class="slot-meta">
|
||||
<view class="slot-capacity" :class="capacityClass">
|
||||
<text class="capacity-dot" />
|
||||
<text class="capacity-text">{{ capacityLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Action area -->
|
||||
<!-- Right: Action -->
|
||||
<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>
|
||||
<!-- OPEN + not booked -->
|
||||
<template v-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
|
||||
<view class="btn btn-book" @tap.stop="emit('book', timeSlot)">
|
||||
<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">
|
||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
||||
<view class="booked-badge-col">
|
||||
<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 class="btn-cancel" @tap.stop="emit('cancel', timeSlot)">
|
||||
<text class="btn-cancel-text">取消预约</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- FULL -->
|
||||
<template v-else-if="slot.status === TimeSlotStatus.FULL">
|
||||
<view class="btn btn-disabled">
|
||||
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
|
||||
<view class="btn btn-full">
|
||||
<text class="btn-text">已约满</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- CLOSED -->
|
||||
<template v-else>
|
||||
<view class="btn btn-disabled">
|
||||
<view class="btn btn-closed">
|
||||
<text class="btn-text">已关闭</text>
|
||||
</view>
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Booked indicator bar -->
|
||||
<view v-if="slot.isBookedByMe" class="booked-bar" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -57,23 +70,31 @@ import { TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import { computed } from 'vue'
|
||||
|
||||
interface Props {
|
||||
slot: TimeSlotWithBookingStatus
|
||||
timeSlot: TimeSlotWithBookingStatus
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
book: [slot: TimeSlotWithBookingStatus]
|
||||
cancel: [slot: TimeSlotWithBookingStatus]
|
||||
book: [timeSlot: TimeSlotWithBookingStatus]
|
||||
cancel: [timeSlot: TimeSlotWithBookingStatus]
|
||||
}>()
|
||||
|
||||
const durationMin = computed(() => {
|
||||
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
|
||||
const [eh, em] = props.timeSlot.endTime.split(':').map(Number)
|
||||
return (eh * 60 + em) - (sh * 60 + sm)
|
||||
})
|
||||
|
||||
const capacityLabel = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.slot
|
||||
const { bookedCount, capacity, status } = props.timeSlot
|
||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||
return `${bookedCount}/${capacity} 人`
|
||||
if (status === TimeSlotStatus.FULL) return '已约满'
|
||||
const remaining = capacity - bookedCount
|
||||
return `剩余 ${remaining} 个名额`
|
||||
})
|
||||
|
||||
const capacityClass = computed(() => {
|
||||
const { bookedCount, capacity, status } = props.slot
|
||||
const { bookedCount, capacity, status } = props.timeSlot
|
||||
if (status === TimeSlotStatus.CLOSED) return 'cap-closed'
|
||||
if (status === TimeSlotStatus.FULL) return 'cap-full'
|
||||
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
||||
@@ -84,145 +105,218 @@ const capacityClass = computed(() => {
|
||||
<style lang="scss" scoped>
|
||||
.slot-card {
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
border-radius: 24rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.05);
|
||||
position: relative;
|
||||
transition: transform 0.15s, box-shadow 0.15s;
|
||||
|
||||
.booked-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 6rpx;
|
||||
background: #c9a87c;
|
||||
border-radius: 20rpx 0 0 20rpx;
|
||||
&:active {
|
||||
transform: scale(0.985);
|
||||
}
|
||||
|
||||
.slot-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
||||
gap: 20rpx;
|
||||
&--booked {
|
||||
background: #fffdf8;
|
||||
box-shadow: 0 4rpx 24rpx rgba(201, 168, 124, 0.12);
|
||||
}
|
||||
}
|
||||
|
||||
.booked-bar {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 8rpx;
|
||||
background: linear-gradient(180deg, #d4b896, #c9a87c);
|
||||
border-radius: 24rpx 0 0 24rpx;
|
||||
}
|
||||
|
||||
.slot-main {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
/* ── Time column ─── */
|
||||
.slot-time-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 80rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.slot-start {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.time-divider {
|
||||
width: 2rpx;
|
||||
height: 16rpx;
|
||||
background: #e0dcd6;
|
||||
margin: 6rpx 0;
|
||||
border-radius: 1rpx;
|
||||
}
|
||||
|
||||
.slot-end {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
/* ── Info ─── */
|
||||
.slot-info {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.slot-title-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: baseline;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.slot-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
|
||||
.slot-duration {
|
||||
font-size: 22rpx;
|
||||
color: #bbb;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.slot-meta {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.slot-capacity {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
|
||||
.capacity-dot {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.slot-time-block {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
.capacity-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.slot-time {
|
||||
font-size: 36rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
letter-spacing: 1rpx;
|
||||
&.cap-open {
|
||||
.capacity-dot { background: #4caf50; }
|
||||
.capacity-text { color: #4caf50; }
|
||||
}
|
||||
|
||||
.slot-capacity {
|
||||
display: inline-flex;
|
||||
align-self: flex-start;
|
||||
&.cap-almost {
|
||||
.capacity-dot { background: #f59e0b; }
|
||||
.capacity-text { color: #f59e0b; }
|
||||
}
|
||||
|
||||
.capacity-text {
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: 20rpx;
|
||||
}
|
||||
&.cap-full {
|
||||
.capacity-dot { background: #ef4444; }
|
||||
.capacity-text { color: #ef4444; }
|
||||
}
|
||||
|
||||
&.cap-open .capacity-text {
|
||||
background: #f0faf3;
|
||||
color: #4caf50;
|
||||
}
|
||||
&.cap-closed {
|
||||
.capacity-dot { background: #ccc; }
|
||||
.capacity-text { color: #999; }
|
||||
}
|
||||
}
|
||||
|
||||
&.cap-almost .capacity-text {
|
||||
background: #fff8ed;
|
||||
color: #f59e0b;
|
||||
}
|
||||
/* ── Action ─── */
|
||||
.slot-action {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
&.cap-full .capacity-text {
|
||||
background: #fef0f0;
|
||||
color: #ef4444;
|
||||
}
|
||||
.btn {
|
||||
min-width: 140rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 32rpx;
|
||||
|
||||
&.cap-closed .capacity-text {
|
||||
background: #f5f5f5;
|
||||
color: #999;
|
||||
.btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.btn-book {
|
||||
background: linear-gradient(135deg, #d4b896, #c9a87c);
|
||||
box-shadow: 0 4rpx 16rpx rgba(201, 168, 124, 0.3);
|
||||
|
||||
.btn-text { color: #fff; }
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
}
|
||||
}
|
||||
|
||||
.slot-action {
|
||||
flex-shrink: 0;
|
||||
&.btn-full {
|
||||
background: #fef0f0;
|
||||
|
||||
.btn-text { color: #ef4444; }
|
||||
}
|
||||
|
||||
.btn {
|
||||
min-width: 140rpx;
|
||||
height: 68rpx;
|
||||
border-radius: 34rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 28rpx;
|
||||
&.btn-closed {
|
||||
background: #f5f5f5;
|
||||
|
||||
.btn-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
&.btn-book {
|
||||
background: #c9a87c;
|
||||
|
||||
.btn-text {
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
&.btn-disabled {
|
||||
background: #f0f0f0;
|
||||
|
||||
.btn-text {
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
.btn-text { color: #bbb; }
|
||||
}
|
||||
}
|
||||
|
||||
.booked-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16rpx;
|
||||
.booked-badge-col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.badge-booked {
|
||||
height: 52rpx;
|
||||
padding: 0 24rpx;
|
||||
background: linear-gradient(135deg, #fff8ee, #fff4e0);
|
||||
border-radius: 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.badge-text {
|
||||
font-size: 24rpx;
|
||||
color: #c9a87c;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.badge-booked {
|
||||
height: 52rpx;
|
||||
padding: 0 20rpx;
|
||||
background: #fff8ee;
|
||||
border-radius: 26rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
.btn-cancel {
|
||||
padding: 4rpx 8rpx;
|
||||
display: flex;
|
||||
align-items: 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;
|
||||
}
|
||||
.btn-cancel-text {
|
||||
font-size: 22rpx;
|
||||
color: #ef4444;
|
||||
font-weight: 400;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="user-card">
|
||||
<!-- Header: gradient background -->
|
||||
<view class="user-card__header">
|
||||
<!-- Header: gradient background, padded to sit below nav bar -->
|
||||
<view class="user-card__header" :style="{ paddingTop: (navBarHeight ?? 0) + 'px' }">
|
||||
<!-- Not logged in state -->
|
||||
<view v-if="!loggedIn" class="user-card__guest">
|
||||
<view class="user-card__avatar-wrap">
|
||||
@@ -83,6 +83,8 @@ const props = defineProps<{
|
||||
stats: UserStatsResponse | null
|
||||
memberships?: readonly MembershipWithCardType[]
|
||||
loading?: boolean
|
||||
/** Height of the custom nav bar in px, so header content starts below it */
|
||||
navBarHeight?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -150,7 +152,7 @@ function handleLogin() {
|
||||
overflow: hidden;
|
||||
|
||||
&__header {
|
||||
padding: 60rpx $spacing-lg $spacing-lg;
|
||||
padding: $spacing-lg $spacing-lg $spacing-lg;
|
||||
}
|
||||
|
||||
// ── Guest state ──
|
||||
|
||||
@@ -3,92 +3,91 @@
|
||||
{
|
||||
"path": "pages/home/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "首页",
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/booking/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "预约课程"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/card/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "购买会员卡"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/membership",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的会员卡"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/bookings",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的预约"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/info",
|
||||
"style": {
|
||||
"navigationBarTitleText": "个人信息"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "管理中心"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/schedule",
|
||||
"style": {
|
||||
"navigationBarTitleText": "排课管理"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/week-template",
|
||||
"style": {
|
||||
"navigationBarTitleText": "排课模板"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/slot-adjust",
|
||||
"style": {
|
||||
"navigationBarTitleText": "时段调整"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/members",
|
||||
"style": {
|
||||
"navigationBarTitleText": "会员管理"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/orders",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订单管理"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/card-types",
|
||||
"style": {
|
||||
"navigationBarTitleText": "卡种管理"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/admin/studio",
|
||||
"style": {
|
||||
"navigationBarTitleText": "工作室设置"
|
||||
"navigationStyle": "custom"
|
||||
}
|
||||
}
|
||||
],
|
||||
@@ -102,7 +101,7 @@
|
||||
"color": "#999999",
|
||||
"selectedColor": "#1a1a2e",
|
||||
"backgroundColor": "#ffffff",
|
||||
"borderStyle": "black",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/home/index",
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- Add button -->
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="卡种管理" show-back />
|
||||
<!-- Toolbar -->
|
||||
<view class="toolbar">
|
||||
<text class="toolbar-hint">共 {{ cardTypes.length }} 个卡种</text>
|
||||
<view class="add-btn" @tap="openAdd">
|
||||
@@ -70,7 +71,7 @@
|
||||
<view
|
||||
class="ct-action-btn toggle-btn"
|
||||
:class="ct.isActive ? 'toggle-off' : 'toggle-on'"
|
||||
@tap.stop="toggleActive(ct)"
|
||||
@tap.stop="confirmToggle(ct)"
|
||||
>
|
||||
<text class="ct-action-text">{{ ct.isActive ? '下架' : '上架' }}</text>
|
||||
</view>
|
||||
@@ -81,123 +82,136 @@
|
||||
</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>
|
||||
<!-- ──────── Add / Edit modal ──────── -->
|
||||
<view v-if="showModal" class="modal-mask" @tap.stop="closeModal">
|
||||
<view class="modal-container" @tap.stop>
|
||||
<scroll-view scroll-y class="modal-scroll">
|
||||
<!-- Header -->
|
||||
<view class="modal-header">
|
||||
<text class="modal-title">{{ editTarget ? '编辑卡种' : '新增卡种' }}</text>
|
||||
<view class="modal-close" @tap="closeModal">
|
||||
<text class="modal-close-icon">✕</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>
|
||||
|
||||
<!-- Form fields -->
|
||||
<view class="modal-body">
|
||||
<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="onTypeChange"
|
||||
>
|
||||
<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>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- Action buttons -->
|
||||
<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>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { formatPrice } from '../../utils/format'
|
||||
import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
@@ -205,6 +219,12 @@ import type { CardType } from '@mp-pilates/shared'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
})
|
||||
|
||||
const cardTypes = ref<CardType[]>([])
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
@@ -217,7 +237,7 @@ const typeOptions = [
|
||||
{ label: '体验卡', value: CardTypeCategory.TRIAL },
|
||||
]
|
||||
|
||||
const form = ref({
|
||||
const defaultForm = () => ({
|
||||
name: '',
|
||||
typeIdx: 0,
|
||||
priceStr: '',
|
||||
@@ -228,6 +248,10 @@ const form = ref({
|
||||
description: '',
|
||||
})
|
||||
|
||||
const form = ref(defaultForm())
|
||||
|
||||
// ─── Data loading ────────────────────────────────────
|
||||
|
||||
async function fetchCardTypes() {
|
||||
loading.value = true
|
||||
try {
|
||||
@@ -239,18 +263,11 @@ async function fetchCardTypes() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modal open / close ──────────────────────────────
|
||||
|
||||
function openAdd() {
|
||||
editTarget.value = null
|
||||
form.value = {
|
||||
name: '',
|
||||
typeIdx: 0,
|
||||
priceStr: '',
|
||||
originalPriceStr: '',
|
||||
totalTimesStr: '',
|
||||
durationDaysStr: '90',
|
||||
sortOrderStr: '0',
|
||||
description: '',
|
||||
}
|
||||
form.value = defaultForm()
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -259,8 +276,8 @@ function openEdit(ct: CardType) {
|
||||
form.value = {
|
||||
name: ct.name,
|
||||
typeIdx: typeOptions.findIndex((t) => t.value === ct.type),
|
||||
priceStr: String(ct.price),
|
||||
originalPriceStr: ct.originalPrice ? String(ct.originalPrice) : '',
|
||||
priceStr: String(Number(ct.price) / 100),
|
||||
originalPriceStr: ct.originalPrice ? String(Number(ct.originalPrice) / 100) : '',
|
||||
totalTimesStr: ct.totalTimes ? String(ct.totalTimes) : '',
|
||||
durationDaysStr: String(ct.durationDays),
|
||||
sortOrderStr: String(ct.sortOrder),
|
||||
@@ -274,8 +291,16 @@ function closeModal() {
|
||||
editTarget.value = null
|
||||
}
|
||||
|
||||
function onTypeChange(e: { detail: { value: number } }) {
|
||||
form.value.typeIdx = Number(e.detail.value)
|
||||
}
|
||||
|
||||
// ─── Form submit ─────────────────────────────────────
|
||||
|
||||
async function submitForm() {
|
||||
if (submitting.value) return
|
||||
|
||||
// Validation
|
||||
if (!form.value.name.trim()) {
|
||||
uni.showToast({ title: '请填写卡种名称', icon: 'none' })
|
||||
return
|
||||
@@ -291,19 +316,35 @@ async function submitForm() {
|
||||
return
|
||||
}
|
||||
|
||||
const selectedType = typeOptions[form.value.typeIdx].value
|
||||
const totalTimes = form.value.totalTimesStr ? parseInt(form.value.totalTimesStr, 10) : null
|
||||
|
||||
// Times-based card must have totalTimes
|
||||
if (
|
||||
(selectedType === CardTypeCategory.TIMES || selectedType === CardTypeCategory.TRIAL) &&
|
||||
(!totalTimes || totalTimes < 1)
|
||||
) {
|
||||
uni.showToast({ title: '次卡/体验卡请填写次数', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
// Convert yuan → cents for storage
|
||||
const priceCents = Math.round(price * 100)
|
||||
|
||||
const payload: Record<string, unknown> = {
|
||||
name: form.value.name.trim(),
|
||||
type: typeOptions[form.value.typeIdx].value,
|
||||
price,
|
||||
type: selectedType,
|
||||
price: priceCents,
|
||||
durationDays,
|
||||
sortOrder: parseInt(form.value.sortOrderStr, 10) || 0,
|
||||
}
|
||||
|
||||
if (form.value.originalPriceStr) {
|
||||
payload.originalPrice = parseFloat(form.value.originalPriceStr)
|
||||
const originalPrice = parseFloat(form.value.originalPriceStr)
|
||||
payload.originalPrice = Math.round(originalPrice * 100)
|
||||
}
|
||||
if (form.value.totalTimesStr) {
|
||||
payload.totalTimes = parseInt(form.value.totalTimesStr, 10)
|
||||
if (totalTimes) {
|
||||
payload.totalTimes = totalTimes
|
||||
}
|
||||
if (form.value.description.trim()) {
|
||||
payload.description = form.value.description.trim()
|
||||
@@ -319,33 +360,70 @@ async function submitForm() {
|
||||
uni.showToast({ title: '保存成功', icon: 'success' })
|
||||
closeModal()
|
||||
await fetchCardTypes()
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message ?? '保存失败', icon: 'none' })
|
||||
} catch (e: unknown) {
|
||||
const message = e instanceof Error ? e.message : '保存失败'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleActive(ct: CardType) {
|
||||
try {
|
||||
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive })
|
||||
await fetchCardTypes()
|
||||
} catch {
|
||||
uni.showToast({ title: '操作失败', icon: 'none' })
|
||||
}
|
||||
// ─── Toggle active (上架 / 下架) ─────────────────────
|
||||
|
||||
function confirmToggle(ct: CardType) {
|
||||
const action = ct.isActive ? '下架' : '上架'
|
||||
const content = ct.isActive
|
||||
? `下架后用户将无法购买「${ct.name}」,已持有的会员卡不受影响。`
|
||||
: `上架后「${ct.name}」将重新对用户可见并可购买。`
|
||||
|
||||
uni.showModal({
|
||||
title: `确认${action}`,
|
||||
content,
|
||||
confirmText: action,
|
||||
confirmColor: ct.isActive ? '#e67e22' : '#27ae60',
|
||||
cancelText: '取消',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showLoading({ title: `${action}中...` })
|
||||
try {
|
||||
await adminStore.updateCardType(ct.id, { isActive: !ct.isActive } as any)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: `已${action}`, icon: 'success' })
|
||||
await fetchCardTypes()
|
||||
} catch {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: `${action}失败`, icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Delete ──────────────────────────────────────────
|
||||
|
||||
function confirmDelete(ct: CardType) {
|
||||
uni.showModal({
|
||||
title: '确认删除',
|
||||
content: `删除卡种「${ct.name}」?此操作不可恢复。`,
|
||||
content: `删除卡种「${ct.name}」?\n若有用户已购买此卡种,将自动下架而非删除。`,
|
||||
confirmText: '删除',
|
||||
confirmColor: '#c0392b',
|
||||
cancelText: '取消',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
uni.showLoading({ title: '删除中...' })
|
||||
try {
|
||||
await adminStore.deleteCardType(ct.id)
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
const result = await adminStore.deleteCardType(ct.id)
|
||||
uni.hideLoading()
|
||||
// result may contain { deleted, deactivated } from server
|
||||
const resultData = result as unknown as { deleted?: boolean; deactivated?: boolean }
|
||||
if (resultData?.deactivated) {
|
||||
uni.showToast({ title: '存在关联数据,已自动下架', icon: 'none', duration: 2500 })
|
||||
} else {
|
||||
uni.showToast({ title: '已删除', icon: 'success' })
|
||||
}
|
||||
await fetchCardTypes()
|
||||
} catch {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '删除失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
@@ -353,6 +431,8 @@ function confirmDelete(ct: CardType) {
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Helpers ─────────────────────────────────────────
|
||||
|
||||
function typeLabel(ct: CardType): string {
|
||||
const map: Record<CardTypeCategory, string> = {
|
||||
[CardTypeCategory.TIMES]: '次卡',
|
||||
@@ -368,6 +448,8 @@ function headerClass(ct: CardType): string {
|
||||
return 'header--times'
|
||||
}
|
||||
|
||||
// ─── Lifecycle ───────────────────────────────────────
|
||||
|
||||
onMounted(fetchCardTypes)
|
||||
</script>
|
||||
|
||||
@@ -403,7 +485,7 @@ onMounted(fetchCardTypes)
|
||||
height: 260rpx;
|
||||
border-radius: 16rpx;
|
||||
margin-bottom: 20rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
@@ -435,7 +517,7 @@ onMounted(fetchCardTypes)
|
||||
margin-bottom: 20rpx;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0,0,0,0.08);
|
||||
|
||||
&--inactive { opacity: 0.6; }
|
||||
&--inactive { opacity: 0.55; }
|
||||
}
|
||||
|
||||
.ct-header {
|
||||
@@ -510,6 +592,8 @@ onMounted(fetchCardTypes)
|
||||
border-right: 1rpx solid #f5f5f5;
|
||||
|
||||
&:last-child { border-right: none; }
|
||||
|
||||
&:active { background: #f9f9f9; }
|
||||
}
|
||||
|
||||
.ct-action-text { font-size: 26rpx; font-weight: 600; }
|
||||
@@ -526,23 +610,58 @@ onMounted(fetchCardTypes)
|
||||
background: rgba(0,0,0,0.5);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
z-index: 100;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal {
|
||||
.modal-container {
|
||||
width: 100%;
|
||||
max-height: 85vh;
|
||||
background: #ffffff;
|
||||
border-radius: 24rpx 24rpx 0 0;
|
||||
padding: 40rpx 32rpx 60rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-scroll {
|
||||
flex: 1;
|
||||
max-height: 85vh;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 32rpx 32rpx 16rpx;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: #ffffff;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
display: block;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
.modal-close {
|
||||
width: 56rpx;
|
||||
height: 56rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #f5f5f5;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.modal-close-icon {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 0 32rpx;
|
||||
}
|
||||
|
||||
.modal-field {
|
||||
@@ -575,7 +694,8 @@ onMounted(fetchCardTypes)
|
||||
.modal-actions {
|
||||
display: flex;
|
||||
gap: 16rpx;
|
||||
margin-top: 32rpx;
|
||||
padding: 24rpx 32rpx calc(24rpx + env(safe-area-inset-bottom));
|
||||
background: #ffffff;
|
||||
}
|
||||
|
||||
.modal-cancel {
|
||||
@@ -586,6 +706,8 @@ onMounted(fetchCardTypes)
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:active { background: #e8e8e8; }
|
||||
}
|
||||
|
||||
.modal-cancel-text { font-size: 28rpx; color: #555; }
|
||||
@@ -599,7 +721,8 @@ onMounted(fetchCardTypes)
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--loading { opacity: 0.6; }
|
||||
&:active { opacity: 0.85; }
|
||||
&--loading { opacity: 0.6; pointer-events: none; }
|
||||
}
|
||||
|
||||
.modal-confirm-text { font-size: 28rpx; font-weight: 700; color: #c9a87c; }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="管理中心" show-back />
|
||||
<!-- Stats row -->
|
||||
<view class="stats-row">
|
||||
<view v-if="statsLoading" class="stats-shimmer-wrap">
|
||||
@@ -40,9 +41,12 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import type { AdminStats } from '../../stores/admin'
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const statsLoading = ref(false)
|
||||
@@ -72,7 +76,11 @@ async function loadStats() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(loadStats)
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
loadStats()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="会员管理" show-back />
|
||||
<!-- Search bar -->
|
||||
<view class="filter-bar">
|
||||
<input
|
||||
@@ -104,11 +105,18 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import type { MemberSummary } from '../../stores/admin'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
})
|
||||
|
||||
const members = ref<MemberSummary[]>([])
|
||||
const loading = ref(false)
|
||||
const searchQuery = ref('')
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="订单管理" show-back />
|
||||
<!-- Status filter tabs -->
|
||||
<scroll-view scroll-x class="filter-scroll" :show-scrollbar="false">
|
||||
<view class="filter-row">
|
||||
@@ -77,6 +78,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { formatPrice, formatDate } from '../../utils/format'
|
||||
import { OrderStatus } from '@mp-pilates/shared'
|
||||
@@ -84,6 +86,12 @@ import type { OrderWithDetails } from '@mp-pilates/shared'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
})
|
||||
|
||||
const filters = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '已支付', value: OrderStatus.PAID },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="排课管理" show-back />
|
||||
<!-- Date selector -->
|
||||
<view class="sticky-header">
|
||||
<DateSelector v-model="selectedDate" @select="onDateSelect" />
|
||||
@@ -156,6 +157,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import type { ScheduleSlotPreview } from '@mp-pilates/shared'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import DateSelector from '../../components/DateSelector.vue'
|
||||
@@ -174,6 +176,7 @@ interface EditableSlot {
|
||||
}
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const selectedDate = ref(formatDate(new Date()))
|
||||
const loading = ref(false)
|
||||
const publishing = ref(false)
|
||||
@@ -405,7 +408,11 @@ function slotBadgeText(slot: EditableSlot): string {
|
||||
|
||||
// ── Lifecycle ─────────────────────────────────────────────
|
||||
|
||||
onMounted(() => loadPreview(selectedDate.value))
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
loadPreview(selectedDate.value)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="时段调整" show-back />
|
||||
<!-- Tabs -->
|
||||
<view class="tabs">
|
||||
<view
|
||||
@@ -138,13 +139,15 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { formatDate } from '../../utils/format'
|
||||
import type { TimeSlot } from '@mp-pilates/shared'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
const tabs = ['新增时段', '关闭时段', '批量生成']
|
||||
const activeTab = ref(0)
|
||||
const submitting = ref(false)
|
||||
@@ -242,6 +245,11 @@ async function submitGenerate() {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="工作室设置" show-back />
|
||||
<!-- Loading state -->
|
||||
<view v-if="loading" class="skeleton-page">
|
||||
<view class="skeleton-section" />
|
||||
@@ -150,10 +151,17 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
})
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
address: '',
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="排课模板" show-back />
|
||||
<!-- Toolbar -->
|
||||
<view class="toolbar">
|
||||
<text class="toolbar-hint">共 {{ templates.length }} 条模板</text>
|
||||
@@ -137,6 +138,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import { useAdminStore } from '../../stores/admin'
|
||||
import { WEEKDAY_LABELS } from '@mp-pilates/shared'
|
||||
import type { WeekTemplate } from '@mp-pilates/shared'
|
||||
@@ -151,6 +153,7 @@ type LocalTemplate = Partial<WeekTemplate> & {
|
||||
}
|
||||
|
||||
const adminStore = useAdminStore()
|
||||
const navBarHeight = ref('64px')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const isDirty = ref(false)
|
||||
@@ -318,7 +321,11 @@ async function handleSave() {
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchTemplates)
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
fetchTemplates()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="booking-page">
|
||||
<view class="booking-page" :style="pageStyle">
|
||||
<!-- ──────────── Custom nav bar ──────────── -->
|
||||
<CustomNavBar title="预约课程" />
|
||||
|
||||
<!-- ──────────── Sticky header area ──────────── -->
|
||||
<view class="sticky-header">
|
||||
<!-- Date selector -->
|
||||
@@ -13,29 +16,45 @@
|
||||
<scroll-view
|
||||
class="slot-scroll"
|
||||
scroll-y
|
||||
:style="{ height: scrollHeight }"
|
||||
:style="{ height: scrollHeight, paddingTop: stickyHeaderHeight }"
|
||||
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 v-for="i in 3" :key="i" class="skeleton-card">
|
||||
<view class="skeleton-time" />
|
||||
<view class="skeleton-body">
|
||||
<view class="skeleton-title" />
|
||||
<view class="skeleton-sub" />
|
||||
</view>
|
||||
<view class="skeleton-btn" />
|
||||
</view>
|
||||
</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" />
|
||||
<view class="empty-icon-circle">
|
||||
<text class="empty-icon-text">📅</text>
|
||||
</view>
|
||||
<text class="empty-text">当日暂无可约时段</text>
|
||||
<text class="empty-sub">请选择其他日期或时段</text>
|
||||
<text class="empty-sub">请选择其他日期或时段查看</text>
|
||||
</view>
|
||||
|
||||
<!-- Slot cards -->
|
||||
<view v-else class="slot-list">
|
||||
<!-- Date summary -->
|
||||
<view class="date-summary">
|
||||
<text class="date-summary-text">
|
||||
共 {{ filteredSlots.length }} 个可选时段
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<SlotCard
|
||||
v-for="slot in filteredSlots"
|
||||
:key="slot.id"
|
||||
:slot="slot"
|
||||
v-for="item in filteredSlots"
|
||||
:key="item.id"
|
||||
:time-slot="item"
|
||||
@book="onBookTap"
|
||||
@cancel="onCancelTap"
|
||||
/>
|
||||
@@ -48,7 +67,7 @@
|
||||
<!-- ──────────── Confirm popup ──────────── -->
|
||||
<BookingConfirmPopup
|
||||
:visible="showConfirmPopup"
|
||||
:slot="pendingSlot"
|
||||
:time-slot="pendingSlot"
|
||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||
@confirm="onConfirmBooking"
|
||||
@cancel="showConfirmPopup = false"
|
||||
@@ -62,11 +81,12 @@ import type { TimeSlotWithBookingStatus, MembershipWithCardType } from '@mp-pila
|
||||
import { TIME_PERIODS } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { formatDate, getDateRange } from '../../utils/format'
|
||||
import { formatDate } 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'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
type PeriodKey = keyof typeof TIME_PERIODS | null
|
||||
|
||||
@@ -82,13 +102,34 @@ 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(() => {
|
||||
// Default: statusBar ~20px + 88rpx ≈ 64px; avoid empty string on first render
|
||||
const navBarHeight = ref('64px')
|
||||
const scrollHeight = ref('500px')
|
||||
const stickyHeaderHeight = ref('240rpx')
|
||||
|
||||
function updateLayout() {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
const headerPx = 220 * (sysInfo.windowWidth / 750)
|
||||
const tabbarPx = 100 * (sysInfo.windowWidth / 750)
|
||||
return `${sysInfo.windowHeight - headerPx - tabbarPx}px`
|
||||
})
|
||||
const ratio = sysInfo.windowWidth / 750
|
||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||
const navTitlePx = 88 * ratio
|
||||
const navBarPx = Math.round(statusBarPx + navTitlePx)
|
||||
navBarHeight.value = `${navBarPx}px`
|
||||
|
||||
// Measure sticky header: DateSelector (~160rpx) + TimePeriodFilter (~76rpx) + borders
|
||||
const stickyPx = Math.round(240 * ratio)
|
||||
stickyHeaderHeight.value = `${stickyPx}px`
|
||||
|
||||
// scrollHeight: from below nav bar to above tabbar
|
||||
const tabbarPx = Math.round(100 * ratio)
|
||||
scrollHeight.value = `${sysInfo.windowHeight - navBarPx - tabbarPx}px`
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
|
||||
// CSS variable for sticky header offset
|
||||
const pageStyle = computed(() => ({
|
||||
'--nav-bar-height': navBarHeight.value,
|
||||
}))
|
||||
|
||||
// ─── Filtered slots ───────────────────────────────────────
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
@@ -226,24 +267,29 @@ onMounted(async () => {
|
||||
<style lang="scss" scoped>
|
||||
.booking-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f3f0;
|
||||
background: #f7f4f0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
--nav-bar-height: v-bind(navBarHeight);
|
||||
padding-top: var(--nav-bar-height);
|
||||
}
|
||||
|
||||
/* ── Sticky header ─────────────────────────────────── */
|
||||
.sticky-header {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
position: fixed;
|
||||
top: var(--nav-bar-height);
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 100;
|
||||
background: #fff;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.06);
|
||||
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
/* ── Scroll container ──────────────────────────────── */
|
||||
.slot-scroll {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* ── Slot list ─────────────────────────────────────── */
|
||||
@@ -251,7 +297,18 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20rpx;
|
||||
padding: 28rpx 24rpx 0;
|
||||
padding: 24rpx 24rpx 0;
|
||||
}
|
||||
|
||||
/* ── Date summary ──────────────────────────────────── */
|
||||
.date-summary {
|
||||
padding: 0 8rpx 4rpx;
|
||||
}
|
||||
|
||||
.date-summary-text {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Loading skeleton ──────────────────────────────── */
|
||||
@@ -264,10 +321,59 @@ onMounted(async () => {
|
||||
|
||||
.skeleton-card {
|
||||
height: 140rpx;
|
||||
border-radius: 20rpx;
|
||||
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||
border-radius: 24rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
padding: 32rpx 28rpx 32rpx 36rpx;
|
||||
gap: 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
.skeleton-time {
|
||||
width: 80rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 12rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.skeleton-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.skeleton-title {
|
||||
width: 60%;
|
||||
height: 28rpx;
|
||||
border-radius: 8rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-sub {
|
||||
width: 40%;
|
||||
height: 20rpx;
|
||||
border-radius: 6rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
}
|
||||
|
||||
.skeleton-btn {
|
||||
width: 140rpx;
|
||||
height: 72rpx;
|
||||
border-radius: 36rpx;
|
||||
background: linear-gradient(90deg, #f0ece8 25%, #e8e4df 50%, #f0ece8 75%);
|
||||
background-size: 400% 100%;
|
||||
animation: shimmer 1.4s infinite;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
@@ -281,15 +387,23 @@ onMounted(async () => {
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx 40rpx;
|
||||
padding: 140rpx 40rpx;
|
||||
gap: 16rpx;
|
||||
}
|
||||
|
||||
.empty-img {
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
opacity: 0.5;
|
||||
margin-bottom: 8rpx;
|
||||
.empty-icon-circle {
|
||||
width: 140rpx;
|
||||
height: 140rpx;
|
||||
border-radius: 50%;
|
||||
background: #f0ece8;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-bottom: 16rpx;
|
||||
}
|
||||
|
||||
.empty-icon-text {
|
||||
font-size: 56rpx;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="card-detail-page">
|
||||
<view class="card-detail-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="购买会员卡" show-back />
|
||||
<!-- Loading state -->
|
||||
<view v-if="loading" class="loading-wrap">
|
||||
<view class="skeleton-header" />
|
||||
@@ -130,9 +131,13 @@ import { CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { get, post } from '../../utils/request'
|
||||
import { formatPrice } from '../../utils/format'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
// ─── Route params ──────────────────────────────────────────
|
||||
const cardId = ref<string>('')
|
||||
const isTrial = ref(false)
|
||||
@@ -273,6 +278,9 @@ async function doPurchase() {
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
const options = (current as { options?: Record<string, string> }).options ?? {}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="home-page">
|
||||
<view class="home-page" :style="pageStyle">
|
||||
<!-- ──────────── Custom nav bar ──────────── -->
|
||||
<CustomNavBar title="场馆首页" />
|
||||
|
||||
<!-- Pull-to-refresh wrapper -->
|
||||
<scroll-view
|
||||
class="page-scroll"
|
||||
@@ -36,9 +39,10 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import BrandBanner from '../../components/BrandBanner.vue'
|
||||
import StudioInfo from '../../components/StudioInfo.vue'
|
||||
import QuickEntry from '../../components/QuickEntry.vue'
|
||||
@@ -53,6 +57,24 @@ const userStore = useUserStore()
|
||||
const studioStore = useStudioStore()
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
// ─── Layout ───────────────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
function updateLayout() {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
const ratio = sysInfo.windowWidth / 750
|
||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||
const navTitlePx = 88 * ratio
|
||||
const navBarPx = Math.round(statusBarPx + navTitlePx)
|
||||
navBarHeight.value = `${navBarPx}px`
|
||||
}
|
||||
|
||||
updateLayout()
|
||||
|
||||
const pageStyle = computed(() => ({
|
||||
'--nav-bar-height': navBarHeight.value,
|
||||
}))
|
||||
|
||||
const refreshing = ref(false)
|
||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||
const cardShopAnchorId = 'card-shop-anchor'
|
||||
@@ -99,10 +121,11 @@ function scrollToCardShop() {
|
||||
.home-page {
|
||||
min-height: 100vh;
|
||||
background: #f5f5f5;
|
||||
padding-top: var(--nav-bar-height);
|
||||
}
|
||||
|
||||
.page-scroll {
|
||||
height: 100vh;
|
||||
height: calc(100vh - var(--nav-bar-height));
|
||||
}
|
||||
|
||||
.section-divider {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="bookings-page">
|
||||
<view class="bookings-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的预约" show-back />
|
||||
<!-- Tab bar -->
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
@@ -134,9 +135,13 @@ import type { BookingWithDetails } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { formatDate, getWeekdayLabel } from '../../utils/format'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
// ─── Tab state ────────────────────────────────────────────
|
||||
type TabKey = 'upcoming' | 'history'
|
||||
|
||||
@@ -273,7 +278,11 @@ async function handleCancel(booking: BookingWithDetails) {
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(() => bookingStore.fetchMyBookings())
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
bookingStore.fetchMyBookings()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
<template>
|
||||
<view class="profile-page">
|
||||
<!-- Custom nav bar (transparent, blends with UserCard gradient) -->
|
||||
<CustomNavBar title="我的" transparent />
|
||||
|
||||
<!-- User card -->
|
||||
<UserCard
|
||||
:logged-in="loggedIn"
|
||||
@@ -8,6 +11,7 @@
|
||||
:stats="stats"
|
||||
:memberships="memberships"
|
||||
:loading="loginLoading"
|
||||
:nav-bar-height="navBarHeight"
|
||||
@login="handleLogin"
|
||||
/>
|
||||
|
||||
@@ -28,17 +32,26 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import UserCard from '../../components/UserCard.vue'
|
||||
import ProfileMenu from '../../components/ProfileMenu.vue'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
const { loggedIn, hasProfile, user, stats, memberships, isAdmin } = storeToRefs(userStore)
|
||||
|
||||
const loginLoading = ref(false)
|
||||
const navBarHeight = ref(64)
|
||||
|
||||
onMounted(() => {
|
||||
const sysInfo = uni.getSystemInfoSync()
|
||||
const statusBarPx = sysInfo.statusBarHeight ?? 20
|
||||
const navTitlePx = 88 * (sysInfo.windowWidth / 750)
|
||||
navBarHeight.value = Math.round(statusBarPx + navTitlePx)
|
||||
})
|
||||
|
||||
onShow(async () => {
|
||||
if (loggedIn.value) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="info-page">
|
||||
<view class="info-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="个人信息" show-back />
|
||||
<!-- Avatar section -->
|
||||
<view class="avatar-section">
|
||||
<button class="avatar-btn" open-type="chooseAvatar" @chooseavatar="handleChooseAvatar">
|
||||
@@ -84,9 +85,13 @@
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { wxBindPhone } from '../../utils/auth'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
|
||||
// ─── Form state ───────────────────────────────────────────
|
||||
const form = ref({
|
||||
nickname: '',
|
||||
@@ -211,6 +216,8 @@ async function handleSave() {
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(async () => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
await userStore.fetchProfile()
|
||||
if (userStore.user) {
|
||||
form.value = { nickname: userStore.user.nickname }
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<template>
|
||||
<view class="membership-page">
|
||||
<view class="membership-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="我的会员卡" show-back />
|
||||
<!-- Pull-to-refresh scroll view -->
|
||||
<scroll-view
|
||||
class="scroll"
|
||||
@@ -146,9 +147,12 @@ import { ref, computed, onMounted } from 'vue'
|
||||
import type { MembershipWithCardType } from '@mp-pilates/shared'
|
||||
import { MembershipStatus, CardTypeCategory } from '@mp-pilates/shared'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
// ─── Nav bar height ──────────────────────────────────────
|
||||
const navBarHeight = ref('64px')
|
||||
// ─── State ────────────────────────────────────────────────
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
@@ -235,7 +239,11 @@ function goStore() {
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────
|
||||
onMounted(loadMemberships)
|
||||
onMounted(() => {
|
||||
const sys = uni.getSystemInfoSync()
|
||||
navBarHeight.value = `${(sys.statusBarHeight ?? 20) + Math.round(88 * sys.windowWidth / 750)}px`
|
||||
loadMemberships()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -70,9 +70,10 @@ export const useAdminStore = defineStore('admin', () => {
|
||||
return data
|
||||
}
|
||||
|
||||
async function deleteCardType(id: string): Promise<void> {
|
||||
await del(`/admin/card-types/${id}`)
|
||||
async function deleteCardType(id: string): Promise<{ deleted: boolean; deactivated: boolean }> {
|
||||
const result = await del<{ deleted: boolean; deactivated: boolean }>(`/admin/card-types/${id}`)
|
||||
await fetchCardTypes()
|
||||
return result
|
||||
}
|
||||
|
||||
// ── Studio config ────────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user