feat: migrate ice venue module
This commit is contained in:
@@ -0,0 +1,511 @@
|
||||
<template>
|
||||
<view class="detail-container">
|
||||
<StatePanel
|
||||
v-if="pageState === 'loading'"
|
||||
title="正在加载预约信息"
|
||||
description="请稍候"
|
||||
type="loading"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="sessionStatus === SESSION_STATUS.GUEST"
|
||||
title="登录后查看预约详情"
|
||||
description="请先登录并完善会员资料。"
|
||||
primary-text="微信一键登录"
|
||||
type="login"
|
||||
:loading="isLoginSubmitting"
|
||||
@primary="onWechatLogin"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="sessionStatus === SESSION_STATUS.EXPIRED"
|
||||
title="登录状态已过期"
|
||||
description="为了保护会员资料,请重新登录后继续查看。"
|
||||
primary-text="重新登录"
|
||||
type="expired"
|
||||
:loading="isLoginSubmitting"
|
||||
@primary="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'
|
||||
|
||||
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>
|
||||
@@ -0,0 +1,544 @@
|
||||
<template>
|
||||
<view class="bookings-container">
|
||||
<StatePanel
|
||||
v-if="sessionStatus === SESSION_STATUS.CHECKING"
|
||||
title="正在检查登录状态"
|
||||
description="请稍候"
|
||||
type="loading"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="sessionStatus === SESSION_STATUS.GUEST"
|
||||
primary-text="微信一键登录"
|
||||
type="login"
|
||||
:show-mark="false"
|
||||
:loading="isLoginSubmitting"
|
||||
@primary="onWechatLogin"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="sessionStatus === SESSION_STATUS.EXPIRED"
|
||||
title="登录状态已过期"
|
||||
description="为了保护会员资料,请重新登录后继续查看预约行程。"
|
||||
primary-text="重新登录"
|
||||
type="expired"
|
||||
:loading="isLoginSubmitting"
|
||||
@primary="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="sessionMessage || '请检查网络后重试。'"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
@primary="checkSessionAndLoad"
|
||||
/>
|
||||
|
||||
<!-- 已登录:正常预约列表 -->
|
||||
<block v-else>
|
||||
<!-- 顶部极简选项卡 -->
|
||||
<view class="tab-header">
|
||||
<view
|
||||
v-for="tab in tabs"
|
||||
:key="tab.status"
|
||||
class="tab-item"
|
||||
:class="{ active: currentStatus === tab.status }"
|
||||
@tap="onSelectTab(tab.status)"
|
||||
>
|
||||
<text class="tab-label">{{ tab.label }}</text>
|
||||
<view class="active-indicator"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 预约列表 -->
|
||||
<view class="booking-list">
|
||||
<StatePanel
|
||||
v-if="listState === 'error'"
|
||||
title="预约行程加载失败"
|
||||
:description="listErrorMessage"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
compact
|
||||
@primary="loadBookings"
|
||||
/>
|
||||
|
||||
<!-- 列表卡片 -->
|
||||
<block v-else>
|
||||
<view
|
||||
v-for="booking in bookings"
|
||||
:key="booking.id"
|
||||
class="booking-card"
|
||||
:class="[booking.status]"
|
||||
@tap="onViewDetail(booking.id)"
|
||||
>
|
||||
<view class="card-top">
|
||||
<text class="course-name">{{ booking.course.title }}</text>
|
||||
<text class="status-badge" :class="booking.status">{{ formatStatus(booking.status) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="card-details">
|
||||
<text class="detail-timehighlight highlight-blue">时间:{{ formatDateTimeRange(booking.course.start_time, booking.course.end_time) }}</text>
|
||||
<text class="detail-capacity">• 人数:已约 {{ booking.course.booked_count }}/{{ booking.course.capacity }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<view v-if="isLoadingMore" class="load-more-tip">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="bookings.length > 0 && !hasMore" class="load-more-tip">
|
||||
<text>— 已加载全部 —</text>
|
||||
</view>
|
||||
|
||||
<!-- 极致优雅空状态 -->
|
||||
<StatePanel
|
||||
v-if="listState !== 'error' && !isLoading && bookings.length === 0"
|
||||
title="暂无预约行程"
|
||||
description="场馆暂未为你登记预约行程。"
|
||||
type="empty"
|
||||
compact
|
||||
/>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
|
||||
import { bookingApi } from '@/modules/ice/api/booking'
|
||||
import { authStorage } from '@/modules/ice/api/member'
|
||||
import { errorMessage, getMemberSession, loginAndStoreToken, sessionStatusFromError, SESSION_STATUS } from '@/modules/ice/api/session'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
|
||||
const isLoggedIn = ref(false)
|
||||
const isLoginSubmitting = ref(false)
|
||||
const sessionStatus = ref(SESSION_STATUS.CHECKING)
|
||||
const sessionMessage = ref('')
|
||||
|
||||
const currentStatus = ref('all')
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
const listState = ref('idle')
|
||||
const listErrorMessage = ref('')
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
|
||||
const hasMore = computed(() => bookings.value.length < total.value)
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', status: 'all' },
|
||||
{ label: '已预约', status: 'booked' },
|
||||
{ label: '已核销', status: 'checked_in' },
|
||||
{ label: '已取消', status: 'cancelled' }
|
||||
]
|
||||
|
||||
const bookings = ref([])
|
||||
|
||||
onShow(() => {
|
||||
checkSessionAndLoad()
|
||||
})
|
||||
|
||||
const checkSessionAndLoad = async () => {
|
||||
sessionStatus.value = SESSION_STATUS.CHECKING
|
||||
const session = await getMemberSession()
|
||||
sessionStatus.value = session.status
|
||||
sessionMessage.value = session.message || ''
|
||||
isLoggedIn.value = session.status === SESSION_STATUS.MEMBER
|
||||
|
||||
if (session.status === SESSION_STATUS.MEMBER) {
|
||||
await loadBookings()
|
||||
return
|
||||
}
|
||||
|
||||
bookings.value = []
|
||||
total.value = 0
|
||||
listState.value = 'idle'
|
||||
}
|
||||
|
||||
const onWechatLogin = async () => {
|
||||
if (isLoginSubmitting.value) return
|
||||
isLoginSubmitting.value = true
|
||||
uni.showLoading({ title: '登录中...' })
|
||||
try {
|
||||
await loginAndStoreToken()
|
||||
await checkSessionAndLoad()
|
||||
if (sessionStatus.value === SESSION_STATUS.MEMBER) {
|
||||
uni.showToast({ title: '登录成功', icon: 'success' })
|
||||
}
|
||||
} catch (err) {
|
||||
uni.showModal({
|
||||
title: '登录失败',
|
||||
content: errorMessage(err, '微信登录失败,请稍后重试'),
|
||||
showCancel: false,
|
||||
confirmText: '知道了'
|
||||
})
|
||||
} finally {
|
||||
isLoginSubmitting.value = false
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
page.value = 1
|
||||
const task = sessionStatus.value === SESSION_STATUS.MEMBER ? loadBookings() : checkSessionAndLoad()
|
||||
task.finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (sessionStatus.value === SESSION_STATUS.MEMBER && hasMore.value && !isLoadingMore.value) {
|
||||
page.value++
|
||||
loadMoreBookings()
|
||||
}
|
||||
})
|
||||
|
||||
const loadBookings = async () => {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await bookingApi.getBookings({
|
||||
status: currentStatus.value,
|
||||
page: 1,
|
||||
pageSize
|
||||
})
|
||||
bookings.value = data.items
|
||||
total.value = data.total
|
||||
listState.value = 'ready'
|
||||
listErrorMessage.value = ''
|
||||
} catch (err) {
|
||||
if (applySessionError(err)) return
|
||||
listState.value = 'error'
|
||||
listErrorMessage.value = errorMessage(err, '预约行程加载失败,请稍后重试。')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreBookings = async () => {
|
||||
if (isLoadingMore.value) return
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
const data = await bookingApi.getBookings({
|
||||
status: currentStatus.value,
|
||||
page: page.value,
|
||||
pageSize
|
||||
})
|
||||
bookings.value = [...bookings.value, ...data.items]
|
||||
total.value = data.total
|
||||
} catch (err) {
|
||||
if (applySessionError(err)) return
|
||||
page.value--
|
||||
uni.showToast({ title: errorMessage(err, '加载更多失败,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onSelectTab = (status) => {
|
||||
currentStatus.value = status
|
||||
page.value = 1
|
||||
if (sessionStatus.value === SESSION_STATUS.MEMBER) {
|
||||
loadBookings()
|
||||
}
|
||||
}
|
||||
|
||||
const onViewDetail = (id) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ice/bookings/detail?id=${id}`
|
||||
})
|
||||
}
|
||||
|
||||
const formatStatus = (status) => {
|
||||
const map = {
|
||||
booked: '已预约',
|
||||
checked_in: '已核销',
|
||||
cancelled: '已取消'
|
||||
}
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
const formatDateTimeRange = (startStr, endStr) => {
|
||||
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 applySessionError = (err) => {
|
||||
const status = sessionStatusFromError(err)
|
||||
if (!status) return false
|
||||
sessionStatus.value = status
|
||||
sessionMessage.value = err?.message || ''
|
||||
isLoggedIn.value = false
|
||||
bookings.value = []
|
||||
total.value = 0
|
||||
listState.value = 'idle'
|
||||
if (status === SESSION_STATUS.EXPIRED) {
|
||||
authStorage.clear()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const goProfile = () => {
|
||||
uni.reLaunch({ url: '/pages/ice/profile/index' })
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bookings-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-color-soft;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 未登录全屏占位 */
|
||||
.unlogged-full-wrapper {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 160rpx 0 $spacing-xl 0;
|
||||
width: 100%;
|
||||
|
||||
.brand-logo-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
margin-bottom: 120rpx;
|
||||
text-align: center;
|
||||
|
||||
.brand-title-en {
|
||||
font-size: 48rpx;
|
||||
font-weight: 800;
|
||||
color: $text-color-main;
|
||||
letter-spacing: 6rpx;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.brand-subtitle-zh {
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
color: $text-color-muted;
|
||||
letter-spacing: 4rpx;
|
||||
margin-top: 16rpx;
|
||||
}
|
||||
|
||||
.brand-line {
|
||||
width: 40rpx;
|
||||
height: 2rpx;
|
||||
background-color: $text-color-main;
|
||||
margin-top: 24rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-auth-login-only {
|
||||
width: 80%;
|
||||
height: 90rpx;
|
||||
background-color: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
border-radius: $border-radius-base;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
box-shadow: 0 4rpx 15rpx rgba(15, 23, 42, 0.08);
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
&:active {
|
||||
opacity: 0.9;
|
||||
background-color: #000000;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 顶部极简选项卡 */
|
||||
.tab-header {
|
||||
display: flex;
|
||||
background-color: $bg-color-main;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
margin: (-$spacing-lg) (-$spacing-md) 0;
|
||||
padding: 0 $spacing-md;
|
||||
}
|
||||
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
padding: $spacing-md 0;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
.tab-label {
|
||||
font-size: 26rpx;
|
||||
color: $text-color-muted;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 40%;
|
||||
right: 40%;
|
||||
height: 4rpx;
|
||||
background-color: transparent;
|
||||
border-radius: 2rpx;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
&.active {
|
||||
.tab-label {
|
||||
color: $text-color-main;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.active-indicator {
|
||||
background-color: $text-color-main;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 预约列表 */
|
||||
.booking-list {
|
||||
padding: $spacing-lg 0 $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.booking-card {
|
||||
background-color: $bg-color-main;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||||
border-radius: $border-radius-lg;
|
||||
padding: $spacing-lg;
|
||||
margin-bottom: $spacing-md;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
/* 状态左侧极简粗边指示 */
|
||||
&.booked {
|
||||
border-left: 6rpx solid $color-primary;
|
||||
}
|
||||
&.checked_in {
|
||||
border-left: 6rpx solid $color-success;
|
||||
}
|
||||
&.cancelled {
|
||||
border-left: 6rpx solid $text-color-light;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $spacing-sm;
|
||||
min-height: 38rpx;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
line-height: 1.35;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.status-badge {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
margin-left: $spacing-xs;
|
||||
|
||||
&.booked {
|
||||
color: $color-primary;
|
||||
}
|
||||
&.checked_in {
|
||||
color: $color-success;
|
||||
}
|
||||
&.cancelled {
|
||||
color: $text-color-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.card-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.detail-timehighlight {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
|
||||
&.highlight-blue {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-capacity {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* 极致空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 160rpx $spacing-lg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
.empty-text {
|
||||
font-size: 30rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 24rpx;
|
||||
color: $text-color-muted;
|
||||
line-height: 1.6;
|
||||
max-width: 520rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-tip {
|
||||
text-align: center;
|
||||
padding: 0 0 $spacing-md;
|
||||
font-size: 22rpx;
|
||||
color: $text-color-light;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user