feat: 新的预约列表样式
This commit is contained in:
@@ -1,83 +1,88 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="slot-card-wrapper">
|
<view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)">
|
||||||
<!-- Date display above card -->
|
<!-- Ticket background image -->
|
||||||
<view class="slot-date">
|
<image
|
||||||
{{ timeSlot.date }}
|
class="ticket-bg"
|
||||||
</view>
|
src="/static/courseBg.png"
|
||||||
|
mode="aspectFill"
|
||||||
|
/>
|
||||||
|
|
||||||
<view class="slot-card" :class="[
|
<!-- Card content overlay -->
|
||||||
{ 'slot-card--booked': timeSlot.isBookedByMe },
|
<view class="ticket-content">
|
||||||
{ 'slot-card--past': isPast && !timeSlot.isBookedByMe },
|
<!-- ── Top section: Time row (like flight ticket) ── -->
|
||||||
{ 'slot-card--full': timeSlot.status === TimeSlotStatus.FULL },
|
<view class="ticket-top">
|
||||||
`status-${statusClass}`
|
<!-- Left: Start time -->
|
||||||
]">
|
<view class="time-block">
|
||||||
<!-- Decorative left curves (ticket style) -->
|
<text class="time-main">{{ startTimeDisplay }}</text>
|
||||||
<view class="curve curve-left">
|
<text class="time-label">{{ timeSlot.date }}</text>
|
||||||
<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>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Center divider with dashes -->
|
<!-- Center: Duration + icon -->
|
||||||
<view class="divider-dashes" />
|
<view class="duration-block">
|
||||||
|
<view class="duration-line">
|
||||||
<!-- Center: Course info -->
|
<view class="line-dot" />
|
||||||
<view class="course-section">
|
<view class="line-dash" />
|
||||||
<text class="course-name">普拉提私教</text>
|
<view class="duration-icon">
|
||||||
<view class="course-meta">
|
<text class="icon-text">⏱</text>
|
||||||
<text class="course-duration">{{ durationMin }}分钟</text>
|
|
||||||
<text class="meta-separator">•</text>
|
|
||||||
<view class="course-capacity" :class="capacityClass">
|
|
||||||
<text class="capacity-text">{{ capacityLabel }}</text>
|
|
||||||
</view>
|
</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>
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<!-- Center divider with dashes -->
|
<!-- ── Dashed tear-off line ── -->
|
||||||
<view class="divider-dashes" />
|
<view class="tear-line" />
|
||||||
|
|
||||||
<!-- Right: Action section -->
|
<!-- ── Bottom section: Course name + Action ── -->
|
||||||
<view class="action-section">
|
<view class="ticket-bottom">
|
||||||
<!-- Expired badge -->
|
<view class="course-info">
|
||||||
|
<text class="course-name">普拉提私教</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Action area -->
|
||||||
|
<view class="action-area">
|
||||||
|
<!-- Expired -->
|
||||||
<template v-if="isPast && !timeSlot.isBookedByMe">
|
<template v-if="isPast && !timeSlot.isBookedByMe">
|
||||||
<view class="action-badge badge-expired">
|
<view class="action-badge badge-expired">
|
||||||
<text>已过期</text>
|
<text>已过期</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- OPEN + not booked: Book button -->
|
<!-- OPEN + not booked -->
|
||||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
|
<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>
|
<text>预约</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- OPEN + booked by me: Cancel link -->
|
<!-- OPEN + booked by me -->
|
||||||
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
|
||||||
<view class="action-badge badge-booked">
|
<view class="action-badge badge-booked">
|
||||||
<text>已预约</text>
|
<text>已预约</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="action-cancel" @tap.stop="emit('cancel', timeSlot)">
|
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
|
||||||
<text>取消</text>
|
<text>取消</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- FULL: Full badge -->
|
<!-- FULL -->
|
||||||
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
|
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
|
||||||
<view class="action-badge badge-full">
|
<view class="action-badge badge-full">
|
||||||
<text>已约满</text>
|
<text>已约满</text>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- CLOSED: Closed badge -->
|
<!-- CLOSED -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<view class="action-badge badge-closed">
|
<view class="action-badge badge-closed">
|
||||||
<text>已关闭</text>
|
<text>已关闭</text>
|
||||||
@@ -85,12 +90,6 @@
|
|||||||
</template>
|
</template>
|
||||||
</view>
|
</view>
|
||||||
</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>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -109,8 +108,12 @@ const props = defineProps<Props>()
|
|||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
book: [timeSlot: TimeSlotWithBookingStatus]
|
book: [timeSlot: TimeSlotWithBookingStatus]
|
||||||
cancel: [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 durationMin = computed(() => {
|
||||||
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
|
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
|
||||||
const [eh, em] = props.timeSlot.endTime.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.CLOSED) return '已关闭'
|
||||||
if (status === TimeSlotStatus.FULL) return '已约满'
|
if (status === TimeSlotStatus.FULL) return '已约满'
|
||||||
const remaining = capacity - bookedCount
|
const remaining = capacity - bookedCount
|
||||||
return `剩余 ${remaining} 个名额`
|
return `剩余${remaining}位`
|
||||||
})
|
})
|
||||||
|
|
||||||
const capacityClass = computed(() => {
|
const capacityClass = computed(() => {
|
||||||
@@ -145,187 +148,147 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
/* ─── Wrapper ─── */
|
||||||
.slot-card-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;
|
position: relative;
|
||||||
display: flex;
|
margin: 0 24rpx 20rpx;
|
||||||
align-items: center;
|
min-height: 220rpx;
|
||||||
background: #fff;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
overflow: hidden;
|
|
||||||
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
|
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
min-height: 140rpx;
|
|
||||||
padding: 0;
|
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
transform: scale(0.98);
|
transform: scale(0.98);
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Status variants */
|
/* Status-based opacity */
|
||||||
&.status-open {
|
&.status-expired {
|
||||||
background: linear-gradient(135deg, #fff 0%, #fafaf8 100%);
|
opacity: 0.55;
|
||||||
}
|
|
||||||
|
|
||||||
&.status-booked {
|
|
||||||
background: linear-gradient(135deg, #f0f7fb 0%, #f5fbff 100%);
|
|
||||||
box-shadow: 0 4rpx 20rpx rgba(66, 133, 244, 0.1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.status-full,
|
&.status-full,
|
||||||
&.status-closed {
|
&.status-closed {
|
||||||
background: #fafaf8;
|
opacity: 0.75;
|
||||||
opacity: 0.85;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.status-expired {
|
|
||||||
background: #f8f8f6;
|
|
||||||
opacity: 0.7;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Decorative curves (ticket punch holes) ─── */
|
/* ─── Ticket background image ─── */
|
||||||
.curve {
|
.ticket-bg {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
width: 48rpx;
|
top: 0;
|
||||||
height: 140rpx;
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── Content overlay ─── */
|
||||||
|
.ticket-content {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
padding: 28rpx 40rpx 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ═══ Top section: Time row ═══ */
|
||||||
|
.ticket-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
pointer-events: none;
|
}
|
||||||
|
|
||||||
&.curve-left {
|
.time-block {
|
||||||
left: -24rpx;
|
display: flex;
|
||||||
}
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 100rpx;
|
||||||
|
|
||||||
&.curve-right {
|
&--right {
|
||||||
right: -24rpx;
|
align-items: flex-end;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.curve-inner {
|
.time-main {
|
||||||
width: 48rpx;
|
font-size: 40rpx;
|
||||||
height: 48rpx;
|
font-weight: 800;
|
||||||
background: #FAF8F5; /* Match page background */
|
color: #1a1a2e;
|
||||||
border-radius: 0 48rpx 48rpx 0;
|
line-height: 1;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
.curve-right & {
|
|
||||||
border-radius: 48rpx 0 0 48rpx;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Main content flex layout ─── */
|
.time-label {
|
||||||
.slot-content {
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 28rpx 48rpx;
|
margin-top: 8rpx;
|
||||||
gap: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Time Section ─── */
|
.line-dot {
|
||||||
.time-section {
|
width: 10rpx;
|
||||||
display: flex;
|
height: 10rpx;
|
||||||
flex-direction: column;
|
border-radius: 50%;
|
||||||
align-items: center;
|
background: #ccc;
|
||||||
min-width: 90rpx;
|
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
gap: 8rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.start-time {
|
.line-dash {
|
||||||
font-size: 38rpx;
|
flex: 1;
|
||||||
font-weight: 700;
|
height: 2rpx;
|
||||||
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;
|
|
||||||
background: repeating-linear-gradient(
|
background: repeating-linear-gradient(
|
||||||
to bottom,
|
to right,
|
||||||
#e0dcd6 0,
|
#d0d0d0 0,
|
||||||
#e0dcd6 8rpx,
|
#d0d0d0 8rpx,
|
||||||
transparent 8rpx,
|
transparent 8rpx,
|
||||||
transparent 16rpx
|
transparent 16rpx
|
||||||
);
|
);
|
||||||
margin: 0 20rpx;
|
|
||||||
opacity: 0.6;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Course Section (center) ─── */
|
.duration-icon {
|
||||||
.course-section {
|
flex-shrink: 0;
|
||||||
display: flex;
|
width: 48rpx;
|
||||||
flex-direction: column;
|
height: 48rpx;
|
||||||
flex: 1;
|
border-radius: 50%;
|
||||||
align-items: center;
|
background: rgba($primary-color, 0.1);
|
||||||
gap: 8rpx;
|
|
||||||
min-width: 120rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-name {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: #1a1a1a;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-meta {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 10rpx;
|
justify-content: center;
|
||||||
|
margin: 0 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.duration-text {
|
||||||
|
margin-top: 6rpx;
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #999;
|
color: #999;
|
||||||
}
|
|
||||||
|
|
||||||
.course-duration {
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meta-separator {
|
/* Capacity tag */
|
||||||
color: #ddd;
|
.capacity-tag {
|
||||||
}
|
margin-top: 8rpx;
|
||||||
|
padding: 4rpx 12rpx;
|
||||||
.course-capacity {
|
border-radius: 6rpx;
|
||||||
font-weight: 600;
|
font-size: 20rpx;
|
||||||
padding: 4rpx 8rpx;
|
|
||||||
border-radius: 4rpx;
|
|
||||||
|
|
||||||
&.cap-open {
|
&.cap-open {
|
||||||
color: #4caf50;
|
|
||||||
background: rgba(76, 175, 80, 0.08);
|
background: rgba(76, 175, 80, 0.08);
|
||||||
|
|
||||||
.capacity-text {
|
.capacity-text {
|
||||||
@@ -334,7 +297,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.cap-almost {
|
&.cap-almost {
|
||||||
color: #f59e0b;
|
|
||||||
background: rgba(245, 158, 11, 0.08);
|
background: rgba(245, 158, 11, 0.08);
|
||||||
|
|
||||||
.capacity-text {
|
.capacity-text {
|
||||||
@@ -343,7 +305,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.cap-full {
|
&.cap-full {
|
||||||
color: #ef4444;
|
|
||||||
background: rgba(239, 68, 68, 0.08);
|
background: rgba(239, 68, 68, 0.08);
|
||||||
|
|
||||||
.capacity-text {
|
.capacity-text {
|
||||||
@@ -352,7 +313,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.cap-closed {
|
&.cap-closed {
|
||||||
color: #999;
|
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
|
||||||
.capacity-text {
|
.capacity-text {
|
||||||
@@ -362,53 +322,76 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
|||||||
}
|
}
|
||||||
|
|
||||||
.capacity-text {
|
.capacity-text {
|
||||||
|
font-size: 20rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ─── Action Section (right) ─── */
|
/* ═══ Tear-off dashed line ═══ */
|
||||||
.action-section {
|
.tear-line {
|
||||||
display: flex;
|
margin: 20rpx -40rpx 16rpx;
|
||||||
flex-direction: column;
|
height: 2rpx;
|
||||||
align-items: center;
|
background: repeating-linear-gradient(
|
||||||
min-width: 100rpx;
|
to right,
|
||||||
flex-shrink: 0;
|
#e0dcd6 0,
|
||||||
gap: 8rpx;
|
#e0dcd6 10rpx,
|
||||||
|
transparent 10rpx,
|
||||||
|
transparent 20rpx
|
||||||
|
);
|
||||||
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-button,
|
/* ═══ Bottom section ═══ */
|
||||||
.action-badge {
|
.ticket-bottom {
|
||||||
padding: 10rpx 20rpx;
|
|
||||||
border-radius: 20rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
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-size: 26rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
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;
|
min-width: 120rpx;
|
||||||
height: 60rpx;
|
height: 60rpx;
|
||||||
transition: all 0.15s;
|
transition: all 0.15s;
|
||||||
|
|
||||||
&:active {
|
&:active {
|
||||||
opacity: 0.85;
|
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 {
|
.badge-booked {
|
||||||
background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
|
background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
|
||||||
color: $primary-dark;
|
color: $primary-dark;
|
||||||
@@ -429,8 +412,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
|
|||||||
color: #bbb;
|
color: #bbb;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-cancel {
|
.cancel-link {
|
||||||
padding: 4rpx 12rpx;
|
|
||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
color: #ef4444;
|
color: #ef4444;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
|||||||
@@ -1,18 +1,24 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="booking-detail-page" :style="{ paddingTop: navBarHeight }">
|
<view class="booking-detail-page" :style="{ paddingTop: navBarHeight }">
|
||||||
<CustomNavBar title="预约详情" show-back />
|
<CustomNavBar :title="isSlotMode ? '预约课程' : '预约详情'" show-back />
|
||||||
|
|
||||||
<!-- Loading state -->
|
<!-- Loading state -->
|
||||||
<view v-if="loading" class="loading-wrap">
|
<view v-if="loading" class="loading-wrap">
|
||||||
<view class="skeleton-card" />
|
<view class="skeleton-card" />
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Booking mode: not found -->
|
||||||
<view v-else-if="!booking" class="empty-wrap">
|
<view v-else-if="!isSlotMode && !booking" class="empty-wrap">
|
||||||
<text class="empty-title">预约不存在</text>
|
<text class="empty-title">预约不存在</text>
|
||||||
</view>
|
</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 -->
|
<!-- Booking info card -->
|
||||||
<view class="info-card">
|
<view class="info-card">
|
||||||
<!-- Status banner -->
|
<!-- Status banner -->
|
||||||
@@ -31,7 +37,7 @@
|
|||||||
<text class="info-value">
|
<text class="info-value">
|
||||||
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
|
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
|
||||||
</text>
|
</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text class="info-label">使用卡种</text>
|
<text class="info-label">使用卡种</text>
|
||||||
<text class="info-value">{{ booking.membership?.cardType?.name }}</text>
|
<text class="info-value">{{ booking.membership?.cardType?.name }}</text>
|
||||||
@@ -99,7 +105,7 @@
|
|||||||
<!-- Content -->
|
<!-- Content -->
|
||||||
<view class="timeline-content">
|
<view class="timeline-content">
|
||||||
<view class="timeline-content-header">
|
<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>
|
<text class="timeline-time">{{ formatDateTime(item.createdAt) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text v-if="item.remark" class="timeline-remark">{{ item.remark }}</text>
|
<text v-if="item.remark" class="timeline-remark">{{ item.remark }}</text>
|
||||||
@@ -139,17 +145,102 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</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>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import type { BookingWithDetails, BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
|
import type {
|
||||||
import { BookingStatus } from '@mp-pilates/shared'
|
BookingWithDetails,
|
||||||
|
BookingWithUser,
|
||||||
|
BookingStatusHistory,
|
||||||
|
TimeSlotWithBookingStatus,
|
||||||
|
MembershipWithCardType,
|
||||||
|
} from '@mp-pilates/shared'
|
||||||
|
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
import { useBookingStore } from '../../stores/booking'
|
import { useBookingStore } from '../../stores/booking'
|
||||||
import { useUserStore } from '../../stores/user'
|
import { useUserStore } from '../../stores/user'
|
||||||
import { getSystemLayout } from '../../utils/system'
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import { isSlotPast } from '../../utils/format'
|
||||||
import {
|
import {
|
||||||
formatDateDisplay,
|
formatDateDisplay,
|
||||||
bookingStatusLabel,
|
bookingStatusLabel,
|
||||||
@@ -157,16 +248,29 @@ import {
|
|||||||
bookingTimelineDotClass,
|
bookingTimelineDotClass,
|
||||||
} from '../../utils/booking-helpers'
|
} from '../../utils/booking-helpers'
|
||||||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
|
||||||
|
|
||||||
const bookingStore = useBookingStore()
|
const bookingStore = useBookingStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
|
|
||||||
const navBarHeight = ref('64px')
|
const navBarHeight = ref('64px')
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// ─── Page mode ────────────────────────────────────────────────────────────
|
||||||
|
const isSlotMode = ref(false)
|
||||||
|
|
||||||
|
// ─── Booking mode state ───────────────────────────────────────────────────
|
||||||
const bookingId = ref('')
|
const bookingId = ref('')
|
||||||
const booking = ref<BookingWithDetails | BookingWithUser | null>(null)
|
const booking = ref<BookingWithDetails | BookingWithUser | null>(null)
|
||||||
const history = ref<BookingStatusHistory[]>([])
|
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 isAdmin = computed(() => userStore.isAdmin)
|
||||||
const showActions = computed(() =>
|
const showActions = computed(() =>
|
||||||
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
|
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)
|
const bookingUser = computed(() => hasUser(booking.value) ? booking.value.user : null)
|
||||||
|
|
||||||
// ─── Status helpers ───────────────────────────────────────────────────────
|
// Slot mode computed
|
||||||
function formatHistoryStatus(status: string): string {
|
const canBook = computed(() => {
|
||||||
return bookingStatusLabel(status)
|
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 {
|
function formatDateTime(dateStr: string): string {
|
||||||
if (!dateStr) return '-'
|
if (!dateStr) return '-'
|
||||||
const d = new Date(dateStr)
|
const d = new Date(dateStr)
|
||||||
@@ -197,10 +313,9 @@ function formatDateTime(dateStr: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Data loading ─────────────────────────────────────────────────────────
|
// ─── Data loading ─────────────────────────────────────────────────────────
|
||||||
async function loadData() {
|
async function loadBookingData() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
// Fetch booking details and history in parallel
|
|
||||||
const [bookingData, historyData] = await Promise.all([
|
const [bookingData, historyData] = await Promise.all([
|
||||||
bookingStore.fetchBookingById(bookingId.value),
|
bookingStore.fetchBookingById(bookingId.value),
|
||||||
bookingStore.fetchBookingHistory(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() {
|
async function handleConfirm() {
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '确认预约',
|
title: '确认预约',
|
||||||
@@ -229,7 +416,7 @@ async function handleConfirm() {
|
|||||||
await bookingStore.confirmBooking(bookingId.value)
|
await bookingStore.confirmBooking(bookingId.value)
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
uni.showToast({ title: '已确认', icon: 'success' })
|
uni.showToast({ title: '已确认', icon: 'success' })
|
||||||
await loadData()
|
await loadBookingData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
const msg = err instanceof Error ? err.message : '操作失败'
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
@@ -251,7 +438,7 @@ async function handleComplete() {
|
|||||||
await bookingStore.completeBooking(bookingId.value)
|
await bookingStore.completeBooking(bookingId.value)
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
uni.showToast({ title: '已核销', icon: 'success' })
|
uni.showToast({ title: '已核销', icon: 'success' })
|
||||||
await loadData()
|
await loadBookingData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
const msg = err instanceof Error ? err.message : '操作失败'
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
@@ -273,7 +460,7 @@ async function handleNoShow() {
|
|||||||
await bookingStore.markNoShow(bookingId.value)
|
await bookingStore.markNoShow(bookingId.value)
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
uni.showToast({ title: '已标记', icon: 'success' })
|
uni.showToast({ title: '已标记', icon: 'success' })
|
||||||
await loadData()
|
await loadBookingData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
const msg = err instanceof Error ? err.message : '操作失败'
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
@@ -291,12 +478,12 @@ async function handleCancel() {
|
|||||||
confirmColor: '#ef4444',
|
confirmColor: '#ef4444',
|
||||||
success: async (res) => {
|
success: async (res) => {
|
||||||
if (!res.confirm) return
|
if (!res.confirm) return
|
||||||
uni.showLoading({ title: '处理中...' })
|
uni.showLoading({ title: '取消中...' })
|
||||||
try {
|
try {
|
||||||
await bookingStore.cancelBooking(bookingId.value)
|
await bookingStore.cancelBooking(bookingId.value)
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
uni.showToast({ title: '已取消', icon: 'success' })
|
uni.showToast({ title: '已取消', icon: 'success' })
|
||||||
await loadData()
|
await loadBookingData()
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
uni.hideLoading()
|
uni.hideLoading()
|
||||||
const msg = err instanceof Error ? err.message : '操作失败'
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
@@ -307,14 +494,28 @@ async function handleCancel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
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) => {
|
onLoad((query) => {
|
||||||
bookingId.value = (query as Record<string, string>).id || ''
|
const q = query as Record<string, string>
|
||||||
if (bookingId.value) {
|
|
||||||
loadData()
|
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>
|
</script>
|
||||||
@@ -361,30 +562,6 @@ onLoad((query) => {
|
|||||||
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
|
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 {
|
.info-section {
|
||||||
padding: 24rpx;
|
padding: 24rpx;
|
||||||
border-bottom: 1rpx solid #f5f5f5;
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
@@ -421,6 +598,34 @@ onLoad((query) => {
|
|||||||
font-weight: 500;
|
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 ────────────────────────────────────── */
|
||||||
.timeline-card {
|
.timeline-card {
|
||||||
margin: 24rpx;
|
margin: 24rpx;
|
||||||
@@ -570,6 +775,10 @@ onLoad((query) => {
|
|||||||
&--cancel {
|
&--cancel {
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&--disabled {
|
||||||
|
background: rgba(0, 0, 0, 0.06);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn-text {
|
.action-btn-text {
|
||||||
@@ -584,5 +793,9 @@ onLoad((query) => {
|
|||||||
.action-btn--noshow & {
|
.action-btn--noshow & {
|
||||||
color: #ef5350;
|
color: #ef5350;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-btn--disabled & {
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -61,6 +61,7 @@
|
|||||||
:time-slot="item"
|
:time-slot="item"
|
||||||
@book="onBookTap"
|
@book="onBookTap"
|
||||||
@cancel="onCancelTap"
|
@cancel="onCancelTap"
|
||||||
|
@card-tap="onSlotCardTap"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -150,7 +151,7 @@ updateLayout()
|
|||||||
// ─── Filtered slots ───────────────────────────────────────
|
// ─── Filtered slots ───────────────────────────────────────
|
||||||
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
|
||||||
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
|
||||||
if (!selectedPeriod.value) return [...slots]
|
if (!selectedPeriod.value) return slots
|
||||||
|
|
||||||
const period = TIME_PERIODS[selectedPeriod.value]
|
const period = TIME_PERIODS[selectedPeriod.value]
|
||||||
return slots.filter((slot) => {
|
return slots.filter((slot) => {
|
||||||
@@ -177,7 +178,19 @@ function onDateSelect(date: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPeriodChange(_period: PeriodKey) {
|
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 ────────────────────────────────────────────
|
// ─── Book flow ────────────────────────────────────────────
|
||||||
@@ -213,7 +226,9 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
|
|||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) {
|
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 {
|
.skeleton-card {
|
||||||
height: 140rpx;
|
height: 220rpx;
|
||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
background: #fff;
|
background: #fff;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -360,6 +375,7 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 28rpx 48rpx;
|
padding: 28rpx 48rpx;
|
||||||
gap: 20rpx;
|
gap: 20rpx;
|
||||||
|
margin: 0 24rpx;
|
||||||
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -48,8 +48,8 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
import { ref, nextTick } from 'vue'
|
||||||
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
import { onShow, onUnmount, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
|
||||||
|
|
||||||
import BrandBanner from '../../components/BrandBanner.vue'
|
import BrandBanner from '../../components/BrandBanner.vue'
|
||||||
import StudioInfo from '../../components/StudioInfo.vue'
|
import StudioInfo from '../../components/StudioInfo.vue'
|
||||||
@@ -86,10 +86,27 @@ const refreshing = ref(false)
|
|||||||
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
|
||||||
const cardShopAnchorId = 'card-shop-anchor'
|
const cardShopAnchorId = 'card-shop-anchor'
|
||||||
const scrollTop = ref(0)
|
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
|
// Refresh all data on every show
|
||||||
onShow(async () => {
|
onShow(async () => {
|
||||||
await refreshData()
|
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() {
|
async function refreshData() {
|
||||||
@@ -118,14 +135,22 @@ async function handleRefresh() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scrollToCardShop() {
|
function scrollToCardShop() {
|
||||||
uni.createSelectorQuery()
|
// Reset first so setting the same value still triggers scroll
|
||||||
.select(`#${cardShopAnchorId}`)
|
scrollTop.value = 0
|
||||||
.boundingClientRect((rect) => {
|
nextTick(() => {
|
||||||
if (rect) {
|
uni.createSelectorQuery()
|
||||||
scrollTop.value = rect.top
|
.select(`#${cardShopAnchorId}`)
|
||||||
}
|
.boundingClientRect()
|
||||||
})
|
.selectViewport()
|
||||||
.exec()
|
.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>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -4,25 +4,12 @@
|
|||||||
<CustomNavBar title="我的" transparent />
|
<CustomNavBar title="我的" transparent />
|
||||||
|
|
||||||
<!-- User card -->
|
<!-- User card -->
|
||||||
<UserCard
|
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
|
||||||
:logged-in="loggedIn"
|
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
|
||||||
:has-profile="hasProfile"
|
|
||||||
:user="user"
|
|
||||||
:stats="stats"
|
|
||||||
:memberships="memberships"
|
|
||||||
:loading="loginLoading"
|
|
||||||
:nav-bar-height="navBarHeight"
|
|
||||||
@login="handleLogin"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Menu section: always visible -->
|
<!-- Menu section: always visible -->
|
||||||
<ProfileMenu
|
<ProfileMenu :is-admin="isAdmin" :require-auth="loggedIn" @clear-cache="handleClearCache" @about="handleAbout"
|
||||||
:is-admin="isAdmin"
|
@require-login="handleLogin" />
|
||||||
:require-auth="loggedIn"
|
|
||||||
@clear-cache="handleClearCache"
|
|
||||||
@about="handleAbout"
|
|
||||||
@require-login="handleLogin"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<!-- Logout button: only when logged in -->
|
<!-- Logout button: only when logged in -->
|
||||||
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
<view v-if="loggedIn" class="profile-page__logout-wrap">
|
||||||
@@ -131,7 +118,6 @@ function handleAbout() {
|
|||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.profile-page {
|
.profile-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: $bg-page;
|
|
||||||
|
|
||||||
&__logout-wrap {
|
&__logout-wrap {
|
||||||
margin: $spacing-xl $spacing-lg $spacing-xl;
|
margin: $spacing-xl $spacing-lg $spacing-xl;
|
||||||
|
|||||||
@@ -299,7 +299,7 @@ onMounted(async () => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
margin: 0 $spacing-lg $spacing-md;
|
margin: 0 $spacing-lg $spacing-md;
|
||||||
padding: 36rpx 32rpx;
|
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;
|
border-radius: $radius-lg;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|
||||||
@@ -551,7 +551,7 @@ onMounted(async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 96rpx;
|
height: 96rpx;
|
||||||
border-radius: 48rpx;
|
border-radius: 48rpx;
|
||||||
background: linear-gradient(135deg, $brand-color, lighten($brand-color, 8%));
|
background: linear-gradient(135deg, $brand-color, #5e5045);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchSlotById(slotId: string) {
|
||||||
|
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slots,
|
slots,
|
||||||
myBookings,
|
myBookings,
|
||||||
@@ -131,6 +136,7 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
completeBooking,
|
completeBooking,
|
||||||
markNoShow,
|
markNoShow,
|
||||||
fetchBookingHistory,
|
fetchBookingHistory,
|
||||||
|
fetchSlotById,
|
||||||
fetchBookingById,
|
fetchBookingById,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -40,8 +40,11 @@ export class TimeSlotController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get(':id')
|
@Get(':id')
|
||||||
getSlotById(@Param('id') id: string) {
|
getSlotById(
|
||||||
return this.timeSlotService.getSlotById(id)
|
@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 {
|
export class TimeSlotService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
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(
|
async getAvailableSlots(
|
||||||
date: string,
|
date: string,
|
||||||
userId?: string,
|
userId?: string,
|
||||||
): Promise<TimeSlotWithBookingStatus[]> {
|
): Promise<TimeSlotWithBookingStatus[]> {
|
||||||
const parsedDate = new Date(date)
|
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({
|
const slots = await this.prisma.timeSlot.findMany({
|
||||||
where: {
|
where: {
|
||||||
date: {
|
date: {
|
||||||
gte: startOfDay,
|
gte: this.toDateOfDay(parsedDate),
|
||||||
lte: endOfDay,
|
lte: this.toEndOfDay(parsedDate),
|
||||||
},
|
},
|
||||||
status: { not: TimeSlotStatus.CLOSED },
|
status: { not: TimeSlotStatus.CLOSED },
|
||||||
},
|
},
|
||||||
@@ -50,44 +85,44 @@ export class TimeSlotService {
|
|||||||
? slot.bookings[0]
|
? slot.bookings[0]
|
||||||
: null
|
: null
|
||||||
|
|
||||||
return {
|
return this.mapToWithBookingStatus(slot, myBooking)
|
||||||
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
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
async getSlotById(id: string) {
|
async getSlotById(id: string, userId?: string): Promise<TimeSlotWithBookingStatus> {
|
||||||
const slot = await this.prisma.timeSlot.findUnique({
|
const slot = await this.prisma.timeSlot.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: { bookings: true },
|
include: {
|
||||||
|
bookings: userId
|
||||||
|
? {
|
||||||
|
where: {
|
||||||
|
userId,
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
},
|
||||||
|
select: { id: true },
|
||||||
|
}
|
||||||
|
: false,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!slot) {
|
if (!slot) {
|
||||||
throw new NotFoundException(`TimeSlot ${id} not found`)
|
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) {
|
async createManualSlot(dto: CreateManualSlotDto) {
|
||||||
const date = new Date(dto.date)
|
const parsedDate = new Date(dto.date + 'T00:00:00Z')
|
||||||
date.setUTCHours(0, 0, 0, 0)
|
|
||||||
|
|
||||||
return this.prisma.timeSlot.create({
|
return this.prisma.timeSlot.create({
|
||||||
data: {
|
data: {
|
||||||
date,
|
date: parsedDate,
|
||||||
startTime: dto.startTime,
|
startTime: dto.startTime,
|
||||||
endTime: dto.endTime,
|
endTime: dto.endTime,
|
||||||
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
|
capacity: dto.capacity ?? DEFAULT_SLOT_CAPACITY,
|
||||||
@@ -156,15 +191,11 @@ export class TimeSlotService {
|
|||||||
*/
|
*/
|
||||||
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
|
async getSchedulePreview(date: string): Promise<ScheduleSlotPreview[]> {
|
||||||
const parsedDate = new Date(date)
|
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)
|
// 1. Check for existing TimeSlot records (all statuses)
|
||||||
const existingSlots = await this.prisma.timeSlot.findMany({
|
const existingSlots = await this.prisma.timeSlot.findMany({
|
||||||
where: {
|
where: {
|
||||||
date: { gte: startOfDay, lte: endOfDay },
|
date: { gte: this.toDateOfDay(parsedDate), lte: this.toEndOfDay(parsedDate) },
|
||||||
},
|
},
|
||||||
orderBy: { startTime: 'asc' },
|
orderBy: { startTime: 'asc' },
|
||||||
})
|
})
|
||||||
@@ -212,17 +243,12 @@ export class TimeSlotService {
|
|||||||
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
|
* - Existing DB slots not referenced → delete (or CLOSE if they have bookings)
|
||||||
*/
|
*/
|
||||||
async publishDaySlots(dto: PublishDaySlotsDto) {
|
async publishDaySlots(dto: PublishDaySlotsDto) {
|
||||||
const parsedDate = new Date(dto.date)
|
const parsedDate = new Date(dto.date + 'T00:00:00Z')
|
||||||
parsedDate.setUTCHours(0, 0, 0, 0)
|
|
||||||
|
|
||||||
const startOfDay = new Date(parsedDate)
|
|
||||||
const endOfDay = new Date(parsedDate)
|
|
||||||
endOfDay.setUTCHours(23, 59, 59, 999)
|
|
||||||
|
|
||||||
return this.prisma.$transaction(async (tx) => {
|
return this.prisma.$transaction(async (tx) => {
|
||||||
// 1. Get existing slots for this date
|
// 1. Get existing slots for this date
|
||||||
const existing = await tx.timeSlot.findMany({
|
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 existingMap = new Map(existing.map((s) => [s.id, s]))
|
||||||
const keptIds = new Set<string>()
|
const keptIds = new Set<string>()
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user