第一个版本
This commit is contained in:
@@ -1,88 +1,31 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 购买类 -->
|
||||
<view class="category">
|
||||
<text class="category-title">购买类</text>
|
||||
<view class="category-divider" />
|
||||
<view v-if="loading" class="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<template v-for="([catName, items], catIdx) in categories" :key="catIdx">
|
||||
<view class="category">
|
||||
<text class="category-title">{{ catName }}</text>
|
||||
<view class="category-divider" />
|
||||
|
||||
<view
|
||||
v-for="(item, idx) in purchaseList"
|
||||
:key="'p' + idx"
|
||||
class="faq-card"
|
||||
>
|
||||
<view class="faq-header" @tap="toggle('p', idx)">
|
||||
<view class="faq-index">{{ idx + 1 }}</view>
|
||||
<text class="faq-question">{{ item.q }}</text>
|
||||
<view class="faq-arrow" :class="{ 'faq-arrow--up': isOpen('p', idx) }" />
|
||||
</view>
|
||||
<view v-show="isOpen('p', idx)" class="faq-body">
|
||||
<view v-for="(p, pi) in item.a" :key="pi" class="faq-paragraph">
|
||||
<text
|
||||
v-for="(seg, si) in p"
|
||||
:key="si"
|
||||
:class="seg.highlight ? 'faq-highlight' : 'faq-text'"
|
||||
@tap="seg.copy ? copyText(seg.text) : null"
|
||||
>{{ seg.text }}</text>
|
||||
<view
|
||||
v-for="(item, idx) in items"
|
||||
:key="item.id"
|
||||
class="faq-card"
|
||||
>
|
||||
<view class="faq-header" @tap="toggle(idx)">
|
||||
<view class="faq-index">{{ idx + 1 }}</view>
|
||||
<text class="faq-question">{{ item.title }}</text>
|
||||
<view class="faq-arrow" :class="{ 'faq-arrow--up': isOpen(idx) }" />
|
||||
</view>
|
||||
<view v-show="isOpen(idx)" class="faq-body">
|
||||
<view class="faq-paragraph">
|
||||
<text class="faq-text">{{ item.content }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 预约类 -->
|
||||
<view class="category">
|
||||
<text class="category-title">预约类</text>
|
||||
<view class="category-divider" />
|
||||
|
||||
<view
|
||||
v-for="(item, idx) in reserveList"
|
||||
:key="'r' + idx"
|
||||
class="faq-card"
|
||||
>
|
||||
<view class="faq-header" @tap="toggle('r', idx)">
|
||||
<view class="faq-index">{{ idx + 1 }}</view>
|
||||
<text class="faq-question">{{ item.q }}</text>
|
||||
<view class="faq-arrow" :class="{ 'faq-arrow--up': isOpen('r', idx) }" />
|
||||
</view>
|
||||
<view v-show="isOpen('r', idx)" class="faq-body">
|
||||
<view v-for="(p, pi) in item.a" :key="pi" class="faq-paragraph">
|
||||
<text
|
||||
v-for="(seg, si) in p"
|
||||
:key="si"
|
||||
:class="seg.highlight ? 'faq-highlight' : 'faq-text'"
|
||||
@tap="seg.copy ? copyText(seg.text) : null"
|
||||
>{{ seg.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 绑定类 -->
|
||||
<view class="category">
|
||||
<text class="category-title">绑定类</text>
|
||||
<view class="category-divider" />
|
||||
|
||||
<view
|
||||
v-for="(item, idx) in bindList"
|
||||
:key="'b' + idx"
|
||||
class="faq-card"
|
||||
>
|
||||
<view class="faq-header" @tap="toggle('b', idx)">
|
||||
<view class="faq-index">{{ idx + 1 }}</view>
|
||||
<text class="faq-question">{{ item.q }}</text>
|
||||
<view class="faq-arrow" :class="{ 'faq-arrow--up': isOpen('b', idx) }" />
|
||||
</view>
|
||||
<view v-show="isOpen('b', idx)" class="faq-body">
|
||||
<view v-for="(p, pi) in item.a" :key="pi" class="faq-paragraph">
|
||||
<text
|
||||
v-for="(seg, si) in p"
|
||||
:key="si"
|
||||
:class="seg.highlight ? 'faq-highlight' : 'faq-text'"
|
||||
@tap="seg.copy ? copyText(seg.text) : null"
|
||||
>{{ seg.text }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 底部安全区 -->
|
||||
<view class="safe-bottom" />
|
||||
@@ -90,27 +33,55 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { post } from '../../../api/request'
|
||||
import type { BannerItem } from '../../../api/types'
|
||||
|
||||
// 展开状态:默认全部展开
|
||||
const openMap = ref<Record<string, boolean>>({})
|
||||
|
||||
// 初始化所有条目为展开
|
||||
const purchaseList = getPurchaseList()
|
||||
const reserveList = getReserveList()
|
||||
const bindList = getBindList()
|
||||
|
||||
purchaseList.forEach((_, i) => { openMap.value[`p${i}`] = true })
|
||||
reserveList.forEach((_, i) => { openMap.value[`r${i}`] = true })
|
||||
bindList.forEach((_, i) => { openMap.value[`b${i}`] = true })
|
||||
|
||||
function toggle(cat: string, idx: number) {
|
||||
const key = `${cat}${idx}`
|
||||
openMap.value[key] = !openMap.value[key]
|
||||
interface FaqRecord {
|
||||
id: number
|
||||
title: string
|
||||
content: string
|
||||
category: string | null
|
||||
}
|
||||
|
||||
function isOpen(cat: string, idx: number) {
|
||||
return openMap.value[`${cat}${idx}`] ?? false
|
||||
const faqList = ref<FaqRecord[]>([])
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'FAQ' })
|
||||
faqList.value = (data || []).map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
content: n.content || '',
|
||||
category: n.category,
|
||||
}))
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
const categories = computed(() => {
|
||||
const map = new Map<string | null, FaqRecord[]>()
|
||||
faqList.value.forEach(item => {
|
||||
const key = item.category || '其他'
|
||||
if (!map.has(key)) map.set(key, [])
|
||||
map.get(key)!.push(item)
|
||||
})
|
||||
return Array.from(map.entries())
|
||||
})
|
||||
|
||||
/** 展开状态 */
|
||||
const openMap = ref<Record<string, boolean>>({})
|
||||
|
||||
function toggle(idx: number) {
|
||||
openMap.value[`${idx}`] = !openMap.value[`${idx}`]
|
||||
}
|
||||
|
||||
function isOpen(idx: number) {
|
||||
return openMap.value[`${idx}`] ?? true
|
||||
}
|
||||
|
||||
function copyText(text: string) {
|
||||
@@ -119,124 +90,6 @@ function copyText(text: string) {
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
type Seg = { text: string; highlight?: boolean; copy?: boolean }
|
||||
type FaqItem = { q: string; a: Seg[][] }
|
||||
|
||||
function getPurchaseList(): FaqItem[] {
|
||||
return [
|
||||
{
|
||||
q: '赣湘全域旅游年卡/超级玩家卡该如何进行购买?',
|
||||
a: [
|
||||
[{ text: '答:首先搜索"爱上萍乡"小程序,进入首页界面,点击购买即可。' }],
|
||||
[{ text: '温馨提醒:', highlight: false }, { text: '1. 赣湘全域旅游年卡/超级玩家卡一经激活或销售七日以上,不适用于退款规则;2. 部分商家限制仅对女性或特定年龄段开放,详情请查阅各商家资料规则展示页面。' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '对于赣湘全域旅游年卡/超级玩家卡的使用疑问怎么进行人工咨询?',
|
||||
a: [
|
||||
[
|
||||
{ text: '答:爱上萍乡公众号的客服电话是:' },
|
||||
{ text: '15388025272', highlight: true, copy: true },
|
||||
{ text: ',微信号为' },
|
||||
{ text: 'cshaika2024', highlight: true, copy: true },
|
||||
{ text: ',人工客服工作时间:9:00-17:00(周一至周五),周末及法定节假日不受理,请大家在工作日与我们联系哦~' }
|
||||
]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '赣湘全域旅游年卡/超级玩家卡该如何进行退款',
|
||||
a: [
|
||||
[{ text: '答:赣湘全域旅游年卡/超级玩家卡绑定激活或销售七日以上不可退卡,不适用7天无理由退货,在"爱上萍乡"小程序上购买但未激活的用户请咨询客服中心处理退卡事宜,在其他平台上购买年卡的用户请于对应平台客服进行沟通。' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '未成年人可以购买吗?',
|
||||
a: [
|
||||
[{ text: '答:可以的,赣湘全域旅游年卡/超级玩家卡覆盖的部分景区、商家目前已经对未满18周岁的少年儿童开放,未成年人也可使用本人身份证信息实名制激活使用,大小同价,由于系统对接或行业法规等限制,部分景区、商家未向少年儿童开放,建议购买前一定要详细查阅平台公告及商品详细信息哦~' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '小孩可以去哪些景区?在哪里可以查看?',
|
||||
a: [
|
||||
[{ text: '答:自2023年7月31日起,赣湘全域旅游年卡/超级玩家卡包含的景区陆续向18周岁以下儿童放开使用限制,目前已有部分景区允许儿童凭借年卡免费入园。儿童可使用权益的景区商户名单可在"爱上萍乡"-购买 - 商品详情页或各景区、商户规则展示页面进行了解。' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '港澳台/外国人可以购买使用赣湘全域旅游年卡/超级玩家卡吗?',
|
||||
a: [
|
||||
[{ text: '答:赣湘全域旅游年卡/超级玩家卡需要实名上传中国大陆的身份证信息,港澳台/外国人目前是不能激活使用年卡的。' }]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function getReserveList(): FaqItem[] {
|
||||
return [
|
||||
{
|
||||
q: '如何预约',
|
||||
a: [
|
||||
[{ text: '答:部分景区、商家至少需要提前一天进行线上预约或电话预约(最多可预约七天),详细规则请进入"爱上萍乡"公众号 - 点击使用指引 - 商家预约 - 预约即可,提交预约信息后将扣除赣湘全域旅游年卡/超级玩家卡使用次数。' }],
|
||||
[{ text: '需至少提前一天取消预约,详细规则请进入"爱上萍乡"公众号 - 点击使用指引 - 我的预约 - 进入预约详情界面 - 点击取消预约即可。' }],
|
||||
[{ text: '使用日当天不可取消预约,过期视为自动放弃使用权益。' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '如何出示赣湘全域旅游年卡/超级玩家卡二维码',
|
||||
a: [
|
||||
[{ text: '答:关注"爱上萍乡"公众号 - 点击使用指引 - 点击入园码即可出示年卡二维码;' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '如何找到景区和商家',
|
||||
a: [
|
||||
[{ text: '答:关注"爱上萍乡"公众号 - 点击年卡 - 年卡权益,下方的游吃住行娱乐购里的景区商家都可点击查看具体的赣湘全域旅游年卡/超级玩家卡使用规则。' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '预约了,如果不去会扣除次数吗?',
|
||||
a: [
|
||||
[{ text: '答:如预约之后因非客观因素无法前往且未提前取消预约则会扣除年卡使用次数,扣除使用次数后的权益不可恢复。' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '景区没有预约不能进去客服可以帮我预约吗?',
|
||||
a: [
|
||||
[{ text: '答:后台并没有权限给客户操作预约,请大家在"爱上萍乡"公众号自行预约哦。' }]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
function getBindList(): FaqItem[] {
|
||||
return [
|
||||
{
|
||||
q: '赣湘全域旅游年卡/超级玩家卡如何绑定?',
|
||||
a: [
|
||||
[{ text: '答:购买赣湘全域旅游年卡/超级玩家卡后,平台会通过短信发送一个8位数的核销码,请大家搜索"爱上萍乡"公众号,点击使用指引—激活年卡—核销码激活,最后上传个人身份信息即可。' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '赣湘全域旅游年卡/超级玩家卡可以解绑吗?',
|
||||
a: [
|
||||
[{ text: '答:注意啦!赣湘全域旅游年卡/超级玩家卡一旦绑定身份信息后,是无法进行解绑或换绑的哦。' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '一个账号能绑定多张卡吗?',
|
||||
a: [
|
||||
[{ text: '答:一个微信用户只能绑定4张赣湘全域旅游年卡/超级玩家卡,请不要多绑或者错绑哦~' }],
|
||||
[{ text: '成人绑定年卡建议分开绑定,以免造成出行不便哦(只需把8位数的核销码发给需要绑定的人,选择核销码激活上传自己的身份信息即可)' }]
|
||||
]
|
||||
},
|
||||
{
|
||||
q: '赣湘全域旅游年卡/超级玩家卡已激活绑定能否给别人使用?',
|
||||
a: [
|
||||
[{ text: '答:赣湘全域旅游年卡/超级玩家卡实行一卡一人,已激活绑定的年卡仅限本人使用,不可转借他人。' }]
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -359,6 +212,18 @@ function getBindList(): FaqItem[] {
|
||||
line-height: 1.7;
|
||||
}
|
||||
|
||||
/* ===== 加载状态 ===== */
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-tips-color;
|
||||
}
|
||||
|
||||
/* ===== 底部安全区 ===== */
|
||||
.safe-bottom {
|
||||
height: 64rpx;
|
||||
|
||||
@@ -33,24 +33,33 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import AnnouncementModal from '../../../components/announcement-modal.vue'
|
||||
import { post } from '../../../api/request'
|
||||
import type { BannerItem } from '../../../api/types'
|
||||
|
||||
interface NoticeItem {
|
||||
id: number
|
||||
title: string
|
||||
date: string
|
||||
content: string
|
||||
body: string
|
||||
}
|
||||
|
||||
const notices = ref<NoticeItem[]>([
|
||||
{ id: 1, title: '赣湘全域旅游年卡限时优惠', date: '2026-06-20', content: '赣湘全域旅游年卡限时优惠,工会会员专享¥300/套卡!', body: '赣湘全域旅游年卡限时优惠活动现已开启,工会会员凭有效证件可享专享价¥300/套卡(原价¥580)。套卡包含武功山金顶景区、杨岐山孽龙洞景区、仙凤三宝景区等42家合作景区全年免首道门票权益,覆盖萍乡、长沙、株洲、湘潭等地区。活动期间购买还可额外获赠君澜温泉单次体验券一张。活动截止至2026年7月31日,数量有限,售完即止。' },
|
||||
{ id: 2, title: '武功山金顶景区开放通知', date: '2026-06-18', content: '武功山金顶景区已开放,持卡可免费入园1次。', body: '武功山金顶景区即日起正常开放,营业时间08:00-18:00。赣湘全域旅游年卡持卡用户可免费入园1次(含首道门票,价值¥70)。温馨提示:山顶温差较大,建议携带防风保暖衣物;雷雨天气请勿登顶;需提前1天通过小程序预约,每日限流5000人。' },
|
||||
{ id: 3, title: '超级玩家卡全新上线', date: '2026-06-15', content: '超级玩家卡全新上线,40家景区免门票,仅¥198!', body: '超级玩家卡全新上线!仅需¥198即可享受40家合作景区免首道门票权益,单次最高可省¥228。覆盖景区包括武功山金顶(¥70)、君澜温泉(¥228)、杨岐山孽龙洞(¥68)、仙凤三宝(¥50)等热门景区。卡片有效期为购买之日起一年,法定节假日通用,不限购买次数。适合经常出游的家庭和户外爱好者。' },
|
||||
{ id: 4, title: '端午节假期景区预约提醒', date: '2026-06-10', content: '端午假期热门景区需提前预约,请合理安排出行。', body: '端午节假期(6月14日至16日)期间,武功山金顶景区、君澜温泉等热门景区将实行限流措施,每日限流5000人。请持卡用户提前通过小程序预约,未预约将无法入园。建议错峰出行,关注景区实时客流信息。' },
|
||||
{ id: 5, title: '新增合作景区:莲花荷博园', date: '2026-06-05', content: '莲花荷博园正式加入合作景区,持卡免门票6次。', body: '莲花荷博园于2026年6月起正式加入赣湘全域旅游年卡合作景区。持卡用户全年可免首道门票入园6次(门票原价¥35)。莲花荷博园位于萍乡市莲花县,夏季荷花盛开时景色最佳,是休闲观光的好去处。' },
|
||||
])
|
||||
const notices = ref<NoticeItem[]>([])
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'NOTICE' })
|
||||
notices.value = (data || []).map(n => ({
|
||||
id: n.id,
|
||||
title: n.title,
|
||||
date: n.createdTime ? n.createdTime.slice(0, 10) : '',
|
||||
content: n.content || '',
|
||||
}))
|
||||
} catch {
|
||||
// 静默失败
|
||||
}
|
||||
})
|
||||
|
||||
/** 弹窗状态 */
|
||||
const showModal = ref(false)
|
||||
@@ -59,7 +68,7 @@ const modalBody = ref('')
|
||||
|
||||
function openDetail(item: NoticeItem) {
|
||||
modalTitle.value = item.title
|
||||
modalBody.value = item.body
|
||||
modalBody.value = item.content
|
||||
showModal.value = true
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -54,9 +54,9 @@
|
||||
<view class="info-line" @click="onHoursClick">
|
||||
<view class="icon-wrap icon-clock" />
|
||||
<text class="text-body">{{ scenic.hours }}</text>
|
||||
<view class="status-tag" :class="isOpen ? 'open' : 'closed'">
|
||||
<view class="status-tag" :class="'st-' + scenic.status">
|
||||
<view class="status-dot" />
|
||||
<text>{{ isOpen ? '营业中' : '已闭园' }}</text>
|
||||
<text>{{ statusLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -77,27 +77,31 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 模块3:卡种权益卡片(独立白卡,无绑定自动隐藏) -->
|
||||
<!-- 模块3:卡种权益卡片 -->
|
||||
<view
|
||||
class="benefit-card"
|
||||
v-for="card in scenic.cards"
|
||||
:key="card.type"
|
||||
v-for="(card, idx) in scenic.cards"
|
||||
:key="idx"
|
||||
>
|
||||
<view class="benefit-top">
|
||||
<view class="card-type-tag" :class="card.type">
|
||||
<text>{{ card.type === 'super' ? '超级玩家卡' : '赣湘全域旅游年卡' }}</text>
|
||||
<view class="benefit-body">
|
||||
<view class="benefit-top">
|
||||
<view class="card-type-tag">
|
||||
<text>{{ card.cardName }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text v-if="scenic.price > 0" class="ticket-price">¥{{ scenic.price }}</text>
|
||||
</view>
|
||||
<view class="benefit-mid">
|
||||
<text class="ticket-label">首道门票</text>
|
||||
<view class="member-row">
|
||||
<view class="benefit-sku">
|
||||
<text class="sku-name">{{ card.skuName }}</text>
|
||||
</view>
|
||||
<view class="benefit-meta">
|
||||
<view class="icon-wrap icon-crown" />
|
||||
<text class="member-text">持卡用户免费</text>
|
||||
<text class="remaining-text">
|
||||
{{ card.remainingCount > 0 ? '全年剩余' + card.remainingCount + '次' : '全年可用' + card.freeCount + '次' }}
|
||||
</text>
|
||||
<text v-if="card.bookingDeadline" class="deadline-text">{{ card.bookingDeadline }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="remaining-text">当前卡剩余可使用 {{ card.remaining }} 次</text>
|
||||
<view class="book-btn" @click="onBookClick(card.type)">
|
||||
<view class="benefit-divider" />
|
||||
<view class="book-btn" @click="onBookClick(card)">
|
||||
<text>立即预约</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -144,69 +148,81 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { post, getToken } from '../../../api/request'
|
||||
|
||||
interface NoticeItem { title: string; content: string }
|
||||
interface CardBenefit { type: 'super' | 'annual'; remaining: number }
|
||||
interface CardBenefit { type: string; cardName: string; skuName: string; remainingCount: number; freeCount: number; bookingDeadline: string; cardModelId: string; resourceId: string; goodsId: string }
|
||||
interface ScenicDetail {
|
||||
id: number; name: string; price: number; freeCount: number; level: string
|
||||
hours: string; needReservation: boolean; bookingDeadline: string
|
||||
phone: string; address: string; tags: string[]; images: string[]
|
||||
introduction: string; bookingNotice: NoticeItem[]; cards: CardBenefit[]
|
||||
scenicId: number; shopId: number; mainPic: string; status: number
|
||||
}
|
||||
|
||||
const scenic = ref<ScenicDetail>({ tags: [], images: [], cards: [], bookingNotice: [] } as unknown as ScenicDetail)
|
||||
const activeTab = ref<'notice' | 'intro'>('notice')
|
||||
const currentSlide = ref(0)
|
||||
const loading = ref(true)
|
||||
|
||||
function onSwiperChange(e: any) {
|
||||
currentSlide.value = e.detail.current
|
||||
}
|
||||
|
||||
/** 根据营业时间判断运营状态 */
|
||||
const isOpen = computed(() => {
|
||||
const h = scenic.value.hours
|
||||
if (!h || h.includes('暂停') || h.includes('闭')) return false
|
||||
const match = h.match(/(\d{2}):(\d{2})\s*-\s*(\d{2}):(\d{2})/)
|
||||
if (!match) return true
|
||||
const now = new Date()
|
||||
const current = now.getHours() * 60 + now.getMinutes()
|
||||
const open = parseInt(match[1]) * 60 + parseInt(match[2])
|
||||
const close = parseInt(match[3]) * 60 + parseInt(match[4])
|
||||
return current >= open && current <= close
|
||||
})
|
||||
|
||||
// 双卡种数据:超级玩家卡 remaining 与 freeCount 一致,年卡 remaining 稍多
|
||||
function buildCards(freeCount: number): CardBenefit[] {
|
||||
if (freeCount <= 0) return []
|
||||
return [
|
||||
{ type: 'super', remaining: freeCount },
|
||||
{ type: 'annual', remaining: freeCount + 2 },
|
||||
]
|
||||
}
|
||||
|
||||
const scenicData: ScenicDetail[] = [
|
||||
{ id: 1, name: '武功山金顶景区', price: 70, freeCount: 1, level: '5A', hours: '08:00-18:00', needReservation: true, bookingDeadline: '游玩当日16:00前预约', phone: '0799-7636666', address: '江西省萍乡市芦溪县东南边境', tags: ['5A景区', '高山草甸', '日出云海', '徒步圣地'], images: ['https://images.unsplash.com/photo-1513415564515-763d91423bdd?fm=jpg&q=80&w=750&h=500&fit=crop', 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?fm=jpg&q=80&w=750&h=500&fit=crop', 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?fm=jpg&q=80&w=750&h=500&fit=crop'], introduction: '武功山位于江西省萍乡市东南边缘,主峰白鹤峰海拔1918.3米,为江西省第一高峰。武功山以壮观的高山草甸、日出云海和千年古祭坛群著称,被誉为"云中草原、户外天堂"。景区内有金顶、羊狮幕、发云界等核心景观,四季景色各异,春赏杜鹃、夏避酷暑、秋观红叶、冬看雾凇,是徒步露营和摄影爱好者的理想目的地。', bookingNotice: [{ title: '预约方式', content: '需提前通过小程序在线预约,预约成功后凭二维码入园。每日限流5000人,建议提前1-3天预约。' }, { title: '入园须知', content: '请携带身份证原件,配合工作人员核验健康码。山顶温差较大,建议携带防风保暖衣物。雷雨天气请勿登顶。' }, { title: '退改规则', content: '预约成功后如需取消,请至少提前24小时操作。逾期未使用视为自动放弃,不予退款。' }], cards: buildCards(1) },
|
||||
{ id: 2, name: '杨岐山孽龙洞景区', price: 68, freeCount: 6, level: '4A', hours: '08:30-17:30', needReservation: false, bookingDeadline: '', phone: '0799-3862888', address: '江西省萍乡市上栗县杨岐山', tags: ['4A景区', '溶洞奇观', '地质公园', '天然氧吧'], images: ['https://images.unsplash.com/photo-1506905925346-21bda4d32df4?fm=jpg&q=80&w=750&h=500&fit=crop', 'https://images.unsplash.com/photo-1519681393784-d120267933ba?fm=jpg&q=80&w=750&h=500&fit=crop'], introduction: '孽龙洞位于杨岐山风景名胜区内,是一个形成于1.8亿年前的天然溶洞。洞内全长4公里,已开发游览线路2.8公里,洞内常年恒温18℃,钟乳石形态各异,有"地下艺术长廊"之美誉。', bookingNotice: [{ title: '入园方式', content: '持惠民卡直接到景区售票处验证入园,无需预约。' }, { title: '游览提示', content: '洞内温度恒定18℃,建议携带薄外套。洞内道路湿滑,请穿防滑鞋,老人和儿童需有成人陪同。' }], cards: buildCards(6) },
|
||||
{ id: 3, name: '仙凤三宝景区', price: 50, freeCount: 6, level: '4A', hours: '09:00-17:00', needReservation: false, bookingDeadline: '', phone: '0799-7558888', address: '江西省萍乡市芦溪县宣风镇', tags: ['4A景区', '亲子乐园', '萌宠互动', '田园体验'], images: ['https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?fm=jpg&q=80&w=750&h=500&fit=crop'], introduction: '仙凤三宝景区是一个集生态农业、亲子游乐、休闲度假于一体的综合性旅游景区。园内有萌宠乐园、花海观赏、果蔬采摘等特色项目,适合全家出行。', bookingNotice: [{ title: '入园方式', content: '持惠民卡直接到景区售票处验证入园,无需预约。' }, { title: '游玩提示', content: '适合亲子游玩,园内有小动物喂养体验,请勿投喂自带食物。' }], cards: buildCards(6) },
|
||||
{ id: 4, name: '武功山君澜温泉', price: 228, freeCount: 6, level: '4A', hours: '10:00-22:00', needReservation: true, bookingDeadline: '游玩当日12:00前预约', phone: '0799-7639999', address: '江西省萍乡市芦溪县武功山景区内', tags: ['4A景区', '温泉养生', '度假休闲', '山间秘境'], images: ['https://images.unsplash.com/photo-1433086966358-54859d0ed716?fm=jpg&q=80&w=750&h=500&fit=crop', 'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?fm=jpg&q=80&w=750&h=500&fit=crop'], introduction: '武功山君澜温泉坐落于武功山脚下,依山傍水,环境优美。温泉水取自地下1500米深层矿泉,富含多种对人体有益的微量元素。', bookingNotice: [{ title: '预约方式', content: '需提前通过小程序预约,建议提前1天。请自备泳衣泳帽。' }, { title: '退改规则', content: '提前24小时可免费取消。' }], cards: buildCards(6) },
|
||||
{ id: 5, name: '凯光新天地生态旅游区', price: 90, freeCount: 6, level: '4A', hours: '08:00-18:00', needReservation: false, bookingDeadline: '', phone: '0799-6881888', address: '江西省萍乡市安源区高坑镇', tags: ['4A景区', '生态观光', '户外拓展', '花卉园林'], images: ['https://images.unsplash.com/photo-1441974231531-c6227db76b6e?fm=jpg&q=80&w=750&h=500&fit=crop'], introduction: '凯光新天地生态旅游区是集生态观光、休闲娱乐、户外运动于一体的大型生态旅游综合体。', bookingNotice: [{ title: '入园方式', content: '持惠民卡直接到景区售票处验证入园,无需预约。' }], cards: buildCards(6) },
|
||||
{ id: 14, name: '安源路矿工人运动纪念馆', price: 0, freeCount: 0, level: '4A', hours: '09:00-16:30', needReservation: false, bookingDeadline: '', phone: '0799-6832001', address: '江西省萍乡市安源区安源镇', tags: ['4A景区', '红色教育', '免费参观', '爱国主义基地'], images: ['https://images.unsplash.com/photo-1565008576549-57569a49371d?fm=jpg&q=80&w=750&h=500&fit=crop'], introduction: '安源路矿工人运动纪念馆是全国重点文物保护单位,全面展示了安源路矿工人大罢工的光辉历史。', bookingNotice: [{ title: '参观须知', content: '免费参观,周一闭馆。请携带身份证原件入馆。' }], cards: buildCards(0) },
|
||||
{ id: 30, name: '坊楼红色文化园', price: 0, freeCount: 0, level: '3A', hours: '09:00-17:00', needReservation: false, bookingDeadline: '', phone: '0799-7220000', address: '江西省萍乡市莲花县坊楼镇', tags: ['3A景区', '红色教育', '免费参观', '革命历史'], images: ['https://images.unsplash.com/photo-1565008576549-57569a49371d?fm=jpg&q=80&w=750&h=500&fit=crop'], introduction: '坊楼红色文化园展示了莲花县丰富的革命历史,是重要的红色教育基地。', bookingNotice: [{ title: '参观须知', content: '免费参观,请保持安静。' }], cards: buildCards(0) },
|
||||
{ id: 38, name: '荷尧古渡口', price: 0, freeCount: 0, level: '2A', hours: '全天开放', needReservation: false, bookingDeadline: '', phone: '0799-3377200', address: '江西省萍乡市湘东区荷尧镇', tags: ['2A景区', '古渡口遗址', '免费开放', '历史文化'], images: ['https://images.unsplash.com/photo-1504280390367-361c6d9f38f4?fm=jpg&q=80&w=750&h=500&fit=crop'], introduction: '荷尧古渡口是一处保存完好的古渡口遗址,见证了千年的交通商贸历史。', bookingNotice: [{ title: '游览提示', content: '免费开放,全天可参观。临水区域请注意安全。' }], cards: buildCards(0) },
|
||||
]
|
||||
/** 根据运营状态字段判断是否运营中 */
|
||||
const isOpen = computed(() => scenic.value.status === 1)
|
||||
const statusLabel = computed(() => ['', '正常运营', '审核中', '暂停运营', '已关店'][scenic.value.status] || '')
|
||||
|
||||
onLoad((options) => {
|
||||
const id = parseInt(options?.id || '0')
|
||||
const found = scenicData.find(s => s.id === id)
|
||||
if (found) {
|
||||
scenic.value = found
|
||||
uni.setNavigationBarTitle({ title: found.name })
|
||||
} else {
|
||||
scenic.value = scenicData[0]
|
||||
uni.setNavigationBarTitle({ title: scenicData[0].name })
|
||||
const shopId = options?.shopId || options?.id || ''
|
||||
if (!shopId) {
|
||||
uni.showToast({ title: '缺少景区参数', icon: 'none' })
|
||||
uni.navigateBack()
|
||||
return
|
||||
}
|
||||
uni.setNavigationBarTitle({ title: '景区详情' })
|
||||
fetchDetail(String(shopId))
|
||||
})
|
||||
|
||||
async function fetchDetail(shopId: string) {
|
||||
loading.value = true
|
||||
try {
|
||||
const params: Record<string, any> = { shopId, channel: 'pingxiang' }
|
||||
const phone = uni.getStorageSync('phone') as string
|
||||
if (phone) params.phone = phone
|
||||
|
||||
const res = await post<any>('/mp-api/mpScenic/getScenicDetail', params)
|
||||
if (res) {
|
||||
scenic.value = {
|
||||
id: res.id || res.scenicId || shopId,
|
||||
name: res.name || '',
|
||||
price: ((res.price || res.ticketPrice || 0) / 100),
|
||||
freeCount: res.freeCount ?? 0,
|
||||
level: res.level || '',
|
||||
hours: res.hours || res.businessHours || '',
|
||||
needReservation: res.needReservation ?? false,
|
||||
bookingDeadline: res.bookingDeadline || '',
|
||||
phone: res.phone || '',
|
||||
address: res.address || '',
|
||||
tags: res.tags || [],
|
||||
images: res.images || (res.mainPic ? [res.mainPic] : []),
|
||||
introduction: res.introduction || '',
|
||||
bookingNotice: res.bookingNotice || [],
|
||||
cards: res.cards || [],
|
||||
scenicId: res.scenicId || res.id || shopId,
|
||||
shopId: res.shopId || shopId,
|
||||
mainPic: res.mainPic || '',
|
||||
status: res.status ?? 1,
|
||||
}
|
||||
if (res.name) uni.setNavigationBarTitle({ title: res.name })
|
||||
}
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err?.msg || err?.message || '加载失败', icon: 'none' })
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function onHoursClick() {
|
||||
uni.showToast({ title: '营业时间仅供参考', icon: 'none' })
|
||||
}
|
||||
@@ -221,8 +237,42 @@ function onCopyAddress() {
|
||||
}
|
||||
})
|
||||
}
|
||||
function onBookClick(cardType: string) {
|
||||
uni.navigateTo({ url: '/packages/user/pages/reserve/index' })
|
||||
async function onBookClick(card: CardBenefit) {
|
||||
if (!getToken()) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
const res = await post<any>('/mpReserve/checkCardBind', {
|
||||
phone,
|
||||
cardModelId: card.cardModelId,
|
||||
resourceId: card.resourceId,
|
||||
})
|
||||
|
||||
if (res?.hasValidCard) {
|
||||
uni.navigateTo({
|
||||
url: `/packages/user/pages/reserve/index?cardNumber=${res.cardNumber}&resourceId=${res.resourceId}&cardModelId=${card.cardModelId}`,
|
||||
})
|
||||
} else {
|
||||
uni.showModal({
|
||||
title: '提示',
|
||||
content: '暂无可用年卡,请先购买',
|
||||
confirmText: '去购买',
|
||||
cancelText: '取消',
|
||||
success: (modalRes) => {
|
||||
if (modalRes.confirm) {
|
||||
uni.navigateTo({
|
||||
url: `/packages/user/pages/buy?goodsId=${card.goodsId}`,
|
||||
})
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err?.msg || err?.message || '检查失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -427,8 +477,10 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
&.open text { color: $uv-success; }
|
||||
&.closed text { color: $uv-error; }
|
||||
&.st-1 text { color: $uv-success; }
|
||||
&.st-2 text { color: $uv-warning; }
|
||||
&.st-3 text { color: $uv-error; }
|
||||
&.st-4 text { color: #999; }
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
@@ -436,8 +488,10 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
|
||||
.open & { background: $uv-success; }
|
||||
.closed & { background: $uv-error; }
|
||||
.st-1 & { background: $uv-success; }
|
||||
.st-2 & { background: $uv-warning; }
|
||||
.st-3 & { background: $uv-error; }
|
||||
.st-4 & { background: #999; }
|
||||
}
|
||||
|
||||
.map-btn {
|
||||
@@ -459,94 +513,99 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
|
||||
.benefit-card {
|
||||
background: $color-card;
|
||||
margin: $spacing-3 $spacing-3 $spacing-2;
|
||||
padding: $spacing-3;
|
||||
border-radius: $radius-lg;
|
||||
box-shadow: $shadow-sm;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.benefit-body {
|
||||
padding: $spacing-3;
|
||||
}
|
||||
|
||||
.benefit-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: $spacing-2;
|
||||
}
|
||||
|
||||
.card-type-tag {
|
||||
padding: 4rpx 16rpx;
|
||||
border-radius: $radius-sm;
|
||||
position: relative;
|
||||
padding-left: 16rpx;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
&::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 6rpx;
|
||||
height: 24rpx;
|
||||
background: $uv-primary;
|
||||
border-radius: $radius-full;
|
||||
}
|
||||
|
||||
text {
|
||||
font-size: 22rpx;
|
||||
color: #fff;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
&.super {
|
||||
background: #f9ae3d;
|
||||
}
|
||||
|
||||
&.annual {
|
||||
background: $uv-success;
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-primary;
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
}
|
||||
|
||||
.ticket-price {
|
||||
font-size: $font-size-base;
|
||||
color: $uv-placeholder-color;
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.benefit-mid {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12rpx;
|
||||
}
|
||||
|
||||
.ticket-label {
|
||||
font-size: $font-size-base;
|
||||
color: $uv-content-color;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.member-row {
|
||||
.benefit-sku {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8rpx;
|
||||
margin-bottom: $spacing-1;
|
||||
}
|
||||
|
||||
.member-text {
|
||||
font-size: $font-size-sm;
|
||||
color: #f9ae3d;
|
||||
.sku-name {
|
||||
font-size: $font-size-base;
|
||||
color: $uv-main-color;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
.benefit-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6rpx;
|
||||
}
|
||||
|
||||
.remaining-text {
|
||||
font-size: 22rpx;
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-success;
|
||||
display: block;
|
||||
margin-bottom: $spacing-2;
|
||||
}
|
||||
|
||||
.deadline-text {
|
||||
margin-left: auto;
|
||||
font-size: $font-size-xs;
|
||||
color: $uv-tips-color;
|
||||
}
|
||||
|
||||
.benefit-divider {
|
||||
height: $border-width;
|
||||
background: $uv-border-color;
|
||||
margin: 0 $spacing-3;
|
||||
}
|
||||
|
||||
.book-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 88rpx;
|
||||
background: linear-gradient(135deg, $uv-primary, $uv-primary-dark);
|
||||
padding: $spacing-3;
|
||||
margin: 0 $spacing-3;
|
||||
margin-bottom: $spacing-3;
|
||||
background: $uv-primary-light;
|
||||
border-radius: $radius-full;
|
||||
box-shadow: 0 4rpx 16rpx rgba($uv-primary, 0.3);
|
||||
transition: transform $transition-fast;
|
||||
|
||||
text {
|
||||
font-size: $font-size-base;
|
||||
color: #fff;
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
&:active {
|
||||
transform: scale(0.97);
|
||||
opacity: 0.9;
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,10 +13,10 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 页面主体(避开导航栏 + 吸顶筛选区) -->
|
||||
<view class="page-body" :style="{ paddingTop: (navTotalHeight + stickyHeaderHeight) + 'px' }">
|
||||
<!-- 吸顶搜索筛选区 -->
|
||||
<view class="sticky-header" :style="{ top: navTotalHeight + 'px' }">
|
||||
<!-- 页面主体(避开导航栏) -->
|
||||
<view class="page-body" :style="{ paddingTop: navTotalHeight + 'px' }">
|
||||
<!-- 吸顶搜索筛选区(跟随 scroll-view 滚动) -->
|
||||
<view class="sticky-header">
|
||||
<!-- 搜索框 -->
|
||||
<view class="search-box">
|
||||
<view class="search-icon">
|
||||
@@ -45,15 +45,25 @@
|
||||
<view class="dropdown-arrow" />
|
||||
</view>
|
||||
<view class="dropdown-trigger" @click="openPicker('cardType')">
|
||||
<text class="dropdown-label" :class="{ active: selectedCardType }">{{ selectedCardType ? (selectedCardType === '年卡' ? '赣湘全域旅游年卡' : '超级玩家卡') : '全部卡种' }}</text>
|
||||
<text class="dropdown-label" :class="{ active: selectedCardType }">{{ selectedCardTypeLabel }}</text>
|
||||
<view class="dropdown-arrow" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 景区列表 -->
|
||||
<view class="list-area">
|
||||
<view v-if="filteredScenics.length === 0" class="empty-state">
|
||||
<!-- 可滚动的列表区域 -->
|
||||
<scroll-view
|
||||
class="list-scroll"
|
||||
scroll-y
|
||||
:style="{ height: scrollViewHeight + 'px' }"
|
||||
@scrolltolower="loadMore"
|
||||
:lower-threshold="50"
|
||||
>
|
||||
<view v-if="loading" class="loading-state">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
|
||||
<view v-else-if="scenicList.length === 0" class="empty-state">
|
||||
<view class="empty-icon">
|
||||
<view class="empty-circle" />
|
||||
<view class="empty-line line-1" />
|
||||
@@ -64,7 +74,7 @@
|
||||
|
||||
<view v-else class="list-container">
|
||||
<view
|
||||
v-for="scenic in filteredScenics"
|
||||
v-for="scenic in scenicList"
|
||||
:key="scenic.id"
|
||||
class="scenic-card"
|
||||
@click="onCardClick(scenic)"
|
||||
@@ -87,11 +97,8 @@
|
||||
<view class="name-price-row">
|
||||
<text class="scenic-name">{{ scenic.name }}</text>
|
||||
<view class="price-group">
|
||||
<text v-if="scenic.price === 0" class="price-free">免费</text>
|
||||
<template v-else>
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-value">{{ scenic.price }}</text>
|
||||
</template>
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-value">{{ scenic.price }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -102,20 +109,20 @@
|
||||
<text class="time-text">{{ scenic.hours }}</text>
|
||||
</view>
|
||||
<view class="status-block">
|
||||
<view class="status-icon" :class="scenic.status === '正常营业' ? 'open' : 'closed'" />
|
||||
<text class="status-text" :class="scenic.status === '正常营业' ? 'open' : 'closed'">
|
||||
{{ scenic.status }}
|
||||
<view class="status-icon" :class="'st-' + scenic.status" />
|
||||
<text class="status-text" :class="'st-' + scenic.status">
|
||||
{{ ['', '正常运营', '审核中', '暂停运营', '已关店'][scenic.status] || '' }}
|
||||
</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 剩余次数 + 免票标签 + 预约标签 同行 -->
|
||||
<!-- 剩余次数 + 预约标签 同行 -->
|
||||
<view class="benefit-row">
|
||||
<view v-if="scenic.freeCount > 0" class="remaining-tag">
|
||||
<text>全年可用{{ scenic.freeCount }}次</text>
|
||||
<view v-if="scenic.remainingCount > 0" class="remaining-tag">
|
||||
<text>全年还剩{{ scenic.remainingCount }}次</text>
|
||||
</view>
|
||||
<view class="benefit-tag primary">
|
||||
<text>{{ scenic.freeCount > 0 ? `免${scenic.freeCount}次` : '免费' }}</text>
|
||||
<view v-else-if="scenic.freeCount > 0" class="remaining-tag">
|
||||
<text>全年可用{{ scenic.freeCount }}次</text>
|
||||
</view>
|
||||
<view class="benefit-tag" :class="scenic.needReservation ? 'warning' : 'success'">
|
||||
<text>{{ scenic.needReservation ? '需预约' : '免预约' }}</text>
|
||||
@@ -130,76 +137,99 @@
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 加载更多 -->
|
||||
<view v-if="loadMoreLoading" class="load-more">
|
||||
<text class="load-more-text">加载更多...</text>
|
||||
</view>
|
||||
<view v-else-if="!hasMore && scenicList.length > 0" class="load-more">
|
||||
<text class="load-more-text">— 没有更多了 —</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
|
||||
<!-- 筛选弹窗(单实例,动态内容) -->
|
||||
<uni-popup
|
||||
ref="filterPopup"
|
||||
type="bottom"
|
||||
:mask-click="true"
|
||||
:safe-area="true"
|
||||
background-color="#ffffff"
|
||||
@change="onPopupChange"
|
||||
>
|
||||
<view class="popup-sheet" @touchmove.stop.prevent>
|
||||
<!-- 头部:标题 + 重置 -->
|
||||
<view class="popup-header">
|
||||
<text class="popup-title">{{ popupTitle }}</text>
|
||||
<view class="popup-reset" @click="resetFilter">
|
||||
<text>重置</text>
|
||||
</view>
|
||||
<!-- 筛选弹窗(单实例,动态内容) -->
|
||||
<uni-popup
|
||||
ref="filterPopup"
|
||||
type="bottom"
|
||||
:mask-click="true"
|
||||
:safe-area="true"
|
||||
background-color="#ffffff"
|
||||
@change="onPopupChange"
|
||||
>
|
||||
<view class="popup-sheet" @touchmove.stop.prevent>
|
||||
<!-- 头部:标题 + 重置 -->
|
||||
<view class="popup-header">
|
||||
<view class="popup-back" v-if="activePickerType === 'region' && regionStep > 0" @click="goBackRegionStep">
|
||||
<text class="back-text">← 返回</text>
|
||||
</view>
|
||||
|
||||
<!-- 可滚动选项列表 -->
|
||||
<scroll-view scroll-y class="popup-scroll">
|
||||
<view
|
||||
v-for="item in currentOptions"
|
||||
:key="item.value"
|
||||
class="popup-option"
|
||||
:class="{ active: tempValue === item.value }"
|
||||
@click="onOptionTap(item.value)"
|
||||
>
|
||||
<text>{{ item.label }}</text>
|
||||
<view v-if="tempValue === item.value" class="check-mark" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部确定按钮 -->
|
||||
<view class="popup-footer" @click="confirmFilter">
|
||||
<text>确定筛选</text>
|
||||
<text class="popup-title">{{ popupTitle }}</text>
|
||||
<view class="popup-reset" @click="resetFilter">
|
||||
<text>重置</text>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</view>
|
||||
|
||||
<!-- 可滚动选项列表 -->
|
||||
<scroll-view scroll-y class="popup-scroll" :key="activePickerType + '-' + regionStep">
|
||||
<view
|
||||
v-for="item in currentOptions"
|
||||
:key="item.value"
|
||||
class="popup-option"
|
||||
:class="{ active: item.value === getTempValue() }"
|
||||
@click="onOptionTap(item.value)"
|
||||
>
|
||||
<text class="option-label">{{ item.label }}</text>
|
||||
<view v-if="activePickerType !== 'region' && tempValue === item.value" class="check-mark" />
|
||||
<view v-else-if="activePickerType === 'region' && item.value === getTempValue()" class="check-mark" />
|
||||
</view>
|
||||
</scroll-view>
|
||||
|
||||
<!-- 底部确定按钮 -->
|
||||
<view class="popup-footer" @click="confirmFilter">
|
||||
<text>确定筛选</text>
|
||||
</view>
|
||||
</view>
|
||||
</uni-popup>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted, getCurrentInstance } from 'vue'
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { post } from '../../../api/request'
|
||||
|
||||
interface ScenicItem {
|
||||
id: number
|
||||
shopId: number
|
||||
name: string
|
||||
price: number
|
||||
price: string
|
||||
freeCount: number
|
||||
remainingCount: number
|
||||
level: string
|
||||
status: '正常营业' | '暂停营业'
|
||||
status: number
|
||||
hours: string
|
||||
needReservation: boolean
|
||||
tips: string
|
||||
type: string
|
||||
region: string
|
||||
image: string
|
||||
cardTypes: string[]
|
||||
}
|
||||
|
||||
interface RegionNode {
|
||||
id: string
|
||||
name: string
|
||||
children: RegionNode[] | null
|
||||
}
|
||||
|
||||
// ========== 导航栏动态高度 ==========
|
||||
const statusBarHeight = ref(20)
|
||||
const navContentHeight = ref(44)
|
||||
const navTotalHeight = ref(64)
|
||||
const stickyHeaderHeight = ref(96) // 预估,onMounted 后精确测量
|
||||
const stickyHeaderHeight = ref(100)
|
||||
|
||||
const windowHeight = ref(0)
|
||||
const scrollViewHeight = computed(() => Math.max(0, windowHeight.value - navTotalHeight.value - stickyHeaderHeight.value))
|
||||
|
||||
// 页面加载时获取系统信息
|
||||
;(function initNavBar() {
|
||||
try {
|
||||
const windowInfo = uni.getWindowInfo()
|
||||
@@ -207,14 +237,11 @@ const stickyHeaderHeight = ref(96) // 预估,onMounted 后精确测量
|
||||
} catch {
|
||||
statusBarHeight.value = 20
|
||||
}
|
||||
|
||||
// #ifdef MP-WEIXIN
|
||||
try {
|
||||
const menuBtn = uni.getMenuButtonBoundingClientRect()
|
||||
const capsuleHeight = menuBtn.height
|
||||
const capsuleTop = menuBtn.top
|
||||
const capsuleBottom = capsuleTop + capsuleHeight
|
||||
// 导航栏内容区高度:胶囊上下各留等距间距
|
||||
const contentH = (capsuleBottom - statusBarHeight.value) + (capsuleTop - statusBarHeight.value)
|
||||
navContentHeight.value = Math.max(contentH, 44)
|
||||
navTotalHeight.value = statusBarHeight.value + navContentHeight.value
|
||||
@@ -222,186 +249,353 @@ const stickyHeaderHeight = ref(96) // 预估,onMounted 后精确测量
|
||||
navContentHeight.value = 44
|
||||
navTotalHeight.value = statusBarHeight.value + 44
|
||||
}
|
||||
// #endif
|
||||
|
||||
// #ifndef MP-WEIXIN
|
||||
navContentHeight.value = 44
|
||||
navTotalHeight.value = statusBarHeight.value + 44
|
||||
// #endif
|
||||
})()
|
||||
|
||||
// 吸顶筛选区高度:onMounted 后精确测量,预估值兜底
|
||||
stickyHeaderHeight.value = 100
|
||||
|
||||
onMounted(() => {
|
||||
const query = uni.createSelectorQuery().in(getCurrentInstance())
|
||||
query.select('.sticky-header').boundingClientRect((rect: any) => {
|
||||
if (rect && rect.height) {
|
||||
// 实际高度 + 4px 安全间距,防止不同机型小数精度导致遮挡
|
||||
stickyHeaderHeight.value = Math.ceil(rect.height) + 4
|
||||
}
|
||||
}).exec()
|
||||
})
|
||||
// ========== URL 参数 ==========
|
||||
const cardNumber = ref('')
|
||||
const cardModelId = ref('')
|
||||
|
||||
// ========== 分页 ==========
|
||||
const pageNo = ref(1)
|
||||
const pageSize = ref(10)
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const loadMoreLoading = ref(false)
|
||||
const hasMore = computed(() => scenicList.value.length < total.value)
|
||||
|
||||
// ========== 数据 ==========
|
||||
const scenicList: ScenicItem[] = [
|
||||
{ id: 1, name: '武功山金顶景区', price: 70, freeCount: 1, level: '5A', status: '正常营业', hours: '08:00-18:00', needReservation: true, tips: '建议提前1天预约,山顶温差大请备好外套', type: '自然风光', region: '芦溪县', image: 'https://images.unsplash.com/photo-1513415564515-763d91423bdd?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 2, name: '杨岐山孽龙洞景区', price: 68, freeCount: 6, level: '4A', status: '正常营业', hours: '08:30-17:30', needReservation: false, tips: '洞内温度恒定18℃,建议携带薄外套', type: '自然风光', region: '上栗县', image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 3, name: '仙凤三宝景区', price: 50, freeCount: 6, level: '4A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '适合亲子游玩,园内有小动物喂养体验', type: '乡村田园', region: '芦溪县', image: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 4, name: '武功山君澜温泉', price: 228, freeCount: 6, level: '4A', status: '正常营业', hours: '10:00-22:00', needReservation: true, tips: '需提前预约,请自备泳衣泳帽', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1433086966358-54859d0ed716?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 5, name: '凯光新天地生态旅游区', price: 90, freeCount: 6, level: '4A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '园区较大,建议穿舒适运动鞋', type: '休闲娱乐', region: '安源区', image: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 6, name: '和平农场·哎马乐园', price: 79, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:30', needReservation: false, tips: '亲子乐园,周末人流较多建议错峰', type: '乡村田园', region: '湘东区', image: 'https://images.unsplash.com/photo-1501854140801-50d01698950b?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 7, name: '武功山滑草场', price: 60, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '雨天暂停开放,请关注天气', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1551632811-561732d1e306?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 8, name: '芦溪仙凤三宝农业休闲观光园', price: 50, freeCount: 6, level: '3A', status: '正常营业', hours: '08:30-17:30', needReservation: false, tips: '可体验采摘,季节性水果品种丰富', type: '乡村田园', region: '芦溪县', image: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 9, name: '麻山幸福村', price: 40, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '乡村旅游景区,可体验农家生活', type: '乡村田园', region: '湘东区', image: 'https://images.unsplash.com/photo-1523712999610-f77fbcfc3843?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 10, name: '东桥双月湾', price: 38, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '临水景区,请注意安全', type: '自然风光', region: '湘东区', image: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 11, name: '上栗花炮文化博览园', price: 45, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '了解花炮历史文化,适合研学', type: '文化古迹', region: '上栗县', image: 'https://images.unsplash.com/photo-1533174072545-7a4b6ad7a6c3?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 12, name: '杨岐普通寺', price: 30, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-17:00', needReservation: false, tips: '佛教圣地,请保持安静', type: '文化古迹', region: '上栗县', image: 'https://images.unsplash.com/photo-1518241353330-0f7941c2d9b5?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 13, name: '孽龙洞漂流', price: 88, freeCount: 6, level: '3A', status: '暂停营业', hours: '暂停开放', needReservation: false, tips: '季节性项目,预计6月开放', type: '休闲娱乐', region: '上栗县', image: 'https://images.unsplash.com/photo-1504280390367-361c6d9f38f4?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 14, name: '安源路矿工人运动纪念馆', price: 0, freeCount: 0, level: '4A', status: '正常营业', hours: '09:00-16:30', needReservation: false, tips: '红色教育基地,周一闭馆', type: '红色教育', region: '安源区', image: 'https://images.unsplash.com/photo-1565008576549-57569a49371d?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 15, name: '莲花荷博园', price: 35, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '夏季荷花盛开时最佳观赏期', type: '自然风光', region: '莲花县', image: 'https://images.unsplash.com/photo-1490750967868-88aa4486c946?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 16, name: '山口岩水利风景区', price: 25, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '水利工程景观,可了解水利知识', type: '自然风光', region: '芦溪县', image: 'https://images.unsplash.com/photo-1439066615861-d1af74d74000?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 17, name: '宣风三宝水上乐园', price: 65, freeCount: 6, level: '3A', status: '暂停营业', hours: '暂停开放', needReservation: false, tips: '夏季开放,请关注开园公告', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 18, name: '万龙山温泉', price: 168, freeCount: 6, level: '4A', status: '正常营业', hours: '10:00-22:00', needReservation: true, tips: '需提前预约,温泉养生好去处', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 19, name: '杨岐山野生动物科普园', price: 42, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '适合亲子科普教育', type: '乡村田园', region: '上栗县', image: 'https://images.unsplash.com/photo-1518241353330-0f7941c2d9b5?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 20, name: '鸡冠山森林步道', price: 0, freeCount: 0, level: '3A', status: '正常营业', hours: '全天开放', needReservation: false, tips: '森林氧吧,适合徒步健身', type: '自然风光', region: '上栗县', image: 'https://images.unsplash.com/photo-1551632811-561732d1e306?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 21, name: '赤山杨岐山民宿集群配套景区', price: 28, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-20:00', needReservation: false, tips: '可体验民宿住宿,感受乡村风情', type: '乡村田园', region: '上栗县', image: 'https://images.unsplash.com/photo-1510798831971-661eb04b3739?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 22, name: '湘东傩文化园', price: 32, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '了解傩文化,感受非遗魅力', type: '文化古迹', region: '湘东区', image: 'https://images.unsplash.com/photo-1533174072545-7a4b6ad7a6c3?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 23, name: '广寒寨四季花海', price: 36, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '四季花卉轮番绽放,拍照打卡好去处', type: '自然风光', region: '湘东区', image: 'https://images.unsplash.com/photo-1490750967868-88aa4486c946?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 24, name: '上栗泉水湾度假村', price: 58, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-18:00', needReservation: false, tips: '休闲度假好去处,可垂钓烧烤', type: '休闲娱乐', region: '上栗县', image: 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 25, name: '莲花棋盘山', price: 22, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '登山步道完善,适合休闲健身', type: '自然风光', region: '莲花县', image: 'https://images.unsplash.com/photo-1444464666168-49d633b86797?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 26, name: '芦溪东阳古村', price: 26, freeCount: 6, level: '3A', status: '正常营业', hours: '08:30-17:30', needReservation: false, tips: '古建筑群保存完好,可感受传统村落文化', type: '文化古迹', region: '芦溪县', image: 'https://images.unsplash.com/photo-1528181304800-259b08848526?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 27, name: '安源欢乐谷', price: 72, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-18:00', needReservation: false, tips: '游乐设施丰富,适合亲子游玩', type: '休闲娱乐', region: '安源区', image: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 28, name: '湘东太阳广场游乐区', price: 48, freeCount: 6, level: '2A', status: '正常营业', hours: '09:00-21:00', needReservation: false, tips: '夜间灯光秀值得一看', type: '休闲娱乐', region: '湘东区', image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 29, name: '长平养生谷', price: 52, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '养生休闲好去处,空气清新', type: '自然风光', region: '上栗县', image: 'https://images.unsplash.com/photo-1518241353330-0f7941c2d9b5?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 30, name: '坊楼红色文化园', price: 0, freeCount: 0, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '红色教育基地,免费参观', type: '红色教育', region: '莲花县', image: 'https://images.unsplash.com/photo-1565008576549-57569a49371d?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 31, name: '高坑生态采摘园', price: 33, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '季节性水果采摘,体验田园乐趣', type: '乡村田园', region: '安源区', image: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 32, name: '源南田园综合体', price: 39, freeCount: 6, level: '3A', status: '正常营业', hours: '08:30-17:30', needReservation: false, tips: '田园风光优美,可体验农耕文化', type: '乡村田园', region: '芦溪县', image: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 33, name: '下埠陶瓷文化小镇', price: 29, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '可体验陶瓷制作,了解陶瓷文化', type: '文化古迹', region: '湘东区', image: 'https://images.unsplash.com/photo-1528181304800-259b08848526?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 34, name: '桐木水上乐园', price: 66, freeCount: 6, level: '3A', status: '暂停营业', hours: '暂停开放', needReservation: false, tips: '夏季开放,请关注开园时间', type: '休闲娱乐', region: '上栗县', image: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 35, name: '东源温泉度假村', price: 138, freeCount: 6, level: '4A', status: '正常营业', hours: '10:00-22:00', needReservation: true, tips: '需提前预约,温泉品质优良', type: '休闲娱乐', region: '上栗县', image: 'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 36, name: '长丰山水景区', price: 24, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '山水相依,适合休闲散步', type: '自然风光', region: '莲花县', image: 'https://images.unsplash.com/photo-1439066615861-d1af74d74000?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 37, name: '白竺青草湖', price: 27, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '湖光山色,可垂钓泛舟', type: '自然风光', region: '湘东区', image: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 38, name: '荷尧古渡口', price: 0, freeCount: 0, level: '2A', status: '正常营业', hours: '全天开放', needReservation: false, tips: '古渡口遗址,感受历史文化', type: '文化古迹', region: '湘东区', image: 'https://images.unsplash.com/photo-1504280390367-361c6d9f38f4?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 39, name: '新泉红色研学基地', price: 31, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: true, tips: '研学活动需提前预约', type: '红色教育', region: '芦溪县', image: 'https://images.unsplash.com/photo-1565008576549-57569a49371d?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 40, name: '张佳坊山地乐园', price: 55, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:30', needReservation: false, tips: '山地运动项目丰富,注意安全', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1551632811-561732d1e306?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 41, name: '南坑户外拓展基地', price: 44, freeCount: 6, level: '3A', status: '正常营业', hours: '08:30-18:00', needReservation: true, tips: '团队拓展需提前预约', type: '休闲娱乐', region: '莲花县', image: 'https://images.unsplash.com/photo-1501854140801-50d01698950b?fm=jpg&q=80&w=800&h=400&fit=crop' },
|
||||
{ id: 42, name: '老关荷花景区', price: 34, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '夏季荷花盛开,景色宜人', type: '自然风光', region: '湘东区', image: 'https://images.unsplash.com/photo-1490750967868-88aa4486c946?fm=jpg&q=80&w=800&h=400&fit=crop', cardTypes: ['年卡', '超级卡'] },
|
||||
]
|
||||
|
||||
// 卡种分配:部分景区仅支持年卡,部分仅支持超级卡,其余两者均可
|
||||
;(function assignCardTypes() {
|
||||
const annualOnly = new Set([4, 14, 18, 30, 35])
|
||||
const superOnly = new Set([1, 13, 17, 34, 38])
|
||||
scenicList.forEach(s => {
|
||||
if (annualOnly.has(s.id)) s.cardTypes = ['年卡']
|
||||
else if (superOnly.has(s.id)) s.cardTypes = ['超级卡']
|
||||
else if (!s.cardTypes) s.cardTypes = ['年卡', '超级卡']
|
||||
})
|
||||
})()
|
||||
const scenicList = ref<ScenicItem[]>([])
|
||||
|
||||
// ========== 搜索筛选 ==========
|
||||
const searchText = ref('')
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null
|
||||
watch(searchText, () => {
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
searchTimer = setTimeout(() => fetchScenicList(), 400)
|
||||
})
|
||||
const selectedRegion = ref('')
|
||||
const selectedLevel = ref('')
|
||||
const selectedCardType = ref('')
|
||||
|
||||
// uni-popup 单实例引用
|
||||
// 地区三级联动
|
||||
const regionTree = ref<RegionNode[]>([])
|
||||
const regionStep = ref(0)
|
||||
const selectedProvince = ref<RegionNode | null>(null)
|
||||
const selectedCity = ref<RegionNode | null>(null)
|
||||
|
||||
// 地区 ID(传给 API)
|
||||
const selectedProvinceId = ref('')
|
||||
const selectedCityId = ref('')
|
||||
const selectedDistrictId = ref('')
|
||||
|
||||
// uni-popup
|
||||
const filterPopup = ref()
|
||||
|
||||
// 当前弹窗类型 & 临时选中值
|
||||
const activePickerType = ref<'region' | 'level' | 'cardType'>('region')
|
||||
const tempValue = ref('')
|
||||
|
||||
const regionOptions = [
|
||||
// ========== 数据获取 ==========
|
||||
async function fetchScenicList(resetPage = true) {
|
||||
if (resetPage) {
|
||||
pageNo.value = 1
|
||||
loading.value = true
|
||||
} else {
|
||||
loadMoreLoading.value = true
|
||||
}
|
||||
try {
|
||||
const params: Record<string, any> = {
|
||||
channel: 'pingxiang',
|
||||
pageNo: pageNo.value,
|
||||
pageSize: pageSize.value,
|
||||
cardNumber: cardNumber.value || '',
|
||||
keyword: searchText.value || '',
|
||||
provinceId: selectedProvinceId.value || '',
|
||||
cityId: selectedCityId.value || '',
|
||||
districtId: selectedDistrictId.value || '',
|
||||
level: selectedLevel.value || '',
|
||||
cardModelId: selectedCardType.value || '',
|
||||
phone: uni.getStorageSync('phone') || '',
|
||||
}
|
||||
|
||||
const res = await post<any>('/mp-api/mpScenic/queryScenicList', params)
|
||||
const list = res?.list || res?.rows || res?.records || res || []
|
||||
const totalCount = res?.total || res?.totalCount || 0
|
||||
total.value = totalCount
|
||||
|
||||
const mapped = (Array.isArray(list) ? list : []).map((s: any) => ({
|
||||
id: s.id || s.scenicId,
|
||||
shopId: s.shopId || s.id || s.scenicId,
|
||||
name: s.name || s.scenicName,
|
||||
price: ((s.price || s.ticketPrice || 0) / 100).toFixed(0),
|
||||
freeCount: s.freeCount ?? 6,
|
||||
remainingCount: s.remainingCount ?? 0,
|
||||
level: s.level || '',
|
||||
status: s.status ?? 1,
|
||||
hours: s.hours || s.businessHours || '',
|
||||
needReservation: s.needReservation ?? false,
|
||||
tips: s.tips || '',
|
||||
type: s.type || s.category || '',
|
||||
region: s.region?.name || s.region || '',
|
||||
image: s.image || s.pic || s.coverImage || '',
|
||||
}))
|
||||
|
||||
if (resetPage) {
|
||||
scenicList.value = mapped
|
||||
} else {
|
||||
scenicList.value.push(...mapped)
|
||||
}
|
||||
} catch {
|
||||
if (resetPage) scenicList.value = []
|
||||
} finally {
|
||||
loading.value = false
|
||||
loadMoreLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function loadMore() {
|
||||
if (loadMoreLoading.value || !hasMore.value) return
|
||||
pageNo.value++
|
||||
fetchScenicList(false)
|
||||
}
|
||||
|
||||
onLoad((query: any) => {
|
||||
cardNumber.value = query.cardNumber || ''
|
||||
cardModelId.value = query.cardModelId || ''
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const filterRes = await post<any>('/mp-api/mpScenic/queryFilterOptions', { channel: 'pingxiang' }).catch(() => null)
|
||||
|
||||
if (filterRes) {
|
||||
const levels = filterRes.levels || filterRes.levelList || []
|
||||
const cardTypes = filterRes.cardTypes || filterRes.cardTypeList || []
|
||||
if (levels.length) levelOptions.value = buildOptions(levels, '全部等级')
|
||||
if (cardTypes.length) cardTypeOptions.value = buildOptions(cardTypes, '全部卡种')
|
||||
if (filterRes.regions || filterRes.regionList) regionTree.value = filterRes.regions || filterRes.regionList || []
|
||||
}
|
||||
|
||||
if (cardModelId.value) {
|
||||
selectedCardType.value = cardModelId.value
|
||||
}
|
||||
|
||||
await fetchScenicList()
|
||||
|
||||
// 计算 scroll-view 高度
|
||||
try {
|
||||
const winInfo = uni.getWindowInfo()
|
||||
windowHeight.value = winInfo.windowHeight || winInfo.screenHeight || 667
|
||||
} catch {
|
||||
windowHeight.value = 667
|
||||
}
|
||||
})
|
||||
|
||||
/** 将接口返回的标签列表转为选项数组 */
|
||||
function buildOptions(items: any[], allLabel: string): Array<{ label: string; value: string }> {
|
||||
const result: Array<{ label: string; value: string }> = [{ label: allLabel, value: '' }]
|
||||
items.forEach((v: any) => {
|
||||
if (typeof v === 'string') {
|
||||
result.push({ label: v, value: v })
|
||||
} else if (v && typeof v === 'object') {
|
||||
const val = String(v.value ?? v.id ?? '')
|
||||
result.push({
|
||||
label: v.label || v.name || val,
|
||||
value: val || v.label || v.name || val,
|
||||
})
|
||||
}
|
||||
})
|
||||
return result
|
||||
}
|
||||
|
||||
const regionOptions = ref<Array<{ label: string; value: string }>>([
|
||||
{ label: '全部地区', value: '' },
|
||||
{ label: '安源区', value: '安源区' },
|
||||
{ label: '湘东区', value: '湘东区' },
|
||||
{ label: '芦溪县', value: '芦溪县' },
|
||||
{ label: '上栗县', value: '上栗县' },
|
||||
{ label: '莲花县', value: '莲花县' },
|
||||
]
|
||||
])
|
||||
|
||||
const levelOptions = [
|
||||
const levelOptions = ref<Array<{ label: string; value: string }>>([
|
||||
{ label: '全部等级', value: '' },
|
||||
{ label: '5A', value: '5A' },
|
||||
{ label: '4A', value: '4A' },
|
||||
{ label: '3A', value: '3A' },
|
||||
{ label: '2A', value: '2A' },
|
||||
]
|
||||
])
|
||||
|
||||
const cardTypeOptions = [
|
||||
const cardTypeOptions = ref<Array<{ label: string; value: string }>>([
|
||||
{ label: '全部卡种', value: '' },
|
||||
{ label: '赣湘全域旅游年卡', value: '年卡' },
|
||||
{ label: '超级玩家卡', value: '超级卡' },
|
||||
]
|
||||
{ label: '赣湘全域旅游年卡', value: '820093340659013' },
|
||||
{ label: '超级玩家卡', value: '820093438328133' },
|
||||
])
|
||||
|
||||
/** 当前级联地区的选项列表 */
|
||||
const currentRegionOptions = computed(() => {
|
||||
if (regionStep.value === 0) return regionTree.value.map(n => ({ label: n.name, value: n.id }))
|
||||
if (regionStep.value === 1) return (selectedProvince.value?.children || []).map(n => ({ label: n.name, value: n.id }))
|
||||
if (regionStep.value === 2) return (selectedCity.value?.children || []).map(n => ({ label: n.name, value: n.id }))
|
||||
return []
|
||||
})
|
||||
|
||||
// 动态标题 & 选项列表
|
||||
const popupTitle = computed(() => {
|
||||
const map = { region: '选择地区', level: '选择等级', cardType: '选择卡种' }
|
||||
if (activePickerType.value === 'region') {
|
||||
const titles = ['选择省份', '选择城市', '选择区县']
|
||||
return titles[regionStep.value] || '选择地区'
|
||||
}
|
||||
const map: Record<string, string> = { region: '选择地区', level: '选择等级', cardType: '选择卡种' }
|
||||
return map[activePickerType.value]
|
||||
})
|
||||
|
||||
const currentOptions = computed(() => {
|
||||
const map = { region: regionOptions, level: levelOptions, cardType: cardTypeOptions }
|
||||
if (activePickerType.value === 'region') return currentRegionOptions.value
|
||||
const map: Record<string, any> = { region: regionOptions.value, level: levelOptions.value, cardType: cardTypeOptions.value }
|
||||
return map[activePickerType.value]
|
||||
})
|
||||
|
||||
const selectedCardTypeLabel = computed(() => {
|
||||
const opt = cardTypeOptions.value.find(o => o.value === selectedCardType.value)
|
||||
return opt?.label || '全部卡种'
|
||||
})
|
||||
|
||||
function getCardTypeLabel(value: string): string {
|
||||
const opt = cardTypeOptions.value.find(o => o.value === value)
|
||||
return opt?.label || value
|
||||
}
|
||||
|
||||
function getTempValue() {
|
||||
return tempValue.value
|
||||
}
|
||||
|
||||
function openPicker(type: 'region' | 'level' | 'cardType') {
|
||||
activePickerType.value = type
|
||||
const valMap = { region: selectedRegion.value, level: selectedLevel.value, cardType: selectedCardType.value }
|
||||
tempValue.value = valMap[type]
|
||||
if (type === 'region') {
|
||||
regionStep.value = 0
|
||||
selectedProvince.value = null
|
||||
selectedCity.value = null
|
||||
tempValue.value = ''
|
||||
} else {
|
||||
const valMap: Record<string, string> = { region: selectedRegion.value, level: selectedLevel.value, cardType: selectedCardType.value }
|
||||
tempValue.value = valMap[type]
|
||||
}
|
||||
filterPopup.value?.open()
|
||||
}
|
||||
|
||||
function onOptionTap(value: string) {
|
||||
if (activePickerType.value === 'region') {
|
||||
if (regionStep.value === 0) {
|
||||
if (tempValue.value === value) {
|
||||
selectedProvince.value = regionTree.value.find(n => n.id === value) || null
|
||||
if (selectedProvince.value?.children?.length) {
|
||||
regionStep.value = 1
|
||||
tempValue.value = ''
|
||||
} else {
|
||||
tempValue.value = value
|
||||
}
|
||||
} else {
|
||||
tempValue.value = value
|
||||
}
|
||||
} else if (regionStep.value === 1) {
|
||||
if (tempValue.value === value) {
|
||||
selectedCity.value = selectedProvince.value?.children?.find(n => n.id === value) || null
|
||||
if (selectedCity.value?.children?.length) {
|
||||
regionStep.value = 2
|
||||
tempValue.value = ''
|
||||
} else {
|
||||
tempValue.value = value
|
||||
}
|
||||
} else {
|
||||
tempValue.value = value
|
||||
}
|
||||
} else if (regionStep.value === 2) {
|
||||
tempValue.value = value
|
||||
}
|
||||
return
|
||||
}
|
||||
tempValue.value = value
|
||||
}
|
||||
|
||||
function goBackRegionStep() {
|
||||
if (regionStep.value === 1) {
|
||||
regionStep.value = 0
|
||||
tempValue.value = selectedProvince.value?.id || ''
|
||||
} else if (regionStep.value === 2) {
|
||||
regionStep.value = 1
|
||||
tempValue.value = selectedCity.value?.id || ''
|
||||
}
|
||||
}
|
||||
|
||||
function findRegionNameById(id: string): string {
|
||||
if (!id) return ''
|
||||
for (const p of regionTree.value) {
|
||||
if (p.id === id) return p.name
|
||||
for (const c of p.children || []) {
|
||||
if (c.id === id) return c.name
|
||||
for (const d of c.children || []) {
|
||||
if (d.id === id) return d.name
|
||||
}
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function confirmFilter() {
|
||||
const map = { region: selectedRegion, level: selectedLevel, cardType: selectedCardType }
|
||||
if (activePickerType.value === 'region') {
|
||||
selectedRegion.value = findRegionNameById(tempValue.value)
|
||||
if (regionStep.value === 0) {
|
||||
selectedProvinceId.value = tempValue.value
|
||||
selectedCityId.value = ''
|
||||
selectedDistrictId.value = ''
|
||||
} else if (regionStep.value === 1) {
|
||||
selectedProvinceId.value = selectedProvince.value?.id || ''
|
||||
selectedCityId.value = tempValue.value
|
||||
selectedDistrictId.value = ''
|
||||
} else if (regionStep.value === 2) {
|
||||
selectedProvinceId.value = selectedProvince.value?.id || ''
|
||||
selectedCityId.value = selectedCity.value?.id || ''
|
||||
selectedDistrictId.value = tempValue.value
|
||||
}
|
||||
filterPopup.value?.close()
|
||||
fetchScenicList()
|
||||
return
|
||||
}
|
||||
const map: Record<string, any> = { region: selectedRegion, level: selectedLevel, cardType: selectedCardType }
|
||||
map[activePickerType.value].value = tempValue.value
|
||||
filterPopup.value?.close()
|
||||
fetchScenicList()
|
||||
}
|
||||
|
||||
function resetFilter() {
|
||||
if (activePickerType.value === 'region') {
|
||||
regionStep.value = 0
|
||||
selectedProvince.value = null
|
||||
selectedCity.value = null
|
||||
selectedRegion.value = ''
|
||||
selectedProvinceId.value = ''
|
||||
selectedCityId.value = ''
|
||||
selectedDistrictId.value = ''
|
||||
tempValue.value = ''
|
||||
return
|
||||
}
|
||||
tempValue.value = ''
|
||||
}
|
||||
|
||||
function onPopupChange(e: { show: boolean }) {
|
||||
// 弹窗关闭时无需额外处理
|
||||
}
|
||||
|
||||
function clearSearch() {
|
||||
searchText.value = ''
|
||||
if (searchTimer) clearTimeout(searchTimer)
|
||||
fetchScenicList()
|
||||
}
|
||||
|
||||
// 过滤后的景区列表
|
||||
const filteredScenics = computed(() => {
|
||||
return scenicList.filter(scenic => {
|
||||
if (searchText.value && !scenic.name.toLowerCase().includes(searchText.value.toLowerCase())) {
|
||||
return false
|
||||
}
|
||||
if (selectedRegion.value && scenic.region !== selectedRegion.value) {
|
||||
return false
|
||||
}
|
||||
if (selectedLevel.value && scenic.level !== selectedLevel.value) {
|
||||
return false
|
||||
}
|
||||
if (selectedCardType.value && !scenic.cardTypes.includes(selectedCardType.value)) {
|
||||
return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
function goBack() {
|
||||
uni.navigateBack()
|
||||
}
|
||||
|
||||
function onCardClick(scenic: ScenicItem) {
|
||||
uni.navigateTo({
|
||||
url: `/packages/content/pages/scenic-detail?id=${scenic.id}`,
|
||||
url: `/packages/content/pages/scenic-detail?shopId=${scenic.shopId}`,
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -470,17 +664,15 @@ function onCardClick(scenic: ScenicItem) {
|
||||
|
||||
/* ====== 页面主体 ====== */
|
||||
.page-body {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100vh;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ====== 吸顶搜索筛选区 ====== */
|
||||
/* ====== 吸顶搜索筛选区(正常流,不固定) ====== */
|
||||
.sticky-header {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 50;
|
||||
flex-shrink: 0;
|
||||
background: $color-background;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
box-shadow: $shadow-sm;
|
||||
@@ -556,12 +748,14 @@ function onCardClick(scenic: ScenicItem) {
|
||||
|
||||
.dropdown-trigger {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
background: $color-card;
|
||||
border-radius: $radius-base;
|
||||
padding: $spacing-2 $spacing-3;
|
||||
overflow: hidden;
|
||||
transition: background $transition-fast;
|
||||
|
||||
&:active {
|
||||
@@ -570,6 +764,8 @@ function onCardClick(scenic: ScenicItem) {
|
||||
}
|
||||
|
||||
.dropdown-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: $font-size-sm;
|
||||
color: $color-foreground;
|
||||
white-space: nowrap;
|
||||
@@ -594,15 +790,35 @@ function onCardClick(scenic: ScenicItem) {
|
||||
|
||||
/* ====== 列表区域 ====== */
|
||||
|
||||
/* 空状态 */
|
||||
/* 加载 / 空状态 */
|
||||
.loading-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 120rpx $spacing-3;
|
||||
}
|
||||
|
||||
.loading-text,
|
||||
.empty-text {
|
||||
font-size: $font-size-base;
|
||||
color: $uv-tips-color;
|
||||
}
|
||||
|
||||
/* 加载更多 */
|
||||
.load-more {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: $spacing-4;
|
||||
}
|
||||
|
||||
.load-more-text {
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-tips-color;
|
||||
}
|
||||
|
||||
/* 空状态图标 */
|
||||
.empty-icon {
|
||||
position: relative;
|
||||
width: 160rpx;
|
||||
@@ -645,9 +861,10 @@ function onCardClick(scenic: ScenicItem) {
|
||||
color: $uv-tips-color;
|
||||
}
|
||||
|
||||
/* 列表区域 */
|
||||
.list-area {
|
||||
/* 可滚动列表 */
|
||||
.list-scroll {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* 列表容器 */
|
||||
@@ -775,12 +992,6 @@ function onCardClick(scenic: ScenicItem) {
|
||||
font-weight: $font-weight-bold;
|
||||
}
|
||||
|
||||
.price-free {
|
||||
font-size: $font-size-base;
|
||||
color: $uv-success;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
/* 营业时间 + 营业状态 同行 */
|
||||
.hours-status-row {
|
||||
display: flex;
|
||||
@@ -841,15 +1052,19 @@ function onCardClick(scenic: ScenicItem) {
|
||||
border-radius: 50%;
|
||||
margin-right: 8rpx;
|
||||
|
||||
&.open { background: $uv-success; }
|
||||
&.closed { background: $uv-error; }
|
||||
&.st-1 { background: $uv-success; }
|
||||
&.st-2 { background: $uv-warning; }
|
||||
&.st-3 { background: $uv-error; }
|
||||
&.st-4 { background: #999; }
|
||||
}
|
||||
|
||||
.status-text {
|
||||
font-size: $font-size-sm;
|
||||
|
||||
&.open { color: $uv-success; }
|
||||
&.closed { color: $uv-error; }
|
||||
&.st-1 { color: $uv-success; }
|
||||
&.st-2 { color: $uv-warning; }
|
||||
&.st-3 { color: $uv-error; }
|
||||
&.st-4 { color: #999; }
|
||||
}
|
||||
|
||||
/* 权益标签行:剩余次数 + 免票 + 预约 同行 */
|
||||
@@ -885,6 +1100,7 @@ function onCardClick(scenic: ScenicItem) {
|
||||
padding: 4rpx $spacing-2;
|
||||
border-radius: $radius-sm;
|
||||
min-height: 36rpx;
|
||||
margin-left: auto;
|
||||
|
||||
text {
|
||||
font-size: $font-size-xs;
|
||||
@@ -893,15 +1109,36 @@ function onCardClick(scenic: ScenicItem) {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
&.primary {
|
||||
background: $uv-primary;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
&.warning { background: $uv-warning; }
|
||||
&.success { background: $uv-success; }
|
||||
}
|
||||
|
||||
/* 卡种标签行 */
|
||||
.card-types-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-1;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: $spacing-2;
|
||||
}
|
||||
|
||||
.card-type-tag {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4rpx $spacing-2;
|
||||
background: rgba($uv-primary, 0.1);
|
||||
border: 1rpx solid rgba($uv-primary, 0.3);
|
||||
border-radius: $radius-sm;
|
||||
min-height: 36rpx;
|
||||
|
||||
text {
|
||||
font-size: $font-size-xs;
|
||||
color: $uv-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
}
|
||||
|
||||
/* 温馨提示 */
|
||||
.tips-bar {
|
||||
display: flex;
|
||||
@@ -976,7 +1213,7 @@ function onCardClick(scenic: ScenicItem) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: $spacing-3;
|
||||
padding: $spacing-3 $spacing-3;
|
||||
min-height: 96rpx;
|
||||
border-bottom: $border-width solid rgba($uv-border-color, 0.25);
|
||||
transition: background $transition-fast;
|
||||
@@ -989,12 +1226,17 @@ function onCardClick(scenic: ScenicItem) {
|
||||
background: $color-muted;
|
||||
}
|
||||
|
||||
text {
|
||||
.option-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: $font-size-base;
|
||||
color: $color-foreground;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
&.active text {
|
||||
&.active .option-label {
|
||||
color: $uv-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
@@ -1014,7 +1256,9 @@ function onCardClick(scenic: ScenicItem) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 96rpx;
|
||||
height: 80rpx;
|
||||
margin: $spacing-2;
|
||||
border-radius: $radius-base;
|
||||
background: $uv-primary;
|
||||
transition: opacity $transition-fast;
|
||||
|
||||
|
||||
@@ -1,19 +1,51 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<image
|
||||
class="guide-image"
|
||||
src="https://hn12301.oss-cn-shenzhen.aliyuncs.com/hn12301/%E5%9B%BE%E7%89%87%E6%96%87%E5%AD%97%E6%9B%BF%E6%8D%A2.png"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-if="loading" class="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<image v-if="imageUrl" class="guide-image" :src="imageUrl" mode="widthFix" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { post } from '../../../api/request'
|
||||
import type { BannerItem } from '../../../api/types'
|
||||
|
||||
const imageUrl = ref('')
|
||||
const loading = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'GUIDE' })
|
||||
if (data && data.length > 0) {
|
||||
imageUrl.value = data[0].imageUrl || ''
|
||||
}
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.page {
|
||||
min-height: 100vh;
|
||||
background: $color-card;
|
||||
}
|
||||
|
||||
.loading {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-tips-color;
|
||||
}
|
||||
|
||||
.guide-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { get, post } from '../../../api/request'
|
||||
import { BASE_URL } from '../../../config'
|
||||
import type { CardInfo, CardDetail, MyCard, ActivateParams, QrCodeInfo } from '../../../api/types'
|
||||
|
||||
/** 获取年卡列表 */
|
||||
@@ -17,8 +18,21 @@ export function activateCard(cardId: string, data: ActivateParams): Promise<bool
|
||||
}
|
||||
|
||||
/** 绑定年卡 */
|
||||
export function bindCard(cardNo: string): Promise<boolean> {
|
||||
return post<boolean>('/card/bind', { cardNo })
|
||||
export function bindCard(data: {
|
||||
cardNum: string
|
||||
userName: string
|
||||
mobile: string
|
||||
idType: string
|
||||
idNo: string
|
||||
img: string
|
||||
smsCode: string
|
||||
thirdId: string
|
||||
bindType: string
|
||||
bindSource: string
|
||||
channel: string
|
||||
activeCode: string
|
||||
}): Promise<boolean> {
|
||||
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/bind`, data, false)
|
||||
}
|
||||
|
||||
/** 获取我的年卡列表 */
|
||||
@@ -30,3 +44,52 @@ export function getMyCards(): Promise<MyCard[]> {
|
||||
export function getEntryQrCode(cardId: string): Promise<QrCodeInfo> {
|
||||
return get<QrCodeInfo>('/card/qrcode', { cardId })
|
||||
}
|
||||
|
||||
/** 人脸绑定(上传照片) */
|
||||
export function bindFaceImg(data: {
|
||||
cardNum: string
|
||||
userName: string
|
||||
mobile: string
|
||||
idType: string
|
||||
idNo: string
|
||||
img: string
|
||||
smsCode: string
|
||||
thirdId: string
|
||||
bindType: string
|
||||
bindSource: string
|
||||
channel: string
|
||||
activeCode: string
|
||||
}): Promise<boolean> {
|
||||
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/bindForAddFaceImg`, data, false)
|
||||
}
|
||||
|
||||
/** 查询是否需要人脸绑定 */
|
||||
export function checkFaceFlag(cardNo: string): Promise<boolean> {
|
||||
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/faceFlag?cardNo=${cardNo}`, {}, false)
|
||||
}
|
||||
|
||||
/** 上传图片到 OSS */
|
||||
export function uploadImage(filePath: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
uni.uploadFile({
|
||||
url: `${BASE_URL}/open-api/oss/upload`,
|
||||
filePath,
|
||||
name: 'file',
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
try {
|
||||
const data = JSON.parse(res.data as string)
|
||||
if (data.errcode === 0 && data.data?.fileUrl) {
|
||||
resolve(data.data.fileUrl)
|
||||
} else {
|
||||
reject(new Error(data.errmsg || '上传失败'))
|
||||
}
|
||||
} catch { reject(new Error('解析响应失败')) }
|
||||
} else {
|
||||
reject(new Error('上传失败,状态码:' + res.statusCode))
|
||||
}
|
||||
},
|
||||
fail: reject,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3,10 +3,10 @@
|
||||
<!-- 1. 头部标题区 -->
|
||||
<view class="header">
|
||||
<view class="header-title">
|
||||
<text class="header-title__bind">绑定</text>
|
||||
<text class="header-title__card">赣湘全域旅游年卡/超级玩家卡</text>
|
||||
<text class="header-title__bind">{{ faceBindMode ? '人脸' : '绑定' }}</text>
|
||||
<text class="header-title__card">{{ faceBindMode ? '绑定' : '赣湘全域旅游年卡/超级玩家卡' }}</text>
|
||||
</view>
|
||||
<text class="header-desc">请务必填写您的真实信息,以便于景区实名制预约</text>
|
||||
<text class="header-desc">{{ faceBindMode ? '请上传持卡人照片完成人脸绑定' : '请务必填写您的真实信息,以便于景区实名制预约' }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 2. 白色圆角表单卡片 -->
|
||||
@@ -19,6 +19,8 @@
|
||||
v-model="form.cardNo"
|
||||
placeholder="请输入卡号"
|
||||
placeholder-class="placeholder"
|
||||
:disabled="faceBindMode"
|
||||
@blur="onCardNoBlur"
|
||||
/>
|
||||
</view>
|
||||
<view class="divider" />
|
||||
@@ -31,6 +33,7 @@
|
||||
v-model="form.name"
|
||||
placeholder="请输入持卡人姓名"
|
||||
placeholder-class="placeholder"
|
||||
:disabled="faceBindMode"
|
||||
/>
|
||||
</view>
|
||||
<view class="divider" />
|
||||
@@ -43,19 +46,19 @@
|
||||
v-model="form.phone"
|
||||
type="number"
|
||||
maxlength="11"
|
||||
placeholder="请输入持卡人手机号"
|
||||
disabled
|
||||
placeholder-class="placeholder"
|
||||
/>
|
||||
</view>
|
||||
<view class="divider" />
|
||||
|
||||
<!-- 证件类型 -->
|
||||
<view class="form-row" @tap="showIdTypePicker = true">
|
||||
<!-- 证件类型(绑卡模式显示) -->
|
||||
<view v-if="!faceBindMode" class="form-row" @tap="showIdTypePicker = true">
|
||||
<text class="form-label">证件类型</text>
|
||||
<text class="form-value">{{ docType }}</text>
|
||||
<text class="form-value">{{ idTypeLabel }}</text>
|
||||
<view class="chevron-right" />
|
||||
</view>
|
||||
<view class="divider" />
|
||||
<view v-if="!faceBindMode" class="divider" />
|
||||
|
||||
<!-- 证件号码 -->
|
||||
<view class="form-row">
|
||||
@@ -65,16 +68,17 @@
|
||||
v-model="form.idCard"
|
||||
:placeholder="idPlaceholder"
|
||||
placeholder-class="placeholder"
|
||||
:maxlength="docType === '身份证' ? 18 : 30"
|
||||
:maxlength="docType === '1' ? 18 : 30"
|
||||
:disabled="faceBindMode"
|
||||
/>
|
||||
</view>
|
||||
<view class="divider" />
|
||||
|
||||
<!-- 持卡人照片 -->
|
||||
<view class="form-row form-row--photo">
|
||||
<view v-if="needFaceBind" class="form-row form-row--photo">
|
||||
<text class="form-label form-label--top">持卡人照片</text>
|
||||
<view class="upload-area" @tap="photoUrl ? previewPhoto() : choosePhoto()">
|
||||
<image v-if="photoUrl" class="upload-preview" :src="photoUrl" mode="aspectFill" />
|
||||
<view class="upload-area" @tap="photoUrl ? previewPhoto() : chooseImage()">
|
||||
<image v-if="photoUrl" class="upload-preview" :src="photoUrl" mode="aspectFit" />
|
||||
<view v-else class="upload-placeholder">
|
||||
<view class="upload-icon" />
|
||||
<text class="upload-text">点击上传</text>
|
||||
@@ -86,8 +90,8 @@
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 3. 协议勾选区域 -->
|
||||
<view class="agreement" @tap="agreed = !agreed">
|
||||
<!-- 3. 协议勾选区域(绑卡模式显示) -->
|
||||
<view v-if="!faceBindMode" class="agreement" @tap="agreed = !agreed">
|
||||
<view class="checkbox" :class="{ 'checkbox--checked': agreed }">
|
||||
<view v-if="agreed" class="checkbox__check" />
|
||||
</view>
|
||||
@@ -105,7 +109,7 @@
|
||||
:disabled="submitting"
|
||||
@tap="handleSubmit"
|
||||
>
|
||||
<text class="btn-confirm__text">{{ submitting ? '提交中...' : '确认绑卡' }}</text>
|
||||
<text class="btn-confirm__text">{{ submitting ? '提交中...' : (faceBindMode ? '确认绑定人脸' : '确认绑卡') }}</text>
|
||||
</button>
|
||||
<button class="btn-cancel" @tap="handleCancel">
|
||||
<text class="btn-cancel__text">取消</text>
|
||||
@@ -124,13 +128,13 @@
|
||||
<view class="id-type-list">
|
||||
<view
|
||||
v-for="item in idTypeOptions"
|
||||
:key="item"
|
||||
:key="item.value"
|
||||
class="id-type-item"
|
||||
:class="{ 'id-type-item--active': docType === item }"
|
||||
@tap="selectIdType(item)"
|
||||
:class="{ 'id-type-item--active': docType === item.value }"
|
||||
@tap="selectIdType(item.value)"
|
||||
>
|
||||
<text class="id-type-item__text">{{ item }}</text>
|
||||
<view v-if="docType === item" class="id-type-item__check" />
|
||||
<text class="id-type-item__text">{{ item.label }}</text>
|
||||
<view v-if="docType === item.value" class="id-type-item__check" />
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -151,28 +155,45 @@
|
||||
</view>
|
||||
</uni-popup>
|
||||
|
||||
<!-- 隐藏 canvas,用于图片压缩 -->
|
||||
<canvas
|
||||
canvas-id="myCanvas"
|
||||
id="myCanvas"
|
||||
style="position:absolute;left:0;top:-10000rpx;width:375px;height:375px;z-index:-1;opacity:0"
|
||||
/>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, watch, computed } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { checkFaceFlag, uploadImage, bindCard as apiBindCard, bindFaceImg } from '../api/card'
|
||||
|
||||
const faceBindMode = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
cardNo: '',
|
||||
name: '',
|
||||
phone: '18684839400',
|
||||
phone: (uni.getStorageSync('phone') as string) || '',
|
||||
idCard: ''
|
||||
})
|
||||
|
||||
const docType = ref('身份证')
|
||||
const idTypeOptions = ['身份证', '护照', '港澳通行证', '台胞证']
|
||||
const idTypeOptions = [
|
||||
{ value: '1', label: '身份证' },
|
||||
{ value: '2', label: '护照' },
|
||||
{ value: '3', label: '港澳居民来往内地通行证' },
|
||||
{ value: '4', label: '台湾居民来往大陆通行证' },
|
||||
]
|
||||
const docType = ref('1')
|
||||
|
||||
const idTypeLabel = computed(() => idTypeOptions.find(i => i.value === docType.value)?.label || '')
|
||||
|
||||
const idPlaceholder = computed(() => {
|
||||
const map: Record<string, string> = {
|
||||
'身份证': '请输入身份证号码',
|
||||
'护照': '请输入护照号码',
|
||||
'港澳通行证': '请输入通行证号码',
|
||||
'台胞证': '请输入台胞证号码'
|
||||
'1': '请输入身份证号码',
|
||||
'2': '请输入护照号码',
|
||||
'3': '请输入通行证号码',
|
||||
'4': '请输入台胞证号码',
|
||||
}
|
||||
return map[docType.value] || '请输入证件号码'
|
||||
})
|
||||
@@ -180,11 +201,34 @@ const idPlaceholder = computed(() => {
|
||||
const photoUrl = ref('')
|
||||
const agreed = ref(false)
|
||||
const submitting = ref(false)
|
||||
const uploading = ref(false)
|
||||
const needFaceBind = ref(false)
|
||||
const showNotice = ref(false)
|
||||
const showIdTypePicker = ref(false)
|
||||
const noticePopup = ref<any>(null)
|
||||
const idTypePopup = ref<any>(null)
|
||||
|
||||
onLoad(async (options) => {
|
||||
if (options?.faceBind === '1') {
|
||||
faceBindMode.value = true
|
||||
form.cardNo = options.cardNo || ''
|
||||
form.name = options.userName || ''
|
||||
form.phone = options.mobile || form.phone
|
||||
const idType = options.idType || ''
|
||||
docType.value = idType || '1'
|
||||
form.idCard = options.idNo || ''
|
||||
needFaceBind.value = true
|
||||
} else if (options?.redeemCode) {
|
||||
form.cardNo = options.redeemCode
|
||||
try {
|
||||
const res = await checkFaceFlag(options.redeemCode)
|
||||
needFaceBind.value = !!res
|
||||
} catch {
|
||||
needFaceBind.value = false
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
watch(showNotice, (v) => {
|
||||
if (v) noticePopup.value?.open()
|
||||
else noticePopup.value?.close()
|
||||
@@ -195,22 +239,85 @@ watch(showIdTypePicker, (v) => {
|
||||
else idTypePopup.value?.close()
|
||||
})
|
||||
|
||||
function selectIdType(item: string) {
|
||||
docType.value = item
|
||||
function selectIdType(value: string) {
|
||||
docType.value = value
|
||||
showIdTypePicker.value = false
|
||||
}
|
||||
|
||||
function choosePhoto() {
|
||||
uni.chooseMedia({
|
||||
async function onCardNoBlur() {
|
||||
if (!form.cardNo.trim()) return
|
||||
try {
|
||||
const res = await checkFaceFlag(form.cardNo.trim())
|
||||
needFaceBind.value = !!res
|
||||
} catch {
|
||||
needFaceBind.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function chooseImage() {
|
||||
if (uploading.value) return
|
||||
uni.chooseImage({
|
||||
count: 1,
|
||||
mediaType: ['image'],
|
||||
sourceType: ['camera'],
|
||||
success: (res) => {
|
||||
photoUrl.value = res.tempFiles[0].tempFilePath
|
||||
sizeType: ['original'],
|
||||
sourceType: ['album', 'camera'],
|
||||
success: (res) => handleImage(res.tempFilePaths[0]),
|
||||
fail: (err) => {
|
||||
if (!err.errMsg.includes('cancel')) console.log('选择图片失败:', err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function handleImage(filePath: string) {
|
||||
uploading.value = true
|
||||
uni.showLoading({ title: '处理中...' })
|
||||
try {
|
||||
const compressedPath = await compressImage(filePath)
|
||||
uni.showLoading({ title: '上传中...' })
|
||||
const url = await uploadImage(compressedPath)
|
||||
photoUrl.value = url
|
||||
uni.showToast({ title: '上传成功', icon: 'success' })
|
||||
} catch {
|
||||
uni.showToast({ title: '上传失败', icon: 'none' })
|
||||
} finally {
|
||||
uploading.value = false
|
||||
uni.hideLoading()
|
||||
}
|
||||
}
|
||||
|
||||
function compressImage(filePath: string): Promise<string> {
|
||||
return new Promise((resolve) => {
|
||||
const maxSize = 375
|
||||
uni.getImageInfo({
|
||||
src: filePath,
|
||||
success: (info) => {
|
||||
let { width, height } = info
|
||||
if (width <= maxSize && height <= maxSize) { resolve(filePath); return }
|
||||
if (width > height) {
|
||||
height = Math.round((height / width) * maxSize)
|
||||
width = maxSize
|
||||
} else {
|
||||
width = Math.round((width / height) * maxSize)
|
||||
height = maxSize
|
||||
}
|
||||
const ctx = uni.createCanvasContext('myCanvas')
|
||||
ctx.drawImage(filePath, 0, 0, width, height)
|
||||
ctx.draw(false, () => {
|
||||
setTimeout(() => {
|
||||
uni.canvasToTempFilePath({
|
||||
canvasId: 'myCanvas', x: 0, y: 0,
|
||||
width, height, destWidth: width, destHeight: height,
|
||||
quality: 0.8,
|
||||
success: (r) => resolve(r.tempFilePath),
|
||||
fail: () => resolve(filePath),
|
||||
})
|
||||
}, 200)
|
||||
})
|
||||
},
|
||||
fail: () => resolve(filePath)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function previewPhoto() {
|
||||
uni.previewImage({ urls: [photoUrl.value] })
|
||||
}
|
||||
@@ -224,37 +331,58 @@ function validateForm() {
|
||||
if (!form.name.trim()) return '请输入姓名'
|
||||
if (!form.phone.trim()) return '请输入联系电话'
|
||||
if (!/^1[3-9]\d{9}$/.test(form.phone)) return '手机号格式不正确'
|
||||
if (!form.idCard.trim()) return `请输入${docType.value}号码`
|
||||
if (docType.value === '身份证' && !/^\d{17}[\dXx]$/.test(form.idCard)) return '身份证号格式不正确'
|
||||
if (!photoUrl.value) return '请上传持卡人照片'
|
||||
if (!form.idCard.trim()) return `请输入${idTypeLabel.value}号码`
|
||||
if (docType.value === '1' && !/^\d{17}[\dXx]$/.test(form.idCard)) return '身份证号格式不正确'
|
||||
if (needFaceBind.value && !photoUrl.value) return '请上传持卡人照片'
|
||||
return ''
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!agreed.value) {
|
||||
if (!faceBindMode.value && !agreed.value) {
|
||||
uni.showToast({ title: '请先阅读并知晓用卡须知', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const err = validateForm()
|
||||
if (err) {
|
||||
uni.showToast({ title: err, icon: 'none' })
|
||||
return
|
||||
if (faceBindMode.value) {
|
||||
if (!photoUrl.value) {
|
||||
uni.showToast({ title: '请上传持卡人照片', icon: 'none' })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
const err = validateForm()
|
||||
if (err) {
|
||||
uni.showToast({ title: err, icon: 'none' })
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
submitting.value = true
|
||||
uni.showLoading({ title: '提交中...', mask: true })
|
||||
|
||||
try {
|
||||
await new Promise(r => setTimeout(r, 1500))
|
||||
const apiFn = faceBindMode.value ? bindFaceImg : apiBindCard
|
||||
await apiFn({
|
||||
cardNum: form.cardNo.trim(),
|
||||
userName: form.name.trim(),
|
||||
mobile: form.phone.trim(),
|
||||
idType: docType.value,
|
||||
idNo: form.idCard.trim(),
|
||||
img: photoUrl.value,
|
||||
smsCode: '',
|
||||
thirdId: '',
|
||||
bindType: 'SELF',
|
||||
bindSource: 'zfb',
|
||||
channel: '',
|
||||
activeCode: '',
|
||||
})
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '绑卡成功', icon: 'success' })
|
||||
uni.showToast({ title: faceBindMode.value ? '人脸绑定成功' : '绑卡成功', icon: 'success' })
|
||||
setTimeout(() => {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
uni.switchTab({ url: '/pages/qrcode/index' })
|
||||
}, 1500)
|
||||
} catch {
|
||||
} catch (err: any) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: '绑卡失败,请重试', icon: 'none' })
|
||||
uni.showToast({ title: err.message || (faceBindMode.value ? '人脸绑定失败' : '绑卡失败'), icon: 'none' })
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
|
||||
+114
-39
@@ -1,25 +1,21 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 图片区域 -->
|
||||
<view class="image-section">
|
||||
<image
|
||||
class="card-image"
|
||||
src="https://aliyunoss.hntgov.net/image/2026/06/23/70e9f4b3b5fd49adb1ff06edcb8fa032.jpg"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<image
|
||||
class="card-image"
|
||||
src="https://aliyunoss.hntgov.net/image/2026/06/25/f3e45bcb3b4f4a2997f107cc6335a1bc.jpg"
|
||||
mode="widthFix"
|
||||
/>
|
||||
<view v-if="loading" class="loading">
|
||||
<text class="loading-text">加载中...</text>
|
||||
</view>
|
||||
<template v-else>
|
||||
<image class="card-image" :src="mainPic" mode="widthFix" />
|
||||
<view class="intro-section">
|
||||
<rich-text :nodes="introduction" />
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<!-- 底部固定购买区域 -->
|
||||
<view class="bottom-bar">
|
||||
<view class="price-row">
|
||||
<text class="price-label">价格:</text>
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-value">198</text>
|
||||
<text class="price-value">{{ buyPrice }}</text>
|
||||
<text class="price-original">¥3800+</text>
|
||||
</view>
|
||||
<button class="btn-buy" :disabled="paying" @tap="handleBuy">
|
||||
@@ -30,38 +26,105 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { post, getToken } from '../../../api/request'
|
||||
import { BASE_URL } from '../../../config'
|
||||
import { useUserStore } from '../../../store/user'
|
||||
import { createOrder, prepay } from '../../../api/order'
|
||||
|
||||
const userStore = useUserStore()
|
||||
|
||||
const goodsId = ref('')
|
||||
const cardModleId = ref('')
|
||||
const mainPic = ref('')
|
||||
const introduction = ref('')
|
||||
const buyPrice = ref('0')
|
||||
const skuId = ref('')
|
||||
const loading = ref(true)
|
||||
const paying = ref(false)
|
||||
|
||||
onLoad((query) => {
|
||||
goodsId.value = (query?.goodsId as string) || '821926819630853'
|
||||
})
|
||||
|
||||
function sanitizeHtml(html: string): string {
|
||||
return html.replace(/<img[^>]*>/g, (match) => {
|
||||
return match.replace(/style="[^"]*"/g, 'style="width:100%"')
|
||||
})
|
||||
}
|
||||
|
||||
interface CardModelDetail {
|
||||
cardModel: any
|
||||
goods: { id: string; mainPic: string }
|
||||
goodsInfo: { introduction: string }
|
||||
sku: Array<{ id: string; buyPrice: number }>
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const data = await post<CardModelDetail>(`${BASE_URL}/open-api/xgh/cardModelDetail?goodsId=${goodsId.value}`, {})
|
||||
cardModleId.value = data.cardModel?.id || ''
|
||||
mainPic.value = data.goods?.mainPic || ''
|
||||
introduction.value = sanitizeHtml(data.goodsInfo?.introduction || '')
|
||||
const sku = data.sku?.[0]
|
||||
skuId.value = sku?.id || ''
|
||||
const price = sku?.buyPrice || 0
|
||||
buyPrice.value = (price / 100).toFixed(2)
|
||||
} catch {
|
||||
// 静默失败
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
})
|
||||
|
||||
async function handleBuy() {
|
||||
if (!userStore.isLogin) {
|
||||
uni.navigateTo({ url: '/pages/login/login' })
|
||||
return
|
||||
}
|
||||
if (paying.value) return
|
||||
paying.value = true
|
||||
|
||||
try {
|
||||
// 调用后端创建订单接口(mock)
|
||||
const orderRes: any = await new Promise((resolve) => {
|
||||
setTimeout(() => {
|
||||
resolve({
|
||||
orderId: 'ORD' + Date.now(),
|
||||
payParams: {
|
||||
timeStamp: String(Math.floor(Date.now() / 1000)),
|
||||
nonceStr: 'mock_nonce_' + Date.now(),
|
||||
package: 'prepay_id=mock_prepay_id',
|
||||
signType: 'MD5',
|
||||
paySign: 'mock_pay_sign'
|
||||
}
|
||||
})
|
||||
}, 800)
|
||||
const mobile = (uni.getStorageSync('phone') as string) || ''
|
||||
const orderRes = await createOrder({
|
||||
cardModleId: cardModleId.value,
|
||||
buyNum: 1,
|
||||
skuId: skuId.value,
|
||||
mobile,
|
||||
couponId: '',
|
||||
})
|
||||
|
||||
// 调用微信支付
|
||||
const openId = getToken()
|
||||
if (!openId) {
|
||||
uni.showToast({ title: '请先登录', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
const payParams = await prepay({
|
||||
openId,
|
||||
orderNo: orderRes.outTradeNo,
|
||||
description: mobile,
|
||||
amount: Math.round(Number(buyPrice.value) * 100),
|
||||
})
|
||||
|
||||
if (!payParams?.timeStamp || !payParams?.nonce || !payParams?.pack || !payParams?.paySign) {
|
||||
uni.showToast({ title: '支付参数异常,请重试', icon: 'none' })
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
...orderRes.payParams,
|
||||
appId: payParams.appId,
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonce,
|
||||
package: payParams.pack,
|
||||
signType: payParams.signType,
|
||||
paySign: payParams.paySign,
|
||||
success: () => resolve(),
|
||||
fail: (err: any) => reject(err)
|
||||
fail: (err: any) => reject(err),
|
||||
})
|
||||
})
|
||||
|
||||
@@ -70,11 +133,10 @@ async function handleBuy() {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}, 1500)
|
||||
} catch (err: any) {
|
||||
if (err?.errMsg?.includes('cancel')) {
|
||||
uni.showToast({ title: '已取消支付', icon: 'none' })
|
||||
} else {
|
||||
uni.showToast({ title: '支付失败,请重试', icon: 'none' })
|
||||
}
|
||||
uni.showToast({ title: err?.errMsg?.includes('cancel') ? '已取消支付' : '支付失败,请重试', icon: 'none' })
|
||||
setTimeout(() => {
|
||||
uni.redirectTo({ url: '/packages/user/pages/order/list' })
|
||||
}, 1500)
|
||||
} finally {
|
||||
paying.value = false
|
||||
}
|
||||
@@ -91,12 +153,19 @@ $bar-height: 120rpx;
|
||||
font-family: $font-family-body;
|
||||
}
|
||||
|
||||
/* ===== 图片区域 ===== */
|
||||
.image-section {
|
||||
/* ===== 加载状态 ===== */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
padding: 80rpx 0;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-tips-color;
|
||||
}
|
||||
|
||||
/* ===== 图片 ===== */
|
||||
.card-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
@@ -104,6 +173,12 @@ $bar-height: 120rpx;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ===== 富文本介绍 ===== */
|
||||
.intro-section {
|
||||
width: 100%;
|
||||
margin-bottom: 24rpx;
|
||||
}
|
||||
|
||||
/* ===== 底部固定购买区域 ===== */
|
||||
.bottom-bar {
|
||||
position: fixed;
|
||||
|
||||
@@ -37,7 +37,20 @@ async function handleBind() {
|
||||
}
|
||||
binding.value = true
|
||||
try {
|
||||
await bindCard(cardNo.value.trim())
|
||||
await bindCard({
|
||||
cardNum: cardNo.value.trim(),
|
||||
userName: '',
|
||||
mobile: '',
|
||||
idType: '',
|
||||
idNo: '',
|
||||
img: '',
|
||||
smsCode: '',
|
||||
thirdId: '',
|
||||
bindType: 'SELF',
|
||||
bindSource: 'zfb',
|
||||
channel: '',
|
||||
activeCode: '',
|
||||
})
|
||||
showToast('绑定成功', 'success')
|
||||
setTimeout(() => {
|
||||
uni.navigateBack()
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 模块1:订单状态卡片 -->
|
||||
<view class="status-card" :class="`status-card--${order.status}`">
|
||||
<view class="status-card" :class="`status-card--${orderStatus}`">
|
||||
<view class="status-icon-wrap">
|
||||
<view class="status-icon" :class="`status-icon--${order.status}`" />
|
||||
<view class="status-icon" :class="`status-icon--${orderStatus}`" />
|
||||
</view>
|
||||
<text class="status-title">{{ statusLabel }}</text>
|
||||
<text class="status-desc">{{ statusDesc }}</text>
|
||||
@@ -15,7 +15,7 @@
|
||||
<view class="price-row">
|
||||
<view class="price-current">
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-value">198</text>
|
||||
<text class="price-value">{{ (order.payAmount / 100).toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
<text class="product-desc">一卡全年畅玩赣湘合作景区商户,实名绑定使用</text>
|
||||
@@ -27,11 +27,11 @@
|
||||
<text class="detail-label">订单编号</text>
|
||||
<text class="detail-value">{{ order.id }}</text>
|
||||
</view>
|
||||
<view v-if="order.status === 'pending' || order.status === 'activated'" class="detail-row">
|
||||
<view v-if="order.redeemCode && order.payStatus === 2" class="detail-row">
|
||||
<text class="detail-label">卡号</text>
|
||||
<view class="detail-value-row">
|
||||
<text class="detail-value detail-value--code">{{ order.redeemCode }}</text>
|
||||
<view v-if="order.status === 'pending'" class="copy-btn" @tap="copyCode">
|
||||
<view class="copy-btn" @tap="copyCode">
|
||||
<text class="copy-btn__text">复制</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -42,7 +42,7 @@
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">支付时间</text>
|
||||
<text class="detail-value">{{ order.payTime }}</text>
|
||||
<text class="detail-value">{{ order.payTime || '-' }}</text>
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">支付渠道</text>
|
||||
@@ -50,66 +50,52 @@
|
||||
</view>
|
||||
<view class="detail-row">
|
||||
<text class="detail-label">支付金额</text>
|
||||
<text class="detail-value detail-value--price">¥{{ order.payAmount }}.00</text>
|
||||
<text class="detail-value detail-value--price">¥{{ (order.payAmount / 100).toFixed(2) }}</text>
|
||||
</view>
|
||||
<view v-if="order.status === 'activated' && order.activatedTime" class="detail-row detail-row--last">
|
||||
<view v-if="order.bindName" class="detail-row">
|
||||
<text class="detail-label">绑定人</text>
|
||||
<text class="detail-value">{{ order.bindName }}</text>
|
||||
</view>
|
||||
<view v-if="order.bindCertificateNo" class="detail-row">
|
||||
<text class="detail-label">绑定证件</text>
|
||||
<text class="detail-value">{{ order.bindCertificateNo }}</text>
|
||||
</view>
|
||||
<view v-if="order.activatedTime" class="detail-row">
|
||||
<text class="detail-label">绑定时间</text>
|
||||
<text class="detail-value">{{ order.activatedTime }}</text>
|
||||
</view>
|
||||
<view v-if="order.status === 'refunded' && order.refundedTime" class="detail-row detail-row--last">
|
||||
<view v-if="order.validStartDate" class="detail-row">
|
||||
<text class="detail-label">有效期起</text>
|
||||
<text class="detail-value">{{ order.validStartDate }}</text>
|
||||
</view>
|
||||
<view v-if="order.validEndDate" class="detail-row detail-row--last">
|
||||
<text class="detail-label">有效期止</text>
|
||||
<text class="detail-value">{{ order.validEndDate }}</text>
|
||||
</view>
|
||||
<view v-if="order.refundedTime" class="detail-row detail-row--last">
|
||||
<text class="detail-label">退款时间</text>
|
||||
<text class="detail-value">{{ order.refundedTime }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 模块4:使用提示卡片 -->
|
||||
<view class="tips-card">
|
||||
<text class="tips-title">使用须知</text>
|
||||
<view class="tips-list">
|
||||
<view class="tips-item">
|
||||
<view class="tips-dot" />
|
||||
<text class="tips-text">卡一经绑定或售出满7天不支持退款</text>
|
||||
</view>
|
||||
<view class="tips-item">
|
||||
<view class="tips-dot" />
|
||||
<text class="tips-text">实名绑定大陆身份证,港澳台/外籍无法绑定</text>
|
||||
</view>
|
||||
<view class="tips-item">
|
||||
<view class="tips-dot" />
|
||||
<text class="tips-text">预约需提前1天,未提前取消将扣除使用次数</text>
|
||||
</view>
|
||||
<view class="tips-item">
|
||||
<view class="tips-dot" />
|
||||
<text class="tips-text">一卡一人不可转借</text>
|
||||
</view>
|
||||
<view class="tips-item">
|
||||
<view class="tips-dot" />
|
||||
<text class="tips-text">第三方平台下单退款需联系对应平台客服</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="tips-link" @tap="goFaq">
|
||||
<text class="tips-link__text">查看完整FAQ</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 底部安全区占位 -->
|
||||
<view class="safe-bottom" />
|
||||
|
||||
<!-- 模块5:底部固定操作栏 -->
|
||||
<view class="bottom-bar">
|
||||
<view v-if="order.status === 'pending'" class="bottom-bar__right">
|
||||
<view v-if="Number(order.payStatus) === 1" class="bottom-bar__right">
|
||||
<view class="btn-action btn-action--primary" @tap="goPay">
|
||||
<text class="btn-action__text">立即支付</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else-if="Number(order.payStatus) === 2" class="bottom-bar__right">
|
||||
<view class="btn-action btn-action--outline" @tap="handleRefund">
|
||||
<text class="btn-action__text--outline">申请退款</text>
|
||||
</view>
|
||||
<view class="btn-action btn-action--primary" @tap="goActivate">
|
||||
<view v-if="order.redeemCode" class="btn-action btn-action--primary" @tap="goActivate">
|
||||
<text class="btn-action__text">立即绑定</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="bottom-bar__right">
|
||||
<view class="btn-action btn-action--outline" @tap="goHome">
|
||||
<text class="btn-action__text--outline">返回首页</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -117,62 +103,108 @@
|
||||
<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'
|
||||
|
||||
type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired'
|
||||
|
||||
interface OrderDetail {
|
||||
id: string
|
||||
status: OrderStatus
|
||||
payStatus: number
|
||||
orderStatus: number
|
||||
createTime: string
|
||||
payTime: string
|
||||
payTime: string | null
|
||||
payAmount: number
|
||||
redeemCode: string
|
||||
payChannel: string
|
||||
activatedTime?: string
|
||||
refundedTime?: string
|
||||
cardNo: string | null
|
||||
redeemCode: string | null
|
||||
isBind: number | null
|
||||
bindName: string | null
|
||||
bindCertificateNo: string | null
|
||||
cardStatus: number | null
|
||||
isActive: number | null
|
||||
activatedTime: string | null
|
||||
validStartDate: string | null
|
||||
validEndDate: string | null
|
||||
refundedTime: string | null
|
||||
}
|
||||
|
||||
const order = ref<OrderDetail>({
|
||||
id: 'ORD20260625001',
|
||||
status: 'pending',
|
||||
createTime: '2026-06-25 10:32',
|
||||
payTime: '2026-06-25 10:33',
|
||||
payAmount: 198,
|
||||
redeemCode: 'CJ0000012398',
|
||||
id: '',
|
||||
payStatus: 1,
|
||||
orderStatus: 1,
|
||||
createTime: '',
|
||||
payTime: null,
|
||||
payAmount: 0,
|
||||
payChannel: '微信支付',
|
||||
activatedTime: undefined,
|
||||
refundedTime: undefined
|
||||
cardNo: null,
|
||||
redeemCode: null,
|
||||
isBind: null,
|
||||
bindName: null,
|
||||
bindCertificateNo: null,
|
||||
cardStatus: null,
|
||||
isActive: null,
|
||||
activatedTime: null,
|
||||
validStartDate: null,
|
||||
validEndDate: null,
|
||||
refundedTime: null,
|
||||
})
|
||||
|
||||
// Mock 订单数据集
|
||||
const mockOrders: OrderDetail[] = [
|
||||
{ id: 'ORD20260625001', status: 'pending', createTime: '2026-06-25 10:32', payTime: '2026-06-25 10:33', payAmount: 198, redeemCode: 'CJ0000012398', payChannel: '微信支付' },
|
||||
{ id: 'ORD20260624002', status: 'activated', createTime: '2026-06-24 14:18', payTime: '2026-06-24 14:19', payAmount: 198, redeemCode: 'CJ0000012399', payChannel: '微信支付', activatedTime: '2026-06-24 15:00' },
|
||||
{ id: 'ORD20260623003', status: 'pending', createTime: '2026-06-23 09:45', payTime: '2026-06-23 09:46', payAmount: 198, redeemCode: 'CJ0000012400', payChannel: '微信支付' },
|
||||
{ id: 'ORD20260620004', status: 'refunded', createTime: '2026-06-20 16:22', payTime: '2026-06-20 16:23', payAmount: 198, redeemCode: 'CJ0000012401', payChannel: '微信支付', refundedTime: '2026-06-21 10:15' },
|
||||
{ id: 'ORD20260618005', status: 'activated', createTime: '2026-06-18 11:08', payTime: '2026-06-18 11:09', payAmount: 198, redeemCode: 'CJ0000012402', payChannel: '微信支付', activatedTime: '2026-06-18 12:30' },
|
||||
{ id: 'ORD20260510007', status: 'activated', createTime: '2026-05-10 13:40', payTime: '2026-05-10 13:41', payAmount: 198, redeemCode: 'CJ0000012403', payChannel: '微信支付', activatedTime: '2026-05-10 14:20' },
|
||||
]
|
||||
function toStatus(payStatus: number | string): OrderStatus {
|
||||
const status = Number(payStatus)
|
||||
if (status === 1) return 'pending'
|
||||
if (status === 2) return 'activated'
|
||||
return 'refunded'
|
||||
}
|
||||
|
||||
onLoad((query: any) => {
|
||||
if (query?.orderId) {
|
||||
const found = mockOrders.find(o => o.id === query.orderId)
|
||||
if (found) {
|
||||
order.value = { ...found }
|
||||
} else {
|
||||
order.value.id = query.orderId
|
||||
let currentOrderId = ''
|
||||
|
||||
onLoad(async (query: any) => {
|
||||
currentOrderId = query?.orderId || ''
|
||||
await fetchOrderDetail()
|
||||
})
|
||||
|
||||
async function fetchOrderDetail() {
|
||||
if (!currentOrderId) return
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
try {
|
||||
const res = await post<any>('/mpOrder/getOrderDetail', { phone, orderId: currentOrderId })
|
||||
if (res) {
|
||||
order.value = {
|
||||
id: res.id || currentOrderId,
|
||||
payStatus: Number(res.payStatus) ?? 1,
|
||||
orderStatus: res.orderStatus ?? 1,
|
||||
createTime: res.createTime || '',
|
||||
payTime: res.payTime || null,
|
||||
payAmount: res.payAmount || 0,
|
||||
payChannel: res.payChannel || '微信支付',
|
||||
cardNo: res.cardNo || null,
|
||||
redeemCode: res.redeemCode || null,
|
||||
isBind: res.isBind ?? null,
|
||||
bindName: res.bindName || null,
|
||||
bindCertificateNo: res.bindCertificateNo || null,
|
||||
cardStatus: res.cardStatus ?? null,
|
||||
isActive: res.isActive ?? null,
|
||||
activatedTime: res.activatedTime || null,
|
||||
validStartDate: res.validStartDate || null,
|
||||
validEndDate: res.validEndDate || null,
|
||||
refundedTime: res.refundedTime || null,
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const orderStatus = computed(() => toStatus(order.value.payStatus))
|
||||
|
||||
const statusLabel = computed(() => {
|
||||
const map: Record<OrderStatus, string> = {
|
||||
pending: '待绑定',
|
||||
activated: '已绑定',
|
||||
pending: '待支付',
|
||||
activated: '已支付',
|
||||
refunded: '已退款',
|
||||
expired: '已过期'
|
||||
}
|
||||
return map[order.value.status]
|
||||
return map[orderStatus.value]
|
||||
})
|
||||
|
||||
const statusDesc = computed(() => {
|
||||
@@ -182,43 +214,77 @@ const statusDesc = computed(() => {
|
||||
refunded: '退款审核通过,金额原路返还微信',
|
||||
expired: '下单超过绑定有效期,卡号失效无法绑卡'
|
||||
}
|
||||
return map[order.value.status]
|
||||
return map[orderStatus.value]
|
||||
})
|
||||
|
||||
function copyCode() {
|
||||
if (!order.value.redeemCode) return
|
||||
uni.setClipboardData({
|
||||
data: order.value.redeemCode,
|
||||
success: () => uni.showToast({ title: '已复制', icon: 'success' })
|
||||
})
|
||||
}
|
||||
|
||||
async function goPay() {
|
||||
const openId = (uni.getStorageSync('app_token') as string) || ''
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
uni.showLoading({ title: '调起支付...', mask: true })
|
||||
try {
|
||||
const payParams = await post<any>(`${BASE_URL}/open-api/pingXiang/prepay`, {
|
||||
openId,
|
||||
orderNo: order.value.id,
|
||||
description: phone,
|
||||
amount: 1
|
||||
})
|
||||
uni.hideLoading()
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
appId: payParams.appId,
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonce,
|
||||
package: payParams.pack,
|
||||
signType: payParams.signType,
|
||||
paySign: payParams.paySign,
|
||||
success: () => {
|
||||
uni.showToast({ title: '支付成功', icon: 'success' })
|
||||
fetchOrderDetail()
|
||||
},
|
||||
fail: () => {
|
||||
fetchOrderDetail()
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: err.message || '支付失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function goActivate() {
|
||||
if (!order.value.redeemCode) return
|
||||
uni.navigateTo({
|
||||
url: `/packages/user/pages/bind?redeemCode=${order.value.redeemCode}`
|
||||
})
|
||||
}
|
||||
|
||||
function handleRefund() {
|
||||
async function handleRefund() {
|
||||
uni.showModal({
|
||||
title: '申请退款',
|
||||
content: '赣湘全域旅游年卡/超级玩家卡绑定或销售七日以上不可退卡。确认申请退款?',
|
||||
success: (res) => {
|
||||
success: async (res) => {
|
||||
if (res.confirm) {
|
||||
order.value.status = 'refunded'
|
||||
order.value.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
uni.showToast({ title: '退款申请已提交', icon: 'success' })
|
||||
try {
|
||||
await post(`${BASE_URL}/open-api/pingXiang/refund?orderNo=${order.value.id}`)
|
||||
order.value.payStatus = 3
|
||||
order.value.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
uni.showToast({ title: '退款申请已提交', icon: 'success' })
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err.message || '退款失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function goFaq() {
|
||||
uni.navigateTo({ url: '/packages/content/pages/faq' })
|
||||
}
|
||||
|
||||
function goHome() {
|
||||
uni.switchTab({ url: '/pages/index/index' })
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@@ -462,63 +528,6 @@ $bar-height: 120rpx;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
/* ===== 模块4:使用提示 ===== */
|
||||
.tips-card {
|
||||
background: rgba(60, 156, 255, 0.04);
|
||||
border-radius: $radius-lg;
|
||||
padding: $spacing-4;
|
||||
margin-top: $spacing-3;
|
||||
}
|
||||
|
||||
.tips-title {
|
||||
display: block;
|
||||
font-family: $font-family-body;
|
||||
font-size: $font-size-sm;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $uv-main-color;
|
||||
margin-bottom: $spacing-2;
|
||||
}
|
||||
|
||||
.tips-list {
|
||||
margin-bottom: $spacing-3;
|
||||
}
|
||||
|
||||
.tips-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $spacing-2;
|
||||
}
|
||||
|
||||
.tips-dot {
|
||||
width: 8rpx;
|
||||
height: 8rpx;
|
||||
border-radius: 50%;
|
||||
background: $uv-primary;
|
||||
flex-shrink: 0;
|
||||
margin-top: 14rpx;
|
||||
margin-right: $spacing-2;
|
||||
}
|
||||
|
||||
.tips-text {
|
||||
font-family: $font-family-body;
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-tips-color;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.tips-link {
|
||||
&:active {
|
||||
opacity: 0.7;
|
||||
}
|
||||
}
|
||||
|
||||
.tips-link__text {
|
||||
font-family: $font-family-body;
|
||||
font-size: $font-size-sm;
|
||||
color: $uv-primary;
|
||||
font-weight: $font-weight-medium;
|
||||
}
|
||||
|
||||
/* ===== 底部安全区 ===== */
|
||||
.safe-bottom {
|
||||
height: $spacing-3;
|
||||
|
||||
@@ -59,7 +59,7 @@
|
||||
<text class="product-name">超级玩家卡</text>
|
||||
<view class="price-current">
|
||||
<text class="price-symbol">¥</text>
|
||||
<text class="price-value">{{ order.payAmount }}</text>
|
||||
<text class="price-value">{{ (order.payAmount / 100).toFixed(2) }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -72,10 +72,15 @@
|
||||
|
||||
<!-- 操作按钮 -->
|
||||
<view class="card-actions" v-if="order.status === 'pending'">
|
||||
<view class="btn-primary" @tap.stop="goPay(order.id)">
|
||||
<text class="btn-primary__text">立即支付</text>
|
||||
</view>
|
||||
</view>
|
||||
<view class="card-actions" v-else-if="order.status === 'activated'">
|
||||
<view class="btn-outline" @tap.stop="showRefundModal(order)">
|
||||
<text class="btn-outline__text">申请退款</text>
|
||||
</view>
|
||||
<view class="btn-primary" @tap.stop="goActivate(order)">
|
||||
<view v-if="order.redeemCode" class="btn-primary" @tap.stop="goActivate(order)">
|
||||
<text class="btn-primary__text">立即绑定</text>
|
||||
</view>
|
||||
</view>
|
||||
@@ -117,14 +122,25 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { mockOrders, type OrderItem } from '../../mock/order'
|
||||
import { onShow } from '@dcloudio/uni-app'
|
||||
import { post, get } from '../../../../api/request'
|
||||
import { BASE_URL } from '../../../../config'
|
||||
|
||||
type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired'
|
||||
|
||||
interface OrderItem {
|
||||
id: string
|
||||
status: OrderStatus
|
||||
createTime: string
|
||||
payAmount: number
|
||||
redeemCode: string | null
|
||||
payChannel: string
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待绑定', value: 'pending' },
|
||||
{ label: '已绑定', value: 'activated' },
|
||||
{ label: '待支付', value: 'pending' },
|
||||
{ label: '已支付', value: 'activated' },
|
||||
{ label: '已退款', value: 'refunded' },
|
||||
]
|
||||
|
||||
@@ -132,11 +148,12 @@ const activeTab = ref('')
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const noMore = ref(false)
|
||||
const currentPage = ref(1)
|
||||
const pageNo = ref(1)
|
||||
const pageSize = 10
|
||||
const refundPopup = ref<any>(null)
|
||||
const refundOrderId = ref('')
|
||||
|
||||
const orders = ref<OrderItem[]>(mockOrders)
|
||||
const orders = ref<OrderItem[]>([])
|
||||
|
||||
const filteredOrders = computed(() => {
|
||||
if (!activeTab.value) return orders.value
|
||||
@@ -145,34 +162,106 @@ const filteredOrders = computed(() => {
|
||||
|
||||
function statusLabel(status: OrderStatus) {
|
||||
const map: Record<OrderStatus, string> = {
|
||||
pending: '待绑定',
|
||||
activated: '已绑定',
|
||||
pending: '待支付',
|
||||
activated: '已支付',
|
||||
refunded: '已退款',
|
||||
expired: '已过期'
|
||||
}
|
||||
return map[status]
|
||||
}
|
||||
|
||||
function payStatusValue(tab: string): number | undefined {
|
||||
if (tab === 'pending') return 1
|
||||
if (tab === 'activated') return 2
|
||||
if (tab === 'refunded') return 3
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function fetchOrders(append = false) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
const res = await post<any>('/mpOrder/queryOrderList', {
|
||||
phone,
|
||||
payStatus: payStatusValue(activeTab.value),
|
||||
pageNo: pageNo.value,
|
||||
pageSize,
|
||||
})
|
||||
const list = (res.list || res.records || []).map((item: any) => ({
|
||||
id: item.id || '',
|
||||
status: item.payStatus === 1 ? 'pending' : item.payStatus === 2 ? 'activated' : 'refunded',
|
||||
createTime: item.createTime || '',
|
||||
payAmount: item.payAmount || 0,
|
||||
redeemCode: item.redeemCode || null,
|
||||
payChannel: item.payChannel || '微信支付',
|
||||
}))
|
||||
if (append) {
|
||||
orders.value.push(...list)
|
||||
} else {
|
||||
orders.value = list
|
||||
}
|
||||
noMore.value = list.length < pageSize
|
||||
} catch {
|
||||
if (!append) orders.value = []
|
||||
noMore.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function switchTab(value: string) {
|
||||
activeTab.value = value
|
||||
pageNo.value = 1
|
||||
fetchOrders()
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
refreshing.value = true
|
||||
currentPage.value = 1
|
||||
noMore.value = false
|
||||
setTimeout(() => {
|
||||
refreshing.value = false
|
||||
}, 800)
|
||||
pageNo.value = 1
|
||||
fetchOrders().finally(() => { refreshing.value = false })
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
if (loading.value || noMore.value) return
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
noMore.value = true
|
||||
}, 800)
|
||||
pageNo.value++
|
||||
fetchOrders(true)
|
||||
}
|
||||
|
||||
async function goPay(orderId: string) {
|
||||
const openId = (uni.getStorageSync('app_token') as string) || ''
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
uni.showLoading({ title: '调起支付...', mask: true })
|
||||
try {
|
||||
const payParams = await post<any>(`${BASE_URL}/open-api/pingXiang/prepay`, {
|
||||
openId,
|
||||
orderNo: orderId,
|
||||
description: phone,
|
||||
amount: 1
|
||||
})
|
||||
uni.hideLoading()
|
||||
uni.requestPayment({
|
||||
provider: 'wxpay',
|
||||
appId: payParams.appId,
|
||||
timeStamp: payParams.timeStamp,
|
||||
nonceStr: payParams.nonce,
|
||||
package: payParams.pack,
|
||||
signType: payParams.signType,
|
||||
paySign: payParams.paySign,
|
||||
success: () => {
|
||||
uni.showToast({ title: '支付成功', icon: 'success' })
|
||||
pageNo.value = 1
|
||||
fetchOrders()
|
||||
},
|
||||
fail: () => {
|
||||
pageNo.value = 1
|
||||
fetchOrders()
|
||||
}
|
||||
})
|
||||
} catch (err: any) {
|
||||
uni.hideLoading()
|
||||
uni.showToast({ title: err.message || '支付失败', icon: 'none' })
|
||||
}
|
||||
}
|
||||
|
||||
function goDetail(orderId: string) {
|
||||
@@ -180,6 +269,10 @@ function goDetail(orderId: string) {
|
||||
}
|
||||
|
||||
function goActivate(order: OrderItem) {
|
||||
if (!order.redeemCode) {
|
||||
uni.showToast({ title: '暂无兑换码', icon: 'none' })
|
||||
return
|
||||
}
|
||||
uni.navigateTo({
|
||||
url: `/packages/user/pages/bind?redeemCode=${order.redeemCode}`
|
||||
})
|
||||
@@ -194,19 +287,27 @@ function closeRefund() {
|
||||
refundPopup.value?.close()
|
||||
}
|
||||
|
||||
function submitRefund() {
|
||||
async function submitRefund() {
|
||||
refundPopup.value?.close()
|
||||
const order = orders.value.find(o => o.id === refundOrderId.value)
|
||||
if (order) {
|
||||
if (!order) return
|
||||
try {
|
||||
await post(`${BASE_URL}/open-api/pingXiang/refund?orderNo=${refundOrderId.value}`)
|
||||
order.status = 'refunded'
|
||||
order.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
uni.showToast({ title: '退款申请已提交', icon: 'success' })
|
||||
} catch (err: any) {
|
||||
uni.showToast({ title: err.message || '退款失败', icon: 'none' })
|
||||
}
|
||||
uni.showToast({ title: '退款申请已提交', icon: 'success' })
|
||||
}
|
||||
|
||||
function goBuy() {
|
||||
uni.navigateTo({ url: '/packages/user/pages/buy' })
|
||||
}
|
||||
|
||||
onShow(() => {
|
||||
pageNo.value = 1
|
||||
fetchOrders()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@
|
||||
<view class="code-modal">
|
||||
<text class="code-modal__title">入园码</text>
|
||||
<view class="qr-box">
|
||||
<image class="qr-img" src="/static/qr-demo.png" mode="aspectFit" />
|
||||
<image v-if="codeSrc" class="qr-img" :src="codeSrc" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
|
||||
</view>
|
||||
@@ -162,7 +162,7 @@
|
||||
</view>
|
||||
<view class="sheet-row">
|
||||
<text class="sheet-label">预约状态</text>
|
||||
<text class="sheet-value">{{ statusLabel(detailRecord?.status || '') }}</text>
|
||||
<text class="sheet-value">{{ detailStatusLabel(detailRecord?.status || '') }}</text>
|
||||
</view>
|
||||
<view class="sheet-row">
|
||||
<text class="sheet-label">预约申请时间</text>
|
||||
@@ -177,12 +177,12 @@
|
||||
<text class="sheet-value">{{ detailRecord.cancelTime }}</text>
|
||||
</view>
|
||||
<!-- 核销人脸照片 -->
|
||||
<view v-if="detailRecord?.verifyPhoto && detailRecord?.status !== 'auto_used'" class="sheet-photo">
|
||||
<view v-if="detailRecord?.verifyPhoto" class="sheet-photo">
|
||||
<text class="sheet-label">核销照片</text>
|
||||
<image class="sheet-photo-avatar" :src="detailRecord.verifyPhoto" mode="aspectFill" @tap="previewPhoto(detailRecord.verifyPhoto)" />
|
||||
</view>
|
||||
<view v-if="detailRecord?.status !== 'used'" class="sheet-tip">
|
||||
<text class="sheet-tip__text">未提前取消且未入园将扣除1次年卡使用次数</text>
|
||||
<view v-if="detailRecord?.status !== 'used' && detailRecord?.cardInstruction" class="sheet-tip">
|
||||
<text class="sheet-tip__text">{{ detailRecord.cardInstruction }}</text>
|
||||
</view>
|
||||
</scroll-view>
|
||||
</view>
|
||||
@@ -191,9 +191,12 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { onLoad } from '@dcloudio/uni-app'
|
||||
import { post } from '../../../../api/request'
|
||||
import { BASE_URL } from '../../../../config'
|
||||
|
||||
type RecordStatus = 'pending' | 'used' | 'cancelled' | 'auto_used'
|
||||
type RecordStatus = 'pending' | 'cancelled' | 'used'
|
||||
|
||||
interface ReserveRecord {
|
||||
id: string
|
||||
@@ -209,46 +212,95 @@ interface ReserveRecord {
|
||||
cancelTime?: string
|
||||
verifyTime?: string
|
||||
verifyPhoto?: string
|
||||
cardInstruction?: string
|
||||
cardModelId?: string
|
||||
}
|
||||
|
||||
const tabs = [
|
||||
{ label: '全部', value: '' },
|
||||
{ label: '待入园', value: 'pending' },
|
||||
{ label: '待使用', value: 'pending' },
|
||||
{ label: '已入园', value: 'used' },
|
||||
{ label: '已取消', value: 'cancelled' },
|
||||
{ label: '自动核销', value: 'auto_used' },
|
||||
]
|
||||
|
||||
const activeTab = ref('')
|
||||
const loading = ref(false)
|
||||
const refreshing = ref(false)
|
||||
const noMore = ref(false)
|
||||
const pageNo = ref(1)
|
||||
const pageSize = 10
|
||||
const codePopup = ref<any>(null)
|
||||
const cancelPopup = ref<any>(null)
|
||||
const detailPopup = ref<any>(null)
|
||||
const popupCode = ref('')
|
||||
const codeSrc = ref('')
|
||||
const detailRecord = ref<ReserveRecord | null>(null)
|
||||
const cancelRecord = ref<ReserveRecord | null>(null)
|
||||
|
||||
const records = ref<ReserveRecord[]>([
|
||||
{ id: 'YP26062601', scenicName: '武功山金顶景区', date: '2026-06-27', session: '08:00-10:00', status: 'pending', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012398', applyTime: '2026-06-25 14:30' },
|
||||
{ id: 'YP26062502', scenicName: '杨岐山孽龙洞景区', date: '2026-06-26', session: '10:00-12:00', status: 'pending', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012399', applyTime: '2026-06-24 10:15' },
|
||||
{ id: 'YP26062003', scenicName: '仙凤三宝景区', date: '2026-06-20', session: '14:00-16:00', status: 'used', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012400', applyTime: '2026-06-18 09:20', verifyTime: '2026-06-20 09:45', verifyPhoto: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?fm=jpg&q=80&w=200&h=200&fit=crop' },
|
||||
{ id: 'YP26061804', scenicName: '武功山君澜温泉', date: '2026-06-18', status: 'cancelled', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012401', applyTime: '2026-06-16 16:40', cancelTime: '2026-06-17 20:30' },
|
||||
{ id: 'YP26061505', scenicName: '凯光新天地生态旅游区', date: '2026-06-15', status: 'auto_used', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012402', applyTime: '2026-06-13 11:05', verifyTime: '2026-06-16 00:00', verifyPhoto: 'https://images.unsplash.com/photo-1527980965255-d3b416303d12?fm=jpg&q=80&w=200&h=200&fit=crop' },
|
||||
{ id: 'YP26061006', scenicName: '和平农场·哎马乐园', date: '2026-06-10', session: '08:00-12:00', status: 'used', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012403', applyTime: '2026-06-08 08:50', verifyTime: '2026-06-10 10:22', verifyPhoto: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?fm=jpg&q=80&w=200&h=200&fit=crop' },
|
||||
])
|
||||
const records = ref<ReserveRecord[]>([])
|
||||
|
||||
const filteredRecords = computed(() => {
|
||||
if (!activeTab.value) return records.value
|
||||
return records.value.filter(r => r.status === activeTab.value)
|
||||
})
|
||||
const filteredRecords = computed(() => records.value)
|
||||
|
||||
function statusLabel(status: string) {
|
||||
const map: Record<string, string> = { pending: '待入园', used: '已入园', cancelled: '已取消', auto_used: '自动核销' }
|
||||
const map: Record<string, string> = { pending: '待使用', cancelled: '已取消', used: '已入园' }
|
||||
return map[status] || status
|
||||
}
|
||||
|
||||
function detailStatusLabel(status: number | string) {
|
||||
const map: Record<string, string> = { '1': '预约成功', '2': '预约失败', '3': '已取消', '4': '已入园' }
|
||||
return map[String(status)] || String(status)
|
||||
}
|
||||
|
||||
async function fetchRecords(append = false) {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
const res = await post<any>('/mpReserve/queryUsageRecordList', {
|
||||
phone,
|
||||
status: activeTab.value,
|
||||
pageNo: pageNo.value,
|
||||
pageSize,
|
||||
})
|
||||
const list = (res.list || res.records || []).map((item: any) => ({
|
||||
id: item.id || '',
|
||||
scenicName: item.scenicName || item.resourceName || '',
|
||||
date: item.appTime || item.date || '',
|
||||
session: item.sessionTime || item.session || '',
|
||||
status: item.status || 'pending',
|
||||
userName: item.name || item.userName || '',
|
||||
maskedId: item.identityNo ? `${item.identityNo.slice(0, 6)}****${item.identityNo.slice(-4)}` : '',
|
||||
cardType: item.cardModelName || item.cardType || '',
|
||||
cardNo: item.cardNum || item.cardNo || '',
|
||||
applyTime: item.createTime || item.applyTime || '',
|
||||
cancelTime: item.cancelTime || '',
|
||||
verifyTime: item.verifyTime || '',
|
||||
verifyPhoto: item.verifyPhoto || '',
|
||||
cardInstruction: item.cardInstruction || '',
|
||||
cardModelId: item.cardModelId || item.thirdId || '',
|
||||
}))
|
||||
if (append) {
|
||||
records.value.push(...list)
|
||||
} else {
|
||||
records.value = list
|
||||
}
|
||||
noMore.value = list.length < pageSize
|
||||
} catch (_e) {
|
||||
records.value = []
|
||||
noMore.value = true
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onLoad(() => {
|
||||
fetchRecords()
|
||||
})
|
||||
|
||||
watch(activeTab, () => {
|
||||
pageNo.value = 1
|
||||
fetchRecords()
|
||||
})
|
||||
|
||||
function canCancel(date: string) {
|
||||
const today = new Date()
|
||||
today.setHours(0, 0, 0, 0)
|
||||
@@ -261,40 +313,105 @@ function showCancelModal(r: ReserveRecord) {
|
||||
cancelPopup.value?.open()
|
||||
}
|
||||
|
||||
function confirmCancel() {
|
||||
async function confirmCancel() {
|
||||
cancelPopup.value?.close()
|
||||
if (cancelRecord.value) {
|
||||
const idx = records.value.findIndex(o => o.id === cancelRecord.value!.id)
|
||||
if (idx !== -1) {
|
||||
records.value[idx].status = 'cancelled'
|
||||
records.value[idx].cancelTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
try {
|
||||
await post<any>(`${BASE_URL}/zhy-api/cardBag/cancelAppoint`, {
|
||||
appointId: cancelRecord.value.id,
|
||||
channel: 'Channel_SZH',
|
||||
})
|
||||
const idx = records.value.findIndex(o => o.id === cancelRecord.value!.id)
|
||||
if (idx !== -1) {
|
||||
records.value[idx].status = 'cancelled'
|
||||
records.value[idx].cancelTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
|
||||
}
|
||||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||||
} catch (e: any) {
|
||||
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
|
||||
}
|
||||
uni.showToast({ title: '已取消预约', icon: 'success' })
|
||||
}
|
||||
}
|
||||
|
||||
function showCode(r: ReserveRecord) {
|
||||
popupCode.value = r.id
|
||||
codePopup.value?.open()
|
||||
async function showCode(r: ReserveRecord) {
|
||||
codeSrc.value = ''
|
||||
try {
|
||||
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${r.id}`)
|
||||
if (res.qrType == 1) {
|
||||
codeSrc.value = 'data:image/png;base64,' + res.qrCode
|
||||
codePopup.value?.open()
|
||||
} else if (res.qrType == 2) {
|
||||
codeSrc.value = res.qrUrl
|
||||
codePopup.value?.open()
|
||||
} else {
|
||||
await fetchQrByCard(r)
|
||||
}
|
||||
} catch (_e) {
|
||||
await fetchQrByCard(r)
|
||||
}
|
||||
}
|
||||
|
||||
function viewDetail(r: ReserveRecord) {
|
||||
detailRecord.value = r
|
||||
async function fetchQrByCard(r: ReserveRecord) {
|
||||
try {
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/qrAndUserInfo`, {
|
||||
cardNum: r.cardNo,
|
||||
channel: 'Channel_SZH',
|
||||
mobile: phone,
|
||||
scenicId: '',
|
||||
thirdId: r.cardModelId,
|
||||
})
|
||||
if (res.qrCode) {
|
||||
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
|
||||
codePopup.value?.open()
|
||||
} else {
|
||||
uni.showToast({ icon: 'none', title: '暂无核销码' })
|
||||
}
|
||||
} catch (_e) {
|
||||
uni.showToast({ icon: 'none', title: '获取入园码失败' })
|
||||
}
|
||||
}
|
||||
|
||||
async function viewDetail(r: ReserveRecord) {
|
||||
try {
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
const res = await post<any>('/mpReserve/getAppointmentDetail', { phone, appointId: r.id })
|
||||
if (res) {
|
||||
detailRecord.value = {
|
||||
id: res.id || r.id,
|
||||
scenicName: res.resourceName || r.scenicName,
|
||||
date: res.appTime || r.date,
|
||||
session: res.sessionTime || r.session,
|
||||
status: res.status || r.status || 'pending',
|
||||
userName: res.name || r.userName,
|
||||
maskedId: res.identityNo ? `${res.identityNo.slice(0, 6)}****${res.identityNo.slice(-4)}` : r.maskedId,
|
||||
cardType: res.cardModelName || r.cardType,
|
||||
cardNo: res.cardNum || r.cardNo,
|
||||
applyTime: res.createTime || r.applyTime,
|
||||
cancelTime: res.cancelTime || r.cancelTime,
|
||||
verifyTime: res.verifyTime || r.verifyTime,
|
||||
verifyPhoto: res.verifyPhoto || r.verifyPhoto,
|
||||
cardInstruction: res.cardInstruction || r.cardInstruction || '',
|
||||
cardModelId: res.cardModelId || r.cardModelId || '',
|
||||
}
|
||||
}
|
||||
} catch (_e) {
|
||||
detailRecord.value = r
|
||||
}
|
||||
detailPopup.value?.open()
|
||||
}
|
||||
|
||||
function onRefresh() {
|
||||
async function onRefresh() {
|
||||
refreshing.value = true
|
||||
setTimeout(() => { refreshing.value = false }, 800)
|
||||
pageNo.value = 1
|
||||
await fetchRecords()
|
||||
refreshing.value = false
|
||||
}
|
||||
|
||||
function onLoadMore() {
|
||||
async function onLoadMore() {
|
||||
if (loading.value || noMore.value) return
|
||||
loading.value = true
|
||||
setTimeout(() => {
|
||||
loading.value = false
|
||||
noMore.value = true
|
||||
}, 800)
|
||||
pageNo.value++
|
||||
await fetchRecords(true)
|
||||
}
|
||||
|
||||
function goReserve() {
|
||||
@@ -392,9 +509,8 @@ function previewPhoto(url: string) {
|
||||
border-radius: 8rpx;
|
||||
|
||||
&--pending { background: #e8f3ff; }
|
||||
&--used { background: #f0fff4; }
|
||||
&--cancelled { background: $uv-bg-color; }
|
||||
&--auto_used { background: #fff8e8; }
|
||||
&--used { background: #f0fff4; }
|
||||
}
|
||||
|
||||
.status-tag__text {
|
||||
@@ -404,9 +520,8 @@ function previewPhoto(url: string) {
|
||||
}
|
||||
|
||||
.status-tag--pending .status-tag__text { color: $uv-primary; }
|
||||
.status-tag--used .status-tag__text { color: $color-status-success; }
|
||||
.status-tag--cancelled .status-tag__text { color: $color-status-cancel; }
|
||||
.status-tag--auto_used .status-tag__text { color: $color-status-pending; }
|
||||
.status-tag--used .status-tag__text { color: $color-status-success; }
|
||||
|
||||
.record-card-info {
|
||||
display: flex;
|
||||
@@ -511,7 +626,7 @@ function previewPhoto(url: string) {
|
||||
|
||||
.qr-img { width: 300rpx; height: 300rpx; }
|
||||
|
||||
.code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; margin-top: 24rpx; }
|
||||
.code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; }
|
||||
|
||||
/* ===== Cancel Modal ===== */
|
||||
.cancel-modal {
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
<template>
|
||||
<view class="page">
|
||||
<!-- 成功图标区域 -->
|
||||
<!-- 状态图标区域 -->
|
||||
<view class="success-header">
|
||||
<view class="success-icon">
|
||||
<view class="success-circle" />
|
||||
<view class="success-check" />
|
||||
<view class="success-icon" :class="`success-icon--${status}`">
|
||||
<view v-if="status === 1" class="success-check" />
|
||||
<view v-else-if="status === 2" class="fail-cross" />
|
||||
<view v-else-if="status === 3" class="cancel-icon" />
|
||||
<view v-else-if="status === 4" class="used-icon" />
|
||||
</view>
|
||||
<text class="success-title">预约成功</text>
|
||||
<text class="success-desc">请携带本人身份证+入园码按时入园</text>
|
||||
<text class="success-title">{{ statusTitle }}</text>
|
||||
<text class="success-desc">{{ statusDesc }}</text>
|
||||
</view>
|
||||
|
||||
<!-- 预约信息卡片 -->
|
||||
@@ -36,25 +38,25 @@
|
||||
<text class="info-label">预约编号</text>
|
||||
<text class="info-value">{{ reserveId }}</text>
|
||||
</view>
|
||||
<view class="info-row info-row--last">
|
||||
<view v-if="status === 1" class="info-row info-row--last">
|
||||
<text class="info-label">入园凭证</text>
|
||||
<view class="code-btn" @tap="showCode">
|
||||
<text class="code-btn__text">查看入园码</text>
|
||||
</view>
|
||||
</view>
|
||||
<view v-else class="info-row info-row--last">
|
||||
<text class="info-label">预约状态</text>
|
||||
<text class="info-value">{{ statusLabel }}</text>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
<!-- 重要提醒 -->
|
||||
<view class="warn-card">
|
||||
<view class="warn-card" v-if="cardInstruction">
|
||||
<text class="warn-title">重要预约规则</text>
|
||||
<view class="warn-list">
|
||||
<view class="warn-item">
|
||||
<view class="warn-item" v-for="(item, idx) in cardInstruction.split('\n')" :key="idx">
|
||||
<view class="warn-dot" />
|
||||
<text class="warn-text">预约当日无法取消,如需改期请至少提前1天操作</text>
|
||||
</view>
|
||||
<view class="warn-item">
|
||||
<view class="warn-dot" />
|
||||
<text class="warn-text">爽约未取消将扣除年卡使用次数,无法恢复</text>
|
||||
<text class="warn-text">{{ item }}</text>
|
||||
</view>
|
||||
</view>
|
||||
</view>
|
||||
@@ -74,7 +76,7 @@
|
||||
<view class="code-modal">
|
||||
<text class="code-modal__title">入园码</text>
|
||||
<view class="qr-box">
|
||||
<image class="qr-img" src="/static/qr-demo.png" mode="aspectFit" />
|
||||
<image v-if="codeSrc" class="qr-img" :src="codeSrc" mode="aspectFit" />
|
||||
</view>
|
||||
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
|
||||
</view>
|
||||
@@ -85,24 +87,56 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { onLoad, onBackPress } from '@dcloudio/uni-app'
|
||||
import { post } from '../../../../api/request'
|
||||
import { BASE_URL } from '../../../../config'
|
||||
|
||||
const reserveId = ref('YP26062601')
|
||||
const scenicName = ref('武功山金顶景区')
|
||||
const reserveDate = ref('2026-06-27(周六)')
|
||||
const status = ref(1)
|
||||
const statusTitle = ref('预约成功')
|
||||
const statusDesc = ref('请携带本人身份证+入园码按时入园')
|
||||
const statusLabel = ref('待入园')
|
||||
const reserveId = ref('')
|
||||
const scenicName = ref('')
|
||||
const reserveDate = ref('')
|
||||
const reserveSession = ref('')
|
||||
const userName = ref('张三')
|
||||
const maskedId = ref('360302****1234')
|
||||
const userName = ref('')
|
||||
const maskedId = ref('')
|
||||
const cardInstruction = ref('')
|
||||
const codeSrc = ref('')
|
||||
const codePopup = ref<any>(null)
|
||||
const cardNumber = ref('')
|
||||
const cardModelId = ref('')
|
||||
|
||||
onLoad((query: any) => {
|
||||
if (query?.reserveId) reserveId.value = query.reserveId
|
||||
if (query?.date) {
|
||||
const d = new Date(query.date)
|
||||
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
reserveDate.value = `${query.date}(${weekNames[d.getDay()]})`
|
||||
onLoad(async (query: any) => {
|
||||
const appointId = query?.appointId
|
||||
if (!appointId) return
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
cardNumber.value = query?.cardNumber || ''
|
||||
cardModelId.value = query?.cardModelId || ''
|
||||
try {
|
||||
const res = await post<any>('/mpReserve/getAppointmentDetail', { phone, appointId })
|
||||
if (res) {
|
||||
const s = Number(res.status) || 1
|
||||
status.value = s
|
||||
const statusMap: Record<number, { title: string; desc: string; label: string }> = {
|
||||
1: { title: '预约成功', desc: '请携带本人身份证+入园码按时入园', label: '待入园' },
|
||||
2: { title: '预约失败', desc: '预约申请未通过,请重新预约', label: '预约失败' },
|
||||
3: { title: '已取消', desc: '该预约已被取消', label: '已取消' },
|
||||
4: { title: '已入园', desc: '已成功核销入园', label: '已入园' },
|
||||
}
|
||||
const info = statusMap[s] || statusMap[1]
|
||||
statusTitle.value = info.title
|
||||
statusDesc.value = info.desc
|
||||
statusLabel.value = info.label
|
||||
reserveId.value = res.id || appointId
|
||||
scenicName.value = res.resourceName || ''
|
||||
reserveDate.value = res.appTime || ''
|
||||
reserveSession.value = res.sessionTime || ''
|
||||
userName.value = res.name || ''
|
||||
maskedId.value = res.identityNo ? `${res.identityNo.slice(0, 6)}****${res.identityNo.slice(-4)}` : ''
|
||||
cardInstruction.value = res.cardInstruction || ''
|
||||
}
|
||||
} catch (_e) {
|
||||
}
|
||||
if (query?.scenic) scenicName.value = decodeURIComponent(query.scenic)
|
||||
if (query?.session) reserveSession.value = decodeURIComponent(query.session)
|
||||
})
|
||||
|
||||
// 拦截返回,跳转预约记录页
|
||||
@@ -111,8 +145,42 @@ onBackPress(() => {
|
||||
return true
|
||||
})
|
||||
|
||||
function showCode() {
|
||||
codePopup.value?.open()
|
||||
async function showCode() {
|
||||
try {
|
||||
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${reserveId.value}`)
|
||||
if (res.qrType == 1) {
|
||||
codeSrc.value = 'data:image/png;base64,' + res.qrCode
|
||||
codePopup.value?.open()
|
||||
} else if (res.qrType == 2) {
|
||||
codeSrc.value = res.qrUrl
|
||||
codePopup.value?.open()
|
||||
} else {
|
||||
await fetchQrByCard()
|
||||
}
|
||||
} catch (_e) {
|
||||
await fetchQrByCard()
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchQrByCard() {
|
||||
try {
|
||||
const phone = (uni.getStorageSync('phone') as string) || ''
|
||||
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/qrAndUserInfo`, {
|
||||
cardNum: cardNumber.value,
|
||||
channel: 'Channel_SZH',
|
||||
mobile: phone,
|
||||
scenicId: '',
|
||||
thirdId: cardModelId.value,
|
||||
})
|
||||
if (res.qrCode) {
|
||||
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
|
||||
codePopup.value?.open()
|
||||
} else {
|
||||
uni.showToast({ icon: 'none', title: '暂无核销码' })
|
||||
}
|
||||
} catch (_e) {
|
||||
uni.showToast({ icon: 'none', title: '获取入园码失败' })
|
||||
}
|
||||
}
|
||||
|
||||
function goRecord() {
|
||||
@@ -135,7 +203,7 @@ function goHome() {
|
||||
font-family: $font-family-body;
|
||||
}
|
||||
|
||||
/* ===== 成功图标 ===== */
|
||||
/* ===== 状态图标 ===== */
|
||||
.success-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -147,13 +215,13 @@ function goHome() {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.success-circle {
|
||||
width: 120rpx;
|
||||
height: 120rpx;
|
||||
border-radius: 50%;
|
||||
background: rgba(60, 156, 255, 0.1);
|
||||
|
||||
&--1 { background: rgba(60, 156, 255, 0.1); }
|
||||
&--2 { background: rgba(245, 63, 63, 0.1); }
|
||||
&--3 { background: rgba(153, 153, 153, 0.1); }
|
||||
&--4 { background: rgba(60, 179, 113, 0.1); }
|
||||
}
|
||||
|
||||
.success-check {
|
||||
@@ -167,6 +235,65 @@ function goHome() {
|
||||
left: 38rpx;
|
||||
}
|
||||
|
||||
.fail-cross {
|
||||
width: 48rpx;
|
||||
height: 48rpx;
|
||||
position: absolute;
|
||||
top: 36rpx;
|
||||
left: 36rpx;
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 48rpx;
|
||||
height: 6rpx;
|
||||
background: #f53f3f;
|
||||
border-radius: 3rpx;
|
||||
}
|
||||
&::before { transform: translate(-50%, -50%) rotate(45deg); }
|
||||
&::after { transform: translate(-50%, -50%) rotate(-45deg); }
|
||||
}
|
||||
|
||||
.cancel-icon {
|
||||
width: 12rpx;
|
||||
height: 12rpx;
|
||||
border-radius: 50%;
|
||||
background: #999;
|
||||
position: absolute;
|
||||
top: 54rpx;
|
||||
left: 54rpx;
|
||||
&::before, &::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
background: #999;
|
||||
border-radius: 2rpx;
|
||||
}
|
||||
&::before {
|
||||
width: 4rpx;
|
||||
height: 32rpx;
|
||||
top: -24rpx;
|
||||
left: 4rpx;
|
||||
}
|
||||
&::after {
|
||||
width: 24rpx;
|
||||
height: 4rpx;
|
||||
top: 4rpx;
|
||||
left: -6rpx;
|
||||
}
|
||||
}
|
||||
|
||||
.used-icon {
|
||||
width: 48rpx;
|
||||
height: 24rpx;
|
||||
border-left: 6rpx solid #3CB371;
|
||||
border-bottom: 6rpx solid #3CB371;
|
||||
transform: rotate(-45deg);
|
||||
position: absolute;
|
||||
top: 40rpx;
|
||||
left: 38rpx;
|
||||
}
|
||||
|
||||
.success-title {
|
||||
font-family: $font-family-body;
|
||||
font-size: $font-size-title;
|
||||
|
||||
Reference in New Issue
Block a user