foreign-admin-ui/src/views/realName/index.vue

1043 lines
24 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>
<div class="mobile-id-upload">
<!-- 顶部导航 -->
<div class="nav-bar">
<div class="nav-back" @click="goBack">
<el-icon><ArrowLeft /></el-icon>
</div>
<h1 class="nav-title">证件上传</h1>
<div class="nav-placeholder"></div>
</div>
<!-- 步骤指示 -->
<!-- <div class="step-bar">-->
<!-- <div class="step-item" :class="{ active: step >= 1 }">-->
<!-- <div class="step-num">1</div>-->
<!-- <span>正面</span>-->
<!-- </div>-->
<!-- <div class="step-line" :class="{ active: step >= 2 }"></div>-->
<!-- <div class="step-item" :class="{ active: step >= 2 }">-->
<!-- <div class="step-num">2</div>-->
<!-- <span>反面</span>-->
<!-- </div>-->
<!-- <div class="step-line" :class="{ active: step >= 3 }"></div>-->
<!-- <div class="step-item" :class="{ active: step >= 3 }">-->
<!-- <div class="step-num">3</div>-->
<!-- <span>确认</span>-->
<!-- </div>-->
<!-- </div>-->
<div class="content-box">
<!-- 证件类型 -->
<div class="type-selector">
<div
class="type-item"
:class="{ active: form.idType === 'idcard' }"
@click="switchType('idcard')"
>
<el-icon><Postcard /></el-icon>
<span>身份证</span>
</div>
<div
class="type-item"
:class="{ active: form.idType === 'passport' }"
@click="switchType('passport')"
>
<el-icon><Ticket /></el-icon>
<span>护照</span>
</div>
</div>
<!-- 拍摄提示 -->
<div class="shoot-tips">
<div class="tips-title">
<el-icon><InfoFilled /></el-icon>
拍摄要求
</div>
<div class="tips-grid">
<div class="tip-item">
<div class="tip-icon">📷</div>
<span>光线充足</span>
</div>
<div class="tip-item">
<div class="tip-icon"></div>
<span>背景纯色</span>
</div>
<div class="tip-item">
<div class="tip-icon"></div>
<span>手持平稳</span>
</div>
<div class="tip-item">
<div class="tip-icon">🔍</div>
<span>文字清晰</span>
</div>
</div>
</div>
<!-- 上传区域 -->
<div class="upload-section">
<!-- 正面 -->
<div class="upload-card" :class="{ uploaded: form.frontImage }">
<div class="card-label">
<span class="label-tag">必传</span>
{{ frontLabel }}
</div>
<div
class="upload-area"
@click="handleClickUpload('front')"
@touchstart="touchStart"
@touchend="touchEndFront"
>
<input
ref="frontInputRef"
type="file"
accept="image/*"
style="display: none"
@change="(e: Event) => handleFileChange(e, 'front')"
/>
<template v-if="form.frontImage">
<div class="preview-wrap">
<img
:src="form.frontPreview"
alt="正面"
@click.stop="previewImage(form.frontPreview)"
/>
<div class="preview-mask" @click.stop>
<el-icon @click="previewImage(form.frontPreview)"><ZoomIn /></el-icon>
<el-icon @click="removeImage('front')"><Delete /></el-icon>
</div>
</div>
</template>
<template v-else>
<div class="upload-inner" :class="{ pressing: frontPressing }">
<div class="camera-icon">
<el-icon><Camera /></el-icon>
<div class="camera-ring"></div>
</div>
<p class="upload-title">点击拍照或选择相册</p>
<p class="upload-desc">支持 JPGPNG最大 5MB</p>
</div>
</template>
</div>
</div>
<!-- 反面 -->
<div class="upload-card" :class="{ uploaded: form.backImage }">
<div class="card-label">
<span class="label-tag">必传</span>
{{ backLabel }}
</div>
<div
class="upload-area"
@click="handleClickUpload('back')"
@touchstart="touchStartBack"
@touchend="touchEndBack"
>
<input
ref="backInputRef"
type="file"
accept="image/*"
style="display: none"
@change="(e: Event) => handleFileChange(e, 'back')"
/>
<template v-if="form.backImage">
<div class="preview-wrap">
<img
:src="form.backPreview"
alt="反面"
@click.stop="previewImage(form.backPreview)"
/>
<div class="preview-mask" @click.stop>
<el-icon @click="previewImage(form.backPreview)"><ZoomIn /></el-icon>
<el-icon @click="removeImage('back')"><Delete /></el-icon>
</div>
</div>
</template>
<template v-else>
<div class="upload-inner" :class="{ pressing: backPressing }">
<div class="camera-icon">
<el-icon><Camera /></el-icon>
<div class="camera-ring"></div>
</div>
<p class="upload-title">点击拍照或选择相册</p>
<p class="upload-desc">支持 JPGPNG最大 5MB</p>
</div>
</template>
</div>
</div>
</div>
</div>
<!-- 底部提交 -->
<div class="bottom-bar">
<el-button
class="submit-btn"
type="primary"
size="large"
:loading="submitting"
:disabled="!isReady"
@click="handleSubmit"
>
{{ isReady ? '提交审核' : `请上传${!form.frontImage ? '正面' : '反面'}照片` }}
</el-button>
</div>
<!-- 大图预览 -->
<el-dialog
v-model="previewVisible"
:show-close="false"
width="90%"
class="preview-dialog"
destroy-on-close
>
<img :src="previewUrl" class="preview-img" alt="preview" />
<div class="preview-close" @click="previewVisible = false">
<el-icon><Close /></el-icon>
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, computed } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import {
ArrowLeft,
Postcard,
Ticket,
Camera,
ZoomIn,
Delete,
InfoFilled,
Close,
} from '@element-plus/icons-vue'
import { uploadCard } from '@/api/modules/livenessVerification'
import { useUserStore } from '@/stores/modules/user'
/* ==================== 类型定义 ==================== */
/** 证件类型 */
type IdType = 'idcard' | 'passport'
/** 照片面类型 */
type PhotoSide = 'front' | 'back'
/** 证件表单数据 */
interface IdForm {
idType: IdType
frontImage: File | null
frontPreview: string
backImage: File | null
backPreview: string
}
/** 提交载荷 */
interface SubmitPayload {
idType: IdType
frontImage: File
backImage: File
}
/** 上传错误类型 */
interface UploadError {
side: PhotoSide
message: string
}
/* ==================== 常量配置 ==================== */
const MAX_SIZE_MB = 5 // 最大文件大小MB
/* ==================== 响应式数据 ==================== */
const router = useRouter()
const form = reactive<IdForm>({
idType: 'idcard',
frontImage: null,
frontPreview: '',
backImage: null,
backPreview: '',
})
const errors = reactive<Record<PhotoSide, string>>({
front: '',
back: '',
})
const frontInputRef = ref<HTMLInputElement | null>(null)
const backInputRef = ref<HTMLInputElement | null>(null)
const submitting = ref<boolean>(false)
const previewVisible = ref<boolean>(false)
const previewUrl = ref<string>('')
const frontPressing = ref<boolean>(false)
const backPressing = ref<boolean>(false)
let touchTimer: ReturnType<typeof setTimeout> | null = null
// 标记是否已通过触摸事件处理(防止 touchEnd 和 click 双重触发)
let touchedFront = false
let touchedBack = false
/* ==================== 计算属性 ==================== */
const step = computed<number>(() => {
if (form.frontImage && form.backImage) return 3
if (form.frontImage || form.backImage) return 2
return 1
})
const frontLabel = computed<string>(() =>
form.idType === 'idcard' ? '身份证人像面' : '护照个人信息页',
)
const backLabel = computed<string>(() =>
form.idType === 'idcard' ? '身份证国徽面' : '护照封底/签证页',
)
const isReady = computed<boolean>(() => Boolean(form.frontImage && form.backImage))
const maxSizeBytes = computed<number>(() => MAX_SIZE_MB * 1024 * 1024)
/* ==================== 方法 ==================== */
/**
* 切换证件类型
* @param type - 目标证件类型
*/
const switchType = async (type: IdType): Promise<void> => {
if (form.frontImage || form.backImage) {
try {
await ElMessageBox.confirm('切换证件类型将清空已上传照片,是否继续?', '提示', {
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning',
})
resetAll()
form.idType = type
} catch {
// 用户取消,不做任何操作
}
} else {
form.idType = type
}
}
/** 重置所有数据 */
const resetAll = (): void => {
if (form.frontPreview) URL.revokeObjectURL(form.frontPreview)
if (form.backPreview) URL.revokeObjectURL(form.backPreview)
form.frontImage = null
form.frontPreview = ''
form.backImage = null
form.backPreview = ''
errors.front = ''
errors.back = ''
if (frontInputRef.value) frontInputRef.value.value = ''
if (backInputRef.value) backInputRef.value.value = ''
}
/**
* 触发文件选择
* @param side - 正面或反面
*/
const triggerUpload = (side: PhotoSide): void => {
const input = side === 'front' ? frontInputRef.value : backInputRef.value
input?.click()
}
/**
* 点击上传(带触摸防抖)
* 移动端touchEnd 已处理 → touched=true → click 跳过
* 桌面端:无 touch 事件 → touched=false → click 正常执行
*/
const handleClickUpload = (side: PhotoSide): void => {
const isTouched = side === 'front' ? touchedFront : touchedBack
if (isTouched) {
// 已经被 touchEnd 处理过了,跳过
return
}
// 桌面端或未经过触摸的情况,正常触发
triggerUpload(side)
}
/* ---------- 触摸反馈 ---------- */
const touchStart = (): void => {
frontPressing.value = true
touchedFront = true // 标记正面已触摸
if (touchTimer) clearTimeout(touchTimer)
}
/** 正面:触摸结束 → 触发上传 */
const touchEndFront = (): void => {
frontPressing.value = true
triggerUpload('front')
touchTimer = setTimeout(() => {
frontPressing.value = false
touchedFront = false // 延迟重置,允许下一次操作
}, 300) // 300ms > 浏览器 click 延迟
}
const touchStartBack = (): void => {
backPressing.value = true
touchedBack = true // 标记反面已触摸
if (touchTimer) clearTimeout(touchTimer)
}
/** 反面:触摸结束 → 触发上传 */
const touchEndBack = (): void => {
backPressing.value = true
triggerUpload('back')
touchTimer = setTimeout(() => {
backPressing.value = false
touchedBack = false // 延迟重置,允许下一次操作
}, 300) // 300ms > 浏览器 click 延迟
}
/* ---------- 图片处理 ---------- */
/**
* 验证文件
* @param file - 待验证文件
* @param side - 上传面
* @returns 是否通过验证
*/
const validateFile = (file: File, side: PhotoSide): boolean => {
errors[side] = ''
if (!file.type.startsWith('image/')) {
errors[side] = '仅支持图片文件'
ElMessage.error('请选择图片文件')
return false
}
if (file.size > maxSizeBytes.value) {
errors[side] = `文件大小不能超过 ${MAX_SIZE_MB}MB`
ElMessage.error(`文件过大,最大支持 ${MAX_SIZE_MB}MB`)
return false
}
return true
}
/**
* 处理文件选择变化
* @param event - 输入事件
* @param side - 上传面
*/
const handleFileChange = async (event: Event, side: PhotoSide): Promise<void> => {
const target = event.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
if (!validateFile(file, side)) {
target.value = ''
return
}
try {
// 创建预览 URL
const previewUrl = URL.createObjectURL(file)
if (side === 'front') {
// 释放旧的预览 URL
if (form.frontPreview) URL.revokeObjectURL(form.frontPreview)
form.frontImage = file
form.frontPreview = previewUrl
} else {
if (form.backPreview) URL.revokeObjectURL(form.backPreview)
form.backImage = file
form.backPreview = previewUrl
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '图片处理失败'
ElMessage.error(errorMsg)
} finally {
target.value = ''
}
}
/**
* 删除已上传图片
* @param side - 要删除的面
*/
const removeImage = async (side: PhotoSide): Promise<void> => {
try {
await ElMessageBox.confirm('确定删除这张照片?', '删除确认', {
confirmButtonText: '删除',
cancelButtonText: '保留',
type: 'warning',
})
if (side === 'front') {
if (form.frontPreview) URL.revokeObjectURL(form.frontPreview)
form.frontImage = null
form.frontPreview = ''
} else {
if (form.backPreview) URL.revokeObjectURL(form.backPreview)
form.backImage = null
form.backPreview = ''
}
const input = side === 'front' ? frontInputRef.value : backInputRef.value
if (input) input.value = ''
ElMessage.success('已删除')
} catch {
// 取消删除
}
}
/**
* 预览图片
* @param url - 图片地址
*/
const previewImage = (url: string): void => {
previewUrl.value = url
previewVisible.value = true
}
/** 提交表单 */
const handleSubmit = async (): Promise<void> => {
if (!isReady.value) return
submitting.value = true
try {
const formData = new FormData()
formData.append('type', form.idType === 'idcard' ? '1' : '2')
formData.append('card_1', form.frontImage!)
formData.append('card_2', form.backImage!)
await uploadCard(formData)
router.push('/livenessVerification')
} catch (err) {
const errorMsg = err instanceof Error ? err.message : '提交失败'
ElMessage.error(errorMsg)
} finally {
submitting.value = false
}
}
/** 返回上一页 */
const goBack = (): void => {
const userStore = useUserStore()
// real_check === 1 表示未认证状态real_check !== 1 表示已认证
const isRealNameVerified = userStore.userInfo?.real_check !== 1
console.log('real_check value:', userStore.userInfo?.real_check, 'isRealNameVerified:', isRealNameVerified)
if (form.frontImage || form.backImage) {
ElMessageBox.confirm('退出将丢失已上传的照片,是否继续?', '提示', {
confirmButtonText: '退出',
cancelButtonText: '留在页面',
type: 'warning',
})
.then(() => {
// 清理预览 URL
if (form.frontPreview) URL.revokeObjectURL(form.frontPreview)
if (form.backPreview) URL.revokeObjectURL(form.backPreview)
if (isRealNameVerified) {
console.log('已认证用户可以返回上一页')
// 已认证用户可以返回上一页
router.back()
} else {
// 未认证用户跳转到登录页
console.log('未实名验证只能去登录页')
router.push('/user/login')
}
})
.catch(() => { })
} else {
if (isRealNameVerified) {
console.log('已认证用户可以返回上一页')
// 已认证用户可以返回上一页
router.back()
} else {
// 未认证用户跳转到登录页
console.log('未实名验证只能去登录页')
router.push('/user/login')
}
}
}
</script>
<style scoped lang="scss">
.mobile-id-upload {
width: 100vw;
min-height: 100vh;
background: #f5f6fa;
padding-bottom: 100px;
-webkit-tap-highlight-color: transparent;
display: flex;
flex-direction: column;
align-items: center;
}
// ========== 导航栏 ==========
.nav-bar {
width: 100%;
position: sticky;
top: 0;
z-index: 100;
display: flex;
align-items: center;
justify-content: space-between;
height: 44px;
padding: 0 16px;
background: #fff;
border-bottom: 1px solid #f0f0f0;
.nav-back {
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: #333;
border-radius: 50%;
&:active {
background: #f5f5f5;
}
}
.nav-title {
font-size: 17px;
font-weight: 600;
color: #1a1a1a;
}
.nav-placeholder {
width: 32px;
}
}
.content-box {
width: 100%;
height: 100%;
margin: 0 auto;
overflow-y: auto;
}
// ========== 步骤条 ==========
.step-bar {
display: flex;
align-items: center;
justify-content: center;
padding: 20px 24px;
background: #fff;
margin-bottom: 12px;
.step-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
.step-num {
width: 28px;
height: 28px;
border-radius: 50%;
background: #e4e7ed;
color: #909399;
display: flex;
align-items: center;
justify-content: center;
font-size: 13px;
font-weight: 600;
transition: all 0.3s;
}
span {
font-size: 12px;
color: #909399;
}
&.active {
.step-num {
background: var(--el-color-primary);
color: #fff;
}
span {
color: var(--el-color-primary);
font-weight: 500;
}
}
}
.step-line {
width: 40px;
height: 2px;
background: #e4e7ed;
margin: 0 12px;
margin-bottom: 20px;
transition: all 0.3s;
&.active {
background: var(--el-color-primary);
}
}
}
// ========== 证件类型选择 ==========
.type-selector {
display: flex;
gap: 12px;
padding: 0 16px;
margin-top: 16px;
margin-bottom: 16px;
.type-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 16px 0;
background: #fff;
border-radius: 12px;
border: 2px solid transparent;
transition: all 0.2s;
.el-icon {
font-size: 28px;
color: #909399;
}
span {
font-size: 14px;
color: #606266;
}
&.active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
.el-icon,
span {
color: var(--el-color-primary);
}
}
&:active {
transform: scale(0.98);
}
}
}
// ========== 上传卡片 ==========
.upload-section {
padding: 0 16px;
display: flex;
flex-direction: column;
gap: 16px;
}
.upload-card {
background: #fff;
border-radius: 16px;
padding: 16px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
&.uploaded {
.upload-area {
border-color: var(--el-color-success);
background: var(--el-color-success-light-9);
}
}
}
.card-label {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 12px;
font-size: 15px;
font-weight: 600;
color: #1a1a1a;
.label-tag {
font-size: 11px;
padding: 2px 6px;
background: var(--el-color-danger-light-9);
color: var(--el-color-danger);
border-radius: 4px;
font-weight: 500;
}
}
// ========== 上传区域 ==========
.upload-area {
width: 100%;
aspect-ratio: 1.6;
border-radius: 12px;
border: 2px dashed #dcdfe6;
background: #fafbfc;
overflow: hidden;
position: relative;
transition: all 0.2s;
&:active {
border-color: var(--el-color-primary);
background: var(--el-color-primary-light-9);
}
}
.upload-inner {
width: 100%;
height: 100%;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
transition: transform 0.1s;
&.pressing {
transform: scale(0.96);
}
.camera-icon {
position: relative;
width: 64px;
height: 64px;
display: flex;
align-items: center;
justify-content: center;
.el-icon {
font-size: 36px;
color: var(--el-color-primary);
z-index: 2;
}
.camera-ring {
position: absolute;
inset: 0;
border-radius: 50%;
border: 2px solid var(--el-color-primary-light-5);
animation: pulse 2s infinite;
}
}
.upload-title {
font-size: 15px;
color: #303133;
font-weight: 500;
margin: 0;
}
.upload-desc {
font-size: 12px;
color: #909399;
margin: 0;
}
}
@keyframes pulse {
0%,
100% {
transform: scale(1);
opacity: 1;
}
50% {
transform: scale(1.1);
opacity: 0.6;
}
}
// ========== 预览 ==========
.preview-wrap {
width: 100%;
height: 100%;
position: relative;
img {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.preview-mask {
position: absolute;
inset: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
opacity: 0;
transition: opacity 0.2s;
.el-icon {
font-size: 28px;
color: #fff;
padding: 12px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
}
}
&:active .preview-mask {
opacity: 1;
}
}
// ========== 拍摄提示 ==========
.shoot-tips {
margin: 16px;
padding: 16px;
background: #fff;
border-radius: 16px;
.tips-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 14px;
font-weight: 600;
color: #303133;
margin-bottom: 12px;
.el-icon {
color: var(--el-color-primary);
}
}
.tips-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 8px;
.tip-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
.tip-icon {
font-size: 24px;
}
span {
font-size: 12px;
color: #606266;
}
}
}
}
// ========== 底部提交 ==========
.bottom-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
padding: 12px 16px 24px;
background: #fff;
border-top: 1px solid #f0f0f0;
z-index: 100;
.submit-btn {
width: 100%;
height: 48px;
font-size: 16px;
font-weight: 600;
border-radius: 24px;
&:disabled {
background: #c0c4cc;
border-color: #c0c4cc;
}
}
}
// ========== 预览弹窗 ==========
.preview-dialog {
:deep(.el-dialog) {
border-radius: 12px;
overflow: hidden;
margin-top: 15vh !important;
}
:deep(.el-dialog__body) {
padding: 0;
}
.preview-img {
width: 100%;
display: block;
}
.preview-close {
position: absolute;
top: 12px;
right: 12px;
width: 36px;
height: 36px;
border-radius: 50%;
background: rgba(0, 0, 0, 0.5);
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-size: 20px;
}
}
// ========== 加载遮罩 ==========
.loading-mask {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
color: #fff;
z-index: 9999;
.loading-icon {
font-size: 32px;
animation: rotate 1s linear infinite;
}
}
@keyframes rotate {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
// ========== 适配安全区 ==========
@supports (padding-bottom: env(safe-area-inset-bottom)) {
.bottom-bar {
padding-bottom: calc(24px + env(safe-area-inset-bottom));
}
.mobile-id-upload {
padding-bottom: calc(100px + env(safe-area-inset-bottom));
}
}
</style>