Compare commits
10 Commits
1cc96633ad
...
0582a5f6a8
| Author | SHA1 | Date | |
|---|---|---|---|
| 0582a5f6a8 | |||
| 6a18d3de61 | |||
| 5487ece142 | |||
| b06cf69942 | |||
| 5c97e8b1f8 | |||
| 9b38518426 | |||
| f85c3300be | |||
| c26ede3e03 | |||
| df1e2fe267 | |||
| 4b11447875 |
@@ -1,2 +1,2 @@
|
||||
VITE_ICE_API_BASE_URL=https://venue.test.chatgqt.top
|
||||
VITE_COMPREHENSIVE_API_BASE_URL=https://venue2.test.chatgqt.top
|
||||
VITE_ICE_API_BASE_URL=https://8002.frp.chatgqt.top
|
||||
VITE_COMPREHENSIVE_API_BASE_URL=https://8002.frp.chatgqt.top
|
||||
@@ -10,15 +10,29 @@ const expectIncludes = (content, expected, label) => {
|
||||
if (!content.includes(expected)) failures.push(`${label} is missing: ${expected}`)
|
||||
}
|
||||
|
||||
const expectExcludes = (content, unexpected, label) => {
|
||||
if (content.includes(unexpected)) failures.push(`${label} should not contain: ${unexpected}`)
|
||||
}
|
||||
|
||||
const membershipApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/membership.js'), 'utf8')
|
||||
const storedValueApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/storedValue.js'), 'utf8')
|
||||
const pagesJson = readFileSync(resolve(srcRoot, 'pages.json'), 'utf8')
|
||||
const mallPage = readFileSync(resolve(srcRoot, 'pages/comprehensive/mall/index.vue'), 'utf8')
|
||||
const availablePage = readFileSync(resolve(srcRoot, 'pages/comprehensive/membership/available-sessions.vue'), 'utf8')
|
||||
const bookingDetail = readFileSync(resolve(srcRoot, 'pages/comprehensive/membership/booking-detail.vue'), 'utf8')
|
||||
const profilePage = readFileSync(resolve(srcRoot, 'pages/comprehensive/profile/index.vue'), 'utf8')
|
||||
const orderPage = readFileSync(resolve(srcRoot, 'pages/comprehensive/profile/orders.vue'), 'utf8')
|
||||
const payPage = readFileSync(resolve(srcRoot, 'pages/comprehensive/pay/index.vue'), 'utf8')
|
||||
|
||||
expectIncludes(membershipApi, "url: '/api/membership/plans'", 'membership plans')
|
||||
expectIncludes(membershipApi, "url: '/api/membership/orders'", 'membership order')
|
||||
expectIncludes(membershipApi, 'data: { plan_id: planId }', 'membership payload')
|
||||
expectIncludes(membershipApi, 'toOrderBrief(data.order)', 'membership order mapping')
|
||||
expectIncludes(membershipApi, "url: '/api/membership/cards'", 'member cards')
|
||||
expectIncludes(membershipApi, 'cards/${cardId}/usage-records', 'card usage records')
|
||||
expectIncludes(membershipApi, "url: '/api/membership/available-courses'", 'available sessions')
|
||||
expectIncludes(membershipApi, "url: '/api/membership/bookings'", 'card bookings')
|
||||
expectIncludes(membershipApi, 'data: { card_id: cardId, session_id: sessionId }', 'card booking payload')
|
||||
expectIncludes(storedValueApi, "url: '/api/stored-value/cards'", 'stored value cards')
|
||||
expectIncludes(storedValueApi, "url: '/api/stored-value/orders'", 'stored value order')
|
||||
expectIncludes(storedValueApi, 'data: { card_id: cardId }', 'stored value payload')
|
||||
@@ -26,6 +40,16 @@ expectIncludes(storedValueApi, 'toOrderBrief(data.order)', 'stored value order m
|
||||
expectIncludes(pagesJson, 'pages/comprehensive/mall/index', 'mall route')
|
||||
expectIncludes(mallPage, '储蓄卡', 'stored value UI copy')
|
||||
expectIncludes(mallPage, 'face_value', 'face value display')
|
||||
expectIncludes(availablePage, 'available_card_ids', 'available card selection')
|
||||
expectExcludes(bookingDetail, 'booking.order', 'card booking has no order')
|
||||
|
||||
expectIncludes(profilePage, '/pages/comprehensive/mall/index', 'mall entry')
|
||||
expectIncludes(profilePage, '/pages/comprehensive/membership/cards', 'my cards entry')
|
||||
expectIncludes(profilePage, '/pages/comprehensive/profile/balance', 'balance records entry')
|
||||
expectIncludes(profilePage, '/pages/comprehensive/profile/orders', 'consumption records entry')
|
||||
expectIncludes(orderPage, "value: 'membership'", 'membership filter')
|
||||
expectIncludes(orderPage, "value: 'stored_value'", 'stored value filter')
|
||||
expectIncludes(payPage, "order.value?.business_type === 'stored_value'", 'stored value payment guard')
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`Comprehensive commerce verification failed with ${failures.length} issue(s):`)
|
||||
|
||||
@@ -16,6 +16,10 @@ const expectExcludes = (content, unexpected, label) => {
|
||||
|
||||
const courseApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/course.js'), 'utf8')
|
||||
const bookingApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/booking.js'), 'utf8')
|
||||
const comprehensiveHome = readFileSync(resolve(srcRoot, 'pages/comprehensive/home/index.vue'), 'utf8')
|
||||
const comprehensiveContentApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/content.js'), 'utf8')
|
||||
const iceHome = readFileSync(resolve(srcRoot, 'pages/ice/home/index.vue'), 'utf8')
|
||||
const iceHomeApi = readFileSync(resolve(srcRoot, 'modules/ice/api/home.js'), 'utf8')
|
||||
|
||||
expectIncludes(courseApi, "url: '/api/sessions'", 'session list endpoint')
|
||||
expectIncludes(courseApi, 'url: `/api/sessions/${id}`', 'session detail endpoint')
|
||||
@@ -25,6 +29,15 @@ expectIncludes(bookingApi, 'session_id: backendBooking.session_id', 'booking ses
|
||||
expectIncludes(bookingApi, 'session: toBookingSession(backendBooking.session)', 'booking session mapper')
|
||||
expectExcludes(bookingApi, 'course: toBookingCourse(backendBooking.course)', 'legacy booking course')
|
||||
|
||||
expectExcludes(comprehensiveHome, '近期课程', 'comprehensive recent courses section')
|
||||
expectExcludes(comprehensiveHome, 'upcomingCourses', 'comprehensive recent course state')
|
||||
expectExcludes(comprehensiveContentApi, 'upcoming_courses', 'legacy comprehensive home mapping')
|
||||
expectExcludes(comprehensiveContentApi, 'upcoming_sessions', 'unused comprehensive home mapping')
|
||||
expectExcludes(iceHome, '近期课程', 'ice recent courses section')
|
||||
expectExcludes(iceHome, 'upcomingCourses', 'ice recent course state')
|
||||
expectExcludes(iceHomeApi, 'course_limit', 'ice recent course request')
|
||||
expectExcludes(iceHomeApi, 'upcoming_courses', 'ice recent course mapping')
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`Comprehensive session contract verification failed with ${failures.length} issue(s):`)
|
||||
for (const failure of failures) console.error(`- ${failure}`)
|
||||
|
||||
@@ -6,57 +6,82 @@ const root = resolve(dirname(fileURLToPath(import.meta.url)), '..')
|
||||
const srcRoot = resolve(root, 'src')
|
||||
|
||||
const tabPages = [
|
||||
{ file: 'pages/ice/home/index.vue', venue: 'ice', active: 'home' },
|
||||
{ file: 'pages/ice/courses/index.vue', venue: 'ice', active: 'courses' },
|
||||
{ file: 'pages/ice/bookings/index.vue', venue: 'ice', active: 'bookings' },
|
||||
{ file: 'pages/ice/profile/index.vue', venue: 'ice', active: 'profile' },
|
||||
{ file: 'pages/comprehensive/home/index.vue', venue: 'comprehensive', active: 'home' },
|
||||
{ file: 'pages/comprehensive/courses/index.vue', venue: 'comprehensive', active: 'courses' },
|
||||
{ file: 'pages/comprehensive/bookings/index.vue', venue: 'comprehensive', active: 'bookings' },
|
||||
{ file: 'pages/comprehensive/profile/index.vue', venue: 'comprehensive', active: 'profile' }
|
||||
{ file: 'pages/home/index.vue' },
|
||||
{ file: 'pages/private/index.vue' },
|
||||
{ file: 'pages/mall/index.vue' },
|
||||
{ file: 'pages/courses/index.vue' },
|
||||
{ file: 'pages/profile/index.vue' }
|
||||
]
|
||||
|
||||
const icons = ['home', 'course', 'booking', 'mine']
|
||||
const venues = ['ice', 'comprehensive']
|
||||
const tabBarPages = [
|
||||
'pages/home/index',
|
||||
'pages/private/index',
|
||||
'pages/mall/index',
|
||||
'pages/courses/index',
|
||||
'pages/profile/index'
|
||||
]
|
||||
|
||||
const icons = ['home', 'course', 'mine', 'mall', 'private']
|
||||
const failures = []
|
||||
|
||||
const assertFile = (path) => {
|
||||
if (!existsSync(path)) failures.push(`Missing file: ${path}`)
|
||||
const pagesJson = JSON.parse(readFileSync(resolve(srcRoot, 'pages.json'), 'utf8'))
|
||||
|
||||
if (!pagesJson.tabBar) {
|
||||
failures.push('pages.json missing tabBar configuration')
|
||||
} else {
|
||||
const tabPaths = pagesJson.tabBar.list.map(item => item.pagePath)
|
||||
for (const page of tabBarPages) {
|
||||
if (!tabPaths.includes(page)) {
|
||||
failures.push(`tabBar missing page: ${page}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
assertFile(resolve(srcRoot, 'components/VenueTabBar.vue'))
|
||||
|
||||
for (const { file, venue, active } of tabPages) {
|
||||
for (const { file } of tabPages) {
|
||||
const path = resolve(srcRoot, file)
|
||||
assertFile(path)
|
||||
if (!existsSync(path)) continue
|
||||
|
||||
const content = readFileSync(path, 'utf8')
|
||||
if (!content.includes('<VenueTabBar')) {
|
||||
failures.push(`${file} does not render <VenueTabBar>`)
|
||||
}
|
||||
if (!content.includes(`venue="${venue}"`)) {
|
||||
failures.push(`${file} does not pass venue="${venue}"`)
|
||||
}
|
||||
if (!content.includes(`active="${active}"`)) {
|
||||
failures.push(`${file} does not pass active="${active}"`)
|
||||
}
|
||||
if (!content.includes("import VenueTabBar from '@/components/VenueTabBar.vue'")) {
|
||||
failures.push(`${file} does not import VenueTabBar`)
|
||||
if (!existsSync(path)) {
|
||||
failures.push(`Missing tab page: ${file}`)
|
||||
}
|
||||
}
|
||||
|
||||
for (const venue of venues) {
|
||||
for (const icon of icons) {
|
||||
assertFile(resolve(srcRoot, `static/${venue}/tab/${icon}.png`))
|
||||
assertFile(resolve(srcRoot, `static/${venue}/tab/${icon}-active.png`))
|
||||
for (const icon of icons) {
|
||||
const iconPath = resolve(srcRoot, `static/comprehensive/tab/${icon}.png`)
|
||||
const activeIconPath = resolve(srcRoot, `static/comprehensive/tab/${icon}-active.png`)
|
||||
if (!existsSync(iconPath)) failures.push(`Missing icon: static/comprehensive/tab/${icon}.png`)
|
||||
if (!existsSync(activeIconPath)) failures.push(`Missing icon: static/comprehensive/tab/${icon}-active.png`)
|
||||
}
|
||||
|
||||
const venuePages = [
|
||||
'pages/comprehensive/home/index.vue',
|
||||
'pages/comprehensive/courses/index.vue',
|
||||
'pages/comprehensive/profile/index.vue',
|
||||
'pages/ice/home/index.vue',
|
||||
'pages/ice/courses/index.vue',
|
||||
'pages/ice/profile/index.vue',
|
||||
'pages/ice/private/index.vue',
|
||||
'pages/ice/mall/index.vue'
|
||||
]
|
||||
|
||||
for (const file of venuePages) {
|
||||
const path = resolve(srcRoot, file)
|
||||
if (!existsSync(path)) {
|
||||
failures.push(`Missing venue page: ${file}`)
|
||||
continue
|
||||
}
|
||||
const content = readFileSync(path, 'utf8')
|
||||
if (content.includes('VenueTabBar')) {
|
||||
failures.push(`${file} still references VenueTabBar`)
|
||||
}
|
||||
}
|
||||
|
||||
if (existsSync(resolve(srcRoot, 'components/VenueTabBar.vue'))) {
|
||||
failures.push('VenueTabBar.vue should be deleted')
|
||||
}
|
||||
|
||||
if (failures.length) {
|
||||
console.error(`Venue tabbar verification failed with ${failures.length} issue(s):`)
|
||||
console.error(`TabBar verification failed with ${failures.length} issue(s):`)
|
||||
for (const failure of failures) console.error(`- ${failure}`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
console.log('Venue tabbar verification passed.')
|
||||
console.log('TabBar verification passed.')
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
<template>
|
||||
<view class="venue-switch-header" @tap="switchVenue">
|
||||
<view class="venue-switch-main">
|
||||
<text class="venue-switch-kicker">当前场馆</text>
|
||||
<text class="venue-switch-name">{{ venueName }}</text>
|
||||
</view>
|
||||
<view class="venue-switch-action">
|
||||
<text class="venue-switch-action__text">切换场馆</text>
|
||||
<view class="venue-switch-action__arrow" />
|
||||
</view>
|
||||
<text class="venue-name">{{ venueName }}</text>
|
||||
<view class="venue-arrow" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -26,74 +20,30 @@ const switchVenue = () => {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.venue-switch-header {
|
||||
min-height: 96rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-md;
|
||||
padding: 18rpx 20rpx;
|
||||
border: 1rpx solid rgba(226, 232, 240, 0.96);
|
||||
padding: $spacing-md $spacing-lg;
|
||||
border-radius: $border-radius-base;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 10rpx 28rpx rgba(15, 23, 42, 0.04);
|
||||
background: $bg-color-main;
|
||||
border: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.venue-switch-header:active {
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.venue-switch-main {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.venue-switch-kicker {
|
||||
color: $text-color-light;
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.venue-switch-name {
|
||||
overflow: hidden;
|
||||
.venue-name {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.25;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.venue-switch-action {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
height: 56rpx;
|
||||
padding: 0 16rpx;
|
||||
border: 1rpx solid $border-color;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $bg-color-soft;
|
||||
color: $text-color-regular;
|
||||
}
|
||||
|
||||
.venue-switch-action__text,
|
||||
.venue-switch-action__arrow {
|
||||
color: $text-color-regular;
|
||||
font-size: 23rpx;
|
||||
font-weight: 800;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.venue-switch-action__arrow {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-top: 2rpx solid $text-color-muted;
|
||||
border-right: 2rpx solid $text-color-muted;
|
||||
.venue-arrow {
|
||||
width: 16rpx;
|
||||
height: 16rpx;
|
||||
border-top: 3rpx solid $text-color-light;
|
||||
border-right: 3rpx solid $text-color-light;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
<template>
|
||||
<view class="venue-tabbar-wrap">
|
||||
<view class="venue-tabbar-spacer" />
|
||||
<view class="venue-tabbar">
|
||||
<view
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
class="venue-tabbar__item"
|
||||
:class="{ active: item.key === active }"
|
||||
@tap="go(item)"
|
||||
>
|
||||
<image
|
||||
class="venue-tabbar__icon"
|
||||
:src="item.key === active ? item.activeIcon : item.icon"
|
||||
mode="aspectFit"
|
||||
/>
|
||||
<text class="venue-tabbar__label">{{ item.label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { setStoredVenue } from '@/utils/venue'
|
||||
|
||||
const props = defineProps({
|
||||
venue: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['ice', 'comprehensive'].includes(value)
|
||||
},
|
||||
active: {
|
||||
type: String,
|
||||
required: true,
|
||||
validator: (value) => ['home', 'courses', 'bookings', 'profile'].includes(value)
|
||||
}
|
||||
})
|
||||
|
||||
const tabDefinitions = [
|
||||
{ key: 'home', label: '首页', icon: 'home', path: 'home' },
|
||||
{ key: 'courses', label: '课程', icon: 'course', path: 'courses' },
|
||||
{ key: 'bookings', label: '预约', icon: 'booking', path: 'bookings' },
|
||||
{ key: 'profile', label: '我的', icon: 'mine', path: 'profile' }
|
||||
]
|
||||
|
||||
const items = computed(() => {
|
||||
return tabDefinitions.map((item) => ({
|
||||
...item,
|
||||
icon: `/static/${props.venue}/tab/${item.icon}.png`,
|
||||
activeIcon: `/static/${props.venue}/tab/${item.icon}-active.png`,
|
||||
url: `/pages/${props.venue}/${item.path}/index`
|
||||
}))
|
||||
})
|
||||
|
||||
const go = (item) => {
|
||||
setStoredVenue(props.venue)
|
||||
if (item.key === props.active) return
|
||||
|
||||
uni.reLaunch({ url: item.url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.venue-tabbar-spacer {
|
||||
height: calc(124rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.venue-tabbar {
|
||||
position: fixed;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 1000;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
padding: 10rpx 0 calc(10rpx + env(safe-area-inset-bottom));
|
||||
border-top: 1rpx solid $border-color;
|
||||
background: rgba(255, 255, 255, 0.98);
|
||||
box-shadow: 0 -8rpx 24rpx rgba(15, 23, 42, 0.06);
|
||||
}
|
||||
|
||||
.venue-tabbar__item {
|
||||
min-width: 0;
|
||||
height: 104rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6rpx;
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.venue-tabbar__item.active {
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.venue-tabbar__icon {
|
||||
width: 46rpx;
|
||||
height: 46rpx;
|
||||
}
|
||||
|
||||
.venue-tabbar__label {
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -10,6 +10,7 @@ const toMember = (backendMember = {}) => ({
|
||||
avatar_url: backendMember.avatar_url || '',
|
||||
balance: backendMember.balance || '0.00',
|
||||
is_active: backendMember.is_active === true,
|
||||
profile_completed: backendMember.profile_completed === true,
|
||||
created_at: backendMember.created_at || ''
|
||||
})
|
||||
|
||||
@@ -33,7 +34,7 @@ export const authApi = {
|
||||
}
|
||||
}).then(toWechatLogin)
|
||||
|
||||
authStorage.setToken(loginData.access_token)
|
||||
authStorage.setPendingToken(loginData.access_token)
|
||||
return loginData
|
||||
},
|
||||
logout() {
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { request } from './request'
|
||||
import { compactParams, toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
||||
import { toSession } from './course'
|
||||
|
||||
const toBanner = (backendBanner = {}) => ({
|
||||
id: backendBanner.id ?? null,
|
||||
@@ -22,7 +21,6 @@ const toArticle = (backendArticle = {}) => ({
|
||||
const toHomeContent = (backendData = {}) => ({
|
||||
banners: toArray(backendData.banners).map(toBanner),
|
||||
articles: toArray(backendData.articles).map(toArticle),
|
||||
upcoming_courses: toArray(backendData.upcoming_courses).map(toSession),
|
||||
venue: {
|
||||
name: backendData.venue?.name || ''
|
||||
}
|
||||
|
||||
@@ -25,6 +25,9 @@ export const toSession = (backendSession = {}) => ({
|
||||
status_label: backendSession.status_label || backendSession.status || '',
|
||||
courts: toArray(backendSession.courts).map(toCourseCourt),
|
||||
detail_html: backendSession.detail_html || '',
|
||||
booked: backendSession.booked === true,
|
||||
booking_id: backendSession.booking_id ?? null,
|
||||
booking_type: backendSession.booking_type || null,
|
||||
created_at: backendSession.created_at || '',
|
||||
updated_at: backendSession.updated_at || ''
|
||||
})
|
||||
|
||||
@@ -10,6 +10,7 @@ const toMember = (backendMember = {}) => ({
|
||||
avatar_url: backendMember.avatar_url || '',
|
||||
balance: backendMember.balance || '0.00',
|
||||
is_active: backendMember.is_active === true,
|
||||
profile_completed: backendMember.profile_completed === true,
|
||||
created_at: backendMember.created_at || ''
|
||||
})
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { request } from './request'
|
||||
import { toArray, toNumber } from './mappers'
|
||||
import { toArray, toNumber, toPageQuery, toPagination, compactParams } from './mappers'
|
||||
import { toOrderBrief } from './order'
|
||||
|
||||
const toPlan = (item = {}) => ({
|
||||
@@ -15,6 +15,82 @@ const toPlan = (item = {}) => ({
|
||||
created_at: item.created_at || ''
|
||||
})
|
||||
|
||||
const toCard = (backendCard = {}) => ({
|
||||
id: backendCard.id ?? null,
|
||||
plan_id: backendCard.plan_id ?? null,
|
||||
plan_name: backendCard.plan_name || '',
|
||||
total_count: toNumber(backendCard.total_count),
|
||||
remaining_count: toNumber(backendCard.remaining_count),
|
||||
status: backendCard.status || '',
|
||||
status_label: backendCard.status_label || backendCard.status || '',
|
||||
order_id: backendCard.order_id ?? null,
|
||||
created_at: backendCard.created_at || ''
|
||||
})
|
||||
|
||||
const toUsageRecord = (backendRecord = {}) => ({
|
||||
id: backendRecord.id ?? null,
|
||||
card_id: backendRecord.card_id ?? null,
|
||||
count: toNumber(backendRecord.count),
|
||||
kind: backendRecord.kind || '',
|
||||
kind_label: backendRecord.kind_label || backendRecord.kind || '',
|
||||
booking_id: backendRecord.booking_id ?? null,
|
||||
order_id: backendRecord.order_id ?? null,
|
||||
remark: backendRecord.remark || '',
|
||||
created_at: backendRecord.created_at || ''
|
||||
})
|
||||
|
||||
const toAvailableSession = (backendSession = {}) => ({
|
||||
id: backendSession.id ?? null,
|
||||
course_id: backendSession.course_id ?? null,
|
||||
title: backendSession.title || '',
|
||||
cover_image: backendSession.cover_image || '',
|
||||
coach_name: backendSession.coach_name || '',
|
||||
start_at: backendSession.start_at || '',
|
||||
end_at: backendSession.end_at || '',
|
||||
capacity: toNumber(backendSession.capacity),
|
||||
booked_count: toNumber(backendSession.booked_count),
|
||||
remaining_count: toNumber(backendSession.remaining_count),
|
||||
price: backendSession.price || '0.00',
|
||||
available_card_ids: toArray(backendSession.available_card_ids).map(toNumber),
|
||||
booked: backendSession.booked === true,
|
||||
booking_id: backendSession.booking_id ?? null,
|
||||
booking_type: backendSession.booking_type || null
|
||||
})
|
||||
|
||||
const toCardBookingSession = (backendSession = {}) => ({
|
||||
id: backendSession.id ?? null,
|
||||
course_id: backendSession.course_id ?? null,
|
||||
title: backendSession.title || '',
|
||||
cover_image: backendSession.cover_image || '',
|
||||
coach_name: backendSession.coach_name || '',
|
||||
start_at: backendSession.start_at || '',
|
||||
end_at: backendSession.end_at || '',
|
||||
capacity: toNumber(backendSession.capacity),
|
||||
booked_count: toNumber(backendSession.booked_count),
|
||||
remaining_count: toNumber(backendSession.remaining_count),
|
||||
price: backendSession.price || '0.00'
|
||||
})
|
||||
|
||||
const toCardBookingCard = (backendCard = {}) => ({
|
||||
id: backendCard.id ?? null,
|
||||
plan_name: backendCard.plan_name || '',
|
||||
remaining_count: toNumber(backendCard.remaining_count)
|
||||
})
|
||||
|
||||
const toCardBooking = (backendBooking = {}) => ({
|
||||
id: backendBooking.id ?? null,
|
||||
card_id: backendBooking.card_id ?? null,
|
||||
session_id: backendBooking.session_id ?? null,
|
||||
status: backendBooking.status || '',
|
||||
status_label: backendBooking.status_label || backendBooking.status || '',
|
||||
checked_in_at: backendBooking.checked_in_at || null,
|
||||
cancelled_at: backendBooking.cancelled_at || null,
|
||||
cancel_reason: backendBooking.cancel_reason || '',
|
||||
created_at: backendBooking.created_at || '',
|
||||
card: toCardBookingCard(backendBooking.card),
|
||||
session: toCardBookingSession(backendBooking.session)
|
||||
})
|
||||
|
||||
export const membershipApi = {
|
||||
getPlans() {
|
||||
return request({
|
||||
@@ -27,5 +103,44 @@ export const membershipApi = {
|
||||
method: 'POST',
|
||||
data: { plan_id: planId }
|
||||
}).then((data) => toOrderBrief(data.order))
|
||||
},
|
||||
getCards(params = {}) {
|
||||
return request({
|
||||
url: '/api/membership/cards',
|
||||
data: toPageQuery(params)
|
||||
}).then((data) => toPagination(data, toCard))
|
||||
},
|
||||
getUsageRecords(cardId, params = {}) {
|
||||
return request({
|
||||
url: `/api/membership/cards/${cardId}/usage-records`,
|
||||
data: toPageQuery(params)
|
||||
}).then((data) => toPagination(data, toUsageRecord))
|
||||
},
|
||||
getAvailableSessions(params = {}) {
|
||||
return request({
|
||||
url: '/api/membership/available-courses',
|
||||
data: compactParams({
|
||||
...toPageQuery(params),
|
||||
date: params.date
|
||||
})
|
||||
}).then((data) => toPagination(data, toAvailableSession))
|
||||
},
|
||||
createCardBooking({ cardId, sessionId }) {
|
||||
return request({
|
||||
url: '/api/membership/bookings',
|
||||
method: 'POST',
|
||||
data: { card_id: cardId, session_id: sessionId }
|
||||
}).then(toCardBooking)
|
||||
},
|
||||
getCardBookings(params = {}) {
|
||||
return request({
|
||||
url: '/api/membership/bookings',
|
||||
data: toPageQuery(params)
|
||||
}).then((data) => toPagination(data, toCardBooking))
|
||||
},
|
||||
getCardBookingDetail(bookingId) {
|
||||
return request({
|
||||
url: `/api/membership/bookings/${bookingId}`
|
||||
}).then(toCardBooking)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,8 @@ const KEY_TOKEN = 'COMPREHENSIVE_VENUE_TOKEN'
|
||||
|
||||
const API_BASE_URL = import.meta.env.VITE_COMPREHENSIVE_API_BASE_URL || 'http://127.0.0.1:8001'
|
||||
|
||||
let pendingToken = null
|
||||
|
||||
const normalizeError = (res, fallbackMessage = '请求服务失败') => {
|
||||
const body = res?.data || res || {}
|
||||
return {
|
||||
@@ -19,16 +21,41 @@ export const authStorage = {
|
||||
getToken() {
|
||||
return uni.getStorageSync(KEY_TOKEN)
|
||||
},
|
||||
setPendingToken(token) {
|
||||
pendingToken = token
|
||||
},
|
||||
getPendingToken() {
|
||||
return pendingToken
|
||||
},
|
||||
clearPendingToken() {
|
||||
pendingToken = null
|
||||
},
|
||||
clear() {
|
||||
uni.removeStorageSync(KEY_TOKEN)
|
||||
pendingToken = null
|
||||
},
|
||||
isLoggedIn() {
|
||||
return !!uni.getStorageSync(KEY_TOKEN)
|
||||
}
|
||||
}
|
||||
|
||||
let isRedirecting = false
|
||||
|
||||
function redirectToLogin() {
|
||||
if (isRedirecting) return
|
||||
const pages = getCurrentPages()
|
||||
const current = pages[pages.length - 1]
|
||||
const currentRoute = current?.route || ''
|
||||
if (currentRoute === 'pages/comprehensive/profile/login' || currentRoute === 'pages/comprehensive/profile/setup') {
|
||||
return
|
||||
}
|
||||
isRedirecting = true
|
||||
authStorage.clear()
|
||||
uni.reLaunch({ url: '/pages/comprehensive/profile/login', complete: () => { isRedirecting = false } })
|
||||
}
|
||||
|
||||
export function request(options = {}) {
|
||||
const token = authStorage.getToken()
|
||||
const token = authStorage.getToken() || authStorage.getPendingToken()
|
||||
const header = {
|
||||
'Content-Type': 'application/json',
|
||||
...(options.header || {})
|
||||
@@ -48,7 +75,9 @@ export function request(options = {}) {
|
||||
const body = res.data
|
||||
|
||||
if (res.statusCode === 401 || body?.code === 40001) {
|
||||
authStorage.clear()
|
||||
redirectToLogin()
|
||||
reject(normalizeError(res, ''))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
|
||||
@@ -76,7 +105,7 @@ export function request(options = {}) {
|
||||
}
|
||||
|
||||
export function uploadFile(options = {}) {
|
||||
const token = authStorage.getToken()
|
||||
const token = authStorage.getToken() || authStorage.getPendingToken()
|
||||
const header = {
|
||||
...(options.header || {})
|
||||
}
|
||||
@@ -101,7 +130,9 @@ export function uploadFile(options = {}) {
|
||||
}
|
||||
|
||||
if (res.statusCode === 401 || body?.code === 40001) {
|
||||
authStorage.clear()
|
||||
redirectToLogin()
|
||||
reject(normalizeError({ ...res, data: body }, ''))
|
||||
return
|
||||
}
|
||||
|
||||
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
|
||||
|
||||
@@ -33,8 +33,7 @@ const toTicketAccount = (backendAccount = {}) => ({
|
||||
|
||||
const toTicketOrder = (backendData = {}) => ({
|
||||
order: toOrderBrief(backendData.order),
|
||||
product: toTicketProduct(backendData.product),
|
||||
quantity: toNumber(backendData.quantity)
|
||||
product: toTicketProduct(backendData.product)
|
||||
})
|
||||
|
||||
export const ticketApi = {
|
||||
@@ -49,11 +48,10 @@ export const ticketApi = {
|
||||
data: toPageQuery(params)
|
||||
}).then(toTicketAccount)
|
||||
},
|
||||
createTicketOrder(quantity) {
|
||||
createTicketOrder() {
|
||||
return request({
|
||||
url: '/api/tickets/orders',
|
||||
method: 'POST',
|
||||
data: { quantity }
|
||||
method: 'POST'
|
||||
}).then(toTicketOrder)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ export const useComprehensiveSessionStore = defineStore('comprehensiveVenueSessi
|
||||
state: () => ({
|
||||
isReady: false,
|
||||
isLoggedIn: false,
|
||||
needsSetup: false,
|
||||
member: null
|
||||
}),
|
||||
actions: {
|
||||
@@ -28,12 +29,23 @@ export const useComprehensiveSessionStore = defineStore('comprehensiveVenueSessi
|
||||
const loginResult = await loginByWechat()
|
||||
const data = await authApi.wechatLogin({ code: loginResult.code })
|
||||
this.member = data.member
|
||||
this.isLoggedIn = true
|
||||
this.needsSetup = true
|
||||
return data
|
||||
},
|
||||
completeLogin() {
|
||||
const pendingToken = authStorage.getPendingToken()
|
||||
if (pendingToken) {
|
||||
authStorage.setToken(pendingToken)
|
||||
authStorage.clearPendingToken()
|
||||
this.isLoggedIn = true
|
||||
this.needsSetup = false
|
||||
}
|
||||
},
|
||||
logout() {
|
||||
authApi.logout()
|
||||
this.member = null
|
||||
this.isLoggedIn = false
|
||||
this.needsSetup = false
|
||||
},
|
||||
async refreshMember() {
|
||||
if (!authStorage.isLoggedIn()) return
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { request } from './request'
|
||||
import { toCourseViewModel } from './course'
|
||||
|
||||
const toNumber = (value) => {
|
||||
const numberValue = Number(value)
|
||||
@@ -24,9 +23,6 @@ const toActivitySummary = (backendActivity = {}) => ({
|
||||
|
||||
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 || '奥林匹克滑冰中心',
|
||||
@@ -36,13 +32,11 @@ const toHomeViewModel = (backendData = {}) => ({
|
||||
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',
|
||||
|
||||
@@ -12,29 +12,61 @@
|
||||
"path": "pages/venue-select/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "选择场馆",
|
||||
"navigationStyle": "custom",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
"navigationBarTextStyle": "black"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/home/index",
|
||||
"path": "pages/home/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "首页",
|
||||
"navigationStyle": "custom",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/private/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订场"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/mall/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商城",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/courses/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "约课",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/profile/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/home/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "首页"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/courses/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "课程",
|
||||
"enablePullDownRefresh": true
|
||||
"navigationBarTitleText": "约课"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/bookings/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的预约",
|
||||
"enablePullDownRefresh": true
|
||||
"navigationBarTitleText": "我的预约"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -62,6 +94,12 @@
|
||||
"navigationBarTitleText": "课程详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/courses/membership-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "次卡预约"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/courses/confirm",
|
||||
"style": {
|
||||
@@ -71,7 +109,7 @@
|
||||
{
|
||||
"path": "pages/comprehensive/private/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "包场预约"
|
||||
"navigationBarTitleText": "订场"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -137,6 +175,12 @@
|
||||
"navigationBarTitleText": "个人信息"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/profile/login",
|
||||
"style": {
|
||||
"navigationBarTitleText": "登录"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/mall/index",
|
||||
"style": {
|
||||
@@ -144,18 +188,62 @@
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/mall/detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商品详情"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/membership/cards",
|
||||
"style": {
|
||||
"navigationBarTitleText": "我的次卡",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/membership/card-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "次卡详情",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/membership/available-sessions",
|
||||
"style": {
|
||||
"navigationBarTitleText": "可约排课",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/comprehensive/membership/booking-detail",
|
||||
"style": {
|
||||
"navigationBarTitleText": "次卡预约详情",
|
||||
"enablePullDownRefresh": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/ice/home/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "首页",
|
||||
"enablePullDownRefresh": true
|
||||
"navigationBarTitleText": "首页"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/ice/private/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "订场"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/ice/mall/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "商城"
|
||||
}
|
||||
},
|
||||
{
|
||||
"path": "pages/ice/courses/index",
|
||||
"style": {
|
||||
"navigationBarTitleText": "近期课程",
|
||||
"enablePullDownRefresh": true
|
||||
"navigationBarTitleText": "近期课程"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -210,6 +298,44 @@
|
||||
}
|
||||
}
|
||||
],
|
||||
"tabBar": {
|
||||
"color": "#6D7C84",
|
||||
"selectedColor": "#0B8FB3",
|
||||
"backgroundColor": "#FFFFFF",
|
||||
"borderStyle": "white",
|
||||
"list": [
|
||||
{
|
||||
"pagePath": "pages/home/index",
|
||||
"text": "首页",
|
||||
"iconPath": "static/comprehensive/tab/home.png",
|
||||
"selectedIconPath": "static/comprehensive/tab/home-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/private/index",
|
||||
"text": "订场",
|
||||
"iconPath": "static/comprehensive/tab/private.png",
|
||||
"selectedIconPath": "static/comprehensive/tab/private-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/mall/index",
|
||||
"text": "商城",
|
||||
"iconPath": "static/comprehensive/tab/mall.png",
|
||||
"selectedIconPath": "static/comprehensive/tab/mall-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/courses/index",
|
||||
"text": "约课",
|
||||
"iconPath": "static/comprehensive/tab/course.png",
|
||||
"selectedIconPath": "static/comprehensive/tab/course-active.png"
|
||||
},
|
||||
{
|
||||
"pagePath": "pages/profile/index",
|
||||
"text": "我的",
|
||||
"iconPath": "static/comprehensive/tab/mine.png",
|
||||
"selectedIconPath": "static/comprehensive/tab/mine-active.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
"globalStyle": {
|
||||
"navigationBarTextStyle": "black",
|
||||
"navigationBarBackgroundColor": "#FFFFFF",
|
||||
|
||||
@@ -12,25 +12,30 @@
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="businessType === 'course'" class="sub-tabs">
|
||||
<view v-for="sub in courseSubTabs" :key="sub.value" class="sub-tab" :class="{ active: courseSubType === sub.value }" @tap="selectCourseSub(sub.value)">
|
||||
{{ sub.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list">
|
||||
<view v-for="item in bookedItems" :key="`${businessType}-${item.id}`" class="booking-card card" @tap="goDetail(item)">
|
||||
<view v-for="item in bookedItems" :key="`${businessType}-${courseSubType}-${item.id}`" class="booking-card card" @tap="goDetail(item)">
|
||||
<view class="card-top">
|
||||
<text class="item-title">{{ getItemTitle(item) }}</text>
|
||||
</view>
|
||||
<text class="item-meta primary">{{ getItemTime(item) }}</text>
|
||||
<text class="item-meta">{{ getItemDesc(item) }}</text>
|
||||
<view class="card-bottom">
|
||||
<text class="item-meta">订单金额</text>
|
||||
<text class="amount">¥{{ item.order?.amount || '--' }}</text>
|
||||
<text class="item-meta">{{ isMembershipCourse ? '次卡预约' : '订单金额' }}</text>
|
||||
<text v-if="isMembershipCourse" class="amount tag-amount">{{ item.card?.plan_name || '次卡预约' }}</text>
|
||||
<text v-else class="amount">¥{{ item.order?.amount || '--' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!isLoading && bookedItems.length === 0" class="empty-state">暂无已预约记录</view>
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
</view>
|
||||
</block>
|
||||
<VenueTabBar venue="comprehensive" active="bookings" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -38,13 +43,14 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const businessType = ref('course')
|
||||
const courseSubType = ref('regular')
|
||||
const items = ref([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
@@ -54,8 +60,15 @@ const businessTabs = [
|
||||
{ label: '门票', value: 'ticket' }
|
||||
]
|
||||
|
||||
const courseSubTabs = [
|
||||
{ label: '普通预约', value: 'regular' },
|
||||
{ label: '次卡预约', value: 'membership' }
|
||||
]
|
||||
|
||||
const isMembershipCourse = computed(() => businessType.value === 'course' && courseSubType.value === 'membership')
|
||||
|
||||
const bookedItems = computed(() => {
|
||||
return items.value.filter((item) => ['booked', 'available'].includes(item.status))
|
||||
return items.value.filter((item) => ['booked', 'available', 'checked_in'].includes(item.status))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
@@ -70,6 +83,12 @@ const login = async () => {
|
||||
|
||||
const selectBusiness = (value) => {
|
||||
businessType.value = value
|
||||
if (value !== 'course') courseSubType.value = 'regular'
|
||||
loadItems()
|
||||
}
|
||||
|
||||
const selectCourseSub = (value) => {
|
||||
courseSubType.value = value
|
||||
loadItems()
|
||||
}
|
||||
|
||||
@@ -77,8 +96,13 @@ const loadItems = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (businessType.value === 'course') {
|
||||
const data = await bookingApi.getCourseBookings({ page: 1, pageSize: 50 })
|
||||
items.value = data.items
|
||||
if (courseSubType.value === 'membership') {
|
||||
const data = await membershipApi.getCardBookings({ page: 1, pageSize: 50 })
|
||||
items.value = data.items
|
||||
} else {
|
||||
const data = await bookingApi.getCourseBookings({ page: 1, pageSize: 50 })
|
||||
items.value = data.items
|
||||
}
|
||||
} else if (businessType.value === 'private') {
|
||||
const data = await bookingApi.getPrivateBookings({ page: 1, pageSize: 50 })
|
||||
items.value = data.items
|
||||
@@ -98,6 +122,8 @@ const loadItems = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ refresh: loadItems })
|
||||
|
||||
const getItemTitle = (item) => {
|
||||
if (businessType.value === 'course') return item.session?.title || '课程预约'
|
||||
if (businessType.value === 'private') return item.courts?.map((court) => court.name).join('、') || '包场预约'
|
||||
@@ -111,14 +137,21 @@ const getItemTime = (item) => {
|
||||
}
|
||||
|
||||
const getItemDesc = (item) => {
|
||||
if (businessType.value === 'course') return `${item.session?.coach_name || ''} · ${item.session?.courts?.map((court) => court.name).join('、') || ''}`
|
||||
if (businessType.value === 'course') {
|
||||
if (courseSubType.value === 'membership') return `${item.session?.coach_name || ''}`
|
||||
return `${item.session?.coach_name || ''} · ${item.session?.courts?.map((court) => court.name).join('、') || ''}`
|
||||
}
|
||||
if (businessType.value === 'private') return `共 ${item.courts?.length || 0} 个场地`
|
||||
return item.status === 'available' ? '可到馆核销使用' : item.status_label
|
||||
}
|
||||
|
||||
const goDetail = (item) => {
|
||||
if (businessType.value === 'course') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.id}` })
|
||||
if (courseSubType.value === 'membership') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/membership/booking-detail?id=${item.id}` })
|
||||
} else {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.id}` })
|
||||
}
|
||||
} else if (businessType.value === 'private') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/private-detail?id=${item.id}` })
|
||||
} else if (item.order_id) {
|
||||
@@ -165,6 +198,30 @@ const goDetail = (item) => {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.sub-tabs {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.sub-tab {
|
||||
flex: 1;
|
||||
height: 56rpx;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.sub-tab.active {
|
||||
background: $color-primary;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
@@ -212,4 +269,13 @@ const goDetail = (item) => {
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.tag-amount {
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: $border-radius-base;
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,99 +1,164 @@
|
||||
<template>
|
||||
<view class="courses-page">
|
||||
<view class="filter-bar">
|
||||
<picker mode="date" :value="selectedDate" @change="onDateChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">日期</text>
|
||||
<text class="filter-value">{{ selectedDate || '全部日期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker mode="selector" :range="coachOptions" range-key="name" :value="selectedCoachIndex" @change="onCoachChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">教练</text>
|
||||
<text class="filter-value">{{ selectedCoachName }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker mode="selector" :range="courtTypeOptions" range-key="label" :value="selectedCourtTypeIndex" @change="onCourtTypeChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">类型</text>
|
||||
<text class="filter-value">{{ selectedCourtTypeLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="course-list">
|
||||
<view v-for="session in sessions" :key="session.id" class="course-card" @tap="goDetail(session.id)">
|
||||
<image :src="session.cover_image" mode="aspectFill" class="course-cover" />
|
||||
<view class="course-info">
|
||||
<view class="course-top">
|
||||
<text class="course-title">{{ session.title }}</text>
|
||||
</view>
|
||||
<text class="time-line">{{ formatTimeRange(session.start_at, session.end_at) }}</text>
|
||||
<text class="meta">{{ session.coach_name }}</text>
|
||||
<view class="course-bottom">
|
||||
<text class="meta">已约 {{ session.booked_count }}/{{ session.capacity }}</text>
|
||||
<text class="course-price">¥{{ session.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<block>
|
||||
<view class="tab-bar">
|
||||
<view class="tab-item" :class="{ active: activeTab === 'membership' }" @tap="switchTab('membership')">会员卡</view>
|
||||
<view class="tab-item" :class="{ active: activeTab === 'group' }" @tap="switchTab('group')">团课</view>
|
||||
<view class="tab-item" :class="{ active: activeTab === 'tickets' }" @tap="switchTab('tickets')">门票</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="sessions.length === 0" class="empty-state">当前筛选条件下暂无课程</view>
|
||||
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||
</view>
|
||||
<VenueTabBar venue="comprehensive" active="courses" />
|
||||
<template v-if="activeTab === 'tickets'">
|
||||
<view v-if="ticketProduct" class="ticket-section">
|
||||
<view class="card product-card">
|
||||
<view class="product-head">
|
||||
<view>
|
||||
<text class="product-title">篮球门票</text>
|
||||
<text class="product-subtitle">购买后进入会员权益账户,到馆由前台核销。</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="price-block">
|
||||
<text class="unit-price">¥{{ ticketProduct.unit_price }}</text>
|
||||
<text class="unit-label">/ 次</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card count-card">
|
||||
<text class="card-title">购买次数</text>
|
||||
<view class="stepper-row">
|
||||
<button class="step-btn" @tap="decreaseTicketQty">-</button>
|
||||
<text class="quantity">{{ ticketQty }}</text>
|
||||
<button class="step-btn" @tap="increaseTicketQty">+</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="ticketRecords.length" class="ticket-records-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">我的门票</text>
|
||||
</view>
|
||||
<view v-for="record in ticketRecords" :key="record.id" class="card ticket-record-card">
|
||||
<view class="ticket-record-top">
|
||||
<text class="ticket-record-no">订单号 {{ record.order_no }}</text>
|
||||
<text class="ticket-status-badge" :class="record.status">{{ record.status_label }}</text>
|
||||
</view>
|
||||
<text class="ticket-record-date">{{ formatDate(record.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="bar-price">
|
||||
<text class="bar-label">合计</text>
|
||||
<text class="price-text">¥{{ ticketTotal }}</text>
|
||||
</view>
|
||||
<button class="primary-button submit-btn" :disabled="ticketSubmitting || !ticketProduct.is_active" @tap="buyTickets">
|
||||
立即购买
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="load-more-tip">加载中...</view>
|
||||
</template>
|
||||
|
||||
<template v-else>
|
||||
<scroll-view scroll-x class="date-bar" :show-scrollbar="false">
|
||||
<view class="date-list">
|
||||
<view
|
||||
v-for="d in dateOptions"
|
||||
:key="d.value"
|
||||
class="date-chip"
|
||||
:class="{ active: selectedDate === d.value }"
|
||||
@tap="onDateSelect(d.value)"
|
||||
>
|
||||
<text class="date-label">{{ d.label }}</text>
|
||||
<text class="date-day">{{ d.date }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="course-list">
|
||||
<view v-for="item in sessions" :key="item.id" class="card course-card" @tap="goDetail(item)">
|
||||
<image :src="item.cover_image" mode="aspectFill" class="course-cover" />
|
||||
<view class="course-info">
|
||||
<view class="course-top">
|
||||
<text class="course-title">{{ item.title }}</text>
|
||||
<text v-if="item.booked" class="booked-badge">已预约</text>
|
||||
</view>
|
||||
<text class="time-line">{{ formatTimeRange(item.start_at, item.end_at) }}</text>
|
||||
<text class="meta">{{ item.coach_name }}</text>
|
||||
<view class="course-bottom">
|
||||
<text class="meta">剩余 {{ item.remaining_count }}/{{ item.capacity }}</text>
|
||||
<text class="course-price">¥{{ item.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="sessions.length === 0" class="empty-state">当天暂无课程</view>
|
||||
</view>
|
||||
</template>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ref, computed } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import dayjs from 'dayjs'
|
||||
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||
import { venueApi } from '@/modules/comprehensive/api/venue'
|
||||
import { formatTimeRange, todayString } from '@/modules/comprehensive/utils/date'
|
||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||
import { multiplyAmount } from '@/modules/comprehensive/utils/money'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import { formatTimeRange, formatDate, todayString } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const activeTab = ref('membership')
|
||||
const selectedDate = ref(todayString())
|
||||
const selectedCoachIndex = ref(0)
|
||||
const selectedCourtTypeIndex = ref(0)
|
||||
const sessions = ref([])
|
||||
const coaches = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const courtTypeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '羽毛球', value: 'badminton' },
|
||||
{ label: '篮球', value: 'basketball' }
|
||||
]
|
||||
const ticketProduct = ref(null)
|
||||
const ticketQty = ref(1)
|
||||
const ticketSubmitting = ref(false)
|
||||
const ticketRecords = ref([])
|
||||
const ticketRemaining = ref(0)
|
||||
const ticketTotal = computed(() => ticketProduct.value ? multiplyAmount(ticketProduct.value.unit_price, ticketQty.value) : '0.00')
|
||||
|
||||
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
const dateOptions = computed(() => {
|
||||
const list = []
|
||||
for (let i = 0; i < 7; i++) {
|
||||
const d = dayjs().add(i, 'day')
|
||||
list.push({
|
||||
value: d.format('YYYY-MM-DD'),
|
||||
label: i === 0 ? '今天' : i === 1 ? '明天' : weekDays[d.day()],
|
||||
date: `${d.month() + 1}/${d.date()}`
|
||||
})
|
||||
}
|
||||
return list
|
||||
})
|
||||
|
||||
const coachOptions = computed(() => [{ id: '', name: '全部教练' }, ...coaches.value])
|
||||
const selectedCoachId = computed(() => coachOptions.value[selectedCoachIndex.value]?.id || '')
|
||||
const selectedCoachName = computed(() => coachOptions.value[selectedCoachIndex.value]?.name || '全部教练')
|
||||
const selectedCourtType = computed(() => courtTypeOptions[selectedCourtTypeIndex.value]?.value || '')
|
||||
const selectedCourtTypeLabel = computed(() => courtTypeOptions[selectedCourtTypeIndex.value]?.label || '全部')
|
||||
const hasMore = computed(() => sessions.value.length < total.value)
|
||||
|
||||
onMounted(async () => {
|
||||
const coachData = await venueApi.getCoaches()
|
||||
coaches.value = coachData.items
|
||||
const switchTab = (tab) => {
|
||||
activeTab.value = tab
|
||||
if (tab === 'tickets') {
|
||||
loadTicketProduct()
|
||||
loadTicketRecords()
|
||||
} else {
|
||||
loadSessions(true)
|
||||
}
|
||||
}
|
||||
|
||||
const onDateSelect = (date) => {
|
||||
selectedDate.value = date
|
||||
loadSessions(true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadSessions(true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadSessions(false)
|
||||
})
|
||||
}
|
||||
|
||||
const loadSessions = async (reset) => {
|
||||
if (activeTab.value === 'membership' && !session.isLoggedIn) return
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
isLoading.value = true
|
||||
@@ -101,13 +166,12 @@ const loadSessions = async (reset) => {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const data = await courseApi.getSessions({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
date: selectedDate.value,
|
||||
coachId: selectedCoachId.value,
|
||||
courtType: selectedCourtType.value
|
||||
})
|
||||
let data
|
||||
if (activeTab.value === 'membership') {
|
||||
data = await membershipApi.getAvailableSessions({ page: page.value, pageSize, date: selectedDate.value })
|
||||
} else {
|
||||
data = await courseApi.getSessions({ page: page.value, pageSize, date: selectedDate.value })
|
||||
}
|
||||
sessions.value = reset ? data.items : [...sessions.value, ...data.items]
|
||||
total.value = data.total
|
||||
} finally {
|
||||
@@ -116,24 +180,106 @@ const loadSessions = async (reset) => {
|
||||
}
|
||||
}
|
||||
|
||||
const onDateChange = (e) => {
|
||||
selectedDate.value = e.detail.value
|
||||
loadSessions(true)
|
||||
const loadTicketProduct = async () => {
|
||||
if (ticketProduct.value) return
|
||||
try {
|
||||
ticketProduct.value = await ticketApi.getTicketProduct()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
const onCoachChange = (e) => {
|
||||
selectedCoachIndex.value = Number(e.detail.value)
|
||||
loadSessions(true)
|
||||
const loadTicketRecords = async () => {
|
||||
try {
|
||||
const data = await ticketApi.getTicketAccount({ page: 1, pageSize: 20 })
|
||||
ticketRecords.value = (data.records || []).filter((r) =>
|
||||
['available', 'checked_in', 'refunded'].includes(r.status)
|
||||
)
|
||||
ticketRemaining.value = data.remaining_count || 0
|
||||
} catch {
|
||||
ticketRecords.value = []
|
||||
ticketRemaining.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
const onCourtTypeChange = (e) => {
|
||||
selectedCourtTypeIndex.value = Number(e.detail.value)
|
||||
loadSessions(true)
|
||||
const decreaseTicketQty = () => {
|
||||
ticketQty.value = Math.max(1, ticketQty.value - 1)
|
||||
}
|
||||
|
||||
const goDetail = (id) => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/courses/detail?id=${id}` })
|
||||
const increaseTicketQty = () => {
|
||||
ticketQty.value += 1
|
||||
}
|
||||
|
||||
const buyTickets = async () => {
|
||||
if (ticketSubmitting.value || !ticketProduct.value?.is_active) return
|
||||
ticketSubmitting.value = true
|
||||
try {
|
||||
const orderIds = []
|
||||
for (let i = 0; i < ticketQty.value; i++) {
|
||||
const data = await ticketApi.createTicketOrder()
|
||||
orderIds.push(data.order.id)
|
||||
}
|
||||
if (orderIds.length > 1) {
|
||||
uni.showToast({ title: `已创建${orderIds.length}笔订单,请逐笔完成支付`, icon: 'none', duration: 2000 })
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${orderIds[0]}` })
|
||||
} finally {
|
||||
ticketSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (item) => {
|
||||
if (item.booked && item.booking_id) {
|
||||
if (item.booking_type === 'card') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/membership/booking-detail?id=${item.booking_id}` })
|
||||
} else {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.booking_id}` })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (activeTab.value === 'membership') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/courses/membership-detail?id=${item.id}` })
|
||||
} else {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/courses/detail?id=${item.id}` })
|
||||
}
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
if (activeTab.value === 'tickets') {
|
||||
ticketProduct.value = null
|
||||
ticketRecords.value = []
|
||||
await Promise.all([loadTicketProduct(), loadTicketRecords()])
|
||||
} else {
|
||||
page.value = 1
|
||||
await loadSessions(true)
|
||||
}
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (activeTab.value === 'tickets') return
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadSessions(false)
|
||||
}
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await refresh()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
loadMore()
|
||||
})
|
||||
|
||||
session.init().then(() => {
|
||||
if (activeTab.value === 'membership' && session.isLoggedIn) {
|
||||
loadSessions(true)
|
||||
} else if (activeTab.value === 'group') {
|
||||
loadSessions(true)
|
||||
}
|
||||
})
|
||||
|
||||
defineExpose({ refresh, loadMore })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -142,35 +288,93 @@ const goDetail = (id) => {
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
.tab-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
height: 94rpx;
|
||||
display: flex;
|
||||
height: 88rpx;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.filter-inner {
|
||||
height: 94rpx;
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-muted;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-item.active {
|
||||
color: $text-color-main;
|
||||
font-weight: 900;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
background: $text-color-main;
|
||||
}
|
||||
|
||||
.date-bar {
|
||||
position: sticky;
|
||||
top: 88rpx;
|
||||
z-index: 9;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-list {
|
||||
display: inline-flex;
|
||||
padding: $spacing-sm $spacing-xs;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.date-chip {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 96rpx;
|
||||
height: 88rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: $text-color-light;
|
||||
font-size: 20rpx;
|
||||
.date-chip.active {
|
||||
background: $text-color-main;
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-main;
|
||||
.date-label {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.date-chip.active .date-label {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.date-day {
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.date-chip.active .date-day {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.course-list {
|
||||
@@ -181,7 +385,6 @@ const goDetail = (id) => {
|
||||
}
|
||||
|
||||
.course-card {
|
||||
@include minimal-card;
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
@@ -199,19 +402,31 @@ const goDetail = (id) => {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.course-top,
|
||||
.course-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.course-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.booked-badge {
|
||||
flex-shrink: 0;
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $color-success-light;
|
||||
color: $color-success;
|
||||
font-size: 20rpx;
|
||||
font-weight: 800;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.time-line {
|
||||
@@ -229,9 +444,187 @@ const goDetail = (id) => {
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.course-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.course-price {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
/* 篮球门票 */
|
||||
.ticket-section {
|
||||
padding: $spacing-md;
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.product-card,
|
||||
.count-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.product-head,
|
||||
.amount-row,
|
||||
.stepper-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.product-title,
|
||||
.card-title {
|
||||
display: block;
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.product-subtitle {
|
||||
display: block;
|
||||
margin-top: $spacing-xs;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.price-block {
|
||||
margin-top: $spacing-xl;
|
||||
}
|
||||
|
||||
.unit-price {
|
||||
color: $text-color-main;
|
||||
font-size: 64rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.unit-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.stepper-row {
|
||||
margin: $spacing-lg 0;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 86rpx;
|
||||
height: 70rpx;
|
||||
border-radius: $border-radius-base;
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
color: $text-color-main;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.amount-row {
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1rpx solid $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.amount-row text:last-child {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bar-price {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: block;
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 320rpx;
|
||||
}
|
||||
|
||||
/* 门票记录 */
|
||||
.ticket-records-section {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.ticket-remaining {
|
||||
color: $color-success;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.ticket-record-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.ticket-record-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.ticket-record-no {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.ticket-record-date {
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.ticket-status-badge {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.ticket-status-badge.available {
|
||||
background: $color-success-light;
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.ticket-status-badge.checked_in {
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-light;
|
||||
}
|
||||
|
||||
.ticket-status-badge.cancelled {
|
||||
background: $color-danger-light;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.ticket-status-badge.pending_pay {
|
||||
background: $color-warning-light;
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
.ticket-status-badge.refunded {
|
||||
background: $color-danger-light;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.ticket-status-badge.closed {
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-light;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,352 @@
|
||||
<template>
|
||||
<view class="detail-page page-bottom-safe" v-if="sessionData">
|
||||
<image :src="sessionData.cover_image" mode="aspectFill" class="hero-image" />
|
||||
<view class="content">
|
||||
<view class="title-card card">
|
||||
<view class="title-row">
|
||||
<text class="course-title">{{ sessionData.title }}</text>
|
||||
</view>
|
||||
<view class="price-row">
|
||||
<text class="price-text">¥{{ sessionData.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="info-card card">
|
||||
<view v-for="item in infoItems" :key="item.label" class="info-row">
|
||||
<text class="info-label">{{ item.label }}</text>
|
||||
<text class="info-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">课程详情</text>
|
||||
</view>
|
||||
<view class="rich-card card">
|
||||
<rich-text :nodes="sessionData.detail_html"></rich-text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">选择次卡</text>
|
||||
<view v-if="availableCards.length" class="card-pick-list">
|
||||
<view
|
||||
v-for="card in availableCards"
|
||||
:key="card.id"
|
||||
class="card-pick-item"
|
||||
:class="{ active: selectedCardId === String(card.id) }"
|
||||
@tap="selectedCardId = String(card.id)"
|
||||
>
|
||||
<view class="card-pick-info">
|
||||
<text class="card-pick-name">{{ card.plan_name }}</text>
|
||||
<text class="card-pick-meta">剩余 {{ card.remaining_count }} 次</text>
|
||||
</view>
|
||||
<view class="card-pick-check" v-if="selectedCardId === String(card.id)">✓</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-state">暂无可用次卡</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<button class="primary-button submit-btn" :disabled="!canBook || submitting" @tap="onBookTap">
|
||||
{{ submitting ? '提交中...' : '使用次卡预约' }}
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view v-if="confirmVisible" class="confirm-mask" @tap="closeConfirm">
|
||||
<view class="confirm-sheet" @tap.stop>
|
||||
<text class="confirm-title">确认预约</text>
|
||||
<view class="confirm-body">
|
||||
<view class="confirm-row">
|
||||
<text class="confirm-label">课程</text>
|
||||
<text class="confirm-value">{{ sessionData.title }}</text>
|
||||
</view>
|
||||
<view class="confirm-row">
|
||||
<text class="confirm-label">时间</text>
|
||||
<text class="confirm-value">{{ formatTimeRange(sessionData.start_at, sessionData.end_at) }}</text>
|
||||
</view>
|
||||
<view class="confirm-row">
|
||||
<text class="confirm-label">次卡</text>
|
||||
<text class="confirm-value">{{ selectedCardLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="confirm-actions">
|
||||
<button class="ghost-button cancel-btn" @tap="closeConfirm">取消</button>
|
||||
<button class="primary-button ok-btn" :disabled="submitting" @tap="confirmBook">确认预约</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="load-more-tip">加载中...</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const sessionData = ref(null)
|
||||
const cards = ref([])
|
||||
const selectedCardId = ref('')
|
||||
const confirmVisible = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
const availableCards = computed(() =>
|
||||
cards.value.filter((c) => c.status === 'available' && c.remaining_count > 0)
|
||||
)
|
||||
|
||||
const canBook = computed(() => !!selectedCardId.value)
|
||||
|
||||
const selectedCardLabel = computed(() => {
|
||||
const card = availableCards.value.find((c) => String(c.id) === selectedCardId.value)
|
||||
return card ? `${card.plan_name}(剩余${card.remaining_count}次)` : ''
|
||||
})
|
||||
|
||||
const infoItems = computed(() => {
|
||||
if (!sessionData.value) return []
|
||||
return [
|
||||
{ label: '上课时间', value: formatTimeRange(sessionData.value.start_at, sessionData.value.end_at) },
|
||||
{ label: '授课教练', value: sessionData.value.coach_name },
|
||||
{ label: '上课场地', value: sessionData.value.courts.map((item) => item.name).join('、') },
|
||||
{ label: '剩余名额', value: `${sessionData.value.remaining_count}/${sessionData.value.capacity}` }
|
||||
]
|
||||
})
|
||||
|
||||
onLoad(async (query) => {
|
||||
const [detail, cardData] = await Promise.all([
|
||||
courseApi.getSessionDetail(query.id),
|
||||
membershipApi.getCards({ page: 1, pageSize: 50 })
|
||||
])
|
||||
sessionData.value = detail
|
||||
cards.value = cardData.items
|
||||
})
|
||||
|
||||
const onBookTap = () => {
|
||||
if (!canBook.value) return
|
||||
confirmVisible.value = true
|
||||
}
|
||||
|
||||
const closeConfirm = () => {
|
||||
confirmVisible.value = false
|
||||
}
|
||||
|
||||
const confirmBook = async () => {
|
||||
if (submitting.value || !selectedCardId.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const booking = await membershipApi.createCardBooking({
|
||||
cardId: selectedCardId.value,
|
||||
sessionId: sessionData.value.id
|
||||
})
|
||||
uni.redirectTo({ url: `/pages/comprehensive/membership/booking-detail?id=${booking.id}` })
|
||||
} catch {
|
||||
uni.showToast({ title: '预约失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
confirmVisible.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.title-card {
|
||||
margin-top: -54rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.price-row,
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.rich-card {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
min-height: 72rpx;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.rich-card {
|
||||
color: $text-color-regular;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
margin-bottom: $spacing-md;
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-pick-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.card-pick-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border: 2rpx solid $border-color;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-main;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card-pick-item.active {
|
||||
border-color: $text-color-main;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.card-pick-info {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4rpx;
|
||||
}
|
||||
|
||||
.card-pick-name {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-pick-meta {
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.card-pick-check {
|
||||
color: $text-color-main;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.confirm-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.4);
|
||||
z-index: 100;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.confirm-sheet {
|
||||
width: 600rpx;
|
||||
background: $bg-color-main;
|
||||
border-radius: $border-radius-lg;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
}
|
||||
|
||||
.confirm-title {
|
||||
display: block;
|
||||
text-align: center;
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.confirm-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.confirm-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.confirm-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.confirm-value {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.confirm-actions {
|
||||
display: flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.ok-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,7 +1,5 @@
|
||||
<template>
|
||||
<view class="home-container">
|
||||
<VenueSwitchHeader venue-name="综合场馆" />
|
||||
|
||||
<view class="banner-wrapper">
|
||||
<swiper
|
||||
v-if="banners.length"
|
||||
@@ -11,110 +9,79 @@
|
||||
interval="4000"
|
||||
duration="600"
|
||||
indicator-dots
|
||||
indicator-color="rgba(15, 23, 42, 0.1)"
|
||||
indicator-active-color="#0F172A"
|
||||
indicator-color="rgba(255, 255, 255, 0.4)"
|
||||
indicator-active-color="#FFFFFF"
|
||||
>
|
||||
<swiper-item v-for="banner in banners" :key="banner.id">
|
||||
<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="content-wrapper">
|
||||
<view class="section-container">
|
||||
<view class="section-header">
|
||||
<text class="section-title">公告活动</text>
|
||||
</view>
|
||||
|
||||
<view v-if="upcomingCourses.length" class="course-list">
|
||||
<view v-for="course in upcomingCourses" :key="course.id" class="course-item" @tap="go(`/pages/comprehensive/courses/detail?id=${course.id}`)">
|
||||
<image :src="course.cover_image" mode="aspectFill" class="course-cover" />
|
||||
<view class="course-info-wrap">
|
||||
<view class="course-title-row">
|
||||
<text class="course-name">{{ course.title }}</text>
|
||||
</view>
|
||||
<text class="course-time highlight-blue">时间:{{ formatTimeRange(course.start_at, course.end_at) }}</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 v-if="articles.length" class="activity-grid">
|
||||
<view v-for="article in articles" :key="article.id" class="activity-card" @tap="go(`/pages/comprehensive/activities/detail?id=${article.id}`)">
|
||||
<image :src="article.cover_image" mode="aspectFill" class="activity-cover" />
|
||||
<view class="activity-info">
|
||||
<text class="activity-title">{{ article.title }}</text>
|
||||
<text class="activity-date">{{ formatDate(article.published_at || article.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-placeholder">今日暂无课程排期</view>
|
||||
</view>
|
||||
|
||||
<view class="section-container last-section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">公告活动</text>
|
||||
<view v-else class="empty-placeholder">暂无公告活动</view>
|
||||
</view>
|
||||
|
||||
<view v-if="articles.length" class="activity-grid">
|
||||
<view v-for="article in articles" :key="article.id" class="activity-card" @tap="go(`/pages/comprehensive/activities/detail?id=${article.id}`)">
|
||||
<image :src="article.cover_image" mode="aspectFill" class="activity-cover" />
|
||||
<view class="activity-info">
|
||||
<text class="activity-title">{{ article.title }}</text>
|
||||
<text class="activity-date">{{ formatDate(article.published_at || article.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-placeholder">暂无公告活动</view>
|
||||
<VenueSwitchHeader venue-name="综合场馆" />
|
||||
</view>
|
||||
<VenueTabBar venue="comprehensive" active="home" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { contentApi } from '@/modules/comprehensive/api/content'
|
||||
import { formatDate, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
||||
import { formatDate } from '@/modules/comprehensive/utils/date'
|
||||
import VenueSwitchHeader from '@/components/VenueSwitchHeader.vue'
|
||||
|
||||
const banners = ref([])
|
||||
const upcomingCourses = ref([])
|
||||
const articles = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
loadPage()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
const loadPage = async () => {
|
||||
const home = await contentApi.getHomeContent({ banner_limit: 5, article_limit: 3 })
|
||||
banners.value = home.banners || []
|
||||
upcomingCourses.value = (home.upcoming_courses || []).slice(0, 2)
|
||||
articles.value = home.articles || []
|
||||
}
|
||||
|
||||
const go = (url) => {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
defineExpose({ refresh: loadPage })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-container {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
background-color: $bg-color-soft;
|
||||
}
|
||||
|
||||
.banner-wrapper {
|
||||
margin-bottom: $spacing-lg;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.banner-swiper {
|
||||
height: 320rpx;
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 33.33vh;
|
||||
}
|
||||
|
||||
.banner-item {
|
||||
@@ -129,30 +96,12 @@ const go = (url) => {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
padding: $spacing-md;
|
||||
background: linear-gradient(to top, rgba(15, 23, 42, 0.6), transparent);
|
||||
}
|
||||
|
||||
.banner-title {
|
||||
color: #FFFFFF;
|
||||
font-size: 24rpx;
|
||||
font-weight: 400;
|
||||
line-height: 1.4;
|
||||
.content-wrapper {
|
||||
padding: $spacing-lg $spacing-md;
|
||||
}
|
||||
|
||||
.section-container {
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.section-container.last-section {
|
||||
margin-bottom: 0;
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -182,14 +131,12 @@ const go = (url) => {
|
||||
background-color: $text-color-main;
|
||||
}
|
||||
|
||||
.course-list,
|
||||
.activity-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-item,
|
||||
.activity-card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
@@ -199,67 +146,10 @@ const go = (url) => {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.course-item:active,
|
||||
.activity-card:active {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.course-cover {
|
||||
width: 180rpx;
|
||||
height: 130rpx;
|
||||
flex-shrink: 0;
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.course-info-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
}
|
||||
|
||||
.course-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.course-time {
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.course-time.highlight-blue {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.course-bottom-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.course-meta-text {
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.activity-cover {
|
||||
width: 180rpx;
|
||||
height: 130rpx;
|
||||
|
||||
@@ -0,0 +1,195 @@
|
||||
<template>
|
||||
<view class="detail-page">
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<block v-else-if="item">
|
||||
<view class="card price-card">
|
||||
<text class="item-name">{{ item.name }}</text>
|
||||
<text class="item-price">¥{{ item.price }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="type === 'membership'" class="card info-card">
|
||||
<view class="info-row">
|
||||
<text class="info-label">总次数</text>
|
||||
<text class="info-value">{{ item.total_count }} 次</text>
|
||||
</view>
|
||||
<view v-if="item.courses && item.courses.length" class="course-section">
|
||||
<text class="info-label">适用课程</text>
|
||||
<view class="course-tags">
|
||||
<text v-for="course in item.courses" :key="course.id" class="course-tag">{{ course.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="type === 'stored_value'" class="card info-card">
|
||||
<view class="info-row">
|
||||
<text class="info-label">支付金额</text>
|
||||
<text class="info-value">¥{{ item.price }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text class="info-label">到账金额</text>
|
||||
<text class="info-value highlight">¥{{ item.face_value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<button
|
||||
class="primary-button bar-btn"
|
||||
:disabled="submitting"
|
||||
@tap="handleBuy"
|
||||
>
|
||||
{{ submitting ? '提交中...' : '立即支付' }}
|
||||
</button>
|
||||
</view>
|
||||
</block>
|
||||
|
||||
<view v-else class="empty-state">商品不存在或已下架</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const type = ref('')
|
||||
const itemId = ref(null)
|
||||
const item = ref(null)
|
||||
const isLoading = ref(false)
|
||||
const submitting = ref(false)
|
||||
|
||||
onLoad(async (query) => {
|
||||
type.value = query.type || ''
|
||||
itemId.value = Number(query.id)
|
||||
await loadDetail()
|
||||
})
|
||||
|
||||
const loadDetail = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (type.value === 'membership') {
|
||||
const plans = await membershipApi.getPlans()
|
||||
item.value = plans.find((p) => p.id === itemId.value)
|
||||
} else if (type.value === 'stored_value') {
|
||||
const cards = await storedValueApi.getCards()
|
||||
item.value = cards.find((c) => c.id === itemId.value)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const ensureLogin = async () => {
|
||||
if (session.isLoggedIn) return true
|
||||
try {
|
||||
await session.login()
|
||||
return true
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBuy = async () => {
|
||||
if (submitting.value) return
|
||||
if (!(await ensureLogin())) return
|
||||
submitting.value = true
|
||||
try {
|
||||
let order
|
||||
if (type.value === 'membership') {
|
||||
order = await membershipApi.createMembershipOrder(item.value.id)
|
||||
} else {
|
||||
order = await storedValueApi.createOrder(item.value.id)
|
||||
}
|
||||
uni.redirectTo({ url: `/pages/comprehensive/pay/index?order_id=${order.id}` })
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '下单失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-md $spacing-md calc(160rpx + env(safe-area-inset-bottom));
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.price-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-xl $spacing-md;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.item-name {
|
||||
color: $text-color-main;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.item-price {
|
||||
color: $color-primary;
|
||||
font-size: 56rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.info-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.info-value {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.info-value.highlight {
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.course-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.course-tag {
|
||||
padding: 6rpx 16rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.bar-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -1,85 +1,65 @@
|
||||
<template>
|
||||
<view class="mall-page">
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">次卡套餐</text>
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'stored_value' }"
|
||||
@tap="activeTab = 'stored_value'"
|
||||
>
|
||||
储值卡
|
||||
</view>
|
||||
<view class="card-list">
|
||||
<view v-for="plan in plans" :key="`plan-${plan.id}`" class="card plan-card">
|
||||
<view class="card-top">
|
||||
<text class="item-name">{{ plan.name }}</text>
|
||||
<text class="item-price">¥{{ plan.price }}</text>
|
||||
</view>
|
||||
<text class="item-meta">总次数 {{ plan.total_count }}</text>
|
||||
<view v-if="plan.courses.length" class="course-tags">
|
||||
<text v-for="course in plan.courses" :key="course.id" class="course-tag">{{ course.title }}</text>
|
||||
</view>
|
||||
<button
|
||||
class="primary-button buy-btn"
|
||||
:disabled="submittingId === `plan-${plan.id}`"
|
||||
@tap="buyPlan(plan)"
|
||||
>
|
||||
{{ submittingId === `plan-${plan.id}` ? '提交中...' : '立即购买' }}
|
||||
</button>
|
||||
</view>
|
||||
<view v-if="!isLoading && plans.length === 0" class="empty-state">暂无可购买的次卡套餐</view>
|
||||
<view
|
||||
class="tab-item"
|
||||
:class="{ active: activeTab === 'membership' }"
|
||||
@tap="activeTab = 'membership'"
|
||||
>
|
||||
会员卡
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section">
|
||||
<view class="section-header">
|
||||
<text class="section-title">储蓄卡</text>
|
||||
</view>
|
||||
<view class="card-list">
|
||||
<view v-for="card in cards" :key="`card-${card.id}`" class="card value-card">
|
||||
<view class="card-top">
|
||||
<text class="item-name">{{ card.name }}</text>
|
||||
<text class="item-price">¥{{ card.price }}</text>
|
||||
</view>
|
||||
<view class="value-row">
|
||||
<text class="value-label">支付</text>
|
||||
<text class="value-pay">¥{{ card.price }}</text>
|
||||
<text class="value-arrow">到账</text>
|
||||
<text class="value-face">¥{{ card.face_value }}</text>
|
||||
</view>
|
||||
<button
|
||||
class="primary-button buy-btn"
|
||||
:disabled="submittingId === `card-${card.id}`"
|
||||
@tap="buyCard(card)"
|
||||
>
|
||||
{{ submittingId === `card-${card.id}` ? '提交中...' : '立即购买' }}
|
||||
</button>
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<view v-else class="card-list">
|
||||
<view
|
||||
v-for="item in currentList"
|
||||
:key="item.id"
|
||||
class="card item-card"
|
||||
@tap="goDetail(item)"
|
||||
>
|
||||
<view class="card-body">
|
||||
<text class="item-name">{{ item.name }}</text>
|
||||
<text class="item-price">¥{{ item.price }}</text>
|
||||
</view>
|
||||
<view v-if="!isLoading && cards.length === 0" class="empty-state">暂无可购买的储蓄卡</view>
|
||||
</view>
|
||||
<view v-if="currentList.length === 0" class="empty-state">暂无可购买的商品</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const activeTab = ref('stored_value')
|
||||
const plans = ref([])
|
||||
const cards = ref([])
|
||||
const isLoading = ref(false)
|
||||
const submittingId = ref('')
|
||||
|
||||
const currentList = computed(() => {
|
||||
if (activeTab.value === 'membership') {
|
||||
return plans.value.map((p) => ({ ...p, _type: 'membership' }))
|
||||
}
|
||||
return cards.value.map((c) => ({ ...c, _type: 'stored_value' }))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await session.init()
|
||||
await loadPage()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
const loadPage = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
@@ -94,79 +74,82 @@ const loadPage = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const ensureLogin = async () => {
|
||||
if (session.isLoggedIn) return true
|
||||
try {
|
||||
await session.login()
|
||||
return true
|
||||
} catch (error) {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
return false
|
||||
}
|
||||
const goDetail = (item) => {
|
||||
uni.navigateTo({
|
||||
url: `/pages/comprehensive/mall/detail?type=${item._type}&id=${item.id}`
|
||||
})
|
||||
}
|
||||
|
||||
const buyPlan = async (plan) => {
|
||||
if (submittingId.value) return
|
||||
if (!(await ensureLogin())) return
|
||||
submittingId.value = `plan-${plan.id}`
|
||||
try {
|
||||
const order = await membershipApi.createMembershipOrder(plan.id)
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${order.id}` })
|
||||
} finally {
|
||||
submittingId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const buyCard = async (card) => {
|
||||
if (submittingId.value) return
|
||||
if (!(await ensureLogin())) return
|
||||
submittingId.value = `card-${card.id}`
|
||||
try {
|
||||
const order = await storedValueApi.createOrder(card.id)
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${order.id}` })
|
||||
} finally {
|
||||
submittingId.value = ''
|
||||
}
|
||||
}
|
||||
defineExpose({ refresh: loadPage })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.mall-page {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-md $spacing-md calc($spacing-lg + env(safe-area-inset-bottom));
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.section {
|
||||
margin-bottom: $spacing-lg;
|
||||
.tab-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
height: 88rpx;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
margin-bottom: $spacing-sm;
|
||||
.tab-item {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
color: $text-color-muted;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
.tab-item.active {
|
||||
color: $text-color-main;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.tab-item.active::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 48rpx;
|
||||
height: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
background: $text-color-main;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-md;
|
||||
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.plan-card,
|
||||
.value-card {
|
||||
.item-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
.item-card:active {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
align-items: center;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
@@ -174,7 +157,7 @@ const buyCard = async (card) => {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
font-weight: 800;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
@@ -183,56 +166,4 @@ const buyCard = async (card) => {
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: block;
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.course-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: $spacing-xs;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-tag {
|
||||
padding: 4rpx 14rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.value-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xs;
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.value-label,
|
||||
.value-arrow {
|
||||
color: $text-color-light;
|
||||
}
|
||||
|
||||
.value-pay {
|
||||
color: $text-color-main;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.value-face {
|
||||
color: $color-success;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
margin-top: $spacing-md;
|
||||
height: 80rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,136 @@
|
||||
<template>
|
||||
<view class="available-page">
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<view v-else-if="sessions.length" class="session-list">
|
||||
<view v-for="session in sessions" :key="session.id" class="card session-card">
|
||||
<image :src="session.cover_image" mode="aspectFill" class="session-cover" />
|
||||
<view class="session-info">
|
||||
<text class="session-title">{{ session.title }}</text>
|
||||
<text class="time-line">{{ formatTimeRange(session.start_at, session.end_at) }}</text>
|
||||
<text class="meta">{{ session.coach_name }}</text>
|
||||
<view class="session-bottom">
|
||||
<text class="meta">剩余 {{ session.remaining_count }}/{{ session.capacity }}</text>
|
||||
<text class="session-price">¥{{ session.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!hasMore && sessions.length >= pageSize" class="load-more-tip">已加载全部</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty-state">暂无可约课程</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const sessions = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const hasMore = computed(() => sessions.value.length < total.value)
|
||||
|
||||
const loadSessions = async (reset) => {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
isLoading.value = true
|
||||
} else {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const data = await membershipApi.getAvailableSessions({ page: page.value, pageSize })
|
||||
sessions.value = reset ? data.items : [...sessions.value, ...data.items]
|
||||
total.value = data.total
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadSessions(true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadSessions(false)
|
||||
})
|
||||
|
||||
loadSessions(true)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.available-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.session-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.session-cover {
|
||||
width: 180rpx;
|
||||
height: 172rpx;
|
||||
flex-shrink: 0;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-title {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.time-line {
|
||||
display: block;
|
||||
margin-top: $spacing-sm;
|
||||
color: $color-primary;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.meta {
|
||||
display: block;
|
||||
margin-top: 6rpx;
|
||||
color: $text-color-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.session-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
margin-top: 6rpx;
|
||||
}
|
||||
|
||||
.session-price {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,278 @@
|
||||
<template>
|
||||
<view class="booking-detail-page container page-bottom-safe">
|
||||
<UnauthLoginPanel
|
||||
v-if="!session.isLoggedIn"
|
||||
@login="login"
|
||||
/>
|
||||
<view v-else-if="loaded && !booking.id" class="card not-found-card">
|
||||
<text class="not-found-text">预约不存在</text>
|
||||
<text class="not-found-desc">该次卡预约可能已被删除或链接失效</text>
|
||||
</view>
|
||||
<block v-else-if="booking.id">
|
||||
<view class="status-card card">
|
||||
<text class="status-title">{{ booking.status_label }}</text>
|
||||
<text class="status-desc">{{ statusDesc }}</text>
|
||||
</view>
|
||||
|
||||
<view class="card session-card">
|
||||
<image :src="booking.session.cover_image" mode="aspectFill" class="cover" />
|
||||
<view class="session-info">
|
||||
<text class="session-title">{{ booking.session.title }}</text>
|
||||
<text class="meta primary">{{ formatTimeRange(booking.session.start_at, booking.session.end_at) }}</text>
|
||||
<text class="meta">{{ booking.session.coach_name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">次卡信息</text>
|
||||
<view class="info-row">
|
||||
<text>次卡名称</text>
|
||||
<text>{{ booking.card.plan_name }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>剩余次数</text>
|
||||
<text>{{ booking.card.remaining_count }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">预约时间线</text>
|
||||
<view class="timeline-item">
|
||||
<text class="dot"></text>
|
||||
<view>
|
||||
<text class="timeline-title">创建预约</text>
|
||||
<text class="timeline-time">{{ formatDateTime(booking.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="booking.checked_in_at" class="timeline-item">
|
||||
<text class="dot success"></text>
|
||||
<view>
|
||||
<text class="timeline-title">已核销</text>
|
||||
<text class="timeline-time">{{ formatDateTime(booking.checked_in_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="booking.cancelled_at" class="timeline-item">
|
||||
<text class="dot danger"></text>
|
||||
<view>
|
||||
<text class="timeline-title">已取消</text>
|
||||
<text class="timeline-time">{{ formatDateTime(booking.cancelled_at) }}</text>
|
||||
<text v-if="booking.cancel_reason" class="timeline-reason">原因:{{ booking.cancel_reason }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<view v-else class="load-more-tip">加载中...</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const bookingId = ref('')
|
||||
const booking = ref({})
|
||||
const loaded = ref(false)
|
||||
|
||||
const statusDesc = computed(() => {
|
||||
if (!booking.value || !booking.value.id) return ''
|
||||
if (booking.value.status === 'booked') return '预约已生效,到馆后按场馆流程核销。'
|
||||
if (booking.value.status === 'checked_in') return '预约已核销,次数已扣减。'
|
||||
if (booking.value.status === 'cancelled') return booking.value.cancel_reason || '预约已取消。'
|
||||
return ''
|
||||
})
|
||||
|
||||
onLoad(async (query) => {
|
||||
bookingId.value = query.id
|
||||
await session.init()
|
||||
if (session.isLoggedIn) await loadBooking()
|
||||
})
|
||||
|
||||
const login = async () => {
|
||||
await session.login()
|
||||
await loadBooking()
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadBooking().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
const loadBooking = async () => {
|
||||
try {
|
||||
booking.value = await membershipApi.getCardBookingDetail(bookingId.value)
|
||||
} catch {
|
||||
// request layer already shows toast; ensure loaded state is set
|
||||
} finally {
|
||||
loaded.value = true
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.booking-detail-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.status-card,
|
||||
.session-card,
|
||||
.card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-title,
|
||||
.status-desc,
|
||||
.session-title,
|
||||
.meta,
|
||||
.card-title,
|
||||
.timeline-title,
|
||||
.timeline-time,
|
||||
.not-found-text,
|
||||
.not-found-desc {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status-title {
|
||||
color: $text-color-main;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.status-desc {
|
||||
margin: $spacing-sm 0 $spacing-md;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.session-card {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 180rpx;
|
||||
height: 150rpx;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-hover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-title,
|
||||
.card-title {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: $spacing-xs;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.primary {
|
||||
margin-top: $spacing-sm;
|
||||
color: $color-primary;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
min-height: 70rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
color: $text-color-muted;
|
||||
font-size: 25rpx;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.info-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-row text:last-child {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.timeline-item {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md 0;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.timeline-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
margin-top: 10rpx;
|
||||
border-radius: 50%;
|
||||
background: $color-primary;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.success {
|
||||
background: $color-success;
|
||||
}
|
||||
|
||||
.dot.danger {
|
||||
background: $color-danger;
|
||||
}
|
||||
|
||||
.timeline-title {
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.timeline-time {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.timeline-reason {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
color: $color-danger;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.not-found-card {
|
||||
text-align: center;
|
||||
padding: $spacing-xl $spacing-md;
|
||||
}
|
||||
|
||||
.not-found-text {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.not-found-desc {
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,370 @@
|
||||
<template>
|
||||
<view class="card-detail-page container page-bottom-safe">
|
||||
<UnauthLoginPanel
|
||||
v-if="!session.isLoggedIn"
|
||||
@login="login"
|
||||
/>
|
||||
<view v-else-if="!card && !isLoadingCard" class="card not-found-card">
|
||||
<text class="not-found-text">次卡不存在</text>
|
||||
<text class="not-found-desc">该次卡可能已被删除或链接失效</text>
|
||||
</view>
|
||||
<view v-else-if="!card" class="load-more-tip">加载中...</view>
|
||||
<block v-else-if="card">
|
||||
<view class="card info-card">
|
||||
<view class="card-top">
|
||||
<text class="plan-name">{{ card.plan_name }}</text>
|
||||
<text class="badge" :class="statusBadgeClass(card.status)">{{ card.status_label }}</text>
|
||||
</view>
|
||||
<view class="count-row">
|
||||
<view class="count-block">
|
||||
<text class="count-value">{{ card.remaining_count }}</text>
|
||||
<text class="count-label">剩余次数</text>
|
||||
</view>
|
||||
<view class="count-divider" />
|
||||
<view class="count-block">
|
||||
<text class="count-value muted">{{ card.total_count }}</text>
|
||||
<text class="count-label">总次数</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>购买时间</text>
|
||||
<text>{{ formatDate(card.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card" v-if="applicableCourses.length > 0">
|
||||
<text class="card-title">适用课程</text>
|
||||
<view v-for="course in applicableCourses" :key="course.id" class="course-item">
|
||||
<text class="course-title">{{ course.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">使用记录</text>
|
||||
<view v-if="usageRecords.length === 0 && !isLoadingRecords" class="empty-mini">暂无使用记录</view>
|
||||
<view v-for="record in usageRecords" :key="record.id" class="record-item">
|
||||
<view class="record-top">
|
||||
<text class="record-kind">{{ record.kind_label }}</text>
|
||||
<text class="record-count" :class="{ minus: record.count < 0, plus: record.count > 0 }">
|
||||
{{ record.count > 0 ? '+' : '' }}{{ record.count }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="record.remark" class="record-remark">{{ record.remark }}</text>
|
||||
<text class="record-time">{{ formatDateTime(record.created_at) }}</text>
|
||||
</view>
|
||||
<view v-if="isLoadingRecords" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="usageRecords.length > 0 && !hasMoreRecords" class="load-more-tip">已加载全部</view>
|
||||
<view v-if="isLoadingMoreRecords" class="load-more-tip">加载中...</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<button class="primary-button action-btn" @tap="goBooking" :disabled="card.remaining_count <= 0">
|
||||
{{ card.remaining_count > 0 ? '去预约' : '次卡已用完' }}
|
||||
</button>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import { formatDateTime, formatDate } from '@/modules/comprehensive/utils/date'
|
||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const cardId = ref('')
|
||||
const card = ref(null)
|
||||
const plans = ref([])
|
||||
const usageRecords = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const isLoadingRecords = ref(false)
|
||||
const isLoadingMoreRecords = ref(false)
|
||||
const isLoadingCard = ref(false)
|
||||
|
||||
const hasMoreRecords = computed(() => usageRecords.value.length < total.value)
|
||||
|
||||
const applicableCourses = computed(() => {
|
||||
if (!card.value || !card.value.plan_id) return []
|
||||
const plan = plans.value.find((item) => item.id === card.value.plan_id)
|
||||
return plan ? plan.courses : []
|
||||
})
|
||||
|
||||
onLoad(async (query) => {
|
||||
cardId.value = query.id
|
||||
await session.init()
|
||||
if (session.isLoggedIn) loadAll()
|
||||
})
|
||||
|
||||
const login = async () => {
|
||||
await session.login()
|
||||
loadAll()
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadAll().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMoreRecords.value || isLoadingMoreRecords.value) return
|
||||
page.value += 1
|
||||
loadUsageRecords(false)
|
||||
})
|
||||
|
||||
const loadAll = async () => {
|
||||
await Promise.all([
|
||||
loadCard(),
|
||||
loadPlans(),
|
||||
loadUsageRecords(true)
|
||||
])
|
||||
}
|
||||
|
||||
const loadCard = async () => {
|
||||
isLoadingCard.value = true
|
||||
try {
|
||||
const data = await membershipApi.getCards({ page: 1, pageSize: 50 })
|
||||
card.value = data.items.find((item) => String(item.id) === String(cardId.value)) || null
|
||||
} finally {
|
||||
isLoadingCard.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadPlans = async () => {
|
||||
plans.value = await membershipApi.getPlans()
|
||||
}
|
||||
|
||||
const loadUsageRecords = async (reset) => {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
isLoadingRecords.value = true
|
||||
} else {
|
||||
isLoadingMoreRecords.value = true
|
||||
}
|
||||
try {
|
||||
const data = await membershipApi.getUsageRecords(cardId.value, { page: page.value, pageSize })
|
||||
usageRecords.value = reset ? data.items : [...usageRecords.value, ...data.items]
|
||||
total.value = data.total
|
||||
} finally {
|
||||
isLoadingRecords.value = false
|
||||
isLoadingMoreRecords.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goBooking = () => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/membership/available-sessions?card_id=${cardId.value}` })
|
||||
}
|
||||
|
||||
const statusBadgeClass = (status) => {
|
||||
if (status === 'available') return 'success'
|
||||
if (status === 'exhausted') return 'warning'
|
||||
return 'muted'
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-detail-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.info-card,
|
||||
.card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.badge {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.badge.success {
|
||||
background: $color-success-light;
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.badge.warning {
|
||||
background: $color-warning-light;
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
.badge.muted {
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.count-row {
|
||||
margin-top: $spacing-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.count-block {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.count-value {
|
||||
color: $color-primary;
|
||||
font-size: 44rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.count-value.muted {
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.count-label {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.count-divider {
|
||||
width: 1rpx;
|
||||
height: 56rpx;
|
||||
background: $border-color;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
margin-top: $spacing-md;
|
||||
min-height: 60rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
color: $text-color-muted;
|
||||
font-size: 25rpx;
|
||||
border-top: 1rpx solid $bg-color-hover;
|
||||
padding-top: $spacing-md;
|
||||
}
|
||||
|
||||
.info-row text:last-child {
|
||||
color: $text-color-main;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-item {
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.course-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.empty-mini {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
padding: $spacing-md 0;
|
||||
}
|
||||
|
||||
.record-item {
|
||||
padding: $spacing-sm 0;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.record-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.record-kind {
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.record-count {
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.record-count.minus {
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.record-count.plus {
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.record-remark {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-muted;
|
||||
font-size: 23rpx;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.not-found-card {
|
||||
text-align: center;
|
||||
padding: $spacing-xl $spacing-md;
|
||||
}
|
||||
|
||||
.not-found-text {
|
||||
display: block;
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.not-found-desc {
|
||||
display: block;
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
<template>
|
||||
<view class="cards-page">
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<view v-else-if="cards.length" class="card-list">
|
||||
<view v-for="card in cards" :key="card.id" class="card item-card" @tap="goDetail(card.id)">
|
||||
<view class="card-header">
|
||||
<text class="plan-name">{{ card.plan_name }}</text>
|
||||
<text class="status-tag" :class="card.status">{{ card.status_label }}</text>
|
||||
</view>
|
||||
<view class="card-body">
|
||||
<view class="stat">
|
||||
<text class="stat-value">{{ card.remaining_count }}</text>
|
||||
<text class="stat-label">剩余</text>
|
||||
</view>
|
||||
<view class="stat-divider" />
|
||||
<view class="stat">
|
||||
<text class="stat-value muted">{{ card.total_count }}</text>
|
||||
<text class="stat-label">总数</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!hasMore && cards.length >= pageSize" class="load-more-tip">已加载全部</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty-state">暂无次卡</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||
|
||||
const cards = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const hasMore = computed(() => cards.value.length < total.value)
|
||||
|
||||
const loadCards = async (reset) => {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
isLoading.value = true
|
||||
} else {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const data = await membershipApi.getCards({ page: page.value, pageSize })
|
||||
const items = data.items.filter((c) => c.status === 'available')
|
||||
cards.value = reset ? items : [...cards.value, ...items]
|
||||
total.value = items.length
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadCards(true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadCards(false)
|
||||
})
|
||||
|
||||
loadCards(true)
|
||||
|
||||
const goDetail = (id) => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/membership/card-detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.cards-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.card-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.item-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.plan-name {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.status-tag {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-tag.available {
|
||||
background: $color-success-light;
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.status-tag.exhausted {
|
||||
background: $color-warning-light;
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
.status-tag.expired {
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
margin-top: $spacing-md;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.stat {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
color: $color-primary;
|
||||
font-size: 48rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.stat-value.muted {
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.stat-divider {
|
||||
width: 1rpx;
|
||||
height: 56rpx;
|
||||
background: $border-color;
|
||||
}
|
||||
</style>
|
||||
@@ -24,6 +24,7 @@
|
||||
<view class="card method-card">
|
||||
<text class="card-title">支付方式</text>
|
||||
<view
|
||||
v-if="!isStoredValueOrder"
|
||||
class="method-item"
|
||||
:class="{ active: payMethod === 'balance', disabled: !canUseBalance }"
|
||||
@tap="selectMethod('balance')"
|
||||
@@ -73,7 +74,11 @@ const isSubmitting = ref(false)
|
||||
const nowTimestamp = ref(Date.now())
|
||||
let countdownTimer = null
|
||||
|
||||
const canUseBalance = computed(() => toAmountNumber(balance.value.balance) >= toAmountNumber(order.value?.amount))
|
||||
const isStoredValueOrder = computed(() => order.value?.business_type === 'stored_value')
|
||||
const canUseBalance = computed(() => {
|
||||
if (isStoredValueOrder.value) return false
|
||||
return toAmountNumber(balance.value.balance) >= toAmountNumber(order.value?.amount)
|
||||
})
|
||||
const canPay = computed(() => order.value?.status === 'pending_pay' && (payMethod.value !== 'balance' || canUseBalance.value))
|
||||
const payButtonText = computed(() => {
|
||||
if (order.value?.status !== 'pending_pay') return '订单不可支付'
|
||||
@@ -108,7 +113,11 @@ const loadPage = async () => {
|
||||
])
|
||||
order.value = orderData
|
||||
balance.value = balanceData
|
||||
if (!canUseBalance.value) payMethod.value = 'wechat'
|
||||
if (isStoredValueOrder.value) {
|
||||
payMethod.value = 'wechat'
|
||||
} else if (!canUseBalance.value) {
|
||||
payMethod.value = 'wechat'
|
||||
}
|
||||
updateCountdownTimer()
|
||||
}
|
||||
|
||||
@@ -131,15 +140,22 @@ const stopCountdown = () => {
|
||||
const padTime = (value) => String(value).padStart(2, '0')
|
||||
|
||||
const selectMethod = (method) => {
|
||||
if (method === 'balance' && !canUseBalance.value) {
|
||||
uni.showToast({ title: '余额不足', icon: 'none' })
|
||||
return
|
||||
if (method === 'balance') {
|
||||
if (isStoredValueOrder.value) {
|
||||
uni.showToast({ title: '储值卡订单仅支持微信支付', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!canUseBalance.value) {
|
||||
uni.showToast({ title: '余额不足', icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
payMethod.value = method
|
||||
}
|
||||
|
||||
const pay = async () => {
|
||||
if (!canPay.value || isSubmitting.value) return
|
||||
if (isStoredValueOrder.value && payMethod.value === 'balance') return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
if (payMethod.value === 'balance') {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<view class="private-page page-bottom-safe">
|
||||
<view class="private-page">
|
||||
<view class="top-panel">
|
||||
<view class="type-tabs">
|
||||
<view
|
||||
@@ -475,4 +475,5 @@ const goConfirm = () => {
|
||||
.submit-btn {
|
||||
width: 260rpx;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,41 +1,36 @@
|
||||
<template>
|
||||
<view class="balance-page container">
|
||||
<view class="balance-card card">
|
||||
<text class="label">当前余额</text>
|
||||
<text class="amount">¥{{ balance.balance }}</text>
|
||||
<view class="balance-page">
|
||||
<view class="card balance-card">
|
||||
<text class="balance-label">当前余额</text>
|
||||
<text class="balance-amount">¥{{ balance.balance }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">流水明细</text>
|
||||
</view>
|
||||
<view v-for="record in records" :key="record.id" class="record-card card">
|
||||
<view class="record-top">
|
||||
<text class="kind">{{ record.kind_label }}</text>
|
||||
<text class="record-amount" :class="{ income: Number(record.amount) > 0 }">{{ record.amount }}</text>
|
||||
<view v-if="records.length" class="record-list">
|
||||
<view v-for="record in records" :key="record.id" class="card record-card">
|
||||
<view class="record-top">
|
||||
<text class="record-kind">{{ record.kind_label }}</text>
|
||||
<text class="record-amount" :class="{ income: Number(record.amount) > 0 }">
|
||||
{{ Number(record.amount) > 0 ? '+' : '' }}{{ record.amount }}
|
||||
</text>
|
||||
</view>
|
||||
<text v-if="record.remark" class="record-remark">{{ record.remark }}</text>
|
||||
<text class="record-time">{{ formatDateTime(record.created_at) }}</text>
|
||||
</view>
|
||||
<text class="remark">{{ record.remark }}</text>
|
||||
<text class="time">{{ formatDateTime(record.created_at) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="records.length === 0" class="empty-state">暂无余额流水</view>
|
||||
<view v-else class="empty-state">暂无余额流水</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { ref } from 'vue'
|
||||
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const balance = ref({ balance: '0.00' })
|
||||
const records = ref([])
|
||||
|
||||
onMounted(loadPage)
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
const loadPage = async () => {
|
||||
const [balanceData, recordData] = await Promise.all([
|
||||
memberApi.getBalance(),
|
||||
@@ -44,42 +39,63 @@ const loadPage = async () => {
|
||||
balance.value = balanceData
|
||||
records.value = recordData.items
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
loadPage()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.balance-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.balance-card {
|
||||
min-height: 220rpx;
|
||||
margin: $spacing-md;
|
||||
min-height: 200rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.label,
|
||||
.remark,
|
||||
.time {
|
||||
display: block;
|
||||
.balance-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.amount {
|
||||
.balance-amount {
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-main;
|
||||
font-size: 64rpx;
|
||||
font-size: 72rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.record-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
padding: 0 $spacing-md $spacing-md;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
margin-bottom: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.kind {
|
||||
.record-kind {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
@@ -95,11 +111,17 @@ const loadPage = async () => {
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.remark {
|
||||
.record-remark {
|
||||
display: block;
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.time {
|
||||
.record-time {
|
||||
display: block;
|
||||
margin-top: $spacing-xs;
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -35,17 +35,22 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="action-area">
|
||||
<button class="primary-button save-button" :disabled="isSaving || !canSave" @tap="saveProfile">
|
||||
{{ isSaving ? '保存中...' : '保存资料' }}
|
||||
</button>
|
||||
<button class="ghost-button home-button" @tap="goHome">返回首页</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="load-more-tip">加载中...</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||
import { authStorage } from '@/modules/comprehensive/api/auth'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
@@ -54,29 +59,31 @@ const isSaving = ref(false)
|
||||
const isUploadingAvatar = ref(false)
|
||||
|
||||
const avatarInitial = computed(() => (form.value?.nickname || '用户').slice(0, 1))
|
||||
const canSave = computed(() => {
|
||||
return !!form.value?.nickname?.trim() || !!form.value?.avatar_url?.trim()
|
||||
})
|
||||
const canSave = computed(() => !!form.value?.nickname?.trim() && !!form.value?.avatar_url?.trim())
|
||||
|
||||
onMounted(async () => {
|
||||
onLoad(async () => {
|
||||
await session.init()
|
||||
form.value = { ...(session.member || await memberApi.getMemberMe()) }
|
||||
if (session.member) {
|
||||
form.value = { ...session.member }
|
||||
} else {
|
||||
try {
|
||||
form.value = await memberApi.getMemberMe()
|
||||
} catch {
|
||||
form.value = { nickname: '', avatar_url: '', phone: '' }
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const chooseAvatar = async (event) => {
|
||||
const avatarUrl = event.detail?.avatarUrl
|
||||
if (!avatarUrl) {
|
||||
uni.showToast({ title: '未获取到头像', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (!avatarUrl) return
|
||||
if (isUploadingAvatar.value) return
|
||||
|
||||
isUploadingAvatar.value = true
|
||||
try {
|
||||
const data = await memberApi.uploadAvatar(avatarUrl)
|
||||
form.value = { ...data.member }
|
||||
await session.refreshMember()
|
||||
uni.showToast({ title: '头像已更新', icon: 'success' })
|
||||
form.value = { ...form.value, avatar_url: data.avatar_url }
|
||||
} catch {
|
||||
form.value = { ...form.value, avatar_url: avatarUrl }
|
||||
} finally {
|
||||
isUploadingAvatar.value = false
|
||||
}
|
||||
@@ -91,8 +98,13 @@ const saveProfile = async () => {
|
||||
avatar_url: form.value.avatar_url
|
||||
})
|
||||
form.value = { ...data }
|
||||
if (authStorage.getPendingToken()) {
|
||||
session.completeLogin()
|
||||
}
|
||||
await session.refreshMember()
|
||||
uni.showToast({ title: '已保存', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
@@ -100,14 +112,17 @@ const saveProfile = async () => {
|
||||
|
||||
const bindPhone = async (event) => {
|
||||
const code = event.detail?.code
|
||||
if (!code) {
|
||||
uni.showToast({ title: '未获取到手机号授权', icon: 'none' })
|
||||
return
|
||||
if (!code) return
|
||||
try {
|
||||
const data = await memberApi.bindMemberPhone(code)
|
||||
form.value = { ...form.value, phone: data.phone }
|
||||
uni.showToast({ title: '手机号已绑定', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
}
|
||||
const data = await memberApi.bindMemberPhone(code)
|
||||
form.value = { ...data }
|
||||
await session.refreshMember()
|
||||
uni.showToast({ title: '手机号已绑定', icon: 'success' })
|
||||
}
|
||||
const goHome = () => {
|
||||
uni.switchTab({ url: '/pages/home/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -115,6 +130,7 @@ const bindPhone = async (event) => {
|
||||
.profile-detail {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-md;
|
||||
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
@@ -227,7 +243,18 @@ const bindPhone = async (event) => {
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.action-area {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
padding: 0 $spacing-md;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
flex: 1;
|
||||
height: 88rpx;
|
||||
}
|
||||
|
||||
.home-button {
|
||||
height: 88rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,99 +1,110 @@
|
||||
<template>
|
||||
<view class="profile-page container">
|
||||
<UnauthLoginPanel
|
||||
v-if="!session.isLoggedIn"
|
||||
@login="login"
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<view class="user-profile-hero" @tap="go('/pages/comprehensive/profile/detail')">
|
||||
<view class="hero-left">
|
||||
<image v-if="session.member?.avatar_url" :src="session.member.avatar_url" mode="aspectFill" class="hero-avatar" />
|
||||
<view v-else class="hero-avatar placeholder">
|
||||
<text>{{ avatarInitial }}</text>
|
||||
</view>
|
||||
<view class="hero-meta">
|
||||
<text class="hero-name">{{ session.member?.nickname }}</text>
|
||||
<view v-if="session.member?.phone" class="hero-sub-row">
|
||||
<text class="hero-phone">{{ session.member.phone }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!session.isLoggedIn" class="user-profile-hero" @tap="login">
|
||||
<view class="hero-left">
|
||||
<view class="hero-avatar placeholder">
|
||||
<text class="hero-placeholder-text">点击登录</text>
|
||||
</view>
|
||||
<view class="hero-right">
|
||||
<view class="hero-arrow" />
|
||||
<view class="hero-meta">
|
||||
<text class="hero-name">点击登录</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-right">
|
||||
<view class="hero-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-group-card">
|
||||
<view v-for="item in menuItems" :key="item.title" class="menu-item" @tap="go(item.url)">
|
||||
<view class="item-left">
|
||||
<view class="item-icon-container" :class="item.theme">
|
||||
<view class="icon-order" v-if="item.icon === 'order'">
|
||||
<view class="icon-order-line" />
|
||||
<view class="icon-order-line short" />
|
||||
</view>
|
||||
<view class="icon-private" v-else-if="item.icon === 'private'">
|
||||
<view class="icon-private-net" />
|
||||
</view>
|
||||
<view class="icon-ticket" v-else-if="item.icon === 'ticket'">
|
||||
<view class="icon-ticket-dot" />
|
||||
</view>
|
||||
<view class="icon-profile" v-else>
|
||||
<view class="icon-profile-head" />
|
||||
<view class="icon-profile-body" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="menu-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<view class="menu-arrow" />
|
||||
</view>
|
||||
<view v-else class="user-profile-hero" @tap="go('/pages/comprehensive/profile/detail')">
|
||||
<view class="hero-left">
|
||||
<image v-if="session.member?.avatar_url" :src="session.member.avatar_url" mode="aspectFill" class="hero-avatar" />
|
||||
<view v-else class="hero-avatar placeholder">
|
||||
<text>{{ avatarInitial }}</text>
|
||||
</view>
|
||||
<view class="menu-item logout" @tap="logout">
|
||||
<view class="item-left">
|
||||
<view class="item-icon-container logout-theme">
|
||||
<view class="icon-logout">
|
||||
<view class="icon-logout-door" />
|
||||
<view class="icon-logout-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="menu-title">退出登录</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<view class="menu-arrow" />
|
||||
<view class="hero-meta">
|
||||
<text class="hero-name">{{ session.member?.nickname }}</text>
|
||||
<view v-if="session.member?.phone" class="hero-sub-row">
|
||||
<text class="hero-phone">{{ session.member.phone }}</text>
|
||||
</view>
|
||||
<text class="hero-balance">余额 ¥{{ balanceText }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
<AgreementLinks v-if="session.isLoggedIn" class="profile-agreement-links" compact />
|
||||
<VenueTabBar venue="comprehensive" active="profile" />
|
||||
<view class="hero-right">
|
||||
<view class="hero-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-group-card">
|
||||
<view v-for="item in menuItems" :key="item.title" class="menu-item" @tap="go(item.url)">
|
||||
<view class="item-left">
|
||||
<view class="item-icon-container" :class="item.theme">
|
||||
<view class="icon-order" v-if="item.icon === 'order'">
|
||||
<view class="icon-order-line" />
|
||||
<view class="icon-order-line short" />
|
||||
</view>
|
||||
<view class="icon-private" v-else-if="item.icon === 'private'">
|
||||
<view class="icon-private-net" />
|
||||
</view>
|
||||
<view class="icon-ticket" v-else-if="item.icon === 'ticket'">
|
||||
<view class="icon-ticket-dot" />
|
||||
</view>
|
||||
<view class="icon-profile" v-else>
|
||||
<view class="icon-profile-head" />
|
||||
<view class="icon-profile-body" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="menu-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<view class="menu-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="session.isLoggedIn" class="menu-item logout" @tap="logout">
|
||||
<view class="item-left">
|
||||
<view class="item-icon-container logout-theme">
|
||||
<view class="icon-logout">
|
||||
<view class="icon-logout-door" />
|
||||
<view class="icon-logout-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="menu-title">退出登录</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<view class="menu-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
||||
import AgreementLinks from '@/components/AgreementLinks.vue'
|
||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||
import { formatAmount } from '@/modules/comprehensive/utils/money'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1))
|
||||
const balance = ref('0.00')
|
||||
const balanceText = computed(() => formatAmount(balance.value))
|
||||
|
||||
const menuItems = [
|
||||
{ title: '包场预约', url: '/pages/comprehensive/private/index', theme: 'private-theme', icon: 'private' },
|
||||
{ title: '篮球门票', url: '/pages/comprehensive/tickets/index', theme: 'ticket-theme', icon: 'ticket' },
|
||||
{ title: '我的会员卡', url: '/pages/comprehensive/membership/cards', theme: 'profile-theme', icon: 'profile' },
|
||||
{ title: '我的订单', url: '/pages/comprehensive/profile/orders', theme: 'order-theme', icon: 'order' },
|
||||
{ title: '个人信息', url: '/pages/comprehensive/profile/detail', theme: 'profile-theme', icon: 'profile' },
|
||||
{ title: '切换场馆', url: '/pages/venue-select/index?mode=switch', theme: 'profile-theme', icon: 'profile' }
|
||||
{ title: '余额流水', url: '/pages/comprehensive/profile/balance', theme: 'order-theme', icon: 'order' }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await session.init()
|
||||
try {
|
||||
const data = await memberApi.getBalance()
|
||||
balance.value = data.balance
|
||||
} catch {
|
||||
balance.value = '0.00'
|
||||
}
|
||||
})
|
||||
|
||||
const login = async () => {
|
||||
await session.login()
|
||||
const login = () => {
|
||||
uni.navigateTo({ url: '/pages/comprehensive/profile/login' })
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
@@ -104,9 +115,70 @@ const logout = () => {
|
||||
const go = (url) => {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
|
||||
defineExpose({ onPageShow: () => {} })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-entry {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-height: 160rpx;
|
||||
padding: $spacing-lg;
|
||||
border: 1rpx solid rgba(241, 245, 249, 0.9);
|
||||
border-radius: $border-radius-lg;
|
||||
background-color: $bg-color-main;
|
||||
box-shadow: 0 10rpx 40rpx rgba(15, 23, 42, 0.02);
|
||||
}
|
||||
|
||||
.login-entry:active {
|
||||
opacity: 0.96;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.login-entry-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.login-entry-avatar {
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: $bg-color-hover;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.login-entry-avatar.placeholder {
|
||||
background: $color-primary-light;
|
||||
}
|
||||
|
||||
.login-entry-icon {
|
||||
font-size: 48rpx;
|
||||
}
|
||||
|
||||
.login-entry-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.login-entry-title {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.login-entry-sub {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.user-profile-hero {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -150,6 +222,12 @@ const go = (url) => {
|
||||
background: $color-primary-light;
|
||||
}
|
||||
|
||||
.hero-placeholder-text {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-muted;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -159,6 +237,7 @@ const go = (url) => {
|
||||
|
||||
.hero-name,
|
||||
.hero-phone,
|
||||
.hero-balance,
|
||||
.menu-title {
|
||||
display: block;
|
||||
}
|
||||
@@ -176,6 +255,13 @@ const go = (url) => {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-balance {
|
||||
margin-top: 4rpx;
|
||||
color: $color-primary;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.hero-arrow,
|
||||
.menu-arrow {
|
||||
width: 18rpx;
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<view class="login-page">
|
||||
<UnauthLoginPanel @login="handleLogin" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
|
||||
const handleLogin = async () => {
|
||||
try {
|
||||
await session.login()
|
||||
uni.redirectTo({ url: '/pages/comprehensive/profile/detail' })
|
||||
} catch {
|
||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
</style>
|
||||
@@ -1,27 +1,20 @@
|
||||
<template>
|
||||
<view class="orders-page">
|
||||
<view class="filter-panel">
|
||||
<scroll-view scroll-x class="filter-scroll">
|
||||
<view class="filter-row">
|
||||
<view v-for="item in businessOptions" :key="item.value" class="filter-chip" :class="{ active: businessType === item.value }" @tap="selectBusiness(item.value)">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
<scroll-view scroll-x class="filter-bar" :show-scrollbar="false">
|
||||
<view class="filter-row">
|
||||
<view v-for="item in statusOptions" :key="item.value" class="filter-chip" :class="{ active: orderStatus === item.value }" @tap="selectStatus(item.value)">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</scroll-view>
|
||||
<scroll-view scroll-x class="filter-scroll second">
|
||||
<view class="filter-row">
|
||||
<view v-for="item in statusOptions" :key="item.value" class="filter-chip" :class="{ active: orderStatus === item.value }" @tap="selectStatus(item.value)">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<view class="list">
|
||||
<view v-for="order in orders" :key="order.id" class="order-card card" @tap="goDetail(order.id)">
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
|
||||
<view v-else-if="orders.length" class="order-list">
|
||||
<view v-for="order in orders" :key="order.id" class="card order-card" @tap="goDetail(order.id)">
|
||||
<view class="order-top">
|
||||
<text class="business">{{ order.business_type_label }}</text>
|
||||
<text class="badge" :class="statusBadgeClass(order.status)">{{ order.status_label }}</text>
|
||||
<text class="status-tag" :class="order.status">{{ order.status_label }}</text>
|
||||
</view>
|
||||
<text class="order-no">{{ order.order_no }}</text>
|
||||
<view class="order-bottom">
|
||||
@@ -29,31 +22,22 @@
|
||||
<text class="amount">¥{{ order.amount }}</text>
|
||||
</view>
|
||||
<view v-if="order.status === 'pending_pay'" class="actions" @tap.stop>
|
||||
<button class="ghost-button small-btn" @tap="closeOrder(order.id)">取消订单</button>
|
||||
<button class="ghost-button small-btn" @tap="closeOrder(order.id)">取消</button>
|
||||
<button class="primary-button small-btn" @tap="goPay(order.id)">去支付</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="orders.length === 0" class="empty-state">暂无订单</view>
|
||||
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||
<view v-if="!hasMore && orders.length >= pageSize" class="load-more-tip">已加载全部</view>
|
||||
</view>
|
||||
|
||||
<view v-else class="empty-state">暂无订单</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||
|
||||
const businessOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '课程', value: 'course' },
|
||||
{ label: '包场', value: 'private_booking' },
|
||||
{ label: '门票', value: 'ticket' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
@@ -63,7 +47,6 @@ const statusOptions = [
|
||||
{ label: '已退款', value: 'refunded' }
|
||||
]
|
||||
|
||||
const businessType = ref('')
|
||||
const orderStatus = ref('')
|
||||
const orders = ref([])
|
||||
const page = ref(1)
|
||||
@@ -74,18 +57,6 @@ const isLoadingMore = ref(false)
|
||||
|
||||
const hasMore = computed(() => orders.value.length < total.value)
|
||||
|
||||
onMounted(() => loadOrders(true))
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadOrders(true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadOrders(false)
|
||||
})
|
||||
|
||||
const loadOrders = async (reset) => {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
@@ -97,7 +68,6 @@ const loadOrders = async (reset) => {
|
||||
const data = await orderApi.getOrders({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
businessType: businessType.value,
|
||||
status: orderStatus.value
|
||||
})
|
||||
orders.value = reset ? data.items : [...orders.value, ...data.items]
|
||||
@@ -108,11 +78,6 @@ const loadOrders = async (reset) => {
|
||||
}
|
||||
}
|
||||
|
||||
const selectBusiness = (value) => {
|
||||
businessType.value = value
|
||||
loadOrders(true)
|
||||
}
|
||||
|
||||
const selectStatus = (value) => {
|
||||
orderStatus.value = value
|
||||
loadOrders(true)
|
||||
@@ -131,6 +96,18 @@ const closeOrder = async (id) => {
|
||||
uni.showToast({ title: '订单已取消', icon: 'none' })
|
||||
loadOrders(true)
|
||||
}
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadOrders(true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadOrders(false)
|
||||
})
|
||||
|
||||
loadOrders(true)
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -139,34 +116,26 @@ const closeOrder = async (id) => {
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: $spacing-md;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-scroll.second {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: inline-flex;
|
||||
gap: $spacing-sm;
|
||||
padding: $spacing-sm $spacing-xs;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
min-width: 118rpx;
|
||||
height: 62rpx;
|
||||
padding: 0 $spacing-sm;
|
||||
border-radius: $border-radius-base;
|
||||
min-width: 100rpx;
|
||||
height: 64rpx;
|
||||
padding: 0 $spacing-md;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
@@ -181,21 +150,23 @@ const closeOrder = async (id) => {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.list {
|
||||
.order-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
margin-bottom: $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-top,
|
||||
.order-bottom,
|
||||
.actions {
|
||||
.order-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.business {
|
||||
@@ -204,19 +175,51 @@ const closeOrder = async (id) => {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.order-no,
|
||||
.time {
|
||||
.status-tag {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.status-tag.paid {
|
||||
background: $color-success-light;
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.status-tag.pending_pay {
|
||||
background: $color-warning-light;
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
.status-tag.closed {
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.status-tag.refunded {
|
||||
background: $color-danger-light;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
display: block;
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
margin-top: $spacing-sm;
|
||||
.order-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.order-bottom {
|
||||
margin-top: $spacing-md;
|
||||
.time {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.amount {
|
||||
@@ -226,14 +229,16 @@ const closeOrder = async (id) => {
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: $spacing-sm;
|
||||
margin-top: $spacing-md;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
width: 180rpx;
|
||||
width: 160rpx;
|
||||
height: 64rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,265 @@
|
||||
<template>
|
||||
<view class="setup-page">
|
||||
<view class="setup-card">
|
||||
<view class="avatar-row">
|
||||
<button class="avatar-picker" open-type="chooseAvatar" @chooseavatar="onChooseAvatar">
|
||||
<image v-if="avatarUrl" :src="avatarUrl" mode="aspectFill" class="avatar-img" />
|
||||
<view v-else class="avatar-placeholder">
|
||||
<text class="avatar-placeholder-text">选择头像</text>
|
||||
</view>
|
||||
</button>
|
||||
</view>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-label">昵称</text>
|
||||
<input
|
||||
type="nickname"
|
||||
class="field-input"
|
||||
placeholder="请输入昵称"
|
||||
:value="nickname"
|
||||
@blur="onNicknameBlur"
|
||||
/>
|
||||
</view>
|
||||
|
||||
<view class="field-row">
|
||||
<text class="field-label">手机号</text>
|
||||
<view class="phone-area">
|
||||
<text v-if="phone" class="phone-text">{{ phone }}</text>
|
||||
<button class="phone-btn" open-type="getPhoneNumber" @getphonenumber="onGetPhone">
|
||||
{{ phone ? '更换' : '获取' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button
|
||||
class="submit-button"
|
||||
:class="{ disabled: submitting || !canSubmit }"
|
||||
:disabled="submitting || !canSubmit"
|
||||
@tap="onSubmit"
|
||||
>
|
||||
{{ submitting ? '提交中...' : '完成' }}
|
||||
</button>
|
||||
|
||||
<view class="home-link" @tap="goHome">返回首页</view>
|
||||
</view>
|
||||
|
||||
<AgreementLinks ref="agreementLinksRef" :show-entry="false" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||
import { authStorage } from '@/modules/comprehensive/api/auth'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import AgreementLinks from '@/components/AgreementLinks.vue'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const avatarUrl = ref(session.member?.avatar_url || '')
|
||||
const nickname = ref(session.member?.nickname || '')
|
||||
const phone = ref(session.member?.phone || '')
|
||||
const submitting = ref(false)
|
||||
|
||||
const canSubmit = computed(() => !!avatarUrl.value && !!nickname.value.trim())
|
||||
|
||||
const onChooseAvatar = async (e) => {
|
||||
if (!e.detail.avatarUrl) return
|
||||
try {
|
||||
const data = await memberApi.uploadAvatar(e.detail.avatarUrl)
|
||||
avatarUrl.value = data.avatar_url
|
||||
} catch {
|
||||
avatarUrl.value = e.detail.avatarUrl
|
||||
}
|
||||
}
|
||||
|
||||
const onNicknameBlur = (e) => {
|
||||
nickname.value = e.detail.value || ''
|
||||
}
|
||||
|
||||
const onGetPhone = async (e) => {
|
||||
if (e.detail.errMsg !== 'getPhoneNumber:ok') return
|
||||
try {
|
||||
const member = await memberApi.bindMemberPhone(e.detail.code)
|
||||
phone.value = member.phone || ''
|
||||
} catch {
|
||||
uni.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const onSubmit = async () => {
|
||||
if (!canSubmit.value || submitting.value) return
|
||||
submitting.value = true
|
||||
try {
|
||||
await memberApi.updateMemberProfile({
|
||||
nickname: nickname.value.trim(),
|
||||
avatar_url: avatarUrl.value
|
||||
})
|
||||
session.completeLogin()
|
||||
uni.showToast({ title: '设置成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/profile/index' })
|
||||
}, 500)
|
||||
} catch {
|
||||
uni.showToast({ title: '提交失败,请重试', icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goHome = () => {
|
||||
authStorage.clear()
|
||||
session.member = null
|
||||
session.needsSetup = false
|
||||
uni.switchTab({ url: '/pages/home/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.setup-page {
|
||||
min-height: 100vh;
|
||||
padding: 0 $spacing-lg 48rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.setup-card {
|
||||
width: 100%;
|
||||
max-width: 560rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.avatar-row {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.avatar-picker {
|
||||
width: 144rpx;
|
||||
height: 144rpx;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: $bg-color-hover;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.avatar-picker::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.avatar-img {
|
||||
width: 144rpx;
|
||||
height: 144rpx;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.avatar-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.avatar-placeholder-text {
|
||||
font-size: 20rpx;
|
||||
color: $text-color-light;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
height: 96rpx;
|
||||
padding: 0 $spacing-md;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-main;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
flex-shrink: 0;
|
||||
color: $text-color-muted;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
width: 100rpx;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.field-input {
|
||||
flex: 1;
|
||||
text-align: right;
|
||||
font-size: 28rpx;
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.phone-area {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.phone-text {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.phone-btn {
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
color: $color-primary;
|
||||
background: $color-primary-light;
|
||||
padding: 8rpx 24rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.phone-btn::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
width: 100%;
|
||||
height: 88rpx;
|
||||
border-radius: $border-radius-base;
|
||||
color: #FFFFFF;
|
||||
background-color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.submit-button::after {
|
||||
border: none;
|
||||
}
|
||||
|
||||
.submit-button.disabled,
|
||||
.submit-button[disabled] {
|
||||
color: #FFFFFF;
|
||||
background-color: $text-color-light;
|
||||
}
|
||||
|
||||
.home-link {
|
||||
text-align: center;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
padding: $spacing-sm 0;
|
||||
}
|
||||
</style>
|
||||
@@ -65,8 +65,15 @@ const submit = async () => {
|
||||
if (isSubmitting.value || !product.value?.is_active) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const data = await ticketApi.createTicketOrder(quantity.value)
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${data.order.id}` })
|
||||
const orderIds = []
|
||||
for (let i = 0; i < quantity.value; i++) {
|
||||
const data = await ticketApi.createTicketOrder()
|
||||
orderIds.push(data.order.id)
|
||||
}
|
||||
if (orderIds.length > 1) {
|
||||
uni.showToast({ title: `已创建${orderIds.length}笔订单,请逐笔完成支付`, icon: 'none', duration: 2000 })
|
||||
}
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${orderIds[0]}` })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
<template>
|
||||
<view class="tab-page">
|
||||
<ComprehensiveCourses v-if="isComprehensive" ref="contentRef" />
|
||||
<IceCourses v-else ref="contentRef" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { getStoredVenue } from '@/utils/venue'
|
||||
import ComprehensiveCourses from '@/pages/comprehensive/courses/index.vue'
|
||||
import IceCourses from '@/pages/ice/courses/index.vue'
|
||||
|
||||
const isComprehensive = getStoredVenue() === 'comprehensive'
|
||||
const contentRef = ref()
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await contentRef.value?.refresh?.()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
contentRef.value?.loadMore?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,29 @@
|
||||
<template>
|
||||
<view class="tab-page">
|
||||
<ComprehensiveHome v-if="isComprehensive" ref="contentRef" />
|
||||
<IceHome v-else ref="contentRef" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getStoredVenue } from '@/utils/venue'
|
||||
import ComprehensiveHome from '@/pages/comprehensive/home/index.vue'
|
||||
import IceHome from '@/pages/ice/home/index.vue'
|
||||
|
||||
const venue = getStoredVenue()
|
||||
const isComprehensive = venue === 'comprehensive'
|
||||
const contentRef = ref()
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await contentRef.value?.refresh?.()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -107,7 +107,6 @@
|
||||
/>
|
||||
</view>
|
||||
</block>
|
||||
<VenueTabBar venue="ice" active="bookings" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -117,7 +116,6 @@ 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 VenueTabBar from '@/components/VenueTabBar.vue'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||
|
||||
@@ -297,7 +295,7 @@ const applySessionError = (err) => {
|
||||
}
|
||||
|
||||
const goProfile = () => {
|
||||
uni.reLaunch({ url: '/pages/ice/profile/index' })
|
||||
uni.switchTab({ url: '/pages/profile/index' })
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
@@ -90,16 +90,13 @@
|
||||
compact
|
||||
/>
|
||||
</view>
|
||||
<VenueTabBar venue="ice" active="courses" />
|
||||
</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 VenueTabBar from '@/components/VenueTabBar.vue'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
|
||||
const selectedDate = ref('')
|
||||
@@ -138,20 +135,6 @@ onMounted(() => {
|
||||
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()])
|
||||
}
|
||||
@@ -242,6 +225,20 @@ const formatTimeRange = (startStr, endStr) => {
|
||||
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
|
||||
return `${mm}-${dd} ${startHhMm} - ${endHhMm}`
|
||||
}
|
||||
|
||||
const refresh = async () => {
|
||||
page.value = 1
|
||||
await loadCourses()
|
||||
}
|
||||
|
||||
const loadMore = () => {
|
||||
if (hasMore.value && !isLoadingMore.value) {
|
||||
page.value++
|
||||
loadMoreCourses()
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ refresh, loadMore })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -10,98 +10,65 @@
|
||||
/>
|
||||
|
||||
<block v-else>
|
||||
<VenueSwitchHeader venue-name="冰场馆" />
|
||||
|
||||
<!-- 极简无感大图轮播 -->
|
||||
<!-- 全屏轮播 -->
|
||||
<view class="banner-wrapper">
|
||||
<swiper
|
||||
class="banner-swiper"
|
||||
circular
|
||||
autoplay
|
||||
interval="4000"
|
||||
<swiper
|
||||
class="banner-swiper"
|
||||
circular
|
||||
autoplay
|
||||
interval="4000"
|
||||
duration="600"
|
||||
indicator-dots
|
||||
indicator-color="rgba(15, 23, 42, 0.1)"
|
||||
indicator-active-color="#0F172A"
|
||||
indicator-color="rgba(255, 255, 255, 0.4)"
|
||||
indicator-active-color="#FFFFFF"
|
||||
>
|
||||
<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="content-wrapper">
|
||||
<!-- 活动资讯版块 -->
|
||||
<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 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 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>
|
||||
<VenueSwitchHeader venue-name="冰场馆" />
|
||||
</view>
|
||||
</block>
|
||||
<VenueTabBar venue="ice" active="home" />
|
||||
</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 VenueTabBar from '@/components/VenueTabBar.vue'
|
||||
import VenueSwitchHeader from '@/components/VenueSwitchHeader.vue'
|
||||
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('')
|
||||
@@ -113,20 +80,12 @@ onMounted(() => {
|
||||
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 = ''
|
||||
@@ -148,71 +107,27 @@ const onTapBanner = (banner) => {
|
||||
}
|
||||
}
|
||||
|
||||
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')}`
|
||||
}
|
||||
|
||||
defineExpose({ refresh: loadHome })
|
||||
</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;
|
||||
|
||||
width: 100%;
|
||||
|
||||
.banner-swiper {
|
||||
height: 320rpx;
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
width: 100%;
|
||||
height: 33.33vh;
|
||||
}
|
||||
|
||||
.banner-item {
|
||||
@@ -226,99 +141,16 @@ const formatDate = (dateStr) => {
|
||||
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;
|
||||
}
|
||||
/* 内容区域 */
|
||||
.content-wrapper {
|
||||
padding: $spacing-lg $spacing-md;
|
||||
}
|
||||
|
||||
/* 板块通用头部 */
|
||||
.section-container {
|
||||
margin-bottom: $spacing-xl;
|
||||
|
||||
&.last-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
@@ -353,77 +185,7 @@ const formatDate = (dateStr) => {
|
||||
}
|
||||
}
|
||||
|
||||
/* 课程卡片 */
|
||||
.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;
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<view class="dev-page">
|
||||
<view class="dev-content">
|
||||
<text class="dev-icon">🚧</text>
|
||||
<text class="dev-title">正在开发中</text>
|
||||
<text class="dev-desc">此功能即将上线,敬请期待</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dev-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.dev-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.dev-icon {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
|
||||
.dev-title {
|
||||
color: $text-color-main;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dev-desc {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,44 @@
|
||||
<template>
|
||||
<view class="dev-page">
|
||||
<view class="dev-content">
|
||||
<text class="dev-icon">🚧</text>
|
||||
<text class="dev-title">正在开发中</text>
|
||||
<text class="dev-desc">此功能即将上线,敬请期待</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dev-page {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.dev-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.dev-icon {
|
||||
font-size: 80rpx;
|
||||
}
|
||||
|
||||
.dev-title {
|
||||
color: $text-color-main;
|
||||
font-size: 36rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.dev-desc {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -176,6 +176,21 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 我的预约条目 -->
|
||||
<view class="menu-item" @tap="navigateTo('/pages/ice/bookings/index')">
|
||||
<view class="item-left">
|
||||
<view class="item-icon-container card-theme">
|
||||
<view class="svg-card-minimal">
|
||||
<view class="svg-card-chip" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="item-title">我的预约</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<view class="menu-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 切换场馆条目 -->
|
||||
<view class="menu-item" @tap="navigateTo('/pages/venue-select/index?mode=switch')">
|
||||
<view class="item-left">
|
||||
@@ -209,16 +224,13 @@
|
||||
</view>
|
||||
</block>
|
||||
<AgreementLinks v-if="isLoggedIn" class="profile-agreement-links" compact />
|
||||
<VenueTabBar venue="ice" active="profile" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { api, authStorage } from '@/modules/ice/api/member'
|
||||
import { errorMessage, getMemberSession, loginAndStoreToken, SESSION_STATUS } from '@/modules/ice/api/session'
|
||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||
import AgreementLinks from '@/components/AgreementLinks.vue'
|
||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||
@@ -271,10 +283,6 @@ const canSubmitBind = computed(() => {
|
||||
return Boolean(bindForm.value.name.trim()) && Boolean(bindForm.value.phoneCode)
|
||||
})
|
||||
|
||||
onShow(() => {
|
||||
refreshSession()
|
||||
})
|
||||
|
||||
const applySession = (session) => {
|
||||
sessionStatus.value = session.status
|
||||
sessionMessage.value = session.message || ''
|
||||
@@ -488,6 +496,8 @@ const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr)
|
||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
defineExpose({ onPageShow: refreshSession })
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<template>
|
||||
<view class="tab-page">
|
||||
<ComprehensiveMall v-if="isComprehensive" ref="contentRef" />
|
||||
<IceMall v-else ref="contentRef" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { getStoredVenue } from '@/utils/venue'
|
||||
import ComprehensiveMall from '@/pages/comprehensive/mall/index.vue'
|
||||
import IceMall from '@/pages/ice/mall/index.vue'
|
||||
|
||||
const isComprehensive = getStoredVenue() === 'comprehensive'
|
||||
const contentRef = ref()
|
||||
|
||||
onPullDownRefresh(async () => {
|
||||
await contentRef.value?.refresh?.()
|
||||
uni.stopPullDownRefresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<view class="tab-page">
|
||||
<ComprehensivePrivate v-if="isComprehensive" ref="contentRef" />
|
||||
<IcePrivate v-else ref="contentRef" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { getStoredVenue } from '@/utils/venue'
|
||||
import ComprehensivePrivate from '@/pages/comprehensive/private/index.vue'
|
||||
import IcePrivate from '@/pages/ice/private/index.vue'
|
||||
|
||||
const isComprehensive = getStoredVenue() === 'comprehensive'
|
||||
const contentRef = ref()
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<template>
|
||||
<view class="tab-page">
|
||||
<ComprehensiveProfile v-if="isComprehensive" ref="contentRef" />
|
||||
<IceProfile v-else ref="contentRef" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { getStoredVenue } from '@/utils/venue'
|
||||
import ComprehensiveProfile from '@/pages/comprehensive/profile/index.vue'
|
||||
import IceProfile from '@/pages/ice/profile/index.vue'
|
||||
|
||||
const isComprehensive = getStoredVenue() === 'comprehensive'
|
||||
const contentRef = ref()
|
||||
|
||||
onShow(() => {
|
||||
contentRef.value?.onPageShow?.()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.tab-page {
|
||||
min-height: 100vh;
|
||||
}
|
||||
</style>
|
||||
@@ -1,32 +1,13 @@
|
||||
<template>
|
||||
<view v-if="isReady" class="venue-select-page">
|
||||
<view class="header">
|
||||
<text class="title">选择场馆</text>
|
||||
<text class="subtitle">进入对应场馆后,会员、预约、订单和支付数据彼此独立。</text>
|
||||
</view>
|
||||
|
||||
<view v-if="selectedVenue" class="current-card">
|
||||
<text class="current-label">当前场馆</text>
|
||||
<text class="current-name">{{ selectedVenue.name }}</text>
|
||||
<button class="primary-button continue-btn" @tap="continueCurrentVenue">继续进入</button>
|
||||
</view>
|
||||
|
||||
<view class="venue-list">
|
||||
<view
|
||||
v-for="venue in venues"
|
||||
:key="venue.key"
|
||||
class="venue-card"
|
||||
:class="{ active: venue.key === currentVenue }"
|
||||
@tap="selectVenue(venue.key)"
|
||||
>
|
||||
<view class="venue-main">
|
||||
<text class="venue-name">{{ venue.name }}</text>
|
||||
<text class="venue-desc">{{ venue.description }}</text>
|
||||
</view>
|
||||
<view class="venue-action">
|
||||
<text>{{ venue.key === currentVenue ? '切换进入' : '进入' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view
|
||||
v-for="venue in venues"
|
||||
:key="venue.key"
|
||||
class="venue-half"
|
||||
:class="venue.key === 'comprehensive' ? 'dark' : 'light'"
|
||||
@tap="selectVenue(venue.key)"
|
||||
>
|
||||
<text class="venue-name">{{ venue.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -41,7 +22,6 @@ const venueStore = useVenueStore()
|
||||
const venues = VENUE_OPTIONS
|
||||
const isReady = ref(false)
|
||||
const currentVenue = computed(() => venueStore.currentVenue)
|
||||
const selectedVenue = computed(() => venueStore.currentVenueOption)
|
||||
|
||||
onLoad((options) => {
|
||||
const storedVenue = venueStore.init()
|
||||
@@ -58,122 +38,43 @@ onLoad((options) => {
|
||||
const selectVenue = (venueKey) => {
|
||||
venueStore.enterVenue(venueKey)
|
||||
}
|
||||
|
||||
const continueCurrentVenue = () => {
|
||||
if (!venueStore.currentVenue) return
|
||||
venueStore.enterVenue(venueStore.currentVenue)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.venue-select-page {
|
||||
min-height: 100vh;
|
||||
padding: 96rpx 32rpx 48rpx;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18rpx;
|
||||
margin-bottom: 56rpx;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $text-color-main;
|
||||
font-size: 48rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.venue-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24rpx;
|
||||
}
|
||||
|
||||
.current-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
margin-bottom: 32rpx;
|
||||
padding: 28rpx;
|
||||
border-radius: $border-radius-lg;
|
||||
background: $bg-color-main;
|
||||
border: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.current-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.current-name {
|
||||
color: $text-color-main;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.continue-btn {
|
||||
margin-top: 12rpx;
|
||||
}
|
||||
|
||||
.venue-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 24rpx;
|
||||
padding: 32rpx;
|
||||
border: 1rpx solid $border-color;
|
||||
border-radius: $border-radius-lg;
|
||||
background: $bg-color-main;
|
||||
}
|
||||
|
||||
.venue-card.active {
|
||||
border-color: $color-primary;
|
||||
background: $color-primary-light;
|
||||
}
|
||||
|
||||
.venue-main {
|
||||
display: flex;
|
||||
.venue-half {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex-direction: column;
|
||||
gap: 12rpx;
|
||||
}
|
||||
|
||||
.venue-name {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.venue-desc {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.venue-action {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 96rpx;
|
||||
height: 56rpx;
|
||||
border-radius: $border-radius-sm;
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.venue-card.active .venue-action {
|
||||
background: $color-primary;
|
||||
.venue-half:active {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.venue-half.dark {
|
||||
background: #0F172A;
|
||||
}
|
||||
|
||||
.venue-half.dark .venue-name {
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.venue-half.light {
|
||||
background: #F8FAFC;
|
||||
}
|
||||
|
||||
.venue-half.light .venue-name {
|
||||
color: #0F172A;
|
||||
}
|
||||
|
||||
.venue-name {
|
||||
font-size: 40rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
|
||||
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 425 B |
|
After Width: | Height: | Size: 422 B |
|
After Width: | Height: | Size: 404 B |
|
After Width: | Height: | Size: 401 B |
|
After Width: | Height: | Size: 425 B |
|
After Width: | Height: | Size: 422 B |
@@ -6,31 +6,17 @@ export const VENUE_KEYS = Object.freeze({
|
||||
})
|
||||
|
||||
export const VENUE_OPTIONS = Object.freeze([
|
||||
{
|
||||
key: VENUE_KEYS.ICE,
|
||||
name: '冰场馆',
|
||||
description: '课程预约、会员卡与滑冰服务',
|
||||
homePath: '/pages/ice/home/index'
|
||||
},
|
||||
{
|
||||
key: VENUE_KEYS.COMPREHENSIVE,
|
||||
name: '综合场馆',
|
||||
description: '课程、包场、门票、订单与余额',
|
||||
homePath: '/pages/comprehensive/home/index'
|
||||
}
|
||||
{ key: VENUE_KEYS.COMPREHENSIVE, name: '综合场馆' },
|
||||
{ key: VENUE_KEYS.ICE, name: '冰场馆' }
|
||||
])
|
||||
|
||||
export const isVenueKey = (value) => {
|
||||
return value === VENUE_KEYS.ICE || value === VENUE_KEYS.COMPREHENSIVE
|
||||
}
|
||||
export const isVenueKey = (value) => value === VENUE_KEYS.ICE || value === VENUE_KEYS.COMPREHENSIVE
|
||||
|
||||
export const getVenueOption = (venueKey) => {
|
||||
return VENUE_OPTIONS.find((item) => item.key === venueKey) || null
|
||||
}
|
||||
export const getVenueOption = (venueKey) => VENUE_OPTIONS.find((item) => item.key === venueKey) || null
|
||||
|
||||
export const getStoredVenue = () => {
|
||||
const venue = uni.getStorageSync(CURRENT_VENUE_KEY)
|
||||
return isVenueKey(venue) ? venue : ''
|
||||
return isVenueKey(venue) ? venue : VENUE_KEYS.COMPREHENSIVE
|
||||
}
|
||||
|
||||
export const setStoredVenue = (venueKey) => {
|
||||
@@ -38,18 +24,14 @@ export const setStoredVenue = (venueKey) => {
|
||||
uni.removeStorageSync(CURRENT_VENUE_KEY)
|
||||
return ''
|
||||
}
|
||||
|
||||
uni.setStorageSync(CURRENT_VENUE_KEY, venueKey)
|
||||
return venueKey
|
||||
}
|
||||
|
||||
export const navigateToVenueHome = (venueKey, options = {}) => {
|
||||
const venue = getVenueOption(venueKey)
|
||||
if (!venue) {
|
||||
export const navigateToVenueHome = (venueKey) => {
|
||||
if (!isVenueKey(venueKey)) {
|
||||
uni.reLaunch({ url: '/pages/venue-select/index' })
|
||||
return
|
||||
}
|
||||
|
||||
const navigate = options.redirect === true ? uni.redirectTo : uni.reLaunch
|
||||
navigate({ url: venue.homePath })
|
||||
uni.reLaunch({ url: '/pages/home/index' })
|
||||
}
|
||||
|
||||