Compare commits

..

6 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
gqt d00304ec9c fix: 订场页时间标签显示修复 + 约课页列表刷新修复
- private/index.vue: 时间标签改为单时间+竖线样式,第一行加 margin-top 防止被表头遮挡
- courses/index.vue: 修复切换Tab/日期列表不刷新问题(竞态条件+不清空旧数据+无错误处理)
- .env: 冰场馆URL修正
2026-07-15 13:57:57 +08:00
gqt 4b886840c0 feat: 订场页改为网格时间表,支持多场地多时段选择
- private/index.vue 重写为场地×时间网格,双向滚动,sticky表头
- 当前时间红线指示,已过时灰块/已占用浅红/已选中蓝色
- 支持多场地连续时段选择,非连续会提示
- 日期改为近7天,Tab栏样式与约课页统一
- 底部按钮:未选时全宽长条,选中后显示摘要+小按钮
- privateBooking.js 新增 getPrivateBookingGrid API
- quote/create 改为 items[] 格式,每场地独立时间段
- confirm.vue 按 items 逐条展示
- pages/private/index.vue onShow 自动刷新
- .env 冰场馆URL修正
2026-07-14 18:06:04 +08:00
12 changed files with 700 additions and 456 deletions
+2 -2
View File
@@ -1,2 +1,2 @@
VITE_ICE_API_BASE_URL=https://8002.frp.chatgqt.top VITE_ICE_API_BASE_URL=https://venue.test.chatgqt.top
VITE_COMPREHENSIVE_API_BASE_URL=https://8002.frp.chatgqt.top VITE_COMPREHENSIVE_API_BASE_URL=https://venue2.test.chatgqt.top
@@ -58,7 +58,7 @@ defineProps({
}, },
primaryText: { primaryText: {
type: String, type: String,
default: '微信一键登录' default: '点击登录'
}, },
loading: { loading: {
type: Boolean, type: Boolean,
@@ -10,12 +10,22 @@ const toQuoteCourt = (backendCourt = {}) => ({
court_type_label: backendCourt.court_type_label || '' court_type_label: backendCourt.court_type_label || ''
}) })
const toQuoteItem = (backendItem = {}) => ({
court_id: backendItem.court_id ?? null,
court_name: backendItem.court_name || '',
start_at: backendItem.start_at || '',
end_at: backendItem.end_at || '',
unit_count: toNumber(backendItem.unit_count),
amount: backendItem.amount || '0.00'
})
const toPrivateQuote = (backendQuote = {}) => ({ const toPrivateQuote = (backendQuote = {}) => ({
amount: backendQuote.amount || '0.00', amount: backendQuote.amount || '0.00',
unit_count: toNumber(backendQuote.unit_count), unit_count: toNumber(backendQuote.unit_count),
start_at: backendQuote.start_at || '', start_at: backendQuote.start_at || '',
end_at: backendQuote.end_at || '', end_at: backendQuote.end_at || '',
courts: toArray(backendQuote.courts).map(toQuoteCourt) courts: toArray(backendQuote.courts).map(toQuoteCourt),
items: toArray(backendQuote.items).map(toQuoteItem)
}) })
const toPrivateCreateResult = (backendData = {}) => ({ const toPrivateCreateResult = (backendData = {}) => ({
@@ -36,14 +46,41 @@ const toPrivateBookingOptions = (backendData = {}) => ({
court_type_label: backendData.court_type_label || '', court_type_label: backendData.court_type_label || '',
open_time: backendData.open_time || '', open_time: backendData.open_time || '',
close_time: backendData.close_time || '', close_time: backendData.close_time || '',
unit_price: backendData.unit_price || '0.00', duration_units: toNumber(backendData.duration_units, 1),
duration_units: toNumber(backendData.duration_units, 2),
duration_minutes: toNumber(backendData.duration_minutes, 60), duration_minutes: toNumber(backendData.duration_minutes, 60),
amount_per_court: backendData.amount_per_court || '0.00',
total_court_count: toNumber(backendData.total_court_count), total_court_count: toNumber(backendData.total_court_count),
time_options: toArray(backendData.time_options).map(toPrivateTimeOption) time_options: toArray(backendData.time_options).map(toPrivateTimeOption)
}) })
const toGridCourt = (backendCourt = {}) => ({
id: backendCourt.id ?? null,
name: backendCourt.name || ''
})
const toGridSlot = (backendSlot = {}) => ({
start: backendSlot.start || '',
end: backendSlot.end || ''
})
const toGridOccupation = (backendOcc = {}) => ({
court_id: backendOcc.court_id ?? null,
start_at: backendOcc.start_at || '',
end_at: backendOcc.end_at || '',
source_type: backendOcc.source_type || ''
})
const toPrivateBookingGrid = (backendData = {}) => ({
date: backendData.date || '',
court_type: backendData.court_type || '',
court_type_label: backendData.court_type_label || '',
open_time: backendData.open_time || '',
close_time: backendData.close_time || '',
courts: toArray(backendData.courts).map(toGridCourt),
time_slots: toArray(backendData.time_slots).map(toGridSlot),
occupations: toArray(backendData.occupations).map(toGridOccupation),
slot_prices: backendData.slot_prices || {}
})
export const privateBookingApi = { export const privateBookingApi = {
getPrivateBookingOptions(params = {}) { getPrivateBookingOptions(params = {}) {
return request({ return request({
@@ -55,25 +92,33 @@ export const privateBookingApi = {
}) })
}).then(toPrivateBookingOptions) }).then(toPrivateBookingOptions)
}, },
getPrivateBookingGrid(params = {}) {
return request({
url: '/api/bookings/private/grid',
data: compactParams({
date: params.date,
court_type: params.courtType || params.court_type
})
}).then(toPrivateBookingGrid)
},
quotePrivateBooking(payload = {}) { quotePrivateBooking(payload = {}) {
return request({ return request({
url: '/api/bookings/private/quote', url: '/api/bookings/private/quote',
method: 'POST', method: 'POST',
data: { data: {
court_ids: toArray(payload.court_ids), items: toArray(payload.items)
start_at: payload.start_at,
end_at: payload.end_at
} }
}).then(toPrivateQuote) }).then(toPrivateQuote)
}, },
createPrivateBooking(payload = {}) { createPrivateBooking(payload = {}) {
return request({ return request({
url: '/api/bookings/private', url: '/api/bookings/private',
method: 'POST', method: 'POST',
data: { data: {
court_ids: toArray(payload.court_ids), items: toArray(payload.items)
start_at: payload.start_at,
end_at: payload.end_at
} }
}).then(toPrivateCreateResult) }).then(toPrivateCreateResult)
} }
@@ -20,10 +20,6 @@
<text>订单号</text> <text>订单号</text>
<text>{{ booking.order.order_no }}</text> <text>{{ booking.order.order_no }}</text>
</view> </view>
<view class="info-row">
<text>订单金额</text>
<text>¥{{ booking.order.amount }}</text>
</view>
<view class="info-row"> <view class="info-row">
<text>订单状态</text> <text>订单状态</text>
<text>{{ booking.order.status_label }}</text> <text>{{ booking.order.status_label }}</text>
@@ -3,7 +3,6 @@
<block> <block>
<view class="tab-bar"> <view class="tab-bar">
<view class="tab-item" :class="{ active: activeTab === 'membership' }" @tap="switchTab('membership')">会员卡</view> <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 class="tab-item" :class="{ active: activeTab === 'tickets' }" @tap="switchTab('tickets')">门票</view>
</view> </view>
@@ -85,7 +84,6 @@
<text class="meta">{{ item.coach_name }}</text> <text class="meta">{{ item.coach_name }}</text>
<view class="course-bottom"> <view class="course-bottom">
<text class="meta">剩余 {{ item.remaining_count }}/{{ item.capacity }}</text> <text class="meta">剩余 {{ item.remaining_count }}/{{ item.capacity }}</text>
<text class="course-price">¥{{ item.price }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -118,6 +116,7 @@ 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)
let loadRequestId = 0
const ticketProduct = ref(null) const ticketProduct = ref(null)
const ticketQty = ref(1) const ticketQty = ref(1)
@@ -143,6 +142,7 @@ const dateOptions = computed(() => {
const hasMore = computed(() => sessions.value.length < total.value) const hasMore = computed(() => sessions.value.length < total.value)
const switchTab = (tab) => { const switchTab = (tab) => {
if (activeTab.value === tab) return
activeTab.value = tab activeTab.value = tab
if (tab === 'tickets') { if (tab === 'tickets') {
loadTicketProduct() loadTicketProduct()
@@ -153,15 +153,22 @@ const switchTab = (tab) => {
} }
const onDateSelect = (date) => { const onDateSelect = (date) => {
if (selectedDate.value === date) return
selectedDate.value = date selectedDate.value = date
loadSessions(true) loadSessions(true)
} }
const loadSessions = async (reset) => { const loadSessions = async (reset) => {
if (activeTab.value === 'membership' && !session.isLoggedIn) return if (activeTab.value === 'membership' && !session.isLoggedIn) {
sessions.value = []
total.value = 0
return
}
const requestId = ++loadRequestId
if (reset) { if (reset) {
page.value = 1 page.value = 1
isLoading.value = true isLoading.value = true
sessions.value = []
} else { } else {
isLoadingMore.value = true isLoadingMore.value = true
} }
@@ -172,12 +179,21 @@ const loadSessions = async (reset) => {
} else { } else {
data = await courseApi.getSessions({ page: page.value, pageSize, date: selectedDate.value }) data = await courseApi.getSessions({ page: page.value, pageSize, date: selectedDate.value })
} }
if (requestId !== loadRequestId) return
sessions.value = reset ? data.items : [...sessions.value, ...data.items] sessions.value = reset ? data.items : [...sessions.value, ...data.items]
total.value = data.total total.value = data.total
} catch {
if (requestId !== loadRequestId) return
if (reset) {
sessions.value = []
total.value = 0
}
} finally { } finally {
if (requestId === loadRequestId) {
isLoading.value = false isLoading.value = false
isLoadingMore.value = false isLoadingMore.value = false
} }
}
} }
const loadTicketProduct = async () => { const loadTicketProduct = async () => {
@@ -451,12 +467,6 @@ defineExpose({ refresh, loadMore })
margin-top: 6rpx; margin-top: 6rpx;
} }
.course-price {
color: $text-color-main;
font-size: 28rpx;
font-weight: 900;
}
/* 篮球门票 */ /* 篮球门票 */
.ticket-section { .ticket-section {
padding: $spacing-md; padding: $spacing-md;
@@ -6,9 +6,6 @@
<view class="title-row"> <view class="title-row">
<text class="course-title">{{ sessionData.title }}</text> <text class="course-title">{{ sessionData.title }}</text>
</view> </view>
<view class="price-row">
<text class="price-text">¥{{ sessionData.price }}</text>
</view>
</view> </view>
<view class="info-card card"> <view class="info-card card">
@@ -172,7 +169,6 @@ const confirmBook = async () => {
} }
.title-row, .title-row,
.price-row,
.info-row { .info-row {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -188,10 +184,6 @@ const confirmBook = async () => {
line-height: 1.3; line-height: 1.3;
} }
.price-row {
margin-top: $spacing-md;
}
.info-card, .info-card,
.rich-card { .rich-card {
margin-top: $spacing-md; margin-top: $spacing-md;
@@ -23,24 +23,12 @@
<view class="card method-card"> <view class="card method-card">
<text class="card-title">支付方式</text> <text class="card-title">支付方式</text>
<view <view class="method-item active">
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> <view>
<text class="method-title">微信支付</text> <text class="method-title">微信支付</text>
<text class="method-sub">使用微信支付完成订单</text> <text class="method-sub">使用微信支付完成订单</text>
</view> </view>
<text class="method-check">{{ payMethod === 'wechat' ? '已选' : '' }}</text> <text class="method-check">已选</text>
</view> </view>
</view> </view>
@@ -61,28 +49,19 @@
<script setup> <script setup>
import { computed, onUnmounted, ref } from 'vue' import { computed, onUnmounted, ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { memberApi } from '@/modules/comprehensive/api/member'
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 { toAmountNumber } from '@/modules/comprehensive/utils/money'
const orderId = ref('') const orderId = ref('')
const order = ref(null) const order = ref(null)
const balance = ref({ balance: '0.00' })
const payMethod = ref('balance')
const isSubmitting = ref(false) const isSubmitting = ref(false)
const nowTimestamp = ref(Date.now()) const nowTimestamp = ref(Date.now())
let countdownTimer = null let countdownTimer = null
const isStoredValueOrder = computed(() => order.value?.business_type === 'stored_value') const canPay = computed(() => order.value?.status === 'pending_pay')
const canUseBalance = computed(() => {
if (isStoredValueOrder.value) return false
return toAmountNumber(balance.value.balance) >= toAmountNumber(order.value?.amount)
})
const canPay = computed(() => order.value?.status === 'pending_pay' && (payMethod.value !== 'balance' || canUseBalance.value))
const payButtonText = computed(() => { const payButtonText = computed(() => {
if (order.value?.status !== 'pending_pay') return '订单不可支付' if (order.value?.status !== 'pending_pay') return '订单不可支付'
return payMethod.value === 'balance' ? '余额支付' : '微信支付' return '微信支付'
}) })
const paymentRemainingSeconds = computed(() => { const paymentRemainingSeconds = computed(() => {
if (!order.value?.expire_at) return 0 if (!order.value?.expire_at) return 0
@@ -107,17 +86,7 @@ onUnmounted(() => {
}) })
const loadPage = async () => { const loadPage = async () => {
const [orderData, balanceData] = await Promise.all([ order.value = await orderApi.getOrderDetail(orderId.value)
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'
}
updateCountdownTimer() updateCountdownTimer()
} }
@@ -139,33 +108,13 @@ const stopCountdown = () => {
const padTime = (value) => String(value).padStart(2, '0') 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 () => { const pay = async () => {
if (!canPay.value || isSubmitting.value) return if (!canPay.value || isSubmitting.value) return
if (isStoredValueOrder.value && payMethod.value === 'balance') return
isSubmitting.value = true isSubmitting.value = true
try { 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) const paymentData = await orderApi.payOrderByWechat(order.value.id)
await requestWechatPayment(paymentData.wechat) await requestWechatPayment(paymentData.wechat)
uni.showToast({ title: '支付已提交', icon: 'success' }) uni.showToast({ title: '支付已提交', icon: 'success' })
}
await loadPage() await loadPage()
setTimeout(() => { setTimeout(() => {
uni.redirectTo({ url: `/pages/comprehensive/orders/detail?id=${order.value.id}` }) 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; color: $color-primary;
} }
.method-item.disabled {
opacity: 0.45;
}
.method-title, .method-title,
.method-sub { .method-sub {
display: block; display: block;
@@ -2,24 +2,26 @@
<view class="confirm-page container page-bottom-safe" v-if="quote"> <view class="confirm-page container page-bottom-safe" v-if="quote">
<view class="card summary-card"> <view class="card summary-card">
<text class="card-title">{{ courtTypeLabel(payload.court_type) }}包场</text> <text class="card-title">{{ courtTypeLabel(payload.court_type) }}包场</text>
<view class="summary-row"> <view
<text>预约场地</text> v-for="(item, index) in quote.items"
<text>{{ quote.courts.map(item => item.name).join('、') }}</text> :key="index"
class="item-row"
>
<view class="item-header">
<text class="item-name">{{ item.court_name }}</text>
<text class="item-amount">¥{{ item.amount }}</text>
</view> </view>
<view class="summary-row"> <view class="item-meta">
<text>预约时间</text> <text>{{ formatTime(item.start_at) }}-{{ formatTime(item.end_at) }}</text>
<text>{{ formatTimeRange(quote.start_at, quote.end_at) }}</text> <text>{{ formatDuration(item.start_at, item.end_at) }}</text>
</view> </view>
<view class="summary-row">
<text>预约时长</text>
<text>{{ formatDuration(quote.start_at, quote.end_at) }}</text>
</view> </view>
</view> </view>
<view class="card fee-card"> <view class="card fee-card">
<text class="card-title">费用明细</text> <text class="card-title">费用明细</text>
<view class="summary-row"> <view class="summary-row">
<text>半小时段</text> <text>时段</text>
<text>{{ quote.unit_count }} </text> <text>{{ quote.unit_count }} </text>
</view> </view>
<view class="summary-row"> <view class="summary-row">
@@ -47,7 +49,7 @@ import { ref } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking' import { privateBookingApi } from '@/modules/comprehensive/api/privateBooking'
import { courtTypeLabel } from '@/modules/comprehensive/utils/status' import { courtTypeLabel } from '@/modules/comprehensive/utils/status'
import { formatDuration, formatTimeRange } from '@/modules/comprehensive/utils/date' import { formatDuration, formatTime } from '@/modules/comprehensive/utils/date'
const payload = ref({}) const payload = ref({})
const quote = ref(null) const quote = ref(null)
@@ -84,6 +86,42 @@ const submit = async () => {
font-weight: 900; font-weight: 900;
} }
.item-row {
padding: $spacing-sm 0;
border-bottom: 1rpx solid $bg-color-hover;
}
.item-row:last-child {
border-bottom: none;
}
.item-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8rpx;
}
.item-name {
color: $text-color-main;
font-size: 28rpx;
font-weight: 800;
}
.item-amount {
color: $text-color-main;
font-size: 26rpx;
font-weight: 800;
}
.item-meta {
display: flex;
align-items: center;
gap: $spacing-md;
color: $text-color-muted;
font-size: 24rpx;
}
.summary-row { .summary-row {
min-height: 72rpx; min-height: 72rpx;
display: flex; display: flex;
File diff suppressed because it is too large Load Diff
@@ -122,6 +122,9 @@ const bindPhone = async (event) => {
} }
} }
const goHome = () => { const goHome = () => {
if (authStorage.getPendingToken()) {
session.completeLogin()
}
uni.switchTab({ url: '/pages/home/index' }) uni.switchTab({ url: '/pages/home/index' })
} }
</script> </script>
@@ -9,6 +9,9 @@
</template> </template>
<script setup> <script setup>
defineExpose({
refresh() {}
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
+10
View File
@@ -7,12 +7,22 @@
<script setup> <script setup>
import { ref } from 'vue' import { ref } from 'vue'
import { onShow } from '@dcloudio/uni-app'
import { getStoredVenue } from '@/utils/venue' import { getStoredVenue } from '@/utils/venue'
import ComprehensivePrivate from '@/pages/comprehensive/private/index.vue' import ComprehensivePrivate from '@/pages/comprehensive/private/index.vue'
import IcePrivate from '@/pages/ice/private/index.vue' import IcePrivate from '@/pages/ice/private/index.vue'
const isComprehensive = getStoredVenue() === 'comprehensive' const isComprehensive = getStoredVenue() === 'comprehensive'
const contentRef = ref() const contentRef = ref()
let isFirstShow = true
onShow(() => {
if (isFirstShow) {
isFirstShow = false
return
}
contentRef.value?.refresh?.()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>