feat: migrate comprehensive venue module
This commit is contained in:
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<view class="article-detail container" v-if="article">
|
||||
<text class="title">{{ article.title }}</text>
|
||||
<text class="date">{{ formatDate(article.published_at || article.created_at) }}</text>
|
||||
<image v-if="article.cover_image" :src="article.cover_image" mode="aspectFill" class="cover" />
|
||||
<view class="content-card card">
|
||||
<rich-text :nodes="article.content_html"></rich-text>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { contentApi } from '@/modules/comprehensive/api/content'
|
||||
import { formatDate } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const article = ref(null)
|
||||
|
||||
onLoad(async (query) => {
|
||||
article.value = await contentApi.getArticleDetail(query.id)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title,
|
||||
.date {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $text-color-main;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
height: 360rpx;
|
||||
margin: $spacing-lg 0;
|
||||
border-radius: $border-radius-lg;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.content-card {
|
||||
color: $text-color-regular;
|
||||
font-size: 28rpx;
|
||||
line-height: 1.8;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<view class="activity-page container">
|
||||
<view class="page-title-block">
|
||||
<text class="page-title">活动公告</text>
|
||||
<text class="page-subtitle">场馆运营通知、课程活动与权益说明</text>
|
||||
</view>
|
||||
|
||||
<view class="article-list">
|
||||
<view v-for="article in articles" :key="article.id" class="article-card card" @tap="goDetail(article.id)">
|
||||
<image :src="article.cover_image" mode="aspectFill" class="cover" />
|
||||
<view class="article-info">
|
||||
<text class="title">{{ article.title }}</text>
|
||||
<text class="date">{{ formatDate(article.published_at || article.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="articles.length === 0" class="empty-state">暂无活动公告</view>
|
||||
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { contentApi } from '@/modules/comprehensive/api/content'
|
||||
import { formatDate } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const articles = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 10
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
const hasMore = computed(() => articles.value.length < total.value)
|
||||
|
||||
onMounted(() => loadArticles(true))
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadArticles(true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadArticles(false)
|
||||
})
|
||||
|
||||
const loadArticles = async (reset) => {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
isLoading.value = true
|
||||
} else {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const data = await contentApi.getArticles({ page: page.value, pageSize })
|
||||
articles.value = reset ? data.items : [...articles.value, ...data.items]
|
||||
total.value = data.total
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const goDetail = (id) => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/activities/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page-title-block {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.page-title,
|
||||
.page-subtitle,
|
||||
.title,
|
||||
.date {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
color: $text-color-main;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.page-subtitle {
|
||||
margin-top: $spacing-xs;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.article-card {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 190rpx;
|
||||
height: 136rpx;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-hover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.article-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: $spacing-md;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,245 @@
|
||||
<template>
|
||||
<view class="booking-detail container page-bottom-safe" v-if="booking">
|
||||
<view class="status-card card">
|
||||
<text class="status-title">{{ booking.status_label }}</text>
|
||||
<text class="status-desc">{{ statusDesc }}</text>
|
||||
</view>
|
||||
|
||||
<view class="card course-card">
|
||||
<image :src="booking.course.cover_image" mode="aspectFill" class="cover" />
|
||||
<view class="course-info">
|
||||
<text class="course-title">{{ booking.course.title }}</text>
|
||||
<text class="meta primary">{{ formatTimeRange(booking.course.start_at, booking.course.end_at) }}</text>
|
||||
<text class="meta">{{ booking.course.coach_name }} · {{ booking.course.courts.map(item => item.name).join('、') }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">订单信息</text>
|
||||
<view class="info-row">
|
||||
<text>订单号</text>
|
||||
<text>{{ booking.order.order_no }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>订单金额</text>
|
||||
<text>¥{{ booking.order.amount }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>订单状态</text>
|
||||
<text>{{ booking.order.status_label }}</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.expire_at" class="timeline-item">
|
||||
<text class="dot warning"></text>
|
||||
<view>
|
||||
<text class="timeline-title">支付截止</text>
|
||||
<text class="timeline-time">{{ formatDateTime(booking.expire_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="booking.cancel_deadline_at" class="timeline-item">
|
||||
<text class="dot"></text>
|
||||
<view>
|
||||
<text class="timeline-title">取消截止</text>
|
||||
<text class="timeline-time">{{ formatDateTime(booking.cancel_deadline_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>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="booking.status === 'pending_pay' || booking.can_cancel" class="fixed-action-bar">
|
||||
<button v-if="booking.can_cancel" class="ghost-button action-btn" @tap="cancelBooking">取消预约</button>
|
||||
<button v-if="booking.status === 'pending_pay'" class="primary-button action-btn" @tap="goPay">去支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const bookingId = ref('')
|
||||
const booking = ref(null)
|
||||
|
||||
const statusDesc = computed(() => {
|
||||
if (!booking.value) return ''
|
||||
if (booking.value.status === 'pending_pay') return '订单待支付,完成支付后预约生效。'
|
||||
if (booking.value.status === 'booked') return '预约已生效,到馆后按场馆流程核销。'
|
||||
if (booking.value.status === 'cancelled') return booking.value.cancel_reason || '预约已取消。'
|
||||
return '预约记录已结束。'
|
||||
})
|
||||
|
||||
onLoad(async (query) => {
|
||||
bookingId.value = query.id
|
||||
await loadBooking()
|
||||
})
|
||||
|
||||
const loadBooking = async () => {
|
||||
booking.value = await bookingApi.getCourseBookingDetail(bookingId.value)
|
||||
}
|
||||
|
||||
const goPay = () => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${booking.value.order_id}` })
|
||||
}
|
||||
|
||||
const cancelBooking = async () => {
|
||||
await bookingApi.cancelCourseBooking(booking.value.id)
|
||||
uni.showToast({ title: '预约已取消', icon: 'none' })
|
||||
await loadBooking()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-card,
|
||||
.course-card,
|
||||
.card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-title,
|
||||
.status-desc,
|
||||
.course-title,
|
||||
.meta,
|
||||
.card-title,
|
||||
.timeline-title,
|
||||
.timeline-time {
|
||||
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;
|
||||
}
|
||||
|
||||
.course-card {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 180rpx;
|
||||
height: 150rpx;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-hover;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.course-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.course-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-success;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.warning {
|
||||
background: $color-warning;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<view class="bookings-page">
|
||||
<view v-if="!session.isLoggedIn" class="unlogged">
|
||||
<text class="brand">星河综合运动中心</text>
|
||||
<text class="desc">登录后查看课程预约、包场预约与篮球门票权益。</text>
|
||||
<button class="primary-button login-btn" @tap="login">微信一键登录</button>
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<view class="sticky-tabs">
|
||||
<view class="business-tabs">
|
||||
<view v-for="tab in businessTabs" :key="tab.value" class="business-tab" :class="{ active: businessType === tab.value }" @tap="selectBusiness(tab.value)">
|
||||
{{ tab.label }}
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="list">
|
||||
<view v-for="item in bookedItems" :key="`${businessType}-${item.id}`" class="booking-card card" @tap="goDetail(item)">
|
||||
<view class="card-top">
|
||||
<text class="item-title">{{ getItemTitle(item) }}</text>
|
||||
</view>
|
||||
<text class="item-meta primary">{{ getItemTime(item) }}</text>
|
||||
<text class="item-meta">{{ getItemDesc(item) }}</text>
|
||||
<view class="card-bottom">
|
||||
<text class="item-meta">订单金额</text>
|
||||
<text class="amount">¥{{ item.order?.amount || '--' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-if="!isLoading && bookedItems.length === 0" class="empty-state">暂无已预约记录</view>
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import { formatDateTime, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const businessType = ref('course')
|
||||
const items = ref([])
|
||||
const isLoading = ref(false)
|
||||
|
||||
const businessTabs = [
|
||||
{ label: '课程', value: 'course' },
|
||||
{ label: '包场', value: 'private' },
|
||||
{ label: '门票', value: 'ticket' }
|
||||
]
|
||||
|
||||
const bookedItems = computed(() => {
|
||||
return items.value.filter((item) => ['booked', 'available'].includes(item.status))
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await session.init()
|
||||
if (session.isLoggedIn) loadItems()
|
||||
})
|
||||
|
||||
const login = async () => {
|
||||
await session.login()
|
||||
loadItems()
|
||||
}
|
||||
|
||||
const selectBusiness = (value) => {
|
||||
businessType.value = value
|
||||
loadItems()
|
||||
}
|
||||
|
||||
const loadItems = async () => {
|
||||
isLoading.value = true
|
||||
try {
|
||||
if (businessType.value === 'course') {
|
||||
const data = await bookingApi.getCourseBookings({ page: 1, pageSize: 50 })
|
||||
items.value = data.items
|
||||
} else if (businessType.value === 'private') {
|
||||
const data = await bookingApi.getPrivateBookings({ page: 1, pageSize: 50 })
|
||||
items.value = data.items
|
||||
} else {
|
||||
const data = await ticketApi.getTicketAccount({ page: 1, pageSize: 50 })
|
||||
items.value = data.records.map((record) => ({
|
||||
...record,
|
||||
order: {
|
||||
id: record.order_id,
|
||||
order_no: record.order_no,
|
||||
amount: record.order_id ? '80.00' : '--'
|
||||
}
|
||||
}))
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getItemTitle = (item) => {
|
||||
if (businessType.value === 'course') return item.course?.title || '课程预约'
|
||||
if (businessType.value === 'private') return item.courts?.map((court) => court.name).join('、') || '包场预约'
|
||||
return `篮球门票 ${item.order_no}`
|
||||
}
|
||||
|
||||
const getItemTime = (item) => {
|
||||
if (businessType.value === 'course') return formatTimeRange(item.course?.start_at, item.course?.end_at)
|
||||
if (businessType.value === 'private') return formatTimeRange(item.start_at, item.end_at)
|
||||
return item.checked_in_at ? `核销时间 ${formatDateTime(item.checked_in_at)}` : `生成时间 ${formatDateTime(item.created_at)}`
|
||||
}
|
||||
|
||||
const getItemDesc = (item) => {
|
||||
if (businessType.value === 'course') return `${item.course?.coach_name || ''} · ${item.course?.courts?.map((court) => court.name).join('、') || ''}`
|
||||
if (businessType.value === 'private') return `共 ${item.courts?.length || 0} 个场地`
|
||||
return item.status === 'available' ? '可到馆核销使用' : item.status_label
|
||||
}
|
||||
|
||||
const goDetail = (item) => {
|
||||
if (businessType.value === 'course') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/course-detail?id=${item.id}` })
|
||||
} else if (businessType.value === 'private') {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/bookings/private-detail?id=${item.id}` })
|
||||
} else if (item.order_id) {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/orders/detail?id=${item.order_id}` })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bookings-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.unlogged {
|
||||
min-height: 80vh;
|
||||
padding: 180rpx $spacing-lg 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: $text-color-main;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-top: $spacing-md;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 520rpx;
|
||||
margin-top: 100rpx;
|
||||
}
|
||||
|
||||
.sticky-tabs {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: $spacing-md $spacing-md $spacing-sm;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.business-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.business-tab {
|
||||
height: 64rpx;
|
||||
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;
|
||||
}
|
||||
|
||||
.business-tab.active {
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.booking-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.card-top,
|
||||
.card-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.item-title {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.item-meta {
|
||||
display: block;
|
||||
margin-top: $spacing-xs;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.primary {
|
||||
margin-top: $spacing-md;
|
||||
color: $color-primary;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.card-bottom {
|
||||
margin-top: $spacing-md;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,201 @@
|
||||
<template>
|
||||
<view class="booking-detail container page-bottom-safe" v-if="booking">
|
||||
<view class="status-card card">
|
||||
<text class="status-title">{{ booking.status_label }}</text>
|
||||
<text class="status-desc">{{ statusDesc }}</text>
|
||||
<text class="badge" :class="statusBadgeClass(booking.status)">{{ booking.status_label }}</text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">包场信息</text>
|
||||
<view class="info-row">
|
||||
<text>预约场地</text>
|
||||
<text>{{ booking.courts.map(item => item.name).join('、') }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>预约时间</text>
|
||||
<text>{{ formatTimeRange(booking.start_at, booking.end_at) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>预约时长</text>
|
||||
<text>{{ formatDuration(booking.start_at, booking.end_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">订单信息</text>
|
||||
<view class="info-row">
|
||||
<text>订单号</text>
|
||||
<text>{{ booking.order.order_no }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>订单金额</text>
|
||||
<text>¥{{ booking.order.amount }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>订单状态</text>
|
||||
<text>{{ booking.order.status_label }}</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.expire_at" class="timeline-item">
|
||||
<text class="dot warning"></text>
|
||||
<view>
|
||||
<text class="timeline-title">支付截止</text>
|
||||
<text class="timeline-time">{{ formatDateTime(booking.expire_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">{{ booking.cancel_reason || '已取消' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="booking.status === 'pending_pay'" class="fixed-action-bar">
|
||||
<button class="primary-button action-btn" @tap="goPay">去支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||
import { formatDateTime, formatDuration, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||
|
||||
const bookingId = ref('')
|
||||
const booking = ref(null)
|
||||
|
||||
const statusDesc = computed(() => {
|
||||
if (!booking.value) return ''
|
||||
if (booking.value.status === 'pending_pay') return '订单待支付,完成支付后包场生效。'
|
||||
if (booking.value.status === 'booked') return '包场预约已生效,请按预约时间到馆。'
|
||||
return '包场预约记录已结束。'
|
||||
})
|
||||
|
||||
onLoad(async (query) => {
|
||||
bookingId.value = query.id
|
||||
booking.value = await bookingApi.getPrivateBookingDetail(bookingId.value)
|
||||
})
|
||||
|
||||
const goPay = () => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${booking.value.order_id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-card,
|
||||
.card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.status-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.status-title,
|
||||
.status-desc,
|
||||
.card-title,
|
||||
.timeline-title,
|
||||
.timeline-time {
|
||||
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;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.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-success;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dot.warning {
|
||||
background: $color-warning;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<view class="confirm-page container page-bottom-safe" v-if="course">
|
||||
<view class="summary-card card">
|
||||
<text class="card-title">{{ course.title }}</text>
|
||||
<view class="summary-row">
|
||||
<text>上课时间</text>
|
||||
<text>{{ formatTimeRange(course.start_at, course.end_at) }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text>授课教练</text>
|
||||
<text>{{ course.coach_name }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text>上课场地</text>
|
||||
<text>{{ course.courts.map(item => item.name).join('、') }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text>课程价格</text>
|
||||
<text class="strong">¥{{ course.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="bar-price">
|
||||
<text class="bar-label">应付金额</text>
|
||||
<text class="price-text">¥{{ course.price }}</text>
|
||||
</view>
|
||||
<button class="primary-button submit-btn" :disabled="isSubmitting || !canSubmit" @tap="submit">
|
||||
提交预约
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const course = ref(null)
|
||||
const isSubmitting = ref(false)
|
||||
const canSubmit = computed(() => course.value?.status === 'published' && course.value?.remaining_count > 0)
|
||||
|
||||
onLoad(async (query) => {
|
||||
course.value = await courseApi.getCourseDetail(query.id)
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value || !course.value || !canSubmit.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const data = await courseApi.createCourseBooking(course.value.id)
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${data.order.id}` })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.summary-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
margin-bottom: $spacing-md;
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
min-height: 70rpx;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.summary-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.summary-row text:last-child {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.strong {
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bar-price {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: block;
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 340rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,146 @@
|
||||
<template>
|
||||
<view class="detail-page page-bottom-safe" v-if="course">
|
||||
<image :src="course.cover_image" mode="aspectFill" class="hero-image" />
|
||||
<view class="content">
|
||||
<view class="title-card card">
|
||||
<view class="title-row">
|
||||
<text class="course-title">{{ course.title }}</text>
|
||||
</view>
|
||||
<view class="price-row">
|
||||
<text class="price-text">¥{{ course.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="info-card card">
|
||||
<view v-for="item in infoItems" :key="item.label" class="info-row">
|
||||
<text class="info-label">{{ item.label }}</text>
|
||||
<text class="info-value">{{ item.value }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">课程详情</text>
|
||||
</view>
|
||||
<view class="rich-card card">
|
||||
<rich-text :nodes="course.detail_html"></rich-text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<button class="primary-button submit-btn" :disabled="!canBook" @tap="goConfirm">
|
||||
{{ canBook ? '立即预约' : '暂不可约' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const course = ref(null)
|
||||
|
||||
const canBook = computed(() => {
|
||||
return course.value && course.value.remaining_count > 0 && course.value.status === 'published'
|
||||
})
|
||||
|
||||
const infoItems = computed(() => {
|
||||
if (!course.value) return []
|
||||
return [
|
||||
{ label: '上课时间', value: formatTimeRange(course.value.start_at, course.value.end_at) },
|
||||
{ label: '授课教练', value: course.value.coach_name },
|
||||
{ label: '上课场地', value: course.value.courts.map((item) => item.name).join('、') }
|
||||
]
|
||||
})
|
||||
|
||||
onLoad(async (query) => {
|
||||
course.value = await courseApi.getCourseDetail(query.id)
|
||||
})
|
||||
|
||||
const goConfirm = () => {
|
||||
if (!canBook.value) return
|
||||
uni.navigateTo({ url: `/pages/comprehensive/courses/confirm?id=${course.value.id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.detail-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.hero-image {
|
||||
width: 100%;
|
||||
height: 420rpx;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.content {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.title-card {
|
||||
margin-top: -54rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.title-row,
|
||||
.price-row,
|
||||
.info-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.price-row {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.info-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.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-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;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,235 @@
|
||||
<template>
|
||||
<view class="courses-page">
|
||||
<view class="filter-bar">
|
||||
<picker mode="date" :value="selectedDate" @change="onDateChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">日期</text>
|
||||
<text class="filter-value">{{ selectedDate || '全部日期' }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker mode="selector" :range="coachOptions" range-key="name" :value="selectedCoachIndex" @change="onCoachChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">教练</text>
|
||||
<text class="filter-value">{{ selectedCoachName }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
<picker mode="selector" :range="courtTypeOptions" range-key="label" :value="selectedCourtTypeIndex" @change="onCourtTypeChange" class="filter-item">
|
||||
<view class="filter-inner">
|
||||
<text class="filter-label">类型</text>
|
||||
<text class="filter-value">{{ selectedCourtTypeLabel }}</text>
|
||||
</view>
|
||||
</picker>
|
||||
</view>
|
||||
|
||||
<view class="course-list">
|
||||
<view v-for="course in courses" :key="course.id" class="course-card" @tap="goDetail(course.id)">
|
||||
<image :src="course.cover_image" mode="aspectFill" class="course-cover" />
|
||||
<view class="course-info">
|
||||
<view class="course-top">
|
||||
<text class="course-title">{{ course.title }}</text>
|
||||
</view>
|
||||
<text class="time-line">{{ formatTimeRange(course.start_at, course.end_at) }}</text>
|
||||
<text class="meta">{{ course.coach_name }}</text>
|
||||
<view class="course-bottom">
|
||||
<text class="meta">已约 {{ course.booked_count }}/{{ course.capacity }}</text>
|
||||
<text class="course-price">¥{{ course.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="courses.length === 0" class="empty-state">当前筛选条件下暂无课程</view>
|
||||
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { courseApi } from '@/modules/comprehensive/api/course'
|
||||
import { venueApi } from '@/modules/comprehensive/api/venue'
|
||||
import { formatTimeRange, todayString } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const selectedDate = ref(todayString())
|
||||
const selectedCoachIndex = ref(0)
|
||||
const selectedCourtTypeIndex = ref(0)
|
||||
const courses = ref([])
|
||||
const coaches = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const courtTypeOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '羽毛球', value: 'badminton' },
|
||||
{ label: '篮球', value: 'basketball' }
|
||||
]
|
||||
|
||||
const 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(() => courses.value.length < total.value)
|
||||
|
||||
onMounted(async () => {
|
||||
const coachData = await venueApi.getCoaches()
|
||||
coaches.value = coachData.items
|
||||
loadCourses(true)
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadCourses(true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadCourses(false)
|
||||
})
|
||||
|
||||
const loadCourses = async (reset) => {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
isLoading.value = true
|
||||
} else {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const data = await courseApi.getCourses({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
date: selectedDate.value,
|
||||
coachId: selectedCoachId.value,
|
||||
courtType: selectedCourtType.value
|
||||
})
|
||||
courses.value = reset ? data.items : [...courses.value, ...data.items]
|
||||
total.value = data.total
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const onDateChange = (e) => {
|
||||
selectedDate.value = e.detail.value
|
||||
loadCourses(true)
|
||||
}
|
||||
|
||||
const onCoachChange = (e) => {
|
||||
selectedCoachIndex.value = Number(e.detail.value)
|
||||
loadCourses(true)
|
||||
}
|
||||
|
||||
const onCourtTypeChange = (e) => {
|
||||
selectedCourtTypeIndex.value = Number(e.detail.value)
|
||||
loadCourses(true)
|
||||
}
|
||||
|
||||
const goDetail = (id) => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/courses/detail?id=${id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.courses-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.filter-bar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
height: 94rpx;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.filter-inner {
|
||||
height: 94rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
color: $text-color-light;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.filter-value {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-main;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.course-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.course-card {
|
||||
@include minimal-card;
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.course-cover {
|
||||
width: 180rpx;
|
||||
height: 172rpx;
|
||||
flex-shrink: 0;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.course-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.course-top,
|
||||
.course-bottom {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-title {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
.course-price {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,295 @@
|
||||
<template>
|
||||
<view class="home-container">
|
||||
<view class="banner-wrapper">
|
||||
<swiper
|
||||
v-if="banners.length"
|
||||
class="banner-swiper"
|
||||
circular
|
||||
autoplay
|
||||
interval="4000"
|
||||
duration="600"
|
||||
indicator-dots
|
||||
indicator-color="rgba(15, 23, 42, 0.1)"
|
||||
indicator-active-color="#0F172A"
|
||||
>
|
||||
<swiper-item v-for="banner in banners" :key="banner.id">
|
||||
<view class="banner-item">
|
||||
<image :src="banner.image" mode="aspectFill" class="banner-img" />
|
||||
<view class="banner-overlay">
|
||||
<text class="banner-title">{{ banner.title }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</swiper-item>
|
||||
</swiper>
|
||||
</view>
|
||||
|
||||
<view class="section-container">
|
||||
<view class="section-header">
|
||||
<text class="section-title">近期课程</text>
|
||||
</view>
|
||||
|
||||
<view 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">
|
||||
<text class="section-title">公告活动</text>
|
||||
</view>
|
||||
|
||||
<view v-if="articles.length" class="activity-grid">
|
||||
<view v-for="article in articles" :key="article.id" class="activity-card" @tap="go(`/pages/comprehensive/activities/detail?id=${article.id}`)">
|
||||
<image :src="article.cover_image" mode="aspectFill" class="activity-cover" />
|
||||
<view class="activity-info">
|
||||
<text class="activity-title">{{ article.title }}</text>
|
||||
<text class="activity-date">{{ formatDate(article.published_at || article.created_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="empty-placeholder">暂无公告活动</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { contentApi } from '@/modules/comprehensive/api/content'
|
||||
import { formatDate, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const banners = ref([])
|
||||
const upcomingCourses = ref([])
|
||||
const articles = ref([])
|
||||
|
||||
onMounted(() => {
|
||||
loadPage()
|
||||
})
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
const loadPage = async () => {
|
||||
const home = await contentApi.getHomeContent({ banner_limit: 5, article_limit: 3 })
|
||||
banners.value = home.banners || []
|
||||
upcomingCourses.value = (home.upcoming_courses || []).slice(0, 2)
|
||||
articles.value = home.articles || []
|
||||
}
|
||||
|
||||
const go = (url) => {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.home-container {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-lg $spacing-md;
|
||||
background-color: $bg-color-soft;
|
||||
}
|
||||
|
||||
.banner-wrapper {
|
||||
margin-bottom: $spacing-lg;
|
||||
}
|
||||
|
||||
.banner-swiper {
|
||||
height: 320rpx;
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.banner-item {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.banner-img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.banner-overlay {
|
||||
position: absolute;
|
||||
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 {
|
||||
margin-bottom: $spacing-xl;
|
||||
}
|
||||
|
||||
.section-container.last-section {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $spacing-md;
|
||||
padding: 0 4rpx;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
position: relative;
|
||||
padding-left: $spacing-sm;
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.5rpx;
|
||||
}
|
||||
|
||||
.section-title::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 10%;
|
||||
bottom: 10%;
|
||||
width: 4rpx;
|
||||
background-color: $text-color-main;
|
||||
}
|
||||
|
||||
.course-list,
|
||||
.activity-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-item,
|
||||
.activity-card {
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
border: 1rpx solid $bg-color-hover;
|
||||
border-radius: $border-radius-base;
|
||||
background-color: $bg-color-main;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.course-item:active,
|
||||
.activity-card:active {
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.course-cover {
|
||||
width: 180rpx;
|
||||
height: 130rpx;
|
||||
flex-shrink: 0;
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.course-info-wrap {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
}
|
||||
|
||||
.course-title-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.course-name {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 1;
|
||||
}
|
||||
|
||||
.course-time {
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.course-time.highlight-blue {
|
||||
color: $color-primary;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.course-bottom-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-xl;
|
||||
}
|
||||
|
||||
.course-meta-text {
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.activity-cover {
|
||||
width: 180rpx;
|
||||
height: 130rpx;
|
||||
background-color: $bg-color-hover;
|
||||
}
|
||||
|
||||
.activity-info {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
}
|
||||
|
||||
.activity-title {
|
||||
display: block;
|
||||
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: 2;
|
||||
}
|
||||
|
||||
.activity-date {
|
||||
color: $text-color-light;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
|
||||
.empty-placeholder {
|
||||
padding: $spacing-xl 0;
|
||||
color: $text-color-light;
|
||||
font-size: 24rpx;
|
||||
text-align: center;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<view class="order-detail container page-bottom-safe" v-if="order">
|
||||
<view class="status-card card">
|
||||
<text class="amount">¥{{ order.amount }}</text>
|
||||
<text class="badge" :class="statusBadgeClass(order.status)">{{ order.status_label }}</text>
|
||||
</view>
|
||||
|
||||
<view class="card">
|
||||
<text class="card-title">订单信息</text>
|
||||
<view class="info-row">
|
||||
<text>订单号</text>
|
||||
<text>{{ order.order_no }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>创建时间</text>
|
||||
<text>{{ formatDateTime(order.created_at) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>更新时间</text>
|
||||
<text>{{ formatDateTime(order.updated_at) }}</text>
|
||||
</view>
|
||||
<view v-if="order.expire_at" class="info-row">
|
||||
<text>支付过期</text>
|
||||
<text>{{ formatDateTime(order.expire_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card list-card">
|
||||
<text class="card-title">支付记录</text>
|
||||
<view v-if="order.payments.length === 0" class="placeholder">暂无支付记录</view>
|
||||
<view v-for="payment in order.payments" :key="`${payment.method}-${payment.paid_at}`" class="record-row">
|
||||
<view>
|
||||
<text class="record-title">{{ payment.method_label || payment.method }}</text>
|
||||
<text class="record-time">{{ formatDateTime(payment.paid_at || payment.created_at) }}</text>
|
||||
</view>
|
||||
<text class="record-amount">¥{{ payment.amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card list-card">
|
||||
<text class="card-title">退款记录</text>
|
||||
<view v-if="order.refunds.length === 0" class="placeholder">暂无退款记录</view>
|
||||
<view v-for="refund in order.refunds" :key="refund.refund_no" class="record-row">
|
||||
<view>
|
||||
<text class="record-title">{{ refund.reason }}</text>
|
||||
<text class="record-time">{{ formatDateTime(refund.refunded_at) }}</text>
|
||||
</view>
|
||||
<text class="record-amount">¥{{ refund.amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="order.status === 'pending_pay'" class="fixed-action-bar">
|
||||
<button class="ghost-button action-btn" @tap="closeCurrentOrder">关闭订单</button>
|
||||
<button class="primary-button action-btn" @tap="goPay">去支付</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||
|
||||
const orderId = ref('')
|
||||
const order = ref(null)
|
||||
|
||||
onLoad(async (query) => {
|
||||
orderId.value = query.id
|
||||
await loadOrder()
|
||||
})
|
||||
|
||||
const loadOrder = async () => {
|
||||
order.value = await orderApi.getOrderDetail(orderId.value)
|
||||
}
|
||||
|
||||
const goPay = () => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${order.value.id}` })
|
||||
}
|
||||
|
||||
const closeCurrentOrder = async () => {
|
||||
await orderApi.closeOrder(order.value.id)
|
||||
uni.showToast({ title: '订单已关闭', icon: 'none' })
|
||||
await loadOrder()
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-card {
|
||||
text-align: center;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.amount {
|
||||
display: block;
|
||||
margin-bottom: $spacing-sm;
|
||||
color: $text-color-main;
|
||||
font-size: 64rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
margin-bottom: $spacing-sm;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.info-row,
|
||||
.record-row {
|
||||
min-height: 70rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.info-row:last-child,
|
||||
.record-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
color: $text-color-muted;
|
||||
font-size: 25rpx;
|
||||
}
|
||||
|
||||
.info-row text:last-child {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-weight: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
padding: $spacing-md 0;
|
||||
color: $text-color-light;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.record-title,
|
||||
.record-time {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.record-title {
|
||||
color: $text-color-main;
|
||||
font-size: 26rpx;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.record-time {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.record-amount {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,299 @@
|
||||
<template>
|
||||
<view class="pay-page container page-bottom-safe" v-if="order">
|
||||
<view class="status-card card">
|
||||
<text class="business">{{ order.business_type_label }}</text>
|
||||
<text class="amount">¥{{ order.amount }}</text>
|
||||
<text v-if="order.expire_at" class="expire">{{ paymentCountdownText }}</text>
|
||||
</view>
|
||||
|
||||
<view class="card order-card">
|
||||
<view class="info-row">
|
||||
<text>订单号</text>
|
||||
<text>{{ order.order_no }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>创建时间</text>
|
||||
<text>{{ formatDateTime(order.created_at) }}</text>
|
||||
</view>
|
||||
<view class="info-row">
|
||||
<text>订单状态</text>
|
||||
<text>{{ order.status_label }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card method-card">
|
||||
<text class="card-title">支付方式</text>
|
||||
<view
|
||||
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>
|
||||
<text class="method-title">微信支付</text>
|
||||
<text class="method-sub">使用微信支付完成订单</text>
|
||||
</view>
|
||||
<text class="method-check">{{ payMethod === 'wechat' ? '已选' : '' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<button v-if="order.status === 'pending_pay'" class="ghost-button cancel-btn" @tap="closeCurrentOrder">取消订单</button>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="bar-price">
|
||||
<text class="bar-label">待支付</text>
|
||||
<text class="price-text">¥{{ order.amount }}</text>
|
||||
</view>
|
||||
<button class="primary-button submit-btn" :disabled="!canPay || isSubmitting" @tap="pay">
|
||||
{{ payButtonText }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||
import { toAmountNumber } from '@/modules/comprehensive/utils/money'
|
||||
|
||||
const orderId = ref('')
|
||||
const order = ref(null)
|
||||
const balance = ref({ balance: '0.00' })
|
||||
const payMethod = ref('balance')
|
||||
const isSubmitting = ref(false)
|
||||
const nowTimestamp = ref(Date.now())
|
||||
let countdownTimer = null
|
||||
|
||||
const canUseBalance = computed(() => toAmountNumber(balance.value.balance) >= toAmountNumber(order.value?.amount))
|
||||
const canPay = computed(() => order.value?.status === 'pending_pay' && (payMethod.value !== 'balance' || canUseBalance.value))
|
||||
const payButtonText = computed(() => {
|
||||
if (order.value?.status !== 'pending_pay') return '订单不可支付'
|
||||
return payMethod.value === 'balance' ? '余额支付' : '微信支付'
|
||||
})
|
||||
const paymentRemainingSeconds = computed(() => {
|
||||
if (!order.value?.expire_at) return 0
|
||||
return Math.max(0, Math.ceil((new Date(order.value.expire_at).getTime() - nowTimestamp.value) / 1000))
|
||||
})
|
||||
const paymentCountdownText = computed(() => {
|
||||
const seconds = paymentRemainingSeconds.value
|
||||
if (seconds <= 0) return '支付倒计时 00:00:00'
|
||||
const hours = Math.floor(seconds / 3600)
|
||||
const minutes = Math.floor((seconds % 3600) / 60)
|
||||
const remainSeconds = seconds % 60
|
||||
return `支付倒计时 ${padTime(hours)}:${padTime(minutes)}:${padTime(remainSeconds)}`
|
||||
})
|
||||
|
||||
onLoad(async (query) => {
|
||||
orderId.value = query.order_id || query.id
|
||||
await loadPage()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
stopCountdown()
|
||||
})
|
||||
|
||||
const loadPage = async () => {
|
||||
const [orderData, balanceData] = await Promise.all([
|
||||
orderApi.getOrderDetail(orderId.value),
|
||||
memberApi.getBalance()
|
||||
])
|
||||
order.value = orderData
|
||||
balance.value = balanceData
|
||||
if (!canUseBalance.value) payMethod.value = 'wechat'
|
||||
updateCountdownTimer()
|
||||
}
|
||||
|
||||
const updateCountdownTimer = () => {
|
||||
nowTimestamp.value = Date.now()
|
||||
stopCountdown()
|
||||
if (!order.value?.expire_at || order.value.status !== 'pending_pay') return
|
||||
countdownTimer = setInterval(() => {
|
||||
nowTimestamp.value = Date.now()
|
||||
if (paymentRemainingSeconds.value <= 0) stopCountdown()
|
||||
}, 1000)
|
||||
}
|
||||
|
||||
const stopCountdown = () => {
|
||||
if (!countdownTimer) return
|
||||
clearInterval(countdownTimer)
|
||||
countdownTimer = null
|
||||
}
|
||||
|
||||
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 () => {
|
||||
if (!canPay.value || isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
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)
|
||||
await requestWechatPayment(paymentData.wechat)
|
||||
uni.showToast({ title: '支付已提交', icon: 'success' })
|
||||
}
|
||||
await loadPage()
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: `/pages/comprehensive/orders/detail?id=${order.value.id}` })
|
||||
}, 350)
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const closeCurrentOrder = async () => {
|
||||
await orderApi.closeOrder(order.value.id)
|
||||
uni.showToast({ title: '订单已取消', icon: 'none' })
|
||||
await loadPage()
|
||||
}
|
||||
|
||||
const requestWechatPayment = (wechatPayload) => new Promise((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
...wechatPayload,
|
||||
success: resolve,
|
||||
fail: reject
|
||||
})
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.status-card {
|
||||
text-align: center;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.business {
|
||||
display: block;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.amount {
|
||||
display: block;
|
||||
margin: $spacing-sm 0;
|
||||
color: $text-color-main;
|
||||
font-size: 68rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.expire {
|
||||
display: block;
|
||||
margin-top: $spacing-md;
|
||||
color: $color-warning;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.order-card,
|
||||
.method-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.info-row {
|
||||
min-height: 68rpx;
|
||||
display: flex;
|
||||
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: 700;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
margin-bottom: $spacing-sm;
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.method-item {
|
||||
min-height: 110rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md 0;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.method-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.method-item.active .method-title {
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.method-item.disabled {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.method-title,
|
||||
.method-sub {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.method-title {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.method-sub,
|
||||
.method-check {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.method-check {
|
||||
color: $color-primary;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.cancel-btn {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.bar-price {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: block;
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 320rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<template>
|
||||
<view class="confirm-page container page-bottom-safe" v-if="quote">
|
||||
<view class="card summary-card">
|
||||
<text class="card-title">{{ courtTypeLabel(payload.court_type) }}包场</text>
|
||||
<view class="summary-row">
|
||||
<text>预约场地</text>
|
||||
<text>{{ quote.courts.map(item => item.name).join('、') }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text>预约时间</text>
|
||||
<text>{{ formatTimeRange(quote.start_at, quote.end_at) }}</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text>预约时长</text>
|
||||
<text>{{ formatDuration(quote.start_at, quote.end_at) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="card fee-card">
|
||||
<text class="card-title">费用明细</text>
|
||||
<view class="summary-row">
|
||||
<text>半小时段</text>
|
||||
<text>{{ quote.unit_count }} 段</text>
|
||||
</view>
|
||||
<view class="summary-row">
|
||||
<text>场地数量</text>
|
||||
<text>{{ quote.courts.length }} 个</text>
|
||||
</view>
|
||||
<view class="summary-row total">
|
||||
<text>合计金额</text>
|
||||
<text>¥{{ quote.amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="bar-price">
|
||||
<text class="bar-label">应付金额</text>
|
||||
<text class="price-text">¥{{ quote.amount }}</text>
|
||||
</view>
|
||||
<button class="primary-button submit-btn" :disabled="isSubmitting" @tap="submit">提交订单</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking'
|
||||
import { courtTypeLabel } from '@/modules/comprehensive/utils/status'
|
||||
import { formatDuration, formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const payload = ref({})
|
||||
const quote = ref(null)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
onLoad(async (query) => {
|
||||
payload.value = JSON.parse(decodeURIComponent(query.payload || '{}'))
|
||||
quote.value = await privateBookingApi.quotePrivateBooking(payload.value)
|
||||
})
|
||||
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const data = await privateBookingApi.createPrivateBooking(payload.value)
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${data.order.id}` })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.summary-card,
|
||||
.fee-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
display: block;
|
||||
margin-bottom: $spacing-md;
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.summary-row {
|
||||
min-height: 72rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.summary-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.summary-row text:last-child {
|
||||
flex: 1;
|
||||
color: $text-color-main;
|
||||
font-weight: 800;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.total text:last-child {
|
||||
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;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,478 @@
|
||||
<template>
|
||||
<view class="private-page page-bottom-safe">
|
||||
<view class="top-panel">
|
||||
<view class="type-tabs">
|
||||
<view
|
||||
v-for="item in typeOptions"
|
||||
:key="item.value"
|
||||
class="type-tab"
|
||||
:class="{ active: courtType === item.value }"
|
||||
@tap="selectType(item.value)"
|
||||
>
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
<view class="venue-meta">
|
||||
<text>{{ businessHoursText }}</text>
|
||||
<text>{{ options ? `¥${options.unit_price}/半小时` : '加载价格中' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="date-section">
|
||||
<scroll-view scroll-x class="date-scroll" :show-scrollbar="false">
|
||||
<view class="date-list">
|
||||
<view
|
||||
v-for="item in dateOptions"
|
||||
:key="item.value"
|
||||
class="date-chip"
|
||||
:class="{ active: selectedDate === item.value }"
|
||||
@tap="selectDate(item.value)"
|
||||
>
|
||||
<text class="date-week">{{ item.week }}</text>
|
||||
<text class="date-day">{{ item.day }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="duration-section section-card">
|
||||
<view class="section-header compact">
|
||||
<text class="section-title">预约时长</text>
|
||||
</view>
|
||||
<view class="duration-control">
|
||||
<button class="step-btn" :disabled="durationUnits <= 1" @tap="decreaseDuration">-</button>
|
||||
<view class="duration-value">
|
||||
<text class="duration-main">{{ durationText }}</text>
|
||||
<text class="duration-sub">{{ options ? `单场 ¥${options.amount_per_court}` : '按选择时长试算' }}</text>
|
||||
</view>
|
||||
<button class="step-btn" :disabled="durationUnits >= MAX_DURATION_UNITS" @tap="increaseDuration">+</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="time-section section-card">
|
||||
<view class="section-header compact">
|
||||
<text class="section-title">开始时间</text>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="state-box">正在加载可预约时段</view>
|
||||
<view v-else-if="loadError" class="state-box">{{ loadError }}</view>
|
||||
<view v-else-if="!timeOptions.length" class="state-box">当前日期暂无可预约时段</view>
|
||||
<view v-else class="time-grid">
|
||||
<view
|
||||
v-for="item in timeOptions"
|
||||
:key="item.start_at"
|
||||
class="time-chip"
|
||||
:class="{ active: selectedTime?.start_at === item.start_at, disabled: !item.available_court_count }"
|
||||
@tap="selectTime(item)"
|
||||
>
|
||||
<text class="time-main">{{ formatTime(item.start_at) }}</text>
|
||||
<text class="time-sub">{{ item.available_court_count ? `余 ${item.available_court_count} 场` : '已满' }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="court-section section-card">
|
||||
<view class="section-header compact">
|
||||
<text class="section-title">选择场地</text>
|
||||
</view>
|
||||
|
||||
<view v-if="!selectedTime" class="state-box">请先选择开始时间</view>
|
||||
<view v-else-if="!availableCourts.length" class="state-box">该时段无可预约场地</view>
|
||||
<view v-else class="court-list">
|
||||
<view
|
||||
v-for="court in availableCourts"
|
||||
:key="court.id"
|
||||
class="court-chip"
|
||||
:class="{ active: selectedCourtIds.includes(court.id) }"
|
||||
@tap="toggleCourt(court.id)"
|
||||
>
|
||||
<text>{{ court.name }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="selection-info">
|
||||
<text class="selection-main">{{ selectionTitle }}</text>
|
||||
<text class="selection-sub">{{ selectionSubText }}</text>
|
||||
</view>
|
||||
<button class="primary-button submit-btn" :disabled="!canConfirm" @tap="goConfirm">确认预约</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking'
|
||||
import { formatAmount, toAmountNumber } from '@/modules/comprehensive/utils/money'
|
||||
import { formatTime, todayString } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const ADVANCE_DAYS = 7
|
||||
const MAX_DURATION_UNITS = 8
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '羽毛球', value: 'badminton' },
|
||||
{ label: '篮球', value: 'basketball' }
|
||||
]
|
||||
|
||||
const courtType = ref('badminton')
|
||||
const selectedDate = ref(todayString())
|
||||
const durationUnits = ref(2)
|
||||
const options = ref(null)
|
||||
const selectedTime = ref(null)
|
||||
const selectedCourtIds = ref([])
|
||||
const isLoading = ref(false)
|
||||
const loadError = ref('')
|
||||
let loadRequestId = 0
|
||||
|
||||
const dateOptions = computed(() => {
|
||||
const today = dayjs()
|
||||
const weekLabels = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
return Array.from({ length: ADVANCE_DAYS }).map((_, index) => {
|
||||
const date = today.add(index, 'day')
|
||||
const week = index === 0 ? '今天' : index === 1 ? '明天' : weekLabels[date.day()]
|
||||
return {
|
||||
value: date.format('YYYY-MM-DD'),
|
||||
week,
|
||||
day: date.format('MM/DD')
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
const timeOptions = computed(() => options.value?.time_options || [])
|
||||
const availableCourts = computed(() => selectedTime.value?.available_courts || [])
|
||||
const businessHoursText = computed(() => {
|
||||
if (!options.value) return '营业时间加载中'
|
||||
return `营业 ${normalizeClock(options.value.open_time)}-${normalizeClock(options.value.close_time)}`
|
||||
})
|
||||
const durationText = computed(() => {
|
||||
const minutes = durationUnits.value * 30
|
||||
if (minutes % 60 === 0) return `${minutes / 60} 小时`
|
||||
if (minutes > 60) return `${Math.floor(minutes / 60)} 小时 ${minutes % 60} 分钟`
|
||||
return `${minutes} 分钟`
|
||||
})
|
||||
const canConfirm = computed(() => selectedTime.value && selectedCourtIds.value.length > 0)
|
||||
const estimatedAmount = computed(() => {
|
||||
return formatAmount(toAmountNumber(options.value?.amount_per_court) * selectedCourtIds.value.length)
|
||||
})
|
||||
const selectionTitle = computed(() => {
|
||||
if (!selectedTime.value) return '请选择开始时间'
|
||||
if (!selectedCourtIds.value.length) return `${formatTime(selectedTime.value.start_at)}-${formatTime(selectedTime.value.end_at)}`
|
||||
return `${selectedCourtIds.value.length} 个场地 · ¥${estimatedAmount.value}`
|
||||
})
|
||||
const selectionSubText = computed(() => {
|
||||
if (!selectedTime.value) return '选择时长后展示可预约时间'
|
||||
if (!selectedCourtIds.value.length) return '请选择至少一个场地'
|
||||
return `${formatTime(selectedTime.value.start_at)}-${formatTime(selectedTime.value.end_at)} · ${durationText.value}`
|
||||
})
|
||||
|
||||
onMounted(loadOptions)
|
||||
|
||||
const loadOptions = async () => {
|
||||
const requestId = ++loadRequestId
|
||||
isLoading.value = true
|
||||
loadError.value = ''
|
||||
selectedTime.value = null
|
||||
selectedCourtIds.value = []
|
||||
try {
|
||||
const data = await privateBookingApi.getPrivateBookingOptions({
|
||||
date: selectedDate.value,
|
||||
courtType: courtType.value,
|
||||
durationUnits: durationUnits.value
|
||||
})
|
||||
if (requestId !== loadRequestId) return
|
||||
options.value = data
|
||||
selectFirstAvailableTime()
|
||||
} catch (error) {
|
||||
if (requestId !== loadRequestId) return
|
||||
options.value = null
|
||||
loadError.value = error?.message || '加载失败,请稍后重试'
|
||||
} finally {
|
||||
if (requestId === loadRequestId) {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const selectFirstAvailableTime = () => {
|
||||
const first = timeOptions.value.find((item) => item.available_court_count > 0)
|
||||
if (first) {
|
||||
selectTime(first)
|
||||
}
|
||||
}
|
||||
|
||||
const selectType = (type) => {
|
||||
if (courtType.value === type) return
|
||||
courtType.value = type
|
||||
loadOptions()
|
||||
}
|
||||
|
||||
const selectDate = (date) => {
|
||||
if (selectedDate.value === date) return
|
||||
selectedDate.value = date
|
||||
loadOptions()
|
||||
}
|
||||
|
||||
const decreaseDuration = () => {
|
||||
if (durationUnits.value <= 1) return
|
||||
durationUnits.value -= 1
|
||||
loadOptions()
|
||||
}
|
||||
|
||||
const increaseDuration = () => {
|
||||
if (durationUnits.value >= MAX_DURATION_UNITS) return
|
||||
durationUnits.value += 1
|
||||
loadOptions()
|
||||
}
|
||||
|
||||
const selectTime = (timeOption) => {
|
||||
if (!timeOption.available_court_count) return
|
||||
selectedTime.value = timeOption
|
||||
selectedCourtIds.value = timeOption.available_courts.slice(0, 1).map((court) => court.id)
|
||||
}
|
||||
|
||||
const toggleCourt = (id) => {
|
||||
if (selectedCourtIds.value.includes(id)) {
|
||||
selectedCourtIds.value = selectedCourtIds.value.filter((item) => item !== id)
|
||||
} else {
|
||||
selectedCourtIds.value = [...selectedCourtIds.value, id]
|
||||
}
|
||||
}
|
||||
|
||||
const normalizeClock = (value) => {
|
||||
if (!value) return '--:--'
|
||||
return value.slice(0, 5)
|
||||
}
|
||||
|
||||
const goConfirm = () => {
|
||||
if (!canConfirm.value) return
|
||||
const payload = encodeURIComponent(JSON.stringify({
|
||||
court_type: courtType.value,
|
||||
court_ids: selectedCourtIds.value,
|
||||
start_at: selectedTime.value.start_at,
|
||||
end_at: selectedTime.value.end_at
|
||||
}))
|
||||
uni.navigateTo({ url: `/pages/comprehensive/private/confirm?payload=${payload}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.private-page {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-md;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.top-panel,
|
||||
.section-card {
|
||||
@include minimal-card;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.court-section {
|
||||
margin-bottom: 180rpx;
|
||||
}
|
||||
|
||||
.type-tabs {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $spacing-sm;
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.type-tab,
|
||||
.date-chip,
|
||||
.time-chip,
|
||||
.court-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.type-tab {
|
||||
height: 72rpx;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.type-tab.active,
|
||||
.date-chip.active,
|
||||
.time-chip.active,
|
||||
.court-chip.active {
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.venue-meta {
|
||||
min-height: 54rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
border-top: 1rpx solid $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.date-section {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.date-scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.date-list {
|
||||
display: inline-flex;
|
||||
gap: $spacing-sm;
|
||||
padding-right: $spacing-md;
|
||||
}
|
||||
|
||||
.date-chip {
|
||||
width: 128rpx;
|
||||
height: 92rpx;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
background: $bg-color-main;
|
||||
border: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.date-week {
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.date-day {
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.compact {
|
||||
margin: 0 0 $spacing-md;
|
||||
}
|
||||
|
||||
.duration-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 88rpx;
|
||||
height: 76rpx;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $border-radius-base;
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.step-btn[disabled] {
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-light;
|
||||
}
|
||||
|
||||
.duration-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-main,
|
||||
.duration-sub,
|
||||
.time-main,
|
||||
.time-sub,
|
||||
.selection-main,
|
||||
.selection-sub {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.duration-main {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.duration-sub {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.state-box {
|
||||
min-height: 140rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-color-light;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.time-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.time-chip {
|
||||
height: 94rpx;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.time-chip.disabled {
|
||||
color: $text-color-light;
|
||||
background: rgba(241, 245, 249, 0.7);
|
||||
}
|
||||
|
||||
.time-main {
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.time-sub {
|
||||
font-size: 20rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.court-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.court-chip {
|
||||
min-height: 76rpx;
|
||||
padding: 0 $spacing-sm;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.selection-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.selection-main {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.selection-sub {
|
||||
margin-top: 4rpx;
|
||||
color: $text-color-muted;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 260rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<view class="balance-page container">
|
||||
<view class="balance-card card">
|
||||
<text class="label">当前余额</text>
|
||||
<text class="amount">¥{{ balance.balance }}</text>
|
||||
</view>
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">流水明细</text>
|
||||
</view>
|
||||
<view v-for="record in records" :key="record.id" class="record-card card">
|
||||
<view class="record-top">
|
||||
<text class="kind">{{ record.kind_label }}</text>
|
||||
<text class="record-amount" :class="{ income: Number(record.amount) > 0 }">{{ record.amount }}</text>
|
||||
</view>
|
||||
<text class="remark">{{ record.remark }}</text>
|
||||
<text class="time">{{ formatDateTime(record.created_at) }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="records.length === 0" class="empty-state">暂无余额流水</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||
|
||||
const balance = ref({ balance: '0.00' })
|
||||
const records = ref([])
|
||||
|
||||
onMounted(loadPage)
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
const loadPage = async () => {
|
||||
const [balanceData, recordData] = await Promise.all([
|
||||
memberApi.getBalance(),
|
||||
memberApi.getBalanceRecords({ page: 1, pageSize: 30 })
|
||||
])
|
||||
balance.value = balanceData
|
||||
records.value = recordData.items
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.balance-card {
|
||||
min-height: 220rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.label,
|
||||
.remark,
|
||||
.time {
|
||||
display: block;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.amount {
|
||||
margin-top: $spacing-sm;
|
||||
color: $text-color-main;
|
||||
font-size: 64rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.kind {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.record-amount {
|
||||
color: $color-danger;
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.record-amount.income {
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.remark {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.time {
|
||||
margin-top: $spacing-xs;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<view class="profile-detail page-bottom-safe" v-if="form">
|
||||
<view class="profile-header">
|
||||
<button class="avatar-button" open-type="chooseAvatar" @chooseavatar="chooseAvatar">
|
||||
<image v-if="form.avatar_url" :src="form.avatar_url" mode="aspectFill" class="avatar" />
|
||||
<view v-else class="avatar placeholder">
|
||||
<text>{{ avatarInitial }}</text>
|
||||
</view>
|
||||
</button>
|
||||
<text class="avatar-tip">更换头像</text>
|
||||
</view>
|
||||
|
||||
<view class="section-card">
|
||||
<view class="field-row">
|
||||
<view class="field-main">
|
||||
<text class="field-label">昵称</text>
|
||||
<input
|
||||
v-model="form.nickname"
|
||||
class="nickname-input"
|
||||
type="nickname"
|
||||
placeholder="请输入昵称"
|
||||
placeholder-class="input-placeholder"
|
||||
/>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="field-row">
|
||||
<view class="field-main">
|
||||
<text class="field-label">手机号</text>
|
||||
<text class="field-value">{{ form.phone || '未绑定' }}</text>
|
||||
</view>
|
||||
<button class="inline-button" open-type="getPhoneNumber" @getphonenumber="bindPhone">
|
||||
{{ form.phone ? '更换' : '绑定' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<button class="primary-button save-button" :disabled="isSaving || !canSave" @tap="saveProfile">
|
||||
{{ isSaving ? '保存中...' : '保存资料' }}
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { memberApi } from '@/modules/comprehensive/api/member'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const form = ref(null)
|
||||
const isSaving = ref(false)
|
||||
const isUploadingAvatar = ref(false)
|
||||
|
||||
const avatarInitial = computed(() => (form.value?.nickname || '用户').slice(0, 1))
|
||||
const canSave = computed(() => {
|
||||
return !!form.value?.nickname?.trim() || !!form.value?.avatar_url?.trim()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
await session.init()
|
||||
form.value = { ...(session.member || await memberApi.getMemberMe()) }
|
||||
})
|
||||
|
||||
const chooseAvatar = async (event) => {
|
||||
const avatarUrl = event.detail?.avatarUrl
|
||||
if (!avatarUrl) {
|
||||
uni.showToast({ title: '未获取到头像', icon: 'none' })
|
||||
return
|
||||
}
|
||||
if (isUploadingAvatar.value) return
|
||||
|
||||
isUploadingAvatar.value = true
|
||||
try {
|
||||
const data = await memberApi.uploadAvatar(avatarUrl)
|
||||
form.value = { ...data.member }
|
||||
await session.refreshMember()
|
||||
uni.showToast({ title: '头像已更新', icon: 'success' })
|
||||
} finally {
|
||||
isUploadingAvatar.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveProfile = async () => {
|
||||
if (!canSave.value || isSaving.value) return
|
||||
isSaving.value = true
|
||||
try {
|
||||
const data = await memberApi.updateMemberProfile({
|
||||
nickname: form.value.nickname,
|
||||
avatar_url: form.value.avatar_url
|
||||
})
|
||||
form.value = { ...data }
|
||||
await session.refreshMember()
|
||||
uni.showToast({ title: '已保存', icon: 'success' })
|
||||
} finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const bindPhone = async (event) => {
|
||||
const code = event.detail?.code
|
||||
if (!code) {
|
||||
uni.showToast({ title: '未获取到手机号授权', icon: 'none' })
|
||||
return
|
||||
}
|
||||
const data = await memberApi.bindMemberPhone(code)
|
||||
form.value = { ...data }
|
||||
await session.refreshMember()
|
||||
uni.showToast({ title: '手机号已绑定', icon: 'success' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.profile-detail {
|
||||
min-height: 100vh;
|
||||
padding: $spacing-md;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.profile-header {
|
||||
min-height: 360rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-button {
|
||||
width: 184rpx;
|
||||
height: 184rpx;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
background: transparent;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 184rpx;
|
||||
height: 184rpx;
|
||||
border-radius: 50%;
|
||||
background: $bg-color-hover;
|
||||
border: 6rpx solid $bg-color-main;
|
||||
box-shadow: 0 12rpx 36rpx rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.avatar.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-color-main;
|
||||
font-size: 56rpx;
|
||||
font-weight: 900;
|
||||
background: $color-primary-light;
|
||||
}
|
||||
|
||||
.avatar-tip {
|
||||
margin-top: $spacing-md;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
margin-bottom: $spacing-md;
|
||||
padding: 0 $spacing-md;
|
||||
border: 1rpx solid $border-color;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-main;
|
||||
}
|
||||
|
||||
.field-row {
|
||||
min-height: 124rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.field-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.field-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.field-label,
|
||||
.field-value {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 23rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.nickname-input,
|
||||
.field-value {
|
||||
width: 100%;
|
||||
min-height: 54rpx;
|
||||
margin-top: 8rpx;
|
||||
color: $text-color-main;
|
||||
font-size: 31rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.input-placeholder {
|
||||
color: $text-color-light;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.inline-button {
|
||||
min-width: 112rpx;
|
||||
height: 58rpx;
|
||||
padding: 0 $spacing-md;
|
||||
border-radius: $border-radius-base;
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
font-size: 24rpx;
|
||||
font-weight: 800;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.save-button {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,453 @@
|
||||
<template>
|
||||
<view class="profile-page container">
|
||||
<view v-if="!session.isLoggedIn" class="unlogged">
|
||||
<text class="brand">星河综合运动中心</text>
|
||||
<text class="desc">登录后查看余额、订单、门票权益与个人资料。</text>
|
||||
<button class="primary-button login-btn" @tap="login">微信一键登录</button>
|
||||
</view>
|
||||
|
||||
<block v-else>
|
||||
<view class="user-profile-hero" @tap="go('/pages/comprehensive/profile/detail')">
|
||||
<view class="hero-left">
|
||||
<image v-if="session.member?.avatar_url" :src="session.member.avatar_url" mode="aspectFill" class="hero-avatar" />
|
||||
<view v-else class="hero-avatar placeholder">
|
||||
<text>{{ avatarInitial }}</text>
|
||||
</view>
|
||||
<view class="hero-meta">
|
||||
<text class="hero-name">{{ session.member?.nickname }}</text>
|
||||
<view v-if="session.member?.phone" class="hero-sub-row">
|
||||
<text class="hero-phone">{{ session.member.phone }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
<view class="hero-right">
|
||||
<view class="hero-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="menu-group-card">
|
||||
<view v-for="item in menuItems" :key="item.title" class="menu-item" @tap="go(item.url)">
|
||||
<view class="item-left">
|
||||
<view class="item-icon-container" :class="item.theme">
|
||||
<view class="icon-order" v-if="item.icon === 'order'">
|
||||
<view class="icon-order-line" />
|
||||
<view class="icon-order-line short" />
|
||||
</view>
|
||||
<view class="icon-private" v-else-if="item.icon === 'private'">
|
||||
<view class="icon-private-net" />
|
||||
</view>
|
||||
<view class="icon-ticket" v-else-if="item.icon === 'ticket'">
|
||||
<view class="icon-ticket-dot" />
|
||||
</view>
|
||||
<view class="icon-profile" v-else>
|
||||
<view class="icon-profile-head" />
|
||||
<view class="icon-profile-body" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="menu-title">{{ item.title }}</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<view class="menu-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="menu-item logout" @tap="logout">
|
||||
<view class="item-left">
|
||||
<view class="item-icon-container logout-theme">
|
||||
<view class="icon-logout">
|
||||
<view class="icon-logout-door" />
|
||||
<view class="icon-logout-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="menu-title">退出登录</text>
|
||||
</view>
|
||||
<view class="item-right">
|
||||
<view class="menu-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</block>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1))
|
||||
|
||||
const menuItems = [
|
||||
{ title: '包场预约', url: '/pages/comprehensive/private/index', theme: 'private-theme', icon: 'private' },
|
||||
{ title: '篮球门票', url: '/pages/comprehensive/tickets/index', theme: 'ticket-theme', icon: 'ticket' },
|
||||
{ title: '我的订单', url: '/pages/comprehensive/profile/orders', theme: 'order-theme', icon: 'order' },
|
||||
{ title: '个人信息', url: '/pages/comprehensive/profile/detail', theme: 'profile-theme', icon: 'profile' },
|
||||
{ title: '切换场馆', url: '/pages/venue-select/index', theme: 'profile-theme', icon: 'profile' }
|
||||
]
|
||||
|
||||
onMounted(async () => {
|
||||
await session.init()
|
||||
})
|
||||
|
||||
const login = async () => {
|
||||
await session.login()
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
session.logout()
|
||||
uni.showToast({ title: '已退出', icon: 'none' })
|
||||
}
|
||||
|
||||
const go = (url) => {
|
||||
uni.navigateTo({ url })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.unlogged {
|
||||
min-height: 80vh;
|
||||
padding-top: 180rpx;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.brand {
|
||||
color: $text-color-main;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
letter-spacing: 2rpx;
|
||||
}
|
||||
|
||||
.desc {
|
||||
margin-top: $spacing-md;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.login-btn {
|
||||
width: 520rpx;
|
||||
margin-top: 100rpx;
|
||||
}
|
||||
|
||||
.user-profile-hero {
|
||||
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);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.user-profile-hero:active {
|
||||
opacity: 0.96;
|
||||
transform: scale(0.99);
|
||||
}
|
||||
|
||||
.hero-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-md;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-avatar {
|
||||
width: 104rpx;
|
||||
height: 104rpx;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
background: $bg-color-hover;
|
||||
}
|
||||
|
||||
.hero-avatar.placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-color-main;
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
background: $color-primary-light;
|
||||
}
|
||||
|
||||
.hero-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6rpx;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.hero-name,
|
||||
.hero-phone,
|
||||
.menu-title {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.hero-name {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 700;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.hero-phone {
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.hero-arrow,
|
||||
.menu-arrow {
|
||||
width: 18rpx;
|
||||
height: 18rpx;
|
||||
border-top: 3rpx solid $text-color-light;
|
||||
border-right: 3rpx solid $text-color-light;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.menu-group-card {
|
||||
margin-top: $spacing-lg;
|
||||
overflow: hidden;
|
||||
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);
|
||||
}
|
||||
|
||||
.menu-item {
|
||||
min-height: 112rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0 $spacing-lg;
|
||||
border-bottom: 1rpx solid rgba(241, 245, 249, 0.9);
|
||||
}
|
||||
|
||||
.menu-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.item-left,
|
||||
.item-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.item-left {
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.item-icon-container {
|
||||
width: 58rpx;
|
||||
height: 58rpx;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: $border-radius-base;
|
||||
background: $bg-color-soft;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.item-icon-container.order-theme {
|
||||
background: $color-primary-light;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.item-icon-container.profile-theme {
|
||||
background: $color-success-light;
|
||||
color: $color-success;
|
||||
}
|
||||
|
||||
.item-icon-container.private-theme {
|
||||
background: rgba(14, 165, 233, 0.12);
|
||||
color: #0284C7;
|
||||
}
|
||||
|
||||
.item-icon-container.ticket-theme {
|
||||
background: rgba(245, 158, 11, 0.14);
|
||||
color: #D97706;
|
||||
}
|
||||
|
||||
.item-icon-container.logout-theme {
|
||||
background: $color-danger-light;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.icon-order,
|
||||
.icon-private,
|
||||
.icon-ticket,
|
||||
.icon-profile,
|
||||
.icon-logout {
|
||||
position: relative;
|
||||
width: 32rpx;
|
||||
height: 32rpx;
|
||||
}
|
||||
|
||||
.icon-order {
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 4rpx;
|
||||
}
|
||||
|
||||
.icon-order::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: -8rpx;
|
||||
left: 7rpx;
|
||||
width: 14rpx;
|
||||
height: 10rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-bottom: none;
|
||||
border-radius: 4rpx 4rpx 0 0;
|
||||
background: inherit;
|
||||
}
|
||||
|
||||
.icon-order-line {
|
||||
position: absolute;
|
||||
left: 7rpx;
|
||||
right: 7rpx;
|
||||
top: 10rpx;
|
||||
height: 3rpx;
|
||||
border-radius: 2rpx;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.icon-order-line.short {
|
||||
top: 19rpx;
|
||||
right: 12rpx;
|
||||
}
|
||||
|
||||
.icon-private {
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.icon-private::before,
|
||||
.icon-private::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 5rpx;
|
||||
right: 5rpx;
|
||||
height: 3rpx;
|
||||
border-radius: 2rpx;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.icon-private::before {
|
||||
top: 9rpx;
|
||||
}
|
||||
|
||||
.icon-private::after {
|
||||
bottom: 9rpx;
|
||||
}
|
||||
|
||||
.icon-private-net {
|
||||
position: absolute;
|
||||
top: 3rpx;
|
||||
bottom: 3rpx;
|
||||
left: 12rpx;
|
||||
width: 4rpx;
|
||||
border-radius: 2rpx;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.icon-ticket {
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 6rpx;
|
||||
transform: rotate(-8deg);
|
||||
}
|
||||
|
||||
.icon-ticket::before,
|
||||
.icon-ticket::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 9rpx;
|
||||
bottom: 9rpx;
|
||||
width: 3rpx;
|
||||
border-radius: 2rpx;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.icon-ticket::before {
|
||||
left: 8rpx;
|
||||
}
|
||||
|
||||
.icon-ticket::after {
|
||||
right: 8rpx;
|
||||
}
|
||||
|
||||
.icon-ticket-dot {
|
||||
position: absolute;
|
||||
top: 12rpx;
|
||||
left: 13rpx;
|
||||
width: 6rpx;
|
||||
height: 6rpx;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.icon-profile-head {
|
||||
position: absolute;
|
||||
top: 3rpx;
|
||||
left: 10rpx;
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.icon-profile-body {
|
||||
position: absolute;
|
||||
left: 5rpx;
|
||||
right: 5rpx;
|
||||
bottom: 3rpx;
|
||||
height: 12rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-radius: 16rpx 16rpx 4rpx 4rpx;
|
||||
}
|
||||
|
||||
.icon-logout-door {
|
||||
position: absolute;
|
||||
left: 4rpx;
|
||||
top: 4rpx;
|
||||
width: 15rpx;
|
||||
height: 24rpx;
|
||||
border: 3rpx solid currentColor;
|
||||
border-right: none;
|
||||
border-radius: 4rpx 0 0 4rpx;
|
||||
}
|
||||
|
||||
.icon-logout-arrow {
|
||||
position: absolute;
|
||||
right: 2rpx;
|
||||
top: 14rpx;
|
||||
width: 20rpx;
|
||||
height: 3rpx;
|
||||
border-radius: 2rpx;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.icon-logout-arrow::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: -6rpx;
|
||||
width: 10rpx;
|
||||
height: 10rpx;
|
||||
border-top: 3rpx solid currentColor;
|
||||
border-right: 3rpx solid currentColor;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.menu-title {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.logout .menu-title {
|
||||
color: $color-danger;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,240 @@
|
||||
<template>
|
||||
<view class="orders-page">
|
||||
<view class="filter-panel">
|
||||
<scroll-view scroll-x class="filter-scroll">
|
||||
<view class="filter-row">
|
||||
<view v-for="item in businessOptions" :key="item.value" class="filter-chip" :class="{ active: businessType === item.value }" @tap="selectBusiness(item.value)">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
<scroll-view scroll-x class="filter-scroll second">
|
||||
<view class="filter-row">
|
||||
<view v-for="item in statusOptions" :key="item.value" class="filter-chip" :class="{ active: orderStatus === item.value }" @tap="selectStatus(item.value)">
|
||||
{{ item.label }}
|
||||
</view>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<view class="list">
|
||||
<view v-for="order in orders" :key="order.id" class="order-card card" @tap="goDetail(order.id)">
|
||||
<view class="order-top">
|
||||
<text class="business">{{ order.business_type_label }}</text>
|
||||
<text class="badge" :class="statusBadgeClass(order.status)">{{ order.status_label }}</text>
|
||||
</view>
|
||||
<text class="order-no">{{ order.order_no }}</text>
|
||||
<view class="order-bottom">
|
||||
<text class="time">{{ formatDateTime(order.created_at) }}</text>
|
||||
<text class="amount">¥{{ order.amount }}</text>
|
||||
</view>
|
||||
<view v-if="order.status === 'pending_pay'" class="actions" @tap.stop>
|
||||
<button class="ghost-button small-btn" @tap="closeOrder(order.id)">取消订单</button>
|
||||
<button class="primary-button small-btn" @tap="goPay(order.id)">去支付</button>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="isLoading" class="load-more-tip">加载中...</view>
|
||||
<view v-else-if="orders.length === 0" class="empty-state">暂无订单</view>
|
||||
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
|
||||
import { orderApi } from '@/modules/comprehensive/api/order'
|
||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||
|
||||
const businessOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '课程', value: 'course' },
|
||||
{ label: '包场', value: 'private_booking' },
|
||||
{ label: '门票', value: 'ticket' }
|
||||
]
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待支付', value: 'pending_pay' },
|
||||
{ label: '已支付', value: 'paid' },
|
||||
{ label: '已关闭', value: 'closed' },
|
||||
{ label: '已退款', value: 'refunded' }
|
||||
]
|
||||
|
||||
const businessType = ref('')
|
||||
const orderStatus = ref('')
|
||||
const orders = ref([])
|
||||
const page = ref(1)
|
||||
const pageSize = 20
|
||||
const total = ref(0)
|
||||
const isLoading = ref(false)
|
||||
const isLoadingMore = ref(false)
|
||||
|
||||
const hasMore = computed(() => orders.value.length < total.value)
|
||||
|
||||
onMounted(() => loadOrders(true))
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadOrders(true).finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
onReachBottom(() => {
|
||||
if (!hasMore.value || isLoadingMore.value) return
|
||||
page.value += 1
|
||||
loadOrders(false)
|
||||
})
|
||||
|
||||
const loadOrders = async (reset) => {
|
||||
if (reset) {
|
||||
page.value = 1
|
||||
isLoading.value = true
|
||||
} else {
|
||||
isLoadingMore.value = true
|
||||
}
|
||||
try {
|
||||
const data = await orderApi.getOrders({
|
||||
page: page.value,
|
||||
pageSize,
|
||||
businessType: businessType.value,
|
||||
status: orderStatus.value
|
||||
})
|
||||
orders.value = reset ? data.items : [...orders.value, ...data.items]
|
||||
total.value = data.total
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
isLoadingMore.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const selectBusiness = (value) => {
|
||||
businessType.value = value
|
||||
loadOrders(true)
|
||||
}
|
||||
|
||||
const selectStatus = (value) => {
|
||||
orderStatus.value = value
|
||||
loadOrders(true)
|
||||
}
|
||||
|
||||
const goDetail = (id) => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/orders/detail?id=${id}` })
|
||||
}
|
||||
|
||||
const goPay = (id) => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${id}` })
|
||||
}
|
||||
|
||||
const closeOrder = async (id) => {
|
||||
await orderApi.closeOrder(id)
|
||||
uni.showToast({ title: '订单已取消', icon: 'none' })
|
||||
loadOrders(true)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.orders-page {
|
||||
min-height: 100vh;
|
||||
background: $bg-color-soft;
|
||||
}
|
||||
|
||||
.filter-panel {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
padding: $spacing-md;
|
||||
background: $bg-color-main;
|
||||
border-bottom: 1rpx solid $border-color;
|
||||
}
|
||||
|
||||
.filter-scroll {
|
||||
width: 100%;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.filter-scroll.second {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.filter-row {
|
||||
display: inline-flex;
|
||||
gap: $spacing-sm;
|
||||
}
|
||||
|
||||
.filter-chip {
|
||||
min-width: 118rpx;
|
||||
height: 62rpx;
|
||||
padding: 0 $spacing-sm;
|
||||
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;
|
||||
}
|
||||
|
||||
.filter-chip.active {
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: $spacing-md;
|
||||
}
|
||||
|
||||
.order-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.order-top,
|
||||
.order-bottom,
|
||||
.actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.business {
|
||||
color: $text-color-main;
|
||||
font-size: 30rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.order-no,
|
||||
.time {
|
||||
display: block;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
|
||||
.order-bottom {
|
||||
margin-top: $spacing-md;
|
||||
}
|
||||
|
||||
.amount {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.actions {
|
||||
justify-content: flex-end;
|
||||
margin-top: $spacing-md;
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.small-btn {
|
||||
width: 180rpx;
|
||||
height: 64rpx;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<template>
|
||||
<view class="tickets-page container">
|
||||
<view class="remaining-card card">
|
||||
<text class="label">当前可核销次数</text>
|
||||
<text class="remaining">{{ account.remaining_count }} 次</text>
|
||||
<button class="primary-button buy-btn" @tap="goBuy">购买篮球门票</button>
|
||||
</view>
|
||||
|
||||
<view class="section-header">
|
||||
<text class="section-title">权益记录</text>
|
||||
</view>
|
||||
<view v-for="record in account.records" :key="record.id" class="record-card card" @tap="goOrder(record)">
|
||||
<view class="record-top">
|
||||
<text class="order-no">{{ record.order_no }}</text>
|
||||
<text class="badge" :class="statusBadgeClass(record.status)">{{ record.status_label }}</text>
|
||||
</view>
|
||||
<text class="meta">{{ record.checked_in_at ? `核销时间 ${formatDateTime(record.checked_in_at)}` : `生成时间 ${formatDateTime(record.created_at)}` }}</text>
|
||||
<text class="meta">订单状态:{{ record.order_status }}</text>
|
||||
</view>
|
||||
|
||||
<view v-if="account.records.length === 0" class="empty-state">暂无门票权益</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { onPullDownRefresh } from '@dcloudio/uni-app'
|
||||
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||
import { formatDateTime } from '@/modules/comprehensive/utils/date'
|
||||
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
|
||||
|
||||
const account = ref({ remaining_count: 0, records: [] })
|
||||
|
||||
onMounted(loadPage)
|
||||
|
||||
onPullDownRefresh(() => {
|
||||
loadPage().finally(() => uni.stopPullDownRefresh())
|
||||
})
|
||||
|
||||
const loadPage = async () => {
|
||||
account.value = await ticketApi.getTicketAccount({ page: 1, pageSize: 50 })
|
||||
}
|
||||
|
||||
const goBuy = () => {
|
||||
uni.navigateTo({ url: '/pages/comprehensive/tickets/index' })
|
||||
}
|
||||
|
||||
const goOrder = (record) => {
|
||||
if (!record.order_id) return
|
||||
uni.navigateTo({ url: `/pages/comprehensive/orders/detail?id=${record.order_id}` })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.remaining-card {
|
||||
min-height: 250rpx;
|
||||
}
|
||||
|
||||
.label,
|
||||
.meta {
|
||||
display: block;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.remaining {
|
||||
display: block;
|
||||
margin: $spacing-sm 0 $spacing-lg;
|
||||
color: $text-color-main;
|
||||
font-size: 58rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.buy-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.record-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.record-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.order-no {
|
||||
color: $text-color-main;
|
||||
font-size: 28rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.meta {
|
||||
margin-top: $spacing-sm;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,171 @@
|
||||
<template>
|
||||
<view class="tickets-page container page-bottom-safe" v-if="product">
|
||||
<view class="product-card 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">¥{{ product.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="decrease">-</button>
|
||||
<text class="quantity">{{ quantity }}</text>
|
||||
<button class="step-btn" @tap="increase">+</button>
|
||||
</view>
|
||||
<view class="amount-row">
|
||||
<text>合计金额</text>
|
||||
<text>¥{{ totalAmount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view class="fixed-action-bar">
|
||||
<view class="bar-price">
|
||||
<text class="bar-label">合计</text>
|
||||
<text class="price-text">¥{{ totalAmount }}</text>
|
||||
</view>
|
||||
<button class="primary-button submit-btn" :disabled="isSubmitting || !product.is_active" @tap="submit">
|
||||
立即购买
|
||||
</button>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { ticketApi } from '@/modules/comprehensive/api/ticket'
|
||||
import { multiplyAmount } from '@/modules/comprehensive/utils/money'
|
||||
|
||||
const product = ref(null)
|
||||
const quantity = ref(1)
|
||||
const isSubmitting = ref(false)
|
||||
|
||||
const totalAmount = computed(() => product.value ? multiplyAmount(product.value.unit_price, quantity.value) : '0.00')
|
||||
|
||||
onMounted(async () => {
|
||||
product.value = await ticketApi.getTicketProduct()
|
||||
})
|
||||
|
||||
const decrease = () => {
|
||||
quantity.value = Math.max(1, quantity.value - 1)
|
||||
}
|
||||
|
||||
const increase = () => {
|
||||
quantity.value += 1
|
||||
}
|
||||
|
||||
const submit = async () => {
|
||||
if (isSubmitting.value || !product.value?.is_active) return
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const data = await ticketApi.createTicketOrder(quantity.value)
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${data.order.id}` })
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.product-card,
|
||||
.count-card {
|
||||
margin-bottom: $spacing-md;
|
||||
}
|
||||
|
||||
.product-head,
|
||||
.amount-row,
|
||||
.stepper-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: $spacing-md;
|
||||
}
|
||||
|
||||
.product-title,
|
||||
.card-title {
|
||||
display: block;
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.product-subtitle {
|
||||
display: block;
|
||||
margin-top: $spacing-xs;
|
||||
color: $text-color-muted;
|
||||
font-size: 24rpx;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.price-block {
|
||||
margin-top: $spacing-xl;
|
||||
}
|
||||
|
||||
.unit-price {
|
||||
color: $text-color-main;
|
||||
font-size: 64rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.unit-label {
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.stepper-row {
|
||||
margin: $spacing-lg 0;
|
||||
}
|
||||
|
||||
.step-btn {
|
||||
width: 86rpx;
|
||||
height: 70rpx;
|
||||
border-radius: $border-radius-base;
|
||||
background: $text-color-main;
|
||||
color: #FFFFFF;
|
||||
font-size: 38rpx;
|
||||
font-weight: 900;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.quantity {
|
||||
color: $text-color-main;
|
||||
font-size: 42rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.amount-row {
|
||||
padding-top: $spacing-md;
|
||||
border-top: 1rpx solid $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
font-size: 26rpx;
|
||||
}
|
||||
|
||||
.amount-row text:last-child {
|
||||
color: $text-color-main;
|
||||
font-size: 34rpx;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.bar-price {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bar-label {
|
||||
display: block;
|
||||
color: $text-color-light;
|
||||
font-size: 22rpx;
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 320rpx;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user