perf: 优化页面

This commit is contained in:
richarjiang
2026-04-05 13:25:54 +08:00
parent a85270efd4
commit 9811c9a13b
31 changed files with 3135 additions and 375 deletions

View File

@@ -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,
})
}

View 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>

View File

@@ -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>

View File

@@ -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 ──