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

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