新增个人贷款申请
parent
2d168b2a77
commit
03fc69cd2b
|
|
@ -2,3 +2,4 @@ VITE_API_BASE_URL=http://localhost:8888
|
|||
VITE_H5_QRCODE_BASE_URL=http://www.baidu.com
|
||||
VITE_H5_QRCODE_PATH=/mobile/personal-open
|
||||
VITE_H5_QRCODE_PATH_ENTERPRISE=/mobile/enterprise-open
|
||||
VITE_H5_QRCODE_PATH_LOAN=/mobile/loan-application
|
||||
|
|
|
|||
|
|
@ -2,3 +2,4 @@ VITE_API_BASE_URL=
|
|||
VITE_H5_QRCODE_BASE_URL=
|
||||
VITE_H5_QRCODE_PATH=/mobile/personal-open
|
||||
VITE_H5_QRCODE_PATH_ENTERPRISE=/mobile/enterprise-open
|
||||
VITE_H5_QRCODE_PATH_LOAN=/mobile/loan-application
|
||||
|
|
|
|||
|
|
@ -44,3 +44,11 @@ export const fetchDictItemTreeApi = (dictTypeCode) =>
|
|||
export const createDictItemApi = (data) => request.post('/api/dict-management/dict-item/create', data)
|
||||
export const updateDictItemApi = (data) => request.post('/api/dict-management/dict-item/update', data)
|
||||
export const deleteDictItemApi = (data) => request.post('/api/dict-management/dict-item/delete', data)
|
||||
|
||||
// ---------------- 行政区划(省/市/区县/乡镇/村,懒加载逐级查询) ----------------
|
||||
// GET /api/base-config/administrative-division/children?parentCode=&level=
|
||||
// level:1=省 2=地市 3=区县 4=乡镇/街道 5=村/居委会(查第一级即省级时 parentCode 传空字符串)。
|
||||
// 用于个人贷款申请"户籍/常住地市"五级级联选择(AdministrativeDivisionCascader.vue),
|
||||
// 提交给后端的是选中的最深一级叶子节点的 zoningCode(统计用区划代码字符串)。
|
||||
export const fetchAdministrativeDivisionChildrenApi = (parentCode, level) =>
|
||||
request.get('/api/base-config/administrative-division/children', { params: { parentCode: parentCode || '', level } })
|
||||
|
|
|
|||
|
|
@ -19,3 +19,10 @@ export const updateEnterpriseLoanApplicationApi = (data) =>
|
|||
|
||||
// 注:confirm 接口(/api/credit-apply/loan-application/confirm)语义为"申请人微信扫码确认",
|
||||
// 需短信验证码+人脸识别,属移动端客户操作流程,不在本管理后台前端实现范围,不在此导出。
|
||||
|
||||
// 贷款产品查询(用于"贷款产品"选择弹窗 LoanProductPicker.vue):后端目前没有任何贷款产品
|
||||
// 列表/字典查询接口(已在《缺失的后端接口.md》第28条登记),这里按与本文件其它 credit-apply
|
||||
// 接口一致的命名风格假定一个契约,方便后端后续对齐实现;若接口暂未提供,调用会失败,弹窗内已
|
||||
// 同时提供"手动输入产品编号"兜底入口,不阻塞柜员录入。入参/出参字段为前端假定,以实际后端
|
||||
// 实现为准,登记于《缺失的后端接口.md》第135条。
|
||||
export const fetchLoanProductListApi = (data) => request.post('/api/credit-apply/loan-product/list', data)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,15 @@ export const uploadCustomerFileApi = (data, channelNo) =>
|
|||
export const faceRecognizeApi = (data, channelNo) =>
|
||||
request.post('/api/customer-info/personal/face-recognize', data, { channelNo })
|
||||
|
||||
// 银行协议模板(base64 PDF):按 fileType 获取浙江稠州商业银行业务协议/授权书模板文件,
|
||||
// 转 base64 返回给前端。此前仅 fryl_h5(移动端)有此封装(src/api/personalOpen.js),
|
||||
// 个人贷款申请页面(PersonalLoanForm.vue)"本人已阅读并同意"协议预览需要复用同一接口,
|
||||
// 故在主项目补充这份封装。fileType 取值见 BankAgreementFileTypeEnum:
|
||||
// PAY_SERVICE_AGREEMENT/PAY_SERVICE_PRIVACY/CREDIT_REPORT_AUTH/THIRD_PARTY_DATA_AUTH。
|
||||
// 响应 data:{ fileType, fileName, fileContent(base64) }。
|
||||
export const fetchBankAgreementApi = (fileType) =>
|
||||
request.get('/api/customer-info/bank-agreement/base64', { params: { fileType } })
|
||||
|
||||
// ---------------- 企业客户 ----------------
|
||||
export const fetchEnterpriseCustomerPageApi = (data) => request.post('/api/enterprise-customer/page', data)
|
||||
export const fetchEnterpriseCustomerDetailApi = (data) => request.post('/api/enterprise-customer/detail', data)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
<!-- src/components/AdministrativeDivisionCascader.vue -->
|
||||
<!-- 个人贷款申请"户籍/常住地市"五级行政区划级联选择器(省/市/区县/乡镇街道/村居委会)。
|
||||
与 src/components/RegionCascader.vue(静态 npm 包 china-division,仅省市区三级)不同,
|
||||
本组件基于后端接口 GET /api/base-config/administrative-division/children 逐级懒加载
|
||||
(parentCode + level,level 1~5,5=村/居委会),不预加载整棵树。
|
||||
|
||||
对外只暴露"最终选中的最深一级叶子节点的 zoningCode(统计用区划代码字符串)"这一个标量值
|
||||
(v-model:value),不是路径数组——与业务提交字段(registeredCity)的取值约定一致。回显时
|
||||
(编辑/查看已有申请)只拿到这个叶子 code,需要沿着"子级 code 必然以父级 code 为前缀"的
|
||||
假设,从省级开始逐级查询并按前缀匹配反查出完整路径,重建 a-cascader 的选中链路展示;
|
||||
若某一级查询不到匹配项(如字典数据变更/网络异常),回显会中断在能匹配到的最深一级,
|
||||
不阻断整体展示。 -->
|
||||
<template>
|
||||
<a-cascader
|
||||
:value="internalPath"
|
||||
:options="options"
|
||||
:load-data="loadData"
|
||||
:placeholder="placeholder"
|
||||
:disabled="disabled"
|
||||
:allow-clear="allowClear"
|
||||
style="width: 100%"
|
||||
@change="handleChange"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue'
|
||||
import { fetchAdministrativeDivisionChildrenApi } from '@/api/baseConfig'
|
||||
|
||||
const props = defineProps({
|
||||
// 最终选中的叶子节点 zoningCode(统计用区划代码字符串),不是路径数组
|
||||
value: { type: String, default: undefined },
|
||||
placeholder: { type: String, default: '请选择所属地区' },
|
||||
disabled: { type: Boolean, default: false },
|
||||
allowClear: { type: Boolean, default: true }
|
||||
})
|
||||
const emit = defineEmits(['update:value', 'update:text'])
|
||||
|
||||
// a-cascader 内部展示用的选中链路(code 数组),受控组件模式,由 handleChange/resolveInitialPath 维护
|
||||
const internalPath = ref(undefined)
|
||||
const options = ref([])
|
||||
|
||||
function mapItems(list, level) {
|
||||
return (list || []).map((item) => ({
|
||||
value: item.zoningCode,
|
||||
label: item.zoningName,
|
||||
level,
|
||||
isLeaf: level >= 5
|
||||
}))
|
||||
}
|
||||
|
||||
async function fetchLevel(parentCode, level) {
|
||||
const res = await fetchAdministrativeDivisionChildrenApi(parentCode, level)
|
||||
if (res.data?.code !== 200) return []
|
||||
return mapItems(res.data.data, level)
|
||||
}
|
||||
|
||||
// a-cascader 展开某一级节点且该节点尚无 children 时触发;若查询结果为空(如该乡镇下无村居数据),
|
||||
// 将该节点就地标记为 isLeaf,允许用户直接选中它作为最终值,不强行要求凑满5级
|
||||
async function loadData(selectedOptions) {
|
||||
const target = selectedOptions[selectedOptions.length - 1]
|
||||
target.loading = true
|
||||
try {
|
||||
const children = await fetchLevel(target.value, target.level + 1)
|
||||
if (!children.length) {
|
||||
target.isLeaf = true
|
||||
} else {
|
||||
target.children = children
|
||||
}
|
||||
} finally {
|
||||
target.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
function handleChange(pathValues, selectedOptions) {
|
||||
const leaf = pathValues && pathValues.length ? pathValues[pathValues.length - 1] : undefined
|
||||
internalPath.value = pathValues && pathValues.length ? pathValues : undefined
|
||||
emit('update:value', leaf)
|
||||
emit('update:text', (selectedOptions || []).map((o) => o.label).join('/'))
|
||||
}
|
||||
|
||||
// 根据叶子 zoningCode 反查完整路径:从省级开始逐级查询,按"子级code以父级code为前缀"匹配,
|
||||
// 边查边把结果挂到 options 树上对应父节点的 children,使 a-cascader 能正确展示每一级中文标签
|
||||
async function resolveInitialPath(leafCode) {
|
||||
if (!leafCode) {
|
||||
internalPath.value = undefined
|
||||
return
|
||||
}
|
||||
let parentCode = ''
|
||||
let level = 1
|
||||
let currentList = options.value.length ? options.value : await fetchLevel('', 1)
|
||||
if (!options.value.length) options.value = currentList
|
||||
const path = []
|
||||
let parentNode = null
|
||||
while (level <= 5) {
|
||||
const match = currentList.find((item) => item.value === leafCode || leafCode.startsWith(item.value))
|
||||
if (!match) break
|
||||
path.push(match.value)
|
||||
if (match.value === leafCode) break
|
||||
parentCode = match.value
|
||||
parentNode = match
|
||||
level += 1
|
||||
if (!parentNode.children) {
|
||||
parentNode.children = await fetchLevel(parentCode, level)
|
||||
}
|
||||
currentList = parentNode.children
|
||||
if (!currentList.length) break
|
||||
}
|
||||
internalPath.value = path.length ? path : undefined
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (props.value) {
|
||||
await resolveInitialPath(props.value)
|
||||
} else if (!options.value.length) {
|
||||
options.value = await fetchLevel('', 1)
|
||||
}
|
||||
})
|
||||
|
||||
// 详情/编辑页数据是异步加载的(先渲染组件、后填充 value),需要监听外部传入值的变化重新解析路径;
|
||||
// 用户在组件内部手动选择时也会走 handleChange 更新 internalPath,这里只处理"外部赋值"场景,
|
||||
// 通过比较 leaf 是否与当前路径末尾一致避免手动选择后被自身触发的 update:value 又反向重置一遍
|
||||
watch(
|
||||
() => props.value,
|
||||
(val) => {
|
||||
const currentLeaf = internalPath.value?.[internalPath.value.length - 1]
|
||||
if (val === currentLeaf) return
|
||||
resolveInitialPath(val)
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
|
@ -19,11 +19,11 @@
|
|||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<LoadingOutlined v-if="uploading" />
|
||||
<PlusOutlined v-else />
|
||||
<div class="upload-text">上传营业执照</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<LoadingOutlined v-if="uploading" />
|
||||
<PlusOutlined v-else />
|
||||
<div class="upload-text">{{ uploadText }}</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div v-if="!channelNo" class="hint">请先选择所属渠道</div>
|
||||
|
||||
|
|
@ -43,7 +43,11 @@ import { uploadCustomerFileApi } from '@/api/customer'
|
|||
const props = defineProps({
|
||||
channelNo: { type: String, default: undefined },
|
||||
// 只读态(查看详情):图片区域只保留预览,隐藏重新上传/删除操作
|
||||
readonly: { type: Boolean, default: false }
|
||||
readonly: { type: Boolean, default: false },
|
||||
// 空态占位文案:默认"上传营业执照"(历史默认用途),部分调用场景(如个人贷款申请的
|
||||
// "代付情况说明"影像项)复用本组件承载不同性质的文件,需按实际展示内容自定义该文案,
|
||||
// 避免出现"上传营业执照"这类与实际用途不符的误导性提示。
|
||||
uploadText: { type: String, default: '上传营业执照' }
|
||||
})
|
||||
// update:uploading 用于告知父组件当前上传是否仍在进行,父组件应据此阻止在上传完成前提交表单,
|
||||
// 否则会出现 fileNo 尚未写回 form 就已提交、导致 businessLicenseFileNo 传空的竞态问题
|
||||
|
|
|
|||
|
|
@ -0,0 +1,98 @@
|
|||
<!-- src/components/LoanProductPicker.vue -->
|
||||
<!-- 个人贷款申请"贷款产品"选择弹窗。后端目前没有任何贷款产品列表/字典查询接口(已在
|
||||
《缺失的后端接口.md》第28条登记,第135条补充记录本弹窗现状),fetchLoanProductListApi
|
||||
对应的 /api/credit-apply/loan-product/list 是前端假定的契约,接口不存在时查询会失败;
|
||||
弹窗内在列表查询区下方常驻提供"手动输入产品编号"入口,不管后端接口是否已实现都能完成
|
||||
选择,接口实现后列表区自然可用,不需要再改动本组件结构。 -->
|
||||
<template>
|
||||
<a-modal v-model:open="open" title="选择贷款产品" width="720px" :footer="null">
|
||||
<a-alert
|
||||
v-if="loadError"
|
||||
type="warning"
|
||||
show-icon
|
||||
message="贷款产品查询接口暂不可用,可在下方手动输入产品编号完成选择"
|
||||
style="margin-bottom: 12px"
|
||||
/>
|
||||
<ProTable
|
||||
:columns="columns"
|
||||
:fetch-data="loadList"
|
||||
:row-selection="false"
|
||||
:initial-search="{ productCode: '', productName: '' }"
|
||||
>
|
||||
<template #search="{ form }">
|
||||
<a-form-item label="产品编号">
|
||||
<a-input v-model:value="form.productCode" placeholder="请输入产品编号" allow-clear style="width: 160px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="产品名称">
|
||||
<a-input v-model:value="form.productName" placeholder="请输入产品名称" allow-clear style="width: 160px" />
|
||||
</a-form-item>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.dataIndex === 'index'">{{ index + 1 }}</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a @click="handleSelect(record)">选择</a>
|
||||
</template>
|
||||
</template>
|
||||
</ProTable>
|
||||
|
||||
<a-divider>手动输入产品编号</a-divider>
|
||||
<a-space>
|
||||
<a-input v-model:value="manualCode" placeholder="请输入贷款产品编号" style="width: 240px" />
|
||||
<a-button type="primary" :disabled="!manualCode" @click="handleManualConfirm">确定</a-button>
|
||||
</a-space>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import ProTable from '@/components/ProTable.vue'
|
||||
import { fetchLoanProductListApi } from '@/api/credit'
|
||||
|
||||
// select 事件携带 { loanProductCode, loanProductName? }:列表选择时两者都有,
|
||||
// 手动输入时只有 loanProductCode
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const open = ref(false)
|
||||
const loadError = ref(false)
|
||||
const manualCode = ref('')
|
||||
|
||||
const columns = [
|
||||
{ title: '序号', dataIndex: 'index', width: 60 },
|
||||
{ title: '产品编号', dataIndex: 'productCode' },
|
||||
{ title: '产品名称', dataIndex: 'productName' },
|
||||
{ title: '操作', dataIndex: 'action', width: 80 }
|
||||
]
|
||||
|
||||
async function loadList(params) {
|
||||
try {
|
||||
const res = await fetchLoanProductListApi(params)
|
||||
if (res.data.code === 200) {
|
||||
loadError.value = false
|
||||
return { list: res.data.data.records || [], total: res.data.data.total || 0 }
|
||||
}
|
||||
loadError.value = true
|
||||
return { list: [], total: 0 }
|
||||
} catch {
|
||||
loadError.value = true
|
||||
return { list: [], total: 0 }
|
||||
}
|
||||
}
|
||||
|
||||
function handleSelect(record) {
|
||||
emit('select', { loanProductCode: record.productCode, loanProductName: record.productName })
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function handleManualConfirm() {
|
||||
if (!manualCode.value) return
|
||||
emit('select', { loanProductCode: manualCode.value })
|
||||
manualCode.value = ''
|
||||
open.value = false
|
||||
}
|
||||
|
||||
function show() {
|
||||
open.value = true
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
|
@ -1,15 +1,24 @@
|
|||
<!-- src/components/WalletAccountPicker.vue -->
|
||||
<!-- 通用钱包账户选择弹窗:查询 /api/wallet-account/page,按客户名称/账号/账户类型筛选,
|
||||
选择后 emit 完整记录(含 accountNo/accountName/accountType/mainAccountNo/customerNo)。
|
||||
用于商户管理(选择主体钱包/担保人账户)、发票管理(选择收款/归集账户)等需要引用
|
||||
"已开立钱包账户"的场景,避免各处重复实现同一套弹窗逻辑。 -->
|
||||
用于商户管理(选择主体钱包/担保人账户)、发票管理(选择收款/归集账户)、个人贷款申请
|
||||
(选择贷款申请人钱包/经营企业钱包)等需要引用"已开立钱包账户"的场景,避免各处重复实现
|
||||
同一套弹窗逻辑。
|
||||
|
||||
customerType(可选,'PERSONAL'/'ENTERPRISE'):按客户类型固定过滤。业务确认
|
||||
`/api/wallet-account/page` 请求体的 `accountType` 字段,除了后端响应 `WalletAccountVo`
|
||||
里代表"账户子类型"(A1主账户/A2/A3/A6保证金/A7分户,swagger 已声明为 AccountTypeEnum)外,
|
||||
在**查询入参**场景还复用同一字段名传 `PERSONAL`/`ENTERPRISE` 做客户类型过滤——这是业务口头
|
||||
确认的取值,swagger 对请求体 `accountType` 未声明枚举(纯 `string`),不是文档化契约。同一个
|
||||
请求只有一个 `accountType` 字段,客户类型过滤和账户子类型(A1~A7)筛选不能同时生效,故设置
|
||||
`customerType` 后会隐藏原有的"账户类型(A1~A7)"筛选框,固定按 `customerType` 查询。 -->
|
||||
<template>
|
||||
<a-modal v-model:open="open" title="选择钱包账户" width="900px" :footer="null">
|
||||
<a-modal v-model:open="open" :title="title" width="900px" :footer="null">
|
||||
<ProTable
|
||||
:columns="columns"
|
||||
:fetch-data="loadList"
|
||||
:row-selection="false"
|
||||
:initial-search="{ accountName: '', accountNo: '', accountType: undefined }"
|
||||
:initial-search="{ accountName: '', accountNo: '', accountType: customerType }"
|
||||
>
|
||||
<template #search="{ form }">
|
||||
<a-form-item label="客户名称">
|
||||
|
|
@ -18,7 +27,7 @@
|
|||
<a-form-item label="账号">
|
||||
<a-input v-model:value="form.accountNo" placeholder="请输入账号" allow-clear style="width: 180px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="账户类型">
|
||||
<a-form-item v-if="!customerType" label="账户类型">
|
||||
<a-select v-model:value="form.accountType" placeholder="请选择账户类型" allow-clear style="width: 140px">
|
||||
<a-select-option v-for="opt in accountTypeOptions" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||||
</a-select>
|
||||
|
|
@ -43,6 +52,12 @@ import ProTable from '@/components/ProTable.vue'
|
|||
import StatusTag from '@/components/StatusTag.vue'
|
||||
import { fetchWalletAccountPageApi } from '@/api/wallet'
|
||||
|
||||
const props = defineProps({
|
||||
title: { type: String, default: '选择钱包账户' },
|
||||
// 'PERSONAL'(个人钱包)/'ENTERPRISE'(企业钱包),见组件顶部注释;不传则保留原有的
|
||||
// "账户类型(A1~A7)"筛选行为,不做客户类型区分
|
||||
customerType: { type: String, default: undefined }
|
||||
})
|
||||
const emit = defineEmits(['select'])
|
||||
|
||||
const open = ref(false)
|
||||
|
|
@ -73,7 +88,9 @@ const columns = [
|
|||
]
|
||||
|
||||
async function loadList(params) {
|
||||
const res = await fetchWalletAccountPageApi(params)
|
||||
// customerType 固定时始终以它为准,忽略(已隐藏的)搜索表单里的 accountType 值
|
||||
const query = props.customerType ? { ...params, accountType: props.customerType } : params
|
||||
const res = await fetchWalletAccountPageApi(query)
|
||||
if (res.data.code === 200) {
|
||||
return { list: res.data.data.records || [], total: res.data.data.total || 0 }
|
||||
}
|
||||
|
|
@ -91,3 +108,4 @@ function show() {
|
|||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
|
||||
|
|
|
|||
|
|
@ -123,3 +123,34 @@ export const LOAN_APPLICATION_STATUS_OPTIONS = [
|
|||
{ value: "APPLY_FAILED", label: "申请失败" }
|
||||
]
|
||||
|
||||
// 个人贷款申请"本人已阅读并同意"协议链接对应的银行协议模板 fileType(BankAgreementFileTypeEnum,
|
||||
// 取自 swagger,与 fryl_h5/src/constants/enums.js 的 BANK_AGREEMENT_FILE_TYPE_LABELS 同源但
|
||||
// 该文件只列了 PAY_SERVICE_AGREEMENT/PAY_SERVICE_PRIVACY 两项,这里补充贷款场景实际用到的
|
||||
// CREDIT_REPORT_AUTH/THIRD_PARTY_DATA_AUTH 两项标签,详见《缺失的后端接口.md》第112条)。
|
||||
export const BANK_AGREEMENT_FILE_TYPE_LABELS = {
|
||||
CREDIT_REPORT_AUTH: "个人信用信息报送查询使用授权书",
|
||||
THIRD_PARTY_DATA_AUTH: "第三方数据信息查询和使用授权书"
|
||||
}
|
||||
|
||||
// 影像资料上传时提交给 LoanApplicationImageBto.fileType 的取值:身份证正面/身份证反面沿用
|
||||
// swagger description 里给出的中文自由文本("身份证正面"/"身份证反面"),经营证明按业务方
|
||||
// 给定的编码约定传字符串 "17"(非 swagger 已声明枚举,系人工指定的既有编码,详见
|
||||
// 《缺失的后端接口.md》相关登记条目)。
|
||||
export const LOAN_APPLICATION_IMAGE_FILE_TYPE = {
|
||||
ID_CARD_FRONT: "身份证正面",
|
||||
ID_CARD_BACK: "身份证反面",
|
||||
BUSINESS_PROOF: "17",
|
||||
FACE_PHOTO: "04"
|
||||
}
|
||||
|
||||
// 个人贷款申请"经营实体(企业)"区块"与经营企业关系"下拉选项:业务方直接指定的5个固定选项,
|
||||
// 未在 swagger 中定义对应枚举,CreatePersonalLoanApplicationDraftBto 也没有承接该字段的
|
||||
// 位置(已知缺口,登记于《缺失的后端接口.md》),仅作页面展示,不随表单提交。
|
||||
export const ENTERPRISE_RELATION_OPTIONS = [
|
||||
{ value: "LEGAL_REPRESENTATIVE", label: "法定代表人" },
|
||||
{ value: "SHAREHOLDER", label: "股东" },
|
||||
{ value: "PARTNER", label: "合伙人" },
|
||||
{ value: "CONTRACTOR", label: "承包人" },
|
||||
{ value: "ACTUAL_CONTROLLER", label: "实控人" }
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -7,3 +7,15 @@ export function buildPersonalOpenQrCodeUrl(applicationNo) {
|
|||
const path = import.meta.env.VITE_H5_QRCODE_PATH || '/mobile/personal-open'
|
||||
return `${base}${path}?applicationNo=${applicationNo}`
|
||||
}
|
||||
|
||||
// 个人贷款申请"扫码确认"(PersonalLoanForm.vue)专用的 H5 确认页二维码地址拼接,与上面个人开户
|
||||
// 同一模式:域名复用同一个 VITE_H5_QRCODE_BASE_URL,路径走独立的 VITE_H5_QRCODE_PATH_LOAN
|
||||
// (与企业开户 EnterpriseOpenForm.vue 内联的 VITE_H5_QRCODE_PATH_ENTERPRISE 是同类做法,只是放在
|
||||
// 本文件统一维护)。查询参数用 loanApplicationId(贷款申请单号),与后端字段命名保持一致。
|
||||
// 扫码后的 H5 确认页面本身不在本项目前端实现范围(见《缺失的后端接口.md》第19条),这里只负责
|
||||
// 生成二维码内容,不阻断柜员操作。
|
||||
export function buildLoanApplicationQrCodeUrl(loanApplicationId) {
|
||||
const base = import.meta.env.VITE_H5_QRCODE_BASE_URL || 'https://example.com'
|
||||
const path = import.meta.env.VITE_H5_QRCODE_PATH_LOAN || '/mobile/loan-application'
|
||||
return `${base}${path}?loanApplicationId=${loanApplicationId}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,33 @@
|
|||
<!-- src/views/credit/loan-application/PersonalLoanForm.vue -->
|
||||
<!-- 2.2.1 贷款申请 —— 个人贷款申请表单。mode 从 route.path 后缀推导(create/edit/detail)。
|
||||
字段严格对齐 /api/credit-apply/personal-loan-application/create、update 的
|
||||
CreatePersonalLoanApplicationDraftBto/UpdatePersonalLoanApplicationBto。
|
||||
已知缺口(登记于《缺失的后端接口.md》):该 Bto 不含 customerId/accountNo(钱包账号)字段,
|
||||
无法像企业贷款那样将申请与具体客户ID/钱包账号强关联,选择客户仅用于回填 customerName/
|
||||
phoneNo/permanentAddress 三个文本字段,不产生实际的外键关联;短信验证码校验与人脸识别
|
||||
属客户微信扫码后的移动端流程(/api/credit-apply/loan-application/confirm),不在本页面实现。 -->
|
||||
CreatePersonalLoanApplicationDraftBto/UpdatePersonalLoanApplicationBto,融资条件/渠道机构/
|
||||
配偶信息区块维持原有设计,2026-08-10 按最新原型截图补充调整了"贷款申请人钱包/贷款产品"
|
||||
选择弹窗、五级行政区划级联、证件号码展示、协议勾选与预览、经营证明影像项等交互,详见
|
||||
docs/superpowers/specs 目录同日期设计讨论与《缺失的后端接口.md》相关登记条目。
|
||||
"确认"按钮点击后不直接提交,而是弹出二次确认弹窗(保存/取消/扫码确认),与
|
||||
PersonalOpenForm.vue/EnterpriseOpenForm.vue 同款模式:"保存"与"扫码确认"共用同一次真实
|
||||
create/update 提交,区别仅在成功后是否切到二维码态;二维码内容是前端拼接的一个 H5 URL
|
||||
(`buildLoanApplicationQrCodeUrl`,域名/路径来自 VITE_H5_QRCODE_BASE_URL/
|
||||
VITE_H5_QRCODE_PATH_LOAN),扫码后走向的移动端 H5 确认页本身不在本项目前端实现范围。
|
||||
已知缺口(登记于《缺失的后端接口.md》):该 Bto 不含 customerId/accountNo(钱包账号)/
|
||||
certificateNumber(证件号码)字段,选择钱包/产品仅用于回填 customerName/phoneNo/
|
||||
permanentAddress/loanProductCode 等文本字段,不产生实际的外键关联,钱包账号/证件号码
|
||||
只在页面展示,不随表单提交;短信验证码校验与人脸识别属客户微信扫码后的移动端流程
|
||||
(/api/credit-apply/loan-application/confirm),不在本页面实现。 -->
|
||||
<template>
|
||||
<div class="personal-loan-form-page">
|
||||
<a-page-header :title="pageTitle" @back="goBack" />
|
||||
<a-page-header :title="pageTitle" @back="goBack">
|
||||
<template v-if="!isDetail" #extra>
|
||||
<a-space wrap ref="agreementSectionRef">
|
||||
<a-checkbox v-model:checked="agreementChecked">本人已阅读并同意</a-checkbox>
|
||||
<a @click="openAgreement('CREDIT_REPORT_AUTH')">【模板】个人征信授权书</a>
|
||||
<a @click="openAgreement('THIRD_PARTY_DATA_AUTH')">【模板】个人信息查询使用授权书</a>
|
||||
<a-button type="primary" :disabled="!agreementChecked || busy" @click="handleConfirmButtonClick">确认</a-button>
|
||||
<a-button @click="goBack">取消</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-page-header>
|
||||
|
||||
<a-card v-if="!isCreate" style="margin-bottom: 16px">
|
||||
<a-descriptions :column="3">
|
||||
|
|
@ -18,6 +37,7 @@
|
|||
</a-descriptions>
|
||||
</a-card>
|
||||
|
||||
<a-spin :spinning="busy" :tip="busyTip">
|
||||
<a-form layout="vertical" :model="form" :disabled="isDetail">
|
||||
<a-card title="基础信息" style="margin-bottom: 16px">
|
||||
<a-row :gutter="16">
|
||||
|
|
@ -33,74 +53,18 @@
|
|||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="贷款产品编号" required>
|
||||
<a-input v-model:value="form.loanProductCode" placeholder="请输入贷款产品编号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="客户">
|
||||
<a-space>
|
||||
<span v-if="form.customerName">{{ form.customerName }}</span>
|
||||
<a-button v-if="!isDetail" @click="openCustomerPicker">选择客户</a-button>
|
||||
<a-space style="width: 100%">
|
||||
<a-input v-model:value="form.loanProductCode" placeholder="请输入或选择贷款产品编号" />
|
||||
<a-button v-if="!isDetail" @click="openProductPicker">选择</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="手机号码">
|
||||
<a-input v-model:value="form.phoneNo" placeholder="请输入11位手机号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="户籍/常住地市">
|
||||
<a-input v-model:value="form.registeredCity" placeholder="请输入户籍/常住地市" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="16">
|
||||
<a-form-item label="户籍常驻地址">
|
||||
<a-input v-model:value="form.permanentAddress" placeholder="请输入户籍常驻地址" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<a-card title="融资条件" style="margin-bottom: 16px">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="商品类目" required>
|
||||
<a-select v-model:value="form.goodsCategory" placeholder="请选择商品类目">
|
||||
<a-select-option v-for="opt in GOODS_CATEGORY_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="回款周期" required>
|
||||
<a-select v-model:value="form.paymentCycle" placeholder="请选择回款周期">
|
||||
<a-select-option v-for="opt in PAYMENT_CYCLE_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="单笔汇总订单限额(元)" required>
|
||||
<a-input v-model:value="form.singleOrderLimit" placeholder="请输入单笔汇总订单限额" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="融资比例(%)" required>
|
||||
<a-input-number v-model:value="form.financingRatio" :min="0" :max="100" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="预计退货率(%)">
|
||||
<a-input-number v-model:value="form.estimatedReturnRate" :min="0" :max="100" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="借款期限(天)" required>
|
||||
<a-input-number v-model:value="form.loanTerm" :min="1" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="订单融资期限(天)" required>
|
||||
<a-input-number v-model:value="form.orderFinancingTerm" :min="1" style="width: 100%" />
|
||||
<a-form-item label="贷款申请人钱包" required>
|
||||
<a-space>
|
||||
<span v-if="walletAccountNo">{{ walletAccountNo }}<template v-if="walletMainBalance"> (余额:{{ walletMainBalance }})</template></span>
|
||||
<a-button v-if="!isDetail" @click="openWalletPicker">{{ walletAccountNo ? '重新选择' : '选择' }}</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
|
|
@ -109,89 +73,132 @@
|
|||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="申请利率(%)">
|
||||
<a-form-item label="申请利率(%)" required>
|
||||
<a-input v-model:value="form.applicationInterestRate" placeholder="请输入申请利率" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="贷款投向">
|
||||
<a-input v-model:value="form.loanPurpose" placeholder="请输入贷款投向" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<a-card title="经营实体信息" style="margin-bottom: 16px">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="经营实体名称">
|
||||
<a-input v-model:value="form.enterpriseName" placeholder="请输入经营实体名称" />
|
||||
<a-form-item label="证件号码">
|
||||
<a-input v-model:value="certificateNumber" placeholder="选择钱包后自动回显,可手动修改" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="经营起始日">
|
||||
<a-date-picker v-model:value="businessStartDateModel" style="width: 100%" />
|
||||
<a-form-item label="手机号码">
|
||||
<a-input v-model:value="form.phoneNo" placeholder="请输入11位手机号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="经营范围" required>
|
||||
<a-input v-model:value="form.businessScope" placeholder="请输入经营范围/经营活动描述" />
|
||||
<a-form-item label="户籍/常住地市" required>
|
||||
<AdministrativeDivisionCascader v-model:value="form.registeredCity" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="16">
|
||||
<a-form-item label="户籍/常住地址" required>
|
||||
<a-input v-model:value="form.permanentAddress" placeholder="请输入户籍/常住地址" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<a-card title="个人基础信息" style="margin-bottom: 16px">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="民族">
|
||||
<a-form-item label="民族" required>
|
||||
<a-select v-model:value="form.ethnicity" placeholder="请选择民族" show-search option-filter-prop="label">
|
||||
<a-select-option v-for="opt in ETHNICITY_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="学历">
|
||||
<a-form-item label="学历" required>
|
||||
<a-select v-model:value="form.educationLevel" placeholder="请选择学历">
|
||||
<a-select-option v-for="opt in EDUCATION_LEVEL_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="婚姻状况">
|
||||
<a-form-item label="婚姻状况" required>
|
||||
<a-select v-model:value="form.maritalStatus" placeholder="请选择婚姻状况">
|
||||
<a-select-option v-for="opt in MARITAL_STATUS_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="个人年收入(元)">
|
||||
<a-form-item label="个人年收入(元)" required>
|
||||
<a-input v-model:value="form.annualPersonalIncome" placeholder="请输入个人年收入" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="家庭年收入(元)">
|
||||
<a-form-item label="家庭年收入(元)" required>
|
||||
<a-input v-model:value="form.annualFamilyIncome" placeholder="请输入家庭年收入" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
<a-card title="紧急联系人信息" style="margin-bottom: 16px">
|
||||
<a-row :gutter="16">
|
||||
<a-card title="经营实体信息" style="margin-bottom: 16px">
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<span>经营实体(企业)</span>
|
||||
<a-switch v-model:checked="businessEntityEnabled" :disabled="isDetail" />
|
||||
</a-space>
|
||||
</template>
|
||||
<a-row v-if="businessEntityEnabled" :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="姓名" required>
|
||||
<a-input v-model:value="form.emergencyContactName" placeholder="请输入紧急联系人姓名" />
|
||||
<a-form-item label="证件类型">
|
||||
<a-input value="统一社会信用代码" disabled />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="联系电话">
|
||||
<a-form-item label="经营企业" required>
|
||||
<a-space>
|
||||
<span v-if="form.enterpriseName">{{ form.enterpriseName }}</span>
|
||||
<a-button v-if="!isDetail" @click="openEnterpriseWalletPicker">{{ form.enterpriseName ? '重新选择' : '选择' }}</a-button>
|
||||
</a-space>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="证件号码">
|
||||
<a-input v-model:value="enterpriseCertificateNumber" placeholder="选择经营企业后自动回显,可手动修改" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="与经营企业关系" required>
|
||||
<a-select v-model:value="enterpriseRelation" placeholder="请选择与经营企业关系">
|
||||
<a-select-option v-for="opt in ENTERPRISE_RELATION_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="企业类型">
|
||||
<DictSelect v-model:model-value="enterpriseType" dict-type-code="qiyeleixing" placeholder="请选择企业类型" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="经营起始日" required>
|
||||
<a-date-picker v-model:value="businessStartDateModel" style="width: 100%" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="经营范围" required>
|
||||
<a-input v-model:value="form.businessScope" placeholder="经营执照、挂靠说明描述等" />
|
||||
<div class="business-scope-hint">请输入经营范围,至少10个汉字</div>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
|
||||
|
||||
<a-card title="紧急联系人信息" style="margin-bottom: 16px">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="联系人名称" required>
|
||||
<a-input v-model:value="form.emergencyContactName" placeholder="请输入紧急联系人名称" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="联系人电话">
|
||||
<a-input v-model:value="form.emergencyContactPhone" placeholder="请输入紧急联系人电话" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="与本人关系">
|
||||
<a-select v-model:value="form.emergencyContactRelation" placeholder="请选择关系">
|
||||
<a-form-item label="与紧急联系人关系">
|
||||
<a-select v-model:value="form.emergencyContactRelation" placeholder="请选择与紧急联系人关系">
|
||||
<a-select-option v-for="opt in EMERGENCY_CONTACT_RELATION_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
|
@ -236,54 +243,135 @@
|
|||
<a-space size="large">
|
||||
<div>
|
||||
<div class="upload-label">身份证人像面</div>
|
||||
<IdCardUpload side="front" :channel-no="form.channel" v-model:file-no="idCardFrontFileNo" />
|
||||
<IdCardUpload
|
||||
side="front"
|
||||
:channel-no="form.channel"
|
||||
:readonly="isDetail"
|
||||
v-model:file-no="idCardFrontFileNo"
|
||||
v-model:uploading="idCardFrontUploading"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="upload-label">身份证国徽面</div>
|
||||
<IdCardUpload side="back" :channel-no="form.channel" v-model:file-no="idCardBackFileNo" />
|
||||
<IdCardUpload
|
||||
side="back"
|
||||
:channel-no="form.channel"
|
||||
:readonly="isDetail"
|
||||
v-model:file-no="idCardBackFileNo"
|
||||
v-model:uploading="idCardBackUploading"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div class="upload-label">营业执照(选填)</div>
|
||||
<LicenseUpload :channel-no="form.channel" :readonly="isDetail" v-model:file-no="businessLicenseFileNo" />
|
||||
<div class="upload-label">代付情况说明</div>
|
||||
<a-space align="start">
|
||||
<LicenseUpload
|
||||
:channel-no="form.channel"
|
||||
:readonly="isDetail"
|
||||
upload-text="上传代付情况说明"
|
||||
v-model:file-no="businessLicenseFileNo"
|
||||
v-model:uploading="businessLicenseUploading"
|
||||
/>
|
||||
<a-button v-if="!isDetail" disabled title="模板文件待业务方提供" @click="handleDownloadTemplate">下载模板</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
<div>
|
||||
<div class="upload-label">人脸识别信息</div>
|
||||
<FacePhotoUpload
|
||||
:channel-no="form.channel"
|
||||
:id-no="certificateNumber"
|
||||
:name="form.customerName"
|
||||
:readonly="isDetail"
|
||||
v-model:file-no="facePhotoFileNo"
|
||||
v-model:uploading="facePhotoUploading"
|
||||
/>
|
||||
</div>
|
||||
</a-space>
|
||||
</a-card>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
|
||||
<a-space v-if="!isDetail">
|
||||
<a-button type="primary" :loading="submitting" @click="handleSubmit">提交</a-button>
|
||||
<a-button @click="goBack">取消</a-button>
|
||||
</a-space>
|
||||
<WalletAccountPicker ref="walletPickerRef" customer-type="PERSONAL" title="选择个人钱包账户" @select="handleSelectWallet" />
|
||||
<WalletAccountPicker ref="enterpriseWalletPickerRef" customer-type="ENTERPRISE" title="选择企业钱包账户" @select="handleSelectEnterpriseWallet" />
|
||||
<LoanProductPicker ref="productPickerRef" @select="handleSelectProduct" />
|
||||
|
||||
<WalletCustomerPicker ref="customerPickerRef" mode="personal" @select="handleSelectCustomer" />
|
||||
<!-- 二次确认弹窗:参照 PersonalOpenForm.vue/EnterpriseOpenForm.vue 同款模式,confirmModalStep
|
||||
在同一个 a-modal 内切换"确认(保存/取消/扫码确认)"与"二维码"两种视图。扫码后走向的移动端
|
||||
H5 确认页不在本项目前端实现范围(详见《缺失的后端接口.md》第19条),这里只负责生成并展示
|
||||
二维码内容。 -->
|
||||
<a-modal
|
||||
v-model:open="confirmModalVisible"
|
||||
title="提示"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
@cancel="handleModalCancel"
|
||||
>
|
||||
<template v-if="confirmModalStep === 'confirm'">
|
||||
<p>是否确认{{ isCreate ? '提交' : '修改' }}贷款申请?</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>
|
||||
|
||||
<a-modal v-model:open="agreementPreviewOpen" :title="agreementPreviewTitle" width="720px" :footer="null">
|
||||
<a-spin :spinning="agreementPreviewLoading">
|
||||
<a-empty v-if="!agreementPreviewLoading && agreementPreviewError" :description="agreementPreviewError" />
|
||||
<iframe
|
||||
v-else-if="agreementPreviewSrc"
|
||||
:src="agreementPreviewSrc"
|
||||
title="协议预览"
|
||||
style="width: 100%; height: 70vh; border: none"
|
||||
></iframe>
|
||||
</a-spin>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { ref, reactive, computed, onMounted, onBeforeUnmount } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { message } from 'ant-design-vue'
|
||||
import dayjs from 'dayjs'
|
||||
import { showErrorModal } from '@/utils/errorModal'
|
||||
import ChannelSelect from '@/components/ChannelSelect.vue'
|
||||
import OrgTreeSelect from '@/components/OrgTreeSelect.vue'
|
||||
import DictSelect from '@/components/DictSelect.vue'
|
||||
import IdCardUpload from '@/components/IdCardUpload.vue'
|
||||
import LicenseUpload from '@/components/LicenseUpload.vue'
|
||||
import WalletCustomerPicker from '@/components/WalletCustomerPicker.vue'
|
||||
import FacePhotoUpload from '@/components/FacePhotoUpload.vue'
|
||||
import WalletAccountPicker from '@/components/WalletAccountPicker.vue'
|
||||
import LoanProductPicker from '@/components/LoanProductPicker.vue'
|
||||
import AdministrativeDivisionCascader from '@/components/AdministrativeDivisionCascader.vue'
|
||||
import QrcodeVue from 'qrcode.vue'
|
||||
import { buildLoanApplicationQrCodeUrl } from '@/utils/qrCode'
|
||||
import {
|
||||
createPersonalLoanApplicationApi,
|
||||
updatePersonalLoanApplicationApi,
|
||||
fetchLoanApplicationDetailApi
|
||||
} from '@/api/credit'
|
||||
import { fetchWalletAccountDetailApi } from '@/api/wallet'
|
||||
import { fetchPersonalCustomerDetailApi, fetchEnterpriseCustomerDetailApi, fetchBankAgreementApi } from '@/api/customer'
|
||||
import { getCurrentOrganizationId } from '@/utils/orgScope'
|
||||
import {
|
||||
GOODS_CATEGORY_OPTIONS,
|
||||
PAYMENT_CYCLE_OPTIONS,
|
||||
ETHNICITY_OPTIONS,
|
||||
EDUCATION_LEVEL_OPTIONS,
|
||||
MARITAL_STATUS_OPTIONS,
|
||||
EMERGENCY_CONTACT_RELATION_OPTIONS,
|
||||
LOAN_APPLICATION_STATUS_OPTIONS
|
||||
LOAN_APPLICATION_STATUS_OPTIONS,
|
||||
LOAN_APPLICATION_IMAGE_FILE_TYPE,
|
||||
BANK_AGREEMENT_FILE_TYPE_LABELS,
|
||||
ENTERPRISE_RELATION_OPTIONS
|
||||
} from '@/constants/creditEnums'
|
||||
|
||||
const route = useRoute()
|
||||
|
|
@ -299,10 +387,70 @@ const isDetail = computed(() => mode.value === 'detail')
|
|||
const pageTitle = computed(() => ({ create: '新增个人贷款申请', edit: '编辑个人贷款申请', detail: '查看个人贷款申请' }[mode.value]))
|
||||
|
||||
const submitting = ref(false)
|
||||
|
||||
// 二次确认弹窗:参照 PersonalOpenForm.vue/EnterpriseOpenForm.vue 同款"确认(保存/取消/扫码确认)
|
||||
// →二维码"模式。confirmModalStep 为 'confirm'(默认)/'qrcode',在同一个 a-modal 内切换视图。
|
||||
const confirmModalVisible = ref(false)
|
||||
const confirmModalStep = ref('confirm')
|
||||
const modalSaving = ref(false)
|
||||
const modalInviting = ref(false)
|
||||
const qrApplicationNo = ref('')
|
||||
const qrCodeUrl = ref('')
|
||||
const idCardFrontFileNo = ref('')
|
||||
const idCardBackFileNo = ref('')
|
||||
const businessLicenseFileNo = ref('')
|
||||
const customerPickerRef = ref(null)
|
||||
const facePhotoFileNo = ref('')
|
||||
// 影像资料各上传项的上传中状态,纳入下方 busy 统一遮罩,并在提交前校验阻止上传未完成时提交
|
||||
// (对齐 AGENTS.md"耗时操作统一等待态规范"与 EnterpriseForm.vue 已有实现)
|
||||
const idCardFrontUploading = ref(false)
|
||||
const idCardBackUploading = ref(false)
|
||||
const businessLicenseUploading = ref(false)
|
||||
const facePhotoUploading = ref(false)
|
||||
const busy = computed(
|
||||
() =>
|
||||
submitting.value ||
|
||||
idCardFrontUploading.value ||
|
||||
idCardBackUploading.value ||
|
||||
businessLicenseUploading.value ||
|
||||
facePhotoUploading.value
|
||||
)
|
||||
const busyTip = computed(() => {
|
||||
if (idCardFrontUploading.value || idCardBackUploading.value) return '身份证图片上传中,请稍候...'
|
||||
if (businessLicenseUploading.value) return '代付情况说明上传中,请稍候...'
|
||||
if (facePhotoUploading.value) return '人脸识别中,请稍候...'
|
||||
if (submitting.value) return '提交中,请稍候...'
|
||||
return ''
|
||||
})
|
||||
const walletPickerRef = ref(null)
|
||||
const enterpriseWalletPickerRef = ref(null)
|
||||
const productPickerRef = ref(null)
|
||||
|
||||
// 经营实体(企业)区块:开关仅前端控制显隐,CreatePersonalLoanApplicationDraftBto 无对应布尔字段
|
||||
// (已知缺口),关闭时不提交经营实体相关字段。证件号码/与经营企业关系/企业类型均为本区块新增字段,
|
||||
// Bto 完全没有承接位置(已知缺口,登记于《缺失的后端接口.md》),仅作页面展示,不随表单提交。
|
||||
const businessEntityEnabled = ref(true)
|
||||
const enterpriseCertificateNumber = ref('')
|
||||
const enterpriseRelation = ref(undefined)
|
||||
const enterpriseType = ref(undefined)
|
||||
|
||||
// 证件号码:选择钱包后从个人客户详情自动回显,但 CreatePersonalLoanApplicationDraftBto 没有
|
||||
// 对应字段(已知缺口,登记于《缺失的后端接口.md》),仅作页面展示/人工核对用,不随表单提交。
|
||||
const certificateNumber = ref('')
|
||||
// 选中钱包账户的账号,仅展示用(Bto 无 accountNo/accountId 字段可提交,已知缺口)
|
||||
const walletAccountNo = ref('')
|
||||
|
||||
// 协议勾选:未勾选不允许点击"确认"(按钮 disabled),不随表单提交(Bto 无对应字段)
|
||||
const agreementChecked = ref(false)
|
||||
const agreementSectionRef = ref(null)
|
||||
|
||||
// 协议原文预览:调用真实后端接口 GET /api/customer-info/bank-agreement/base64 获取 base64 PDF,
|
||||
// 转 Blob URL 后用 iframe 展示,参考 fryl_h5/src/views/steps/AgreementStep.vue 的实现迁移到 PC 端
|
||||
const agreementPreviewOpen = ref(false)
|
||||
const agreementPreviewLoading = ref(false)
|
||||
const agreementPreviewError = ref('')
|
||||
const agreementPreviewTitle = ref('')
|
||||
const agreementPreviewSrc = ref('')
|
||||
const agreementPreviewCache = {}
|
||||
|
||||
const detailInfo = reactive({ createdBy: '', applicationStatus: '' })
|
||||
const statusText = computed(
|
||||
|
|
@ -328,9 +476,9 @@ const form = reactive({
|
|||
permanentAddress: '',
|
||||
enterpriseName: '',
|
||||
applicationInterestRate: '',
|
||||
ethnicity: undefined,
|
||||
ethnicity: 'HAN',
|
||||
educationLevel: undefined,
|
||||
maritalStatus: undefined,
|
||||
maritalStatus: 'UNMARRIED',
|
||||
registeredCity: '',
|
||||
annualPersonalIncome: '',
|
||||
annualFamilyIncome: '',
|
||||
|
|
@ -351,16 +499,121 @@ const businessStartDateModel = computed({
|
|||
set: (val) => (form.businessStartDate = val ? val.format('YYYY-MM-DD') : '')
|
||||
})
|
||||
|
||||
function openCustomerPicker() {
|
||||
customerPickerRef.value.show()
|
||||
function openWalletPicker() {
|
||||
walletPickerRef.value.show()
|
||||
}
|
||||
|
||||
function handleSelectCustomer(record) {
|
||||
form.customerName = record.customerName
|
||||
form.phoneNo = record.mobilePhone || form.phoneNo
|
||||
form.permanentAddress = record.certificateAddress || form.permanentAddress
|
||||
const walletMainBalance = ref('')
|
||||
|
||||
// 选择钱包账户后:①尝试调用账户详情展示余额信息(需要 channelNo,渠道未选择时跳过,不阻断
|
||||
// 选择流程,仅展示不提交——CreatePersonalLoanApplicationDraftBto 无 accountNo/accountId 字段
|
||||
// 可提交,已知缺口,登记于《缺失的后端接口.md》);②用账户所属 customerId 查询个人客户详情,
|
||||
// 自动回填证件号码/手机号/户籍地址/客户名称,对齐业务需求"证件号码、户籍常住地址、手机号码,
|
||||
// 选择钱包后自动反显"的描述。
|
||||
async function handleSelectWallet(record) {
|
||||
walletAccountNo.value = record.accountNo || ''
|
||||
walletMainBalance.value = ''
|
||||
if (form.channel) {
|
||||
try {
|
||||
const detailRes = await fetchWalletAccountDetailApi({ id: record.id, channelNo: form.channel })
|
||||
if (detailRes.data.code === 200) {
|
||||
walletMainBalance.value = detailRes.data.data?.mainBalance || ''
|
||||
}
|
||||
} catch {
|
||||
// 仅用于页面展示账户余额信息,查询失败不阻断选择流程
|
||||
}
|
||||
}
|
||||
if (!record.customerId) return
|
||||
try {
|
||||
const customerRes = await fetchPersonalCustomerDetailApi({ id: record.customerId })
|
||||
if (customerRes.data.code === 200) {
|
||||
const customer = customerRes.data.data
|
||||
form.customerName = customer.customerName || form.customerName
|
||||
form.phoneNo = customer.mobilePhone || form.phoneNo
|
||||
form.permanentAddress = customer.certificateAddress || form.permanentAddress
|
||||
certificateNumber.value = customer.certificateNumber || ''
|
||||
}
|
||||
} catch {
|
||||
// 客户详情查询失败不阻断选择流程,可人工手动补录证件号码/手机号/地址
|
||||
}
|
||||
}
|
||||
|
||||
function openProductPicker() {
|
||||
productPickerRef.value.show()
|
||||
}
|
||||
|
||||
function handleSelectProduct({ loanProductCode }) {
|
||||
form.loanProductCode = loanProductCode
|
||||
}
|
||||
|
||||
function openEnterpriseWalletPicker() {
|
||||
enterpriseWalletPickerRef.value.show()
|
||||
}
|
||||
|
||||
// 选择经营企业的钱包账户后,用其 customerId 查询企业客户详情,回填经营实体名称(form.enterpriseName,
|
||||
// Bto 已有字段)与证件号码(企业统一社会信用代码,仅展示,Bto 无对应字段,已知缺口)
|
||||
async function handleSelectEnterpriseWallet(record) {
|
||||
if (!record.customerId) return
|
||||
try {
|
||||
const res = await fetchEnterpriseCustomerDetailApi({ id: record.customerId })
|
||||
if (res.data.code === 200) {
|
||||
const enterprise = res.data.data
|
||||
form.enterpriseName = enterprise.enterpriseName || form.enterpriseName
|
||||
enterpriseCertificateNumber.value = enterprise.certificateNumber || ''
|
||||
}
|
||||
} catch {
|
||||
// 企业客户详情查询失败不阻断选择流程,可人工手动补录
|
||||
}
|
||||
}
|
||||
|
||||
function handleDownloadTemplate() {
|
||||
message.info('经营证明模板文件由业务方另行提供,暂未接入')
|
||||
}
|
||||
|
||||
// base64 -> Blob URL:不能直接把 data: URI 赋给 iframe.src(Safari/WebKit 会拦截 iframe 对
|
||||
// data: URI 的导航),需转成 Blob 再用 URL.createObjectURL 生成 blob: URL,参考
|
||||
// fryl_h5/src/views/steps/AgreementStep.vue 的同款实现。
|
||||
function base64ToBlobUrl(base64, mime = 'application/pdf') {
|
||||
const byteChars = atob(base64)
|
||||
const byteNumbers = new Uint8Array(byteChars.length)
|
||||
for (let i = 0; i < byteChars.length; i++) {
|
||||
byteNumbers[i] = byteChars.charCodeAt(i)
|
||||
}
|
||||
return URL.createObjectURL(new Blob([byteNumbers], { type: mime }))
|
||||
}
|
||||
|
||||
async function openAgreement(fileType) {
|
||||
agreementPreviewTitle.value = BANK_AGREEMENT_FILE_TYPE_LABELS[fileType]
|
||||
agreementPreviewOpen.value = true
|
||||
agreementPreviewError.value = ''
|
||||
if (agreementPreviewCache[fileType]) {
|
||||
agreementPreviewSrc.value = agreementPreviewCache[fileType]
|
||||
agreementPreviewLoading.value = false
|
||||
return
|
||||
}
|
||||
agreementPreviewSrc.value = ''
|
||||
agreementPreviewLoading.value = true
|
||||
try {
|
||||
const res = await fetchBankAgreementApi(fileType)
|
||||
const fileContent = res.data?.code === 200 ? res.data.data?.fileContent : ''
|
||||
if (fileContent) {
|
||||
const url = base64ToBlobUrl(fileContent)
|
||||
agreementPreviewCache[fileType] = url
|
||||
agreementPreviewSrc.value = url
|
||||
} else {
|
||||
agreementPreviewError.value = res.data?.message || '协议内容加载失败,请稍后重试'
|
||||
}
|
||||
} catch {
|
||||
agreementPreviewError.value = '协议内容加载失败,请检查网络后重试'
|
||||
} finally {
|
||||
agreementPreviewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
Object.values(agreementPreviewCache).forEach((url) => URL.revokeObjectURL(url))
|
||||
})
|
||||
|
||||
function toFullDate(dateStr) {
|
||||
return dateStr ? `${dateStr} 00:00:00` : undefined
|
||||
}
|
||||
|
|
@ -405,6 +658,9 @@ async function loadDetail() {
|
|||
businessScope: data.businessScope,
|
||||
phoneNo: data.phoneNo
|
||||
})
|
||||
// accountNo 在 LoanApplicationDetailVo 中存在,但个人贷款草稿创建接口不接受该字段
|
||||
// (已知缺口),此处仅用于编辑/查看态尽量展示历史数据,不代表可通过本页面重新写入。
|
||||
walletAccountNo.value = data.accountNo || ''
|
||||
detailInfo.createdBy = data.createdBy
|
||||
detailInfo.applicationStatus = data.applicationStatus
|
||||
}
|
||||
|
|
@ -417,17 +673,40 @@ onMounted(() => {
|
|||
|
||||
function buildImageList() {
|
||||
const list = []
|
||||
if (idCardFrontFileNo.value) list.push({ fileType: '身份证正面', fileNo: idCardFrontFileNo.value })
|
||||
if (idCardBackFileNo.value) list.push({ fileType: '身份证反面', fileNo: idCardBackFileNo.value })
|
||||
if (businessLicenseFileNo.value) list.push({ fileType: '营业执照', fileNo: businessLicenseFileNo.value })
|
||||
if (idCardFrontFileNo.value) list.push({ fileType: LOAN_APPLICATION_IMAGE_FILE_TYPE.ID_CARD_FRONT, fileNo: idCardFrontFileNo.value })
|
||||
if (idCardBackFileNo.value) list.push({ fileType: LOAN_APPLICATION_IMAGE_FILE_TYPE.ID_CARD_BACK, fileNo: idCardBackFileNo.value })
|
||||
if (businessLicenseFileNo.value) list.push({ fileType: LOAN_APPLICATION_IMAGE_FILE_TYPE.BUSINESS_PROOF, fileNo: businessLicenseFileNo.value })
|
||||
if (facePhotoFileNo.value) list.push({ fileType: LOAN_APPLICATION_IMAGE_FILE_TYPE.FACE_PHOTO, fileNo: facePhotoFileNo.value })
|
||||
return list
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
async function handleConfirmButtonClick() {
|
||||
if (!form.channel) {
|
||||
showErrorModal('请先选择所属渠道')
|
||||
return
|
||||
}
|
||||
if (idCardFrontUploading.value || idCardBackUploading.value || businessLicenseUploading.value || facePhotoUploading.value) {
|
||||
showErrorModal('影像资料仍在上传中,请稍候再提交')
|
||||
return
|
||||
}
|
||||
if (!isDetail.value && !agreementChecked.value) {
|
||||
agreementSectionRef.value?.$el?.scrollIntoView?.({ behavior: 'smooth', block: 'center' })
|
||||
showErrorModal('请先阅读并勾选同意相关授权书')
|
||||
return
|
||||
}
|
||||
if (businessEntityEnabled.value && (form.businessScope.match(/[\u4e00-\u9fa5]/g) || []).length < 10) {
|
||||
showErrorModal('请输入经营范围,至少10个汉字')
|
||||
return
|
||||
}
|
||||
confirmModalStep.value = 'confirm'
|
||||
confirmModalVisible.value = true
|
||||
}
|
||||
|
||||
// 真正提交草稿(创建/修改)的动作,弹窗内"保存"/"扫码确认"两个按钮共用同一次提交,区别只在
|
||||
// 成功后的前端行为(是否切到二维码态),与 PersonalOpenForm.vue 的 submitCreateDraft 同一模式。
|
||||
// create 场景返回后端新生成的 loanApplicationId(create 接口 data 即为该字符串);edit 场景直接
|
||||
// 沿用已知的 form.loanApplicationId(update 接口的响应字段未在契约中明确声明为该值,不依赖它)。
|
||||
async function submitApplication() {
|
||||
submitting.value = true
|
||||
try {
|
||||
const payload = {
|
||||
|
|
@ -435,6 +714,9 @@ async function handleSubmit() {
|
|||
channel: form.channel,
|
||||
loanProductCode: form.loanProductCode,
|
||||
customerName: form.customerName,
|
||||
// 2026-08-10 按最新截图删除"融资条件"整卡,页面不再收集下面这 7 个字段;create 场景下它们
|
||||
// 始终是初始值(undefined/空串),edit 场景下 loadDetail 仍会把历史值写回 form,此处原样
|
||||
// 透传而不清空,避免"编辑一条历史上带这些字段的旧申请"时把已有数据静默覆盖为空。
|
||||
goodsCategory: form.goodsCategory,
|
||||
paymentCycle: form.paymentCycle,
|
||||
singleOrderLimit: form.singleOrderLimit,
|
||||
|
|
@ -446,7 +728,7 @@ async function handleSubmit() {
|
|||
emergencyContactPhone: form.emergencyContactPhone,
|
||||
loanPurpose: form.loanPurpose,
|
||||
permanentAddress: form.permanentAddress,
|
||||
enterpriseName: form.enterpriseName,
|
||||
enterpriseName: businessEntityEnabled.value ? form.enterpriseName : undefined,
|
||||
applicationInterestRate: form.applicationInterestRate,
|
||||
ethnicity: form.ethnicity,
|
||||
educationLevel: form.educationLevel,
|
||||
|
|
@ -454,7 +736,7 @@ async function handleSubmit() {
|
|||
registeredCity: form.registeredCity,
|
||||
annualPersonalIncome: form.annualPersonalIncome,
|
||||
annualFamilyIncome: form.annualFamilyIncome,
|
||||
businessStartDate: toFullDate(form.businessStartDate),
|
||||
businessStartDate: businessEntityEnabled.value ? toFullDate(form.businessStartDate) : undefined,
|
||||
emergencyContactRelation: form.emergencyContactRelation,
|
||||
spouseName: form.spouseName,
|
||||
spousePhone: form.spousePhone,
|
||||
|
|
@ -462,7 +744,7 @@ async function handleSubmit() {
|
|||
spouseGender: form.spouseGender,
|
||||
spouseCertId: form.spouseCertId,
|
||||
emergencyContactName: form.emergencyContactName,
|
||||
businessScope: form.businessScope,
|
||||
businessScope: businessEntityEnabled.value ? form.businessScope : undefined,
|
||||
phoneNo: form.phoneNo,
|
||||
loanApplicationImageBtoList: buildImageList()
|
||||
}
|
||||
|
|
@ -473,14 +755,53 @@ async function handleSubmit() {
|
|||
res = await updatePersonalLoanApplicationApi({ loanApplicationId: form.loanApplicationId, ...payload })
|
||||
}
|
||||
if (res.data.code === 200) {
|
||||
message.success(isCreate.value ? '贷款申请草稿已提交' : '修改成功')
|
||||
goBack()
|
||||
return isCreate.value ? res.data.data : form.loanApplicationId
|
||||
}
|
||||
return null
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function handleModalSave() {
|
||||
modalSaving.value = true
|
||||
try {
|
||||
const loanApplicationId = await submitApplication()
|
||||
if (loanApplicationId) {
|
||||
message.success(isCreate.value ? '贷款申请草稿已提交' : '修改成功')
|
||||
confirmModalVisible.value = false
|
||||
goBack()
|
||||
}
|
||||
} finally {
|
||||
modalSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// "扫码确认":与"保存"共用同一次真实提交(不是先弹二维码后再确认存不存),提交成功后不关闭弹窗,
|
||||
// 切到二维码态展示;扫码后走向的移动端 H5 确认页不在本项目前端实现范围,这里只生成并展示二维码。
|
||||
async function handleModalInvite() {
|
||||
modalInviting.value = true
|
||||
try {
|
||||
const loanApplicationId = await submitApplication()
|
||||
if (loanApplicationId) {
|
||||
qrApplicationNo.value = loanApplicationId
|
||||
qrCodeUrl.value = buildLoanApplicationQrCodeUrl(loanApplicationId)
|
||||
confirmModalStep.value = 'qrcode'
|
||||
}
|
||||
} finally {
|
||||
modalInviting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 二维码态下点击"关闭":此时草稿已提交成功,等价于"保存"的收尾动作,直接返回列表;
|
||||
// 确认态下点击"取消"/弹窗右上角关闭图标:仅关闭弹窗,不发任何请求,表单数据保留。
|
||||
function handleModalCancel() {
|
||||
const wasQrcode = confirmModalStep.value === 'qrcode'
|
||||
confirmModalVisible.value = false
|
||||
confirmModalStep.value = 'confirm'
|
||||
if (wasQrcode) goBack()
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push('/credit/loan-application')
|
||||
}
|
||||
|
|
@ -492,4 +813,20 @@ function goBack() {
|
|||
font-size: 12px;
|
||||
color: #666;
|
||||
}
|
||||
.business-scope-hint {
|
||||
font-size: 12px;
|
||||
color: #ff4d4f;
|
||||
line-height: 1.5;
|
||||
}
|
||||
.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