refactor: 拆分个人开户提交逻辑并新增确认弹窗(保存/取消)
parent
e49e75e7ed
commit
b5cd02452b
|
|
@ -1,50 +1,167 @@
|
|||
<!-- src/views/wallet/personal-open/PersonalOpenForm.vue -->
|
||||
<!-- 2.6.2 个人账户开立:选择个人客户回填资料 -> 补充手机号/职业/银行卡 -> 上传身份证 -> 提交草稿 ->
|
||||
展示申请编号,提供"刷新状态"手动轮询按钮(不做自动定时轮询)。 -->
|
||||
<!-- 2.6.2 个人账户开立(2026-07-22 按最新原型截图改造,2026-07-23 补充 create/edit/detail 三态):
|
||||
选择个人客户回填基础资料(只读,作为身份核验基准) -> 补充职业/银行卡/手机号 ->
|
||||
上传身份证正反面(触发OCR,识别结果与所选客户信息比对,不一致弹窗阻断提交)+正面免冠照
|
||||
(调用后端人脸识别接口 face-recognize,与所选客户的身份证号/姓名做匹配,识别通过才拿到可提交
|
||||
的文件编号) -> 勾选同意协议 -> 提交草稿 -> 展示申请编号,提供"刷新状态"手动轮询按钮(不做
|
||||
自动定时轮询)。
|
||||
选择客户改用与"客户管理 > 个人客户管理"页面一致的处理方式:先分页查询 /api/customer-info/personal/list
|
||||
(PersonalCustomerPicker.vue),选中后再调用 /api/customer-info/personal/detail 取完整字段回填,
|
||||
不使用本模块专属但字段未必对齐的 personal-opening/query-customer 接口。
|
||||
按产品要求(2026-07-22)本页面不需要短信验证码环节。
|
||||
mode(create/edit/detail)由 route.path 后缀推导,照抄 EnterpriseOpenForm.vue 的写法,避免
|
||||
复刻阶段3 PersonalForm/EnterpriseForm 的Bug。edit/detail 模式下的 list/detail/update 接口
|
||||
后端尚未提供,前端已按 enterprise-open-application 四件套的假设契约实现,detail/edit 模式
|
||||
下无法真正联调验证,详见《缺失的后端接口.md》Part 1 §1.10 与 Part 2 新增条目。
|
||||
已知缺口(详见《缺失的后端接口.md》):人脸识别接口 recode/recodeInfo 取值枚举未文档化
|
||||
(前端仅按"是否返回 fileNo"判断识别是否通过)、协议正文/URL无真实内容、职业码表为前端占位值、
|
||||
编辑/查看态因缺少"按 fileNo 回显图片"的接口,无法重新展示已上传的证件/人脸照片缩略图(与
|
||||
企业开户表单 LicenseUpload/IdCardUpload 在编辑态的现状一致,非本次新增的独立缺口)。 -->
|
||||
<template>
|
||||
<div class="personal-open-form-page">
|
||||
<a-page-header title="个人账户开立" @back="goBack" />
|
||||
|
||||
<a-card v-if="!applicationNo">
|
||||
<a-form ref="formRef" layout="vertical" :model="form" :rules="rules">
|
||||
<a-form-item label="所属渠道" required>
|
||||
<ChannelSelect v-model:model-value="channelNo" />
|
||||
</a-form-item>
|
||||
<a-form-item label="账户名称" required>
|
||||
<a-space>
|
||||
<span v-if="form.customerName">{{ form.customerName }}</span>
|
||||
<a-button @click="openPicker">选择客户</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item label="证件号码"><a-input :value="form.certificateNumber" disabled /></a-form-item>
|
||||
<a-form-item label="签发机关"><a-input :value="form.issuingAuthority" disabled /></a-form-item>
|
||||
<a-form-item label="证件地址"><a-input :value="form.certificateAddress" disabled /></a-form-item>
|
||||
<a-form-item label="手机号码" name="mobilePhone">
|
||||
<a-input v-model:value="form.mobilePhone" placeholder="请输入11位手机号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="职业" name="occupation">
|
||||
<a-input v-model:value="form.occupation" placeholder="请输入职业" />
|
||||
</a-form-item>
|
||||
<a-form-item label="绑定银行账号" name="bankCardNumber">
|
||||
<a-input v-model:value="form.bankCardNumber" placeholder="请输入银行卡号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="开户行号"><a-input v-model:value="form.bankNo" placeholder="请输入开户行号(选填)" /></a-form-item>
|
||||
<a-form-item label="开户行名称"><a-input v-model:value="form.bankName" placeholder="请输入开户行全称(选填)" /></a-form-item>
|
||||
<a-form-item label="身份证照片" required>
|
||||
<a-space size="large">
|
||||
<IdCardUpload side="front" :channel-no="channelNo" v-model:file-no="idCardFrontFileNo" />
|
||||
<IdCardUpload side="back" :channel-no="channelNo" v-model:file-no="idCardBackFileNo" />
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="submitting" @click="handleSubmit">提交</a-button>
|
||||
<a-page-header :title="pageTitle" @back="goBack">
|
||||
<template v-if="isCreate && !submittedApplicationNo" #extra>
|
||||
<a-space wrap>
|
||||
<a-checkbox v-model:checked="agreementChecked">本人已阅读并同意</a-checkbox>
|
||||
<a @click="showAgreement('service')">《浙江稠州商业银行用户支付服务协议》</a>
|
||||
<a @click="showAgreement('privacy')">《浙江稠州商业银行用户支付服务隐私政策》</a>
|
||||
<a-button type="primary" :disabled="!agreementChecked" @click="handleConfirmButtonClick">确认</a-button>
|
||||
<a-button @click="goBack">取消</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="isEdit" #extra>
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="submitting" @click="handleEditSubmit">保存</a-button>
|
||||
<a-button @click="goBack">取消</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card v-if="isDetail && (detailInfo.applicationStatus || detailInfo.failReason)" style="margin-bottom: 16px">
|
||||
<a-descriptions :column="2">
|
||||
<a-descriptions-item label="申请状态">{{ statusText }}</a-descriptions-item>
|
||||
<a-descriptions-item label="失败原因">{{ detailInfo.failReason || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="主账户账号">{{ detailInfo.mainAccountNo || '-' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="客户编号">{{ detailInfo.customerNo || '-' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
<a-button style="margin-top: 12px" :loading="polling" @click="handleQueryProgress">刷新状态</a-button>
|
||||
</a-card>
|
||||
|
||||
<a-card v-if="!(isCreate && submittedApplicationNo)">
|
||||
<div class="section-title">基础信息<span v-if="!isDetail" class="required-mark">*</span></div>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
layout="horizontal"
|
||||
:label-col="{ style: { width: '130px' } }"
|
||||
:wrapper-col="{ style: { flex: '1 1 auto', maxWidth: '420px' } }"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:disabled="isDetail"
|
||||
class="open-form"
|
||||
>
|
||||
<a-form-item label="渠道归属" required>
|
||||
<ChannelSelect v-model:model-value="channelNo" placeholder="请选择核心企业" style="width: 300px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="客户" required>
|
||||
<a-space>
|
||||
<a-input :value="form.customerName" placeholder="请选择客户" disabled style="width: 200px" />
|
||||
<a-button v-if="!isDetail" @click="openPicker">{{ form.customerId ? '重新选择' : '选择' }}</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item label="身份证号" required>
|
||||
<a-input :value="form.certificateNumber" disabled placeholder="请输入身份证号" />
|
||||
</a-form-item>
|
||||
<a-form-item label="证件生效日期" required>
|
||||
<a-date-picker :value="effectiveDateValue" disabled style="width: 100%" placeholder="请选择日期" />
|
||||
</a-form-item>
|
||||
<a-form-item label="证件失效日期" required>
|
||||
<a-space>
|
||||
<a-date-picker :value="expiryDateValue" disabled style="width: 200px" placeholder="请选择日期" />
|
||||
<a-checkbox :checked="isLongTerm" disabled>长期</a-checkbox>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
<a-form-item label="签发机关名称" required>
|
||||
<a-input :value="form.issuingAuthority" disabled placeholder="请输入签发机关名称" />
|
||||
</a-form-item>
|
||||
<a-form-item label="证件地址" required>
|
||||
<a-input :value="form.certificateAddress" disabled placeholder="请输入证件地址或常住地址" />
|
||||
</a-form-item>
|
||||
<a-form-item label="性别">
|
||||
<a-radio-group :value="form.gender" disabled>
|
||||
<a-radio value="MALE">男性</a-radio>
|
||||
<a-radio value="FEMALE">女性</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="民族">
|
||||
<a-select :value="form.ethnic" disabled :options="ethnicOptions" style="width: 200px" placeholder="-" />
|
||||
</a-form-item>
|
||||
<a-form-item label="职业" name="occupationType" required>
|
||||
<a-select v-model:value="form.occupationType" placeholder="请选择职业" style="width: 300px" allow-clear>
|
||||
<a-select-option v-for="opt in OCCUPATION_TYPE_OPTIONS" :key="opt.value" :value="opt.value">
|
||||
{{ opt.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="form.occupationType === 'OTHER'" label="职业备注" name="jobNote" required>
|
||||
<a-input v-model:value="form.jobNote" placeholder="职业为“其他”时请填写备注" style="width: 300px" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="证件照片" :required="isCreate">
|
||||
<a-space size="large" align="start">
|
||||
<div>
|
||||
<div class="upload-label">身份证人像面</div>
|
||||
<IdCardUpload
|
||||
side="front"
|
||||
:channel-no="channelNo"
|
||||
:readonly="isDetail"
|
||||
v-model:file-no="idCardFrontFileNo"
|
||||
v-model:uploading="idCardFrontUploading"
|
||||
@ocr-result="handleFrontOcrResult"
|
||||
@ocr-status="(s) => (frontOcrStatus = s)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="upload-label">身份证国徽面</div>
|
||||
<IdCardUpload
|
||||
side="back"
|
||||
:channel-no="channelNo"
|
||||
:readonly="isDetail"
|
||||
v-model:file-no="idCardBackFileNo"
|
||||
v-model:uploading="idCardBackUploading"
|
||||
@ocr-result="handleBackOcrResult"
|
||||
@ocr-status="(s) => (backOcrStatus = s)"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="upload-label">正面免冠照</div>
|
||||
<FacePhotoUpload
|
||||
:key="form.customerId"
|
||||
:channel-no="channelNo"
|
||||
:id-no="form.certificateNumber"
|
||||
:name="form.customerName"
|
||||
:readonly="isDetail"
|
||||
v-model:file-no="facePhotoFileNo"
|
||||
v-model:uploading="facePhotoUploading"
|
||||
/>
|
||||
</div>
|
||||
</a-space>
|
||||
<div v-if="!isDetail" class="id-card-hint">*请确保身份证信息在有效期范围内,若证件信息过期 需要去线下银行网点更新</div>
|
||||
<div v-if="identityMismatch" class="id-card-hint">
|
||||
身份证信息与所选客户信息不一致,请核实后重新上传身份证或重新选择客户,当前无法提交
|
||||
</div>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="银行卡号" name="bankCardNumber" required>
|
||||
<a-input v-model:value="form.bankCardNumber" placeholder="请输入银行卡号" style="width: 300px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="手机号" name="mobilePhone" required>
|
||||
<a-input v-model:value="form.mobilePhone" placeholder="请输入手机号" style="width: 300px" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-card>
|
||||
|
||||
<a-card v-else>
|
||||
<a-result status="success" title="开户申请已提交" :sub-title="`申请编号:${applicationNo}`">
|
||||
<a-result status="success" title="开户申请已提交" :sub-title="`申请编号:${submittedApplicationNo}`">
|
||||
<template #extra>
|
||||
<a-space direction="vertical" style="width: 100%">
|
||||
<div v-if="progress.applicationStatus">当前状态:{{ progressStatusText }}</div>
|
||||
|
|
@ -58,30 +175,103 @@
|
|||
</a-result>
|
||||
</a-card>
|
||||
|
||||
<WalletCustomerPicker ref="pickerRef" mode="personal" @select="handleSelectCustomer" />
|
||||
<a-modal
|
||||
v-model:open="confirmModalVisible"
|
||||
title="提示"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
@cancel="handleModalCancel"
|
||||
>
|
||||
<template v-if="confirmModalStep === 'confirm'">
|
||||
<p>是否确认开户?</p>
|
||||
<div class="confirm-modal-actions">
|
||||
<a-space>
|
||||
<a-button type="primary" :loading="modalSaving" :disabled="modalInviting" @click="handleModalSave">保存</a-button>
|
||||
<a-button :disabled="modalSaving || modalInviting" @click="handleModalCancel">取消</a-button>
|
||||
<a-button :loading="modalInviting" :disabled="modalSaving" @click="handleModalInvite">邀请扫码办理</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="qrcode-panel">
|
||||
<qrcode-vue :value="qrCodeUrl" :size="200" level="M" />
|
||||
<p class="qrcode-application-no">申请编号:{{ qrApplicationNo }}</p>
|
||||
<a-button @click="handleModalCancel">关闭</a-button>
|
||||
</div>
|
||||
</template>
|
||||
</a-modal>
|
||||
|
||||
<PersonalCustomerPicker ref="pickerRef" @select="handleSelectCustomer" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import { ref, reactive, computed, watch, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import ChannelSelect from '@/components/ChannelSelect.vue'
|
||||
import IdCardUpload from '@/components/IdCardUpload.vue'
|
||||
import WalletCustomerPicker from '@/components/WalletCustomerPicker.vue'
|
||||
import { createPersonalOpenDraftApi, queryPersonalOpenProgressApi } from '@/api/wallet'
|
||||
import FacePhotoUpload from '@/components/FacePhotoUpload.vue'
|
||||
import PersonalCustomerPicker from '@/components/PersonalCustomerPicker.vue'
|
||||
import {
|
||||
createPersonalOpenDraftApi,
|
||||
queryPersonalOpenProgressApi,
|
||||
fetchPersonalOpenDetailApi,
|
||||
updatePersonalOpenApplicationApi
|
||||
} from '@/api/wallet'
|
||||
import { fetchPersonalCustomerDetailApi } from '@/api/customer'
|
||||
import { phoneValidatorRule } from '@/utils/validators'
|
||||
import { showErrorModal } from '@/utils/errorModal'
|
||||
import { mapOcrGender } from '@/utils/ocrMapping'
|
||||
import { OCCUPATION_TYPE_OPTIONS, CERTIFICATE_LONG_TERM_DATE } from '@/constants/customerEnums'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const formRef = ref(null)
|
||||
const pickerRef = ref(null)
|
||||
const channelNo = ref(undefined)
|
||||
const agreementChecked = ref(false)
|
||||
|
||||
const mode = computed(() => {
|
||||
if (route.path.endsWith('/edit')) return 'edit'
|
||||
if (route.path.endsWith('/detail')) return 'detail'
|
||||
return 'create'
|
||||
})
|
||||
const isCreate = computed(() => mode.value === 'create')
|
||||
const isEdit = computed(() => mode.value === 'edit')
|
||||
const isDetail = computed(() => mode.value === 'detail')
|
||||
const pageTitle = computed(
|
||||
() => ({ create: '新增个人开户申请', edit: '编辑个人开户申请', detail: '查看个人开户申请' }[mode.value])
|
||||
)
|
||||
|
||||
const idCardFrontFileNo = ref('')
|
||||
const idCardBackFileNo = ref('')
|
||||
const facePhotoFileNo = ref('')
|
||||
const idCardFrontUploading = ref(false)
|
||||
const idCardBackUploading = ref(false)
|
||||
const facePhotoUploading = ref(false)
|
||||
const frontOcrStatus = ref('PENDING')
|
||||
const backOcrStatus = ref('PENDING')
|
||||
// 身份证正/反面 OCR 原始结果,选择客户后作为比对基准发生变化时需要重新核验,故保留原始数据而非只保留结论
|
||||
const frontOcrData = ref(null)
|
||||
const backOcrData = ref(null)
|
||||
const identityMismatch = ref(false)
|
||||
|
||||
const submitting = ref(false)
|
||||
const applicationNo = ref('')
|
||||
// 仅 create 模式下"提交成功后原地展示结果面板"使用,与 edit/detail 模式读取的 route.query.applicationNo 区分
|
||||
const submittedApplicationNo = ref('')
|
||||
|
||||
// "确认"按钮触发的二次确认弹窗:confirm=文案+三按钮态,qrcode=邀请扫码办理成功后的二维码展示态
|
||||
const confirmModalVisible = ref(false)
|
||||
const confirmModalStep = ref('confirm')
|
||||
const modalSaving = ref(false)
|
||||
const modalInviting = ref(false)
|
||||
const qrCodeUrl = ref('')
|
||||
const qrApplicationNo = ref('')
|
||||
const polling = ref(false)
|
||||
const progress = reactive({ applicationStatus: '', failReason: '' })
|
||||
const detailInfo = reactive({ applicationStatus: '', failReason: '', mainAccountNo: '', customerNo: '' })
|
||||
|
||||
const form = reactive({
|
||||
customerId: undefined,
|
||||
|
|
@ -94,17 +284,36 @@ const form = reactive({
|
|||
certificateAddress: '',
|
||||
gender: undefined,
|
||||
ethnic: '',
|
||||
occupation: '',
|
||||
occupationType: undefined,
|
||||
jobNote: '',
|
||||
mobilePhone: '',
|
||||
bankCardNumber: '',
|
||||
bankNo: '',
|
||||
bankName: ''
|
||||
bankCardNumber: ''
|
||||
})
|
||||
|
||||
const effectiveDateValue = computed(() => (form.certificateEffectiveDate ? dayjs(form.certificateEffectiveDate) : undefined))
|
||||
const isLongTerm = computed(
|
||||
() => !!form.certificateExpiryDate && String(form.certificateExpiryDate).startsWith(CERTIFICATE_LONG_TERM_DATE.slice(0, 10))
|
||||
)
|
||||
const expiryDateValue = computed(() =>
|
||||
!isLongTerm.value && form.certificateExpiryDate ? dayjs(form.certificateExpiryDate) : undefined
|
||||
)
|
||||
// 民族字段选择客户后只读展示,不提供可选枚举(避免拟定不准确的56民族码表),仅用当前值构造唯一选项以正常显示
|
||||
const ethnicOptions = computed(() => (form.ethnic ? [{ value: form.ethnic, label: form.ethnic }] : []))
|
||||
|
||||
const rules = {
|
||||
mobilePhone: [phoneValidatorRule()],
|
||||
occupation: [{ required: true, message: '请输入职业' }],
|
||||
bankCardNumber: [{ required: true, message: '请输入银行卡号' }]
|
||||
occupationType: [{ required: true, message: '请选择职业' }],
|
||||
jobNote: [
|
||||
{
|
||||
validator: (_rule, value) => {
|
||||
if (form.occupationType === 'OTHER' && !value) {
|
||||
return Promise.reject(new Error('职业为“其他”时请填写职业备注'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
}
|
||||
}
|
||||
],
|
||||
bankCardNumber: [{ required: true, message: '请输入银行卡号' }],
|
||||
mobilePhone: [phoneValidatorRule()]
|
||||
}
|
||||
|
||||
const progressStatusMap = {
|
||||
|
|
@ -115,51 +324,198 @@ const progressStatusMap = {
|
|||
APPLY_FAILED: '申请失败'
|
||||
}
|
||||
const progressStatusText = computed(() => progressStatusMap[progress.applicationStatus] || progress.applicationStatus)
|
||||
const statusText = computed(() => progressStatusMap[detailInfo.applicationStatus] || detailInfo.applicationStatus)
|
||||
|
||||
// 顶部协议链接:项目暂无真实协议正文/URL,以弹窗占位说明代替,详见《缺失的后端接口.md》
|
||||
const AGREEMENT_CONTENT = {
|
||||
service: {
|
||||
title: '浙江稠州商业银行用户支付服务协议',
|
||||
content: '协议正文内容待产品/法务提供,当前为占位说明,不代表最终协议条款。'
|
||||
},
|
||||
privacy: {
|
||||
title: '浙江稠州商业银行用户支付服务隐私政策',
|
||||
content: '隐私政策正文内容待产品/法务提供,当前为占位说明,不代表最终政策条款。'
|
||||
}
|
||||
}
|
||||
function showAgreement(type) {
|
||||
const info = AGREEMENT_CONTENT[type]
|
||||
Modal.info({ title: info.title, content: info.content, okText: '我知道了' })
|
||||
}
|
||||
|
||||
function openPicker() {
|
||||
if (!channelNo.value) {
|
||||
message.warning('请先选择所属渠道')
|
||||
showErrorModal('请先选择渠道归属')
|
||||
return
|
||||
}
|
||||
pickerRef.value.show()
|
||||
}
|
||||
|
||||
function handleSelectCustomer(record) {
|
||||
form.customerId = record.id
|
||||
form.customerName = record.customerName
|
||||
form.certificateType = record.certificateType || 'ID_CARD'
|
||||
form.certificateNumber = record.certificateNumber
|
||||
form.certificateEffectiveDate = record.certificateEffectiveDate
|
||||
form.certificateExpiryDate = record.certificateExpiryDate
|
||||
form.issuingAuthority = record.issuingAuthority
|
||||
form.certificateAddress = record.certificateAddress
|
||||
form.gender = record.gender
|
||||
form.ethnic = record.ethnicity
|
||||
form.occupation = record.occupation || ''
|
||||
form.mobilePhone = record.mobilePhone || ''
|
||||
// 比对身份证 OCR 结果与所选客户档案信息(身份证号/姓名/性别/民族),不一致则弹窗报错并阻断提交
|
||||
function runIdentityComparison() {
|
||||
if (!form.customerId) return
|
||||
const mismatches = []
|
||||
const front = frontOcrData.value
|
||||
if (front) {
|
||||
if (front.idcard && form.certificateNumber && front.idcard !== form.certificateNumber) mismatches.push('身份证号')
|
||||
if (front.name && form.customerName && front.name !== form.customerName) mismatches.push('姓名')
|
||||
const ocrGender = mapOcrGender(front.sex)
|
||||
if (ocrGender && form.gender && ocrGender !== form.gender) mismatches.push('性别')
|
||||
if (front.nation && form.ethnic && front.nation !== form.ethnic) mismatches.push('民族')
|
||||
}
|
||||
if (mismatches.length > 0) {
|
||||
identityMismatch.value = true
|
||||
showErrorModal(
|
||||
`身份证识别信息与所选客户档案不一致:${mismatches.join('、')},请核实后重新上传身份证或重新选择客户`,
|
||||
'身份信息核验失败'
|
||||
)
|
||||
} else {
|
||||
identityMismatch.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!channelNo.value) {
|
||||
message.warning('请先选择所属渠道')
|
||||
function handleFrontOcrResult(data) {
|
||||
frontOcrData.value = data
|
||||
runIdentityComparison()
|
||||
}
|
||||
function handleBackOcrResult(data) {
|
||||
backOcrData.value = data
|
||||
}
|
||||
|
||||
watch(idCardFrontFileNo, (val) => {
|
||||
if (!val) {
|
||||
frontOcrData.value = null
|
||||
runIdentityComparison()
|
||||
}
|
||||
})
|
||||
watch(idCardBackFileNo, (val) => {
|
||||
if (!val) backOcrData.value = null
|
||||
})
|
||||
|
||||
async function handleSelectCustomer(record) {
|
||||
// 与"客户管理 > 个人客户管理"页面一致的处理方式:列表接口字段有限,选中后需再查一次详情接口
|
||||
// 取得性别/民族/证件生效失效日期/签发机关/证件地址/职业等完整字段
|
||||
const res = await fetchPersonalCustomerDetailApi({ id: record.id })
|
||||
if (res.data.code !== 200) {
|
||||
showErrorModal(res.data.message || '获取客户详情失败')
|
||||
return
|
||||
}
|
||||
const detail = res.data.data
|
||||
form.customerId = detail.id
|
||||
form.customerName = detail.customerName || ''
|
||||
form.certificateType = detail.certificateType || 'ID_CARD'
|
||||
form.certificateNumber = detail.certificateNumber
|
||||
form.certificateEffectiveDate = detail.certificateEffectiveDate
|
||||
form.certificateExpiryDate = detail.certificateExpiryDate
|
||||
form.issuingAuthority = detail.issuingAuthority
|
||||
form.certificateAddress = detail.certificateAddress
|
||||
form.gender = detail.gender
|
||||
form.ethnic = detail.ethnicity
|
||||
form.mobilePhone = detail.mobilePhone || form.mobilePhone
|
||||
// occupation 目前是后端自由文本字段,仅当命中前端职业码表占位选项时才预选,否则留空由人工选择
|
||||
// (真实标准职业码表待后端/产品确认,详见《缺失的后端接口.md》)
|
||||
form.occupationType = OCCUPATION_TYPE_OPTIONS.some((opt) => opt.value === detail.occupation)
|
||||
? detail.occupation
|
||||
: undefined
|
||||
form.jobNote = ''
|
||||
// 选择/重新选择客户后,身份核验基准变化,需要用已上传身份证的 OCR 结果重新核验
|
||||
runIdentityComparison()
|
||||
// 人脸识别结果(fileNo)是针对"上一次选中的客户"的 idNo/name 匹配出来的,重新选择客户后不再有效,
|
||||
// 需要清空并要求重新采集/上传(FacePhotoUpload 上绑定了 :key="form.customerId" 会同步重置组件内部预览态)
|
||||
facePhotoFileNo.value = ''
|
||||
}
|
||||
|
||||
// fileType 编码:01身份证正面/02身份证反面/04正面免冠照,与 create-draft 提交时保持一致
|
||||
function applyFileList(fileList) {
|
||||
const files = fileList || []
|
||||
idCardFrontFileNo.value = files.find((f) => f.fileType === '01')?.fileNo || ''
|
||||
idCardBackFileNo.value = files.find((f) => f.fileType === '02')?.fileNo || ''
|
||||
facePhotoFileNo.value = files.find((f) => f.fileType === '04')?.fileNo || ''
|
||||
}
|
||||
|
||||
// edit/detail 模式加载申请单详情:personal-opening/detail 接口后端尚未提供,前端按
|
||||
// create-draft 请求体反推的假设契约实现,详见《缺失的后端接口.md》Part 2 新增条目
|
||||
async function loadDetail() {
|
||||
const res = await fetchPersonalOpenDetailApi({ applicationNo: route.query.applicationNo })
|
||||
if (res.data.code !== 200) {
|
||||
showErrorModal(res.data.message || '获取申请详情失败')
|
||||
return
|
||||
}
|
||||
const data = res.data.data || {}
|
||||
channelNo.value = data.channelNo
|
||||
Object.assign(form, {
|
||||
customerId: data.customerId,
|
||||
customerName: data.customerName,
|
||||
certificateType: data.certificateType || 'ID_CARD',
|
||||
certificateNumber: data.certificateNumber,
|
||||
certificateEffectiveDate: data.certificateEffectiveDate,
|
||||
certificateExpiryDate: data.certificateExpiryDate,
|
||||
issuingAuthority: data.issuingAuthority,
|
||||
certificateAddress: data.certificateAddress,
|
||||
gender: data.gender,
|
||||
ethnic: data.ethnic,
|
||||
occupationType: data.occupation,
|
||||
jobNote: data.jobNote || '',
|
||||
mobilePhone: data.mobilePhone,
|
||||
bankCardNumber: data.bankCardNumber
|
||||
})
|
||||
applyFileList(data.fileList)
|
||||
detailInfo.applicationStatus = data.applicationStatus
|
||||
detailInfo.failReason = data.failReason
|
||||
detailInfo.mainAccountNo = data.mainAccountNo
|
||||
detailInfo.customerNo = data.customerNo
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
if (!isCreate.value && route.query.applicationNo) {
|
||||
loadDetail()
|
||||
}
|
||||
})
|
||||
|
||||
// 提交前的全部前置校验:协议勾选/渠道/客户/证件照片上传状态/身份证信息一致性/表单必填项。
|
||||
// 供"确认"按钮预检(create 模式,通过后弹二次确认弹窗)与编辑模式"保存"按钮共用。
|
||||
async function validateBeforeSubmit() {
|
||||
if (isCreate.value && !agreementChecked.value) {
|
||||
showErrorModal('请先阅读并勾选同意用户支付服务协议与隐私政策')
|
||||
return false
|
||||
}
|
||||
if (!channelNo.value) {
|
||||
showErrorModal('请先选择渠道归属')
|
||||
return false
|
||||
}
|
||||
if (!form.customerId) {
|
||||
message.warning('请先选择账户客户')
|
||||
return
|
||||
showErrorModal('请先选择客户')
|
||||
return false
|
||||
}
|
||||
if (!idCardFrontFileNo.value || !idCardBackFileNo.value) {
|
||||
message.warning('请上传身份证正反面照片')
|
||||
return
|
||||
if (idCardFrontUploading.value || idCardBackUploading.value || facePhotoUploading.value) {
|
||||
showErrorModal('证件照片正在上传中,请稍候')
|
||||
return false
|
||||
}
|
||||
// 编辑模式下若未重新上传,保留 loadDetail 回填的原 fileNo 即可提交,故只在创建时强制校验必须已上传
|
||||
if (isCreate.value && (!idCardFrontFileNo.value || !idCardBackFileNo.value || !facePhotoFileNo.value)) {
|
||||
showErrorModal('请上传身份证人像面、国徽面和正面免冠照')
|
||||
return false
|
||||
}
|
||||
if (identityMismatch.value) {
|
||||
showErrorModal('身份证信息与所选客户信息不一致,请核实后重新上传身份证或重新选择客户')
|
||||
return false
|
||||
}
|
||||
try {
|
||||
await formRef.value.validate()
|
||||
return true
|
||||
} catch {
|
||||
return
|
||||
return false
|
||||
}
|
||||
submitting.value = true
|
||||
try {
|
||||
const res = await createPersonalOpenDraftApi({
|
||||
}
|
||||
|
||||
// 构造提交给 create-draft/update 的公共 payload 与证件文件列表,create/edit 提交内容一致,仅
|
||||
// 文件字段名不同(update 接口要求 accountOpenApplicationFileBtoList,由调用方自行拼接)。
|
||||
function buildSubmitPayload() {
|
||||
const fileList = [
|
||||
{ fileNo: idCardFrontFileNo.value, fileType: '01' },
|
||||
{ fileNo: idCardBackFileNo.value, fileType: '02' },
|
||||
{ fileNo: facePhotoFileNo.value, fileType: '04' }
|
||||
]
|
||||
const payload = {
|
||||
accountProperty: '1',
|
||||
channelNo: channelNo.value,
|
||||
customerId: form.customerId,
|
||||
|
|
@ -172,19 +528,44 @@ async function handleSubmit() {
|
|||
certificateAddress: form.certificateAddress,
|
||||
gender: form.gender,
|
||||
ethnic: form.ethnic,
|
||||
occupation: form.occupation,
|
||||
occupation: form.occupationType,
|
||||
jobNote: form.occupationType === 'OTHER' ? form.jobNote : undefined,
|
||||
mobilePhone: form.mobilePhone,
|
||||
bankCardNumber: form.bankCardNumber,
|
||||
bankNo: form.bankNo,
|
||||
bankName: form.bankName,
|
||||
fileList: [
|
||||
{ fileNo: idCardFrontFileNo.value, fileType: '01' },
|
||||
{ fileNo: idCardBackFileNo.value, fileType: '02' }
|
||||
]
|
||||
bankCardNumber: form.bankCardNumber
|
||||
}
|
||||
return { payload, fileList }
|
||||
}
|
||||
|
||||
// 提交个人开户草稿(create-draft),成功时记录 applicationNo 并返回,失败返回 null。
|
||||
// 失败时的错误提示由 src/api/request.js 的响应拦截器统一弹出,这里不重复提示。
|
||||
async function submitCreateDraft() {
|
||||
const { payload, fileList } = buildSubmitPayload()
|
||||
const res = await createPersonalOpenDraftApi({ ...payload, fileList })
|
||||
if (res.data.code === 200) {
|
||||
submittedApplicationNo.value = res.data.data.applicationNo
|
||||
return res.data.data.applicationNo
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// 编辑模式"保存"按钮:校验通过后调用 update 接口,行为与改造前的 handleSubmit 编辑分支完全一致。
|
||||
async function handleEditSubmit() {
|
||||
const valid = await validateBeforeSubmit()
|
||||
if (!valid) return
|
||||
submitting.value = true
|
||||
try {
|
||||
const { payload, fileList } = buildSubmitPayload()
|
||||
// 已知缺口:personal-opening/update 接口尚未提供,前端按 applicationNo 作为定位字段假设实现,
|
||||
// 详见《缺失的后端接口.md》Part 2 新增条目;证件照片参数名与 create-draft 不同,后端要求为
|
||||
// accountOpenApplicationFileBtoList
|
||||
const res = await updatePersonalOpenApplicationApi({
|
||||
applicationNo: route.query.applicationNo,
|
||||
...payload,
|
||||
accountOpenApplicationFileBtoList: fileList
|
||||
})
|
||||
if (res.data.code === 200) {
|
||||
applicationNo.value = res.data.data.applicationNo
|
||||
message.success('申请已提交')
|
||||
message.success('修改成功')
|
||||
goBack()
|
||||
}
|
||||
} finally {
|
||||
submitting.value = false
|
||||
|
|
@ -194,16 +575,88 @@ async function handleSubmit() {
|
|||
async function handleQueryProgress() {
|
||||
polling.value = true
|
||||
try {
|
||||
const res = await queryPersonalOpenProgressApi({ applicationNo: applicationNo.value })
|
||||
const res = await queryPersonalOpenProgressApi({ applicationNo: submittedApplicationNo.value || route.query.applicationNo })
|
||||
if (res.data.code === 200) {
|
||||
if (isDetail.value) {
|
||||
Object.assign(detailInfo, {
|
||||
applicationStatus: res.data.data.applicationStatus,
|
||||
failReason: res.data.data.failReason,
|
||||
mainAccountNo: res.data.data.mainAccountNo,
|
||||
customerNo: res.data.data.customerNo
|
||||
})
|
||||
} else {
|
||||
Object.assign(progress, res.data.data)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
polling.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// "确认"按钮点击:校验通过后打开二次确认弹窗,不直接提交
|
||||
async function handleConfirmButtonClick() {
|
||||
const valid = await validateBeforeSubmit()
|
||||
if (!valid) return
|
||||
confirmModalStep.value = 'confirm'
|
||||
confirmModalVisible.value = true
|
||||
}
|
||||
|
||||
// 弹窗内"保存":与原有提交逻辑一致,成功后关闭弹窗并展示"已提交"结果面板
|
||||
async function handleModalSave() {
|
||||
modalSaving.value = true
|
||||
try {
|
||||
const applicationNo = await submitCreateDraft()
|
||||
if (applicationNo) {
|
||||
message.success('申请已提交')
|
||||
confirmModalVisible.value = false
|
||||
}
|
||||
} finally {
|
||||
modalSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 弹窗内"取消"/关闭图标/邀请扫码办理成功后的"关闭"按钮:统一只关闭弹窗,不发请求、不跳转
|
||||
function handleModalCancel() {
|
||||
confirmModalVisible.value = false
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/wallet/account')
|
||||
router.push('/wallet/personal-open')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-title {
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.required-mark {
|
||||
color: #ff4d4f;
|
||||
margin-left: 2px;
|
||||
}
|
||||
.open-form {
|
||||
max-width: 680px;
|
||||
}
|
||||
.upload-label {
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.id-card-hint {
|
||||
color: #ff4d4f;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.confirm-modal-actions {
|
||||
margin-top: 24px;
|
||||
text-align: right;
|
||||
}
|
||||
.qrcode-panel {
|
||||
text-align: center;
|
||||
}
|
||||
.qrcode-application-no {
|
||||
margin: 12px 0 8px;
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
Loading…
Reference in New Issue