Files
mp-pilates/packages/app/src/pages/booking/detail.vue
2026-04-06 08:38:05 +08:00

589 lines
16 KiB
Vue

<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>