Files
pingxiangcard/src/pages/qrcode/index.vue
T

713 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<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-else-if="!hasCard && loaded">
<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="goBuyCard">
<text class="empty-btn-text">去购买</text>
</view>
<view class="empty-btn-secondary" @click="goBindCard">
<text class="empty-btn-secondary-text">绑定已有旅游卡</text>
</view>
</view>
<!-- 持卡状态 -->
<scroll-view scroll-y class="scroll-content" v-else>
<!-- 卡面展示区域 -->
<view class="card-swiper-wrap">
<swiper
class="card-swiper"
:current="currentCardIndex"
@change="onCardChange"
circular
previous-margin="120rpx"
next-margin="120rpx"
>
<swiper-item v-for="(card, index) in userCards" :key="card.cardNo">
<view
class="card-face"
:class="{ 'card-face--active': index === currentCardIndex }"
>
<view class="card-face-inner">
<image
class="card-bg"
: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="aspectFill"
/>
</view>
<view class="card-dots" v-if="userCards.length > 1">
<view
v-for="(_, idx) in userCards"
:key="idx"
class="card-dot"
:class="{ active: idx === index }"
/>
</view>
</view>
</swiper-item>
</swiper>
</view>
<!-- 功能入口 -->
<view class="feature-panel">
<view class="feature-grid">
<view class="feature-item" v-for="item in featureActions" :key="item.label" @click="onFeatureClick(item)">
<image class="feature-icon-img" :src="item.icon" mode="aspectFit" />
<text class="feature-label">{{ item.label }}</text>
</view>
</view>
</view>
<!-- 持卡人信息 -->
<view class="holder-panel">
<view class="avatar-wrap" @click="previewAvatar">
<image
class="holder-avatar"
:src="userInfoData?.img || '/static/logo.png'"
mode="aspectFill"
/>
<view class="avatar-zoom">
<view class="zoom-icon" />
</view>
</view>
<view class="holder-details">
<view class="detail-row">
<text class="detail-label">姓名:</text>
<text class="detail-value">{{ userInfoData?.userName || '-' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">手机:</text>
<text class="detail-value">{{ userInfoData?.mobile || userStore.userInfo?.phone || '-' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">卡号:</text>
<view class="detail-value-wrap">
<text class="detail-value">{{ currentCard?.cardNo }}</text>
<text class="copy-btn" @click="copyCardNo">复制</text>
</view>
</view>
<view class="detail-row">
<text class="detail-label">有效期至:</text>
<text class="detail-value">{{ formatDateOnly(currentCard?.validityTime) }}</text>
</view>
<view class="detail-row">
<text class="detail-label">{{ userInfoData?.idType || '证件' }}:</text>
<text class="detail-value">{{ userInfoData?.idNo || '-' }}</text>
</view>
</view>
</view>
<!-- 入园提示(持卡人信息下方) -->
<view class="qrcode-section" v-if="!showFaceModal && !isLeaving">
<text class="qrcode-title">入园提示</text>
<view v-if="!entryNotice" class="qrcode-fallback">为确保信息安全,现入园核销只能使用人脸核销。</view>
<view v-else class="qrcode-content">
<rich-text :nodes="entryNotice" />
</view>
</view>
<!-- 底部占位(滑到底时与tabBar的间隔) -->
<view style="height: 48rpx;"/>
</scroll-view>
<!-- 人脸绑定提醒弹窗 -->
<uni-popup
ref="faceModalPopup"
type="center"
:mask-click="false"
@change="(e: any) => showFaceModal = e.show"
>
<view class="face-modal">
<view class="face-modal__title">人脸核销升级通知</view>
<view class="face-modal__row face-modal__row--spec">
<image class="face-modal__icon" src="https://aliyunoss.hn12301.net/sd/ticket_icon1.png" mode="aspectFit" />
<view class="face-modal__col">
<text class="face-modal__sub-title">拍照规范</text>
<view class="face-modal__desc">仅可现场真人实拍正面头像,禁止艺术写真、美颜修图、远景全身、特写大头照;人脸要求完整。</view>
</view>
</view>
<view class="face-modal__row">
<image class="face-modal__icon" src="https://aliyunoss.hn12301.net/sd/ticket_icon3.png" mode="aspectFit" />
<view class="face-modal__col">
<text class="face-modal__sub-title">隐私说明</text>
<view class="face-modal__desc">采集人脸仅用于本人入园身份核验,信息加密严格保密,不会挪作其他用途。</view>
</view>
</view>
<view class="face-modal__tip">
<image class="face-modal__tip-icon" src="https://aliyunoss.hn12301.net/sd/ticket_icon2.png" mode="aspectFit" />
<view class="face-modal__tip-text">完成人脸重新绑定后,才可正常使用旅游卡</br>全部入园权益功能</view>
</view>
<view class="face-modal__btn" @click="onFaceModalConfirm">立即重新绑定</view>
<view class="face-modal__hint">请使用本人实拍照片</view>
</view>
</uni-popup>
</view>
</template>
<script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'
import { onShow, onHide } from '@dcloudio/uni-app'
import { useUserStore } from '../../store/user'
import { getUserCardList, getQrAndUserInfo } from '../../api/card'
import type { UserCardListItem, QrAndUserInfoResult, BannerItem } from '../../api/types'
import { post } from '../../api/request'
const userStore = useUserStore()
const notLoggedIn = ref(false)
const loading = ref(true)
const loaded = ref(false)
const userCards = ref<UserCardListItem[]>([])
const userInfoData = ref<QrAndUserInfoResult | null>(null)
const showFaceModal = ref(false)
const faceModalPopup = ref<any>(null)
watch(showFaceModal, (v) => {
if (v) faceModalPopup.value?.open()
else faceModalPopup.value?.close()
})
const isLeaving = ref(false)
const entryNotice = ref('')
const entryNoticeLoaded = ref(false)
const hasCard = computed(() => userCards.value.length > 0)
const currentCardIndex = ref(0)
const currentCard = computed(() => userCards.value[currentCardIndex.value])
const countdown = ref(20)
let timer: ReturnType<typeof setInterval> | null = null
/** 获取用户年卡列表 */
async function fetchCards() {
const mobile = (uni.getStorageSync('phone') as string) || userStore.userInfo?.phone || ''
loading.value = true
fetchEntryNotice()
try {
const list = (await getUserCardList({ mobile, saleChannel: 'xgh' })) || []
const allowedIds = ['820093340659013', '820093438328133']
userCards.value = list.filter((c: any) => allowedIds.includes(String(c.cardModelId)))
currentCardIndex.value = 0
const firstCard = userCards.value[0]
if (firstCard?.needFaceImgFlag) {
showFaceModal.value = true
}
if (list.length > 0) {
fetchUserInfo(firstCard)
}
} catch {
userCards.value = []
} finally {
loaded.value = true
loading.value = false
}
}
/** 获取入园提示内容(ENTRY_NOTICE 公告) */
async function fetchEntryNotice() {
if (entryNoticeLoaded.value) return
try {
const list = await post<BannerItem[]>('/mpContent/queryList', { channel: 'pingxiang', type: 'ENTRY_NOTICE' })
entryNotice.value = list?.[0]?.content || ''
} catch {
entryNotice.value = ''
} finally {
entryNoticeLoaded.value = true
}
}
/** 刷新当前卡的人脸绑定状态(从接口获取最新数据) */
async function refreshNeedFace() {
const mobile = (uni.getStorageSync('phone') as string) || userStore.userInfo?.phone || ''
try {
const list = (await getUserCardList({ mobile, saleChannel: 'xgh' })) || []
const allowedIds = ['820093340659013', '820093438328133']
userCards.value = list.filter((c: any) => allowedIds.includes(String(c.cardModelId)))
currentCardIndex.value = 0
const current = userCards.value[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) {
currentCardIndex.value = e.detail.current
resetCountdown()
const card = userCards.value[currentCardIndex.value]
if (card) {
fetchUserInfo(card)
if (card.needFaceImgFlag) {
showFaceModal.value = true
}
}
}
/** 有效期只显示年月日(去掉时间部分) */
function formatDateOnly(time?: string): string {
if (!time) return '-'
return time.split(' ')[0]
}
function previewAvatar() {
if (!userInfoData.value?.img) return
uni.previewImage({
urls: [userInfoData.value.img],
current: 0,
})
}
function resetCountdown() {
countdown.value = 20
}
function startTimer() {
timer = setInterval(() => {
countdown.value--
if (countdown.value <= 0) {
const card = currentCard.value
if (card) fetchUserInfo(card)
resetCountdown()
}
}, 1000)
}
function goLogin() {
uni.navigateTo({ url: '/pages/login/login' })
}
function goBuyCard() {
uni.navigateTo({ url: '/packages/user/pages/buy' })
}
function copyCardNo() {
const card = currentCard.value
if (!card) return
uni.setClipboardData({
data: card.cardNo,
success: () => { uni.showToast({ title: '已复制', icon: 'success' }) },
})
}
function goBindCard() {
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 = [
{ label: '我要绑定', icon: 'https://aliyunoss.hn12301.net/pxmall/card.png' },
{ label: '我要预约', icon: 'https://aliyunoss.hn12301.net/pxmall/reserve.png' },
{ label: '预约/使用记录', icon: 'https://aliyunoss.hn12301.net/pxmall/record.png' },
{ label: '使用说明', icon: 'https://aliyunoss.hn12301.net/pxmall/scenic.png' },
]
function onFeatureClick(item: { label: string }) {
if (item.label === '我要绑定') {
uni.navigateTo({ url: '/packages/user/pages/bind' })
return
}
if (item.label === '我要预约') {
const card = currentCard.value
if (!card) {
uni.showToast({ title: '请先选择一张旅游卡', icon: 'none' })
return
}
post<any>('/mpCard/checkFaceBind', { cardModelId: card.cardModelId, cardNumber: card.cardNo })
.then((res) => {
if (res?.needBindFace) {
showFaceModal.value = true
} else {
const url = card.thirdId
? `/packages/content/pages/scenic-list?cardModelId=${card.thirdId}`
: '/packages/content/pages/scenic-list'
uni.navigateTo({ url })
}
})
.catch(() => {
const url = card.thirdId
? `/packages/content/pages/scenic-list?cardModelId=${card.thirdId}`
: '/packages/content/pages/scenic-list'
uni.navigateTo({ url })
})
return
}
if (item.label === '使用说明') {
uni.navigateTo({ url: '/packages/content/pages/usage-guide' })
return
}
if (item.label === '预约/使用记录') {
uni.navigateTo({ url: '/packages/user/pages/reserve/list' })
return
}
uni.showToast({ title: `${item.label}功能开发中`, icon: 'none' })
}
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) })
</script>
<style lang="scss" scoped>
.page { min-height: 100vh; background: $color-background; font-family: $font-family-body; }
.scroll-content { }
/* === 人脸绑定提醒弹窗 === */
.face-modal {
width: 600rpx;
padding: 24rpx;
position: relative;
box-sizing: border-box;
background: #ffffff;
border-radius: 24rpx;
}
.face-modal__title {
color: #000;
font-size: 36rpx;
font-weight: bold;
text-align: center;
}
.face-modal__row {
width: 552rpx;
display: flex;
padding-bottom: 24rpx;
margin-top: 24rpx;
}
.face-modal__row--spec {
border-bottom: 2rpx solid #f2f2f7;
margin-top: 36rpx;
}
.face-modal__icon {
width: 50rpx;
height: 50rpx;
}
.face-modal__col {
display: flex;
flex-direction: column;
margin-left: 12rpx;
}
.face-modal__sub-title {
font-size: 32rpx;
font-weight: bold;
}
.face-modal__desc {
width: 490rpx;
line-height: 44rpx;
margin-top: 24rpx;
}
.face-modal__tip {
width: 552rpx;
display: flex;
margin-top: 24rpx;
padding: 7rpx;
background: #f6f9fe;
box-sizing: border-box;
}
.face-modal__tip-icon {
width: 36rpx;
height: 36rpx;
}
.face-modal__tip-text {
display: flex;
flex-direction: column;
margin-left: 16rpx;
font-size: 24rpx;
line-height: 36rpx;
}
.face-modal__btn {
width: 552rpx;
height: 80rpx;
display: flex;
align-items: center;
justify-content: center;
font-size: 32rpx;
border-radius: 40rpx;
color: #fff;
margin-top: 32rpx;
background: #0088ff;
}
.face-modal__hint {
width: 552rpx;
display: flex;
align-items: center;
justify-content: center;
margin-top: 24rpx;
color: #404040;
}
/* === 加载中 === */
.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 {
display: flex; flex-direction: column; align-items: center;
justify-content: center; min-height: 100vh; padding: 40rpx;
}
.empty-icon-card {
width: 120rpx; height: 80rpx; border-radius: 24rpx;
background: linear-gradient(135deg, $uv-primary, #398ade);
display: flex; flex-direction: column; justify-content: center;
padding: 16rpx; gap: 8rpx; margin-bottom: 32rpx;
}
.empty-icon-stripe { height: 8rpx; background: rgba(255,255,255,0.6); border-radius: 4rpx; width: 100%; }
.empty-icon-stripe.short { width: 60%; }
.empty-text { font-size: $font-size-lg; font-weight: $font-weight-bold; color: $uv-main-color; margin-bottom: 12rpx; }
.empty-desc { font-size: $font-size-sm; color: $uv-tips-color; margin-bottom: 40rpx; }
.empty-btn {
background: $uv-primary; border-radius: 44rpx; padding: 20rpx 80rpx;
min-height: 88rpx; display: flex; align-items: center; justify-content: center;
margin-bottom: 20rpx; transition: opacity 150ms;
}
.empty-btn:active { opacity: 0.7; }
.empty-btn-text { font-size: $font-size-lg; color: #fff; font-weight: $font-weight-medium; }
.empty-btn-secondary {
padding: 20rpx 60rpx; border-radius: 44rpx;
border: 2rpx solid #eeeeee; background: $color-card;
transition: opacity 150ms;
}
.empty-btn-secondary:active { opacity: 0.7; }
.empty-btn-secondary-text { font-size: $font-size-base; color: $uv-content-color; }
/* === 卡面展示区域(3D Coverflow) === */
.card-swiper-wrap { margin: 14rpx 14rpx 14rpx; overflow: visible; perspective: 1200rpx; }
.card-swiper { height: 360rpx; }
.card-face {
height: 340rpx;
margin: 0 8rpx;
border-radius: 16rpx;
background: #fff;
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 {
width: 100%;
height: 100%;
display: block;
}
/* 指示点(卡内右下角) */
.card-dots {
position: absolute; bottom: 48rpx; right: 48rpx; z-index: 2;
display: flex; gap: 10rpx;
}
.card-dot {
width: 10rpx; height: 10rpx; border-radius: 50%;
background: rgba(255,255,255,0.4); transition: all 300ms;
}
.card-dot.active { background: $color-card; width: 22rpx; border-radius: 5rpx; }
/* === 持卡人信息 === */
.holder-panel {
display: flex; align-items: flex-start; gap: 12px;
margin: 0 20rpx; background: $color-card;
border-radius: 24rpx; padding: 28rpx;
box-shadow: 0 4rpx 20rpx rgba(15, 23, 42, 0.06);
}
.avatar-wrap { position: relative; width: 288rpx; height: 358.4rpx; flex-shrink: 0; }
.holder-avatar {
width: 288rpx; height: 358.4rpx; border-radius: 0;
border: 3rpx solid #eeeeee;
}
.avatar-zoom {
position: absolute; bottom: -2rpx; right: -2rpx;
width: 34rpx; height: 34rpx; background: $color-card;
border-radius: 50%; display: flex; align-items: center;
justify-content: center; box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.1);
}
.zoom-icon {
width: 18rpx; height: 18rpx;
border: 1rpx solid $uv-primary; border-radius: 50%;
position: relative;
}
.zoom-icon::after {
content: ''; width: 6rpx; height: 2rpx;
background: $uv-primary; position: absolute;
bottom: -2rpx; right: -2rpx;
transform: rotate(45deg); border-radius: 1rpx;
}
.holder-details { flex: 1; min-width: 0; align-self: stretch; display: flex; flex-direction: column; justify-content: space-between; gap: 16rpx; }
.detail-row { display: flex; justify-content: space-between; align-items: flex-start; gap: 12rpx; }
.detail-label { font-size: $font-size-sm; color: #909399; flex-shrink: 0; }
.detail-value-wrap { display: flex; align-items: center; gap: 12rpx; }
.detail-value { font-size: $font-size-sm; color: $uv-content-color; word-break: break-all; text-align: right; }
.copy-btn {
font-size: 22rpx; color: $uv-primary;
padding: 4rpx 16rpx; border: 1rpx solid $uv-primary;
border-radius: 8rpx; transition: opacity 150ms;
}
.copy-btn:active { opacity: 0.6; }
/* === 二维码区域 === */
.qrcode-section {
display: flex; flex-direction: column; align-items: center;
margin: 20rpx 20rpx 20rpx; background: $color-card;
border-radius: 24rpx; padding: 28rpx;
box-shadow: 0 4rpx 20rpx rgba(15, 23, 42, 0.06);
}
.qrcode-title {
font-size: $font-size-lg; font-weight: $font-weight-medium; color: $uv-main-color;
display: block; margin-bottom: 20rpx; align-self: flex-start;
}
.qrcode-content {
font-size: $font-size-base; color: $uv-content-color;
line-height: 1.7; word-break: break-all; align-self: flex-start;
}
.qrcode-fallback {
font-size: $font-size-base; color: $uv-content-color;
line-height: 1.7; word-break: break-all; align-self: flex-start;
}
.qrcode-box {
width: 360rpx; height: 360rpx; background: #f8fafc;
border-radius: 16rpx; display: flex; align-items: center;
justify-content: center; border: 1rpx solid #eeeeee;
margin-bottom: 16rpx;
}
.qrcode-img { width: 300rpx; height: 300rpx; }
.qrcode-countdown { display: flex; align-items: center; gap: 8rpx; }
.countdown-icon {
width: 20rpx; height: 20rpx;
border: 3rpx solid #666666; border-radius: 50%;
position: relative;
}
.countdown-icon::before {
content: ''; position: absolute;
width: 3rpx; height: 8rpx; background: #666666;
top: 2rpx; left: 50%; transform: translateX(-50%);
border-radius: 1rpx;
}
.countdown-text { font-size: $font-size-sm; color: $uv-tips-color; }
/* === 功能入口 === */
.feature-panel {
margin: 0 20rpx 20rpx; background: $color-card;
border-radius: 24rpx; padding: 28rpx;
box-shadow: 0 4rpx 20rpx rgba(15, 23, 42, 0.06);
}
.feature-grid {
display: flex; justify-content: space-around;
}
.feature-item {
display: flex; flex-direction: column; align-items: center;
min-width: 110rpx; transition: opacity 150ms;
}
.feature-item:active { opacity: 0.6; }
.feature-icon-img {
width: 88rpx; height: 88rpx;
margin-bottom: 10rpx;
}
.feature-label { font-size: $font-size-sm; color: $uv-content-color; }
</style>