Compare commits

...

10 Commits

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

After

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 404 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 401 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 425 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 422 B

+8 -26
View File
@@ -6,31 +6,17 @@ export const VENUE_KEYS = Object.freeze({
})
export const VENUE_OPTIONS = Object.freeze([
{
key: VENUE_KEYS.ICE,
name: '冰场馆',
description: '课程预约、会员卡与滑冰服务',
homePath: '/pages/ice/home/index'
},
{
key: VENUE_KEYS.COMPREHENSIVE,
name: '综合场馆',
description: '课程、包场、门票、订单与余额',
homePath: '/pages/comprehensive/home/index'
}
{ key: VENUE_KEYS.COMPREHENSIVE, name: '综合场馆' },
{ key: VENUE_KEYS.ICE, name: '冰场馆' }
])
export const isVenueKey = (value) => {
return value === VENUE_KEYS.ICE || value === VENUE_KEYS.COMPREHENSIVE
}
export const isVenueKey = (value) => value === VENUE_KEYS.ICE || value === VENUE_KEYS.COMPREHENSIVE
export const getVenueOption = (venueKey) => {
return VENUE_OPTIONS.find((item) => item.key === venueKey) || null
}
export const getVenueOption = (venueKey) => VENUE_OPTIONS.find((item) => item.key === venueKey) || null
export const getStoredVenue = () => {
const venue = uni.getStorageSync(CURRENT_VENUE_KEY)
return isVenueKey(venue) ? venue : ''
return isVenueKey(venue) ? venue : VENUE_KEYS.COMPREHENSIVE
}
export const setStoredVenue = (venueKey) => {
@@ -38,18 +24,14 @@ export const setStoredVenue = (venueKey) => {
uni.removeStorageSync(CURRENT_VENUE_KEY)
return ''
}
uni.setStorageSync(CURRENT_VENUE_KEY, venueKey)
return venueKey
}
export const navigateToVenueHome = (venueKey, options = {}) => {
const venue = getVenueOption(venueKey)
if (!venue) {
export const navigateToVenueHome = (venueKey) => {
if (!isVenueKey(venueKey)) {
uni.reLaunch({ url: '/pages/venue-select/index' })
return
}
const navigate = options.redirect === true ? uni.redirectTo : uni.reLaunch
navigate({ url: venue.homePath })
uni.reLaunch({ url: '/pages/home/index' })
}