feat: migrate comprehensive venue module

This commit is contained in:
gqt
2026-06-08 20:54:25 +08:00
parent c9650b0db1
commit 8e1443f7e5
43 changed files with 5017 additions and 0 deletions
@@ -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>