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,83 +1,88 @@
<template>
<view class="slot-card-wrapper">
<!-- Date display above card -->
<view class="slot-date">
{{ timeSlot.date }}
</view>
<view class="slot-card-wrapper" :class="[`status-${statusClass}`]" @tap="emit('cardTap', timeSlot)">
<!-- Ticket background image -->
<image
class="ticket-bg"
src="/static/courseBg.png"
mode="aspectFill"
/>
<view class="slot-card" :class="[
{ 'slot-card--booked': timeSlot.isBookedByMe },
{ 'slot-card--past': isPast && !timeSlot.isBookedByMe },
{ 'slot-card--full': timeSlot.status === TimeSlotStatus.FULL },
`status-${statusClass}`
]">
<!-- Decorative left curves (ticket style) -->
<view class="curve curve-left">
<view class="curve-inner curve-top" />
<view class="curve-inner curve-bottom" />
</view>
<!-- Main content -->
<view class="slot-content">
<!-- Left: Time column -->
<view class="time-section">
<text class="start-time">{{ timeSlot.startTime.slice(0, 5) }}</text>
<view class="time-divider" />
<text class="end-time">{{ timeSlot.endTime.slice(0, 5) }}</text>
<!-- Card content overlay -->
<view class="ticket-content">
<!-- Top section: Time row (like flight ticket) -->
<view class="ticket-top">
<!-- Left: Start time -->
<view class="time-block">
<text class="time-main">{{ startTimeDisplay }}</text>
<text class="time-label">{{ timeSlot.date }}</text>
</view>
<!-- Center divider with dashes -->
<view class="divider-dashes" />
<!-- Center: Course info -->
<view class="course-section">
<text class="course-name">普拉提私教</text>
<view class="course-meta">
<text class="course-duration">{{ durationMin }}分钟</text>
<text class="meta-separator"></text>
<view class="course-capacity" :class="capacityClass">
<text class="capacity-text">{{ capacityLabel }}</text>
<!-- Center: Duration + icon -->
<view class="duration-block">
<view class="duration-line">
<view class="line-dot" />
<view class="line-dash" />
<view class="duration-icon">
<text class="icon-text"></text>
</view>
<view class="line-dash" />
<view class="line-dot" />
</view>
<text class="duration-text">{{ durationMin }}分钟</text>
</view>
<!-- Right: End time -->
<view class="time-block time-block--right">
<text class="time-main">{{ endTimeDisplay }}</text>
<view class="capacity-tag" :class="capacityClass">
<text class="capacity-text">{{ capacityLabel }}</text>
</view>
</view>
</view>
<!-- Center divider with dashes -->
<view class="divider-dashes" />
<!-- Dashed tear-off line -->
<view class="tear-line" />
<!-- Right: Action section -->
<view class="action-section">
<!-- Expired badge -->
<!-- Bottom section: Course name + Action -->
<view class="ticket-bottom">
<view class="course-info">
<text class="course-name">普拉提私教</text>
</view>
<!-- Action area -->
<view class="action-area">
<!-- Expired -->
<template v-if="isPast && !timeSlot.isBookedByMe">
<view class="action-badge badge-expired">
<text>已过期</text>
</view>
</template>
<!-- OPEN + not booked: Book button -->
<!-- OPEN + not booked -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && !timeSlot.isBookedByMe">
<view class="action-button btn-book" @tap.stop="emit('book', timeSlot)">
<view class="action-btn btn-book" @tap.stop="emit('book', timeSlot)">
<text>预约</text>
</view>
</template>
<!-- OPEN + booked by me: Cancel link -->
<!-- OPEN + booked by me -->
<template v-else-if="timeSlot.status === TimeSlotStatus.OPEN && timeSlot.isBookedByMe">
<view class="action-badge badge-booked">
<text>已预约</text>
</view>
<view class="action-cancel" @tap.stop="emit('cancel', timeSlot)">
<view class="cancel-link" @tap.stop="emit('cancel', timeSlot)">
<text>取消</text>
</view>
</template>
<!-- FULL: Full badge -->
<!-- FULL -->
<template v-else-if="timeSlot.status === TimeSlotStatus.FULL">
<view class="action-badge badge-full">
<text>已约满</text>
</view>
</template>
<!-- CLOSED: Closed badge -->
<!-- CLOSED -->
<template v-else>
<view class="action-badge badge-closed">
<text>已关闭</text>
@@ -85,12 +90,6 @@
</template>
</view>
</view>
<!-- Decorative right curves (ticket style) -->
<view class="curve curve-right">
<view class="curve-inner curve-top" />
<view class="curve-inner curve-bottom" />
</view>
</view>
</view>
</template>
@@ -109,8 +108,12 @@ const props = defineProps<Props>()
const emit = defineEmits<{
book: [timeSlot: TimeSlotWithBookingStatus]
cancel: [timeSlot: TimeSlotWithBookingStatus]
cardTap: [timeSlot: TimeSlotWithBookingStatus]
}>()
const startTimeDisplay = computed(() => props.timeSlot.startTime.slice(0, 5))
const endTimeDisplay = computed(() => props.timeSlot.endTime.slice(0, 5))
const durationMin = computed(() => {
const [sh, sm] = props.timeSlot.startTime.split(':').map(Number)
const [eh, em] = props.timeSlot.endTime.split(':').map(Number)
@@ -122,7 +125,7 @@ const capacityLabel = computed(() => {
if (status === TimeSlotStatus.CLOSED) return '已关闭'
if (status === TimeSlotStatus.FULL) return '已约满'
const remaining = capacity - bookedCount
return `剩余 ${remaining} 个名额`
return `剩余${remaining}`
})
const capacityClass = computed(() => {
@@ -145,187 +148,147 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
</script>
<style lang="scss" scoped>
/* ─── Wrapper ─── */
.slot-card-wrapper {
padding: 0 24rpx 24rpx;
display: flex;
flex-direction: column;
gap: 8rpx;
}
.slot-date {
font-size: 24rpx;
color: #999;
padding: 0 12rpx;
font-weight: 500;
}
/* ─── Main Card ─── */
.slot-card {
position: relative;
display: flex;
align-items: center;
background: #fff;
border-radius: 20rpx;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
margin: 0 24rpx 20rpx;
min-height: 220rpx;
transition: all 0.2s ease;
min-height: 140rpx;
padding: 0;
&:active {
transform: scale(0.98);
box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.08);
}
/* Status variants */
&.status-open {
background: linear-gradient(135deg, #fff 0%, #fafaf8 100%);
}
&.status-booked {
background: linear-gradient(135deg, #f0f7fb 0%, #f5fbff 100%);
box-shadow: 0 4rpx 20rpx rgba(66, 133, 244, 0.1);
/* Status-based opacity */
&.status-expired {
opacity: 0.55;
}
&.status-full,
&.status-closed {
background: #fafaf8;
opacity: 0.85;
}
&.status-expired {
background: #f8f8f6;
opacity: 0.7;
opacity: 0.75;
}
}
/* ─── Decorative curves (ticket punch holes) ─── */
.curve {
/* ─── Ticket background image ─── */
.ticket-bg {
position: absolute;
width: 48rpx;
height: 140rpx;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
}
/* ─── Content overlay ─── */
.ticket-content {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
padding: 28rpx 40rpx 24rpx;
}
/* ═══ Top section: Time row ═══ */
.ticket-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
pointer-events: none;
}
&.curve-left {
left: -24rpx;
}
.time-block {
display: flex;
flex-direction: column;
align-items: flex-start;
min-width: 100rpx;
&.curve-right {
right: -24rpx;
&--right {
align-items: flex-end;
}
}
.curve-inner {
width: 48rpx;
height: 48rpx;
background: #FAF8F5; /* Match page background */
border-radius: 0 48rpx 48rpx 0;
.curve-right & {
border-radius: 48rpx 0 0 48rpx;
}
.time-main {
font-size: 40rpx;
font-weight: 800;
color: #1a1a2e;
line-height: 1;
letter-spacing: 1rpx;
}
/* ─── Main content flex layout ─── */
.slot-content {
.time-label {
margin-top: 8rpx;
font-size: 22rpx;
color: #999;
font-weight: 500;
}
/* Duration center block */
.duration-block {
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
padding: 0 16rpx;
}
.duration-line {
display: flex;
align-items: center;
width: 100%;
padding: 28rpx 48rpx;
gap: 0;
margin-top: 8rpx;
}
/* ─── Time Section ─── */
.time-section {
display: flex;
flex-direction: column;
align-items: center;
min-width: 90rpx;
.line-dot {
width: 10rpx;
height: 10rpx;
border-radius: 50%;
background: #ccc;
flex-shrink: 0;
gap: 8rpx;
}
.start-time {
font-size: 38rpx;
font-weight: 700;
color: #1a1a1a;
line-height: 1;
}
.end-time {
font-size: 26rpx;
font-weight: 500;
color: #999;
line-height: 1;
}
.time-divider {
width: 2rpx;
height: 20rpx;
background: #e0dcd6;
border-radius: 1rpx;
margin: 4rpx 0;
}
/* ─── Dashed dividers ─── */
.divider-dashes {
flex: 0 0 auto;
width: 2rpx;
height: 80rpx;
.line-dash {
flex: 1;
height: 2rpx;
background: repeating-linear-gradient(
to bottom,
#e0dcd6 0,
#e0dcd6 8rpx,
to right,
#d0d0d0 0,
#d0d0d0 8rpx,
transparent 8rpx,
transparent 16rpx
);
margin: 0 20rpx;
opacity: 0.6;
}
/* ─── Course Section (center) ─── */
.course-section {
display: flex;
flex-direction: column;
flex: 1;
align-items: center;
gap: 8rpx;
min-width: 120rpx;
}
.course-name {
font-size: 32rpx;
font-weight: 700;
color: #1a1a1a;
line-height: 1.2;
}
.course-meta {
.duration-icon {
flex-shrink: 0;
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba($primary-color, 0.1);
display: flex;
align-items: center;
gap: 10rpx;
justify-content: center;
margin: 0 8rpx;
}
.icon-text {
font-size: 22rpx;
}
.duration-text {
margin-top: 6rpx;
font-size: 22rpx;
color: #999;
}
.course-duration {
font-weight: 500;
}
.meta-separator {
color: #ddd;
}
.course-capacity {
font-weight: 600;
padding: 4rpx 8rpx;
border-radius: 4rpx;
/* Capacity tag */
.capacity-tag {
margin-top: 8rpx;
padding: 4rpx 12rpx;
border-radius: 6rpx;
font-size: 20rpx;
&.cap-open {
color: #4caf50;
background: rgba(76, 175, 80, 0.08);
.capacity-text {
@@ -334,7 +297,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
}
&.cap-almost {
color: #f59e0b;
background: rgba(245, 158, 11, 0.08);
.capacity-text {
@@ -343,7 +305,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
}
&.cap-full {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
.capacity-text {
@@ -352,7 +313,6 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
}
&.cap-closed {
color: #999;
background: rgba(0, 0, 0, 0.04);
.capacity-text {
@@ -362,53 +322,76 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
}
.capacity-text {
font-size: 20rpx;
font-weight: 600;
}
/* ─── Action Section (right) ─── */
.action-section {
display: flex;
flex-direction: column;
align-items: center;
min-width: 100rpx;
flex-shrink: 0;
gap: 8rpx;
/* ═══ Tear-off dashed line ═══ */
.tear-line {
margin: 20rpx -40rpx 16rpx;
height: 2rpx;
background: repeating-linear-gradient(
to right,
#e0dcd6 0,
#e0dcd6 10rpx,
transparent 10rpx,
transparent 20rpx
);
opacity: 0.6;
}
.action-button,
.action-badge {
padding: 10rpx 20rpx;
border-radius: 20rpx;
/* ═══ Bottom section ═══ */
.ticket-bottom {
display: flex;
align-items: center;
justify-content: center;
justify-content: space-between;
}
.course-info {
flex: 1;
}
.course-name {
font-size: 30rpx;
font-weight: 700;
color: #1a1a2e;
letter-spacing: 1rpx;
}
/* ─── Action area ─── */
.action-area {
display: flex;
align-items: center;
gap: 12rpx;
flex-shrink: 0;
}
.action-btn,
.action-badge {
padding: 10rpx 24rpx;
border-radius: 20rpx;
font-size: 26rpx;
font-weight: 600;
white-space: nowrap;
display: flex;
align-items: center;
justify-content: center;
}
.action-button {
.btn-book {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
color: #fff;
box-shadow: 0 4rpx 16rpx rgba($primary-dark, 0.3);
min-width: 120rpx;
height: 60rpx;
transition: all 0.15s;
&:active {
opacity: 0.85;
transform: scale(0.96);
}
}
.btn-book {
background: linear-gradient(135deg, $primary-color 0%, $primary-dark 100%);
color: #fff;
box-shadow: 0 4rpx 12rpx rgba($primary-dark, 0.25);
}
.action-badge {
padding: 8rpx 16rpx;
font-size: 24rpx;
white-space: nowrap;
}
.badge-booked {
background: linear-gradient(135deg, $primary-selected-bg, $primary-border);
color: $primary-dark;
@@ -429,8 +412,7 @@ const isPast = computed(() => isSlotPast(props.timeSlot.date, props.timeSlot.sta
color: #bbb;
}
.action-cancel {
padding: 4rpx 12rpx;
.cancel-link {
font-size: 22rpx;
color: #ef4444;
font-weight: 500;

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;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@@ -115,6 +115,11 @@ export const useBookingStore = defineStore('booking', () => {
return result
}
async function fetchSlotById(slotId: string) {
const result = await get<TimeSlotWithBookingStatus>(`/time-slot/${slotId}`)
return result
}
return {
slots,
myBookings,
@@ -131,6 +136,7 @@ export const useBookingStore = defineStore('booking', () => {
completeBooking,
markNoShow,
fetchBookingHistory,
fetchSlotById,
fetchBookingById,
}
})