Files
pingxiangcard/src/packages/user/pages/reserve/index.vue
T
2026-07-06 09:17:41 +08:00

760 lines
19 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<view class="page">
<view class="scenic-card">
<image class="scenic-img" :src="scenic.image" mode="aspectFill" />
<view class="scenic-info">
<text class="scenic-name">{{ scenic.name }}</text>
<view class="scenic-row">
<view class="scenic-icon scenic-icon--pin" />
<text class="scenic-text">{{ scenic.address }}</text>
</view>
<view class="scenic-row">
<view class="scenic-icon scenic-icon--clock" />
<text class="scenic-text">{{ scenic.hours }}</text>
</view>
<text class="scenic-tip">持{{ cardModelName }}免费入园,单人单日仅可预约1次</text>
</view>
</view>
<view class="date-card">
<text class="section-title">选择预约日期</text>
<scroll-view scroll-x class="date-scroll">
<view class="date-list">
<view
v-for="d in dateOptions"
:key="d.key"
class="date-item"
:class="{
'date-item--active': selectedDate === d.key,
'date-item--disabled': d.disabled || (d.stock != null && d.stock <= 0)
}"
@tap="!(d.disabled || (d.stock != null && d.stock <= 0)) && (selectedDate = d.key)"
>
<text class="date-week">{{ d.week }}</text>
<text class="date-day">{{ d.label }}</text>
<text v-if="d.disabled" class="date-stock date-stock--empty">不可约</text>
<text v-else-if="d.stock == null" class="date-stock">无限库存</text>
<text v-else-if="d.stock >= 50" class="date-stock">可预约</text>
<text v-else-if="d.stock > 0" class="date-stock">余{{ d.stock }}位</text>
<text v-else class="date-stock date-stock--empty">已约满</text>
</view>
</view>
</scroll-view>
<view v-if="selectedSessions.length > 0" class="session-section">
<text class="session-title">选择入园场次</text>
<view class="session-list">
<view
v-for="s in selectedSessions"
:key="s.id"
class="session-item"
:class="{
'session-item--active': selectedSession === s.id && (s.stock == null || s.stock > 0),
'session-item--disabled': s.stock != null && s.stock <= 0
}"
@tap="(s.stock == null || s.stock > 0) && (selectedSession = s.id)"
>
<text class="session-time">{{ s.time }}</text>
<text v-if="s.stock == null" class="session-stock session-stock--available">可预约</text>
<text v-else-if="s.stock >= 50" class="session-stock session-stock--available">可预约</text>
<text v-else-if="s.stock > 0" class="session-stock session-stock--limited">余{{ s.stock }}位</text>
<text v-else class="session-stock session-stock--empty">已约满</text>
</view>
</view>
</view>
<view v-else-if="selectedDateObj?.disabled" class="no-session-tip">
<text class="no-session-tip__text no-session-tip__text--error">{{ selectedDateObj.reason || '该日期不可预约' }}</text>
</view>
<view v-else-if="selectedDate && selectedDateStock === 0" class="no-session-tip">
<text class="no-session-tip__text">该日期已约满</text>
</view>
<view v-else-if="selectedDate && selectedDateStock > 0 && !selectedSessions.length" class="no-session-tip">
<text class="no-session-tip__text">该日期无需选择场次,直接预约即可</text>
</view>
<text class="date-tip">仅可预约近{{ appointmentDays || 3 }}日内时段</text>
</view>
<view class="user-card">
<text class="section-title">预约人信息</text>
<view class="user-row">
<text class="user-label">姓名</text>
<text class="user-value">{{ userName }}</text>
</view>
<view class="user-row">
<text class="user-label">身份证号</text>
<text class="user-value">{{ userIdCard }}</text>
</view>
<text class="user-warn">预约信息必须与持卡实名一致,不可更换他人</text>
</view>
<view class="notice-card" v-if="bookingNotice">
<text class="notice-title">预约须知</text>
<view class="notice-list">
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">{{ bookingNotice }}</text>
</view>
</view>
<view class="notice-link" @tap="goFaq">
<text class="notice-link__text">查看完整FAQ</text>
</view>
</view>
<view class="safe-bottom" />
<view class="bottom-bar">
<button
class="btn-submit"
:class="{ 'btn-submit--disabled': !selectedDate }"
:disabled="!selectedDate"
@tap="handleSubmit"
>
<text class="btn-submit__text">提交预约</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
interface ReserveOptionSession {
id: string | number
time: string
stock: number
}
interface DateOption {
key: string
week: string
label: string
stock: number | null
sessions: ReserveOptionSession[]
disabled?: boolean
reason?: string
}
const scenic = ref({ name: '', address: '', hours: '', image: '' })
const userName = ref('')
const userIdCard = ref('')
const bookingNotice = ref('')
const cardModelName = ref('')
const appointmentDays = ref<number | null>(null)
const reserveParams = ref({
scenicId: 0,
channel: '',
thirdId: '',
cardNum: '',
mobile: '',
cardModelName: ''
})
const dateOptions = ref<DateOption[]>([])
const selectedDate = ref('')
const selectedSession = ref<string | number>('')
const submitting = ref(false)
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function formatDateDisplay(dateStr: string): { week: string; label: string } {
const d = new Date(dateStr)
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const diff = Math.round((d.getTime() - today.getTime()) / 86400000)
const week = diff === 1 ? '明天' : diff === 2 ? '后天' : weekNames[d.getDay()]
const label = `${d.getMonth() + 1}月${d.getDate()}日`
return { week, label }
}
const selectedDateObj = computed(() => {
if (!selectedDate.value) return null
return dateOptions.value.find(o => o.key === selectedDate.value) || null
})
const selectedSessions = computed(() => {
if (!selectedDate.value || selectedDateObj.value?.disabled) return []
const d = dateOptions.value.find(d => d.key === selectedDate.value)
const sessions = d?.sessions || []
if (sessions.length > 0 && !sessions.find(s => s.id === selectedSession.value)) {
const available = sessions.find(s => s.stock == null || s.stock > 0)
selectedSession.value = available ? available.id : ''
}
return sessions
})
const selectedDateStock = computed(() => {
if (!selectedDate.value) return -1
const d = dateOptions.value.find(o => o.key === selectedDate.value)
if (!d) return 0
return d.stock == null ? 1 : d.stock
})
onLoad(async (query: any) => {
const resourceId = query?.resourceId
if (!resourceId) return
const cardNumber = query?.cardNumber || ''
const cardModelId = query?.cardModelId || ''
const phone = (uni.getStorageSync('phone') as string) || ''
try {
const res = await post<any>('/mpReserve/queryReserveOptions', {
resourceId: parseInt(resourceId),
phone,
cardNumber: cardNumber || undefined,
})
if (res) {
scenic.value = {
name: res.scenicName || '',
address: res.address || '',
hours: res.hours || '',
image: res.scenicImage || ''
}
userName.value = res.userName || ''
userIdCard.value = res.userIdCard || ''
bookingNotice.value = Array.isArray(res.bookingNotice) ? res.bookingNotice.join('\n') : (res.bookingNotice || '')
cardModelName.value = res.cardModelName || ''
appointmentDays.value = (res.dates || []).length
reserveParams.value = {
scenicId: parseInt(resourceId),
channel: 'Channel_SZH',
thirdId: cardModelId,
cardNum: cardNumber,
mobile: phone,
cardModelName: res.cardModelName || ''
}
dateOptions.value = (res.dates || []).map((d: any) => ({
key: d.key,
week: d.week,
label: d.label,
stock: d.stock,
sessions: d.sessions || [],
disabled: d.disabled,
reason: d.reason,
}))
const firstAvailable = dateOptions.value.find(d => d.stock == null || d.stock > 0)
if (firstAvailable) selectedDate.value = firstAvailable.key
}
} catch (err: any) {
const mockStocks: (number | null)[] = [null, 200, 0]
const mockSessions: Record<string, ReserveOptionSession[]> = {
['mock0']: [
{ id: 's1', time: '08:00-10:00', stock: 56 },
{ id: 's2', time: '10:00-12:00', stock: 42 },
{ id: 's3', time: '14:00-16:00', stock: 58 },
],
['mock1']: [
{ id: 's4', time: '08:00-12:00', stock: 100 },
{ id: 's5', time: '12:00-18:00', stock: 100 },
],
}
dateOptions.value = Array.from({ length: 3 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() + i + 1)
const month = d.getMonth() + 1
const day = d.getDate()
const key = `${d.getFullYear()}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const { week, label } = formatDateDisplay(key)
return {
key,
week,
label,
stock: mockStocks[i],
sessions: mockSessions[`mock${i}`] || [],
}
})
appointmentDays.value = 3
scenic.value = {
name: '武功山金顶景区',
address: '江西省萍乡市芦溪县武功山风景名胜区',
hours: '08:00-18:00',
image: 'https://images.unsplash.com/photo-1513415564515-763d91423bdd?fm=jpg&q=80&w=800&h=400&fit=crop',
}
bookingNotice.value = '请提前10分钟到达景区检票口;\n入园需出示预约码和身份证;\n如无法按时入园请提前取消预约。'
userName.value = '张三'
userIdCard.value = '360302199001011234'
cardModelName.value = '赣湘全域旅游年卡'
const firstAvailable = dateOptions.value.find(d => d.stock == null || d.stock > 0)
if (firstAvailable) selectedDate.value = firstAvailable.key
}
})
async function handleSubmit() {
if (!selectedDate.value || submitting.value) return
const dateObj = dateOptions.value.find(d => d.key === selectedDate.value)
if (dateObj?.disabled) {
uni.showToast({ title: dateObj.reason || '该日期不可预约', icon: 'none' })
return
}
if (dateObj && dateObj.stock != null && dateObj.stock <= 0) {
uni.showToast({ title: '该日期已约满', icon: 'none' })
return
}
submitting.value = true
try {
const session = selectedSessions.value.find(s => s.id === selectedSession.value)
const p = reserveParams.value
const result = await post<any>(`${BASE_URL}/zhy-api/cardBag/appoint`, {
scenicId: String(p.scenicId),
channel: p.channel,
thirdId: p.thirdId,
cardNum: p.cardNum,
mobile: p.mobile,
cardModelName: p.cardModelName,
appointDate: selectedDate.value,
timeId: session ? String(session.id) : '',
name: userName.value,
idNo: userIdCard.value,
})
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => {
uni.navigateTo({
url: `/packages/user/pages/reserve/success?appointId=${result}&cardNumber=${p.cardNum}&cardModelId=${p.thirdId}`
})
}, 1500)
} catch (e: any) {
uni.showToast({ title: e?.message || '预约失败', icon: 'none' })
} finally {
submitting.value = false
}
}
function goFaq() {
uni.navigateTo({ url: '/packages/content/pages/faq' })
}
</script>
<style lang="scss" scoped>
$bar-height: 120rpx;
.page {
min-height: 100vh;
background: $color-background;
padding-bottom: calc(#{$bar-height} + env(safe-area-inset-bottom));
font-family: $font-family-body;
}
.scenic-card {
margin: 24rpx 24rpx 0;
background: $color-card;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.scenic-img {
width: 100%;
height: 280rpx;
}
.scenic-info {
padding: 28rpx;
}
.scenic-name {
display: block;
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 12rpx;
}
.scenic-row {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.scenic-icon {
width: 24rpx;
height: 24rpx;
margin-right: 8rpx;
flex-shrink: 0;
&--pin {
border: 3rpx solid $uv-tips-color;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
position: relative;
&::after {
content: '';
width: 10rpx;
height: 10rpx;
border-radius: 50%;
border: 2rpx solid $uv-tips-color;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
&--clock {
border: 3rpx solid $uv-tips-color;
border-radius: 50%;
position: relative;
&::before {
content: '';
width: 2rpx;
height: 10rpx;
background: $uv-tips-color;
position: absolute;
top: 4rpx;
left: 50%;
transform: translateX(-50%);
}
&::after {
content: '';
width: 8rpx;
height: 2rpx;
background: $uv-tips-color;
position: absolute;
top: 50%;
left: 50%;
}
}
}
.scenic-text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
}
.scenic-tip {
display: block;
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-placeholder-color;
margin-top: 16rpx;
line-height: 1.6;
}
.date-card {
margin: 24rpx 32rpx 0;
background: $color-card;
border-radius: $radius-lg;
padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.section-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $uv-main-color;
margin-bottom: 24rpx;
}
.date-scroll {
white-space: nowrap;
}
.date-list {
display: flex;
gap: 16rpx;
}
.date-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 12rpx;
border-radius: 16rpx;
border: 2rpx solid $uv-primary;
transition: all $transition-fast;
&--active {
background: $uv-primary;
border-color: $uv-primary;
}
}
.date-week {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-tips-color;
margin-bottom: 4rpx;
}
.date-item--active .date-week {
color: rgba(255, 255, 255, 0.8);
}
.date-day {
font-family: $font-family-body;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 4rpx;
}
.date-item--active .date-day {
color: #ffffff;
}
.date-stock {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-main-color;
&--empty {
color: $uv-placeholder-color;
}
}
.date-item--active .date-stock {
color: rgba(255, 255, 255, 0.7);
}
.date-item--disabled {
border-color: $uv-border-color;
.date-week,
.date-day,
.date-stock {
color: $uv-placeholder-color;
}
}
.session-section {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid $uv-border-color;
}
.session-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-main-color;
margin-bottom: 16rpx;
}
.session-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.session-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 28rpx;
border-radius: 12rpx;
border: 2rpx solid $uv-border-color;
transition: all $transition-fast;
&--active {
background: rgba(60, 156, 255, 0.08);
border-color: $uv-primary;
}
&--disabled {
opacity: 0.5;
}
}
.session-time {
font-family: $font-family-body;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $uv-main-color;
margin-bottom: 4rpx;
}
.session-item--active .session-time {
color: $uv-primary;
}
.session-stock {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
&--empty {
color: $uv-error;
}
}
.no-session-tip {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid $uv-border-color;
}
.no-session-tip__text {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
}
.no-session-tip__text--error {
color: $uv-error;
font-weight: $font-weight-medium;
}
.date-tip {
display: block;
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
margin-top: 16rpx;
}
.user-card {
margin: 24rpx 32rpx 0;
background: $color-card;
border-radius: $radius-lg;
padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.user-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
}
.user-label {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-main-color;
}
.user-value {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-content-color;
}
.user-warn {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-error;
margin-top: 12rpx;
}
.notice-card {
margin: 24rpx 32rpx 0;
background: #eef4ff;
border-radius: $radius-lg;
padding: 32rpx;
}
.notice-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 20rpx;
}
.notice-list {
margin-bottom: 16rpx;
}
.notice-item {
display: flex;
align-items: flex-start;
margin-bottom: 12rpx;
}
.notice-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: $uv-primary;
flex-shrink: 0;
margin-top: 14rpx;
margin-right: 12rpx;
}
.notice-text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 1.6;
}
.notice-link {
&:active { opacity: 0.7; }
}
.notice-link__text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-primary;
font-weight: $font-weight-medium;
}
.safe-bottom {
height: 24rpx;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
box-sizing: border-box;
min-height: $bar-height;
background: $color-card;
display: flex;
align-items: center;
justify-content: center;
padding: 0 48rpx;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
z-index: 100;
}
.btn-submit {
width: 100%;
height: 80rpx;
border-radius: 40rpx;
background: $uv-primary;
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: all $transition-fast;
&::after { display: none; }
&:active:not(.btn-submit--disabled) {
transform: scale(0.98);
}
&--disabled {
background: $uv-bg-color;
}
}
.btn-submit__text {
font-family: $font-family-body;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: #ffffff;
}
.btn-submit--disabled .btn-submit__text {
color: $uv-placeholder-color;
}
</style>