feat: migrate ice venue module
@@ -0,0 +1,210 @@
|
|||||||
|
<template>
|
||||||
|
<view class="state-panel" :class="[type, { compact }]">
|
||||||
|
<view class="state-mark" v-if="showMark">
|
||||||
|
<text class="state-mark-text">{{ markText }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="state-copy" v-if="title || description">
|
||||||
|
<text class="state-title" v-if="title">{{ title }}</text>
|
||||||
|
<text class="state-description" v-if="description">{{ description }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view class="state-actions" v-if="primaryText || secondaryText">
|
||||||
|
<button
|
||||||
|
v-if="primaryText"
|
||||||
|
class="state-btn primary"
|
||||||
|
:loading="loading"
|
||||||
|
:disabled="loading"
|
||||||
|
@tap="$emit('primary')"
|
||||||
|
>
|
||||||
|
{{ primaryText }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="secondaryText"
|
||||||
|
class="state-btn secondary"
|
||||||
|
:disabled="loading"
|
||||||
|
@tap="$emit('secondary')"
|
||||||
|
>
|
||||||
|
{{ secondaryText }}
|
||||||
|
</button>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
primaryText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
secondaryText: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
},
|
||||||
|
type: {
|
||||||
|
type: String,
|
||||||
|
default: 'default'
|
||||||
|
},
|
||||||
|
loading: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
compact: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
},
|
||||||
|
showMark: {
|
||||||
|
type: Boolean,
|
||||||
|
default: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
defineEmits(['primary', 'secondary'])
|
||||||
|
|
||||||
|
const markText = computed(() => {
|
||||||
|
const map = {
|
||||||
|
login: '登',
|
||||||
|
expired: '重',
|
||||||
|
profile: '档',
|
||||||
|
error: '!',
|
||||||
|
empty: '空',
|
||||||
|
loading: '...'
|
||||||
|
}
|
||||||
|
return map[props.type] || 'i'
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.state-panel {
|
||||||
|
min-height: 520rpx;
|
||||||
|
padding: 80rpx $spacing-lg;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
gap: $spacing-lg;
|
||||||
|
|
||||||
|
&.compact {
|
||||||
|
min-height: 280rpx;
|
||||||
|
padding: $spacing-xl $spacing-lg;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-mark {
|
||||||
|
width: 88rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: 44rpx;
|
||||||
|
background-color: $color-primary-light;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
.state-mark-text {
|
||||||
|
font-size: 30rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
color: $color-primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-panel.expired,
|
||||||
|
.state-panel.error {
|
||||||
|
.state-mark {
|
||||||
|
background-color: $color-danger-light;
|
||||||
|
|
||||||
|
.state-mark-text {
|
||||||
|
color: $color-danger;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-panel.profile {
|
||||||
|
.state-mark {
|
||||||
|
background-color: $color-warning-light;
|
||||||
|
|
||||||
|
.state-mark-text {
|
||||||
|
color: $color-warning;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-panel.empty {
|
||||||
|
.state-mark {
|
||||||
|
background-color: $bg-color-hover;
|
||||||
|
|
||||||
|
.state-mark-text {
|
||||||
|
color: $text-color-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
max-width: 560rpx;
|
||||||
|
|
||||||
|
.state-title {
|
||||||
|
font-size: 34rpx;
|
||||||
|
line-height: 1.35;
|
||||||
|
font-weight: 700;
|
||||||
|
color: $text-color-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-description {
|
||||||
|
font-size: 26rpx;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: $text-color-muted;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-actions {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 520rpx;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-btn {
|
||||||
|
width: 100%;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: $border-radius-base;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.primary {
|
||||||
|
color: #FFFFFF;
|
||||||
|
background-color: $text-color-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.secondary {
|
||||||
|
color: $text-color-regular;
|
||||||
|
background-color: $bg-color-main;
|
||||||
|
border: 1rpx solid $border-color;
|
||||||
|
}
|
||||||
|
|
||||||
|
&[disabled] {
|
||||||
|
color: #FFFFFF;
|
||||||
|
background-color: $text-color-light;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBookingCourse = (backendCourse = {}) => ({
|
||||||
|
id: backendCourse.id ?? null,
|
||||||
|
title: backendCourse.title || '',
|
||||||
|
start_time: backendCourse.start_time || '',
|
||||||
|
end_time: backendCourse.end_time || '',
|
||||||
|
coach_name: backendCourse.coach_name || '',
|
||||||
|
capacity: toNumber(backendCourse.capacity),
|
||||||
|
booked_count: toNumber(backendCourse.booked_count)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toBookingCard = (backendCard) => {
|
||||||
|
if (!backendCard) return null
|
||||||
|
return {
|
||||||
|
id: backendCard.id ?? null,
|
||||||
|
card_name: backendCard.card_name || '',
|
||||||
|
remained_detail: backendCard.remained_detail || ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBookingViewModel = (backendBooking = {}) => ({
|
||||||
|
id: backendBooking.id,
|
||||||
|
status: backendBooking.status || 'booked',
|
||||||
|
checked_in_at: backendBooking.checked_in_at || null,
|
||||||
|
cancelled_at: backendBooking.cancelled_at || null,
|
||||||
|
created_at: backendBooking.created_at || '',
|
||||||
|
course: toBookingCourse(backendBooking.course),
|
||||||
|
card: toBookingCard(backendBooking.card)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPaginatedBookings = (backendData = {}) => ({
|
||||||
|
items: Array.isArray(backendData.items) ? backendData.items.map(toBookingViewModel) : [],
|
||||||
|
total: toNumber(backendData.total),
|
||||||
|
page: toNumber(backendData.page) || 1,
|
||||||
|
page_size: toNumber(backendData.page_size) || 20
|
||||||
|
})
|
||||||
|
|
||||||
|
const toQuery = (params = {}) => ({
|
||||||
|
status: params.status || 'all',
|
||||||
|
page: params.page || 1,
|
||||||
|
page_size: params.pageSize || 20
|
||||||
|
})
|
||||||
|
|
||||||
|
export const bookingApi = {
|
||||||
|
getBookings(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/bookings',
|
||||||
|
data: toQuery(params)
|
||||||
|
}).then(toPaginatedBookings)
|
||||||
|
},
|
||||||
|
|
||||||
|
getBookingDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/bookings/${id}`
|
||||||
|
}).then(toBookingViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMemberCard = (backendCard = {}) => ({
|
||||||
|
id: backendCard.id,
|
||||||
|
card_no: backendCard.card_no || '',
|
||||||
|
card_name: backendCard.card_name || '',
|
||||||
|
card_type: backendCard.card_type || 'times',
|
||||||
|
backend_card_type: backendCard.backend_card_type || '',
|
||||||
|
status: backendCard.status || 'active',
|
||||||
|
total_times: toNumber(backendCard.total_times),
|
||||||
|
remained_times: toNumber(backendCard.remained_times),
|
||||||
|
original_balance: backendCard.original_balance || '0.00',
|
||||||
|
gift_balance: backendCard.gift_balance || '0.00',
|
||||||
|
balance: backendCard.balance || '0.00',
|
||||||
|
start_date: backendCard.start_date || '',
|
||||||
|
expire_date: backendCard.expire_date || '',
|
||||||
|
remained_detail: backendCard.remained_detail || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCardChange = (backendChange = {}) => ({
|
||||||
|
id: backendChange.id,
|
||||||
|
change_type: backendChange.change_type || 'adjustment',
|
||||||
|
backend_change_type: backendChange.backend_change_type || '',
|
||||||
|
delta: backendChange.delta || '0',
|
||||||
|
post_times: toNumber(backendChange.post_times),
|
||||||
|
post_balance: backendChange.post_balance || '0.00',
|
||||||
|
memo: backendChange.memo || '',
|
||||||
|
created_at: backendChange.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toCardHistory = (backendData = {}) => ({
|
||||||
|
card: toMemberCard(backendData.card),
|
||||||
|
items: Array.isArray(backendData.items) ? backendData.items.map(toCardChange) : [],
|
||||||
|
total: toNumber(backendData.total),
|
||||||
|
page: toNumber(backendData.page) || 1,
|
||||||
|
page_size: toNumber(backendData.page_size) || 20
|
||||||
|
})
|
||||||
|
|
||||||
|
export const cardApi = {
|
||||||
|
getCards(status = 'all') {
|
||||||
|
return request({
|
||||||
|
url: '/api/members/me/cards',
|
||||||
|
data: { status }
|
||||||
|
}).then((data = {}) => (Array.isArray(data.items) ? data.items.map(toMemberCard) : []))
|
||||||
|
},
|
||||||
|
|
||||||
|
getCardChanges(cardId, params = {}) {
|
||||||
|
return request({
|
||||||
|
url: `/api/members/me/cards/${cardId}/changes`,
|
||||||
|
data: {
|
||||||
|
page: params.page || 1,
|
||||||
|
page_size: params.pageSize || 20
|
||||||
|
}
|
||||||
|
}).then(toCardHistory)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
|
||||||
|
const toActivityDetail = (backendActivity = {}) => ({
|
||||||
|
id: backendActivity.id,
|
||||||
|
title: backendActivity.title || '',
|
||||||
|
cover: backendActivity.cover || '',
|
||||||
|
created_at: backendActivity.created_at || '',
|
||||||
|
content_html: backendActivity.content_html || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
export const contentApi = {
|
||||||
|
getActivityDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/contents/activities/${id}`
|
||||||
|
}).then(toActivityDetail)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const toCoach = (backendCoach = {}) => ({
|
||||||
|
id: backendCoach.id ?? null,
|
||||||
|
name: backendCoach.name || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
export const toCourseViewModel = (backendCourse = {}) => {
|
||||||
|
const coach = toCoach(backendCourse.coach)
|
||||||
|
return {
|
||||||
|
id: backendCourse.id,
|
||||||
|
title: backendCourse.title || '',
|
||||||
|
start_time: backendCourse.start_time || '',
|
||||||
|
end_time: backendCourse.end_time || '',
|
||||||
|
coach,
|
||||||
|
coach_name: backendCourse.coach_name || coach.name,
|
||||||
|
capacity: toNumber(backendCourse.capacity),
|
||||||
|
booked_count: toNumber(backendCourse.booked_count),
|
||||||
|
available_count: toNumber(backendCourse.available_count),
|
||||||
|
is_booked: backendCourse.is_booked === true,
|
||||||
|
my_booking_id: backendCourse.my_booking_id ?? null,
|
||||||
|
cover: backendCourse.cover || '',
|
||||||
|
detail_html: backendCourse.detail_html || '',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toPaginatedCourses = (backendData = {}) => ({
|
||||||
|
items: Array.isArray(backendData.items) ? backendData.items.map(toCourseViewModel) : [],
|
||||||
|
total: toNumber(backendData.total),
|
||||||
|
page: toNumber(backendData.page) || 1,
|
||||||
|
page_size: toNumber(backendData.page_size) || 20
|
||||||
|
})
|
||||||
|
|
||||||
|
const toQuery = (params = {}) => {
|
||||||
|
const data = {}
|
||||||
|
if (params.date) data.date = params.date
|
||||||
|
if (params.coachId) data.coach_id = params.coachId
|
||||||
|
if (params.page) data.page = params.page
|
||||||
|
if (params.pageSize) data.page_size = params.pageSize
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const courseApi = {
|
||||||
|
getCoaches(status = 'active') {
|
||||||
|
return request({
|
||||||
|
url: '/api/coaches',
|
||||||
|
data: { status }
|
||||||
|
}).then((data = {}) => (Array.isArray(data.items) ? data.items.map(toCoach) : []))
|
||||||
|
},
|
||||||
|
|
||||||
|
getCourses(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/courses',
|
||||||
|
data: toQuery(params)
|
||||||
|
}).then(toPaginatedCourses)
|
||||||
|
},
|
||||||
|
|
||||||
|
getCourseDetail(id) {
|
||||||
|
return request({
|
||||||
|
url: `/api/courses/${id}`
|
||||||
|
}).then(toCourseViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { request } from './request'
|
||||||
|
import { toCourseViewModel } from './course'
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const toBanner = (backendBanner = {}) => ({
|
||||||
|
id: backendBanner.id,
|
||||||
|
title: backendBanner.title || '',
|
||||||
|
image: backendBanner.image || '',
|
||||||
|
link_type: backendBanner.link_type || 'none',
|
||||||
|
link_value: backendBanner.link_value || '',
|
||||||
|
sort_order: toNumber(backendBanner.sort_order)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toActivitySummary = (backendActivity = {}) => ({
|
||||||
|
id: backendActivity.id,
|
||||||
|
title: backendActivity.title || '',
|
||||||
|
cover: backendActivity.cover || '',
|
||||||
|
created_at: backendActivity.created_at || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toHomeViewModel = (backendData = {}) => ({
|
||||||
|
banners: Array.isArray(backendData.banners) ? backendData.banners.map(toBanner) : [],
|
||||||
|
upcomingCourses: Array.isArray(backendData.upcoming_courses)
|
||||||
|
? backendData.upcoming_courses.map(toCourseViewModel)
|
||||||
|
: [],
|
||||||
|
activities: Array.isArray(backendData.activities) ? backendData.activities.map(toActivitySummary) : [],
|
||||||
|
venue: {
|
||||||
|
name: backendData.venue?.name || '奥林匹克滑冰中心',
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export const homeApi = {
|
||||||
|
getHome(options = {}) {
|
||||||
|
const data = {
|
||||||
|
course_limit: options.courseLimit || 2,
|
||||||
|
banner_limit: options.bannerLimit || 5
|
||||||
|
}
|
||||||
|
if (options.activityLimit !== undefined) {
|
||||||
|
data.activity_limit = options.activityLimit
|
||||||
|
}
|
||||||
|
if (options.courseDate) data.course_date = options.courseDate
|
||||||
|
|
||||||
|
return request({
|
||||||
|
url: '/api/contents/home',
|
||||||
|
data
|
||||||
|
}).then(toHomeViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,151 @@
|
|||||||
|
import { authStorage, request } from './request'
|
||||||
|
|
||||||
|
// 防腐层:小程序端只在本文件感知后端字段名,页面层统一使用下方 mapper 输出的前端模型。
|
||||||
|
export const memberBackendContract = Object.freeze({
|
||||||
|
wechatLoginResponse: [
|
||||||
|
'access_token',
|
||||||
|
'token_type',
|
||||||
|
'expires_in',
|
||||||
|
'member_bound',
|
||||||
|
'member'
|
||||||
|
],
|
||||||
|
registerOrBindRequest: [
|
||||||
|
'phone_code',
|
||||||
|
'phone',
|
||||||
|
'name',
|
||||||
|
'gender',
|
||||||
|
'birthday'
|
||||||
|
],
|
||||||
|
registerOrBindResponse: [
|
||||||
|
'created',
|
||||||
|
'bound',
|
||||||
|
'member'
|
||||||
|
],
|
||||||
|
memberSummary: [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'phone',
|
||||||
|
'gender',
|
||||||
|
'birthday',
|
||||||
|
'joined_at'
|
||||||
|
],
|
||||||
|
memberProfile: [
|
||||||
|
'id',
|
||||||
|
'name',
|
||||||
|
'phone',
|
||||||
|
'gender',
|
||||||
|
'gender_label',
|
||||||
|
'birthday',
|
||||||
|
'joined_at',
|
||||||
|
'sales.id',
|
||||||
|
'sales.name',
|
||||||
|
'card_summary.total',
|
||||||
|
'card_summary.active',
|
||||||
|
'card_summary.disabled',
|
||||||
|
'card_summary.expired'
|
||||||
|
]
|
||||||
|
})
|
||||||
|
|
||||||
|
const GENDER_LABELS = Object.freeze({
|
||||||
|
male: '男',
|
||||||
|
female: '女',
|
||||||
|
unknown: '未设置'
|
||||||
|
})
|
||||||
|
|
||||||
|
const emptyCardSummary = () => ({
|
||||||
|
total: 0,
|
||||||
|
active: 0,
|
||||||
|
disabled: 0,
|
||||||
|
expired: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const toNumber = (value) => {
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const toMemberCardSummary = (backendCardSummary = {}) => ({
|
||||||
|
total: toNumber(backendCardSummary.total),
|
||||||
|
active: toNumber(backendCardSummary.active),
|
||||||
|
disabled: toNumber(backendCardSummary.disabled),
|
||||||
|
expired: toNumber(backendCardSummary.expired)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toMemberViewModel = (backendMember, options = {}) => {
|
||||||
|
if (!backendMember || !backendMember.id) return null
|
||||||
|
|
||||||
|
const cardSummary = options.withProfileFields
|
||||||
|
? toMemberCardSummary(backendMember.card_summary)
|
||||||
|
: emptyCardSummary()
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: backendMember.id,
|
||||||
|
name: backendMember.name || '',
|
||||||
|
phone: backendMember.phone || '',
|
||||||
|
gender: backendMember.gender || 'unknown',
|
||||||
|
genderLabel: backendMember.gender_label || GENDER_LABELS[backendMember.gender] || GENDER_LABELS.unknown,
|
||||||
|
birthday: backendMember.birthday || '',
|
||||||
|
joinDate: backendMember.joined_at || '',
|
||||||
|
salesId: backendMember.sales?.id || null,
|
||||||
|
salesName: backendMember.sales?.name || '无',
|
||||||
|
cardSummary,
|
||||||
|
cardCount: cardSummary.total
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const toWechatLoginViewModel = (backendData = {}) => ({
|
||||||
|
accessToken: backendData.access_token || '',
|
||||||
|
tokenType: backendData.token_type || 'Bearer',
|
||||||
|
expiresIn: toNumber(backendData.expires_in),
|
||||||
|
memberBound: backendData.member_bound === true,
|
||||||
|
member: toMemberViewModel(backendData.member)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toRegisterOrBindPayload = (formData = {}) => ({
|
||||||
|
phone_code: formData.phoneCode || null,
|
||||||
|
phone: formData.phone || null,
|
||||||
|
name: formData.name || null,
|
||||||
|
gender: formData.gender || 'unknown',
|
||||||
|
birthday: formData.birthday || null
|
||||||
|
})
|
||||||
|
|
||||||
|
const toRegisterOrBindViewModel = (backendData = {}) => ({
|
||||||
|
created: backendData.created === true,
|
||||||
|
bound: backendData.bound === true,
|
||||||
|
member: toMemberViewModel(backendData.member)
|
||||||
|
})
|
||||||
|
|
||||||
|
const toMemberProfileViewModel = (backendData = {}) => {
|
||||||
|
return toMemberViewModel(backendData, { withProfileFields: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录注册与绑定 API 接口组
|
||||||
|
export const api = {
|
||||||
|
// 1. 微信小程序登录 (用 code 换 token)
|
||||||
|
wechatLogin(code) {
|
||||||
|
return request({
|
||||||
|
url: '/api/auth/wechat-login',
|
||||||
|
method: 'POST',
|
||||||
|
data: { code }
|
||||||
|
}).then(toWechatLoginViewModel)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 2. 绑定或注册会员
|
||||||
|
registerOrBind(data) {
|
||||||
|
return request({
|
||||||
|
url: '/api/auth/register-or-bind',
|
||||||
|
method: 'POST',
|
||||||
|
data: toRegisterOrBindPayload(data)
|
||||||
|
}).then(toRegisterOrBindViewModel)
|
||||||
|
},
|
||||||
|
|
||||||
|
// 3. 获取当前登录会员信息
|
||||||
|
getMemberMe() {
|
||||||
|
return request({
|
||||||
|
url: '/api/members/me',
|
||||||
|
method: 'GET'
|
||||||
|
}).then(toMemberProfileViewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export { authStorage, request }
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
const KEY_TOKEN = 'ICE_MEMBER_TOKEN'
|
||||||
|
const KEY_TOKEN_EXPIRES_AT = 'ICE_MEMBER_TOKEN_EXPIRES_AT'
|
||||||
|
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_ICE_API_BASE_URL || 'http://127.0.0.1:8000'
|
||||||
|
|
||||||
|
export const authStorage = {
|
||||||
|
setToken(token, expiresIn) {
|
||||||
|
uni.setStorageSync(KEY_TOKEN, token)
|
||||||
|
if (expiresIn) {
|
||||||
|
uni.setStorageSync(KEY_TOKEN_EXPIRES_AT, Date.now() + Number(expiresIn) * 1000)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
getToken() {
|
||||||
|
return uni.getStorageSync(KEY_TOKEN)
|
||||||
|
},
|
||||||
|
isTokenExpired() {
|
||||||
|
const expiresAt = Number(uni.getStorageSync(KEY_TOKEN_EXPIRES_AT) || 0)
|
||||||
|
return expiresAt > 0 && Date.now() >= expiresAt
|
||||||
|
},
|
||||||
|
clear() {
|
||||||
|
uni.removeStorageSync(KEY_TOKEN)
|
||||||
|
uni.removeStorageSync(KEY_TOKEN_EXPIRES_AT)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeApiError = (rawError = {}) => {
|
||||||
|
const statusCode = rawError.statusCode
|
||||||
|
const body = rawError.data || rawError
|
||||||
|
const code = body?.code
|
||||||
|
const backendMessage = body?.message || ''
|
||||||
|
|
||||||
|
if (statusCode === 401 || code === 40001) {
|
||||||
|
return {
|
||||||
|
type: 'auth_expired',
|
||||||
|
code,
|
||||||
|
statusCode,
|
||||||
|
message: '登录状态已过期,请重新登录',
|
||||||
|
raw: rawError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 40002) {
|
||||||
|
return {
|
||||||
|
type: 'profile_required',
|
||||||
|
code,
|
||||||
|
statusCode,
|
||||||
|
message: '请先完善会员资料',
|
||||||
|
raw: rawError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (code === 40003) {
|
||||||
|
return {
|
||||||
|
type: 'bind_conflict',
|
||||||
|
code,
|
||||||
|
statusCode,
|
||||||
|
message: backendMessage || '该手机号已绑定其他微信账号,请联系场馆前台处理',
|
||||||
|
raw: rawError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!statusCode) {
|
||||||
|
return {
|
||||||
|
type: 'network_error',
|
||||||
|
code,
|
||||||
|
statusCode,
|
||||||
|
message: '网络连接失败,请检查后重试',
|
||||||
|
raw: rawError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode === 404 || code === 40400) {
|
||||||
|
return {
|
||||||
|
type: 'not_found',
|
||||||
|
code,
|
||||||
|
statusCode,
|
||||||
|
message: backendMessage || '未找到对应信息或无访问权限',
|
||||||
|
raw: rawError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'api_error',
|
||||||
|
code,
|
||||||
|
statusCode,
|
||||||
|
message: backendMessage || '服务暂时不可用,请稍后重试',
|
||||||
|
raw: rawError
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function request(options = {}) {
|
||||||
|
const token = authStorage.getToken()
|
||||||
|
const header = {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(options.header || {})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (token) {
|
||||||
|
header.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
uni.request({
|
||||||
|
url: options.url.startsWith('http') ? options.url : `${API_BASE_URL}${options.url}`,
|
||||||
|
method: options.method || 'GET',
|
||||||
|
data: options.data,
|
||||||
|
header,
|
||||||
|
success: (res) => {
|
||||||
|
const body = res.data
|
||||||
|
|
||||||
|
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||||
|
if (body && body.code === 0) {
|
||||||
|
resolve(body.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(normalizeApiError({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
data: body
|
||||||
|
}))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(normalizeApiError({
|
||||||
|
statusCode: res.statusCode,
|
||||||
|
data: body
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
fail: (err) => {
|
||||||
|
reject(normalizeApiError(err))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import { api, authStorage } from './member'
|
||||||
|
|
||||||
|
export const SESSION_STATUS = Object.freeze({
|
||||||
|
CHECKING: 'checking',
|
||||||
|
GUEST: 'guest',
|
||||||
|
EXPIRED: 'expired',
|
||||||
|
PROFILE_REQUIRED: 'profile_required',
|
||||||
|
MEMBER: 'member',
|
||||||
|
NETWORK_ERROR: 'network_error'
|
||||||
|
})
|
||||||
|
|
||||||
|
const createLoginCode = () => new Promise((resolve, reject) => {
|
||||||
|
uni.login({
|
||||||
|
provider: 'weixin',
|
||||||
|
success: (loginRes) => {
|
||||||
|
if (loginRes.code) {
|
||||||
|
resolve(loginRes.code)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
reject({
|
||||||
|
type: 'login_error',
|
||||||
|
message: '微信登录没有返回有效凭证,请重新尝试'
|
||||||
|
})
|
||||||
|
},
|
||||||
|
fail: () => {
|
||||||
|
reject({
|
||||||
|
type: 'login_error',
|
||||||
|
message: '微信登录失败,请稍后重试'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
export const loginAndStoreToken = async () => {
|
||||||
|
const code = await createLoginCode()
|
||||||
|
const data = await api.wechatLogin(code)
|
||||||
|
if (!data?.accessToken) {
|
||||||
|
throw {
|
||||||
|
type: 'login_error',
|
||||||
|
message: '登录失败,请稍后重试'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
authStorage.setToken(data.accessToken, data.expiresIn)
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getMemberSession = async () => {
|
||||||
|
const token = authStorage.getToken()
|
||||||
|
if (!token) {
|
||||||
|
return { status: SESSION_STATUS.GUEST }
|
||||||
|
}
|
||||||
|
|
||||||
|
if (authStorage.isTokenExpired()) {
|
||||||
|
authStorage.clear()
|
||||||
|
return {
|
||||||
|
status: SESSION_STATUS.EXPIRED,
|
||||||
|
message: '登录状态已过期,请重新登录'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const member = await api.getMemberMe()
|
||||||
|
return {
|
||||||
|
status: SESSION_STATUS.MEMBER,
|
||||||
|
member
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (err?.type === 'profile_required' || err?.code === 40002) {
|
||||||
|
return {
|
||||||
|
status: SESSION_STATUS.PROFILE_REQUIRED,
|
||||||
|
message: '请先完善会员资料'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (err?.type === 'auth_expired' || err?.code === 40001) {
|
||||||
|
authStorage.clear()
|
||||||
|
return {
|
||||||
|
status: SESSION_STATUS.EXPIRED,
|
||||||
|
message: '登录状态已过期,请重新登录'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: SESSION_STATUS.NETWORK_ERROR,
|
||||||
|
message: err?.message || '暂时无法连接服务,请稍后重试',
|
||||||
|
error: err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSessionBlockingError = (err) => {
|
||||||
|
return ['auth_expired', 'profile_required'].includes(err?.type) || [40001, 40002].includes(err?.code)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sessionStatusFromError = (err) => {
|
||||||
|
if (err?.type === 'auth_expired' || err?.code === 40001) {
|
||||||
|
authStorage.clear()
|
||||||
|
return SESSION_STATUS.EXPIRED
|
||||||
|
}
|
||||||
|
if (err?.type === 'profile_required' || err?.code === 40002) {
|
||||||
|
return SESSION_STATUS.PROFILE_REQUIRED
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const errorMessage = (err, fallback = '操作失败,请稍后重试') => {
|
||||||
|
return err?.message || fallback
|
||||||
|
}
|
||||||
@@ -136,6 +136,71 @@
|
|||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "个人信息"
|
"navigationBarTitleText": "个人信息"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/home/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "首页",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/courses/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "近期课程",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/bookings/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的预约",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/profile/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "会员中心"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/bookings/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "预约详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/courses/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "课程详情"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/profile/cards",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "我的会员卡",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/profile/card-history",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "会员卡使用流水",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/profile/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "个人信息"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/activities/detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "活动详情"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
After Width: | Height: | Size: 433 B |
|
After Width: | Height: | Size: 429 B |
|
After Width: | Height: | Size: 350 B |
|
After Width: | Height: | Size: 347 B |
|
After Width: | Height: | Size: 496 B |
|
After Width: | Height: | Size: 493 B |
|
After Width: | Height: | Size: 469 B |
|
After Width: | Height: | Size: 466 B |