Files
pingxiangcard/src/packages/user/pages/bind.vue
T
2026-09-04 16:20:56 +08:00

907 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">
<!-- 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>
<!-- 5. 照片拍摄要求提示 -->
<view class="photo-tips">
<text class="photo-tips__title">照片拍摄要求</text>
<view class="photo-tips__item">
<view class="photo-tips__dot" />
<text class="photo-tips__text">图片仅允许1张人脸,多人脸直接驳回</text>
</view>
<view class="photo-tips__item">
<view class="photo-tips__dot" />
<text class="photo-tips__text">图片不能无人脸、侧脸过大、遮挡严重(口罩/帽子遮大半脸)</text>
</view>
<view class="photo-tips__item">
<view class="photo-tips__dot" />
<text class="photo-tips__text">不允许全身、远景、远距离人像照片</text>
</view>
<view class="photo-tips__item">
<view class="photo-tips__dot" />
<text class="photo-tips__text">不允许脸部极致特写、贴脸拍摄无留白</text>
</view>
<view class="photo-tips__item">
<view class="photo-tips__dot" />
<text class="photo-tips__text">图片清晰,无严重模糊、逆光、黑影、反光</text>
</view>
<view class="photo-tips__item">
<view class="photo-tips__dot" />
<text class="photo-tips__text">不允许翻拍屏幕、截图、证件复印件照片</text>
</view>
</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, '/open-api/oss/uploadWithFaceCheck')
photoUrl.value = url
uni.showToast({ title: '上传成功', icon: 'success' })
} catch (e) {
uni.showModal({
title: '上传失败',
content: (e as Error).message || '上传失败',
showCancel: false,
confirmText: '确定',
})
} 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 {
min-height: 100vh;
overflow-y: auto;
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;
}
/* ===== 5. Photo Tips ===== */
.photo-tips {
margin: 32rpx 24rpx;
padding: 28rpx;
background: #f7f8fa;
border-radius: 16rpx;
}
.photo-tips__title {
display: block;
font-family: $font-family-body;
font-size: $font-size-sm;
font-weight: $font-weight-medium;
color: $uv-main-color;
margin-bottom: 16rpx;
}
.photo-tips__item {
display: flex;
align-items: flex-start;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 0;
}
}
.photo-tips__dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
background: $uv-primary;
flex-shrink: 0;
margin-top: 14rpx;
margin-right: 12rpx;
}
.photo-tips__text {
flex: 1;
font-family: $font-family-body;
font-size: $font-size-xs;
font-weight: 400;
color: $uv-tips-color;
line-height: 36rpx;
}
/* ===== 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>