Files
tms-erp-web/src/views/business/project-apply.vue
T
gxwebsoft 704c3093d7 feat(project-apply): 调整项目管理表单及搜索功能
- 项目编号放开编辑,新增时可输入,编辑时禁用
- 取消项目简称的必填校验,仅限制最大长度20
- 业务部门、项目由来说明表单项占满一行,优化布局
- 业务部门下拉增加过滤,重大项目补录模式不显示外部组织
- 新增变更编号及变更日期字段,关联变更类型显示
- 搜索栏将项目负责人由下拉切换为手动输入模糊查询
- 新增项目编号、客户名称、下游承运商三个搜索字段
- 清理无用的搜索工具函数和配置,优化代码结构
2026-08-27 20:05:51 +08:00

2405 lines
80 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<basic-container class="project-apply-page">
<avue-crud
v-if="!isProjectFormPage"
:option="tableOption"
:table-loading="loading"
:data="data"
v-model:page="page"
v-model="tableForm"
ref="crud"
:permission="permissionList"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@on-load="onLoad"
>
<template #menu-left>
<el-button
type="primary"
icon="el-icon-plus"
v-if="canCreate"
@click="openProjectDialog('add')"
>
新增
</el-button>
<el-button
type="primary"
icon="el-icon-plus"
v-if="canMajorProjectSupplement"
@click="openProjectDialog('majorSupplement')"
>
重大项目补录
</el-button>
<el-button
type="primary"
icon="el-icon-download"
plain
v-if="config.exportUrl && hasPermission(`${config.permission}_export`)"
@click="handleExport"
>
批量导出
</el-button>
<el-button
type="danger"
icon="el-icon-delete"
plain
v-if="hasPermission(`${config.permission}_delete`) && !readonlyScope"
@click="handleDelete"
>
批量删除
</el-button>
</template>
<template #approvalStatus="{ row }">
{{ displayStatus(row, 'approvalStatus') }}
</template>
<template #menu="{ row, index }">
<el-link
type="primary"
v-if="hasPermission(`${config.permission}_view`)"
@click="openProjectDialog('view', row)"
>
查看
</el-link>
<el-link type="primary" v-if="canEdit(row)" @click="openProjectDialog('edit', row)">
编辑
</el-link>
<el-link
v-for="operation in customOperations(row)"
:key="operation.action"
:type="operation.type || 'primary'"
@click="handleCustomOperation(operation, row)"
>
{{ operation.label }}
</el-link>
<el-link type="danger" v-if="canDelete(row)" @click="rowDel(row)"> 删除 </el-link>
</template>
</avue-crud>
<empty-pagination
v-if="!isProjectFormPage"
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
<component
:is="projectFormContainer"
v-if="projectBox"
v-bind="projectFormContainerProps"
@update:model-value="projectBox = $event"
@closed="resetProjectDialog"
>
<div v-if="isProjectFormPage" class="archive-page-form__title">
{{ projectDialogTitle }}
</div>
<el-form
ref="projectForm"
:model="form"
:rules="formRules"
label-position="right"
label-width="150"
class="project-apply-form"
:disabled="dialogReadonly"
>
<div class="dialog-section-title">项目基本信息</div>
<div class="project-apply-form__grid">
<el-form-item v-if="isChangeDialog" label="变更类型" prop="changeType">
<el-select v-model="form.changeType" placeholder="请选择变更类型">
<el-option
v-for="item in changeTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="isChangeDialog" label="变更编号" prop="changeNo">
<el-input v-model="form.changeNo" disabled placeholder="系统自动生成" />
</el-form-item>
<el-form-item v-if="isChangeDialog" label="变更日期" prop="changeDate">
<el-input v-model="form.changeDate" disabled placeholder="系统自动生成" />
</el-form-item>
<el-form-item label="项目类型" prop="projectType">
<el-select
v-model="form.projectType"
placeholder="请选择项目类型"
clearable
:disabled="isBasicInfoReadonly || isMajorProjectSupplementDialog"
@change="handleProjectTypeChange"
>
<el-option
v-for="item in projectTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="项目编号" prop="projectCode">
<el-input
v-model="form.projectCode"
placeholder="请输入项目编号,不填自动生成"
:disabled="dialogType === 'edit'"
/>
</el-form-item>
<el-form-item label="项目名称" prop="projectName">
<el-input
v-model="form.projectName"
placeholder="请填写项目名称"
maxlength="50"
:disabled="isBasicInfoReadonly"
/>
</el-form-item>
<el-form-item label="项目简称" prop="projectShortName">
<el-input
v-model="form.projectShortName"
placeholder="请填写项目简称"
maxlength="20"
:disabled="isBasicInfoReadonly"
/>
</el-form-item>
<el-form-item label="业务部门" prop="businessDeptId" class="project-apply-form__item--full">
<el-tree-select
v-model="form.businessDeptId"
:data="businessDeptTreeOptions"
:props="deptTreeSelectProps"
node-key="value"
placeholder="请选择业务部门"
check-strictly
filterable
clearable
:render-after-expand="false"
:disabled="isBasicInfoReadonly"
@change="handleBusinessDeptChange"
/>
</el-form-item>
<el-form-item label="承办部门" prop="undertakeDeptId">
<el-tree-select
v-model="form.undertakeDeptId"
:data="deptTreeOptions"
:props="deptTreeSelectProps"
node-key="value"
placeholder="请选择承办部门"
check-strictly
clearable
filterable
:render-after-expand="false"
:disabled="isBasicInfoReadonly"
@change="handleUndertakeDeptChange"
/>
</el-form-item>
<el-form-item label="项目由来" prop="projectSource">
<el-select
v-model="form.projectSource"
placeholder="请选择项目由来"
clearable
:disabled="isBasicInfoReadonly"
>
<el-option
v-for="item in projectSourceOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="项目由来说明" prop="sourceRemark" class="project-apply-form__item--full">
<el-input v-model="form.sourceRemark" maxlength="300" :disabled="isBasicInfoReadonly" />
</el-form-item>
</div>
<div class="dialog-section-title">项目主要内容</div>
<div class="project-apply-form__grid">
<el-form-item label="客户名称" prop="customerNames">
<el-select
v-model="selectedCustomerId"
placeholder="支持多选,选择后下方列表展示"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
:loading="customerLoading"
@visible-change="visible => visible && loadCustomerOptions('客户')"
@change="handleCustomerChange"
>
<el-option
v-for="item in customerOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="下游承运商" prop="carrierNames">
<el-select
v-model="selectedCarrierIds"
placeholder="支持多选选择后下方列表展示"
multiple
filterable
clearable
collapse-tags
collapse-tags-tooltip
:loading="carrierLoading"
@visible-change="visible => visible && loadCustomerOptions('承运商')"
@change="handleCarrierChange"
>
<el-option
v-for="item in carrierOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="项目资金使用额度万元" prop="fundLimit">
<el-input
v-model="form.fundLimit"
placeholder="实际业务回款周期内付款额度"
@input="value => handleNumberInput('fundLimit', value)"
@blur="formatAmountField('fundLimit')"
/>
</el-form-item>
<el-form-item label="项目应收账款额度万元" prop="receivableLimit">
<el-input
v-model="form.receivableLimit"
placeholder="回款期内最大应收账款额度"
@input="value => handleNumberInput('receivableLimit', value)"
@blur="formatAmountField('receivableLimit')"
/>
</el-form-item>
<el-form-item label="应收账款回款期限" prop="receivableDays">
<el-input
v-model="form.receivableDays"
@input="value => handleIntegerInput('receivableDays', value)"
>
<template #suffix>天</template>
</el-input>
</el-form-item>
<el-form-item label="回款账期" prop="paymentDays">
<el-input
v-model="form.paymentDays"
@input="value => handleIntegerInput('paymentDays', value)"
>
<template #suffix>天</template>
</el-input>
</el-form-item>
<el-form-item label="货物类型" prop="cargoType">
<el-select v-model="form.cargoType" placeholder="请选择货物类型" filterable clearable>
<el-option
v-for="item in cargoTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="预估货物数量" prop="cargoQuantity">
<el-input
v-model="form.cargoQuantity"
placeholder="请输入数字"
@input="value => handleIntegerInput('cargoQuantity', value)"
/>
</el-form-item>
<el-form-item label="业务周期" prop="businessDateRange">
<el-date-picker
v-model="form.businessDateRange"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
unlink-panels
/>
</el-form-item>
<el-form-item label="运输线路" prop="transportRoute">
<el-input v-model="form.transportRoute" maxlength="100" />
</el-form-item>
<el-form-item label="运输类型" prop="transportType">
<el-select v-model="form.transportType" placeholder="请选择运输类型" clearable>
<el-option
v-for="item in transportTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="业务类型" prop="businessType">
<el-select v-model="form.businessType" placeholder="请选择业务类型" clearable>
<el-option
v-for="item in businessTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="项目规模万元" prop="projectScale">
<el-input
v-model="form.projectScale"
@input="value => handleNumberInput('projectScale', value)"
/>
</el-form-item>
<el-form-item label="结算方式" prop="settlementMode">
<el-select v-model="form.settlementMode" placeholder="请选择结算方式" clearable>
<el-option
v-for="item in settlementModeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="预估利润万元" prop="estimatedProfit">
<el-input
v-model="form.estimatedProfit"
@input="value => handleNumberInput('estimatedProfit', value)"
/>
</el-form-item>
<el-form-item label="资金需求万元" prop="fundDemand">
<el-input
v-model="form.fundDemand"
@input="value => handleNumberInput('fundDemand', value)"
/>
</el-form-item>
<el-form-item label="项目经办人" prop="handlerUserId">
<el-input
v-model="form.handlerUserName"
readonly
placeholder="弹窗搜索选择"
@click="openUserPicker('handler')"
>
<template #suffix>
<el-icon class="project-apply-form__picker-icon"><Search /></el-icon>
</template>
</el-input>
</el-form-item>
<el-form-item label="项目负责人" prop="principalUserId">
<el-input
v-model="form.principalUserName"
readonly
placeholder="弹窗搜索选择"
@click="openUserPicker('principal')"
>
<template #suffix>
<el-icon class="project-apply-form__picker-icon"><Search /></el-icon>
</template>
</el-input>
</el-form-item>
</div>
<div class="dialog-section-title project-apply-form__table-title">客户信息</div>
<el-table :data="customerRows" border class="project-apply-form__table">
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="customer" label="客户" min-width="160" align="center" />
<el-table-column prop="credit" label="客户信用额度" min-width="150" align="center">
<template #default="{ row }">
{{ normalizeReadonlyAmount(row.credit) }}
</template>
</el-table-column>
<el-table-column prop="companyNature" label="企业性质" min-width="140" align="center" />
<el-table-column prop="legalPerson" label="法定代表人" min-width="140" align="center" />
<el-table-column
prop="address"
label="联系地址"
min-width="180"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="contact" label="联系人" min-width="120" align="center" />
<el-table-column prop="phone" label="联系方式" min-width="140" align="center" />
</el-table>
<div class="dialog-section-title">承运商信息</div>
<el-table :data="carrierRows" border class="project-apply-form__table">
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="carrier" label="承运商" min-width="180" align="center" />
<el-table-column prop="companyNature" label="企业性质" min-width="140" align="center" />
<el-table-column prop="legalPerson" label="法定代表人" min-width="140" align="center" />
<el-table-column
prop="address"
label="联系地址"
min-width="180"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="contact" label="联系人" min-width="120" align="center" />
<el-table-column prop="phone" label="联系方式" min-width="140" align="center" />
</el-table>
<div class="dialog-section-title">项目情况说明</div>
<div class="project-apply-form__textarea-list">
<el-form-item label="项目简介" prop="projectIntro">
<el-input
v-model="form.projectIntro"
type="textarea"
:rows="2"
placeholder="请填写备注信息"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="盈利模式及利润测算情况说明" prop="profitRemark">
<el-input
v-model="form.profitRemark"
type="textarea"
:rows="2"
placeholder="请填写备注信息"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="项目风险点" prop="riskPoint">
<el-input
v-model="form.riskPoint"
type="textarea"
:rows="2"
placeholder="请填写备注信息"
maxlength="200"
show-word-limit
/>
</el-form-item>
<el-form-item label="风险防范措施及应急预案" prop="emergencyPlan">
<el-input
v-model="form.emergencyPlan"
type="textarea"
:rows="2"
placeholder="请填写备注信息"
maxlength="200"
show-word-limit
/>
</el-form-item>
</div>
<div class="project-apply-form__material-card">
<div class="project-apply-form__material-head">
<div class="dialog-section-title project-apply-form__material-title">项目材料</div>
<el-button
type="primary"
:disabled="!attachmentRows.length"
@click="handleBatchDownload"
>批量下载</el-button
>
</div>
<el-alert
v-if="dialogReadonly && missingRequiredAttachmentTypes.length"
class="project-apply-form__material-warning"
type="warning"
:closable="false"
show-icon
:title="`缺少必需材料:${missingRequiredAttachmentTypes.join('、')}`"
/>
<el-table
:data="attachmentRows"
border
class="project-apply-form__material-table"
@selection-change="handleAttachmentSelectionChange"
>
<el-table-column type="selection" width="55" align="center" />
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column label="附件类型" min-width="160" align="center">
<template #default="{ row }">
<el-select v-model="row.fileType" placeholder="请选择" :disabled="dialogReadonly">
<el-option
v-for="item in projectAttachmentTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</template>
</el-table-column>
<el-table-column label="文件名" min-width="220" align="center" show-overflow-tooltip>
<template #default="{ row }">
<el-link type="primary" @click="previewAttachment(row)">
{{ attachmentName(row) }}
</el-link>
</template>
</el-table-column>
<el-table-column label="附件描述" min-width="240" align="center">
<template #default="{ row }">
<el-input
v-model="row.description"
placeholder="请输入附件描述"
:disabled="dialogReadonly"
/>
</template>
</el-table-column>
<el-table-column label="文件大小" min-width="120" align="center">
<template #default="{ row }">{{ formatFileSize(row.size) }}</template>
</el-table-column>
<el-table-column prop="uploadUserName" label="上传人" min-width="140" align="center" />
<el-table-column prop="uploadTime" label="上传时间" min-width="170" align="center" sortable/>
<el-table-column label="操作" width="110" align="center">
<template #default="{ row, $index }">
<el-link type="primary" @click="downloadAttachment(row)">下载</el-link>
<el-link v-if="!dialogReadonly" type="danger" @click="removeAttachment($index)"
>删除</el-link
>
</template>
</el-table-column>
</el-table>
<vehicle-attachment-upload
v-model="attachmentRows"
class="project-apply-form__upload"
:readonly="dialogReadonly"
:file-types="attachmentFileTypes"
:max-size="500"
:show-file-list="false"
button-text="上传附件"
tip="支持pdfbmpjpegpngjpgdocdocxpptpptxxlsxxlsemlmsgzip的文件格式单个文件不超过500M"
@change="handleAttachmentChange"
/>
</div>
<div v-if="!isChangeDialog" class="dialog-section-title project-apply-form__table-title">
变更记录
</div>
<el-table
v-if="!isChangeDialog"
:data="changeRows"
border
class="project-apply-form__table"
>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="changeDate" label="变更日期" min-width="140" align="center" sortable/>
<el-table-column prop="changeType" label="变更类型" min-width="160" align="center" />
<el-table-column prop="handler" label="经办人" min-width="120" align="center" />
<el-table-column
prop="content"
label="变更内容"
min-width="180"
align="center"
show-overflow-tooltip
/>
<el-table-column
prop="reason"
label="变更原因"
min-width="180"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="status" label="状态" min-width="120" align="center" />
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-link type="primary" @click="handleChangeRecordAction(row)">
{{ row.action || '查看' }}
</el-link>
</template>
</el-table-column>
</el-table>
<template v-if="isChangeDialog">
<div class="dialog-section-title">变更内容</div>
<el-form-item prop="changeContent" class="project-apply-form__change-textarea">
<el-input
v-model="form.changeContent"
type="textarea"
:rows="2"
placeholder="2000字以内"
maxlength="2000"
show-word-limit
/>
</el-form-item>
<div class="dialog-section-title">变更原因</div>
<el-form-item prop="changeReason" class="project-apply-form__change-textarea">
<el-input
v-model="form.changeReason"
type="textarea"
:rows="2"
placeholder="2000字以内"
maxlength="2000"
show-word-limit
/>
</el-form-item>
</template>
</el-form>
<div
class="project-apply-dialog__footer"
:class="{ 'project-apply-dialog__footer--change': isChangeDialog }"
>
<template v-if="isChangeDialog">
<el-button v-if="!isProjectFormPage" @click="handleCancelProject">取消</el-button>
<el-button v-if="isProjectFormPage" @click="closeProjectForm">取消</el-button>
<el-button type="primary" plain :loading="submitLoading" @click="saveChangeProject">
保存
</el-button>
<el-button type="primary" :loading="submitLoading" @click="submitChangeProject">
提交
</el-button>
</template>
<template v-else>
<el-button v-if="!isProjectFormPage" @click="handleCancelProject">取消</el-button>
<el-button v-if="isProjectFormPage" @click="closeProjectForm">取消</el-button>
<el-button
v-if="!dialogReadonly"
type="primary"
plain
:loading="submitLoading"
@click="saveProject"
>
保存
</el-button>
<el-button
v-if="!dialogReadonly"
type="primary"
:loading="submitLoading"
@click="submitProject"
>
提交
</el-button>
</template>
</div>
</component>
<el-dialog
v-model="attachmentDocumentPreviewVisible"
:title="attachmentPreviewFile.name || '附件预览'"
append-to-body
destroy-on-close
width="90%"
top="4vh"
class="project-apply-form__attachment-viewer-dialog"
>
<pdf-preview
v-if="
attachmentDocumentPreviewVisible &&
attachmentPreviewFile.url &&
attachmentPreviewFile.isPdf
"
:source="attachmentPreviewFile.url"
@error="handleAttachmentPreviewError"
/>
<open-file-viewer
v-else-if="attachmentDocumentPreviewVisible && attachmentPreviewFile.url"
:file="attachmentPreviewFile.url"
:file-name="attachmentPreviewFile.name"
:mime-type="attachmentPreviewFile.mimeType"
width="100%"
height="72vh"
fit="contain"
theme="auto"
locale="zh-CN"
:toolbar="attachmentViewerToolbar"
:plugins="attachmentViewerPlugins"
@unsupported="handleAttachmentPreviewUnsupported"
@error="handleAttachmentPreviewError"
/>
</el-dialog>
<el-image-viewer
v-if="attachmentImagePreviewVisible"
:url-list="attachmentImagePreviewUrls"
:initial-index="attachmentImagePreviewIndex"
@close="attachmentImagePreviewVisible = false"
/>
<el-dialog
v-model="userBox"
title="选择用户"
append-to-body
destroy-on-close
width="900px"
class="project-apply-user-dialog"
>
<div class="project-apply-user-dialog__search">
<el-input
v-model="userQuery.realName"
placeholder="用户姓名"
clearable
@keyup.enter="loadUsers"
/>
<el-input
v-model="userQuery.account"
placeholder="登录账号"
clearable
@keyup.enter="loadUsers"
/>
<el-button type="primary" @click="loadUsers">查询</el-button>
<el-button @click="resetUserQuery">重置</el-button>
</div>
<el-table :data="userData" border v-loading="userLoading" @row-dblclick="selectUser">
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column prop="account" label="登录账号" min-width="140" align="center" />
<el-table-column prop="realName" label="用户姓名" min-width="140" align="center" />
<el-table-column prop="deptName" label="所属部门" min-width="180" align="center" />
<el-table-column label="操作" width="100" align="center">
<template #default="{ row }">
<el-link type="primary" @click="selectUser(row)">选择</el-link>
</template>
</el-table-column>
</el-table>
<div class="project-apply-user-dialog__pager">
<el-pagination
v-model:current-page="userPage.currentPage"
v-model:page-size="userPage.pageSize"
:page-sizes="[10, 20, 50]"
:total="userPage.total"
layout="total, sizes, prev, pager, next"
@current-change="loadUsers"
@size-change="loadUsers"
/>
</div>
</el-dialog>
</basic-container>
</template>
<script>
import { exportBlob } from '@/api/common';
import * as api from '@/api/business/project-apply';
import {
getList as getCustomerArchiveList,
getDetail as getCustomerArchiveDetail,
} from '@/api/vehicle/customer-archive';
import { getDeptTree } from '@/api/system/dept';
import { getDictionary as getSystemDictionary } from '@/api/system/dict';
import { getDictionary as getBizDictionary } from '@/api/system/dictbiz';
import { getList as getUserList } from '@/api/system/user';
import { getToken } from '@/utils/auth';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import { ElImageViewer } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/vue';
import PdfPreview from '@/components/pdf-preview/main.vue';
import {
fallbackPlugin,
imagePlugin,
officePlugin,
textPlugin,
} from '@open-file-viewer/core';
import '@open-file-viewer/core/style.css';
import pdfWorkerSrc from 'pdfjs-dist/build/pdf.worker.mjs?url';
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import {
businessTypeOptions,
projectSourceOptions,
projectTypeOptions,
settlementModeOptions,
} from '@/option/business/common';
import { config, option } from '@/option/business/project-apply';
const emptyForm = () => ({
projectType: '',
projectCode: '',
projectName: '',
projectShortName: '',
businessDeptId: '',
businessDeptName: '',
undertakeDeptId: '',
undertakeDeptName: '',
projectSource: '商务洽谈',
sourceRemark: '',
changeType: '',
changeNo: '',
changeDate: '',
customerNames: '',
carrierNames: '',
fundLimit: '',
receivableLimit: '',
receivableDays: '',
paymentDays: '',
cargoType: '',
cargoQuantity: '',
businessDateRange: [],
businessStartDate: '',
businessEndDate: '',
transportRoute: '',
transportType: '',
businessType: '',
projectScale: '',
settlementMode: '',
estimatedProfit: '',
fundDemand: '',
handlerUserId: '',
handlerUserName: '',
principalUserId: '',
principalUserName: '',
customerJson: '',
carrierJson: '',
attachmentsJson: '',
situationRemark: '',
projectIntro: '',
profitRemark: '',
riskPoint: '',
emergencyPlan: '',
changeContent: '',
changeReason: '',
});
const optionalSentinelFields = [
'projectScale',
'estimatedProfit',
'fundDemand',
'receivableDays',
'paymentDays',
'cargoQuantity',
];
const changeTypeOptions = [
{ label: '项目变更', value: '项目变更' },
{ label: '项目备案调整', value: '项目备案调整' },
];
const attachmentViewerPlugins = [
imagePlugin(),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
const normalProjectAttachmentTypeOptions = [
'利润测算表',
'单一来源供应商申请表/三方比价表',
'合同模板',
'项目操作说明',
'项目分析报告',
'项目考察报告',
].map(item => ({ label: item, value: item }));
const majorProjectAttachmentTypeOptions = [
'会议纪要',
'评审表决票',
'利润测算表',
'单一来源供应商申请表/三方比价表',
'项目操作说明',
'项目分析报告',
'项目考察报告',
'合同模板',
'上游尽职调查报告',
'下游尽职调查报告',
'业务承诺函',
].map(item => ({ label: item, value: item }));
export default {
components: {
ElImageViewer,
OpenFileViewer,
PdfPreview,
},
data() {
const validateAmount = (rule, value, callback) => {
if (value === '' || value === undefined || value === null) {
callback();
return;
}
if (Number(value) < 0) {
callback(new Error(`${rule.label || '金额'}不能小于0`));
return;
}
callback();
};
const validateRequiredAmount = (rule, value, callback) => {
if (value === '' || value === undefined || value === null) {
callback(new Error(`请输入${rule.label}`));
return;
}
validateAmount(rule, value, callback);
};
const validateDays = (rule, value, callback) => {
if (value === '' || value === undefined || value === null) {
callback();
return;
}
const number = Number(value);
if (!Number.isInteger(number) || number < 0 || number > 999) {
callback(new Error(`${rule.label}范围为0到999天`));
return;
}
callback();
};
const validateUser = (rule, value, callback) => {
if (value || this.form[rule.nameProp]) {
callback();
return;
}
callback(new Error(`请选择${rule.label}`));
};
return {
api,
config,
projectTypeOptions,
projectSourceOptions,
transportTypeOptions: [],
businessTypeOptions,
settlementModeOptions,
changeTypeOptions,
tableForm: {},
tableOption: this.buildTableOption(),
loading: true,
data: [],
query: {},
allDept: 0,
page: {
pageSize: 10,
pageSizes: [10, 20, 50, 100],
currentPage: 1,
total: 0,
},
selectionList: [],
projectBox: false,
dialogType: 'add',
dialogReadonly: false,
submitLoading: false,
form: emptyForm(),
formRules: {
changeType: [{ required: true, message: '请选择变更类型', trigger: 'change' }],
projectType: [{ required: true, message: '请选择项目类型', trigger: 'change' }],
projectName: [
{ required: true, message: '请输入项目名称', trigger: 'blur' },
{ max: 50, message: '项目名称不能超过50个字符', trigger: 'blur' },
],
projectShortName: [
{ max: 20, message: '项目简称不能超过20个字符', trigger: 'blur' },
],
businessDeptId: [{ required: true, message: '请选择业务部门', trigger: 'change' }],
undertakeDeptId: [{ required: true, message: '请选择承办部门', trigger: 'change' }],
customerNames: [{ required: true, message: '请选择客户名称', trigger: 'change' }],
carrierNames: [{ required: true, message: '请选择下游承运商', trigger: 'change' }],
fundLimit: [
{
required: true,
label: '项目资金使用额度',
validator: validateRequiredAmount,
trigger: 'blur',
},
],
receivableLimit: [
{
required: true,
label: '项目应收账款额度',
validator: validateRequiredAmount,
trigger: 'blur',
},
],
receivableDays: [
{ label: '应收账款回款期限', validator: validateDays, trigger: 'blur' },
],
paymentDays: [
{ label: '回款账期', validator: validateDays, trigger: 'blur' },
],
projectScale: [{ label: '项目规模', validator: validateAmount, trigger: 'blur' }],
estimatedProfit: [{ label: '预估利润', validator: validateAmount, trigger: 'blur' }],
fundDemand: [{ label: '资金需求', validator: validateAmount, trigger: 'blur' }],
handlerUserId: [
{
required: true,
message: '请选择项目经办人',
label: '项目经办人',
nameProp: 'handlerUserName',
validator: validateUser,
trigger: 'change',
},
],
principalUserId: [
{
required: true,
message: '请选择项目负责人',
label: '项目负责人',
nameProp: 'principalUserName',
validator: validateUser,
trigger: 'change',
},
],
changeContent: [
{ max: 2000, message: '变更内容不能超过2000个字符', trigger: 'blur' },
],
changeReason: [
{ max: 2000, message: '变更原因不能超过2000个字符', trigger: 'blur' },
],
},
deptOptions: [],
businessDeptTreeOptions: [],
deptTreeOptions: [],
deptTreeSelectProps: {
label: 'label',
value: 'value',
children: 'children',
},
cargoTypeOptions: [],
selectedCustomerId: [],
selectedCarrierIds: [],
customerOptions: [],
carrierOptions: [],
customerLoading: false,
carrierLoading: false,
customerRows: [],
carrierRows: [],
attachmentRows: [],
selectedAttachmentRows: [],
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
attachmentFileTypes: [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
],
changeRows: [],
userBox: false,
userPickType: '',
userLoading: false,
userData: [],
userQuery: {
realName: '',
account: '',
},
userPage: {
currentPage: 1,
pageSize: 10,
total: 0,
},
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return {
addBtn: this.canCreate,
};
},
isAdmin() {
const authority = this.userInfo && this.userInfo.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
},
canViewAllDept() {
return this.isAdmin;
},
readonlyScope() {
return this.config.enableAllDept && this.allDept === 1;
},
canCreate() {
return this.hasPermission(`${this.config.permission}_add`) && !this.readonlyScope;
},
canMajorProjectSupplement() {
return this.hasPermission(`${this.config.permission}_supplement`) && !this.readonlyScope;
},
isMajorProjectSupplementDialog() {
return this.dialogType === 'majorSupplement';
},
isChangeDialog() {
return this.dialogType === 'change';
},
isProjectFormPage() {
return this.$route.path === '/business/project-apply/form';
},
projectFormContainer() {
return this.isProjectFormPage ? 'div' : 'el-dialog';
},
projectFormContainerProps() {
if (this.isProjectFormPage) return { class: 'project-apply-page-form' };
return {
modelValue: this.projectBox,
title: this.projectDialogTitle,
appendToBody: true,
destroyOnClose: true,
width: '1440px',
top: '4vh',
class: 'project-apply-dialog',
};
},
isBasicInfoReadonly() {
return this.dialogReadonly || this.isChangeDialog;
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
projectDialogTitle() {
if (this.isChangeDialog) {
return '项目变更';
}
if (this.isMajorProjectSupplementDialog) {
return '重大项目补录';
}
const text = { add: '新增', edit: '编辑', view: '查看' }[this.dialogType] || '新增';
return `${text}${this.config.title}`;
},
projectAttachmentTypeOptions() {
return this.form.projectType === '重大项目'
? majorProjectAttachmentTypeOptions
: normalProjectAttachmentTypeOptions;
},
missingRequiredAttachmentTypes() {
const uploadedTypes = new Set(this.attachmentRows.map(item => item.fileType).filter(Boolean));
return this.projectAttachmentTypeOptions
.map(item => item.value)
.filter(fileType => !uploadedTypes.has(fileType));
},
},
created() {
this.loadDeptOptions();
this.loadCargoTypeOptions();
this.loadTransportTypeOptions();
if (this.isProjectFormPage) {
this.openProjectFormPage();
}
},
methods: {
buildTableOption() {
return {
...option,
dialogType: 'dialog',
addBtn: false,
viewBtn: false,
editBtn: false,
delBtn: false,
// 操作列收窄:本项目操作按钮较少,320px 过宽,调整为 220px(链接会自动换行兜底)
menuWidth: 200,
column: (option.column || []).map(column => ({
...column,
display: false,
align: 'center',
headerAlign: 'center',
})),
};
},
hasPermission(code) {
return this.isAdmin || this.validData(this.permission && this.permission[code], false);
},
openProjectFormPage() {
const type = this.$route.query.mode || 'add';
const id = this.$route.query.id;
this.dialogType = type;
this.dialogReadonly = type === 'view';
this.projectBox = true;
if (['add', 'majorSupplement'].includes(type)) {
this.applyProjectDetail({
...emptyForm(),
projectType: type === 'majorSupplement' ? '重大项目' : '普通项目',
});
this.fillDefaultUsers();
return;
}
this.api.getDetail(id).then(res => {
const detail = res.data.data || {};
const isChange = type === 'change';
this.applyProjectDetail({
...detail,
changeType: isChange ? this.resolveChangeType(detail) : detail.changeType,
changeNo: isChange ? this.generateChangeNo(detail) : '',
changeDate: isChange ? this.$dayjs().format('YYYY-MM-DD') : '',
});
});
},
closeProjectForm() {
if (this.isProjectFormPage) {
this.$router.push('/business/project-apply');
} else {
this.projectBox = false;
}
},
statusValue(row) {
return row[this.config.statusProp || 'status'];
},
inStatus(row, statusList) {
if (!statusList || !statusList.length) return true;
return statusList.includes(this.statusValue(row));
},
canEdit(row) {
return (
this.hasPermission(`${this.config.permission}_edit`) &&
!this.readonlyScope &&
!row.readonly &&
this.inStatus(row, this.config.editStatus)
);
},
canDelete(row) {
return (
this.hasPermission(`${this.config.permission}_delete`) &&
!this.readonlyScope &&
!row.readonly &&
this.inStatus(row, this.config.deleteStatus)
);
},
customOperations(row) {
return (this.config.operations || []).filter(operation => this.canOperation(operation, row));
},
canOperation(operation, row) {
const permission = operation.permission || `${this.config.permission}_${operation.action}`;
if (!this.hasPermission(permission) || this.readonlyScope || row.readonly) return false;
const prop = operation.statusProp || this.config.statusProp || 'status';
const statusList = operation.status || [];
return !statusList.length || statusList.includes(row[prop]);
},
displayStatus(row, prop) {
const textProp = prop === this.config.statusProp ? this.config.statusTextProp : '';
if (textProp && row[textProp]) return row[textProp];
const column = this.findColumn(this.tableOption.column, prop);
const item = column && (column.dicData || []).find(dic => dic.value === row[prop]);
return item ? item.label : row[prop] || '未知';
},
onLoad(page, params = {}) {
this.loading = true;
this.api
.getList(page.currentPage, page.pageSize, {
...params,
...this.query,
allDept: this.allDept,
})
.then(res => {
const result = res.data.data || {};
this.page.total = result.total || 0;
this.data = result.records || [];
this.selectionClear();
})
.finally(() => {
this.loading = false;
});
},
searchReset() {
this.query = {};
this.page.currentPage = 1;
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = params;
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
selectionChange(list) {
this.selectionList = list;
},
selectionClear() {
this.selectionList = [];
if (this.$refs.crud) {
this.$refs.crud.toggleSelection();
}
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
handleScopeChange() {
if (!this.canViewAllDept) this.allDept = 0;
this.page.currentPage = 1;
this.onLoad(this.page);
},
rowDel(row) {
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => this.api.remove(row.id))
.then(res => this.afterRemove(res.data.data));
},
handleDelete() {
if (!this.selectionList.length) {
this.$message.warning('请选择至少一条数据');
return;
}
const selection = this.selectionList.filter(item => this.canDelete(item));
if (!selection.length) {
this.$message.warning('所选数据当前状态不可删除');
return;
}
this.$confirm('确定将选择数据删除?', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
})
.then(() => this.api.remove(selection.map(item => item.id).join(',')))
.then(res => this.afterRemove(res.data.data));
},
afterRemove(result = {}) {
this.onLoad(this.page);
const successCount = result.successCount || 0;
const skippedCount = result.skippedCount || 0;
if (skippedCount) {
this.$message.warning(`成功删除${successCount}条,因业务引用跳过${skippedCount}条`);
} else {
this.$message.success('删除成功!');
}
this.selectionClear();
},
handleCustomOperation(operation, row) {
if (operation.action === 'startChange') {
this.openProjectDialog('change', row);
return;
}
const run = value => {
const args = operation.prompt ? [row.id, value] : [row.id];
this.api[operation.action](...args).then(res => {
if (res.data?.success === false || res.data?.data === false) {
this.$message.error(res.data?.msg || `${operation.label}失败,请联系管理员`);
return;
}
this.$message.success(`${operation.label}成功`);
this.onLoad(this.page, this.query);
});
};
if (operation.prompt) {
this.$prompt(operation.prompt, operation.label, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: operation.promptError || '请输入内容',
}).then(({ value }) => run(value));
return;
}
this.$confirm(operation.confirm || `确定${operation.label}该${this.config.title}?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => run());
},
buildExportParams() {
return {
...this.query,
allDept: this.allDept,
ids: this.ids,
[this.website.tokenHeader]: getToken(),
};
},
handleExport() {
this.$confirm(`是否导出${this.config.exportName || this.config.title}?`, '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
NProgress.start();
exportBlob(this.config.exportUrl, this.buildExportParams(), { feedback: true })
.then(res => {
downloadXls(
res.data,
`${this.config.exportName || this.config.title}${this.$dayjs().format(
'YYYY-MM-DD HH:mm:ss'
)}.xlsx`
);
})
.finally(() => {
NProgress.done();
});
});
},
openProjectDialog(type, row = {}) {
const query = { mode: type };
if (['edit', 'view', 'change'].includes(type)) {
query.id = row.id;
}
if (type === 'add') query.name = `新增${this.config.title}`;
else if (type === 'edit') query.name = `编辑${this.config.title}`;
else if (type === 'view') query.name = `查看${this.config.title}`;
else if (type === 'majorSupplement') query.name = '重大项目补录';
else if (type === 'change') query.name = '项目变更';
this.$router.push({ path: '/business/project-apply/form', query });
},
resetProjectDialog() {
this.$refs.projectForm?.clearValidate();
this.form = emptyForm();
this.customerRows = [];
this.carrierRows = [];
this.attachmentRows = [];
this.selectedAttachmentRows = [];
this.changeRows = [];
this.selectedCustomerId = [];
this.selectedCarrierIds = [];
},
applyProjectDetail(row) {
const displayRow = {
...row,
fundLimit: this.formatAmount(row.fundLimit),
receivableLimit: this.formatAmount(row.receivableLimit),
projectScale: this.normalizeOptionalSentinel(row.projectScale),
estimatedProfit: this.normalizeOptionalSentinel(row.estimatedProfit),
fundDemand: this.normalizeOptionalSentinel(row.fundDemand),
receivableDays: this.normalizeOptionalSentinel(row.receivableDays),
paymentDays: this.normalizeOptionalSentinel(row.paymentDays),
cargoQuantity: this.normalizeOptionalSentinel(row.cargoQuantity),
};
const form = {
...emptyForm(),
...displayRow,
handlerUserName: this.resolveUserRealName(displayRow, 'handler'),
principalUserName: this.resolveUserRealName(displayRow, 'principal'),
businessDateRange:
displayRow.businessStartDate && displayRow.businessEndDate
? [displayRow.businessStartDate, displayRow.businessEndDate]
: [],
};
const situation = this.parseSituation(displayRow.situationRemark);
const customerRows = this.parseJsonArray(displayRow.customerJson);
const carrierRows = this.parseJsonArray(displayRow.carrierJson);
this.form = {
...form,
...situation,
customerNames: this.getCustomerRowNames(customerRows, 'customer') || form.customerNames,
carrierNames: this.getCustomerRowNames(carrierRows, 'carrier') || form.carrierNames,
};
this.customerRows = customerRows;
this.carrierRows = carrierRows;
this.attachmentRows = this.parseJsonArray(displayRow.attachmentsJson);
this.selectedAttachmentRows = [];
this.changeRows = this.buildChangeRows(displayRow);
this.selectedCustomerId = this.customerRows
.map(item => this.getCustomerRowId(item))
.filter(Boolean);
this.selectedCarrierIds = this.carrierRows
.map(item => this.getCustomerRowId(item))
.filter(Boolean);
this.syncSelectedCustomerOptions();
},
fillDefaultUsers() {
const userId = this.userInfo?.userId || this.userInfo?.id || '';
const userName =
this.userInfo?.realName ||
this.userInfo?.real_name ||
this.userInfo?.name ||
this.userInfo?.userName ||
this.userInfo?.user_name ||
this.userInfo?.account ||
'';
if (userId && userName) {
this.form.handlerUserId = userId;
this.form.handlerUserName = userName;
this.form.principalUserId = userId;
this.form.principalUserName = userName;
}
},
resolveUserRealName(row = {}, role) {
return (
row[`${role}RealName`] ||
row[`${role}UserRealName`] ||
row[`${role}User`]?.realName ||
row[role]?.realName ||
row[`${role}UserName`] ||
''
);
},
resolveChangeType(row = {}) {
if (row.changeType) return row.changeType;
const node = row.currentNode || '';
if (String(node).includes('备案')) return '项目备案调整';
return '项目变更';
},
submitProject() {
this.$refs.projectForm.validate(valid => {
if (!valid) {
this.handleValidateFail();
return;
}
if (!this.validateAttachmentFileTypes()) return;
this.submitLoading = true;
this.api
.submit(this.normalizeSubmitForm())
.then(
res => {
if (res.data?.success === false || res.data?.data === false) {
this.$message.error(res.data?.msg || '保存失败,请联系管理员');
return;
}
this.$message.success('操作成功!');
this.closeProjectForm();
this.onLoad(this.page, this.query);
},
error => {
window.console.log(error);
}
)
.finally(() => {
this.submitLoading = false;
});
});
},
saveProject() {
this.submitLoading = true;
this.api
.saveDraft(this.normalizeSubmitForm())
.then(res => {
if (res.data?.success === false || res.data?.data === false) {
this.$message.error(res.data?.msg || '保存失败,请联系管理员');
return;
}
this.$message.success('保存成功!');
this.closeProjectForm();
this.onLoad(this.page, this.query);
})
.finally(() => {
this.submitLoading = false;
});
},
handleCancelProject() {
if (this.isProjectFormPage) {
this.closeProjectForm();
return;
}
if (!['add', 'majorSupplement', 'change'].includes(this.dialogType)) {
this.closeProjectForm();
return;
}
this.$confirm('确认后直接关闭弹窗,列表无数据变更,是否继续?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning',
}).then(() => {
this.closeProjectForm();
});
},
saveChangeProject() {
this.submitChangeForm(false);
},
submitChangeProject() {
this.submitChangeForm(true);
},
submitChangeForm(needSubmit) {
this.$refs.projectForm.validate(valid => {
if (!valid) {
this.handleValidateFail();
return;
}
if (!this.validateAttachmentFileTypes()) return;
this.submitLoading = true;
const request = needSubmit ? this.api.submitChange : this.api.saveChange;
request(this.normalizeSubmitForm({ includeChangeType: true }))
.then(res => {
if (res.data?.success === false || res.data?.data === false) {
this.$message.error(
res.data?.msg || `${needSubmit ? '提交' : '保存'}失败,请联系管理员`
);
return;
}
this.$message.success(`${needSubmit ? '提交' : '保存'}成功!`);
this.closeProjectForm();
this.onLoad(this.page, this.query);
})
.finally(() => {
this.submitLoading = false;
});
});
},
normalizeSubmitForm(options = {}) {
const [businessStartDate, businessEndDate] = this.form.businessDateRange || [];
const submitRow = {
...this.form,
fundLimit: this.formatAmount(this.form.fundLimit),
receivableLimit: this.formatAmount(this.form.receivableLimit),
...Object.fromEntries(
optionalSentinelFields.map(key => [key, this.normalizeOptionalSentinel(this.form[key], null)])
),
businessStartDate,
businessEndDate,
customerJson: JSON.stringify(this.customerRows),
carrierJson: JSON.stringify(this.carrierRows),
attachmentsJson: JSON.stringify(this.attachmentRows),
situationRemark: JSON.stringify({
projectIntro: this.form.projectIntro || '',
profitRemark: this.form.profitRemark || '',
riskPoint: this.form.riskPoint || '',
emergencyPlan: this.form.emergencyPlan || '',
}),
};
delete submitRow.businessDateRange;
if (!options.includeChangeType) {
delete submitRow.changeType;
}
['projectIntro', 'profitRemark', 'riskPoint', 'emergencyPlan'].forEach(
key => delete submitRow[key]
);
Object.keys(submitRow).forEach(key => {
if (typeof submitRow[key] === 'string') {
submitRow[key] = submitRow[key].trim();
}
});
return submitRow;
},
normalizeAttachmentFileTypes() {
this.attachmentRows = this.attachmentRows.map(row => ({
...row,
fileType: this.resolveAttachmentFileType(row),
}));
},
normalizeAttachmentTypeText(value) {
let text = String(value || '').split(/[?#]/)[0];
try {
text = decodeURIComponent(text);
} catch (error) {
// 文件名可能包含不完整的转义字符,直接使用原始名称继续匹配。
}
return text
.replace(/\.[^.\\/]+$/, '')
.toLowerCase()
.replace(/[\s_\-—–·•()()\[\]【】{}《》<>“”"'、,,。.]/g, '');
},
attachmentTypeKeywords(fileType) {
if (fileType === '单一来源供应商申请表/三方比价表') {
return ['单一来源供应商申请表', '三方比价表'];
}
return [fileType];
},
resolveAttachmentFileType(row = {}) {
const values = this.projectAttachmentTypeOptions.map(item => item.value);
if (values.includes(row.fileType)) return row.fileType;
const fileName = this.normalizeAttachmentTypeText(this.attachmentName(row));
if (!fileName) return '';
const option = this.projectAttachmentTypeOptions.find(item =>
this.attachmentTypeKeywords(item.value).some(keyword =>
fileName.includes(this.normalizeAttachmentTypeText(keyword))
)
);
return option?.value || '';
},
validateAttachmentFileTypes() {
const values = this.projectAttachmentTypeOptions.map(item => item.value);
const invalidIndex = this.attachmentRows.findIndex(row => !values.includes(row.fileType));
if (invalidIndex === -1) return true;
this.$message.warning(`请选择第${invalidIndex + 1}行项目材料的附件类型`);
this.$nextTick(() => {
const field = this.$el.querySelector('.project-apply-form__material-head');
if (field && typeof field.scrollIntoView === 'function') {
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
return false;
},
handleValidateFail() {
this.$message.warning('请完善表单必填项后再提交');
this.$nextTick(() => {
const field = this.$el.querySelector('.is-error');
if (field && typeof field.scrollIntoView === 'function') {
field.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
},
parseSituation(value) {
if (!value) {
return { projectIntro: '', profitRemark: '', riskPoint: '', emergencyPlan: '' };
}
try {
const data = JSON.parse(value);
if (data && typeof data === 'object') {
return {
projectIntro: data.projectIntro || '',
profitRemark: data.profitRemark || '',
riskPoint: data.riskPoint || '',
emergencyPlan: data.emergencyPlan || '',
};
}
} catch (error) {
return { projectIntro: value, profitRemark: '', riskPoint: '', emergencyPlan: '' };
}
return { projectIntro: String(value), profitRemark: '', riskPoint: '', emergencyPlan: '' };
},
parseJsonArray(value) {
if (!value) return [];
if (Array.isArray(value)) return value;
try {
const list = JSON.parse(value);
return Array.isArray(list) ? list : [];
} catch (error) {
return [];
}
},
getCustomerRowId(row = {}) {
return row.id || row.customerId || row.carrierId || '';
},
getCustomerRowLabel(row = {}, type) {
return (
row[type === 'carrier' ? 'carrier' : 'customer'] ||
row.fullName ||
row.shortName ||
row.customerName ||
row.carrierName ||
row.customerCode ||
''
);
},
getCustomerRowNames(rows = [], type) {
return rows
.map(row => this.getCustomerRowLabel(row, type))
.filter(Boolean)
.join('');
},
buildCustomerOptionsFromRows(rows = [], type) {
return rows
.map(row => {
const value = this.getCustomerRowId(row);
const label = this.getCustomerRowLabel(row, type);
return value && label ? { label, value, raw: row } : null;
})
.filter(Boolean);
},
normalizeReadonlyAmount(value) {
return this.normalizeOptionalSentinel(value);
},
normalizeOptionalSentinel(value, emptyValue = '') {
return value === null || value === undefined || value === '' || Number(value) === -1
? emptyValue
: value;
},
mergeCustomerOptions(options = [], selectedOptions = []) {
const result = [...selectedOptions];
options.forEach(option => {
const exists = result.some(item => String(item.value) === String(option.value));
if (!exists) {
result.push(option);
}
});
return result;
},
syncSelectedCustomerOptions() {
this.customerOptions = this.mergeCustomerOptions(
this.customerOptions,
this.buildCustomerOptionsFromRows(this.customerRows, 'customer')
);
this.carrierOptions = this.mergeCustomerOptions(
this.carrierOptions,
this.buildCustomerOptionsFromRows(this.carrierRows, 'carrier')
);
},
buildChangeRows(row) {
if (!row || (!row.changeContent && !row.changeReason)) return [];
return [
{
changeDate: row.updateTime || row.createTime || '',
changeType:
row.changeType ||
(String(row.currentNode || '').includes('备案') ? '项目备案调整' : '项目变更'),
handler: this.resolveUserRealName(row, 'handler'),
content: row.changeContent || '',
reason: row.changeReason || '',
status: row.approvalStatusName || row.approvalStatus || '',
action: this.dialogReadonly ? '流程' : '编辑',
},
];
},
generateChangeNo(detail) {
const base = detail && detail.projectCode ? detail.projectCode : 'XM';
return `${base}-BG${this.$dayjs().format('YYYYMMDD')}`;
},
handleBusinessDeptChange(value) {
const dept = this.deptOptions.find(item => item.value === value);
this.form.businessDeptName = dept ? dept.rawLabel : '';
this.$refs.projectForm?.validateField('businessDeptId');
},
handleUndertakeDeptChange(value) {
const dept = this.deptOptions.find(item => item.value === value);
this.form.undertakeDeptName = dept ? dept.rawLabel : '';
this.$refs.projectForm?.validateField('undertakeDeptId');
},
handleProjectTypeChange() {
this.normalizeAttachmentFileTypes();
this.$refs.projectForm?.validateField('projectType');
},
loadDeptOptions() {
getDeptTree().then(res => {
const deptTree = res.data.data || [];
const businessDeptTree = ['add', 'edit', 'majorSupplement'].includes(this.dialogType)
? this.excludeExternalOrganization(deptTree)
: deptTree;
const undertakeDeptTree =
this.dialogType === 'add' ? this.excludeExternalOrganization(deptTree) : deptTree;
this.deptOptions = this.flattenDept(businessDeptTree);
this.businessDeptTreeOptions = this.toDeptCascaderOptions(businessDeptTree);
this.deptTreeOptions = this.toDeptCascaderOptions(undertakeDeptTree);
});
},
excludeExternalOrganization(list = []) {
return list.reduce((result, item) => {
const deptName = item.title || item.deptName || item.name;
if (String(deptName || '').trim() === '外部组织') return result;
result.push({
...item,
children: item.children?.length
? this.excludeExternalOrganization(item.children)
: item.children,
});
return result;
}, []);
},
toDeptCascaderOptions(list = []) {
return list.map(item => ({
label: item.title || item.deptName || item.name,
value: item.id,
children: item.children?.length ? this.toDeptCascaderOptions(item.children) : undefined,
}));
},
flattenDept(list = [], level = 0) {
return list.reduce((result, item) => {
const label = item.title || item.deptName || item.name;
result.push({
label: `${' '.repeat(level)}${label}`,
rawLabel: label,
value: item.id,
});
if (item.children && item.children.length) {
result.push(...this.flattenDept(item.children, level + 1));
}
return result;
}, []);
},
loadCargoTypeOptions() {
getSystemDictionary({ code: 'type_of_goods' }).then(res => {
this.cargoTypeOptions = (res.data.data || []).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
});
},
loadTransportTypeOptions() {
getBizDictionary({ code: 'transport_type' }).then(res => {
this.transportTypeOptions = (res.data.data || []).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
});
},
loadCustomerOptions(type) {
const loadingKey = type === '客户' ? 'customerLoading' : 'carrierLoading';
const optionsKey = type === '客户' ? 'customerOptions' : 'carrierOptions';
this[loadingKey] = true;
getCustomerArchiveList(1, 100, {
customerType: type,
status: 1,
...(this.dialogType !== 'add' && type === '客户'
? { approvalStatus: 'approved' }
: {}),
})
.then(res => {
const records = (res.data.data && res.data.data.records) || [];
const selectedOptions = this.buildCustomerOptionsFromRows(
type === '客户' ? this.customerRows : this.carrierRows,
type === '客户' ? 'customer' : 'carrier'
);
this[optionsKey] = this.mergeCustomerOptions(
records.map(item => this.mapCustomerOption(item)),
selectedOptions
);
})
.finally(() => {
this[loadingKey] = false;
});
},
mapCustomerOption(item) {
const label = item.fullName || item.shortName || item.customerCode;
return {
label,
value: item.id,
raw: item,
};
},
handleCustomerChange(ids) {
if (!ids || !ids.length) {
this.customerRows = [];
this.form.customerNames = '';
this.form.customerJson = '';
return;
}
Promise.all(ids.map(id => this.loadCustomerDetailRow(id, 'customer'))).then(rows => {
this.customerRows = rows;
this.form.customerNames = rows.map(item => item.customer).join('');
this.form.customerJson = JSON.stringify(this.customerRows);
this.syncSelectedCustomerOptions();
this.$refs.projectForm?.validateField('customerNames');
});
},
handleCarrierChange(ids) {
if (!ids || !ids.length) {
this.carrierRows = [];
this.form.carrierNames = '';
this.form.carrierJson = '';
return;
}
Promise.all(ids.map(id => this.loadCustomerDetailRow(id, 'carrier'))).then(rows => {
this.carrierRows = rows;
this.form.carrierNames = rows.map(item => item.carrier).join('');
this.form.carrierJson = JSON.stringify(this.carrierRows);
this.syncSelectedCustomerOptions();
this.$refs.projectForm?.validateField('carrierNames');
});
},
loadCustomerDetailRow(id, type) {
return getCustomerArchiveDetail(id).then(res => {
const detail = res.data.data || {};
return this.mapCustomerTableRow(detail, type);
});
},
mapCustomerTableRow(item, type) {
const contacts = item.contacts || [];
const defaultContact = contacts.find(contact => contact.isDefault === 1) || contacts[0] || {};
const name = item.fullName || item.shortName || item.customerCode || '';
const row = {
id: item.id,
credit: item.maxCreditLimit || item.applyCreditLimit || '',
companyNature: item.customerNature || '',
legalPerson: item.legalPerson || '',
address: item.registeredAddress || '',
contact: defaultContact.contactName || item.principal || '',
phone: defaultContact.contactPhone || item.contactPhone || '',
};
if (type === 'carrier') {
row.carrier = name;
} else {
row.customer = name;
}
return row;
},
handleNumberInput(prop, value) {
if (optionalSentinelFields.includes(prop) && Number(value) === -1) {
this.form[prop] = '';
return;
}
const text = String(value || '').replace(/[^\d.]/g, '');
const parts = text.split('.');
this.form[prop] =
parts.length > 1 ? `${parts[0]}.${parts.slice(1).join('').slice(0, 2)}` : parts[0];
},
formatAmount(value) {
if (value === null || value === undefined || value === '' || Number(value) === -1) return '';
const amount = Number(value);
return Number.isFinite(amount) ? amount.toFixed(2) : value;
},
formatAmountField(prop) {
this.form[prop] = this.formatAmount(this.form[prop]);
},
handleIntegerInput(prop, value) {
if (optionalSentinelFields.includes(prop) && Number(value) === -1) {
this.form[prop] = '';
return;
}
this.form[prop] = String(value || '').replace(/[^\d]/g, '');
},
handleAttachmentChange(list) {
const userName = this.userInfo?.realName || this.userInfo?.userName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
this.attachmentRows = (list || []).map(item => ({
...item,
fileType: this.resolveAttachmentFileType(item),
description: item.description || '',
uploadUserName: item.uploadUserName || userName,
uploadTime: item.uploadTime || uploadTime,
}));
this.selectedAttachmentRows = [];
},
handleAttachmentSelectionChange(rows) {
this.selectedAttachmentRows = rows || [];
},
removeAttachment(index) {
this.attachmentRows.splice(index, 1);
},
formatFileSize(size) {
const number = Number(size);
if (!number) return '';
if (number < 1024) return `${number}B`;
if (number < 1024 * 1024) return `${(number / 1024).toFixed(1)}KB`;
return `${(number / 1024 / 1024).toFixed(1)}MB`;
},
attachmentUrl(row = {}) {
return row.url || row.link || row.fileUrl || row.src || row.domain || '';
},
attachmentName(row = {}) {
return row.originalName || row.name || row.fileName || '附件';
},
attachmentExtension(row = {}) {
const source = String(this.attachmentName(row) || this.attachmentUrl(row)).split('?')[0];
const index = source.lastIndexOf('.');
return index > -1 ? source.slice(index + 1).toLowerCase() : '';
},
isAttachmentImage(row) {
return ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(
this.attachmentExtension(row)
);
},
isAttachmentPdf(row) {
const mimeType = String(row.mimeType || row.contentType || '').toLowerCase();
return mimeType.includes('application/pdf') || this.attachmentExtension(row) === 'pdf';
},
previewAttachment(row) {
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空无法预览');
return;
}
if (this.isAttachmentImage(row)) {
this.attachmentImagePreviewUrls = this.attachmentRows
.filter(item => this.isAttachmentImage(item) && this.attachmentUrl(item))
.map(item => this.attachmentUrl(item));
this.attachmentImagePreviewIndex = Math.max(
this.attachmentImagePreviewUrls.indexOf(url),
0
);
this.attachmentImagePreviewVisible = true;
return;
}
const isPdf = this.isAttachmentPdf(row);
this.attachmentPreviewFile = {
name: this.attachmentName(row),
url,
mimeType: isPdf ? 'application/pdf' : row.mimeType || row.contentType || '',
isPdf,
};
this.attachmentDocumentPreviewVisible = true;
},
handleAttachmentPreviewUnsupported() {
this.$message.warning('当前文件暂不支持在线预览');
},
handleAttachmentPreviewError() {
this.$message.error('附件预览失败');
},
downloadAttachment(row) {
const url = this.attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空');
return;
}
downloadFileByUrl(url, this.attachmentName(row));
},
handleBatchDownload() {
const rows = this.selectedAttachmentRows.length
? this.selectedAttachmentRows
: this.attachmentRows;
const downloadableRows = rows.filter(row => this.attachmentUrl(row));
if (!downloadableRows.length) {
this.$message.warning(
this.selectedAttachmentRows.length ? '所选附件暂无可下载地址' : '暂无可下载附件'
);
return;
}
downloadableRows.forEach((row, index) => {
window.setTimeout(() => this.downloadAttachment(row), index * 300);
});
},
handleChangeRecordAction() {
this.$message.info('变更记录详情由后端明细能力生成后展示');
},
openUserPicker(type) {
if (this.dialogReadonly) return;
this.userPickType = type;
this.userBox = true;
this.loadUsers();
},
loadUsers() {
this.userLoading = true;
getUserList(this.userPage.currentPage, this.userPage.pageSize, this.userQuery)
.then(res => {
const result = res.data.data || {};
this.userData = result.records || [];
this.userPage.total = result.total || 0;
})
.finally(() => {
this.userLoading = false;
});
},
resetUserQuery() {
this.userQuery = { realName: '', account: '' };
this.userPage.currentPage = 1;
this.loadUsers();
},
selectUser(row) {
if (this.userPickType === 'handler') {
this.form.handlerUserId = row.id;
this.form.handlerUserName = row.realName || row.name || row.account;
this.$refs.projectForm?.validateField('handlerUserId');
} else {
this.form.principalUserId = row.id;
this.form.principalUserName = row.realName || row.name || row.account;
this.$refs.projectForm?.validateField('principalUserId');
}
this.userBox = false;
},
},
};
</script>
<style lang="scss" scoped>
.project-apply-form {
padding: 0;
:deep(.el-input),
:deep(.el-select),
:deep(.el-tree-select),
:deep(.el-date-editor) {
width: 100%;
}
:deep(.el-form-item) {
margin-bottom: 14px;
}
:deep(.el-form-item__label) {
min-width: 160px;
white-space: normal;
line-height: 20px;
}
// 分区标题融入白色卡片顶部(与下方内容块拼成一张完整卡片)
:deep(.dialog-section-title:not(.project-apply-form__material-title)) {
position: relative;
z-index: 1;
display: flex;
align-items: center;
gap: 8px;
background: #fff;
border-radius: 6px 6px 0 0;
padding: 14px 16px 4px;
margin-bottom: 0;
font-weight: 500;
}
&__grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
column-gap: 24px;
row-gap: 0;
margin-bottom: 12px;
background: #fff;
border-radius: 0 0 6px 6px;
padding: 14px 16px 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
// label 固定宽度:可容纳 6 个汉字,超出自动换行(覆盖全局 min-width:160px 与 auto 行内宽度)
:deep(.el-form-item__label) {
width: 108px !important;
min-width: 0 !important;
white-space: normal;
}
// 占满整行的表单项(业务部门 / 项目由来说明)
:deep(.project-apply-form__item--full) {
grid-column: 1 / -1;
.el-form-item__content {
width: 100%;
}
}
}
&__picker-icon {
color: #a8abb2;
cursor: pointer;
}
&__table-title {
display: flex;
align-items: center;
gap: 24px;
}
&__tip {
color: #ff7a95;
font-size: 14px;
font-weight: 500;
}
&__table {
width: 100%;
margin-bottom: 12px;
background: #fff;
border-radius: 0 0 6px 6px;
padding: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
:deep(.el-table__header th .cell) {
font-weight: 600;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
}
&__textarea-list {
margin-bottom: 12px;
background: #fff;
border-radius: 0 0 6px 6px;
padding: 14px 16px 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
:deep(.el-form-item) {
align-items: flex-start;
}
:deep(.el-form-item__content) {
width: calc(100% - 180px);
}
}
&__material-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
&__material-card {
margin-bottom: 12px;
background: #fff;
border-radius: 6px;
padding: 14px 16px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
&__material-table {
width: 100%;
margin-bottom: 12px;
:deep(.el-table__header th .cell) {
font-weight: 600;
}
:deep(.el-table__cell) {
border-color: #eff1f7;
}
}
&__material-warning {
margin-bottom: 12px;
}
&__material-title {
flex: 1;
}
&__material-warn {
margin-left: 24px;
color: #f56c6c;
font-size: 14px;
font-weight: 500;
}
&__upload {
margin: 0 0 16px;
background: #fff;
border-radius: 6px;
padding: 12px 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
:deep(.vehicle-attachment-upload) {
display: flex;
align-items: center;
}
:deep(.el-upload) {
display: inline-flex;
}
:deep(.el-upload__tip) {
display: inline-block;
margin: 0 0 0 30px;
color: #909399;
font-size: 13px;
line-height: 1.4;
}
}
&__change-textarea {
margin-bottom: 16px;
background: #fff;
border-radius: 0 0 6px 6px;
padding: 14px 16px 4px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
:deep(.el-form-item__content) {
width: 100%;
margin-left: 0 !important;
}
}
}
:global(.project-apply-dialog .el-dialog__body) {
max-height: 76vh;
overflow-y: auto;
overflow-x: hidden;
padding: 16px;
background: #f5f6fa;
}
.project-apply-page-form {
min-height: 100%;
margin-bottom: 60px;
//padding: 20px 24px 92px;
//background: #f5f6fa;
}
.project-apply-dialog__footer {
display: flex;
justify-content: flex-end;
.project-apply-page-form & {
position: fixed;
right: 0;
left: 230px;
bottom: 0;
z-index: 10;
margin: 0;
padding: 12px 24px;
border-top: 1px solid #eff1f7;
background: #fff;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
}
&--change {
justify-content: flex-start;
padding-left: 24px;
}
}
:global(.avue--collapse .project-apply-page-form .project-apply-dialog__footer) {
left: 60px;
}
:global(.avue-layout--horizontal .project-apply-page-form .project-apply-dialog__footer) {
left: 0;
}
.project-apply-user-dialog {
&__search {
display: grid;
grid-template-columns: 1fr 1fr auto auto;
gap: 8px;
margin-bottom: 12px;
}
&__pager {
display: flex;
justify-content: flex-end;
margin-top: 12px;
}
}
@media (max-width: 1200px) {
.project-apply-form {
&__grid {
grid-template-columns: 1fr;
column-gap: 0;
}
}
}
</style>