feat: 新的预约列表样式
This commit is contained in:
@@ -1,83 +1,88 @@
|
||||
<template>
|
||||
<view class="slot-card-wrapper">
|
||||
<!-- Date display above card -->
|
||||
<view class="slot-date">
|
||||
{{ timeSlot.date }}
|
||||
</view>
|
||||
<view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)">
|
||||
<!-- Ticket background image -->
|
||||
<image
|
||||
class="ticket-bg"
|
||||
src="/static/courseBg.png"
|
||||
mode="aspectFill"
|
||||
/>
|
||||
|
||||
<view class="slot-card" :class="[
|
||||
{ 'slot-card--booked': timeSlot.isBookedByMe },
|
||||
{ 'slot-card--past': isPast && !timeSlot.isBookedByMe },
|
||||
{ 'slot-card--full': timeSlot.status === TimeSlotStatus.FULL },
|
||||
`status-${statusClass}`
|
||||
]">
|
||||
<!-- Decorative left curves (ticket style) -->
|
||||
<view class="curve curve-left">
|
||||
<view class="curve-inner curve-top" />
|
||||
<view class="curve-inner curve-bottom" />
|
||||
</view>
|
||||
|
||||
<!-- Main content -->
|
||||
<view class="slot-content">
|
||||
<!-- Left: Time column -->
|
||||
<view class="time-section">
|
||||
<text class="start-time">{{ timeSlot.startTime.slice(0, 5) }}</text>
|
||||
<view class="time-divider" />
|
||||
<text class="end-time">{{ timeSlot.endTime.slice(0, 5) }}</text>
|
||||
<!-- Card content overlay -->
|
||||
<view class="ticket-content">
|
||||
<!-- ── Top section: Time row (like flight ticket) ── -->
|
||||
<view class="ticket-top">
|
||||
<!-- Left: Start time -->
|
||||
<view class="time-block">
|
||||
<text class="time-main">{{ startTimeDisplay }}</text>
|
||||
<text class="time-label">{{ timeSlot.date }}</text>
|
||||
</view>
|
||||
|
||||
<!-- Center divider with dashes -->
|
||||
<view class="divider-dashes" />
|
||||
|
||||
<!-- Center: Course info -->
|
||||
<view class="course-section">
|
||||
<text class="course-name">普拉提私教</text>
|
||||
<view class="course-meta">
|
||||
<text class="course-duration">{{ durationMin }}分钟</text>
|
||||
<text class="meta-separator">•</text>
|
||||
<view class="course-capacity" :class="capacityClass">
|
||||
<text class="capacity-text">{{ capacityLabel }}</text>
|
||||
<!-- Center: Duration + icon -->
|
||||
<view class="duration-block">
|
||||
<view class="duration-line">
|
||||
<view class="line-dot" />
|
||||
<view class="line-dash" />
|
||||
<view class="duration-icon">
|
||||
<text class="icon-text">⏱</text>
|
||||
</view>
|
||||
<view class="line-dash" />
|
||||
<view class="line-dot" />
|
||||
</view>
|
||||
<text class="duration-text">{{ durationMin }}分钟</text>
|
||||
</view>
|
||||
|
||||
<!-- Right: End time -->
|
||||
<view class="time-block time-block--right">
|
||||
<text class="time-main">{{ endTimeDisplay }}</text>
|
||||
<view class="capacity-tag" :class="capacityClass">
|
||||
<text class="capacity-text">{{ capacityLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Center divider with dashes -->
|
||||
<view class="divider-dashes" />
|
||||
<!-- ── Dashed tear-off line ── -->
|
||||
<view class="tear-line" />
|
||||
|
||||
<!-- Right: Action section -->
|
||||
<view class="action-section">
|
||||
<!-- Expired badge -->
|
||||
<!-- ── Bottom section: Course name + Action ── -->
|
||||
<view class="ticket-bottom">
|
||||
<view class="course-info">
|
||||
<text class="course-name">普拉提私教</text>
|
||||
</view>
|
||||
|
||||
<!-- Action area -->
|
||||
<view class="action-area">
|
||||
<!-- Expired -->
|
||||
<template v-if="isPast && !timeSlot.isBookedByMe">
|
||||
<view class="action-badge badge-expired">
|
||||
<text>已过期</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- OPEN + not booked: Book button -->
|
||||
<!-- OPEN + not booked -->
|
||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
|
||||
<view class="action-button btn-book" @tap.stop="emit('book', timeSlot)">
|
||||
<view class="action-btn btn-book" @tap.stop="emit('book', timeSlot)">
|
||||
<text>预约</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- OPEN + booked by me: Cancel link -->
|
||||
<!-- OPEN + booked by me -->
|
||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
||||
<view class="action-badge badge-booked">
|
||||
<text>已预约</text>
|
||||
</view>
|
||||
<view class="action-cancel" @tap.stop="emit('cancel', timeSlot)">
|
||||
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
||||
<text>取消</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- FULL: Full badge -->
|
||||
<!-- FULL -->
|
||||
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
|
||||
<view class="action-badge badge-full">
|
||||
<text>已约满</text>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- CLOSED: Closed badge -->
|
||||
<!-- CLOSED -->
|
||||
<template v-else>
|
||||
<view class="action-badge badge-closed">
|
||||
<text>已关闭</text>
|
||||
@@ -85,12 +90,6 @@
|
||||
</template>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Decorative right curves (ticket style) -->
|
||||
<view class="curve curve-right">
|
||||
<view class="curve-inner curve-top" />
|
||||
<view class="curve-inner curve-bottom" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -109,8 +108,12 @@ const props = defineProps<Props>()
|
||||
const emit = defineEmits<{
|
||||
book: [timeSlot: TimeSlotWithBookingStatus]
|
||||
cancel: [timeSlot: TimeSlotWithBookingStatus]
|
||||
cardTap: [timeSlot: TimeSlotWithBookingStatus]
|
||||
}>()
|
||||
|
||||
const startTimeDisplay = computed(() => props.timeSlot.startTime.slice(0, 5))
|
||||
const endTimeDisplay = computed(() => props.timeSlot.endTime.slice(0, 5))
|
||||
|
||||
const durationMin = computed(() => {
|
||||
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
|
||||
const [eh, em] = props.timeSlot.endTime.split(':').map(Number)
|
||||
@@ -122,7 +125,7 @@ const capacityLabel = computed(() => {
|
||||
if (status === TimeSlotStatus.CLOSED) return '已关闭'
|
||||
if (status === TimeSlotStatus.FULL) return '已约满'
|
||||
const remaining = capacity - bookedCount
|
||||
return `剩余 ${remaining} 个名额`
|
||||
return `剩余${remaining}位`
|
||||
})
|
||||
|
||||
const capacityClass = computed(() => {
|
||||
@@ -145,187 +148,147 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
/* ─── Wrapper ─── */
|
||||
.slot-card-wrapper {
|
||||
padding: 0 24rpx 24rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.slot-date {
|
||||
font-size: 24rpx;
|
||||
color: #999;
|
||||
padding: 0 12rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ─── Main Card ─── */
|
||||
.slot-card {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: #fff;
|
||||
border-radius: 20rpx;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
|
||||
margin: 0 24rpx 20rpx;
|
||||
min-height: 220rpx;
|
||||
transition: all 0.2s ease;
|
||||
min-height: 140rpx;
|
||||
padding: 0;
|
||||
|
||||
&:active {
|
||||
transform: scale(0.98);
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
/* Status variants */
|
||||
&.status-open {
|
||||
background: linear-gradient(135deg, #fff 0%, #fafaf8 100%);
|
||||
}
|
||||
|
||||
&.status-booked {
|
||||
background: linear-gradient(135deg, #f0f7fb 0%, #f5fbff 100%);
|
||||
box-shadow: 0 4rpx 20rpx rgba(66, 133, 244, 0.1);
|
||||
/* Status-based opacity */
|
||||
&.status-expired {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
&.status-full,
|
||||
&.status-closed {
|
||||
background: #fafaf8;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
&.status-expired {
|
||||
background: #f8f8f6;
|
||||
opacity: 0.7;
|
||||
opacity: 0.75;
|
||||
}
|
||||
}
|
||||
|
||||
/* ─── Decorative curves (ticket punch holes) ─── */
|
||||
.curve {
|
||||
/* ─── Ticket background image ─── */
|
||||
.ticket-bg {
|
||||
position: absolute;
|
||||
width: 48rpx;
|
||||
height: 140rpx;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* ─── Content overlay ─── */
|
||||
.ticket-content {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 28rpx 40rpx 24rpx;
|
||||
}
|
||||
|
||||
/* ═══ Top section: Time row ═══ */
|
||||
.ticket-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&.curve-left {
|
||||
left: -24rpx;
|
||||
}
|
||||
.time-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
min-width: 100rpx;
|
||||
|
||||
&.curve-right {
|
||||
right: -24rpx;
|
||||
&--right {
|
||||
align-items: flex-end;
|
||||
}
|
||||
}
|
||||
|
||||
.curve-inner {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
background: #FAF8F5; /* Match page background */
|
||||
border-radius: 0 48rpx 48rpx 0;
|
||||
|
||||
.curve-right & {
|
||||
border-radius: 48rpx 0 0 48rpx;
|
||||
}
|
||||
.time-main {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
color: #1a1a2e;
|
||||
line-height: 1;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* ─── Main content flex layout ─── */
|
||||
.slot-content {
|
||||
.time-label {
|
||||
margin-top: 8rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Duration center block */
|
||||
.duration-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
padding: 0 16rpx;
|
||||
}
|
||||
|
||||
.duration-line {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
padding: 28rpx 48rpx;
|
||||
gap: 0;
|
||||
margin-top: 8rpx;
|
||||
}
|
||||
|
||||
/* ─── Time Section ─── */
|
||||
.time-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 90rpx;
|
||||
.line-dot {
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: #ccc;
|
||||
flex-shrink: 0;
|
||||
gap: 8rpx;
|
||||
}
|
||||
|
||||
.start-time {
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.end-time {
|
||||
font-size: 26rpx;
|
||||
font-weight: 500;
|
||||
color: #999;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.time-divider {
|
||||
width: 2rpx;
|
||||
height: 20rpx;
|
||||
background: #e0dcd6;
|
||||
border-radius: 1rpx;
|
||||
margin: 4rpx 0;
|
||||
}
|
||||
|
||||
/* ─── Dashed dividers ─── */
|
||||
.divider-dashes {
|
||||
flex: 0 0 auto;
|
||||
width: 2rpx;
|
||||
height: 80rpx;
|
||||
.line-dash {
|
||||
flex: 1;
|
||||
height: 2rpx;
|
||||
background: repeating-linear-gradient(
|
||||
to bottom,
|
||||
#e0dcd6 0,
|
||||
#e0dcd6 8rpx,
|
||||
to right,
|
||||
#d0d0d0 0,
|
||||
#d0d0d0 8rpx,
|
||||
transparent 8rpx,
|
||||
transparent 16rpx
|
||||
);
|
||||
margin: 0 20rpx;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ─── Course Section (center) ─── */
|
||||
.course-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
min-width: 120rpx;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a1a;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.course-meta {
|
||||
.duration-icon {
|
||||
flex-shrink: 0;
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba($primary-color, 0.1);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10rpx;
|
||||
justify-content: center;
|
||||
margin: 0 8rpx;
|
||||
}
|
||||
|
||||
.icon-text {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.duration-text {
|
||||
margin-top: 6rpx;
|
||||
font-size: 22rpx;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.course-duration {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.meta-separator {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
.course-capacity {
|
||||
font-weight: 600;
|
||||
padding: 4rpx 8rpx;
|
||||
border-radius: 4rpx;
|
||||
/* Capacity tag */
|
||||
.capacity-tag {
|
||||
margin-top: 8rpx;
|
||||
padding: 4rpx 12rpx;
|
||||
border-radius: 6rpx;
|
||||
font-size: 20rpx;
|
||||
|
||||
&.cap-open {
|
||||
color: #4caf50;
|
||||
background: rgba(76, 175, 80, 0.08);
|
||||
|
||||
.capacity-text {
|
||||
@@ -334,7 +297,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
||||
}
|
||||
|
||||
&.cap-almost {
|
||||
color: #f59e0b;
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
|
||||
.capacity-text {
|
||||
@@ -343,7 +305,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
||||
}
|
||||
|
||||
&.cap-full {
|
||||
color: #ef4444;
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
|
||||
.capacity-text {
|
||||
@@ -352,7 +313,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
||||
}
|
||||
|
||||
&.cap-closed {
|
||||
color: #999;
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
|
||||
.capacity-text {
|
||||
@@ -362,53 +322,76 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
||||
}
|
||||
|
||||
.capacity-text {
|
||||
font-size: 20rpx;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ─── Action Section (right) ─── */
|
||||
.action-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
min-width: 100rpx;
|
||||
flex-shrink: 0;
|
||||
gap: 8rpx;
|
||||
/* ═══ Tear-off dashed line ═══ */
|
||||
.tear-line {
|
||||
margin: 20rpx -40rpx 16rpx;
|
||||
height: 2rpx;
|
||||
background: repeating-linear-gradient(
|
||||
to right,
|
||||
#e0dcd6 0,
|
||||
#e0dcd6 10rpx,
|
||||
transparent 10rpx,
|
||||
transparent 20rpx
|
||||
);
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.action-button,
|
||||
.action-badge {
|
||||
padding: 10rpx 20rpx;
|
||||
border-radius: 20rpx;
|
||||
/* ═══ Bottom section ═══ */
|
||||
.ticket-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.course-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: #1a1a2e;
|
||||
letter-spacing: 1rpx;
|
||||
}
|
||||
|
||||
/* ─── Action area ─── */
|
||||
.action-area {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12rpx;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-btn,
|
||||
.action-badge {
|
||||
padding: 10rpx 24rpx;
|
||||
border-radius: 20rpx;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
.btn-book {
|
||||
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 16rpx rgba($primary-dark, 0.3);
|
||||
min-width: 120rpx;
|
||||
height: 60rpx;
|
||||
transition: all 0.15s;
|
||||
|
||||
&:active {
|
||||
opacity: 0.85;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
}
|
||||
|
||||
.btn-book {
|
||||
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
|
||||
color: #fff;
|
||||
box-shadow: 0 4rpx 12rpx rgba($primary-dark, 0.25);
|
||||
}
|
||||
|
||||
.action-badge {
|
||||
padding: 8rpx 16rpx;
|
||||
font-size: 24rpx;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.badge-booked {
|
||||
background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
|
||||
color: $primary-dark;
|
||||
@@ -429,8 +412,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.action-cancel {
|
||||
padding: 4rpx 12rpx;
|
||||
.cancel-link {
|
||||
font-size: 22rpx;
|
||||
color: #ef4444;
|
||||
font-weight: 500;
|
||||
|
||||
@@ -1,18 +1,24 @@
|
||||
<template>
|
||||
<view class="booking-detail-page" :style="{ paddingTop: navBarHeight }">
|
||||
<CustomNavBar title="预约详情" show-back />
|
||||
<CustomNavBar :title="isSlotMode ? '预约课程' : '预约详情'" show-back />
|
||||
|
||||
<!-- Loading state -->
|
||||
<view v-if="loading" class="loading-wrap">
|
||||
<view class="skeleton-card" />
|
||||
</view>
|
||||
|
||||
<!-- Error state -->
|
||||
<view v-else-if="!booking" class="empty-wrap">
|
||||
<!-- Booking mode: not found -->
|
||||
<view v-else-if="!isSlotMode && !booking" class="empty-wrap">
|
||||
<text class="empty-title">预约不存在</text>
|
||||
</view>
|
||||
|
||||
<template v-else>
|
||||
<!-- Slot mode: not found -->
|
||||
<view v-else-if="isSlotMode && !slotData" class="empty-wrap">
|
||||
<text class="empty-title">课程不存在</text>
|
||||
</view>
|
||||
|
||||
<!-- Booking detail mode -->
|
||||
<template v-else-if="!isSlotMode && booking">
|
||||
<!-- Booking info card -->
|
||||
<view class="info-card">
|
||||
<!-- Status banner -->
|
||||
@@ -31,7 +37,7 @@
|
||||
<text class="info-value">
|
||||
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">使用卡种</text>
|
||||
<text class="info-value">{{ booking.membership?.cardType?.name }}</text>
|
||||
@@ -99,7 +105,7 @@
|
||||
<!-- Content -->
|
||||
<view class="timeline-content">
|
||||
<view class="timeline-content-header">
|
||||
<text class="timeline-status">{{ formatHistoryStatus(item.toStatus) }}</text>
|
||||
<text class="timeline-status">{{ bookingStatusLabel(item.toStatus) }}</text>
|
||||
<text class="timeline-time">{{ formatDateTime(item.createdAt) }}</text>
|
||||
</view>
|
||||
<text v-if="item.remark" class="timeline-remark">{{ item.remark }}</text>
|
||||
@@ -139,17 +145,102 @@
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- Slot preview mode -->
|
||||
<template v-else-if="isSlotMode && slotData">
|
||||
<!-- Slot info card -->
|
||||
<view class="info-card">
|
||||
<!-- Course info -->
|
||||
<view class="info-section">
|
||||
<view class="info-row">
|
||||
<text class="info-label">课程日期</text>
|
||||
<text class="info-value">{{ formatDateDisplay(slotData.date) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">课程时间</text>
|
||||
<text class="info-value">
|
||||
{{ slotData.startTime.slice(0, 5) }} - {{ slotData.endTime.slice(0, 5) }}
|
||||
</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">课程类型</text>
|
||||
<text class="info-value">普拉提私教</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">授课方式</text>
|
||||
<text class="info-value">私教</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Capacity info -->
|
||||
<view class="info-section">
|
||||
<view class="info-row">
|
||||
<text class="info-label">课程容量</text>
|
||||
<text class="info-value">{{ slotData.bookedCount }} / {{ slotData.capacity }} 人</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">剩余名额</text>
|
||||
<view class="capacity-tag" :class="slotCapacityClass">
|
||||
<text>{{ slotData.capacity - slotData.bookedCount }} 位</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Course description -->
|
||||
<view class="info-section">
|
||||
<view class="section-title">课程介绍</view>
|
||||
<text class="desc-text">
|
||||
普拉提私教课程,由专业教练一对一指导,根据您的身体状况制定个性化训练方案。通过精准的核心控制训练,帮助您改善体态、增强肌力、提升柔韧性。
|
||||
</text>
|
||||
</view>
|
||||
|
||||
<!-- Notes -->
|
||||
<view class="info-section">
|
||||
<view class="section-title">温馨提示</view>
|
||||
<text class="desc-text">· 请提前10分钟到达场馆\n· 建议穿着舒适运动服装\n· 课前2小时内避免大量进食\n· 如需取消请提前联系</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- Action button -->
|
||||
<view v-if="canBook" class="action-bar">
|
||||
<view class="action-btn action-btn--confirm" @tap="handleSlotBook">
|
||||
<text class="action-btn-text">立即预约</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="slotData.status === TimeSlotStatus.FULL" class="action-bar">
|
||||
<view class="action-btn action-btn--disabled">
|
||||
<text class="action-btn-text">已约满</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- Booking confirm popup (slot mode) -->
|
||||
<BookingConfirmPopup
|
||||
v-if="isSlotMode"
|
||||
:visible="showConfirmPopup"
|
||||
:time-slot="slotData"
|
||||
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
|
||||
@confirm="onConfirmBooking"
|
||||
@cancel="showConfirmPopup = false"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import type { BookingWithDetails, BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
|
||||
import { BookingStatus } from '@mp-pilates/shared'
|
||||
import type {
|
||||
BookingWithDetails,
|
||||
BookingWithUser,
|
||||
BookingStatusHistory,
|
||||
TimeSlotWithBookingStatus,
|
||||
MembershipWithCardType,
|
||||
} from '@mp-pilates/shared'
|
||||
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||
import { useBookingStore } from '../../stores/booking'
|
||||
import { useUserStore } from '../../stores/user'
|
||||
import { getSystemLayout } from '../../utils/system'
|
||||
import { isSlotPast } from '../../utils/format'
|
||||
import {
|
||||
formatDateDisplay,
|
||||
bookingStatusLabel,
|
||||
@@ -157,16 +248,29 @@ import {
|
||||
bookingTimelineDotClass,
|
||||
} from '../../utils/booking-helpers'
|
||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||
|
||||
const bookingStore = useBookingStore()
|
||||
const userStore = useUserStore()
|
||||
|
||||
const navBarHeight = ref('64px')
|
||||
const loading = ref(false)
|
||||
|
||||
// ─── Page mode ────────────────────────────────────────────────────────────
|
||||
const isSlotMode = ref(false)
|
||||
|
||||
// ─── Booking mode state ───────────────────────────────────────────────────
|
||||
const bookingId = ref('')
|
||||
const booking = ref<BookingWithDetails | BookingWithUser | null>(null)
|
||||
const history = ref<BookingStatusHistory[]>([])
|
||||
|
||||
// ─── Slot mode state ──────────────────────────────────────────────────────
|
||||
const slotId = ref('')
|
||||
const slotDate = ref('')
|
||||
const slotData = ref<TimeSlotWithBookingStatus | null>(null)
|
||||
const showConfirmPopup = ref(false)
|
||||
|
||||
// ─── Shared computed ──────────────────────────────────────────────────────
|
||||
const isAdmin = computed(() => userStore.isAdmin)
|
||||
const showActions = computed(() =>
|
||||
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
|
||||
@@ -180,11 +284,23 @@ function hasUser(b: BookingWithDetails | BookingWithUser | null): b is BookingWi
|
||||
|
||||
const bookingUser = computed(() => hasUser(booking.value) ? booking.value.user : null)
|
||||
|
||||
// ─── Status helpers ───────────────────────────────────────────────────────
|
||||
function formatHistoryStatus(status: string): string {
|
||||
return bookingStatusLabel(status)
|
||||
}
|
||||
// Slot mode computed
|
||||
const canBook = computed(() => {
|
||||
if (!slotData.value) return false
|
||||
if (slotData.value.status !== TimeSlotStatus.OPEN) return false
|
||||
if (slotData.value.isBookedByMe) return false
|
||||
return !isSlotPast(slotData.value.date, slotData.value.startTime)
|
||||
})
|
||||
|
||||
const slotCapacityClass = computed(() => {
|
||||
if (!slotData.value) return ''
|
||||
const { bookedCount, capacity } = slotData.value
|
||||
if (bookedCount >= capacity) return 'cap-full'
|
||||
if (bookedCount >= capacity * 0.8) return 'cap-almost'
|
||||
return 'cap-open'
|
||||
})
|
||||
|
||||
// ─── Status helpers ───────────────────────────────────────────────────────
|
||||
function formatDateTime(dateStr: string): string {
|
||||
if (!dateStr) return '-'
|
||||
const d = new Date(dateStr)
|
||||
@@ -197,10 +313,9 @@ function formatDateTime(dateStr: string): string {
|
||||
}
|
||||
|
||||
// ─── Data loading ─────────────────────────────────────────────────────────
|
||||
async function loadData() {
|
||||
async function loadBookingData() {
|
||||
loading.value = true
|
||||
try {
|
||||
// Fetch booking details and history in parallel
|
||||
const [bookingData, historyData] = await Promise.all([
|
||||
bookingStore.fetchBookingById(bookingId.value),
|
||||
bookingStore.fetchBookingHistory(bookingId.value),
|
||||
@@ -216,7 +331,79 @@ async function loadData() {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||
async function loadSlotData() {
|
||||
loading.value = true
|
||||
try {
|
||||
slotData.value = await bookingStore.fetchSlotById(slotId.value)
|
||||
} catch (err: unknown) {
|
||||
const message = err instanceof Error ? err.message : '加载失败'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Slot mode: Booking flow ─────────────────────────────────────────────
|
||||
async function handleSlotBook() {
|
||||
if (!userStore.loggedIn) {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '请先登录后再预约课程',
|
||||
confirmText: '去登录',
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
try {
|
||||
const { isNewUser } = await userStore.loginWithSetup()
|
||||
if (!isNewUser) {
|
||||
handleSlotBook()
|
||||
}
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!userStore.hasValidMembership) {
|
||||
uni.showModal({
|
||||
title: '暂无可用会员卡',
|
||||
content: '您当前没有有效的会员卡,购买后即可预约课程',
|
||||
confirmText: '去购买',
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.$emit('scrollToCardShop')
|
||||
uni.switchTab({ url: '/pages/home/index' })
|
||||
}
|
||||
},
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
showConfirmPopup.value = true
|
||||
}
|
||||
|
||||
async function onConfirmBooking(payload: { timeSlotId: string; membershipId: string }) {
|
||||
showConfirmPopup.value = false
|
||||
uni.showLoading({ title: '预约中...' })
|
||||
try {
|
||||
const result = await bookingStore.createBooking(payload)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '预约成功!', icon: 'success' })
|
||||
// Switch to booking detail mode to show the new booking
|
||||
isSlotMode.value = false
|
||||
bookingId.value = result.id
|
||||
await loadBookingData()
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const message = err instanceof Error ? err.message : '预约失败,请重试'
|
||||
uni.showToast({ title: message, icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Booking mode: Admin/User actions ────────────────────────────────────
|
||||
async function handleConfirm() {
|
||||
uni.showModal({
|
||||
title: '确认预约',
|
||||
@@ -229,7 +416,7 @@ async function handleConfirm() {
|
||||
await bookingStore.confirmBooking(bookingId.value)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '已确认', icon: 'success' })
|
||||
await loadData()
|
||||
await loadBookingData()
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const msg = err instanceof Error ? err.message : '操作失败'
|
||||
@@ -251,7 +438,7 @@ async function handleComplete() {
|
||||
await bookingStore.completeBooking(bookingId.value)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '已核销', icon: 'success' })
|
||||
await loadData()
|
||||
await loadBookingData()
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const msg = err instanceof Error ? err.message : '操作失败'
|
||||
@@ -273,7 +460,7 @@ async function handleNoShow() {
|
||||
await bookingStore.markNoShow(bookingId.value)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '已标记', icon: 'success' })
|
||||
await loadData()
|
||||
await loadBookingData()
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const msg = err instanceof Error ? err.message : '操作失败'
|
||||
@@ -291,12 +478,12 @@ async function handleCancel() {
|
||||
confirmColor: '#ef4444',
|
||||
success: async (res) => {
|
||||
if (!res.confirm) return
|
||||
uni.showLoading({ title: '处理中...' })
|
||||
uni.showLoading({ title: '取消中...' })
|
||||
try {
|
||||
await bookingStore.cancelBooking(bookingId.value)
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '已取消', icon: 'success' })
|
||||
await loadData()
|
||||
await loadBookingData()
|
||||
} catch (err: unknown) {
|
||||
uni.hideLoading()
|
||||
const msg = err instanceof Error ? err.message : '操作失败'
|
||||
@@ -307,14 +494,28 @@ async function handleCancel() {
|
||||
}
|
||||
|
||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||
onMounted(() => {
|
||||
onMounted(async () => {
|
||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||
// Load memberships if logged in (needed for BookingConfirmPopup)
|
||||
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
|
||||
await userStore.fetchMemberships()
|
||||
}
|
||||
})
|
||||
|
||||
onLoad((query) => {
|
||||
bookingId.value = (query as Record<string, string>).id || ''
|
||||
if (bookingId.value) {
|
||||
loadData()
|
||||
const q = query as Record<string, string>
|
||||
|
||||
if (q.slotId) {
|
||||
// Slot preview mode
|
||||
isSlotMode.value = true
|
||||
slotId.value = q.slotId
|
||||
slotDate.value = q.date || ''
|
||||
loadSlotData()
|
||||
} else if (q.id) {
|
||||
// Booking detail mode
|
||||
isSlotMode.value = false
|
||||
bookingId.value = q.id
|
||||
loadBookingData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -361,30 +562,6 @@ onLoad((query) => {
|
||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
.status-banner {
|
||||
padding: 24rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&--pending { background: rgba(245, 158, 11, 0.1); }
|
||||
&--confirmed { background: rgba(201, 168, 124, 0.1); }
|
||||
&--completed { background: rgba(102, 187, 106, 0.1); }
|
||||
&--cancelled { background: rgba(0, 0, 0, 0.04); }
|
||||
&--noshow { background: rgba(239, 83, 80, 0.1); }
|
||||
}
|
||||
|
||||
.status-banner-text {
|
||||
font-size: 32rpx;
|
||||
font-weight: 600;
|
||||
|
||||
.status-banner--pending & { color: #f59e0b; }
|
||||
.status-banner--confirmed & { color: $primary-dark; }
|
||||
.status-banner--completed & { color: #66bb6a; }
|
||||
.status-banner--cancelled & { color: #bbb; }
|
||||
.status-banner--noshow & { color: #ef5350; }
|
||||
}
|
||||
|
||||
.info-section {
|
||||
padding: 24rpx;
|
||||
border-bottom: 1rpx solid #f5f5f5;
|
||||
@@ -421,6 +598,34 @@ onLoad((query) => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.capacity-tag {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: 8rpx;
|
||||
font-size: 24rpx;
|
||||
font-weight: 600;
|
||||
|
||||
&.cap-open {
|
||||
background: rgba(76, 175, 80, 0.08);
|
||||
color: #4caf50;
|
||||
}
|
||||
|
||||
&.cap-almost {
|
||||
background: rgba(245, 158, 11, 0.08);
|
||||
color: #f59e0b;
|
||||
}
|
||||
|
||||
&.cap-full {
|
||||
background: rgba(239, 68, 68, 0.08);
|
||||
color: #ef4444;
|
||||
}
|
||||
}
|
||||
|
||||
.desc-text {
|
||||
font-size: 26rpx;
|
||||
color: #666;
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ── Timeline card ────────────────────────────────────── */
|
||||
.timeline-card {
|
||||
margin: 24rpx;
|
||||
@@ -570,6 +775,10 @@ onLoad((query) => {
|
||||
&--cancel {
|
||||
background: rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
&--disabled {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn-text {
|
||||
@@ -584,5 +793,9 @@ onLoad((query) => {
|
||||
.action-btn--noshow & {
|
||||
color: #ef5350;
|
||||
}
|
||||
|
||||
.action-btn--disabled & {
|
||||
color: #bbb;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -61,6 +61,7 @@
|
||||
:time-slot="item"
|
||||
@book="onBookTap"
|
||||
@cancel="onCancelTap"
|
||||
@card-tap="onSlotCardTap"
|
||||
/>
|
||||
</view>
|
||||
|
||||
@@ -150,7 +151,7 @@ updateLayout()
|
||||
// ─── Filtered slots ───────────────────────────────────────
|
||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||
if (!selectedPeriod.value) return [...slots]
|
||||
if (!selectedPeriod.value) return slots
|
||||
|
||||
const period = TIME_PERIODS[selectedPeriod.value]
|
||||
return slots.filter((slot) => {
|
||||
@@ -177,7 +178,19 @@ function onDateSelect(date: string) {
|
||||
}
|
||||
|
||||
function onPeriodChange(_period: PeriodKey) {
|
||||
// Filtering is done client-side via computed property
|
||||
// No-op: filtering is done client-side via computed property
|
||||
void _period
|
||||
}
|
||||
|
||||
// ─── Card tap → navigate to detail ───────────────────────
|
||||
function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
|
||||
if (slot.isBookedByMe && slot.myBookingId) {
|
||||
// Already booked → show booking detail
|
||||
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
|
||||
} else {
|
||||
// Not booked → show slot preview with booking action
|
||||
uni.navigateTo({ url: `/pages/booking/detail?slotId=${slot.id}&date=${slot.date}` })
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Book flow ────────────────────────────────────────────
|
||||
@@ -213,7 +226,9 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
||||
cancelText: '取消',
|
||||
success: (res) => {
|
||||
if (res.confirm) {
|
||||
uni.navigateTo({ url: '/pages/store/index' })
|
||||
// Switch to home tab and auto-scroll to card shop
|
||||
uni.$emit('scrollToCardShop')
|
||||
uni.switchTab({ url: '/pages/home/index' })
|
||||
}
|
||||
},
|
||||
})
|
||||
@@ -352,7 +367,7 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.skeleton-card {
|
||||
height: 140rpx;
|
||||
height: 220rpx;
|
||||
border-radius: 20rpx;
|
||||
background: #fff;
|
||||
display: flex;
|
||||
@@ -360,6 +375,7 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
padding: 28rpx 48rpx;
|
||||
gap: 20rpx;
|
||||
margin: 0 24rpx;
|
||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
|
||||
@@ -48,8 +48,8 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
import { ref, nextTick } from 'vue'
|
||||
import { onShow, onUnmount, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||
|
||||
import BrandBanner from '../../components/BrandBanner.vue'
|
||||
import StudioInfo from '../../components/StudioInfo.vue'
|
||||
@@ -86,10 +86,27 @@ const refreshing = ref(false)
|
||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||
const cardShopAnchorId = 'card-shop-anchor'
|
||||
const scrollTop = ref(0)
|
||||
const pendingScrollToCardShop = ref(false)
|
||||
|
||||
// Listen for cross-page scroll request (e.g. from booking page "去购买")
|
||||
uni.$on('scrollToCardShop', () => {
|
||||
pendingScrollToCardShop.value = true
|
||||
})
|
||||
|
||||
onUnmount(() => {
|
||||
uni.$off('scrollToCardShop')
|
||||
})
|
||||
|
||||
// Refresh all data on every show
|
||||
onShow(async () => {
|
||||
await refreshData()
|
||||
|
||||
// If another page requested scroll to card shop, execute after data is ready
|
||||
if (pendingScrollToCardShop.value) {
|
||||
pendingScrollToCardShop.value = false
|
||||
await nextTick()
|
||||
scrollToCardShop()
|
||||
}
|
||||
})
|
||||
|
||||
async function refreshData() {
|
||||
@@ -118,14 +135,22 @@ async function handleRefresh() {
|
||||
}
|
||||
|
||||
function scrollToCardShop() {
|
||||
uni.createSelectorQuery()
|
||||
.select(`#${cardShopAnchorId}`)
|
||||
.boundingClientRect((rect) => {
|
||||
if (rect) {
|
||||
scrollTop.value = rect.top
|
||||
}
|
||||
})
|
||||
.exec()
|
||||
// Reset first so setting the same value still triggers scroll
|
||||
scrollTop.value = 0
|
||||
nextTick(() => {
|
||||
uni.createSelectorQuery()
|
||||
.select(`#${cardShopAnchorId}`)
|
||||
.boundingClientRect()
|
||||
.selectViewport()
|
||||
.scrollOffset()
|
||||
.exec((res) => {
|
||||
if (res && res[0] && res[1]) {
|
||||
const rectTop = (res[0] as UniApp.NodeInfo).top ?? 0
|
||||
const viewportScroll = (res[1] as UniApp.NodeInfo).scrollTop ?? 0
|
||||
scrollTop.value = viewportScroll + rectTop
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
|
||||
@@ -4,25 +4,12 @@
|
||||
<CustomNavBar title="我的" transparent />
|
||||
|
||||
<!-- User card -->
|
||||
<UserCard
|
||||
:logged-in="loggedIn"
|
||||
:has-profile="hasProfile"
|
||||
:user="user"
|
||||
:stats="stats"
|
||||
:memberships="memberships"
|
||||
:loading="loginLoading"
|
||||
:nav-bar-height="navBarHeight"
|
||||
@login="handleLogin"
|
||||
/>
|
||||
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
||||
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
|
||||
|
||||
<!-- Menu section: always visible -->
|
||||
<ProfileMenu
|
||||
:is-admin="isAdmin"
|
||||
:require-auth="loggedIn"
|
||||
@clear-cache="handleClearCache"
|
||||
@about="handleAbout"
|
||||
@require-login="handleLogin"
|
||||
/>
|
||||
<ProfileMenu :is-admin="isAdmin" :require-auth="loggedIn" @clear-cache="handleClearCache" @about="handleAbout"
|
||||
@require-login="handleLogin" />
|
||||
|
||||
<!-- Logout button: only when logged in -->
|
||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||
@@ -131,7 +118,6 @@ function handleAbout() {
|
||||
<style lang="scss" scoped>
|
||||
.profile-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-page;
|
||||
|
||||
&__logout-wrap {
|
||||
margin: $spacing-xl $spacing-lg $spacing-xl;
|
||||
|
||||
@@ -299,7 +299,7 @@ onMounted(async () => {
|
||||
position: relative;
|
||||
margin: 0 $spacing-lg $spacing-md;
|
||||
padding: 36rpx 32rpx;
|
||||
background: linear-gradient(135deg, $brand-color 0%, lighten($brand-color, 12%) 100%);
|
||||
background: linear-gradient(135deg, $brand-color 0%, #6b5d52 100%);
|
||||
border-radius: $radius-lg;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -551,7 +551,7 @@ onMounted(async () => {
|
||||
width: 100%;
|
||||
height: 96rpx;
|
||||
border-radius: 48rpx;
|
||||
background: linear-gradient(135deg, $brand-color, lighten($brand-color, 8%));
|
||||
background: linear-gradient(135deg, $brand-color, #5e5045);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
BIN
packages/app/src/static/courseBg.png
Normal file
BIN
packages/app/src/static/courseBg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.9 KiB |
@@ -115,6 +115,11 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
return result
|
||||
}
|
||||
|
||||
async function fetchSlotById(slotId: string) {
|
||||
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
|
||||
return result
|
||||
}
|
||||
|
||||
return {
|
||||
slots,
|
||||
myBookings,
|
||||
@@ -131,6 +136,7 @@ export const useBookingStore = defineStore('booking', () => {
|
||||
completeBooking,
|
||||
markNoShow,
|
||||
fetchBookingHistory,
|
||||
fetchSlotById,
|
||||
fetchBookingById,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -40,8 +40,11 @@ export class TimeSlotController {
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
getSlotById(@Param('id') id: string) {
|
||||
return this.timeSlotService.getSlotById(id)
|
||||
getSlotById(
|
||||
@Param('id') id: string,
|
||||
@CurrentUser('sub') userId: string,
|
||||
) {
|
||||
return this.timeSlotService.getSlotById(id, userId)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,23 +10,58 @@ import type { PublishDaySlotsDto } from './dto/publish-day-slots.dto'
|
||||
export class TimeSlotService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
private toDateOfDay(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 0, 0, 0, 0))
|
||||
}
|
||||
|
||||
private toEndOfDay(date: Date): Date {
|
||||
return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate(), 23, 59, 59, 999))
|
||||
}
|
||||
|
||||
private mapToWithBookingStatus(
|
||||
slot: {
|
||||
id: string
|
||||
date: Date
|
||||
startTime: string
|
||||
endTime: string
|
||||
capacity: number
|
||||
bookedCount: number
|
||||
status: string
|
||||
source: string
|
||||
templateId: string | null
|
||||
createdAt: Date
|
||||
updatedAt: Date
|
||||
},
|
||||
myBooking: { id: string } | null,
|
||||
): TimeSlotWithBookingStatus {
|
||||
return {
|
||||
id: slot.id,
|
||||
date: slot.date.toISOString().split('T')[0],
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
capacity: slot.capacity,
|
||||
bookedCount: slot.bookedCount,
|
||||
status: slot.status as TimeSlotStatus,
|
||||
source: slot.source as TimeSlotSource,
|
||||
templateId: slot.templateId,
|
||||
createdAt: slot.createdAt.toISOString(),
|
||||
updatedAt: slot.updatedAt.toISOString(),
|
||||
isBookedByMe: myBooking !== null,
|
||||
myBookingId: myBooking?.id ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableSlots(
|
||||
date: string,
|
||||
userId?: string,
|
||||
): Promise<TimeSlotWithBookingStatus[]> {
|
||||
const parsedDate = new Date(date)
|
||||
|
||||
// Build start/end of day boundaries for the query
|
||||
const startOfDay = new Date(parsedDate)
|
||||
startOfDay.setUTCHours(0, 0, 0, 0)
|
||||
const endOfDay = new Date(parsedDate)
|
||||
endOfDay.setUTCHours(23, 59, 59, 999)
|
||||
|
||||
const slots = await this.prisma.timeSlot.findMany({
|
||||
where: {
|
||||
date: {
|
||||
gte: startOfDay,
|
||||
lte: endOfDay,
|
||||
gte: this.toDateOfDay(parsedDate),
|
||||
lte: this.toEndOfDay(parsedDate),
|
||||
},
|
||||
status: { not: TimeSlotStatus.CLOSED },
|
||||
},
|
||||
@@ -50,44 +85,44 @@ export class TimeSlotService {
|
||||
? slot.bookings[0]
|
||||
: null
|
||||
|
||||
return {
|
||||
id: slot.id,
|
||||
date: slot.date.toISOString().split('T')[0],
|
||||
startTime: slot.startTime,
|
||||
endTime: slot.endTime,
|
||||
capacity: slot.capacity,
|
||||
bookedCount: slot.bookedCount,
|
||||
status: slot.status as TimeSlotStatus,
|
||||
source: slot.source as TimeSlotSource,
|
||||
templateId: slot.templateId,
|
||||
createdAt: slot.createdAt.toISOString(),
|
||||
updatedAt: slot.updatedAt.toISOString(),
|
||||
isBookedByMe: myBooking !== null,
|
||||
myBookingId: myBooking?.id ?? null,
|
||||
} satisfies TimeSlotWithBookingStatus
|
||||
return this.mapToWithBookingStatus(slot, myBooking)
|
||||
})
|
||||
}
|
||||
|
||||
async getSlotById(id: string) {
|
||||
async getSlotById(id: string, userId?: string): Promise<TimeSlotWithBookingStatus> {
|
||||
const slot = await this.prisma.timeSlot.findUnique({
|
||||
where: { id },
|
||||
include: { bookings: true },
|
||||
include: {
|
||||
bookings: userId
|
||||
? {
|
||||
where: {
|
||||
userId,
|
||||
status: BookingStatus.CONFIRMED,
|
||||
},
|
||||
select: { id: true },
|
||||
}
|
||||
: false,
|
||||
},
|
||||
})
|
||||
|
||||
if (!slot) {
|
||||
throw new NotFoundException(`TimeSlot ${id} not found`)
|
||||
}
|
||||
|
||||
return slot
|
||||
const myBooking =
|
||||
userId && Array.isArray(slot.bookings) && slot.bookings.length > 0
|
||||
? slot.bookings[0]
|
||||
: null
|
||||
|
||||
return this.mapToWithBookingStatus(slot, myBooking)
|
||||
}
|
||||
|
||||
async createManualSlot(dto: CreateManualSlotDto) {
|
||||
const date = new Date(dto.date)
|
||||
date.setUTCHours(0, 0, 0, 0)
|
||||
const parsedDate = new Date(dto.date + 'T00:00:00Z')
|
||||
|
||||
return this.prisma.timeSlot.create({
|
||||
data: {
|
||||
date,
|
||||
date: parsedDate,
|
||||
startTime: dto.startTime,
|
||||
endTime: dto.endTime,
|
||||
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
|
||||
@@ -156,15 +191,11 @@ export class TimeSlotService {
|
||||
*/
|
||||
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
|
||||
const parsedDate = new Date(date)
|
||||
const startOfDay = new Date(parsedDate)
|
||||
startOfDay.setUTCHours(0, 0, 0, 0)
|
||||
const endOfDay = new Date(parsedDate)
|
||||
endOfDay.setUTCHours(23, 59, 59, 999)
|
||||
|
||||
// 1. Check for existing TimeSlot records (all statuses)
|
||||
const existingSlots = await this.prisma.timeSlot.findMany({
|
||||
where: {
|
||||
date: { gte: startOfDay, lte: endOfDay },
|
||||
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
|
||||
},
|
||||
orderBy: { startTime: 'asc' },
|
||||
})
|
||||
@@ -212,17 +243,12 @@ export class TimeSlotService {
|
||||
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
|
||||
*/
|
||||
async publishDaySlots(dto: PublishDaySlotsDto) {
|
||||
const parsedDate = new Date(dto.date)
|
||||
parsedDate.setUTCHours(0, 0, 0, 0)
|
||||
|
||||
const startOfDay = new Date(parsedDate)
|
||||
const endOfDay = new Date(parsedDate)
|
||||
endOfDay.setUTCHours(23, 59, 59, 999)
|
||||
const parsedDate = new Date(dto.date + 'T00:00:00Z')
|
||||
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
// 1. Get existing slots for this date
|
||||
const existing = await tx.timeSlot.findMany({
|
||||
where: { date: { gte: startOfDay, lte: endOfDay } },
|
||||
where: { date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) } },
|
||||
})
|
||||
const existingMap = new Map(existing.map((s) => [s.id, s]))
|
||||
const keptIds = new Set<string>()
|
||||
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user