Compare commits
16 Commits
1cc96633ad
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 7139865bff | |||
| d07356e9f1 | |||
| 30cad4e765 | |||
| 37eb77aede | |||
| d00304ec9c | |||
| 4b886840c0 | |||
| 0582a5f6a8 | |||
| 6a18d3de61 | |||
| 5487ece142 | |||
| b06cf69942 | |||
| 5c97e8b1f8 | |||
| 9b38518426 | |||
| f85c3300be | |||
| c26ede3e03 | |||
| df1e2fe267 | |||
| 4b11447875 |
@@ -10,15 +10,29 @@ const expectIncludes = (content, expected, label) => {
|
|||||||
if (!content.includes(expected)) failures.push(`${label} is missing: ${expected}`)
|
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 membershipApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/membership.js'), 'utf8')
|
||||||
const storedValueApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/storedValue.js'), 'utf8')
|
const storedValueApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/storedValue.js'), 'utf8')
|
||||||
const pagesJson = readFileSync(resolve(srcRoot, 'pages.json'), 'utf8')
|
const pagesJson = readFileSync(resolve(srcRoot, 'pages.json'), 'utf8')
|
||||||
const mallPage = readFileSync(resolve(srcRoot, 'pages/comprehensive/mall/index.vue'), '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/plans'", 'membership plans')
|
||||||
expectIncludes(membershipApi, "url: '/api/membership/orders'", 'membership order')
|
expectIncludes(membershipApi, "url: '/api/membership/orders'", 'membership order')
|
||||||
expectIncludes(membershipApi, 'data: { plan_id: planId }', 'membership payload')
|
expectIncludes(membershipApi, 'data: { plan_id: planId }', 'membership payload')
|
||||||
expectIncludes(membershipApi, 'toOrderBrief(data.order)', 'membership order mapping')
|
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/cards'", 'stored value cards')
|
||||||
expectIncludes(storedValueApi, "url: '/api/stored-value/orders'", 'stored value order')
|
expectIncludes(storedValueApi, "url: '/api/stored-value/orders'", 'stored value order')
|
||||||
expectIncludes(storedValueApi, 'data: { card_id: cardId }', 'stored value payload')
|
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(pagesJson, 'pages/comprehensive/mall/index', 'mall route')
|
||||||
expectIncludes(mallPage, '储蓄卡', 'stored value UI copy')
|
expectIncludes(mallPage, '储蓄卡', 'stored value UI copy')
|
||||||
expectIncludes(mallPage, 'face_value', 'face value display')
|
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) {
|
if (failures.length) {
|
||||||
console.error(`Comprehensive commerce verification failed with ${failures.length} issue(s):`)
|
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 courseApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/course.js'), 'utf8')
|
||||||
const bookingApi = readFileSync(resolve(srcRoot, 'modules/comprehensive/api/booking.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'", 'session list endpoint')
|
||||||
expectIncludes(courseApi, 'url: `/api/sessions/${id}`', 'session detail 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')
|
expectIncludes(bookingApi, 'session: toBookingSession(backendBooking.session)', 'booking session mapper')
|
||||||
expectExcludes(bookingApi, 'course: toBookingCourse(backendBooking.course)', 'legacy booking course')
|
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) {
|
if (failures.length) {
|
||||||
console.error(`Comprehensive session contract verification failed with ${failures.length} issue(s):`)
|
console.error(`Comprehensive session contract verification failed with ${failures.length} issue(s):`)
|
||||||
for (const failure of failures) console.error(`- ${failure}`)
|
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 srcRoot = resolve(root, 'src')
|
||||||
|
|
||||||
const tabPages = [
|
const tabPages = [
|
||||||
{ file: 'pages/ice/home/index.vue', venue: 'ice', active: 'home' },
|
{ file: 'pages/home/index.vue' },
|
||||||
{ file: 'pages/ice/courses/index.vue', venue: 'ice', active: 'courses' },
|
{ file: 'pages/private/index.vue' },
|
||||||
{ file: 'pages/ice/bookings/index.vue', venue: 'ice', active: 'bookings' },
|
{ file: 'pages/mall/index.vue' },
|
||||||
{ file: 'pages/ice/profile/index.vue', venue: 'ice', active: 'profile' },
|
{ file: 'pages/courses/index.vue' },
|
||||||
{ file: 'pages/comprehensive/home/index.vue', venue: 'comprehensive', active: 'home' },
|
{ file: 'pages/profile/index.vue' }
|
||||||
{ 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' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const icons = ['home', 'course', 'booking', 'mine']
|
const tabBarPages = [
|
||||||
const venues = ['ice', 'comprehensive']
|
'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 failures = []
|
||||||
|
|
||||||
const assertFile = (path) => {
|
const pagesJson = JSON.parse(readFileSync(resolve(srcRoot, 'pages.json'), 'utf8'))
|
||||||
if (!existsSync(path)) failures.push(`Missing file: ${path}`)
|
|
||||||
|
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 } of tabPages) {
|
||||||
|
|
||||||
for (const { file, venue, active } of tabPages) {
|
|
||||||
const path = resolve(srcRoot, file)
|
const path = resolve(srcRoot, file)
|
||||||
assertFile(path)
|
if (!existsSync(path)) {
|
||||||
if (!existsSync(path)) continue
|
failures.push(`Missing tab page: ${file}`)
|
||||||
|
|
||||||
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`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const venue of venues) {
|
for (const icon of icons) {
|
||||||
for (const icon of icons) {
|
const iconPath = resolve(srcRoot, `static/comprehensive/tab/${icon}.png`)
|
||||||
assertFile(resolve(srcRoot, `static/${venue}/tab/${icon}.png`))
|
const activeIconPath = resolve(srcRoot, `static/comprehensive/tab/${icon}-active.png`)
|
||||||
assertFile(resolve(srcRoot, `static/${venue}/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) {
|
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}`)
|
for (const failure of failures) console.error(`- ${failure}`)
|
||||||
process.exit(1)
|
process.exit(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('Venue tabbar verification passed.')
|
console.log('TabBar verification passed.')
|
||||||
|
|||||||
@@ -58,7 +58,7 @@ defineProps({
|
|||||||
},
|
},
|
||||||
primaryText: {
|
primaryText: {
|
||||||
type: String,
|
type: String,
|
||||||
default: '微信一键登录'
|
default: '点击登录'
|
||||||
},
|
},
|
||||||
loading: {
|
loading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
|
|||||||
@@ -1,13 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="venue-switch-header" @tap="switchVenue">
|
<view class="venue-switch-header" @tap="switchVenue">
|
||||||
<view class="venue-switch-main">
|
<text class="venue-name">{{ venueName }}</text>
|
||||||
<text class="venue-switch-kicker">当前场馆</text>
|
<view class="venue-arrow" />
|
||||||
<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>
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -26,74 +20,30 @@ const switchVenue = () => {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.venue-switch-header {
|
.venue-switch-header {
|
||||||
min-height: 96rpx;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: $spacing-md;
|
padding: $spacing-md $spacing-lg;
|
||||||
margin-bottom: $spacing-md;
|
|
||||||
padding: 18rpx 20rpx;
|
|
||||||
border: 1rpx solid rgba(226, 232, 240, 0.96);
|
|
||||||
border-radius: $border-radius-base;
|
border-radius: $border-radius-base;
|
||||||
background: rgba(255, 255, 255, 0.98);
|
background: $bg-color-main;
|
||||||
box-shadow: 0 10rpx 28rpx rgba(15, 23, 42, 0.04);
|
border: 1rpx solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.venue-switch-header:active {
|
.venue-switch-header:active {
|
||||||
background: $bg-color-hover;
|
background: $bg-color-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.venue-switch-main {
|
.venue-name {
|
||||||
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;
|
|
||||||
color: $text-color-main;
|
color: $text-color-main;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.25;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.venue-switch-action {
|
.venue-arrow {
|
||||||
flex-shrink: 0;
|
width: 16rpx;
|
||||||
display: inline-flex;
|
height: 16rpx;
|
||||||
align-items: center;
|
border-top: 3rpx solid $text-color-light;
|
||||||
justify-content: center;
|
border-right: 3rpx solid $text-color-light;
|
||||||
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;
|
|
||||||
transform: rotate(45deg);
|
transform: rotate(45deg);
|
||||||
}
|
}
|
||||||
</style>
|
</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 || '',
|
avatar_url: backendMember.avatar_url || '',
|
||||||
balance: backendMember.balance || '0.00',
|
balance: backendMember.balance || '0.00',
|
||||||
is_active: backendMember.is_active === true,
|
is_active: backendMember.is_active === true,
|
||||||
|
profile_completed: backendMember.profile_completed === true,
|
||||||
created_at: backendMember.created_at || ''
|
created_at: backendMember.created_at || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -33,7 +34,7 @@ export const authApi = {
|
|||||||
}
|
}
|
||||||
}).then(toWechatLogin)
|
}).then(toWechatLogin)
|
||||||
|
|
||||||
authStorage.setToken(loginData.access_token)
|
authStorage.setPendingToken(loginData.access_token)
|
||||||
return loginData
|
return loginData
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
import { compactParams, toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
import { compactParams, toArray, toNumber, toPageQuery, toPagination } from './mappers'
|
||||||
import { toSession } from './course'
|
|
||||||
|
|
||||||
const toBanner = (backendBanner = {}) => ({
|
const toBanner = (backendBanner = {}) => ({
|
||||||
id: backendBanner.id ?? null,
|
id: backendBanner.id ?? null,
|
||||||
@@ -22,7 +21,6 @@ const toArticle = (backendArticle = {}) => ({
|
|||||||
const toHomeContent = (backendData = {}) => ({
|
const toHomeContent = (backendData = {}) => ({
|
||||||
banners: toArray(backendData.banners).map(toBanner),
|
banners: toArray(backendData.banners).map(toBanner),
|
||||||
articles: toArray(backendData.articles).map(toArticle),
|
articles: toArray(backendData.articles).map(toArticle),
|
||||||
upcoming_courses: toArray(backendData.upcoming_courses).map(toSession),
|
|
||||||
venue: {
|
venue: {
|
||||||
name: backendData.venue?.name || ''
|
name: backendData.venue?.name || ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,9 @@ export const toSession = (backendSession = {}) => ({
|
|||||||
status_label: backendSession.status_label || backendSession.status || '',
|
status_label: backendSession.status_label || backendSession.status || '',
|
||||||
courts: toArray(backendSession.courts).map(toCourseCourt),
|
courts: toArray(backendSession.courts).map(toCourseCourt),
|
||||||
detail_html: backendSession.detail_html || '',
|
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 || '',
|
created_at: backendSession.created_at || '',
|
||||||
updated_at: backendSession.updated_at || ''
|
updated_at: backendSession.updated_at || ''
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ const toMember = (backendMember = {}) => ({
|
|||||||
avatar_url: backendMember.avatar_url || '',
|
avatar_url: backendMember.avatar_url || '',
|
||||||
balance: backendMember.balance || '0.00',
|
balance: backendMember.balance || '0.00',
|
||||||
is_active: backendMember.is_active === true,
|
is_active: backendMember.is_active === true,
|
||||||
|
profile_completed: backendMember.profile_completed === true,
|
||||||
created_at: backendMember.created_at || ''
|
created_at: backendMember.created_at || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
import { toArray, toNumber } from './mappers'
|
import { toArray, toNumber, toPageQuery, toPagination, compactParams } from './mappers'
|
||||||
import { toOrderBrief } from './order'
|
import { toOrderBrief } from './order'
|
||||||
|
|
||||||
const toPlan = (item = {}) => ({
|
const toPlan = (item = {}) => ({
|
||||||
@@ -15,6 +15,82 @@ const toPlan = (item = {}) => ({
|
|||||||
created_at: item.created_at || ''
|
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 = {
|
export const membershipApi = {
|
||||||
getPlans() {
|
getPlans() {
|
||||||
return request({
|
return request({
|
||||||
@@ -27,5 +103,44 @@ export const membershipApi = {
|
|||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: { plan_id: planId }
|
data: { plan_id: planId }
|
||||||
}).then((data) => toOrderBrief(data.order))
|
}).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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,12 +10,22 @@ const toQuoteCourt = (backendCourt = {}) => ({
|
|||||||
court_type_label: backendCourt.court_type_label || ''
|
court_type_label: backendCourt.court_type_label || ''
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toQuoteItem = (backendItem = {}) => ({
|
||||||
|
court_id: backendItem.court_id ?? null,
|
||||||
|
court_name: backendItem.court_name || '',
|
||||||
|
start_at: backendItem.start_at || '',
|
||||||
|
end_at: backendItem.end_at || '',
|
||||||
|
unit_count: toNumber(backendItem.unit_count),
|
||||||
|
amount: backendItem.amount || '0.00'
|
||||||
|
})
|
||||||
|
|
||||||
const toPrivateQuote = (backendQuote = {}) => ({
|
const toPrivateQuote = (backendQuote = {}) => ({
|
||||||
amount: backendQuote.amount || '0.00',
|
amount: backendQuote.amount || '0.00',
|
||||||
unit_count: toNumber(backendQuote.unit_count),
|
unit_count: toNumber(backendQuote.unit_count),
|
||||||
start_at: backendQuote.start_at || '',
|
start_at: backendQuote.start_at || '',
|
||||||
end_at: backendQuote.end_at || '',
|
end_at: backendQuote.end_at || '',
|
||||||
courts: toArray(backendQuote.courts).map(toQuoteCourt)
|
courts: toArray(backendQuote.courts).map(toQuoteCourt),
|
||||||
|
items: toArray(backendQuote.items).map(toQuoteItem)
|
||||||
})
|
})
|
||||||
|
|
||||||
const toPrivateCreateResult = (backendData = {}) => ({
|
const toPrivateCreateResult = (backendData = {}) => ({
|
||||||
@@ -36,14 +46,41 @@ const toPrivateBookingOptions = (backendData = {}) => ({
|
|||||||
court_type_label: backendData.court_type_label || '',
|
court_type_label: backendData.court_type_label || '',
|
||||||
open_time: backendData.open_time || '',
|
open_time: backendData.open_time || '',
|
||||||
close_time: backendData.close_time || '',
|
close_time: backendData.close_time || '',
|
||||||
unit_price: backendData.unit_price || '0.00',
|
duration_units: toNumber(backendData.duration_units, 1),
|
||||||
duration_units: toNumber(backendData.duration_units, 2),
|
|
||||||
duration_minutes: toNumber(backendData.duration_minutes, 60),
|
duration_minutes: toNumber(backendData.duration_minutes, 60),
|
||||||
amount_per_court: backendData.amount_per_court || '0.00',
|
|
||||||
total_court_count: toNumber(backendData.total_court_count),
|
total_court_count: toNumber(backendData.total_court_count),
|
||||||
time_options: toArray(backendData.time_options).map(toPrivateTimeOption)
|
time_options: toArray(backendData.time_options).map(toPrivateTimeOption)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const toGridCourt = (backendCourt = {}) => ({
|
||||||
|
id: backendCourt.id ?? null,
|
||||||
|
name: backendCourt.name || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toGridSlot = (backendSlot = {}) => ({
|
||||||
|
start: backendSlot.start || '',
|
||||||
|
end: backendSlot.end || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toGridOccupation = (backendOcc = {}) => ({
|
||||||
|
court_id: backendOcc.court_id ?? null,
|
||||||
|
start_at: backendOcc.start_at || '',
|
||||||
|
end_at: backendOcc.end_at || '',
|
||||||
|
source_type: backendOcc.source_type || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const toPrivateBookingGrid = (backendData = {}) => ({
|
||||||
|
date: backendData.date || '',
|
||||||
|
court_type: backendData.court_type || '',
|
||||||
|
court_type_label: backendData.court_type_label || '',
|
||||||
|
open_time: backendData.open_time || '',
|
||||||
|
close_time: backendData.close_time || '',
|
||||||
|
courts: toArray(backendData.courts).map(toGridCourt),
|
||||||
|
time_slots: toArray(backendData.time_slots).map(toGridSlot),
|
||||||
|
occupations: toArray(backendData.occupations).map(toGridOccupation),
|
||||||
|
slot_prices: backendData.slot_prices || {}
|
||||||
|
})
|
||||||
|
|
||||||
export const privateBookingApi = {
|
export const privateBookingApi = {
|
||||||
getPrivateBookingOptions(params = {}) {
|
getPrivateBookingOptions(params = {}) {
|
||||||
return request({
|
return request({
|
||||||
@@ -55,25 +92,33 @@ export const privateBookingApi = {
|
|||||||
})
|
})
|
||||||
}).then(toPrivateBookingOptions)
|
}).then(toPrivateBookingOptions)
|
||||||
},
|
},
|
||||||
|
|
||||||
|
getPrivateBookingGrid(params = {}) {
|
||||||
|
return request({
|
||||||
|
url: '/api/bookings/private/grid',
|
||||||
|
data: compactParams({
|
||||||
|
date: params.date,
|
||||||
|
court_type: params.courtType || params.court_type
|
||||||
|
})
|
||||||
|
}).then(toPrivateBookingGrid)
|
||||||
|
},
|
||||||
|
|
||||||
quotePrivateBooking(payload = {}) {
|
quotePrivateBooking(payload = {}) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/bookings/private/quote',
|
url: '/api/bookings/private/quote',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
court_ids: toArray(payload.court_ids),
|
items: toArray(payload.items)
|
||||||
start_at: payload.start_at,
|
|
||||||
end_at: payload.end_at
|
|
||||||
}
|
}
|
||||||
}).then(toPrivateQuote)
|
}).then(toPrivateQuote)
|
||||||
},
|
},
|
||||||
|
|
||||||
createPrivateBooking(payload = {}) {
|
createPrivateBooking(payload = {}) {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/bookings/private',
|
url: '/api/bookings/private',
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
data: {
|
data: {
|
||||||
court_ids: toArray(payload.court_ids),
|
items: toArray(payload.items)
|
||||||
start_at: payload.start_at,
|
|
||||||
end_at: payload.end_at
|
|
||||||
}
|
}
|
||||||
}).then(toPrivateCreateResult)
|
}).then(toPrivateCreateResult)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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'
|
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 normalizeError = (res, fallbackMessage = '请求服务失败') => {
|
||||||
const body = res?.data || res || {}
|
const body = res?.data || res || {}
|
||||||
return {
|
return {
|
||||||
@@ -19,16 +21,41 @@ export const authStorage = {
|
|||||||
getToken() {
|
getToken() {
|
||||||
return uni.getStorageSync(KEY_TOKEN)
|
return uni.getStorageSync(KEY_TOKEN)
|
||||||
},
|
},
|
||||||
|
setPendingToken(token) {
|
||||||
|
pendingToken = token
|
||||||
|
},
|
||||||
|
getPendingToken() {
|
||||||
|
return pendingToken
|
||||||
|
},
|
||||||
|
clearPendingToken() {
|
||||||
|
pendingToken = null
|
||||||
|
},
|
||||||
clear() {
|
clear() {
|
||||||
uni.removeStorageSync(KEY_TOKEN)
|
uni.removeStorageSync(KEY_TOKEN)
|
||||||
|
pendingToken = null
|
||||||
},
|
},
|
||||||
isLoggedIn() {
|
isLoggedIn() {
|
||||||
return !!uni.getStorageSync(KEY_TOKEN)
|
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 = {}) {
|
export function request(options = {}) {
|
||||||
const token = authStorage.getToken()
|
const token = authStorage.getToken() || authStorage.getPendingToken()
|
||||||
const header = {
|
const header = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
...(options.header || {})
|
...(options.header || {})
|
||||||
@@ -48,7 +75,9 @@ export function request(options = {}) {
|
|||||||
const body = res.data
|
const body = res.data
|
||||||
|
|
||||||
if (res.statusCode === 401 || body?.code === 40001) {
|
if (res.statusCode === 401 || body?.code === 40001) {
|
||||||
authStorage.clear()
|
redirectToLogin()
|
||||||
|
reject(normalizeError(res, ''))
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
|
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
|
||||||
@@ -76,7 +105,7 @@ export function request(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function uploadFile(options = {}) {
|
export function uploadFile(options = {}) {
|
||||||
const token = authStorage.getToken()
|
const token = authStorage.getToken() || authStorage.getPendingToken()
|
||||||
const header = {
|
const header = {
|
||||||
...(options.header || {})
|
...(options.header || {})
|
||||||
}
|
}
|
||||||
@@ -101,7 +130,9 @@ export function uploadFile(options = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (res.statusCode === 401 || body?.code === 40001) {
|
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) {
|
if (res.statusCode >= 200 && res.statusCode < 300 && body?.code === 0) {
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ const toTicketAccount = (backendAccount = {}) => ({
|
|||||||
|
|
||||||
const toTicketOrder = (backendData = {}) => ({
|
const toTicketOrder = (backendData = {}) => ({
|
||||||
order: toOrderBrief(backendData.order),
|
order: toOrderBrief(backendData.order),
|
||||||
product: toTicketProduct(backendData.product),
|
product: toTicketProduct(backendData.product)
|
||||||
quantity: toNumber(backendData.quantity)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
export const ticketApi = {
|
export const ticketApi = {
|
||||||
@@ -49,11 +48,10 @@ export const ticketApi = {
|
|||||||
data: toPageQuery(params)
|
data: toPageQuery(params)
|
||||||
}).then(toTicketAccount)
|
}).then(toTicketAccount)
|
||||||
},
|
},
|
||||||
createTicketOrder(quantity) {
|
createTicketOrder() {
|
||||||
return request({
|
return request({
|
||||||
url: '/api/tickets/orders',
|
url: '/api/tickets/orders',
|
||||||
method: 'POST',
|
method: 'POST'
|
||||||
data: { quantity }
|
|
||||||
}).then(toTicketOrder)
|
}).then(toTicketOrder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ export const useComprehensiveSessionStore = defineStore('comprehensiveVenueSessi
|
|||||||
state: () => ({
|
state: () => ({
|
||||||
isReady: false,
|
isReady: false,
|
||||||
isLoggedIn: false,
|
isLoggedIn: false,
|
||||||
|
needsSetup: false,
|
||||||
member: null
|
member: null
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
@@ -28,12 +29,23 @@ export const useComprehensiveSessionStore = defineStore('comprehensiveVenueSessi
|
|||||||
const loginResult = await loginByWechat()
|
const loginResult = await loginByWechat()
|
||||||
const data = await authApi.wechatLogin({ code: loginResult.code })
|
const data = await authApi.wechatLogin({ code: loginResult.code })
|
||||||
this.member = data.member
|
this.member = data.member
|
||||||
|
this.needsSetup = true
|
||||||
|
return data
|
||||||
|
},
|
||||||
|
completeLogin() {
|
||||||
|
const pendingToken = authStorage.getPendingToken()
|
||||||
|
if (pendingToken) {
|
||||||
|
authStorage.setToken(pendingToken)
|
||||||
|
authStorage.clearPendingToken()
|
||||||
this.isLoggedIn = true
|
this.isLoggedIn = true
|
||||||
|
this.needsSetup = false
|
||||||
|
}
|
||||||
},
|
},
|
||||||
logout() {
|
logout() {
|
||||||
authApi.logout()
|
authApi.logout()
|
||||||
this.member = null
|
this.member = null
|
||||||
this.isLoggedIn = false
|
this.isLoggedIn = false
|
||||||
|
this.needsSetup = false
|
||||||
},
|
},
|
||||||
async refreshMember() {
|
async refreshMember() {
|
||||||
if (!authStorage.isLoggedIn()) return
|
if (!authStorage.isLoggedIn()) return
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { request } from './request'
|
import { request } from './request'
|
||||||
import { toCourseViewModel } from './course'
|
|
||||||
|
|
||||||
const toNumber = (value) => {
|
const toNumber = (value) => {
|
||||||
const numberValue = Number(value)
|
const numberValue = Number(value)
|
||||||
@@ -24,9 +23,6 @@ const toActivitySummary = (backendActivity = {}) => ({
|
|||||||
|
|
||||||
const toHomeViewModel = (backendData = {}) => ({
|
const toHomeViewModel = (backendData = {}) => ({
|
||||||
banners: Array.isArray(backendData.banners) ? backendData.banners.map(toBanner) : [],
|
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) : [],
|
activities: Array.isArray(backendData.activities) ? backendData.activities.map(toActivitySummary) : [],
|
||||||
venue: {
|
venue: {
|
||||||
name: backendData.venue?.name || '奥林匹克滑冰中心',
|
name: backendData.venue?.name || '奥林匹克滑冰中心',
|
||||||
@@ -36,13 +32,11 @@ const toHomeViewModel = (backendData = {}) => ({
|
|||||||
export const homeApi = {
|
export const homeApi = {
|
||||||
getHome(options = {}) {
|
getHome(options = {}) {
|
||||||
const data = {
|
const data = {
|
||||||
course_limit: options.courseLimit || 2,
|
|
||||||
banner_limit: options.bannerLimit || 5
|
banner_limit: options.bannerLimit || 5
|
||||||
}
|
}
|
||||||
if (options.activityLimit !== undefined) {
|
if (options.activityLimit !== undefined) {
|
||||||
data.activity_limit = options.activityLimit
|
data.activity_limit = options.activityLimit
|
||||||
}
|
}
|
||||||
if (options.courseDate) data.course_date = options.courseDate
|
|
||||||
|
|
||||||
return request({
|
return request({
|
||||||
url: '/api/contents/home',
|
url: '/api/contents/home',
|
||||||
|
|||||||
@@ -12,29 +12,61 @@
|
|||||||
"path": "pages/venue-select/index",
|
"path": "pages/venue-select/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "选择场馆",
|
"navigationBarTitleText": "选择场馆",
|
||||||
|
"navigationStyle": "custom",
|
||||||
"navigationBarBackgroundColor": "#FFFFFF",
|
"navigationBarBackgroundColor": "#FFFFFF",
|
||||||
"navigationBarTextStyle": "black"
|
"navigationBarTextStyle": "black"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/comprehensive/home/index",
|
"path": "pages/home/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "首页",
|
"navigationBarTitleText": "首页",
|
||||||
|
"navigationStyle": "custom",
|
||||||
"enablePullDownRefresh": true
|
"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",
|
"path": "pages/comprehensive/courses/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "课程",
|
"navigationBarTitleText": "约课"
|
||||||
"enablePullDownRefresh": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/comprehensive/bookings/index",
|
"path": "pages/comprehensive/bookings/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "我的预约",
|
"navigationBarTitleText": "我的预约"
|
||||||
"enablePullDownRefresh": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -62,6 +94,12 @@
|
|||||||
"navigationBarTitleText": "课程详情"
|
"navigationBarTitleText": "课程详情"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/courses/membership-detail",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "次卡预约"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/comprehensive/courses/confirm",
|
"path": "pages/comprehensive/courses/confirm",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -71,7 +109,7 @@
|
|||||||
{
|
{
|
||||||
"path": "pages/comprehensive/private/index",
|
"path": "pages/comprehensive/private/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "包场预约"
|
"navigationBarTitleText": "订场"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -137,6 +175,12 @@
|
|||||||
"navigationBarTitleText": "个人信息"
|
"navigationBarTitleText": "个人信息"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/comprehensive/profile/login",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "登录"
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/comprehensive/mall/index",
|
"path": "pages/comprehensive/mall/index",
|
||||||
"style": {
|
"style": {
|
||||||
@@ -144,18 +188,62 @@
|
|||||||
"enablePullDownRefresh": true
|
"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",
|
"path": "pages/ice/home/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "首页",
|
"navigationBarTitleText": "首页"
|
||||||
"enablePullDownRefresh": true
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/private/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "订场"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/ice/mall/index",
|
||||||
|
"style": {
|
||||||
|
"navigationBarTitleText": "商城"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"path": "pages/ice/courses/index",
|
"path": "pages/ice/courses/index",
|
||||||
"style": {
|
"style": {
|
||||||
"navigationBarTitleText": "近期课程",
|
"navigationBarTitleText": "近期课程"
|
||||||
"enablePullDownRefresh": true
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -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": {
|
"globalStyle": {
|
||||||
"navigationBarTextStyle": "black",
|
"navigationBarTextStyle": "black",
|
||||||
"navigationBarBackgroundColor": "#FFFFFF",
|
"navigationBarBackgroundColor": "#FFFFFF",
|
||||||
|
|||||||
@@ -20,10 +20,6 @@
|
|||||||
<text>订单号</text>
|
<text>订单号</text>
|
||||||
<text>{{ booking.order.order_no }}</text>
|
<text>{{ booking.order.order_no }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="info-row">
|
|
||||||
<text>订单金额</text>
|
|
||||||
<text>¥{{ booking.order.amount }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="info-row">
|
<view class="info-row">
|
||||||
<text>订单状态</text>
|
<text>订单状态</text>
|
||||||
<text>{{ booking.order.status_label }}</text>
|
<text>{{ booking.order.status_label }}</text>
|
||||||
|
|||||||
@@ -12,25 +12,30 @@
|
|||||||
{{ tab.label }}
|
{{ tab.label }}
|
||||||
</view>
|
</view>
|
||||||
</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>
|
||||||
|
|
||||||
<view class="list">
|
<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">
|
<view class="card-top">
|
||||||
<text class="item-title">{{ getItemTitle(item) }}</text>
|
<text class="item-title">{{ getItemTitle(item) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="item-meta primary">{{ getItemTime(item) }}</text>
|
<text class="item-meta primary">{{ getItemTime(item) }}</text>
|
||||||
<text class="item-meta">{{ getItemDesc(item) }}</text>
|
<text class="item-meta">{{ getItemDesc(item) }}</text>
|
||||||
<view class="card-bottom">
|
<view class="card-bottom">
|
||||||
<text class="item-meta">订单金额</text>
|
<text class="item-meta">{{ isMembershipCourse ? '次卡预约' : '订单金额' }}</text>
|
||||||
<text class="amount">¥{{ item.order?.amount || '--' }}</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>
|
</view>
|
||||||
<view v-if="!isLoading && bookedItems.length === 0" class="empty-state">暂无已预约记录</view>
|
<view v-if="!isLoading && bookedItems.length === 0" class="empty-state">暂无已预约记录</view>
|
||||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<VenueTabBar venue="comprehensive" active="bookings" />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -38,13 +43,14 @@
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||||
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||||
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
|
||||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||||
|
|
||||||
const session = useComprehensiveSessionStore()
|
const session = useComprehensiveSessionStore()
|
||||||
const businessType = ref('course')
|
const businessType = ref('course')
|
||||||
|
const courseSubType = ref('regular')
|
||||||
const items = ref([])
|
const items = ref([])
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
|
|
||||||
@@ -54,8 +60,15 @@ const businessTabs = [
|
|||||||
{ label: '门票', value: 'ticket' }
|
{ label: '门票', value: 'ticket' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const courseSubTabs = [
|
||||||
|
{ label: '普通预约', value: 'regular' },
|
||||||
|
{ label: '次卡预约', value: 'membership' }
|
||||||
|
]
|
||||||
|
|
||||||
|
const isMembershipCourse = computed(() => businessType.value === 'course' && courseSubType.value === 'membership')
|
||||||
|
|
||||||
const bookedItems = computed(() => {
|
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 () => {
|
onMounted(async () => {
|
||||||
@@ -70,6 +83,12 @@ const login = async () => {
|
|||||||
|
|
||||||
const selectBusiness = (value) => {
|
const selectBusiness = (value) => {
|
||||||
businessType.value = value
|
businessType.value = value
|
||||||
|
if (value !== 'course') courseSubType.value = 'regular'
|
||||||
|
loadItems()
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectCourseSub = (value) => {
|
||||||
|
courseSubType.value = value
|
||||||
loadItems()
|
loadItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -77,8 +96,13 @@ const loadItems = async () => {
|
|||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
if (businessType.value === 'course') {
|
if (businessType.value === 'course') {
|
||||||
|
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 })
|
const data = await bookingApi.getCourseBookings({ page: 1, pageSize: 50 })
|
||||||
items.value = data.items
|
items.value = data.items
|
||||||
|
}
|
||||||
} else if (businessType.value === 'private') {
|
} else if (businessType.value === 'private') {
|
||||||
const data = await bookingApi.getPrivateBookings({ page: 1, pageSize: 50 })
|
const data = await bookingApi.getPrivateBookings({ page: 1, pageSize: 50 })
|
||||||
items.value = data.items
|
items.value = data.items
|
||||||
@@ -98,6 +122,8 @@ const loadItems = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh: loadItems })
|
||||||
|
|
||||||
const getItemTitle = (item) => {
|
const getItemTitle = (item) => {
|
||||||
if (businessType.value === 'course') return item.session?.title || '课程预约'
|
if (businessType.value === 'course') return item.session?.title || '课程预约'
|
||||||
if (businessType.value === 'private') return item.courts?.map((court) => court.name).join('、') || '包场预约'
|
if (businessType.value === 'private') return item.courts?.map((court) => court.name).join('、') || '包场预约'
|
||||||
@@ -111,14 +137,21 @@ const getItemTime = (item) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const getItemDesc = (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} 个场地`
|
if (businessType.value === 'private') return `共 ${item.courts?.length || 0} 个场地`
|
||||||
return item.status === 'available' ? '可到馆核销使用' : item.status_label
|
return item.status === 'available' ? '可到馆核销使用' : item.status_label
|
||||||
}
|
}
|
||||||
|
|
||||||
const goDetail = (item) => {
|
const goDetail = (item) => {
|
||||||
if (businessType.value === 'course') {
|
if (businessType.value === 'course') {
|
||||||
|
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}` })
|
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.id}` })
|
||||||
|
}
|
||||||
} else if (businessType.value === 'private') {
|
} else if (businessType.value === 'private') {
|
||||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/private-detail?id=${item.id}` })
|
uni.navigateTo({ url: `/pages/comprehensive/bookings/private-detail?id=${item.id}` })
|
||||||
} else if (item.order_id) {
|
} else if (item.order_id) {
|
||||||
@@ -165,6 +198,30 @@ const goDetail = (item) => {
|
|||||||
color: #FFFFFF;
|
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 {
|
.list {
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
}
|
}
|
||||||
@@ -212,4 +269,13 @@ const goDetail = (item) => {
|
|||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 900;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,139 +1,301 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="courses-page">
|
<view class="courses-page">
|
||||||
<view class="filter-bar">
|
<block>
|
||||||
<picker mode="date" :value="selectedDate" @change="onDateChange" class="filter-item">
|
<view class="tab-bar">
|
||||||
<view class="filter-inner">
|
<view class="tab-item" :class="{ active: activeTab === 'membership' }" @tap="switchTab('membership')">会员卡</view>
|
||||||
<text class="filter-label">日期</text>
|
<view class="tab-item" :class="{ active: activeTab === 'tickets' }" @tap="switchTab('tickets')">门票</view>
|
||||||
<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>
|
||||||
|
|
||||||
|
<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 class="course-list">
|
||||||
<view v-for="session in sessions" :key="session.id" class="course-card" @tap="goDetail(session.id)">
|
<view v-for="item in sessions" :key="item.id" class="card course-card" @tap="goDetail(item)">
|
||||||
<image :src="session.cover_image" mode="aspectFill" class="course-cover" />
|
<image :src="item.cover_image" mode="aspectFill" class="course-cover" />
|
||||||
<view class="course-info">
|
<view class="course-info">
|
||||||
<view class="course-top">
|
<view class="course-top">
|
||||||
<text class="course-title">{{ session.title }}</text>
|
<text class="course-title">{{ item.title }}</text>
|
||||||
|
<text v-if="item.booked" class="booked-badge">已预约</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="time-line">{{ formatTimeRange(session.start_at, session.end_at) }}</text>
|
<text class="time-line">{{ formatTimeRange(item.start_at, item.end_at) }}</text>
|
||||||
<text class="meta">{{ session.coach_name }}</text>
|
<text class="meta">{{ item.coach_name }}</text>
|
||||||
<view class="course-bottom">
|
<view class="course-bottom">
|
||||||
<text class="meta">已约 {{ session.booked_count }}/{{ session.capacity }}</text>
|
<text class="meta">剩余 {{ item.remaining_count }}/{{ item.capacity }}</text>
|
||||||
<text class="course-price">¥{{ session.price }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="isLoading" class="load-more-tip">加载中...</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="sessions.length === 0" class="empty-state">当天暂无课程</view>
|
||||||
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
|
||||||
</view>
|
</view>
|
||||||
<VenueTabBar venue="comprehensive" active="courses" />
|
</template>
|
||||||
|
</block>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
|
import dayjs from 'dayjs'
|
||||||
import { courseApi } from '@/modules/comprehensive/api/course'
|
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||||
import { venueApi } from '@/modules/comprehensive/api/venue'
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
import { formatTimeRange, todayString } from '@/modules/comprehensive/utils/date'
|
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
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 selectedDate = ref(todayString())
|
||||||
const selectedCoachIndex = ref(0)
|
|
||||||
const selectedCourtTypeIndex = ref(0)
|
|
||||||
const sessions = ref([])
|
const sessions = ref([])
|
||||||
const coaches = ref([])
|
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
const pageSize = 20
|
const pageSize = 20
|
||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const isLoadingMore = ref(false)
|
const isLoadingMore = ref(false)
|
||||||
|
let loadRequestId = 0
|
||||||
|
|
||||||
const courtTypeOptions = [
|
const ticketProduct = ref(null)
|
||||||
{ label: '全部', value: '' },
|
const ticketQty = ref(1)
|
||||||
{ label: '羽毛球', value: 'badminton' },
|
const ticketSubmitting = ref(false)
|
||||||
{ label: '篮球', value: 'basketball' }
|
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)
|
const hasMore = computed(() => sessions.value.length < total.value)
|
||||||
|
|
||||||
onMounted(async () => {
|
const switchTab = (tab) => {
|
||||||
const coachData = await venueApi.getCoaches()
|
if (activeTab.value === tab) return
|
||||||
coaches.value = coachData.items
|
activeTab.value = tab
|
||||||
|
if (tab === 'tickets') {
|
||||||
|
loadTicketProduct()
|
||||||
|
loadTicketRecords()
|
||||||
|
} else {
|
||||||
loadSessions(true)
|
loadSessions(true)
|
||||||
})
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onPullDownRefresh(() => {
|
const onDateSelect = (date) => {
|
||||||
loadSessions(true).finally(() => uni.stopPullDownRefresh())
|
if (selectedDate.value === date) return
|
||||||
})
|
selectedDate.value = date
|
||||||
|
loadSessions(true)
|
||||||
onReachBottom(() => {
|
}
|
||||||
if (!hasMore.value || isLoadingMore.value) return
|
|
||||||
page.value += 1
|
|
||||||
loadSessions(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadSessions = async (reset) => {
|
const loadSessions = async (reset) => {
|
||||||
|
if (activeTab.value === 'membership' && !session.isLoggedIn) {
|
||||||
|
sessions.value = []
|
||||||
|
total.value = 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const requestId = ++loadRequestId
|
||||||
if (reset) {
|
if (reset) {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
|
sessions.value = []
|
||||||
} else {
|
} else {
|
||||||
isLoadingMore.value = true
|
isLoadingMore.value = true
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const data = await courseApi.getSessions({
|
let data
|
||||||
page: page.value,
|
if (activeTab.value === 'membership') {
|
||||||
pageSize,
|
data = await membershipApi.getAvailableSessions({ page: page.value, pageSize, date: selectedDate.value })
|
||||||
date: selectedDate.value,
|
} else {
|
||||||
coachId: selectedCoachId.value,
|
data = await courseApi.getSessions({ page: page.value, pageSize, date: selectedDate.value })
|
||||||
courtType: selectedCourtType.value
|
}
|
||||||
})
|
if (requestId !== loadRequestId) return
|
||||||
sessions.value = reset ? data.items : [...sessions.value, ...data.items]
|
sessions.value = reset ? data.items : [...sessions.value, ...data.items]
|
||||||
total.value = data.total
|
total.value = data.total
|
||||||
|
} catch {
|
||||||
|
if (requestId !== loadRequestId) return
|
||||||
|
if (reset) {
|
||||||
|
sessions.value = []
|
||||||
|
total.value = 0
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (requestId === loadRequestId) {
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
isLoadingMore.value = false
|
isLoadingMore.value = false
|
||||||
}
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const onDateChange = (e) => {
|
const loadTicketProduct = async () => {
|
||||||
selectedDate.value = e.detail.value
|
if (ticketProduct.value) return
|
||||||
|
try {
|
||||||
|
ticketProduct.value = await ticketApi.getTicketProduct()
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 decreaseTicketQty = () => {
|
||||||
|
ticketQty.value = Math.max(1, ticketQty.value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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)
|
loadSessions(true)
|
||||||
}
|
} else if (activeTab.value === 'group') {
|
||||||
|
|
||||||
const onCoachChange = (e) => {
|
|
||||||
selectedCoachIndex.value = Number(e.detail.value)
|
|
||||||
loadSessions(true)
|
loadSessions(true)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
const onCourtTypeChange = (e) => {
|
defineExpose({ refresh, loadMore })
|
||||||
selectedCourtTypeIndex.value = Number(e.detail.value)
|
|
||||||
loadSessions(true)
|
|
||||||
}
|
|
||||||
|
|
||||||
const goDetail = (id) => {
|
|
||||||
uni.navigateTo({ url: `/pages/comprehensive/courses/detail?id=${id}` })
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -142,35 +304,93 @@ const goDetail = (id) => {
|
|||||||
background: $bg-color-soft;
|
background: $bg-color-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-bar {
|
.tab-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: repeat(3, 1fr);
|
height: 88rpx;
|
||||||
height: 94rpx;
|
|
||||||
background: $bg-color-main;
|
background: $bg-color-main;
|
||||||
border-bottom: 1rpx solid $border-color;
|
border-bottom: 1rpx solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-inner {
|
.tab-item {
|
||||||
height: 94rpx;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 96rpx;
|
||||||
|
height: 88rpx;
|
||||||
|
border-radius: $border-radius-sm;
|
||||||
|
gap: 4rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-label {
|
.date-chip.active {
|
||||||
color: $text-color-light;
|
background: $text-color-main;
|
||||||
font-size: 20rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-value {
|
.date-label {
|
||||||
margin-top: 4rpx;
|
font-size: 22rpx;
|
||||||
color: $text-color-main;
|
font-weight: 700;
|
||||||
|
color: $text-color-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip.active .date-label {
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-day {
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
|
color: $text-color-main;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-chip.active .date-day {
|
||||||
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-list {
|
.course-list {
|
||||||
@@ -181,7 +401,6 @@ const goDetail = (id) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.course-card {
|
.course-card {
|
||||||
@include minimal-card;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
}
|
}
|
||||||
@@ -199,19 +418,31 @@ const goDetail = (id) => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-top,
|
|
||||||
.course-bottom {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
gap: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.course-title {
|
.course-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
color: $text-color-main;
|
color: $text-color-main;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
line-height: 1.35;
|
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 {
|
.time-line {
|
||||||
@@ -229,9 +460,181 @@ const goDetail = (id) => {
|
|||||||
font-size: 23rpx;
|
font-size: 23rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-price {
|
.course-bottom {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
margin-top: 6rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 篮球门票 */
|
||||||
|
.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;
|
color: $text-color-main;
|
||||||
font-size: 28rpx;
|
font-size: 34rpx;
|
||||||
font-weight: 900;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,344 @@
|
|||||||
|
<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>
|
||||||
|
|
||||||
|
<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,
|
||||||
|
.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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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>
|
<template>
|
||||||
<view class="home-container">
|
<view class="home-container">
|
||||||
<VenueSwitchHeader venue-name="综合场馆" />
|
|
||||||
|
|
||||||
<view class="banner-wrapper">
|
<view class="banner-wrapper">
|
||||||
<swiper
|
<swiper
|
||||||
v-if="banners.length"
|
v-if="banners.length"
|
||||||
@@ -11,44 +9,19 @@
|
|||||||
interval="4000"
|
interval="4000"
|
||||||
duration="600"
|
duration="600"
|
||||||
indicator-dots
|
indicator-dots
|
||||||
indicator-color="rgba(15, 23, 42, 0.1)"
|
indicator-color="rgba(255, 255, 255, 0.4)"
|
||||||
indicator-active-color="#0F172A"
|
indicator-active-color="#FFFFFF"
|
||||||
>
|
>
|
||||||
<swiper-item v-for="banner in banners" :key="banner.id">
|
<swiper-item v-for="banner in banners" :key="banner.id">
|
||||||
<view class="banner-item">
|
<view class="banner-item">
|
||||||
<image :src="banner.image" mode="aspectFill" class="banner-img" />
|
<image :src="banner.image" mode="aspectFill" class="banner-img" />
|
||||||
<view class="banner-overlay">
|
|
||||||
<text class="banner-title">{{ banner.title }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</swiper-item>
|
</swiper-item>
|
||||||
</swiper>
|
</swiper>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view class="content-wrapper">
|
||||||
<view class="section-container">
|
<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>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view v-else class="empty-placeholder">今日暂无课程排期</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="section-container last-section">
|
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">公告活动</text>
|
<text class="section-title">公告活动</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -64,57 +37,51 @@
|
|||||||
</view>
|
</view>
|
||||||
<view v-else class="empty-placeholder">暂无公告活动</view>
|
<view v-else class="empty-placeholder">暂无公告活动</view>
|
||||||
</view>
|
</view>
|
||||||
<VenueTabBar venue="comprehensive" active="home" />
|
|
||||||
|
<VenueSwitchHeader venue-name="综合场馆" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
|
||||||
import { contentApi } from '@/modules/comprehensive/api/content'
|
import { contentApi } from '@/modules/comprehensive/api/content'
|
||||||
import { formatDate, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
import { formatDate } from '@/modules/comprehensive/utils/date'
|
||||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
|
||||||
import VenueSwitchHeader from '@/components/VenueSwitchHeader.vue'
|
import VenueSwitchHeader from '@/components/VenueSwitchHeader.vue'
|
||||||
|
|
||||||
const banners = ref([])
|
const banners = ref([])
|
||||||
const upcomingCourses = ref([])
|
|
||||||
const articles = ref([])
|
const articles = ref([])
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
loadPage()
|
loadPage()
|
||||||
})
|
})
|
||||||
|
|
||||||
onPullDownRefresh(() => {
|
|
||||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadPage = async () => {
|
const loadPage = async () => {
|
||||||
const home = await contentApi.getHomeContent({ banner_limit: 5, article_limit: 3 })
|
const home = await contentApi.getHomeContent({ banner_limit: 5, article_limit: 3 })
|
||||||
banners.value = home.banners || []
|
banners.value = home.banners || []
|
||||||
upcomingCourses.value = (home.upcoming_courses || []).slice(0, 2)
|
|
||||||
articles.value = home.articles || []
|
articles.value = home.articles || []
|
||||||
}
|
}
|
||||||
|
|
||||||
const go = (url) => {
|
const go = (url) => {
|
||||||
uni.navigateTo({ url })
|
uni.navigateTo({ url })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh: loadPage })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.home-container {
|
.home-container {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: $spacing-lg $spacing-md;
|
|
||||||
background-color: $bg-color-soft;
|
background-color: $bg-color-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-wrapper {
|
.banner-wrapper {
|
||||||
margin-bottom: $spacing-lg;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-swiper {
|
.banner-swiper {
|
||||||
height: 320rpx;
|
width: 100%;
|
||||||
border-radius: $border-radius-base;
|
height: 33.33vh;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-item {
|
.banner-item {
|
||||||
@@ -129,30 +96,12 @@ const go = (url) => {
|
|||||||
background-color: $bg-color-hover;
|
background-color: $bg-color-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-overlay {
|
.content-wrapper {
|
||||||
position: absolute;
|
padding: $spacing-lg $spacing-md;
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-container {
|
.section-container {
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-lg;
|
||||||
}
|
|
||||||
|
|
||||||
.section-container.last-section {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.section-header {
|
||||||
@@ -182,14 +131,12 @@ const go = (url) => {
|
|||||||
background-color: $text-color-main;
|
background-color: $text-color-main;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-list,
|
|
||||||
.activity-grid {
|
.activity-grid {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-item,
|
|
||||||
.activity-card {
|
.activity-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -199,67 +146,10 @@ const go = (url) => {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.course-item:active,
|
|
||||||
.activity-card:active {
|
.activity-card:active {
|
||||||
background-color: $bg-color-hover;
|
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 {
|
.activity-cover {
|
||||||
width: 180rpx;
|
width: 180rpx;
|
||||||
height: 130rpx;
|
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>
|
<template>
|
||||||
<view class="mall-page">
|
<view class="mall-page">
|
||||||
|
<view class="tab-bar">
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'stored_value' }"
|
||||||
|
@tap="activeTab = 'stored_value'"
|
||||||
|
>
|
||||||
|
储值卡
|
||||||
|
</view>
|
||||||
|
<view
|
||||||
|
class="tab-item"
|
||||||
|
:class="{ active: activeTab === 'membership' }"
|
||||||
|
@tap="activeTab = 'membership'"
|
||||||
|
>
|
||||||
|
会员卡
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
|
|
||||||
<view class="section">
|
<view v-else class="card-list">
|
||||||
<view class="section-header">
|
<view
|
||||||
<text class="section-title">次卡套餐</text>
|
v-for="item in currentList"
|
||||||
</view>
|
:key="item.id"
|
||||||
<view class="card-list">
|
class="card item-card"
|
||||||
<view v-for="plan in plans" :key="`plan-${plan.id}`" class="card plan-card">
|
@tap="goDetail(item)"
|
||||||
<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}` ? '提交中...' : '立即购买' }}
|
<view class="card-body">
|
||||||
</button>
|
<text class="item-name">{{ item.name }}</text>
|
||||||
</view>
|
<text class="item-price">¥{{ item.price }}</text>
|
||||||
<view v-if="!isLoading && plans.length === 0" class="empty-state">暂无可购买的次卡套餐</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="currentList.length === 0" class="empty-state">暂无可购买的商品</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>
|
|
||||||
<view v-if="!isLoading && cards.length === 0" class="empty-state">暂无可购买的储蓄卡</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
|
||||||
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
import { membershipApi } from '@/modules/comprehensive/api/membership'
|
||||||
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
|
import { storedValueApi } from '@/modules/comprehensive/api/storedValue'
|
||||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
|
||||||
const session = useComprehensiveSessionStore()
|
const session = useComprehensiveSessionStore()
|
||||||
|
const activeTab = ref('stored_value')
|
||||||
const plans = ref([])
|
const plans = ref([])
|
||||||
const cards = ref([])
|
const cards = ref([])
|
||||||
const isLoading = ref(false)
|
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 () => {
|
onMounted(async () => {
|
||||||
await session.init()
|
await session.init()
|
||||||
await loadPage()
|
await loadPage()
|
||||||
})
|
})
|
||||||
|
|
||||||
onPullDownRefresh(() => {
|
|
||||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadPage = async () => {
|
const loadPage = async () => {
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
@@ -94,79 +74,82 @@ const loadPage = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const ensureLogin = async () => {
|
const goDetail = (item) => {
|
||||||
if (session.isLoggedIn) return true
|
uni.navigateTo({
|
||||||
try {
|
url: `/pages/comprehensive/mall/detail?type=${item._type}&id=${item.id}`
|
||||||
await session.login()
|
})
|
||||||
return true
|
|
||||||
} catch (error) {
|
|
||||||
uni.showToast({ title: '登录失败,请重试', icon: 'none' })
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const buyPlan = async (plan) => {
|
defineExpose({ refresh: loadPage })
|
||||||
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 = ''
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.mall-page {
|
.mall-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: $spacing-md $spacing-md calc($spacing-lg + env(safe-area-inset-bottom));
|
|
||||||
background: $bg-color-soft;
|
background: $bg-color-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section {
|
.tab-bar {
|
||||||
margin-bottom: $spacing-lg;
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 10;
|
||||||
|
display: flex;
|
||||||
|
height: 88rpx;
|
||||||
|
background: $bg-color-main;
|
||||||
|
border-bottom: 1rpx solid $border-color;
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.tab-item {
|
||||||
margin-bottom: $spacing-sm;
|
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;
|
color: $text-color-main;
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 900;
|
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 {
|
.card-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: $spacing-md;
|
gap: $spacing-sm;
|
||||||
|
padding: $spacing-md;
|
||||||
|
padding-bottom: calc($spacing-lg + env(safe-area-inset-bottom));
|
||||||
}
|
}
|
||||||
|
|
||||||
.plan-card,
|
.item-card {
|
||||||
.value-card {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-top {
|
.item-card:active {
|
||||||
|
background-color: $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: baseline;
|
align-items: center;
|
||||||
gap: $spacing-sm;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -174,7 +157,7 @@ const buyCard = async (card) => {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
color: $text-color-main;
|
color: $text-color-main;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 900;
|
font-weight: 800;
|
||||||
line-height: 1.35;
|
line-height: 1.35;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,56 +166,4 @@ const buyCard = async (card) => {
|
|||||||
font-size: 36rpx;
|
font-size: 36rpx;
|
||||||
font-weight: 900;
|
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>
|
</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>
|
||||||
@@ -23,23 +23,12 @@
|
|||||||
|
|
||||||
<view class="card method-card">
|
<view class="card method-card">
|
||||||
<text class="card-title">支付方式</text>
|
<text class="card-title">支付方式</text>
|
||||||
<view
|
<view class="method-item active">
|
||||||
class="method-item"
|
|
||||||
:class="{ active: payMethod === 'balance', disabled: !canUseBalance }"
|
|
||||||
@tap="selectMethod('balance')"
|
|
||||||
>
|
|
||||||
<view>
|
|
||||||
<text class="method-title">余额支付</text>
|
|
||||||
<text class="method-sub">当前余额 ¥{{ balance.balance }}</text>
|
|
||||||
</view>
|
|
||||||
<text class="method-check">{{ payMethod === 'balance' ? '已选' : '' }}</text>
|
|
||||||
</view>
|
|
||||||
<view class="method-item" :class="{ active: payMethod === 'wechat' }" @tap="selectMethod('wechat')">
|
|
||||||
<view>
|
<view>
|
||||||
<text class="method-title">微信支付</text>
|
<text class="method-title">微信支付</text>
|
||||||
<text class="method-sub">使用微信支付完成订单</text>
|
<text class="method-sub">使用微信支付完成订单</text>
|
||||||
</view>
|
</view>
|
||||||
<text class="method-check">{{ payMethod === 'wechat' ? '已选' : '' }}</text>
|
<text class="method-check">已选</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
@@ -60,24 +49,19 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onUnmounted, ref } from 'vue'
|
import { computed, onUnmounted, ref } from 'vue'
|
||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
|
||||||
import { orderApi } from '@/modules/comprehensive/api/order'
|
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||||
import { toAmountNumber } from '@/modules/comprehensive/utils/money'
|
|
||||||
|
|
||||||
const orderId = ref('')
|
const orderId = ref('')
|
||||||
const order = ref(null)
|
const order = ref(null)
|
||||||
const balance = ref({ balance: '0.00' })
|
|
||||||
const payMethod = ref('balance')
|
|
||||||
const isSubmitting = ref(false)
|
const isSubmitting = ref(false)
|
||||||
const nowTimestamp = ref(Date.now())
|
const nowTimestamp = ref(Date.now())
|
||||||
let countdownTimer = null
|
let countdownTimer = null
|
||||||
|
|
||||||
const canUseBalance = computed(() => toAmountNumber(balance.value.balance) >= toAmountNumber(order.value?.amount))
|
const canPay = computed(() => order.value?.status === 'pending_pay')
|
||||||
const canPay = computed(() => order.value?.status === 'pending_pay' && (payMethod.value !== 'balance' || canUseBalance.value))
|
|
||||||
const payButtonText = computed(() => {
|
const payButtonText = computed(() => {
|
||||||
if (order.value?.status !== 'pending_pay') return '订单不可支付'
|
if (order.value?.status !== 'pending_pay') return '订单不可支付'
|
||||||
return payMethod.value === 'balance' ? '余额支付' : '微信支付'
|
return '微信支付'
|
||||||
})
|
})
|
||||||
const paymentRemainingSeconds = computed(() => {
|
const paymentRemainingSeconds = computed(() => {
|
||||||
if (!order.value?.expire_at) return 0
|
if (!order.value?.expire_at) return 0
|
||||||
@@ -102,13 +86,7 @@ onUnmounted(() => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const loadPage = async () => {
|
const loadPage = async () => {
|
||||||
const [orderData, balanceData] = await Promise.all([
|
order.value = await orderApi.getOrderDetail(orderId.value)
|
||||||
orderApi.getOrderDetail(orderId.value),
|
|
||||||
memberApi.getBalance()
|
|
||||||
])
|
|
||||||
order.value = orderData
|
|
||||||
balance.value = balanceData
|
|
||||||
if (!canUseBalance.value) payMethod.value = 'wechat'
|
|
||||||
updateCountdownTimer()
|
updateCountdownTimer()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,26 +108,13 @@ const stopCountdown = () => {
|
|||||||
|
|
||||||
const padTime = (value) => String(value).padStart(2, '0')
|
const padTime = (value) => String(value).padStart(2, '0')
|
||||||
|
|
||||||
const selectMethod = (method) => {
|
|
||||||
if (method === 'balance' && !canUseBalance.value) {
|
|
||||||
uni.showToast({ title: '余额不足', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
payMethod.value = method
|
|
||||||
}
|
|
||||||
|
|
||||||
const pay = async () => {
|
const pay = async () => {
|
||||||
if (!canPay.value || isSubmitting.value) return
|
if (!canPay.value || isSubmitting.value) return
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
if (payMethod.value === 'balance') {
|
|
||||||
await orderApi.payOrderByBalance(order.value.id)
|
|
||||||
uni.showToast({ title: '支付成功', icon: 'success' })
|
|
||||||
} else {
|
|
||||||
const paymentData = await orderApi.payOrderByWechat(order.value.id)
|
const paymentData = await orderApi.payOrderByWechat(order.value.id)
|
||||||
await requestWechatPayment(paymentData.wechat)
|
await requestWechatPayment(paymentData.wechat)
|
||||||
uni.showToast({ title: '支付已提交', icon: 'success' })
|
uni.showToast({ title: '支付已提交', icon: 'success' })
|
||||||
}
|
|
||||||
await loadPage()
|
await loadPage()
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
uni.redirectTo({ url: `/pages/comprehensive/orders/detail?id=${order.value.id}` })
|
uni.redirectTo({ url: `/pages/comprehensive/orders/detail?id=${order.value.id}` })
|
||||||
@@ -253,10 +218,6 @@ const requestWechatPayment = (wechatPayload) => new Promise((resolve, reject) =>
|
|||||||
color: $color-primary;
|
color: $color-primary;
|
||||||
}
|
}
|
||||||
|
|
||||||
.method-item.disabled {
|
|
||||||
opacity: 0.45;
|
|
||||||
}
|
|
||||||
|
|
||||||
.method-title,
|
.method-title,
|
||||||
.method-sub {
|
.method-sub {
|
||||||
display: block;
|
display: block;
|
||||||
|
|||||||
@@ -2,24 +2,26 @@
|
|||||||
<view class="confirm-page container page-bottom-safe" v-if="quote">
|
<view class="confirm-page container page-bottom-safe" v-if="quote">
|
||||||
<view class="card summary-card">
|
<view class="card summary-card">
|
||||||
<text class="card-title">{{ courtTypeLabel(payload.court_type) }}包场</text>
|
<text class="card-title">{{ courtTypeLabel(payload.court_type) }}包场</text>
|
||||||
<view class="summary-row">
|
<view
|
||||||
<text>预约场地</text>
|
v-for="(item, index) in quote.items"
|
||||||
<text>{{ quote.courts.map(item => item.name).join('、') }}</text>
|
:key="index"
|
||||||
|
class="item-row"
|
||||||
|
>
|
||||||
|
<view class="item-header">
|
||||||
|
<text class="item-name">{{ item.court_name }}</text>
|
||||||
|
<text class="item-amount">¥{{ item.amount }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="summary-row">
|
<view class="item-meta">
|
||||||
<text>预约时间</text>
|
<text>{{ formatTime(item.start_at) }}-{{ formatTime(item.end_at) }}</text>
|
||||||
<text>{{ formatTimeRange(quote.start_at, quote.end_at) }}</text>
|
<text>{{ formatDuration(item.start_at, item.end_at) }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="summary-row">
|
|
||||||
<text>预约时长</text>
|
|
||||||
<text>{{ formatDuration(quote.start_at, quote.end_at) }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="card fee-card">
|
<view class="card fee-card">
|
||||||
<text class="card-title">费用明细</text>
|
<text class="card-title">费用明细</text>
|
||||||
<view class="summary-row">
|
<view class="summary-row">
|
||||||
<text>半小时段</text>
|
<text>时段</text>
|
||||||
<text>{{ quote.unit_count }} 段</text>
|
<text>{{ quote.unit_count }} 段</text>
|
||||||
</view>
|
</view>
|
||||||
<view class="summary-row">
|
<view class="summary-row">
|
||||||
@@ -47,7 +49,7 @@ import { ref } from 'vue'
|
|||||||
import { onLoad } from '@dcloudio/uni-app'
|
import { onLoad } from '@dcloudio/uni-app'
|
||||||
import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking'
|
import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking'
|
||||||
import { courtTypeLabel } from '@/modules/comprehensive/utils/status'
|
import { courtTypeLabel } from '@/modules/comprehensive/utils/status'
|
||||||
import { formatDuration, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
import { formatDuration, formatTime } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
const payload = ref({})
|
const payload = ref({})
|
||||||
const quote = ref(null)
|
const quote = ref(null)
|
||||||
@@ -84,6 +86,42 @@ const submit = async () => {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
padding: $spacing-sm 0;
|
||||||
|
border-bottom: 1rpx solid $bg-color-hover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-name {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 28rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-amount {
|
||||||
|
color: $text-color-main;
|
||||||
|
font-size: 26rpx;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $spacing-md;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
|
}
|
||||||
|
|
||||||
.summary-row {
|
.summary-row {
|
||||||
min-height: 72rpx;
|
min-height: 72rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,41 +1,36 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="balance-page container">
|
<view class="balance-page">
|
||||||
<view class="balance-card card">
|
<view class="card balance-card">
|
||||||
<text class="label">当前余额</text>
|
<text class="balance-label">当前余额</text>
|
||||||
<text class="amount">¥{{ balance.balance }}</text>
|
<text class="balance-amount">¥{{ balance.balance }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="section-header">
|
<view v-if="records.length" class="record-list">
|
||||||
<text class="section-title">流水明细</text>
|
<view v-for="record in records" :key="record.id" class="card record-card">
|
||||||
</view>
|
|
||||||
<view v-for="record in records" :key="record.id" class="record-card card">
|
|
||||||
<view class="record-top">
|
<view class="record-top">
|
||||||
<text class="kind">{{ record.kind_label }}</text>
|
<text class="record-kind">{{ record.kind_label }}</text>
|
||||||
<text class="record-amount" :class="{ income: Number(record.amount) > 0 }">{{ record.amount }}</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>
|
</view>
|
||||||
<text class="remark">{{ record.remark }}</text>
|
|
||||||
<text class="time">{{ formatDateTime(record.created_at) }}</text>
|
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view v-if="records.length === 0" class="empty-state">暂无余额流水</view>
|
<view v-else class="empty-state">暂无余额流水</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||||
|
|
||||||
const balance = ref({ balance: '0.00' })
|
const balance = ref({ balance: '0.00' })
|
||||||
const records = ref([])
|
const records = ref([])
|
||||||
|
|
||||||
onMounted(loadPage)
|
|
||||||
|
|
||||||
onPullDownRefresh(() => {
|
|
||||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadPage = async () => {
|
const loadPage = async () => {
|
||||||
const [balanceData, recordData] = await Promise.all([
|
const [balanceData, recordData] = await Promise.all([
|
||||||
memberApi.getBalance(),
|
memberApi.getBalance(),
|
||||||
@@ -44,42 +39,63 @@ const loadPage = async () => {
|
|||||||
balance.value = balanceData
|
balance.value = balanceData
|
||||||
records.value = recordData.items
|
records.value = recordData.items
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadPage()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
.balance-page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg-color-soft;
|
||||||
|
}
|
||||||
|
|
||||||
.balance-card {
|
.balance-card {
|
||||||
min-height: 220rpx;
|
margin: $spacing-md;
|
||||||
|
min-height: 200rpx;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.label,
|
.balance-label {
|
||||||
.remark,
|
|
||||||
.time {
|
|
||||||
display: block;
|
|
||||||
color: $text-color-muted;
|
color: $text-color-muted;
|
||||||
font-size: 24rpx;
|
font-size: 26rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount {
|
.balance-amount {
|
||||||
margin-top: $spacing-sm;
|
margin-top: $spacing-sm;
|
||||||
color: $text-color-main;
|
color: $text-color-main;
|
||||||
font-size: 64rpx;
|
font-size: 72rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.record-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
|
padding: 0 $spacing-md $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
.record-card {
|
.record-card {
|
||||||
margin-bottom: $spacing-md;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.record-top {
|
.record-top {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: $spacing-md;
|
gap: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kind {
|
.record-kind {
|
||||||
color: $text-color-main;
|
color: $text-color-main;
|
||||||
font-size: 30rpx;
|
font-size: 30rpx;
|
||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
@@ -95,11 +111,17 @@ const loadPage = async () => {
|
|||||||
color: $color-success;
|
color: $color-success;
|
||||||
}
|
}
|
||||||
|
|
||||||
.remark {
|
.record-remark {
|
||||||
|
display: block;
|
||||||
margin-top: $spacing-sm;
|
margin-top: $spacing-sm;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time {
|
.record-time {
|
||||||
|
display: block;
|
||||||
margin-top: $spacing-xs;
|
margin-top: $spacing-xs;
|
||||||
|
color: $text-color-light;
|
||||||
|
font-size: 22rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -35,17 +35,22 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<view class="fixed-action-bar">
|
<view class="action-area">
|
||||||
<button class="primary-button save-button" :disabled="isSaving || !canSave" @tap="saveProfile">
|
<button class="primary-button save-button" :disabled="isSaving || !canSave" @tap="saveProfile">
|
||||||
{{ isSaving ? '保存中...' : '保存资料' }}
|
{{ isSaving ? '保存中...' : '保存资料' }}
|
||||||
</button>
|
</button>
|
||||||
|
<button class="ghost-button home-button" @tap="goHome">返回首页</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="load-more-tip">加载中...</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<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 { memberApi } from '@/modules/comprehensive/api/member'
|
||||||
|
import { authStorage } from '@/modules/comprehensive/api/auth'
|
||||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
|
|
||||||
const session = useComprehensiveSessionStore()
|
const session = useComprehensiveSessionStore()
|
||||||
@@ -54,29 +59,31 @@ const isSaving = ref(false)
|
|||||||
const isUploadingAvatar = ref(false)
|
const isUploadingAvatar = ref(false)
|
||||||
|
|
||||||
const avatarInitial = computed(() => (form.value?.nickname || '用户').slice(0, 1))
|
const avatarInitial = computed(() => (form.value?.nickname || '用户').slice(0, 1))
|
||||||
const canSave = computed(() => {
|
const canSave = computed(() => !!form.value?.nickname?.trim() && !!form.value?.avatar_url?.trim())
|
||||||
return !!form.value?.nickname?.trim() || !!form.value?.avatar_url?.trim()
|
|
||||||
})
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onLoad(async () => {
|
||||||
await session.init()
|
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 chooseAvatar = async (event) => {
|
||||||
const avatarUrl = event.detail?.avatarUrl
|
const avatarUrl = event.detail?.avatarUrl
|
||||||
if (!avatarUrl) {
|
if (!avatarUrl) return
|
||||||
uni.showToast({ title: '未获取到头像', icon: 'none' })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (isUploadingAvatar.value) return
|
if (isUploadingAvatar.value) return
|
||||||
|
|
||||||
isUploadingAvatar.value = true
|
isUploadingAvatar.value = true
|
||||||
try {
|
try {
|
||||||
const data = await memberApi.uploadAvatar(avatarUrl)
|
const data = await memberApi.uploadAvatar(avatarUrl)
|
||||||
form.value = { ...data.member }
|
form.value = { ...form.value, avatar_url: data.avatar_url }
|
||||||
await session.refreshMember()
|
} catch {
|
||||||
uni.showToast({ title: '头像已更新', icon: 'success' })
|
form.value = { ...form.value, avatar_url: avatarUrl }
|
||||||
} finally {
|
} finally {
|
||||||
isUploadingAvatar.value = false
|
isUploadingAvatar.value = false
|
||||||
}
|
}
|
||||||
@@ -91,8 +98,13 @@ const saveProfile = async () => {
|
|||||||
avatar_url: form.value.avatar_url
|
avatar_url: form.value.avatar_url
|
||||||
})
|
})
|
||||||
form.value = { ...data }
|
form.value = { ...data }
|
||||||
|
if (authStorage.getPendingToken()) {
|
||||||
|
session.completeLogin()
|
||||||
|
}
|
||||||
await session.refreshMember()
|
await session.refreshMember()
|
||||||
uni.showToast({ title: '已保存', icon: 'success' })
|
uni.showToast({ title: '已保存', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
|
||||||
} finally {
|
} finally {
|
||||||
isSaving.value = false
|
isSaving.value = false
|
||||||
}
|
}
|
||||||
@@ -100,14 +112,20 @@ const saveProfile = async () => {
|
|||||||
|
|
||||||
const bindPhone = async (event) => {
|
const bindPhone = async (event) => {
|
||||||
const code = event.detail?.code
|
const code = event.detail?.code
|
||||||
if (!code) {
|
if (!code) return
|
||||||
uni.showToast({ title: '未获取到手机号授权', icon: 'none' })
|
try {
|
||||||
return
|
|
||||||
}
|
|
||||||
const data = await memberApi.bindMemberPhone(code)
|
const data = await memberApi.bindMemberPhone(code)
|
||||||
form.value = { ...data }
|
form.value = { ...form.value, phone: data.phone }
|
||||||
await session.refreshMember()
|
|
||||||
uni.showToast({ title: '手机号已绑定', icon: 'success' })
|
uni.showToast({ title: '手机号已绑定', icon: 'success' })
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '获取手机号失败', icon: 'none' })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const goHome = () => {
|
||||||
|
if (authStorage.getPendingToken()) {
|
||||||
|
session.completeLogin()
|
||||||
|
}
|
||||||
|
uni.switchTab({ url: '/pages/home/index' })
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -115,6 +133,7 @@ const bindPhone = async (event) => {
|
|||||||
.profile-detail {
|
.profile-detail {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
|
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
|
||||||
background: $bg-color-soft;
|
background: $bg-color-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -227,7 +246,18 @@ const bindPhone = async (event) => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.action-area {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-sm;
|
||||||
|
padding: 0 $spacing-md;
|
||||||
|
}
|
||||||
|
|
||||||
.save-button {
|
.save-button {
|
||||||
flex: 1;
|
height: 88rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.home-button {
|
||||||
|
height: 88rpx;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -1,12 +1,20 @@
|
|||||||
<template>
|
<template>
|
||||||
<view class="profile-page container">
|
<view class="profile-page container">
|
||||||
<UnauthLoginPanel
|
<view v-if="!session.isLoggedIn" class="user-profile-hero" @tap="login">
|
||||||
v-if="!session.isLoggedIn"
|
<view class="hero-left">
|
||||||
@login="login"
|
<view class="hero-avatar placeholder">
|
||||||
/>
|
<text class="hero-placeholder-text">点击登录</text>
|
||||||
|
</view>
|
||||||
|
<view class="hero-meta">
|
||||||
|
<text class="hero-name">点击登录</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
<view class="hero-right">
|
||||||
|
<view class="hero-arrow" />
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
<block v-else>
|
<view v-else class="user-profile-hero" @tap="go('/pages/comprehensive/profile/detail')">
|
||||||
<view class="user-profile-hero" @tap="go('/pages/comprehensive/profile/detail')">
|
|
||||||
<view class="hero-left">
|
<view class="hero-left">
|
||||||
<image v-if="session.member?.avatar_url" :src="session.member.avatar_url" mode="aspectFill" class="hero-avatar" />
|
<image v-if="session.member?.avatar_url" :src="session.member.avatar_url" mode="aspectFill" class="hero-avatar" />
|
||||||
<view v-else class="hero-avatar placeholder">
|
<view v-else class="hero-avatar placeholder">
|
||||||
@@ -17,6 +25,7 @@
|
|||||||
<view v-if="session.member?.phone" class="hero-sub-row">
|
<view v-if="session.member?.phone" class="hero-sub-row">
|
||||||
<text class="hero-phone">{{ session.member.phone }}</text>
|
<text class="hero-phone">{{ session.member.phone }}</text>
|
||||||
</view>
|
</view>
|
||||||
|
<text class="hero-balance">余额 ¥{{ balanceText }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="hero-right">
|
<view class="hero-right">
|
||||||
@@ -49,7 +58,7 @@
|
|||||||
<view class="menu-arrow" />
|
<view class="menu-arrow" />
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
<view class="menu-item logout" @tap="logout">
|
<view v-if="session.isLoggedIn" class="menu-item logout" @tap="logout">
|
||||||
<view class="item-left">
|
<view class="item-left">
|
||||||
<view class="item-icon-container logout-theme">
|
<view class="item-icon-container logout-theme">
|
||||||
<view class="icon-logout">
|
<view class="icon-logout">
|
||||||
@@ -64,36 +73,38 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</block>
|
|
||||||
<AgreementLinks v-if="session.isLoggedIn" class="profile-agreement-links" compact />
|
|
||||||
<VenueTabBar venue="comprehensive" active="profile" />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||||
import AgreementLinks from '@/components/AgreementLinks.vue'
|
import { formatAmount } from '@/modules/comprehensive/utils/money'
|
||||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
|
||||||
|
|
||||||
const session = useComprehensiveSessionStore()
|
const session = useComprehensiveSessionStore()
|
||||||
const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1))
|
const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1))
|
||||||
|
const balance = ref('0.00')
|
||||||
|
const balanceText = computed(() => formatAmount(balance.value))
|
||||||
|
|
||||||
const menuItems = [
|
const menuItems = [
|
||||||
{ title: '包场预约', url: '/pages/comprehensive/private/index', theme: 'private-theme', icon: 'private' },
|
{ title: '我的会员卡', url: '/pages/comprehensive/membership/cards', theme: 'profile-theme', icon: 'profile' },
|
||||||
{ title: '篮球门票', url: '/pages/comprehensive/tickets/index', theme: 'ticket-theme', icon: 'ticket' },
|
|
||||||
{ title: '我的订单', url: '/pages/comprehensive/profile/orders', theme: 'order-theme', icon: 'order' },
|
{ 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/comprehensive/profile/balance', theme: 'order-theme', icon: 'order' }
|
||||||
{ title: '切换场馆', url: '/pages/venue-select/index?mode=switch', theme: 'profile-theme', icon: 'profile' }
|
|
||||||
]
|
]
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await session.init()
|
await session.init()
|
||||||
|
try {
|
||||||
|
const data = await memberApi.getBalance()
|
||||||
|
balance.value = data.balance
|
||||||
|
} catch {
|
||||||
|
balance.value = '0.00'
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const login = async () => {
|
const login = () => {
|
||||||
await session.login()
|
uni.navigateTo({ url: '/pages/comprehensive/profile/login' })
|
||||||
}
|
}
|
||||||
|
|
||||||
const logout = () => {
|
const logout = () => {
|
||||||
@@ -104,9 +115,70 @@ const logout = () => {
|
|||||||
const go = (url) => {
|
const go = (url) => {
|
||||||
uni.navigateTo({ url })
|
uni.navigateTo({ url })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ onPageShow: () => {} })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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 {
|
.user-profile-hero {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -150,6 +222,12 @@ const go = (url) => {
|
|||||||
background: $color-primary-light;
|
background: $color-primary-light;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-placeholder-text {
|
||||||
|
font-size: 20rpx;
|
||||||
|
color: $text-color-muted;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-meta {
|
.hero-meta {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -159,6 +237,7 @@ const go = (url) => {
|
|||||||
|
|
||||||
.hero-name,
|
.hero-name,
|
||||||
.hero-phone,
|
.hero-phone,
|
||||||
|
.hero-balance,
|
||||||
.menu-title {
|
.menu-title {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
@@ -176,6 +255,13 @@ const go = (url) => {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hero-balance {
|
||||||
|
margin-top: 4rpx;
|
||||||
|
color: $color-primary;
|
||||||
|
font-size: 24rpx;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
.hero-arrow,
|
.hero-arrow,
|
||||||
.menu-arrow {
|
.menu-arrow {
|
||||||
width: 18rpx;
|
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>
|
<template>
|
||||||
<view class="orders-page">
|
<view class="orders-page">
|
||||||
<view class="filter-panel">
|
<scroll-view scroll-x class="filter-bar" :show-scrollbar="false">
|
||||||
<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>
|
|
||||||
</view>
|
|
||||||
</scroll-view>
|
|
||||||
<scroll-view scroll-x class="filter-scroll second">
|
|
||||||
<view class="filter-row">
|
<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)">
|
<view v-for="item in statusOptions" :key="item.value" class="filter-chip" :class="{ active: orderStatus === item.value }" @tap="selectStatus(item.value)">
|
||||||
{{ item.label }}
|
{{ item.label }}
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</scroll-view>
|
</scroll-view>
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="list">
|
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||||
<view v-for="order in orders" :key="order.id" class="order-card card" @tap="goDetail(order.id)">
|
|
||||||
|
<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">
|
<view class="order-top">
|
||||||
<text class="business">{{ order.business_type_label }}</text>
|
<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>
|
</view>
|
||||||
<text class="order-no">{{ order.order_no }}</text>
|
<text class="order-no">{{ order.order_no }}</text>
|
||||||
<view class="order-bottom">
|
<view class="order-bottom">
|
||||||
@@ -29,31 +22,22 @@
|
|||||||
<text class="amount">¥{{ order.amount }}</text>
|
<text class="amount">¥{{ order.amount }}</text>
|
||||||
</view>
|
</view>
|
||||||
<view v-if="order.status === 'pending_pay'" class="actions" @tap.stop>
|
<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>
|
<button class="primary-button small-btn" @tap="goPay(order.id)">去支付</button>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
<view v-if="!hasMore && orders.length >= pageSize" class="load-more-tip">已加载全部</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>
|
</view>
|
||||||
|
|
||||||
|
<view v-else class="empty-state">暂无订单</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||||
import { orderApi } from '@/modules/comprehensive/api/order'
|
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
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 = [
|
const statusOptions = [
|
||||||
{ label: '全部', value: '' },
|
{ label: '全部', value: '' },
|
||||||
@@ -63,7 +47,6 @@ const statusOptions = [
|
|||||||
{ label: '已退款', value: 'refunded' }
|
{ label: '已退款', value: 'refunded' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const businessType = ref('')
|
|
||||||
const orderStatus = ref('')
|
const orderStatus = ref('')
|
||||||
const orders = ref([])
|
const orders = ref([])
|
||||||
const page = ref(1)
|
const page = ref(1)
|
||||||
@@ -74,18 +57,6 @@ const isLoadingMore = ref(false)
|
|||||||
|
|
||||||
const hasMore = computed(() => orders.value.length < total.value)
|
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) => {
|
const loadOrders = async (reset) => {
|
||||||
if (reset) {
|
if (reset) {
|
||||||
page.value = 1
|
page.value = 1
|
||||||
@@ -97,7 +68,6 @@ const loadOrders = async (reset) => {
|
|||||||
const data = await orderApi.getOrders({
|
const data = await orderApi.getOrders({
|
||||||
page: page.value,
|
page: page.value,
|
||||||
pageSize,
|
pageSize,
|
||||||
businessType: businessType.value,
|
|
||||||
status: orderStatus.value
|
status: orderStatus.value
|
||||||
})
|
})
|
||||||
orders.value = reset ? data.items : [...orders.value, ...data.items]
|
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) => {
|
const selectStatus = (value) => {
|
||||||
orderStatus.value = value
|
orderStatus.value = value
|
||||||
loadOrders(true)
|
loadOrders(true)
|
||||||
@@ -131,6 +96,18 @@ const closeOrder = async (id) => {
|
|||||||
uni.showToast({ title: '订单已取消', icon: 'none' })
|
uni.showToast({ title: '订单已取消', icon: 'none' })
|
||||||
loadOrders(true)
|
loadOrders(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPullDownRefresh(() => {
|
||||||
|
loadOrders(true).finally(() => uni.stopPullDownRefresh())
|
||||||
|
})
|
||||||
|
|
||||||
|
onReachBottom(() => {
|
||||||
|
if (!hasMore.value || isLoadingMore.value) return
|
||||||
|
page.value += 1
|
||||||
|
loadOrders(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
loadOrders(true)
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -139,34 +116,26 @@ const closeOrder = async (id) => {
|
|||||||
background: $bg-color-soft;
|
background: $bg-color-soft;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-panel {
|
.filter-bar {
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
padding: $spacing-md;
|
|
||||||
background: $bg-color-main;
|
background: $bg-color-main;
|
||||||
border-bottom: 1rpx solid $border-color;
|
border-bottom: 1rpx solid $border-color;
|
||||||
}
|
|
||||||
|
|
||||||
.filter-scroll {
|
|
||||||
width: 100%;
|
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-scroll.second {
|
|
||||||
margin-top: $spacing-sm;
|
|
||||||
}
|
|
||||||
|
|
||||||
.filter-row {
|
.filter-row {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
gap: $spacing-sm;
|
padding: $spacing-sm $spacing-xs;
|
||||||
|
gap: $spacing-xs;
|
||||||
}
|
}
|
||||||
|
|
||||||
.filter-chip {
|
.filter-chip {
|
||||||
min-width: 118rpx;
|
min-width: 100rpx;
|
||||||
height: 62rpx;
|
height: 64rpx;
|
||||||
padding: 0 $spacing-sm;
|
padding: 0 $spacing-md;
|
||||||
border-radius: $border-radius-base;
|
border-radius: $border-radius-sm;
|
||||||
background: $bg-color-hover;
|
background: $bg-color-hover;
|
||||||
color: $text-color-muted;
|
color: $text-color-muted;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
@@ -181,21 +150,23 @@ const closeOrder = async (id) => {
|
|||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list {
|
.order-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $spacing-md;
|
||||||
padding: $spacing-md;
|
padding: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-card {
|
.order-card {
|
||||||
margin-bottom: $spacing-md;
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-top,
|
.order-top {
|
||||||
.order-bottom,
|
|
||||||
.actions {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: $spacing-md;
|
gap: $spacing-sm;
|
||||||
}
|
}
|
||||||
|
|
||||||
.business {
|
.business {
|
||||||
@@ -204,19 +175,51 @@ const closeOrder = async (id) => {
|
|||||||
font-weight: 900;
|
font-weight: 900;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-no,
|
.status-tag {
|
||||||
.time {
|
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;
|
display: block;
|
||||||
|
margin-top: $spacing-sm;
|
||||||
color: $text-color-muted;
|
color: $text-color-muted;
|
||||||
font-size: 24rpx;
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-no {
|
.order-bottom {
|
||||||
margin-top: $spacing-sm;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: $spacing-md;
|
||||||
|
margin-top: $spacing-md;
|
||||||
}
|
}
|
||||||
|
|
||||||
.order-bottom {
|
.time {
|
||||||
margin-top: $spacing-md;
|
color: $text-color-muted;
|
||||||
|
font-size: 24rpx;
|
||||||
}
|
}
|
||||||
|
|
||||||
.amount {
|
.amount {
|
||||||
@@ -226,14 +229,16 @@ const closeOrder = async (id) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.actions {
|
.actions {
|
||||||
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
|
gap: $spacing-sm;
|
||||||
margin-top: $spacing-md;
|
margin-top: $spacing-md;
|
||||||
padding-top: $spacing-md;
|
padding-top: $spacing-md;
|
||||||
border-top: 1rpx solid $bg-color-hover;
|
border-top: 1rpx solid $bg-color-hover;
|
||||||
}
|
}
|
||||||
|
|
||||||
.small-btn {
|
.small-btn {
|
||||||
width: 180rpx;
|
width: 160rpx;
|
||||||
height: 64rpx;
|
height: 64rpx;
|
||||||
font-size: 24rpx;
|
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
|
if (isSubmitting.value || !product.value?.is_active) return
|
||||||
isSubmitting.value = true
|
isSubmitting.value = true
|
||||||
try {
|
try {
|
||||||
const data = await ticketApi.createTicketOrder(quantity.value)
|
const orderIds = []
|
||||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${data.order.id}` })
|
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 {
|
} finally {
|
||||||
isSubmitting.value = false
|
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>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<VenueTabBar venue="ice" active="bookings" />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -117,7 +116,6 @@ import { onPullDownRefresh, onReachBottom, onShow } from '@dcloudio/uni-app'
|
|||||||
import { bookingApi } from '@/modules/ice/api/booking'
|
import { bookingApi } from '@/modules/ice/api/booking'
|
||||||
import { authStorage } from '@/modules/ice/api/member'
|
import { authStorage } from '@/modules/ice/api/member'
|
||||||
import { errorMessage, getMemberSession, loginAndStoreToken, sessionStatusFromError, SESSION_STATUS } from '@/modules/ice/api/session'
|
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 StatePanel from '@/components/ice/StatePanel.vue'
|
||||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||||
|
|
||||||
@@ -297,7 +295,7 @@ const applySessionError = (err) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const goProfile = () => {
|
const goProfile = () => {
|
||||||
uni.reLaunch({ url: '/pages/ice/profile/index' })
|
uni.switchTab({ url: '/pages/profile/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -90,16 +90,13 @@
|
|||||||
compact
|
compact
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
<VenueTabBar venue="ice" active="courses" />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted } from 'vue'
|
||||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
|
||||||
import { courseApi } from '@/modules/ice/api/course'
|
import { courseApi } from '@/modules/ice/api/course'
|
||||||
import { errorMessage } from '@/modules/ice/api/session'
|
import { errorMessage } from '@/modules/ice/api/session'
|
||||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
|
||||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||||
|
|
||||||
const selectedDate = ref('')
|
const selectedDate = ref('')
|
||||||
@@ -138,20 +135,6 @@ onMounted(() => {
|
|||||||
loadInitialData()
|
loadInitialData()
|
||||||
})
|
})
|
||||||
|
|
||||||
onPullDownRefresh(() => {
|
|
||||||
page.value = 1
|
|
||||||
loadCourses().finally(() => {
|
|
||||||
uni.stopPullDownRefresh()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
onReachBottom(() => {
|
|
||||||
if (hasMore.value && !isLoadingMore.value) {
|
|
||||||
page.value++
|
|
||||||
loadMoreCourses()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadInitialData = async () => {
|
const loadInitialData = async () => {
|
||||||
await Promise.all([loadCoaches(), loadCourses()])
|
await Promise.all([loadCoaches(), loadCourses()])
|
||||||
}
|
}
|
||||||
@@ -242,6 +225,20 @@ const formatTimeRange = (startStr, endStr) => {
|
|||||||
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
|
const endHhMm = `${pad(end.getHours())}:${pad(end.getMinutes())}`
|
||||||
return `${mm}-${dd} ${startHhMm} - ${endHhMm}`
|
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>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
@@ -10,9 +10,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<block v-else>
|
<block v-else>
|
||||||
<VenueSwitchHeader venue-name="冰场馆" />
|
<!-- 全屏轮播 -->
|
||||||
|
|
||||||
<!-- 极简无感大图轮播 -->
|
|
||||||
<view class="banner-wrapper">
|
<view class="banner-wrapper">
|
||||||
<swiper
|
<swiper
|
||||||
class="banner-swiper"
|
class="banner-swiper"
|
||||||
@@ -21,51 +19,21 @@
|
|||||||
interval="4000"
|
interval="4000"
|
||||||
duration="600"
|
duration="600"
|
||||||
indicator-dots
|
indicator-dots
|
||||||
indicator-color="rgba(15, 23, 42, 0.1)"
|
indicator-color="rgba(255, 255, 255, 0.4)"
|
||||||
indicator-active-color="#0F172A"
|
indicator-active-color="#FFFFFF"
|
||||||
>
|
>
|
||||||
<swiper-item v-for="(banner, index) in banners" :key="index" @tap="onTapBanner(banner)">
|
<swiper-item v-for="(banner, index) in banners" :key="index" @tap="onTapBanner(banner)">
|
||||||
<view class="banner-item">
|
<view class="banner-item">
|
||||||
<image :src="banner.image" mode="aspectFill" class="banner-img" />
|
<image :src="banner.image" mode="aspectFill" class="banner-img" />
|
||||||
<view class="banner-overlay">
|
|
||||||
<text class="banner-title">{{ banner.title }}</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
</view>
|
||||||
</swiper-item>
|
</swiper-item>
|
||||||
</swiper>
|
</swiper>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
<!-- 近期课程板块 (克制留白,无封面纯排版) -->
|
<!-- 内容区域 -->
|
||||||
<view class="section-container">
|
<view class="content-wrapper">
|
||||||
<view class="section-header">
|
|
||||||
<text class="section-title">近期课程</text>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<view class="course-list">
|
|
||||||
<view
|
|
||||||
v-for="course in upcomingCourses"
|
|
||||||
:key="course.id"
|
|
||||||
class="course-item"
|
|
||||||
@tap="navigateToDetail('/pages/ice/courses/detail?id=' + course.id)"
|
|
||||||
>
|
|
||||||
<image :src="course.cover" mode="aspectFill" class="course-cover" />
|
|
||||||
<view class="course-info-wrap">
|
|
||||||
<text class="course-name">{{ course.title }}</text>
|
|
||||||
<text class="course-time highlight-blue">时间:{{ formatCourseDate(course.start_time, course.end_time) }}</text>
|
|
||||||
<view class="course-bottom-meta">
|
|
||||||
<text class="course-meta-text">• 教练:{{ course.coach_name }}</text>
|
|
||||||
<text class="course-meta-text">• 人数:已约 {{ course.booked_count }}/{{ course.capacity }}人</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
<view v-if="upcomingCourses.length === 0" class="empty-placeholder">
|
|
||||||
<text>今日暂无课程排期</text>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
</view>
|
|
||||||
|
|
||||||
<!-- 活动资讯版块 -->
|
<!-- 活动资讯版块 -->
|
||||||
<view class="section-container last-section">
|
<view class="section-container">
|
||||||
<view class="section-header">
|
<view class="section-header">
|
||||||
<text class="section-title">公告活动</text>
|
<text class="section-title">公告活动</text>
|
||||||
</view>
|
</view>
|
||||||
@@ -85,23 +53,22 @@
|
|||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
|
|
||||||
|
<VenueSwitchHeader venue-name="冰场馆" />
|
||||||
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<VenueTabBar venue="ice" active="home" />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, onMounted } from 'vue'
|
import { ref, onMounted } from 'vue'
|
||||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
|
||||||
import { homeApi } from '@/modules/ice/api/home'
|
import { homeApi } from '@/modules/ice/api/home'
|
||||||
import { errorMessage } from '@/modules/ice/api/session'
|
import { errorMessage } from '@/modules/ice/api/session'
|
||||||
import VenueTabBar from '@/components/VenueTabBar.vue'
|
|
||||||
import VenueSwitchHeader from '@/components/VenueSwitchHeader.vue'
|
import VenueSwitchHeader from '@/components/VenueSwitchHeader.vue'
|
||||||
import StatePanel from '@/components/ice/StatePanel.vue'
|
import StatePanel from '@/components/ice/StatePanel.vue'
|
||||||
|
|
||||||
const todayStr = ref('')
|
const todayStr = ref('')
|
||||||
const banners = ref([])
|
const banners = ref([])
|
||||||
const upcomingCourses = ref([])
|
|
||||||
const activities = ref([])
|
const activities = ref([])
|
||||||
const pageState = ref('ready')
|
const pageState = ref('ready')
|
||||||
const pageMessage = ref('')
|
const pageMessage = ref('')
|
||||||
@@ -113,20 +80,12 @@ onMounted(() => {
|
|||||||
loadHome()
|
loadHome()
|
||||||
})
|
})
|
||||||
|
|
||||||
onPullDownRefresh(() => {
|
|
||||||
loadHome().finally(() => {
|
|
||||||
uni.stopPullDownRefresh()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
const loadHome = async () => {
|
const loadHome = async () => {
|
||||||
try {
|
try {
|
||||||
const data = await homeApi.getHome({
|
const data = await homeApi.getHome({
|
||||||
courseLimit: 2,
|
|
||||||
bannerLimit: 5
|
bannerLimit: 5
|
||||||
})
|
})
|
||||||
banners.value = data.banners
|
banners.value = data.banners
|
||||||
upcomingCourses.value = data.upcomingCourses
|
|
||||||
activities.value = data.activities
|
activities.value = data.activities
|
||||||
pageState.value = 'ready'
|
pageState.value = 'ready'
|
||||||
pageMessage.value = ''
|
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 formatDate = (dateStr) => {
|
||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ refresh: loadHome })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.home-container {
|
.home-container {
|
||||||
padding: $spacing-lg $spacing-md;
|
|
||||||
background-color: $bg-color-soft;
|
background-color: $bg-color-soft;
|
||||||
min-height: 100vh;
|
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 {
|
.banner-wrapper {
|
||||||
margin-bottom: $spacing-lg;
|
width: 100%;
|
||||||
|
|
||||||
.banner-swiper {
|
.banner-swiper {
|
||||||
height: 320rpx;
|
width: 100%;
|
||||||
border-radius: $border-radius-base;
|
height: 33.33vh;
|
||||||
overflow: hidden;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.banner-item {
|
.banner-item {
|
||||||
@@ -226,99 +141,16 @@ const formatDate = (dateStr) => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: $bg-color-hover;
|
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 {
|
.content-wrapper {
|
||||||
display: flex;
|
padding: $spacing-lg $spacing-md;
|
||||||
flex-direction: column;
|
|
||||||
gap: $spacing-md;
|
|
||||||
margin-bottom: $spacing-xl;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-card {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
background: $bg-color-main;
|
|
||||||
border: 1rpx solid rgba(226, 232, 240, 0.8);
|
|
||||||
border-radius: $border-radius-base;
|
|
||||||
padding: $spacing-md $spacing-lg;
|
|
||||||
position: relative;
|
|
||||||
transition: all 0.2s ease;
|
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: $bg-color-hover;
|
|
||||||
}
|
|
||||||
|
|
||||||
&.primary-entry {
|
|
||||||
border-color: rgba(14, 165, 233, 0.15);
|
|
||||||
background: linear-gradient(135deg, $bg-color-main 0%, #F0F9FF 100%);
|
|
||||||
|
|
||||||
.entry-num {
|
|
||||||
color: $color-primary;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-icon-wrap {
|
|
||||||
margin-right: $spacing-lg;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-num {
|
|
||||||
font-size: 32rpx;
|
|
||||||
font-weight: 700;
|
|
||||||
color: $text-color-light;
|
|
||||||
font-family: monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-text-wrap {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-title {
|
|
||||||
font-size: 30rpx;
|
|
||||||
font-weight: 600;
|
|
||||||
color: $text-color-main;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-desc {
|
|
||||||
font-size: 22rpx;
|
|
||||||
color: $text-color-muted;
|
|
||||||
margin-top: 4rpx;
|
|
||||||
}
|
|
||||||
|
|
||||||
.entry-arrow {
|
|
||||||
font-size: 32rpx;
|
|
||||||
color: $text-color-light;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 板块通用头部 */
|
/* 板块通用头部 */
|
||||||
.section-container {
|
.section-container {
|
||||||
margin-bottom: $spacing-xl;
|
margin-bottom: $spacing-lg;
|
||||||
|
|
||||||
&.last-section {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-header {
|
.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 {
|
.activity-grid {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1;
|
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,47 @@
|
|||||||
|
<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>
|
||||||
|
defineExpose({
|
||||||
|
refresh() {}
|
||||||
|
})
|
||||||
|
</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>
|
</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="menu-item" @tap="navigateTo('/pages/venue-select/index?mode=switch')">
|
||||||
<view class="item-left">
|
<view class="item-left">
|
||||||
@@ -209,16 +224,13 @@
|
|||||||
</view>
|
</view>
|
||||||
</block>
|
</block>
|
||||||
<AgreementLinks v-if="isLoggedIn" class="profile-agreement-links" compact />
|
<AgreementLinks v-if="isLoggedIn" class="profile-agreement-links" compact />
|
||||||
<VenueTabBar venue="ice" active="profile" />
|
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { onShow } from '@dcloudio/uni-app'
|
|
||||||
import { api, authStorage } from '@/modules/ice/api/member'
|
import { api, authStorage } from '@/modules/ice/api/member'
|
||||||
import { errorMessage, getMemberSession, loginAndStoreToken, SESSION_STATUS } from '@/modules/ice/api/session'
|
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 StatePanel from '@/components/ice/StatePanel.vue'
|
||||||
import AgreementLinks from '@/components/AgreementLinks.vue'
|
import AgreementLinks from '@/components/AgreementLinks.vue'
|
||||||
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
|
||||||
@@ -271,10 +283,6 @@ const canSubmitBind = computed(() => {
|
|||||||
return Boolean(bindForm.value.name.trim()) && Boolean(bindForm.value.phoneCode)
|
return Boolean(bindForm.value.name.trim()) && Boolean(bindForm.value.phoneCode)
|
||||||
})
|
})
|
||||||
|
|
||||||
onShow(() => {
|
|
||||||
refreshSession()
|
|
||||||
})
|
|
||||||
|
|
||||||
const applySession = (session) => {
|
const applySession = (session) => {
|
||||||
sessionStatus.value = session.status
|
sessionStatus.value = session.status
|
||||||
sessionMessage.value = session.message || ''
|
sessionMessage.value = session.message || ''
|
||||||
@@ -488,6 +496,8 @@ const formatDate = (dateStr) => {
|
|||||||
const date = new Date(dateStr)
|
const date = new Date(dateStr)
|
||||||
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
return `${date.getFullYear()}年${date.getMonth() + 1}月${date.getDate()}日`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
defineExpose({ onPageShow: refreshSession })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<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,32 @@
|
|||||||
|
<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 { onShow } from '@dcloudio/uni-app'
|
||||||
|
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()
|
||||||
|
let isFirstShow = true
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
if (isFirstShow) {
|
||||||
|
isFirstShow = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
contentRef.value?.refresh?.()
|
||||||
|
})
|
||||||
|
</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>
|
<template>
|
||||||
<view v-if="isReady" class="venue-select-page">
|
<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
|
<view
|
||||||
v-for="venue in venues"
|
v-for="venue in venues"
|
||||||
:key="venue.key"
|
:key="venue.key"
|
||||||
class="venue-card"
|
class="venue-half"
|
||||||
:class="{ active: venue.key === currentVenue }"
|
:class="venue.key === 'comprehensive' ? 'dark' : 'light'"
|
||||||
@tap="selectVenue(venue.key)"
|
@tap="selectVenue(venue.key)"
|
||||||
>
|
>
|
||||||
<view class="venue-main">
|
|
||||||
<text class="venue-name">{{ venue.name }}</text>
|
<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>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -41,7 +22,6 @@ const venueStore = useVenueStore()
|
|||||||
const venues = VENUE_OPTIONS
|
const venues = VENUE_OPTIONS
|
||||||
const isReady = ref(false)
|
const isReady = ref(false)
|
||||||
const currentVenue = computed(() => venueStore.currentVenue)
|
const currentVenue = computed(() => venueStore.currentVenue)
|
||||||
const selectedVenue = computed(() => venueStore.currentVenueOption)
|
|
||||||
|
|
||||||
onLoad((options) => {
|
onLoad((options) => {
|
||||||
const storedVenue = venueStore.init()
|
const storedVenue = venueStore.init()
|
||||||
@@ -58,122 +38,43 @@ onLoad((options) => {
|
|||||||
const selectVenue = (venueKey) => {
|
const selectVenue = (venueKey) => {
|
||||||
venueStore.enterVenue(venueKey)
|
venueStore.enterVenue(venueKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
const continueCurrentVenue = () => {
|
|
||||||
if (!venueStore.currentVenue) return
|
|
||||||
venueStore.enterVenue(venueStore.currentVenue)
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.venue-select-page {
|
.venue-select-page {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
padding: 96rpx 32rpx 48rpx;
|
|
||||||
background: $bg-color-soft;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
|
||||||
gap: 18rpx;
|
|
||||||
margin-bottom: 56rpx;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.title {
|
.venue-half {
|
||||||
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;
|
|
||||||
flex: 1;
|
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;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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 {
|
.venue-half:active {
|
||||||
background: $color-primary;
|
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>
|
</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([
|
export const VENUE_OPTIONS = Object.freeze([
|
||||||
{
|
{ key: VENUE_KEYS.COMPREHENSIVE, name: '综合场馆' },
|
||||||
key: VENUE_KEYS.ICE,
|
{ key: VENUE_KEYS.ICE, name: '冰场馆' }
|
||||||
name: '冰场馆',
|
|
||||||
description: '课程预约、会员卡与滑冰服务',
|
|
||||||
homePath: '/pages/ice/home/index'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: VENUE_KEYS.COMPREHENSIVE,
|
|
||||||
name: '综合场馆',
|
|
||||||
description: '课程、包场、门票、订单与余额',
|
|
||||||
homePath: '/pages/comprehensive/home/index'
|
|
||||||
}
|
|
||||||
])
|
])
|
||||||
|
|
||||||
export const isVenueKey = (value) => {
|
export const isVenueKey = (value) => value === VENUE_KEYS.ICE || value === VENUE_KEYS.COMPREHENSIVE
|
||||||
return value === VENUE_KEYS.ICE || value === VENUE_KEYS.COMPREHENSIVE
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getVenueOption = (venueKey) => {
|
export const getVenueOption = (venueKey) => VENUE_OPTIONS.find((item) => item.key === venueKey) || null
|
||||||
return VENUE_OPTIONS.find((item) => item.key === venueKey) || null
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStoredVenue = () => {
|
export const getStoredVenue = () => {
|
||||||
const venue = uni.getStorageSync(CURRENT_VENUE_KEY)
|
const venue = uni.getStorageSync(CURRENT_VENUE_KEY)
|
||||||
return isVenueKey(venue) ? venue : ''
|
return isVenueKey(venue) ? venue : VENUE_KEYS.COMPREHENSIVE
|
||||||
}
|
}
|
||||||
|
|
||||||
export const setStoredVenue = (venueKey) => {
|
export const setStoredVenue = (venueKey) => {
|
||||||
@@ -38,18 +24,14 @@ export const setStoredVenue = (venueKey) => {
|
|||||||
uni.removeStorageSync(CURRENT_VENUE_KEY)
|
uni.removeStorageSync(CURRENT_VENUE_KEY)
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|
||||||
uni.setStorageSync(CURRENT_VENUE_KEY, venueKey)
|
uni.setStorageSync(CURRENT_VENUE_KEY, venueKey)
|
||||||
return venueKey
|
return venueKey
|
||||||
}
|
}
|
||||||
|
|
||||||
export const navigateToVenueHome = (venueKey, options = {}) => {
|
export const navigateToVenueHome = (venueKey) => {
|
||||||
const venue = getVenueOption(venueKey)
|
if (!isVenueKey(venueKey)) {
|
||||||
if (!venue) {
|
|
||||||
uni.reLaunch({ url: '/pages/venue-select/index' })
|
uni.reLaunch({ url: '/pages/venue-select/index' })
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
uni.reLaunch({ url: '/pages/home/index' })
|
||||||
const navigate = options.redirect === true ? uni.redirectTo : uni.reLaunch
|
|
||||||
navigate({ url: venue.homePath })
|
|
||||||
}
|
}
|
||||||
|
|||||||