refactor: move pages to subpackages to reduce main package size

This commit is contained in:
jacobxu666
2026-06-26 11:59:59 +08:00
parent 4b7c540343
commit 4acb2e6a9c
18 changed files with 113 additions and 108 deletions
+367
View File
@@ -0,0 +1,367 @@
<template>
<view class="page">
<!-- 购买类 -->
<view class="category">
<text class="category-title">购买类</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>
</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>
<!-- 底部安全区 -->
<view class="safe-bottom" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
// 展开状态:默认全部展开
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]
}
function isOpen(cat: string, idx: number) {
return openMap.value[`${cat}${idx}`] ?? false
}
function copyText(text: string) {
uni.setClipboardData({
data: text,
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>
.page {
min-height: 100vh;
background: $color-background;
padding: 48rpx 24rpx 0;
font-family: $font-family-body;
}
/* ===== 分类区块 ===== */
.category {
margin-bottom: 32rpx;
}
.category-title {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
display: block;
margin-bottom: 16rpx;
}
.category-divider {
height: 1rpx;
background: $uv-border-color;
margin-bottom: 24rpx;
}
/* ===== 问答卡片 ===== */
.faq-card {
background: $color-card;
border-radius: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
margin-bottom: 20rpx;
overflow: hidden;
}
.faq-header {
display: flex;
align-items: flex-start;
padding: 32rpx;
transition: background 0.15s ease;
&:active {
background: $uv-bg-color;
}
}
.faq-index {
width: 40rpx;
height: 40rpx;
border-radius: 50%;
background: rgba(60, 156, 255, 0.08);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
margin-right: 16rpx;
margin-top: 4rpx;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: $uv-primary;
}
.faq-question {
flex: 1;
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $uv-main-color;
line-height: 1.5;
}
.faq-arrow {
width: 16rpx;
height: 16rpx;
border-right: 3rpx solid #cccccc;
border-bottom: 3rpx solid #cccccc;
transform: rotate(45deg);
flex-shrink: 0;
margin-left: 16rpx;
margin-top: 10rpx;
transition: transform 0.2s ease;
}
.faq-arrow--up {
transform: rotate(-135deg);
}
/* ===== 回答区域 ===== */
.faq-body {
padding: 0 32rpx 32rpx;
}
.faq-paragraph {
margin-bottom: 16rpx;
&:last-child {
margin-bottom: 0;
}
}
.faq-text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
line-height: 1.7;
}
.faq-highlight {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-primary;
line-height: 1.7;
}
/* ===== 底部安全区 ===== */
.safe-bottom {
height: 64rpx;
padding-bottom: env(safe-area-inset-bottom);
}
</style>
+201
View File
@@ -0,0 +1,201 @@
<template>
<view class="page">
<view class="list-container">
<view
v-for="(item, index) in notices"
:key="item.id"
class="notice-card fade-in"
:style="{ animationDelay: index * 60 + 'ms' }"
@click="openDetail(item)"
>
<!-- 第一行:标题 + 日期 + 箭头 -->
<view class="card-header">
<view class="header-text">
<text class="card-title">{{ item.title }}</text>
<text class="card-summary">{{ item.content }}</text>
</view>
<text class="card-date">{{ item.date }}</text>
<view class="card-arrow" />
</view>
</view>
</view>
<!-- 公告详情弹窗 -->
<AnnouncementModal
v-model="showModal"
:title="modalTitle"
:body="modalBody"
/>
<!-- 底部安全区 -->
<view class="safe-bottom" />
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import AnnouncementModal from '../../../components/AnnouncementModal.vue'
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 showModal = ref(false)
const modalTitle = ref('公告')
const modalBody = ref('')
function openDetail(item: NoticeItem) {
modalTitle.value = item.title
modalBody.value = item.body
showModal.value = true
}
</script>
<style lang="scss" scoped>
/* ================================================================
通知公告列表页 — Premium Utilitarian Minimalism
Typography:
Body/UI: PingFang SC (system)
Title: 28rpx / 600 weight / #1A1A1A / tracking -0.3rpx
Summary: 26rpx / 400 weight / #888888 / line-height 1.6
Date: 22rpx / 400 weight / #BBBBBB
Action: 26rpx / 500 weight / #3C9CFF
Color palette:
Canvas: #F7F8FA
Surface: #FFFFFF
Title: #1A1A1A (off-black, never pure #000)
Body: #888888
Muted: #BBBBBB
Primary: #3C9CFF
Icon bg: #ECF5FF ($uv-primary-light)
Spacing (rpx):
Card radius: 16
Card gap: 24
Side margin: 30
Card padding: 32 / 28
Icon: 56 × 56, radius 14
Icon→title: 16
Title→summary: 16
Summary→action: 24
================================================================ */
.page {
min-height: 100vh;
background: #F7F8FA;
font-family: 'SourceHanSerifCN', '思源宋体', -apple-system, 'PingFang SC', sans-serif;
}
.list-container {
padding: 24rpx 30rpx;
}
/* ====== 入场动画 ====== */
@keyframes cardFadeIn {
from {
opacity: 0;
transform: translateY(16rpx);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.fade-in {
animation: cardFadeIn 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* ====== 卡片 ====== */
.notice-card {
background: #FFFFFF;
border-radius: 24rpx;
padding: 32rpx 28rpx;
margin-bottom: 24rpx;
border: 1rpx solid rgba(0, 0, 0, 0.04);
&:active {
opacity: 0.88;
transition: opacity 100ms;
}
}
/* ====== 卡片内容行 ====== */
.card-header {
display: flex;
align-items: center;
}
/* 左侧文字区(标题 + 摘要) */
.header-text {
flex: 1;
min-width: 0;
margin-right: 16rpx;
}
/* 标题 */
.card-title {
display: block;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
line-height: 1.3;
letter-spacing: -0.3rpx;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
margin-bottom: 8rpx;
}
/* 摘要 */
.card-summary {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
font-size: $font-size-sm;
font-weight: $font-weight-normal;
color: $uv-tips-color;
line-height: 1.6;
}
/* 日期 */
.card-date {
font-size: 22rpx;
font-weight: $font-weight-normal;
color: $uv-placeholder-color;
flex-shrink: 0;
letter-spacing: 0.5rpx;
}
/* 右箭头 */
.card-arrow {
width: 14rpx;
height: 14rpx;
border-right: 3rpx solid #dadbde;
border-bottom: 3rpx solid #dadbde;
transform: rotate(-45deg);
flex-shrink: 0;
margin-left: 16rpx;
}
/* ====== 底部安全区 ====== */
.safe-bottom {
height: calc(40rpx + env(safe-area-inset-bottom));
}
</style>
@@ -0,0 +1,618 @@
<template>
<view class="page">
<!-- 模块1:轮播大图 -->
<swiper
class="hero-swiper"
:indicator-dots="scenic.images.length > 1"
indicator-color="rgba(255,255,255,0.4)"
indicator-active-color="#ffffff"
:autoplay="scenic.images.length > 1"
circular
:interval="4000"
>
<swiper-item v-for="(img, idx) in scenic.images" :key="idx">
<image class="hero-img" :src="img" mode="aspectFill" />
</swiper-item>
</swiper>
<!-- 模块2:基础信息卡片 -->
<view class="info-card">
<!-- 标题行 -->
<view class="title-row">
<text class="scenic-name">{{ scenic.name }}</text>
</view>
<!-- 预约提醒横幅 -->
<view v-if="scenic.needReservation" class="notice-banner">
<view class="icon-wrap icon-alert" />
<text class="notice-text">需预约后出行,未预约无法入园</text>
</view>
<!-- 特色标签行 -->
<view class="tags-row" v-if="scenic.tags && scenic.tags.length">
<view class="feature-tag" v-for="tag in scenic.tags" :key="tag">
<text>{{ tag }}</text>
</view>
</view>
<!-- 预约时限 -->
<view v-if="scenic.needReservation && scenic.bookingDeadline" class="info-line">
<text class="text-assist">{{ scenic.bookingDeadline }}</text>
</view>
<!-- 开放时间 + 运营状态 -->
<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-dot" />
<text>{{ isOpen ? '营业中' : '已闭园' }}</text>
</view>
</view>
<!-- 联系电话 -->
<view class="info-line clickable" @click="onPhoneClick">
<view class="icon-wrap icon-phone" />
<text class="text-body">{{ scenic.phone }}</text>
<view class="icon-wrap icon-chevron chevron-right" />
</view>
<!-- 地址 -->
<view class="info-line clickable" @click="onCopyAddress">
<view class="icon-wrap icon-map-pin" />
<text class="text-body address-text">{{ scenic.address }}</text>
<view class="map-btn" @click.stop="onCopyAddress">
<text>复制</text>
</view>
</view>
</view>
<!-- 模块3:卡种权益卡片(独立白卡,无绑定自动隐藏) -->
<view
class="benefit-card"
v-for="card in scenic.cards"
:key="card.type"
>
<view class="benefit-top">
<view class="card-type-tag" :class="card.type">
<text>{{ card.type === 'super' ? '超级玩家卡' : '赣湘全域旅游年卡' }}</text>
</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="icon-wrap icon-crown" />
<text class="member-text">持卡用户免费</text>
</view>
</view>
<text class="remaining-text">当前卡剩余可使用 {{ card.remaining }} 次</text>
<view class="book-btn" @click="onBookClick(card.type)">
<text>立即预约</text>
</view>
</view>
<!-- 模块4:Tab切换栏 -->
<view class="tab-bar">
<view
class="tab-item"
:class="{ active: activeTab === 'notice' }"
@click="activeTab = 'notice'"
>
<text>预约须知</text>
<view v-if="activeTab === 'notice'" class="tab-indicator" />
</view>
<view
class="tab-item"
:class="{ active: activeTab === 'intro' }"
@click="activeTab = 'intro'"
>
<text>景区介绍</text>
<view v-if="activeTab === 'intro'" class="tab-indicator" />
</view>
</view>
<!-- 模块5:Tab内容区域 -->
<view class="tab-content">
<view v-if="activeTab === 'notice'" class="content-section">
<view class="content-block" v-for="(item, idx) in scenic.bookingNotice" :key="idx">
<text class="block-title">{{ item.title }}</text>
<view class="divider" />
<text class="block-text">{{ item.content }}</text>
</view>
</view>
<view v-if="activeTab === 'intro'" class="content-section">
<text class="intro-text">{{ scenic.introduction }}</text>
</view>
</view>
<!-- 底部安全区 -->
<view class="safe-bottom" />
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
interface NoticeItem { title: string; content: string }
interface CardBenefit { type: 'super' | 'annual'; remaining: number }
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[]
}
const scenic = ref<ScenicDetail>({ tags: [], images: [], cards: [], bookingNotice: [] } as unknown as ScenicDetail)
const activeTab = ref<'notice' | 'intro'>('notice')
/** 根据营业时间判断运营状态 */
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) },
]
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 })
}
})
function onHoursClick() {
uni.showToast({ title: '营业时间仅供参考', icon: 'none' })
}
function onPhoneClick() {
uni.makePhoneCall({ phoneNumber: scenic.value.phone })
}
function onCopyAddress() {
uni.setClipboardData({
data: scenic.value.address,
success: () => {
uni.showToast({ title: '地址已复制', icon: 'success' })
}
})
}
function onBookClick(cardType: string) {
uni.navigateTo({ url: '/packages/user/pages/reserve/index' })
}
</script>
<style lang="scss" scoped>
/* Lucide 图标 data URI — 统一 28rpx、stroke-width:2 */
$icon-size: 28rpx;
$icon-alert: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23ff7d00' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3'/%3E%3Cpath d='M12 9v4'/%3E%3Cpath d='M12 17h.01'/%3E%3C/svg%3E");
$icon-clock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='10'/%3E%3Cpolyline points='12 6 12 12 16 14'/%3E%3C/svg%3E");
$icon-phone: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M22 16.92v3a2 2 0 0 1-2.18 2 19.79 19.79 0 0 1-8.63-3.07 19.5 19.5 0 0 1-6-6 19.79 19.79 0 0 1-3.07-8.67A2 2 0 0 1 4.11 2h3a2 2 0 0 1 2 1.72 12.84 12.84 0 0 0 .7 2.81 2 2 0 0 1-.45 2.11L8.09 9.91a16 16 0 0 0 6 6l1.27-1.27a2 2 0 0 1 2.11-.45 12.84 12.84 0 0 0 2.81.7A2 2 0 0 1 22 16.92z'/%3E%3C/svg%3E");
$icon-chevron: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23999999' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 18 6-6-6-6'/%3E%3C/svg%3E");
$icon-map-pin: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23666666' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0Z'/%3E%3Ccircle cx='12' cy='10' r='3'/%3E%3C/svg%3E");
$icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%23f9ae3d' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m2 4 3 12h14l3-12-6 7-4-7-4 7-6-7zm3 16h14'/%3E%3C/svg%3E");
.page {
min-height: 100vh;
background: $color-background;
font-family: 'SourceHanSerifCN', '思源宋体', serif;
}
/* ====== 图标基础 ====== */
.icon-wrap {
width: $icon-size;
height: $icon-size;
background-size: contain;
background-repeat: no-repeat;
background-position: center;
flex-shrink: 0;
}
.icon-alert { background-image: $icon-alert; }
.icon-clock { background-image: $icon-clock; }
.icon-phone { background-image: $icon-phone; }
.icon-chevron { background-image: $icon-chevron; }
.chevron-right { margin-left: auto; }
.icon-map-pin { background-image: $icon-map-pin; }
.icon-crown { background-image: $icon-crown; }
/* ====== 字体层级 ====== */
.text-title {
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $uv-main-color;
line-height: 48rpx;
}
.text-body {
font-size: $font-size-base;
color: $uv-content-color;
line-height: 40rpx;
}
.text-assist {
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 32rpx;
}
.text-hint {
font-size: 22rpx;
color: $uv-placeholder-color;
line-height: 32rpx;
}
/* ====== 模块1:轮播大图 ====== */
.hero-swiper {
width: 100%;
height: 480rpx;
:deep(.uni-swiper-dots) {
z-index: 10;
bottom: 64rpx !important;
}
:deep(.uni-swiper-dot) {
width: 16rpx;
height: 16rpx;
border-radius: 50%;
margin: 0 8rpx !important;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.3);
}
:deep(.uni-swiper-dot-active) {
width: 32rpx;
border-radius: 8rpx;
}
}
.hero-img {
width: 100%;
height: 480rpx;
}
/* ====== 模块2:基础信息卡片 ====== */
.info-card {
background: $color-card;
margin: -40rpx $spacing-3 0;
padding: $spacing-3;
border-radius: $radius-lg;
box-shadow: $shadow-base;
position: relative;
z-index: 5;
}
.title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
margin-bottom: $spacing-2;
}
.scenic-name {
@extend .text-title;
flex: 1;
}
.notice-banner {
display: flex;
align-items: center;
background: $uv-warning-light;
border-radius: $radius-base;
padding: $spacing-2;
margin-bottom: $spacing-2;
gap: 12rpx;
}
.notice-text {
font-size: $font-size-sm;
color: #ff7d00;
font-weight: $font-weight-medium;
}
.tags-row {
display: flex;
flex-wrap: wrap;
gap: 12rpx;
margin-bottom: $spacing-2;
}
.feature-tag {
padding: 4rpx 16rpx;
border: $border-width solid rgba($uv-primary, 0.3);
border-radius: $radius-sm;
text {
font-size: $font-size-sm;
color: $uv-primary;
}
}
.info-line {
display: flex;
align-items: center;
min-height: 64rpx;
padding: 16rpx 0;
gap: 12rpx;
border-bottom: $border-width solid rgba($uv-border-color, 0.3);
&:last-child {
border-bottom: none;
}
&.clickable {
min-height: 88rpx;
transition: background $transition-fast;
&:active {
background: $color-muted;
margin: 0 (-$spacing-3);
padding-left: $spacing-3;
padding-right: $spacing-3;
}
}
}
.address-text {
flex: 1;
line-height: 1.5;
}
/* 运营状态标签 */
.status-tag {
display: flex;
align-items: center;
gap: 8rpx;
margin-left: auto;
flex-shrink: 0;
text {
font-size: $font-size-sm;
font-weight: $font-weight-medium;
}
&.open text { color: $uv-success; }
&.closed text { color: $uv-error; }
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
.open & { background: $uv-success; }
.closed & { background: $uv-error; }
}
.map-btn {
padding: 8rpx 20rpx;
background: $uv-primary-light;
border-radius: $radius-sm;
flex-shrink: 0;
text {
font-size: $font-size-sm;
color: $uv-primary;
font-weight: $font-weight-medium;
}
&:active { opacity: 0.7; }
}
/* ====== 模块3:卡种权益卡片 ====== */
.benefit-card {
background: $color-card;
margin: $spacing-3 $spacing-3 $spacing-2;
padding: $spacing-3;
border-radius: $radius-lg;
box-shadow: $shadow-sm;
}
.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;
text {
font-size: 22rpx;
color: #fff;
font-weight: $font-weight-medium;
}
&.super {
background: #f9ae3d;
}
&.annual {
background: $uv-success;
}
}
.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 {
display: flex;
align-items: center;
gap: 8rpx;
}
.member-text {
font-size: $font-size-sm;
color: #f9ae3d;
font-weight: $font-weight-medium;
}
.remaining-text {
font-size: 22rpx;
color: $uv-success;
display: block;
margin-bottom: $spacing-2;
}
.book-btn {
display: flex;
align-items: center;
justify-content: center;
height: 88rpx;
background: linear-gradient(135deg, $uv-primary, $uv-primary-dark);
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-weight: $font-weight-medium;
}
&:active {
transform: scale(0.97);
opacity: 0.9;
}
}
/* ====== 模块4:Tab切换栏 ====== */
.tab-bar {
display: flex;
background: $color-card;
margin: 0 $spacing-3;
border-radius: $radius-lg $radius-lg 0 0;
box-shadow: $shadow-sm;
overflow: hidden;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: $spacing-3 0 $spacing-2;
position: relative;
text {
font-size: $font-size-base;
color: $uv-placeholder-color;
font-weight: $font-weight-normal;
}
&.active text {
color: $uv-primary;
font-weight: $font-weight-medium;
}
&:active {
background: $color-muted;
}
}
.tab-indicator {
width: 48rpx;
height: 6rpx;
background: $uv-primary;
border-radius: $radius-full;
margin-top: 8rpx;
}
/* ====== 模块5:Tab内容 ====== */
.tab-content {
background: $color-card;
margin: 0 $spacing-3;
border-radius: 0 0 $radius-lg $radius-lg;
box-shadow: $shadow-sm;
padding: $spacing-3;
}
.content-section {
animation: fadeIn 200ms ease;
}
@keyframes fadeIn {
from { opacity: 0; }
to { opacity: 1; }
}
.content-block {
margin-bottom: $spacing-3;
&:last-child { margin-bottom: 0; }
}
.block-title {
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-content-color;
display: block;
margin-bottom: 8rpx;
}
.divider {
height: $border-width;
background: rgba($uv-border-color, 0.4);
margin-bottom: $spacing-2;
}
.block-text {
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 1.8;
display: block;
}
.intro-text {
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 2;
display: block;
}
/* ====== 底部安全区 ====== */
.safe-bottom {
height: calc(40rpx + env(safe-area-inset-bottom));
}
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,21 @@
<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>
</template>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $color-card;
}
.guide-image {
width: 100%;
display: block;
}
</style>
+744
View File
@@ -0,0 +1,744 @@
<template>
<view class="page">
<!-- 1. 头部标题区 -->
<view class="header">
<view class="header-title">
<text class="header-title__bind">绑定</text>
<text class="header-title__card">赣湘全域旅游年卡/超级玩家卡</text>
</view>
<text class="header-desc">请务必填写您的真实信息,以便于景区实名制预约</text>
</view>
<!-- 2. 白色圆角表单卡片 -->
<view class="form-card">
<!-- 卡号 -->
<view class="form-row">
<text class="form-label">卡号</text>
<input
class="form-input"
v-model="form.cardNo"
placeholder="请输入卡号"
placeholder-class="placeholder"
/>
</view>
<view class="divider" />
<!-- 持卡人姓名 -->
<view class="form-row">
<text class="form-label">持卡人姓名</text>
<input
class="form-input"
v-model="form.name"
placeholder="请输入持卡人姓名"
placeholder-class="placeholder"
/>
</view>
<view class="divider" />
<!-- 持卡人手机 -->
<view class="form-row">
<text class="form-label">持卡人手机</text>
<input
class="form-input"
v-model="form.phone"
type="number"
maxlength="11"
placeholder="请输入持卡人手机号"
placeholder-class="placeholder"
/>
</view>
<view class="divider" />
<!-- 证件类型 -->
<view class="form-row" @tap="showIdTypePicker = true">
<text class="form-label">证件类型</text>
<text class="form-value">{{ docType }}</text>
<view class="chevron-right" />
</view>
<view class="divider" />
<!-- 证件号码 -->
<view class="form-row">
<text class="form-label">证件号码</text>
<input
class="form-input"
v-model="form.idCard"
:placeholder="idPlaceholder"
placeholder-class="placeholder"
:maxlength="docType === '身份证' ? 18 : 30"
/>
</view>
<view class="divider" />
<!-- 持卡人照片 -->
<view 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 v-else class="upload-placeholder">
<view class="upload-icon" />
<text class="upload-text">点击上传</text>
</view>
<view v-if="photoUrl" class="upload-remove" @tap.stop="removePhoto">
<view class="upload-remove__x" />
</view>
</view>
</view>
</view>
<!-- 3. 协议勾选区域 -->
<view class="agreement" @tap="agreed = !agreed">
<view class="checkbox" :class="{ 'checkbox--checked': agreed }">
<view v-if="agreed" class="checkbox__check" />
</view>
<view class="agreement__text">
<text class="agreement__label">我已阅读并知晓</text>
<text class="agreement__link" @tap.stop="showNotice = true">《用卡须知》</text>
</view>
</view>
<!-- 4. 底部操作按钮组 -->
<view class="btn-group">
<button
class="btn-confirm"
:class="{ 'btn-confirm--disabled': submitting }"
:disabled="submitting"
@tap="handleSubmit"
>
<text class="btn-confirm__text">{{ submitting ? '提交中...' : '确认绑卡' }}</text>
</button>
<button class="btn-cancel" @tap="handleCancel">
<text class="btn-cancel__text">取消</text>
</button>
</view>
<!-- 弹窗:证件类型选择 -->
<uni-popup ref="idTypePopup" type="bottom" :mask-click="true" @change="(e: any) => showIdTypePicker = e.show">
<view class="popup-sheet">
<view class="popup-header">
<text class="popup-title">选择证件类型</text>
<view class="popup-close" @tap="showIdTypePicker = false">
<view class="popup-close__icon" />
</view>
</view>
<view class="id-type-list">
<view
v-for="item in idTypeOptions"
:key="item"
class="id-type-item"
:class="{ 'id-type-item--active': docType === item }"
@tap="selectIdType(item)"
>
<text class="id-type-item__text">{{ item }}</text>
<view v-if="docType === item" class="id-type-item__check" />
</view>
</view>
</view>
</uni-popup>
<!-- 弹窗:用卡须知 -->
<uni-popup ref="noticePopup" type="bottom" :mask-click="true" @change="(e: any) => showNotice = e.show">
<view class="popup-sheet">
<view class="popup-header">
<text class="popup-title">用卡须知</text>
<view class="popup-close" @tap="showNotice = false">
<view class="popup-close__icon" />
</view>
</view>
<scroll-view scroll-y class="popup-body">
<text class="popup-content">1. 年卡仅限本人使用,不得转借他人。\n2. 入园时需出示有效身份证件及年卡二维码。\n3. 年卡有效期内可不限次数游览合作景区。\n4. 部分景区节假日需提前预约,请以景区公告为准。\n5. 年卡一经激活,不予退换。</text>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
const form = reactive({
cardNo: '',
name: '',
phone: '18684839400',
idCard: ''
})
const docType = ref('身份证')
const idTypeOptions = ['身份证', '护照', '港澳通行证', '台胞证']
const idPlaceholder = computed(() => {
const map: Record<string, string> = {
'身份证': '请输入身份证号码',
'护照': '请输入护照号码',
'港澳通行证': '请输入通行证号码',
'台胞证': '请输入台胞证号码'
}
return map[docType.value] || '请输入证件号码'
})
const photoUrl = ref('')
const agreed = ref(false)
const submitting = ref(false)
const showNotice = ref(false)
const showIdTypePicker = ref(false)
const noticePopup = ref<any>(null)
const idTypePopup = ref<any>(null)
watch(showNotice, (v) => {
if (v) noticePopup.value?.open()
else noticePopup.value?.close()
})
watch(showIdTypePicker, (v) => {
if (v) idTypePopup.value?.open()
else idTypePopup.value?.close()
})
function selectIdType(item: string) {
docType.value = item
showIdTypePicker.value = false
}
function choosePhoto() {
uni.chooseMedia({
count: 1,
mediaType: ['image'],
sourceType: ['camera'],
success: (res) => {
photoUrl.value = res.tempFiles[0].tempFilePath
}
})
}
function previewPhoto() {
uni.previewImage({ urls: [photoUrl.value] })
}
function removePhoto() {
photoUrl.value = ''
}
function validateForm() {
if (!form.cardNo.trim()) return '请输入卡号'
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 '请上传持卡人照片'
return ''
}
async function handleSubmit() {
if (!agreed.value) {
uni.showToast({ title: '请先阅读并知晓用卡须知', icon: 'none' })
return
}
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))
uni.hideLoading()
uni.showToast({ title: '绑卡成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
}, 1500)
} catch {
uni.hideLoading()
uni.showToast({ title: '绑卡失败,请重试', icon: 'none' })
} finally {
submitting.value = false
}
}
function handleCancel() {
uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
}
</script>
<style lang="scss" scoped>
/* ===== Design Tokens ===== */
/* ===== Page ===== */
.page {
height: 100vh;
overflow: hidden;
background: $color-card;
font-family: $font-family-body;
}
/* ===== 1. Header ===== */
.header {
padding: 40rpx 32rpx 0;
text-align: center;
}
.header-title {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: baseline;
line-height: 48rpx;
}
.header-title__bind {
font-family: $font-family-body;
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $uv-main-color;
}
.header-title__card {
font-family: $font-family-body;
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $uv-primary;
}
.header-desc {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-tips-color;
line-height: 36rpx;
margin-top: 12rpx;
text-align: center;
}
/* ===== 2. Form Card ===== */
.form-card {
margin: 24rpx 24rpx 0;
background: $color-card;
border-radius: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.form-row {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 32rpx;
}
.form-row--photo {
align-items: flex-start;
height: auto;
padding: 24rpx 32rpx;
}
.form-label {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-main-color;
font-weight: 400;
line-height: 40rpx;
flex-shrink: 0;
width: 160rpx;
}
.form-label--top {
padding-top: 20rpx;
}
.form-input {
flex: 1;
height: 88rpx;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
line-height: 40rpx;
text-align: right;
}
.placeholder {
font-family: $font-family-body;
color: $uv-placeholder-color;
font-size: $font-size-sm;
font-weight: 400;
}
.divider {
height: 1rpx;
background: $uv-border-color;
margin: 0 32rpx;
}
/* ===== Form Value + Chevron ===== */
.form-value {
flex: 1;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
text-align: right;
}
.chevron-right {
width: 12rpx;
height: 12rpx;
border-top: 2rpx solid $uv-placeholder-color;
border-right: 2rpx solid $uv-placeholder-color;
transform: rotate(45deg);
flex-shrink: 0;
margin-left: 12rpx;
}
/* ===== Photo Upload ===== */
.upload-area {
width: 180rpx;
height: 180rpx;
border-radius: 16rpx;
background: #f7f7f7;
flex-shrink: 0;
margin-left: auto;
overflow: hidden;
position: relative;
transition: transform 0.15s ease;
&:active {
transform: scale(0.97);
}
}
.upload-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
width: 60rpx;
height: 60rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: 8rpx;
}
.upload-text {
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-primary;
font-weight: 400;
}
.upload-preview {
width: 100%;
height: 100%;
}
.upload-remove {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 40rpx;
height: 40rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.upload-remove__x {
width: 16rpx;
height: 16rpx;
position: relative;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16rpx;
height: 2rpx;
background: $color-card;
border-radius: 1rpx;
}
&::before {
transform: translate(-50%, -50%) rotate(45deg);
}
&::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
/* ===== 3. Agreement ===== */
.agreement {
display: flex;
align-items: flex-start;
margin: 24rpx 24rpx;
}
.checkbox {
flex-shrink: 0;
width: 20rpx;
height: 20rpx;
border: 2rpx solid #dadbde;
border-radius: 50%;
margin-right: 12rpx;
margin-top: 8rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.checkbox--checked {
background: $uv-primary;
border-color: $uv-primary;
}
.checkbox__check {
width: 12rpx;
height: 12rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.agreement__text {
display: flex;
flex-wrap: wrap;
align-items: center;
line-height: 36rpx;
}
.agreement__label {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-tips-color;
}
.agreement__link {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-primary;
&:active {
opacity: 0.7;
}
}
/* ===== 4. Buttons ===== */
.btn-group {
display: flex;
gap: 24rpx;
padding: 0 24rpx;
}
.btn-confirm {
flex: 1;
height: 96rpx;
border-radius: 48rpx;
background: #3c9cff;
border: none;
padding: 0;
margin: 0;
line-height: normal;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 20rpx rgba(60, 156, 255, 0.28);
transition: all 0.2s ease;
&::after {
display: none;
}
&:active:not(.btn-confirm--disabled) {
transform: scale(0.98);
box-shadow: 0 2rpx 8rpx rgba(57, 138, 222, 0.2);
}
}
.btn-confirm--disabled {
background: $uv-bg-color;
box-shadow: none;
}
.btn-confirm__text {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: #ffffff;
letter-spacing: 2rpx;
}
.btn-confirm--disabled .btn-confirm__text {
color: $uv-placeholder-color;
}
.btn-cancel {
flex: 1;
height: 96rpx;
border-radius: 48rpx;
background: $color-card;
border: 2rpx solid #dadbde;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&::after {
display: none;
}
&:active {
transform: scale(0.98);
background: $uv-bg-color;
}
}
.btn-cancel__text {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: 400;
color: $uv-tips-color;
}
/* ===== Popup Sheet ===== */
.popup-sheet {
background: $color-card;
border-radius: 24rpx 24rpx 0 0;
max-height: 70vh;
display: flex;
flex-direction: column;
font-family: $font-family-body;
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid $uv-border-color;
}
.popup-title {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
line-height: 48rpx;
}
.popup-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.6;
}
}
.popup-close__icon {
width: 20rpx;
height: 20rpx;
position: relative;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20rpx;
height: 3rpx;
background: $uv-tips-color;
border-radius: 2rpx;
}
&::before {
transform: translate(-50%, -50%) rotate(45deg);
}
&::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
.popup-body {
padding: 32rpx;
max-height: 55vh;
}
.popup-content {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
line-height: 1.8;
}
/* ===== ID Type Picker List ===== */
.id-type-list {
padding: 16rpx 0;
}
.id-type-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 96rpx;
padding: 0 32rpx;
transition: background 0.15s ease;
&:active {
background: $uv-bg-color;
}
&--active {
background: rgba(60, 156, 255, 0.04);
}
}
.id-type-item__text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
line-height: 40rpx;
}
.id-type-item--active .id-type-item__text {
color: $uv-primary;
font-weight: 400;
}
.id-type-item__check {
width: 24rpx;
height: 24rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%233c9cff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
</style>
+194
View File
@@ -0,0 +1,194 @@
<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>
<!-- 底部固定购买区域 -->
<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-original">¥3800+</text>
</view>
<button class="btn-buy" :disabled="paying" @tap="handleBuy">
<text class="btn-buy__text">{{ paying ? '支付中...' : '立即购买' }}</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
const paying = ref(false)
async function handleBuy() {
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)
})
// 调用微信支付
await new Promise<void>((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
...orderRes.payParams,
success: () => resolve(),
fail: (err: any) => reject(err)
})
})
uni.showToast({ title: '支付成功', icon: 'success' })
setTimeout(() => {
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' })
}
} finally {
paying.value = false
}
}
</script>
<style lang="scss" scoped>
$bar-height: 120rpx;
.page {
min-height: 100vh;
background: $color-card;
padding-bottom: calc(#{$bar-height} + env(safe-area-inset-bottom));
font-family: $font-family-body;
}
/* ===== 图片区域 ===== */
.image-section {
display: flex;
flex-direction: column;
}
.card-image {
width: 100%;
display: block;
margin: 0;
padding: 0;
}
/* ===== 底部固定购买区域 ===== */
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
box-sizing: border-box;
min-height: $bar-height;
background: $color-card;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 0rpx 0 178rpx;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
z-index: 100;
}
.price-label {
font-family: $font-family-body;
font-size: 26rpx;
color: $uv-tips-color;
}
.price-row {
display: flex;
align-items: baseline;
}
.price-symbol {
font-family: $font-family-body;
font-size: 28rpx;
font-weight: 700;
color: $color-price;
}
.price-value {
font-family: $font-family-body;
font-size: 38rpx;
font-weight: 700;
color: $color-price;
line-height: 1;
}
.price-original {
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-placeholder-color;
text-decoration: line-through;
margin-left: 12rpx;
}
.btn-buy {
width: auto;
min-width: auto;
height: 80rpx;
padding: 0 56rpx;
border-radius: 40rpx;
background: $uv-primary;
border: none;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 16rpx rgba(60, 156, 255, 0.3);
transition: all 0.2s ease;
&::after {
display: none;
}
&:active:not([disabled]) {
transform: scale(0.97);
}
&[disabled] {
opacity: 0.6;
}
}
.btn-buy__text {
font-family: $font-family-body;
font-size: 28rpx;
font-weight: 700;
color: #ffffff;
letter-spacing: 2rpx;
}
</style>
+591
View File
@@ -0,0 +1,591 @@
<template>
<view class="page">
<!-- 模块1:订单状态卡片 -->
<view class="status-card" :class="`status-card--${order.status}`">
<view class="status-icon-wrap">
<view class="status-icon" :class="`status-icon--${order.status}`" />
</view>
<text class="status-title">{{ statusLabel }}</text>
<text class="status-desc">{{ statusDesc }}</text>
</view>
<!-- 模块2:商品信息卡片 -->
<view class="info-card">
<text class="product-name">超级玩家卡</text>
<view class="price-row">
<view class="price-current">
<text class="price-symbol">¥</text>
<text class="price-value">198</text>
</view>
</view>
<text class="product-desc">一卡全年畅玩赣湘合作景区商户,实名绑定使用</text>
</view>
<!-- 模块3:订单信息明细 -->
<view class="info-card">
<view class="detail-row">
<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">
<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">
<text class="copy-btn__text">复制</text>
</view>
</view>
</view>
<view class="detail-row">
<text class="detail-label">下单时间</text>
<text class="detail-value">{{ order.createTime }}</text>
</view>
<view class="detail-row">
<text class="detail-label">支付时间</text>
<text class="detail-value">{{ order.payTime }}</text>
</view>
<view class="detail-row">
<text class="detail-label">支付渠道</text>
<text class="detail-value">{{ order.payChannel }}</text>
</view>
<view class="detail-row detail-row--last">
<text class="detail-label">支付金额</text>
<text class="detail-value detail-value--price">¥{{ order.payAmount }}.00</text>
</view>
<view v-if="order.status === 'activated' && order.activatedTime" class="detail-row detail-row--last">
<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">
<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 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">
<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>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired'
interface OrderDetail {
id: string
status: OrderStatus
createTime: string
payTime: string
payAmount: number
redeemCode: string
payChannel: string
activatedTime?: string
refundedTime?: string
}
const order = ref<OrderDetail>({
id: 'ORD20260625001',
status: 'pending',
createTime: '2026-06-25 10:32',
payTime: '2026-06-25 10:33',
payAmount: 198,
redeemCode: 'CJ0000012398',
payChannel: '微信支付',
activatedTime: undefined,
refundedTime: undefined
})
// 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' },
]
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
}
}
})
const statusLabel = computed(() => {
const map: Record<OrderStatus, string> = {
pending: '待绑定',
activated: '已绑定',
refunded: '已退款',
expired: '已过期'
}
return map[order.value.status]
})
const statusDesc = computed(() => {
const map: Record<OrderStatus, string> = {
pending: '已微信支付,未实名绑定,可绑定或申请退款',
activated: '卡已实名绑定,不可退款,仅限本人使用',
refunded: '退款审核通过,金额原路返还微信',
expired: '下单超过绑定有效期,卡号失效无法绑卡'
}
return map[order.value.status]
})
function copyCode() {
uni.setClipboardData({
data: order.value.redeemCode,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
function goActivate() {
uni.navigateTo({
url: `/packages/user/pages/bind?redeemCode=${order.value.redeemCode}`
})
}
function handleRefund() {
uni.showModal({
title: '申请退款',
content: '赣湘全域旅游年卡/超级玩家卡绑定或销售七日以上不可退卡。确认申请退款?',
success: (res) => {
if (res.confirm) {
order.value.status = 'refunded'
order.value.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
uni.showToast({ title: '退款申请已提交', icon: 'success' })
}
}
})
}
function goFaq() {
uni.navigateTo({ url: '/packages/content/pages/faq' })
}
function goHome() {
uni.switchTab({ url: '/pages/index/index' })
}
</script>
<style lang="scss" scoped>
$bar-height: 120rpx;
.page {
min-height: 100vh;
background: $color-background;
padding: $spacing-3;
padding-bottom: calc(#{$bar-height} + env(safe-area-inset-bottom) + #{$spacing-3});
font-family: $font-family-body;
}
/* ===== 模块1:状态卡片 ===== */
.status-card {
background: $color-card;
border-radius: $radius-lg;
padding: $spacing-6 $spacing-4;
display: flex;
flex-direction: column;
align-items: center;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
&--pending { border-top: 6rpx solid $color-status-pending; }
&--activated { border-top: 6rpx solid $color-status-success; }
&--refunded { border-top: 6rpx solid $color-status-cancel; }
&--expired { border-top: 6rpx solid $uv-error; }
}
.status-icon-wrap {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: $spacing-3;
}
.status-card--pending .status-icon-wrap { background: rgba(255, 149, 0, 0.1); }
.status-card--activated .status-icon-wrap { background: rgba(0, 180, 42, 0.1); }
.status-card--refunded .status-icon-wrap { background: rgba(134, 144, 156, 0.1); }
.status-card--expired .status-icon-wrap { background: rgba(245, 63, 63, 0.1); }
.status-icon {
width: 36rpx;
height: 36rpx;
&--pending {
border: 4rpx solid $color-status-pending;
border-radius: 50%;
position: relative;
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 4rpx;
height: 16rpx;
background: $color-status-pending;
transform: translate(-50%, -70%);
}
}
&--activated {
border: 4rpx solid $color-status-success;
border-radius: 50%;
position: relative;
&::after {
content: '';
position: absolute;
top: 40%;
left: 50%;
width: 16rpx;
height: 8rpx;
border-left: 4rpx solid $color-status-success;
border-bottom: 4rpx solid $color-status-success;
transform: translate(-50%, -50%) rotate(-45deg);
}
}
&--refunded {
position: relative;
&::before, &::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 28rpx;
height: 4rpx;
background: $color-status-cancel;
border-radius: 2rpx;
}
&::before { transform: translate(-50%, -50%) rotate(45deg); }
&::after { transform: translate(-50%, -50%) rotate(-45deg); }
}
&--expired {
border: 4rpx solid $uv-error;
border-radius: 50%;
position: relative;
&::before, &::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20rpx;
height: 4rpx;
background: $uv-error;
border-radius: 2rpx;
}
&::before { transform: translate(-50%, -50%) rotate(45deg); }
&::after { transform: translate(-50%, -50%) rotate(-45deg); }
}
}
.status-title {
font-family: $font-family-body;
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: $spacing-2;
}
.status-desc {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
text-align: center;
}
/* ===== 模块2:商品信息卡片 ===== */
.info-card {
background: $color-card;
border-radius: $radius-lg;
padding: $spacing-4;
margin-top: $spacing-3;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.product-name {
display: block;
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: $spacing-2;
}
.price-row {
display: flex;
align-items: baseline;
margin-bottom: $spacing-2;
}
.price-current {
display: flex;
align-items: baseline;
margin-right: $spacing-2;
}
.price-symbol {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: $color-price;
}
.price-value {
font-family: $font-family-body;
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $color-price;
line-height: 1;
}
.product-desc {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 1.6;
}
/* ===== 模块3:订单明细 ===== */
.detail-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: $spacing-3 0;
border-bottom: $border-width solid $uv-border-color;
&--last {
border-bottom: none;
}
}
.detail-label {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-main-color;
flex-shrink: 0;
}
.detail-value {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-content-color;
text-align: right;
&--code {
font-weight: $font-weight-bold;
letter-spacing: 4rpx;
}
&--price {
font-weight: $font-weight-bold;
color: $color-price;
}
}
.detail-value-row {
display: flex;
align-items: center;
gap: $spacing-2;
}
.copy-btn {
padding: 4rpx $spacing-2;
border-radius: $radius-sm;
background: rgba(60, 156, 255, 0.08);
&:active {
transform: scale(0.95);
}
}
.copy-btn__text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-primary;
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;
}
/* ===== 模块5:底部操作栏 ===== */
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
box-sizing: border-box;
min-height: $bar-height;
background: $color-card;
display: flex;
align-items: center;
justify-content: flex-end;
padding: $spacing-3 $spacing-6;
padding-bottom: calc(#{$spacing-3} + env(safe-area-inset-bottom));
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
z-index: $z-sticky;
}
.bottom-bar__right {
display: flex;
gap: $spacing-3;
}
.btn-action {
height: 80rpx;
padding: 0 56rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all $transition-fast;
&--primary {
background: $uv-primary;
&:active {
transform: scale(0.97);
}
}
&--outline {
border: 2rpx solid $uv-border-color;
&:active {
transform: scale(0.97);
background: $uv-bg-color;
}
}
}
.btn-action__text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: #fff;
}
.btn-action__text--outline {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-tips-color;
}
</style>
+560
View File
@@ -0,0 +1,560 @@
<template>
<view class="page">
<!-- Tab 状态切换 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view
v-for="tab in tabs"
:key="tab.value"
class="tab-item"
:class="{ 'tab-item--active': activeTab === tab.value }"
@tap="switchTab(tab.value)"
>
<text class="tab-text">{{ tab.label }}</text>
<view v-if="activeTab === tab.value" class="tab-underline" />
</view>
</view>
</scroll-view>
<!-- 订单列表 -->
<scroll-view
scroll-y
class="order-list"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<!-- 空状态 -->
<view v-if="!loading && filteredOrders.length === 0" class="empty-state">
<view class="empty-icon">
<view class="empty-bag" />
<view class="empty-bag__handle" />
</view>
<text class="empty-text">暂无订单,快去选购赣湘全域旅游年卡/超级玩家卡</text>
<view class="empty-btn" @tap="goBuy">
<text class="empty-btn__text">去购买</text>
</view>
</view>
<!-- 订单卡片 -->
<view v-else class="order-cards">
<view
v-for="order in filteredOrders"
:key="order.id"
class="order-card"
@tap="goDetail(order.id)"
>
<!-- 卡片头部 -->
<view class="card-header">
<text class="order-no">#{{ order.id }}</text>
<view class="status-tag" :class="`status-tag--${order.status}`">
<text class="status-tag__text">{{ statusLabel(order.status) }}</text>
</view>
</view>
<!-- 商品信息 -->
<view class="card-product">
<view class="product-name-row">
<text class="product-name">超级玩家卡</text>
<view class="price-current">
<text class="price-symbol">¥</text>
<text class="price-value">{{ order.payAmount }}</text>
</view>
</view>
</view>
<!-- 底部信息行 -->
<view class="card-info">
<text class="info-time">{{ order.createTime }}</text>
<text class="info-pay">{{ order.payChannel }}</text>
</view>
<!-- 操作按钮 -->
<view class="card-actions" v-if="order.status === 'pending'">
<view class="btn-outline" @tap.stop="showRefundModal(order)">
<text class="btn-outline__text">申请退款</text>
</view>
<view class="btn-primary" @tap.stop="goActivate(order)">
<text class="btn-primary__text">立即绑定</text>
</view>
</view>
<view class="card-actions" v-else>
<view class="btn-outline" @tap.stop="goDetail(order.id)">
<text class="btn-outline__text">查看详情</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="filteredOrders.length > 0" class="load-more">
<text v-if="loading" class="load-more__text">加载中...</text>
<text v-else-if="noMore" class="load-more__text">没有更多订单了</text>
</view>
</view>
</scroll-view>
<!-- 退款弹窗 -->
<uni-popup ref="refundPopup" type="center" :mask-click="true">
<view class="refund-modal">
<text class="refund-title">申请退款</text>
<view class="refund-body">
<text class="refund-text">赣湘全域旅游年卡/超级玩家卡绑定或销售七日以上不可退卡,不适用7天无理由退货。</text>
<text class="refund-text">仅小程序下单、未绑定、购买未满7天可申请退款;第三方平台下单需联系对应平台客服。</text>
</view>
<view class="refund-actions">
<view class="btn-outline" @tap="closeRefund">
<text class="btn-outline__text">取消</text>
</view>
<view class="btn-primary" @tap="submitRefund">
<text class="btn-primary__text">确认退款</text>
</view>
</view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { mockOrders, type OrderItem } from '@/mock/order'
type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired'
const tabs = [
{ label: '全部', value: '' },
{ label: '待绑定', value: 'pending' },
{ label: '已绑定', value: 'activated' },
{ label: '已退款', value: 'refunded' },
]
const activeTab = ref('')
const loading = ref(false)
const refreshing = ref(false)
const noMore = ref(false)
const currentPage = ref(1)
const refundPopup = ref<any>(null)
const refundOrderId = ref('')
const orders = ref<OrderItem[]>(mockOrders)
const filteredOrders = computed(() => {
if (!activeTab.value) return orders.value
return orders.value.filter(o => o.status === activeTab.value)
})
function statusLabel(status: OrderStatus) {
const map: Record<OrderStatus, string> = {
pending: '待绑定',
activated: '已绑定',
refunded: '已退款',
expired: '已过期'
}
return map[status]
}
function switchTab(value: string) {
activeTab.value = value
}
function onRefresh() {
refreshing.value = true
currentPage.value = 1
noMore.value = false
setTimeout(() => {
refreshing.value = false
}, 800)
}
function onLoadMore() {
if (loading.value || noMore.value) return
loading.value = true
setTimeout(() => {
loading.value = false
noMore.value = true
}, 800)
}
function goDetail(orderId: string) {
uni.navigateTo({ url: `/packages/user/pages/order/detail?orderId=${orderId}` })
}
function goActivate(order: OrderItem) {
uni.navigateTo({
url: `/packages/user/pages/bind?redeemCode=${order.redeemCode}`
})
}
function showRefundModal(order: OrderItem) {
refundOrderId.value = order.id
refundPopup.value?.open()
}
function closeRefund() {
refundPopup.value?.close()
}
function submitRefund() {
refundPopup.value?.close()
const order = orders.value.find(o => o.id === refundOrderId.value)
if (order) {
order.status = 'refunded'
order.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
}
uni.showToast({ title: '退款申请已提交', icon: 'success' })
}
function goBuy() {
uni.navigateTo({ url: '/packages/user/pages/buy' })
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $color-background;
font-family: $font-family-body;
}
/* ===== Tab Bar ===== */
.tab-bar {
white-space: nowrap;
background: $color-card;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.tab-inner {
display: flex;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: $spacing-3 0;
position: relative;
}
.tab-text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-tips-color;
}
.tab-item--active .tab-text {
color: $uv-primary;
font-weight: $font-weight-bold;
}
.tab-underline {
width: 40rpx;
height: 4rpx;
border-radius: 2rpx;
background: $uv-primary;
margin-top: $spacing-1;
}
/* ===== Order List ===== */
.order-list {
height: calc(100vh - 96rpx);
}
.order-cards {
padding: $spacing-3;
}
/* ===== Order Card ===== */
.order-card {
background: $color-card;
border-radius: $radius-lg;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
padding: $spacing-4;
margin-bottom: $spacing-3;
&:active {
transform: scale(0.99);
}
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: $spacing-3;
}
.order-no {
font-family: $font-family-body;
font-size: $font-size-xs;
color: $uv-placeholder-color;
}
/* Status Tags */
.status-tag {
padding: 4rpx $spacing-2;
border-radius: $radius-sm;
&--pending {
background: rgba(255, 149, 0, 0.1);
}
&--activated {
background: rgba(0, 180, 42, 0.1);
}
&--refunded {
background: rgba(134, 144, 156, 0.1);
}
&--expired {
background: rgba(245, 63, 63, 0.1);
}
}
.status-tag__text {
font-family: $font-family-body;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
}
.status-tag--pending .status-tag__text { color: $color-status-pending; }
.status-tag--activated .status-tag__text { color: $color-status-success; }
.status-tag--refunded .status-tag__text { color: $color-status-cancel; }
.status-tag--expired .status-tag__text { color: $uv-error; }
/* Product Info */
.card-product {
margin-bottom: $spacing-3;
}
.product-name-row {
display: flex;
align-items: center;
justify-content: space-between;
}
.product-name {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $uv-main-color;
}
.price-current {
display: flex;
align-items: baseline;
}
.price-symbol {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: $color-price;
}
.price-value {
font-family: $font-family-body;
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $color-price;
line-height: 1;
}
/* Info Row */
.card-info {
display: flex;
justify-content: space-between;
padding-bottom: $spacing-3;
border-bottom: $border-width solid $uv-border-color;
margin-bottom: $spacing-3;
}
.info-time {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
}
.info-pay {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
}
/* Action Buttons */
.card-actions {
display: flex;
justify-content: flex-end;
gap: $spacing-3;
}
.btn-outline {
height: 64rpx;
padding: 0 36rpx;
border-radius: 32rpx;
border: 2rpx solid $uv-border-color;
display: flex;
align-items: center;
justify-content: center;
transition: all $transition-fast;
&:active {
transform: scale(0.97);
background: $uv-bg-color;
}
}
.btn-outline__text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-tips-color;
}
.btn-primary {
height: 64rpx;
padding: 0 36rpx;
border-radius: 32rpx;
background: $uv-primary;
display: flex;
align-items: center;
justify-content: center;
transition: all $transition-fast;
&:active {
transform: scale(0.97);
}
}
.btn-primary__text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: #fff;
}
/* ===== Empty State ===== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 160rpx $spacing-6 0;
}
.empty-icon {
width: 120rpx;
height: 120rpx;
position: relative;
margin-bottom: $spacing-4;
}
.empty-bag {
width: 80rpx;
height: 64rpx;
border: 4rpx solid $uv-disabled-color;
border-radius: $radius-sm;
position: absolute;
bottom: 0;
left: 50%;
transform: translateX(-50%);
}
.empty-bag__handle {
width: 40rpx;
height: 24rpx;
border: 4rpx solid $uv-disabled-color;
border-bottom: none;
border-radius: 20rpx 20rpx 0 0;
position: absolute;
top: $spacing-2;
left: 50%;
transform: translateX(-50%);
}
.empty-text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-placeholder-color;
text-align: center;
line-height: 1.6;
margin-bottom: $spacing-4;
}
.empty-btn {
height: 72rpx;
padding: 0 56rpx;
border-radius: 36rpx;
background: $uv-primary;
display: flex;
align-items: center;
justify-content: center;
&:active {
transform: scale(0.97);
}
}
.empty-btn__text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: #fff;
}
/* ===== Load More ===== */
.load-more {
text-align: center;
padding: $spacing-2 0 120rpx;
}
.load-more__text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-placeholder-color;
}
/* ===== Refund Modal ===== */
.refund-modal {
width: 600rpx;
background: $color-card;
border-radius: $radius-lg;
padding: $spacing-5;
}
.refund-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
text-align: center;
margin-bottom: $spacing-3;
}
.refund-body {
margin-bottom: $spacing-4;
}
.refund-text {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-content-color;
line-height: 1.7;
margin-bottom: $spacing-2;
}
.refund-actions {
display: flex;
gap: $spacing-3;
.btn-outline,
.btn-primary {
flex: 1;
}
}
</style>
+658
View File
@@ -0,0 +1,658 @@
<template>
<view class="page">
<!-- 景区基础信息卡片 -->
<view class="scenic-card">
<image class="scenic-img" :src="scenic.image" mode="aspectFill" />
<view class="scenic-info">
<text class="scenic-name">{{ scenic.name }}</text>
<view class="scenic-row">
<view class="scenic-icon scenic-icon--pin" />
<text class="scenic-text">{{ scenic.address }}</text>
</view>
<view class="scenic-row">
<view class="scenic-icon scenic-icon--clock" />
<text class="scenic-text">{{ scenic.hours }}</text>
</view>
<text class="scenic-tip">持赣湘全域旅游年卡免费入园,单人单日仅可预约1次</text>
</view>
</view>
<!-- 日期选择模块 -->
<view class="date-card">
<text class="section-title">选择预约日期</text>
<scroll-view scroll-x class="date-scroll">
<view class="date-list">
<view
v-for="d in dateOptions"
:key="d.key"
class="date-item"
:class="{
'date-item--active': selectedDate === d.key && d.stock > 0,
'date-item--disabled': d.stock <= 0
}"
@tap="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-else class="date-stock date-stock--empty">已约满</text>
</view>
</view>
</scroll-view>
<!-- 场次选择 -->
<view v-if="selectedSessions.length > 0" class="session-section">
<text class="session-title">选择入园场次</text>
<view class="session-list">
<view
v-for="s in selectedSessions"
:key="s.id"
class="session-item"
:class="{
'session-item--active': selectedSession === s.id && s.stock > 0,
'session-item--disabled': s.stock <= 0
}"
@tap="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-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>
<view v-else-if="selectedDate" class="no-session-tip">
<text class="no-session-tip__text">该日期无需选择场次,直接预约即可</text>
</view>
<text class="date-tip">仅可预约近3日内时段,当日预约需提前1天取消</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>
</view>
<view class="user-row">
<text class="user-label">身份证号</text>
<text class="user-value">{{ maskedId }}</text>
</view>
<text class="user-warn">预约信息必须与持卡实名一致,不可更换他人</text>
</view>
<!-- 预约须知 -->
<view class="notice-card">
<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>
</view>
</view>
<view class="notice-link" @tap="goFaq">
<text class="notice-link__text">查看完整FAQ</text>
</view>
</view>
<!-- 底部安全区占位 -->
<view class="safe-bottom" />
<!-- 底部固定预约按钮 -->
<view class="bottom-bar">
<button
class="btn-submit"
:class="{ 'btn-submit--disabled': !selectedDate }"
:disabled="!selectedDate"
@tap="handleSubmit"
>
<text class="btn-submit__text">提交预约</text>
</button>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
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
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 },
],
}
const selectedSession = ref('')
const selectedSessions = computed(() => {
if (!selectedDate.value) return []
const sessions = sessionMap[selectedDate.value] || []
// 切换日期时自动选中第一个有库存的场次
if (sessions.length > 0 && !sessions.find(s => s.id === selectedSession.value)) {
const available = sessions.find(s => 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
})
onLoad((query: any) => {
if (query?.scenicId) {
// 实际项目中根据 scenicId 请求景区详情
}
})
function handleSubmit() {
if (!selectedDate.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}`
})
}
function goFaq() {
uni.navigateTo({ url: '/packages/content/pages/faq' })
}
</script>
<style lang="scss" scoped>
$bar-height: 120rpx;
.page {
min-height: 100vh;
background: $color-background;
padding-bottom: calc(#{$bar-height} + env(safe-area-inset-bottom));
font-family: $font-family-body;
}
/* ===== 景区信息卡片 ===== */
.scenic-card {
margin: 24rpx 24rpx 0;
background: $color-card;
border-radius: $radius-lg;
overflow: hidden;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.scenic-img {
width: 100%;
height: 280rpx;
}
.scenic-info {
padding: 28rpx;
}
.scenic-name {
display: block;
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 12rpx;
}
.scenic-row {
display: flex;
align-items: center;
margin-bottom: 12rpx;
}
.scenic-icon {
width: 24rpx;
height: 24rpx;
margin-right: 8rpx;
flex-shrink: 0;
&--pin {
border: 3rpx solid $uv-tips-color;
border-radius: 50% 50% 50% 0;
transform: rotate(-45deg);
position: relative;
&::after {
content: '';
width: 10rpx;
height: 10rpx;
border-radius: 50%;
border: 2rpx solid $uv-tips-color;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
}
&--clock {
border: 3rpx solid $uv-tips-color;
border-radius: 50%;
position: relative;
&::before {
content: '';
width: 2rpx;
height: 10rpx;
background: $uv-tips-color;
position: absolute;
top: 4rpx;
left: 50%;
transform: translateX(-50%);
}
&::after {
content: '';
width: 8rpx;
height: 2rpx;
background: $uv-tips-color;
position: absolute;
top: 50%;
left: 50%;
}
}
}
.scenic-text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
}
.scenic-tip {
display: block;
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-placeholder-color;
margin-top: 16rpx;
line-height: 1.6;
}
/* ===== 日期选择 ===== */
.date-card {
margin: 24rpx 32rpx 0;
background: $color-card;
border-radius: $radius-lg;
padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.section-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-lg;
font-weight: $font-weight-medium;
color: $uv-main-color;
margin-bottom: 24rpx;
}
.date-scroll {
white-space: nowrap;
}
.date-list {
display: flex;
gap: 16rpx;
}
.date-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 12rpx;
border-radius: 16rpx;
border: 2rpx solid $uv-border-color;
transition: all $transition-fast;
&--active {
background: $uv-primary;
border-color: $uv-primary;
}
}
.date-week {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-tips-color;
margin-bottom: 4rpx;
}
.date-item--active .date-week {
color: rgba(255, 255, 255, 0.8);
}
.date-day {
font-family: $font-family-body;
font-size: $font-size-xs;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 4rpx;
}
.date-item--active .date-day {
color: #ffffff;
}
.date-stock {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
&--empty {
color: $uv-error;
}
}
.date-item--active .date-stock {
color: rgba(255, 255, 255, 0.7);
}
.date-item--disabled {
opacity: 0.5;
.date-week,
.date-day {
color: $uv-placeholder-color;
}
}
/* ===== 场次选择 ===== */
.session-section {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid $uv-border-color;
}
.session-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-main-color;
margin-bottom: 16rpx;
}
.session-list {
display: flex;
flex-wrap: wrap;
gap: 16rpx;
}
.session-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 16rpx 28rpx;
border-radius: 12rpx;
border: 2rpx solid $uv-border-color;
transition: all $transition-fast;
&--active {
background: rgba(60, 156, 255, 0.08);
border-color: $uv-primary;
}
&--disabled {
opacity: 0.5;
}
}
.session-time {
font-family: $font-family-body;
font-size: $font-size-xs;
font-weight: $font-weight-medium;
color: $uv-main-color;
margin-bottom: 4rpx;
}
.session-item--active .session-time {
color: $uv-primary;
}
.session-stock {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
&--empty {
color: $uv-error;
}
}
.no-session-tip {
margin-top: 24rpx;
padding-top: 24rpx;
border-top: 1rpx solid $uv-border-color;
}
.no-session-tip__text {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
}
.date-tip {
display: block;
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
margin-top: 16rpx;
}
/* ===== 预约人信息 ===== */
.user-card {
margin: 24rpx 32rpx 0;
background: $color-card;
border-radius: $radius-lg;
padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.user-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
}
.user-label {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-main-color;
}
.user-value {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-content-color;
}
.user-warn {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-error;
margin-top: 12rpx;
}
/* ===== 预约须知 ===== */
.notice-card {
margin: 24rpx 32rpx 0;
background: #eef4ff;
border-radius: $radius-lg;
padding: 32rpx;
}
.notice-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 20rpx;
}
.notice-list {
margin-bottom: 16rpx;
}
.notice-item {
display: flex;
align-items: flex-start;
margin-bottom: 12rpx;
}
.notice-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: $uv-primary;
flex-shrink: 0;
margin-top: 14rpx;
margin-right: 12rpx;
}
.notice-text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 1.6;
}
.notice-link {
&:active { opacity: 0.7; }
}
.notice-link__text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-primary;
font-weight: $font-weight-medium;
}
/* ===== 底部 ===== */
.safe-bottom {
height: 24rpx;
}
.bottom-bar {
position: fixed;
left: 0;
right: 0;
bottom: 0;
width: 100%;
box-sizing: border-box;
min-height: $bar-height;
background: $color-card;
display: flex;
align-items: center;
justify-content: center;
padding: 0 48rpx;
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
z-index: 100;
}
.btn-submit {
width: 100%;
height: 80rpx;
border-radius: 40rpx;
background: $uv-primary;
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: all $transition-fast;
&::after { display: none; }
&:active:not(.btn-submit--disabled) {
transform: scale(0.98);
}
&--disabled {
background: $uv-bg-color;
}
}
.btn-submit__text {
font-family: $font-family-body;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: #ffffff;
}
.btn-submit--disabled .btn-submit__text {
color: $uv-placeholder-color;
}
</style>
+608
View File
@@ -0,0 +1,608 @@
<template>
<view class="page">
<!-- Tab 筛选栏 -->
<scroll-view scroll-x class="tab-bar">
<view class="tab-inner">
<view
v-for="tab in tabs"
:key="tab.value"
class="tab-item"
:class="{ 'tab-item--active': activeTab === tab.value }"
@tap="activeTab = tab.value"
>
<text class="tab-text">{{ tab.label }}</text>
<view v-if="activeTab === tab.value" class="tab-underline" />
</view>
</view>
</scroll-view>
<!-- 列表区域 -->
<scroll-view
scroll-y
class="list-area"
refresher-enabled
:refresher-triggered="refreshing"
@refresherrefresh="onRefresh"
@scrolltolower="onLoadMore"
>
<!-- 空状态 -->
<view v-if="filteredRecords.length === 0" class="empty-state">
<view class="empty-icon">
<view class="empty-cal" />
<view class="empty-cal__line" />
</view>
<text class="empty-text">暂无预约记录,快去预约景区</text>
<view class="empty-btn" @tap="goReserve">
<text class="empty-btn__text">去预约</text>
</view>
</view>
<!-- 记录列表 -->
<view v-else class="record-list">
<view
v-for="r in filteredRecords"
:key="r.id"
class="record-card"
>
<!-- 头部 -->
<view class="record-header">
<text class="record-name">{{ r.scenicName }}</text>
<view class="status-tag" :class="`status-tag--${r.status}`">
<text class="status-tag__text">{{ statusLabel(r.status) }}</text>
</view>
</view>
<!-- 卡种+卡号 -->
<view class="record-card-info">
<text class="record-card-type">{{ r.cardType }}</text>
<text class="record-card-no">卡号:{{ r.cardNo }}</text>
</view>
<!-- 预约入园日期 -->
<view class="record-info">
<text class="record-date">预约入园日期:{{ r.date }}</text>
</view>
<!-- 操作 -->
<view class="record-actions">
<view
v-if="r.status === 'pending' && canCancel(r.date)"
class="btn-sm btn-sm--outline"
@tap="showCancelModal(r)"
>
<text class="btn-sm__text--outline">取消预约</text>
</view>
<view
v-if="r.status === 'pending'"
class="btn-sm btn-sm--outline"
@tap="showCode(r)"
>
<text class="btn-sm__text--outline">查看入园码</text>
</view>
<view
class="btn-sm btn-sm--outline"
@tap="viewDetail(r)"
>
<text class="btn-sm__text--outline">查看详情</text>
</view>
</view>
</view>
<!-- 加载更多 -->
<view v-if="filteredRecords.length > 0" class="load-more">
<text v-if="loading" class="load-more__text">加载中...</text>
<text v-else-if="noMore" class="load-more__text">没有更多记录了</text>
</view>
</view>
</scroll-view>
<!-- 入园码弹窗 -->
<uni-popup ref="codePopup" type="center" :mask-click="true">
<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" />
</view>
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
</view>
</uni-popup>
<!-- 取消预约弹窗 -->
<uni-popup ref="cancelPopup" type="center" :mask-click="true">
<view class="cancel-modal">
<text class="cancel-modal__title">取消预约</text>
<view class="cancel-modal__body">
<text class="cancel-modal__text">确定取消该预约吗?未提前取消且未入园将扣除1次年卡使用次数,权益不可恢复。</text>
</view>
<view class="cancel-modal__actions">
<view class="btn-modal btn-modal--outline" @tap="cancelPopup?.close()">
<text class="btn-modal__text--outline">再想想</text>
</view>
<view class="btn-modal btn-modal--primary" @tap="confirmCancel">
<text class="btn-modal__text--primary">确认取消</text>
</view>
</view>
</view>
</uni-popup>
<!-- 详情弹窗 -->
<uni-popup ref="detailPopup" type="bottom" :mask-click="true">
<view class="detail-sheet">
<view class="sheet-header">
<text class="sheet-title">使用详情</text>
<view class="sheet-close" @tap="detailPopup?.close()">
<view class="sheet-close__icon" />
</view>
</view>
<scroll-view scroll-y class="sheet-body">
<view class="sheet-row">
<text class="sheet-label">预约景区</text>
<text class="sheet-value">{{ detailRecord?.scenicName }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">卡种</text>
<text class="sheet-value">{{ detailRecord?.cardType }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">卡号</text>
<text class="sheet-value">{{ detailRecord?.cardNo }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约入园日期</text>
<text class="sheet-value">{{ detailRecord?.date }}</text>
</view>
<view v-if="detailRecord?.session" class="sheet-row">
<text class="sheet-label">预约场次</text>
<text class="sheet-value">{{ detailRecord.session }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约人</text>
<text class="sheet-value">{{ detailRecord?.userName }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">证件号</text>
<text class="sheet-value">{{ detailRecord?.maskedId }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约状态</text>
<text class="sheet-value">{{ statusLabel(detailRecord?.status || '') }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约申请时间</text>
<text class="sheet-value">{{ detailRecord?.applyTime }}</text>
</view>
<view v-if="detailRecord?.verifyTime" class="sheet-row">
<text class="sheet-label">核销时间</text>
<text class="sheet-value">{{ detailRecord.verifyTime }}</text>
</view>
<view v-if="detailRecord?.cancelTime" class="sheet-row">
<text class="sheet-label">取消时间</text>
<text class="sheet-value">{{ detailRecord.cancelTime }}</text>
</view>
<!-- 核销人脸照片 -->
<view v-if="detailRecord?.verifyPhoto && detailRecord?.status !== 'auto_used'" 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>
</scroll-view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
type RecordStatus = 'pending' | 'used' | 'cancelled' | 'auto_used'
interface ReserveRecord {
id: string
scenicName: string
date: string
session?: string
status: RecordStatus
userName: string
maskedId: string
cardType: string
cardNo: string
applyTime: string
cancelTime?: string
verifyTime?: string
verifyPhoto?: string
}
const tabs = [
{ label: '全部', value: '' },
{ 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 codePopup = ref<any>(null)
const cancelPopup = ref<any>(null)
const detailPopup = ref<any>(null)
const popupCode = 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 filteredRecords = computed(() => {
if (!activeTab.value) return records.value
return records.value.filter(r => r.status === activeTab.value)
})
function statusLabel(status: string) {
const map: Record<string, string> = { pending: '待入园', used: '已入园', cancelled: '已取消', auto_used: '自动核销' }
return map[status] || status
}
function canCancel(date: string) {
const today = new Date()
today.setHours(0, 0, 0, 0)
const d = new Date(date)
return (d.getTime() - today.getTime()) / (1000 * 60 * 60 * 24) >= 1
}
function showCancelModal(r: ReserveRecord) {
cancelRecord.value = r
cancelPopup.value?.open()
}
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', ' ')
}
uni.showToast({ title: '已取消预约', icon: 'success' })
}
}
function showCode(r: ReserveRecord) {
popupCode.value = r.id
codePopup.value?.open()
}
function viewDetail(r: ReserveRecord) {
detailRecord.value = r
detailPopup.value?.open()
}
function onRefresh() {
refreshing.value = true
setTimeout(() => { refreshing.value = false }, 800)
}
function onLoadMore() {
if (loading.value || noMore.value) return
loading.value = true
setTimeout(() => {
loading.value = false
noMore.value = true
}, 800)
}
function goReserve() {
uni.navigateTo({ url: '/packages/user/pages/reserve/index' })
}
function previewPhoto(url: string) {
uni.previewImage({ urls: [url] })
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $color-background;
font-family: $font-family-body;
}
/* ===== Tab ===== */
.tab-bar {
white-space: nowrap;
background: $color-card;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.04);
}
.tab-inner {
display: flex;
}
.tab-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 16rpx;
position: relative;
min-width: 120rpx;
}
.tab-text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-tips-color;
}
.tab-item--active .tab-text {
color: $uv-primary;
font-weight: $font-weight-bold;
}
.tab-underline {
width: 32rpx;
height: 4rpx;
border-radius: 2rpx;
background: $uv-primary;
margin-top: 6rpx;
}
/* ===== List ===== */
.list-area {
height: calc(100vh - 88rpx);
}
.record-list {
padding: 0 24rpx;
}
/* ===== Record Card ===== */
.record-card {
background: $color-card;
border-radius: $radius-lg;
padding: 28rpx;
margin-top: 20rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.record-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.record-name {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: $uv-main-color;
}
.status-tag {
padding: 4rpx 14rpx;
border-radius: 8rpx;
&--pending { background: #e8f3ff; }
&--used { background: #f0fff4; }
&--cancelled { background: $uv-bg-color; }
&--auto_used { background: #fff8e8; }
}
.status-tag__text {
font-family: $font-family-body;
font-size: 22rpx;
font-weight: $font-weight-medium;
}
.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; }
.record-card-info {
display: flex;
align-items: center;
gap: 16rpx;
margin-bottom: 8rpx;
}
.record-card-type {
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-primary;
background: rgba(60, 156, 255, 0.06);
padding: 2rpx 12rpx;
border-radius: 6rpx;
}
.record-card-no {
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-placeholder-color;
}
.record-info {
padding-bottom: 12rpx;
border-bottom: 1rpx solid $uv-border-color;
margin-bottom: 12rpx;
}
.record-date {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
}
.record-actions {
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
.btn-sm {
height: 52rpx;
padding: 0 24rpx;
border-radius: 26rpx;
display: flex;
align-items: center;
justify-content: center;
&--outline {
border: 2rpx solid $uv-border-color;
&:active { transform: scale(0.97); background: $uv-bg-color; }
}
}
.btn-sm__text--outline {
font-family: $font-family-body;
font-size: 22rpx;
font-weight: $font-weight-medium;
color: $uv-tips-color;
}
/* ===== Empty ===== */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
padding: 160rpx 48rpx 0;
}
.empty-icon { width: 120rpx; height: 120rpx; position: relative; margin-bottom: 32rpx; }
.empty-cal { width: 80rpx; height: 72rpx; border: 4rpx solid $uv-disabled-color; border-radius: 8rpx; position: absolute; bottom: 0; left: 50%; transform: translateX(-50%); }
.empty-cal__line { width: 80rpx; height: 4rpx; background: $uv-disabled-color; position: absolute; bottom: 48rpx; left: 50%; transform: translateX(-50%); }
.empty-text { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-placeholder-color; margin-bottom: 32rpx; }
.empty-btn {
height: 72rpx; padding: 0 56rpx; border-radius: 36rpx; background: $uv-primary;
display: flex; align-items: center; justify-content: center;
&:active { transform: scale(0.97); }
}
.empty-btn__text { font-family: $font-family-body; font-size: $font-size-sm; font-weight: $font-weight-bold; color: #ffffff; }
/* ===== Load More ===== */
.load-more { text-align: center; padding: 16rpx 0 120rpx; }
.load-more__text { font-family: $font-family-body; font-size: 22rpx; color: $uv-placeholder-color; }
/* ===== Code Modal ===== */
.code-modal {
width: 560rpx; background: $color-card; border-radius: $radius-lg; padding: 40rpx;
display: flex; flex-direction: column; align-items: center;
}
.code-modal__title { font-family: $font-family-body; font-size: 32rpx; font-weight: $font-weight-bold; color: $uv-main-color; margin-bottom: 32rpx; }
.qr-box {
width: 360rpx; height: 360rpx; background: #F8FAFC;
border-radius: 16rpx; display: flex; align-items: center;
justify-content: center; border: 1rpx solid #E2E8F0;
}
.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; }
/* ===== Cancel Modal ===== */
.cancel-modal {
width: 600rpx; background: $color-card; border-radius: $radius-lg; padding: 40rpx;
}
.cancel-modal__title {
display: block; font-family: $font-family-body; font-size: 32rpx; font-weight: $font-weight-bold;
color: $uv-main-color; text-align: center; margin-bottom: 24rpx;
}
.cancel-modal__body { margin-bottom: 32rpx; }
.cancel-modal__text {
font-family: $font-family-body; font-size: $font-size-sm; color: $uv-content-color; line-height: 1.7;
}
.cancel-modal__actions { display: flex; gap: 24rpx; }
.btn-modal {
flex: 1; height: 72rpx; border-radius: 36rpx; display: flex;
align-items: center; justify-content: center; transition: all $transition-fast;
&--outline {
border: 2rpx solid $uv-border-color;
&:active { transform: scale(0.97); background: $uv-bg-color; }
}
&--primary {
background: $uv-primary;
&:active { transform: scale(0.97); }
}
}
.btn-modal__text--outline { font-family: $font-family-body; font-size: $font-size-sm; font-weight: $font-weight-medium; color: $uv-tips-color; }
.btn-modal__text--primary { font-family: $font-family-body; font-size: $font-size-sm; font-weight: $font-weight-bold; color: #ffffff; }
/* ===== Detail Sheet ===== */
.detail-sheet {
background: $color-card; border-radius: $radius-lg $radius-lg 0 0; max-height: 75vh;
display: flex; flex-direction: column;
}
.sheet-header {
display: flex; align-items: center; justify-content: space-between;
padding: 32rpx; border-bottom: 1rpx solid $uv-border-color; flex-shrink: 0;
}
.sheet-title { font-family: $font-family-body; font-size: $font-size-lg; font-weight: $font-weight-bold; color: $uv-main-color; }
.sheet-close {
width: 48rpx; height: 48rpx; display: flex; align-items: center; justify-content: center;
&:active { opacity: 0.6; }
}
.sheet-close__icon {
width: 20rpx; height: 20rpx; position: relative;
&::before, &::after {
content: ''; position: absolute; top: 50%; left: 50%;
width: 20rpx; height: 3rpx; background: $uv-tips-color; border-radius: 2rpx;
}
&::before { transform: translate(-50%, -50%) rotate(45deg); }
&::after { transform: translate(-50%, -50%) rotate(-45deg); }
}
.sheet-body { padding: 32rpx; flex: 1; }
.sheet-row {
display: flex; justify-content: space-between; align-items: center;
padding: 14rpx 0; border-bottom: 1rpx solid $uv-border-color;
}
.sheet-label { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-main-color; }
.sheet-value { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-content-color; text-align: right; }
.sheet-photo {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14rpx 0;
border-bottom: 1rpx solid $uv-border-color;
}
.sheet-photo-avatar {
width: 100rpx;
height: 100rpx;
border-radius: 50%;
}
.sheet-tip {
margin-top: 24rpx; padding: 20rpx; background: #fff2f2; border-radius: 12rpx;
}
.sheet-tip__text { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-error; line-height: 1.5; }
</style>
+378
View File
@@ -0,0 +1,378 @@
<template>
<view class="page">
<!-- 成功图标区域 -->
<view class="success-header">
<view class="success-icon">
<view class="success-circle" />
<view class="success-check" />
</view>
<text class="success-title">预约成功</text>
<text class="success-desc">请携带本人身份证+入园码按时入园</text>
</view>
<!-- 预约信息卡片 -->
<view class="info-card">
<view class="info-row">
<text class="info-label">预约景区</text>
<text class="info-value">{{ scenicName }}</text>
</view>
<view class="info-row">
<text class="info-label">预约日期</text>
<text class="info-value">{{ reserveDate }}</text>
</view>
<view v-if="reserveSession" class="info-row">
<text class="info-label">预约场次</text>
<text class="info-value">{{ reserveSession }}</text>
</view>
<view class="info-row">
<text class="info-label">预约人</text>
<text class="info-value">{{ userName }}</text>
</view>
<view class="info-row">
<text class="info-label">证件号</text>
<text class="info-value">{{ maskedId }}</text>
</view>
<view class="info-row">
<text class="info-label">预约编号</text>
<text class="info-value">{{ reserveId }}</text>
</view>
<view 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>
<!-- 重要提醒 -->
<view class="warn-card">
<text class="warn-title">重要预约规则</text>
<view class="warn-list">
<view class="warn-item">
<view class="warn-dot" />
<text class="warn-text">预约当日无法取消,如需改期请至少提前1天操作</text>
</view>
<view class="warn-item">
<view class="warn-dot" />
<text class="warn-text">爽约未取消将扣除年卡使用次数,无法恢复</text>
</view>
</view>
</view>
<!-- 底部按钮 -->
<view class="bottom-btns">
<view class="btn-half btn-half--outline" @tap="goRecord">
<text class="btn-half__text--outline">查看预约记录</text>
</view>
<view class="btn-half btn-half--primary" @tap="goHome">
<text class="btn-half__text--primary">返回首页</text>
</view>
</view>
<!-- 入园码弹窗 -->
<uni-popup ref="codePopup" type="center" :mask-click="true">
<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" />
</view>
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onBackPress } from '@dcloudio/uni-app'
const reserveId = ref('YP26062601')
const scenicName = ref('武功山金顶景区')
const reserveDate = ref('2026-06-27(周六)')
const reserveSession = ref('')
const userName = ref('张三')
const maskedId = ref('360302****1234')
const codePopup = ref<any>(null)
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()]})`
}
if (query?.scenic) scenicName.value = decodeURIComponent(query.scenic)
if (query?.session) reserveSession.value = decodeURIComponent(query.session)
})
// 拦截返回,跳转预约记录页
onBackPress(() => {
uni.redirectTo({ url: '/packages/user/pages/reserve/list' })
return true
})
function showCode() {
codePopup.value?.open()
}
function goRecord() {
uni.redirectTo({ url: '/packages/user/pages/reserve/list' })
}
function goHome() {
uni.switchTab({ url: '/pages/index/index' })
}
</script>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $color-background;
display: flex;
flex-direction: column;
padding-bottom: env(safe-area-inset-bottom);
font-family: $font-family-body;
}
/* ===== 成功图标 ===== */
.success-header {
display: flex;
flex-direction: column;
align-items: center;
padding: 64rpx 0 0;
}
.success-icon {
width: 120rpx;
height: 120rpx;
position: relative;
}
.success-circle {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba(60, 156, 255, 0.1);
}
.success-check {
width: 48rpx;
height: 24rpx;
border-left: 6rpx solid $uv-primary;
border-bottom: 6rpx solid $uv-primary;
transform: rotate(-45deg);
position: absolute;
top: 40rpx;
left: 38rpx;
}
.success-title {
font-family: $font-family-body;
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-top: 32rpx;
}
.success-desc {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
margin-top: 16rpx;
}
/* ===== 预约信息卡片 ===== */
.info-card {
margin: 48rpx 32rpx 0;
background: $color-card;
border-radius: $radius-lg;
padding: 32rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.04);
}
.info-row {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
border-bottom: 1rpx solid $uv-border-color;
&--last {
border-bottom: none;
}
}
.info-label {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-main-color;
}
.info-value {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-content-color;
text-align: right;
}
.code-btn {
padding: 6rpx 20rpx;
border-radius: 8rpx;
background: rgba(60, 156, 255, 0.08);
&:active { transform: scale(0.95); }
}
.code-btn__text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-primary;
font-weight: $font-weight-medium;
}
/* ===== 重要提醒 ===== */
.warn-card {
margin: 24rpx 32rpx 0;
background: #fff2f2;
border-radius: $radius-lg;
padding: 32rpx;
}
.warn-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-error;
margin-bottom: 16rpx;
}
.warn-list {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.warn-item {
display: flex;
align-items: flex-start;
}
.warn-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: #f53f3f;
flex-shrink: 0;
margin-top: 14rpx;
margin-right: 12rpx;
}
.warn-text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 1.6;
}
/* ===== 底部按钮 ===== */
.bottom-btns {
display: flex;
gap: 32rpx;
padding: 48rpx 32rpx 64rpx;
margin-top: auto;
}
.btn-half {
flex: 1;
height: 80rpx;
border-radius: 40rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all $transition-fast;
&--outline {
border: 2rpx solid $uv-border-color;
&:active { transform: scale(0.97); background: #f5f5f5; }
}
&--primary {
background: $uv-primary;
&:active { transform: scale(0.97); }
}
}
.btn-half__text--outline {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-tips-color;
}
.btn-half__text--primary {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: #ffffff;
}
/* ===== 入园码弹窗 ===== */
.code-modal {
width: 560rpx;
background: $color-card;
border-radius: $radius-lg;
padding: 40rpx;
display: flex;
flex-direction: column;
align-items: center;
}
.code-modal__title {
font-family: $font-family-body;
font-size: 32rpx;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: 32rpx;
}
.code-modal__desc {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
margin-top: 8rpx;
}
.qr-box {
width: 360rpx;
height: 360rpx;
background: #F8FAFC;
border-radius: 16rpx;
display: flex;
align-items: center;
justify-content: center;
border: 1rpx solid #E2E8F0;
margin-bottom: 32rpx;
}
.qr-img {
width: 300rpx;
height: 300rpx;
}
.copy-btn {
padding: 16rpx 48rpx;
border-radius: 32rpx;
background: rgba(60, 156, 255, 0.08);
&:active { transform: scale(0.95); }
}
.copy-btn__text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-primary;
font-weight: $font-weight-medium;
}
</style>
+21
View File
@@ -0,0 +1,21 @@
<template>
<view class="page">
<image
class="guide-image"
src="https://hn12301.oss-cn-shenzhen.aliyuncs.com/hn12301/px-062501.png"
mode="widthFix"
/>
</view>
</template>
<style lang="scss" scoped>
.page {
min-height: 100vh;
background: $color-card;
}
.guide-image {
width: 100%;
display: block;
}
</style>