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:
@@ -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) => {
|
|
||||||
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)
|
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;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user