第一个版本

This commit is contained in:
lcj
2026-07-06 09:17:41 +08:00
parent 1b4dcabad2
commit 203d129ffb
37 changed files with 2625 additions and 1199 deletions
+220 -119
View File
@@ -1,6 +1,5 @@
<template>
<view class="page">
<!-- 景区基础信息卡片 -->
<view class="scenic-card">
<image class="scenic-img" :src="scenic.image" mode="aspectFill" />
<view class="scenic-info">
@@ -13,11 +12,10 @@
<view class="scenic-icon scenic-icon--clock" />
<text class="scenic-text">{{ scenic.hours }}</text>
</view>
<text class="scenic-tip">持赣湘全域旅游年卡免费入园,单人单日仅可预约1次</text>
<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">
@@ -27,19 +25,21 @@
:key="d.key"
class="date-item"
:class="{
'date-item--active': selectedDate === d.key && d.stock > 0,
'date-item--disabled': d.stock <= 0
'date-item--active': selectedDate === d.key,
'date-item--disabled': d.disabled || (d.stock != null && d.stock <= 0)
}"
@tap="d.stock > 0 && (selectedDate = d.key)"
@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.stock > 0" class="date-stock">余{{ d.stock }}位</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">
@@ -48,59 +48,50 @@
:key="s.id"
class="session-item"
:class="{
'session-item--active': selectedSession === s.id && s.stock > 0,
'session-item--disabled': s.stock <= 0
'session-item--active': selectedSession === s.id && (s.stock == null || s.stock > 0),
'session-item--disabled': s.stock != null && s.stock <= 0
}"
@tap="s.stock > 0 && (selectedSession = s.id)"
@tap="(s.stock == null || s.stock > 0) && (selectedSession = s.id)"
>
<text class="session-time">{{ s.time }}</text>
<text v-if="s.stock > 0" class="session-stock">余{{ s.stock }}位</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="selectedDate && selectedDateStock <= 0" class="no-session-tip">
<text class="no-session-tip__text">该日期暂无可预约场次</text>
<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" class="no-session-tip">
<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">仅可预约近3日内时段,当日预约需提前1天取消</text>
<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">{{ userInfo.name }}</text>
<text class="user-value">{{ userName }}</text>
</view>
<view class="user-row">
<text class="user-label">身份证号</text>
<text class="user-value">{{ maskedId }}</text>
<text class="user-value">{{ userIdCard }}</text>
</view>
<text class="user-warn">预约信息必须与持卡实名一致,不可更换他人</text>
</view>
<!-- 预约须知 -->
<view class="notice-card">
<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">赣湘全域旅游年卡单人每日仅可预约该景区1次</text>
</view>
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">需至少提前1天线上取消预约,预约当日无法取消</text>
</view>
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">未提前取消且未入园,将扣除1次年卡使用次数,权益不可恢复</text>
</view>
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">入园需携带本人身份证+年卡入园二维码,人证合一核验</text>
<text class="notice-text">{{ bookingNotice }}</text>
</view>
</view>
<view class="notice-link" @tap="goFaq">
@@ -108,10 +99,8 @@
</view>
</view>
<!-- 底部安全区占位 -->
<view class="safe-bottom" />
<!-- 底部固定预约按钮 -->
<view class="bottom-bar">
<button
class="btn-submit"
@@ -128,100 +117,212 @@
<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'
const scenic = ref({
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'
})
const userInfo = ref({
name: '张三',
idCard: '360302199001011234'
})
const maskedId = computed(() => {
const id = userInfo.value.idCard
return id.slice(0, 6) + '****' + id.slice(-4)
})
// 生成近3天日期选项(明天起,含库存,第3天已约满)
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const mockStocks = [0, 200, 88]
const dateOptions = Array.from({ length: 3 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() + i + 1) // 从明天开始
const month = d.getMonth() + 1
const day = d.getDate()
return {
key: `${d.getFullYear()}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
week: i === 0 ? '明天' : weekNames[d.getDay()],
label: `${month}月${day}日`,
stock: mockStocks[i]
}
})
// 默认选中第一个有库存的日期
const firstAvailable = dateOptions.find(d => d.stock > 0)
const selectedDate = ref(firstAvailable ? firstAvailable.key : '')
// 场次数据(根据选中日期动态展示)
interface Session {
id: string
interface ReserveOptionSession {
id: string | number
time: string
stock: number
}
const sessionMap: Record<string, Session[]> = {
[dateOptions[0].key]: [
{ 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 },
],
[dateOptions[1].key]: [
{ id: 's4', time: '08:00-12:00', stock: 100 },
{ id: 's5', time: '12:00-18:00', stock: 100 },
],
interface DateOption {
key: string
week: string
label: string
stock: number | null
sessions: ReserveOptionSession[]
disabled?: boolean
reason?: string
}
const selectedSession = ref('')
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) return []
const sessions = sessionMap[selectedDate.value] || []
// 切换日期时自动选中第一个有库存的场次
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 > 0)
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 0
const d = dateOptions.find(o => o.key === selectedDate.value)
return d ? d.stock : 0
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((query: any) => {
if (query?.scenicId) {
// 实际项目中根据 scenicId 请求景区详情
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
}
})
function handleSubmit() {
if (!selectedDate.value) return
async function handleSubmit() {
if (!selectedDate.value || submitting.value) return
// Mock 预约成功
const reserveId = 'YP' + Date.now().toString().slice(-8)
// 获取选中场次的时间段
const session = selectedSessions.value.find(s => s.id === selectedSession.value)
const sessionTime = session ? session.time : ''
const sessionParam = sessionTime ? `&session=${encodeURIComponent(sessionTime)}` : ''
uni.navigateTo({
url: `/packages/user/pages/reserve/success?reserveId=${reserveId}&date=${selectedDate.value}&scenic=${encodeURIComponent(scenic.value.name)}${sessionParam}`
})
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() {
@@ -239,7 +340,6 @@ $bar-height: 120rpx;
font-family: $font-family-body;
}
/* ===== 景区信息卡片 ===== */
.scenic-card {
margin: 24rpx 24rpx 0;
background: $color-card;
@@ -337,7 +437,6 @@ $bar-height: 120rpx;
line-height: 1.6;
}
/* ===== 日期选择 ===== */
.date-card {
margin: 24rpx 32rpx 0;
background: $color-card;
@@ -371,7 +470,7 @@ $bar-height: 120rpx;
align-items: center;
padding: 16rpx 12rpx;
border-radius: 16rpx;
border: 2rpx solid $uv-border-color;
border: 2rpx solid $uv-primary;
transition: all $transition-fast;
&--active {
@@ -406,10 +505,10 @@ $bar-height: 120rpx;
.date-stock {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
color: $uv-main-color;
&--empty {
color: $uv-error;
color: $uv-placeholder-color;
}
}
@@ -418,15 +517,15 @@ $bar-height: 120rpx;
}
.date-item--disabled {
opacity: 0.5;
border-color: $uv-border-color;
.date-week,
.date-day {
.date-day,
.date-stock {
color: $uv-placeholder-color;
}
}
/* ===== 场次选择 ===== */
.session-section {
margin-top: 24rpx;
padding-top: 24rpx;
@@ -501,6 +600,11 @@ $bar-height: 120rpx;
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;
@@ -509,7 +613,6 @@ $bar-height: 120rpx;
margin-top: 16rpx;
}
/* ===== 预约人信息 ===== */
.user-card {
margin: 24rpx 32rpx 0;
background: $color-card;
@@ -545,7 +648,6 @@ $bar-height: 120rpx;
margin-top: 12rpx;
}
/* ===== 预约须知 ===== */
.notice-card {
margin: 24rpx 32rpx 0;
background: #eef4ff;
@@ -600,7 +702,6 @@ $bar-height: 120rpx;
font-weight: $font-weight-medium;
}
/* ===== 底部 ===== */
.safe-bottom {
height: 24rpx;
}