589 lines
16 KiB
Vue
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>
|