feat: migrate ice venue module
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
<template>
|
||||
<view class="activity-container">
|
||||
<StatePanel
|
||||
v-if="pageState === 'loading'"
|
||||
title="正在加载活动详情"
|
||||
description="请稍候"
|
||||
type="loading"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="pageState === 'not_found'"
|
||||
title="未找到活动信息"
|
||||
:description="pageMessage || '该活动不存在或已下线。'"
|
||||
primary-text="返回首页"
|
||||
type="empty"
|
||||
@primary="goHome"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="pageState === 'error'"
|
||||
title="活动详情加载失败"
|
||||
:description="pageMessage"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
@primary="loadActivity"
|
||||
/>
|
||||
|
||||
<view class="detail-card" v-else>
|
||||
<view class="activity-header">
|
||||
<text class="section-tag">ANNOUNCEMENT</text>
|
||||
<text class="activity-title">{{ activity.title }}</text>
|
||||
<text class="activity-date">发布时间: {{ formatDate(activity.created_at) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="divider"></view>
|
||||
|
||||
<!-- 公告大图 -->
|
||||
<image :src="activity.cover" mode="widthFix" class="activity-banner-img" />
|
||||
|
||||
<rich-text :nodes="activity.content_html" class="activity-content"></rich-text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { contentApi } from '@/modules/ice/api/content'
|
||||
import { errorMessage } from '@/modules/ice/api/session'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
|
||||
const activity = ref({
|
||||
id: null,
|
||||
title: '',
|
||||
cover: '',
|
||||
created_at: '',
|
||||
content_html: ''
|
||||
})
|
||||
const pageState = ref('loading')
|
||||
const pageMessage = ref('')
|
||||
const currentActivityId = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = currentPage.options || {}
|
||||
currentActivityId.value = options.id || ''
|
||||
loadActivity()
|
||||
})
|
||||
|
||||
const loadActivity = async () => {
|
||||
if (!currentActivityId.value) {
|
||||
pageState.value = 'not_found'
|
||||
pageMessage.value = '未找到要查看的活动。'
|
||||
return
|
||||
}
|
||||
|
||||
pageState.value = 'loading'
|
||||
pageMessage.value = ''
|
||||
try {
|
||||
activity.value = await contentApi.getActivityDetail(currentActivityId.value)
|
||||
pageState.value = 'ready'
|
||||
} catch (err) {
|
||||
if (err?.type === 'not_found') {
|
||||
pageState.value = 'not_found'
|
||||
pageMessage.value = '该活动不存在或已下线。'
|
||||
return
|
||||
}
|
||||
pageState.value = 'error'
|
||||
pageMessage.value = errorMessage(err, '活动详情加载失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => {
|
||||
uni.reLaunch({ url: '/pages/ice/home/index' })
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.activity-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-color-soft;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.detail-card {
|
||||
background-color: $bg-color-main;
|
||||
border-radius: $border-radius-lg;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||||
padding: $spacing-lg;
|
||||
}
|
||||
|
||||
.activity-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
|
||||
.section-tag {
|
||||
font-size: 18rpx;
|
||||
font-weight: 700;
|
||||
color: $color-primary;
|
||||
letter-spacing: 1.5rpx;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.activity-date {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.activity-banner-img {
|
||||
width: 100%;
|
||||
border-radius: $border-radius-base;
|
||||
background-color: $bg-color-soft;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.activity-content {
|
||||
display: block;
|
||||
font-size: 26rpx;
|
||||
color: $text-color-regular;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,258 @@
|
||||
<template>
|
||||
<view class="detail-container">
|
||||
<StatePanel
|
||||
v-if="pageState === 'loading'"
|
||||
title="正在加载课程详情"
|
||||
description="请稍候"
|
||||
type="loading"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="pageState === 'not_found'"
|
||||
title="未找到课程信息"
|
||||
:description="pageMessage || '该课程不存在或已下线。'"
|
||||
primary-text="返回课程列表"
|
||||
type="empty"
|
||||
@primary="goCourses"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="pageState === 'error'"
|
||||
title="课程详情加载失败"
|
||||
:description="pageMessage"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
@primary="loadCourse"
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<!-- 1. 课程大图封面 -->
|
||||
<view class="course-banner-wrap">
|
||||
<image :src="course.cover" mode="aspectFill" class="course-banner" />
|
||||
</view>
|
||||
|
||||
<!-- 2. 课程主要信息卡片 -->
|
||||
<view class="detail-card main-info-card">
|
||||
<view class="title-row">
|
||||
<text class="course-title">{{ course.title }}</text>
|
||||
<view class="badge success animate-fade" v-if="course.is_booked">我已约此课</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(course.start_time, course.end_time) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">执教教练</text>
|
||||
<text class="info-val">{{ course.coach?.name }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">预约人数</text>
|
||||
<text class="info-val font-semibold">已约 {{ course.booked_count }}/{{ course.capacity }} 人</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. 富文本正文卡片 -->
|
||||
<view class="detail-card content-card" v-if="course.detail_html">
|
||||
<view class="section-title">课程详情</view>
|
||||
<rich-text :nodes="course.detail_html" class="rich-text-content"></rich-text>
|
||||
</view>
|
||||
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { courseApi } from '@/modules/ice/api/course'
|
||||
import { errorMessage } from '@/modules/ice/api/session'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
|
||||
const course = ref({
|
||||
id: null,
|
||||
title: '',
|
||||
start_time: '',
|
||||
end_time: '',
|
||||
capacity: 0,
|
||||
booked_count: 0,
|
||||
available_count: 0,
|
||||
is_booked: false,
|
||||
cover: '',
|
||||
coach: { name: '' },
|
||||
detail_html: '',
|
||||
})
|
||||
const pageState = ref('loading')
|
||||
const pageMessage = ref('')
|
||||
const currentCourseId = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = currentPage.options || {}
|
||||
currentCourseId.value = options.id || ''
|
||||
loadCourse()
|
||||
})
|
||||
|
||||
const loadCourse = async () => {
|
||||
if (!currentCourseId.value) {
|
||||
pageState.value = 'not_found'
|
||||
pageMessage.value = '未找到要查看的课程。'
|
||||
return
|
||||
}
|
||||
|
||||
pageState.value = 'loading'
|
||||
pageMessage.value = ''
|
||||
try {
|
||||
course.value = await courseApi.getCourseDetail(currentCourseId.value)
|
||||
pageState.value = 'ready'
|
||||
} catch (err) {
|
||||
if (err?.type === 'not_found') {
|
||||
pageState.value = 'not_found'
|
||||
pageMessage.value = '该课程不存在或已下线。'
|
||||
return
|
||||
}
|
||||
pageState.value = 'error'
|
||||
pageMessage.value = errorMessage(err, '课程详情加载失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
const goCourses = () => {
|
||||
uni.reLaunch({ url: '/pages/ice/courses/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 yyyy = start.getFullYear()
|
||||
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 `${yyyy}-${mm}-${dd} ${startHhMm} - ${endHhMm}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-color-soft;
|
||||
padding-bottom: $spacing-xl;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
/* 1. 课程大图封面 */
|
||||
.course-banner-wrap {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
background-color: $bg-color-hover;
|
||||
overflow: hidden;
|
||||
|
||||
.course-banner {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 通用详情卡片 */
|
||||
.detail-card {
|
||||
background-color: $bg-color-main;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
padding: $spacing-lg;
|
||||
margin: 0 $spacing-md;
|
||||
border-radius: $border-radius-lg;
|
||||
box-shadow: 0 2rpx 10rpx rgba(15, 23, 42, 0.01);
|
||||
|
||||
&:first-of-type {
|
||||
margin-top: -$spacing-lg; // 让主要信息卡片微悬浮盖在封面底图上方,增加层次感
|
||||
position: relative;
|
||||
z-index: 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* 主要信息 */
|
||||
.main-info-card {
|
||||
.title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
line-height: 1.35;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.info-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
padding-top: $spacing-xs;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 富文本内容 */
|
||||
.content-card {
|
||||
.section-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
margin-bottom: $spacing-md;
|
||||
letter-spacing: 0.5rpx;
|
||||
position: relative;
|
||||
padding-left: $spacing-sm;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 15%;
|
||||
bottom: 15%;
|
||||
width: 4rpx;
|
||||
background-color: $text-color-main;
|
||||
}
|
||||
}
|
||||
|
||||
.rich-text-content {
|
||||
display: block;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -0,0 +1,419 @@
|
||||
<template>
|
||||
<view class="courses-container">
|
||||
<!-- 极简原生筛选栏 -->
|
||||
<view class="native-filter-bar">
|
||||
<!-- 微信原生日期选择器 -->
|
||||
<picker
|
||||
mode="date"
|
||||
:value="selectedDate"
|
||||
@change="onDateChange"
|
||||
class="filter-picker-item"
|
||||
>
|
||||
<view class="picker-inner">
|
||||
<text class="picker-label">日期:</text>
|
||||
<text class="picker-value">{{ selectedDate ? formatDateDisplay(selectedDate) : '选择日期' }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
|
||||
<view class="vertical-divider"></view>
|
||||
|
||||
<!-- 微信原生教练单列选择器 -->
|
||||
<picker
|
||||
mode="selector"
|
||||
:range="coachOptions"
|
||||
range-key="name"
|
||||
:value="selectedCoachIndex"
|
||||
@change="onCoachChange"
|
||||
class="filter-picker-item"
|
||||
>
|
||||
<view class="picker-inner">
|
||||
<text class="picker-label">教练:</text>
|
||||
<text class="picker-value">{{ selectedCoachName }}</text>
|
||||
<text class="picker-arrow">▼</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<!-- 课程列表(双栏高质感极简卡片) -->
|
||||
<view class="course-list">
|
||||
<StatePanel
|
||||
v-if="listState === 'error'"
|
||||
title="课程加载失败"
|
||||
:description="listErrorMessage"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
compact
|
||||
@primary="loadCourses"
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<view
|
||||
v-for="course in courses"
|
||||
:key="course.id"
|
||||
class="course-card"
|
||||
@tap="onViewDetail(course.id)"
|
||||
>
|
||||
<image :src="course.cover" mode="aspectFill" class="course-cover" />
|
||||
|
||||
<view class="course-info">
|
||||
<view class="card-top-row">
|
||||
<text class="course-title">{{ course.title }}</text>
|
||||
<view class="badge success" v-if="course.is_booked">已预约</view>
|
||||
</view>
|
||||
|
||||
<view class="course-details">
|
||||
<text class="detail-item-text highlight-blue">时间:{{ formatTimeRange(course.start_time, course.end_time) }}</text>
|
||||
<view class="detail-bottom-meta">
|
||||
<text class="detail-item-text"><text class="label-dot">•</text>教练:{{ course.coach.name }}</text>
|
||||
<text class="detail-item-text"><text class="label-dot">•</text>人数:已约 {{ course.booked_count }}/{{ course.capacity }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 加载更多提示 -->
|
||||
<view v-if="isLoadingMore" class="load-more-tip">
|
||||
<text>加载中...</text>
|
||||
</view>
|
||||
<view v-else-if="courses.length > 0 && !hasMore" class="load-more-tip">
|
||||
<text>— 已加载全部 —</text>
|
||||
</view>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<StatePanel
|
||||
v-if="listState !== 'error' && !isLoading && courses.length === 0"
|
||||
title="暂无排课行程"
|
||||
description="当前筛选条件下暂时没有可查看的课程。"
|
||||
type="empty"
|
||||
compact
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { courseApi } from '@/modules/ice/api/course'
|
||||
import { errorMessage } from '@/modules/ice/api/session'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
|
||||
const selectedDate = ref('')
|
||||
const selectedCoachIndex = ref(0) // 索引 0 默认是 "全部教练"
|
||||
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(() => courses.value.length < total.value)
|
||||
|
||||
const coaches = ref([])
|
||||
|
||||
// 原生 Picker 需要的可选数组
|
||||
const coachOptions = computed(() => {
|
||||
return [{ id: null, name: '全部教练' }, ...coaches.value]
|
||||
})
|
||||
|
||||
const selectedCoachId = computed(() => {
|
||||
return coachOptions.value[selectedCoachIndex.value]?.id || null
|
||||
})
|
||||
|
||||
const selectedCoachName = computed(() => {
|
||||
return coachOptions.value[selectedCoachIndex.value]?.name || '全部教练'
|
||||
})
|
||||
|
||||
const courses = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
const today = new Date()
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
selectedDate.value = `${today.getFullYear()}-${pad(today.getMonth() + 1)}-${pad(today.getDate())}`
|
||||
loadInitialData()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
page.value = 1
|
||||
loadCourses().finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (hasMore.value && !isLoadingMore.value) {
|
||||
page.value++
|
||||
loadMoreCourses()
|
||||
}
|
||||
})
|
||||
|
||||
const loadInitialData = async () => {
|
||||
await Promise.all([loadCoaches(), loadCourses()])
|
||||
}
|
||||
|
||||
const loadCoaches = async () => {
|
||||
try {
|
||||
coaches.value = await courseApi.getCoaches()
|
||||
} catch (err) {
|
||||
coaches.value = []
|
||||
}
|
||||
}
|
||||
|
||||
const loadCourses = async () => {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
const data = await courseApi.getCourses({
|
||||
date: selectedDate.value,
|
||||
coachId: selectedCoachId.value,
|
||||
page: 1,
|
||||
pageSize
|
||||
})
|
||||
courses.value = data.items
|
||||
total.value = data.total
|
||||
listState.value = 'ready'
|
||||
listErrorMessage.value = ''
|
||||
} catch (err) {
|
||||
courses.value = []
|
||||
total.value = 0
|
||||
listState.value = 'error'
|
||||
listErrorMessage.value = errorMessage(err, '课程加载失败,请稍后重试。')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadMoreCourses = async () => {
|
||||
if (isLoadingMore.value) return
|
||||
isLoadingMore.value = true
|
||||
try {
|
||||
const data = await courseApi.getCourses({
|
||||
date: selectedDate.value,
|
||||
coachId: selectedCoachId.value,
|
||||
page: page.value,
|
||||
pageSize
|
||||
})
|
||||
courses.value = [...courses.value, ...data.items]
|
||||
total.value = data.total
|
||||
} catch (err) {
|
||||
page.value--
|
||||
uni.showToast({ title: errorMessage(err, '加载更多失败,请重试'), icon: 'none' })
|
||||
} finally {
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDateChange = (e) => {
|
||||
selectedDate.value = e.detail.value
|
||||
page.value = 1
|
||||
loadCourses()
|
||||
}
|
||||
|
||||
const onCoachChange = (e) => {
|
||||
selectedCoachIndex.value = Number(e.detail.value)
|
||||
page.value = 1
|
||||
loadCourses()
|
||||
}
|
||||
|
||||
const onViewDetail = (id) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ice/courses/detail?id=${id}`
|
||||
})
|
||||
}
|
||||
|
||||
const formatDateDisplay = (dateStr) => {
|
||||
if (!dateStr) return ''
|
||||
const parts = dateStr.split('-')
|
||||
return `${parts[1]}月${parts[2]}日`
|
||||
}
|
||||
|
||||
const formatTimeRange = (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}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.courses-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-color-soft;
|
||||
padding-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
/* 极简原生筛选栏 */
|
||||
.native-filter-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background-color: $bg-color-main;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
height: 90rpx;
|
||||
}
|
||||
|
||||
.filter-picker-item {
|
||||
flex: 1;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.picker-inner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 90rpx;
|
||||
font-size: 26rpx;
|
||||
color: $text-color-regular;
|
||||
|
||||
.picker-label {
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.picker-value {
|
||||
color: $text-color-main;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.picker-arrow {
|
||||
font-size: 16rpx;
|
||||
color: $text-color-light;
|
||||
margin-left: 8rpx;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-divider {
|
||||
width: 1rpx;
|
||||
height: 40rpx;
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
/* 课程卡片 */
|
||||
.course-list {
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.course-card {
|
||||
display: flex;
|
||||
background-color: $bg-color-main;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||||
border-radius: $border-radius-lg;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.course-cover {
|
||||
width: 180rpx;
|
||||
height: 150rpx;
|
||||
background-color: $bg-color-hover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.course-info {
|
||||
flex: 1;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.card-top-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
overflow: hidden;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.course-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.detail-item-text {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
&.highlight-blue {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.label-dot {
|
||||
font-size: 20rpx;
|
||||
margin-right: 8rpx;
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-bottom-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xl;
|
||||
margin-top: 2rpx;
|
||||
}
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 160rpx 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 64rpx;
|
||||
margin-bottom: $spacing-md;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.empty-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 600;
|
||||
color: $text-color-main;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
}
|
||||
|
||||
.load-more-tip {
|
||||
text-align: center;
|
||||
padding: $spacing-md 0;
|
||||
font-size: 22rpx;
|
||||
color: $text-color-light;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,474 @@
|
||||
<template>
|
||||
<view class="home-container">
|
||||
<StatePanel
|
||||
v-if="pageState === 'error'"
|
||||
title="首页内容加载失败"
|
||||
:description="pageMessage"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
@primary="loadHome"
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<!-- 极简无感大图轮播 -->
|
||||
<view class="banner-wrapper">
|
||||
<swiper
|
||||
class="banner-swiper"
|
||||
circular
|
||||
autoplay
|
||||
interval="4000"
|
||||
duration="600"
|
||||
indicator-dots
|
||||
indicator-color="rgba(15, 23, 42, 0.1)"
|
||||
indicator-active-color="#0F172A"
|
||||
>
|
||||
<swiper-item v-for="(banner, index) in banners" :key="index" @tap="onTapBanner(banner)">
|
||||
<view class="banner-item">
|
||||
<image :src="banner.image" mode="aspectFill" class="banner-img" />
|
||||
<view class="banner-overlay">
|
||||
<text class="banner-title">{{ banner.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
|
||||
<!-- 近期课程板块 (克制留白,无封面纯排版) -->
|
||||
<view class="section-container">
|
||||
<view class="section-header">
|
||||
<text class="section-title">近期课程</text>
|
||||
</view>
|
||||
|
||||
<view class="course-list">
|
||||
<view
|
||||
v-for="course in upcomingCourses"
|
||||
:key="course.id"
|
||||
class="course-item"
|
||||
@tap="navigateToDetail('/pages/ice/courses/detail?id=' + course.id)"
|
||||
>
|
||||
<image :src="course.cover" mode="aspectFill" class="course-cover" />
|
||||
<view class="course-info-wrap">
|
||||
<text class="course-name">{{ course.title }}</text>
|
||||
<text class="course-time highlight-blue">时间:{{ formatCourseDate(course.start_time, course.end_time) }}</text>
|
||||
<view class="course-bottom-meta">
|
||||
<text class="course-meta-text">• 教练:{{ course.coach_name }}</text>
|
||||
<text class="course-meta-text">• 人数:已约 {{ course.booked_count }}/{{ course.capacity }}人</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="upcomingCourses.length === 0" class="empty-placeholder">
|
||||
<text>今日暂无课程排期</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 活动资讯版块 -->
|
||||
<view class="section-container last-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">公告活动</text>
|
||||
</view>
|
||||
|
||||
<view class="activity-grid">
|
||||
<view
|
||||
v-for="activity in activities"
|
||||
:key="activity.id"
|
||||
class="activity-card"
|
||||
@tap="navigateToDetail('/pages/ice/activities/detail?id=' + activity.id)"
|
||||
>
|
||||
<image :src="activity.cover" mode="aspectFill" class="activity-cover" />
|
||||
<view class="activity-info">
|
||||
<text class="activity-title">{{ activity.title }}</text>
|
||||
<text class="activity-date">{{ formatDate(activity.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { homeApi } from '@/modules/ice/api/home'
|
||||
import { errorMessage } from '@/modules/ice/api/session'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
|
||||
const todayStr = ref('')
|
||||
const banners = ref([])
|
||||
const upcomingCourses = ref([])
|
||||
const activities = ref([])
|
||||
const pageState = ref('ready')
|
||||
const pageMessage = ref('')
|
||||
|
||||
onMounted(() => {
|
||||
const days = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六']
|
||||
const now = new Date()
|
||||
todayStr.value = `${now.getMonth() + 1}月${now.getDate()}日 ${days[now.getDay()]}`
|
||||
loadHome()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadHome().finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
|
||||
const loadHome = async () => {
|
||||
try {
|
||||
const data = await homeApi.getHome({
|
||||
courseLimit: 2,
|
||||
bannerLimit: 5
|
||||
})
|
||||
banners.value = data.banners
|
||||
upcomingCourses.value = data.upcomingCourses
|
||||
activities.value = data.activities
|
||||
pageState.value = 'ready'
|
||||
pageMessage.value = ''
|
||||
} catch (err) {
|
||||
pageState.value = 'error'
|
||||
pageMessage.value = errorMessage(err, '首页内容加载失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
const navigateToDetail = (url) => {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
const onTapBanner = (banner) => {
|
||||
if (banner.link_type === 'course' && banner.link_value) {
|
||||
navigateToDetail(`/pages/ice/courses/detail?id=${banner.link_value}`)
|
||||
} else if (banner.link_type === 'activity' && banner.link_value) {
|
||||
navigateToDetail(`/pages/ice/activities/detail?id=${banner.link_value}`)
|
||||
}
|
||||
}
|
||||
|
||||
const formatCourseDate = (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 formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-container {
|
||||
padding: $spacing-lg $spacing-md;
|
||||
background-color: $bg-color-soft;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* 顶部场馆标题 */
|
||||
.header-section {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
margin-bottom: $spacing-lg;
|
||||
padding: 0 4rpx;
|
||||
|
||||
.venue-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.venue-title {
|
||||
font-size: 38rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
letter-spacing: -0.5rpx;
|
||||
}
|
||||
|
||||
.venue-status-tag {
|
||||
font-size: 20rpx;
|
||||
color: $color-success;
|
||||
font-weight: 500;
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
|
||||
.today-date {
|
||||
font-size: 24rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* 无圆角,极简轮播 */
|
||||
.banner-wrapper {
|
||||
margin-bottom: $spacing-lg;
|
||||
|
||||
.banner-swiper {
|
||||
height: 320rpx;
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.banner-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background: linear-gradient(to top, rgba(15, 23, 42, 0.6), transparent);
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
color: #FFFFFF;
|
||||
font-size: 24rpx;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
}
|
||||
}
|
||||
|
||||
/* 核心快捷卡片入口 */
|
||||
.quick-entry-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.entry-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: $bg-color-main;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||||
border-radius: $border-radius-base;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
position: relative;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
&.primary-entry {
|
||||
border-color: rgba(14, 165, 233, 0.15);
|
||||
background: linear-gradient(135deg, $bg-color-main 0%, #F0F9FF 100%);
|
||||
|
||||
.entry-num {
|
||||
color: $color-primary;
|
||||
}
|
||||
}
|
||||
|
||||
.entry-icon-wrap {
|
||||
margin-right: $spacing-lg;
|
||||
}
|
||||
|
||||
.entry-num {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-light;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.entry-text-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.entry-title {
|
||||
font-size: 30rpx;
|
||||
font-weight: 600;
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.entry-desc {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.entry-arrow {
|
||||
font-size: 32rpx;
|
||||
color: $text-color-light;
|
||||
}
|
||||
}
|
||||
|
||||
/* 板块通用头部 */
|
||||
.section-container {
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
&.last-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-md;
|
||||
padding: 0 4rpx;
|
||||
|
||||
.section-title {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5rpx;
|
||||
color: $text-color-main;
|
||||
position: relative;
|
||||
padding-left: $spacing-sm;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10%;
|
||||
bottom: 10%;
|
||||
width: 4rpx;
|
||||
background-color: $text-color-main;
|
||||
}
|
||||
}
|
||||
|
||||
.section-more {
|
||||
font-size: 24rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
}
|
||||
|
||||
/* 课程卡片 */
|
||||
.course-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-item {
|
||||
display: flex;
|
||||
background-color: $bg-color-main;
|
||||
border-radius: $border-radius-base;
|
||||
border: 1rpx solid $bg-color-hover;
|
||||
overflow: hidden;
|
||||
transition: all 0.2s ease;
|
||||
|
||||
&:active {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.course-cover {
|
||||
width: 180rpx;
|
||||
height: 130rpx;
|
||||
background-color: $bg-color-hover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.course-info-wrap {
|
||||
flex: 1;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: $text-color-main;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.course-time {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
font-weight: 500;
|
||||
|
||||
&.highlight-blue {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.course-bottom-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.course-meta-text {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 极简活动卡片(两列横向拼图排版) */
|
||||
.activity-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.activity-card {
|
||||
display: flex;
|
||||
background-color: $bg-color-main;
|
||||
border-radius: $border-radius-base;
|
||||
border: 1rpx solid $bg-color-hover;
|
||||
overflow: hidden;
|
||||
|
||||
.activity-cover {
|
||||
width: 180rpx;
|
||||
height: 130rpx;
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
flex: 1;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: $text-color-main;
|
||||
line-height: 1.3;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 2;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.activity-date {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-light;
|
||||
}
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
text-align: center;
|
||||
padding: $spacing-xl 0;
|
||||
color: $text-color-light;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,516 @@
|
||||
<template>
|
||||
<view class="history-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="goCards"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="pageState === 'error'"
|
||||
title="会员卡流水加载失败"
|
||||
:description="pageMessage"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
@primary="loadPage"
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<!-- 头部:会员卡摘要卡片 -->
|
||||
<view class="card-summary-card">
|
||||
<view class="summary-top">
|
||||
<text class="card-title">{{ card.card_name }}</text>
|
||||
<view class="badge success" v-if="card.status === 'active'">正常使用中</view>
|
||||
</view>
|
||||
<view class="summary-meta">
|
||||
<text class="card-no">卡号: {{ card.card_no }}</text>
|
||||
<text class="card-validity">截止有效期: {{ formatDate(card.expire_date) }}</text>
|
||||
</view>
|
||||
|
||||
<view class="divider"></view>
|
||||
|
||||
<view class="summary-equity">
|
||||
<view class="equity-cell" v-if="card.card_type === 'times'">
|
||||
<text class="eq-label">当前剩余</text>
|
||||
<text class="eq-val font-large">{{ card.remained_times }} <text class="unit">次</text></text>
|
||||
</view>
|
||||
<view class="equity-cell" v-else-if="card.card_type === 'balance'">
|
||||
<text class="eq-label">当前余额</text>
|
||||
<text class="eq-val font-large">¥ {{ card.balance }}</text>
|
||||
</view>
|
||||
<view class="equity-cell" v-else>
|
||||
<text class="eq-label">卡项类型</text>
|
||||
<text class="eq-val">无限通滑年卡</text>
|
||||
</view>
|
||||
|
||||
<view class="vertical-divider"></view>
|
||||
|
||||
<view class="equity-cell right-cell">
|
||||
<text class="eq-label">累计变动</text>
|
||||
<text class="eq-val">{{ historyList.length }} 次变动记录</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 极简历史流水列表 -->
|
||||
<view class="history-section">
|
||||
<view class="section-title">流水明细日志</view>
|
||||
|
||||
<view class="timeline-list">
|
||||
<view v-for="log in historyList" :key="log.id" class="timeline-log-card">
|
||||
<view class="log-card-left">
|
||||
<view class="log-icon-bubble" :class="[log.change_type]">
|
||||
<view class="log-dot-decor"></view>
|
||||
</view>
|
||||
<view class="log-meta">
|
||||
<text class="log-title-text">{{ formatChangeType(log.change_type) }}</text>
|
||||
<text class="log-desc">{{ log.memo }}</text>
|
||||
<text class="log-time">{{ formatFullDateTime(log.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="log-card-right">
|
||||
<!-- 变动值 -->
|
||||
<text class="log-delta" :class="{ plus: Number(log.delta) > 0 }">
|
||||
{{ Number(log.delta) > 0 ? '+' : '' }}{{ log.delta }}{{ card.card_type === 'times' ? '次' : '元' }}
|
||||
</text>
|
||||
<!-- 变更后结果 -->
|
||||
<text class="log-post-balance" v-if="card.card_type === 'times'">余 {{ log.post_times }} 次</text>
|
||||
<text class="log-post-balance" v-else-if="card.card_type === 'balance'">余 ¥{{ log.post_balance }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 无流水空状态 -->
|
||||
<StatePanel
|
||||
v-if="historyList.length === 0"
|
||||
title="暂无使用流水"
|
||||
description="这张会员卡暂时没有变动记录。"
|
||||
type="empty"
|
||||
compact
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||||
import { cardApi } from '@/modules/ice/api/card'
|
||||
import { errorMessage, getMemberSession, loginAndStoreToken, sessionStatusFromError, SESSION_STATUS } from '@/modules/ice/api/session'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
|
||||
const card = ref({
|
||||
id: null,
|
||||
card_no: '',
|
||||
card_name: '',
|
||||
card_type: 'times',
|
||||
status: 'active',
|
||||
remained_times: 0,
|
||||
balance: '0.00',
|
||||
expire_date: ''
|
||||
})
|
||||
|
||||
const historyList = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const currentCardId = ref(null)
|
||||
const pageState = ref('loading')
|
||||
const pageMessage = ref('')
|
||||
const sessionStatus = ref(SESSION_STATUS.CHECKING)
|
||||
const isLoginSubmitting = ref(false)
|
||||
|
||||
onShow(() => {
|
||||
const pages = getCurrentPages()
|
||||
const currentPage = pages[pages.length - 1]
|
||||
const options = currentPage.options || {}
|
||||
const id = options.id
|
||||
|
||||
if (!id) {
|
||||
pageState.value = 'not_found'
|
||||
pageMessage.value = '未找到要查看的会员卡。'
|
||||
return
|
||||
}
|
||||
|
||||
currentCardId.value = id
|
||||
loadPage()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
page.value = 1
|
||||
loadPage().finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
|
||||
const loadPage = async () => {
|
||||
if (!currentCardId.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 {
|
||||
const data = await cardApi.getCardChanges(currentCardId.value, {
|
||||
page: page.value,
|
||||
pageSize
|
||||
})
|
||||
card.value = data.card
|
||||
historyList.value = data.items
|
||||
total.value = data.total
|
||||
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
|
||||
}
|
||||
historyList.value = []
|
||||
total.value = 0
|
||||
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 goProfile = () => {
|
||||
uni.reLaunch({ url: '/pages/ice/profile/index' })
|
||||
}
|
||||
|
||||
const goCards = () => {
|
||||
uni.redirectTo({ url: '/pages/ice/profile/cards' })
|
||||
}
|
||||
|
||||
const formatChangeType = (type) => {
|
||||
const map = {
|
||||
issue: '开卡入账',
|
||||
recharge: '充值金额',
|
||||
consume: '课程上课消费',
|
||||
adjustment: '手工调整额度',
|
||||
disable: '停用卡项',
|
||||
enable: '启用卡项',
|
||||
extend_validity: '延长有效期',
|
||||
shorten_validity: '缩短有效期'
|
||||
}
|
||||
return map[type] || '其他变动'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
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())}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.history-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-color-soft;
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
/* 顶部会员卡摘要卡片 */
|
||||
.card-summary-card {
|
||||
background-color: $bg-color-main;
|
||||
border-radius: $border-radius-lg;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||||
padding: $spacing-lg;
|
||||
|
||||
.summary-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: $spacing-sm;
|
||||
|
||||
.card-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
}
|
||||
}
|
||||
|
||||
.summary-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.summary-equity {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.equity-cell {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
|
||||
&.right-cell {
|
||||
padding-left: $spacing-lg;
|
||||
}
|
||||
|
||||
.eq-label {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-light;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.eq-val {
|
||||
font-size: 24rpx;
|
||||
color: $text-color-regular;
|
||||
font-weight: 600;
|
||||
|
||||
&.font-large {
|
||||
font-size: 38rpx;
|
||||
font-weight: 800;
|
||||
color: $text-color-main;
|
||||
font-family: -apple-system, BlinkMacSystemFont, monospace;
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 22rpx;
|
||||
font-weight: 400;
|
||||
color: $text-color-muted;
|
||||
margin-left: 4rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.vertical-divider {
|
||||
width: 1rpx;
|
||||
height: 60rpx;
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 历史流水列表 */
|
||||
.history-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
.section-title {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-muted;
|
||||
text-transform: uppercase;
|
||||
padding-left: 4rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.timeline-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.timeline-log-card {
|
||||
background-color: $bg-color-main;
|
||||
border-radius: $border-radius-base;
|
||||
border: 1rpx solid $bg-color-hover;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.log-card-left {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: $spacing-md;
|
||||
flex: 1;
|
||||
padding-right: $spacing-md;
|
||||
}
|
||||
|
||||
.log-icon-bubble {
|
||||
width: 64rpx;
|
||||
height: 64rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $bg-color-soft;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
margin-top: 4rpx;
|
||||
|
||||
.log-dot-decor {
|
||||
width: 14rpx;
|
||||
height: 14rpx;
|
||||
border-radius: 50%;
|
||||
background-color: $text-color-light;
|
||||
}
|
||||
|
||||
&.consume {
|
||||
background-color: $color-primary-light;
|
||||
.log-dot-decor {
|
||||
background-color: $color-primary;
|
||||
}
|
||||
}
|
||||
&.recharge, &.issue {
|
||||
background-color: $color-success-light;
|
||||
.log-dot-decor {
|
||||
background-color: $color-success;
|
||||
}
|
||||
}
|
||||
&.adjustment {
|
||||
background-color: $color-warning-light;
|
||||
.log-dot-decor {
|
||||
background-color: $color-warning;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.log-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
|
||||
.log-title-text {
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.log-desc {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-regular;
|
||||
}
|
||||
|
||||
.log-time {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-light;
|
||||
}
|
||||
}
|
||||
|
||||
.log-card-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 4rpx;
|
||||
flex-shrink: 0;
|
||||
|
||||
.log-delta {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
|
||||
&.plus {
|
||||
color: $color-success;
|
||||
}
|
||||
}
|
||||
|
||||
.log-post-balance {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 80rpx 0;
|
||||
color: $text-color-light;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,430 @@
|
||||
<template>
|
||||
<view class="cards-container">
|
||||
<StatePanel
|
||||
v-if="sessionStatus === SESSION_STATUS.CHECKING"
|
||||
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="sessionMessage || '请检查网络后重试。'"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
@primary="checkSessionAndLoad"
|
||||
/>
|
||||
|
||||
<!-- 会员卡列表 -->
|
||||
<view class="cards-list" v-else>
|
||||
<StatePanel
|
||||
v-if="listState === 'error'"
|
||||
title="会员卡加载失败"
|
||||
:description="listErrorMessage"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
compact
|
||||
@primary="loadCards"
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<view
|
||||
v-for="card in filteredCards"
|
||||
:key="card.id"
|
||||
class="member-card-box"
|
||||
:class="[card.status, card.card_type]"
|
||||
>
|
||||
<!-- 会员卡卡面 -->
|
||||
<view class="card-face">
|
||||
<view class="face-top">
|
||||
<view class="card-brand">
|
||||
<text class="card-no">{{ card.card_no }}</text>
|
||||
</view>
|
||||
<view class="card-type-label">
|
||||
{{ formatCardType(card.card_type) }}
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="face-middle">
|
||||
<text class="card-name">{{ card.card_name }}</text>
|
||||
</view>
|
||||
|
||||
<view class="face-bottom">
|
||||
<!-- 剩余次卡权益 -->
|
||||
<view class="equity-display" v-if="card.card_type === 'times'">
|
||||
<text class="equity-label">剩余次数</text>
|
||||
<text class="equity-value"><text class="number">{{ card.remained_times }}</text> 次</text>
|
||||
</view>
|
||||
<!-- 剩余储值权益 -->
|
||||
<view class="equity-display" v-else-if="card.card_type === 'balance'">
|
||||
<text class="equity-label">剩余额度</text>
|
||||
<text class="equity-value">¥ <text class="number">{{ card.balance }}</text></text>
|
||||
</view>
|
||||
<!-- 月卡不限次 -->
|
||||
<view class="equity-display" v-else-if="card.card_type === 'month'">
|
||||
<text class="equity-label">专属权益</text>
|
||||
<text class="equity-value">不限次月卡</text>
|
||||
</view>
|
||||
|
||||
<view class="card-validity">
|
||||
<text class="date-range">{{ card.start_date }} 至 {{ card.expire_date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 卡片底部使用流水操作行 -->
|
||||
<view class="card-actions">
|
||||
<view class="financial-details">
|
||||
<text class="fin-text" v-if="card.card_type === 'times'">总次数: {{ card.total_times }}次</text>
|
||||
<text class="fin-text" v-else-if="card.card_type === 'balance'">含赠送: ¥{{ card.gift_balance }}</text>
|
||||
<text class="fin-text" v-else-if="card.card_type === 'month'">有效期内无限通滑</text>
|
||||
</view>
|
||||
|
||||
<view class="action-btn" @tap="onViewHistory(card.id)">
|
||||
<text>使用流水</text>
|
||||
<text class="arrow">→</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<StatePanel
|
||||
v-if="listState !== 'error' && !isLoading && filteredCards.length === 0"
|
||||
title="未查到会员卡"
|
||||
description="场馆暂未为你登记会员卡。"
|
||||
type="empty"
|
||||
compact
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onPullDownRefresh, onShow } from '@dcloudio/uni-app'
|
||||
import { cardApi } from '@/modules/ice/api/card'
|
||||
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 cards = ref([])
|
||||
const isLoading = ref(false)
|
||||
const isLoginSubmitting = ref(false)
|
||||
const sessionStatus = ref(SESSION_STATUS.CHECKING)
|
||||
const sessionMessage = ref('')
|
||||
const listState = ref('idle')
|
||||
const listErrorMessage = ref('')
|
||||
|
||||
const filteredCards = computed(() => {
|
||||
return cards.value
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
checkSessionAndLoad()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
const task = sessionStatus.value === SESSION_STATUS.MEMBER ? loadCards() : checkSessionAndLoad()
|
||||
task.finally(() => {
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
})
|
||||
|
||||
const checkSessionAndLoad = async () => {
|
||||
sessionStatus.value = SESSION_STATUS.CHECKING
|
||||
const session = await getMemberSession()
|
||||
sessionStatus.value = session.status
|
||||
sessionMessage.value = session.message || ''
|
||||
|
||||
if (session.status === SESSION_STATUS.MEMBER) {
|
||||
await loadCards()
|
||||
return
|
||||
}
|
||||
|
||||
cards.value = []
|
||||
listState.value = 'idle'
|
||||
}
|
||||
|
||||
const loadCards = async () => {
|
||||
if (isLoading.value) return
|
||||
isLoading.value = true
|
||||
try {
|
||||
cards.value = await cardApi.getCards()
|
||||
listState.value = 'ready'
|
||||
listErrorMessage.value = ''
|
||||
} catch (err) {
|
||||
if (applySessionError(err)) return
|
||||
cards.value = []
|
||||
listState.value = 'error'
|
||||
listErrorMessage.value = errorMessage(err, '会员卡加载失败,请稍后重试。')
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onViewHistory = (id) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/ice/profile/card-history?id=${id}`
|
||||
})
|
||||
}
|
||||
|
||||
const formatCardType = (type) => {
|
||||
const map = {
|
||||
times: '课时次卡',
|
||||
balance: '储值卡',
|
||||
month: '不限次月卡'
|
||||
}
|
||||
return map[type] || '普通会员卡'
|
||||
}
|
||||
|
||||
const applySessionError = (err) => {
|
||||
const status = sessionStatusFromError(err)
|
||||
if (!status) return false
|
||||
sessionStatus.value = status
|
||||
sessionMessage.value = err?.message || ''
|
||||
cards.value = []
|
||||
listState.value = 'idle'
|
||||
if (status === SESSION_STATUS.EXPIRED) {
|
||||
authStorage.clear()
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
const goProfile = () => {
|
||||
uni.reLaunch({ url: '/pages/ice/profile/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cards-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-color-soft;
|
||||
padding-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
/* 卡片列表 */
|
||||
.cards-list {
|
||||
padding: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.member-card-box {
|
||||
background-color: $bg-color-main;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||||
border-radius: $border-radius-lg;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 4rpx 15rpx rgba(15, 23, 42, 0.02);
|
||||
|
||||
&.expired {
|
||||
opacity: 0.6;
|
||||
.card-face {
|
||||
background: linear-gradient(135deg, #F1F5F9 0%, #E2E8F0 100%);
|
||||
}
|
||||
}
|
||||
|
||||
/* 针对启用卡定制淡雅微渐变皮肤,避免刺眼大图 */
|
||||
&.active {
|
||||
&.times {
|
||||
.card-face {
|
||||
background: linear-gradient(135deg, #FFFFFF 0%, #F0F9FF 100%);
|
||||
}
|
||||
}
|
||||
&.balance {
|
||||
.card-face {
|
||||
background: linear-gradient(135deg, #FFFFFF 0%, #ECFDF5 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.card-face {
|
||||
padding: $spacing-lg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
|
||||
.face-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.card-no {
|
||||
font-size: 20rpx;
|
||||
font-family: monospace;
|
||||
color: $text-color-light;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.card-type-label {
|
||||
font-size: 20rpx;
|
||||
font-weight: 500;
|
||||
color: $text-color-light;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.face-middle {
|
||||
.card-name {
|
||||
font-size: 32rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
.face-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-end;
|
||||
|
||||
.equity-display {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.equity-label {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-light;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.equity-value {
|
||||
font-size: 24rpx;
|
||||
color: $text-color-main;
|
||||
font-weight: 500;
|
||||
|
||||
.number {
|
||||
font-size: 40rpx;
|
||||
font-weight: 800;
|
||||
font-family: -apple-system, BlinkMacSystemFont, monospace;
|
||||
}
|
||||
|
||||
.sub-label {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-muted;
|
||||
margin-left: 6rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.card-validity {
|
||||
text-align: right;
|
||||
|
||||
.date-range {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 卡片下方极简操作行 */
|
||||
.card-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
font-size: 22rpx;
|
||||
|
||||
.financial-details {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: $text-color-muted;
|
||||
|
||||
.fin-text {
|
||||
&.dot {
|
||||
margin: 0 8rpx;
|
||||
color: $text-color-light;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
color: $text-color-main;
|
||||
font-weight: 600;
|
||||
|
||||
.arrow {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* 极致空状态 */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 160rpx $spacing-lg;
|
||||
|
||||
.empty-text {
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.empty-sub {
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,271 @@
|
||||
<template>
|
||||
<view class="profile-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="loadProfile"
|
||||
/>
|
||||
|
||||
<StatePanel
|
||||
v-else-if="pageState === 'error'"
|
||||
title="个人信息加载失败"
|
||||
:description="pageMessage"
|
||||
primary-text="重试"
|
||||
type="error"
|
||||
@primary="loadProfile"
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<!-- 头部人物名片 -->
|
||||
<view class="detail-header-card">
|
||||
<view class="header-meta">
|
||||
<text class="header-name">{{ member.name }}</text>
|
||||
<text class="header-phone">{{ member.phone }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 基本资料 -->
|
||||
<view class="info-card">
|
||||
<text class="card-section-tag">基本资料</text>
|
||||
<view class="info-row">
|
||||
<text class="info-label">姓名</text>
|
||||
<text class="info-val">{{ member.name || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">手机号</text>
|
||||
<text class="info-val">{{ member.phone || '-' }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">性别</text>
|
||||
<text class="info-val">{{ formatGender(member.gender) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">生日</text>
|
||||
<text class="info-val">{{ member.birthday || '-' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 会员档案 -->
|
||||
<view class="info-card">
|
||||
<text class="card-section-tag">会员档案</text>
|
||||
<view class="info-row">
|
||||
<text class="info-label">入会日期</text>
|
||||
<text class="info-val">{{ formatDate(member.joinDate) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { errorMessage, getMemberSession, loginAndStoreToken, SESSION_STATUS } from '@/modules/ice/api/session'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
|
||||
const member = ref({
|
||||
name: '',
|
||||
phone: '',
|
||||
gender: 'unknown',
|
||||
birthday: '',
|
||||
joinDate: '',
|
||||
salesName: '',
|
||||
cardCount: 0
|
||||
})
|
||||
const pageState = ref('loading')
|
||||
const pageMessage = ref('')
|
||||
const sessionStatus = ref(SESSION_STATUS.CHECKING)
|
||||
const isLoginSubmitting = ref(false)
|
||||
|
||||
onShow(() => {
|
||||
loadProfile()
|
||||
})
|
||||
|
||||
const loadProfile = async () => {
|
||||
pageState.value = 'loading'
|
||||
pageMessage.value = ''
|
||||
try {
|
||||
const session = await getMemberSession()
|
||||
sessionStatus.value = session.status
|
||||
if (session.status !== SESSION_STATUS.MEMBER) {
|
||||
pageState.value = 'blocked'
|
||||
pageMessage.value = session.message || ''
|
||||
return
|
||||
}
|
||||
|
||||
const data = session.member
|
||||
if (data?.id) {
|
||||
member.value = {
|
||||
name: data.name || '',
|
||||
phone: data.phone || '',
|
||||
gender: data.gender || 'unknown',
|
||||
birthday: data.birthday || '',
|
||||
joinDate: data.joinDate || '',
|
||||
salesName: data.salesName || '无',
|
||||
cardCount: data.cardCount || 0
|
||||
}
|
||||
}
|
||||
pageState.value = 'ready'
|
||||
} catch (err) {
|
||||
pageState.value = 'error'
|
||||
pageMessage.value = errorMessage(err, '个人信息加载失败,请稍后重试。')
|
||||
}
|
||||
}
|
||||
|
||||
const onWechatLogin = async () => {
|
||||
if (isLoginSubmitting.value) return
|
||||
isLoginSubmitting.value = true
|
||||
uni.showLoading({ title: '登录中...' })
|
||||
try {
|
||||
await loginAndStoreToken()
|
||||
await loadProfile()
|
||||
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 goProfile = () => {
|
||||
uni.reLaunch({ url: '/pages/ice/profile/index' })
|
||||
}
|
||||
|
||||
const formatGender = (g) => {
|
||||
const map = { male: '男', female: '女', unknown: '未设置' }
|
||||
return map[g] || '未设置'
|
||||
}
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
if (!dateStr) return '-'
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-detail-container {
|
||||
min-height: 100vh;
|
||||
background-color: $bg-color-soft;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-lg;
|
||||
}
|
||||
|
||||
.detail-header-card {
|
||||
background-color: $bg-color-main;
|
||||
border-radius: $border-radius-lg;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||||
padding: $spacing-xl $spacing-lg;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
|
||||
.header-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
.header-name {
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.header-phone {
|
||||
font-size: 24rpx;
|
||||
color: $text-color-muted;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.info-card {
|
||||
background-color: $bg-color-main;
|
||||
border-radius: $border-radius-lg;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
||||
padding: $spacing-lg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
|
||||
.card-section-tag {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-light;
|
||||
letter-spacing: 1rpx;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: $spacing-xs;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
|
||||
&:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
font-size: 26rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.info-val {
|
||||
font-size: 26rpx;
|
||||
color: $text-color-main;
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user