第一个版本

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
+65 -2
View File
@@ -1,4 +1,5 @@
import { get, post } from '../../../api/request'
import { BASE_URL } from '../../../config'
import type { CardInfo, CardDetail, MyCard, ActivateParams, QrCodeInfo } from '../../../api/types'
/** 获取年卡列表 */
@@ -17,8 +18,21 @@ export function activateCard(cardId: string, data: ActivateParams): Promise<bool
}
/** 绑定年卡 */
export function bindCard(cardNo: string): Promise<boolean> {
return post<boolean>('/card/bind', { cardNo })
export function bindCard(data: {
cardNum: string
userName: string
mobile: string
idType: string
idNo: string
img: string
smsCode: string
thirdId: string
bindType: string
bindSource: string
channel: string
activeCode: string
}): Promise<boolean> {
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/bind`, data, false)
}
/** 获取我的年卡列表 */
@@ -30,3 +44,52 @@ export function getMyCards(): Promise<MyCard[]> {
export function getEntryQrCode(cardId: string): Promise<QrCodeInfo> {
return get<QrCodeInfo>('/card/qrcode', { cardId })
}
/** 人脸绑定(上传照片) */
export function bindFaceImg(data: {
cardNum: string
userName: string
mobile: string
idType: string
idNo: string
img: string
smsCode: string
thirdId: string
bindType: string
bindSource: string
channel: string
activeCode: string
}): Promise<boolean> {
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/bindForAddFaceImg`, data, false)
}
/** 查询是否需要人脸绑定 */
export function checkFaceFlag(cardNo: string): Promise<boolean> {
return post<boolean>(`${BASE_URL}/zhy-api/cardBag/faceFlag?cardNo=${cardNo}`, {}, false)
}
/** 上传图片到 OSS */
export function uploadImage(filePath: string): Promise<string> {
return new Promise((resolve, reject) => {
uni.uploadFile({
url: `${BASE_URL}/open-api/oss/upload`,
filePath,
name: 'file',
success: (res) => {
if (res.statusCode === 200) {
try {
const data = JSON.parse(res.data as string)
if (data.errcode === 0 && data.data?.fileUrl) {
resolve(data.data.fileUrl)
} else {
reject(new Error(data.errmsg || '上传失败'))
}
} catch { reject(new Error('解析响应失败')) }
} else {
reject(new Error('上传失败,状态码:' + res.statusCode))
}
},
fail: reject,
})
})
}
+176 -48
View File
@@ -3,10 +3,10 @@
<!-- 1. 头部标题区 -->
<view class="header">
<view class="header-title">
<text class="header-title__bind">绑定</text>
<text class="header-title__card">赣湘全域旅游年卡/超级玩家卡</text>
<text class="header-title__bind">{{ faceBindMode ? '人脸' : '绑定' }}</text>
<text class="header-title__card">{{ faceBindMode ? '绑定' : '赣湘全域旅游年卡/超级玩家卡' }}</text>
</view>
<text class="header-desc">请务必填写您的真实信息,以便于景区实名制预约</text>
<text class="header-desc">{{ faceBindMode ? '请上传持卡人照片完成人脸绑定' : '请务必填写您的真实信息,以便于景区实名制预约' }}</text>
</view>
<!-- 2. 白色圆角表单卡片 -->
@@ -19,6 +19,8 @@
v-model="form.cardNo"
placeholder="请输入卡号"
placeholder-class="placeholder"
:disabled="faceBindMode"
@blur="onCardNoBlur"
/>
</view>
<view class="divider" />
@@ -31,6 +33,7 @@
v-model="form.name"
placeholder="请输入持卡人姓名"
placeholder-class="placeholder"
:disabled="faceBindMode"
/>
</view>
<view class="divider" />
@@ -43,19 +46,19 @@
v-model="form.phone"
type="number"
maxlength="11"
placeholder="请输入持卡人手机号"
disabled
placeholder-class="placeholder"
/>
</view>
<view class="divider" />
<!-- 证件类型 -->
<view class="form-row" @tap="showIdTypePicker = true">
<!-- 证件类型(绑卡模式显示) -->
<view v-if="!faceBindMode" class="form-row" @tap="showIdTypePicker = true">
<text class="form-label">证件类型</text>
<text class="form-value">{{ docType }}</text>
<text class="form-value">{{ idTypeLabel }}</text>
<view class="chevron-right" />
</view>
<view class="divider" />
<view v-if="!faceBindMode" class="divider" />
<!-- 证件号码 -->
<view class="form-row">
@@ -65,16 +68,17 @@
v-model="form.idCard"
:placeholder="idPlaceholder"
placeholder-class="placeholder"
:maxlength="docType === '身份证' ? 18 : 30"
:maxlength="docType === '1' ? 18 : 30"
:disabled="faceBindMode"
/>
</view>
<view class="divider" />
<!-- 持卡人照片 -->
<view class="form-row form-row--photo">
<view v-if="needFaceBind" class="form-row form-row--photo">
<text class="form-label form-label--top">持卡人照片</text>
<view class="upload-area" @tap="photoUrl ? previewPhoto() : choosePhoto()">
<image v-if="photoUrl" class="upload-preview" :src="photoUrl" mode="aspectFill" />
<view class="upload-area" @tap="photoUrl ? previewPhoto() : chooseImage()">
<image v-if="photoUrl" class="upload-preview" :src="photoUrl" mode="aspectFit" />
<view v-else class="upload-placeholder">
<view class="upload-icon" />
<text class="upload-text">点击上传</text>
@@ -86,8 +90,8 @@
</view>
</view>
<!-- 3. 协议勾选区域 -->
<view class="agreement" @tap="agreed = !agreed">
<!-- 3. 协议勾选区域(绑卡模式显示) -->
<view v-if="!faceBindMode" class="agreement" @tap="agreed = !agreed">
<view class="checkbox" :class="{ 'checkbox--checked': agreed }">
<view v-if="agreed" class="checkbox__check" />
</view>
@@ -105,7 +109,7 @@
:disabled="submitting"
@tap="handleSubmit"
>
<text class="btn-confirm__text">{{ submitting ? '提交中...' : '确认绑卡' }}</text>
<text class="btn-confirm__text">{{ submitting ? '提交中...' : (faceBindMode ? '确认绑定人脸' : '确认绑卡') }}</text>
</button>
<button class="btn-cancel" @tap="handleCancel">
<text class="btn-cancel__text">取消</text>
@@ -124,13 +128,13 @@
<view class="id-type-list">
<view
v-for="item in idTypeOptions"
:key="item"
:key="item.value"
class="id-type-item"
:class="{ 'id-type-item--active': docType === item }"
@tap="selectIdType(item)"
:class="{ 'id-type-item--active': docType === item.value }"
@tap="selectIdType(item.value)"
>
<text class="id-type-item__text">{{ item }}</text>
<view v-if="docType === item" class="id-type-item__check" />
<text class="id-type-item__text">{{ item.label }}</text>
<view v-if="docType === item.value" class="id-type-item__check" />
</view>
</view>
</view>
@@ -151,28 +155,45 @@
</view>
</uni-popup>
<!-- 隐藏 canvas,用于图片压缩 -->
<canvas
canvas-id="myCanvas"
id="myCanvas"
style="position:absolute;left:0;top:-10000rpx;width:375px;height:375px;z-index:-1;opacity:0"
/>
</view>
</template>
<script setup lang="ts">
import { ref, reactive, watch, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { checkFaceFlag, uploadImage, bindCard as apiBindCard, bindFaceImg } from '../api/card'
const faceBindMode = ref(false)
const form = reactive({
cardNo: '',
name: '',
phone: '18684839400',
phone: (uni.getStorageSync('phone') as string) || '',
idCard: ''
})
const docType = ref('身份证')
const idTypeOptions = ['身份证', '护照', '港澳通行证', '台胞证']
const idTypeOptions = [
{ value: '1', label: '身份证' },
{ value: '2', label: '护照' },
{ value: '3', label: '港澳居民来往内地通行证' },
{ value: '4', label: '台湾居民来往大陆通行证' },
]
const docType = ref('1')
const idTypeLabel = computed(() => idTypeOptions.find(i => i.value === docType.value)?.label || '')
const idPlaceholder = computed(() => {
const map: Record<string, string> = {
'身份证': '请输入身份证号码',
'护照': '请输入护照号码',
'港澳通行证': '请输入通行证号码',
'台胞证': '请输入台胞证号码'
'1': '请输入身份证号码',
'2': '请输入护照号码',
'3': '请输入通行证号码',
'4': '请输入台胞证号码',
}
return map[docType.value] || '请输入证件号码'
})
@@ -180,11 +201,34 @@ const idPlaceholder = computed(() => {
const photoUrl = ref('')
const agreed = ref(false)
const submitting = ref(false)
const uploading = ref(false)
const needFaceBind = ref(false)
const showNotice = ref(false)
const showIdTypePicker = ref(false)
const noticePopup = ref<any>(null)
const idTypePopup = ref<any>(null)
onLoad(async (options) => {
if (options?.faceBind === '1') {
faceBindMode.value = true
form.cardNo = options.cardNo || ''
form.name = options.userName || ''
form.phone = options.mobile || form.phone
const idType = options.idType || ''
docType.value = idType || '1'
form.idCard = options.idNo || ''
needFaceBind.value = true
} else if (options?.redeemCode) {
form.cardNo = options.redeemCode
try {
const res = await checkFaceFlag(options.redeemCode)
needFaceBind.value = !!res
} catch {
needFaceBind.value = false
}
}
})
watch(showNotice, (v) => {
if (v) noticePopup.value?.open()
else noticePopup.value?.close()
@@ -195,22 +239,85 @@ watch(showIdTypePicker, (v) => {
else idTypePopup.value?.close()
})
function selectIdType(item: string) {
docType.value = item
function selectIdType(value: string) {
docType.value = value
showIdTypePicker.value = false
}
function choosePhoto() {
uni.chooseMedia({
async function onCardNoBlur() {
if (!form.cardNo.trim()) return
try {
const res = await checkFaceFlag(form.cardNo.trim())
needFaceBind.value = !!res
} catch {
needFaceBind.value = false
}
}
function chooseImage() {
if (uploading.value) return
uni.chooseImage({
count: 1,
mediaType: ['image'],
sourceType: ['camera'],
success: (res) => {
photoUrl.value = res.tempFiles[0].tempFilePath
sizeType: ['original'],
sourceType: ['album', 'camera'],
success: (res) => handleImage(res.tempFilePaths[0]),
fail: (err) => {
if (!err.errMsg.includes('cancel')) console.log('选择图片失败:', err)
}
})
}
async function handleImage(filePath: string) {
uploading.value = true
uni.showLoading({ title: '处理中...' })
try {
const compressedPath = await compressImage(filePath)
uni.showLoading({ title: '上传中...' })
const url = await uploadImage(compressedPath)
photoUrl.value = url
uni.showToast({ title: '上传成功', icon: 'success' })
} catch {
uni.showToast({ title: '上传失败', icon: 'none' })
} finally {
uploading.value = false
uni.hideLoading()
}
}
function compressImage(filePath: string): Promise<string> {
return new Promise((resolve) => {
const maxSize = 375
uni.getImageInfo({
src: filePath,
success: (info) => {
let { width, height } = info
if (width <= maxSize && height <= maxSize) { resolve(filePath); return }
if (width > height) {
height = Math.round((height / width) * maxSize)
width = maxSize
} else {
width = Math.round((width / height) * maxSize)
height = maxSize
}
const ctx = uni.createCanvasContext('myCanvas')
ctx.drawImage(filePath, 0, 0, width, height)
ctx.draw(false, () => {
setTimeout(() => {
uni.canvasToTempFilePath({
canvasId: 'myCanvas', x: 0, y: 0,
width, height, destWidth: width, destHeight: height,
quality: 0.8,
success: (r) => resolve(r.tempFilePath),
fail: () => resolve(filePath),
})
}, 200)
})
},
fail: () => resolve(filePath)
})
})
}
function previewPhoto() {
uni.previewImage({ urls: [photoUrl.value] })
}
@@ -224,37 +331,58 @@ function validateForm() {
if (!form.name.trim()) return '请输入姓名'
if (!form.phone.trim()) return '请输入联系电话'
if (!/^1[3-9]\d{9}$/.test(form.phone)) return '手机号格式不正确'
if (!form.idCard.trim()) return `请输入${docType.value}号码`
if (docType.value === '身份证' && !/^\d{17}[\dXx]$/.test(form.idCard)) return '身份证号格式不正确'
if (!photoUrl.value) return '请上传持卡人照片'
if (!form.idCard.trim()) return `请输入${idTypeLabel.value}号码`
if (docType.value === '1' && !/^\d{17}[\dXx]$/.test(form.idCard)) return '身份证号格式不正确'
if (needFaceBind.value && !photoUrl.value) return '请上传持卡人照片'
return ''
}
async function handleSubmit() {
if (!agreed.value) {
if (!faceBindMode.value && !agreed.value) {
uni.showToast({ title: '请先阅读并知晓用卡须知', icon: 'none' })
return
}
const err = validateForm()
if (err) {
uni.showToast({ title: err, icon: 'none' })
return
if (faceBindMode.value) {
if (!photoUrl.value) {
uni.showToast({ title: '请上传持卡人照片', icon: 'none' })
return
}
} else {
const err = validateForm()
if (err) {
uni.showToast({ title: err, icon: 'none' })
return
}
}
submitting.value = true
uni.showLoading({ title: '提交中...', mask: true })
try {
await new Promise(r => setTimeout(r, 1500))
const apiFn = faceBindMode.value ? bindFaceImg : apiBindCard
await apiFn({
cardNum: form.cardNo.trim(),
userName: form.name.trim(),
mobile: form.phone.trim(),
idType: docType.value,
idNo: form.idCard.trim(),
img: photoUrl.value,
smsCode: '',
thirdId: '',
bindType: 'SELF',
bindSource: 'zfb',
channel: '',
activeCode: '',
})
uni.hideLoading()
uni.showToast({ title: '绑卡成功', icon: 'success' })
uni.showToast({ title: faceBindMode.value ? '人脸绑定成功' : '绑卡成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/index/index' })
uni.switchTab({ url: '/pages/qrcode/index' })
}, 1500)
} catch {
} catch (err: any) {
uni.hideLoading()
uni.showToast({ title: '绑卡失败,请重试', icon: 'none' })
uni.showToast({ title: err.message || (faceBindMode.value ? '人脸绑定失败' : '绑卡失败'), icon: 'none' })
} finally {
submitting.value = false
}
+114 -39
View File
@@ -1,25 +1,21 @@
<template>
<view class="page">
<!-- 图片区域 -->
<view class="image-section">
<image
class="card-image"
src="https://aliyunoss.hntgov.net/image/2026/06/23/70e9f4b3b5fd49adb1ff06edcb8fa032.jpg"
mode="widthFix"
/>
<image
class="card-image"
src="https://aliyunoss.hntgov.net/image/2026/06/25/f3e45bcb3b4f4a2997f107cc6335a1bc.jpg"
mode="widthFix"
/>
<view v-if="loading" class="loading">
<text class="loading-text">加载中...</text>
</view>
<template v-else>
<image class="card-image" :src="mainPic" mode="widthFix" />
<view class="intro-section">
<rich-text :nodes="introduction" />
</view>
</template>
<!-- 底部固定购买区域 -->
<view class="bottom-bar">
<view class="price-row">
<text class="price-label">价格:</text>
<text class="price-symbol">¥</text>
<text class="price-value">198</text>
<text class="price-value">{{ buyPrice }}</text>
<text class="price-original">¥3800+</text>
</view>
<button class="btn-buy" :disabled="paying" @tap="handleBuy">
@@ -30,38 +26,105 @@
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { ref, onMounted } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post, getToken } from '../../../api/request'
import { BASE_URL } from '../../../config'
import { useUserStore } from '../../../store/user'
import { createOrder, prepay } from '../../../api/order'
const userStore = useUserStore()
const goodsId = ref('')
const cardModleId = ref('')
const mainPic = ref('')
const introduction = ref('')
const buyPrice = ref('0')
const skuId = ref('')
const loading = ref(true)
const paying = ref(false)
onLoad((query) => {
goodsId.value = (query?.goodsId as string) || '821926819630853'
})
function sanitizeHtml(html: string): string {
return html.replace(/<img[^>]*>/g, (match) => {
return match.replace(/style="[^"]*"/g, 'style="width:100%"')
})
}
interface CardModelDetail {
cardModel: any
goods: { id: string; mainPic: string }
goodsInfo: { introduction: string }
sku: Array<{ id: string; buyPrice: number }>
}
onMounted(async () => {
try {
const data = await post<CardModelDetail>(`${BASE_URL}/open-api/xgh/cardModelDetail?goodsId=${goodsId.value}`, {})
cardModleId.value = data.cardModel?.id || ''
mainPic.value = data.goods?.mainPic || ''
introduction.value = sanitizeHtml(data.goodsInfo?.introduction || '')
const sku = data.sku?.[0]
skuId.value = sku?.id || ''
const price = sku?.buyPrice || 0
buyPrice.value = (price / 100).toFixed(2)
} catch {
// 静默失败
} finally {
loading.value = false
}
})
async function handleBuy() {
if (!userStore.isLogin) {
uni.navigateTo({ url: '/pages/login/login' })
return
}
if (paying.value) return
paying.value = true
try {
// 调用后端创建订单接口(mock)
const orderRes: any = await new Promise((resolve) => {
setTimeout(() => {
resolve({
orderId: 'ORD' + Date.now(),
payParams: {
timeStamp: String(Math.floor(Date.now() / 1000)),
nonceStr: 'mock_nonce_' + Date.now(),
package: 'prepay_id=mock_prepay_id',
signType: 'MD5',
paySign: 'mock_pay_sign'
}
})
}, 800)
const mobile = (uni.getStorageSync('phone') as string) || ''
const orderRes = await createOrder({
cardModleId: cardModleId.value,
buyNum: 1,
skuId: skuId.value,
mobile,
couponId: '',
})
// 调用微信支付
const openId = getToken()
if (!openId) {
uni.showToast({ title: '请先登录', icon: 'none' })
return
}
const payParams = await prepay({
openId,
orderNo: orderRes.outTradeNo,
description: mobile,
amount: Math.round(Number(buyPrice.value) * 100),
})
if (!payParams?.timeStamp || !payParams?.nonce || !payParams?.pack || !payParams?.paySign) {
uni.showToast({ title: '支付参数异常,请重试', icon: 'none' })
return
}
await new Promise<void>((resolve, reject) => {
uni.requestPayment({
provider: 'wxpay',
...orderRes.payParams,
appId: payParams.appId,
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonce,
package: payParams.pack,
signType: payParams.signType,
paySign: payParams.paySign,
success: () => resolve(),
fail: (err: any) => reject(err)
fail: (err: any) => reject(err),
})
})
@@ -70,11 +133,10 @@ async function handleBuy() {
uni.switchTab({ url: '/pages/index/index' })
}, 1500)
} catch (err: any) {
if (err?.errMsg?.includes('cancel')) {
uni.showToast({ title: '已取消支付', icon: 'none' })
} else {
uni.showToast({ title: '支付失败,请重试', icon: 'none' })
}
uni.showToast({ title: err?.errMsg?.includes('cancel') ? '已取消支付' : '支付失败,请重试', icon: 'none' })
setTimeout(() => {
uni.redirectTo({ url: '/packages/user/pages/order/list' })
}, 1500)
} finally {
paying.value = false
}
@@ -91,12 +153,19 @@ $bar-height: 120rpx;
font-family: $font-family-body;
}
/* ===== 图片区域 ===== */
.image-section {
/* ===== 加载状态 ===== */
.loading {
display: flex;
flex-direction: column;
justify-content: center;
padding: 80rpx 0;
}
.loading-text {
font-size: $font-size-sm;
color: $uv-tips-color;
}
/* ===== 图片 ===== */
.card-image {
width: 100%;
display: block;
@@ -104,6 +173,12 @@ $bar-height: 120rpx;
padding: 0;
}
/* ===== 富文本介绍 ===== */
.intro-section {
width: 100%;
margin-bottom: 24rpx;
}
/* ===== 底部固定购买区域 ===== */
.bottom-bar {
position: fixed;
+14 -1
View File
@@ -37,7 +37,20 @@ async function handleBind() {
}
binding.value = true
try {
await bindCard(cardNo.value.trim())
await bindCard({
cardNum: cardNo.value.trim(),
userName: '',
mobile: '',
idType: '',
idNo: '',
img: '',
smsCode: '',
thirdId: '',
bindType: 'SELF',
bindSource: 'zfb',
channel: '',
activeCode: '',
})
showToast('绑定成功', 'success')
setTimeout(() => {
uni.navigateBack()
+158 -149
View File
@@ -1,9 +1,9 @@
<template>
<view class="page">
<!-- 模块1:订单状态卡片 -->
<view class="status-card" :class="`status-card--${order.status}`">
<view class="status-card" :class="`status-card--${orderStatus}`">
<view class="status-icon-wrap">
<view class="status-icon" :class="`status-icon--${order.status}`" />
<view class="status-icon" :class="`status-icon--${orderStatus}`" />
</view>
<text class="status-title">{{ statusLabel }}</text>
<text class="status-desc">{{ statusDesc }}</text>
@@ -15,7 +15,7 @@
<view class="price-row">
<view class="price-current">
<text class="price-symbol">¥</text>
<text class="price-value">198</text>
<text class="price-value">{{ (order.payAmount / 100).toFixed(2) }}</text>
</view>
</view>
<text class="product-desc">一卡全年畅玩赣湘合作景区商户,实名绑定使用</text>
@@ -27,11 +27,11 @@
<text class="detail-label">订单编号</text>
<text class="detail-value">{{ order.id }}</text>
</view>
<view v-if="order.status === 'pending' || order.status === 'activated'" class="detail-row">
<view v-if="order.redeemCode && order.payStatus === 2" class="detail-row">
<text class="detail-label">卡号</text>
<view class="detail-value-row">
<text class="detail-value detail-value--code">{{ order.redeemCode }}</text>
<view v-if="order.status === 'pending'" class="copy-btn" @tap="copyCode">
<view class="copy-btn" @tap="copyCode">
<text class="copy-btn__text">复制</text>
</view>
</view>
@@ -42,7 +42,7 @@
</view>
<view class="detail-row">
<text class="detail-label">支付时间</text>
<text class="detail-value">{{ order.payTime }}</text>
<text class="detail-value">{{ order.payTime || '-' }}</text>
</view>
<view class="detail-row">
<text class="detail-label">支付渠道</text>
@@ -50,66 +50,52 @@
</view>
<view class="detail-row">
<text class="detail-label">支付金额</text>
<text class="detail-value detail-value--price">¥{{ order.payAmount }}.00</text>
<text class="detail-value detail-value--price">¥{{ (order.payAmount / 100).toFixed(2) }}</text>
</view>
<view v-if="order.status === 'activated' && order.activatedTime" class="detail-row detail-row--last">
<view v-if="order.bindName" class="detail-row">
<text class="detail-label">绑定人</text>
<text class="detail-value">{{ order.bindName }}</text>
</view>
<view v-if="order.bindCertificateNo" class="detail-row">
<text class="detail-label">绑定证件</text>
<text class="detail-value">{{ order.bindCertificateNo }}</text>
</view>
<view v-if="order.activatedTime" class="detail-row">
<text class="detail-label">绑定时间</text>
<text class="detail-value">{{ order.activatedTime }}</text>
</view>
<view v-if="order.status === 'refunded' && order.refundedTime" class="detail-row detail-row--last">
<view v-if="order.validStartDate" class="detail-row">
<text class="detail-label">有效期起</text>
<text class="detail-value">{{ order.validStartDate }}</text>
</view>
<view v-if="order.validEndDate" class="detail-row detail-row--last">
<text class="detail-label">有效期止</text>
<text class="detail-value">{{ order.validEndDate }}</text>
</view>
<view v-if="order.refundedTime" class="detail-row detail-row--last">
<text class="detail-label">退款时间</text>
<text class="detail-value">{{ order.refundedTime }}</text>
</view>
</view>
<!-- 模块4:使用提示卡片 -->
<view class="tips-card">
<text class="tips-title">使用须知</text>
<view class="tips-list">
<view class="tips-item">
<view class="tips-dot" />
<text class="tips-text">卡一经绑定或售出满7天不支持退款</text>
</view>
<view class="tips-item">
<view class="tips-dot" />
<text class="tips-text">实名绑定大陆身份证,港澳台/外籍无法绑定</text>
</view>
<view class="tips-item">
<view class="tips-dot" />
<text class="tips-text">预约需提前1天,未提前取消将扣除使用次数</text>
</view>
<view class="tips-item">
<view class="tips-dot" />
<text class="tips-text">一卡一人不可转借</text>
</view>
<view class="tips-item">
<view class="tips-dot" />
<text class="tips-text">第三方平台下单退款需联系对应平台客服</text>
</view>
</view>
<view class="tips-link" @tap="goFaq">
<text class="tips-link__text">查看完整FAQ</text>
</view>
</view>
<!-- 底部安全区占位 -->
<view class="safe-bottom" />
<!-- 模块5:底部固定操作栏 -->
<view class="bottom-bar">
<view v-if="order.status === 'pending'" class="bottom-bar__right">
<view v-if="Number(order.payStatus) === 1" class="bottom-bar__right">
<view class="btn-action btn-action--primary" @tap="goPay">
<text class="btn-action__text">立即支付</text>
</view>
</view>
<view v-else-if="Number(order.payStatus) === 2" class="bottom-bar__right">
<view class="btn-action btn-action--outline" @tap="handleRefund">
<text class="btn-action__text--outline">申请退款</text>
</view>
<view class="btn-action btn-action--primary" @tap="goActivate">
<view v-if="order.redeemCode" class="btn-action btn-action--primary" @tap="goActivate">
<text class="btn-action__text">立即绑定</text>
</view>
</view>
<view v-else class="bottom-bar__right">
<view class="btn-action btn-action--outline" @tap="goHome">
<text class="btn-action__text--outline">返回首页</text>
</view>
</view>
</view>
</view>
</template>
@@ -117,62 +103,108 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired'
interface OrderDetail {
id: string
status: OrderStatus
payStatus: number
orderStatus: number
createTime: string
payTime: string
payTime: string | null
payAmount: number
redeemCode: string
payChannel: string
activatedTime?: string
refundedTime?: string
cardNo: string | null
redeemCode: string | null
isBind: number | null
bindName: string | null
bindCertificateNo: string | null
cardStatus: number | null
isActive: number | null
activatedTime: string | null
validStartDate: string | null
validEndDate: string | null
refundedTime: string | null
}
const order = ref<OrderDetail>({
id: 'ORD20260625001',
status: 'pending',
createTime: '2026-06-25 10:32',
payTime: '2026-06-25 10:33',
payAmount: 198,
redeemCode: 'CJ0000012398',
id: '',
payStatus: 1,
orderStatus: 1,
createTime: '',
payTime: null,
payAmount: 0,
payChannel: '微信支付',
activatedTime: undefined,
refundedTime: undefined
cardNo: null,
redeemCode: null,
isBind: null,
bindName: null,
bindCertificateNo: null,
cardStatus: null,
isActive: null,
activatedTime: null,
validStartDate: null,
validEndDate: null,
refundedTime: null,
})
// Mock 订单数据集
const mockOrders: OrderDetail[] = [
{ id: 'ORD20260625001', status: 'pending', createTime: '2026-06-25 10:32', payTime: '2026-06-25 10:33', payAmount: 198, redeemCode: 'CJ0000012398', payChannel: '微信支付' },
{ id: 'ORD20260624002', status: 'activated', createTime: '2026-06-24 14:18', payTime: '2026-06-24 14:19', payAmount: 198, redeemCode: 'CJ0000012399', payChannel: '微信支付', activatedTime: '2026-06-24 15:00' },
{ id: 'ORD20260623003', status: 'pending', createTime: '2026-06-23 09:45', payTime: '2026-06-23 09:46', payAmount: 198, redeemCode: 'CJ0000012400', payChannel: '微信支付' },
{ id: 'ORD20260620004', status: 'refunded', createTime: '2026-06-20 16:22', payTime: '2026-06-20 16:23', payAmount: 198, redeemCode: 'CJ0000012401', payChannel: '微信支付', refundedTime: '2026-06-21 10:15' },
{ id: 'ORD20260618005', status: 'activated', createTime: '2026-06-18 11:08', payTime: '2026-06-18 11:09', payAmount: 198, redeemCode: 'CJ0000012402', payChannel: '微信支付', activatedTime: '2026-06-18 12:30' },
{ id: 'ORD20260510007', status: 'activated', createTime: '2026-05-10 13:40', payTime: '2026-05-10 13:41', payAmount: 198, redeemCode: 'CJ0000012403', payChannel: '微信支付', activatedTime: '2026-05-10 14:20' },
]
function toStatus(payStatus: number | string): OrderStatus {
const status = Number(payStatus)
if (status === 1) return 'pending'
if (status === 2) return 'activated'
return 'refunded'
}
onLoad((query: any) => {
if (query?.orderId) {
const found = mockOrders.find(o => o.id === query.orderId)
if (found) {
order.value = { ...found }
} else {
order.value.id = query.orderId
let currentOrderId = ''
onLoad(async (query: any) => {
currentOrderId = query?.orderId || ''
await fetchOrderDetail()
})
async function fetchOrderDetail() {
if (!currentOrderId) return
const phone = (uni.getStorageSync('phone') as string) || ''
try {
const res = await post<any>('/mpOrder/getOrderDetail', { phone, orderId: currentOrderId })
if (res) {
order.value = {
id: res.id || currentOrderId,
payStatus: Number(res.payStatus) ?? 1,
orderStatus: res.orderStatus ?? 1,
createTime: res.createTime || '',
payTime: res.payTime || null,
payAmount: res.payAmount || 0,
payChannel: res.payChannel || '微信支付',
cardNo: res.cardNo || null,
redeemCode: res.redeemCode || null,
isBind: res.isBind ?? null,
bindName: res.bindName || null,
bindCertificateNo: res.bindCertificateNo || null,
cardStatus: res.cardStatus ?? null,
isActive: res.isActive ?? null,
activatedTime: res.activatedTime || null,
validStartDate: res.validStartDate || null,
validEndDate: res.validEndDate || null,
refundedTime: res.refundedTime || null,
}
}
} catch {
}
})
}
const orderStatus = computed(() => toStatus(order.value.payStatus))
const statusLabel = computed(() => {
const map: Record<OrderStatus, string> = {
pending: '待绑定',
activated: '已绑定',
pending: '待支付',
activated: '已支付',
refunded: '已退款',
expired: '已过期'
}
return map[order.value.status]
return map[orderStatus.value]
})
const statusDesc = computed(() => {
@@ -182,43 +214,77 @@ const statusDesc = computed(() => {
refunded: '退款审核通过,金额原路返还微信',
expired: '下单超过绑定有效期,卡号失效无法绑卡'
}
return map[order.value.status]
return map[orderStatus.value]
})
function copyCode() {
if (!order.value.redeemCode) return
uni.setClipboardData({
data: order.value.redeemCode,
success: () => uni.showToast({ title: '已复制', icon: 'success' })
})
}
async function goPay() {
const openId = (uni.getStorageSync('app_token') as string) || ''
const phone = (uni.getStorageSync('phone') as string) || ''
uni.showLoading({ title: '调起支付...', mask: true })
try {
const payParams = await post<any>(`${BASE_URL}/open-api/pingXiang/prepay`, {
openId,
orderNo: order.value.id,
description: phone,
amount: 1
})
uni.hideLoading()
uni.requestPayment({
provider: 'wxpay',
appId: payParams.appId,
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonce,
package: payParams.pack,
signType: payParams.signType,
paySign: payParams.paySign,
success: () => {
uni.showToast({ title: '支付成功', icon: 'success' })
fetchOrderDetail()
},
fail: () => {
fetchOrderDetail()
}
})
} catch (err: any) {
uni.hideLoading()
uni.showToast({ title: err.message || '支付失败', icon: 'none' })
}
}
function goActivate() {
if (!order.value.redeemCode) return
uni.navigateTo({
url: `/packages/user/pages/bind?redeemCode=${order.value.redeemCode}`
})
}
function handleRefund() {
async function handleRefund() {
uni.showModal({
title: '申请退款',
content: '赣湘全域旅游年卡/超级玩家卡绑定或销售七日以上不可退卡。确认申请退款?',
success: (res) => {
success: async (res) => {
if (res.confirm) {
order.value.status = 'refunded'
order.value.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
uni.showToast({ title: '退款申请已提交', icon: 'success' })
try {
await post(`${BASE_URL}/open-api/pingXiang/refund?orderNo=${order.value.id}`)
order.value.payStatus = 3
order.value.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
uni.showToast({ title: '退款申请已提交', icon: 'success' })
} catch (err: any) {
uni.showToast({ title: err.message || '退款失败', icon: 'none' })
}
}
}
})
}
function goFaq() {
uni.navigateTo({ url: '/packages/content/pages/faq' })
}
function goHome() {
uni.switchTab({ url: '/pages/index/index' })
}
</script>
<style lang="scss" scoped>
@@ -462,63 +528,6 @@ $bar-height: 120rpx;
font-weight: $font-weight-medium;
}
/* ===== 模块4:使用提示 ===== */
.tips-card {
background: rgba(60, 156, 255, 0.04);
border-radius: $radius-lg;
padding: $spacing-4;
margin-top: $spacing-3;
}
.tips-title {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-bold;
color: $uv-main-color;
margin-bottom: $spacing-2;
}
.tips-list {
margin-bottom: $spacing-3;
}
.tips-item {
display: flex;
align-items: flex-start;
margin-bottom: $spacing-2;
}
.tips-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: $uv-primary;
flex-shrink: 0;
margin-top: 14rpx;
margin-right: $spacing-2;
}
.tips-text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-tips-color;
line-height: 1.6;
}
.tips-link {
&:active {
opacity: 0.7;
}
}
.tips-link__text {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-primary;
font-weight: $font-weight-medium;
}
/* ===== 底部安全区 ===== */
.safe-bottom {
height: $spacing-3;
+124 -23
View File
@@ -59,7 +59,7 @@
<text class="product-name">超级玩家卡</text>
<view class="price-current">
<text class="price-symbol">¥</text>
<text class="price-value">{{ order.payAmount }}</text>
<text class="price-value">{{ (order.payAmount / 100).toFixed(2) }}</text>
</view>
</view>
</view>
@@ -72,10 +72,15 @@
<!-- 操作按钮 -->
<view class="card-actions" v-if="order.status === 'pending'">
<view class="btn-primary" @tap.stop="goPay(order.id)">
<text class="btn-primary__text">立即支付</text>
</view>
</view>
<view class="card-actions" v-else-if="order.status === 'activated'">
<view class="btn-outline" @tap.stop="showRefundModal(order)">
<text class="btn-outline__text">申请退款</text>
</view>
<view class="btn-primary" @tap.stop="goActivate(order)">
<view v-if="order.redeemCode" class="btn-primary" @tap.stop="goActivate(order)">
<text class="btn-primary__text">立即绑定</text>
</view>
</view>
@@ -117,14 +122,25 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { mockOrders, type OrderItem } from '../../mock/order'
import { onShow } from '@dcloudio/uni-app'
import { post, get } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
type OrderStatus = 'pending' | 'activated' | 'refunded' | 'expired'
interface OrderItem {
id: string
status: OrderStatus
createTime: string
payAmount: number
redeemCode: string | null
payChannel: string
}
const tabs = [
{ label: '全部', value: '' },
{ label: '待绑定', value: 'pending' },
{ label: '已绑定', value: 'activated' },
{ label: '待支付', value: 'pending' },
{ label: '已支付', value: 'activated' },
{ label: '已退款', value: 'refunded' },
]
@@ -132,11 +148,12 @@ const activeTab = ref('')
const loading = ref(false)
const refreshing = ref(false)
const noMore = ref(false)
const currentPage = ref(1)
const pageNo = ref(1)
const pageSize = 10
const refundPopup = ref<any>(null)
const refundOrderId = ref('')
const orders = ref<OrderItem[]>(mockOrders)
const orders = ref<OrderItem[]>([])
const filteredOrders = computed(() => {
if (!activeTab.value) return orders.value
@@ -145,34 +162,106 @@ const filteredOrders = computed(() => {
function statusLabel(status: OrderStatus) {
const map: Record<OrderStatus, string> = {
pending: '待绑定',
activated: '已绑定',
pending: '待支付',
activated: '已支付',
refunded: '已退款',
expired: '已过期'
}
return map[status]
}
function payStatusValue(tab: string): number | undefined {
if (tab === 'pending') return 1
if (tab === 'activated') return 2
if (tab === 'refunded') return 3
return undefined
}
async function fetchOrders(append = false) {
if (loading.value) return
loading.value = true
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpOrder/queryOrderList', {
phone,
payStatus: payStatusValue(activeTab.value),
pageNo: pageNo.value,
pageSize,
})
const list = (res.list || res.records || []).map((item: any) => ({
id: item.id || '',
status: item.payStatus === 1 ? 'pending' : item.payStatus === 2 ? 'activated' : 'refunded',
createTime: item.createTime || '',
payAmount: item.payAmount || 0,
redeemCode: item.redeemCode || null,
payChannel: item.payChannel || '微信支付',
}))
if (append) {
orders.value.push(...list)
} else {
orders.value = list
}
noMore.value = list.length < pageSize
} catch {
if (!append) orders.value = []
noMore.value = true
} finally {
loading.value = false
}
}
function switchTab(value: string) {
activeTab.value = value
pageNo.value = 1
fetchOrders()
}
function onRefresh() {
refreshing.value = true
currentPage.value = 1
noMore.value = false
setTimeout(() => {
refreshing.value = false
}, 800)
pageNo.value = 1
fetchOrders().finally(() => { refreshing.value = false })
}
function onLoadMore() {
if (loading.value || noMore.value) return
loading.value = true
setTimeout(() => {
loading.value = false
noMore.value = true
}, 800)
pageNo.value++
fetchOrders(true)
}
async function goPay(orderId: string) {
const openId = (uni.getStorageSync('app_token') as string) || ''
const phone = (uni.getStorageSync('phone') as string) || ''
uni.showLoading({ title: '调起支付...', mask: true })
try {
const payParams = await post<any>(`${BASE_URL}/open-api/pingXiang/prepay`, {
openId,
orderNo: orderId,
description: phone,
amount: 1
})
uni.hideLoading()
uni.requestPayment({
provider: 'wxpay',
appId: payParams.appId,
timeStamp: payParams.timeStamp,
nonceStr: payParams.nonce,
package: payParams.pack,
signType: payParams.signType,
paySign: payParams.paySign,
success: () => {
uni.showToast({ title: '支付成功', icon: 'success' })
pageNo.value = 1
fetchOrders()
},
fail: () => {
pageNo.value = 1
fetchOrders()
}
})
} catch (err: any) {
uni.hideLoading()
uni.showToast({ title: err.message || '支付失败', icon: 'none' })
}
}
function goDetail(orderId: string) {
@@ -180,6 +269,10 @@ function goDetail(orderId: string) {
}
function goActivate(order: OrderItem) {
if (!order.redeemCode) {
uni.showToast({ title: '暂无兑换码', icon: 'none' })
return
}
uni.navigateTo({
url: `/packages/user/pages/bind?redeemCode=${order.redeemCode}`
})
@@ -194,19 +287,27 @@ function closeRefund() {
refundPopup.value?.close()
}
function submitRefund() {
async function submitRefund() {
refundPopup.value?.close()
const order = orders.value.find(o => o.id === refundOrderId.value)
if (order) {
if (!order) return
try {
await post(`${BASE_URL}/open-api/pingXiang/refund?orderNo=${refundOrderId.value}`)
order.status = 'refunded'
order.refundedTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
uni.showToast({ title: '退款申请已提交', icon: 'success' })
} catch (err: any) {
uni.showToast({ title: err.message || '退款失败', icon: 'none' })
}
uni.showToast({ title: '退款申请已提交', icon: 'success' })
}
function goBuy() {
uni.navigateTo({ url: '/packages/user/pages/buy' })
}
onShow(() => {
pageNo.value = 1
fetchOrders()
})
</script>
<style lang="scss" scoped>
+220 -119
View File
@@ -1,6 +1,5 @@
<template>
<view class="page">
<!-- 景区基础信息卡片 -->
<view class="scenic-card">
<image class="scenic-img" :src="scenic.image" mode="aspectFill" />
<view class="scenic-info">
@@ -13,11 +12,10 @@
<view class="scenic-icon scenic-icon--clock" />
<text class="scenic-text">{{ scenic.hours }}</text>
</view>
<text class="scenic-tip">持赣湘全域旅游年卡免费入园,单人单日仅可预约1次</text>
<text class="scenic-tip">持{{ cardModelName }}免费入园,单人单日仅可预约1次</text>
</view>
</view>
<!-- 日期选择模块 -->
<view class="date-card">
<text class="section-title">选择预约日期</text>
<scroll-view scroll-x class="date-scroll">
@@ -27,19 +25,21 @@
:key="d.key"
class="date-item"
:class="{
'date-item--active': selectedDate === d.key && d.stock > 0,
'date-item--disabled': d.stock <= 0
'date-item--active': selectedDate === d.key,
'date-item--disabled': d.disabled || (d.stock != null && d.stock <= 0)
}"
@tap="d.stock > 0 && (selectedDate = d.key)"
@tap="!(d.disabled || (d.stock != null && d.stock <= 0)) && (selectedDate = d.key)"
>
<text class="date-week">{{ d.week }}</text>
<text class="date-day">{{ d.label }}</text>
<text v-if="d.stock > 0" class="date-stock">余{{ d.stock }}位</text>
<text v-if="d.disabled" class="date-stock date-stock--empty">不可约</text>
<text v-else-if="d.stock == null" class="date-stock">无限库存</text>
<text v-else-if="d.stock >= 50" class="date-stock">可预约</text>
<text v-else-if="d.stock > 0" class="date-stock">余{{ d.stock }}位</text>
<text v-else class="date-stock date-stock--empty">已约满</text>
</view>
</view>
</scroll-view>
<!-- 场次选择 -->
<view v-if="selectedSessions.length > 0" class="session-section">
<text class="session-title">选择入园场次</text>
<view class="session-list">
@@ -48,59 +48,50 @@
:key="s.id"
class="session-item"
:class="{
'session-item--active': selectedSession === s.id && s.stock > 0,
'session-item--disabled': s.stock <= 0
'session-item--active': selectedSession === s.id && (s.stock == null || s.stock > 0),
'session-item--disabled': s.stock != null && s.stock <= 0
}"
@tap="s.stock > 0 && (selectedSession = s.id)"
@tap="(s.stock == null || s.stock > 0) && (selectedSession = s.id)"
>
<text class="session-time">{{ s.time }}</text>
<text v-if="s.stock > 0" class="session-stock">余{{ s.stock }}位</text>
<text v-if="s.stock == null" class="session-stock session-stock--available">可预约</text>
<text v-else-if="s.stock >= 50" class="session-stock session-stock--available">可预约</text>
<text v-else-if="s.stock > 0" class="session-stock session-stock--limited">余{{ s.stock }}位</text>
<text v-else class="session-stock session-stock--empty">已约满</text>
</view>
</view>
</view>
<view v-else-if="selectedDate && selectedDateStock <= 0" class="no-session-tip">
<text class="no-session-tip__text">该日期暂无可预约场次</text>
<view v-else-if="selectedDateObj?.disabled" class="no-session-tip">
<text class="no-session-tip__text no-session-tip__text--error">{{ selectedDateObj.reason || '该日期不可预约' }}</text>
</view>
<view v-else-if="selectedDate" class="no-session-tip">
<view v-else-if="selectedDate && selectedDateStock === 0" class="no-session-tip">
<text class="no-session-tip__text">该日期已约满</text>
</view>
<view v-else-if="selectedDate && selectedDateStock > 0 && !selectedSessions.length" class="no-session-tip">
<text class="no-session-tip__text">该日期无需选择场次,直接预约即可</text>
</view>
<text class="date-tip">仅可预约近3日内时段,当日预约需提前1天取消</text>
<text class="date-tip">仅可预约近{{ appointmentDays || 3 }}日内时段</text>
</view>
<!-- 预约人实名信息 -->
<view class="user-card">
<text class="section-title">预约人信息</text>
<view class="user-row">
<text class="user-label">姓名</text>
<text class="user-value">{{ userInfo.name }}</text>
<text class="user-value">{{ userName }}</text>
</view>
<view class="user-row">
<text class="user-label">身份证号</text>
<text class="user-value">{{ maskedId }}</text>
<text class="user-value">{{ userIdCard }}</text>
</view>
<text class="user-warn">预约信息必须与持卡实名一致,不可更换他人</text>
</view>
<!-- 预约须知 -->
<view class="notice-card">
<view class="notice-card" v-if="bookingNotice">
<text class="notice-title">预约须知</text>
<view class="notice-list">
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">赣湘全域旅游年卡单人每日仅可预约该景区1次</text>
</view>
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">需至少提前1天线上取消预约,预约当日无法取消</text>
</view>
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">未提前取消且未入园,将扣除1次年卡使用次数,权益不可恢复</text>
</view>
<view class="notice-item">
<view class="notice-dot" />
<text class="notice-text">入园需携带本人身份证+年卡入园二维码,人证合一核验</text>
<text class="notice-text">{{ bookingNotice }}</text>
</view>
</view>
<view class="notice-link" @tap="goFaq">
@@ -108,10 +99,8 @@
</view>
</view>
<!-- 底部安全区占位 -->
<view class="safe-bottom" />
<!-- 底部固定预约按钮 -->
<view class="bottom-bar">
<button
class="btn-submit"
@@ -128,100 +117,212 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
const scenic = ref({
name: '武功山金顶景区',
address: '江西省萍乡市芦溪县武功山风景名胜区',
hours: '08:00-18:00',
image: 'https://images.unsplash.com/photo-1513415564515-763d91423bdd?fm=jpg&q=80&w=800&h=400&fit=crop'
})
const userInfo = ref({
name: '张三',
idCard: '360302199001011234'
})
const maskedId = computed(() => {
const id = userInfo.value.idCard
return id.slice(0, 6) + '****' + id.slice(-4)
})
// 生成近3天日期选项(明天起,含库存,第3天已约满)
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const mockStocks = [0, 200, 88]
const dateOptions = Array.from({ length: 3 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() + i + 1) // 从明天开始
const month = d.getMonth() + 1
const day = d.getDate()
return {
key: `${d.getFullYear()}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`,
week: i === 0 ? '明天' : weekNames[d.getDay()],
label: `${month}月${day}日`,
stock: mockStocks[i]
}
})
// 默认选中第一个有库存的日期
const firstAvailable = dateOptions.find(d => d.stock > 0)
const selectedDate = ref(firstAvailable ? firstAvailable.key : '')
// 场次数据(根据选中日期动态展示)
interface Session {
id: string
interface ReserveOptionSession {
id: string | number
time: string
stock: number
}
const sessionMap: Record<string, Session[]> = {
[dateOptions[0].key]: [
{ id: 's1', time: '08:00-10:00', stock: 56 },
{ id: 's2', time: '10:00-12:00', stock: 42 },
{ id: 's3', time: '14:00-16:00', stock: 58 },
],
[dateOptions[1].key]: [
{ id: 's4', time: '08:00-12:00', stock: 100 },
{ id: 's5', time: '12:00-18:00', stock: 100 },
],
interface DateOption {
key: string
week: string
label: string
stock: number | null
sessions: ReserveOptionSession[]
disabled?: boolean
reason?: string
}
const selectedSession = ref('')
const scenic = ref({ name: '', address: '', hours: '', image: '' })
const userName = ref('')
const userIdCard = ref('')
const bookingNotice = ref('')
const cardModelName = ref('')
const appointmentDays = ref<number | null>(null)
const reserveParams = ref({
scenicId: 0,
channel: '',
thirdId: '',
cardNum: '',
mobile: '',
cardModelName: ''
})
const dateOptions = ref<DateOption[]>([])
const selectedDate = ref('')
const selectedSession = ref<string | number>('')
const submitting = ref(false)
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
function formatDateDisplay(dateStr: string): { week: string; label: string } {
const d = new Date(dateStr)
const today = new Date()
today.setHours(0, 0, 0, 0)
const tomorrow = new Date(today)
tomorrow.setDate(tomorrow.getDate() + 1)
const diff = Math.round((d.getTime() - today.getTime()) / 86400000)
const week = diff === 1 ? '明天' : diff === 2 ? '后天' : weekNames[d.getDay()]
const label = `${d.getMonth() + 1}月${d.getDate()}日`
return { week, label }
}
const selectedDateObj = computed(() => {
if (!selectedDate.value) return null
return dateOptions.value.find(o => o.key === selectedDate.value) || null
})
const selectedSessions = computed(() => {
if (!selectedDate.value) return []
const sessions = sessionMap[selectedDate.value] || []
// 切换日期时自动选中第一个有库存的场次
if (!selectedDate.value || selectedDateObj.value?.disabled) return []
const d = dateOptions.value.find(d => d.key === selectedDate.value)
const sessions = d?.sessions || []
if (sessions.length > 0 && !sessions.find(s => s.id === selectedSession.value)) {
const available = sessions.find(s => s.stock > 0)
const available = sessions.find(s => s.stock == null || s.stock > 0)
selectedSession.value = available ? available.id : ''
}
return sessions
})
const selectedDateStock = computed(() => {
if (!selectedDate.value) return 0
const d = dateOptions.find(o => o.key === selectedDate.value)
return d ? d.stock : 0
if (!selectedDate.value) return -1
const d = dateOptions.value.find(o => o.key === selectedDate.value)
if (!d) return 0
return d.stock == null ? 1 : d.stock
})
onLoad((query: any) => {
if (query?.scenicId) {
// 实际项目中根据 scenicId 请求景区详情
onLoad(async (query: any) => {
const resourceId = query?.resourceId
if (!resourceId) return
const cardNumber = query?.cardNumber || ''
const cardModelId = query?.cardModelId || ''
const phone = (uni.getStorageSync('phone') as string) || ''
try {
const res = await post<any>('/mpReserve/queryReserveOptions', {
resourceId: parseInt(resourceId),
phone,
cardNumber: cardNumber || undefined,
})
if (res) {
scenic.value = {
name: res.scenicName || '',
address: res.address || '',
hours: res.hours || '',
image: res.scenicImage || ''
}
userName.value = res.userName || ''
userIdCard.value = res.userIdCard || ''
bookingNotice.value = Array.isArray(res.bookingNotice) ? res.bookingNotice.join('\n') : (res.bookingNotice || '')
cardModelName.value = res.cardModelName || ''
appointmentDays.value = (res.dates || []).length
reserveParams.value = {
scenicId: parseInt(resourceId),
channel: 'Channel_SZH',
thirdId: cardModelId,
cardNum: cardNumber,
mobile: phone,
cardModelName: res.cardModelName || ''
}
dateOptions.value = (res.dates || []).map((d: any) => ({
key: d.key,
week: d.week,
label: d.label,
stock: d.stock,
sessions: d.sessions || [],
disabled: d.disabled,
reason: d.reason,
}))
const firstAvailable = dateOptions.value.find(d => d.stock == null || d.stock > 0)
if (firstAvailable) selectedDate.value = firstAvailable.key
}
} catch (err: any) {
const mockStocks: (number | null)[] = [null, 200, 0]
const mockSessions: Record<string, ReserveOptionSession[]> = {
['mock0']: [
{ id: 's1', time: '08:00-10:00', stock: 56 },
{ id: 's2', time: '10:00-12:00', stock: 42 },
{ id: 's3', time: '14:00-16:00', stock: 58 },
],
['mock1']: [
{ id: 's4', time: '08:00-12:00', stock: 100 },
{ id: 's5', time: '12:00-18:00', stock: 100 },
],
}
dateOptions.value = Array.from({ length: 3 }, (_, i) => {
const d = new Date()
d.setDate(d.getDate() + i + 1)
const month = d.getMonth() + 1
const day = d.getDate()
const key = `${d.getFullYear()}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`
const { week, label } = formatDateDisplay(key)
return {
key,
week,
label,
stock: mockStocks[i],
sessions: mockSessions[`mock${i}`] || [],
}
})
appointmentDays.value = 3
scenic.value = {
name: '武功山金顶景区',
address: '江西省萍乡市芦溪县武功山风景名胜区',
hours: '08:00-18:00',
image: 'https://images.unsplash.com/photo-1513415564515-763d91423bdd?fm=jpg&q=80&w=800&h=400&fit=crop',
}
bookingNotice.value = '请提前10分钟到达景区检票口;\n入园需出示预约码和身份证;\n如无法按时入园请提前取消预约。'
userName.value = '张三'
userIdCard.value = '360302199001011234'
cardModelName.value = '赣湘全域旅游年卡'
const firstAvailable = dateOptions.value.find(d => d.stock == null || d.stock > 0)
if (firstAvailable) selectedDate.value = firstAvailable.key
}
})
function handleSubmit() {
if (!selectedDate.value) return
async function handleSubmit() {
if (!selectedDate.value || submitting.value) return
// Mock 预约成功
const reserveId = 'YP' + Date.now().toString().slice(-8)
// 获取选中场次的时间段
const session = selectedSessions.value.find(s => s.id === selectedSession.value)
const sessionTime = session ? session.time : ''
const sessionParam = sessionTime ? `&session=${encodeURIComponent(sessionTime)}` : ''
uni.navigateTo({
url: `/packages/user/pages/reserve/success?reserveId=${reserveId}&date=${selectedDate.value}&scenic=${encodeURIComponent(scenic.value.name)}${sessionParam}`
})
const dateObj = dateOptions.value.find(d => d.key === selectedDate.value)
if (dateObj?.disabled) {
uni.showToast({ title: dateObj.reason || '该日期不可预约', icon: 'none' })
return
}
if (dateObj && dateObj.stock != null && dateObj.stock <= 0) {
uni.showToast({ title: '该日期已约满', icon: 'none' })
return
}
submitting.value = true
try {
const session = selectedSessions.value.find(s => s.id === selectedSession.value)
const p = reserveParams.value
const result = await post<any>(`${BASE_URL}/zhy-api/cardBag/appoint`, {
scenicId: String(p.scenicId),
channel: p.channel,
thirdId: p.thirdId,
cardNum: p.cardNum,
mobile: p.mobile,
cardModelName: p.cardModelName,
appointDate: selectedDate.value,
timeId: session ? String(session.id) : '',
name: userName.value,
idNo: userIdCard.value,
})
uni.showToast({ title: '预约成功', icon: 'success' })
setTimeout(() => {
uni.navigateTo({
url: `/packages/user/pages/reserve/success?appointId=${result}&cardNumber=${p.cardNum}&cardModelId=${p.thirdId}`
})
}, 1500)
} catch (e: any) {
uni.showToast({ title: e?.message || '预约失败', icon: 'none' })
} finally {
submitting.value = false
}
}
function goFaq() {
@@ -239,7 +340,6 @@ $bar-height: 120rpx;
font-family: $font-family-body;
}
/* ===== 景区信息卡片 ===== */
.scenic-card {
margin: 24rpx 24rpx 0;
background: $color-card;
@@ -337,7 +437,6 @@ $bar-height: 120rpx;
line-height: 1.6;
}
/* ===== 日期选择 ===== */
.date-card {
margin: 24rpx 32rpx 0;
background: $color-card;
@@ -371,7 +470,7 @@ $bar-height: 120rpx;
align-items: center;
padding: 16rpx 12rpx;
border-radius: 16rpx;
border: 2rpx solid $uv-border-color;
border: 2rpx solid $uv-primary;
transition: all $transition-fast;
&--active {
@@ -406,10 +505,10 @@ $bar-height: 120rpx;
.date-stock {
font-family: $font-family-body;
font-size: $font-size-xxs;
color: $uv-placeholder-color;
color: $uv-main-color;
&--empty {
color: $uv-error;
color: $uv-placeholder-color;
}
}
@@ -418,15 +517,15 @@ $bar-height: 120rpx;
}
.date-item--disabled {
opacity: 0.5;
border-color: $uv-border-color;
.date-week,
.date-day {
.date-day,
.date-stock {
color: $uv-placeholder-color;
}
}
/* ===== 场次选择 ===== */
.session-section {
margin-top: 24rpx;
padding-top: 24rpx;
@@ -501,6 +600,11 @@ $bar-height: 120rpx;
color: $uv-placeholder-color;
}
.no-session-tip__text--error {
color: $uv-error;
font-weight: $font-weight-medium;
}
.date-tip {
display: block;
font-family: $font-family-body;
@@ -509,7 +613,6 @@ $bar-height: 120rpx;
margin-top: 16rpx;
}
/* ===== 预约人信息 ===== */
.user-card {
margin: 24rpx 32rpx 0;
background: $color-card;
@@ -545,7 +648,6 @@ $bar-height: 120rpx;
margin-top: 12rpx;
}
/* ===== 预约须知 ===== */
.notice-card {
margin: 24rpx 32rpx 0;
background: #eef4ff;
@@ -600,7 +702,6 @@ $bar-height: 120rpx;
font-weight: $font-weight-medium;
}
/* ===== 底部 ===== */
.safe-bottom {
height: 24rpx;
}
+162 -47
View File
@@ -98,7 +98,7 @@
<view class="code-modal">
<text class="code-modal__title">入园码</text>
<view class="qr-box">
<image class="qr-img" src="/static/qr-demo.png" mode="aspectFit" />
<image v-if="codeSrc" class="qr-img" :src="codeSrc" mode="aspectFit" />
</view>
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
</view>
@@ -162,7 +162,7 @@
</view>
<view class="sheet-row">
<text class="sheet-label">预约状态</text>
<text class="sheet-value">{{ statusLabel(detailRecord?.status || '') }}</text>
<text class="sheet-value">{{ detailStatusLabel(detailRecord?.status || '') }}</text>
</view>
<view class="sheet-row">
<text class="sheet-label">预约申请时间</text>
@@ -177,12 +177,12 @@
<text class="sheet-value">{{ detailRecord.cancelTime }}</text>
</view>
<!-- 核销人脸照片 -->
<view v-if="detailRecord?.verifyPhoto && detailRecord?.status !== 'auto_used'" class="sheet-photo">
<view v-if="detailRecord?.verifyPhoto" class="sheet-photo">
<text class="sheet-label">核销照片</text>
<image class="sheet-photo-avatar" :src="detailRecord.verifyPhoto" mode="aspectFill" @tap="previewPhoto(detailRecord.verifyPhoto)" />
</view>
<view v-if="detailRecord?.status !== 'used'" class="sheet-tip">
<text class="sheet-tip__text">未提前取消且未入园将扣除1次年卡使用次数</text>
<view v-if="detailRecord?.status !== 'used' && detailRecord?.cardInstruction" class="sheet-tip">
<text class="sheet-tip__text">{{ detailRecord.cardInstruction }}</text>
</view>
</scroll-view>
</view>
@@ -191,9 +191,12 @@
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { ref, computed, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
type RecordStatus = 'pending' | 'used' | 'cancelled' | 'auto_used'
type RecordStatus = 'pending' | 'cancelled' | 'used'
interface ReserveRecord {
id: string
@@ -209,46 +212,95 @@ interface ReserveRecord {
cancelTime?: string
verifyTime?: string
verifyPhoto?: string
cardInstruction?: string
cardModelId?: string
}
const tabs = [
{ label: '全部', value: '' },
{ label: '待入园', value: 'pending' },
{ label: '待使用', value: 'pending' },
{ label: '已入园', value: 'used' },
{ label: '已取消', value: 'cancelled' },
{ label: '自动核销', value: 'auto_used' },
]
const activeTab = ref('')
const loading = ref(false)
const refreshing = ref(false)
const noMore = ref(false)
const pageNo = ref(1)
const pageSize = 10
const codePopup = ref<any>(null)
const cancelPopup = ref<any>(null)
const detailPopup = ref<any>(null)
const popupCode = ref('')
const codeSrc = ref('')
const detailRecord = ref<ReserveRecord | null>(null)
const cancelRecord = ref<ReserveRecord | null>(null)
const records = ref<ReserveRecord[]>([
{ id: 'YP26062601', scenicName: '武功山金顶景区', date: '2026-06-27', session: '08:00-10:00', status: 'pending', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012398', applyTime: '2026-06-25 14:30' },
{ id: 'YP26062502', scenicName: '杨岐山孽龙洞景区', date: '2026-06-26', session: '10:00-12:00', status: 'pending', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012399', applyTime: '2026-06-24 10:15' },
{ id: 'YP26062003', scenicName: '仙凤三宝景区', date: '2026-06-20', session: '14:00-16:00', status: 'used', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012400', applyTime: '2026-06-18 09:20', verifyTime: '2026-06-20 09:45', verifyPhoto: 'https://images.unsplash.com/photo-1535713875002-d1d0cf377fde?fm=jpg&q=80&w=200&h=200&fit=crop' },
{ id: 'YP26061804', scenicName: '武功山君澜温泉', date: '2026-06-18', status: 'cancelled', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012401', applyTime: '2026-06-16 16:40', cancelTime: '2026-06-17 20:30' },
{ id: 'YP26061505', scenicName: '凯光新天地生态旅游区', date: '2026-06-15', status: 'auto_used', userName: '张三', maskedId: '360302****1234', cardType: '超级玩家卡', cardNo: 'CJ0000012402', applyTime: '2026-06-13 11:05', verifyTime: '2026-06-16 00:00', verifyPhoto: 'https://images.unsplash.com/photo-1527980965255-d3b416303d12?fm=jpg&q=80&w=200&h=200&fit=crop' },
{ id: 'YP26061006', scenicName: '和平农场·哎马乐园', date: '2026-06-10', session: '08:00-12:00', status: 'used', userName: '张三', maskedId: '360302****1234', cardType: '赣湘全域旅游年卡', cardNo: 'CJ0000012403', applyTime: '2026-06-08 08:50', verifyTime: '2026-06-10 10:22', verifyPhoto: 'https://images.unsplash.com/photo-1494790108377-be9c29b29330?fm=jpg&q=80&w=200&h=200&fit=crop' },
])
const records = ref<ReserveRecord[]>([])
const filteredRecords = computed(() => {
if (!activeTab.value) return records.value
return records.value.filter(r => r.status === activeTab.value)
})
const filteredRecords = computed(() => records.value)
function statusLabel(status: string) {
const map: Record<string, string> = { pending: '待入园', used: '已入园', cancelled: '已取消', auto_used: '自动核销' }
const map: Record<string, string> = { pending: '待使用', cancelled: '已取消', used: '已入园' }
return map[status] || status
}
function detailStatusLabel(status: number | string) {
const map: Record<string, string> = { '1': '预约成功', '2': '预约失败', '3': '已取消', '4': '已入园' }
return map[String(status)] || String(status)
}
async function fetchRecords(append = false) {
if (loading.value) return
loading.value = true
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpReserve/queryUsageRecordList', {
phone,
status: activeTab.value,
pageNo: pageNo.value,
pageSize,
})
const list = (res.list || res.records || []).map((item: any) => ({
id: item.id || '',
scenicName: item.scenicName || item.resourceName || '',
date: item.appTime || item.date || '',
session: item.sessionTime || item.session || '',
status: item.status || 'pending',
userName: item.name || item.userName || '',
maskedId: item.identityNo ? `${item.identityNo.slice(0, 6)}****${item.identityNo.slice(-4)}` : '',
cardType: item.cardModelName || item.cardType || '',
cardNo: item.cardNum || item.cardNo || '',
applyTime: item.createTime || item.applyTime || '',
cancelTime: item.cancelTime || '',
verifyTime: item.verifyTime || '',
verifyPhoto: item.verifyPhoto || '',
cardInstruction: item.cardInstruction || '',
cardModelId: item.cardModelId || item.thirdId || '',
}))
if (append) {
records.value.push(...list)
} else {
records.value = list
}
noMore.value = list.length < pageSize
} catch (_e) {
records.value = []
noMore.value = true
} finally {
loading.value = false
}
}
onLoad(() => {
fetchRecords()
})
watch(activeTab, () => {
pageNo.value = 1
fetchRecords()
})
function canCancel(date: string) {
const today = new Date()
today.setHours(0, 0, 0, 0)
@@ -261,40 +313,105 @@ function showCancelModal(r: ReserveRecord) {
cancelPopup.value?.open()
}
function confirmCancel() {
async function confirmCancel() {
cancelPopup.value?.close()
if (cancelRecord.value) {
const idx = records.value.findIndex(o => o.id === cancelRecord.value!.id)
if (idx !== -1) {
records.value[idx].status = 'cancelled'
records.value[idx].cancelTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
try {
await post<any>(`${BASE_URL}/zhy-api/cardBag/cancelAppoint`, {
appointId: cancelRecord.value.id,
channel: 'Channel_SZH',
})
const idx = records.value.findIndex(o => o.id === cancelRecord.value!.id)
if (idx !== -1) {
records.value[idx].status = 'cancelled'
records.value[idx].cancelTime = new Date().toISOString().slice(0, 16).replace('T', ' ')
}
uni.showToast({ title: '已取消预约', icon: 'success' })
} catch (e: any) {
uni.showToast({ title: e?.message || '取消失败', icon: 'none' })
}
uni.showToast({ title: '已取消预约', icon: 'success' })
}
}
function showCode(r: ReserveRecord) {
popupCode.value = r.id
codePopup.value?.open()
async function showCode(r: ReserveRecord) {
codeSrc.value = ''
try {
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${r.id}`)
if (res.qrType == 1) {
codeSrc.value = 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else if (res.qrType == 2) {
codeSrc.value = res.qrUrl
codePopup.value?.open()
} else {
await fetchQrByCard(r)
}
} catch (_e) {
await fetchQrByCard(r)
}
}
function viewDetail(r: ReserveRecord) {
detailRecord.value = r
async function fetchQrByCard(r: ReserveRecord) {
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/qrAndUserInfo`, {
cardNum: r.cardNo,
channel: 'Channel_SZH',
mobile: phone,
scenicId: '',
thirdId: r.cardModelId,
})
if (res.qrCode) {
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else {
uni.showToast({ icon: 'none', title: '暂无核销码' })
}
} catch (_e) {
uni.showToast({ icon: 'none', title: '获取入园码失败' })
}
}
async function viewDetail(r: ReserveRecord) {
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>('/mpReserve/getAppointmentDetail', { phone, appointId: r.id })
if (res) {
detailRecord.value = {
id: res.id || r.id,
scenicName: res.resourceName || r.scenicName,
date: res.appTime || r.date,
session: res.sessionTime || r.session,
status: res.status || r.status || 'pending',
userName: res.name || r.userName,
maskedId: res.identityNo ? `${res.identityNo.slice(0, 6)}****${res.identityNo.slice(-4)}` : r.maskedId,
cardType: res.cardModelName || r.cardType,
cardNo: res.cardNum || r.cardNo,
applyTime: res.createTime || r.applyTime,
cancelTime: res.cancelTime || r.cancelTime,
verifyTime: res.verifyTime || r.verifyTime,
verifyPhoto: res.verifyPhoto || r.verifyPhoto,
cardInstruction: res.cardInstruction || r.cardInstruction || '',
cardModelId: res.cardModelId || r.cardModelId || '',
}
}
} catch (_e) {
detailRecord.value = r
}
detailPopup.value?.open()
}
function onRefresh() {
async function onRefresh() {
refreshing.value = true
setTimeout(() => { refreshing.value = false }, 800)
pageNo.value = 1
await fetchRecords()
refreshing.value = false
}
function onLoadMore() {
async function onLoadMore() {
if (loading.value || noMore.value) return
loading.value = true
setTimeout(() => {
loading.value = false
noMore.value = true
}, 800)
pageNo.value++
await fetchRecords(true)
}
function goReserve() {
@@ -392,9 +509,8 @@ function previewPhoto(url: string) {
border-radius: 8rpx;
&--pending { background: #e8f3ff; }
&--used { background: #f0fff4; }
&--cancelled { background: $uv-bg-color; }
&--auto_used { background: #fff8e8; }
&--used { background: #f0fff4; }
}
.status-tag__text {
@@ -404,9 +520,8 @@ function previewPhoto(url: string) {
}
.status-tag--pending .status-tag__text { color: $uv-primary; }
.status-tag--used .status-tag__text { color: $color-status-success; }
.status-tag--cancelled .status-tag__text { color: $color-status-cancel; }
.status-tag--auto_used .status-tag__text { color: $color-status-pending; }
.status-tag--used .status-tag__text { color: $color-status-success; }
.record-card-info {
display: flex;
@@ -511,7 +626,7 @@ function previewPhoto(url: string) {
.qr-img { width: 300rpx; height: 300rpx; }
.code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; margin-top: 24rpx; }
.code-modal__desc { font-family: $font-family-body; font-size: $font-size-sm; color: $uv-tips-color; }
/* ===== Cancel Modal ===== */
.cancel-modal {
+163 -36
View File
@@ -1,13 +1,15 @@
<template>
<view class="page">
<!-- 成功图标区域 -->
<!-- 状态图标区域 -->
<view class="success-header">
<view class="success-icon">
<view class="success-circle" />
<view class="success-check" />
<view class="success-icon" :class="`success-icon--${status}`">
<view v-if="status === 1" class="success-check" />
<view v-else-if="status === 2" class="fail-cross" />
<view v-else-if="status === 3" class="cancel-icon" />
<view v-else-if="status === 4" class="used-icon" />
</view>
<text class="success-title">预约成功</text>
<text class="success-desc">请携带本人身份证+入园码按时入园</text>
<text class="success-title">{{ statusTitle }}</text>
<text class="success-desc">{{ statusDesc }}</text>
</view>
<!-- 预约信息卡片 -->
@@ -36,25 +38,25 @@
<text class="info-label">预约编号</text>
<text class="info-value">{{ reserveId }}</text>
</view>
<view class="info-row info-row--last">
<view v-if="status === 1" class="info-row info-row--last">
<text class="info-label">入园凭证</text>
<view class="code-btn" @tap="showCode">
<text class="code-btn__text">查看入园码</text>
</view>
</view>
<view v-else class="info-row info-row--last">
<text class="info-label">预约状态</text>
<text class="info-value">{{ statusLabel }}</text>
</view>
</view>
<!-- 重要提醒 -->
<view class="warn-card">
<view class="warn-card" v-if="cardInstruction">
<text class="warn-title">重要预约规则</text>
<view class="warn-list">
<view class="warn-item">
<view class="warn-item" v-for="(item, idx) in cardInstruction.split('\n')" :key="idx">
<view class="warn-dot" />
<text class="warn-text">预约当日无法取消,如需改期请至少提前1天操作</text>
</view>
<view class="warn-item">
<view class="warn-dot" />
<text class="warn-text">爽约未取消将扣除年卡使用次数,无法恢复</text>
<text class="warn-text">{{ item }}</text>
</view>
</view>
</view>
@@ -74,7 +76,7 @@
<view class="code-modal">
<text class="code-modal__title">入园码</text>
<view class="qr-box">
<image class="qr-img" src="/static/qr-demo.png" mode="aspectFit" />
<image v-if="codeSrc" class="qr-img" :src="codeSrc" mode="aspectFit" />
</view>
<text class="code-modal__desc">入园时请出示此二维码+本人身份证</text>
</view>
@@ -85,24 +87,56 @@
<script setup lang="ts">
import { ref } from 'vue'
import { onLoad, onBackPress } from '@dcloudio/uni-app'
import { post } from '../../../../api/request'
import { BASE_URL } from '../../../../config'
const reserveId = ref('YP26062601')
const scenicName = ref('武功山金顶景区')
const reserveDate = ref('2026-06-27(周六)')
const status = ref(1)
const statusTitle = ref('预约成功')
const statusDesc = ref('请携带本人身份证+入园码按时入园')
const statusLabel = ref('待入园')
const reserveId = ref('')
const scenicName = ref('')
const reserveDate = ref('')
const reserveSession = ref('')
const userName = ref('张三')
const maskedId = ref('360302****1234')
const userName = ref('')
const maskedId = ref('')
const cardInstruction = ref('')
const codeSrc = ref('')
const codePopup = ref<any>(null)
const cardNumber = ref('')
const cardModelId = ref('')
onLoad((query: any) => {
if (query?.reserveId) reserveId.value = query.reserveId
if (query?.date) {
const d = new Date(query.date)
const weekNames = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
reserveDate.value = `${query.date}(${weekNames[d.getDay()]})`
onLoad(async (query: any) => {
const appointId = query?.appointId
if (!appointId) return
const phone = (uni.getStorageSync('phone') as string) || ''
cardNumber.value = query?.cardNumber || ''
cardModelId.value = query?.cardModelId || ''
try {
const res = await post<any>('/mpReserve/getAppointmentDetail', { phone, appointId })
if (res) {
const s = Number(res.status) || 1
status.value = s
const statusMap: Record<number, { title: string; desc: string; label: string }> = {
1: { title: '预约成功', desc: '请携带本人身份证+入园码按时入园', label: '待入园' },
2: { title: '预约失败', desc: '预约申请未通过,请重新预约', label: '预约失败' },
3: { title: '已取消', desc: '该预约已被取消', label: '已取消' },
4: { title: '已入园', desc: '已成功核销入园', label: '已入园' },
}
const info = statusMap[s] || statusMap[1]
statusTitle.value = info.title
statusDesc.value = info.desc
statusLabel.value = info.label
reserveId.value = res.id || appointId
scenicName.value = res.resourceName || ''
reserveDate.value = res.appTime || ''
reserveSession.value = res.sessionTime || ''
userName.value = res.name || ''
maskedId.value = res.identityNo ? `${res.identityNo.slice(0, 6)}****${res.identityNo.slice(-4)}` : ''
cardInstruction.value = res.cardInstruction || ''
}
} catch (_e) {
}
if (query?.scenic) scenicName.value = decodeURIComponent(query.scenic)
if (query?.session) reserveSession.value = decodeURIComponent(query.session)
})
// 拦截返回,跳转预约记录页
@@ -111,8 +145,42 @@ onBackPress(() => {
return true
})
function showCode() {
codePopup.value?.open()
async function showCode() {
try {
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/getQrByAppointmentId?appointmentId=${reserveId.value}`)
if (res.qrType == 1) {
codeSrc.value = 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else if (res.qrType == 2) {
codeSrc.value = res.qrUrl
codePopup.value?.open()
} else {
await fetchQrByCard()
}
} catch (_e) {
await fetchQrByCard()
}
}
async function fetchQrByCard() {
try {
const phone = (uni.getStorageSync('phone') as string) || ''
const res = await post<any>(`${BASE_URL}/zhy-api/cardBag/qrAndUserInfo`, {
cardNum: cardNumber.value,
channel: 'Channel_SZH',
mobile: phone,
scenicId: '',
thirdId: cardModelId.value,
})
if (res.qrCode) {
codeSrc.value = res.qrCode.startsWith('data:') ? res.qrCode : 'data:image/png;base64,' + res.qrCode
codePopup.value?.open()
} else {
uni.showToast({ icon: 'none', title: '暂无核销码' })
}
} catch (_e) {
uni.showToast({ icon: 'none', title: '获取入园码失败' })
}
}
function goRecord() {
@@ -135,7 +203,7 @@ function goHome() {
font-family: $font-family-body;
}
/* ===== 成功图标 ===== */
/* ===== 状态图标 ===== */
.success-header {
display: flex;
flex-direction: column;
@@ -147,13 +215,13 @@ function goHome() {
width: 120rpx;
height: 120rpx;
position: relative;
}
.success-circle {
width: 120rpx;
height: 120rpx;
border-radius: 50%;
background: rgba(60, 156, 255, 0.1);
&--1 { background: rgba(60, 156, 255, 0.1); }
&--2 { background: rgba(245, 63, 63, 0.1); }
&--3 { background: rgba(153, 153, 153, 0.1); }
&--4 { background: rgba(60, 179, 113, 0.1); }
}
.success-check {
@@ -167,6 +235,65 @@ function goHome() {
left: 38rpx;
}
.fail-cross {
width: 48rpx;
height: 48rpx;
position: absolute;
top: 36rpx;
left: 36rpx;
&::before, &::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 48rpx;
height: 6rpx;
background: #f53f3f;
border-radius: 3rpx;
}
&::before { transform: translate(-50%, -50%) rotate(45deg); }
&::after { transform: translate(-50%, -50%) rotate(-45deg); }
}
.cancel-icon {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background: #999;
position: absolute;
top: 54rpx;
left: 54rpx;
&::before, &::after {
content: '';
position: absolute;
background: #999;
border-radius: 2rpx;
}
&::before {
width: 4rpx;
height: 32rpx;
top: -24rpx;
left: 4rpx;
}
&::after {
width: 24rpx;
height: 4rpx;
top: 4rpx;
left: -6rpx;
}
}
.used-icon {
width: 48rpx;
height: 24rpx;
border-left: 6rpx solid #3CB371;
border-bottom: 6rpx solid #3CB371;
transform: rotate(-45deg);
position: absolute;
top: 40rpx;
left: 38rpx;
}
.success-title {
font-family: $font-family-body;
font-size: $font-size-title;