687 lines
28 KiB
Vue
687 lines
28 KiB
Vue
<!-- src/views/wallet/personal-open/PersonalOpenForm.vue -->
|
|
<!-- 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="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="`申请编号:${submittedApplicationNo}`">
|
|
<template #extra>
|
|
<a-space direction="vertical" style="width: 100%">
|
|
<div v-if="progress.applicationStatus">当前状态:{{ progressStatusText }}</div>
|
|
<div v-if="progress.failReason" style="color: #ff4d4f">失败原因:{{ progress.failReason }}</div>
|
|
<a-space>
|
|
<a-button :loading="polling" @click="handleQueryProgress">刷新状态</a-button>
|
|
<a-button @click="goBack">返回列表</a-button>
|
|
</a-space>
|
|
</a-space>
|
|
</template>
|
|
</a-result>
|
|
</a-card>
|
|
|
|
<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, watch, onMounted } from 'vue'
|
|
import { useRoute, useRouter } from 'vue-router'
|
|
import { message, Modal } from 'ant-design-vue'
|
|
import dayjs from 'dayjs'
|
|
import QrcodeVue from 'qrcode.vue'
|
|
import ChannelSelect from '@/components/ChannelSelect.vue'
|
|
import IdCardUpload from '@/components/IdCardUpload.vue'
|
|
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)
|
|
// 仅 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,
|
|
customerName: '',
|
|
certificateType: 'ID_CARD',
|
|
certificateNumber: '',
|
|
certificateEffectiveDate: '',
|
|
certificateExpiryDate: '',
|
|
issuingAuthority: '',
|
|
certificateAddress: '',
|
|
gender: undefined,
|
|
ethnic: '',
|
|
occupationType: undefined,
|
|
jobNote: '',
|
|
mobilePhone: '',
|
|
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 = {
|
|
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 = {
|
|
DRAFT: '待确认',
|
|
PENDING_ACCOUNT_OPEN: '待调用开户接口',
|
|
CONFIRMED: '已确认',
|
|
OPENED: '已开立',
|
|
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) {
|
|
showErrorModal('请先选择渠道归属')
|
|
return
|
|
}
|
|
pickerRef.value.show()
|
|
}
|
|
|
|
// 比对身份证 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
|
|
}
|
|
}
|
|
|
|
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) {
|
|
showErrorModal('请先选择客户')
|
|
return false
|
|
}
|
|
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 false
|
|
}
|
|
}
|
|
|
|
// 构造提交给 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,
|
|
customerName: form.customerName,
|
|
certificateType: form.certificateType,
|
|
certificateNumber: form.certificateNumber,
|
|
certificateEffectiveDate: form.certificateEffectiveDate,
|
|
certificateExpiryDate: form.certificateExpiryDate,
|
|
issuingAuthority: form.issuingAuthority,
|
|
certificateAddress: form.certificateAddress,
|
|
gender: form.gender,
|
|
ethnic: form.ethnic,
|
|
occupation: form.occupationType,
|
|
jobNote: form.occupationType === 'OTHER' ? form.jobNote : undefined,
|
|
mobilePhone: form.mobilePhone,
|
|
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) {
|
|
message.success('修改成功')
|
|
goBack()
|
|
}
|
|
} finally {
|
|
submitting.value = false
|
|
}
|
|
}
|
|
|
|
async function handleQueryProgress() {
|
|
polling.value = true
|
|
try {
|
|
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
|
|
}
|
|
}
|
|
|
|
// 拼接扫码 H5 地址:域名/路径来自环境变量,域名未配置(生产环境尚未确定)时用占位域名兜底,
|
|
// 保证二维码始终可以生成,不阻断柜员操作;域名确定后只需修改 .env.production,无需改代码。
|
|
function buildQrCodeUrl(applicationNo) {
|
|
const base = import.meta.env.VITE_H5_QRCODE_BASE_URL || 'https://example.com'
|
|
const path = import.meta.env.VITE_H5_QRCODE_PATH || '/mobile/personal-open'
|
|
return `${base}${path}?applicationNo=${applicationNo}`
|
|
}
|
|
|
|
// 弹窗内"邀请扫码办理":与"保存"调用同一个提交接口,成功后不关闭弹窗,切换为二维码展示态
|
|
async function handleModalInvite() {
|
|
modalInviting.value = true
|
|
try {
|
|
const applicationNo = await submitCreateDraft()
|
|
if (applicationNo) {
|
|
qrApplicationNo.value = applicationNo
|
|
qrCodeUrl.value = buildQrCodeUrl(applicationNo)
|
|
confirmModalStep.value = 'qrcode'
|
|
}
|
|
} finally {
|
|
modalInviting.value = false
|
|
}
|
|
}
|
|
|
|
// 弹窗内"取消"/关闭图标/邀请扫码办理成功后的"关闭"按钮:统一只关闭弹窗,不发请求、不跳转
|
|
function handleModalCancel() {
|
|
confirmModalVisible.value = false
|
|
}
|
|
|
|
function goBack() {
|
|
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>
|