Files
pingxiangcard/src/packages/user/pages/bind.vue
T
lcj f0911c67a0 feat: 抖音核销接入小泥人API、公告详情页、景区预约人选人等多处优化
抖音核销(douyin-verify):
- 对接小泥人 OAuth2 token 与 voucher-list 券码接口,按 code 分组展示订单券号及可激活次数
- 支持手动输入券码验证券码;核销流程改为先调后端 sendAndBind 发卡绑定,再验证券码
- 购卡须知改为富文本渲染,新增无可核销订单提示弹窗、客服电话拨打
- 增加 product_id 过滤与 per-page=100,验证券码命中后仅展示不弹提示

公告模块:
- 首页/公告列表点击跳转到新增公告详情页(notice-detail),展示标题、发布时间、富文本内容及视频
- 公告列表卡片改为标题+发布时间+查看详情按钮,时间与按钮同排
- 富文本支持内嵌 video 解析播放;content 为空不显示分隔线;标题取公告 title 而非类型

首页与价格:
- 首页大卡价格/划线价/立省改为商品详情接口动态获取
- 热门景区列表扩展至8条,支持优惠/折扣标签与默认占位图

预约/景区:
- 预约页改为选择预约人(可选/已预约置灰),预约参数改用预约人信息
- 景区列表与详情支持优惠类型、折扣、特惠价展示,富文本图片防溢出
- 景区列表移除自定义导航改用原生导航

订单/绑卡:
- 订单列表/详情增加抖音核销单号(thirdOrderNo)展示,核销订单隐藏金额与退款入口
- 绑卡页移除人脸可选开关,照片必传改为拍照
- 其他:图片域名统一为 hn12301,tabBar'旅游卡'改名,登录页支持 redirect 回跳
2026-08-07 10:45:53 +08:00

827 lines
20 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">
<!-- 1. 头部标题区 -->
<view class="header">
<view class="header-title">
<text class="header-title__bind">{{ faceBindMode ? '人脸' : '绑定' }}</text>
<text class="header-title__card">{{ faceBindMode ? '绑定' : '赣湘全域旅游年卡/超级玩家卡' }}</text>
</view>
<text class="header-desc">{{ faceBindMode ? '请上传持卡人照片完成人脸绑定' : '请务必填写您的真实信息,以便于景区实名制预约' }}</text>
</view>
<!-- 2. 白色圆角表单卡片 -->
<view class="form-card">
<!-- 卡号 -->
<view class="form-row">
<text class="form-label">卡号</text>
<input
class="form-input"
v-model="form.cardNo"
placeholder="请输入卡号"
placeholder-class="placeholder"
:disabled="faceBindMode"
/>
</view>
<view class="divider" />
<!-- 持卡人姓名 -->
<view class="form-row">
<text class="form-label">持卡人姓名</text>
<input
class="form-input"
v-model="form.name"
placeholder="请输入持卡人姓名"
placeholder-class="placeholder"
:disabled="faceBindMode"
/>
</view>
<view class="divider" />
<!-- 持卡人手机 -->
<view class="form-row">
<text class="form-label">持卡人手机</text>
<input
class="form-input"
v-model="form.phone"
type="number"
maxlength="11"
disabled
placeholder-class="placeholder"
/>
</view>
<view class="divider" />
<!-- 证件类型(绑卡模式显示) -->
<view v-if="!faceBindMode" class="form-row" @tap="showIdTypePicker = true">
<text class="form-label">证件类型</text>
<text class="form-value">{{ idTypeLabel }}</text>
<view class="chevron-right" />
</view>
<view v-if="!faceBindMode" class="divider" />
<!-- 证件号码 -->
<view class="form-row">
<text class="form-label">证件号码</text>
<input
class="form-input"
v-model="form.idCard"
:placeholder="idPlaceholder"
placeholder-class="placeholder"
:maxlength="docType === '1' ? 18 : 30"
:disabled="faceBindMode"
/>
</view>
<view class="divider" />
<!-- 持卡人照片 -->
<view class="form-row form-row--photo">
<text class="form-label form-label--top">持卡人照片</text>
<view class="upload-area" @tap="photoUrl ? previewPhoto() : 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>
</view>
<view v-if="photoUrl" class="upload-remove" @tap.stop="removePhoto">
<view class="upload-remove__x" />
</view>
</view>
</view>
</view>
<!-- 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>
<view class="agreement__text">
<text class="agreement__label">我已阅读并知晓</text>
<text class="agreement__link" @tap.stop="openNotice">《用卡须知》</text>
</view>
</view>
<!-- 4. 底部操作按钮组 -->
<view class="btn-group">
<button
class="btn-confirm"
:class="{ 'btn-confirm--disabled': submitting }"
:disabled="submitting"
@tap="handleSubmit"
>
<text class="btn-confirm__text">{{ submitting ? '提交中...' : (faceBindMode ? '确认绑定人脸' : '确认绑卡') }}</text>
</button>
<button class="btn-cancel" @tap="handleCancel">
<text class="btn-cancel__text">取消</text>
</button>
</view>
<!-- 弹窗:证件类型选择 -->
<uni-popup ref="idTypePopup" type="bottom" :mask-click="true" mask-background="transparent" @change="(e: any) => showIdTypePicker = e.show">
<view class="popup-sheet">
<view class="popup-header">
<text class="popup-title">选择证件类型</text>
<view class="popup-close" @tap="showIdTypePicker = false">
<view class="popup-close__icon" />
</view>
</view>
<view class="id-type-list">
<view
v-for="item in idTypeOptions"
:key="item.value"
class="id-type-item"
:class="{ 'id-type-item--active': docType === item.value }"
@tap="selectIdType(item.value)"
>
<text class="id-type-item__text">{{ item.label }}</text>
<view v-if="docType === item.value" class="id-type-item__check" />
</view>
</view>
</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, computed, watch } from 'vue'
import { onLoad } from '@dcloudio/uni-app'
import { uploadImage, bindCard as apiBindCard, bindFaceImg } from '../api/card'
const faceBindMode = ref(false)
const form = reactive({
cardNo: '',
name: '',
phone: (uni.getStorageSync('phone') as string) || '',
idCard: ''
})
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] || '请输入证件号码'
})
const photoUrl = ref('')
const agreed = ref(false)
const submitting = ref(false)
const uploading = ref(false)
const showIdTypePicker = ref(false)
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 || ''
} else if (options?.redeemCode) {
form.cardNo = options.redeemCode
}
})
/** 打开用卡须知(跳转新页面) */
function openNotice() {
uni.navigateTo({ url: '/packages/user/pages/card-notice' })
}
watch(showIdTypePicker, (v) => {
if (v) idTypePopup.value?.open()
else idTypePopup.value?.close()
})
function selectIdType(value: string) {
docType.value = value
showIdTypePicker.value = false
}
function chooseImage() {
if (uploading.value) return
uni.chooseImage({
count: 1,
sizeType: ['original'],
sourceType: ['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] })
}
function removePhoto() {
photoUrl.value = ''
}
function validateForm() {
if (!form.cardNo.trim()) return '请输入卡号'
if (!form.name.trim()) return '请输入姓名'
if (!form.phone.trim()) return '请输入联系电话'
if (!/^1[3-9]\d{9}$/.test(form.phone)) return '手机号格式不正确'
if (!form.idCard.trim()) return `请输入${idTypeLabel.value}号码`
if (docType.value === '1' && !/^\d{17}[\dXx]$/.test(form.idCard)) return '身份证号格式不正确'
if (!photoUrl.value) return '请上传持卡人照片'
return ''
}
async function handleSubmit() {
if (!faceBindMode.value && !agreed.value) {
uni.showToast({ title: '请先阅读并知晓用卡须知', 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 {
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: faceBindMode.value ? '人脸绑定成功' : '绑卡成功', icon: 'success' })
setTimeout(() => {
uni.switchTab({ url: '/pages/qrcode/index' })
}, 1500)
} catch (err: any) {
uni.hideLoading()
uni.showToast({ title: err.message || (faceBindMode.value ? '人脸绑定失败' : '绑卡失败'), icon: 'none' })
} finally {
submitting.value = false
}
}
function handleCancel() {
uni.navigateBack({ fail: () => uni.switchTab({ url: '/pages/index/index' }) })
}
</script>
<style lang="scss" scoped>
/* ===== Design Tokens ===== */
/* ===== Page ===== */
.page {
height: 100vh;
overflow: hidden;
background: $color-card;
font-family: $font-family-body;
}
/* ===== 1. Header ===== */
.header {
padding: 40rpx 32rpx 0;
text-align: center;
}
.header-title {
display: flex;
flex-wrap: wrap;
justify-content: center;
align-items: baseline;
line-height: 48rpx;
}
.header-title__bind {
font-family: $font-family-body;
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $uv-main-color;
}
.header-title__card {
font-family: $font-family-body;
font-size: $font-size-title;
font-weight: $font-weight-bold;
color: $uv-primary;
}
.header-desc {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-tips-color;
line-height: 36rpx;
margin-top: 12rpx;
text-align: center;
}
/* ===== 2. Form Card ===== */
.form-card {
margin: 24rpx 24rpx 0;
background: $color-card;
border-radius: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.06);
overflow: hidden;
}
.form-row {
display: flex;
align-items: center;
height: 88rpx;
padding: 0 32rpx;
}
.form-row--photo {
align-items: flex-start;
height: auto;
padding: 24rpx 32rpx;
}
.form-label {
font-family: $font-family-body;
font-size: $font-size-sm;
color: $uv-main-color;
font-weight: 400;
line-height: 40rpx;
flex-shrink: 0;
width: 160rpx;
}
.form-label--top {
padding-top: 20rpx;
}
.form-input {
flex: 1;
height: 88rpx;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
line-height: 40rpx;
text-align: right;
}
.placeholder {
font-family: $font-family-body;
color: $uv-placeholder-color;
font-size: $font-size-sm;
font-weight: 400;
}
.divider {
height: 1rpx;
background: $uv-border-color;
margin: 0 32rpx;
}
/* ===== Form Value + Chevron ===== */
.form-value {
flex: 1;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
text-align: right;
}
.chevron-right {
width: 12rpx;
height: 12rpx;
border-top: 2rpx solid $uv-placeholder-color;
border-right: 2rpx solid $uv-placeholder-color;
transform: rotate(45deg);
flex-shrink: 0;
margin-left: 12rpx;
}
/* ===== Photo Upload ===== */
.upload-area {
width: 180rpx;
height: 180rpx;
border-radius: 16rpx;
background: #f7f7f7;
flex-shrink: 0;
margin-left: auto;
overflow: hidden;
position: relative;
transition: transform 0.15s ease;
&:active {
transform: scale(0.97);
}
}
.upload-placeholder {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
.upload-icon {
width: 60rpx;
height: 60rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='60' height='60' viewBox='0 0 24 24' fill='none' stroke='%23999' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2'/%3E%3Ccircle cx='12' cy='7' r='4'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
margin-bottom: 8rpx;
}
.upload-text {
font-family: $font-family-body;
font-size: 22rpx;
color: $uv-primary;
font-weight: 400;
}
.upload-preview {
width: 100%;
height: 100%;
}
.upload-remove {
position: absolute;
top: 8rpx;
right: 8rpx;
width: 40rpx;
height: 40rpx;
background: rgba(0, 0, 0, 0.5);
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
}
.upload-remove__x {
width: 16rpx;
height: 16rpx;
position: relative;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 16rpx;
height: 2rpx;
background: $color-card;
border-radius: 1rpx;
}
&::before {
transform: translate(-50%, -50%) rotate(45deg);
}
&::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
/* ===== 3. Agreement ===== */
.agreement {
display: flex;
align-items: flex-start;
margin: 24rpx 24rpx;
}
.checkbox {
flex-shrink: 0;
width: 20rpx;
height: 20rpx;
border: 2rpx solid #dadbde;
border-radius: 50%;
margin-right: 12rpx;
margin-top: 8rpx;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.checkbox--checked {
background: $uv-primary;
border-color: $uv-primary;
}
.checkbox__check {
width: 12rpx;
height: 12rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
.agreement__text {
display: flex;
flex-wrap: wrap;
align-items: center;
line-height: 36rpx;
}
.agreement__label {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-tips-color;
}
.agreement__link {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-primary;
&:active {
opacity: 0.7;
}
}
/* ===== 4. Buttons ===== */
.btn-group {
display: flex;
gap: 24rpx;
padding: 0 24rpx;
margin-top: 48rpx;
}
.btn-confirm {
flex: 1;
height: 96rpx;
border-radius: 48rpx;
background: #3c9cff;
border: none;
padding: 0;
margin: 0;
line-height: normal;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 6rpx 20rpx rgba(60, 156, 255, 0.28);
transition: all 0.2s ease;
&::after {
display: none;
}
&:active:not(.btn-confirm--disabled) {
transform: scale(0.98);
box-shadow: 0 2rpx 8rpx rgba(57, 138, 222, 0.2);
}
}
.btn-confirm--disabled {
background: $uv-bg-color;
box-shadow: none;
}
.btn-confirm__text {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-medium;
color: #ffffff;
letter-spacing: 2rpx;
}
.btn-confirm--disabled .btn-confirm__text {
color: $uv-placeholder-color;
}
.btn-cancel {
flex: 1;
height: 96rpx;
border-radius: 48rpx;
background: $color-card;
border: 2rpx solid #dadbde;
padding: 0;
margin: 0;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&::after {
display: none;
}
&:active {
transform: scale(0.98);
background: $uv-bg-color;
}
}
.btn-cancel__text {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: 400;
color: $uv-tips-color;
}
/* ===== Popup Sheet ===== */
.popup-sheet {
background: $color-card;
border-radius: 24rpx 24rpx 0 0;
max-height: 70vh;
display: flex;
flex-direction: column;
box-shadow: none;
padding-bottom: 0;
font-family: $font-family-body;
}
.popup-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 32rpx;
border-bottom: 1rpx solid $uv-border-color;
}
.popup-title {
font-family: $font-family-body;
font-size: $font-size-base;
font-weight: $font-weight-bold;
color: $uv-main-color;
line-height: 48rpx;
}
.popup-close {
width: 48rpx;
height: 48rpx;
display: flex;
align-items: center;
justify-content: center;
&:active {
opacity: 0.6;
}
}
.popup-close__icon {
width: 20rpx;
height: 20rpx;
position: relative;
&::before,
&::after {
content: '';
position: absolute;
top: 50%;
left: 50%;
width: 20rpx;
height: 3rpx;
background: $uv-tips-color;
border-radius: 2rpx;
}
&::before {
transform: translate(-50%, -50%) rotate(45deg);
}
&::after {
transform: translate(-50%, -50%) rotate(-45deg);
}
}
/* ===== ID Type Picker List ===== */
.id-type-list {
padding: 16rpx 0;
}
.id-type-item {
display: flex;
align-items: center;
justify-content: space-between;
height: 96rpx;
padding: 0 32rpx;
transition: background 0.15s ease;
&:active {
background: $uv-bg-color;
}
&--active {
background: rgba(60, 156, 255, 0.04);
}
}
.id-type-item__text {
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: 400;
color: $uv-content-color;
line-height: 40rpx;
}
.id-type-item--active .id-type-item__text {
color: $uv-primary;
font-weight: 400;
}
.id-type-item__check {
width: 24rpx;
height: 24rpx;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='none' stroke='%233c9cff' stroke-width='3' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 6 9 17l-5-5'/%3E%3C/svg%3E");
background-size: contain;
background-repeat: no-repeat;
background-position: center;
}
</style>