perf: 优化页面
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user