Files
venue-miniapp/miniprogram/src/pages/ice/bookings/detail.vue
T
2026-06-10 11:46:39 +08:00

510 lines
13 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="detail-container">
<StatePanel
v-if="pageState === 'loading'"
title="正在加载预约信息"
description="请稍候"
type="loading"
/>
<UnauthLoginPanel
v-else-if="sessionStatus === SESSION_STATUS.GUEST"
title="登录后查看预约详情"
description="请先登录并完善会员资料。"
:loading="isLoginSubmitting"
@login="onWechatLogin"
/>
<UnauthLoginPanel
v-else-if="sessionStatus === SESSION_STATUS.EXPIRED"
title="登录状态已过期"
description="为了保护会员资料,请重新登录后继续查看。"
primary-text="重新登录"
:loading="isLoginSubmitting"
@login="onWechatLogin"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.PROFILE_REQUIRED"
title="完善会员资料后查看预约"
description="请先在会员中心完成姓名和手机号绑定,用于匹配你的预约记录。"
primary-text="去完善资料"
type="profile"
@primary="goProfile"
/>
<StatePanel
v-else-if="sessionStatus === SESSION_STATUS.NETWORK_ERROR"
title="暂时无法连接服务"
:description="pageMessage || '请检查网络后重试。'"
primary-text="重试"
type="error"
@primary="loadPage"
/>
<StatePanel
v-else-if="pageState === 'not_found'"
title="未找到预约信息"
:description="pageMessage || '该预约不存在,或你没有查看权限。'"
primary-text="返回预约列表"
type="empty"
@primary="goBookings"
/>
<StatePanel
v-else-if="pageState === 'error'"
title="预约详情加载失败"
:description="pageMessage"
primary-text="重试"
type="error"
@primary="loadPage"
/>
<block v-else>
<!-- 1. 顶部状态横幅 -->
<view class="status-banner" :class="booking.status">
<text class="status-text">{{ formatStatus(booking.status) }}</text>
</view>
<!-- 2. 课程信息卡片支持点击跳转到课程详情 -->
<view class="detail-card clickable-card" @tap="onTapCourse(booking.course?.id)">
<view class="card-section">
<text class="section-tag">关联课程</text>
<view class="title-with-arrow">
<text class="course-title">{{ booking.course?.title }}</text>
<text class="nav-arrow"></text>
</view>
</view>
<view class="divider"></view>
<view class="info-grid">
<view class="info-row">
<text class="info-label">上课时段</text>
<text class="info-val highlight-blue">时间{{ formatDateTimeRange(booking.course?.start_time, booking.course?.end_time) }}</text>
</view>
<view class="info-row">
<text class="info-label">预约人数</text>
<text class="info-val font-semibold">已约 {{ booking.course?.booked_count }}/{{ booking.course?.capacity }} </text>
</view>
</view>
</view>
<!-- 3. 会员卡信息卡片支持点击跳转到卡片详情 -->
<view class="detail-card clickable-card" v-if="booking.card" @tap="onTapCard(booking.card?.id)">
<view class="card-section">
<text class="section-tag">使用卡项</text>
<view class="title-with-arrow">
<text class="card-title">{{ booking.card.card_name }}</text>
<text class="nav-arrow"></text>
</view>
</view>
</view>
<!-- 4. 流程时间节点展示 -->
<view class="detail-card timeline-card">
<text class="section-tag">时间节点</text>
<view class="log-timeline">
<view class="timeline-item" v-if="booking.created_at">
<view class="timeline-dot active"></view>
<view class="timeline-content">
<text class="log-action">预约时间</text>
<text class="log-time">{{ formatFullDateTime(booking.created_at) }}</text>
</view>
</view>
<view class="timeline-item" v-if="booking.status === 'checked_in' && booking.checked_in_at">
<view class="timeline-dot active success"></view>
<view class="timeline-content">
<text class="log-action success">核销时间</text>
<text class="log-time">{{ formatFullDateTime(booking.checked_in_at) }}</text>
</view>
</view>
<view class="timeline-item" v-if="booking.status === 'cancelled' && booking.cancelled_at">
<view class="timeline-dot active danger"></view>
<view class="timeline-content">
<text class="log-action danger">取消时间</text>
<text class="log-time">{{ formatFullDateTime(booking.cancelled_at) }}</text>
</view>
</view>
</view>
</view>
<!-- 5. 温馨提示 -->
<view class="bottom-disclaimer">
<text class="disclaimer-title">行程变动须知</text>
<text class="disclaimer-content">所有的上课行程均由场馆统一排定展示如需取消或改约请提前联系场馆前台处理</text>
</view>
</block>
</view>
</template>
<script setup>
import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { bookingApi } from '@/modules/ice/api/booking'
import { errorMessage, getMemberSession, loginAndStoreToken, sessionStatusFromError, SESSION_STATUS } from '@/modules/ice/api/session'
import StatePanel from '@/components/ice/StatePanel.vue'
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
const booking = ref({
id: null,
status: '',
created_at: '',
checked_in_at: null,
cancelled_at: null,
course: {
id: null,
title: '',
start_time: '',
end_time: '',
capacity: 0,
booked_count: 0
},
card: null
})
const pageState = ref('loading')
const pageMessage = ref('')
const sessionStatus = ref(SESSION_STATUS.CHECKING)
const isLoginSubmitting = ref(false)
const currentBookingId = ref('')
onShow(() => {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const options = currentPage.options || {}
currentBookingId.value = options.id || ''
loadPage()
})
const loadPage = async () => {
if (!currentBookingId.value) {
pageState.value = 'not_found'
pageMessage.value = '未找到要查看的预约记录。'
return
}
pageState.value = 'loading'
pageMessage.value = ''
const session = await getMemberSession()
sessionStatus.value = session.status
if (session.status !== SESSION_STATUS.MEMBER) {
pageState.value = 'blocked'
pageMessage.value = session.message || ''
return
}
try {
booking.value = await bookingApi.getBookingDetail(currentBookingId.value)
pageState.value = 'ready'
} catch (err) {
const status = sessionStatusFromError(err)
if (status) {
sessionStatus.value = status
pageState.value = 'blocked'
pageMessage.value = err?.message || ''
return
}
if (err?.type === 'not_found') {
pageState.value = 'not_found'
pageMessage.value = '该预约不存在,或你没有查看权限。'
return
}
pageState.value = 'error'
pageMessage.value = errorMessage(err, '预约详情加载失败,请稍后重试。')
}
}
const onWechatLogin = async () => {
if (isLoginSubmitting.value) return
isLoginSubmitting.value = true
uni.showLoading({ title: '登录中...' })
try {
await loginAndStoreToken()
await loadPage()
if (sessionStatus.value === SESSION_STATUS.MEMBER && pageState.value === 'ready') {
uni.showToast({ title: '登录成功', icon: 'success' })
}
} catch (err) {
uni.showModal({
title: '登录失败',
content: errorMessage(err, '微信登录失败,请稍后重试'),
showCancel: false,
confirmText: '知道了'
})
} finally {
isLoginSubmitting.value = false
uni.hideLoading()
}
}
const formatStatus = (status) => {
const map = {
booked: '待核销上课',
checked_in: '已签到核销',
cancelled: '行程已取消'
}
return map[status] || status
}
const onTapCourse = (courseId) => {
if (!courseId) return
uni.navigateTo({
url: `/pages/ice/courses/detail?id=${courseId}`
})
}
const onTapCard = (cardId) => {
if (!cardId) return
uni.navigateTo({
url: `/pages/ice/profile/card-history?id=${cardId}`
})
}
const goProfile = () => {
uni.reLaunch({ url: '/pages/ice/profile/index' })
}
const goBookings = () => {
uni.reLaunch({ url: '/pages/ice/bookings/index' })
}
const formatDateTimeRange = (startStr, endStr) => {
if (!startStr || !endStr) return '-'
const start = new Date(startStr)
const end = new Date(endStr)
const pad = (n) => String(n).padStart(2, '0')
const mm = pad(start.getMonth() + 1)
const dd = pad(start.getDate())
const startHhMm = `${pad(start.getHours())}:${pad(start.getMinutes())}`
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
return `${mm}-${dd} ${startHhMm} - ${endHhMm}`
}
const formatFullDateTime = (dateStr) => {
if (!dateStr) return ''
const date = new Date(dateStr)
const pad = (n) => String(n).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
}
</script>
<style lang="scss" scoped>
.detail-container {
min-height: 100vh;
background-color: $bg-color-soft;
padding: $spacing-md;
display: flex;
flex-direction: column;
gap: $spacing-md;
}
/* 顶部状态横幅 */
.status-banner {
border-radius: $border-radius-lg;
padding: $spacing-lg;
color: #FFFFFF;
display: flex;
flex-direction: column;
gap: $spacing-xs;
&.booked {
background-color: $text-color-main;
}
&.checked_in {
background-color: $color-success;
}
&.cancelled {
background-color: $text-color-muted;
}
.status-text {
font-size: 36rpx;
font-weight: 700;
}
.status-desc {
font-size: 22rpx;
opacity: 0.9;
}
}
/* 详情卡片通用 */
.detail-card {
background-color: $bg-color-main;
border: 1rpx solid rgba(226, 232, 240, 0.8);
border-radius: $border-radius-lg;
padding: $spacing-lg;
&.clickable-card {
transition: all 0.2s ease;
&:active {
background-color: $bg-color-hover;
}
}
.card-section {
display: flex;
flex-direction: column;
gap: 6rpx;
}
.section-tag {
font-size: 18rpx;
font-weight: 700;
color: $color-primary;
letter-spacing: 1.5rpx;
text-transform: uppercase;
margin-bottom: 4rpx;
display: block;
}
.title-with-arrow {
display: flex;
justify-content: space-between;
align-items: center;
gap: $spacing-md;
.nav-arrow {
font-size: 30rpx;
color: $text-color-light;
font-weight: bold;
}
}
.course-title, .card-title {
font-size: 30rpx;
font-weight: 700;
color: $text-color-main;
line-height: 1.35;
flex: 1;
}
.info-grid {
display: flex;
flex-direction: column;
gap: $spacing-md;
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 24rpx;
}
.info-label {
color: $text-color-muted;
}
.info-val {
color: $text-color-main;
font-weight: 500;
&.font-semibold {
font-weight: 700;
}
&.highlight-blue {
color: $color-primary;
font-weight: 700;
}
}
}
/* 极简时间轴 */
.log-timeline {
display: flex;
flex-direction: column;
gap: $spacing-lg;
padding: $spacing-md 0 0 $spacing-xs;
}
.timeline-item {
display: flex;
gap: $spacing-md;
position: relative;
&::before {
content: '';
position: absolute;
left: 8rpx;
top: 20rpx;
bottom: -32rpx;
width: 2rpx;
background-color: $bg-color-hover;
}
&:last-child::before {
display: none;
}
.timeline-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
background-color: $border-color;
margin-top: 8rpx;
flex-shrink: 0;
&.active {
background-color: $text-color-main;
&.success {
background-color: $color-success;
}
&.danger {
background-color: $color-danger;
}
}
}
.timeline-content {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.log-action {
font-size: 24rpx;
font-weight: 600;
color: $text-color-regular;
&.success {
color: $color-success;
}
&.danger {
color: $color-danger;
}
}
.log-time {
font-size: 20rpx;
color: $text-color-light;
}
}
/* 温馨提示 */
.bottom-disclaimer {
padding: 0 $spacing-xs;
display: flex;
flex-direction: column;
gap: 8rpx;
.disclaimer-title {
font-size: 22rpx;
font-weight: 700;
color: $text-color-muted;
text-transform: uppercase;
}
.disclaimer-content {
font-size: 20rpx;
color: $text-color-light;
line-height: 1.5;
}
}
</style>