提交
This commit is contained in:
4
.env
4
.env
@@ -2,7 +2,7 @@
|
||||
VITE_PORT = 8001
|
||||
|
||||
# spa-title
|
||||
VITE_GLOB_APP_TITLE = AdminPro
|
||||
VITE_GLOB_APP_TITLE = FinLoan
|
||||
|
||||
# spa shortname
|
||||
VITE_GLOB_APP_SHORT_NAME = AdminPro
|
||||
VITE_GLOB_APP_SHORT_NAME = FinLoan
|
||||
|
||||
@@ -5,7 +5,7 @@ VITE_PORT = 8001
|
||||
VITE_PUBLIC_PATH = /
|
||||
|
||||
# 是否开启 mock
|
||||
VITE_USE_MOCK = false
|
||||
VITE_USE_MOCK = mock
|
||||
|
||||
# 是否开启控制台打印 mock 请求信息
|
||||
VITE_LOGGER_MOCK = true
|
||||
@@ -15,13 +15,13 @@ VITE_DROP_CONSOLE = true
|
||||
|
||||
# 跨域代理,可以配置多个,请注意不要换行
|
||||
#VITE_PROXY = [["/appApi","http://localhost:8001"],["/upload","http://localhost:8001/upload"]]
|
||||
VITE_PROXY=[["/api","http://192.168.1.26:7060"]]
|
||||
VITE_PROXY=[["/fg-api","https://fin-loan.mmlizi.com"]]
|
||||
|
||||
# API 接口地址
|
||||
#VITE_GLOB_API_URL = http://192.168.1.26:7060
|
||||
VITE_GLOB_API_URL = /
|
||||
|
||||
# 接口前缀
|
||||
#VITE_GLOB_API_URL_PREFIX =
|
||||
#VITE_GLOB_API_URL_PREFIX = /fg-api
|
||||
|
||||
# 文件上传地址
|
||||
VITE_GLOB_UPLOAD_URL=
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
# 是否开启mock
|
||||
VITE_USE_MOCK = true
|
||||
VITE_USE_MOCK = false
|
||||
|
||||
# 网站根目录
|
||||
VITE_PUBLIC_PATH = /
|
||||
VITE_PUBLIC_PATH = /fg
|
||||
|
||||
# 是否删除console
|
||||
VITE_DROP_CONSOLE = true
|
||||
|
||||
# API
|
||||
VITE_GLOB_API_URL =
|
||||
VITE_GLOB_API_URL = /fg-api
|
||||
|
||||
# 接口前缀
|
||||
VITE_GLOB_API_URL_PREFIX = /api
|
||||
VITE_GLOB_API_URL_PREFIX =
|
||||
|
||||
# 图片上传地址
|
||||
VITE_GLOB_UPLOAD_URL=
|
||||
|
||||
182
src/api/loan/application.ts
Normal file
182
src/api/loan/application.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { Alova } from '@/utils/http/alova/index';
|
||||
import { ResultEnum } from '@/enums/httpEnum';
|
||||
import { PageEnum } from '@/enums/pageEnum';
|
||||
import { ACCESS_TOKEN } from '@/store/mutation-types';
|
||||
import { storage } from '@/utils/Storage';
|
||||
|
||||
const LOAN_FILE_HOST = 'http://192.168.1.26:7060';
|
||||
|
||||
export interface LoanApplicationQueryListReq {
|
||||
pageNum: number;
|
||||
pageSize: number;
|
||||
yearMonth: string;
|
||||
name?: string;
|
||||
contact?: string;
|
||||
idCard?: string;
|
||||
isProcessed?: boolean;
|
||||
}
|
||||
|
||||
export interface LoanApplicationHandleReq {
|
||||
idList: number[];
|
||||
}
|
||||
|
||||
export interface LoanFileObj {
|
||||
suffix: string;
|
||||
fileName: string;
|
||||
original: string;
|
||||
url: string;
|
||||
}
|
||||
|
||||
export interface LoanApplicationQueryListRes {
|
||||
id: number;
|
||||
name: string;
|
||||
contact: string;
|
||||
idCard: string;
|
||||
businessAddress: string;
|
||||
businessItem: string;
|
||||
businessDuration: number;
|
||||
annualIncome: number;
|
||||
hasDebt: boolean;
|
||||
loanAmount: number;
|
||||
familyAssets: string[];
|
||||
businessLicense?: LoanFileObj;
|
||||
auxiliaryMaterials?: LoanFileObj[];
|
||||
loanType: string;
|
||||
isProcessed: boolean;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
export interface LoanApplicationPageRes {
|
||||
records: LoanApplicationQueryListRes[];
|
||||
pageNumber: number;
|
||||
pageSize: number;
|
||||
totalPage: number;
|
||||
totalRow: number;
|
||||
optimizeCountQuery: boolean;
|
||||
}
|
||||
|
||||
export function getLoanFileUrl(file?: LoanFileObj) {
|
||||
if (!file?.url) return '';
|
||||
if (/^https?:\/\//i.test(file.url)) return file.url;
|
||||
return `${LOAN_FILE_HOST}${file.url.startsWith('/') ? file.url : `/${file.url}`}`;
|
||||
}
|
||||
|
||||
interface FinResult<T> {
|
||||
code: number;
|
||||
message: string;
|
||||
data: T;
|
||||
}
|
||||
|
||||
function isSuccessCode(code: number) {
|
||||
return code === ResultEnum.SUCCESS || code === 0;
|
||||
}
|
||||
|
||||
function getEmptyPage(params: LoanApplicationQueryListReq): LoanApplicationPageRes {
|
||||
return {
|
||||
records: [],
|
||||
pageNumber: params.pageNum,
|
||||
pageSize: params.pageSize,
|
||||
totalPage: 0,
|
||||
totalRow: 0,
|
||||
optimizeCountQuery: true,
|
||||
};
|
||||
}
|
||||
|
||||
export async function getLoanApplicationList(params: LoanApplicationQueryListReq) {
|
||||
const response = await Alova.Post<FinResult<LoanApplicationPageRes>>(
|
||||
'/loan/application/applicationList',
|
||||
params,
|
||||
{
|
||||
meta: {
|
||||
isReturnNativeResponse: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(response.message || '获取申请贷款列表失败');
|
||||
}
|
||||
|
||||
return response.data || getEmptyPage(params);
|
||||
}
|
||||
|
||||
export async function handleLoanApplication(params: LoanApplicationHandleReq) {
|
||||
const response = await Alova.Post<FinResult<string>>(
|
||||
'/loan/application/handleApplication',
|
||||
params,
|
||||
{
|
||||
meta: {
|
||||
isReturnNativeResponse: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!isSuccessCode(response.code)) {
|
||||
throw new Error(response.message || '处理申请贷款失败');
|
||||
}
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
function redirectToLogin() {
|
||||
const { pathname, search, hash } = window.location;
|
||||
const currentPath = `${pathname}${search}${hash}`;
|
||||
const redirectPath =
|
||||
pathname === PageEnum.BASE_LOGIN
|
||||
? PageEnum.BASE_LOGIN
|
||||
: `${PageEnum.BASE_LOGIN}?redirect=${encodeURIComponent(currentPath)}`;
|
||||
|
||||
storage.clear();
|
||||
window.location.replace(redirectPath);
|
||||
}
|
||||
|
||||
function getExportFileName(disposition: string | null) {
|
||||
if (!disposition) return `loan-applications-${Date.now()}.xlsx`;
|
||||
|
||||
const utf8FileName = disposition.match(/filename\*=UTF-8''([^;]+)/i);
|
||||
if (utf8FileName?.[1]) {
|
||||
return decodeURIComponent(utf8FileName[1]);
|
||||
}
|
||||
|
||||
const fileName = disposition.match(/filename="?([^"]+)"?/i);
|
||||
if (fileName?.[1]) {
|
||||
return decodeURIComponent(fileName[1]);
|
||||
}
|
||||
|
||||
return `loan-applications-${Date.now()}.xlsx`;
|
||||
}
|
||||
|
||||
export async function exportLoanApplicationList(params: LoanApplicationQueryListReq) {
|
||||
const token = storage.get(ACCESS_TOKEN, '');
|
||||
const response = await fetch('/loan/application/export', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
if (contentType.includes('application/json')) {
|
||||
const result = (await response.json()) as FinResult<unknown>;
|
||||
if (Number(result.code) === ResultEnum.LOGIN_EXPIRED) {
|
||||
redirectToLogin();
|
||||
throw new Error(result.message || '请先登录');
|
||||
}
|
||||
if (!isSuccessCode(Number(result.code))) {
|
||||
throw new Error(result.message || '导出申请贷款失败');
|
||||
}
|
||||
throw new Error('导出接口未返回文件');
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('导出申请贷款失败');
|
||||
}
|
||||
|
||||
return {
|
||||
blob: await response.blob(),
|
||||
fileName: getExportFileName(response.headers.get('content-disposition')),
|
||||
};
|
||||
}
|
||||
@@ -49,7 +49,7 @@ export interface LogoutRes {
|
||||
* @description: 获取用户信息
|
||||
*/
|
||||
export function getUserInfo() {
|
||||
return Alova.Get<UserInfoRes>('/api/user/info', {
|
||||
return Alova.Get<UserInfoRes>('/user/info', {
|
||||
meta: {
|
||||
isReturnNativeResponse: true,
|
||||
},
|
||||
@@ -61,7 +61,7 @@ export function getUserInfo() {
|
||||
*/
|
||||
export function login(params: LoginParams) {
|
||||
return Alova.Post<LoginRes>(
|
||||
'/api/user/login',
|
||||
'/fg-api/user/login',
|
||||
params,
|
||||
{
|
||||
meta: {
|
||||
@@ -82,7 +82,7 @@ export function changePassword(params, uid) {
|
||||
* @description: 用户登出
|
||||
*/
|
||||
export function logout() {
|
||||
return Alova.Get<LogoutRes>('/api/user/logout', {
|
||||
return Alova.Get<LogoutRes>('/user/logout', {
|
||||
meta: {
|
||||
isReturnNativeResponse: true,
|
||||
},
|
||||
|
||||
@@ -239,15 +239,28 @@
|
||||
cacheRoutes = [simpleRoute];
|
||||
}
|
||||
|
||||
// 将最新的路由信息同步到 localStorage 中
|
||||
// 将最新的路由信息同步到 localStorage 中,并清掉已移除菜单留下的旧标签
|
||||
const routes = router.getRoutes();
|
||||
cacheRoutes.forEach((cacheRoute) => {
|
||||
const route = routes.find((route) => route.path === cacheRoute.path);
|
||||
cacheRoutes = cacheRoutes
|
||||
.filter((cacheRoute) =>
|
||||
routes.some(
|
||||
(route) => route.path === cacheRoute.path || route.name === cacheRoute.name
|
||||
)
|
||||
)
|
||||
.map((cacheRoute) => {
|
||||
const route = routes.find(
|
||||
(route) => route.path === cacheRoute.path || route.name === cacheRoute.name
|
||||
);
|
||||
if (route) {
|
||||
cacheRoute.meta = route.meta || cacheRoute.meta;
|
||||
cacheRoute.name = (route.name || cacheRoute.name) as string;
|
||||
}
|
||||
});
|
||||
return cacheRoute;
|
||||
});
|
||||
|
||||
if (!cacheRoutes.length) {
|
||||
cacheRoutes = [simpleRoute];
|
||||
}
|
||||
|
||||
// 初始化标签页
|
||||
tabsViewStore.initTabs(cacheRoutes);
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { App } from 'vue';
|
||||
import { createRouter, createWebHistory, RouteRecordRaw } from 'vue-router';
|
||||
import { createRouter, createWebHashHistory, RouteRecordRaw } from 'vue-router';
|
||||
import { RedirectRoute } from '@/router/base';
|
||||
import { PageEnum } from '@/enums/pageEnum';
|
||||
import { createRouterGuards } from './guards';
|
||||
import type { IModuleType } from './types';
|
||||
|
||||
const modules = import.meta.glob<IModuleType>('./modules/**/*.ts', { eager: true });
|
||||
const modules = import.meta.glob<IModuleType>('./modules/loan.ts', { eager: true });
|
||||
|
||||
const routeModuleList: RouteRecordRaw[] = Object.keys(modules).reduce((list, key) => {
|
||||
const mod = modules[key].default ?? {};
|
||||
@@ -43,11 +43,16 @@ export const asyncRoutes = [...routeModuleList];
|
||||
//普通路由 无需验证权限
|
||||
export const constantRouter: RouteRecordRaw[] = [LoginRoute, RootRoute, RedirectRoute];
|
||||
|
||||
// const router = createRouter({
|
||||
// history: createWebHistory(import.meta.env.BASE_URL),
|
||||
// routes: constantRouter,
|
||||
// strict: true,
|
||||
// scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
// });
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createWebHashHistory(import.meta.env.BASE_URL),
|
||||
routes: constantRouter,
|
||||
strict: true,
|
||||
scrollBehavior: () => ({ left: 0, top: 0 }),
|
||||
});
|
||||
|
||||
export function setupRouter(app: App) {
|
||||
|
||||
41
src/router/modules/loan.ts
Normal file
41
src/router/modules/loan.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { RouteRecordRaw } from 'vue-router';
|
||||
import { Layout } from '@/router/constant';
|
||||
import { TableOutlined } from '@vicons/antd';
|
||||
import { renderIcon } from '@/utils/index';
|
||||
|
||||
const routes: Array<RouteRecordRaw> = [
|
||||
{
|
||||
path: '/loan',
|
||||
name: 'Loan',
|
||||
redirect: '/loan/application',
|
||||
component: Layout,
|
||||
meta: {
|
||||
title: '贷款管理',
|
||||
icon: renderIcon(TableOutlined),
|
||||
sort: 0,
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'application',
|
||||
name: 'LoanApplication',
|
||||
meta: {
|
||||
title: '申请贷款列表',
|
||||
affix: true,
|
||||
},
|
||||
component: () => import('@/views/loan/application/index.vue'),
|
||||
},
|
||||
{
|
||||
path: 'application/:id',
|
||||
name: 'LoanApplicationDetail',
|
||||
meta: {
|
||||
title: '申请贷款详情',
|
||||
hidden: true,
|
||||
activeMenu: 'LoanApplication',
|
||||
},
|
||||
component: () => import('@/views/loan/application/detail.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default routes;
|
||||
12
src/utils/encrypt.ts
Normal file
12
src/utils/encrypt.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import JSEncrypt from 'jsencrypt';
|
||||
|
||||
const PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
|
||||
MFwwDQYJKoZIhvcNAQEBBQADSwAwSAJBALDoU+fLIG+nGS5CugznjdUjym6tBIoz
|
||||
CxLmUifF2Sb3MkzgxVvz5k1fDLKlCrztSRDioHaYo+N/co7hIjlpbtECAwEAAQ==
|
||||
-----END PUBLIC KEY-----`;
|
||||
|
||||
export function encryptPassword(password: string) {
|
||||
const encryptor = new JSEncrypt();
|
||||
encryptor.setPublicKey(PUBLIC_KEY);
|
||||
return encryptor.encrypt(password);
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import { createAlova } from 'alova';
|
||||
import VueHook from 'alova/vue';
|
||||
import adapterFetch from 'alova/fetch';
|
||||
import { createAlovaMockAdapter } from '@alova/mock';
|
||||
import { isString } from 'lodash-es';
|
||||
import mocks from './mocks';
|
||||
import { useUser } from '@/store/modules/user';
|
||||
import { storage } from '@/utils/Storage';
|
||||
@@ -77,9 +76,6 @@ export const Alova = createAlova({
|
||||
if (!isUrlStr && urlPrefix) {
|
||||
method.url = `${urlPrefix}${method.url}`;
|
||||
}
|
||||
if (!isUrlStr && apiUrl && isString(apiUrl)) {
|
||||
method.url = `${apiUrl}${method.url}`;
|
||||
}
|
||||
},
|
||||
responded: {
|
||||
onSuccess: async (response, method) => {
|
||||
|
||||
224
src/views/loan/application/detail.vue
Normal file
224
src/views/loan/application/detail.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<n-card :bordered="false">
|
||||
<n-space justify="space-between" align="center">
|
||||
<n-h3 class="m-0">申请贷款详情</n-h3>
|
||||
<n-button @click="goBack">返回列表</n-button>
|
||||
</n-space>
|
||||
</n-card>
|
||||
|
||||
<template v-if="application">
|
||||
<n-card :bordered="false" class="mt-3" title="申请信息">
|
||||
<n-descriptions bordered :column="2" label-placement="left">
|
||||
<n-descriptions-item label="姓名">{{ application.name || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="联系方式">{{ application.contact || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="身份证号码">{{ application.idCard || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="贷款类型">{{ loanTypeText }}</n-descriptions-item>
|
||||
<n-descriptions-item label="经营地址">{{ application.businessAddress || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="经营项目">{{ application.businessItem || '-' }}</n-descriptions-item>
|
||||
<n-descriptions-item label="经营时间">
|
||||
{{ application.businessDuration ?? 0 }} 年
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="经营收入">
|
||||
{{ application.annualIncome ?? 0 }} 万元/年
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="是否有负债">
|
||||
<n-tag :type="application.hasDebt ? 'warning' : 'success'" size="small">
|
||||
{{ application.hasDebt ? '有' : '无' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="贷款需求金额">
|
||||
{{ application.loanAmount ?? 0 }} 万元
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="家庭主要资产" :span="2">
|
||||
<n-space v-if="familyAssets.length" size="small">
|
||||
<n-tag v-for="asset in familyAssets" :key="asset" size="small">
|
||||
{{ asset }}
|
||||
</n-tag>
|
||||
</n-space>
|
||||
<span v-else>-</span>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="处理状态">
|
||||
<n-tag :type="application.isProcessed ? 'success' : 'warning'" size="small">
|
||||
{{ application.isProcessed ? '已处理' : '未处理' }}
|
||||
</n-tag>
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="创建时间">
|
||||
{{ formatBackendDateTime(application.createTime) }}
|
||||
</n-descriptions-item>
|
||||
<n-descriptions-item label="更新时间" :span="2">
|
||||
{{ formatBackendDateTime(application.updateTime) }}
|
||||
</n-descriptions-item>
|
||||
</n-descriptions>
|
||||
</n-card>
|
||||
|
||||
<n-card :bordered="false" class="mt-3" title="营业执照">
|
||||
<div v-if="businessLicenseFile" class="image-panel">
|
||||
<n-image
|
||||
v-if="businessLicenseFile.isImage"
|
||||
width="360"
|
||||
object-fit="contain"
|
||||
:src="businessLicenseFile.url"
|
||||
:alt="businessLicenseFile.name"
|
||||
/>
|
||||
<div v-else class="file-card">
|
||||
<n-tag size="small">{{ businessLicenseFile.suffix || 'FILE' }}</n-tag>
|
||||
<div class="file-name">{{ businessLicenseFile.name }}</div>
|
||||
</div>
|
||||
<n-button text tag="a" :href="businessLicenseFile.url" target="_blank">
|
||||
{{ businessLicenseFile.isImage ? '查看原图' : '打开文件' }}
|
||||
</n-button>
|
||||
</div>
|
||||
<n-empty v-else description="暂无营业执照" />
|
||||
</n-card>
|
||||
|
||||
<n-card :bordered="false" class="mt-3" title="辅助材料">
|
||||
<n-grid v-if="auxiliaryFiles.length" :cols="3" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<n-gi v-for="file in auxiliaryFiles" :key="file.url">
|
||||
<div class="image-panel">
|
||||
<n-image
|
||||
v-if="file.isImage"
|
||||
width="280"
|
||||
object-fit="contain"
|
||||
:src="file.url"
|
||||
:alt="file.name"
|
||||
/>
|
||||
<div v-else class="file-card">
|
||||
<n-tag size="small">{{ file.suffix || 'FILE' }}</n-tag>
|
||||
<div class="file-name">{{ file.name }}</div>
|
||||
</div>
|
||||
<n-button text tag="a" :href="file.url" target="_blank">
|
||||
{{ file.isImage ? file.name : '打开文件' }}
|
||||
</n-button>
|
||||
</div>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-empty v-else description="暂无辅助材料" />
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<n-card v-else :bordered="false" class="mt-3">
|
||||
<n-empty description="未找到申请详情,请从列表页重新进入" />
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { getLoanFileUrl } from '@/api/loan/application';
|
||||
import type { LoanApplicationQueryListRes, LoanFileObj } from '@/api/loan/application';
|
||||
import { formatBackendDateTime } from '@/utils/dateUtil';
|
||||
|
||||
const DETAIL_STORAGE_PREFIX = 'LOAN_APPLICATION_DETAIL';
|
||||
const IMAGE_SUFFIXES = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'tif', 'tiff'];
|
||||
|
||||
const familyAssetMap: Record<string, string> = {
|
||||
'0': '无',
|
||||
'1': '商品房',
|
||||
'2': '自建房国有',
|
||||
'3': '自建房集体',
|
||||
'4': '商铺',
|
||||
'5': '土地',
|
||||
'6': '车辆',
|
||||
'7': '其他',
|
||||
};
|
||||
|
||||
const loanTypeMap: Record<string, string> = {
|
||||
'0': '信用贷',
|
||||
'1': '抵押贷',
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const application = ref<LoanApplicationQueryListRes | null>(null);
|
||||
|
||||
const loanTypeText = computed(() => {
|
||||
const value = application.value?.loanType;
|
||||
return value === undefined || value === null ? '-' : loanTypeMap[String(value)] || '-';
|
||||
});
|
||||
|
||||
const familyAssets = computed(() => {
|
||||
return (application.value?.familyAssets || []).map((asset) => familyAssetMap[String(asset)] || asset);
|
||||
});
|
||||
|
||||
function getFileSuffix(file: LoanFileObj) {
|
||||
const rawSuffix =
|
||||
file.suffix ||
|
||||
file.url?.split('?')[0].split('.').pop() ||
|
||||
file.original?.split('.').pop() ||
|
||||
file.fileName?.split('.').pop() ||
|
||||
'';
|
||||
return rawSuffix.replace(/^\./, '').toLowerCase();
|
||||
}
|
||||
|
||||
function getDisplayFile(file: LoanFileObj) {
|
||||
const suffix = getFileSuffix(file);
|
||||
return {
|
||||
url: getLoanFileUrl(file),
|
||||
name: file.original || file.fileName || '材料文件',
|
||||
suffix,
|
||||
isImage: IMAGE_SUFFIXES.includes(suffix),
|
||||
};
|
||||
}
|
||||
|
||||
const businessLicenseFile = computed(() => {
|
||||
const file = application.value?.businessLicense;
|
||||
if (!file) return null;
|
||||
|
||||
const displayFile = getDisplayFile(file);
|
||||
return displayFile.url ? displayFile : null;
|
||||
});
|
||||
|
||||
const auxiliaryFiles = computed(() => {
|
||||
return (application.value?.auxiliaryMaterials || [])
|
||||
.map((file) => getDisplayFile(file))
|
||||
.filter((file) => file.url);
|
||||
});
|
||||
|
||||
function loadDetail() {
|
||||
const id = String(route.params.id || '');
|
||||
const raw = sessionStorage.getItem(`${DETAIL_STORAGE_PREFIX}:${id}`);
|
||||
if (!raw) return;
|
||||
|
||||
try {
|
||||
application.value = JSON.parse(raw);
|
||||
} catch (error) {
|
||||
application.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
router.push({ name: 'LoanApplication' });
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadDetail();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.image-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.file-card {
|
||||
width: 280px;
|
||||
min-height: 120px;
|
||||
padding: 16px;
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 8px;
|
||||
background: #fafafa;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.file-name {
|
||||
color: #333;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
541
src/views/loan/application/index.vue
Normal file
541
src/views/loan/application/index.vue
Normal file
@@ -0,0 +1,541 @@
|
||||
<template>
|
||||
<n-card :bordered="false">
|
||||
<n-form label-placement="left" :label-width="92" :model="queryForm" :show-feedback="false">
|
||||
<n-grid :cols="4" :x-gap="16" :y-gap="16" responsive="screen">
|
||||
<n-gi>
|
||||
<n-form-item label="年月" path="yearMonth">
|
||||
<n-date-picker
|
||||
v-model:value="monthValue"
|
||||
type="month"
|
||||
clearable
|
||||
class="w-full"
|
||||
placeholder="请选择年月"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="姓名" path="name">
|
||||
<n-input v-model:value="queryForm.name" clearable placeholder="请输入姓名" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="联系方式" path="contact">
|
||||
<n-input v-model:value="queryForm.contact" clearable placeholder="请输入联系方式" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="身份证号" path="idCard">
|
||||
<n-input v-model:value="queryForm.idCard" clearable placeholder="请输入身份证号码" />
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
<n-gi>
|
||||
<n-form-item label="处理状态" path="isProcessed">
|
||||
<n-select
|
||||
v-model:value="queryForm.isProcessed"
|
||||
:options="processedOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
</n-form-item>
|
||||
</n-gi>
|
||||
</n-grid>
|
||||
<n-space justify="end">
|
||||
<n-button @click="handleReset">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<ReloadOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
重置
|
||||
</n-button>
|
||||
<n-button type="primary" @click="handleSearch">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<SearchOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
查询
|
||||
</n-button>
|
||||
</n-space>
|
||||
</n-form>
|
||||
</n-card>
|
||||
|
||||
<n-card :bordered="false" class="mt-3" title="申请贷款列表">
|
||||
<template #header-extra>
|
||||
<n-space>
|
||||
<n-button
|
||||
type="primary"
|
||||
secondary
|
||||
:disabled="!checkedRowKeys.length"
|
||||
:loading="handleLoading"
|
||||
@click="confirmHandleSelected"
|
||||
>
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<CheckOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
批量处理
|
||||
</n-button>
|
||||
<n-button type="info" :loading="exportLoading" @click="handleExport">
|
||||
<template #icon>
|
||||
<n-icon>
|
||||
<DownloadOutlined />
|
||||
</n-icon>
|
||||
</template>
|
||||
导出
|
||||
</n-button>
|
||||
</n-space>
|
||||
</template>
|
||||
|
||||
<n-data-table
|
||||
remote
|
||||
striped
|
||||
:columns="columns"
|
||||
:data="tableData"
|
||||
:loading="loading"
|
||||
:pagination="pagination"
|
||||
:row-key="getRowKey"
|
||||
:checked-row-keys="checkedRowKeys"
|
||||
:scroll-x="1700"
|
||||
:single-line="false"
|
||||
@update:checked-row-keys="handleCheckedRowKeys"
|
||||
/>
|
||||
</n-card>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, h, onMounted, reactive, ref } from 'vue';
|
||||
import { format } from 'date-fns';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { DataTableColumns, DataTableRowKey } from 'naive-ui';
|
||||
import { NButton, NSpace, NTag, useDialog, useMessage } from 'naive-ui';
|
||||
import { CheckOutlined, DownloadOutlined, ReloadOutlined, SearchOutlined } from '@vicons/antd';
|
||||
import {
|
||||
exportLoanApplicationList,
|
||||
getLoanApplicationList,
|
||||
handleLoanApplication,
|
||||
} from '@/api/loan/application';
|
||||
import type {
|
||||
LoanApplicationQueryListReq,
|
||||
LoanApplicationQueryListRes,
|
||||
} from '@/api/loan/application';
|
||||
import { formatBackendDateTime } from '@/utils/dateUtil';
|
||||
|
||||
const DETAIL_STORAGE_PREFIX = 'LOAN_APPLICATION_DETAIL';
|
||||
|
||||
const familyAssetMap: Record<string, string> = {
|
||||
'0': '无',
|
||||
'1': '商品房',
|
||||
'2': '自建房国有',
|
||||
'3': '自建房集体',
|
||||
'4': '商铺',
|
||||
'5': '土地',
|
||||
'6': '车辆',
|
||||
'7': '其他',
|
||||
};
|
||||
|
||||
const loanTypeMap: Record<string, string> = {
|
||||
'0': '信用贷',
|
||||
'1': '抵押贷',
|
||||
};
|
||||
|
||||
type ProcessedFilterValue = '' | 'true' | 'false';
|
||||
|
||||
const processedOptions = [
|
||||
{
|
||||
label: '全部',
|
||||
value: '',
|
||||
},
|
||||
{
|
||||
label: '已处理',
|
||||
value: 'true',
|
||||
},
|
||||
{
|
||||
label: '未处理',
|
||||
value: 'false',
|
||||
},
|
||||
];
|
||||
|
||||
const message = useMessage();
|
||||
const dialog = useDialog();
|
||||
const router = useRouter();
|
||||
|
||||
const loading = ref(false);
|
||||
const handleLoading = ref(false);
|
||||
const exportLoading = ref(false);
|
||||
const tableData = ref<LoanApplicationQueryListRes[]>([]);
|
||||
const checkedRowKeys = ref<DataTableRowKey[]>([]);
|
||||
const monthValue = ref<number | null>(Date.now());
|
||||
const queryForm = reactive({
|
||||
name: '',
|
||||
contact: '',
|
||||
idCard: '',
|
||||
isProcessed: '' as ProcessedFilterValue,
|
||||
});
|
||||
|
||||
const yearMonth = computed(() => {
|
||||
return monthValue.value ? format(monthValue.value, 'yyyy-MM') : '';
|
||||
});
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
itemCount: 0,
|
||||
pageSizes: [10, 20, 30, 50],
|
||||
showSizePicker: true,
|
||||
showQuickJumper: true,
|
||||
prefix: ({ itemCount }: { itemCount: number }) => `共 ${itemCount} 条`,
|
||||
onChange: (page: number) => {
|
||||
pagination.page = page;
|
||||
fetchList();
|
||||
},
|
||||
onUpdatePageSize: (pageSize: number) => {
|
||||
pagination.pageSize = pageSize;
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
},
|
||||
});
|
||||
|
||||
const columns: DataTableColumns<LoanApplicationQueryListRes> = [
|
||||
{
|
||||
type: 'selection',
|
||||
fixed: 'left',
|
||||
width: 48,
|
||||
},
|
||||
{
|
||||
title: '姓名',
|
||||
key: 'name',
|
||||
width: 100,
|
||||
fixed: 'left',
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '联系方式',
|
||||
key: 'contact',
|
||||
width: 130,
|
||||
},
|
||||
{
|
||||
title: '身份证号码',
|
||||
key: 'idCard',
|
||||
width: 180,
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '经营地址',
|
||||
key: 'businessAddress',
|
||||
width: 180,
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '经营项目',
|
||||
key: 'businessItem',
|
||||
width: 140,
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '经营时间',
|
||||
key: 'businessDuration',
|
||||
width: 110,
|
||||
render: (row) => `${row.businessDuration ?? 0} 年`,
|
||||
},
|
||||
{
|
||||
title: '年收入',
|
||||
key: 'annualIncome',
|
||||
width: 120,
|
||||
render: (row) => `${row.annualIncome ?? 0} 万元`,
|
||||
},
|
||||
{
|
||||
title: '负债',
|
||||
key: 'hasDebt',
|
||||
width: 90,
|
||||
render: (row) => renderBooleanTag(row.hasDebt, '有', '无'),
|
||||
},
|
||||
{
|
||||
title: '需求金额',
|
||||
key: 'loanAmount',
|
||||
width: 120,
|
||||
render: (row) => `${row.loanAmount ?? 0} 万元`,
|
||||
},
|
||||
{
|
||||
title: '家庭资产',
|
||||
key: 'familyAssets',
|
||||
width: 220,
|
||||
render: (row) => renderFamilyAssets(row.familyAssets),
|
||||
},
|
||||
{
|
||||
title: '贷款类型',
|
||||
key: 'loanType',
|
||||
width: 110,
|
||||
render: (row) => renderLoanType(row.loanType),
|
||||
},
|
||||
{
|
||||
title: '处理状态',
|
||||
key: 'isProcessed',
|
||||
width: 110,
|
||||
render: (row) => renderBooleanTag(row.isProcessed, '已处理', '未处理', true),
|
||||
},
|
||||
{
|
||||
title: '创建时间',
|
||||
key: 'createTime',
|
||||
width: 180,
|
||||
render: (row) => formatBackendDateTime(row.createTime),
|
||||
ellipsis: {
|
||||
tooltip: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
key: 'actions',
|
||||
width: 170,
|
||||
fixed: 'right',
|
||||
render: (row) =>
|
||||
h(
|
||||
NSpace,
|
||||
{
|
||||
size: 8,
|
||||
},
|
||||
{
|
||||
default: () => [
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
secondary: true,
|
||||
type: 'info',
|
||||
onClick: () => openDetail(row),
|
||||
},
|
||||
{ default: () => '详情' }
|
||||
),
|
||||
h(
|
||||
NButton,
|
||||
{
|
||||
size: 'small',
|
||||
type: 'primary',
|
||||
disabled: row.isProcessed,
|
||||
loading: handleLoading.value,
|
||||
onClick: () => confirmHandle([row.id]),
|
||||
},
|
||||
{ default: () => (row.isProcessed ? '已处理' : '处理') }
|
||||
),
|
||||
],
|
||||
}
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
function normalizeText(value?: string) {
|
||||
return value?.trim() || undefined;
|
||||
}
|
||||
|
||||
function buildParams(): LoanApplicationQueryListReq {
|
||||
const params: LoanApplicationQueryListReq = {
|
||||
pageNum: pagination.page,
|
||||
pageSize: pagination.pageSize,
|
||||
yearMonth: yearMonth.value,
|
||||
name: normalizeText(queryForm.name),
|
||||
contact: normalizeText(queryForm.contact),
|
||||
idCard: normalizeText(queryForm.idCard),
|
||||
};
|
||||
|
||||
if (queryForm.isProcessed) {
|
||||
params.isProcessed = queryForm.isProcessed === 'true';
|
||||
}
|
||||
|
||||
return params;
|
||||
}
|
||||
|
||||
function getRowKey(row: LoanApplicationQueryListRes) {
|
||||
return row.id;
|
||||
}
|
||||
|
||||
function renderBooleanTag(value: boolean, positiveText: string, negativeText: string, reverse = false) {
|
||||
const type = reverse
|
||||
? value
|
||||
? 'success'
|
||||
: 'warning'
|
||||
: value
|
||||
? 'warning'
|
||||
: 'success';
|
||||
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
type,
|
||||
size: 'small',
|
||||
},
|
||||
{ default: () => (value ? positiveText : negativeText) }
|
||||
);
|
||||
}
|
||||
|
||||
function renderLoanType(value: string) {
|
||||
return h(
|
||||
NTag,
|
||||
{
|
||||
type: value === '1' ? 'info' : 'default',
|
||||
size: 'small',
|
||||
},
|
||||
{ default: () => loanTypeMap[String(value)] || '-' }
|
||||
);
|
||||
}
|
||||
|
||||
function renderFamilyAssets(values?: string[]) {
|
||||
if (!values?.length) return '-';
|
||||
|
||||
return h(
|
||||
NSpace,
|
||||
{
|
||||
size: 4,
|
||||
},
|
||||
{
|
||||
default: () =>
|
||||
values.map((value) =>
|
||||
h(
|
||||
NTag,
|
||||
{
|
||||
size: 'small',
|
||||
},
|
||||
{ default: () => familyAssetMap[String(value)] || value }
|
||||
)
|
||||
),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchList() {
|
||||
if (!yearMonth.value) {
|
||||
message.warning('请选择年月');
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const data = await getLoanApplicationList(buildParams());
|
||||
tableData.value = data.records || [];
|
||||
pagination.page = Number(data.pageNumber || pagination.page);
|
||||
pagination.pageSize = Number(data.pageSize || pagination.pageSize);
|
||||
pagination.itemCount = Number(data.totalRow || 0);
|
||||
checkedRowKeys.value = [];
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '获取申请贷款列表失败';
|
||||
message.error(errorMessage);
|
||||
tableData.value = [];
|
||||
pagination.itemCount = 0;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.page = 1;
|
||||
fetchList();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
queryForm.name = '';
|
||||
queryForm.contact = '';
|
||||
queryForm.idCard = '';
|
||||
queryForm.isProcessed = '';
|
||||
monthValue.value = Date.now();
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleCheckedRowKeys(keys: DataTableRowKey[]) {
|
||||
checkedRowKeys.value = keys;
|
||||
}
|
||||
|
||||
function openDetail(row: LoanApplicationQueryListRes) {
|
||||
sessionStorage.setItem(`${DETAIL_STORAGE_PREFIX}:${row.id}`, JSON.stringify(row));
|
||||
router.push({
|
||||
name: 'LoanApplicationDetail',
|
||||
params: {
|
||||
id: row.id,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function confirmHandleSelected() {
|
||||
confirmHandle(checkedRowKeys.value.map(Number));
|
||||
}
|
||||
|
||||
function confirmHandle(idList: number[]) {
|
||||
if (!idList.length) {
|
||||
message.warning('请选择要处理的申请');
|
||||
return;
|
||||
}
|
||||
|
||||
dialog.warning({
|
||||
title: '确认处理',
|
||||
content: `确定处理选中的 ${idList.length} 条贷款申请吗?`,
|
||||
positiveText: '确认',
|
||||
negativeText: '取消',
|
||||
onPositiveClick: () => processApplications(idList),
|
||||
});
|
||||
}
|
||||
|
||||
async function processApplications(idList: number[]) {
|
||||
handleLoading.value = true;
|
||||
try {
|
||||
await handleLoanApplication({ idList });
|
||||
message.success('处理成功');
|
||||
await fetchList();
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '处理申请贷款失败';
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
handleLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
link.remove();
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
if (!yearMonth.value) {
|
||||
message.warning('请选择年月');
|
||||
return;
|
||||
}
|
||||
|
||||
exportLoading.value = true;
|
||||
try {
|
||||
const { blob, fileName } = await exportLoanApplicationList(buildParams());
|
||||
downloadBlob(blob, fileName);
|
||||
message.success('导出成功');
|
||||
} catch (error) {
|
||||
const errorMessage = error instanceof Error ? error.message : '导出申请贷款失败';
|
||||
message.error(errorMessage);
|
||||
} finally {
|
||||
exportLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchList();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="less" scoped>
|
||||
.file-link {
|
||||
color: #1677ff;
|
||||
text-decoration: none;
|
||||
|
||||
&:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -76,8 +76,8 @@
|
||||
const loading = ref(false);
|
||||
const autoLogin = ref(true);
|
||||
const formInline = reactive({
|
||||
username: 'FinAdmin',
|
||||
password: 'Fin9527',
|
||||
username: '',
|
||||
password: '',
|
||||
isCaptcha: true,
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user