feat: 完善课程订阅
This commit is contained in:
@@ -4,8 +4,10 @@
|
|||||||
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin">
|
<view v-if="!userStore.loggedIn" class="entry-card login-card" @tap="handleLogin">
|
||||||
<view class="entry-content">
|
<view class="entry-content">
|
||||||
<view class="entry-left">
|
<view class="entry-left">
|
||||||
<text class="entry-icon">👋</text>
|
<view class="entry-icon-wrap login-icon">
|
||||||
<view>
|
<view class="icon-user" />
|
||||||
|
</view>
|
||||||
|
<view class="entry-text">
|
||||||
<text class="entry-title">欢迎来到工作室</text>
|
<text class="entry-title">欢迎来到工作室</text>
|
||||||
<text class="entry-subtitle">登录后即可预约课程</text>
|
<text class="entry-subtitle">登录后即可预约课程</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -24,8 +26,10 @@
|
|||||||
>
|
>
|
||||||
<view class="entry-content">
|
<view class="entry-content">
|
||||||
<view class="entry-left">
|
<view class="entry-left">
|
||||||
<text class="entry-icon">✨</text>
|
<view class="entry-icon-wrap trial-icon">
|
||||||
<view>
|
<view class="icon-star" />
|
||||||
|
</view>
|
||||||
|
<view class="entry-text">
|
||||||
<text class="entry-title">初次体验</text>
|
<text class="entry-title">初次体验</text>
|
||||||
<text class="entry-subtitle">专属体验课,了解普拉提</text>
|
<text class="entry-subtitle">专属体验课,了解普拉提</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -42,8 +46,10 @@
|
|||||||
<view class="entry-card active-card" @tap="handleBooking">
|
<view class="entry-card active-card" @tap="handleBooking">
|
||||||
<view class="entry-content">
|
<view class="entry-content">
|
||||||
<view class="entry-left">
|
<view class="entry-left">
|
||||||
<text class="entry-icon">🧘</text>
|
<view class="entry-icon-wrap active-icon">
|
||||||
<view>
|
<view class="icon-clock" />
|
||||||
|
</view>
|
||||||
|
<view class="entry-text">
|
||||||
<text class="entry-title">一键约课</text>
|
<text class="entry-title">一键约课</text>
|
||||||
<text class="entry-subtitle">{{ activeMembershipLabel }}</text>
|
<text class="entry-subtitle">{{ activeMembershipLabel }}</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -60,7 +66,9 @@
|
|||||||
|
|
||||||
<!-- Renew reminder if running low -->
|
<!-- Renew reminder if running low -->
|
||||||
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop">
|
<view v-if="isRunningLow" class="renew-tip" @tap="scrollToCardShop">
|
||||||
<text class="renew-tip-icon">⚠️</text>
|
<view class="renew-tip-icon">
|
||||||
|
<view class="icon-warning" />
|
||||||
|
</view>
|
||||||
<text class="renew-tip-text">课次即将用完,点击续卡保持练习节奏</text>
|
<text class="renew-tip-text">课次即将用完,点击续卡保持练习节奏</text>
|
||||||
<text class="renew-tip-action">续卡 ›</text>
|
<text class="renew-tip-action">续卡 ›</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -74,8 +82,10 @@
|
|||||||
>
|
>
|
||||||
<view class="entry-content">
|
<view class="entry-content">
|
||||||
<view class="entry-left">
|
<view class="entry-left">
|
||||||
<text class="entry-icon">💳</text>
|
<view class="entry-icon-wrap expired-icon">
|
||||||
<view>
|
<view class="icon-card" />
|
||||||
|
</view>
|
||||||
|
<view class="entry-text">
|
||||||
<text class="entry-title">续费会员卡</text>
|
<text class="entry-title">续费会员卡</text>
|
||||||
<text class="entry-subtitle">您的卡已到期,续卡继续练习</text>
|
<text class="entry-subtitle">您的卡已到期,续卡继续练习</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -174,24 +184,24 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
border-radius: 16rpx;
|
border-radius: 16rpx;
|
||||||
padding: 36rpx 32rpx;
|
padding: 36rpx 32rpx;
|
||||||
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.10);
|
box-shadow: 0 4rpx 24rpx rgba(0, 0, 0, 0.08);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-card {
|
.login-card {
|
||||||
background: linear-gradient(135deg, #1a1a2e, #2d2d5e);
|
background: linear-gradient(135deg, #1a1a2e 0%, #2d2d5e 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.trial-card {
|
.trial-card {
|
||||||
background: linear-gradient(135deg, #2d2d5e, #4a3f7a);
|
background: linear-gradient(135deg, #2d2d5e 0%, #4a3f7a 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.active-card {
|
.active-card {
|
||||||
background: linear-gradient(135deg, #1a1a2e, #3a2a1a);
|
background: linear-gradient(135deg, #2a3a4a 0%, #1a2a3a 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.expired-card {
|
.expired-card {
|
||||||
background: linear-gradient(135deg, #4a4a4a, #2a2a2a);
|
background: linear-gradient(135deg, #4a4a4a 0%, #2a2a2a 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-content {
|
.entry-content {
|
||||||
@@ -204,59 +214,196 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
.entry-left {
|
.entry-left {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 24rpx;
|
gap: 28rpx;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-icon {
|
.entry-icon-wrap {
|
||||||
font-size: 56rpx;
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login-icon {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trial-icon {
|
||||||
|
background: rgba(255, 215, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.active-icon {
|
||||||
|
background: rgba(168, 196, 206, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expired-icon {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Icon shapes (pure CSS) ── */
|
||||||
|
|
||||||
|
/* User icon: head + shoulders */
|
||||||
|
.icon-user {
|
||||||
|
position: relative;
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 20rpx;
|
||||||
|
height: 20rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
bottom: 0;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
width: 28rpx;
|
||||||
|
height: 14rpx;
|
||||||
|
border-radius: 14rpx 14rpx 0 0;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Star icon - diamond shape */
|
||||||
|
.icon-star {
|
||||||
|
position: relative;
|
||||||
|
width: 32rpx;
|
||||||
|
height: 32rpx;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%) rotate(45deg);
|
||||||
|
width: 24rpx;
|
||||||
|
height: 24rpx;
|
||||||
|
background: #ffd700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Clock icon - circle with dot */
|
||||||
|
.icon-clock {
|
||||||
|
position: relative;
|
||||||
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
border: 3rpx solid #fff;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card icon */
|
||||||
|
.icon-card {
|
||||||
|
position: relative;
|
||||||
|
width: 36rpx;
|
||||||
|
height: 26rpx;
|
||||||
|
border-radius: 4rpx;
|
||||||
|
border: 3rpx solid #fff;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 12rpx;
|
||||||
|
height: 6rpx;
|
||||||
|
border-radius: 2rpx;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Warning triangle */
|
||||||
|
.icon-warning {
|
||||||
|
position: relative;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-left: 12rpx solid transparent;
|
||||||
|
border-right: 12rpx solid transparent;
|
||||||
|
border-bottom: 20rpx solid #e8a87c;
|
||||||
|
}
|
||||||
|
|
||||||
|
.entry-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-title {
|
.entry-title {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 34rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 700;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
margin-bottom: 8rpx;
|
margin-bottom: 8rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-subtitle {
|
.entry-subtitle {
|
||||||
display: block;
|
display: block;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
color: rgba(255, 255, 255, 0.65);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-btn {
|
.entry-btn {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
padding: 16rpx 32rpx;
|
padding: 18rpx 36rpx;
|
||||||
border-radius: 40rpx;
|
border-radius: 40rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: linear-gradient(180deg, rgba(255,255,255,0.2) 0%, transparent 100%);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.entry-btn-text {
|
.entry-btn-text {
|
||||||
font-size: 28rpx;
|
font-size: 28rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn {
|
.login-btn,
|
||||||
background: $primary-dark;
|
.trial-btn,
|
||||||
}
|
|
||||||
|
|
||||||
.trial-btn {
|
|
||||||
background: $primary-dark;
|
|
||||||
}
|
|
||||||
|
|
||||||
.book-btn {
|
.book-btn {
|
||||||
background: $primary-dark;
|
background: $primary-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-btn {
|
.renew-btn {
|
||||||
background: #888;
|
background: #666;
|
||||||
}
|
}
|
||||||
|
|
||||||
.login-btn .entry-btn-text,
|
.login-btn .entry-btn-text,
|
||||||
@@ -278,7 +425,7 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.trial-badge {
|
.trial-badge {
|
||||||
background: $primary-dark;
|
background: linear-gradient(135deg, #ffd700, #ffaa00);
|
||||||
color: #1a1a2e;
|
color: #1a1a2e;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,11 +443,15 @@ const lowestRemainingTimes = computed(() => {
|
|||||||
padding: 20rpx 24rpx;
|
padding: 20rpx 24rpx;
|
||||||
background: #fff8f0;
|
background: #fff8f0;
|
||||||
border-radius: 12rpx;
|
border-radius: 12rpx;
|
||||||
border: 1rpx solid #f0d9bc;
|
border: 1rpx solid rgba(240, 180, 100, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.renew-tip-icon {
|
.renew-tip-icon {
|
||||||
font-size: 28rpx;
|
width: 36rpx;
|
||||||
|
height: 36rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
{
|
{
|
||||||
|
"easycom": {
|
||||||
|
"autoscan": true
|
||||||
|
},
|
||||||
"pages": [
|
"pages": [
|
||||||
{
|
{
|
||||||
"path": "pages/home/index",
|
"path": "pages/home/index",
|
||||||
@@ -12,6 +15,12 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/booking/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/profile/index",
|
"path": "pages/profile/index",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -48,6 +57,12 @@
|
|||||||
"navigationStyle": "custom"
|
"navigationStyle": "custom"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/bookings",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/admin/schedule",
|
"path": "pages/admin/schedule",
|
||||||
"style": {
|
"style": {
|
||||||
|
|||||||
785
packages/app/src/pages/admin/bookings.vue
Normal file
785
packages/app/src/pages/admin/bookings.vue
Normal file
@@ -0,0 +1,785 @@
|
|||||||
|
<template>
|
||||||
|
<view class="admin-bookings-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="课程管理" show-back />
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<view class="stats-row">
|
||||||
|
<view class="stat-item" @tap="switchTab(null)">
|
||||||
|
<text class="stat-num">{{ stats.total }}</text>
|
||||||
|
<text class="stat-label">全部</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item stat-item--pending" @tap="switchTab('PENDING_CONFIRMATION')">
|
||||||
|
<text class="stat-num">{{ stats.pending }}</text>
|
||||||
|
<text class="stat-label">待确认</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item stat-item--confirmed" @tap="switchTab('CONFIRMED')">
|
||||||
|
<text class="stat-num">{{ stats.confirmed }}</text>
|
||||||
|
<text class="stat-label">已确认</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item stat-item--completed" @tap="switchTab('COMPLETED')">
|
||||||
|
<text class="stat-num">{{ stats.completed }}</text>
|
||||||
|
<text class="stat-label">已完成</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Tab filter bar -->
|
||||||
|
<view class="filter-bar">
|
||||||
|
<view
|
||||||
|
v-for="tab in filterTabs"
|
||||||
|
:key="tab.value ?? 'all'"
|
||||||
|
class="filter-tab"
|
||||||
|
:class="{ active: activeFilter === tab.value }"
|
||||||
|
@tap="switchTab(tab.value)"
|
||||||
|
>
|
||||||
|
<text class="filter-tab-text">{{ tab.label }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Booking list -->
|
||||||
|
<scroll-view
|
||||||
|
class="scroll"
|
||||||
|
scroll-y
|
||||||
|
refresher-enabled
|
||||||
|
:refresher-triggered="refreshing"
|
||||||
|
@refresherrefresh="onRefresh"
|
||||||
|
>
|
||||||
|
<!-- Loading -->
|
||||||
|
<view v-if="loading && !refreshing" class="loading-wrap">
|
||||||
|
<view v-for="i in 4" :key="i" class="skeleton-card">
|
||||||
|
<view class="skeleton-stripe" />
|
||||||
|
<view class="skeleton-body">
|
||||||
|
<view class="skeleton-line skeleton-line--long" />
|
||||||
|
<view class="skeleton-line skeleton-line--medium" />
|
||||||
|
<view class="skeleton-line skeleton-line--short" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Empty -->
|
||||||
|
<view v-else-if="bookings.length === 0" class="empty-wrap">
|
||||||
|
<view class="empty-icon-circle">
|
||||||
|
<text class="empty-icon-text">📋</text>
|
||||||
|
</view>
|
||||||
|
<text class="empty-title">暂无预约</text>
|
||||||
|
<text class="empty-sub">当前筛选条件下没有预约记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Booking cards -->
|
||||||
|
<view v-else class="list">
|
||||||
|
<view
|
||||||
|
v-for="booking in bookings"
|
||||||
|
:key="booking.id"
|
||||||
|
class="booking-card"
|
||||||
|
@tap="goDetail(booking)"
|
||||||
|
>
|
||||||
|
<!-- Left stripe -->
|
||||||
|
<view class="booking-stripe" :class="bookingStatusStripeClass(booking.status)" />
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<view class="booking-content">
|
||||||
|
<!-- Header row -->
|
||||||
|
<view class="booking-header">
|
||||||
|
<view class="student-info">
|
||||||
|
<text class="student-name">{{ booking.user?.nickname || '匿名用户' }}</text>
|
||||||
|
<text v-if="booking.user?.phone" class="student-phone">{{ booking.user.phone }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="status-badge" :class="bookingStatusBadgeClass(booking.status)">
|
||||||
|
<text class="status-text">{{ bookingStatusLabel(booking.status) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Course info -->
|
||||||
|
<view class="course-info">
|
||||||
|
<text class="course-date">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
||||||
|
<text class="course-time">{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Card type -->
|
||||||
|
<view class="card-info">
|
||||||
|
<text class="card-label">使用卡种</text>
|
||||||
|
<text class="card-name">{{ booking.membership?.cardType?.name }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<view v-if="booking.status === 'PENDING_CONFIRMATION'" class="action-row">
|
||||||
|
<view class="action-btn action-btn--confirm" @tap.stop="handleConfirm(booking)">
|
||||||
|
<text class="action-btn-text">确认预约</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn action-btn--cancel" @tap.stop="handleCancel(booking)">
|
||||||
|
<text class="action-btn-text">取消</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else-if="booking.status === 'CONFIRMED'" class="action-row">
|
||||||
|
<view class="action-btn action-btn--complete" @tap.stop="handleComplete(booking)">
|
||||||
|
<text class="action-btn-text">核销完成</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn action-btn--noshow" @tap.stop="handleNoShow(booking)">
|
||||||
|
<text class="action-btn-text">标记未到</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Timeline preview -->
|
||||||
|
<view v-if="getHistory(booking.id).length > 0" class="timeline-preview">
|
||||||
|
<view
|
||||||
|
v-for="(h, idx) in getHistory(booking.id).slice(-2)"
|
||||||
|
:key="idx"
|
||||||
|
class="timeline-item"
|
||||||
|
>
|
||||||
|
<text class="timeline-dot" :class="bookingTimelineDotClass(h.toStatus)" />
|
||||||
|
<text class="timeline-text">{{ formatTimelineText(h) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Load more / pagination -->
|
||||||
|
<view v-if="bookings.length > 0 && hasMore" class="load-more" @tap="loadMore">
|
||||||
|
<text class="load-more-text">加载更多</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="scroll-bottom-spacer" />
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted } from 'vue'
|
||||||
|
import type { BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
|
||||||
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
|
import { useBookingStore } from '../../stores/booking'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import {
|
||||||
|
formatDateDisplay,
|
||||||
|
bookingStatusLabel,
|
||||||
|
bookingStatusBadgeClass,
|
||||||
|
bookingStatusStripeClass,
|
||||||
|
bookingTimelineDotClass,
|
||||||
|
} from '../../utils/booking-helpers'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
|
// ─── Store & Nav ──────────────────────────────────────────────────────────
|
||||||
|
const bookingStore = useBookingStore()
|
||||||
|
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
const refreshing = ref(false)
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
// ─── Filter state ─────────────────────────────────────────────────────────
|
||||||
|
type FilterValue = string | null
|
||||||
|
|
||||||
|
const filterTabs: { label: string; value: FilterValue }[] = [
|
||||||
|
{ label: '全部', value: null },
|
||||||
|
{ label: '待确认', value: 'PENDING_CONFIRMATION' },
|
||||||
|
{ label: '已确认', value: 'CONFIRMED' },
|
||||||
|
{ label: '已完成', value: 'COMPLETED' },
|
||||||
|
{ label: '已取消', value: 'CANCELLED' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const activeFilter = ref<FilterValue>(null)
|
||||||
|
|
||||||
|
// ─── Pagination ───────────────────────────────────────────────────────────
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = 20
|
||||||
|
const hasMore = ref(false)
|
||||||
|
const totalCount = ref(0)
|
||||||
|
|
||||||
|
// ─── Data ────────────────────────────────────────────────────────────────
|
||||||
|
const bookings = ref<BookingWithUser[]>([])
|
||||||
|
const allBookingsCache = ref<BookingWithUser[]>([]) // cache for stats
|
||||||
|
const historyMap = ref<Record<string, BookingStatusHistory[]>>({})
|
||||||
|
|
||||||
|
// ─── Computed stats ──────────────────────────────────────────────────────
|
||||||
|
const stats = computed(() => {
|
||||||
|
const cache = allBookingsCache.value
|
||||||
|
return {
|
||||||
|
total: cache.length,
|
||||||
|
pending: cache.filter((b) => b.status === BookingStatus.PENDING_CONFIRMATION).length,
|
||||||
|
confirmed: cache.filter((b) => b.status === BookingStatus.CONFIRMED).length,
|
||||||
|
completed: cache.filter(
|
||||||
|
(b) => b.status === BookingStatus.COMPLETED || b.status === BookingStatus.NO_SHOW,
|
||||||
|
).length,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ─── Timeline helpers ─────────────────────────────────────────────────────
|
||||||
|
function getHistory(bookingId: string): BookingStatusHistory[] {
|
||||||
|
return historyMap.value[bookingId] || []
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimelineText(h: BookingStatusHistory): string {
|
||||||
|
const d = new Date(h.createdAt)
|
||||||
|
const time = `${d.getMonth() + 1}月${d.getDate()}日 ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`
|
||||||
|
return `${time} ${h.remark || bookingStatusLabel(h.toStatus)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data loading ─────────────────────────────────────────────────────────
|
||||||
|
async function loadBookings(append = false) {
|
||||||
|
if (loading.value) return
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
try {
|
||||||
|
const page = append ? currentPage.value + 1 : 1
|
||||||
|
const result = await bookingStore.fetchAllAdminBookings(page, pageSize, activeFilter.value ?? undefined)
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
bookings.value = [...bookings.value, ...(result.data as BookingWithUser[])]
|
||||||
|
currentPage.value = page
|
||||||
|
} else {
|
||||||
|
bookings.value = result.data as BookingWithUser[]
|
||||||
|
currentPage.value = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCount.value = result.total
|
||||||
|
hasMore.value = bookings.value.length < result.total
|
||||||
|
|
||||||
|
// Fetch history for each booking
|
||||||
|
if (!append) {
|
||||||
|
await Promise.all(
|
||||||
|
bookings.value.map((b) => fetchHistory(b.id)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update cache for stats
|
||||||
|
if (!append && activeFilter.value === null) {
|
||||||
|
allBookingsCache.value = bookings.value
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load bookings failed:', err)
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHistory(bookingId: string) {
|
||||||
|
try {
|
||||||
|
const history = await bookingStore.fetchBookingHistory(bookingId)
|
||||||
|
historyMap.value[bookingId] = history
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Fetch history failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAllForStats() {
|
||||||
|
try {
|
||||||
|
// Load all statuses for stats display
|
||||||
|
const result = await bookingStore.fetchAllAdminBookings(1, 200, undefined)
|
||||||
|
allBookingsCache.value = result.data as BookingWithUser[]
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load stats failed:', err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onRefresh() {
|
||||||
|
refreshing.value = true
|
||||||
|
await Promise.all([loadBookings(false), loadAllForStats()])
|
||||||
|
refreshing.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMore() {
|
||||||
|
if (!hasMore.value) return
|
||||||
|
await loadBookings(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Tab switching ───────────────────────────────────────────────────────
|
||||||
|
function switchTab(value: FilterValue) {
|
||||||
|
if (activeFilter.value === value) return
|
||||||
|
activeFilter.value = value
|
||||||
|
loadBookings(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||||
|
async function handleConfirm(booking: BookingWithUser) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认预约',
|
||||||
|
content: `确认 ${booking.user?.nickname} 的预约?确认后将扣除会员次数。`,
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.confirmBooking(booking.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已确认', icon: 'success' })
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete(booking: BookingWithUser) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '核销完成',
|
||||||
|
content: `标记 ${booking.user?.nickname} 的课程为已完成?`,
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.completeBooking(booking.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已核销', icon: 'success' })
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNoShow(booking: BookingWithUser) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '标记未到',
|
||||||
|
content: `标记 ${booking.user?.nickname} 的课程为未出席?`,
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.markNoShow(booking.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已标记', icon: 'success' })
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel(booking: BookingWithUser) {
|
||||||
|
uni.showModal({
|
||||||
|
title: '取消预约',
|
||||||
|
content: `取消 ${booking.user?.nickname} 的预约?`,
|
||||||
|
confirmText: '确认取消',
|
||||||
|
confirmColor: '#ef4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.cancelBooking(booking.id)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已取消', icon: 'success' })
|
||||||
|
await onRefresh()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goDetail(booking: BookingWithUser) {
|
||||||
|
uni.navigateTo({
|
||||||
|
url: `/pages/booking/detail?id=${booking.id}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
loadBookings(false)
|
||||||
|
loadAllForStats()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.admin-bookings-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $primary-bg;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Stats row ──────────────────────────────────────── */
|
||||||
|
.stats-row {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
padding: 24rpx 16rpx;
|
||||||
|
gap: 8rpx;
|
||||||
|
border-bottom: 1rpx solid $primary-border;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding: 16rpx 8rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
transition: background 0.15s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-num {
|
||||||
|
font-size: 36rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #4A4035;
|
||||||
|
font-family: 'DIN Alternate', 'Helvetica Neue', Arial, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #A09080;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item--pending .stat-num { color: #f59e0b; }
|
||||||
|
.stat-item--confirmed .stat-num { color: $primary-dark; }
|
||||||
|
.stat-item--completed .stat-num { color: #66bb6a; }
|
||||||
|
|
||||||
|
/* ── Filter bar ────────────────────────────────────── */
|
||||||
|
.filter-bar {
|
||||||
|
display: flex;
|
||||||
|
background: #fff;
|
||||||
|
padding: 0 16rpx 16rpx;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
padding: 10rpx 20rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
transition: all 0.15s;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $primary-dark;
|
||||||
|
|
||||||
|
.filter-tab-text {
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab-text {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Scroll ──────────────────────────────────────────── */
|
||||||
|
.scroll {
|
||||||
|
flex: 1;
|
||||||
|
height: calc(100vh - 300rpx);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading skeleton ────────────────────────────────── */
|
||||||
|
.loading-wrap {
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
border-radius: 16rpx;
|
||||||
|
background: #fff;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-stripe {
|
||||||
|
width: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
background: #eee;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-body {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28rpx 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-line {
|
||||||
|
height: 28rpx;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
|
||||||
|
&--long { width: 60%; }
|
||||||
|
&--medium { width: 40%; }
|
||||||
|
&--short { width: 30%; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty ───────────────────────────────────────────── */
|
||||||
|
.empty-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 120rpx 40rpx;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-circle {
|
||||||
|
width: 140rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: $primary-border;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon-text {
|
||||||
|
font-size: 56rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-sub {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── List ────────────────────────────────────────────── */
|
||||||
|
.list {
|
||||||
|
padding: 20rpx 24rpx 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Booking card ────────────────────────────────────── */
|
||||||
|
.booking-card {
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-stripe {
|
||||||
|
width: 8rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.stripe--pending { background: #f59e0b; }
|
||||||
|
&.stripe--confirmed { background: $primary-dark; }
|
||||||
|
&.stripe--completed { background: #66bb6a; }
|
||||||
|
&.stripe--cancelled { background: #e0e0e0; }
|
||||||
|
&.stripe--noshow { background: #ef5350; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 24rpx 20rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 14rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.booking-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-name {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1a1a1a;
|
||||||
|
}
|
||||||
|
|
||||||
|
.student-phone {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Status badge */
|
||||||
|
.status-badge {
|
||||||
|
padding: 8rpx 18rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.badge--pending { background: rgba(245, 158, 11, 0.12); }
|
||||||
|
&.badge--confirmed { background: rgba(201, 168, 124, 0.12); }
|
||||||
|
&.badge--completed { background: rgba(102, 187, 106, 0.12); }
|
||||||
|
&.badge--cancelled { background: rgba(0, 0, 0, 0.04); }
|
||||||
|
&.badge--noshow { background: rgba(239, 83, 80, 0.1); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-text {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.badge--pending & { color: #f59e0b; }
|
||||||
|
.badge--confirmed & { color: $primary-dark; }
|
||||||
|
.badge--completed & { color: #66bb6a; }
|
||||||
|
.badge--cancelled & { color: #bbb; }
|
||||||
|
.badge--noshow & { color: #ef5350; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Course info */
|
||||||
|
.course-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-date {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.course-time {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card info */
|
||||||
|
.card-info {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-label {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #666;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Action buttons */
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 12rpx;
|
||||||
|
padding-top: 8rpx;
|
||||||
|
border-top: 1rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 16rpx 0;
|
||||||
|
border-radius: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm {
|
||||||
|
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--cancel {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--complete {
|
||||||
|
background: linear-gradient(135deg, #66bb6a, #4caf50);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--noshow {
|
||||||
|
background: rgba(239, 83, 80, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.action-btn--cancel & {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--noshow & {
|
||||||
|
color: #ef5350;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Timeline preview */
|
||||||
|
.timeline-preview {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6rpx;
|
||||||
|
padding-top: 8rpx;
|
||||||
|
border-top: 1rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 8rpx;
|
||||||
|
height: 8rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.dot--pending { background: #f59e0b; }
|
||||||
|
&.dot--confirmed { background: $primary-dark; }
|
||||||
|
&.dot--completed { background: #66bb6a; }
|
||||||
|
&.dot--cancelled { background: #e0e0e0; }
|
||||||
|
&.dot--noshow { background: #ef5350; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Load more */
|
||||||
|
.load-more {
|
||||||
|
padding: 32rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.load-more-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: $primary-dark;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bottom spacer */
|
||||||
|
.scroll-bottom-spacer {
|
||||||
|
height: 48rpx;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -34,6 +34,21 @@
|
|||||||
|
|
||||||
<!-- List: schedule -->
|
<!-- List: schedule -->
|
||||||
<view class="list">
|
<view class="list">
|
||||||
|
<view class="list-item" @tap="navigate('/pages/admin/bookings')">
|
||||||
|
<view class="item-left">
|
||||||
|
<view class="item-icon-wrap icon--bookings">
|
||||||
|
<text class="item-icon-text">▣</text>
|
||||||
|
</view>
|
||||||
|
<view class="item-text-group">
|
||||||
|
<text class="item-title">预约管理</text>
|
||||||
|
<text class="item-desc">查看/确认/核销学员预约</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="item-arrow">
|
||||||
|
<text class="arrow-text">›</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
|
<view class="list-item" @tap="navigate('/pages/admin/schedule')">
|
||||||
<view class="item-left">
|
<view class="item-left">
|
||||||
<view class="item-icon-wrap icon--schedule">
|
<view class="item-icon-wrap icon--schedule">
|
||||||
@@ -309,6 +324,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Icon variants — warm muted tones */
|
/* Icon variants — warm muted tones */
|
||||||
|
.icon--bookings { background: linear-gradient(135deg, #C4A87E, #B49868); }
|
||||||
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
|
.icon--schedule { background: linear-gradient(135deg, #8B9E7E, #7A8E6E); }
|
||||||
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
|
.icon--template { background: linear-gradient(135deg, #A090C0, #9080B0); }
|
||||||
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
.icon--members { background: linear-gradient(135deg, $primary-color, $primary-dark); }
|
||||||
|
|||||||
588
packages/app/src/pages/booking/detail.vue
Normal file
588
packages/app/src/pages/booking/detail.vue
Normal file
@@ -0,0 +1,588 @@
|
|||||||
|
<template>
|
||||||
|
<view class="booking-detail-page" :style="{ paddingTop: navBarHeight }">
|
||||||
|
<CustomNavBar title="预约详情" 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">
|
||||||
|
<text class="empty-title">预约不存在</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<!-- Booking info card -->
|
||||||
|
<view class="info-card">
|
||||||
|
<!-- Status banner -->
|
||||||
|
<view class="status-banner" :class="bookingStatusBannerClass(booking.status)">
|
||||||
|
<text class="status-banner-text">{{ bookingStatusLabel(booking.status) }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Course info -->
|
||||||
|
<view class="info-section">
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">课程日期</text>
|
||||||
|
<text class="info-value">{{ formatDateDisplay(booking.timeSlot.date) }}</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">课程时间</text>
|
||||||
|
<text class="info-value">
|
||||||
|
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">使用卡种</text>
|
||||||
|
<text class="info-value">{{ booking.membership?.cardType?.name }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- User info (for admin view) -->
|
||||||
|
<view v-if="isAdmin && bookingUser" class="info-section">
|
||||||
|
<view class="section-title">学员信息</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">学员姓名</text>
|
||||||
|
<text class="info-value">{{ bookingUser.nickname || '匿名用户' }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="bookingUser.phone" class="info-row">
|
||||||
|
<text class="info-label">联系电话</text>
|
||||||
|
<text class="info-value">{{ bookingUser.phone }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Timestamps -->
|
||||||
|
<view class="info-section">
|
||||||
|
<view class="section-title">时间记录</view>
|
||||||
|
<view class="info-row">
|
||||||
|
<text class="info-label">预约时间</text>
|
||||||
|
<text class="info-value">{{ formatDateTime(booking.createdAt) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.confirmedAt" class="info-row">
|
||||||
|
<text class="info-label">确认时间</text>
|
||||||
|
<text class="info-value">{{ formatDateTime(booking.confirmedAt) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.completedAt" class="info-row">
|
||||||
|
<text class="info-label">核销时间</text>
|
||||||
|
<text class="info-value">{{ formatDateTime(booking.completedAt) }}</text>
|
||||||
|
</view>
|
||||||
|
<view v-if="booking.cancelledAt" class="info-row">
|
||||||
|
<text class="info-label">取消时间</text>
|
||||||
|
<text class="info-value">{{ formatDateTime(booking.cancelledAt) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Status timeline -->
|
||||||
|
<view class="timeline-card">
|
||||||
|
<view class="timeline-header">
|
||||||
|
<text class="timeline-title">状态流转记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="history.length === 0" class="timeline-empty">
|
||||||
|
<text class="timeline-empty-text">暂无流转记录</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="timeline">
|
||||||
|
<view
|
||||||
|
v-for="(item, idx) in history"
|
||||||
|
:key="item.id"
|
||||||
|
class="timeline-item"
|
||||||
|
:class="{ 'timeline-item--last': idx === history.length - 1 }"
|
||||||
|
>
|
||||||
|
<!-- Dot -->
|
||||||
|
<view class="timeline-dot-wrap">
|
||||||
|
<view class="timeline-dot" :class="bookingTimelineDotClass(item.toStatus)" />
|
||||||
|
<view v-if="idx < history.length - 1" class="timeline-line" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<view class="timeline-content">
|
||||||
|
<view class="timeline-content-header">
|
||||||
|
<text class="timeline-status">{{ formatHistoryStatus(item.toStatus) }}</text>
|
||||||
|
<text class="timeline-time">{{ formatDateTime(item.createdAt) }}</text>
|
||||||
|
</view>
|
||||||
|
<text v-if="item.remark" class="timeline-remark">{{ item.remark }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Action buttons -->
|
||||||
|
<view v-if="showActions" class="action-bar">
|
||||||
|
<!-- Pending: confirm button for admin -->
|
||||||
|
<view
|
||||||
|
v-if="booking.status === 'PENDING_CONFIRMATION' && isAdmin"
|
||||||
|
class="action-btn action-btn--confirm"
|
||||||
|
@tap="handleConfirm"
|
||||||
|
>
|
||||||
|
<text class="action-btn-text">确认预约</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- Confirmed: complete / noshow buttons for admin -->
|
||||||
|
<view v-if="booking.status === 'CONFIRMED' && isAdmin" class="action-row">
|
||||||
|
<view class="action-btn action-btn--complete" @tap="handleComplete">
|
||||||
|
<text class="action-btn-text">核销完成</text>
|
||||||
|
</view>
|
||||||
|
<view class="action-btn action-btn--noshow" @tap="handleNoShow">
|
||||||
|
<text class="action-btn-text">标记未到</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- User can cancel if pending or confirmed -->
|
||||||
|
<view
|
||||||
|
v-if="booking.status === 'PENDING_CONFIRMATION' || booking.status === 'CONFIRMED'"
|
||||||
|
class="action-btn action-btn--cancel"
|
||||||
|
@tap="handleCancel"
|
||||||
|
>
|
||||||
|
<text class="action-btn-text">取消预约</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
</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 { useBookingStore } from '../../stores/booking'
|
||||||
|
import { useUserStore } from '../../stores/user'
|
||||||
|
import { getSystemLayout } from '../../utils/system'
|
||||||
|
import {
|
||||||
|
formatDateDisplay,
|
||||||
|
bookingStatusLabel,
|
||||||
|
bookingStatusBannerClass,
|
||||||
|
bookingTimelineDotClass,
|
||||||
|
} from '../../utils/booking-helpers'
|
||||||
|
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||||||
|
|
||||||
|
const bookingStore = useBookingStore()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
|
||||||
|
const navBarHeight = ref('64px')
|
||||||
|
const loading = ref(false)
|
||||||
|
const bookingId = ref('')
|
||||||
|
const booking = ref<BookingWithDetails | BookingWithUser | null>(null)
|
||||||
|
const history = ref<BookingStatusHistory[]>([])
|
||||||
|
|
||||||
|
const isAdmin = computed(() => userStore.isAdmin)
|
||||||
|
const showActions = computed(() =>
|
||||||
|
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
|
||||||
|
booking.value?.status === BookingStatus.CONFIRMED,
|
||||||
|
)
|
||||||
|
|
||||||
|
// Type guard to check if booking has user property
|
||||||
|
function hasUser(b: BookingWithDetails | BookingWithUser | null): b is BookingWithUser {
|
||||||
|
return b !== null && 'user' in b
|
||||||
|
}
|
||||||
|
|
||||||
|
const bookingUser = computed(() => hasUser(booking.value) ? booking.value.user : null)
|
||||||
|
|
||||||
|
// ─── Status helpers ───────────────────────────────────────────────────────
|
||||||
|
function formatHistoryStatus(status: string): string {
|
||||||
|
return bookingStatusLabel(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(dateStr: string): string {
|
||||||
|
if (!dateStr) return '-'
|
||||||
|
const d = new Date(dateStr)
|
||||||
|
const y = d.getFullYear()
|
||||||
|
const m = String(d.getMonth() + 1).padStart(2, '0')
|
||||||
|
const day = String(d.getDate()).padStart(2, '0')
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0')
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, '0')
|
||||||
|
return `${y}-${m}-${day} ${hh}:${mm}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Data loading ─────────────────────────────────────────────────────────
|
||||||
|
async function loadData() {
|
||||||
|
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),
|
||||||
|
])
|
||||||
|
|
||||||
|
booking.value = bookingData
|
||||||
|
history.value = historyData
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Load booking detail failed:', err)
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Actions ──────────────────────────────────────────────────────────────
|
||||||
|
async function handleConfirm() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '确认预约',
|
||||||
|
content: '确认该预约?确认后将扣除会员次数。',
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.confirmBooking(bookingId.value)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已确认', icon: 'success' })
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleComplete() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '核销完成',
|
||||||
|
content: '标记该课程为已完成?',
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.completeBooking(bookingId.value)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已核销', icon: 'success' })
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleNoShow() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '标记未到',
|
||||||
|
content: '标记该学员未出席?',
|
||||||
|
confirmText: '确认',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.markNoShow(bookingId.value)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已标记', icon: 'success' })
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCancel() {
|
||||||
|
uni.showModal({
|
||||||
|
title: '取消预约',
|
||||||
|
content: '确定要取消该预约?',
|
||||||
|
confirmText: '确认取消',
|
||||||
|
confirmColor: '#ef4444',
|
||||||
|
success: async (res) => {
|
||||||
|
if (!res.confirm) return
|
||||||
|
uni.showLoading({ title: '处理中...' })
|
||||||
|
try {
|
||||||
|
await bookingStore.cancelBooking(bookingId.value)
|
||||||
|
uni.hideLoading()
|
||||||
|
uni.showToast({ title: '已取消', icon: 'success' })
|
||||||
|
await loadData()
|
||||||
|
} catch (err: unknown) {
|
||||||
|
uni.hideLoading()
|
||||||
|
const msg = err instanceof Error ? err.message : '操作失败'
|
||||||
|
uni.showToast({ title: msg, icon: 'none' })
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Lifecycle ────────────────────────────────────────────────────────────
|
||||||
|
onMounted(() => {
|
||||||
|
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||||||
|
})
|
||||||
|
|
||||||
|
onLoad((query) => {
|
||||||
|
bookingId.value = (query as Record<string, string>).id || ''
|
||||||
|
if (bookingId.value) {
|
||||||
|
loadData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.booking-detail-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $primary-bg;
|
||||||
|
padding-bottom: 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Loading ─────────────────────────────────────────── */
|
||||||
|
.loading-wrap {
|
||||||
|
padding: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.skeleton-card {
|
||||||
|
height: 300rpx;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
background: linear-gradient(90deg, #f0f0f0 25%, #e8e8e8 50%, #f0f0f0 75%);
|
||||||
|
background-size: 400% 100%;
|
||||||
|
animation: shimmer 1.4s infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty ───────────────────────────────────────────── */
|
||||||
|
.empty-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
padding: 120rpx 40rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
font-size: 32rpx;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Info card ────────────────────────────────────────── */
|
||||||
|
.info-card {
|
||||||
|
margin: 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
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;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #A09080;
|
||||||
|
margin-bottom: 16rpx;
|
||||||
|
letter-spacing: 1rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-label {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #999;
|
||||||
|
}
|
||||||
|
|
||||||
|
.info-value {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #333;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Timeline card ────────────────────────────────────── */
|
||||||
|
.timeline-card {
|
||||||
|
margin: 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-header {
|
||||||
|
padding: 24rpx;
|
||||||
|
border-bottom: 1rpx solid #f5f5f5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-title {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-empty {
|
||||||
|
padding: 48rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-empty-text {
|
||||||
|
font-size: 26rpx;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline {
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot-wrap {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-dot {
|
||||||
|
width: 16rpx;
|
||||||
|
height: 16rpx;
|
||||||
|
border-radius: 50%;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.dot--pending { background: #f59e0b; }
|
||||||
|
&.dot--confirmed { background: $primary-dark; }
|
||||||
|
&.dot--completed { background: #66bb6a; }
|
||||||
|
&.dot--cancelled { background: #e0e0e0; }
|
||||||
|
&.dot--noshow { background: #ef5350; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-line {
|
||||||
|
width: 2rpx;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 40rpx;
|
||||||
|
background: #e8e8e8;
|
||||||
|
margin: 4rpx 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-item--last .timeline-line {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content {
|
||||||
|
flex: 1;
|
||||||
|
padding-bottom: 28rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-content-header {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-status {
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-time {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timeline-remark {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #888;
|
||||||
|
display: block;
|
||||||
|
margin-top: 4rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Action bar ──────────────────────────────────────── */
|
||||||
|
.action-bar {
|
||||||
|
margin: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-row {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 16rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn {
|
||||||
|
flex: 1;
|
||||||
|
padding: 28rpx 0;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
&--confirm {
|
||||||
|
background: linear-gradient(135deg, $primary-color, $primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--complete {
|
||||||
|
background: linear-gradient(135deg, #66bb6a, #4caf50);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--noshow {
|
||||||
|
background: rgba(239, 83, 80, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&--cancel {
|
||||||
|
background: rgba(0, 0, 0, 0.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn-text {
|
||||||
|
font-size: 32rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #fff;
|
||||||
|
|
||||||
|
.action-btn--cancel & {
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.action-btn--noshow & {
|
||||||
|
color: #ef5350;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Error state -->
|
<!-- Error state -->
|
||||||
<view v-else-if="!card" class="error-wrap">
|
<view v-else-if="!card && !showAll" class="error-wrap">
|
||||||
<text class="error-icon">😕</text>
|
<text class="error-icon">😕</text>
|
||||||
<text class="error-text">会员卡信息加载失败</text>
|
<text class="error-text">会员卡信息加载失败</text>
|
||||||
<view class="retry-btn" @tap="loadCard">
|
<view class="retry-btn" @tap="loadCard">
|
||||||
@@ -20,7 +20,50 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- Card content -->
|
<!-- All cards list mode -->
|
||||||
|
<template v-else-if="showAll">
|
||||||
|
<view v-if="loading" class="loading-wrap">
|
||||||
|
<view class="skeleton-header" />
|
||||||
|
<view class="skeleton-body">
|
||||||
|
<view class="skeleton-line w80" />
|
||||||
|
<view class="skeleton-line w60" />
|
||||||
|
<view class="skeleton-line w40" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else-if="allCards.length" class="all-cards-list">
|
||||||
|
<view
|
||||||
|
v-for="c in allCards"
|
||||||
|
:key="c.id"
|
||||||
|
class="card-row"
|
||||||
|
@tap="goToDetail(c.id)"
|
||||||
|
>
|
||||||
|
<view class="card-thumb" :class="thumbClass(c)">
|
||||||
|
<view class="thumb-fallback">
|
||||||
|
<text class="thumb-name">{{ truncate(c.name, 8) }}</text>
|
||||||
|
<text class="thumb-price">¥{{ formatPrice(c.price) }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="card-info">
|
||||||
|
<text class="card-name">{{ c.name }}</text>
|
||||||
|
<text class="card-validity">有效期:{{ c.durationDays }} 天</text>
|
||||||
|
<view class="price-row">
|
||||||
|
<text class="price-current">¥{{ formatPrice(c.price) }}</text>
|
||||||
|
<text
|
||||||
|
v-if="c.originalPrice && c.originalPrice > c.price"
|
||||||
|
class="price-original"
|
||||||
|
>
|
||||||
|
原价:¥{{ formatPrice(c.originalPrice) }}
|
||||||
|
</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view v-else class="empty-state">
|
||||||
|
<text class="empty-text">暂无可购买的会员卡</text>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Card content (single card mode) -->
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- Hero section -->
|
<!-- Hero section -->
|
||||||
<view class="card-hero" :class="heroClass">
|
<view class="card-hero" :class="heroClass">
|
||||||
@@ -142,9 +185,11 @@ const navBarHeight = ref('64px')
|
|||||||
// ─── Route params ──────────────────────────────────────────
|
// ─── Route params ──────────────────────────────────────────
|
||||||
const cardId = ref<string>('')
|
const cardId = ref<string>('')
|
||||||
const isTrial = ref(false)
|
const isTrial = ref(false)
|
||||||
|
const showAll = ref(false)
|
||||||
|
|
||||||
// ─── State ────────────────────────────────────────────────
|
// ─── State ────────────────────────────────────────────────
|
||||||
const card = ref<CardType | null>(null)
|
const card = ref<CardType | null>(null)
|
||||||
|
const allCards = ref<CardType[]>([])
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const buying = ref(false)
|
const buying = ref(false)
|
||||||
|
|
||||||
@@ -181,7 +226,12 @@ async function loadCard() {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const types = await get<CardType[]>('/membership/card-types')
|
const types = await get<CardType[]>('/membership/card-types')
|
||||||
const activeTypes = types.filter((c) => c.isActive)
|
const activeTypes = types.filter((c) => c.isActive).sort((a, b) => a.sortOrder - b.sortOrder)
|
||||||
|
|
||||||
|
if (showAll.value) {
|
||||||
|
allCards.value = activeTypes
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (isTrial.value) {
|
if (isTrial.value) {
|
||||||
// Auto-find the trial card type
|
// Auto-find the trial card type
|
||||||
@@ -193,12 +243,30 @@ async function loadCard() {
|
|||||||
card.value = activeTypes.find((c) => c.id === cardId.value) ?? null
|
card.value = activeTypes.find((c) => c.id === cardId.value) ?? null
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
card.value = null
|
if (!showAll.value) {
|
||||||
|
card.value = null
|
||||||
|
}
|
||||||
|
allCards.value = []
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Helpers ───────────────────────────────────────────────
|
||||||
|
function goToDetail(id: string) {
|
||||||
|
uni.navigateTo({ url: `/pages/card/detail?id=${id}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
function thumbClass(card: CardType): string {
|
||||||
|
if (card.type === CardTypeCategory.TRIAL) return 'thumb--trial'
|
||||||
|
if (card.type === CardTypeCategory.DURATION) return 'thumb--duration'
|
||||||
|
return 'thumb--times'
|
||||||
|
}
|
||||||
|
|
||||||
|
function truncate(str: string, maxLen: number): string {
|
||||||
|
return str.length > maxLen ? str.slice(0, maxLen) + '…' : str
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Buy flow ─────────────────────────────────────────────
|
// ─── Buy flow ─────────────────────────────────────────────
|
||||||
async function handleBuy() {
|
async function handleBuy() {
|
||||||
if (buying.value || !card.value) return
|
if (buying.value || !card.value) return
|
||||||
@@ -286,6 +354,7 @@ onMounted(() => {
|
|||||||
const options = (current as { options?: Record<string, string> }).options ?? {}
|
const options = (current as { options?: Record<string, string> }).options ?? {}
|
||||||
cardId.value = options.id ?? ''
|
cardId.value = options.id ?? ''
|
||||||
isTrial.value = options.trial === '1'
|
isTrial.value = options.trial === '1'
|
||||||
|
showAll.value = options.showAll === '1'
|
||||||
loadCard()
|
loadCard()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
@@ -629,4 +698,123 @@ onMounted(() => {
|
|||||||
color: $primary-dark;
|
color: $primary-dark;
|
||||||
letter-spacing: 2rpx;
|
letter-spacing: 2rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── All cards list ────────────────────────────────────── */
|
||||||
|
.all-cards-list {
|
||||||
|
padding: 24rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 20rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 24rpx;
|
||||||
|
padding: 24rpx;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 20rpx;
|
||||||
|
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-thumb {
|
||||||
|
width: 200rpx;
|
||||||
|
height: 140rpx;
|
||||||
|
border-radius: 12rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-fallback {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8rpx;
|
||||||
|
padding: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb--times .thumb-fallback {
|
||||||
|
background: linear-gradient(135deg, #3a3a3a, #555);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb--duration .thumb-fallback {
|
||||||
|
background: linear-gradient(135deg, #6c3483, #9b59b6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb--trial .thumb-fallback {
|
||||||
|
background: linear-gradient(135deg, #5a7a8a, $primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-name {
|
||||||
|
font-size: 22rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.3;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.thumb-price {
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-info {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-name {
|
||||||
|
display: block;
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #222;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-validity {
|
||||||
|
display: block;
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #999;
|
||||||
|
margin-bottom: 12rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-current {
|
||||||
|
font-size: 40rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: #e53935;
|
||||||
|
}
|
||||||
|
|
||||||
|
.price-original {
|
||||||
|
font-size: 22rpx;
|
||||||
|
color: #bbb;
|
||||||
|
text-decoration: line-through;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Empty state ─────────────────────────────────────── */
|
||||||
|
.empty-state {
|
||||||
|
padding: 160rpx 40rpx;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-text {
|
||||||
|
font-size: 28rpx;
|
||||||
|
color: #bbb;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -56,8 +56,9 @@
|
|||||||
v-for="booking in upcomingBookings"
|
v-for="booking in upcomingBookings"
|
||||||
:key="booking.id"
|
:key="booking.id"
|
||||||
class="booking-card"
|
class="booking-card"
|
||||||
|
@tap="goDetail(booking)"
|
||||||
>
|
>
|
||||||
<view class="booking-stripe stripe--confirmed" />
|
<view class="booking-stripe" :class="stripeClass(booking.status)" />
|
||||||
<view class="booking-content">
|
<view class="booking-content">
|
||||||
<view class="booking-header">
|
<view class="booking-header">
|
||||||
<view class="booking-datetime">
|
<view class="booking-datetime">
|
||||||
@@ -66,19 +67,26 @@
|
|||||||
{{ 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="status-badge badge--confirmed">
|
<view class="status-badge" :class="statusBadgeClass(booking.status)">
|
||||||
<text class="status-text">已预约</text>
|
<text class="status-text">{{ statusLabel(booking.status) }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="booking-footer">
|
<view v-if="booking.status !== 'PENDING_CONFIRMATION'" class="booking-footer">
|
||||||
<view class="booking-meta">
|
<view class="booking-meta">
|
||||||
<text class="meta-label">使用卡种</text>
|
<text class="meta-label">使用卡种</text>
|
||||||
<text class="meta-value">{{ booking.membership.cardType.name }}</text>
|
<text class="meta-value">{{ booking.membership.cardType.name }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="cancel-btn" @tap="handleCancel(booking)">
|
<view class="cancel-btn" @tap.stop="handleCancel(booking)">
|
||||||
<text class="cancel-text">取消预约</text>
|
<text class="cancel-text">取消预约</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-else class="booking-footer">
|
||||||
|
<view class="booking-meta">
|
||||||
|
<text class="meta-label">使用卡种</text>
|
||||||
|
<text class="meta-value">{{ booking.membership.cardType.name }}</text>
|
||||||
|
</view>
|
||||||
|
<text class="pending-hint">等待老师确认</text>
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -121,6 +129,7 @@
|
|||||||
v-for="booking in historyBookings"
|
v-for="booking in historyBookings"
|
||||||
:key="booking.id"
|
:key="booking.id"
|
||||||
class="booking-card"
|
class="booking-card"
|
||||||
|
@tap="goDetail(booking)"
|
||||||
>
|
>
|
||||||
<view class="booking-stripe" :class="stripeClass(booking.status)" />
|
<view class="booking-stripe" :class="stripeClass(booking.status)" />
|
||||||
<view class="booking-content">
|
<view class="booking-content">
|
||||||
@@ -190,7 +199,9 @@ const today = computed(() => formatDate(new Date()))
|
|||||||
const upcomingBookings = computed<BookingWithDetails[]>(() => {
|
const upcomingBookings = computed<BookingWithDetails[]>(() => {
|
||||||
return safeBookings()
|
return safeBookings()
|
||||||
.filter(
|
.filter(
|
||||||
(b) => b.status === BookingStatus.CONFIRMED && toDateStr(b.timeSlot.date) >= today.value,
|
(b) =>
|
||||||
|
(b.status === BookingStatus.PENDING_CONFIRMATION || b.status === BookingStatus.CONFIRMED) &&
|
||||||
|
toDateStr(b.timeSlot.date) >= today.value,
|
||||||
)
|
)
|
||||||
.sort((a, b) => {
|
.sort((a, b) => {
|
||||||
const dateA = toDateStr(a.timeSlot.date)
|
const dateA = toDateStr(a.timeSlot.date)
|
||||||
@@ -222,36 +233,39 @@ const historyBookings = computed<BookingWithDetails[]>(() => {
|
|||||||
const upcomingCount = computed(() => upcomingBookings.value.length)
|
const upcomingCount = computed(() => upcomingBookings.value.length)
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────
|
||||||
const STATUS_LABELS: Record<BookingStatus, string> = {
|
const STATUS_LABELS: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||||
[BookingStatus.CONFIRMED]: '已预约',
|
[BookingStatus.CONFIRMED]: '已预约',
|
||||||
[BookingStatus.CANCELLED]: '已取消',
|
[BookingStatus.CANCELLED]: '已取消',
|
||||||
[BookingStatus.COMPLETED]: '已完成',
|
[BookingStatus.COMPLETED]: '已完成',
|
||||||
[BookingStatus.NO_SHOW]: '未出席',
|
[BookingStatus.NO_SHOW]: '未出席',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_BADGE_CLASSES: Record<BookingStatus, string> = {
|
const STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: 'badge--pending',
|
||||||
[BookingStatus.CONFIRMED]: 'badge--confirmed',
|
[BookingStatus.CONFIRMED]: 'badge--confirmed',
|
||||||
[BookingStatus.CANCELLED]: 'badge--cancelled',
|
[BookingStatus.CANCELLED]: 'badge--cancelled',
|
||||||
[BookingStatus.COMPLETED]: 'badge--completed',
|
[BookingStatus.COMPLETED]: 'badge--completed',
|
||||||
[BookingStatus.NO_SHOW]: 'badge--noshow',
|
[BookingStatus.NO_SHOW]: 'badge--noshow',
|
||||||
}
|
}
|
||||||
|
|
||||||
const STATUS_STRIPE_CLASSES: Record<BookingStatus, string> = {
|
const STATUS_STRIPE_CLASSES: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: 'stripe--pending',
|
||||||
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
|
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
|
||||||
[BookingStatus.CANCELLED]: 'stripe--cancelled',
|
[BookingStatus.CANCELLED]: 'stripe--cancelled',
|
||||||
[BookingStatus.COMPLETED]: 'stripe--completed',
|
[BookingStatus.COMPLETED]: 'stripe--completed',
|
||||||
[BookingStatus.NO_SHOW]: 'stripe--noshow',
|
[BookingStatus.NO_SHOW]: 'stripe--noshow',
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusLabel(status: BookingStatus): string {
|
function statusLabel(status: string): string {
|
||||||
return STATUS_LABELS[status] ?? status
|
return STATUS_LABELS[status] ?? status
|
||||||
}
|
}
|
||||||
|
|
||||||
function statusBadgeClass(status: BookingStatus): string {
|
function statusBadgeClass(status: string): string {
|
||||||
return STATUS_BADGE_CLASSES[status] ?? ''
|
return STATUS_BADGE_CLASSES[status] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripeClass(status: BookingStatus): string {
|
function stripeClass(status: string): string {
|
||||||
return STATUS_STRIPE_CLASSES[status] ?? ''
|
return STATUS_STRIPE_CLASSES[status] ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,6 +311,10 @@ function goBooking() {
|
|||||||
uni.switchTab({ url: '/pages/booking/index' })
|
uni.switchTab({ url: '/pages/booking/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function goDetail(booking: BookingWithDetails) {
|
||||||
|
uni.navigateTo({ url: `/pages/booking/detail?id=${booking.id}` })
|
||||||
|
}
|
||||||
|
|
||||||
async function handleCancel(booking: BookingWithDetails) {
|
async function handleCancel(booking: BookingWithDetails) {
|
||||||
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
||||||
const timeLabel = booking.timeSlot.startTime.slice(0, 5)
|
const timeLabel = booking.timeSlot.startTime.slice(0, 5)
|
||||||
@@ -535,6 +553,7 @@ onMounted(() => {
|
|||||||
width: 8rpx;
|
width: 8rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.stripe--pending { background: #f59e0b; }
|
||||||
&.stripe--confirmed { background: $primary-dark; }
|
&.stripe--confirmed { background: $primary-dark; }
|
||||||
&.stripe--completed { background: #66bb6a; }
|
&.stripe--completed { background: #66bb6a; }
|
||||||
&.stripe--cancelled { background: #e0e0e0; }
|
&.stripe--cancelled { background: #e0e0e0; }
|
||||||
@@ -580,6 +599,7 @@ onMounted(() => {
|
|||||||
border-radius: 20rpx;
|
border-radius: 20rpx;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&.badge--pending { background: rgba(245, 158, 11, 0.12); }
|
||||||
&.badge--confirmed { background: rgba(201, 168, 124, 0.12); }
|
&.badge--confirmed { background: rgba(201, 168, 124, 0.12); }
|
||||||
&.badge--completed { background: rgba(102, 187, 106, 0.12); }
|
&.badge--completed { background: rgba(102, 187, 106, 0.12); }
|
||||||
&.badge--cancelled { background: rgba(0, 0, 0, 0.04); }
|
&.badge--cancelled { background: rgba(0, 0, 0, 0.04); }
|
||||||
@@ -590,6 +610,7 @@ onMounted(() => {
|
|||||||
font-size: 22rpx;
|
font-size: 22rpx;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
||||||
|
.badge--pending & { color: #f59e0b; }
|
||||||
.badge--confirmed & { color: $primary-dark; }
|
.badge--confirmed & { color: $primary-dark; }
|
||||||
.badge--completed & { color: #66bb6a; }
|
.badge--completed & { color: #66bb6a; }
|
||||||
.badge--cancelled & { color: #bbb; }
|
.badge--cancelled & { color: #bbb; }
|
||||||
@@ -650,6 +671,12 @@ onMounted(() => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pending-hint {
|
||||||
|
font-size: 24rpx;
|
||||||
|
color: #f59e0b;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
/* ── Spacer ──────────────────────────────────────────── */
|
/* ── Spacer ──────────────────────────────────────────── */
|
||||||
.scroll-bottom-spacer {
|
.scroll-bottom-spacer {
|
||||||
height: 48rpx;
|
height: 48rpx;
|
||||||
|
|||||||
@@ -3,6 +3,8 @@ import { ref } from 'vue'
|
|||||||
import type {
|
import type {
|
||||||
TimeSlotWithBookingStatus,
|
TimeSlotWithBookingStatus,
|
||||||
BookingWithDetails,
|
BookingWithDetails,
|
||||||
|
BookingWithUser,
|
||||||
|
BookingStatusHistory,
|
||||||
CreateBookingDto,
|
CreateBookingDto,
|
||||||
} from '@mp-pilates/shared'
|
} from '@mp-pilates/shared'
|
||||||
import { get, post, put } from '../utils/request'
|
import { get, post, put } from '../utils/request'
|
||||||
@@ -68,6 +70,51 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Admin methods ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function fetchAllAdminBookings(
|
||||||
|
page = 1,
|
||||||
|
limit = 20,
|
||||||
|
status?: string,
|
||||||
|
): Promise<ServerPaginatedResult<BookingWithUser>> {
|
||||||
|
const params: Record<string, unknown> = { page, limit }
|
||||||
|
if (status) params.status = status
|
||||||
|
|
||||||
|
const paginated = await get<ServerPaginatedResult<BookingWithUser>>('/admin/bookings', params)
|
||||||
|
return paginated
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmBooking(bookingId: string, remark?: string) {
|
||||||
|
const result = await put<BookingWithDetails>(`/booking/${bookingId}/confirm`, {
|
||||||
|
remark,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function completeBooking(bookingId: string, remark?: string) {
|
||||||
|
const result = await put<BookingWithDetails>(`/booking/${bookingId}/complete`, {
|
||||||
|
remark,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markNoShow(bookingId: string, remark?: string) {
|
||||||
|
const result = await put<BookingWithDetails>(`/booking/${bookingId}/noshow`, {
|
||||||
|
remark,
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBookingHistory(bookingId: string): Promise<BookingStatusHistory[]> {
|
||||||
|
const result = await get<BookingStatusHistory[]>(`/booking/${bookingId}/history`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchBookingById(bookingId: string) {
|
||||||
|
const result = await get<BookingWithDetails | BookingWithUser>(`/booking/${bookingId}`)
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
slots,
|
slots,
|
||||||
myBookings,
|
myBookings,
|
||||||
@@ -79,5 +126,11 @@ export const useBookingStore = defineStore('booking', () => {
|
|||||||
cancelBooking,
|
cancelBooking,
|
||||||
fetchMyBookings,
|
fetchMyBookings,
|
||||||
fetchUpcomingBookings,
|
fetchUpcomingBookings,
|
||||||
|
fetchAllAdminBookings,
|
||||||
|
confirmBooking,
|
||||||
|
completeBooking,
|
||||||
|
markNoShow,
|
||||||
|
fetchBookingHistory,
|
||||||
|
fetchBookingById,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
81
packages/app/src/utils/booking-helpers.ts
Normal file
81
packages/app/src/utils/booking-helpers.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { BookingStatus } from '@mp-pilates/shared'
|
||||||
|
|
||||||
|
/** 格式化日期显示:今天/明天/M月D日 星期X */
|
||||||
|
export function formatDateDisplay(dateStr: string): string {
|
||||||
|
const normalized = dateStr.slice(0, 10)
|
||||||
|
const [y, m, d] = normalized.split('-').map(Number)
|
||||||
|
const localDate = new Date(y, m - 1, d)
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const todayStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
const tomorrow = new Date(today.getTime() + 86400000)
|
||||||
|
const tomorrowStr = `${tomorrow.getFullYear()}-${String(tomorrow.getMonth() + 1).padStart(2, '0')}-${String(tomorrow.getDate()).padStart(2, '0')}`
|
||||||
|
|
||||||
|
if (normalized === todayStr) return `今天 ${m}月${d}日`
|
||||||
|
if (normalized === tomorrowStr) return `明天 ${m}月${d}日`
|
||||||
|
|
||||||
|
const weekdayLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||||
|
return `${m}月${d}日 ${weekdayLabels[localDate.getDay()]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Booking status helpers ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const BOOKING_STATUS_LABELS: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: '待确认',
|
||||||
|
[BookingStatus.CONFIRMED]: '已确认',
|
||||||
|
[BookingStatus.CANCELLED]: '已取消',
|
||||||
|
[BookingStatus.COMPLETED]: '已完成',
|
||||||
|
[BookingStatus.NO_SHOW]: '未出席',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOKING_STATUS_BADGE_CLASSES: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: 'badge--pending',
|
||||||
|
[BookingStatus.CONFIRMED]: 'badge--confirmed',
|
||||||
|
[BookingStatus.CANCELLED]: 'badge--cancelled',
|
||||||
|
[BookingStatus.COMPLETED]: 'badge--completed',
|
||||||
|
[BookingStatus.NO_SHOW]: 'badge--noshow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOKING_STATUS_STRIPE_CLASSES: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: 'stripe--pending',
|
||||||
|
[BookingStatus.CONFIRMED]: 'stripe--confirmed',
|
||||||
|
[BookingStatus.CANCELLED]: 'stripe--cancelled',
|
||||||
|
[BookingStatus.COMPLETED]: 'stripe--completed',
|
||||||
|
[BookingStatus.NO_SHOW]: 'stripe--noshow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export const BOOKING_STATUS_BANNER_CLASSES: Record<string, string> = {
|
||||||
|
[BookingStatus.PENDING_CONFIRMATION]: 'banner--pending',
|
||||||
|
[BookingStatus.CONFIRMED]: 'banner--confirmed',
|
||||||
|
[BookingStatus.CANCELLED]: 'banner--cancelled',
|
||||||
|
[BookingStatus.COMPLETED]: 'banner--completed',
|
||||||
|
[BookingStatus.NO_SHOW]: 'banner--noshow',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingStatusLabel(status: string): string {
|
||||||
|
return BOOKING_STATUS_LABELS[status] ?? status
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingStatusBadgeClass(status: string): string {
|
||||||
|
return BOOKING_STATUS_BADGE_CLASSES[status] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingStatusStripeClass(status: string): string {
|
||||||
|
return BOOKING_STATUS_STRIPE_CLASSES[status] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingStatusBannerClass(status: string): string {
|
||||||
|
return BOOKING_STATUS_BANNER_CLASSES[status] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function bookingTimelineDotClass(status: string): string {
|
||||||
|
switch (status) {
|
||||||
|
case BookingStatus.PENDING_CONFIRMATION: return 'dot--pending'
|
||||||
|
case BookingStatus.CONFIRMED: return 'dot--confirmed'
|
||||||
|
case BookingStatus.COMPLETED: return 'dot--completed'
|
||||||
|
case BookingStatus.CANCELLED: return 'dot--cancelled'
|
||||||
|
case BookingStatus.NO_SHOW: return 'dot--noshow'
|
||||||
|
default: return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,4 +9,11 @@ export default defineConfig({
|
|||||||
'@': resolve(__dirname, 'src'),
|
'@': resolve(__dirname, 'src'),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
css: {
|
||||||
|
preprocessorOptions: {
|
||||||
|
scss: {
|
||||||
|
api: 'modern',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ enum TimeSlotSource {
|
|||||||
}
|
}
|
||||||
|
|
||||||
enum BookingStatus {
|
enum BookingStatus {
|
||||||
|
PENDING_CONFIRMATION
|
||||||
CONFIRMED
|
CONFIRMED
|
||||||
CANCELLED
|
CANCELLED
|
||||||
COMPLETED
|
COMPLETED
|
||||||
@@ -152,8 +153,11 @@ model Booking {
|
|||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
timeSlotId String @map("time_slot_id")
|
timeSlotId String @map("time_slot_id")
|
||||||
membershipId String @map("membership_id")
|
membershipId String @map("membership_id")
|
||||||
status BookingStatus @default(CONFIRMED)
|
status BookingStatus @default(PENDING_CONFIRMATION)
|
||||||
cancelledAt DateTime? @map("cancelled_at")
|
cancelledAt DateTime? @map("cancelled_at")
|
||||||
|
confirmedAt DateTime? @map("confirmed_at")
|
||||||
|
completedAt DateTime? @map("completed_at")
|
||||||
|
operatorId String? @map("operator_id")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
|
|
||||||
@@ -161,12 +165,29 @@ model Booking {
|
|||||||
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
timeSlot TimeSlot @relation(fields: [timeSlotId], references: [id])
|
||||||
membership Membership @relation(fields: [membershipId], references: [id])
|
membership Membership @relation(fields: [membershipId], references: [id])
|
||||||
|
|
||||||
|
statusHistory BookingStatusHistory[]
|
||||||
|
|
||||||
@@unique([userId, timeSlotId])
|
@@unique([userId, timeSlotId])
|
||||||
@@index([userId])
|
@@index([userId])
|
||||||
@@index([status])
|
@@index([status])
|
||||||
@@map("bookings")
|
@@map("bookings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
model BookingStatusHistory {
|
||||||
|
id String @id @default(uuid())
|
||||||
|
bookingId String @map("booking_id")
|
||||||
|
fromStatus String? @map("from_status")
|
||||||
|
toStatus String @map("to_status")
|
||||||
|
operatorId String? @map("operator_id")
|
||||||
|
remark String?
|
||||||
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
|
|
||||||
|
booking Booking @relation(fields: [bookingId], references: [id])
|
||||||
|
|
||||||
|
@@index([bookingId])
|
||||||
|
@@map("booking_status_history")
|
||||||
|
}
|
||||||
|
|
||||||
model Order {
|
model Order {
|
||||||
id String @id @default(uuid())
|
id String @id @default(uuid())
|
||||||
userId String @map("user_id")
|
userId String @map("user_id")
|
||||||
|
|||||||
@@ -130,6 +130,7 @@ function buildTxMock(overrides: Record<string, unknown> = {}) {
|
|||||||
},
|
},
|
||||||
booking: {
|
booking: {
|
||||||
findUnique: jest.fn(),
|
findUnique: jest.fn(),
|
||||||
|
findFirst: jest.fn(),
|
||||||
create: jest.fn(),
|
create: jest.fn(),
|
||||||
update: jest.fn(),
|
update: jest.fn(),
|
||||||
},
|
},
|
||||||
@@ -205,7 +206,7 @@ describe('BookingService', () => {
|
|||||||
it('creates booking, increments bookedCount, and deducts membership (TIMES card)', async () => {
|
it('creates booking, increments bookedCount, and deducts membership (TIMES card)', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findUnique.mockResolvedValue(null) // no duplicate
|
tx.booking.findFirst.mockResolvedValue(null) // no duplicate
|
||||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||||
@@ -258,7 +259,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(nearFullSlot)
|
||||||
tx.booking.findUnique.mockResolvedValue(null)
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
tx.membership.findUnique.mockResolvedValue(mockActiveMembership)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL })
|
tx.timeSlot.update.mockResolvedValue({ ...nearFullSlot, bookedCount: 5, status: TimeSlotStatus.FULL })
|
||||||
@@ -286,7 +287,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findUnique.mockResolvedValue(null)
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
tx.membership.findUnique.mockResolvedValue(mockDurationMembership)
|
||||||
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
|
tx.booking.create.mockResolvedValue({ ...mockConfirmedBooking, membershipId: mockDurationMembership.id })
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||||
@@ -310,7 +311,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findUnique.mockResolvedValue(null)
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(lastTimeMembership)
|
tx.membership.findUnique.mockResolvedValue(lastTimeMembership)
|
||||||
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
tx.booking.create.mockResolvedValue(mockConfirmedBooking)
|
||||||
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
tx.timeSlot.update.mockResolvedValue({ ...mockOpenSlot, bookedCount: 1 })
|
||||||
@@ -351,7 +352,7 @@ describe('BookingService', () => {
|
|||||||
it('throws ConflictException on duplicate booking (same user + slot)', async () => {
|
it('throws ConflictException on duplicate booking (same user + slot)', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findUnique.mockResolvedValue(mockConfirmedBooking) // duplicate exists
|
tx.booking.findFirst.mockResolvedValue(mockConfirmedBooking) // duplicate exists
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
|
|
||||||
@@ -363,7 +364,7 @@ describe('BookingService', () => {
|
|||||||
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
|
it('throws BadRequestException when membership is not ACTIVE (expired status)', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findUnique.mockResolvedValue(null)
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
|
tx.membership.findUnique.mockResolvedValue(mockExpiredMembership)
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
@@ -376,7 +377,7 @@ describe('BookingService', () => {
|
|||||||
it('throws BadRequestException when TIMES membership has 0 remaining', async () => {
|
it('throws BadRequestException when TIMES membership has 0 remaining', async () => {
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findUnique.mockResolvedValue(null)
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
tx.membership.findUnique.mockResolvedValue(mockMembershipNoTimes)
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
@@ -403,7 +404,7 @@ describe('BookingService', () => {
|
|||||||
|
|
||||||
const tx = buildTxMock()
|
const tx = buildTxMock()
|
||||||
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
tx.timeSlot.findUnique.mockResolvedValue(mockOpenSlot)
|
||||||
tx.booking.findUnique.mockResolvedValue(null)
|
tx.booking.findFirst.mockResolvedValue(null)
|
||||||
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
|
tx.membership.findUnique.mockResolvedValue(otherUserMembership)
|
||||||
|
|
||||||
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
;(prisma.$transaction as jest.Mock).mockImplementation((fn) => fn(tx))
|
||||||
|
|||||||
@@ -62,6 +62,18 @@ export class BookingController {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('booking/:id/history')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getBookingStatusHistory(@Param('id') id: string) {
|
||||||
|
return this.bookingService.getBookingStatusHistory(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('booking/:id')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
async getBookingById(@Param('id') id: string) {
|
||||||
|
return this.bookingService.getBookingById(id)
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Admin Endpoints ──────────────────────────────────────────────────────
|
// ─── Admin Endpoints ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Get('admin/bookings')
|
@Get('admin/bookings')
|
||||||
@@ -78,4 +90,37 @@ export class BookingController {
|
|||||||
status,
|
status,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Put('booking/:id/confirm')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async confirmBooking(
|
||||||
|
@CurrentUser('sub') operatorId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { remark?: string },
|
||||||
|
) {
|
||||||
|
return this.bookingService.confirmBooking(id, operatorId, body.remark)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('booking/:id/complete')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async completeBooking(
|
||||||
|
@CurrentUser('sub') operatorId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { remark?: string },
|
||||||
|
) {
|
||||||
|
return this.bookingService.completeBooking(id, operatorId, body.remark)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put('booking/:id/noshow')
|
||||||
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
|
@Roles(UserRole.ADMIN)
|
||||||
|
async markNoShow(
|
||||||
|
@CurrentUser('sub') operatorId: string,
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() body: { remark?: string },
|
||||||
|
) {
|
||||||
|
return this.bookingService.markNoShow(id, operatorId, body.remark)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import {
|
|||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common'
|
} from '@nestjs/common'
|
||||||
import { Booking, Membership, TimeSlot } from '@prisma/client'
|
import { Booking, Membership, TimeSlot, BookingStatusHistory } from '@prisma/client'
|
||||||
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
import { BookingStatus, CardTypeCategory, MembershipStatus, TimeSlotStatus } from '@mp-pilates/shared'
|
||||||
import { PrismaService } from '../prisma/prisma.service'
|
import { PrismaService } from '../prisma/prisma.service'
|
||||||
import { MembershipService } from '../membership/membership.service'
|
import { MembershipService } from '../membership/membership.service'
|
||||||
@@ -31,10 +31,9 @@ export interface CancelBookingResult {
|
|||||||
refunded: boolean
|
refunded: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Helpers ───────────────────────────────────────────────────────────────
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
function buildSlotStartMs(slotDate: Date, startTime: string): number {
|
function buildSlotStartMs(slotDate: Date, startTime: string): number {
|
||||||
// slotDate is stored as DATE (midnight UTC); startTime is "HH:mm"
|
|
||||||
const [hours, minutes] = startTime.split(':').map(Number)
|
const [hours, minutes] = startTime.split(':').map(Number)
|
||||||
const d = new Date(slotDate)
|
const d = new Date(slotDate)
|
||||||
d.setUTCHours(hours, minutes, 0, 0)
|
d.setUTCHours(hours, minutes, 0, 0)
|
||||||
@@ -71,9 +70,13 @@ export class BookingService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Check for duplicate booking (@@unique [userId, timeSlotId])
|
// 2. Check for active (PENDING_CONFIRMATION or CONFIRMED) booking — cancelled bookings don't block re-booking
|
||||||
const existing = await tx.booking.findUnique({
|
const existing = await tx.booking.findFirst({
|
||||||
where: { userId_timeSlotId: { userId, timeSlotId: dto.timeSlotId } },
|
where: {
|
||||||
|
userId,
|
||||||
|
timeSlotId: dto.timeSlotId,
|
||||||
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
|
},
|
||||||
})
|
})
|
||||||
if (existing) {
|
if (existing) {
|
||||||
throw new ConflictException('You have already booked this time slot')
|
throw new ConflictException('You have already booked this time slot')
|
||||||
@@ -102,10 +105,7 @@ export class BookingService {
|
|||||||
cardType.type === CardTypeCategory.TRIAL
|
cardType.type === CardTypeCategory.TRIAL
|
||||||
|
|
||||||
if (isTimeBased) {
|
if (isTimeBased) {
|
||||||
// 4a. TIMES / TRIAL: must have remaining times
|
// 4a. TIMES / TRIAL: must have remaining times (check at confirm time, not booking time)
|
||||||
if ((membership.remainingTimes ?? 0) <= 0) {
|
|
||||||
throw new BadRequestException('No remaining times on this membership')
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// 4b. DURATION: must not be expired
|
// 4b. DURATION: must not be expired
|
||||||
if (membership.expireDate <= new Date()) {
|
if (membership.expireDate <= new Date()) {
|
||||||
@@ -113,38 +113,107 @@ export class BookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 5. Create booking
|
// 5. Create booking with PENDING_CONFIRMATION status
|
||||||
const newBooking = await tx.booking.create({
|
const newBooking = await tx.booking.create({
|
||||||
data: {
|
data: {
|
||||||
userId,
|
userId,
|
||||||
timeSlotId: dto.timeSlotId,
|
timeSlotId: dto.timeSlotId,
|
||||||
membershipId: dto.membershipId,
|
membershipId: dto.membershipId,
|
||||||
status: BookingStatus.CONFIRMED,
|
status: BookingStatus.PENDING_CONFIRMATION,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 6. Increment bookedCount; set FULL if at capacity
|
// 6. Record status history: created
|
||||||
const newBookedCount = timeSlot.bookedCount + 1
|
await tx.bookingStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
bookingId: newBooking.id,
|
||||||
|
fromStatus: null,
|
||||||
|
toStatus: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
operatorId: userId,
|
||||||
|
remark: '学员发起预约',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return newBooking
|
||||||
|
})
|
||||||
|
|
||||||
|
// Re-fetch with relations after transaction
|
||||||
|
return this.fetchBookingWithRelations(booking.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Confirm Booking (Admin) ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
async confirmBooking(
|
||||||
|
bookingId: string,
|
||||||
|
operatorId: string,
|
||||||
|
remark?: string,
|
||||||
|
): Promise<BookingWithRelations> {
|
||||||
|
const booking = await this.prisma.$transaction(async (tx) => {
|
||||||
|
// 1. Find booking with timeSlot and membership
|
||||||
|
const existing = await tx.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: {
|
||||||
|
timeSlot: true,
|
||||||
|
membership: { include: { cardType: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingId} not found`)
|
||||||
|
}
|
||||||
|
if (existing.status !== BookingStatus.PENDING_CONFIRMATION) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot confirm booking with status: ${existing.status}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Validate membership still has available times
|
||||||
|
const cardType = existing.membership.cardType
|
||||||
|
const isTimeBased =
|
||||||
|
cardType.type === CardTypeCategory.TIMES ||
|
||||||
|
cardType.type === CardTypeCategory.TRIAL
|
||||||
|
|
||||||
|
if (isTimeBased) {
|
||||||
|
if ((existing.membership.remainingTimes ?? 0) <= 0) {
|
||||||
|
throw new BadRequestException('No remaining times on this membership')
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (existing.membership.expireDate <= new Date()) {
|
||||||
|
throw new BadRequestException('Membership has expired')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Update booking status to CONFIRMED
|
||||||
|
const updated = await tx.booking.update({
|
||||||
|
where: { id: bookingId },
|
||||||
|
data: {
|
||||||
|
status: BookingStatus.CONFIRMED,
|
||||||
|
confirmedAt: new Date(),
|
||||||
|
operatorId,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Increment bookedCount; set FULL if at capacity
|
||||||
|
const newBookedCount = existing.timeSlot.bookedCount + 1
|
||||||
const newSlotStatus =
|
const newSlotStatus =
|
||||||
newBookedCount >= timeSlot.capacity ? TimeSlotStatus.FULL : TimeSlotStatus.OPEN
|
newBookedCount >= existing.timeSlot.capacity ? TimeSlotStatus.FULL : TimeSlotStatus.OPEN
|
||||||
|
|
||||||
await tx.timeSlot.update({
|
await tx.timeSlot.update({
|
||||||
where: { id: dto.timeSlotId },
|
where: { id: existing.timeSlotId },
|
||||||
data: {
|
data: {
|
||||||
bookedCount: newBookedCount,
|
bookedCount: newBookedCount,
|
||||||
status: newSlotStatus,
|
status: newSlotStatus,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
// 7. Deduct membership (inside transaction — replicate logic to avoid
|
// 5. Deduct membership times
|
||||||
// calling the service method which uses the outer prisma client)
|
|
||||||
if (isTimeBased) {
|
if (isTimeBased) {
|
||||||
const newRemainingTimes = (membership.remainingTimes ?? 0) - 1
|
const newRemainingTimes = (existing.membership.remainingTimes ?? 0) - 1
|
||||||
const newMembershipStatus =
|
const newMembershipStatus =
|
||||||
newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
|
newRemainingTimes <= 0 ? MembershipStatus.USED_UP : MembershipStatus.ACTIVE
|
||||||
|
|
||||||
await tx.membership.update({
|
await tx.membership.update({
|
||||||
where: { id: dto.membershipId },
|
where: { id: existing.membershipId },
|
||||||
data: {
|
data: {
|
||||||
remainingTimes: newRemainingTimes,
|
remainingTimes: newRemainingTimes,
|
||||||
status: newMembershipStatus,
|
status: newMembershipStatus,
|
||||||
@@ -152,10 +221,88 @@ export class BookingService {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
return newBooking
|
// 6. Record status history
|
||||||
|
await tx.bookingStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
bookingId,
|
||||||
|
fromStatus: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
toStatus: BookingStatus.CONFIRMED,
|
||||||
|
operatorId,
|
||||||
|
remark: remark || '老师确认预约',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
|
})
|
||||||
|
|
||||||
|
return this.fetchBookingWithRelations(booking.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Complete / NoShow Booking (Admin) ──────────────────────────────────
|
||||||
|
|
||||||
|
async completeBooking(
|
||||||
|
bookingId: string,
|
||||||
|
operatorId: string,
|
||||||
|
remark?: string,
|
||||||
|
): Promise<BookingWithRelations> {
|
||||||
|
return this.markBookingStatus(bookingId, operatorId, BookingStatus.COMPLETED, remark || '老师核销完成')
|
||||||
|
}
|
||||||
|
|
||||||
|
async markNoShow(
|
||||||
|
bookingId: string,
|
||||||
|
operatorId: string,
|
||||||
|
remark?: string,
|
||||||
|
): Promise<BookingWithRelations> {
|
||||||
|
return this.markBookingStatus(bookingId, operatorId, BookingStatus.NO_SHOW, remark || '学员未出席')
|
||||||
|
}
|
||||||
|
|
||||||
|
private async markBookingStatus(
|
||||||
|
bookingId: string,
|
||||||
|
operatorId: string,
|
||||||
|
toStatus: BookingStatus,
|
||||||
|
remark: string,
|
||||||
|
): Promise<BookingWithRelations> {
|
||||||
|
const booking = await this.prisma.$transaction(async (tx) => {
|
||||||
|
const existing = await tx.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: { timeSlot: true },
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!existing) {
|
||||||
|
throw new NotFoundException(`Booking ${bookingId} not found`)
|
||||||
|
}
|
||||||
|
if (existing.status !== BookingStatus.CONFIRMED) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Cannot mark as ${toStatus} with status: ${existing.status}`,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {
|
||||||
|
status: toStatus,
|
||||||
|
operatorId,
|
||||||
|
}
|
||||||
|
if (toStatus === BookingStatus.COMPLETED) {
|
||||||
|
updateData.completedAt = new Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
const updated = await tx.booking.update({
|
||||||
|
where: { id: bookingId },
|
||||||
|
data: updateData,
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.bookingStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
bookingId,
|
||||||
|
fromStatus: BookingStatus.CONFIRMED,
|
||||||
|
toStatus,
|
||||||
|
operatorId,
|
||||||
|
remark,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return updated
|
||||||
})
|
})
|
||||||
|
|
||||||
// Re-fetch with relations after transaction
|
|
||||||
return this.fetchBookingWithRelations(booking.id)
|
return this.fetchBookingWithRelations(booking.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -165,7 +312,6 @@ export class BookingService {
|
|||||||
userId: string,
|
userId: string,
|
||||||
bookingId: string,
|
bookingId: string,
|
||||||
): Promise<CancelBookingResult> {
|
): Promise<CancelBookingResult> {
|
||||||
// 1. Find booking with timeSlot and membership
|
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
include: {
|
include: {
|
||||||
@@ -180,13 +326,37 @@ export class BookingService {
|
|||||||
if (booking.userId !== userId) {
|
if (booking.userId !== userId) {
|
||||||
throw new ForbiddenException('This booking does not belong to you')
|
throw new ForbiddenException('This booking does not belong to you')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let refunded = false
|
||||||
|
|
||||||
|
// PENDING_CONFIRMATION: can cancel directly, no refund needed (times never deducted)
|
||||||
|
if (booking.status === BookingStatus.PENDING_CONFIRMATION) {
|
||||||
|
await this.prisma.$transaction(async (tx) => {
|
||||||
|
await tx.booking.update({
|
||||||
|
where: { id: bookingId },
|
||||||
|
data: { status: BookingStatus.CANCELLED },
|
||||||
|
})
|
||||||
|
|
||||||
|
await tx.bookingStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
bookingId,
|
||||||
|
fromStatus: BookingStatus.PENDING_CONFIRMATION,
|
||||||
|
toStatus: BookingStatus.CANCELLED,
|
||||||
|
operatorId: userId,
|
||||||
|
remark: '学员取消预约(待确认状态)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return { booking: { ...booking, status: BookingStatus.CANCELLED }, refunded }
|
||||||
|
}
|
||||||
|
|
||||||
|
// CONFIRMED: check cancel time limit and potentially refund
|
||||||
if (booking.status !== BookingStatus.CONFIRMED) {
|
if (booking.status !== BookingStatus.CONFIRMED) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`Cannot cancel booking with status: ${booking.status}`,
|
`Cannot cancel booking with status: ${booking.status}`,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Determine refund eligibility
|
|
||||||
const studioConfig = await this.studioService.getInfo()
|
const studioConfig = await this.studioService.getInfo()
|
||||||
const { cancelHoursLimit } = studioConfig
|
const { cancelHoursLimit } = studioConfig
|
||||||
|
|
||||||
@@ -194,9 +364,7 @@ export class BookingService {
|
|||||||
const deadlineMs = Date.now() + cancelHoursLimit * 3600 * 1000
|
const deadlineMs = Date.now() + cancelHoursLimit * 3600 * 1000
|
||||||
const withinLimit = slotStartMs >= deadlineMs
|
const withinLimit = slotStartMs >= deadlineMs
|
||||||
|
|
||||||
// 3. Transaction: cancel booking, restore slot, conditionally restore membership
|
|
||||||
const updatedBooking = await this.prisma.$transaction(async (tx) => {
|
const updatedBooking = await this.prisma.$transaction(async (tx) => {
|
||||||
// Cancel the booking
|
|
||||||
const cancelled = await tx.booking.update({
|
const cancelled = await tx.booking.update({
|
||||||
where: { id: bookingId },
|
where: { id: bookingId },
|
||||||
data: {
|
data: {
|
||||||
@@ -241,13 +409,48 @@ export class BookingService {
|
|||||||
status: newStatus,
|
status: newStatus,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
refunded = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tx.bookingStatusHistory.create({
|
||||||
|
data: {
|
||||||
|
bookingId,
|
||||||
|
fromStatus: BookingStatus.CONFIRMED,
|
||||||
|
toStatus: BookingStatus.CANCELLED,
|
||||||
|
operatorId: userId,
|
||||||
|
remark: refunded ? '学员取消预约(超时退款)' : '学员取消预约(未超时不退款)',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
return cancelled
|
return cancelled
|
||||||
})
|
})
|
||||||
|
|
||||||
return { booking: { ...updatedBooking }, refunded: withinLimit }
|
return { booking: { ...updatedBooking }, refunded }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Get Booking Status History ──────────────────────────────────────────
|
||||||
|
|
||||||
|
async getBookingStatusHistory(bookingId: string): Promise<BookingStatusHistory[]> {
|
||||||
|
const history = await this.prisma.bookingStatusHistory.findMany({
|
||||||
|
where: { bookingId },
|
||||||
|
orderBy: { createdAt: 'asc' },
|
||||||
|
})
|
||||||
|
return history
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Get Booking By Id ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async getBookingById(bookingId: string): Promise<BookingWithRelations | null> {
|
||||||
|
const booking = await this.prisma.booking.findUnique({
|
||||||
|
where: { id: bookingId },
|
||||||
|
include: {
|
||||||
|
timeSlot: true,
|
||||||
|
membership: { include: { cardType: true } },
|
||||||
|
user: { select: { id: true, nickname: true, phone: true } },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return booking as BookingWithRelations | null
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Get My Bookings ─────────────────────────────────────────────────────
|
// ─── Get My Bookings ─────────────────────────────────────────────────────
|
||||||
@@ -294,7 +497,7 @@ export class BookingService {
|
|||||||
const bookings = await this.prisma.booking.findMany({
|
const bookings = await this.prisma.booking.findMany({
|
||||||
where: {
|
where: {
|
||||||
userId,
|
userId,
|
||||||
status: BookingStatus.CONFIRMED,
|
status: { in: [BookingStatus.PENDING_CONFIRMATION, BookingStatus.CONFIRMED] },
|
||||||
timeSlot: {
|
timeSlot: {
|
||||||
date: { gte: today },
|
date: { gte: today },
|
||||||
},
|
},
|
||||||
@@ -346,7 +549,7 @@ export class BookingService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Private Helpers ──────────────────────────────────────────────────────
|
// ─── Private Helpers ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
|
private async fetchBookingWithRelations(bookingId: string): Promise<BookingWithRelations> {
|
||||||
const booking = await this.prisma.booking.findUnique({
|
const booking = await this.prisma.booking.findUnique({
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -36,6 +36,7 @@ var TimeSlotSource;
|
|||||||
// ===== Booking =====
|
// ===== Booking =====
|
||||||
var BookingStatus;
|
var BookingStatus;
|
||||||
(function (BookingStatus) {
|
(function (BookingStatus) {
|
||||||
|
BookingStatus["PENDING_CONFIRMATION"] = "PENDING_CONFIRMATION";
|
||||||
BookingStatus["CONFIRMED"] = "CONFIRMED";
|
BookingStatus["CONFIRMED"] = "CONFIRMED";
|
||||||
BookingStatus["CANCELLED"] = "CANCELLED";
|
BookingStatus["CANCELLED"] = "CANCELLED";
|
||||||
BookingStatus["COMPLETED"] = "COMPLETED";
|
BookingStatus["COMPLETED"] = "COMPLETED";
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
{"version":3,"file":"enums.js","sourceRoot":"","sources":["enums.ts"],"names":[],"mappings":";;;AAAA,mBAAmB;AACnB,IAAY,QAGX;AAHD,WAAY,QAAQ;IAClB,6BAAiB,CAAA;IACjB,2BAAe,CAAA;AACjB,CAAC,EAHW,QAAQ,wBAAR,QAAQ,QAGnB;AAED,uBAAuB;AACvB,IAAY,gBAIX;AAJD,WAAY,gBAAgB;IAC1B,mCAAe,CAAA;IACf,yCAAqB,CAAA;IACrB,mCAAe,CAAA;AACjB,CAAC,EAJW,gBAAgB,gCAAhB,gBAAgB,QAI3B;AAED,yBAAyB;AACzB,IAAY,gBAIX;AAJD,WAAY,gBAAgB;IAC1B,qCAAiB,CAAA;IACjB,uCAAmB,CAAA;IACnB,uCAAmB,CAAA;AACrB,CAAC,EAJW,gBAAgB,gCAAhB,gBAAgB,QAI3B;AAED,uBAAuB;AACvB,IAAY,cAIX;AAJD,WAAY,cAAc;IACxB,+BAAa,CAAA;IACb,+BAAa,CAAA;IACb,mCAAiB,CAAA;AACnB,CAAC,EAJW,cAAc,8BAAd,cAAc,QAIzB;AAED,IAAY,cAGX;AAHD,WAAY,cAAc;IACxB,uCAAqB,CAAA;IACrB,mCAAiB,CAAA;AACnB,CAAC,EAHW,cAAc,8BAAd,cAAc,QAGzB;AAED,sBAAsB;AACtB,IAAY,aAKX;AALD,WAAY,aAAa;IACvB,wCAAuB,CAAA;IACvB,wCAAuB,CAAA;IACvB,wCAAuB,CAAA;IACvB,oCAAmB,CAAA;AACrB,CAAC,EALW,aAAa,6BAAb,aAAa,QAKxB;AAED,oBAAoB;AACpB,IAAY,WAIX;AAJD,WAAY,WAAW;IACrB,kCAAmB,CAAA;IACnB,4BAAa,CAAA;IACb,oCAAqB,CAAA;AACvB,CAAC,EAJW,WAAW,2BAAX,WAAW,QAItB"}
|
{"version":3,"file":"enums.js","sourceRoot":"","sources":["enums.ts"],"names":[],"mappings":";;;AAAA,mBAAmB;AACnB,IAAY,QAGX;AAHD,WAAY,QAAQ;IAClB,6BAAiB,CAAA;IACjB,2BAAe,CAAA;AACjB,CAAC,EAHW,QAAQ,wBAAR,QAAQ,QAGnB;AAED,uBAAuB;AACvB,IAAY,gBAIX;AAJD,WAAY,gBAAgB;IAC1B,mCAAe,CAAA;IACf,yCAAqB,CAAA;IACrB,mCAAe,CAAA;AACjB,CAAC,EAJW,gBAAgB,gCAAhB,gBAAgB,QAI3B;AAED,yBAAyB;AACzB,IAAY,gBAIX;AAJD,WAAY,gBAAgB;IAC1B,qCAAiB,CAAA;IACjB,uCAAmB,CAAA;IACnB,uCAAmB,CAAA;AACrB,CAAC,EAJW,gBAAgB,gCAAhB,gBAAgB,QAI3B;AAED,uBAAuB;AACvB,IAAY,cAIX;AAJD,WAAY,cAAc;IACxB,+BAAa,CAAA;IACb,+BAAa,CAAA;IACb,mCAAiB,CAAA;AACnB,CAAC,EAJW,cAAc,8BAAd,cAAc,QAIzB;AAED,IAAY,cAGX;AAHD,WAAY,cAAc;IACxB,uCAAqB,CAAA;IACrB,mCAAiB,CAAA;AACnB,CAAC,EAHW,cAAc,8BAAd,cAAc,QAGzB;AAED,sBAAsB;AACtB,IAAY,aAMX;AAND,WAAY,aAAa;IACvB,8DAA6C,CAAA;IAC7C,wCAAuB,CAAA;IACvB,wCAAuB,CAAA;IACvB,wCAAuB,CAAA;IACvB,oCAAmB,CAAA;AACrB,CAAC,EANW,aAAa,6BAAb,aAAa,QAMxB;AAED,oBAAoB;AACpB,IAAY,WAIX;AAJD,WAAY,WAAW;IACrB,kCAAmB,CAAA;IACnB,4BAAa,CAAA;IACb,oCAAqB,CAAA;AACvB,CAAC,EAJW,WAAW,2BAAX,WAAW,QAItB"}
|
||||||
@@ -32,10 +32,11 @@ export enum TimeSlotSource {
|
|||||||
|
|
||||||
// ===== Booking =====
|
// ===== Booking =====
|
||||||
export enum BookingStatus {
|
export enum BookingStatus {
|
||||||
CONFIRMED = 'CONFIRMED',
|
PENDING_CONFIRMATION = 'PENDING_CONFIRMATION', // 待确认
|
||||||
CANCELLED = 'CANCELLED',
|
CONFIRMED = 'CONFIRMED', // 已确认
|
||||||
COMPLETED = 'COMPLETED',
|
CANCELLED = 'CANCELLED', // 已取消
|
||||||
NO_SHOW = 'NO_SHOW',
|
COMPLETED = 'COMPLETED', // 已完成/已核销
|
||||||
|
NO_SHOW = 'NO_SHOW', // 未出席
|
||||||
}
|
}
|
||||||
|
|
||||||
// ===== Order =====
|
// ===== Order =====
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ export type {
|
|||||||
PublishDaySlotsDto,
|
PublishDaySlotsDto,
|
||||||
Booking,
|
Booking,
|
||||||
BookingWithDetails,
|
BookingWithDetails,
|
||||||
|
BookingWithUser,
|
||||||
|
BookingStatusHistory,
|
||||||
CreateBookingDto,
|
CreateBookingDto,
|
||||||
Order,
|
Order,
|
||||||
OrderWithDetails,
|
OrderWithDetails,
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ export interface Booking {
|
|||||||
readonly membershipId: string
|
readonly membershipId: string
|
||||||
readonly status: BookingStatus
|
readonly status: BookingStatus
|
||||||
readonly cancelledAt: string | null
|
readonly cancelledAt: string | null
|
||||||
|
readonly confirmedAt: string | null
|
||||||
|
readonly completedAt: string | null
|
||||||
|
readonly operatorId: string | null
|
||||||
readonly createdAt: string
|
readonly createdAt: string
|
||||||
readonly updatedAt: string
|
readonly updatedAt: string
|
||||||
}
|
}
|
||||||
@@ -25,6 +28,25 @@ export interface BookingWithDetails extends Booking {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Admin view: booking with user info */
|
||||||
|
export interface BookingWithUser extends BookingWithDetails {
|
||||||
|
readonly user: {
|
||||||
|
readonly id: string
|
||||||
|
readonly nickname: string
|
||||||
|
readonly phone: string | null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BookingStatusHistory {
|
||||||
|
readonly id: string
|
||||||
|
readonly bookingId: string
|
||||||
|
readonly fromStatus: BookingStatus | null
|
||||||
|
readonly toStatus: BookingStatus
|
||||||
|
readonly operatorId: string | null
|
||||||
|
readonly remark: string | null
|
||||||
|
readonly createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export interface CreateBookingDto {
|
export interface CreateBookingDto {
|
||||||
readonly timeSlotId: string
|
readonly timeSlotId: string
|
||||||
readonly membershipId: string
|
readonly membershipId: string
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export type { CardType, CreateCardTypeDto, UpdateCardTypeDto } from './card-type
|
|||||||
export type { Membership, MembershipWithCardType } from './membership'
|
export type { Membership, MembershipWithCardType } from './membership'
|
||||||
export type { WeekTemplate, WeekTemplateInput } from './week-template'
|
export type { WeekTemplate, WeekTemplateInput } from './week-template'
|
||||||
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot'
|
export type { TimeSlot, TimeSlotWithBookingStatus, CreateManualSlotDto, ScheduleSlotPreview, PublishDaySlotItem, PublishDaySlotsDto } from './time-slot'
|
||||||
export type { Booking, BookingWithDetails, CreateBookingDto } from './booking'
|
export type { Booking, BookingWithDetails, BookingWithUser, BookingStatusHistory, CreateBookingDto } from './booking'
|
||||||
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
|
export type { Order, OrderWithDetails, CreateOrderDto, PaymentParams, CreateOrderResponse } from './order'
|
||||||
export type { StudioConfig, UpdateStudioConfigDto } from './studio'
|
export type { StudioConfig, UpdateStudioConfigDto } from './studio'
|
||||||
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
|
export type { ApiResponse, PaginatedData, PaginatedResponse, PaginationQuery } from './api'
|
||||||
|
|||||||
Reference in New Issue
Block a user