Compare commits

..

4 Commits

Author SHA1 Message Date
gqt 7139865bff feat: 支付页仅保留微信支付,移除余额支付 2026-07-27 12:18:04 +08:00
gqt d07356e9f1 feat: 课程列表/课程预约详情/会员卡课程详情移除价格显示 2026-07-27 12:13:56 +08:00
gqt 30cad4e765 feat: 包场预约网格改为1小时粒度+动态价格显示+锁场状态
- 时间线位置 /30 改为 /60
- 价格预览改为按格子累加 slot_prices
- 格子内显示价格(¥xx)
- 新增 locked(锁场)和 no_price(无价格)格子状态
- API映射层移除 unit_price,新增 slot_prices 和 source_type
- 确认页半小时段改为时段
2026-07-26 21:42:46 +08:00
gqt 37eb77aede 1 2026-07-15 16:58:07 +08:00
9 changed files with 99 additions and 109 deletions
@@ -58,7 +58,7 @@ defineProps({
},
primaryText: {
type: String,
default: '微信一键登录'
default: '点击登录'
},
loading: {
type: Boolean,
@@ -46,10 +46,8 @@ const toPrivateBookingOptions = (backendData = {}) => ({
court_type_label: backendData.court_type_label || '',
open_time: backendData.open_time || '',
close_time: backendData.close_time || '',
unit_price: backendData.unit_price || '0.00',
duration_units: toNumber(backendData.duration_units, 2),
duration_units: toNumber(backendData.duration_units, 1),
duration_minutes: toNumber(backendData.duration_minutes, 60),
amount_per_court: backendData.amount_per_court || '0.00',
total_court_count: toNumber(backendData.total_court_count),
time_options: toArray(backendData.time_options).map(toPrivateTimeOption)
})
@@ -67,7 +65,8 @@ const toGridSlot = (backendSlot = {}) => ({
const toGridOccupation = (backendOcc = {}) => ({
court_id: backendOcc.court_id ?? null,
start_at: backendOcc.start_at || '',
end_at: backendOcc.end_at || ''
end_at: backendOcc.end_at || '',
source_type: backendOcc.source_type || ''
})
const toPrivateBookingGrid = (backendData = {}) => ({
@@ -76,10 +75,10 @@ const toPrivateBookingGrid = (backendData = {}) => ({
court_type_label: backendData.court_type_label || '',
open_time: backendData.open_time || '',
close_time: backendData.close_time || '',
unit_price: backendData.unit_price || '0.00',
courts: toArray(backendData.courts).map(toGridCourt),
time_slots: toArray(backendData.time_slots).map(toGridSlot),
occupations: toArray(backendData.occupations).map(toGridOccupation)
occupations: toArray(backendData.occupations).map(toGridOccupation),
slot_prices: backendData.slot_prices || {}
})
export const privateBookingApi = {
@@ -20,10 +20,6 @@
<text>订单号</text>
<text>{{ booking.order.order_no }}</text>
</view>
<view class="info-row">
<text>订单金额</text>
<text>¥{{ booking.order.amount }}</text>
</view>
<view class="info-row">
<text>订单状态</text>
<text>{{ booking.order.status_label }}</text>
@@ -3,7 +3,6 @@
<block>
<view class="tab-bar">
<view class="tab-item" :class="{ active: activeTab === 'membership' }" @tap="switchTab('membership')">会员卡</view>
<view class="tab-item" :class="{ active: activeTab === 'group' }" @tap="switchTab('group')">团课</view>
<view class="tab-item" :class="{ active: activeTab === 'tickets' }" @tap="switchTab('tickets')">门票</view>
</view>
@@ -85,7 +84,6 @@
<text class="meta">{{ item.coach_name }}</text>
<view class="course-bottom">
<text class="meta">剩余 {{ item.remaining_count }}/{{ item.capacity }}</text>
<text class="course-price">¥{{ item.price }}</text>
</view>
</view>
</view>
@@ -469,12 +467,6 @@ defineExpose({ refresh, loadMore })
margin-top: 6rpx;
}
.course-price {
color: $text-color-main;
font-size: 28rpx;
font-weight: 900;
}
/* 篮球门票 */
.ticket-section {
padding: $spacing-md;
@@ -6,9 +6,6 @@
<view class="title-row">
<text class="course-title">{{ sessionData.title }}</text>
</view>
<view class="price-row">
<text class="price-text">¥{{ sessionData.price }}</text>
</view>
</view>
<view class="info-card card">
@@ -172,7 +169,6 @@ const confirmBook = async () => {
}
.title-row,
.price-row,
.info-row {
display: flex;
align-items: center;
@@ -188,10 +184,6 @@ const confirmBook = async () => {
line-height: 1.3;
}
.price-row {
margin-top: $spacing-md;
}
.info-card,
.rich-card {
margin-top: $spacing-md;
@@ -23,24 +23,12 @@
<view class="card method-card">
<text class="card-title">支付方式</text>
<view
v-if="!isStoredValueOrder"
class="method-item"
:class="{ active: payMethod === 'balance', disabled: !canUseBalance }"
@tap="selectMethod('balance')"
>
<view>
<text class="method-title">余额支付</text>
<text class="method-sub">当前余额 ¥{{ balance.balance }}</text>
</view>
<text class="method-check">{{ payMethod === 'balance' ? '已选' : '' }}</text>
</view>
<view class="method-item" :class="{ active: payMethod === 'wechat' }" @tap="selectMethod('wechat')">
<view class="method-item active">
<view>
<text class="method-title">微信支付</text>
<text class="method-sub">使用微信支付完成订单</text>
</view>
<text class="method-check">{{ payMethod === 'wechat' ? '已选' : '' }}</text>
<text class="method-check">已选</text>
</view>
</view>
@@ -61,28 +49,19 @@
<script setup>
import { computed, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { memberApi } from '@/modules/comprehensive/api/member'
import { orderApi } from '@/modules/comprehensive/api/order'
import { formatDateTime } from '@/modules/comprehensive/utils/date'
import { toAmountNumber } from '@/modules/comprehensive/utils/money'
const orderId = ref('')
const order = ref(null)
const balance = ref({ balance: '0.00' })
const payMethod = ref('balance')
const isSubmitting = ref(false)
const nowTimestamp = ref(Date.now())
let countdownTimer = null
const isStoredValueOrder = computed(() => order.value?.business_type === 'stored_value')
const canUseBalance = computed(() => {
if (isStoredValueOrder.value) return false
return toAmountNumber(balance.value.balance) >= toAmountNumber(order.value?.amount)
})
const canPay = computed(() => order.value?.status === 'pending_pay' && (payMethod.value !== 'balance' || canUseBalance.value))
const canPay = computed(() => order.value?.status === 'pending_pay')
const payButtonText = computed(() => {
if (order.value?.status !== 'pending_pay') return '订单不可支付'
return payMethod.value === 'balance' ? '余额支付' : '微信支付'
return '微信支付'
})
const paymentRemainingSeconds = computed(() => {
if (!order.value?.expire_at) return 0
@@ -107,17 +86,7 @@ onUnmounted(() => {
})
const loadPage = async () => {
const [orderData, balanceData] = await Promise.all([
orderApi.getOrderDetail(orderId.value),
memberApi.getBalance()
])
order.value = orderData
balance.value = balanceData
if (isStoredValueOrder.value) {
payMethod.value = 'wechat'
} else if (!canUseBalance.value) {
payMethod.value = 'wechat'
}
order.value = await orderApi.getOrderDetail(orderId.value)
updateCountdownTimer()
}
@@ -139,33 +108,13 @@ const stopCountdown = () => {
const padTime = (value) => String(value).padStart(2, '0')
const selectMethod = (method) => {
if (method === 'balance') {
if (isStoredValueOrder.value) {
uni.showToast({ title: '储值卡订单仅支持微信支付', icon: 'none' })
return
}
if (!canUseBalance.value) {
uni.showToast({ title: '余额不足', icon: 'none' })
return
}
}
payMethod.value = method
}
const pay = async () => {
if (!canPay.value || isSubmitting.value) return
if (isStoredValueOrder.value && payMethod.value === 'balance') return
isSubmitting.value = true
try {
if (payMethod.value === 'balance') {
await orderApi.payOrderByBalance(order.value.id)
uni.showToast({ title: '支付成功', icon: 'success' })
} else {
const paymentData = await orderApi.payOrderByWechat(order.value.id)
await requestWechatPayment(paymentData.wechat)
uni.showToast({ title: '支付已提交', icon: 'success' })
}
await loadPage()
setTimeout(() => {
uni.redirectTo({ url: `/pages/comprehensive/orders/detail?id=${order.value.id}` })
@@ -269,10 +218,6 @@ const requestWechatPayment = (wechatPayload) => new Promise((resolve, reject) =>
color: $color-primary;
}
.method-item.disabled {
opacity: 0.45;
}
.method-title,
.method-sub {
display: block;
@@ -21,7 +21,7 @@
<view class="card fee-card">
<text class="card-title">费用明细</text>
<view class="summary-row">
<text>半小时段</text>
<text>时段</text>
<text>{{ quote.unit_count }} 段</text>
</view>
<view class="summary-row">
@@ -61,7 +61,9 @@
class="grid-cell"
:class="getCellClass(court.id, si)"
@tap="toggleCell(court.id, si)"
></view>
>
<text v-if="getCellPrice(court.id, si)" class="cell-price">¥{{ getCellPrice(court.id, si) }}</text>
</view>
</view>
<view
v-if="currentTimeLineVisible && currentTimeTop >= 0"
@@ -91,6 +93,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 { toAmountNumber } from '@/modules/comprehensive/utils/money'
const HEADER_HEIGHT = 75
const CELL_HEIGHT = 107
@@ -98,10 +102,10 @@ const TIME_COL_WIDTH = 128
const COURT_COL_WIDTH = 149
const typeOptions = [
{ label: '羽毛球', value: 'badminton' },
{ label: '篮球', value: 'basketball' }
{ label: '羽毛球', value: 'badminton' }
]
const session = useComprehensiveSessionStore()
const courtType = ref('badminton')
const selectedDate = ref(dayjs().format('YYYY-MM-DD'))
const grid = ref(null)
@@ -112,9 +116,11 @@ const now = ref(dayjs())
let loadRequestId = 0
const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const hasPositiveBalance = computed(() => toAmountNumber(session.member?.balance) > 0)
const dateOptions = computed(() => {
const today = dayjs()
return Array.from({ length: 7 }).map((_, i) => {
const dateCount = hasPositiveBalance.value ? 7 : 1
return Array.from({ length: dateCount }).map((_, i) => {
const d = today.add(i, 'day')
return {
label: i === 0 ? '今天' : i === 1 ? '明天' : weekDays[d.day()],
@@ -146,6 +152,7 @@ const cellStatus = computed(() => {
for (const court of grid.value.courts) {
status[court.id] = {}
const occs = occupationMap.value[court.id] || []
const courtPrices = grid.value?.slot_prices?.[court.id] || []
for (let si = 0; si < grid.value.time_slots.length; si++) {
const slot = grid.value.time_slots[si]
@@ -157,13 +164,23 @@ const cellStatus = computed(() => {
continue
}
const isOccupied = occs.some((occ) => {
const overlappingOcc = occs.find((occ) => {
const occStart = dayjs(occ.start_at)
const occEnd = dayjs(occ.end_at)
return occStart.isBefore(slotEnd) && occEnd.isAfter(slotStart)
})
status[court.id][si] = isOccupied ? 'occupied' : 'available'
if (overlappingOcc) {
status[court.id][si] = overlappingOcc.source_type === 'lock' ? 'locked' : 'occupied'
continue
}
if (!courtPrices[si]) {
status[court.id][si] = 'no_price'
continue
}
status[court.id][si] = 'available'
}
}
return status
@@ -180,7 +197,7 @@ const currentTimeTop = computed(() => {
const closeMin = parseTimeToMinutes(grid.value.close_time)
const currentMin = now.value.hour() * 60 + now.value.minute()
if (currentMin < openMin || currentMin > closeMin) return -1
return HEADER_HEIGHT + ((currentMin - openMin) / 30) * CELL_HEIGHT
return HEADER_HEIGHT + ((currentMin - openMin) / 60) * CELL_HEIGHT
})
const selectedCourtCount = computed(() => Object.keys(selectedCells.value).length)
@@ -203,11 +220,18 @@ const selectionTimeText = computed(() => {
})
const selectionAmount = computed(() => {
const priceVal = parseFloat(grid.value?.unit_price || '0')
return (priceVal * selectedTotalSlots.value).toFixed(2)
let total = 0
for (const [courtId, slots] of Object.entries(selectedCells.value)) {
const prices = grid.value?.slot_prices?.[courtId] || []
for (const slotIndex of slots) {
const p = parseFloat(prices[slotIndex] || '0')
total += p
}
}
return total.toFixed(2)
})
onMounted(loadGrid)
onMounted(refresh)
async function loadGrid() {
const requestId = ++loadRequestId
@@ -249,6 +273,12 @@ function getCellClass(courtId, slotIndex) {
return { [`cell-${status}`]: true }
}
function getCellPrice(courtId, slotIndex) {
if (cellStatus.value[courtId]?.[slotIndex] !== 'available') return ''
const prices = grid.value?.slot_prices?.[courtId] || []
return prices[slotIndex] || ''
}
function toggleCell(courtId, slotIndex) {
const status = cellStatus.value[courtId]?.[slotIndex]
if (status !== 'available') return
@@ -328,11 +358,17 @@ function normalizeClock(value) {
return value.slice(0, 5)
}
defineExpose({
refresh() {
now.value = dayjs()
loadGrid()
async function refresh() {
await session.init()
if (!hasPositiveBalance.value && selectedDate.value !== dayjs().format('YYYY-MM-DD')) {
selectedDate.value = dayjs().format('YYYY-MM-DD')
}
now.value = dayjs()
await loadGrid()
}
defineExpose({
refresh
})
</script>
@@ -530,18 +566,45 @@ defineExpose({
height: 107rpx;
border-right: 1rpx solid $bg-color-hover;
border-bottom: 1rpx solid $bg-color-hover;
display: flex;
align-items: center;
justify-content: center;
}
.cell-price {
font-size: 22rpx;
font-weight: 700;
color: $text-color-muted;
}
.cell-selected .cell-price {
color: #FFFFFF;
}
.cell-available {
background: $bg-color-main;
}
.cell-occupied {
background: $color-danger-light;
.cell-occupied,
.cell-past,
.cell-locked,
.cell-no_price {
background: $bg-color-hover;
}
.cell-past {
background: $bg-color-hover;
.cell-locked::after {
content: '锁';
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
font-size: 20rpx;
font-weight: 700;
color: $text-color-light;
}
.cell-locked {
position: relative;
}
.cell-selected {
@@ -122,6 +122,9 @@ const bindPhone = async (event) => {
}
}
const goHome = () => {
if (authStorage.getPendingToken()) {
session.completeLogin()
}
uni.switchTab({ url: '/pages/home/index' })
}
</script>