510 lines
13 KiB
Vue
510 lines
13 KiB
Vue
<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>
|