SFT/src/views/wallet/personal-open/PersonalOpenForm.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 的Bugedit/detail 模式下的 list/detail/update 接口
后端尚未提供,前端已按 enterprise-open-application 四件套的假设契约实现,detail/edit 模式
下无法真正联调验证,详见缺失的后端接口.mdPart 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>