feat: 新的预约列表样式

This commit is contained in:
richarjiang
2026-04-06 21:22:18 +08:00
parent 168968f073
commit f94b48203f
11 changed files with 599 additions and 342 deletions

View File

@@ -1,18 +1,24 @@
<template>
<view class="booking-detail-page" :style="{ paddingTop: navBarHeight }">
<CustomNavBar title="预约详情" show-back />
<CustomNavBar :title="isSlotMode ? '预约课程' : '预约详情'" show-back />
<!-- Loading state -->
<view v-if="loading" class="loading-wrap">
<view class="skeleton-card" />
</view>
<!-- Error state -->
<view v-else-if="!booking" class="empty-wrap">
<!-- Booking mode: not found -->
<view v-else-if="!isSlotMode && !booking" class="empty-wrap">
<text class="empty-title">预约不存在</text>
</view>
<template v-else>
<!-- Slot mode: not found -->
<view v-else-if="isSlotMode && !slotData" class="empty-wrap">
<text class="empty-title">课程不存在</text>
</view>
<!-- Booking detail mode -->
<template v-else-if="!isSlotMode && booking">
<!-- Booking info card -->
<view class="info-card">
<!-- Status banner -->
@@ -31,7 +37,7 @@
<text class="info-value">
{{ booking.timeSlot.startTime.slice(0, 5) }} - {{ booking.timeSlot.endTime.slice(0, 5) }}
</text>
</view>
</view>
<view class="info-row">
<text class="info-label">使用卡种</text>
<text class="info-value">{{ booking.membership?.cardType?.name }}</text>
@@ -99,7 +105,7 @@
<!-- Content -->
<view class="timeline-content">
<view class="timeline-content-header">
<text class="timeline-status">{{ formatHistoryStatus(item.toStatus) }}</text>
<text class="timeline-status">{{ bookingStatusLabel(item.toStatus) }}</text>
<text class="timeline-time">{{ formatDateTime(item.createdAt) }}</text>
</view>
<text v-if="item.remark" class="timeline-remark">{{ item.remark }}</text>
@@ -139,17 +145,102 @@
</view>
</view>
</template>
<!-- Slot preview mode -->
<template v-else-if="isSlotMode && slotData">
<!-- Slot info card -->
<view class="info-card">
<!-- Course info -->
<view class="info-section">
<view class="info-row">
<text class="info-label">课程日期</text>
<text class="info-value">{{ formatDateDisplay(slotData.date) }}</text>
</view>
<view class="info-row">
<text class="info-label">课程时间</text>
<text class="info-value">
{{ slotData.startTime.slice(0, 5) }} - {{ slotData.endTime.slice(0, 5) }}
</text>
</view>
<view class="info-row">
<text class="info-label">课程类型</text>
<text class="info-value">普拉提私教</text>
</view>
<view class="info-row">
<text class="info-label">授课方式</text>
<text class="info-value">私教</text>
</view>
</view>
<!-- Capacity info -->
<view class="info-section">
<view class="info-row">
<text class="info-label">课程容量</text>
<text class="info-value">{{ slotData.bookedCount }} / {{ slotData.capacity }} </text>
</view>
<view class="info-row">
<text class="info-label">剩余名额</text>
<view class="capacity-tag" :class="slotCapacityClass">
<text>{{ slotData.capacity - slotData.bookedCount }} </text>
</view>
</view>
</view>
<!-- Course description -->
<view class="info-section">
<view class="section-title">课程介绍</view>
<text class="desc-text">
普拉提私教课程由专业教练一对一指导根据您的身体状况制定个性化训练方案通过精准的核心控制训练帮助您改善体态增强肌力提升柔韧性
</text>
</view>
<!-- Notes -->
<view class="info-section">
<view class="section-title">温馨提示</view>
<text class="desc-text">· 请提前10分钟到达场馆\n· 建议穿着舒适运动服装\n· 课前2小时内避免大量进食\n· 如需取消请提前联系</text>
</view>
</view>
<!-- Action button -->
<view v-if="canBook" class="action-bar">
<view class="action-btn action-btn--confirm" @tap="handleSlotBook">
<text class="action-btn-text">立即预约</text>
</view>
</view>
<view v-else-if="slotData.status === TimeSlotStatus.FULL" class="action-bar">
<view class="action-btn action-btn--disabled">
<text class="action-btn-text">已约满</text>
</view>
</view>
</template>
<!-- Booking confirm popup (slot mode) -->
<BookingConfirmPopup
v-if="isSlotMode"
:visible="showConfirmPopup"
:time-slot="slotData"
:memberships="userStore.activeMemberships as MembershipWithCardType[]"
@confirm="onConfirmBooking"
@cancel="showConfirmPopup = false"
/>
</view>
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import type { BookingWithDetails, BookingWithUser, BookingStatusHistory } from '@mp-pilates/shared'
import { BookingStatus } from '@mp-pilates/shared'
import type {
BookingWithDetails,
BookingWithUser,
BookingStatusHistory,
TimeSlotWithBookingStatus,
MembershipWithCardType,
} from '@mp-pilates/shared'
import { BookingStatus, TimeSlotStatus } from '@mp-pilates/shared'
import { useBookingStore } from '../../stores/booking'
import { useUserStore } from '../../stores/user'
import { getSystemLayout } from '../../utils/system'
import { isSlotPast } from '../../utils/format'
import {
formatDateDisplay,
bookingStatusLabel,
@@ -157,16 +248,29 @@ import {
bookingTimelineDotClass,
} from '../../utils/booking-helpers'
import CustomNavBar from '../../components/CustomNavBar.vue'
import BookingConfirmPopup from '../../components/BookingConfirmPopup.vue'
const bookingStore = useBookingStore()
const userStore = useUserStore()
const navBarHeight = ref('64px')
const loading = ref(false)
// ─── Page mode ────────────────────────────────────────────────────────────
const isSlotMode = ref(false)
// ─── Booking mode state ───────────────────────────────────────────────────
const bookingId = ref('')
const booking = ref<BookingWithDetails | BookingWithUser | null>(null)
const history = ref<BookingStatusHistory[]>([])
// ─── Slot mode state ──────────────────────────────────────────────────────
const slotId = ref('')
const slotDate = ref('')
const slotData = ref<TimeSlotWithBookingStatus | null>(null)
const showConfirmPopup = ref(false)
// ─── Shared computed ──────────────────────────────────────────────────────
const isAdmin = computed(() => userStore.isAdmin)
const showActions = computed(() =>
booking.value?.status === BookingStatus.PENDING_CONFIRMATION ||
@@ -180,11 +284,23 @@ function hasUser(b: BookingWithDetails | BookingWithUser | null): b is BookingWi
const bookingUser = computed(() => hasUser(booking.value) ? booking.value.user : null)
// ─── Status helpers ───────────────────────────────────────────────────────
function formatHistoryStatus(status: string): string {
return bookingStatusLabel(status)
}
// Slot mode computed
const canBook = computed(() => {
if (!slotData.value) return false
if (slotData.value.status !== TimeSlotStatus.OPEN) return false
if (slotData.value.isBookedByMe) return false
return !isSlotPast(slotData.value.date, slotData.value.startTime)
})
const slotCapacityClass = computed(() => {
if (!slotData.value) return ''
const { bookedCount, capacity } = slotData.value
if (bookedCount >= capacity) return 'cap-full'
if (bookedCount >= capacity * 0.8) return 'cap-almost'
return 'cap-open'
})
// ─── Status helpers ───────────────────────────────────────────────────────
function formatDateTime(dateStr: string): string {
if (!dateStr) return '-'
const d = new Date(dateStr)
@@ -197,10 +313,9 @@ function formatDateTime(dateStr: string): string {
}
// ─── Data loading ─────────────────────────────────────────────────────────
async function loadData() {
async function loadBookingData() {
loading.value = true
try {
// Fetch booking details and history in parallel
const [bookingData, historyData] = await Promise.all([
bookingStore.fetchBookingById(bookingId.value),
bookingStore.fetchBookingHistory(bookingId.value),
@@ -216,7 +331,79 @@ async function loadData() {
}
}
// ─── Actions ──────────────────────────────────────────────────────────────
async function loadSlotData() {
loading.value = true
try {
slotData.value = await bookingStore.fetchSlotById(slotId.value)
} catch (err: unknown) {
const message = err instanceof Error ? err.message : '加载失败'
uni.showToast({ title: message, icon: 'none' })
} finally {
loading.value = false
}
}
// ─── Slot mode: Booking flow ─────────────────────────────────────────────
async function handleSlotBook() {
if (!userStore.loggedIn) {
uni.showModal({
title: '提示',
content: '请先登录后再预约课程',
confirmText: '去登录',
success: async (res) => {
if (res.confirm) {
try {
const { isNewUser } = await userStore.loginWithSetup()
if (!isNewUser) {
handleSlotBook()
}
} catch {
uni.showToast({ title: '登录失败', icon: 'none' })
}
}
},
})
return
}
if (!userStore.hasValidMembership) {
uni.showModal({
title: '暂无可用会员卡',
content: '您当前没有有效的会员卡,购买后即可预约课程',
confirmText: '去购买',
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
},
})
return
}
showConfirmPopup.value = true
}
async function onConfirmBooking(payload: { timeSlotId: string; membershipId: string }) {
showConfirmPopup.value = false
uni.showLoading({ title: '预约中...' })
try {
const result = await bookingStore.createBooking(payload)
uni.hideLoading()
uni.showToast({ title: '预约成功!', icon: 'success' })
// Switch to booking detail mode to show the new booking
isSlotMode.value = false
bookingId.value = result.id
await loadBookingData()
} catch (err: unknown) {
uni.hideLoading()
const message = err instanceof Error ? err.message : '预约失败,请重试'
uni.showToast({ title: message, icon: 'none' })
}
}
// ─── Booking mode: Admin/User actions ────────────────────────────────────
async function handleConfirm() {
uni.showModal({
title: '确认预约',
@@ -229,7 +416,7 @@ async function handleConfirm() {
await bookingStore.confirmBooking(bookingId.value)
uni.hideLoading()
uni.showToast({ title: '已确认', icon: 'success' })
await loadData()
await loadBookingData()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
@@ -251,7 +438,7 @@ async function handleComplete() {
await bookingStore.completeBooking(bookingId.value)
uni.hideLoading()
uni.showToast({ title: '已核销', icon: 'success' })
await loadData()
await loadBookingData()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
@@ -273,7 +460,7 @@ async function handleNoShow() {
await bookingStore.markNoShow(bookingId.value)
uni.hideLoading()
uni.showToast({ title: '已标记', icon: 'success' })
await loadData()
await loadBookingData()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
@@ -291,12 +478,12 @@ async function handleCancel() {
confirmColor: '#ef4444',
success: async (res) => {
if (!res.confirm) return
uni.showLoading({ title: '处理中...' })
uni.showLoading({ title: '取消中...' })
try {
await bookingStore.cancelBooking(bookingId.value)
uni.hideLoading()
uni.showToast({ title: '已取消', icon: 'success' })
await loadData()
await loadBookingData()
} catch (err: unknown) {
uni.hideLoading()
const msg = err instanceof Error ? err.message : '操作失败'
@@ -307,14 +494,28 @@ async function handleCancel() {
}
// ─── Lifecycle ────────────────────────────────────────────────────────────
onMounted(() => {
onMounted(async () => {
navBarHeight.value = `${getSystemLayout().navBarHeight}px`
// Load memberships if logged in (needed for BookingConfirmPopup)
if (userStore.loggedIn && userStore.activeMemberships.length === 0) {
await userStore.fetchMemberships()
}
})
onLoad((query) => {
bookingId.value = (query as Record<string, string>).id || ''
if (bookingId.value) {
loadData()
const q = query as Record<string, string>
if (q.slotId) {
// Slot preview mode
isSlotMode.value = true
slotId.value = q.slotId
slotDate.value = q.date || ''
loadSlotData()
} else if (q.id) {
// Booking detail mode
isSlotMode.value = false
bookingId.value = q.id
loadBookingData()
}
})
</script>
@@ -361,30 +562,6 @@ onLoad((query) => {
box-shadow: 0 2rpx 16rpx rgba(0, 0, 0, 0.05);
}
.status-banner {
padding: 24rpx;
display: flex;
align-items: center;
justify-content: center;
&--pending { background: rgba(245, 158, 11, 0.1); }
&--confirmed { background: rgba(201, 168, 124, 0.1); }
&--completed { background: rgba(102, 187, 106, 0.1); }
&--cancelled { background: rgba(0, 0, 0, 0.04); }
&--noshow { background: rgba(239, 83, 80, 0.1); }
}
.status-banner-text {
font-size: 32rpx;
font-weight: 600;
.status-banner--pending & { color: #f59e0b; }
.status-banner--confirmed & { color: $primary-dark; }
.status-banner--completed & { color: #66bb6a; }
.status-banner--cancelled & { color: #bbb; }
.status-banner--noshow & { color: #ef5350; }
}
.info-section {
padding: 24rpx;
border-bottom: 1rpx solid #f5f5f5;
@@ -421,6 +598,34 @@ onLoad((query) => {
font-weight: 500;
}
.capacity-tag {
padding: 4rpx 16rpx;
border-radius: 8rpx;
font-size: 24rpx;
font-weight: 600;
&.cap-open {
background: rgba(76, 175, 80, 0.08);
color: #4caf50;
}
&.cap-almost {
background: rgba(245, 158, 11, 0.08);
color: #f59e0b;
}
&.cap-full {
background: rgba(239, 68, 68, 0.08);
color: #ef4444;
}
}
.desc-text {
font-size: 26rpx;
color: #666;
line-height: 1.7;
}
/* ── Timeline card ────────────────────────────────────── */
.timeline-card {
margin: 24rpx;
@@ -570,6 +775,10 @@ onLoad((query) => {
&--cancel {
background: rgba(0, 0, 0, 0.04);
}
&--disabled {
background: rgba(0, 0, 0, 0.06);
}
}
.action-btn-text {
@@ -584,5 +793,9 @@ onLoad((query) => {
.action-btn--noshow & {
color: #ef5350;
}
.action-btn--disabled & {
color: #bbb;
}
}
</style>

View File

@@ -61,6 +61,7 @@
:time-slot="item"
@book="onBookTap"
@cancel="onCancelTap"
@card-tap="onSlotCardTap"
/>
</view>
@@ -150,7 +151,7 @@ updateLayout()
// ─── Filtered slots ───────────────────────────────────────
const filteredSlots = computed<TimeSlotWithBookingStatus[]>(() => {
const slots = bookingStore.slots as TimeSlotWithBookingStatus[]
if (!selectedPeriod.value) return [...slots]
if (!selectedPeriod.value) return slots
const period = TIME_PERIODS[selectedPeriod.value]
return slots.filter((slot) => {
@@ -177,7 +178,19 @@ function onDateSelect(date: string) {
}
function onPeriodChange(_period: PeriodKey) {
// Filtering is done client-side via computed property
// No-op: filtering is done client-side via computed property
void _period
}
// ─── Card tap → navigate to detail ───────────────────────
function onSlotCardTap(slot: TimeSlotWithBookingStatus) {
if (slot.isBookedByMe && slot.myBookingId) {
// Already booked → show booking detail
uni.navigateTo({ url: `/pages/booking/detail?id=${slot.myBookingId}` })
} else {
// Not booked → show slot preview with booking action
uni.navigateTo({ url: `/pages/booking/detail?slotId=${slot.id}&date=${slot.date}` })
}
}
// ─── Book flow ────────────────────────────────────────────
@@ -213,7 +226,9 @@ async function onBookTap(slot: TimeSlotWithBookingStatus) {
cancelText: '取消',
success: (res) => {
if (res.confirm) {
uni.navigateTo({ url: '/pages/store/index' })
// Switch to home tab and auto-scroll to card shop
uni.$emit('scrollToCardShop')
uni.switchTab({ url: '/pages/home/index' })
}
},
})
@@ -352,7 +367,7 @@ onMounted(async () => {
}
.skeleton-card {
height: 140rpx;
height: 220rpx;
border-radius: 20rpx;
background: #fff;
display: flex;
@@ -360,6 +375,7 @@ onMounted(async () => {
align-items: center;
padding: 28rpx 48rpx;
gap: 20rpx;
margin: 0 24rpx;
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.03);
}

View File

@@ -48,8 +48,8 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onShow, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import { ref, nextTick } from 'vue'
import { onShow, onUnmount, onShareAppMessage, onShareTimeline } from '@dcloudio/uni-app'
import BrandBanner from '../../components/BrandBanner.vue'
import StudioInfo from '../../components/StudioInfo.vue'
@@ -86,10 +86,27 @@ const refreshing = ref(false)
const cardShopRef = ref<InstanceType<typeof CardShop> | null>(null)
const cardShopAnchorId = 'card-shop-anchor'
const scrollTop = ref(0)
const pendingScrollToCardShop = ref(false)
// Listen for cross-page scroll request (e.g. from booking page "去购买")
uni.$on('scrollToCardShop', () => {
pendingScrollToCardShop.value = true
})
onUnmount(() => {
uni.$off('scrollToCardShop')
})
// Refresh all data on every show
onShow(async () => {
await refreshData()
// If another page requested scroll to card shop, execute after data is ready
if (pendingScrollToCardShop.value) {
pendingScrollToCardShop.value = false
await nextTick()
scrollToCardShop()
}
})
async function refreshData() {
@@ -118,14 +135,22 @@ async function handleRefresh() {
}
function scrollToCardShop() {
uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`)
.boundingClientRect((rect) => {
if (rect) {
scrollTop.value = rect.top
}
})
.exec()
// Reset first so setting the same value still triggers scroll
scrollTop.value = 0
nextTick(() => {
uni.createSelectorQuery()
.select(`#${cardShopAnchorId}`)
.boundingClientRect()
.selectViewport()
.scrollOffset()
.exec((res) => {
if (res && res[0] && res[1]) {
const rectTop = (res[0] as UniApp.NodeInfo).top ?? 0
const viewportScroll = (res[1] as UniApp.NodeInfo).scrollTop ?? 0
scrollTop.value = viewportScroll + rectTop
}
})
})
}
</script>

View File

@@ -4,25 +4,12 @@
<CustomNavBar title="我的" transparent />
<!-- User card -->
<UserCard
:logged-in="loggedIn"
:has-profile="hasProfile"
:user="user"
:stats="stats"
:memberships="memberships"
:loading="loginLoading"
:nav-bar-height="navBarHeight"
@login="handleLogin"
/>
<UserCard :logged-in="loggedIn" :has-profile="hasProfile" :user="user" :stats="stats" :memberships="memberships"
:loading="loginLoading" :nav-bar-height="navBarHeight" @login="handleLogin" />
<!-- Menu section: always visible -->
<ProfileMenu
:is-admin="isAdmin"
:require-auth="loggedIn"
@clear-cache="handleClearCache"
@about="handleAbout"
@require-login="handleLogin"
/>
<ProfileMenu :is-admin="isAdmin" :require-auth="loggedIn" @clear-cache="handleClearCache" @about="handleAbout"
@require-login="handleLogin" />
<!-- Logout button: only when logged in -->
<view v-if="loggedIn" class="profile-page__logout-wrap">
@@ -131,7 +118,6 @@ function handleAbout() {
<style lang="scss" scoped>
.profile-page {
min-height: 100vh;
background: $bg-page;
&__logout-wrap {
margin: $spacing-xl $spacing-lg $spacing-xl;

View File

@@ -299,7 +299,7 @@ onMounted(async () => {
position: relative;
margin: 0 $spacing-lg $spacing-md;
padding: 36rpx 32rpx;
background: linear-gradient(135deg, $brand-color 0%, lighten($brand-color, 12%) 100%);
background: linear-gradient(135deg, $brand-color 0%, #6b5d52 100%);
border-radius: $radius-lg;
overflow: hidden;
@@ -551,7 +551,7 @@ onMounted(async () => {
width: 100%;
height: 96rpx;
border-radius: 48rpx;
background: linear-gradient(135deg, $brand-color, lighten($brand-color, 8%));
background: linear-gradient(135deg, $brand-color, #5e5045);
display: flex;
align-items: center;
justify-content: center;