Compare commits
8 Commits
2ca2ead6da
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| e78101f690 | |||
| 5bd4d7dcea | |||
| 0b694ff6db | |||
| e4a05379ce | |||
| 7765d3dae1 | |||
| 3832a686d8 | |||
| 7dc0314565 | |||
| 30c2b5ffea |
@@ -62,6 +62,7 @@ export const toPrivateBooking = (backendBooking = {}) => ({
|
||||
cancelled_at: backendBooking.cancelled_at || null,
|
||||
cancel_by: backendBooking.cancel_by || '',
|
||||
cancel_reason: backendBooking.cancel_reason || '',
|
||||
can_cancel: backendBooking.can_cancel === true,
|
||||
created_at: backendBooking.created_at || '',
|
||||
order: toOrderBrief(backendBooking.order)
|
||||
})
|
||||
@@ -94,5 +95,16 @@ export const bookingApi = {
|
||||
return request({
|
||||
url: `/api/bookings/private/${id}`
|
||||
}).then(toPrivateBooking)
|
||||
},
|
||||
getPrivateBookingRefundQuote(id) {
|
||||
return request({
|
||||
url: `/api/bookings/private/${id}/refund-quote`
|
||||
})
|
||||
},
|
||||
cancelPrivateBooking(id) {
|
||||
return request({
|
||||
url: `/api/bookings/private/${id}/cancel`,
|
||||
method: 'POST'
|
||||
}).then(toPrivateBooking)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,18 @@ const toPrivateBookingGrid = (backendData = {}) => ({
|
||||
slot_prices: backendData.slot_prices || {}
|
||||
})
|
||||
|
||||
const toPrivateBookingConfig = (backendData = {}) => ({
|
||||
member_advance_days: toNumber(backendData.member_advance_days, 7),
|
||||
non_member_advance_days: toNumber(backendData.non_member_advance_days, 3)
|
||||
})
|
||||
|
||||
export const privateBookingApi = {
|
||||
getPrivateBookingConfig() {
|
||||
return request({
|
||||
url: '/api/bookings/private/config'
|
||||
}).then(toPrivateBookingConfig)
|
||||
},
|
||||
|
||||
getPrivateBookingOptions(params = {}) {
|
||||
return request({
|
||||
url: '/api/bookings/private/options',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
export const statusBadgeClass = (status) => {
|
||||
if (['paid', 'booked', 'available', 'success', 'published'].includes(status)) return 'success'
|
||||
if (['pending_pay', 'pending'].includes(status)) return 'warning'
|
||||
if (['pending_pay', 'pending', 'part_refunded'].includes(status)) return 'warning'
|
||||
if (['cancelled', 'closed', 'expired', 'refunded'].includes(status)) return 'danger'
|
||||
return 'muted'
|
||||
}
|
||||
|
||||
@@ -63,8 +63,40 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<view v-if="booking.status === 'pending_pay'" class="fixed-action-bar">
|
||||
<button class="primary-button action-btn" @tap="goPay">去支付</button>
|
||||
<view v-if="booking.status === 'pending_pay' || (booking.status === 'booked' && booking.can_cancel)" class="fixed-action-bar">
|
||||
<button v-if="booking.status === 'booked' && booking.can_cancel" class="ghost-button action-btn" @tap="onCancelBooking">取消预订</button>
|
||||
<button v-if="booking.status === 'pending_pay'" class="primary-button action-btn" @tap="goPay">去支付</button>
|
||||
</view>
|
||||
|
||||
<view v-if="refundPopupVisible" class="refund-popup-mask" @tap="closeRefundPopup">
|
||||
<view class="refund-popup" @tap.stop>
|
||||
<view class="refund-popup-header">
|
||||
<text class="refund-popup-title">取消预订</text>
|
||||
<text class="refund-popup-close" @tap="closeRefundPopup">×</text>
|
||||
</view>
|
||||
<view class="refund-popup-body" v-if="refundQuote">
|
||||
<view class="refund-rules">
|
||||
<view class="refund-rules-header">
|
||||
<text>距开始时间</text>
|
||||
<text>退款比例</text>
|
||||
<text>说明</text>
|
||||
</view>
|
||||
<view v-for="(rule, idx) in refundQuote.rules" :key="idx" class="refund-rule-row" :class="{ active: rule.description === refundQuote.applicable_tier_description }">
|
||||
<text>{{ formatHoursBefore(rule.hours_before_start, idx) }}</text>
|
||||
<text>{{ rule.refund_percentage }}%</text>
|
||||
<text>{{ rule.description }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="refund-amount-box">
|
||||
<text class="refund-amount-label">本次退款金额</text>
|
||||
<text class="refund-amount-value">¥{{ refundQuote.refund_amount }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="refund-popup-footer">
|
||||
<button class="ghost-button refund-btn" @tap="closeRefundPopup">再想想</button>
|
||||
<button class="primary-button refund-btn" @tap="confirmCancelBooking" :disabled="refundLoading">确认取消</button>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -94,6 +126,49 @@ onLoad(async (query) => {
|
||||
const goPay = () => {
|
||||
uni.navigateTo({ url: `/pages/comprehensive/pay/index?order_id=${booking.value.order_id}` })
|
||||
}
|
||||
|
||||
const refundPopupVisible = ref(false)
|
||||
const refundQuote = ref(null)
|
||||
const refundLoading = ref(false)
|
||||
|
||||
const onCancelBooking = async () => {
|
||||
try {
|
||||
const data = await bookingApi.getPrivateBookingRefundQuote(bookingId.value)
|
||||
refundQuote.value = data
|
||||
refundPopupVisible.value = true
|
||||
} catch {
|
||||
uni.showToast({ title: '获取退款信息失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
const closeRefundPopup = () => {
|
||||
refundPopupVisible.value = false
|
||||
refundQuote.value = null
|
||||
}
|
||||
|
||||
const confirmCancelBooking = async () => {
|
||||
if (refundLoading.value) return
|
||||
refundLoading.value = true
|
||||
try {
|
||||
await bookingApi.cancelPrivateBooking(bookingId.value)
|
||||
refundPopupVisible.value = false
|
||||
const amount = refundQuote.value?.refund_amount || '0'
|
||||
uni.showToast({ title: `取消成功,退款¥${amount}`, icon: 'none' })
|
||||
booking.value = await bookingApi.getPrivateBookingDetail(bookingId.value)
|
||||
} catch {
|
||||
uni.showToast({ title: '取消失败,请稍后重试', icon: 'none' })
|
||||
} finally {
|
||||
refundLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const formatHoursBefore = (hours, idx) => {
|
||||
const h = Number(hours)
|
||||
const rules = refundQuote.value?.rules || []
|
||||
if (idx === 0 || h >= 24) return `>${h}小时`
|
||||
const prev = Number(rules[idx - 1]?.hours_before_start ?? h)
|
||||
return `${h}-${prev}小时`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -198,4 +273,131 @@ const goPay = () => {
|
||||
.action-btn {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.refund-popup-mask {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
z-index: 999;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.refund-popup {
|
||||
width: 100%;
|
||||
background: $bg-color-soft;
|
||||
border-radius: $border-radius-lg $border-radius-lg 0 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.refund-popup-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md $spacing-lg;
|
||||
background: #fff;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
}
|
||||
|
||||
.refund-popup-title {
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.refund-popup-close {
|
||||
font-size: 40rpx;
|
||||
color: $text-color-muted;
|
||||
padding: 0 $spacing-sm;
|
||||
}
|
||||
|
||||
.refund-popup-body {
|
||||
padding: $spacing-md $spacing-lg;
|
||||
}
|
||||
|
||||
.refund-rules {
|
||||
margin-bottom: $spacing-md;
|
||||
background: #fff;
|
||||
border-radius: $border-radius-base;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.refund-rules-header {
|
||||
display: flex;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
background: $bg-color-hover;
|
||||
font-size: 22rpx;
|
||||
font-weight: 800;
|
||||
color: $text-color-muted;
|
||||
|
||||
text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
&:first-child { flex: 0.8; }
|
||||
&:last-child { flex: 1.4; }
|
||||
}
|
||||
}
|
||||
|
||||
.refund-rule-row {
|
||||
display: flex;
|
||||
padding: $spacing-sm $spacing-md;
|
||||
border-bottom: 1rpx solid $bg-color-hover;
|
||||
font-size: 22rpx;
|
||||
color: $text-color-muted;
|
||||
|
||||
text {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
&:first-child { flex: 0.8; }
|
||||
&:last-child { flex: 1.4; text-align: left; }
|
||||
}
|
||||
|
||||
&.active {
|
||||
background: rgba(64, 158, 255, 0.08);
|
||||
color: $color-primary;
|
||||
font-weight: 800;
|
||||
}
|
||||
}
|
||||
|
||||
.refund-rule-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.refund-amount-box {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-md;
|
||||
background: #fff;
|
||||
border-radius: $border-radius-base;
|
||||
margin-bottom: $spacing-sm;
|
||||
}
|
||||
|
||||
.refund-amount-label {
|
||||
font-size: 26rpx;
|
||||
color: $text-color-muted;
|
||||
}
|
||||
|
||||
.refund-amount-value {
|
||||
font-size: 36rpx;
|
||||
font-weight: 900;
|
||||
color: $color-danger;
|
||||
}
|
||||
|
||||
.refund-popup-footer {
|
||||
display: flex;
|
||||
gap: $spacing-md;
|
||||
padding: $spacing-md $spacing-lg $spacing-lg;
|
||||
padding-bottom: calc(#{$spacing-lg} + env(safe-area-inset-bottom));
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.refund-btn {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,14 +2,15 @@
|
||||
<view class="private-page">
|
||||
<view class="tab-bar">
|
||||
<view
|
||||
v-for="item in typeOptions"
|
||||
:key="item.value"
|
||||
v-for="tab in mainTabs"
|
||||
:key="tab.value"
|
||||
class="tab-item"
|
||||
:class="{ active: courtType === item.value }"
|
||||
@tap="selectType(item.value)"
|
||||
>{{ item.label }}</view>
|
||||
:class="{ active: activeTab === tab.value }"
|
||||
@tap="switchTab(tab.value)"
|
||||
>{{ tab.label }}</view>
|
||||
</view>
|
||||
|
||||
<template v-if="activeTab === 'booking'">
|
||||
<scroll-view scroll-x class="date-bar" :show-scrollbar="false">
|
||||
<view class="date-list">
|
||||
<view
|
||||
@@ -86,6 +87,22 @@
|
||||
<button class="primary-button submit-btn" :disabled="!canConfirm" @tap="goConfirm">预约</button>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<template v-else-if="activeTab === 'history'">
|
||||
<scroll-view scroll-y class="history-scroll" @scrolltolower="loadHistory()">
|
||||
<view v-for="item in historyList" :key="item.id" class="history-card" @tap="goBookingDetail(item)">
|
||||
<view class="history-card-top">
|
||||
<text class="history-court">{{ historyCourtNames(item) }}</text>
|
||||
<text class="history-status">{{ item.status_label }}</text>
|
||||
</view>
|
||||
<text class="history-time">{{ historyTimeText(item) }}</text>
|
||||
<text class="history-amount">¥{{ item.order?.amount || '--' }}</text>
|
||||
</view>
|
||||
<view v-if="historyLoading" class="history-tip">加载中...</view>
|
||||
<view v-else-if="historyList.length === 0" class="history-empty">暂无预定记录</view>
|
||||
</scroll-view>
|
||||
</template>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
@@ -94,6 +111,8 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking'
|
||||
import { useComprehensiveSessionStore } from '@/modules/comprehensive/stores/session'
|
||||
import { bookingApi } from '@/modules/comprehensive/api/booking'
|
||||
import { formatTimeRange } from '@/modules/comprehensive/utils/date'
|
||||
import { toAmountNumber } from '@/modules/comprehensive/utils/money'
|
||||
|
||||
const HEADER_HEIGHT = 75
|
||||
@@ -101,9 +120,11 @@ const CELL_HEIGHT = 107
|
||||
const TIME_COL_WIDTH = 128
|
||||
const COURT_COL_WIDTH = 149
|
||||
|
||||
const typeOptions = [
|
||||
{ label: '羽毛球', value: 'badminton' }
|
||||
const mainTabs = [
|
||||
{ label: '订场', value: 'booking' },
|
||||
{ label: '我的预定', value: 'history' }
|
||||
]
|
||||
const activeTab = ref('booking')
|
||||
|
||||
const session = useComprehensiveSessionStore()
|
||||
const courtType = ref('badminton')
|
||||
@@ -115,11 +136,21 @@ const selectedCells = ref({})
|
||||
const now = ref(dayjs())
|
||||
let loadRequestId = 0
|
||||
|
||||
const historyList = ref([])
|
||||
const historyLoading = ref(false)
|
||||
const historyPage = ref(1)
|
||||
const historyPageSize = 20
|
||||
const historyHasMore = ref(true)
|
||||
|
||||
const bookingConfig = ref({ member_advance_days: 7, non_member_advance_days: 3 })
|
||||
|
||||
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
const hasPositiveBalance = computed(() => toAmountNumber(session.member?.balance) > 0)
|
||||
const dateOptions = computed(() => {
|
||||
const today = dayjs()
|
||||
const dateCount = hasPositiveBalance.value ? 7 : 3
|
||||
const dateCount = hasPositiveBalance.value
|
||||
? bookingConfig.value.member_advance_days
|
||||
: bookingConfig.value.non_member_advance_days
|
||||
return Array.from({ length: dateCount }).map((_, i) => {
|
||||
const d = today.add(i, 'day')
|
||||
return {
|
||||
@@ -130,9 +161,6 @@ const dateOptions = computed(() => {
|
||||
})
|
||||
})
|
||||
|
||||
const openTime = computed(() => normalizeClock(grid.value?.open_time))
|
||||
const closeTime = computed(() => normalizeClock(grid.value?.close_time))
|
||||
|
||||
const occupationMap = computed(() => {
|
||||
const map = {}
|
||||
if (!grid.value) return map
|
||||
@@ -253,10 +281,59 @@ async function loadGrid() {
|
||||
}
|
||||
}
|
||||
|
||||
function selectType(type) {
|
||||
if (courtType.value === type) return
|
||||
courtType.value = type
|
||||
loadGrid()
|
||||
function switchTab(tab) {
|
||||
if (activeTab.value === tab) return
|
||||
activeTab.value = tab
|
||||
if (tab === 'history') {
|
||||
loadHistory(true)
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory(reset = false) {
|
||||
if (historyLoading.value) return
|
||||
if (reset) {
|
||||
historyPage.value = 1
|
||||
historyHasMore.value = true
|
||||
}
|
||||
if (!historyHasMore.value) return
|
||||
historyLoading.value = true
|
||||
try {
|
||||
const data = await bookingApi.getPrivateBookings({
|
||||
page: historyPage.value,
|
||||
pageSize: historyPageSize
|
||||
})
|
||||
const tenDaysAgo = dayjs().subtract(10, 'day')
|
||||
const booked = data.items.filter(
|
||||
(item) => item.status === 'booked' && item.start_at && dayjs(item.start_at) >= tenDaysAgo
|
||||
)
|
||||
if (reset) {
|
||||
historyList.value = booked
|
||||
} else {
|
||||
historyList.value = [...historyList.value, ...booked]
|
||||
}
|
||||
historyHasMore.value = data.page * data.page_size < data.total
|
||||
if (historyHasMore.value) historyPage.value++
|
||||
} catch {
|
||||
if (reset) historyList.value = []
|
||||
} finally {
|
||||
historyLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function historyCourtNames(item) {
|
||||
return item.courts?.map((c) => c.name).join('、') || '包场预约'
|
||||
}
|
||||
|
||||
function historyTimeText(item) {
|
||||
if (!item.start_at || !item.end_at) return '-'
|
||||
const start = dayjs(item.start_at)
|
||||
return `${start.month() + 1}/${start.date()} ${formatTimeRange(item.start_at, item.end_at)}`
|
||||
}
|
||||
|
||||
function goBookingDetail(item) {
|
||||
uni.navigateTo({
|
||||
url: `/pages/comprehensive/bookings/private-detail?id=${item.id}`
|
||||
})
|
||||
}
|
||||
|
||||
function selectDate(date) {
|
||||
@@ -360,9 +437,15 @@ function normalizeClock(value) {
|
||||
|
||||
async function refresh() {
|
||||
await session.init()
|
||||
try {
|
||||
bookingConfig.value = await privateBookingApi.getPrivateBookingConfig()
|
||||
} catch {
|
||||
// 获取配置失败时使用默认值 7/3,不阻塞页面
|
||||
}
|
||||
if (!hasPositiveBalance.value) {
|
||||
const todayStr = dayjs().format('YYYY-MM-DD')
|
||||
const maxDateStr = dayjs().add(2, 'day').format('YYYY-MM-DD')
|
||||
const maxOffset = bookingConfig.value.non_member_advance_days - 1
|
||||
const maxDateStr = dayjs().add(maxOffset, 'day').format('YYYY-MM-DD')
|
||||
if (selectedDate.value < todayStr || selectedDate.value > maxDateStr) {
|
||||
selectedDate.value = todayStr
|
||||
}
|
||||
@@ -683,4 +766,65 @@ defineExpose({
|
||||
color: $text-color-light;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.history-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: $text-color-light;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
|
||||
.history-scroll {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.history-card {
|
||||
background: $bg-color-main;
|
||||
border-radius: $border-radius-base;
|
||||
padding: $spacing-md;
|
||||
margin: $spacing-sm $spacing-md;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: $spacing-xs;
|
||||
}
|
||||
|
||||
.history-card-top {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.history-court {
|
||||
font-size: 30rpx;
|
||||
font-weight: 800;
|
||||
color: $text-color-main;
|
||||
}
|
||||
|
||||
.history-status {
|
||||
font-size: 22rpx;
|
||||
font-weight: 700;
|
||||
color: #52c41a;
|
||||
}
|
||||
|
||||
.history-time {
|
||||
font-size: 26rpx;
|
||||
font-weight: 600;
|
||||
color: $text-color-regular;
|
||||
}
|
||||
|
||||
.history-amount {
|
||||
font-size: 32rpx;
|
||||
font-weight: 900;
|
||||
color: $color-primary;
|
||||
}
|
||||
|
||||
.history-tip {
|
||||
text-align: center;
|
||||
padding: $spacing-md;
|
||||
color: $text-color-light;
|
||||
font-size: 24rpx;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -43,6 +43,7 @@ const statusOptions = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待支付', value: 'pending_pay' },
|
||||
{ label: '已支付', value: 'paid' },
|
||||
{ label: '部分退款', value: 'part_refunded' },
|
||||
{ label: '已关闭', value: 'closed' },
|
||||
{ label: '已退款', value: 'refunded' }
|
||||
]
|
||||
@@ -192,6 +193,11 @@ loadOrders(true)
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
.status-tag.part_refunded {
|
||||
background: $color-warning-light;
|
||||
color: $color-warning;
|
||||
}
|
||||
|
||||
.status-tag.closed {
|
||||
background: $bg-color-hover;
|
||||
color: $text-color-muted;
|
||||
|
||||
Reference in New Issue
Block a user