新增个人贷款申请

main
halo 2026-08-10 17:28:02 +08:00
parent 2d168b2a77
commit 03fc69cd2b
12 changed files with 805 additions and 148 deletions

View File

@ -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

View File

@ -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

View File

@ -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 } })

View File

@ -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)

View File

@ -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)

View File

@ -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 :,"codecode",
// 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>

View File

@ -22,7 +22,7 @@
<div v-else>
<LoadingOutlined v-if="uploading" />
<PlusOutlined v-else />
<div class="upload-text">上传营业执照</div>
<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

View File

@ -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>

View File

@ -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>

View File

@ -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: "实控人" }
]

View File

@ -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}`
}

View File

@ -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/createupdate
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>
<WalletAccountPicker ref="walletPickerRef" customer-type="PERSONAL" title="选择个人钱包账户" @select="handleSelectWallet" />
<WalletAccountPicker ref="enterpriseWalletPickerRef" customer-type="ENTERPRISE" title="选择企业钱包账户" @select="handleSelectEnterpriseWallet" />
<LoanProductPicker ref="productPickerRef" @select="handleSelectProduct" />
<!-- 二次确认弹窗:参照 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>
<WalletCustomerPicker ref="customerPickerRef" mode="personal" @select="handleSelectCustomer" />
<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>