第一个版本

This commit is contained in:
lcj
2026-07-06 09:17:41 +08:00
parent 1b4dcabad2
commit 203d129ffb
37 changed files with 2625 additions and 1199 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
VITE_API_BASE_URL=http://localhost:48080 VITE_API_BASE_URL=https://dscp-dev.hn12301.net
VITE_APP_TITLE=湖南12301文旅专区 VITE_APP_TITLE=湖南12301文旅专区
VITE_USE_MOCK=true VITE_USE_MOCK=true
+3
View File
@@ -1,3 +1,6 @@
packages:
- '.'
allowBuilds: allowBuilds:
'@parcel/watcher': true '@parcel/watcher': true
core-js: true core-js: true
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"compileType": "miniprogram", "compileType": "miniprogram",
"libVersion": "trial", "libVersion": "3.16.1",
"packOptions": { "packOptions": {
"ignore": [], "ignore": [],
"include": [] "include": []
+3 -2
View File
@@ -1,6 +1,6 @@
{ {
"description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html", "description": "项目私有配置文件。此文件中的内容将覆盖 project.config.json 中的相同字段。项目的改动优先同步到此文件中。详见文档:https://developers.weixin.qq.com/miniprogram/dev/devtools/projectconfig.html",
"projectname": "%E8%B4%A8%E6%83%A0%E6%B8%B8%E6%B5%8B%E8%AF%95", "projectname": "zhy-mp-test",
"setting": { "setting": {
"compileHotReLoad": true, "compileHotReLoad": true,
"urlCheck": true, "urlCheck": true,
@@ -18,5 +18,6 @@
"useLanDebug": false, "useLanDebug": false,
"ignoreDevUnusedFiles": true "ignoreDevUnusedFiles": true
}, },
"libVersion": "3.16.1" "libVersion": "3.16.1",
"condition": {}
} }
-9
View File
@@ -1,6 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { onLaunch } from '@dcloudio/uni-app' import { onLaunch } from '@dcloudio/uni-app'
import { useUserStore } from './store/user'
/** 应用启动时检查隐私协议并尝试获取用户信息 */ /** 应用启动时检查隐私协议并尝试获取用户信息 */
onLaunch(() => { onLaunch(() => {
@@ -27,14 +26,6 @@ onLaunch(() => {
// wx.getDeviceInfo 或 wx.getPrivacySetting 不支持时静默跳过 // wx.getDeviceInfo 或 wx.getPrivacySetting 不支持时静默跳过
} }
// #endif // #endif
const userStore = useUserStore()
// 如果有 token,尝试获取用户信息
if (userStore.isLogin) {
userStore.fetchUserInfo().catch(() => {
// 获取用户信息失败不影响使用
})
}
}) })
</script> </script>
+19
View File
@@ -0,0 +1,19 @@
import { post } from './request'
import { BASE_URL } from '../config'
import type { UserCardListItem, QrAndUserInfoResult } from './types'
/** 获取用户年卡列表 */
export function getUserCardList(data: { mobile: string; saleChannel: string }): Promise<UserCardListItem[]> {
return post<UserCardListItem[]>(`${BASE_URL}/zhy-api/cardBag/userCardList`, data, false)
}
/** 获取用户信息与入园码 */
export function getQrAndUserInfo(data: {
cardNum: string
thirdId: string
mobile: string
channel: string
scenicId: string
}): Promise<QrAndUserInfoResult> {
return post<QrAndUserInfoResult>(`${BASE_URL}/zhy-api/cardBag/qrAndUserInfo`, data, false)
}
+11
View File
@@ -0,0 +1,11 @@
import { post } from './request'
import { BASE_URL } from '../config'
import type { CreateOrderParams, CreateOrderResult, PrepayParams, PrepayResult } from './types'
export function createOrder(params: CreateOrderParams) {
return post<CreateOrderResult>(`${BASE_URL}/open-api/pingXiang/createOrder`, params)
}
export function prepay(params: PrepayParams) {
return post<PrepayResult>(`${BASE_URL}/open-api/pingXiang/prepay`, params)
}
+74
View File
@@ -175,8 +175,82 @@ export interface PageResult<T> {
pageSize: number pageSize: number
} }
/** OAuth2 微信登录结果 */
export interface OAuth2LoginResult {
mobile: string
openId: string
userId: number
}
/** 用户年卡列表项(来自 /zhy-api/cardBag/userCardList) */
export interface UserCardListItem {
cardNo: string
cardModelName: string
validityTime: string
cardModelPic: string
thirdId: string
needFaceImgFlag?: boolean
}
/** 用户信息与入园码(来自 /zhy-api/cardBag/qrAndUserInfo) */
export interface QrAndUserInfoResult {
userName: string
qrCode: string
img: string
mobile: string
idType: string
idNo: string
}
/** 分页请求参数 */ /** 分页请求参数 */
export interface PageQuery { export interface PageQuery {
page: number page: number
pageSize: number pageSize: number
} }
/** Banner 项(来自 mp-api/mpContent/queryList) */
export interface BannerItem {
id: number
channel: string
type: string
title: string
content: string | null
imageUrl: string
linkUrl: string
sortOrder: number
category: string | null
createdTime: string
}
/** 创建订单参数(open-api/pingXiang/createOrder) */
export interface CreateOrderParams {
cardModleId: string
buyNum: number
skuId: string
mobile: string
couponId: string
}
/** 创建订单结果 */
export interface CreateOrderResult {
orderId: string
outTradeNo: string
}
/** 预支付参数(open-api/pingXiang/prepay) */
export interface PrepayParams {
openId: string
orderNo: string
description: string
amount: number
}
/** 预支付结果(含微信支付调起参数) */
export interface PrepayResult {
appId: string
timeStamp: string
nonce: string
pack: string
signType: string
paySign: string
}
+10 -5
View File
@@ -1,14 +1,19 @@
import { get, post } from './request' import { get, post } from './request'
import type { UserInfo, WxLoginResult } from './types' import { BASE_URL } from '../config'
import type { WxLoginResult, OAuth2LoginResult } from './types'
/** 微信小程序登录(loginCode + phoneCode) */ /** 微信小程序登录(loginCode + phoneCode) */
export function wxLogin(data: { loginCode: string; phoneCode: string }): Promise<WxLoginResult> { export function wxLogin(data: { loginCode: string; phoneCode: string }): Promise<WxLoginResult> {
return post<WxLoginResult>('/auth/wx-login', data, false) return post<WxLoginResult>('/auth/wx-login', data, false)
} }
/** 获取当前用户信息 */ /** OAuth2 微信登录(code + encryptedData + iv) */
export function getUserInfo(): Promise<UserInfo> { export function wxLoginByOAuth2(data: {
return get<UserInfo>('/user/info') code: string
encryptedData: string
iv: string
}): Promise<OAuth2LoginResult> {
return post<OAuth2LoginResult>(`${BASE_URL}/open-api/auth/oauth2`, data, false)
} }
export type { UserInfo, WxLoginResult } export type { WxLoginResult, OAuth2LoginResult }
+181
View File
@@ -0,0 +1,181 @@
<template>
<view
v-if="modelValue"
class="modal-overlay"
:class="{ visible: show }"
@click="onOverlayClick"
>
<view class="modal-panel" @click.stop>
<text class="modal-title">{{ title }}</text>
<view class="modal-divider" />
<view class="modal-body">
<text class="modal-content">{{ content }}</text>
</view>
<view class="modal-actions">
<view v-if="showCancel" class="btn btn-cancel" @click="onCancel">
<text class="btn-cancel-text">{{ cancelText }}</text>
</view>
<view class="btn btn-confirm" @click="onConfirm">
<text class="btn-confirm-text">{{ confirmText }}</text>
</view>
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, watch, nextTick } from 'vue'
const props = withDefaults(defineProps<{
modelValue: boolean
title?: string
content?: string
confirmText?: string
cancelText?: string
showCancel?: boolean
}>(), {
title: '提示',
content: '',
confirmText: '确定',
cancelText: '取消',
showCancel: false,
})
const emit = defineEmits<{
'update:modelValue': [value: boolean]
'confirm': []
'cancel': []
}>()
const show = ref(false)
watch(() => props.modelValue, (val) => {
if (val) {
nextTick(() => { show.value = true })
} else {
show.value = false
}
})
function onConfirm() {
emit('confirm')
close()
}
function onCancel() {
emit('cancel')
close()
}
function onOverlayClick() {
if (props.showCancel) close()
}
function close() {
show.value = false
setTimeout(() => { emit('update:modelValue', false) }, 280)
}
</script>
<style lang="scss" scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: $z-modal;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 280ms cubic-bezier(0.16, 1, 0.3, 1);
&.visible {
opacity: 1;
.modal-panel {
transform: scale(1);
opacity: 1;
}
}
}
.modal-panel {
width: calc(100vw - 96rpx);
background: $color-card;
border-radius: $radius-2xl;
padding: $spacing-5 $spacing-4 $spacing-4;
box-shadow: $shadow-xl;
transform: scale(0.88);
opacity: 0;
transition: transform 320ms cubic-bezier(0.16, 1, 0.3, 1),
opacity 260ms cubic-bezier(0.16, 1, 0.3, 1);
}
.modal-title {
display: block;
text-align: center;
font-size: $font-size-xl;
font-weight: $font-weight-bold;
color: $color-foreground;
line-height: $line-height-tight;
}
.modal-divider {
height: $border-width;
background: $uv-border-color;
opacity: 0.4;
margin: $spacing-3 0;
}
.modal-body {
padding: $spacing-2 0 $spacing-5;
}
.modal-content {
display: block;
font-size: $font-size-base;
color: $uv-content-color;
line-height: $line-height-relaxed;
white-space: pre-line;
text-align: left;
}
.modal-actions {
display: flex;
gap: $spacing-3;
}
.btn {
flex: 1;
height: 88rpx;
border-radius: $radius-full;
display: flex;
align-items: center;
justify-content: center;
transition: opacity 150ms;
&:active {
opacity: 0.7;
}
}
.btn-confirm {
background: $uv-primary;
}
.btn-confirm-text {
font-size: $font-size-lg;
color: #fff;
font-weight: $font-weight-medium;
}
.btn-cancel {
background: $uv-bg-color;
}
.btn-cancel-text {
font-size: $font-size-lg;
color: $uv-content-color;
font-weight: $font-weight-medium;
}
</style>
+5 -2
View File
@@ -1,7 +1,7 @@
{ {
"vueVersion" : "3", "vueVersion" : "3",
"mp-weixin" : { "mp-weixin" : {
"appid": "wx636bc46a6570e9fd", "appid" : "wxae3b39ac443fedc3",
"setting" : { "setting" : {
"urlCheck" : false, "urlCheck" : false,
"es6" : true, "es6" : true,
@@ -9,5 +9,8 @@
"minified" : true "minified" : true
}, },
"lazyCodeLoading" : "requiredComponents" "lazyCodeLoading" : "requiredComponents"
} },
"appid" : "__UNI__1AFEBA3",
"name" : "爱上萍乡",
"versionName" : "1.0.0"
} }
+72 -207
View File
@@ -1,88 +1,31 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 购买类 --> <view v-if="loading" class="loading">
<text class="loading-text">加载中...</text>
</view>
<template v-for="([catName, items], catIdx) in categories" :key="catIdx">
<view class="category"> <view class="category">
<text class="category-title">购买类</text> <text class="category-title">{{ catName }}</text>
<view class="category-divider" /> <view class="category-divider" />
<view <view
v-for="(item, idx) in purchaseList" v-for="(item, idx) in items"
:key="'p' + idx" :key="item.id"
class="faq-card" class="faq-card"
> >
<view class="faq-header" @tap="toggle('p', idx)"> <view class="faq-header" @tap="toggle(idx)">
<view class="faq-index">{{ idx + 1 }}</view> <view class="faq-index">{{ idx + 1 }}</view>
<text class="faq-question">{{ item.q }}</text> <text class="faq-question">{{ item.title }}</text>
<view class="faq-arrow" :class="{ 'faq-arrow--up': isOpen('p', idx) }" /> <view class="faq-arrow" :class="{ 'faq-arrow--up': isOpen(idx) }" />
</view> </view>
<view v-show="isOpen('p', idx)" class="faq-body"> <view v-show="isOpen(idx)" class="faq-body">
<view v-for="(p, pi) in item.a" :key="pi" class="faq-paragraph"> <view class="faq-paragraph">
<text <text class="faq-text">{{ item.content }}</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> </view>
</view> </view>
</template>
<!-- 底部安全区 --> <!-- 底部安全区 -->
<view class="safe-bottom" /> <view class="safe-bottom" />
@@ -90,27 +33,55 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, computed, onMounted } from 'vue'
import { post } from '../../../api/request'
import type { BannerItem } from '../../../api/types'
// 展开状态:默认全部展开 interface FaqRecord {
const openMap = ref<Record<string, boolean>>({}) id: number
title: string
// 初始化所有条目为展开 content: string
const purchaseList = getPurchaseList() category: string | null
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) { const faqList = ref<FaqRecord[]>([])
return openMap.value[`${cat}${idx}`] ?? false const loading = ref(true)
onMounted(async () => {
try {
const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'FAQ' })
faqList.value = (data || []).map(n => ({
id: n.id,
title: n.title,
content: n.content || '',
category: n.category,
}))
} catch {
// 静默失败
} finally {
loading.value = false
}
})
const categories = computed(() => {
const map = new Map<string | null, FaqRecord[]>()
faqList.value.forEach(item => {
const key = item.category || '其他'
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(item)
})
return Array.from(map.entries())
})
/** 展开状态 */
const openMap = ref<Record<string, boolean>>({})
function toggle(idx: number) {
openMap.value[`${idx}`] = !openMap.value[`${idx}`]
}
function isOpen(idx: number) {
return openMap.value[`${idx}`] ?? true
} }
function copyText(text: string) { function copyText(text: string) {
@@ -119,124 +90,6 @@ function copyText(text: string) {
success: () => uni.showToast({ title: '已复制', icon: 'success' }) 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> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -359,6 +212,18 @@ function getBindList(): FaqItem[] {
line-height: 1.7; line-height: 1.7;
} }
/* ===== 加载状态 ===== */
.loading {
display: flex;
justify-content: center;
padding: 80rpx 0;
}
.loading-text {
font-size: $font-size-sm;
color: $uv-tips-color;
}
/* ===== 底部安全区 ===== */ /* ===== 底部安全区 ===== */
.safe-bottom { .safe-bottom {
height: 64rpx; height: 64rpx;
+19 -10
View File
@@ -33,24 +33,33 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import AnnouncementModal from '../../../components/announcement-modal.vue' import AnnouncementModal from '../../../components/announcement-modal.vue'
import { post } from '../../../api/request'
import type { BannerItem } from '../../../api/types'
interface NoticeItem { interface NoticeItem {
id: number id: number
title: string title: string
date: string date: string
content: string content: string
body: string
} }
const notices = ref<NoticeItem[]>([ 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人。' }, onMounted(async () => {
{ id: 3, title: '超级玩家卡全新上线', date: '2026-06-15', content: '超级玩家卡全新上线,40家景区免门票,仅¥198!', body: '超级玩家卡全新上线!仅需¥198即可享受40家合作景区免首道门票权益,单次最高可省¥228。覆盖景区包括武功山金顶(¥70)、君澜温泉(¥228)、杨岐山孽龙洞(¥68)、仙凤三宝(¥50)等热门景区。卡片有效期为购买之日起一年,法定节假日通用,不限购买次数。适合经常出游的家庭和户外爱好者。' }, try {
{ id: 4, title: '端午节假期景区预约提醒', date: '2026-06-10', content: '端午假期热门景区需提前预约,请合理安排出行。', body: '端午节假期(6月14日至16日)期间,武功山金顶景区、君澜温泉等热门景区将实行限流措施,每日限流5000人。请持卡用户提前通过小程序预约,未预约将无法入园。建议错峰出行,关注景区实时客流信息。' }, const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'NOTICE' })
{ id: 5, title: '新增合作景区:莲花荷博园', date: '2026-06-05', content: '莲花荷博园正式加入合作景区,持卡免门票6次。', body: '莲花荷博园于2026年6月起正式加入赣湘全域旅游年卡合作景区。持卡用户全年可免首道门票入园6次(门票原价¥35)。莲花荷博园位于萍乡市莲花县,夏季荷花盛开时景色最佳,是休闲观光的好去处。' }, notices.value = (data || []).map(n => ({
]) id: n.id,
title: n.title,
date: n.createdTime ? n.createdTime.slice(0, 10) : '',
content: n.content || '',
}))
} catch {
// 静默失败
}
})
/** 弹窗状态 */ /** 弹窗状态 */
const showModal = ref(false) const showModal = ref(false)
@@ -59,7 +68,7 @@ const modalBody = ref('')
function openDetail(item: NoticeItem) { function openDetail(item: NoticeItem) {
modalTitle.value = item.title modalTitle.value = item.title
modalBody.value = item.body modalBody.value = item.content
showModal.value = true showModal.value = true
} }
</script> </script>
+169 -110
View File
@@ -54,9 +54,9 @@
<view class="info-line" @click="onHoursClick"> <view class="info-line" @click="onHoursClick">
<view class="icon-wrap icon-clock" /> <view class="icon-wrap icon-clock" />
<text class="text-body">{{ scenic.hours }}</text> <text class="text-body">{{ scenic.hours }}</text>
<view class="status-tag" :class="isOpen ? 'open' : 'closed'"> <view class="status-tag" :class="'st-' + scenic.status">
<view class="status-dot" /> <view class="status-dot" />
<text>{{ isOpen ? '营业中' : '已闭园' }}</text> <text>{{ statusLabel }}</text>
</view> </view>
</view> </view>
@@ -77,27 +77,31 @@
</view> </view>
</view> </view>
<!-- 模块3:卡种权益卡片(独立白卡,无绑定自动隐藏) --> <!-- 模块3:卡种权益卡片 -->
<view <view
class="benefit-card" class="benefit-card"
v-for="card in scenic.cards" v-for="(card, idx) in scenic.cards"
:key="card.type" :key="idx"
> >
<view class="benefit-body">
<view class="benefit-top"> <view class="benefit-top">
<view class="card-type-tag" :class="card.type"> <view class="card-type-tag">
<text>{{ card.type === 'super' ? '超级玩家卡' : '赣湘全域旅游年卡' }}</text> <text>{{ card.cardName }}</text>
</view> </view>
<text v-if="scenic.price > 0" class="ticket-price">¥{{ scenic.price }}</text>
</view> </view>
<view class="benefit-mid"> <view class="benefit-sku">
<text class="ticket-label">首道门票</text> <text class="sku-name">{{ card.skuName }}</text>
<view class="member-row"> </view>
<view class="benefit-meta">
<view class="icon-wrap icon-crown" /> <view class="icon-wrap icon-crown" />
<text class="member-text">持卡用户免费</text> <text class="remaining-text">
{{ card.remainingCount > 0 ? '全年剩余' + card.remainingCount + '次' : '全年可用' + card.freeCount + '次' }}
</text>
<text v-if="card.bookingDeadline" class="deadline-text">{{ card.bookingDeadline }}</text>
</view> </view>
</view> </view>
<text class="remaining-text">当前卡剩余可使用 {{ card.remaining }} 次</text> <view class="benefit-divider" />
<view class="book-btn" @click="onBookClick(card.type)"> <view class="book-btn" @click="onBookClick(card)">
<text>立即预约</text> <text>立即预约</text>
</view> </view>
</view> </view>
@@ -144,69 +148,81 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { post, getToken } from '../../../api/request'
interface NoticeItem { title: string; content: string } interface NoticeItem { title: string; content: string }
interface CardBenefit { type: 'super' | 'annual'; remaining: number } interface CardBenefit { type: string; cardName: string; skuName: string; remainingCount: number; freeCount: number; bookingDeadline: string; cardModelId: string; resourceId: string; goodsId: string }
interface ScenicDetail { interface ScenicDetail {
id: number; name: string; price: number; freeCount: number; level: string id: number; name: string; price: number; freeCount: number; level: string
hours: string; needReservation: boolean; bookingDeadline: string hours: string; needReservation: boolean; bookingDeadline: string
phone: string; address: string; tags: string[]; images: string[] phone: string; address: string; tags: string[]; images: string[]
introduction: string; bookingNotice: NoticeItem[]; cards: CardBenefit[] introduction: string; bookingNotice: NoticeItem[]; cards: CardBenefit[]
scenicId: number; shopId: number; mainPic: string; status: number
} }
const scenic = ref<ScenicDetail>({ tags: [], images: [], cards: [], bookingNotice: [] } as unknown as ScenicDetail) const scenic = ref<ScenicDetail>({ tags: [], images: [], cards: [], bookingNotice: [] } as unknown as ScenicDetail)
const activeTab = ref<'notice' | 'intro'>('notice') const activeTab = ref<'notice' | 'intro'>('notice')
const currentSlide = ref(0) const currentSlide = ref(0)
const loading = ref(true)
function onSwiperChange(e: any) { function onSwiperChange(e: any) {
currentSlide.value = e.detail.current currentSlide.value = e.detail.current
} }
/** 根据营业时间判断运营状态 */ /** 根据运营状态字段判断是否运营中 */
const isOpen = computed(() => { const isOpen = computed(() => scenic.value.status === 1)
const h = scenic.value.hours const statusLabel = computed(() => ['', '正常运营', '审核中', '暂停运营', '已关店'][scenic.value.status] || '')
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) => { onLoad((options) => {
const id = parseInt(options?.id || '0') const shopId = options?.shopId || options?.id || ''
const found = scenicData.find(s => s.id === id) if (!shopId) {
if (found) { uni.showToast({ title: '缺少景区参数', icon: 'none' })
scenic.value = found uni.navigateBack()
uni.setNavigationBarTitle({ title: found.name }) return
} else {
scenic.value = scenicData[0]
uni.setNavigationBarTitle({ title: scenicData[0].name })
} }
uni.setNavigationBarTitle({ title: '景区详情' })
fetchDetail(String(shopId))
}) })
async function fetchDetail(shopId: string) {
loading.value = true
try {
const params: Record<string, any> = { shopId, channel: 'pingxiang' }
const phone = uni.getStorageSync('phone') as string
if (phone) params.phone = phone
const res = await post<any>('/mp-api/mpScenic/getScenicDetail', params)
if (res) {
scenic.value = {
id: res.id || res.scenicId || shopId,
name: res.name || '',
price: ((res.price || res.ticketPrice || 0) / 100),
freeCount: res.freeCount ?? 0,
level: res.level || '',
hours: res.hours || res.businessHours || '',
needReservation: res.needReservation ?? false,
bookingDeadline: res.bookingDeadline || '',
phone: res.phone || '',
address: res.address || '',
tags: res.tags || [],
images: res.images || (res.mainPic ? [res.mainPic] : []),
introduction: res.introduction || '',
bookingNotice: res.bookingNotice || [],
cards: res.cards || [],
scenicId: res.scenicId || res.id || shopId,
shopId: res.shopId || shopId,
mainPic: res.mainPic || '',
status: res.status ?? 1,
}
if (res.name) uni.setNavigationBarTitle({ title: res.name })
}
} catch (err: any) {
uni.showToast({ title: err?.msg || err?.message || '加载失败', icon: 'none' })
} finally {
loading.value = false
}
}
function onHoursClick() { function onHoursClick() {
uni.showToast({ title: '营业时间仅供参考', icon: 'none' }) uni.showToast({ title: '营业时间仅供参考', icon: 'none' })
} }
@@ -221,8 +237,42 @@ function onCopyAddress() {
} }
}) })
} }
function onBookClick(cardType: string) { async function onBookClick(card: CardBenefit) {
uni.navigateTo({ url: '/packages/user/pages/reserve/index' }) if (!getToken()) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpReserve/checkCardBind', {
phone,
cardModelId: card.cardModelId,
resourceId: card.resourceId,
})
if (res?.hasValidCard) {
uni.navigateTo({
url: `/packages/user/pages/reserve/index?cardNumber=${res.cardNumber}&resourceId=${res.resourceId}&cardModelId=${card.cardModelId}`,
})
} else {
uni.showModal({
title: '提示',
content: '暂无可用年卡,请先购买',
confirmText: '去购买',
cancelText: '取消',
success: (modalRes) => {
if (modalRes.confirm) {
uni.navigateTo({
url: `/packages/user/pages/buy?goodsId=${card.goodsId}`,
})
}
},
})
}
} catch (err: any) {
uni.showToast({ title: err?.msg || err?.message || '检查失败', icon: 'none' })
}
} }
</script> </script>
@@ -427,8 +477,10 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }
&.open text { color: $uv-success; } &.st-1 text { color: $uv-success; }
&.closed text { color: $uv-error; } &.st-2 text { color: $uv-warning; }
&.st-3 text { color: $uv-error; }
&.st-4 text { color: #999; }
} }
.status-dot { .status-dot {
@@ -436,8 +488,10 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
height: 12rpx; height: 12rpx;
border-radius: 50%; border-radius: 50%;
.open & { background: $uv-success; } .st-1 & { background: $uv-success; }
.closed & { background: $uv-error; } .st-2 & { background: $uv-warning; }
.st-3 & { background: $uv-error; }
.st-4 & { background: #999; }
} }
.map-btn { .map-btn {
@@ -459,94 +513,99 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
.benefit-card { .benefit-card {
background: $color-card; background: $color-card;
margin: $spacing-3 $spacing-3 $spacing-2; margin: $spacing-3 $spacing-3 $spacing-2;
padding: $spacing-3;
border-radius: $radius-lg; border-radius: $radius-lg;
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
overflow: hidden;
}
.benefit-body {
padding: $spacing-3;
} }
.benefit-top { .benefit-top {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between;
margin-bottom: $spacing-2; margin-bottom: $spacing-2;
} }
.card-type-tag { .card-type-tag {
padding: 4rpx 16rpx; position: relative;
border-radius: $radius-sm; padding-left: 16rpx;
font-size: $font-size-sm;
&::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 6rpx;
height: 24rpx;
background: $uv-primary;
border-radius: $radius-full;
}
text { text {
font-size: 22rpx; font-size: $font-size-sm;
color: #fff; color: $uv-primary;
font-weight: $font-weight-medium; font-weight: $font-weight-bold;
}
&.super {
background: #f9ae3d;
}
&.annual {
background: $uv-success;
} }
} }
.ticket-price { .benefit-sku {
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; display: flex;
align-items: center; align-items: center;
gap: 8rpx; gap: 8rpx;
margin-bottom: $spacing-1;
} }
.member-text { .sku-name {
font-size: $font-size-sm; font-size: $font-size-base;
color: #f9ae3d; color: $uv-main-color;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }
.benefit-meta {
display: flex;
align-items: center;
gap: 6rpx;
}
.remaining-text { .remaining-text {
font-size: 22rpx; font-size: $font-size-sm;
color: $uv-success; color: $uv-success;
display: block; }
margin-bottom: $spacing-2;
.deadline-text {
margin-left: auto;
font-size: $font-size-xs;
color: $uv-tips-color;
}
.benefit-divider {
height: $border-width;
background: $uv-border-color;
margin: 0 $spacing-3;
} }
.book-btn { .book-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 88rpx; padding: $spacing-3;
background: linear-gradient(135deg, $uv-primary, $uv-primary-dark); margin: 0 $spacing-3;
margin-bottom: $spacing-3;
background: $uv-primary-light;
border-radius: $radius-full; border-radius: $radius-full;
box-shadow: 0 4rpx 16rpx rgba($uv-primary, 0.3);
transition: transform $transition-fast;
text { text {
font-size: $font-size-base; font-size: $font-size-sm;
color: #fff; color: $uv-primary;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }
&:active { &:active {
transform: scale(0.97); opacity: 0.7;
opacity: 0.9;
} }
} }
+414 -170
View File
@@ -13,10 +13,10 @@
</view> </view>
</view> </view>
<!-- 页面主体(避开导航栏 + 吸顶筛选区) --> <!-- 页面主体(避开导航栏) -->
<view class="page-body" :style="{ paddingTop: (navTotalHeight + stickyHeaderHeight) + 'px' }"> <view class="page-body" :style="{ paddingTop: navTotalHeight + 'px' }">
<!-- 吸顶搜索筛选区 --> <!-- 吸顶搜索筛选区(跟随 scroll-view 滚动) -->
<view class="sticky-header" :style="{ top: navTotalHeight + 'px' }"> <view class="sticky-header">
<!-- 搜索框 --> <!-- 搜索框 -->
<view class="search-box"> <view class="search-box">
<view class="search-icon"> <view class="search-icon">
@@ -45,15 +45,25 @@
<view class="dropdown-arrow" /> <view class="dropdown-arrow" />
</view> </view>
<view class="dropdown-trigger" @click="openPicker('cardType')"> <view class="dropdown-trigger" @click="openPicker('cardType')">
<text class="dropdown-label" :class="{ active: selectedCardType }">{{ selectedCardType ? (selectedCardType === '年卡' ? '赣湘全域旅游年卡' : '超级玩家卡') : '全部卡种' }}</text> <text class="dropdown-label" :class="{ active: selectedCardType }">{{ selectedCardTypeLabel }}</text>
<view class="dropdown-arrow" /> <view class="dropdown-arrow" />
</view> </view>
</view> </view>
</view> </view>
<!-- 景区列表 --> <!-- 可滚动的列表区域 -->
<view class="list-area"> <scroll-view
<view v-if="filteredScenics.length === 0" class="empty-state"> class="list-scroll"
scroll-y
:style="{ height: scrollViewHeight + 'px' }"
@scrolltolower="loadMore"
:lower-threshold="50"
>
<view v-if="loading" class="loading-state">
<text class="loading-text">加载中...</text>
</view>
<view v-else-if="scenicList.length === 0" class="empty-state">
<view class="empty-icon"> <view class="empty-icon">
<view class="empty-circle" /> <view class="empty-circle" />
<view class="empty-line line-1" /> <view class="empty-line line-1" />
@@ -64,7 +74,7 @@
<view v-else class="list-container"> <view v-else class="list-container">
<view <view
v-for="scenic in filteredScenics" v-for="scenic in scenicList"
:key="scenic.id" :key="scenic.id"
class="scenic-card" class="scenic-card"
@click="onCardClick(scenic)" @click="onCardClick(scenic)"
@@ -87,11 +97,8 @@
<view class="name-price-row"> <view class="name-price-row">
<text class="scenic-name">{{ scenic.name }}</text> <text class="scenic-name">{{ scenic.name }}</text>
<view class="price-group"> <view class="price-group">
<text v-if="scenic.price === 0" class="price-free">免费</text>
<template v-else>
<text class="price-symbol">¥</text> <text class="price-symbol">¥</text>
<text class="price-value">{{ scenic.price }}</text> <text class="price-value">{{ scenic.price }}</text>
</template>
</view> </view>
</view> </view>
@@ -102,20 +109,20 @@
<text class="time-text">{{ scenic.hours }}</text> <text class="time-text">{{ scenic.hours }}</text>
</view> </view>
<view class="status-block"> <view class="status-block">
<view class="status-icon" :class="scenic.status === '正常营业' ? 'open' : 'closed'" /> <view class="status-icon" :class="'st-' + scenic.status" />
<text class="status-text" :class="scenic.status === '正常营业' ? 'open' : 'closed'"> <text class="status-text" :class="'st-' + scenic.status">
{{ scenic.status }} {{ ['', '正常运营', '审核中', '暂停运营', '已关店'][scenic.status] || '' }}
</text> </text>
</view> </view>
</view> </view>
<!-- 剩余次数 + 免票标签 + 预约标签 同行 --> <!-- 剩余次数 + 预约标签 同行 -->
<view class="benefit-row"> <view class="benefit-row">
<view v-if="scenic.freeCount > 0" class="remaining-tag"> <view v-if="scenic.remainingCount > 0" class="remaining-tag">
<text>全年可用{{ scenic.freeCount }}次</text> <text>全年还剩{{ scenic.remainingCount }}次</text>
</view> </view>
<view class="benefit-tag primary"> <view v-else-if="scenic.freeCount > 0" class="remaining-tag">
<text>{{ scenic.freeCount > 0 ? `免${scenic.freeCount}次` : '免费' }}</text> <text>全年可用{{ scenic.freeCount }}次</text>
</view> </view>
<view class="benefit-tag" :class="scenic.needReservation ? 'warning' : 'success'"> <view class="benefit-tag" :class="scenic.needReservation ? 'warning' : 'success'">
<text>{{ scenic.needReservation ? '需预约' : '免预约' }}</text> <text>{{ scenic.needReservation ? '需预约' : '免预约' }}</text>
@@ -130,7 +137,15 @@
</view> </view>
</view> </view>
</view> </view>
<!-- 加载更多 -->
<view v-if="loadMoreLoading" class="load-more">
<text class="load-more-text">加载更多...</text>
</view> </view>
<view v-else-if="!hasMore && scenicList.length > 0" class="load-more">
<text class="load-more-text">— 没有更多了 —</text>
</view>
</scroll-view>
</view> </view>
<!-- 筛选弹窗(单实例,动态内容) --> <!-- 筛选弹窗(单实例,动态内容) -->
@@ -145,6 +160,9 @@
<view class="popup-sheet" @touchmove.stop.prevent> <view class="popup-sheet" @touchmove.stop.prevent>
<!-- 头部:标题 + 重置 --> <!-- 头部:标题 + 重置 -->
<view class="popup-header"> <view class="popup-header">
<view class="popup-back" v-if="activePickerType === 'region' && regionStep > 0" @click="goBackRegionStep">
<text class="back-text">← 返回</text>
</view>
<text class="popup-title">{{ popupTitle }}</text> <text class="popup-title">{{ popupTitle }}</text>
<view class="popup-reset" @click="resetFilter"> <view class="popup-reset" @click="resetFilter">
<text>重置</text> <text>重置</text>
@@ -152,16 +170,17 @@
</view> </view>
<!-- 可滚动选项列表 --> <!-- 可滚动选项列表 -->
<scroll-view scroll-y class="popup-scroll"> <scroll-view scroll-y class="popup-scroll" :key="activePickerType + '-' + regionStep">
<view <view
v-for="item in currentOptions" v-for="item in currentOptions"
:key="item.value" :key="item.value"
class="popup-option" class="popup-option"
:class="{ active: tempValue === item.value }" :class="{ active: item.value === getTempValue() }"
@click="onOptionTap(item.value)" @click="onOptionTap(item.value)"
> >
<text>{{ item.label }}</text> <text class="option-label">{{ item.label }}</text>
<view v-if="tempValue === item.value" class="check-mark" /> <view v-if="activePickerType !== 'region' && tempValue === item.value" class="check-mark" />
<view v-else-if="activePickerType === 'region' && item.value === getTempValue()" class="check-mark" />
</view> </view>
</scroll-view> </scroll-view>
@@ -175,31 +194,42 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, getCurrentInstance } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../api/request'
interface ScenicItem { interface ScenicItem {
id: number id: number
shopId: number
name: string name: string
price: number price: string
freeCount: number freeCount: number
remainingCount: number
level: string level: string
status: '正常营业' | '暂停营业' status: number
hours: string hours: string
needReservation: boolean needReservation: boolean
tips: string tips: string
type: string type: string
region: string region: string
image: string image: string
cardTypes: string[] }
interface RegionNode {
id: string
name: string
children: RegionNode[] | null
} }
// ========== 导航栏动态高度 ========== // ========== 导航栏动态高度 ==========
const statusBarHeight = ref(20) const statusBarHeight = ref(20)
const navContentHeight = ref(44) const navContentHeight = ref(44)
const navTotalHeight = ref(64) const navTotalHeight = ref(64)
const stickyHeaderHeight = ref(96) // 预估,onMounted 后精确测量 const stickyHeaderHeight = ref(100)
const windowHeight = ref(0)
const scrollViewHeight = computed(() => Math.max(0, windowHeight.value - navTotalHeight.value - stickyHeaderHeight.value))
// 页面加载时获取系统信息
;(function initNavBar() { ;(function initNavBar() {
try { try {
const windowInfo = uni.getWindowInfo() const windowInfo = uni.getWindowInfo()
@@ -207,14 +237,11 @@ const stickyHeaderHeight = ref(96) // 预估,onMounted 后精确测量
} catch { } catch {
statusBarHeight.value = 20 statusBarHeight.value = 20
} }
// #ifdef MP-WEIXIN
try { try {
const menuBtn = uni.getMenuButtonBoundingClientRect() const menuBtn = uni.getMenuButtonBoundingClientRect()
const capsuleHeight = menuBtn.height const capsuleHeight = menuBtn.height
const capsuleTop = menuBtn.top const capsuleTop = menuBtn.top
const capsuleBottom = capsuleTop + capsuleHeight const capsuleBottom = capsuleTop + capsuleHeight
// 导航栏内容区高度:胶囊上下各留等距间距
const contentH = (capsuleBottom - statusBarHeight.value) + (capsuleTop - statusBarHeight.value) const contentH = (capsuleBottom - statusBarHeight.value) + (capsuleTop - statusBarHeight.value)
navContentHeight.value = Math.max(contentH, 44) navContentHeight.value = Math.max(contentH, 44)
navTotalHeight.value = statusBarHeight.value + navContentHeight.value navTotalHeight.value = statusBarHeight.value + navContentHeight.value
@@ -222,186 +249,353 @@ const stickyHeaderHeight = ref(96) // 预估,onMounted 后精确测量
navContentHeight.value = 44 navContentHeight.value = 44
navTotalHeight.value = statusBarHeight.value + 44 navTotalHeight.value = statusBarHeight.value + 44
} }
// #endif
// #ifndef MP-WEIXIN
navContentHeight.value = 44
navTotalHeight.value = statusBarHeight.value + 44
// #endif
})() })()
// 吸顶筛选区高度:onMounted 后精确测量,预估值兜底
stickyHeaderHeight.value = 100 stickyHeaderHeight.value = 100
onMounted(() => { // ========== URL 参数 ==========
const query = uni.createSelectorQuery().in(getCurrentInstance()) const cardNumber = ref('')
query.select('.sticky-header').boundingClientRect((rect: any) => { const cardModelId = ref('')
if (rect && rect.height) {
// 实际高度 + 4px 安全间距,防止不同机型小数精度导致遮挡 // ========== 分页 ==========
stickyHeaderHeight.value = Math.ceil(rect.height) + 4 const pageNo = ref(1)
} const pageSize = ref(10)
}).exec() const total = ref(0)
}) const loading = ref(false)
const loadMoreLoading = ref(false)
const hasMore = computed(() => scenicList.value.length < total.value)
// ========== 数据 ========== // ========== 数据 ==========
const scenicList: ScenicItem[] = [ const scenicList = ref<ScenicItem[]>([])
{ id: 1, name: '武功山金顶景区', price: 70, freeCount: 1, level: '5A', status: '正常营业', hours: '08:00-18:00', needReservation: true, tips: '建议提前1天预约,山顶温差大请备好外套', type: '自然风光', region: '芦溪县', image: 'https://images.unsplash.com/photo-1513415564515-763d91423bdd?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 2, name: '杨岐山孽龙洞景区', price: 68, freeCount: 6, level: '4A', status: '正常营业', hours: '08:30-17:30', needReservation: false, tips: '洞内温度恒定18℃,建议携带薄外套', type: '自然风光', region: '上栗县', image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 3, name: '仙凤三宝景区', price: 50, freeCount: 6, level: '4A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '适合亲子游玩,园内有小动物喂养体验', type: '乡村田园', region: '芦溪县', image: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 4, name: '武功山君澜温泉', price: 228, freeCount: 6, level: '4A', status: '正常营业', hours: '10:00-22:00', needReservation: true, tips: '需提前预约,请自备泳衣泳帽', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1433086966358-54859d0ed716?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 5, name: '凯光新天地生态旅游区', price: 90, freeCount: 6, level: '4A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '园区较大,建议穿舒适运动鞋', type: '休闲娱乐', region: '安源区', image: 'https://images.unsplash.com/photo-1441974231531-c6227db76b6e?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 6, name: '和平农场·哎马乐园', price: 79, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:30', needReservation: false, tips: '亲子乐园,周末人流较多建议错峰', type: '乡村田园', region: '湘东区', image: 'https://images.unsplash.com/photo-1501854140801-50d01698950b?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 7, name: '武功山滑草场', price: 60, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '雨天暂停开放,请关注天气', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1551632811-561732d1e306?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 8, name: '芦溪仙凤三宝农业休闲观光园', price: 50, freeCount: 6, level: '3A', status: '正常营业', hours: '08:30-17:30', needReservation: false, tips: '可体验采摘,季节性水果品种丰富', type: '乡村田园', region: '芦溪县', image: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 9, name: '麻山幸福村', price: 40, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '乡村旅游景区,可体验农家生活', type: '乡村田园', region: '湘东区', image: 'https://images.unsplash.com/photo-1523712999610-f77fbcfc3843?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 10, name: '东桥双月湾', price: 38, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '临水景区,请注意安全', type: '自然风光', region: '湘东区', image: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 11, name: '上栗花炮文化博览园', price: 45, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '了解花炮历史文化,适合研学', type: '文化古迹', region: '上栗县', image: 'https://images.unsplash.com/photo-1533174072545-7a4b6ad7a6c3?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 12, name: '杨岐普通寺', price: 30, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-17:00', needReservation: false, tips: '佛教圣地,请保持安静', type: '文化古迹', region: '上栗县', image: 'https://images.unsplash.com/photo-1518241353330-0f7941c2d9b5?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 13, name: '孽龙洞漂流', price: 88, freeCount: 6, level: '3A', status: '暂停营业', hours: '暂停开放', needReservation: false, tips: '季节性项目,预计6月开放', type: '休闲娱乐', region: '上栗县', image: 'https://images.unsplash.com/photo-1504280390367-361c6d9f38f4?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 14, name: '安源路矿工人运动纪念馆', price: 0, freeCount: 0, level: '4A', status: '正常营业', hours: '09:00-16:30', needReservation: false, tips: '红色教育基地,周一闭馆', type: '红色教育', region: '安源区', image: 'https://images.unsplash.com/photo-1565008576549-57569a49371d?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 15, name: '莲花荷博园', price: 35, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '夏季荷花盛开时最佳观赏期', type: '自然风光', region: '莲花县', image: 'https://images.unsplash.com/photo-1490750967868-88aa4486c946?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 16, name: '山口岩水利风景区', price: 25, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '水利工程景观,可了解水利知识', type: '自然风光', region: '芦溪县', image: 'https://images.unsplash.com/photo-1439066615861-d1af74d74000?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 17, name: '宣风三宝水上乐园', price: 65, freeCount: 6, level: '3A', status: '暂停营业', hours: '暂停开放', needReservation: false, tips: '夏季开放,请关注开园公告', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 18, name: '万龙山温泉', price: 168, freeCount: 6, level: '4A', status: '正常营业', hours: '10:00-22:00', needReservation: true, tips: '需提前预约,温泉养生好去处', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 19, name: '杨岐山野生动物科普园', price: 42, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '适合亲子科普教育', type: '乡村田园', region: '上栗县', image: 'https://images.unsplash.com/photo-1518241353330-0f7941c2d9b5?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 20, name: '鸡冠山森林步道', price: 0, freeCount: 0, level: '3A', status: '正常营业', hours: '全天开放', needReservation: false, tips: '森林氧吧,适合徒步健身', type: '自然风光', region: '上栗县', image: 'https://images.unsplash.com/photo-1551632811-561732d1e306?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 21, name: '赤山杨岐山民宿集群配套景区', price: 28, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-20:00', needReservation: false, tips: '可体验民宿住宿,感受乡村风情', type: '乡村田园', region: '上栗县', image: 'https://images.unsplash.com/photo-1510798831971-661eb04b3739?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 22, name: '湘东傩文化园', price: 32, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '了解傩文化,感受非遗魅力', type: '文化古迹', region: '湘东区', image: 'https://images.unsplash.com/photo-1533174072545-7a4b6ad7a6c3?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 23, name: '广寒寨四季花海', price: 36, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '四季花卉轮番绽放,拍照打卡好去处', type: '自然风光', region: '湘东区', image: 'https://images.unsplash.com/photo-1490750967868-88aa4486c946?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 24, name: '上栗泉水湾度假村', price: 58, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-18:00', needReservation: false, tips: '休闲度假好去处,可垂钓烧烤', type: '休闲娱乐', region: '上栗县', image: 'https://images.unsplash.com/photo-1571896349842-33c89424de2d?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 25, name: '莲花棋盘山', price: 22, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '登山步道完善,适合休闲健身', type: '自然风光', region: '莲花县', image: 'https://images.unsplash.com/photo-1444464666168-49d633b86797?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 26, name: '芦溪东阳古村', price: 26, freeCount: 6, level: '3A', status: '正常营业', hours: '08:30-17:30', needReservation: false, tips: '古建筑群保存完好,可感受传统村落文化', type: '文化古迹', region: '芦溪县', image: 'https://images.unsplash.com/photo-1528181304800-259b08848526?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 27, name: '安源欢乐谷', price: 72, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-18:00', needReservation: false, tips: '游乐设施丰富,适合亲子游玩', type: '休闲娱乐', region: '安源区', image: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 28, name: '湘东太阳广场游乐区', price: 48, freeCount: 6, level: '2A', status: '正常营业', hours: '09:00-21:00', needReservation: false, tips: '夜间灯光秀值得一看', type: '休闲娱乐', region: '湘东区', image: 'https://images.unsplash.com/photo-1506905925346-21bda4d32df4?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 29, name: '长平养生谷', price: 52, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '养生休闲好去处,空气清新', type: '自然风光', region: '上栗县', image: 'https://images.unsplash.com/photo-1518241353330-0f7941c2d9b5?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 30, name: '坊楼红色文化园', price: 0, freeCount: 0, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '红色教育基地,免费参观', type: '红色教育', region: '莲花县', image: 'https://images.unsplash.com/photo-1565008576549-57569a49371d?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 31, name: '高坑生态采摘园', price: 33, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '季节性水果采摘,体验田园乐趣', type: '乡村田园', region: '安源区', image: 'https://images.unsplash.com/photo-1500382017468-9049fed747ef?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 32, name: '源南田园综合体', price: 39, freeCount: 6, level: '3A', status: '正常营业', hours: '08:30-17:30', needReservation: false, tips: '田园风光优美,可体验农耕文化', type: '乡村田园', region: '芦溪县', image: 'https://images.unsplash.com/photo-1470071459604-3b5ec3a7fe05?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 33, name: '下埠陶瓷文化小镇', price: 29, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: false, tips: '可体验陶瓷制作,了解陶瓷文化', type: '文化古迹', region: '湘东区', image: 'https://images.unsplash.com/photo-1528181304800-259b08848526?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 34, name: '桐木水上乐园', price: 66, freeCount: 6, level: '3A', status: '暂停营业', hours: '暂停开放', needReservation: false, tips: '夏季开放,请关注开园时间', type: '休闲娱乐', region: '上栗县', image: 'https://images.unsplash.com/photo-1464822759023-fed622ff2c3b?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 35, name: '东源温泉度假村', price: 138, freeCount: 6, level: '4A', status: '正常营业', hours: '10:00-22:00', needReservation: true, tips: '需提前预约,温泉品质优良', type: '休闲娱乐', region: '上栗县', image: 'https://images.unsplash.com/photo-1545569341-9eb8b30979d9?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 36, name: '长丰山水景区', price: 24, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '山水相依,适合休闲散步', type: '自然风光', region: '莲花县', image: 'https://images.unsplash.com/photo-1439066615861-d1af74d74000?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 37, name: '白竺青草湖', price: 27, freeCount: 6, level: '3A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '湖光山色,可垂钓泛舟', type: '自然风光', region: '湘东区', image: 'https://images.unsplash.com/photo-1507525428034-b723cf961d3e?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 38, name: '荷尧古渡口', price: 0, freeCount: 0, level: '2A', status: '正常营业', hours: '全天开放', needReservation: false, tips: '古渡口遗址,感受历史文化', type: '文化古迹', region: '湘东区', image: 'https://images.unsplash.com/photo-1504280390367-361c6d9f38f4?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 39, name: '新泉红色研学基地', price: 31, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:00', needReservation: true, tips: '研学活动需提前预约', type: '红色教育', region: '芦溪县', image: 'https://images.unsplash.com/photo-1565008576549-57569a49371d?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 40, name: '张佳坊山地乐园', price: 55, freeCount: 6, level: '3A', status: '正常营业', hours: '09:00-17:30', needReservation: false, tips: '山地运动项目丰富,注意安全', type: '休闲娱乐', region: '芦溪县', image: 'https://images.unsplash.com/photo-1551632811-561732d1e306?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 41, name: '南坑户外拓展基地', price: 44, freeCount: 6, level: '3A', status: '正常营业', hours: '08:30-18:00', needReservation: true, tips: '团队拓展需提前预约', type: '休闲娱乐', region: '莲花县', image: 'https://images.unsplash.com/photo-1501854140801-50d01698950b?fm=jpg&q=80&w=800&h=400&fit=crop' },
{ id: 42, name: '老关荷花景区', price: 34, freeCount: 6, level: '2A', status: '正常营业', hours: '08:00-18:00', needReservation: false, tips: '夏季荷花盛开,景色宜人', type: '自然风光', region: '湘东区', image: 'https://images.unsplash.com/photo-1490750967868-88aa4486c946?fm=jpg&q=80&w=800&h=400&fit=crop', cardTypes: ['年卡', '超级卡'] },
]
// 卡种分配:部分景区仅支持年卡,部分仅支持超级卡,其余两者均可
;(function assignCardTypes() {
const annualOnly = new Set([4, 14, 18, 30, 35])
const superOnly = new Set([1, 13, 17, 34, 38])
scenicList.forEach(s => {
if (annualOnly.has(s.id)) s.cardTypes = ['年卡']
else if (superOnly.has(s.id)) s.cardTypes = ['超级卡']
else if (!s.cardTypes) s.cardTypes = ['年卡', '超级卡']
})
})()
// ========== 搜索筛选 ========== // ========== 搜索筛选 ==========
const searchText = ref('') const searchText = ref('')
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(searchText, () => {
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => fetchScenicList(), 400)
})
const selectedRegion = ref('') const selectedRegion = ref('')
const selectedLevel = ref('') const selectedLevel = ref('')
const selectedCardType = ref('') const selectedCardType = ref('')
// uni-popup 单实例引用 // 地区三级联动
const regionTree = ref<RegionNode[]>([])
const regionStep = ref(0)
const selectedProvince = ref<RegionNode | null>(null)
const selectedCity = ref<RegionNode | null>(null)
// 地区 ID(传给 API)
const selectedProvinceId = ref('')
const selectedCityId = ref('')
const selectedDistrictId = ref('')
// uni-popup
const filterPopup = ref() const filterPopup = ref()
// 当前弹窗类型 & 临时选中值
const activePickerType = ref<'region' | 'level' | 'cardType'>('region') const activePickerType = ref<'region' | 'level' | 'cardType'>('region')
const tempValue = ref('') const tempValue = ref('')
const regionOptions = [ // ========== 数据获取 ==========
async function fetchScenicList(resetPage = true) {
if (resetPage) {
pageNo.value = 1
loading.value = true
} else {
loadMoreLoading.value = true
}
try {
const params: Record<string, any> = {
channel: 'pingxiang',
pageNo: pageNo.value,
pageSize: pageSize.value,
cardNumber: cardNumber.value || '',
keyword: searchText.value || '',
provinceId: selectedProvinceId.value || '',
cityId: selectedCityId.value || '',
districtId: selectedDistrictId.value || '',
level: selectedLevel.value || '',
cardModelId: selectedCardType.value || '',
phone: uni.getStorageSync('phone') || '',
}
const res = await post<any>('/mp-api/mpScenic/queryScenicList', params)
const list = res?.list || res?.rows || res?.records || res || []
const totalCount = res?.total || res?.totalCount || 0
total.value = totalCount
const mapped = (Array.isArray(list) ? list : []).map((s: any) => ({
id: s.id || s.scenicId,
shopId: s.shopId || s.id || s.scenicId,
name: s.name || s.scenicName,
price: ((s.price || s.ticketPrice || 0) / 100).toFixed(0),
freeCount: s.freeCount ?? 6,
remainingCount: s.remainingCount ?? 0,
level: s.level || '',
status: s.status ?? 1,
hours: s.hours || s.businessHours || '',
needReservation: s.needReservation ?? false,
tips: s.tips || '',
type: s.type || s.category || '',
region: s.region?.name || s.region || '',
image: s.image || s.pic || s.coverImage || '',
}))
if (resetPage) {
scenicList.value = mapped
} else {
scenicList.value.push(...mapped)
}
} catch {
if (resetPage) scenicList.value = []
} finally {
loading.value = false
loadMoreLoading.value = false
}
}
function loadMore() {
if (loadMoreLoading.value || !hasMore.value) return
pageNo.value++
fetchScenicList(false)
}
onLoad((query: any) => {
cardNumber.value = query.cardNumber || ''
cardModelId.value = query.cardModelId || ''
})
onMounted(async () => {
const filterRes = await post<any>('/mp-api/mpScenic/queryFilterOptions', { channel: 'pingxiang' }).catch(() => null)
if (filterRes) {
const levels = filterRes.levels || filterRes.levelList || []
const cardTypes = filterRes.cardTypes || filterRes.cardTypeList || []
if (levels.length) levelOptions.value = buildOptions(levels, '全部等级')
if (cardTypes.length) cardTypeOptions.value = buildOptions(cardTypes, '全部卡种')
if (filterRes.regions || filterRes.regionList) regionTree.value = filterRes.regions || filterRes.regionList || []
}
if (cardModelId.value) {
selectedCardType.value = cardModelId.value
}
await fetchScenicList()
// 计算 scroll-view 高度
try {
const winInfo = uni.getWindowInfo()
windowHeight.value = winInfo.windowHeight || winInfo.screenHeight || 667
} catch {
windowHeight.value = 667
}
})
/** 将接口返回的标签列表转为选项数组 */
function buildOptions(items: any[], allLabel: string): Array<{ label: string; value: string }> {
const result: Array<{ label: string; value: string }> = [{ label: allLabel, value: '' }]
items.forEach((v: any) => {
if (typeof v === 'string') {
result.push({ label: v, value: v })
} else if (v && typeof v === 'object') {
const val = String(v.value ?? v.id ?? '')
result.push({
label: v.label || v.name || val,
value: val || v.label || v.name || val,
})
}
})
return result
}
const regionOptions = ref<Array<{ label: string; value: string }>>([
{ label: '全部地区', value: '' }, { label: '全部地区', value: '' },
{ label: '安源区', value: '安源区' }, { label: '安源区', value: '安源区' },
{ label: '湘东区', value: '湘东区' }, { label: '湘东区', value: '湘东区' },
{ label: '芦溪县', value: '芦溪县' }, { label: '芦溪县', value: '芦溪县' },
{ label: '上栗县', value: '上栗县' }, { label: '上栗县', value: '上栗县' },
{ label: '莲花县', value: '莲花县' }, { label: '莲花县', value: '莲花县' },
] ])
const levelOptions = [ const levelOptions = ref<Array<{ label: string; value: string }>>([
{ label: '全部等级', value: '' }, { label: '全部等级', value: '' },
{ label: '5A', value: '5A' }, { label: '5A', value: '5A' },
{ label: '4A', value: '4A' }, { label: '4A', value: '4A' },
{ label: '3A', value: '3A' }, { label: '3A', value: '3A' },
{ label: '2A', value: '2A' }, { label: '2A', value: '2A' },
] ])
const cardTypeOptions = [ const cardTypeOptions = ref<Array<{ label: string; value: string }>>([
{ label: '全部卡种', value: '' }, { label: '全部卡种', value: '' },
{ label: '赣湘全域旅游年卡', value: '年卡' }, { label: '赣湘全域旅游年卡', value: '820093340659013' },
{ label: '超级玩家卡', value: '超级卡' }, { label: '超级玩家卡', value: '820093438328133' },
] ])
/** 当前级联地区的选项列表 */
const currentRegionOptions = computed(() => {
if (regionStep.value === 0) return regionTree.value.map(n => ({ label: n.name, value: n.id }))
if (regionStep.value === 1) return (selectedProvince.value?.children || []).map(n => ({ label: n.name, value: n.id }))
if (regionStep.value === 2) return (selectedCity.value?.children || []).map(n => ({ label: n.name, value: n.id }))
return []
})
// 动态标题 & 选项列表
const popupTitle = computed(() => { const popupTitle = computed(() => {
const map = { region: '选择地区', level: '选择等级', cardType: '选择卡种' } if (activePickerType.value === 'region') {
const titles = ['选择省份', '选择城市', '选择区县']
return titles[regionStep.value] || '选择地区'
}
const map: Record<string, string> = { region: '选择地区', level: '选择等级', cardType: '选择卡种' }
return map[activePickerType.value] return map[activePickerType.value]
}) })
const currentOptions = computed(() => { const currentOptions = computed(() => {
const map = { region: regionOptions, level: levelOptions, cardType: cardTypeOptions } if (activePickerType.value === 'region') return currentRegionOptions.value
const map: Record<string, any> = { region: regionOptions.value, level: levelOptions.value, cardType: cardTypeOptions.value }
return map[activePickerType.value] return map[activePickerType.value]
}) })
const selectedCardTypeLabel = computed(() => {
const opt = cardTypeOptions.value.find(o => o.value === selectedCardType.value)
return opt?.label || '全部卡种'
})
function getCardTypeLabel(value: string): string {
const opt = cardTypeOptions.value.find(o => o.value === value)
return opt?.label || value
}
function getTempValue() {
return tempValue.value
}
function openPicker(type: 'region' | 'level' | 'cardType') { function openPicker(type: 'region' | 'level' | 'cardType') {
activePickerType.value = type activePickerType.value = type
const valMap = { region: selectedRegion.value, level: selectedLevel.value, cardType: selectedCardType.value } if (type === 'region') {
regionStep.value = 0
selectedProvince.value = null
selectedCity.value = null
tempValue.value = ''
} else {
const valMap: Record<string, string> = { region: selectedRegion.value, level: selectedLevel.value, cardType: selectedCardType.value }
tempValue.value = valMap[type] tempValue.value = valMap[type]
}
filterPopup.value?.open() filterPopup.value?.open()
} }
function onOptionTap(value: string) { function onOptionTap(value: string) {
if (activePickerType.value === 'region') {
if (regionStep.value === 0) {
if (tempValue.value === value) {
selectedProvince.value = regionTree.value.find(n => n.id === value) || null
if (selectedProvince.value?.children?.length) {
regionStep.value = 1
tempValue.value = ''
} else {
tempValue.value = value
}
} else {
tempValue.value = value
}
} else if (regionStep.value === 1) {
if (tempValue.value === value) {
selectedCity.value = selectedProvince.value?.children?.find(n => n.id === value) || null
if (selectedCity.value?.children?.length) {
regionStep.value = 2
tempValue.value = ''
} else {
tempValue.value = value
}
} else {
tempValue.value = value
}
} else if (regionStep.value === 2) {
tempValue.value = value
}
return
}
tempValue.value = value tempValue.value = value
} }
function goBackRegionStep() {
if (regionStep.value === 1) {
regionStep.value = 0
tempValue.value = selectedProvince.value?.id || ''
} else if (regionStep.value === 2) {
regionStep.value = 1
tempValue.value = selectedCity.value?.id || ''
}
}
function findRegionNameById(id: string): string {
if (!id) return ''
for (const p of regionTree.value) {
if (p.id === id) return p.name
for (const c of p.children || []) {
if (c.id === id) return c.name
for (const d of c.children || []) {
if (d.id === id) return d.name
}
}
}
return ''
}
function confirmFilter() { function confirmFilter() {
const map = { region: selectedRegion, level: selectedLevel, cardType: selectedCardType } if (activePickerType.value === 'region') {
selectedRegion.value = findRegionNameById(tempValue.value)
if (regionStep.value === 0) {
selectedProvinceId.value = tempValue.value
selectedCityId.value = ''
selectedDistrictId.value = ''
} else if (regionStep.value === 1) {
selectedProvinceId.value = selectedProvince.value?.id || ''
selectedCityId.value = tempValue.value
selectedDistrictId.value = ''
} else if (regionStep.value === 2) {
selectedProvinceId.value = selectedProvince.value?.id || ''
selectedCityId.value = selectedCity.value?.id || ''
selectedDistrictId.value = tempValue.value
}
filterPopup.value?.close()
fetchScenicList()
return
}
const map: Record<string, any> = { region: selectedRegion, level: selectedLevel, cardType: selectedCardType }
map[activePickerType.value].value = tempValue.value map[activePickerType.value].value = tempValue.value
filterPopup.value?.close() filterPopup.value?.close()
fetchScenicList()
} }
function resetFilter() { function resetFilter() {
if (activePickerType.value === 'region') {
regionStep.value = 0
selectedProvince.value = null
selectedCity.value = null
selectedRegion.value = ''
selectedProvinceId.value = ''
selectedCityId.value = ''
selectedDistrictId.value = ''
tempValue.value = ''
return
}
tempValue.value = '' tempValue.value = ''
} }
function onPopupChange(e: { show: boolean }) { function onPopupChange(e: { show: boolean }) {
// 弹窗关闭时无需额外处理
} }
function clearSearch() { function clearSearch() {
searchText.value = '' searchText.value = ''
if (searchTimer) clearTimeout(searchTimer)
fetchScenicList()
} }
// 过滤后的景区列表
const filteredScenics = computed(() => {
return scenicList.filter(scenic => {
if (searchText.value && !scenic.name.toLowerCase().includes(searchText.value.toLowerCase())) {
return false
}
if (selectedRegion.value && scenic.region !== selectedRegion.value) {
return false
}
if (selectedLevel.value && scenic.level !== selectedLevel.value) {
return false
}
if (selectedCardType.value && !scenic.cardTypes.includes(selectedCardType.value)) {
return false
}
return true
})
})
function goBack() { function goBack() {
uni.navigateBack() uni.navigateBack()
} }
function onCardClick(scenic: ScenicItem) { function onCardClick(scenic: ScenicItem) {
uni.navigateTo({ uni.navigateTo({
url: `/packages/content/pages/scenic-detail?id=${scenic.id}`, url: `/packages/content/pages/scenic-detail?shopId=${scenic.shopId}`,
}) })
} }
</script> </script>
@@ -470,17 +664,15 @@ function onCardClick(scenic: ScenicItem) {
/* ====== 页面主体 ====== */ /* ====== 页面主体 ====== */
.page-body { .page-body {
min-height: 100vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100vh;
overflow: hidden;
} }
/* ====== 吸顶搜索筛选区 ====== */ /* ====== 吸顶搜索筛选区(正常流,不固定) ====== */
.sticky-header { .sticky-header {
position: fixed; flex-shrink: 0;
left: 0;
right: 0;
z-index: 50;
background: $color-background; background: $color-background;
padding: $spacing-2 $spacing-3; padding: $spacing-2 $spacing-3;
box-shadow: $shadow-sm; box-shadow: $shadow-sm;
@@ -556,12 +748,14 @@ function onCardClick(scenic: ScenicItem) {
.dropdown-trigger { .dropdown-trigger {
flex: 1; flex: 1;
min-width: 0;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
background: $color-card; background: $color-card;
border-radius: $radius-base; border-radius: $radius-base;
padding: $spacing-2 $spacing-3; padding: $spacing-2 $spacing-3;
overflow: hidden;
transition: background $transition-fast; transition: background $transition-fast;
&:active { &:active {
@@ -570,6 +764,8 @@ function onCardClick(scenic: ScenicItem) {
} }
.dropdown-label { .dropdown-label {
flex: 1;
min-width: 0;
font-size: $font-size-sm; font-size: $font-size-sm;
color: $color-foreground; color: $color-foreground;
white-space: nowrap; white-space: nowrap;
@@ -594,15 +790,35 @@ function onCardClick(scenic: ScenicItem) {
/* ====== 列表区域 ====== */ /* ====== 列表区域 ====== */
/* 空状态 */ /* 加载 / 空状态 */
.loading-state,
.empty-state { .empty-state {
display: flex; display: flex;
flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 120rpx $spacing-3; padding: 120rpx $spacing-3;
} }
.loading-text,
.empty-text {
font-size: $font-size-base;
color: $uv-tips-color;
}
/* 加载更多 */
.load-more {
display: flex;
align-items: center;
justify-content: center;
padding: $spacing-4;
}
.load-more-text {
font-size: $font-size-sm;
color: $uv-tips-color;
}
/* 空状态图标 */
.empty-icon { .empty-icon {
position: relative; position: relative;
width: 160rpx; width: 160rpx;
@@ -645,9 +861,10 @@ function onCardClick(scenic: ScenicItem) {
color: $uv-tips-color; color: $uv-tips-color;
} }
/* 列表区域 */ /* 可滚动列表 */
.list-area { .list-scroll {
flex: 1; flex: 1;
overflow-y: auto;
} }
/* 列表容器 */ /* 列表容器 */
@@ -775,12 +992,6 @@ function onCardClick(scenic: ScenicItem) {
font-weight: $font-weight-bold; font-weight: $font-weight-bold;
} }
.price-free {
font-size: $font-size-base;
color: $uv-success;
font-weight: $font-weight-medium;
}
/* 营业时间 + 营业状态 同行 */ /* 营业时间 + 营业状态 同行 */
.hours-status-row { .hours-status-row {
display: flex; display: flex;
@@ -841,15 +1052,19 @@ function onCardClick(scenic: ScenicItem) {
border-radius: 50%; border-radius: 50%;
margin-right: 8rpx; margin-right: 8rpx;
&.open { background: $uv-success; } &.st-1 { background: $uv-success; }
&.closed { background: $uv-error; } &.st-2 { background: $uv-warning; }
&.st-3 { background: $uv-error; }
&.st-4 { background: #999; }
} }
.status-text { .status-text {
font-size: $font-size-sm; font-size: $font-size-sm;
&.open { color: $uv-success; } &.st-1 { color: $uv-success; }
&.closed { color: $uv-error; } &.st-2 { color: $uv-warning; }
&.st-3 { color: $uv-error; }
&.st-4 { color: #999; }
} }
/* 权益标签行:剩余次数 + 免票 + 预约 同行 */ /* 权益标签行:剩余次数 + 免票 + 预约 同行 */
@@ -885,6 +1100,7 @@ function onCardClick(scenic: ScenicItem) {
padding: 4rpx $spacing-2; padding: 4rpx $spacing-2;
border-radius: $radius-sm; border-radius: $radius-sm;
min-height: 36rpx; min-height: 36rpx;
margin-left: auto;
text { text {
font-size: $font-size-xs; font-size: $font-size-xs;
@@ -893,15 +1109,36 @@ function onCardClick(scenic: ScenicItem) {
white-space: nowrap; white-space: nowrap;
} }
&.primary {
background: $uv-primary;
margin-left: auto;
}
&.warning { background: $uv-warning; } &.warning { background: $uv-warning; }
&.success { background: $uv-success; } &.success { background: $uv-success; }
} }
/* 卡种标签行 */
.card-types-row {
display: flex;
align-items: center;
gap: $spacing-1;
flex-wrap: wrap;
margin-bottom: $spacing-2;
}
.card-type-tag {
display: flex;
align-items: center;
justify-content: center;
padding: 4rpx $spacing-2;
background: rgba($uv-primary, 0.1);
border: 1rpx solid rgba($uv-primary, 0.3);
border-radius: $radius-sm;
min-height: 36rpx;
text {
font-size: $font-size-xs;
color: $uv-primary;
font-weight: $font-weight-medium;
}
}
/* 温馨提示 */ /* 温馨提示 */
.tips-bar { .tips-bar {
display: flex; display: flex;
@@ -976,7 +1213,7 @@ function onCardClick(scenic: ScenicItem) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: $spacing-3; padding: $spacing-3 $spacing-3;
min-height: 96rpx; min-height: 96rpx;
border-bottom: $border-width solid rgba($uv-border-color, 0.25); border-bottom: $border-width solid rgba($uv-border-color, 0.25);
transition: background $transition-fast; transition: background $transition-fast;
@@ -989,12 +1226,17 @@ function onCardClick(scenic: ScenicItem) {
background: $color-muted; background: $color-muted;
} }
text { .option-label {
flex: 1;
min-width: 0;
font-size: $font-size-base; font-size: $font-size-base;
color: $color-foreground; color: $color-foreground;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
} }
&.active text { &.active .option-label {
color: $uv-primary; color: $uv-primary;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }
@@ -1014,7 +1256,9 @@ function onCardClick(scenic: ScenicItem) {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
height: 96rpx; height: 80rpx;
margin: $spacing-2;
border-radius: $radius-base;
background: $uv-primary; background: $uv-primary;
transition: opacity $transition-fast; transition: opacity $transition-fast;
+37 -5
View File
@@ -1,19 +1,51 @@
<template> <template>
<view class="page"> <view class="page">
<image <view v-if="loading" class="loading">
class="guide-image" <text class="loading-text">加载中...</text>
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" </view>
mode="widthFix" <image v-if="imageUrl" class="guide-image" :src="imageUrl" mode="widthFix" />
/>
</view> </view>
</template> </template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { post } from '../../../api/request'
import type { BannerItem } from '../../../api/types'
const imageUrl = ref('')
const loading = ref(true)
onMounted(async () => {
try {
const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'GUIDE' })
if (data && data.length > 0) {
imageUrl.value = data[0].imageUrl || ''
}
} catch {
// 静默失败
} finally {
loading.value = false
}
})
</script>
<style lang="scss" scoped> <style lang="scss" scoped>
.page { .page {
min-height: 100vh; min-height: 100vh;
background: $color-card; background: $color-card;
} }
.loading {
display: flex;
justify-content: center;
padding: 80rpx 0;
}
.loading-text {
font-size: $font-size-sm;
color: $uv-tips-color;
}
.guide-image { .guide-image {
width: 100%; width: 100%;
display: block; display: block;
+65 -2
View File
@@ -1,4 +1,5 @@
import { get, post } from '../../../api/request' import { get, post } from '../../../api/request'
import { BASE_URL } from '../../../config'
import type { CardInfo, CardDetail, MyCard, ActivateParams, QrCodeInfo } from '../../../api/types' import type { CardInfo, CardDetail, MyCard, ActivateParams, QrCodeInfo } from '../../../api/types'
/** 获取年卡列表 */ /** 获取年卡列表 */
@@ -17,8 +18,21 @@ export function activateCard(cardId: string, data: ActivateParams): Promise<bool
} }
/** 绑定年卡 */ /** 绑定年卡 */
export function bindCard(cardNo: string): Promise<boolean> { export function bindCard(data: {
return post<boolean>('/card/bind', { cardNo }) cardNum: string
userName: string
mobile: string
idType: string
idNo: string
img: string
smsCode: string
thirdId: string
bindType: string
bindSource: string
channel: string
activeCode: string
}): Promise<boolean> {
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/bind`, data, false)
} }
/** 获取我的年卡列表 */ /** 获取我的年卡列表 */
@@ -30,3 +44,52 @@ export function getMyCards(): Promise<MyCard[]> {
export function getEntryQrCode(cardId: string): Promise<QrCodeInfo> { export function getEntryQrCode(cardId: string): Promise<QrCodeInfo> {
return get<QrCodeInfo>('/card/qrcode', { cardId }) return get<QrCodeInfo>('/card/qrcode', { cardId })
} }
/** 人脸绑定(上传照片) */
export function bindFaceImg(data: {
cardNum: string
userName: string
mobile: string
idType: string
idNo: string
img: string
smsCode: string
thirdId: string
bindType: string
bindSource: string
channel: string
activeCode: string
}): Promise<boolean> {
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/bindForAddFaceImg`, data, false)
}
/** 查询是否需要人脸绑定 */
export function checkFaceFlag(cardNo: string): Promise<boolean> {
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/faceFlag?cardNo=${cardNo}`, {}, false)
}
/** 上传图片到 OSS */
export function uploadImage(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${BASE_URL}/open-api/oss/upload`,
filePath,
name: 'file',
success: (res) => {
if (res.statusCode === 200) {
try {
const data = JSON.parse(res.data as string)
if (data.errcode === 0 && data.data?.fileUrl) {
resolve(data.data.fileUrl)
} else {
reject(new Error(data.errmsg || '上传失败'))
}
} catch { reject(new Error('解析响应失败')) }
} else {
reject(new Error('上传失败,状态码:' + res.statusCode))
}
},
fail: reject,
})
})
}
+173 -45
View File
@@ -3,10 +3,10 @@
<!-- 1. 头部标题区 --> <!-- 1. 头部标题区 -->
<view class="header"> <view class="header">
<view class="header-title"> <view class="header-title">
<text class="header-title__bind">绑定</text> <text class="header-title__bind">{{ faceBindMode ? '人脸' : '绑定' }}</text>
<text class="header-title__card">赣湘全域旅游年卡/超级玩家卡</text> <text class="header-title__card">{{ faceBindMode ? '绑定' : '赣湘全域旅游年卡/超级玩家卡' }}</text>
</view> </view>
<text class="header-desc">请务必填写您的真实信息,以便于景区实名制预约</text> <text class="header-desc">{{ faceBindMode ? '请上传持卡人照片完成人脸绑定' : '请务必填写您的真实信息,以便于景区实名制预约' }}</text>
</view> </view>
<!-- 2. 白色圆角表单卡片 --> <!-- 2. 白色圆角表单卡片 -->
@@ -19,6 +19,8 @@
v-model="form.cardNo" v-model="form.cardNo"
placeholder="请输入卡号" placeholder="请输入卡号"
placeholder-class="placeholder" placeholder-class="placeholder"
:disabled="faceBindMode"
@blur="onCardNoBlur"
/> />
</view> </view>
<view class="divider" /> <view class="divider" />
@@ -31,6 +33,7 @@
v-model="form.name" v-model="form.name"
placeholder="请输入持卡人姓名" placeholder="请输入持卡人姓名"
placeholder-class="placeholder" placeholder-class="placeholder"
:disabled="faceBindMode"
/> />
</view> </view>
<view class="divider" /> <view class="divider" />
@@ -43,19 +46,19 @@
v-model="form.phone" v-model="form.phone"
type="number" type="number"
maxlength="11" maxlength="11"
placeholder="请输入持卡人手机号" disabled
placeholder-class="placeholder" placeholder-class="placeholder"
/> />
</view> </view>
<view class="divider" /> <view class="divider" />
<!-- 证件类型 --> <!-- 证件类型(绑卡模式显示) -->
<view class="form-row" @tap="showIdTypePicker = true"> <view v-if="!faceBindMode" class="form-row" @tap="showIdTypePicker = true">
<text class="form-label">证件类型</text> <text class="form-label">证件类型</text>
<text class="form-value">{{ docType }}</text> <text class="form-value">{{ idTypeLabel }}</text>
<view class="chevron-right" /> <view class="chevron-right" />
</view> </view>
<view class="divider" /> <view v-if="!faceBindMode" class="divider" />
<!-- 证件号码 --> <!-- 证件号码 -->
<view class="form-row"> <view class="form-row">
@@ -65,16 +68,17 @@
v-model="form.idCard" v-model="form.idCard"
:placeholder="idPlaceholder" :placeholder="idPlaceholder"
placeholder-class="placeholder" placeholder-class="placeholder"
:maxlength="docType === '身份证' ? 18 : 30" :maxlength="docType === '1' ? 18 : 30"
:disabled="faceBindMode"
/> />
</view> </view>
<view class="divider" /> <view class="divider" />
<!-- 持卡人照片 --> <!-- 持卡人照片 -->
<view class="form-row form-row--photo"> <view v-if="needFaceBind" class="form-row form-row--photo">
<text class="form-label form-label--top">持卡人照片</text> <text class="form-label form-label--top">持卡人照片</text>
<view class="upload-area" @tap="photoUrl ? previewPhoto() : choosePhoto()"> <view class="upload-area" @tap="photoUrl ? previewPhoto() : chooseImage()">
<image v-if="photoUrl" class="upload-preview" :src="photoUrl" mode="aspectFill" /> <image v-if="photoUrl" class="upload-preview" :src="photoUrl" mode="aspectFit" />
<view v-else class="upload-placeholder"> <view v-else class="upload-placeholder">
<view class="upload-icon" /> <view class="upload-icon" />
<text class="upload-text">点击上传</text> <text class="upload-text">点击上传</text>
@@ -86,8 +90,8 @@
</view> </view>
</view> </view>
<!-- 3. 协议勾选区域 --> <!-- 3. 协议勾选区域(绑卡模式显示) -->
<view class="agreement" @tap="agreed = !agreed"> <view v-if="!faceBindMode" class="agreement" @tap="agreed = !agreed">
<view class="checkbox" :class="{ 'checkbox--checked': agreed }"> <view class="checkbox" :class="{ 'checkbox--checked': agreed }">
<view v-if="agreed" class="checkbox__check" /> <view v-if="agreed" class="checkbox__check" />
</view> </view>
@@ -105,7 +109,7 @@
:disabled="submitting" :disabled="submitting"
@tap="handleSubmit" @tap="handleSubmit"
> >
<text class="btn-confirm__text">{{ submitting ? '提交中...' : '确认绑卡' }}</text> <text class="btn-confirm__text">{{ submitting ? '提交中...' : (faceBindMode ? '确认绑定人脸' : '确认绑卡') }}</text>
</button> </button>
<button class="btn-cancel" @tap="handleCancel"> <button class="btn-cancel" @tap="handleCancel">
<text class="btn-cancel__text">取消</text> <text class="btn-cancel__text">取消</text>
@@ -124,13 +128,13 @@
<view class="id-type-list"> <view class="id-type-list">
<view <view
v-for="item in idTypeOptions" v-for="item in idTypeOptions"
:key="item" :key="item.value"
class="id-type-item" class="id-type-item"
:class="{ 'id-type-item--active': docType === item }" :class="{ 'id-type-item--active': docType === item.value }"
@tap="selectIdType(item)" @tap="selectIdType(item.value)"
> >
<text class="id-type-item__text">{{ item }}</text> <text class="id-type-item__text">{{ item.label }}</text>
<view v-if="docType === item" class="id-type-item__check" /> <view v-if="docType === item.value" class="id-type-item__check" />
</view> </view>
</view> </view>
</view> </view>
@@ -151,28 +155,45 @@
</view> </view>
</uni-popup> </uni-popup>
<!-- 隐藏 canvas,用于图片压缩 -->
<canvas
canvas-id="myCanvas"
id="myCanvas"
style="position:absolute;left:0;top:-10000rpx;width:375px;height:375px;z-index:-1;opacity:0"
/>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue' import { ref, reactive, watch, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { checkFaceFlag, uploadImage, bindCard as apiBindCard, bindFaceImg } from '../api/card'
const faceBindMode = ref(false)
const form = reactive({ const form = reactive({
cardNo: '', cardNo: '',
name: '', name: '',
phone: '18684839400', phone: (uni.getStorageSync('phone') as string) || '',
idCard: '' idCard: ''
}) })
const docType = ref('身份证') const idTypeOptions = [
const idTypeOptions = ['身份证', '护照', '港澳通行证', '台胞证'] { value: '1', label: '身份证' },
{ value: '2', label: '护照' },
{ value: '3', label: '港澳居民来往内地通行证' },
{ value: '4', label: '台湾居民来往大陆通行证' },
]
const docType = ref('1')
const idTypeLabel = computed(() => idTypeOptions.find(i => i.value === docType.value)?.label || '')
const idPlaceholder = computed(() => { const idPlaceholder = computed(() => {
const map: Record<string, string> = { const map: Record<string, string> = {
'身份证': '请输入身份证号码', '1': '请输入身份证号码',
'护照': '请输入护照号码', '2': '请输入护照号码',
'港澳通行证': '请输入通行证号码', '3': '请输入通行证号码',
'台胞证': '请输入台胞证号码' '4': '请输入台胞证号码',
} }
return map[docType.value] || '请输入证件号码' return map[docType.value] || '请输入证件号码'
}) })
@@ -180,11 +201,34 @@ const idPlaceholder = computed(() => {
const photoUrl = ref('') const photoUrl = ref('')
const agreed = ref(false) const agreed = ref(false)
const submitting = ref(false) const submitting = ref(false)
const uploading = ref(false)
const needFaceBind = ref(false)
const showNotice = ref(false) const showNotice = ref(false)
const showIdTypePicker = ref(false) const showIdTypePicker = ref(false)
const noticePopup = ref<any>(null) const noticePopup = ref<any>(null)
const idTypePopup = ref<any>(null) const idTypePopup = ref<any>(null)
onLoad(async (options) => {
if (options?.faceBind === '1') {
faceBindMode.value = true
form.cardNo = options.cardNo || ''
form.name = options.userName || ''
form.phone = options.mobile || form.phone
const idType = options.idType || ''
docType.value = idType || '1'
form.idCard = options.idNo || ''
needFaceBind.value = true
} else if (options?.redeemCode) {
form.cardNo = options.redeemCode
try {
const res = await checkFaceFlag(options.redeemCode)
needFaceBind.value = !!res
} catch {
needFaceBind.value = false
}
}
})
watch(showNotice, (v) => { watch(showNotice, (v) => {
if (v) noticePopup.value?.open() if (v) noticePopup.value?.open()
else noticePopup.value?.close() else noticePopup.value?.close()
@@ -195,19 +239,82 @@ watch(showIdTypePicker, (v) => {
else idTypePopup.value?.close() else idTypePopup.value?.close()
}) })
function selectIdType(item: string) { function selectIdType(value: string) {
docType.value = item docType.value = value
showIdTypePicker.value = false showIdTypePicker.value = false
} }
function choosePhoto() { async function onCardNoBlur() {
uni.chooseMedia({ if (!form.cardNo.trim()) return
count: 1, try {
mediaType: ['image'], const res = await checkFaceFlag(form.cardNo.trim())
sourceType: ['camera'], needFaceBind.value = !!res
success: (res) => { } catch {
photoUrl.value = res.tempFiles[0].tempFilePath needFaceBind.value = false
} }
}
function chooseImage() {
if (uploading.value) return
uni.chooseImage({
count: 1,
sizeType: ['original'],
sourceType: ['album', 'camera'],
success: (res) => handleImage(res.tempFilePaths[0]),
fail: (err) => {
if (!err.errMsg.includes('cancel')) console.log('选择图片失败:', err)
}
})
}
async function handleImage(filePath: string) {
uploading.value = true
uni.showLoading({ title: '处理中...' })
try {
const compressedPath = await compressImage(filePath)
uni.showLoading({ title: '上传中...' })
const url = await uploadImage(compressedPath)
photoUrl.value = url
uni.showToast({ title: '上传成功', icon: 'success' })
} catch {
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uploading.value = false
uni.hideLoading()
}
}
function compressImage(filePath: string): Promise<string> {
return new Promise((resolve) => {
const maxSize = 375
uni.getImageInfo({
src: filePath,
success: (info) => {
let { width, height } = info
if (width <= maxSize && height <= maxSize) { resolve(filePath); return }
if (width > height) {
height = Math.round((height / width) * maxSize)
width = maxSize
} else {
width = Math.round((width / height) * maxSize)
height = maxSize
}
const ctx = uni.createCanvasContext('myCanvas')
ctx.drawImage(filePath, 0, 0, width, height)
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: 'myCanvas', x: 0, y: 0,
width, height, destWidth: width, destHeight: height,
quality: 0.8,
success: (r) => resolve(r.tempFilePath),
fail: () => resolve(filePath),
})
}, 200)
})
},
fail: () => resolve(filePath)
})
}) })
} }
@@ -224,37 +331,58 @@ function validateForm() {
if (!form.name.trim()) return '请输入姓名' if (!form.name.trim()) return '请输入姓名'
if (!form.phone.trim()) return '请输入联系电话' if (!form.phone.trim()) return '请输入联系电话'
if (!/^1[3-9]\d{9}$/.test(form.phone)) return '手机号格式不正确' if (!/^1[3-9]\d{9}$/.test(form.phone)) return '手机号格式不正确'
if (!form.idCard.trim()) return `请输入${docType.value}号码` if (!form.idCard.trim()) return `请输入${idTypeLabel.value}号码`
if (docType.value === '身份证' && !/^\d{17}[\dXx]$/.test(form.idCard)) return '身份证号格式不正确' if (docType.value === '1' && !/^\d{17}[\dXx]$/.test(form.idCard)) return '身份证号格式不正确'
if (!photoUrl.value) return '请上传持卡人照片' if (needFaceBind.value && !photoUrl.value) return '请上传持卡人照片'
return '' return ''
} }
async function handleSubmit() { async function handleSubmit() {
if (!agreed.value) { if (!faceBindMode.value && !agreed.value) {
uni.showToast({ title: '请先阅读并知晓用卡须知', icon: 'none' }) uni.showToast({ title: '请先阅读并知晓用卡须知', icon: 'none' })
return return
} }
if (faceBindMode.value) {
if (!photoUrl.value) {
uni.showToast({ title: '请上传持卡人照片', icon: 'none' })
return
}
} else {
const err = validateForm() const err = validateForm()
if (err) { if (err) {
uni.showToast({ title: err, icon: 'none' }) uni.showToast({ title: err, icon: 'none' })
return return
} }
}
submitting.value = true submitting.value = true
uni.showLoading({ title: '提交中...', mask: true }) uni.showLoading({ title: '提交中...', mask: true })
try { try {
await new Promise(r => setTimeout(r, 1500)) const apiFn = faceBindMode.value ? bindFaceImg : apiBindCard
await apiFn({
cardNum: form.cardNo.trim(),
userName: form.name.trim(),
mobile: form.phone.trim(),
idType: docType.value,
idNo: form.idCard.trim(),
img: photoUrl.value,
smsCode: '',
thirdId: '',
bindType: 'SELF',
bindSource: 'zfb',
channel: '',
activeCode: '',
})
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '绑卡成功', icon: 'success' }) uni.showToast({ title: faceBindMode.value ? '人脸绑定成功' : '绑卡成功', icon: 'success' })
setTimeout(() => { setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' }) uni.switchTab({ url: '/pages/qrcode/index' })
}, 1500) }, 1500)
} catch { } catch (err: any) {
uni.hideLoading() uni.hideLoading()
uni.showToast({ title: '绑卡失败,请重试', icon: 'none' }) uni.showToast({ title: err.message || (faceBindMode.value ? '人脸绑定失败' : '绑卡失败'), icon: 'none' })
} finally { } finally {
submitting.value = false submitting.value = false
} }
+114 -39
View File
@@ -1,25 +1,21 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 图片区域 --> <view v-if="loading" class="loading">
<view class="image-section"> <text class="loading-text">加载中...</text>
<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>
<template v-else>
<image class="card-image" :src="mainPic" mode="widthFix" />
<view class="intro-section">
<rich-text :nodes="introduction" />
</view>
</template>
<!-- 底部固定购买区域 --> <!-- 底部固定购买区域 -->
<view class="bottom-bar"> <view class="bottom-bar">
<view class="price-row"> <view class="price-row">
<text class="price-label">价格:</text> <text class="price-label">价格:</text>
<text class="price-symbol">¥</text> <text class="price-symbol">¥</text>
<text class="price-value">198</text> <text class="price-value">{{ buyPrice }}</text>
<text class="price-original">¥3800+</text> <text class="price-original">¥3800+</text>
</view> </view>
<button class="btn-buy" :disabled="paying" @tap="handleBuy"> <button class="btn-buy" :disabled="paying" @tap="handleBuy">
@@ -30,38 +26,105 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post, getToken } from '../../../api/request'
import { BASE_URL } from '../../../config'
import { useUserStore } from '../../../store/user'
import { createOrder, prepay } from '../../../api/order'
const userStore = useUserStore()
const goodsId = ref('')
const cardModleId = ref('')
const mainPic = ref('')
const introduction = ref('')
const buyPrice = ref('0')
const skuId = ref('')
const loading = ref(true)
const paying = ref(false) const paying = ref(false)
onLoad((query) => {
goodsId.value = (query?.goodsId as string) || '821926819630853'
})
function sanitizeHtml(html: string): string {
return html.replace(/<img[^>]*>/g, (match) => {
return match.replace(/style="[^"]*"/g, 'style="width:100%"')
})
}
interface CardModelDetail {
cardModel: any
goods: { id: string; mainPic: string }
goodsInfo: { introduction: string }
sku: Array<{ id: string; buyPrice: number }>
}
onMounted(async () => {
try {
const data = await post<CardModelDetail>(`${BASE_URL}/open-api/xgh/cardModelDetail?goodsId=${goodsId.value}`, {})
cardModleId.value = data.cardModel?.id || ''
mainPic.value = data.goods?.mainPic || ''
introduction.value = sanitizeHtml(data.goodsInfo?.introduction || '')
const sku = data.sku?.[0]
skuId.value = sku?.id || ''
const price = sku?.buyPrice || 0
buyPrice.value = (price / 100).toFixed(2)
} catch {
// 静默失败
} finally {
loading.value = false
}
})
async function handleBuy() { async function handleBuy() {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
if (paying.value) return if (paying.value) return
paying.value = true paying.value = true
try { try {
// 调用后端创建订单接口(mock) const mobile = (uni.getStorageSync('phone') as string) || ''
const orderRes: any = await new Promise((resolve) => { const orderRes = await createOrder({
setTimeout(() => { cardModleId: cardModleId.value,
resolve({ buyNum: 1,
orderId: 'ORD' + Date.now(), skuId: skuId.value,
payParams: { mobile,
timeStamp: String(Math.floor(Date.now() / 1000)), couponId: '',
nonceStr: 'mock_nonce_' + Date.now(),
package: 'prepay_id=mock_prepay_id',
signType: 'MD5',
paySign: 'mock_pay_sign'
}
})
}, 800)
}) })
// 调用微信支付 const openId = getToken()
if (!openId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
const payParams = await prepay({
openId,
orderNo: orderRes.outTradeNo,
description: mobile,
amount: Math.round(Number(buyPrice.value) * 100),
})
if (!payParams?.timeStamp || !payParams?.nonce || !payParams?.pack || !payParams?.paySign) {
uni.showToast({ title: '支付参数异常,请重试', icon: 'none' })
return
}
await new Promise<void>((resolve, reject) => { await new Promise<void>((resolve, reject) => {
uni.requestPayment({ uni.requestPayment({
provider: 'wxpay', provider: 'wxpay',
...orderRes.payParams, appId: payParams.appId,
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonce,
package: payParams.pack,
signType: payParams.signType,
paySign: payParams.paySign,
success: () => resolve(), success: () => resolve(),
fail: (err: any) => reject(err) fail: (err: any) => reject(err),
}) })
}) })
@@ -70,11 +133,10 @@ async function handleBuy() {
uni.switchTab({ url: '/pages/index/index' }) uni.switchTab({ url: '/pages/index/index' })
}, 1500) }, 1500)
} catch (err: any) { } catch (err: any) {
if (err?.errMsg?.includes('cancel')) { uni.showToast({ title: err?.errMsg?.includes('cancel') ? '已取消支付' : '支付失败,请重试', icon: 'none' })
uni.showToast({ title: '已取消支付', icon: 'none' }) setTimeout(() => {
} else { uni.redirectTo({ url: '/packages/user/pages/order/list' })
uni.showToast({ title: '支付失败,请重试', icon: 'none' }) }, 1500)
}
} finally { } finally {
paying.value = false paying.value = false
} }
@@ -91,12 +153,19 @@ $bar-height: 120rpx;
font-family: $font-family-body; font-family: $font-family-body;
} }
/* ===== 图片区域 ===== */ /* ===== 加载状态 ===== */
.image-section { .loading {
display: flex; display: flex;
flex-direction: column; justify-content: center;
padding: 80rpx 0;
} }
.loading-text {
font-size: $font-size-sm;
color: $uv-tips-color;
}
/* ===== 图片 ===== */
.card-image { .card-image {
width: 100%; width: 100%;
display: block; display: block;
@@ -104,6 +173,12 @@ $bar-height: 120rpx;
padding: 0; padding: 0;
} }
/* ===== 富文本介绍 ===== */
.intro-section {
width: 100%;
margin-bottom: 24rpx;
}
/* ===== 底部固定购买区域 ===== */ /* ===== 底部固定购买区域 ===== */
.bottom-bar { .bottom-bar {
position: fixed; position: fixed;
+14 -1
View File
@@ -37,7 +37,20 @@ async function handleBind() {
} }
binding.value = true binding.value = true
try { try {
await bindCard(cardNo.value.trim()) await bindCard({
cardNum: cardNo.value.trim(),
userName: '',
mobile: '',
idType: '',
idNo: '',
img: '',
smsCode: '',
thirdId: '',
bindType: 'SELF',
bindSource: 'zfb',
channel: '',
activeCode: '',
})
showToast('绑定成功', 'success') showToast('绑定成功', 'success')
setTimeout(() => { setTimeout(() => {
uni.navigateBack() uni.navigateBack()
+157 -148
View File
@@ -1,9 +1,9 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 模块1:订单状态卡片 --> <!-- 模块1:订单状态卡片 -->
<view class="status-card" :class="`status-card--${order.status}`"> <view class="status-card" :class="`status-card--${orderStatus}`">
<view class="status-icon-wrap"> <view class="status-icon-wrap">
<view class="status-icon" :class="`status-icon--${order.status}`" /> <view class="status-icon" :class="`status-icon--${orderStatus}`" />
</view> </view>
<text class="status-title">{{ statusLabel }}</text> <text class="status-title">{{ statusLabel }}</text>
<text class="status-desc">{{ statusDesc }}</text> <text class="status-desc">{{ statusDesc }}</text>
@@ -15,7 +15,7 @@
<view class="price-row"> <view class="price-row">
<view class="price-current"> <view class="price-current">
<text class="price-symbol">¥</text> <text class="price-symbol">¥</text>
<text class="price-value">198</text> <text class="price-value">{{ (order.payAmount / 100).toFixed(2) }}</text>
</view> </view>
</view> </view>
<text class="product-desc">一卡全年畅玩赣湘合作景区商户,实名绑定使用</text> <text class="product-desc">一卡全年畅玩赣湘合作景区商户,实名绑定使用</text>
@@ -27,11 +27,11 @@
<text class="detail-label">订单编号</text> <text class="detail-label">订单编号</text>
<text class="detail-value">{{ order.id }}</text> <text class="detail-value">{{ order.id }}</text>
</view> </view>
<view v-if="order.status === 'pending' || order.status === 'activated'" class="detail-row"> <view v-if="order.redeemCode && order.payStatus === 2" class="detail-row">
<text class="detail-label">卡号</text> <text class="detail-label">卡号</text>
<view class="detail-value-row"> <view class="detail-value-row">
<text class="detail-value detail-value--code">{{ order.redeemCode }}</text> <text class="detail-value detail-value--code">{{ order.redeemCode }}</text>
<view v-if="order.status === 'pending'" class="copy-btn" @tap="copyCode"> <view class="copy-btn" @tap="copyCode">
<text class="copy-btn__text">复制</text> <text class="copy-btn__text">复制</text>
</view> </view>
</view> </view>
@@ -42,7 +42,7 @@
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">支付时间</text> <text class="detail-label">支付时间</text>
<text class="detail-value">{{ order.payTime }}</text> <text class="detail-value">{{ order.payTime || '-' }}</text>
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">支付渠道</text> <text class="detail-label">支付渠道</text>
@@ -50,66 +50,52 @@
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">支付金额</text> <text class="detail-label">支付金额</text>
<text class="detail-value detail-value--price">¥{{ order.payAmount }}.00</text> <text class="detail-value detail-value--price">¥{{ (order.payAmount / 100).toFixed(2) }}</text>
</view> </view>
<view v-if="order.status === 'activated' && order.activatedTime" class="detail-row detail-row--last"> <view v-if="order.bindName" class="detail-row">
<text class="detail-label">绑定人</text>
<text class="detail-value">{{ order.bindName }}</text>
</view>
<view v-if="order.bindCertificateNo" class="detail-row">
<text class="detail-label">绑定证件</text>
<text class="detail-value">{{ order.bindCertificateNo }}</text>
</view>
<view v-if="order.activatedTime" class="detail-row">
<text class="detail-label">绑定时间</text> <text class="detail-label">绑定时间</text>
<text class="detail-value">{{ order.activatedTime }}</text> <text class="detail-value">{{ order.activatedTime }}</text>
</view> </view>
<view v-if="order.status === 'refunded' && order.refundedTime" class="detail-row detail-row--last"> <view v-if="order.validStartDate" class="detail-row">
<text class="detail-label">有效期起</text>
<text class="detail-value">{{ order.validStartDate }}</text>
</view>
<view v-if="order.validEndDate" class="detail-row detail-row--last">
<text class="detail-label">有效期止</text>
<text class="detail-value">{{ order.validEndDate }}</text>
</view>
<view v-if="order.refundedTime" class="detail-row detail-row--last">
<text class="detail-label">退款时间</text> <text class="detail-label">退款时间</text>
<text class="detail-value">{{ order.refundedTime }}</text> <text class="detail-value">{{ order.refundedTime }}</text>
</view> </view>
</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" /> <view class="safe-bottom" />
<!-- 模块5:底部固定操作栏 --> <!-- 模块5:底部固定操作栏 -->
<view class="bottom-bar"> <view class="bottom-bar">
<view v-if="order.status === 'pending'" class="bottom-bar__right"> <view v-if="Number(order.payStatus) === 1" class="bottom-bar__right">
<view class="btn-action btn-action--primary" @tap="goPay">
<text class="btn-action__text">立即支付</text>
</view>
</view>
<view v-else-if="Number(order.payStatus) === 2" class="bottom-bar__right">
<view class="btn-action btn-action--outline" @tap="handleRefund"> <view class="btn-action btn-action--outline" @tap="handleRefund">
<text class="btn-action__text--outline">申请退款</text> <text class="btn-action__text--outline">申请退款</text>
</view> </view>
<view class="btn-action btn-action--primary" @tap="goActivate"> <view v-if="order.redeemCode" class="btn-action btn-action--primary" @tap="goActivate">
<text class="btn-action__text">立即绑定</text> <text class="btn-action__text">立即绑定</text>
</view> </view>
</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>
</view> </view>
</template> </template>
@@ -117,62 +103,108 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired' type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired'
interface OrderDetail { interface OrderDetail {
id: string id: string
status: OrderStatus payStatus: number
orderStatus: number
createTime: string createTime: string
payTime: string payTime: string | null
payAmount: number payAmount: number
redeemCode: string
payChannel: string payChannel: string
activatedTime?: string cardNo: string | null
refundedTime?: string redeemCode: string | null
isBind: number | null
bindName: string | null
bindCertificateNo: string | null
cardStatus: number | null
isActive: number | null
activatedTime: string | null
validStartDate: string | null
validEndDate: string | null
refundedTime: string | null
} }
const order = ref<OrderDetail>({ const order = ref<OrderDetail>({
id: 'ORD20260625001', id: '',
status: 'pending', payStatus: 1,
createTime: '2026-06-25 10:32', orderStatus: 1,
payTime: '2026-06-25 10:33', createTime: '',
payAmount: 198, payTime: null,
redeemCode: 'CJ0000012398', payAmount: 0,
payChannel: '微信支付', payChannel: '微信支付',
activatedTime: undefined, cardNo: null,
refundedTime: undefined redeemCode: null,
isBind: null,
bindName: null,
bindCertificateNo: null,
cardStatus: null,
isActive: null,
activatedTime: null,
validStartDate: null,
validEndDate: null,
refundedTime: null,
}) })
// Mock 订单数据集 function toStatus(payStatus: number | string): OrderStatus {
const mockOrders: OrderDetail[] = [ const status = Number(payStatus)
{ id: 'ORD20260625001', status: 'pending', createTime: '2026-06-25 10:32', payTime: '2026-06-25 10:33', payAmount: 198, redeemCode: 'CJ0000012398', payChannel: '微信支付' }, if (status === 1) return 'pending'
{ 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' }, if (status === 2) return 'activated'
{ id: 'ORD20260623003', status: 'pending', createTime: '2026-06-23 09:45', payTime: '2026-06-23 09:46', payAmount: 198, redeemCode: 'CJ0000012400', payChannel: '微信支付' }, return 'refunded'
{ 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) => { let currentOrderId = ''
if (query?.orderId) {
const found = mockOrders.find(o => o.id === query.orderId) onLoad(async (query: any) => {
if (found) { currentOrderId = query?.orderId || ''
order.value = { ...found } await fetchOrderDetail()
} else {
order.value.id = query.orderId
}
}
}) })
async function fetchOrderDetail() {
if (!currentOrderId) return
const phone = (uni.getStorageSync('phone') as string) || ''
try {
const res = await post<any>('/mpOrder/getOrderDetail', { phone, orderId: currentOrderId })
if (res) {
order.value = {
id: res.id || currentOrderId,
payStatus: Number(res.payStatus) ?? 1,
orderStatus: res.orderStatus ?? 1,
createTime: res.createTime || '',
payTime: res.payTime || null,
payAmount: res.payAmount || 0,
payChannel: res.payChannel || '微信支付',
cardNo: res.cardNo || null,
redeemCode: res.redeemCode || null,
isBind: res.isBind ?? null,
bindName: res.bindName || null,
bindCertificateNo: res.bindCertificateNo || null,
cardStatus: res.cardStatus ?? null,
isActive: res.isActive ?? null,
activatedTime: res.activatedTime || null,
validStartDate: res.validStartDate || null,
validEndDate: res.validEndDate || null,
refundedTime: res.refundedTime || null,
}
}
} catch {
}
}
const orderStatus = computed(() => toStatus(order.value.payStatus))
const statusLabel = computed(() => { const statusLabel = computed(() => {
const map: Record<OrderStatus, string> = { const map: Record<OrderStatus, string> = {
pending: '待绑定', pending: '待支付',
activated: '已绑定', activated: '已支付',
refunded: '已退款', refunded: '已退款',
expired: '已过期' expired: '已过期'
} }
return map[order.value.status] return map[orderStatus.value]
}) })
const statusDesc = computed(() => { const statusDesc = computed(() => {
@@ -182,43 +214,77 @@ const statusDesc = computed(() => {
refunded: '退款审核通过,金额原路返还微信', refunded: '退款审核通过,金额原路返还微信',
expired: '下单超过绑定有效期,卡号失效无法绑卡' expired: '下单超过绑定有效期,卡号失效无法绑卡'
} }
return map[order.value.status] return map[orderStatus.value]
}) })
function copyCode() { function copyCode() {
if (!order.value.redeemCode) return
uni.setClipboardData({ uni.setClipboardData({
data: order.value.redeemCode, data: order.value.redeemCode,
success: () => uni.showToast({ title: '已复制', icon: 'success' }) success: () => uni.showToast({ title: '已复制', icon: 'success' })
}) })
} }
async function goPay() {
const openId = (uni.getStorageSync('app_token') as string) || ''
const phone = (uni.getStorageSync('phone') as string) || ''
uni.showLoading({ title: '调起支付...', mask: true })
try {
const payParams = await post<any>(`${BASE_URL}/open-api/pingXiang/prepay`, {
openId,
orderNo: order.value.id,
description: phone,
amount: 1
})
uni.hideLoading()
uni.requestPayment({
provider: 'wxpay',
appId: payParams.appId,
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonce,
package: payParams.pack,
signType: payParams.signType,
paySign: payParams.paySign,
success: () => {
uni.showToast({ title: '支付成功', icon: 'success' })
fetchOrderDetail()
},
fail: () => {
fetchOrderDetail()
}
})
} catch (err: any) {
uni.hideLoading()
uni.showToast({ title: err.message || '支付失败', icon: 'none' })
}
}
function goActivate() { function goActivate() {
if (!order.value.redeemCode) return
uni.navigateTo({ uni.navigateTo({
url: `/packages/user/pages/bind?redeemCode=${order.value.redeemCode}` url: `/packages/user/pages/bind?redeemCode=${order.value.redeemCode}`
}) })
} }
function handleRefund() { async function handleRefund() {
uni.showModal({ uni.showModal({
title: '申请退款', title: '申请退款',
content: '赣湘全域旅游年卡/超级玩家卡绑定或销售七日以上不可退卡。确认申请退款?', content: '赣湘全域旅游年卡/超级玩家卡绑定或销售七日以上不可退卡。确认申请退款?',
success: (res) => { success: async (res) => {
if (res.confirm) { if (res.confirm) {
order.value.status = 'refunded' try {
await post(`${BASE_URL}/open-api/pingXiang/refund?orderNo=${order.value.id}`)
order.value.payStatus = 3
order.value.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ') order.value.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
uni.showToast({ title: '退款申请已提交', icon: 'success' }) uni.showToast({ title: '退款申请已提交', icon: 'success' })
} catch (err: any) {
uni.showToast({ title: err.message || '退款失败', icon: 'none' })
}
} }
} }
}) })
} }
function goFaq() {
uni.navigateTo({ url: '/packages/content/pages/faq' })
}
function goHome() {
uni.switchTab({ url: '/pages/index/index' })
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
@@ -462,63 +528,6 @@ $bar-height: 120rpx;
font-weight: $font-weight-medium; 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 { .safe-bottom {
height: $spacing-3; height: $spacing-3;
+124 -23
View File
@@ -59,7 +59,7 @@
<text class="product-name">超级玩家卡</text> <text class="product-name">超级玩家卡</text>
<view class="price-current"> <view class="price-current">
<text class="price-symbol">¥</text> <text class="price-symbol">¥</text>
<text class="price-value">{{ order.payAmount }}</text> <text class="price-value">{{ (order.payAmount / 100).toFixed(2) }}</text>
</view> </view>
</view> </view>
</view> </view>
@@ -72,10 +72,15 @@
<!-- 操作按钮 --> <!-- 操作按钮 -->
<view class="card-actions" v-if="order.status === 'pending'"> <view class="card-actions" v-if="order.status === 'pending'">
<view class="btn-primary" @tap.stop="goPay(order.id)">
<text class="btn-primary__text">立即支付</text>
</view>
</view>
<view class="card-actions" v-else-if="order.status === 'activated'">
<view class="btn-outline" @tap.stop="showRefundModal(order)"> <view class="btn-outline" @tap.stop="showRefundModal(order)">
<text class="btn-outline__text">申请退款</text> <text class="btn-outline__text">申请退款</text>
</view> </view>
<view class="btn-primary" @tap.stop="goActivate(order)"> <view v-if="order.redeemCode" class="btn-primary" @tap.stop="goActivate(order)">
<text class="btn-primary__text">立即绑定</text> <text class="btn-primary__text">立即绑定</text>
</view> </view>
</view> </view>
@@ -117,14 +122,25 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { mockOrders, type OrderItem } from '../../mock/order' import { onShow } from '@dcloudio/uni-app'
import { post, get } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired' type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired'
interface OrderItem {
id: string
status: OrderStatus
createTime: string
payAmount: number
redeemCode: string | null
payChannel: string
}
const tabs = [ const tabs = [
{ label: '全部', value: '' }, { label: '全部', value: '' },
{ label: '待绑定', value: 'pending' }, { label: '待支付', value: 'pending' },
{ label: '已绑定', value: 'activated' }, { label: '已支付', value: 'activated' },
{ label: '已退款', value: 'refunded' }, { label: '已退款', value: 'refunded' },
] ]
@@ -132,11 +148,12 @@ const activeTab = ref('')
const loading = ref(false) const loading = ref(false)
const refreshing = ref(false) const refreshing = ref(false)
const noMore = ref(false) const noMore = ref(false)
const currentPage = ref(1) const pageNo = ref(1)
const pageSize = 10
const refundPopup = ref<any>(null) const refundPopup = ref<any>(null)
const refundOrderId = ref('') const refundOrderId = ref('')
const orders = ref<OrderItem[]>(mockOrders) const orders = ref<OrderItem[]>([])
const filteredOrders = computed(() => { const filteredOrders = computed(() => {
if (!activeTab.value) return orders.value if (!activeTab.value) return orders.value
@@ -145,34 +162,106 @@ const filteredOrders = computed(() => {
function statusLabel(status: OrderStatus) { function statusLabel(status: OrderStatus) {
const map: Record<OrderStatus, string> = { const map: Record<OrderStatus, string> = {
pending: '待绑定', pending: '待支付',
activated: '已绑定', activated: '已支付',
refunded: '已退款', refunded: '已退款',
expired: '已过期' expired: '已过期'
} }
return map[status] return map[status]
} }
function payStatusValue(tab: string): number | undefined {
if (tab === 'pending') return 1
if (tab === 'activated') return 2
if (tab === 'refunded') return 3
return undefined
}
async function fetchOrders(append = false) {
if (loading.value) return
loading.value = true
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpOrder/queryOrderList', {
phone,
payStatus: payStatusValue(activeTab.value),
pageNo: pageNo.value,
pageSize,
})
const list = (res.list || res.records || []).map((item: any) => ({
id: item.id || '',
status: item.payStatus === 1 ? 'pending' : item.payStatus === 2 ? 'activated' : 'refunded',
createTime: item.createTime || '',
payAmount: item.payAmount || 0,
redeemCode: item.redeemCode || null,
payChannel: item.payChannel || '微信支付',
}))
if (append) {
orders.value.push(...list)
} else {
orders.value = list
}
noMore.value = list.length < pageSize
} catch {
if (!append) orders.value = []
noMore.value = true
} finally {
loading.value = false
}
}
function switchTab(value: string) { function switchTab(value: string) {
activeTab.value = value activeTab.value = value
pageNo.value = 1
fetchOrders()
} }
function onRefresh() { function onRefresh() {
refreshing.value = true refreshing.value = true
currentPage.value = 1 pageNo.value = 1
noMore.value = false fetchOrders().finally(() => { refreshing.value = false })
setTimeout(() => {
refreshing.value = false
}, 800)
} }
function onLoadMore() { function onLoadMore() {
if (loading.value || noMore.value) return if (loading.value || noMore.value) return
loading.value = true pageNo.value++
setTimeout(() => { fetchOrders(true)
loading.value = false }
noMore.value = true
}, 800) async function goPay(orderId: string) {
const openId = (uni.getStorageSync('app_token') as string) || ''
const phone = (uni.getStorageSync('phone') as string) || ''
uni.showLoading({ title: '调起支付...', mask: true })
try {
const payParams = await post<any>(`${BASE_URL}/open-api/pingXiang/prepay`, {
openId,
orderNo: orderId,
description: phone,
amount: 1
})
uni.hideLoading()
uni.requestPayment({
provider: 'wxpay',
appId: payParams.appId,
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonce,
package: payParams.pack,
signType: payParams.signType,
paySign: payParams.paySign,
success: () => {
uni.showToast({ title: '支付成功', icon: 'success' })
pageNo.value = 1
fetchOrders()
},
fail: () => {
pageNo.value = 1
fetchOrders()
}
})
} catch (err: any) {
uni.hideLoading()
uni.showToast({ title: err.message || '支付失败', icon: 'none' })
}
} }
function goDetail(orderId: string) { function goDetail(orderId: string) {
@@ -180,6 +269,10 @@ function goDetail(orderId: string) {
} }
function goActivate(order: OrderItem) { function goActivate(order: OrderItem) {
if (!order.redeemCode) {
uni.showToast({ title: '暂无兑换码', icon: 'none' })
return
}
uni.navigateTo({ uni.navigateTo({
url: `/packages/user/pages/bind?redeemCode=${order.redeemCode}` url: `/packages/user/pages/bind?redeemCode=${order.redeemCode}`
}) })
@@ -194,19 +287,27 @@ function closeRefund() {
refundPopup.value?.close() refundPopup.value?.close()
} }
function submitRefund() { async function submitRefund() {
refundPopup.value?.close() refundPopup.value?.close()
const order = orders.value.find(o => o.id === refundOrderId.value) const order = orders.value.find(o => o.id === refundOrderId.value)
if (order) { if (!order) return
try {
await post(`${BASE_URL}/open-api/pingXiang/refund?orderNo=${refundOrderId.value}`)
order.status = 'refunded' order.status = 'refunded'
order.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
}
uni.showToast({ title: '退款申请已提交', icon: 'success' }) uni.showToast({ title: '退款申请已提交', icon: 'success' })
} catch (err: any) {
uni.showToast({ title: err.message || '退款失败', icon: 'none' })
}
} }
function goBuy() { function goBuy() {
uni.navigateTo({ url: '/packages/user/pages/buy' }) uni.navigateTo({ url: '/packages/user/pages/buy' })
} }
onShow(() => {
pageNo.value = 1
fetchOrders()
})
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
+218 -117
View File
@@ -1,6 +1,5 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 景区基础信息卡片 -->
<view class="scenic-card"> <view class="scenic-card">
<image class="scenic-img" :src="scenic.image" mode="aspectFill" /> <image class="scenic-img" :src="scenic.image" mode="aspectFill" />
<view class="scenic-info"> <view class="scenic-info">
@@ -13,11 +12,10 @@
<view class="scenic-icon scenic-icon--clock" /> <view class="scenic-icon scenic-icon--clock" />
<text class="scenic-text">{{ scenic.hours }}</text> <text class="scenic-text">{{ scenic.hours }}</text>
</view> </view>
<text class="scenic-tip">持赣湘全域旅游年卡免费入园,单人单日仅可预约1次</text> <text class="scenic-tip">持{{ cardModelName }}免费入园,单人单日仅可预约1次</text>
</view> </view>
</view> </view>
<!-- 日期选择模块 -->
<view class="date-card"> <view class="date-card">
<text class="section-title">选择预约日期</text> <text class="section-title">选择预约日期</text>
<scroll-view scroll-x class="date-scroll"> <scroll-view scroll-x class="date-scroll">
@@ -27,19 +25,21 @@
:key="d.key" :key="d.key"
class="date-item" class="date-item"
:class="{ :class="{
'date-item--active': selectedDate === d.key && d.stock > 0, 'date-item--active': selectedDate === d.key,
'date-item--disabled': d.stock <= 0 'date-item--disabled': d.disabled || (d.stock != null && d.stock <= 0)
}" }"
@tap="d.stock > 0 && (selectedDate = d.key)" @tap="!(d.disabled || (d.stock != null && d.stock <= 0)) && (selectedDate = d.key)"
> >
<text class="date-week">{{ d.week }}</text> <text class="date-week">{{ d.week }}</text>
<text class="date-day">{{ d.label }}</text> <text class="date-day">{{ d.label }}</text>
<text v-if="d.stock > 0" class="date-stock">余{{ d.stock }}位</text> <text v-if="d.disabled" class="date-stock date-stock--empty">不可约</text>
<text v-else-if="d.stock == null" class="date-stock">无限库存</text>
<text v-else-if="d.stock >= 50" class="date-stock">可预约</text>
<text v-else-if="d.stock > 0" class="date-stock">余{{ d.stock }}位</text>
<text v-else class="date-stock date-stock--empty">已约满</text> <text v-else class="date-stock date-stock--empty">已约满</text>
</view> </view>
</view> </view>
</scroll-view> </scroll-view>
<!-- 场次选择 -->
<view v-if="selectedSessions.length > 0" class="session-section"> <view v-if="selectedSessions.length > 0" class="session-section">
<text class="session-title">选择入园场次</text> <text class="session-title">选择入园场次</text>
<view class="session-list"> <view class="session-list">
@@ -48,59 +48,50 @@
:key="s.id" :key="s.id"
class="session-item" class="session-item"
:class="{ :class="{
'session-item--active': selectedSession === s.id && s.stock > 0, 'session-item--active': selectedSession === s.id && (s.stock == null || s.stock > 0),
'session-item--disabled': s.stock <= 0 'session-item--disabled': s.stock != null && s.stock <= 0
}" }"
@tap="s.stock > 0 && (selectedSession = s.id)" @tap="(s.stock == null || s.stock > 0) && (selectedSession = s.id)"
> >
<text class="session-time">{{ s.time }}</text> <text class="session-time">{{ s.time }}</text>
<text v-if="s.stock > 0" class="session-stock">余{{ s.stock }}位</text> <text v-if="s.stock == null" class="session-stock session-stock--available">可预约</text>
<text v-else-if="s.stock >= 50" class="session-stock session-stock--available">可预约</text>
<text v-else-if="s.stock > 0" class="session-stock session-stock--limited">余{{ s.stock }}位</text>
<text v-else class="session-stock session-stock--empty">已约满</text> <text v-else class="session-stock session-stock--empty">已约满</text>
</view> </view>
</view> </view>
</view> </view>
<view v-else-if="selectedDate && selectedDateStock <= 0" class="no-session-tip"> <view v-else-if="selectedDateObj?.disabled" class="no-session-tip">
<text class="no-session-tip__text">该日期暂无可预约场次</text> <text class="no-session-tip__text no-session-tip__text--error">{{ selectedDateObj.reason || '该日期不可预约' }}</text>
</view> </view>
<view v-else-if="selectedDate" class="no-session-tip"> <view v-else-if="selectedDate && selectedDateStock === 0" class="no-session-tip">
<text class="no-session-tip__text">该日期已约满</text>
</view>
<view v-else-if="selectedDate && selectedDateStock > 0 && !selectedSessions.length" class="no-session-tip">
<text class="no-session-tip__text">该日期无需选择场次,直接预约即可</text> <text class="no-session-tip__text">该日期无需选择场次,直接预约即可</text>
</view> </view>
<text class="date-tip">仅可预约近3日内时段,当日预约需提前1天取消</text> <text class="date-tip">仅可预约近{{ appointmentDays || 3 }}日内时段</text>
</view> </view>
<!-- 预约人实名信息 -->
<view class="user-card"> <view class="user-card">
<text class="section-title">预约人信息</text> <text class="section-title">预约人信息</text>
<view class="user-row"> <view class="user-row">
<text class="user-label">姓名</text> <text class="user-label">姓名</text>
<text class="user-value">{{ userInfo.name }}</text> <text class="user-value">{{ userName }}</text>
</view> </view>
<view class="user-row"> <view class="user-row">
<text class="user-label">身份证号</text> <text class="user-label">身份证号</text>
<text class="user-value">{{ maskedId }}</text> <text class="user-value">{{ userIdCard }}</text>
</view> </view>
<text class="user-warn">预约信息必须与持卡实名一致,不可更换他人</text> <text class="user-warn">预约信息必须与持卡实名一致,不可更换他人</text>
</view> </view>
<!-- 预约须知 --> <view class="notice-card" v-if="bookingNotice">
<view class="notice-card">
<text class="notice-title">预约须知</text> <text class="notice-title">预约须知</text>
<view class="notice-list"> <view class="notice-list">
<view class="notice-item"> <view class="notice-item">
<view class="notice-dot" /> <view class="notice-dot" />
<text class="notice-text">赣湘全域旅游年卡单人每日仅可预约该景区1次</text> <text class="notice-text">{{ bookingNotice }}</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> </view>
<view class="notice-link" @tap="goFaq"> <view class="notice-link" @tap="goFaq">
@@ -108,10 +99,8 @@
</view> </view>
</view> </view>
<!-- 底部安全区占位 -->
<view class="safe-bottom" /> <view class="safe-bottom" />
<!-- 底部固定预约按钮 -->
<view class="bottom-bar"> <view class="bottom-bar">
<button <button
class="btn-submit" class="btn-submit"
@@ -128,100 +117,212 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
const scenic = ref({ interface ReserveOptionSession {
name: '武功山金顶景区', id: string | number
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 time: string
stock: number stock: number
} }
const sessionMap: Record<string, Session[]> = { interface DateOption {
[dateOptions[0].key]: [ key: string
{ id: 's1', time: '08:00-10:00', stock: 56 }, week: string
{ id: 's2', time: '10:00-12:00', stock: 42 }, label: string
{ id: 's3', time: '14:00-16:00', stock: 58 }, stock: number | null
], sessions: ReserveOptionSession[]
[dateOptions[1].key]: [ disabled?: boolean
{ id: 's4', time: '08:00-12:00', stock: 100 }, reason?: string
{ id: 's5', time: '12:00-18:00', stock: 100 },
],
} }
const selectedSession = ref('') const scenic = ref({ name: '', address: '', hours: '', image: '' })
const userName = ref('')
const userIdCard = ref('')
const bookingNotice = ref('')
const cardModelName = ref('')
const appointmentDays = ref<number | null>(null)
const reserveParams = ref({
scenicId: 0,
channel: '',
thirdId: '',
cardNum: '',
mobile: '',
cardModelName: ''
})
const dateOptions = ref<DateOption[]>([])
const selectedDate = ref('')
const selectedSession = ref<string | number>('')
const submitting = ref(false)
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function formatDateDisplay(dateStr: string): { week: string; label: string } {
const d = new Date(dateStr)
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const diff = Math.round((d.getTime() - today.getTime()) / 86400000)
const week = diff === 1 ? '明天' : diff === 2 ? '后天' : weekNames[d.getDay()]
const label = `${d.getMonth() + 1}月${d.getDate()}日`
return { week, label }
}
const selectedDateObj = computed(() => {
if (!selectedDate.value) return null
return dateOptions.value.find(o => o.key === selectedDate.value) || null
})
const selectedSessions = computed(() => { const selectedSessions = computed(() => {
if (!selectedDate.value) return [] if (!selectedDate.value || selectedDateObj.value?.disabled) return []
const sessions = sessionMap[selectedDate.value] || [] const d = dateOptions.value.find(d => d.key === selectedDate.value)
// 切换日期时自动选中第一个有库存的场次 const sessions = d?.sessions || []
if (sessions.length > 0 && !sessions.find(s => s.id === selectedSession.value)) { if (sessions.length > 0 && !sessions.find(s => s.id === selectedSession.value)) {
const available = sessions.find(s => s.stock > 0) const available = sessions.find(s => s.stock == null || s.stock > 0)
selectedSession.value = available ? available.id : '' selectedSession.value = available ? available.id : ''
} }
return sessions return sessions
}) })
const selectedDateStock = computed(() => { const selectedDateStock = computed(() => {
if (!selectedDate.value) return 0 if (!selectedDate.value) return -1
const d = dateOptions.find(o => o.key === selectedDate.value) const d = dateOptions.value.find(o => o.key === selectedDate.value)
return d ? d.stock : 0 if (!d) return 0
return d.stock == null ? 1 : d.stock
}) })
onLoad((query: any) => { onLoad(async (query: any) => {
if (query?.scenicId) { const resourceId = query?.resourceId
// 实际项目中根据 scenicId 请求景区详情 if (!resourceId) return
const cardNumber = query?.cardNumber || ''
const cardModelId = query?.cardModelId || ''
const phone = (uni.getStorageSync('phone') as string) || ''
try {
const res = await post<any>('/mpReserve/queryReserveOptions', {
resourceId: parseInt(resourceId),
phone,
cardNumber: cardNumber || undefined,
})
if (res) {
scenic.value = {
name: res.scenicName || '',
address: res.address || '',
hours: res.hours || '',
image: res.scenicImage || ''
}
userName.value = res.userName || ''
userIdCard.value = res.userIdCard || ''
bookingNotice.value = Array.isArray(res.bookingNotice) ? res.bookingNotice.join('\n') : (res.bookingNotice || '')
cardModelName.value = res.cardModelName || ''
appointmentDays.value = (res.dates || []).length
reserveParams.value = {
scenicId: parseInt(resourceId),
channel: 'Channel_SZH',
thirdId: cardModelId,
cardNum: cardNumber,
mobile: phone,
cardModelName: res.cardModelName || ''
}
dateOptions.value = (res.dates || []).map((d: any) => ({
key: d.key,
week: d.week,
label: d.label,
stock: d.stock,
sessions: d.sessions || [],
disabled: d.disabled,
reason: d.reason,
}))
const firstAvailable = dateOptions.value.find(d => d.stock == null || d.stock > 0)
if (firstAvailable) selectedDate.value = firstAvailable.key
}
} catch (err: any) {
const mockStocks: (number | null)[] = [null, 200, 0]
const mockSessions: Record<string, ReserveOptionSession[]> = {
['mock0']: [
{ id: 's1', time: '08:00-10:00', stock: 56 },
{ id: 's2', time: '10:00-12:00', stock: 42 },
{ id: 's3', time: '14:00-16:00', stock: 58 },
],
['mock1']: [
{ id: 's4', time: '08:00-12:00', stock: 100 },
{ id: 's5', time: '12:00-18:00', stock: 100 },
],
}
dateOptions.value = Array.from({ length: 3 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() + i + 1)
const month = d.getMonth() + 1
const day = d.getDate()
const key = `${d.getFullYear()}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const { week, label } = formatDateDisplay(key)
return {
key,
week,
label,
stock: mockStocks[i],
sessions: mockSessions[`mock${i}`] || [],
}
})
appointmentDays.value = 3
scenic.value = {
name: '武功山金顶景区',
address: '江西省萍乡市芦溪县武功山风景名胜区',
hours: '08:00-18:00',
image: 'https://images.unsplash.com/photo-1513415564515-763d91423bdd?fm=jpg&q=80&w=800&h=400&fit=crop',
}
bookingNotice.value = '请提前10分钟到达景区检票口;\n入园需出示预约码和身份证;\n如无法按时入园请提前取消预约。'
userName.value = '张三'
userIdCard.value = '360302199001011234'
cardModelName.value = '赣湘全域旅游年卡'
const firstAvailable = dateOptions.value.find(d => d.stock == null || d.stock > 0)
if (firstAvailable) selectedDate.value = firstAvailable.key
} }
}) })
function handleSubmit() { async function handleSubmit() {
if (!selectedDate.value) return if (!selectedDate.value || submitting.value) return
// Mock 预约成功 const dateObj = dateOptions.value.find(d => d.key === selectedDate.value)
const reserveId = 'YP' + Date.now().toString().slice(-8) if (dateObj?.disabled) {
// 获取选中场次的时间段 uni.showToast({ title: dateObj.reason || '该日期不可预约', icon: 'none' })
return
}
if (dateObj && dateObj.stock != null && dateObj.stock <= 0) {
uni.showToast({ title: '该日期已约满', icon: 'none' })
return
}
submitting.value = true
try {
const session = selectedSessions.value.find(s => s.id === selectedSession.value) const session = selectedSessions.value.find(s => s.id === selectedSession.value)
const sessionTime = session ? session.time : '' const p = reserveParams.value
const sessionParam = sessionTime ? `&session=${encodeURIComponent(sessionTime)}` : '' const result = await post<any>(`${BASE_URL}/zhy-api/cardBag/appoint`, {
uni.navigateTo({ scenicId: String(p.scenicId),
url: `/packages/user/pages/reserve/success?reserveId=${reserveId}&date=${selectedDate.value}&scenic=${encodeURIComponent(scenic.value.name)}${sessionParam}` channel: p.channel,
thirdId: p.thirdId,
cardNum: p.cardNum,
mobile: p.mobile,
cardModelName: p.cardModelName,
appointDate: selectedDate.value,
timeId: session ? String(session.id) : '',
name: userName.value,
idNo: userIdCard.value,
}) })
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => {
uni.navigateTo({
url: `/packages/user/pages/reserve/success?appointId=${result}&cardNumber=${p.cardNum}&cardModelId=${p.thirdId}`
})
}, 1500)
} catch (e: any) {
uni.showToast({ title: e?.message || '预约失败', icon: 'none' })
} finally {
submitting.value = false
}
} }
function goFaq() { function goFaq() {
@@ -239,7 +340,6 @@ $bar-height: 120rpx;
font-family: $font-family-body; font-family: $font-family-body;
} }
/* ===== 景区信息卡片 ===== */
.scenic-card { .scenic-card {
margin: 24rpx 24rpx 0; margin: 24rpx 24rpx 0;
background: $color-card; background: $color-card;
@@ -337,7 +437,6 @@ $bar-height: 120rpx;
line-height: 1.6; line-height: 1.6;
} }
/* ===== 日期选择 ===== */
.date-card { .date-card {
margin: 24rpx 32rpx 0; margin: 24rpx 32rpx 0;
background: $color-card; background: $color-card;
@@ -371,7 +470,7 @@ $bar-height: 120rpx;
align-items: center; align-items: center;
padding: 16rpx 12rpx; padding: 16rpx 12rpx;
border-radius: 16rpx; border-radius: 16rpx;
border: 2rpx solid $uv-border-color; border: 2rpx solid $uv-primary;
transition: all $transition-fast; transition: all $transition-fast;
&--active { &--active {
@@ -406,10 +505,10 @@ $bar-height: 120rpx;
.date-stock { .date-stock {
font-family: $font-family-body; font-family: $font-family-body;
font-size: $font-size-xxs; font-size: $font-size-xxs;
color: $uv-placeholder-color; color: $uv-main-color;
&--empty { &--empty {
color: $uv-error; color: $uv-placeholder-color;
} }
} }
@@ -418,15 +517,15 @@ $bar-height: 120rpx;
} }
.date-item--disabled { .date-item--disabled {
opacity: 0.5; border-color: $uv-border-color;
.date-week, .date-week,
.date-day { .date-day,
.date-stock {
color: $uv-placeholder-color; color: $uv-placeholder-color;
} }
} }
/* ===== 场次选择 ===== */
.session-section { .session-section {
margin-top: 24rpx; margin-top: 24rpx;
padding-top: 24rpx; padding-top: 24rpx;
@@ -501,6 +600,11 @@ $bar-height: 120rpx;
color: $uv-placeholder-color; color: $uv-placeholder-color;
} }
.no-session-tip__text--error {
color: $uv-error;
font-weight: $font-weight-medium;
}
.date-tip { .date-tip {
display: block; display: block;
font-family: $font-family-body; font-family: $font-family-body;
@@ -509,7 +613,6 @@ $bar-height: 120rpx;
margin-top: 16rpx; margin-top: 16rpx;
} }
/* ===== 预约人信息 ===== */
.user-card { .user-card {
margin: 24rpx 32rpx 0; margin: 24rpx 32rpx 0;
background: $color-card; background: $color-card;
@@ -545,7 +648,6 @@ $bar-height: 120rpx;
margin-top: 12rpx; margin-top: 12rpx;
} }
/* ===== 预约须知 ===== */
.notice-card { .notice-card {
margin: 24rpx 32rpx 0; margin: 24rpx 32rpx 0;
background: #eef4ff; background: #eef4ff;
@@ -600,7 +702,6 @@ $bar-height: 120rpx;
font-weight: $font-weight-medium; font-weight: $font-weight-medium;
} }
/* ===== 底部 ===== */
.safe-bottom { .safe-bottom {
height: 24rpx; height: 24rpx;
} }
+155 -40
View File
@@ -98,7 +98,7 @@
<view class="code-modal"> <view class="code-modal">
<text class="code-modal__title">入园码</text> <text class="code-modal__title">入园码</text>
<view class="qr-box"> <view class="qr-box">
<image class="qr-img" src="/static/qr-demo.png" mode="aspectFit" /> <image v-if="codeSrc" class="qr-img" :src="codeSrc" mode="aspectFit" />
</view> </view>
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text> <text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
</view> </view>
@@ -162,7 +162,7 @@
</view> </view>
<view class="sheet-row"> <view class="sheet-row">
<text class="sheet-label">预约状态</text> <text class="sheet-label">预约状态</text>
<text class="sheet-value">{{ statusLabel(detailRecord?.status || '') }}</text> <text class="sheet-value">{{ detailStatusLabel(detailRecord?.status || '') }}</text>
</view> </view>
<view class="sheet-row"> <view class="sheet-row">
<text class="sheet-label">预约申请时间</text> <text class="sheet-label">预约申请时间</text>
@@ -177,12 +177,12 @@
<text class="sheet-value">{{ detailRecord.cancelTime }}</text> <text class="sheet-value">{{ detailRecord.cancelTime }}</text>
</view> </view>
<!-- 核销人脸照片 --> <!-- 核销人脸照片 -->
<view v-if="detailRecord?.verifyPhoto && detailRecord?.status !== 'auto_used'" class="sheet-photo"> <view v-if="detailRecord?.verifyPhoto" class="sheet-photo">
<text class="sheet-label">核销照片</text> <text class="sheet-label">核销照片</text>
<image class="sheet-photo-avatar" :src="detailRecord.verifyPhoto" mode="aspectFill" @tap="previewPhoto(detailRecord.verifyPhoto)" /> <image class="sheet-photo-avatar" :src="detailRecord.verifyPhoto" mode="aspectFill" @tap="previewPhoto(detailRecord.verifyPhoto)" />
</view> </view>
<view v-if="detailRecord?.status !== 'used'" class="sheet-tip"> <view v-if="detailRecord?.status !== 'used' && detailRecord?.cardInstruction" class="sheet-tip">
<text class="sheet-tip__text">未提前取消且未入园将扣除1次年卡使用次数</text> <text class="sheet-tip__text">{{ detailRecord.cardInstruction }}</text>
</view> </view>
</scroll-view> </scroll-view>
</view> </view>
@@ -191,9 +191,12 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
type RecordStatus = 'pending' | 'used' | 'cancelled' | 'auto_used' type RecordStatus = 'pending' | 'cancelled' | 'used'
interface ReserveRecord { interface ReserveRecord {
id: string id: string
@@ -209,46 +212,95 @@ interface ReserveRecord {
cancelTime?: string cancelTime?: string
verifyTime?: string verifyTime?: string
verifyPhoto?: string verifyPhoto?: string
cardInstruction?: string
cardModelId?: string
} }
const tabs = [ const tabs = [
{ label: '全部', value: '' }, { label: '全部', value: '' },
{ label: '待入园', value: 'pending' }, { label: '待使用', value: 'pending' },
{ label: '已入园', value: 'used' }, { label: '已入园', value: 'used' },
{ label: '已取消', value: 'cancelled' }, { label: '已取消', value: 'cancelled' },
{ label: '自动核销', value: 'auto_used' },
] ]
const activeTab = ref('') const activeTab = ref('')
const loading = ref(false) const loading = ref(false)
const refreshing = ref(false) const refreshing = ref(false)
const noMore = ref(false) const noMore = ref(false)
const pageNo = ref(1)
const pageSize = 10
const codePopup = ref<any>(null) const codePopup = ref<any>(null)
const cancelPopup = ref<any>(null) const cancelPopup = ref<any>(null)
const detailPopup = ref<any>(null) const detailPopup = ref<any>(null)
const popupCode = ref('') const codeSrc = ref('')
const detailRecord = ref<ReserveRecord | null>(null) const detailRecord = ref<ReserveRecord | null>(null)
const cancelRecord = ref<ReserveRecord | null>(null) const cancelRecord = ref<ReserveRecord | null>(null)
const records = ref<ReserveRecord[]>([ 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(() => { const filteredRecords = computed(() => records.value)
if (!activeTab.value) return records.value
return records.value.filter(r => r.status === activeTab.value)
})
function statusLabel(status: string) { function statusLabel(status: string) {
const map: Record<string, string> = { pending: '待入园', used: '已入园', cancelled: '已取消', auto_used: '自动核销' } const map: Record<string, string> = { pending: '待使用', cancelled: '已取消', used: '已入园' }
return map[status] || status return map[status] || status
} }
function detailStatusLabel(status: number | string) {
const map: Record<string, string> = { '1': '预约成功', '2': '预约失败', '3': '已取消', '4': '已入园' }
return map[String(status)] || String(status)
}
async function fetchRecords(append = false) {
if (loading.value) return
loading.value = true
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpReserve/queryUsageRecordList', {
phone,
status: activeTab.value,
pageNo: pageNo.value,
pageSize,
})
const list = (res.list || res.records || []).map((item: any) => ({
id: item.id || '',
scenicName: item.scenicName || item.resourceName || '',
date: item.appTime || item.date || '',
session: item.sessionTime || item.session || '',
status: item.status || 'pending',
userName: item.name || item.userName || '',
maskedId: item.identityNo ? `${item.identityNo.slice(0, 6)}****${item.identityNo.slice(-4)}` : '',
cardType: item.cardModelName || item.cardType || '',
cardNo: item.cardNum || item.cardNo || '',
applyTime: item.createTime || item.applyTime || '',
cancelTime: item.cancelTime || '',
verifyTime: item.verifyTime || '',
verifyPhoto: item.verifyPhoto || '',
cardInstruction: item.cardInstruction || '',
cardModelId: item.cardModelId || item.thirdId || '',
}))
if (append) {
records.value.push(...list)
} else {
records.value = list
}
noMore.value = list.length < pageSize
} catch (_e) {
records.value = []
noMore.value = true
} finally {
loading.value = false
}
}
onLoad(() => {
fetchRecords()
})
watch(activeTab, () => {
pageNo.value = 1
fetchRecords()
})
function canCancel(date: string) { function canCancel(date: string) {
const today = new Date() const today = new Date()
today.setHours(0, 0, 0, 0) today.setHours(0, 0, 0, 0)
@@ -261,40 +313,105 @@ function showCancelModal(r: ReserveRecord) {
cancelPopup.value?.open() cancelPopup.value?.open()
} }
function confirmCancel() { async function confirmCancel() {
cancelPopup.value?.close() cancelPopup.value?.close()
if (cancelRecord.value) { if (cancelRecord.value) {
try {
await post<any>(`${BASE_URL}/zhy-api/cardBag/cancelAppoint`, {
appointId: cancelRecord.value.id,
channel: 'Channel_SZH',
})
const idx = records.value.findIndex(o => o.id === cancelRecord.value!.id) const idx = records.value.findIndex(o => o.id === cancelRecord.value!.id)
if (idx !== -1) { if (idx !== -1) {
records.value[idx].status = 'cancelled' records.value[idx].status = 'cancelled'
records.value[idx].cancelTime = new Date().toISOString().slice(0, 16).replace('T', ' ') records.value[idx].cancelTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
} }
uni.showToast({ title: '已取消预约', icon: 'success' }) uni.showToast({ title: '已取消预约', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
}
} }
} }
function showCode(r: ReserveRecord) { async function showCode(r: ReserveRecord) {
popupCode.value = r.id codeSrc.value = ''
try {
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${r.id}`)
if (res.qrType == 1) {
codeSrc.value = 'data:image/png;base64,' + res.qrCode
codePopup.value?.open() codePopup.value?.open()
} else if (res.qrType == 2) {
codeSrc.value = res.qrUrl
codePopup.value?.open()
} else {
await fetchQrByCard(r)
}
} catch (_e) {
await fetchQrByCard(r)
}
} }
function viewDetail(r: ReserveRecord) { async function fetchQrByCard(r: ReserveRecord) {
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/qrAndUserInfo`, {
cardNum: r.cardNo,
channel: 'Channel_SZH',
mobile: phone,
scenicId: '',
thirdId: r.cardModelId,
})
if (res.qrCode) {
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else {
uni.showToast({ icon: 'none', title: '暂无核销码' })
}
} catch (_e) {
uni.showToast({ icon: 'none', title: '获取入园码失败' })
}
}
async function viewDetail(r: ReserveRecord) {
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpReserve/getAppointmentDetail', { phone, appointId: r.id })
if (res) {
detailRecord.value = {
id: res.id || r.id,
scenicName: res.resourceName || r.scenicName,
date: res.appTime || r.date,
session: res.sessionTime || r.session,
status: res.status || r.status || 'pending',
userName: res.name || r.userName,
maskedId: res.identityNo ? `${res.identityNo.slice(0, 6)}****${res.identityNo.slice(-4)}` : r.maskedId,
cardType: res.cardModelName || r.cardType,
cardNo: res.cardNum || r.cardNo,
applyTime: res.createTime || r.applyTime,
cancelTime: res.cancelTime || r.cancelTime,
verifyTime: res.verifyTime || r.verifyTime,
verifyPhoto: res.verifyPhoto || r.verifyPhoto,
cardInstruction: res.cardInstruction || r.cardInstruction || '',
cardModelId: res.cardModelId || r.cardModelId || '',
}
}
} catch (_e) {
detailRecord.value = r detailRecord.value = r
}
detailPopup.value?.open() detailPopup.value?.open()
} }
function onRefresh() { async function onRefresh() {
refreshing.value = true refreshing.value = true
setTimeout(() => { refreshing.value = false }, 800) pageNo.value = 1
await fetchRecords()
refreshing.value = false
} }
function onLoadMore() { async function onLoadMore() {
if (loading.value || noMore.value) return if (loading.value || noMore.value) return
loading.value = true pageNo.value++
setTimeout(() => { await fetchRecords(true)
loading.value = false
noMore.value = true
}, 800)
} }
function goReserve() { function goReserve() {
@@ -392,9 +509,8 @@ function previewPhoto(url: string) {
border-radius: 8rpx; border-radius: 8rpx;
&--pending { background: #e8f3ff; } &--pending { background: #e8f3ff; }
&--used { background: #f0fff4; }
&--cancelled { background: $uv-bg-color; } &--cancelled { background: $uv-bg-color; }
&--auto_used { background: #fff8e8; } &--used { background: #f0fff4; }
} }
.status-tag__text { .status-tag__text {
@@ -404,9 +520,8 @@ function previewPhoto(url: string) {
} }
.status-tag--pending .status-tag__text { color: $uv-primary; } .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--cancelled .status-tag__text { color: $color-status-cancel; }
.status-tag--auto_used .status-tag__text { color: $color-status-pending; } .status-tag--used .status-tag__text { color: $color-status-success; }
.record-card-info { .record-card-info {
display: flex; display: flex;
@@ -511,7 +626,7 @@ function previewPhoto(url: string) {
.qr-img { width: 300rpx; height: 300rpx; } .qr-img { width: 300rpx; height: 300rpx; }
.code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; margin-top: 24rpx; } .code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; }
/* ===== Cancel Modal ===== */ /* ===== Cancel Modal ===== */
.cancel-modal { .cancel-modal {
+162 -35
View File
@@ -1,13 +1,15 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 成功图标区域 --> <!-- 状态图标区域 -->
<view class="success-header"> <view class="success-header">
<view class="success-icon"> <view class="success-icon" :class="`success-icon--${status}`">
<view class="success-circle" /> <view v-if="status === 1" class="success-check" />
<view class="success-check" /> <view v-else-if="status === 2" class="fail-cross" />
<view v-else-if="status === 3" class="cancel-icon" />
<view v-else-if="status === 4" class="used-icon" />
</view> </view>
<text class="success-title">预约成功</text> <text class="success-title">{{ statusTitle }}</text>
<text class="success-desc">请携带本人身份证+入园码按时入园</text> <text class="success-desc">{{ statusDesc }}</text>
</view> </view>
<!-- 预约信息卡片 --> <!-- 预约信息卡片 -->
@@ -36,25 +38,25 @@
<text class="info-label">预约编号</text> <text class="info-label">预约编号</text>
<text class="info-value">{{ reserveId }}</text> <text class="info-value">{{ reserveId }}</text>
</view> </view>
<view class="info-row info-row--last"> <view v-if="status === 1" class="info-row info-row--last">
<text class="info-label">入园凭证</text> <text class="info-label">入园凭证</text>
<view class="code-btn" @tap="showCode"> <view class="code-btn" @tap="showCode">
<text class="code-btn__text">查看入园码</text> <text class="code-btn__text">查看入园码</text>
</view> </view>
</view> </view>
<view v-else class="info-row info-row--last">
<text class="info-label">预约状态</text>
<text class="info-value">{{ statusLabel }}</text>
</view>
</view> </view>
<!-- 重要提醒 --> <!-- 重要提醒 -->
<view class="warn-card"> <view class="warn-card" v-if="cardInstruction">
<text class="warn-title">重要预约规则</text> <text class="warn-title">重要预约规则</text>
<view class="warn-list"> <view class="warn-list">
<view class="warn-item"> <view class="warn-item" v-for="(item, idx) in cardInstruction.split('\n')" :key="idx">
<view class="warn-dot" /> <view class="warn-dot" />
<text class="warn-text">预约当日无法取消,如需改期请至少提前1天操作</text> <text class="warn-text">{{ item }}</text>
</view>
<view class="warn-item">
<view class="warn-dot" />
<text class="warn-text">爽约未取消将扣除年卡使用次数,无法恢复</text>
</view> </view>
</view> </view>
</view> </view>
@@ -74,7 +76,7 @@
<view class="code-modal"> <view class="code-modal">
<text class="code-modal__title">入园码</text> <text class="code-modal__title">入园码</text>
<view class="qr-box"> <view class="qr-box">
<image class="qr-img" src="/static/qr-demo.png" mode="aspectFit" /> <image v-if="codeSrc" class="qr-img" :src="codeSrc" mode="aspectFit" />
</view> </view>
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text> <text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
</view> </view>
@@ -85,24 +87,56 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { onLoad, onBackPress } from '@dcloudio/uni-app' import { onLoad, onBackPress } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
const reserveId = ref('YP26062601') const status = ref(1)
const scenicName = ref('武功山金顶景区') const statusTitle = ref('预约成功')
const reserveDate = ref('2026-06-27(周六)') const statusDesc = ref('请携带本人身份证+入园码按时入园')
const statusLabel = ref('待入园')
const reserveId = ref('')
const scenicName = ref('')
const reserveDate = ref('')
const reserveSession = ref('') const reserveSession = ref('')
const userName = ref('张三') const userName = ref('')
const maskedId = ref('360302****1234') const maskedId = ref('')
const cardInstruction = ref('')
const codeSrc = ref('')
const codePopup = ref<any>(null) const codePopup = ref<any>(null)
const cardNumber = ref('')
const cardModelId = ref('')
onLoad((query: any) => { onLoad(async (query: any) => {
if (query?.reserveId) reserveId.value = query.reserveId const appointId = query?.appointId
if (query?.date) { if (!appointId) return
const d = new Date(query.date) const phone = (uni.getStorageSync('phone') as string) || ''
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'] cardNumber.value = query?.cardNumber || ''
reserveDate.value = `${query.date}(${weekNames[d.getDay()]})` cardModelId.value = query?.cardModelId || ''
try {
const res = await post<any>('/mpReserve/getAppointmentDetail', { phone, appointId })
if (res) {
const s = Number(res.status) || 1
status.value = s
const statusMap: Record<number, { title: string; desc: string; label: string }> = {
1: { title: '预约成功', desc: '请携带本人身份证+入园码按时入园', label: '待入园' },
2: { title: '预约失败', desc: '预约申请未通过,请重新预约', label: '预约失败' },
3: { title: '已取消', desc: '该预约已被取消', label: '已取消' },
4: { title: '已入园', desc: '已成功核销入园', label: '已入园' },
}
const info = statusMap[s] || statusMap[1]
statusTitle.value = info.title
statusDesc.value = info.desc
statusLabel.value = info.label
reserveId.value = res.id || appointId
scenicName.value = res.resourceName || ''
reserveDate.value = res.appTime || ''
reserveSession.value = res.sessionTime || ''
userName.value = res.name || ''
maskedId.value = res.identityNo ? `${res.identityNo.slice(0, 6)}****${res.identityNo.slice(-4)}` : ''
cardInstruction.value = res.cardInstruction || ''
}
} catch (_e) {
} }
if (query?.scenic) scenicName.value = decodeURIComponent(query.scenic)
if (query?.session) reserveSession.value = decodeURIComponent(query.session)
}) })
// 拦截返回,跳转预约记录页 // 拦截返回,跳转预约记录页
@@ -111,8 +145,42 @@ onBackPress(() => {
return true return true
}) })
function showCode() { async function showCode() {
try {
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${reserveId.value}`)
if (res.qrType == 1) {
codeSrc.value = 'data:image/png;base64,' + res.qrCode
codePopup.value?.open() codePopup.value?.open()
} else if (res.qrType == 2) {
codeSrc.value = res.qrUrl
codePopup.value?.open()
} else {
await fetchQrByCard()
}
} catch (_e) {
await fetchQrByCard()
}
}
async function fetchQrByCard() {
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/qrAndUserInfo`, {
cardNum: cardNumber.value,
channel: 'Channel_SZH',
mobile: phone,
scenicId: '',
thirdId: cardModelId.value,
})
if (res.qrCode) {
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else {
uni.showToast({ icon: 'none', title: '暂无核销码' })
}
} catch (_e) {
uni.showToast({ icon: 'none', title: '获取入园码失败' })
}
} }
function goRecord() { function goRecord() {
@@ -135,7 +203,7 @@ function goHome() {
font-family: $font-family-body; font-family: $font-family-body;
} }
/* ===== 成功图标 ===== */ /* ===== 状态图标 ===== */
.success-header { .success-header {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@@ -147,13 +215,13 @@ function goHome() {
width: 120rpx; width: 120rpx;
height: 120rpx; height: 120rpx;
position: relative; position: relative;
}
.success-circle {
width: 120rpx;
height: 120rpx;
border-radius: 50%; border-radius: 50%;
background: rgba(60, 156, 255, 0.1); background: rgba(60, 156, 255, 0.1);
&--1 { background: rgba(60, 156, 255, 0.1); }
&--2 { background: rgba(245, 63, 63, 0.1); }
&--3 { background: rgba(153, 153, 153, 0.1); }
&--4 { background: rgba(60, 179, 113, 0.1); }
} }
.success-check { .success-check {
@@ -167,6 +235,65 @@ function goHome() {
left: 38rpx; left: 38rpx;
} }
.fail-cross {
width: 48rpx;
height: 48rpx;
position: absolute;
top: 36rpx;
left: 36rpx;
&::before, &::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 48rpx;
height: 6rpx;
background: #f53f3f;
border-radius: 3rpx;
}
&::before { transform: translate(-50%, -50%) rotate(45deg); }
&::after { transform: translate(-50%, -50%) rotate(-45deg); }
}
.cancel-icon {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #999;
position: absolute;
top: 54rpx;
left: 54rpx;
&::before, &::after {
content: '';
position: absolute;
background: #999;
border-radius: 2rpx;
}
&::before {
width: 4rpx;
height: 32rpx;
top: -24rpx;
left: 4rpx;
}
&::after {
width: 24rpx;
height: 4rpx;
top: 4rpx;
left: -6rpx;
}
}
.used-icon {
width: 48rpx;
height: 24rpx;
border-left: 6rpx solid #3CB371;
border-bottom: 6rpx solid #3CB371;
transform: rotate(-45deg);
position: absolute;
top: 40rpx;
left: 38rpx;
}
.success-title { .success-title {
font-family: $font-family-body; font-family: $font-family-body;
font-size: $font-size-title; font-size: $font-size-title;
+1 -1
View File
@@ -122,7 +122,7 @@
{ {
"path": "pages/reserve/success", "path": "pages/reserve/success",
"style": { "style": {
"navigationBarTitleText": "预约成功" "navigationBarTitleText": "预约详情"
} }
}, },
{ {
+74 -46
View File
@@ -1,18 +1,13 @@
<template> <template>
<view class="page"> <view class="page">
<!-- Hero: 运营Banner轮播 --> <!-- Hero: 运营Banner轮播 -->
<view class="hero animate-fade-in-up" @click="goUnionBuy()"> <view class="hero animate-fade-in-up">
<swiper class="hero-swiper" autoplay circular :interval="5000"> <swiper class="hero-swiper" autoplay circular :interval="5000">
<swiper-item> <swiper-item v-for="banner in banners" :key="banner.id" @click="onBannerClick(banner)">
<image <image class="hero-bg" :src="banner.imageUrl" mode="aspectFill" />
class="hero-bg"
src="https://aliyunoss.hntgov.net/image/2026/04/14/d2bf03167e484ba59f7c2d62879ad07f.jpg"
mode="aspectFill"
/>
</swiper-item> </swiper-item>
</swiper> </swiper>
</view> </view>
<scroll-view scroll-y class="main-scroll"> <scroll-view scroll-y class="main-scroll">
<!-- 公告栏 --> <!-- 公告栏 -->
<view v-if="announcements.length" class="notice animate-fade-in-up stagger-1"> <view v-if="announcements.length" class="notice animate-fade-in-up stagger-1">
@@ -31,9 +26,9 @@
@touchstart="onNoticeTouchStart" @touchstart="onNoticeTouchStart"
@touchend="onNoticeTouchEnd" @touchend="onNoticeTouchEnd"
> >
<swiper-item v-for="item in announcements" :key="item.id"> <swiper-item v-for="item in displayNotices" :key="item.id">
<view class="notice-item" @click="openNotice(item)"> <view class="notice-item" @click="openNotice(item)">
<text class="notice-text">{{ item.content }}</text> <text class="notice-text">{{ item.title }}</text>
</view> </view>
</swiper-item> </swiper-item>
</swiper> </swiper>
@@ -140,17 +135,73 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed, onMounted } from 'vue'
import { mockScenicList } from '@/mock/scenic'
import { mockNotices } from '@/mock/notice'
import AnnouncementModal from '../../components/announcement-modal.vue' import AnnouncementModal from '../../components/announcement-modal.vue'
import { post } from '../../api/request'
import type { BannerItem } from '../../api/types'
interface ScenicItem {
id: number
shopId: number
name: string
price: string
benefit: string
region: string
image: string
}
const banners = ref<BannerItem[]>([])
const announcements = ref<(BannerItem & { typeLabel: string })[]>([])
const hotSpots = ref<ScenicItem[]>([])
const showAllNotices = ref(false)
const displayNotices = computed(() =>
showAllNotices.value ? announcements.value : announcements.value.slice(0, 2)
)
onMounted(async () => {
try {
const [bannerData, noticeData, spotsData] = await Promise.all([
post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'BANNER' }),
post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'NOTICE' }),
post<any>('/mp-api/mpScenic/queryScenicList', { channel: 'pingxiang', pageNo: 1, pageSize: 6, phone: uni.getStorageSync('phone') || '' }),
])
banners.value = bannerData || []
announcements.value = (noticeData || []).map(n => ({
...n,
typeLabel: '公告',
}))
const list = spotsData?.list || spotsData || []
hotSpots.value = (Array.isArray(list) ? list : []).slice(0, 6).map((s: any) => ({
id: s.id || s.scenicId,
shopId: s.shopId || s.id || s.scenicId,
name: s.name || s.scenicName,
price: ((s.price || s.ticketPrice || 0) / 100).toFixed(0),
benefit: s.benefit || `免门票`,
region: s.region?.name || s.region || s.city || '',
image: s.image || s.pic || s.coverImage || '',
}))
} catch {
// 静默失败
}
})
function onBannerClick(banner: BannerItem) {
if (!banner.linkUrl) return
if (banner.linkUrl.startsWith('http')) {
// 外部链接
} else if (banner.linkUrl.startsWith('/')) {
uni.navigateTo({ url: banner.linkUrl })
} else {
uni.navigateTo({ url: `/${banner.linkUrl}` })
}
}
/** 金刚区操作项 */ /** 金刚区操作项 */
const quickActions = [ const quickActions = [
{ label: '我要绑卡', icon: '/static/icon/card.png' }, { label: '我要绑卡', icon: 'https://aliyunoss.hntgov.net/pxmall/card.png' },
{ label: '我要预约', icon: '/static/icon/reserve.png' }, { label: '我要预约', icon: 'https://aliyunoss.hntgov.net/pxmall/reserve.png' },
{ label: '我要入园', icon: '/static/icon/check.png' }, { label: '我要入园', icon: 'https://aliyunoss.hntgov.net/pxmall/check.png' },
{ label: '常见问题', icon: '/static/icon/faq.png' }, { label: '常见问题', icon: 'https://aliyunoss.hntgov.net/pxmall/faq.png' },
] ]
/** 处理金刚区点击 */ /** 处理金刚区点击 */
@@ -174,19 +225,6 @@ function onQuickAction(item: { label: string }) {
uni.showToast({ title: `${item.label}功能开发中`, icon: 'none' }) uni.showToast({ title: `${item.label}功能开发中`, icon: 'none' })
} }
/** 公告数据(mock,后续接 API) */
const noticeTypeMap: Record<number, { type: string; typeLabel: string }> = {
1: { type: 'important', typeLabel: '重要' },
2: { type: 'scenic', typeLabel: '景区' },
3: { type: 'promo', typeLabel: '福利' },
}
const announcements = ref(mockNotices.slice(0, 3).map(n => ({
...n,
type: noticeTypeMap[n.id]?.type || 'scenic',
typeLabel: noticeTypeMap[n.id]?.typeLabel || '公告',
})))
/** 公告轮播状态 */ /** 公告轮播状态 */
const swiperAutoplay = ref(true) const swiperAutoplay = ref(true)
const currentNoticeIndex = ref(0) const currentNoticeIndex = ref(0)
@@ -205,9 +243,9 @@ const noticeModalTitle = ref('公告')
const noticeModalBody = ref('') const noticeModalBody = ref('')
/** 打开公告详情弹窗 */ /** 打开公告详情弹窗 */
function openNotice(item: { typeLabel: string; content: string; body?: string }) { function openNotice(item: BannerItem & { typeLabel: string }) {
noticeModalTitle.value = `${item.typeLabel}公告` noticeModalTitle.value = `${item.typeLabel}`
noticeModalBody.value = item.body || item.content noticeModalBody.value = item.content || ''
showNoticeModal.value = true showNoticeModal.value = true
} }
@@ -216,19 +254,9 @@ function goMoreNotice() {
uni.navigateTo({ url: '/packages/content/pages/notice-list' }) uni.navigateTo({ url: '/packages/content/pages/notice-list' })
} }
/** 热门景点数据 */
const hotSpots = mockScenicList.slice(0, 6).map(s => ({
id: s.id,
name: s.name,
price: s.price,
benefit: `免门票${s.freeCount}次`,
region: s.region,
image: s.image,
}))
/** 跳转到年卡购买页 */ /** 跳转到年卡购买页 */
function goCardDetail() { function goCardDetail() {
uni.navigateTo({ url: '/packages/user/pages/buy' }) uni.navigateTo({ url: '/packages/user/pages/buy?goodsId=821926819630853' })
} }
/** 跳转到联名卡购买页 */ /** 跳转到联名卡购买页 */
@@ -237,8 +265,8 @@ function goUnionBuy() {
} }
/** 跳转到景点详情 */ /** 跳转到景点详情 */
function goSpot(spot: { id: number }) { function goSpot(spot: ScenicItem) {
uni.navigateTo({ url: `/packages/content/pages/scenic-detail?id=${spot.id}` }) uni.navigateTo({ url: `/packages/content/pages/scenic-detail?shopId=${spot.shopId}` })
} }
/** 跳转到景点列表 */ /** 跳转到景点列表 */
+11 -10
View File
@@ -95,17 +95,17 @@ function openPrivacy() {
uni.navigateTo({ url: '/pages/login/privacy' }) uni.navigateTo({ url: '/pages/login/privacy' })
} }
/** 微信一键登录(获取手机号后调后端登录接口) */ /** 微信一键登录(获取手机号后调后端 OAuth2 登录接口) */
async function handleLogin(e: { detail: { errMsg: string; code?: string } }) { async function handleLogin(e: AnyObject) {
if (e.detail.errMsg !== 'getPhoneNumber:ok') { if (e.detail.errMsg !== 'getPhoneNumber:ok') {
return return
} }
loading.value = true loading.value = true
try { try {
const phoneCode = e.detail.code const { encryptedData, iv } = e.detail
if (!phoneCode) { if (!encryptedData || !iv) {
showToast('手机号获取失败,请重试') showToast('加密数据获取失败')
return return
} }
@@ -113,19 +113,20 @@ async function handleLogin(e: { detail: { errMsg: string; code?: string } }) {
uni.login({ provider: 'weixin', success: resolve, fail: reject }) uni.login({ provider: 'weixin', success: resolve, fail: reject })
}) })
if (!loginRes || !loginRes.code) { if (!loginRes?.code) {
showToast('微信登录失败,请重试') showToast('微信登录失败')
return return
} }
await userStore.wxLogin({ await userStore.wxLogin({
loginCode: loginRes.code, code: loginRes.code,
phoneCode: phoneCode encryptedData,
iv
}) })
showToast('登录成功', 'success') showToast('登录成功', 'success')
setTimeout(() => { setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' }) uni.navigateBack()
}, 1000) }, 1000)
} catch (err: unknown) { } catch (err: unknown) {
showToast(err instanceof Error ? err.message : '登录失败') showToast(err instanceof Error ? err.message : '登录失败')
+7 -2
View File
@@ -13,7 +13,7 @@
<image class="avatar" src="/static/logo.png" mode="aspectFill" /> <image class="avatar" src="/static/logo.png" mode="aspectFill" />
</view> </view>
<view class="user-info"> <view class="user-info">
<text class="nickname">{{ userStore.userInfo?.nickname || '未登录' }}</text> <text class="nickname">{{ userStore.isLogin ? formatPhone(userStore.userInfo?.phone) : '未登录' }}</text>
<text class="user-desc" v-if="userStore.isLogin">欢迎使用爱上萍乡</text> <text class="user-desc" v-if="userStore.isLogin">欢迎使用爱上萍乡</text>
<text class="user-desc user-desc--link" v-else @click="goLogin">点击登录 →</text> <text class="user-desc user-desc--link" v-else @click="goLogin">点击登录 →</text>
</view> </view>
@@ -85,7 +85,6 @@ function handleLogout() {
if (res.confirm) { if (res.confirm) {
userStore.logout() userStore.logout()
uni.showToast({ title: '已退出', icon: 'success' }) uni.showToast({ title: '已退出', icon: 'success' })
setTimeout(() => { uni.navigateTo({ url: '/pages/login/login' }) }, 1000)
} }
} }
}) })
@@ -105,6 +104,12 @@ function goOrders() { uni.navigateTo({ url: '/packages/user/pages/order/list' })
/** 拨打客服热线 */ /** 拨打客服热线 */
function callService() { uni.makePhoneCall({ phoneNumber: '400-123-0101' }) } function callService() { uni.makePhoneCall({ phoneNumber: '400-123-0101' }) }
/** 手机号脱敏显示(138****5678) */
function formatPhone(phone?: string) {
if (!phone) return '已登录'
return phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2')
}
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
+237 -73
View File
@@ -1,7 +1,26 @@
<template> <template>
<view class="page"> <view class="page">
<!-- 未登录状态 -->
<view class="empty-state" v-if="notLoggedIn">
<view class="empty-icon-card">
<view class="empty-icon-stripe" />
<view class="empty-icon-stripe short" />
</view>
<text class="empty-text">请先登录</text>
<text class="empty-desc">登录后可查看您的旅游卡</text>
<view class="empty-btn" @click="goLogin">
<text class="empty-btn-text">立即登录</text>
</view>
</view>
<!-- 加载中 -->
<view class="loading-state" v-else-if="loading">
<view class="loading-spinner" />
<text class="loading-text">加载中...</text>
</view>
<!-- 未持卡状态 --> <!-- 未持卡状态 -->
<view class="empty-state" v-if="!hasCard"> <view class="empty-state" v-else-if="!hasCard && loaded">
<view class="empty-icon-card"> <view class="empty-icon-card">
<view class="empty-icon-stripe" /> <view class="empty-icon-stripe" />
<view class="empty-icon-stripe short" /> <view class="empty-icon-stripe short" />
@@ -24,23 +43,28 @@
class="card-swiper" class="card-swiper"
:current="currentCardIndex" :current="currentCardIndex"
@change="onCardChange" @change="onCardChange"
previous-margin="24rpx" circular
next-margin="24rpx" previous-margin="60rpx"
next-margin="60rpx"
> >
<swiper-item v-for="(card, index) in userCards" :key="card.id"> <swiper-item v-for="(card, index) in userCards" :key="card.cardNo">
<view class="card-face"> <view
class="card-face"
:class="{ 'card-face--active': index === currentCardIndex }"
>
<view class="card-face-inner">
<image <image
class="card-bg" class="card-bg"
src="https://hn12301.oss-cn-shenzhen.aliyuncs.com/hn12301/%E5%88%9D%E5%BF%83%E6%BA%90%20%E6%B9%98%E9%84%82%E8%B5%A3%E6%96%87%E6%97%85%E4%B8%80%E5%8D%A1%E9%80%9A_1777447994254.png" :src="card.cardModelPic || 'https://hn12301.oss-cn-shenzhen.aliyuncs.com/hn12301/%E5%88%9D%E5%BF%83%E6%BA%90%20%E6%B9%98%E9%84%82%E8%B5%A3%E6%96%87%E6%97%85%E4%B8%80%E5%8D%A1%E9%80%9A_1777447994254.png'"
mode="widthFix" mode="aspectFill"
/> />
<!-- 多卡指示点(卡内右下角) --> </view>
<view class="card-dots" v-if="userCards.length > 1"> <view class="card-dots" v-if="userCards.length > 1">
<view <view
v-for="(_, idx) in userCards" v-for="(_, idx) in userCards"
:key="idx" :key="idx"
class="card-dot" class="card-dot"
:class="{ active: idx === currentCardIndex }" :class="{ active: idx === index }"
/> />
</view> </view>
</view> </view>
@@ -59,12 +83,12 @@
</view> </view>
<!-- 入园码二维码 --> <!-- 入园码二维码 -->
<view class="qrcode-section"> <view class="qrcode-section" v-if="!showFaceModal && !isLeaving">
<text class="qrcode-title">入园码</text> <text class="qrcode-title">入园码</text>
<view class="qrcode-box"> <view class="qrcode-box">
<image <image
class="qrcode-img" class="qrcode-img"
src="/static/qr-demo.png" :src="userInfoData?.qrCode || '/static/qr-demo.png'"
mode="aspectFit" mode="aspectFit"
/> />
</view> </view>
@@ -80,7 +104,7 @@
<view class="avatar-wrap" @click="previewAvatar"> <view class="avatar-wrap" @click="previewAvatar">
<image <image
class="holder-avatar" class="holder-avatar"
src="https://hn12301.oss-cn-shenzhen.aliyuncs.com/hn12301/1782285689132_1782285690033.png" :src="userInfoData?.img || '/static/logo.png'"
mode="aspectFill" mode="aspectFill"
/> />
<view class="avatar-zoom"> <view class="avatar-zoom">
@@ -88,124 +112,190 @@
</view> </view>
</view> </view>
<view class="holder-main"> <view class="holder-main">
<text class="holder-name">{{ currentCard.userName }}</text> <text class="holder-name">{{ userInfoData?.userName || '-' }}</text>
<text class="holder-tag">已激活</text>
</view> </view>
</view> </view>
<view class="holder-details"> <view class="holder-details">
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">卡种名称</text> <text class="detail-label">卡种名称</text>
<text class="detail-value">{{ currentCard.cardName }}</text> <text class="detail-value">{{ currentCard.cardModelName }}</text>
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">旅游卡卡号</text> <text class="detail-label">旅游卡卡号</text>
<view class="detail-value-wrap"> <view class="detail-value-wrap">
<text class="detail-value">PX00002489</text> <text class="detail-value">{{ currentCard.cardNo }}</text>
<text class="copy-btn" @click="copyCardNo">复制</text> <text class="copy-btn" @click="copyCardNo">复制</text>
</view> </view>
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">有效期至</text> <text class="detail-label">有效期至</text>
<text class="detail-value">{{ currentCard.expireDate }}</text> <text class="detail-value">{{ currentCard.validityTime }}</text>
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">证件类型</text> <text class="detail-label">证件类型</text>
<text class="detail-value">{{ currentCard.idType }}</text> <text class="detail-value">{{ userInfoData?.idType || '-' }}</text>
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">证件号码</text> <text class="detail-label">证件号码</text>
<text class="detail-value">{{ currentCard.idNumber }}</text> <text class="detail-value">{{ userInfoData?.idNo || '-' }}</text>
</view> </view>
<view class="detail-row"> <view class="detail-row">
<text class="detail-label">手机号码</text> <text class="detail-label">手机号码</text>
<text class="detail-value">{{ currentCard.phone }}</text> <text class="detail-value">{{ userInfoData?.mobile || userStore.userInfo?.phone || '-' }}</text>
</view> </view>
</view> </view>
</view> </view>
<!-- 底部占位(滑到底时与tabBar的间隔) --> <!-- 底部占位(滑到底时与tabBar的间隔) -->
<view style="height: 48rpx;"/> <view style="height: 48rpx;"/>
</scroll-view> </scroll-view>
<!-- 人脸绑定提醒弹窗 -->
<ConfirmModal
v-model="showFaceModal"
title="系统安全升级提醒"
:content="'为保障您的旅游卡信息安全、规范入园使用,平台已启动人脸核验升级。\n根据规则要求,使用旅游卡前需完成人脸绑定,核验仅用于身份核实,人脸信息严格保密,不会挪作他用。\n请立即完成人脸绑定,方可继续使用全部功能。'"
confirmText="前往人脸绑定"
@confirm="onFaceModalConfirm"
/>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, onUnmounted } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useUserStore } from '../../store/user' import { useUserStore } from '../../store/user'
import { getUserCardList, getQrAndUserInfo } from '../../api/card'
import type { UserCardListItem, QrAndUserInfoResult } from '../../api/types'
import ConfirmModal from '../../components/confirm-modal.vue'
const userStore = useUserStore() const userStore = useUserStore()
interface UserCardData { const notLoggedIn = ref(false)
id: string const loading = ref(false)
cardName: string const loaded = ref(false)
cardNo: string const userCards = ref<UserCardListItem[]>([])
userName: string const userInfoData = ref<QrAndUserInfoResult | null>(null)
expireDate: string const showFaceModal = ref(false)
idType: string const isLeaving = ref(false)
idNumber: string
phone: string
}
const userCards = ref<UserCardData[]>([
{
id: '1',
cardName: '赣湘全域旅游年卡',
cardNo: 'HN2026-0001-8888',
userName: '张三',
expireDate: '2027.06.24',
idType: '身份证',
idNumber: '360102199001011234',
phone: '13812345678',
},
{
id: '2',
cardName: '超级玩家卡',
cardNo: 'HN2026-0002-6666',
userName: '张三',
expireDate: '2027.06.24',
idType: '身份证',
idNumber: '360102199001011234',
phone: '13812345678',
},
])
const hasCard = computed(() => userCards.value.length > 0) const hasCard = computed(() => userCards.value.length > 0)
const currentCardIndex = ref(0) const currentCardIndex = ref(0)
const currentCard = computed(() => userCards.value[currentCardIndex.value]) const currentCard = computed(() => userCards.value[currentCardIndex.value])
const countdown = ref(60) const countdown = ref(20)
let timer: ReturnType<typeof setInterval> | null = null let timer: ReturnType<typeof setInterval> | null = null
/** 获取用户年卡列表 */
async function fetchCards() {
const mobile = uni.getStorageSync('phone') as string
if (!mobile) {
loaded.value = true
return
}
loading.value = true
try {
const list = (await getUserCardList({ mobile, saleChannel: 'xgh' })) || []
userCards.value = list
const firstCard = list[0]
if (firstCard?.needFaceImgFlag) {
showFaceModal.value = true
}
if (list.length > 0) {
fetchUserInfo(firstCard)
}
} catch {
userCards.value = []
} finally {
loaded.value = true
loading.value = false
}
}
/** 刷新当前卡的人脸绑定状态(从接口获取最新数据) */
async function refreshNeedFace() {
const mobile = uni.getStorageSync('phone') as string
if (!mobile) return
try {
const list = (await getUserCardList({ mobile, saleChannel: 'xgh' })) || []
userCards.value = list
const current = list[currentCardIndex.value]
if (current) {
fetchUserInfo(current)
if (current.needFaceImgFlag) {
showFaceModal.value = true
}
}
} catch {
// 静默失败,沿用旧数据
}
}
/** 获取持卡人信息和入园码 */
async function fetchUserInfo(card: UserCardListItem) {
const mobile = uni.getStorageSync('phone') as string
if (!mobile || !card.cardNo || !card.thirdId) return
try {
const res = await getQrAndUserInfo({
cardNum: card.cardNo,
thirdId: card.thirdId,
mobile,
channel: 'Channel_SZH',
scenicId: '',
})
userInfoData.value = res
} catch {
userInfoData.value = null
}
}
function onCardChange(e: any) { function onCardChange(e: any) {
currentCardIndex.value = e.detail.current currentCardIndex.value = e.detail.current
resetCountdown() resetCountdown()
const card = userCards.value[currentCardIndex.value]
if (card) {
fetchUserInfo(card)
if (card.needFaceImgFlag) {
showFaceModal.value = true
}
}
} }
function previewAvatar() { function previewAvatar() {
if (!userInfoData.value?.img) return
uni.previewImage({ uni.previewImage({
urls: ['https://hn12301.oss-cn-shenzhen.aliyuncs.com/hn12301/1782285689132_1782285690033.png'], urls: [userInfoData.value.img],
current: 0, current: 0,
}) })
} }
function resetCountdown() { function resetCountdown() {
countdown.value = 60 countdown.value = 20
} }
function startTimer() { function startTimer() {
timer = setInterval(() => { timer = setInterval(() => {
countdown.value-- countdown.value--
if (countdown.value <= 0) { if (countdown.value <= 0) {
const card = currentCard.value
if (card) fetchUserInfo(card)
resetCountdown() resetCountdown()
} }
}, 1000) }, 1000)
} }
function goLogin() {
uni.navigateTo({ url: '/pages/login/login' })
}
function goBuyCard() { function goBuyCard() {
uni.navigateTo({ url: '/packages/user/pages/card/list' }) uni.navigateTo({ url: '/packages/user/pages/card/list' })
} }
function copyCardNo() { function copyCardNo() {
uni.setClipboardData({ uni.setClipboardData({
data: 'PX00002489', data: currentCard.value.cardNo,
success: () => { uni.showToast({ title: '已复制', icon: 'success' }) }, success: () => { uni.showToast({ title: '已复制', icon: 'success' }) },
}) })
} }
@@ -214,11 +304,36 @@ function goBindCard() {
uni.navigateTo({ url: '/packages/user/pages/bind' }) uni.navigateTo({ url: '/packages/user/pages/bind' })
} }
const idTypeLabelMap: Record<string, string> = {
'身份证': '1',
'护照': '2',
'港澳居民来往内地通行证': '3',
'台湾居民来往大陆通行证': '4',
}
function onFaceModalConfirm() {
isLeaving.value = true
showFaceModal.value = false
const card = currentCard.value
const info = userInfoData.value
if (!card || !info) {
goBindCard()
return
}
const idTypeValue = idTypeLabelMap[info.idType || ''] || '1'
uni.navigateTo({
url: `/packages/user/pages/bind?faceBind=1&cardNo=${card.cardNo}&userName=${info.userName || ''}&mobile=${info.mobile || ''}&idType=${idTypeValue}&idNo=${info.idNo || ''}`
})
}
const featureActions = [ const featureActions = [
{ label: '我要绑定', icon: '/static/icon/card.png' }, { label: '我要绑定', icon: 'https://aliyunoss.hntgov.net/pxmall/card.png' },
{ label: '我要预约', icon: '/static/icon/reserve.png' }, { label: '我要预约', icon: 'https://aliyunoss.hntgov.net/pxmall/reserve.png' },
{ label: '使用记录', icon: '/static/icon/record.png' }, { label: '使用记录', icon: 'https://aliyunoss.hntgov.net/pxmall/record.png' },
{ label: '使用说明', icon: '/static/icon/scenic.png' }, { label: '使用说明', icon: 'https://aliyunoss.hntgov.net/pxmall/scenic.png' },
] ]
function onFeatureClick(item: { label: string }) { function onFeatureClick(item: { label: string }) {
@@ -227,7 +342,10 @@ function onFeatureClick(item: { label: string }) {
return return
} }
if (item.label === '我要预约') { if (item.label === '我要预约') {
uni.navigateTo({ url: '/packages/content/pages/scenic-list' }) const url = currentCard.value?.thirdId
? `/packages/content/pages/scenic-list?cardModelId=${currentCard.value.thirdId}`
: '/packages/content/pages/scenic-list'
uni.navigateTo({ url })
return return
} }
if (item.label === '使用说明') { if (item.label === '使用说明') {
@@ -241,7 +359,20 @@ function onFeatureClick(item: { label: string }) {
uni.showToast({ title: `${item.label}功能开发中`, icon: 'none' }) uni.showToast({ title: `${item.label}功能开发中`, icon: 'none' })
} }
onMounted(() => { startTimer() }) onShow(() => {
notLoggedIn.value = !userStore.isLogin
if (!userStore.isLogin) return
isLeaving.value = false
if (loaded.value) {
refreshNeedFace()
} else {
fetchCards()
}
if (!timer) startTimer()
})
onHide(() => {
if (timer) { clearInterval(timer); timer = null }
})
onUnmounted(() => { if (timer) clearInterval(timer) }) onUnmounted(() => { if (timer) clearInterval(timer) })
</script> </script>
@@ -250,6 +381,22 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
.page { min-height: 100vh; background: $color-background; font-family: $font-family-body; } .page { min-height: 100vh; background: $color-background; font-family: $font-family-body; }
.scroll-content { height: 100vh; } .scroll-content { height: 100vh; }
/* === 加载中 === */
.loading-state {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 100vh; gap: 24rpx;
}
.loading-spinner {
width: 48rpx; height: 48rpx;
border: 4rpx solid #e0e0e0;
border-top-color: $uv-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
.loading-text { font-size: $font-size-base; color: $uv-tips-color; }
@keyframes spin { to { transform: rotate(360deg); } }
/* === 空状态 === */ /* === 空状态 === */
.empty-state { .empty-state {
display: flex; flex-direction: column; align-items: center; display: flex; flex-direction: column; align-items: center;
@@ -280,18 +427,35 @@ onUnmounted(() => { if (timer) clearInterval(timer) })
.empty-btn-secondary:active { opacity: 0.7; } .empty-btn-secondary:active { opacity: 0.7; }
.empty-btn-secondary-text { font-size: $font-size-base; color: $uv-content-color; } .empty-btn-secondary-text { font-size: $font-size-base; color: $uv-content-color; }
/* === 卡面展示区域 === */ /* === 卡面展示区域(3D Coverflow) === */
.card-swiper-wrap { margin: 20rpx 20rpx 20rpx; overflow: visible; } .card-swiper-wrap { margin: 14rpx 14rpx 14rpx; overflow: visible; perspective: 1200rpx; }
.card-swiper { height: 440rpx; } .card-swiper { height: 360rpx; }
.card-face { .card-face {
margin: 0 12rpx; height: 340rpx;
border-radius: 24rpx; margin: 0 8rpx;
position: relative; overflow: hidden; border-radius: 16rpx;
background: #222222; background: #fff;
display: flex; align-items: center; justify-content: center; position: relative;
transform: scale(0.88);
opacity: 0.6;
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
will-change: transform, opacity;
}
.card-face--active {
transform: scale(1);
opacity: 1;
box-shadow: 0 8rpx 32rpx rgba(15, 23, 42, 0.12);
}
.card-face-inner {
width: 100%;
height: 100%;
border-radius: 16rpx;
overflow: hidden;
} }
.card-bg { .card-bg {
width: 100%; display: block; width: 100%;
height: 100%;
display: block;
} }
/* 指示点(卡内右下角) */ /* 指示点(卡内右下角) */
Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.9 KiB

+22 -23
View File
@@ -1,53 +1,52 @@
import { defineStore } from 'pinia' import { defineStore } from 'pinia'
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { getToken, setToken, removeToken } from '../api/request' import { getToken, setToken, removeToken } from '../api/request'
import { wxLogin as apiWxLogin, getUserInfo as apiGetUserInfo } from '../api/user' import { wxLoginByOAuth2 as apiWxLoginByOAuth2 } from '../api/user'
import type { UserInfo } from '../api/types' import type { UserInfo } from '../api/types'
import { TOKEN_KEY, REFRESH_TOKEN_KEY } from '../config' import { TOKEN_KEY } from '../config'
/** 用户认证状态 store(跨页面共享 token 和登录状态) */ /** 用户认证状态 store(跨页面共享 token 和登录状态) */
export const useUserStore = defineStore('user', () => { export const useUserStore = defineStore('user', () => {
const token = ref<string>(getToken()) const token = ref<string>(getToken())
const refreshToken = ref<string>(uni.getStorageSync(REFRESH_TOKEN_KEY) || '') const phoneFromStorage = uni.getStorageSync('phone') as string | undefined
const userInfo = ref<UserInfo | null>(null) const userIdFromStorage = uni.getStorageSync('userId') as string | undefined
const userInfo = ref<UserInfo | null>(
token.value && phoneFromStorage
? { id: userIdFromStorage || '', nickname: '', avatarUrl: '', phone: phoneFromStorage }
: null
)
/** 是否已登录 */ /** 是否已登录 */
const isLogin = computed(() => !!token.value) const isLogin = computed(() => !!token.value)
/** 微信授权登录(接收 loginCode + phoneCode) */ /** OAuth2 微信授权登录(code + encryptedData + iv) */
async function wxLogin(params: { loginCode: string; phoneCode: string }) { async function wxLogin(params: { code: string; encryptedData: string; iv: string }) {
const result = await apiWxLogin(params) const result = await apiWxLoginByOAuth2(params)
token.value = result.accessToken const newToken = result.openId
refreshToken.value = result.refreshToken token.value = newToken
setToken(result.accessToken) setToken(newToken)
uni.setStorageSync(REFRESH_TOKEN_KEY, result.refreshToken) uni.setStorageSync(TOKEN_KEY, newToken)
uni.setStorageSync('phone', result.mobile)
uni.setStorageSync('userId', result.userId)
userInfo.value = { id: result.userId, nickname: '', avatarUrl: '', phone: result.mobile }
return result return result
} }
/** 获取当前用户信息并缓存到 store */
async function fetchUserInfo() {
if (!token.value) return
const info = await apiGetUserInfo()
userInfo.value = info
return info
}
/** 退出登录(清除 token 和用户信息) */ /** 退出登录(清除 token 和用户信息) */
function logout() { function logout() {
token.value = '' token.value = ''
refreshToken.value = ''
userInfo.value = null userInfo.value = null
removeToken() removeToken()
uni.removeStorageSync(REFRESH_TOKEN_KEY) uni.removeStorageSync(TOKEN_KEY)
uni.removeStorageSync('phone')
uni.removeStorageSync('userId')
} }
return { return {
token, token,
refreshToken,
userInfo, userInfo,
isLogin, isLogin,
wxLogin, wxLogin,
fetchUserInfo,
logout logout
} }
}) })