1043 lines
24 KiB
Vue
1043 lines
24 KiB
Vue
<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">支持 JPG、PNG,最大 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">支持 JPG、PNG,最大 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>
|