682 lines
17 KiB
Vue
682 lines
17 KiB
Vue
<template>
|
||
<view class="page" :style="{ paddingTop: navBarHeight }">
|
||
<CustomNavBar title="我的预约" show-back />
|
||
|
||
<view class="tabs">
|
||
<view
|
||
v-for="tab in tabs"
|
||
:key="tab.key"
|
||
class="tabs-item"
|
||
:class="{ 'tabs-item--on': activeTab === tab.key }"
|
||
@tap="selectTab(tab.key)"
|
||
>
|
||
<text class="tabs-label">{{ tab.label }}</text>
|
||
<text v-if="tab.key === 'upcoming' && upcomingCount > 0" class="tabs-count">{{ upcomingCount }}</text>
|
||
</view>
|
||
</view>
|
||
|
||
<scroll-view
|
||
v-show="activeTab === 'upcoming'"
|
||
class="scroll"
|
||
scroll-y
|
||
refresher-enabled
|
||
:refresher-triggered="refreshingUpcoming"
|
||
@refresherrefresh="onRefreshUpcoming"
|
||
>
|
||
<view v-if="bookingStore.loadingBookings && !refreshingUpcoming" class="loading-wrap">
|
||
<view v-for="i in 3" :key="i" class="skeleton-row" />
|
||
</view>
|
||
|
||
<view v-else-if="upcomingGroups.length === 0" class="state-wrap">
|
||
<text class="state-mark">—</text>
|
||
<text class="state-title">暂无即将上课的预约</text>
|
||
<text class="state-copy">去课表选一个时段即可。</text>
|
||
<view class="state-btn" @tap="goBooking">
|
||
<text class="state-btn-text">去预约</text>
|
||
</view>
|
||
</view>
|
||
|
||
<view v-else class="list">
|
||
<view v-for="group in upcomingGroups" :key="group.key" class="day-group">
|
||
<view class="day-head">
|
||
<text class="day-label">{{ group.label }}</text>
|
||
<text class="day-count">{{ group.items.length }} 节</text>
|
||
</view>
|
||
|
||
<view
|
||
v-for="booking in group.items"
|
||
:key="booking.id"
|
||
class="row"
|
||
@tap="goDetail(booking)"
|
||
>
|
||
<view class="row-rail" :class="bookingStatusStripeClass(booking.status)" />
|
||
<view class="row-body">
|
||
<view class="row-top">
|
||
<view class="row-time-wrap">
|
||
<text class="row-time">{{ startTime(booking) }}</text>
|
||
<text class="row-end">— {{ endTime(booking) }}</text>
|
||
</view>
|
||
<text class="row-stamp" :class="stampClass(booking.status)">
|
||
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
|
||
</text>
|
||
</view>
|
||
<view class="row-bottom">
|
||
<text class="row-meta">{{ cardName(booking) }}</text>
|
||
<text class="row-cancel" @tap.stop="handleCancel(booking)">取消</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="scroll-bottom" />
|
||
</scroll-view>
|
||
|
||
<scroll-view
|
||
v-show="activeTab === 'history'"
|
||
class="scroll"
|
||
scroll-y
|
||
refresher-enabled
|
||
:refresher-triggered="refreshingHistory"
|
||
@refresherrefresh="onRefreshHistory"
|
||
>
|
||
<view v-if="supplements.length || supplementsError || supplementsLoading" class="supplement">
|
||
<text class="section-label">历史补录</text>
|
||
<text v-if="supplementsLoading" class="supplement-message">正在加载补录记录…</text>
|
||
<button v-else-if="supplementsError" class="supplement-retry" @tap="fetchSupplements">
|
||
补录记录加载失败,点击重试
|
||
</button>
|
||
<LessonSupplementList v-else :records="supplements" />
|
||
</view>
|
||
|
||
<view v-if="bookingStore.loadingBookings && !refreshingHistory" class="loading-wrap">
|
||
<view v-for="i in 3" :key="i" class="skeleton-row" />
|
||
</view>
|
||
|
||
<view
|
||
v-else-if="historyGroups.length === 0 && !supplements.length && !supplementsLoading && !supplementsError"
|
||
class="state-wrap"
|
||
>
|
||
<text class="state-mark">—</text>
|
||
<text class="state-title">暂无历史记录</text>
|
||
<text class="state-copy">上完或取消的课程会显示在这里。</text>
|
||
</view>
|
||
|
||
<view v-else-if="historyGroups.length > 0" class="list">
|
||
<view v-for="group in historyGroups" :key="group.key" class="day-group">
|
||
<view class="day-head">
|
||
<text class="day-label">{{ group.label }}</text>
|
||
<text class="day-count">{{ group.items.length }} 节</text>
|
||
</view>
|
||
|
||
<view
|
||
v-for="booking in group.items"
|
||
:key="booking.id"
|
||
class="row"
|
||
:class="{ 'row--muted': isMutedStatus(booking.status) }"
|
||
@tap="goDetail(booking)"
|
||
>
|
||
<view class="row-rail" :class="bookingStatusStripeClass(booking.status)" />
|
||
<view class="row-body">
|
||
<view class="row-top">
|
||
<view class="row-time-wrap">
|
||
<text class="row-time">{{ startTime(booking) }}</text>
|
||
<text class="row-end">— {{ endTime(booking) }}</text>
|
||
</view>
|
||
<text class="row-stamp" :class="stampClass(booking.status)">
|
||
{{ booking.review ? '★ ' + booking.review.rating + ' · 已评价' : bookingStatusLabel(booking.status) }}
|
||
</text>
|
||
</view>
|
||
<text class="row-meta">{{ historyDayLabel(booking.timeSlot.date) }} · {{ cardName(booking) }}</text>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
</view>
|
||
|
||
<view class="scroll-bottom" />
|
||
</scroll-view>
|
||
</view>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { onShow } from '@dcloudio/uni-app'
|
||
import type { BookingWithDetails, LessonSupplementRecord } from '@mp-pilates/shared'
|
||
import { BookingStatus } from '@mp-pilates/shared'
|
||
import { useBookingStore } from '../../stores/booking'
|
||
import { formatDate } from '../../utils/format'
|
||
import { getSystemLayout } from '../../utils/system'
|
||
import { get } from '../../utils/request'
|
||
import {
|
||
formatDateDisplay,
|
||
bookingStatusLabel,
|
||
bookingStatusStripeClass,
|
||
} from '../../utils/booking-helpers'
|
||
import CustomNavBar from '../../components/CustomNavBar.vue'
|
||
import LessonSupplementList from '../../components/LessonSupplementList.vue'
|
||
import { requestBookingCancelSubscriptionMessage } from '../../utils/wechat-subscription'
|
||
|
||
type TabKey = 'upcoming' | 'history'
|
||
|
||
interface BookingGroup {
|
||
key: string
|
||
label: string
|
||
items: BookingWithDetails[]
|
||
}
|
||
|
||
const bookingStore = useBookingStore()
|
||
const supplements = ref<LessonSupplementRecord[]>([])
|
||
const supplementsLoading = ref(false)
|
||
const supplementsError = ref(false)
|
||
|
||
const navBarHeight = ref(`${getSystemLayout().navBarHeight}px`)
|
||
|
||
const tabs = [
|
||
{ key: 'upcoming' as TabKey, label: '即将上课' },
|
||
{ key: 'history' as TabKey, label: '历史记录' },
|
||
]
|
||
|
||
const activeTab = ref<TabKey>('upcoming')
|
||
const refreshingUpcoming = ref(false)
|
||
const refreshingHistory = ref(false)
|
||
const hasLoadedOnce = ref(false)
|
||
|
||
async function fetchSupplements() {
|
||
supplementsLoading.value = true
|
||
supplementsError.value = false
|
||
try {
|
||
supplements.value = await get<LessonSupplementRecord[]>('/user/lesson-supplements')
|
||
} catch {
|
||
supplementsError.value = true
|
||
} finally {
|
||
supplementsLoading.value = false
|
||
}
|
||
}
|
||
|
||
function safeBookings(): readonly BookingWithDetails[] {
|
||
const raw = bookingStore.myBookings
|
||
return Array.isArray(raw) ? raw : []
|
||
}
|
||
|
||
function toDateStr(date: string): string {
|
||
return date.slice(0, 10)
|
||
}
|
||
|
||
function isUpcomingBooking(booking: BookingWithDetails, todayStr: string): boolean {
|
||
const upcomingStatus =
|
||
booking.status === BookingStatus.PENDING_CONFIRMATION ||
|
||
booking.status === BookingStatus.CONFIRMED
|
||
return upcomingStatus && toDateStr(booking.timeSlot.date) >= todayStr
|
||
}
|
||
|
||
const today = computed(() => formatDate(new Date()))
|
||
|
||
const upcomingBookings = computed<BookingWithDetails[]>(() => {
|
||
return safeBookings()
|
||
.filter((booking) => isUpcomingBooking(booking, today.value))
|
||
.sort((a, b) => {
|
||
const dateA = toDateStr(a.timeSlot.date)
|
||
const dateB = toDateStr(b.timeSlot.date)
|
||
if (dateA !== dateB) return dateA.localeCompare(dateB)
|
||
return a.timeSlot.startTime.localeCompare(b.timeSlot.startTime)
|
||
})
|
||
})
|
||
|
||
const historyBookings = computed<BookingWithDetails[]>(() => {
|
||
return safeBookings()
|
||
.filter((booking) => !isUpcomingBooking(booking, today.value))
|
||
.sort((a, b) => {
|
||
const dateA = toDateStr(a.timeSlot.date)
|
||
const dateB = toDateStr(b.timeSlot.date)
|
||
if (dateB !== dateA) return dateB.localeCompare(dateA)
|
||
return b.timeSlot.startTime.localeCompare(a.timeSlot.startTime)
|
||
})
|
||
})
|
||
|
||
const upcomingCount = computed(() => upcomingBookings.value.length)
|
||
|
||
const upcomingGroups = computed(() =>
|
||
groupBookings(
|
||
upcomingBookings.value,
|
||
(booking) => toDateStr(booking.timeSlot.date),
|
||
(key) => formatDateDisplay(key),
|
||
),
|
||
)
|
||
|
||
const historyGroups = computed(() =>
|
||
groupBookings(
|
||
historyBookings.value,
|
||
(booking) => toDateStr(booking.timeSlot.date).slice(0, 7),
|
||
(key) => {
|
||
const [year, month] = key.split('-')
|
||
return `${year}年${Number(month)}月`
|
||
},
|
||
),
|
||
)
|
||
|
||
function groupBookings(
|
||
bookings: BookingWithDetails[],
|
||
getKey: (booking: BookingWithDetails) => string,
|
||
getLabel: (key: string) => string,
|
||
): BookingGroup[] {
|
||
const map = new Map<string, BookingWithDetails[]>()
|
||
for (const booking of bookings) {
|
||
const key = getKey(booking)
|
||
const list = map.get(key)
|
||
if (list) list.push(booking)
|
||
else map.set(key, [booking])
|
||
}
|
||
return Array.from(map, ([key, items]) => ({
|
||
key,
|
||
label: getLabel(key),
|
||
items,
|
||
}))
|
||
}
|
||
|
||
function startTime(booking: BookingWithDetails): string {
|
||
return booking.timeSlot.startTime.slice(0, 5)
|
||
}
|
||
|
||
function endTime(booking: BookingWithDetails): string {
|
||
return booking.timeSlot.endTime.slice(0, 5)
|
||
}
|
||
|
||
function cardName(booking: BookingWithDetails): string {
|
||
return booking.membership?.cardType?.name || '会员卡'
|
||
}
|
||
|
||
function historyDayLabel(dateStr: string): string {
|
||
const [year, month, day] = toDateStr(dateStr).split('-').map(Number)
|
||
const weekday = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'][new Date(year, month - 1, day).getDay()]
|
||
return `${day}日 ${weekday}`
|
||
}
|
||
|
||
function stampClass(status: string): string {
|
||
if (status === BookingStatus.PENDING_CONFIRMATION) return 'stamp--pending'
|
||
if (status === BookingStatus.CANCELLED) return 'stamp--cancelled'
|
||
if (status === BookingStatus.NO_SHOW) return 'stamp--noshow'
|
||
if (status === BookingStatus.COMPLETED) return 'stamp--completed'
|
||
return 'stamp--confirmed'
|
||
}
|
||
|
||
function isMutedStatus(status: string): boolean {
|
||
return status === BookingStatus.CANCELLED || status === BookingStatus.NO_SHOW
|
||
}
|
||
|
||
function selectTab(key: TabKey) {
|
||
activeTab.value = key
|
||
}
|
||
|
||
async function onRefreshUpcoming() {
|
||
refreshingUpcoming.value = true
|
||
await bookingStore.fetchMyBookings()
|
||
refreshingUpcoming.value = false
|
||
}
|
||
|
||
async function onRefreshHistory() {
|
||
refreshingHistory.value = true
|
||
await Promise.all([bookingStore.fetchMyBookings(), fetchSupplements()])
|
||
refreshingHistory.value = false
|
||
}
|
||
|
||
function goBooking() {
|
||
uni.switchTab({ url: '/pages/booking/index' })
|
||
}
|
||
|
||
function goDetail(booking: BookingWithDetails) {
|
||
uni.navigateTo({ url: `/pages/booking/detail?id=${booking.id}` })
|
||
}
|
||
|
||
async function handleCancel(booking: BookingWithDetails) {
|
||
try {
|
||
await requestBookingCancelSubscriptionMessage()
|
||
} catch (err: unknown) {
|
||
console.warn('[subscribe] cancel pre-subscribe failed', err)
|
||
}
|
||
|
||
const dateLabel = formatDateDisplay(booking.timeSlot.date)
|
||
const timeLabel = startTime(booking)
|
||
|
||
uni.showModal({
|
||
title: '取消预约',
|
||
content: `确定要取消 ${dateLabel} ${timeLabel} 的课程吗?`,
|
||
confirmText: '确定取消',
|
||
confirmColor: '#ef4444',
|
||
cancelText: '再想想',
|
||
success: async (res) => {
|
||
if (!res.confirm) return
|
||
uni.showLoading({ title: '取消中...' })
|
||
try {
|
||
await bookingStore.cancelBooking(booking.id)
|
||
uni.hideLoading()
|
||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||
await bookingStore.fetchMyBookings()
|
||
} catch (err: unknown) {
|
||
uni.hideLoading()
|
||
const msg = err instanceof Error ? err.message : '取消失败,请重试'
|
||
uni.showToast({ title: msg, icon: 'none' })
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
|
||
fetchSupplements()
|
||
bookingStore.fetchMyBookings().then(() => {
|
||
hasLoadedOnce.value = true
|
||
})
|
||
})
|
||
|
||
onShow(() => {
|
||
if (!hasLoadedOnce.value) return
|
||
fetchSupplements()
|
||
bookingStore.fetchMyBookings(undefined, { silent: true })
|
||
})
|
||
</script>
|
||
|
||
<style lang="scss" scoped>
|
||
.page {
|
||
height: 100vh;
|
||
box-sizing: border-box;
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow: hidden;
|
||
background: #fbf9f6;
|
||
color: #514943;
|
||
}
|
||
|
||
.tabs {
|
||
display: flex;
|
||
flex-shrink: 0;
|
||
border-bottom: 1rpx solid #eee8e3;
|
||
}
|
||
|
||
.tabs-item {
|
||
flex: 1;
|
||
height: 88rpx;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: 8rpx;
|
||
position: relative;
|
||
|
||
&:active {
|
||
opacity: 0.7;
|
||
}
|
||
|
||
&--on {
|
||
.tabs-label {
|
||
color: #3d3833;
|
||
}
|
||
|
||
&::after {
|
||
content: '';
|
||
position: absolute;
|
||
left: 50%;
|
||
bottom: 0;
|
||
width: 48rpx;
|
||
height: 4rpx;
|
||
border-radius: 2rpx;
|
||
background: #6b8276;
|
||
transform: translateX(-50%);
|
||
}
|
||
}
|
||
}
|
||
|
||
.tabs-label {
|
||
font-size: 28rpx;
|
||
color: #8b817b;
|
||
}
|
||
|
||
.tabs-count {
|
||
font-size: 24rpx;
|
||
color: #6b8276;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.scroll {
|
||
flex: 1;
|
||
height: 0;
|
||
min-height: 0;
|
||
width: 100%;
|
||
}
|
||
|
||
.loading-wrap {
|
||
padding: 28rpx 32rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.skeleton-row {
|
||
height: 148rpx;
|
||
border-radius: 24rpx;
|
||
background: linear-gradient(90deg, #eee8e2 25%, #f9f6f2 50%, #eee8e2 75%);
|
||
background-size: 400% 100%;
|
||
animation: shimmer 1.5s infinite;
|
||
}
|
||
|
||
.state-wrap {
|
||
min-height: 520rpx;
|
||
padding: 80rpx 48rpx;
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
}
|
||
|
||
.state-mark {
|
||
font-size: 48rpx;
|
||
color: #c9c0b8;
|
||
margin-bottom: 16rpx;
|
||
}
|
||
|
||
.state-title {
|
||
font-size: 32rpx;
|
||
color: #514943;
|
||
margin-bottom: 10rpx;
|
||
}
|
||
|
||
.state-copy {
|
||
font-size: 24rpx;
|
||
color: #8b817b;
|
||
text-align: center;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.state-btn {
|
||
margin-top: 32rpx;
|
||
min-width: 200rpx;
|
||
height: 80rpx;
|
||
padding: 0 40rpx;
|
||
border-radius: 999rpx;
|
||
background: #6b8276;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
|
||
&:active {
|
||
opacity: 0.88;
|
||
}
|
||
}
|
||
|
||
.state-btn-text {
|
||
font-size: 28rpx;
|
||
color: #fff;
|
||
}
|
||
|
||
.list {
|
||
padding: 24rpx 0 0;
|
||
}
|
||
|
||
.day-group {
|
||
margin: 0 32rpx 28rpx;
|
||
}
|
||
|
||
.day-head {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
padding: 4rpx 4rpx 16rpx;
|
||
}
|
||
|
||
.day-label {
|
||
font-size: 26rpx;
|
||
color: #6f655e;
|
||
}
|
||
|
||
.day-count {
|
||
font-size: 22rpx;
|
||
color: #8b817b;
|
||
}
|
||
|
||
.row {
|
||
display: flex;
|
||
margin-bottom: 16rpx;
|
||
border: 1rpx solid #eee8e3;
|
||
border-radius: 24rpx;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
|
||
&:last-child {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
&--muted {
|
||
background: #f7f5f2;
|
||
|
||
.row-time {
|
||
color: #8b817b;
|
||
}
|
||
}
|
||
}
|
||
|
||
.row-rail {
|
||
width: 6rpx;
|
||
flex-shrink: 0;
|
||
background: #6b8276;
|
||
|
||
&.stripe--pending { background: #c4a064; }
|
||
&.stripe--confirmed { background: #6b8276; }
|
||
&.stripe--completed { background: #6b8276; }
|
||
&.stripe--cancelled { background: #d4ccc4; }
|
||
&.stripe--noshow { background: #c47a74; }
|
||
}
|
||
|
||
.row-body {
|
||
flex: 1;
|
||
min-width: 0;
|
||
padding: 24rpx 28rpx;
|
||
}
|
||
|
||
.row-top {
|
||
display: flex;
|
||
align-items: baseline;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
}
|
||
|
||
.row-time-wrap {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10rpx;
|
||
min-width: 0;
|
||
}
|
||
|
||
.row-time {
|
||
font-size: 44rpx;
|
||
font-weight: 500;
|
||
line-height: 1;
|
||
color: #3d3833;
|
||
font-variant-numeric: tabular-nums;
|
||
font-family: "Songti SC", "STSong", "Noto Serif SC", serif;
|
||
}
|
||
|
||
.row-end {
|
||
font-size: 26rpx;
|
||
color: #8b817b;
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
|
||
.row-stamp {
|
||
flex-shrink: 0;
|
||
font-size: 22rpx;
|
||
letter-spacing: 1rpx;
|
||
color: #5a6f65;
|
||
|
||
&--pending { color: #9b6e22; }
|
||
&--confirmed,
|
||
&--completed { color: #5a6f65; }
|
||
&--cancelled { color: #8b817b; }
|
||
&--noshow { color: #b45a54; }
|
||
}
|
||
|
||
.row-bottom {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16rpx;
|
||
margin-top: 14rpx;
|
||
}
|
||
|
||
.row-meta {
|
||
display: block;
|
||
margin-top: 12rpx;
|
||
font-size: 24rpx;
|
||
color: #8b817b;
|
||
line-height: 1.4;
|
||
|
||
.row-bottom & {
|
||
margin-top: 0;
|
||
}
|
||
}
|
||
|
||
.row-cancel {
|
||
flex-shrink: 0;
|
||
padding: 8rpx 0 0 20rpx;
|
||
font-size: 24rpx;
|
||
color: #8b817b;
|
||
}
|
||
|
||
.supplement {
|
||
padding: 24rpx 32rpx 8rpx;
|
||
}
|
||
|
||
.section-label {
|
||
display: block;
|
||
margin-bottom: 16rpx;
|
||
font-size: 22rpx;
|
||
color: #8b817b;
|
||
letter-spacing: 2rpx;
|
||
}
|
||
|
||
.supplement-message,
|
||
.supplement-retry {
|
||
font-size: 24rpx;
|
||
color: #8b817b;
|
||
}
|
||
|
||
.supplement-retry {
|
||
width: 100%;
|
||
margin: 0;
|
||
padding: 24rpx;
|
||
line-height: 1.6;
|
||
background: #f3eee8;
|
||
border-radius: 20rpx;
|
||
|
||
&::after {
|
||
border: none;
|
||
}
|
||
}
|
||
|
||
.scroll-bottom {
|
||
height: 40rpx;
|
||
}
|
||
|
||
@keyframes shimmer {
|
||
0% { background-position: 100% 0; }
|
||
100% { background-position: -100% 0; }
|
||
}
|
||
</style>
|