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页注册
This commit is contained in:
gqt
2026-07-13 21:43:59 +08:00
parent 5487ece142
commit 6a18d3de61
9 changed files with 385 additions and 433 deletions
-6
View File
@@ -180,12 +180,6 @@
"navigationBarTitleText": "登录" "navigationBarTitleText": "登录"
} }
}, },
{
"path": "pages/comprehensive/profile/setup",
"style": {
"navigationBarTitleText": "完善信息"
}
},
{ {
"path": "pages/comprehensive/mall/index", "path": "pages/comprehensive/mall/index",
"style": { "style": {
@@ -29,9 +29,18 @@
<text class="quantity">{{ ticketQty }}</text> <text class="quantity">{{ ticketQty }}</text>
<button class="step-btn" @tap="increaseTicketQty">+</button> <button class="step-btn" @tap="increaseTicketQty">+</button>
</view> </view>
<view class="amount-row"> </view>
<text>合计金额</text>
<text>¥{{ ticketTotal }}</text> <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> </view>
@@ -98,7 +107,7 @@ import { membershipApi } from '@/modules/comprehensive/api/membership'
import { ticketApi } from '@/modules/comprehensive/api/ticket' import { ticketApi } from '@/modules/comprehensive/api/ticket'
import { multiplyAmount } from '@/modules/comprehensive/utils/money' import { multiplyAmount } from '@/modules/comprehensive/utils/money'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session' import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
import { formatTimeRange, todayString } from '@/modules/comprehensive/utils/date' import { formatTimeRange, formatDate, todayString } from '@/modules/comprehensive/utils/date'
const session = useComprehensiveSessionStore() const session = useComprehensiveSessionStore()
const activeTab = ref('membership') const activeTab = ref('membership')
@@ -113,6 +122,8 @@ const isLoadingMore = ref(false)
const ticketProduct = ref(null) const ticketProduct = ref(null)
const ticketQty = ref(1) const ticketQty = ref(1)
const ticketSubmitting = ref(false) 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 ticketTotal = computed(() => ticketProduct.value ? multiplyAmount(ticketProduct.value.unit_price, ticketQty.value) : '0.00')
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
@@ -135,6 +146,7 @@ const switchTab = (tab) => {
activeTab.value = tab activeTab.value = tab
if (tab === 'tickets') { if (tab === 'tickets') {
loadTicketProduct() loadTicketProduct()
loadTicketRecords()
} else { } else {
loadSessions(true) loadSessions(true)
} }
@@ -177,6 +189,19 @@ const loadTicketProduct = async () => {
} }
} }
const loadTicketRecords = async () => {
try {
const data = await ticketApi.getTicketAccount({ page: 1, pageSize: 20 })
ticketRecords.value = (data.records || []).filter((r) =>
['available', 'checked_in', 'refunded'].includes(r.status)
)
ticketRemaining.value = data.remaining_count || 0
} catch {
ticketRecords.value = []
ticketRemaining.value = 0
}
}
const decreaseTicketQty = () => { const decreaseTicketQty = () => {
ticketQty.value = Math.max(1, ticketQty.value - 1) ticketQty.value = Math.max(1, ticketQty.value - 1)
} }
@@ -222,7 +247,8 @@ const goDetail = (item) => {
const refresh = async () => { const refresh = async () => {
if (activeTab.value === 'tickets') { if (activeTab.value === 'tickets') {
ticketProduct.value = null ticketProduct.value = null
await loadTicketProduct() ticketRecords.value = []
await Promise.all([loadTicketProduct(), loadTicketRecords()])
} else { } else {
page.value = 1 page.value = 1
await loadSessions(true) await loadSessions(true)
@@ -531,4 +557,74 @@ defineExpose({ refresh, loadMore })
.submit-btn { .submit-btn {
width: 320rpx; width: 320rpx;
} }
/* 门票记录 */
.ticket-records-section {
margin-bottom: $spacing-md;
}
.ticket-remaining {
color: $color-success;
font-size: 24rpx;
font-weight: 700;
}
.ticket-record-card {
display: flex;
flex-direction: column;
gap: $spacing-xs;
}
.ticket-record-top {
display: flex;
justify-content: space-between;
align-items: center;
}
.ticket-record-no {
color: $text-color-muted;
font-size: 24rpx;
}
.ticket-record-date {
color: $text-color-light;
font-size: 22rpx;
}
.ticket-status-badge {
padding: 4rpx 16rpx;
border-radius: $border-radius-sm;
font-size: 22rpx;
font-weight: 800;
}
.ticket-status-badge.available {
background: $color-success-light;
color: $color-success;
}
.ticket-status-badge.checked_in {
background: $bg-color-hover;
color: $text-color-light;
}
.ticket-status-badge.cancelled {
background: $color-danger-light;
color: $color-danger;
}
.ticket-status-badge.pending_pay {
background: $color-warning-light;
color: $color-warning;
}
.ticket-status-badge.refunded {
background: $color-danger-light;
color: $color-danger;
}
.ticket-status-badge.closed {
background: $bg-color-hover;
color: $text-color-light;
}
</style> </style>
@@ -1,18 +1,12 @@
<template> <template>
<view class="available-page"> <view class="available-page">
<UnauthLoginPanel <view v-if="isLoading" class="load-more-tip">加载中...</view>
v-if="!session.isLoggedIn"
@login="login"
/>
<block v-else> <view v-else-if="sessions.length" class="session-list">
<view class="session-list"> <view v-for="session in sessions" :key="session.id" class="card session-card">
<view v-for="session in sessions" :key="session.id" class="card session-card" @tap="onSessionTap(session)">
<image :src="session.cover_image" mode="aspectFill" class="session-cover" /> <image :src="session.cover_image" mode="aspectFill" class="session-cover" />
<view class="session-info"> <view class="session-info">
<view class="session-top">
<text class="session-title">{{ session.title }}</text> <text class="session-title">{{ session.title }}</text>
</view>
<text class="time-line">{{ formatTimeRange(session.start_at, session.end_at) }}</text> <text class="time-line">{{ formatTimeRange(session.start_at, session.end_at) }}</text>
<text class="meta">{{ session.coach_name }}</text> <text class="meta">{{ session.coach_name }}</text>
<view class="session-bottom"> <view class="session-bottom">
@@ -21,72 +15,28 @@
</view> </view>
</view> </view>
</view> </view>
<view v-if="!hasMore && sessions.length >= pageSize" class="load-more-tip">已加载全部</view>
<view v-if="isLoading" class="load-more-tip">加载中...</view>
<view v-else-if="sessions.length === 0" class="empty-state">暂无可约课程</view>
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
<view v-if="isLoadingMore" class="load-more-tip">加载中...</view>
</view> </view>
<view v-if="cardPickerVisible" class="picker-mask" @tap="closeCardPicker"> <view v-else class="empty-state">暂无可约课程</view>
<view class="picker-sheet" @tap.stop>
<text class="picker-title">选择次卡</text>
<view v-for="cardId in pickerCardIds" :key="cardId" class="picker-item" @tap="confirmCard(cardId)">
{{ getCardLabel(cardId) }}
</view>
<view class="picker-item cancel" @tap="closeCardPicker">取消</view>
</view>
</view>
</block>
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad, onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app' import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { membershipApi } from '@/modules/comprehensive/api/membership' import { membershipApi } from '@/modules/comprehensive/api/membership'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
import { formatTimeRange } from '@/modules/comprehensive/utils/date' import { formatTimeRange } from '@/modules/comprehensive/utils/date'
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
const session = useComprehensiveSessionStore()
const preselectedCardId = ref('')
const sessions = ref([]) const sessions = ref([])
const cards = ref([])
const page = ref(1) const page = ref(1)
const pageSize = 20 const pageSize = 20
const total = ref(0) const total = ref(0)
const isLoading = ref(false) const isLoading = ref(false)
const isLoadingMore = ref(false) const isLoadingMore = ref(false)
const isSubmitting = ref(false)
const cardPickerVisible = ref(false)
const pendingSession = ref(null)
const hasMore = computed(() => sessions.value.length < total.value) const hasMore = computed(() => sessions.value.length < total.value)
onLoad(async (query) => {
preselectedCardId.value = query.card_id ? String(query.card_id) : ''
await session.init()
if (session.isLoggedIn) loadAll()
})
onPullDownRefresh(() => {
loadAll().finally(() => uni.stopPullDownRefresh())
})
onReachBottom(() => {
if (!hasMore.value || isLoadingMore.value) return
page.value += 1
loadSessions(false)
})
const loadAll = async () => {
await Promise.all([
loadSessions(true),
loadCards()
])
}
const loadSessions = async (reset) => { const loadSessions = async (reset) => {
if (reset) { if (reset) {
page.value = 1 page.value = 1
@@ -96,92 +46,25 @@ const loadSessions = async (reset) => {
} }
try { try {
const data = await membershipApi.getAvailableSessions({ page: page.value, pageSize }) const data = await membershipApi.getAvailableSessions({ page: page.value, pageSize })
let items = data.items sessions.value = reset ? data.items : [...sessions.value, ...data.items]
if (preselectedCardId.value) {
items = items.filter((item) => item.available_card_ids.includes(Number(preselectedCardId.value)))
}
sessions.value = reset ? items : [...sessions.value, ...items]
if (preselectedCardId.value && data.items.length < data.page_size) {
total.value = sessions.value.length
} else {
total.value = data.total total.value = data.total
}
} finally { } finally {
isLoading.value = false isLoading.value = false
isLoadingMore.value = false isLoadingMore.value = false
} }
} }
const loadCards = async () => { onPullDownRefresh(() => {
const data = await membershipApi.getCards({ page: 1, pageSize: 50 }) loadSessions(true).finally(() => uni.stopPullDownRefresh())
cards.value = data.items.filter((card) => card.status === 'available' && card.remaining_count > 0)
}
const onSessionTap = (session) => {
if (isSubmitting.value) return
if (session.remaining_count <= 0) {
uni.showToast({ title: '该课程已约满', icon: 'none' })
return
}
const availableIds = session.available_card_ids
if (availableIds.length === 0) {
uni.showToast({ title: '暂无可用次卡', icon: 'none' })
return
}
if (preselectedCardId.value) {
submitBooking(preselectedCardId.value, session.id)
return
}
if (availableIds.length === 1) {
submitBooking(String(availableIds[0]), session.id)
return
}
pendingSession.value = session
cardPickerVisible.value = true
}
const submitBooking = async (cardId, sessionId) => {
if (isSubmitting.value) return
isSubmitting.value = true
uni.showLoading({ title: '提交中...' })
try {
const booking = await membershipApi.createCardBooking({ cardId, sessionId })
uni.hideLoading()
uni.redirectTo({ url: `/pages/comprehensive/membership/booking-detail?id=${booking.id}` })
} catch (error) {
uni.hideLoading()
} finally {
isSubmitting.value = false
cardPickerVisible.value = false
pendingSession.value = null
}
}
const pickerCardIds = computed(() => {
if (!pendingSession.value) return []
return pendingSession.value.available_card_ids.map(String)
}) })
const getCardLabel = (cardId) => { onReachBottom(() => {
const card = cards.value.find((item) => String(item.id) === String(cardId)) if (!hasMore.value || isLoadingMore.value) return
if (!card) return `次卡 #${cardId}` page.value += 1
return `${card.plan_name}(剩余${card.remaining_count}次)` loadSessions(false)
} })
const confirmCard = (cardId) => { loadSessions(true)
if (!pendingSession.value) return
submitBooking(cardId, pendingSession.value.id)
}
const closeCardPicker = () => {
cardPickerVisible.value = false
pendingSession.value = null
}
const login = async () => {
await session.login()
loadAll()
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -215,19 +98,12 @@ const login = async () => {
min-width: 0; min-width: 0;
} }
.session-top,
.session-bottom {
display: flex;
justify-content: space-between;
gap: $spacing-sm;
}
.session-title { .session-title {
flex: 1;
color: $text-color-main; color: $text-color-main;
font-size: 30rpx; font-size: 30rpx;
font-weight: 900; font-weight: 900;
line-height: 1.35; line-height: 1.35;
display: block;
} }
.time-line { .time-line {
@@ -245,55 +121,16 @@ const login = async () => {
font-size: 23rpx; font-size: 23rpx;
} }
.session-bottom {
display: flex;
justify-content: space-between;
gap: $spacing-sm;
margin-top: 6rpx;
}
.session-price { .session-price {
color: $text-color-main; color: $text-color-main;
font-size: 28rpx; font-size: 28rpx;
font-weight: 900; font-weight: 900;
} }
.picker-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: flex-end;
}
.picker-sheet {
width: 100%;
background: $bg-color-main;
border-radius: $border-radius-lg $border-radius-lg 0 0;
padding: $spacing-md;
padding-bottom: calc($spacing-md + env(safe-area-inset-bottom));
}
.picker-title {
display: block;
text-align: center;
color: $text-color-muted;
font-size: 24rpx;
margin-bottom: $spacing-sm;
}
.picker-item {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
color: $text-color-main;
font-size: 28rpx;
font-weight: 800;
border-bottom: 1rpx solid $bg-color-hover;
}
.picker-item.cancel {
color: $text-color-muted;
font-weight: 600;
border-bottom: none;
margin-top: $spacing-sm;
}
</style> </style>
@@ -1,47 +1,37 @@
<template> <template>
<view class="cards-page"> <view class="cards-page">
<UnauthLoginPanel
v-if="!session.isLoggedIn"
@login="login"
/>
<block v-else>
<view class="list">
<view v-for="card in cards" :key="card.id" class="card membership-card" @tap="goDetail(card.id)">
<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>
<view v-if="isLoading" class="load-more-tip">加载中...</view> <view v-if="isLoading" class="load-more-tip">加载中...</view>
<view v-else-if="cards.length === 0" class="empty-state">暂无次卡</view>
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view> <view v-else-if="cards.length" class="card-list">
<view v-if="isLoadingMore" class="load-more-tip">加载中...</view> <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>
</block> <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> </view>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, ref } from 'vue'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app' import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { membershipApi } from '@/modules/comprehensive/api/membership' import { membershipApi } from '@/modules/comprehensive/api/membership'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
import UnauthLoginPanel from '@/components/UnauthLoginPanel.vue'
const session = useComprehensiveSessionStore()
const cards = ref([]) const cards = ref([])
const page = ref(1) const page = ref(1)
const pageSize = 20 const pageSize = 20
@@ -51,10 +41,23 @@ const isLoadingMore = ref(false)
const hasMore = computed(() => cards.value.length < total.value) const hasMore = computed(() => cards.value.length < total.value)
onMounted(async () => { const loadCards = async (reset) => {
await session.init() if (reset) {
if (session.isLoggedIn) loadCards(true) 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(() => { onPullDownRefresh(() => {
loadCards(true).finally(() => uni.stopPullDownRefresh()) loadCards(true).finally(() => uni.stopPullDownRefresh())
@@ -66,37 +69,11 @@ onReachBottom(() => {
loadCards(false) loadCards(false)
}) })
const loadCards = async (reset) => { loadCards(true)
if (reset) {
page.value = 1
isLoading.value = true
} else {
isLoadingMore.value = true
}
try {
const data = await membershipApi.getCards({ page: page.value, pageSize })
cards.value = reset ? data.items : [...cards.value, ...data.items]
total.value = data.total
} finally {
isLoading.value = false
isLoadingMore.value = false
}
}
const login = async () => {
await session.login()
loadCards(true)
}
const goDetail = (id) => { const goDetail = (id) => {
uni.navigateTo({ url: `/pages/comprehensive/membership/card-detail?id=${id}` }) uni.navigateTo({ url: `/pages/comprehensive/membership/card-detail?id=${id}` })
} }
const statusBadgeClass = (status) => {
if (status === 'available') return 'success'
if (status === 'exhausted') return 'warning'
return 'muted'
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -105,19 +82,23 @@ const statusBadgeClass = (status) => {
background: $bg-color-soft; background: $bg-color-soft;
} }
.list { .card-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
padding: $spacing-md; padding: $spacing-md;
} }
.membership-card { .item-card {
margin-bottom: $spacing-md; display: flex;
flex-direction: column;
} }
.card-top { .card-header {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: $spacing-md; gap: $spacing-sm;
} }
.plan-name { .plan-name {
@@ -125,62 +106,61 @@ const statusBadgeClass = (status) => {
color: $text-color-main; color: $text-color-main;
font-size: 30rpx; font-size: 30rpx;
font-weight: 900; font-weight: 900;
line-height: 1.35;
} }
.badge { .status-tag {
padding: 4rpx 16rpx; padding: 4rpx 16rpx;
border-radius: $border-radius-sm; border-radius: $border-radius-sm;
font-size: 22rpx; font-size: 22rpx;
font-weight: 800; font-weight: 800;
} }
.badge.success { .status-tag.available {
background: $color-success-light; background: $color-success-light;
color: $color-success; color: $color-success;
} }
.badge.warning { .status-tag.exhausted {
background: $color-warning-light; background: $color-warning-light;
color: $color-warning; color: $color-warning;
} }
.badge.muted { .status-tag.expired {
background: $bg-color-hover; background: $bg-color-hover;
color: $text-color-muted; color: $text-color-muted;
} }
.count-row { .card-body {
margin-top: $spacing-md; margin-top: $spacing-md;
display: flex; display: flex;
align-items: center; align-items: center;
gap: $spacing-md; gap: $spacing-md;
} }
.count-block { .stat {
flex: 1; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
} }
.count-value { .stat-value {
color: $color-primary; color: $color-primary;
font-size: 44rpx; font-size: 48rpx;
font-weight: 900; font-weight: 900;
} }
.count-value.muted { .stat-value.muted {
color: $text-color-muted; color: $text-color-muted;
} }
.count-label { .stat-label {
margin-top: 4rpx; margin-top: 4rpx;
color: $text-color-muted; color: $text-color-muted;
font-size: 22rpx; font-size: 22rpx;
} }
.count-divider { .stat-divider {
width: 1rpx; width: 1rpx;
height: 56rpx; height: 56rpx;
background: $border-color; background: $border-color;
@@ -1,41 +1,36 @@
<template> <template>
<view class="balance-page container"> <view class="balance-page">
<view class="balance-card card"> <view class="card balance-card">
<text class="label">当前余额</text> <text class="balance-label">当前余额</text>
<text class="amount">¥{{ balance.balance }}</text> <text class="balance-amount">¥{{ balance.balance }}</text>
</view> </view>
<view class="section-header"> <view v-if="records.length" class="record-list">
<text class="section-title">流水明细</text> <view v-for="record in records" :key="record.id" class="card record-card">
</view>
<view v-for="record in records" :key="record.id" class="record-card card">
<view class="record-top"> <view class="record-top">
<text class="kind">{{ record.kind_label }}</text> <text class="record-kind">{{ record.kind_label }}</text>
<text class="record-amount" :class="{ income: Number(record.amount) > 0 }">{{ record.amount }}</text> <text class="record-amount" :class="{ income: Number(record.amount) > 0 }">
{{ Number(record.amount) > 0 ? '+' : '' }}{{ record.amount }}
</text>
</view>
<text v-if="record.remark" class="record-remark">{{ record.remark }}</text>
<text class="record-time">{{ formatDateTime(record.created_at) }}</text>
</view> </view>
<text class="remark">{{ record.remark }}</text>
<text class="time">{{ formatDateTime(record.created_at) }}</text>
</view> </view>
<view v-if="records.length === 0" class="empty-state">暂无余额流水</view> <view v-else class="empty-state">暂无余额流水</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { onMounted, ref } from 'vue' import { ref } from 'vue'
import { onPullDownRefresh } from '@dcloudio/uni-app' import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
import { memberApi } from '@/modules/comprehensive/api/member' import { memberApi } from '@/modules/comprehensive/api/member'
import { formatDateTime } from '@/modules/comprehensive/utils/date' import { formatDateTime } from '@/modules/comprehensive/utils/date'
const balance = ref({ balance: '0.00' }) const balance = ref({ balance: '0.00' })
const records = ref([]) const records = ref([])
onMounted(loadPage)
onPullDownRefresh(() => {
loadPage().finally(() => uni.stopPullDownRefresh())
})
const loadPage = async () => { const loadPage = async () => {
const [balanceData, recordData] = await Promise.all([ const [balanceData, recordData] = await Promise.all([
memberApi.getBalance(), memberApi.getBalance(),
@@ -44,42 +39,63 @@ const loadPage = async () => {
balance.value = balanceData balance.value = balanceData
records.value = recordData.items records.value = recordData.items
} }
onShow(() => {
loadPage()
})
onPullDownRefresh(() => {
loadPage().finally(() => uni.stopPullDownRefresh())
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
.balance-page {
min-height: 100vh;
background: $bg-color-soft;
}
.balance-card { .balance-card {
min-height: 220rpx; margin: $spacing-md;
min-height: 200rpx;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
align-items: center;
} }
.label, .balance-label {
.remark,
.time {
display: block;
color: $text-color-muted; color: $text-color-muted;
font-size: 24rpx; font-size: 26rpx;
} }
.amount { .balance-amount {
margin-top: $spacing-sm; margin-top: $spacing-sm;
color: $text-color-main; color: $text-color-main;
font-size: 64rpx; font-size: 72rpx;
font-weight: 900; font-weight: 900;
} }
.record-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
padding: 0 $spacing-md $spacing-md;
}
.record-card { .record-card {
margin-bottom: $spacing-md; display: flex;
flex-direction: column;
} }
.record-top { .record-top {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
gap: $spacing-md; gap: $spacing-md;
} }
.kind { .record-kind {
color: $text-color-main; color: $text-color-main;
font-size: 30rpx; font-size: 30rpx;
font-weight: 900; font-weight: 900;
@@ -95,11 +111,17 @@ const loadPage = async () => {
color: $color-success; color: $color-success;
} }
.remark { .record-remark {
display: block;
margin-top: $spacing-sm; margin-top: $spacing-sm;
color: $text-color-muted;
font-size: 24rpx;
} }
.time { .record-time {
display: block;
margin-top: $spacing-xs; margin-top: $spacing-xs;
color: $text-color-light;
font-size: 22rpx;
} }
</style> </style>
@@ -35,17 +35,22 @@
</view> </view>
</view> </view>
<view class="fixed-action-bar"> <view class="action-area">
<button class="primary-button save-button" :disabled="isSaving || !canSave" @tap="saveProfile"> <button class="primary-button save-button" :disabled="isSaving || !canSave" @tap="saveProfile">
{{ isSaving ? '保存中...' : '保存资料' }} {{ isSaving ? '保存中...' : '保存资料' }}
</button> </button>
<button class="ghost-button home-button" @tap="goHome">返回首页</button>
</view> </view>
</view> </view>
<view v-else class="load-more-tip">加载中...</view>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { memberApi } from '@/modules/comprehensive/api/member' import { memberApi } from '@/modules/comprehensive/api/member'
import { authStorage } from '@/modules/comprehensive/api/auth'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session' import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
const session = useComprehensiveSessionStore() const session = useComprehensiveSessionStore()
@@ -54,29 +59,31 @@ const isSaving = ref(false)
const isUploadingAvatar = ref(false) const isUploadingAvatar = ref(false)
const avatarInitial = computed(() => (form.value?.nickname || '用户').slice(0, 1)) const avatarInitial = computed(() => (form.value?.nickname || '用户').slice(0, 1))
const canSave = computed(() => { const canSave = computed(() => !!form.value?.nickname?.trim() && !!form.value?.avatar_url?.trim())
return !!form.value?.nickname?.trim() || !!form.value?.avatar_url?.trim()
})
onMounted(async () => { onLoad(async () => {
await session.init() await session.init()
form.value = { ...(session.member || await memberApi.getMemberMe()) } if (session.member) {
form.value = { ...session.member }
} else {
try {
form.value = await memberApi.getMemberMe()
} catch {
form.value = { nickname: '', avatar_url: '', phone: '' }
}
}
}) })
const chooseAvatar = async (event) => { const chooseAvatar = async (event) => {
const avatarUrl = event.detail?.avatarUrl const avatarUrl = event.detail?.avatarUrl
if (!avatarUrl) { if (!avatarUrl) return
uni.showToast({ title: '未获取到头像', icon: 'none' })
return
}
if (isUploadingAvatar.value) return if (isUploadingAvatar.value) return
isUploadingAvatar.value = true isUploadingAvatar.value = true
try { try {
const data = await memberApi.uploadAvatar(avatarUrl) const data = await memberApi.uploadAvatar(avatarUrl)
form.value = { ...data.member } form.value = { ...form.value, avatar_url: data.avatar_url }
await session.refreshMember() } catch {
uni.showToast({ title: '头像已更新', icon: 'success' }) form.value = { ...form.value, avatar_url: avatarUrl }
} finally { } finally {
isUploadingAvatar.value = false isUploadingAvatar.value = false
} }
@@ -91,8 +98,13 @@ const saveProfile = async () => {
avatar_url: form.value.avatar_url avatar_url: form.value.avatar_url
}) })
form.value = { ...data } form.value = { ...data }
if (authStorage.getPendingToken()) {
session.completeLogin()
}
await session.refreshMember() await session.refreshMember()
uni.showToast({ title: '已保存', icon: 'success' }) uni.showToast({ title: '已保存', icon: 'success' })
} catch {
uni.showToast({ title: '保存失败,请重试', icon: 'none' })
} finally { } finally {
isSaving.value = false isSaving.value = false
} }
@@ -100,14 +112,17 @@ const saveProfile = async () => {
const bindPhone = async (event) => { const bindPhone = async (event) => {
const code = event.detail?.code const code = event.detail?.code
if (!code) { if (!code) return
uni.showToast({ title: '未获取到手机号授权', icon: 'none' }) try {
return
}
const data = await memberApi.bindMemberPhone(code) const data = await memberApi.bindMemberPhone(code)
form.value = { ...data } form.value = { ...form.value, phone: data.phone }
await session.refreshMember()
uni.showToast({ title: '手机号已绑定', icon: 'success' }) uni.showToast({ title: '手机号已绑定', icon: 'success' })
} catch {
uni.showToast({ title: '获取手机号失败', icon: 'none' })
}
}
const goHome = () => {
uni.switchTab({ url: '/pages/home/index' })
} }
</script> </script>
@@ -115,6 +130,7 @@ const bindPhone = async (event) => {
.profile-detail { .profile-detail {
min-height: 100vh; min-height: 100vh;
padding: $spacing-md; padding: $spacing-md;
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
background: $bg-color-soft; background: $bg-color-soft;
} }
@@ -227,7 +243,18 @@ const bindPhone = async (event) => {
justify-content: center; justify-content: center;
} }
.action-area {
display: flex;
flex-direction: column;
gap: $spacing-sm;
padding: 0 $spacing-md;
}
.save-button { .save-button {
flex: 1; height: 88rpx;
}
.home-button {
height: 88rpx;
} }
</style> </style>
@@ -73,7 +73,6 @@
</view> </view>
</view> </view>
</view> </view>
<AgreementLinks v-if="session.isLoggedIn" class="profile-agreement-links" compact />
</view> </view>
</template> </template>
@@ -82,7 +81,6 @@ import { computed, onMounted, ref } from 'vue'
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session' import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
import { memberApi } from '@/modules/comprehensive/api/member' import { memberApi } from '@/modules/comprehensive/api/member'
import { formatAmount } from '@/modules/comprehensive/utils/money' import { formatAmount } from '@/modules/comprehensive/utils/money'
import AgreementLinks from '@/components/AgreementLinks.vue'
const session = useComprehensiveSessionStore() const session = useComprehensiveSessionStore()
const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1)) const avatarInitial = computed(() => (session.member?.nickname || '用户').slice(0, 1))
@@ -90,14 +88,9 @@ const balance = ref('0.00')
const balanceText = computed(() => formatAmount(balance.value)) const balanceText = computed(() => formatAmount(balance.value))
const menuItems = [ const menuItems = [
{ 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/mall/index', theme: 'order-theme', icon: 'order' },
{ 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/orders', theme: 'order-theme', icon: 'order' },
{ title: '我的预约', url: '/pages/comprehensive/bookings/index', theme: 'order-theme', icon: 'order' }, { title: '余额流水', url: '/pages/comprehensive/profile/balance', theme: 'order-theme', icon: 'order' }
{ title: '余额流水', url: '/pages/comprehensive/profile/balance', 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' }
] ]
onMounted(async () => { onMounted(async () => {
@@ -13,7 +13,7 @@ const session = useComprehensiveSessionStore()
const handleLogin = async () => { const handleLogin = async () => {
try { try {
await session.login() await session.login()
uni.redirectTo({ url: '/pages/comprehensive/profile/setup' }) uni.redirectTo({ url: '/pages/comprehensive/profile/detail' })
} catch { } catch {
uni.showToast({ title: '登录失败,请重试', icon: 'none' }) uni.showToast({ title: '登录失败,请重试', icon: 'none' })
} }
@@ -1,27 +1,20 @@
<template> <template>
<view class="orders-page"> <view class="orders-page">
<view class="filter-panel"> <scroll-view scroll-x class="filter-bar" :show-scrollbar="false">
<scroll-view scroll-x class="filter-scroll">
<view class="filter-row">
<view v-for="item in businessOptions" :key="item.value" class="filter-chip" :class="{ active: businessType === item.value }" @tap="selectBusiness(item.value)">
{{ item.label }}
</view>
</view>
</scroll-view>
<scroll-view scroll-x class="filter-scroll second">
<view class="filter-row"> <view class="filter-row">
<view v-for="item in statusOptions" :key="item.value" class="filter-chip" :class="{ active: orderStatus === item.value }" @tap="selectStatus(item.value)"> <view v-for="item in statusOptions" :key="item.value" class="filter-chip" :class="{ active: orderStatus === item.value }" @tap="selectStatus(item.value)">
{{ item.label }} {{ item.label }}
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
</view>
<view class="list"> <view v-if="isLoading" class="load-more-tip">加载中...</view>
<view v-for="order in orders" :key="order.id" class="order-card card" @tap="goDetail(order.id)">
<view v-else-if="orders.length" class="order-list">
<view v-for="order in orders" :key="order.id" class="card order-card" @tap="goDetail(order.id)">
<view class="order-top"> <view class="order-top">
<text class="business">{{ order.business_type_label }}</text> <text class="business">{{ order.business_type_label }}</text>
<text class="badge" :class="statusBadgeClass(order.status)">{{ order.status_label }}</text> <text class="status-tag" :class="order.status">{{ order.status_label }}</text>
</view> </view>
<text class="order-no">{{ order.order_no }}</text> <text class="order-no">{{ order.order_no }}</text>
<view class="order-bottom"> <view class="order-bottom">
@@ -29,33 +22,22 @@
<text class="amount">¥{{ order.amount }}</text> <text class="amount">¥{{ order.amount }}</text>
</view> </view>
<view v-if="order.status === 'pending_pay'" class="actions" @tap.stop> <view v-if="order.status === 'pending_pay'" class="actions" @tap.stop>
<button class="ghost-button small-btn" @tap="closeOrder(order.id)">取消订单</button> <button class="ghost-button small-btn" @tap="closeOrder(order.id)">取消</button>
<button class="primary-button small-btn" @tap="goPay(order.id)">去支付</button> <button class="primary-button small-btn" @tap="goPay(order.id)">去支付</button>
</view> </view>
</view> </view>
<view v-if="!hasMore && orders.length >= pageSize" class="load-more-tip">已加载全部</view>
<view v-if="isLoading" class="load-more-tip">加载中...</view>
<view v-else-if="orders.length === 0" class="empty-state">暂无订单</view>
<view v-else-if="!hasMore" class="load-more-tip">已加载全部</view>
</view> </view>
<view v-else class="empty-state">暂无订单</view>
</view> </view>
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, ref } from 'vue'
import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app' import { onPullDownRefresh, onReachBottom } from '@dcloudio/uni-app'
import { orderApi } from '@/modules/comprehensive/api/order' import { orderApi } from '@/modules/comprehensive/api/order'
import { formatDateTime } from '@/modules/comprehensive/utils/date' import { formatDateTime } from '@/modules/comprehensive/utils/date'
import { statusBadgeClass } from '@/modules/comprehensive/utils/status'
const businessOptions = [
{ label: '全部', value: '' },
{ label: '课程', value: 'course' },
{ label: '包场', value: 'private_booking' },
{ label: '门票', value: 'ticket' },
{ label: '会员卡', value: 'membership' },
{ label: '储值卡', value: 'stored_value' }
]
const statusOptions = [ const statusOptions = [
{ label: '全部', value: '' }, { label: '全部', value: '' },
@@ -65,7 +47,6 @@ const statusOptions = [
{ label: '已退款', value: 'refunded' } { label: '已退款', value: 'refunded' }
] ]
const businessType = ref('')
const orderStatus = ref('') const orderStatus = ref('')
const orders = ref([]) const orders = ref([])
const page = ref(1) const page = ref(1)
@@ -76,18 +57,6 @@ const isLoadingMore = ref(false)
const hasMore = computed(() => orders.value.length < total.value) const hasMore = computed(() => orders.value.length < total.value)
onMounted(() => loadOrders(true))
onPullDownRefresh(() => {
loadOrders(true).finally(() => uni.stopPullDownRefresh())
})
onReachBottom(() => {
if (!hasMore.value || isLoadingMore.value) return
page.value += 1
loadOrders(false)
})
const loadOrders = async (reset) => { const loadOrders = async (reset) => {
if (reset) { if (reset) {
page.value = 1 page.value = 1
@@ -99,7 +68,6 @@ const loadOrders = async (reset) => {
const data = await orderApi.getOrders({ const data = await orderApi.getOrders({
page: page.value, page: page.value,
pageSize, pageSize,
businessType: businessType.value,
status: orderStatus.value status: orderStatus.value
}) })
orders.value = reset ? data.items : [...orders.value, ...data.items] orders.value = reset ? data.items : [...orders.value, ...data.items]
@@ -110,11 +78,6 @@ const loadOrders = async (reset) => {
} }
} }
const selectBusiness = (value) => {
businessType.value = value
loadOrders(true)
}
const selectStatus = (value) => { const selectStatus = (value) => {
orderStatus.value = value orderStatus.value = value
loadOrders(true) loadOrders(true)
@@ -133,6 +96,18 @@ const closeOrder = async (id) => {
uni.showToast({ title: '订单已取消', icon: 'none' }) uni.showToast({ title: '订单已取消', icon: 'none' })
loadOrders(true) loadOrders(true)
} }
onPullDownRefresh(() => {
loadOrders(true).finally(() => uni.stopPullDownRefresh())
})
onReachBottom(() => {
if (!hasMore.value || isLoadingMore.value) return
page.value += 1
loadOrders(false)
})
loadOrders(true)
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -141,34 +116,26 @@ const closeOrder = async (id) => {
background: $bg-color-soft; background: $bg-color-soft;
} }
.filter-panel { .filter-bar {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 10; z-index: 10;
padding: $spacing-md;
background: $bg-color-main; background: $bg-color-main;
border-bottom: 1rpx solid $border-color; border-bottom: 1rpx solid $border-color;
}
.filter-scroll {
width: 100%;
white-space: nowrap; white-space: nowrap;
} }
.filter-scroll.second {
margin-top: $spacing-sm;
}
.filter-row { .filter-row {
display: inline-flex; display: inline-flex;
gap: $spacing-sm; padding: $spacing-sm $spacing-xs;
gap: $spacing-xs;
} }
.filter-chip { .filter-chip {
min-width: 118rpx; min-width: 100rpx;
height: 62rpx; height: 64rpx;
padding: 0 $spacing-sm; padding: 0 $spacing-md;
border-radius: $border-radius-base; border-radius: $border-radius-sm;
background: $bg-color-hover; background: $bg-color-hover;
color: $text-color-muted; color: $text-color-muted;
font-size: 24rpx; font-size: 24rpx;
@@ -183,21 +150,23 @@ const closeOrder = async (id) => {
color: #FFFFFF; color: #FFFFFF;
} }
.list { .order-list {
display: flex;
flex-direction: column;
gap: $spacing-md;
padding: $spacing-md; padding: $spacing-md;
} }
.order-card { .order-card {
margin-bottom: $spacing-md; display: flex;
flex-direction: column;
} }
.order-top, .order-top {
.order-bottom,
.actions {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: $spacing-md; gap: $spacing-sm;
} }
.business { .business {
@@ -206,19 +175,51 @@ const closeOrder = async (id) => {
font-weight: 900; font-weight: 900;
} }
.order-no, .status-tag {
.time { padding: 4rpx 16rpx;
border-radius: $border-radius-sm;
font-size: 22rpx;
font-weight: 800;
}
.status-tag.paid {
background: $color-success-light;
color: $color-success;
}
.status-tag.pending_pay {
background: $color-warning-light;
color: $color-warning;
}
.status-tag.closed {
background: $bg-color-hover;
color: $text-color-muted;
}
.status-tag.refunded {
background: $color-danger-light;
color: $color-danger;
}
.order-no {
display: block; display: block;
margin-top: $spacing-sm;
color: $text-color-muted; color: $text-color-muted;
font-size: 24rpx; font-size: 24rpx;
} }
.order-no { .order-bottom {
margin-top: $spacing-sm; display: flex;
align-items: center;
justify-content: space-between;
gap: $spacing-md;
margin-top: $spacing-md;
} }
.order-bottom { .time {
margin-top: $spacing-md; color: $text-color-muted;
font-size: 24rpx;
} }
.amount { .amount {
@@ -228,14 +229,16 @@ const closeOrder = async (id) => {
} }
.actions { .actions {
display: flex;
justify-content: flex-end; justify-content: flex-end;
gap: $spacing-sm;
margin-top: $spacing-md; margin-top: $spacing-md;
padding-top: $spacing-md; padding-top: $spacing-md;
border-top: 1rpx solid $bg-color-hover; border-top: 1rpx solid $bg-color-hover;
} }
.small-btn { .small-btn {
width: 180rpx; width: 160rpx;
height: 64rpx; height: 64rpx;
font-size: 24rpx; font-size: 24rpx;
} }