This commit is contained in:
lcj
2026-07-07 09:00:53 +08:00
parent 203d129ffb
commit 6d89d1049f
19 changed files with 476 additions and 255 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
VITE_API_BASE_URL=https://dscp-dev.hn12301.net VITE_API_BASE_URL=https://dscp-uat.hn12301.net
VITE_APP_TITLE=湖南12301文旅专区 VITE_APP_TITLE=湖南12301文旅专区
VITE_USE_MOCK=true VITE_USE_MOCK=true
+1 -1
View File
@@ -49,7 +49,7 @@ const loading = ref(true)
onMounted(async () => { onMounted(async () => {
try { try {
const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'FAQ' }) const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'FAQ' })
faqList.value = (data || []).map(n => ({ faqList.value = (data || []).map(n => ({
id: n.id, id: n.id,
title: n.title, title: n.title,
+1 -1
View File
@@ -49,7 +49,7 @@ const notices = ref<NoticeItem[]>([])
onMounted(async () => { onMounted(async () => {
try { try {
const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'NOTICE' }) const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'NOTICE' })
notices.value = (data || []).map(n => ({ notices.value = (data || []).map(n => ({
id: n.id, id: n.id,
title: n.title, title: n.title,
+11 -8
View File
@@ -129,11 +129,7 @@
<!-- 模块5:Tab内容区域 --> <!-- 模块5:Tab内容区域 -->
<view class="tab-content"> <view class="tab-content">
<view v-if="activeTab === 'notice'" class="content-section"> <view v-if="activeTab === 'notice'" class="content-section">
<view class="content-block" v-for="(item, idx) in scenic.bookingNotice" :key="idx"> <rich-text class="notice-html" :nodes="scenic.bookingNoticeHtml" />
<text class="block-title">{{ item.title }}</text>
<view class="divider" />
<text class="block-text">{{ item.content }}</text>
</view>
</view> </view>
<view v-if="activeTab === 'intro'" class="content-section"> <view v-if="activeTab === 'intro'" class="content-section">
<text class="intro-text">{{ scenic.introduction }}</text> <text class="intro-text">{{ scenic.introduction }}</text>
@@ -156,7 +152,7 @@ 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[]; bookingNoticeHtml: string; cards: CardBenefit[]
scenicId: number; shopId: number; mainPic: string; status: number scenicId: number; shopId: number; mainPic: string; status: number
} }
@@ -208,6 +204,7 @@ async function fetchDetail(shopId: string) {
images: res.images || (res.mainPic ? [res.mainPic] : []), images: res.images || (res.mainPic ? [res.mainPic] : []),
introduction: res.introduction || '', introduction: res.introduction || '',
bookingNotice: res.bookingNotice || [], bookingNotice: res.bookingNotice || [],
bookingNoticeHtml: res.bookingNoticeHtml || '',
cards: res.cards || [], cards: res.cards || [],
scenicId: res.scenicId || res.id || shopId, scenicId: res.scenicId || res.id || shopId,
shopId: res.shopId || shopId, shopId: res.shopId || shopId,
@@ -593,8 +590,7 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: $spacing-3; padding: $spacing-3;
margin: 0 $spacing-3; margin: $spacing-3 $spacing-3;
margin-bottom: $spacing-3;
background: $uv-primary-light; background: $uv-primary-light;
border-radius: $radius-full; border-radius: $radius-full;
@@ -702,6 +698,13 @@ $icon-crown: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
display: block; display: block;
} }
.notice-html {
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 1.8;
display: block;
}
/* ====== 底部安全区 ====== */ /* ====== 底部安全区 ====== */
.safe-bottom { .safe-bottom {
height: calc(40rpx + env(safe-area-inset-bottom)); height: calc(40rpx + env(safe-area-inset-bottom));
+9 -2
View File
@@ -8,7 +8,7 @@
<view class="back-arrow" /> <view class="back-arrow" />
</view> </view>
</view> </view>
<text class="nav-title">全部合作景区</text> <text class="nav-title">{{ navTitle }}</text>
<view class="nav-side" /> <view class="nav-side" />
</view> </view>
</view> </view>
@@ -44,7 +44,7 @@
<text class="dropdown-label" :class="{ active: selectedLevel }">{{ selectedLevel || '景区等级' }}</text> <text class="dropdown-label" :class="{ active: selectedLevel }">{{ selectedLevel || '景区等级' }}</text>
<view class="dropdown-arrow" /> <view class="dropdown-arrow" />
</view> </view>
<view class="dropdown-trigger" @click="openPicker('cardType')"> <view class="dropdown-trigger" :class="{ 'dropdown-trigger--disabled': fromQrcode }" @click="!fromQrcode && openPicker('cardType')">
<text class="dropdown-label" :class="{ active: selectedCardType }">{{ selectedCardTypeLabel }}</text> <text class="dropdown-label" :class="{ active: selectedCardType }">{{ selectedCardTypeLabel }}</text>
<view class="dropdown-arrow" /> <view class="dropdown-arrow" />
</view> </view>
@@ -255,6 +255,8 @@ stickyHeaderHeight.value = 100
// ========== URL 参数 ========== // ========== URL 参数 ==========
const cardNumber = ref('') const cardNumber = ref('')
const cardModelId = ref('') const cardModelId = ref('')
const fromQrcode = computed(() => !!cardModelId.value)
const navTitle = computed(() => fromQrcode.value ? '旅游卡景区预约' : '全部合作景区')
// ========== 分页 ========== // ========== 分页 ==========
const pageNo = ref(1) const pageNo = ref(1)
@@ -761,6 +763,11 @@ function onCardClick(scenic: ScenicItem) {
&:active { &:active {
background: $color-muted; background: $color-muted;
} }
&--disabled {
opacity: 0.5;
pointer-events: none;
}
} }
.dropdown-label { .dropdown-label {
+1 -1
View File
@@ -17,7 +17,7 @@ const loading = ref(true)
onMounted(async () => { onMounted(async () => {
try { try {
const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'GUIDE' }) const data = await post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'GUIDE' })
if (data && data.length > 0) { if (data && data.length > 0) {
imageUrl.value = data[0].imageUrl || '' imageUrl.value = data[0].imageUrl || ''
} }
+9 -37
View File
@@ -97,7 +97,7 @@
</view> </view>
<view class="agreement__text"> <view class="agreement__text">
<text class="agreement__label">我已阅读并知晓</text> <text class="agreement__label">我已阅读并知晓</text>
<text class="agreement__link" @tap.stop="showNotice = true">《用卡须知》</text> <text class="agreement__link" @tap.stop="openNotice">《用卡须知》</text>
</view> </view>
</view> </view>
@@ -117,7 +117,7 @@
</view> </view>
<!-- 弹窗:证件类型选择 --> <!-- 弹窗:证件类型选择 -->
<uni-popup ref="idTypePopup" type="bottom" :mask-click="true" @change="(e: any) => showIdTypePicker = e.show"> <uni-popup ref="idTypePopup" type="bottom" :mask-click="true" mask-background="transparent" @change="(e: any) => showIdTypePicker = e.show">
<view class="popup-sheet"> <view class="popup-sheet">
<view class="popup-header"> <view class="popup-header">
<text class="popup-title">选择证件类型</text> <text class="popup-title">选择证件类型</text>
@@ -140,21 +140,6 @@
</view> </view>
</uni-popup> </uni-popup>
<!-- 弹窗:用卡须知 -->
<uni-popup ref="noticePopup" type="bottom" :mask-click="true" @change="(e: any) => showNotice = e.show">
<view class="popup-sheet">
<view class="popup-header">
<text class="popup-title">用卡须知</text>
<view class="popup-close" @tap="showNotice = false">
<view class="popup-close__icon" />
</view>
</view>
<scroll-view scroll-y class="popup-body">
<text class="popup-content">1. 年卡仅限本人使用,不得转借他人。\n2. 入园时需出示有效身份证件及年卡二维码。\n3. 年卡有效期内可不限次数游览合作景区。\n4. 部分景区节假日需提前预约,请以景区公告为准。\n5. 年卡一经激活,不予退换。</text>
</scroll-view>
</view>
</uni-popup>
<!-- 隐藏 canvas,用于图片压缩 --> <!-- 隐藏 canvas,用于图片压缩 -->
<canvas <canvas
canvas-id="myCanvas" canvas-id="myCanvas"
@@ -165,7 +150,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue' import { ref, reactive, computed, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { checkFaceFlag, uploadImage, bindCard as apiBindCard, bindFaceImg } from '../api/card' import { checkFaceFlag, uploadImage, bindCard as apiBindCard, bindFaceImg } from '../api/card'
@@ -203,9 +188,7 @@ const agreed = ref(false)
const submitting = ref(false) const submitting = ref(false)
const uploading = ref(false) const uploading = ref(false)
const needFaceBind = ref(false) const needFaceBind = ref(false)
const showNotice = ref(false)
const showIdTypePicker = ref(false) const showIdTypePicker = ref(false)
const noticePopup = ref<any>(null)
const idTypePopup = ref<any>(null) const idTypePopup = ref<any>(null)
onLoad(async (options) => { onLoad(async (options) => {
@@ -229,10 +212,10 @@ onLoad(async (options) => {
} }
}) })
watch(showNotice, (v) => { /** 打开用卡须知(跳转新页面) */
if (v) noticePopup.value?.open() function openNotice() {
else noticePopup.value?.close() uni.navigateTo({ url: '/packages/user/pages/card-notice' })
}) }
watch(showIdTypePicker, (v) => { watch(showIdTypePicker, (v) => {
if (v) idTypePopup.value?.open() if (v) idTypePopup.value?.open()
@@ -756,6 +739,8 @@ function handleCancel() {
max-height: 70vh; max-height: 70vh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-shadow: none;
padding-bottom: 0;
font-family: $font-family-body; font-family: $font-family-body;
} }
@@ -813,19 +798,6 @@ function handleCancel() {
} }
} }
.popup-body {
padding: 32rpx;
max-height: 55vh;
}
.popup-content {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
line-height: 1.8;
}
/* ===== ID Type Picker List ===== */ /* ===== ID Type Picker List ===== */
.id-type-list { .id-type-list {
padding: 16rpx 0; padding: 16rpx 0;
+18 -23
View File
@@ -4,7 +4,6 @@
<text class="loading-text">加载中...</text> <text class="loading-text">加载中...</text>
</view> </view>
<template v-else> <template v-else>
<image class="card-image" :src="mainPic" mode="widthFix" />
<view class="intro-section"> <view class="intro-section">
<rich-text :nodes="introduction" /> <rich-text :nodes="introduction" />
</view> </view>
@@ -18,9 +17,9 @@
<text class="price-value">{{ buyPrice }}</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"> <view class="btn-buy" :class="{ 'btn-buy--disabled': paying }" @tap="handleBuy">
<text class="btn-buy__text">{{ paying ? '支付中...' : '立即购买' }}</text> <text class="btn-buy__text">{{ paying ? '支付中...' : '立即购买' }}</text>
</button> </view>
</view> </view>
</view> </view>
</template> </template>
@@ -95,6 +94,8 @@ async function handleBuy() {
mobile, mobile,
couponId: '', couponId: '',
}) })
const orderId = orderRes.orderId
const outTradeNo = orderRes.outTradeNo
const openId = getToken() const openId = getToken()
if (!openId) { if (!openId) {
@@ -130,12 +131,12 @@ async function handleBuy() {
uni.showToast({ title: '支付成功', icon: 'success' }) uni.showToast({ title: '支付成功', icon: 'success' })
setTimeout(() => { setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' }) uni.redirectTo({ url: `/packages/user/pages/order/detail?orderId=${outTradeNo}` })
}, 1500) }, 1500)
} catch (err: any) { } catch (err: any) {
uni.showToast({ title: err?.errMsg?.includes('cancel') ? '已取消支付' : '支付失败,请重试', icon: 'none' }) uni.showToast({ title: err?.errMsg?.includes('cancel') ? '已取消支付' : '支付失败,请重试', icon: 'none' })
setTimeout(() => { setTimeout(() => {
uni.redirectTo({ url: '/packages/user/pages/order/list' }) uni.redirectTo({ url: `/packages/user/pages/order/list?outTradeNo=${outTradeNo}` })
}, 1500) }, 1500)
} finally { } finally {
paying.value = false paying.value = false
@@ -149,6 +150,7 @@ $bar-height: 120rpx;
.page { .page {
min-height: 100vh; min-height: 100vh;
background: $color-card; background: $color-card;
padding-top: 20rpx;
padding-bottom: calc(#{$bar-height} + env(safe-area-inset-bottom)); padding-bottom: calc(#{$bar-height} + env(safe-area-inset-bottom));
font-family: $font-family-body; font-family: $font-family-body;
} }
@@ -165,14 +167,6 @@ $bar-height: 120rpx;
color: $uv-tips-color; color: $uv-tips-color;
} }
/* ===== 图片 ===== */
.card-image {
width: 100%;
display: block;
margin: 0;
padding: 0;
}
/* ===== 富文本介绍 ===== */ /* ===== 富文本介绍 ===== */
.intro-section { .intro-section {
width: 100%; width: 100%;
@@ -185,15 +179,13 @@ $bar-height: 120rpx;
left: 0; left: 0;
right: 0; right: 0;
bottom: 0; bottom: 0;
width: 100%;
box-sizing: border-box; box-sizing: border-box;
min-height: $bar-height; min-height: $bar-height;
background: $color-card; background: $color-card;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; padding: 20rpx 32rpx;
padding: 0 0rpx 0 178rpx; padding-bottom: calc(20rpx + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06); box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
z-index: 100; z-index: 100;
} }
@@ -207,6 +199,8 @@ $bar-height: 120rpx;
.price-row { .price-row {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
flex: 1;
min-width: 0;
} }
.price-symbol { .price-symbol {
@@ -246,15 +240,12 @@ $bar-height: 120rpx;
box-shadow: 0 4rpx 16rpx rgba(60, 156, 255, 0.3); box-shadow: 0 4rpx 16rpx rgba(60, 156, 255, 0.3);
transition: all 0.2s ease; transition: all 0.2s ease;
&::after { &:active {
display: none;
}
&:active:not([disabled]) {
transform: scale(0.97); transform: scale(0.97);
background: $uv-primary;
} }
&[disabled] { &--disabled {
opacity: 0.6; opacity: 0.6;
} }
} }
@@ -265,5 +256,9 @@ $bar-height: 120rpx;
font-weight: 700; font-weight: 700;
color: #ffffff; color: #ffffff;
letter-spacing: 2rpx; letter-spacing: 2rpx;
&:active {
color: #ffffff;
}
} }
</style> </style>
+96
View File
@@ -0,0 +1,96 @@
<template>
<view class="page">
<view class="bg-ornament" />
<view class="scroll-wrap">
<view class="content">
<view v-if="loading" class="loading">
<view class="loading-spinner" />
<text class="loading-text">加载中...</text>
</view>
<rich-text v-else class="rich-body" :nodes="content" />
</view>
</view>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { post } from '../../../api/request'
import { showToast } from '../../../utils'
const content = ref('')
const loading = ref(true)
onMounted(async () => {
try {
const list = await post<any[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'CARD_NOTICE' })
content.value = list?.[0]?.content || ''
} catch {
showToast('加载失败')
} finally {
loading.value = false
}
})
</script>
<style scoped>
.page {
min-height: 100vh;
background: linear-gradient(180deg, #f5f7fa 0%, #ffffff 140rpx);
position: relative;
}
.bg-ornament {
position: fixed;
top: -120rpx;
right: -80rpx;
width: 360rpx;
height: 360rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(60, 156, 255, 0.08), rgba(60, 156, 255, 0.02));
pointer-events: none;
z-index: 0;
}
.scroll-wrap {
position: relative;
z-index: 1;
min-height: 100vh;
}
.content {
padding: 48rpx;
}
.rich-body {
font-size: 28rpx;
color: $uv-content-color;
line-height: 1.9;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
gap: 24rpx;
}
.loading-spinner {
width: 48rpx;
height: 48rpx;
border: 4rpx solid #e8ecf1;
border-top-color: $uv-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: $font-size-base;
color: $uv-tips-color;
}
</style>
+1 -1
View File
@@ -718,7 +718,7 @@ $bar-height: 120rpx;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
padding: 0 48rpx; padding: 20rpx 48rpx;
padding-bottom: env(safe-area-inset-bottom); padding-bottom: env(safe-area-inset-bottom);
box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06); box-shadow: 0 -4rpx 16rpx rgba(0, 0, 0, 0.06);
z-index: 100; z-index: 100;
+66 -5
View File
@@ -94,13 +94,14 @@
</scroll-view> </scroll-view>
<!-- 入园码弹窗 --> <!-- 入园码弹窗 -->
<uni-popup ref="codePopup" type="center" :mask-click="true"> <uni-popup ref="codePopup" type="center" :mask-click="true" @close="closeCode">
<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 v-if="codeSrc" class="qr-img" :src="codeSrc" 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>
<text class="code-modal__countdown">{{ countdown }}秒后自动刷新</text>
</view> </view>
</uni-popup> </uni-popup>
@@ -191,7 +192,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app' import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request' import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config' import { BASE_URL } from '../../../../config'
@@ -235,6 +236,16 @@ const detailPopup = ref<any>(null)
const codeSrc = 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 countdown = ref(20)
let refreshTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
onUnmounted(() => {
clearTimer()
// #ifdef MP-WEIXIN
wx.offUserCaptureScreen()
// #endif
})
const records = ref<ReserveRecord[]>([]) const records = ref<ReserveRecord[]>([])
@@ -293,6 +304,15 @@ async function fetchRecords(append = false) {
} }
onLoad(() => { onLoad(() => {
// #ifdef MP-WEIXIN
wx.setVisualEffectOnCapture({ visualEffect: 'hidden' })
wx.onUserCaptureScreen(() => {
uni.showToast({ title: '该页面截图无效,请不要截图', icon: 'none' })
if (currentRecord.value) {
fetchCode(currentRecord.value)
}
})
// #endif
fetchRecords() fetchRecords()
}) })
@@ -335,14 +355,49 @@ async function confirmCancel() {
async function showCode(r: ReserveRecord) { async function showCode(r: ReserveRecord) {
codeSrc.value = '' codeSrc.value = ''
currentRecord.value = r
await fetchCode(r)
codePopup.value?.open()
startTimer()
}
function closeCode() {
clearTimer()
}
function clearTimer() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function startTimer() {
clearTimer()
countdown.value = 20
const id = currentRecord.value?.id
if (!id) return
refreshTimer = setInterval(() => fetchCode(currentRecord.value!), 20000)
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) countdown.value = 20
}, 1000)
}
const currentRecord = ref<ReserveRecord | null>(null)
async function fetchCode(r: ReserveRecord) {
countdown.value = 20
try { try {
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${r.id}`) const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${r.id}`)
if (res.qrType == 1) { if (res.qrType == 1) {
codeSrc.value = 'data:image/png;base64,' + res.qrCode codeSrc.value = 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else if (res.qrType == 2) { } else if (res.qrType == 2) {
codeSrc.value = res.qrUrl codeSrc.value = res.qrUrl
codePopup.value?.open()
} else { } else {
await fetchQrByCard(r) await fetchQrByCard(r)
} }
@@ -363,7 +418,6 @@ async function fetchQrByCard(r: ReserveRecord) {
}) })
if (res.qrCode) { if (res.qrCode) {
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else { } else {
uni.showToast({ icon: 'none', title: '暂无核销码' }) uni.showToast({ icon: 'none', title: '暂无核销码' })
} }
@@ -628,6 +682,13 @@ function previewPhoto(url: string) {
.code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; } .code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; }
.code-modal__countdown {
font-family: $font-family-body;
font-size: 24rpx;
color: $uv-placeholder-color;
margin-top: 12rpx;
}
/* ===== Cancel Modal ===== */ /* ===== Cancel Modal ===== */
.cancel-modal { .cancel-modal {
width: 600rpx; background: $color-card; border-radius: $radius-lg; padding: 40rpx; width: 600rpx; background: $color-card; border-radius: $radius-lg; padding: 40rpx;
+61 -4
View File
@@ -72,20 +72,21 @@
</view> </view>
<!-- 入园码弹窗 --> <!-- 入园码弹窗 -->
<uni-popup ref="codePopup" type="center" :mask-click="true"> <uni-popup ref="codePopup" type="center" :mask-click="true" @close="closeCode">
<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 v-if="codeSrc" class="qr-img" :src="codeSrc" 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>
<text class="code-modal__countdown">{{ countdown }}秒后自动刷新</text>
</view> </view>
</uni-popup> </uni-popup>
</view> </view>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref, onUnmounted } from 'vue'
import { onLoad, onBackPress } from '@dcloudio/uni-app' import { onLoad, onBackPress } from '@dcloudio/uni-app'
import { post } from '../../../../api/request' import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config' import { BASE_URL } from '../../../../config'
@@ -105,8 +106,27 @@ const codeSrc = ref('')
const codePopup = ref<any>(null) const codePopup = ref<any>(null)
const cardNumber = ref('') const cardNumber = ref('')
const cardModelId = ref('') const cardModelId = ref('')
const countdown = ref(20)
let refreshTimer: ReturnType<typeof setInterval> | null = null
let countdownTimer: ReturnType<typeof setInterval> | null = null
onUnmounted(() => {
clearTimer()
// #ifdef MP-WEIXIN
wx.offUserCaptureScreen()
// #endif
})
onLoad(async (query: any) => { onLoad(async (query: any) => {
// #ifdef MP-WEIXIN
wx.setVisualEffectOnCapture({ visualEffect: 'hidden' })
wx.onUserCaptureScreen(() => {
uni.showToast({ title: '该页面截图无效,请不要截图', icon: 'none' })
if (codePopup.value) {
fetchCode()
}
})
// #endif
const appointId = query?.appointId const appointId = query?.appointId
if (!appointId) return if (!appointId) return
const phone = (uni.getStorageSync('phone') as string) || '' const phone = (uni.getStorageSync('phone') as string) || ''
@@ -146,14 +166,44 @@ onBackPress(() => {
}) })
async function showCode() { async function showCode() {
await fetchCode()
codePopup.value?.open()
startTimer()
}
function closeCode() {
clearTimer()
}
function clearTimer() {
if (refreshTimer) {
clearInterval(refreshTimer)
refreshTimer = null
}
if (countdownTimer) {
clearInterval(countdownTimer)
countdownTimer = null
}
}
function startTimer() {
clearTimer()
countdown.value = 20
refreshTimer = setInterval(fetchCode, 20000)
countdownTimer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) countdown.value = 20
}, 1000)
}
async function fetchCode() {
countdown.value = 20
try { try {
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${reserveId.value}`) const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${reserveId.value}`)
if (res.qrType == 1) { if (res.qrType == 1) {
codeSrc.value = 'data:image/png;base64,' + res.qrCode codeSrc.value = 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else if (res.qrType == 2) { } else if (res.qrType == 2) {
codeSrc.value = res.qrUrl codeSrc.value = res.qrUrl
codePopup.value?.open()
} else { } else {
await fetchQrByCard() await fetchQrByCard()
} }
@@ -471,6 +521,13 @@ function goHome() {
margin-top: 8rpx; margin-top: 8rpx;
} }
.code-modal__countdown {
font-family: $font-family-body;
font-size: 24rpx;
color: $uv-placeholder-color;
margin-top: 12rpx;
}
.qr-box { .qr-box {
width: 360rpx; width: 360rpx;
height: 360rpx; height: 360rpx;
+6
View File
@@ -89,6 +89,12 @@
"navigationBarTitleText": "绑卡激活" "navigationBarTitleText": "绑卡激活"
} }
}, },
{
"path": "pages/card-notice",
"style": {
"navigationBarTitleText": "用卡须知"
}
},
{ {
"path": "pages/buy", "path": "pages/buy",
"style": { "style": {
+8 -2
View File
@@ -139,6 +139,7 @@ import { ref, computed, onMounted } from 'vue'
import AnnouncementModal from '../../components/announcement-modal.vue' import AnnouncementModal from '../../components/announcement-modal.vue'
import { post } from '../../api/request' import { post } from '../../api/request'
import type { BannerItem } from '../../api/types' import type { BannerItem } from '../../api/types'
import { useUserStore } from '../../store/user'
interface ScenicItem { interface ScenicItem {
id: number id: number
@@ -157,12 +158,13 @@ const showAllNotices = ref(false)
const displayNotices = computed(() => const displayNotices = computed(() =>
showAllNotices.value ? announcements.value : announcements.value.slice(0, 2) showAllNotices.value ? announcements.value : announcements.value.slice(0, 2)
) )
const userStore = useUserStore()
onMounted(async () => { onMounted(async () => {
try { try {
const [bannerData, noticeData, spotsData] = await Promise.all([ const [bannerData, noticeData, spotsData] = await Promise.all([
post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'BANNER' }), post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'BANNER' }),
post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'aspx', type: 'NOTICE' }), post<BannerItem[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'NOTICE' }),
post<any>('/mp-api/mpScenic/queryScenicList', { channel: 'pingxiang', pageNo: 1, pageSize: 6, phone: uni.getStorageSync('phone') || '' }), post<any>('/mp-api/mpScenic/queryScenicList', { channel: 'pingxiang', pageNo: 1, pageSize: 6, phone: uni.getStorageSync('phone') || '' }),
]) ])
banners.value = bannerData || [] banners.value = bannerData || []
@@ -207,6 +209,10 @@ const quickActions = [
/** 处理金刚区点击 */ /** 处理金刚区点击 */
function onQuickAction(item: { label: string }) { function onQuickAction(item: { label: string }) {
if (item.label === '我要绑卡') { if (item.label === '我要绑卡') {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
uni.navigateTo({ url: '/packages/user/pages/bind' }) uni.navigateTo({ url: '/packages/user/pages/bind' })
return return
} }
+78 -76
View File
@@ -1,94 +1,96 @@
<template> <template>
<view class="page"> <view class="page">
<view class="content"> <view class="bg-ornament" />
<text class="title">服务条款</text> <view class="scroll-wrap">
<text class="update-time">更新日期:2025年6月1日</text> <view class="content">
<view v-if="loading" class="loading">
<view class="section"> <view class="loading-spinner" />
<text class="section-title">一、总则</text> <text class="loading-text">加载中...</text>
<text class="section-body">欢迎您使用爱上萍乡小程序(以下简称"本平台")。本服务条款是您与本平台之间关于使用本平台服务所订立的协议。请您在使用本平台服务前,仔细阅读并充分理解本条款内容。</text> </view>
<text class="section-body">如您不同意本条款的任何内容,请勿使用本平台服务。您一旦使用本平台服务,即视为您已充分理解并同意本条款全部内容。</text> <rich-text v-else class="rich-body" :nodes="content" />
</view>
<view class="section">
<text class="section-title">二、服务内容</text>
<text class="section-body">本平台为用户提供文旅年卡浏览、购买、激活、绑定及入园码展示等相关服务。具体服务内容以平台实际提供为准。</text>
</view>
<view class="section">
<text class="section-title">三、用户注册与账户安全</text>
<text class="section-body">您通过微信授权登录即完成注册。您应妥善保管账户信息及登录凭证,因您个人原因导致的信息泄露或损失,由您自行承担。</text>
</view>
<view class="section">
<text class="section-title">四、用户行为规范</text>
<text class="section-body">您在使用本平台服务时,应遵守中华人民共和国相关法律法规,不得利用本平台从事任何违法违规活动,不得干扰本平台的正常运行。</text>
</view>
<view class="section">
<text class="section-title">五、知识产权</text>
<text class="section-body">本平台的所有内容,包括但不限于文字、图片、界面设计、商标等,均受知识产权法律保护,未经许可不得擅自使用。</text>
</view>
<view class="section">
<text class="section-title">六、免责声明</text>
<text class="section-body">因不可抗力或非本平台原因导致的服务中断或数据丢失,本平台不承担责任。本平台有权根据需要修改服务条款,修改后的条款将在平台公布后生效。</text>
</view>
<view class="section">
<text class="section-title">七、条款修改</text>
<text class="section-body">本平台有权根据需要不时地制定、修改本服务条款,修改后的条款将被公布在本平台上。如您继续使用本平台服务,即视为您已接受经修订的条款。</text>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { post } from '../../api/request'
import { showToast } from '../../utils'
const content = ref('')
const loading = ref(true)
onMounted(async () => {
try {
const list = await post<any[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'TERMS' })
content.value = list?.[0]?.content || ''
} catch {
showToast('加载失败')
} finally {
loading.value = false
}
})
</script>
<style scoped> <style scoped>
.page { .page {
min-height: 100vh; min-height: 100vh;
background: #FFFFFF; background: linear-gradient(180deg, #f5f7fa 0%, #ffffff 140rpx);
padding: $spacing-6 $spacing-6 $spacing-12; position: relative;
}
.bg-ornament {
position: fixed;
top: -120rpx;
right: -80rpx;
width: 360rpx;
height: 360rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(60, 156, 255, 0.08), rgba(60, 156, 255, 0.02));
pointer-events: none;
z-index: 0;
}
.scroll-wrap {
position: relative;
z-index: 1;
min-height: 100vh;
} }
.content { .content {
max-width: 100%; padding: 48rpx;
} }
.title { .rich-body {
display: block; font-size: 28rpx;
font-family: $font-family-display;
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $uv-main-color;
text-align: center;
margin-bottom: $spacing-2;
}
.update-time {
display: block;
font-size: $font-size-sm;
color: $uv-tips-color;
text-align: center;
margin-bottom: $spacing-8;
}
.section {
margin-bottom: $spacing-6;
}
.section-title {
display: block;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: $spacing-2;
}
.section-body {
display: block;
font-size: $font-size-base;
color: $uv-content-color; color: $uv-content-color;
line-height: $line-height-relaxed; line-height: 1.9;
margin-bottom: $spacing-2; }
.loading {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
gap: 24rpx;
}
.loading-spinner {
width: 48rpx;
height: 48rpx;
border: 4rpx solid #e8ecf1;
border-top-color: $uv-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: $font-size-base;
color: $uv-tips-color;
} }
</style> </style>
+22 -6
View File
@@ -73,8 +73,15 @@ const userStore = useUserStore()
const agreed = ref(false) const agreed = ref(false)
const loading = ref(false) const loading = ref(false)
const showContent = ref(false) const showContent = ref(false)
const preLoginCode = ref('')
onMounted(() => { onMounted(async () => {
try {
const res = await uni.login({ provider: 'weixin' })
preLoginCode.value = (res as any)?.code || ''
} catch {
// silent — fallback in handleLogin
}
setTimeout(() => { setTimeout(() => {
showContent.value = true showContent.value = true
}, 80) }, 80)
@@ -109,17 +116,21 @@ async function handleLogin(e: AnyObject) {
return return
} }
const loginRes = await new Promise<UniNamespace.LoginRes>((resolve, reject) => { let code = preLoginCode.value
uni.login({ provider: 'weixin', success: resolve, fail: reject }) if (!code) {
}) const loginRes = await new Promise<UniNamespace.LoginRes>((resolve, reject) => {
uni.login({ provider: 'weixin', success: resolve, fail: reject })
})
code = loginRes.code || ''
}
if (!loginRes?.code) { if (!code) {
showToast('微信登录失败') showToast('微信登录失败')
return return
} }
await userStore.wxLogin({ await userStore.wxLogin({
code: loginRes.code, code,
encryptedData, encryptedData,
iv iv
}) })
@@ -225,6 +236,11 @@ $icon-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' w
.btn-primary--disabled { .btn-primary--disabled {
opacity: 0.6; opacity: 0.6;
background: $uv-primary;
}
.btn-primary[disabled] {
background: $uv-primary;
} }
.btn-primary:active:not(.btn-primary--disabled) { .btn-primary:active:not(.btn-primary--disabled) {
+78 -80
View File
@@ -1,98 +1,96 @@
<template> <template>
<view class="page"> <view class="page">
<view class="content"> <view class="bg-ornament" />
<text class="title">隐私条款</text> <view class="scroll-wrap">
<text class="update-time">更新日期:2025年6月1日</text> <view class="content">
<view v-if="loading" class="loading">
<view class="section"> <view class="loading-spinner" />
<text class="section-title">一、信息收集</text> <text class="loading-text">加载中...</text>
<text class="section-body">为向您提供服务,我们可能会收集以下信息:微信授权登录信息(OpenID)、手机号码、年卡购买及激活信息。我们仅收集为提供服务所必需的最少信息。</text> </view>
</view> <rich-text v-else class="rich-body" :nodes="content" />
<view class="section">
<text class="section-title">二、信息使用</text>
<text class="section-body">我们收集的信息将用于:验证您的身份、处理年卡的购买与激活、提供入园码服务、发送服务通知。我们不会将您的个人信息用于与上述目的无关的其他用途。</text>
</view>
<view class="section">
<text class="section-title">三、信息存储</text>
<text class="section-body">您的个人信息将存储在中华人民共和国境内的安全服务器上。我们采用业界标准的加密技术保护数据传输过程,并采取严格的数据访问控制措施。</text>
</view>
<view class="section">
<text class="section-title">四、信息共享</text>
<text class="section-body">未经您的同意,我们不会向第三方共享、转让或公开披露您的个人信息,法律法规另有规定的除外。</text>
</view>
<view class="section">
<text class="section-title">五、信息安全</text>
<text class="section-body">我们采取符合业界标准的安全防护措施来保护您的个人信息,防止数据未经授权的访问、公开披露、使用、修改、损坏或丢失。</text>
</view>
<view class="section">
<text class="section-title">六、您的权利</text>
<text class="section-body">您有权访问、更正、删除您的个人信息。如需行使上述权利,请通过平台客服渠道联系我们,我们将在合理期限内予以处理。</text>
</view>
<view class="section">
<text class="section-title">七、未成年人保护</text>
<text class="section-body">如您是未满18周岁的未成年人,请在您的监护人陪同下阅读本隐私条款,并在取得监护人对您使用本平台服务的同意后,方可使用本平台服务。</text>
</view>
<view class="section">
<text class="section-title">八、条款更新</text>
<text class="section-body">我们可能不时更新本隐私条款。更新后的条款将在平台上公布,建议您定期查阅。如您在隐私条款更新后继续使用本平台服务,即视为您同意更新后的隐私条款。</text>
</view> </view>
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { post } from '../../api/request'
import { showToast } from '../../utils'
const content = ref('')
const loading = ref(true)
onMounted(async () => {
try {
const list = await post<any[]>('/mp-api/mpContent/queryList', { channel: 'pingxiang', type: 'PRIVACY' })
content.value = list?.[0]?.content || ''
} catch {
showToast('加载失败')
} finally {
loading.value = false
}
})
</script>
<style scoped> <style scoped>
.page { .page {
min-height: 100vh; min-height: 100vh;
background: #FFFFFF; background: linear-gradient(180deg, #f5f7fa 0%, #ffffff 140rpx);
padding: $spacing-6 $spacing-6 $spacing-12; position: relative;
}
.bg-ornament {
position: fixed;
top: -120rpx;
right: -80rpx;
width: 360rpx;
height: 360rpx;
border-radius: 50%;
background: linear-gradient(135deg, rgba(60, 156, 255, 0.08), rgba(60, 156, 255, 0.02));
pointer-events: none;
z-index: 0;
}
.scroll-wrap {
position: relative;
z-index: 1;
min-height: 100vh;
} }
.content { .content {
max-width: 100%; padding: 48rpx;
} }
.title { .rich-body {
display: block; font-size: 28rpx;
font-family: $font-family-display;
font-size: $font-size-2xl;
font-weight: $font-weight-bold;
color: $uv-main-color;
text-align: center;
margin-bottom: $spacing-2;
}
.update-time {
display: block;
font-size: $font-size-sm;
color: $uv-tips-color;
text-align: center;
margin-bottom: $spacing-8;
}
.section {
margin-bottom: $spacing-6;
}
.section-title {
display: block;
font-size: $font-size-lg;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: $spacing-2;
}
.section-body {
display: block;
font-size: $font-size-base;
color: $uv-content-color; color: $uv-content-color;
line-height: $line-height-relaxed; line-height: 1.9;
margin-bottom: $spacing-2; }
.loading {
display: flex;
flex-direction: column;
align-items: center;
padding: 120rpx 0;
gap: 24rpx;
}
.loading-spinner {
width: 48rpx;
height: 48rpx;
border: 4rpx solid #e8ecf1;
border-top-color: $uv-primary;
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.loading-text {
font-size: $font-size-base;
color: $uv-tips-color;
} }
</style> </style>
+9 -7
View File
@@ -44,8 +44,8 @@
:current="currentCardIndex" :current="currentCardIndex"
@change="onCardChange" @change="onCardChange"
circular circular
previous-margin="60rpx" previous-margin="120rpx"
next-margin="60rpx" next-margin="120rpx"
> >
<swiper-item v-for="(card, index) in userCards" :key="card.cardNo"> <swiper-item v-for="(card, index) in userCards" :key="card.cardNo">
<view <view
@@ -194,9 +194,10 @@ async function fetchCards() {
loading.value = true loading.value = true
try { try {
const list = (await getUserCardList({ mobile, saleChannel: 'xgh' })) || [] const list = (await getUserCardList({ mobile, saleChannel: 'xgh' })) || []
userCards.value = list const allowedIds = ['820093340659013', '820093438328133']
userCards.value = list.filter((c: any) => allowedIds.includes(String(c.cardModelId)))
const firstCard = list[0] const firstCard = userCards.value[0]
if (firstCard?.needFaceImgFlag) { if (firstCard?.needFaceImgFlag) {
showFaceModal.value = true showFaceModal.value = true
} }
@@ -218,9 +219,10 @@ async function refreshNeedFace() {
if (!mobile) return if (!mobile) return
try { try {
const list = (await getUserCardList({ mobile, saleChannel: 'xgh' })) || [] const list = (await getUserCardList({ mobile, saleChannel: 'xgh' })) || []
userCards.value = list const allowedIds = ['820093340659013', '820093438328133']
userCards.value = list.filter((c: any) => allowedIds.includes(String(c.cardModelId)))
const current = list[currentCardIndex.value] const current = userCards.value[currentCardIndex.value]
if (current) { if (current) {
fetchUserInfo(current) fetchUserInfo(current)
if (current.needFaceImgFlag) { if (current.needFaceImgFlag) {
@@ -290,7 +292,7 @@ function goLogin() {
} }
function goBuyCard() { function goBuyCard() {
uni.navigateTo({ url: '/packages/user/pages/card/list' }) uni.navigateTo({ url: '/packages/user/pages/buy' })
} }
function copyCardNo() { function copyCardNo() {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 24 KiB