Files
mp-pilates/packages/app/src/pages/profile/bookings.vue

682 lines
17 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>