Files
tms-erp-web/src/views/business/project-apply.vue
T

2960 lines
99 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 #header>
<div class="project-apply-page__risk-search">
<span class="project-apply-page__risk-label">
资金使用风险
<el-tooltip placement="top" effect="dark">
<template #content>
<div>中风险项目资金额度使用率 80%</div>
<div>高风险项目资金额度使用率 90%</div>
</template>
<el-icon class="project-apply-page__risk-help">
<el-icon-question-filled />
</el-icon>
</el-tooltip>
</span>
<el-check-tag
v-for="item in fundRiskTagOptions"
:key="item.value"
:checked="fundRiskScope === item.value"
@change="changeFundRiskScope(item.value)"
>
{{ item.label }}{{ fundRiskStats[item.statKey] || 0 }}
</el-check-tag>
</div>
</template>
<template #fundUseRisk="{ row }">
<span v-if="row.fundUseRisk === 'high'" class="project-apply-page__risk-high"></span>
<span
v-else-if="row.fundUseRisk === 'medium'"
class="project-apply-page__risk-medium"
>
</span>
<span v-else class="project-apply-page__risk-none">-</span>
</template>
<template #fundUseRisk-header>
<span class="project-apply-page__risk-header">
资金使用风险
<el-tooltip placement="top" effect="dark">
<template #content>
<div>中风险项目资金额度使用率 80%</div>
<div>高风险项目资金额度使用率 90%</div>
</template>
<el-icon class="project-apply-page__risk-help">
<el-icon-question-filled />
</el-icon>
</el-tooltip>
</span>
</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">
<el-cascader
v-model="businessDeptCascaderValue"
:options="businessDeptTreeOptions"
:props="deptCascaderProps"
placeholder="请选择"
filterable
clearable
:disabled="isBasicInfoReadonly"
/>
</el-form-item>
<el-form-item label="平台公司" prop="undertakeDeptId">
<el-select
v-model="form.undertakeDeptId"
placeholder="请选择"
clearable
filterable
:disabled="isBasicInfoReadonly"
@change="handleUndertakeDeptChange"
>
<el-option
v-for="item in platformCompanyOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</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"
type="textarea"
rows="2"
placeholder="请输入"
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 prop="fundLimit" class="project-apply-form__tip-label">
<template #label>
<span>项目资金使用额度</span>
<el-tooltip content="实际业务回款周期内付款" placement="top">
<el-icon class="project-apply-form__label-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-input
v-model="form.fundLimit"
placeholder="请输入"
@input="value => handleNumberInput('fundLimit', value)"
@blur="formatAmountField('fundLimit')"
>
<template #suffix>万元</template>
</el-input>
</el-form-item>
<el-form-item prop="receivableLimit" class="project-apply-form__tip-label">
<template #label>
<span>项目应收账款额度</span>
<el-tooltip content="回款期内最大应收账款额" placement="top">
<el-icon class="project-apply-form__label-tip"><QuestionFilled /></el-icon>
</el-tooltip>
</template>
<el-input
v-model="form.receivableLimit"
placeholder="请输入"
@input="value => handleNumberInput('receivableLimit', value)"
@blur="formatAmountField('receivableLimit')"
>
<template #suffix>万元</template>
</el-input>
</el-form-item>
<el-form-item label="应收账款回款期限" prop="receivableDays">
<el-input
v-model="form.receivableDays"
placeholder="请输入"
@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"
placeholder="请输入"
@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="businessStartDate">
<el-date-picker
v-model="form.businessStartDate"
type="date"
placeholder="请选择"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="业务周期止" prop="businessEndDate">
<el-date-picker
v-model="form.businessEndDate"
type="date"
placeholder="请选择"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="运输线路" prop="transportRoute">
<el-input v-model="form.transportRoute" placeholder="请输入" 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="businessMode">
<el-select v-model="form.businessMode" placeholder="请选择">
<el-option
v-for="item in businessModeOptions"
: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"
placeholder="请输入"
@input="value => handleNumberInput('projectScale', value)"
>
<template #suffix>万元</template>
</el-input>
</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"
placeholder="请输入"
@input="value => handleNumberInput('estimatedProfit', value)"
>
<template #suffix>万元</template>
</el-input>
</el-form-item>
<el-form-item label="利润率" prop="profitRate">
<el-input
v-model="form.profitRate"
placeholder="请输入"
@input="value => handleNumberInput('profitRate', value)"
>
<template #suffix>%</template>
</el-input>
</el-form-item>
<el-form-item label="履约保证金" prop="fundDemand">
<el-input
v-model="form.fundDemand"
placeholder="请输入"
@input="value => handleNumberInput('fundDemand', value)"
>
<template #suffix>万元</template>
</el-input>
</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">
<template #default="{ row }">
<el-link
type="primary"
:underline="false"
@click="openCustomerArchive(row)"
>
{{ row.customer }}
</el-link>
</template>
</el-table-column>
<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>
<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">
<template #default="{ row }">
<el-link
type="primary"
:underline="false"
@click="openCustomerArchive(row)"
>
{{ row.carrier }}
</el-link>
</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>
<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>
<span
v-if="missingRequiredAttachmentTypes.length"
class="project-apply-form__material-warn"
>
未上传:{{ missingRequiredAttachmentTypes.join('、') }}
</span>
<el-button
type="primary"
class="project-apply-form__material-download"
@click="handleBatchDownload"
>批量下载</el-button
>
</div>
<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="{ $index }">
<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="50"
:show-file-list="false"
button-text="上传附件"
tip="支持pdfbmpjpegpngjpgdocdocxpptpptxxlsxxlsemlmsgzip的文件格式单个文件不超过50M"
@change="handleAttachmentChange"
/>
</div>
<div
v-if="!isChangeDialog && !isNewProjectDialog"
class="dialog-section-title project-apply-form__table-title"
>
变更记录
</div>
<el-table
v-if="!isChangeDialog && !isNewProjectDialog"
: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"
>
<template #default="{ row }">
<el-tooltip placement="top" :show-after="200">
<template #content>
<div class="project-apply-form__change-record-content-tooltip">{{ row.content || '空' }}</div>
</template>
<span class="project-apply-form__change-record-content-cell">{{ row.content || '空' }}</span>
</el-tooltip>
</template>
</el-table-column>
<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>
<el-dialog
v-model="changeRecordDetailVisible"
title="变更记录详情"
append-to-body
destroy-on-close
width="1100px"
top="10px"
class="project-change-record-detail-dialog"
>
<div v-if="changeRecordDetail" class="project-change-record-detail-meta">
<span>变更日期:{{ changeRecordDetail.changeDate || '-' }}</span>
<span>变更账号:{{ changeRecordDetail.handler || '-' }}</span>
</div>
<el-table :data="changeRecordDetailRows" border :show-overflow-tooltip="false">
<el-table-column prop="field" label="变更字段" min-width="180" />
<el-table-column
prop="before"
label="变更前"
min-width="360"
class-name="project-change-record-detail-value"
/>
<el-table-column
prop="after"
label="变更后"
min-width="500"
class-name="project-change-record-detail-value"
/>
</el-table>
<el-empty
v-if="!changeRecordDetailRows.length"
description="暂无变更内容"
:image-size="60"
/>
<template #footer>
<el-button type="primary" @click="changeRecordDetailVisible = false">关闭</el-button>
</template>
</el-dialog>
<template v-if="isChangeDialog">
<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="请输入"
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.phone"
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="realName" label="用户姓名" min-width="140" align="center" />
<el-table-column prop="phone" 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, getPlatformCompanySelect } 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 {
businessModeOptions,
businessTypeOptions,
projectSourceOptions,
projectTypeOptions,
} 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: '',
businessStartDate: '',
businessEndDate: '',
transportRoute: '',
transportType: '',
businessType: '',
businessMode: '国内运输',
projectScale: '',
settlementMode: '',
estimatedProfit: '',
profitRate: '',
fundDemand: '',
handlerUserId: '',
handlerUserName: '',
principalUserId: '',
principalUserName: '',
customerJson: '',
carrierJson: '',
attachmentsJson: '',
situationRemark: '',
projectIntro: '',
profitRemark: '',
riskPoint: '',
emergencyPlan: '',
changeContent: '',
changeReason: '',
});
const optionalSentinelFields = [
'projectScale',
'estimatedProfit',
'profitRate',
'fundDemand',
'receivableDays',
'paymentDays',
'cargoQuantity',
];
const changeTypeOptions = [
{ label: '项目变更', value: '项目变更' },
{ label: '项目备案调整', value: '项目备案调整' },
];
const attachmentViewerPlugins = [
imagePlugin(),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
const otherAttachmentType = { label: '其他附件', value: '其他附件' };
const normalProjectAttachmentTypeOptions = [
'利润测算表',
'单一来源供应商申请表/三方比价表',
'合同模板',
'项目操作说明',
'项目分析报告',
'项目考察报告',
].map(item => ({ label: item, value: item })).concat(otherAttachmentType);
const majorProjectAttachmentTypeOptions = [
'会议纪要',
'评审表决票',
'利润测算表',
'单一来源供应商申请表/三方比价表',
'项目操作说明',
'项目分析报告',
'项目考察报告',
'合同模板',
'上游尽职调查报告',
'下游尽职调查报告',
'业务承诺函',
].map(item => ({ label: item, value: item })).concat(otherAttachmentType);
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,
businessModeOptions,
settlementModeOptions: [],
changeTypeOptions,
tableForm: {},
tableOption: this.buildTableOption(),
loading: true,
data: [],
query: {},
allDept: 0,
fundRiskScope: '',
fundRiskStats: { high: 0, medium: 0, none: 0 },
fundRiskTagOptions: [
{ label: '高风险', value: 'high', statKey: 'high' },
{ label: '中风险', value: 'medium', statKey: 'medium' },
],
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' }],
businessMode: [{ 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' }],
profitRate: [{ 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',
},
],
changeReason: [
{ max: 2000, message: '变更原因不能超过2000个字符', trigger: 'blur' },
],
},
deptOptions: [],
businessDeptTreeOptions: [],
platformCompanyOptions: [],
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: [],
changeRecordDetailVisible: false,
changeRecordDetail: null,
changeRecordDetailRows: [],
userBox: false,
userPickType: '',
userLoading: false,
userData: [],
userQuery: {
realName: '',
phone: '',
},
userPage: {
currentPage: 1,
pageSize: 10,
total: 0,
},
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
// 部门级联配置:checkStrictly 允许选中任意层级,不必选到末级
deptCascaderProps() {
return {
label: 'label',
value: 'value',
children: 'children',
emitPath: true,
checkStrictly: true,
expandTrigger: 'click',
};
},
// 业务部门:form 存部门 id,级联控件用根到节点的 id 路径,两者在此双向转换
businessDeptCascaderValue: {
get() {
const id = this.form.businessDeptId;
return id ? this.findDeptPath(id, this.businessDeptTreeOptions) || [id] : [];
},
set(path) {
const value = Array.isArray(path)
? path.filter(item => item !== undefined && item !== null && item !== '')
: [];
this.form.businessDeptId = value.length ? value.at(-1) : '';
this.handleBusinessDeptChange(this.form.businessDeptId);
},
},
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';
},
isNewProjectDialog() {
return ['add', 'majorSupplement'].includes(this.dialogType);
},
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 => fileType !== otherAttachmentType.value)
.filter(fileType => !uploadedTypes.has(fileType));
},
},
created() {
this.loadDeptOptions();
this.loadCargoTypeOptions();
this.loadTransportTypeOptions();
this.loadSettlementModeOptions();
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,
...(this.fundRiskScope ? { fundUseRisk: this.fundRiskScope } : {}),
})
.then(res => {
const result = res.data.data || {};
this.page.total = result.total || 0;
this.data = result.records || [];
this.selectionClear();
this.refreshFundRiskStats({ ...params, ...this.query, allDept: this.allDept });
})
.finally(() => {
this.loading = false;
});
},
searchReset() {
this.query = {};
this.fundRiskScope = '';
this.page.currentPage = 1;
this.onLoad(this.page);
},
searchChange(params, done) {
this.query = params;
this.page.currentPage = 1;
this.onLoad(this.page, params);
done();
},
// 切换「资金使用风险」快速筛选:再次点击已选中项则取消筛选
changeFundRiskScope(value) {
this.fundRiskScope = this.fundRiskScope === value ? '' : value;
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
},
refreshFundRiskStats(params) {
this.api.getFundRiskStats(params).then(res => {
const stats = res.data?.data || {};
this.fundRiskStats = { ...this.fundRiskStats, ...stats };
});
},
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),
profitRate: this.normalizeOptionalSentinel(row.profitRate),
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'),
};
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.normalizeAttachmentFileTypes();
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();
this.ensureCurrentPlatformOption();
},
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 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)])
),
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 || '',
}),
};
if (!options.includeChangeType) {
delete submitRow.changeType;
}
// 变更内容字段已下线,提交时始终清空,避免沿用历史值
submitRow.changeContent = '';
if (this.isNewProjectDialog && !options.includeChangeType) {
delete submitRow.changeReason;
}
['projectIntro', 'profitRemark', 'riskPoint', 'emergencyPlan'].forEach(
key => delete submitRow[key]
);
delete submitRow.changeRecordJson;
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 otherAttachmentType.value;
const option = this.projectAttachmentTypeOptions.find(item =>
this.attachmentTypeKeywords(item.value).some(keyword =>
fileName.includes(this.normalizeAttachmentTypeText(keyword))
)
);
return option?.value || otherAttachmentType.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);
if (Array.isArray(list)) return list;
return list && typeof list === 'object' ? [list] : [];
} catch (error) {
return [];
}
},
getCustomerRowId(row = {}) {
return row.id || row.customerId || row.carrierId || '';
},
openCustomerArchive(row = {}) {
const id = this.getCustomerRowId(row);
if (!id) {
this.$message.warning('客商档案 ID 为空无法打开');
return;
}
this.$router.push({
path: '/vehicle/customer-archive/form',
query: { id: String(id), name: '查看客商档案', view: '1' },
});
},
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) {
const records = this.parseJsonArray(row?.changeRecordJson);
return records.map((record, recordIndex) => {
const changeRow = {
...record,
projectId: row?.id,
recordIndex,
changeDate: record.changeDate || record.changeTime || record.createTime || '',
changeType: record.changeType || '项目变更',
handler: record.handlerUserName || record.changeUserName || record.handler || '',
reason: record.changeReason || '',
status: record.statusName || record.status || '',
action: '查看',
};
return { ...changeRow, content: this.formatChangeContent(changeRow) };
});
},
parseChangeData(value) {
if (!value || value === '-1') return {};
if (typeof value === 'object') return value;
const text = String(value).trim();
if (!text || text === '-1') return {};
try {
const parsed = JSON.parse(text);
return parsed && typeof parsed === 'object' ? parsed : { 变更内容: parsed };
} catch (error) {
return { 变更内容: text };
}
},
getProjectChangeFieldLabel(field) {
const labels = {
projectType: '项目类型',
projectName: '项目名称',
projectShortName: '项目简称',
businessDeptName: '业务部门',
undertakeDeptName: '平台公司',
projectSource: '项目由来',
sourceRemark: '项目由来说明',
fundLimit: '项目资金使用额度',
receivableLimit: '项目应收账款额度',
receivableDays: '应收账款回款期限',
paymentDays: '回款账期',
cargoType: '货物类型',
cargoQuantity: '预估货物数量',
businessStartDate: '业务周期起',
businessEndDate: '业务周期止',
transportRoute: '运输线路',
transportType: '运输类型',
businessType: '业务类型',
businessMode: '业务模式',
projectScale: '项目规模',
settlementMode: '结算方式',
estimatedProfit: '预计利润',
profitRate: '利润率',
fundDemand: '履约保证金',
handlerUserName: '项目经办人',
principalUserName: '项目负责人',
customerNames: '客户名称',
carrierNames: '下游承运商',
situationRemark: '项目情况说明',
attachmentsJson: '项目材料',
};
return labels[field] || field;
},
normalizeProjectChangeValue(value) {
if (typeof value !== 'string') return value;
const text = value.trim();
if (!text || (!text.startsWith('[') && !text.startsWith('{') && text !== 'null')) {
return value;
}
try {
return JSON.parse(text);
} catch (error) {
return value;
}
},
isEmptyProjectChangeValue(value) {
const normalized = this.normalizeProjectChangeValue(value);
return (
normalized === undefined ||
normalized === null ||
normalized === '' ||
normalized === '-1' ||
(Array.isArray(normalized) && normalized.length === 0) ||
(typeof normalized === 'object' && !Array.isArray(normalized) && Object.keys(normalized).length === 0)
);
},
isSameProjectChangeValue(before, after) {
if (this.isEmptyProjectChangeValue(before) && this.isEmptyProjectChangeValue(after)) {
return true;
}
return JSON.stringify(this.normalizeProjectChangeValue(before)) === JSON.stringify(this.normalizeProjectChangeValue(after));
},
formatProjectChangeValue(field, value) {
const normalized = this.normalizeProjectChangeValue(value);
if (this.isEmptyProjectChangeValue(normalized)) return '';
if (field === 'transportType') {
const option = (this.transportTypeOptions || []).find(
item => String(item.value) === String(normalized)
);
return option?.label || String(normalized);
}
if (field === 'settlementMode') {
const option = (this.settlementModeOptions || []).find(
item => String(item.value) === String(normalized)
);
return option?.label || String(normalized);
}
if (field === 'attachmentsJson') {
const attachments = Array.isArray(normalized)
? normalized
: normalized && typeof normalized === 'object'
? [normalized]
: [];
const names = attachments
.map(item => (typeof item === 'string' ? item : item?.originalName || item?.name || item?.fileName || item?.url || ''))
.map(item => String(item).split('?')[0].split('/').pop())
.filter(Boolean);
if (names.length) return names.join('');
}
if (Array.isArray(normalized)) {
return normalized.map(item => this.formatProjectChangeValue(field, item)).join('');
}
if (typeof normalized === 'object') return JSON.stringify(normalized);
return String(normalized);
},
buildSituationChangeRows(beforeValue, afterValue) {
const before = this.parseSituation(beforeValue);
const after = this.parseSituation(afterValue);
const labels = {
projectIntro: '项目简介',
profitRemark: '盈利模式及利润测算情况说明',
riskPoint: '项目风险点',
emergencyPlan: '风险防范措施及应急预案',
};
return Object.keys(labels)
.filter(key => !this.isSameProjectChangeValue(before[key], after[key]))
.map(key => ({
field: labels[key],
before: this.formatProjectChangeValue(key, before[key]),
after: this.formatProjectChangeValue(key, after[key]),
}));
},
buildChangeRecordDetailRows(row = {}) {
const beforeData = this.parseChangeData(row.beforeData);
const afterData = this.parseChangeData(row.afterData);
const fields = [
...new Set([
...(Array.isArray(row.changedFields) ? row.changedFields : []),
...Object.keys(beforeData),
...Object.keys(afterData),
]),
];
const rows = [];
fields
.filter(field => field !== '变更内容' && !this.isSameProjectChangeValue(beforeData[field], afterData[field]))
.forEach(field => {
if (field === 'situationRemark') {
rows.push(...this.buildSituationChangeRows(beforeData[field], afterData[field]));
return;
}
rows.push({
field: this.getProjectChangeFieldLabel(field),
before: this.formatProjectChangeValue(field, beforeData[field]),
after: this.formatProjectChangeValue(field, afterData[field]),
});
});
if (!rows.length && String(row.changeContent || '').trim()) {
rows.push({ field: '变更内容', before: '', after: String(row.changeContent).trim() });
}
return rows;
},
formatChangeContent(row = {}) {
const rows = this.buildChangeRecordDetailRows(row);
return rows
.filter(item => item.field !== '变更内容')
.map(item => `${item.field}:变更前:${item.before} → 变更后:${item.after}`)
.join('') || String(row.changeContent || '').trim();
},
generateChangeNo(detail) {
const base = detail && detail.projectCode ? detail.projectCode : 'XM';
return `${base}-BG${this.$dayjs().format('YYYYMMDD')}`;
},
handleBusinessDeptChange(value) {
// 兼容级联路径数组(取末级)与单个部门 id
const id = Array.isArray(value) ? value.at(-1) : value;
const dept = this.deptOptions.find(item => String(item.value) === String(id));
this.form.businessDeptName = dept ? dept.rawLabel : '';
this.$refs.projectForm?.validateField('businessDeptId');
},
handleUndertakeDeptChange(value) {
const id = Array.isArray(value) ? value.at(-1) : value;
const dept = this.platformCompanyOptions.find(item => String(item.value) === String(id));
this.form.undertakeDeptName = dept ? dept.label : '';
this.$refs.projectForm?.validateField('undertakeDeptId');
},
// 由部门 id 反查「根 → 该节点」的 id 路径,供级联回显(找不到返回 null)
findDeptPath(id, tree = [], parents = []) {
const target = Array.isArray(id) ? id.at(-1) : id;
if (target === undefined || target === null || target === '') return null;
for (const node of tree || []) {
const path = [...parents, node.value];
if (String(node.value) === String(target)) return path;
const matched = this.findDeptPath(target, node.children, path);
if (matched) return matched;
}
return null;
},
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;
this.deptOptions = this.flattenDept(businessDeptTree);
this.businessDeptTreeOptions = this.toDeptCascaderOptions(businessDeptTree);
});
this.loadPlatformCompanyOptions();
},
loadPlatformCompanyOptions() {
getPlatformCompanySelect().then(res => {
const list = res.data.data || [];
this.platformCompanyOptions = list.map(item => ({
label: item.deptName || item.fullName || item.title || item.name,
value: item.id,
}));
this.ensureCurrentPlatformOption();
});
},
ensureCurrentPlatformOption() {
const id = this.form.undertakeDeptId;
if (!id) return;
const exists = this.platformCompanyOptions.some(item => String(item.value) === String(id));
if (!exists) {
this.platformCompanyOptions.unshift({
label: this.form.undertakeDeptName || String(id),
value: id,
});
}
},
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() {
return getBizDictionary({ code: 'transport_type' }).then(res => {
this.transportTypeOptions = (res.data.data || []).map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
});
},
loadSettlementModeOptions() {
return getBizDictionary({ code: 'settle_method' }).then(res => {
const data = res.data?.data || [];
this.settlementModeOptions = data.map(item => ({
label: item.dictValue,
value: item.dictKey,
}));
const column = this.findColumn(this.tableOption.column, 'settlementMode');
if (column) column.dicData = this.settlementModeOptions;
});
},
loadCustomerOptions(type) {
const loadingKey = type === '客户' ? 'customerLoading' : 'carrierLoading';
const optionsKey = type === '客户' ? 'customerOptions' : 'carrierOptions';
this[loadingKey] = true;
getCustomerArchiveList(1, 100, {
customerType: type,
status: 1,
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);
});
},
async handleChangeRecordAction(row) {
if (!row?.projectId && !row?.id) {
this.$message.warning('项目ID为空无法查看变更详情');
return;
}
try {
if (!this.transportTypeOptions.length) {
await this.loadTransportTypeOptions();
}
if (!this.settlementModeOptions.length) {
await this.loadSettlementModeOptions();
}
const response = await api.getChangeRecordDetail(row.projectId || row.id, row.recordIndex);
const detail = response?.data?.data || {};
this.changeRecordDetail = { ...row, ...detail };
this.changeRecordDetailRows = this.buildChangeRecordDetailRows(this.changeRecordDetail);
this.changeRecordDetailVisible = true;
} catch (error) {
this.$message.error('变更记录详情加载失败');
}
},
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: '', phone: '' };
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-cascader),
: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;
}
&__tip-label {
// 保持 .el-form-item__label 默认 inline 布局,让文字按全局 white-space: normal 换行显示
:deep(.el-form-item__label) {
vertical-align: top;
}
}
&__label-tip {
margin-left: 4px;
color: #a8abb2;
cursor: help;
font-size: 14px;
vertical-align: -0.125em;
}
&__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;
}
}
&__change-record-content-cell {
display: block;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
&__change-record-content-tooltip {
max-width: 900px;
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
}
&__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);
// 与项目基本信息网格使用相同的 6 字符 label 宽度,长文本自动换行
:deep(.el-form-item__label) {
width: 108px !important;
min-width: 0 !important;
white-space: normal;
}
:deep(.el-form-item) {
align-items: flex-start;
}
:deep(.el-form-item__content) {
width: calc(100% - 108px);
}
}
&__material-head {
display: flex;
align-items: center;
justify-content: flex-start;
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-title {
flex: 0 0 auto;
}
&__material-warn {
margin-left: 8px;
color: #f56c6c;
font-size: 14px;
font-weight: 500;
}
&__material-download {
margin-left: auto;
}
&__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;
}
}
}
:deep(.project-change-record-detail-dialog .el-dialog__body) {
max-height: 65vh;
overflow: auto;
}
:deep(.project-change-record-detail-dialog .project-change-record-detail-value .cell) {
white-space: pre-wrap;
word-break: break-word;
overflow: visible;
text-overflow: clip;
line-height: 1.6;
}
.project-change-record-detail-meta {
display: flex;
gap: 32px;
margin-bottom: 16px;
color: #606266;
font-size: 14px;
}
: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;
}
.project-apply-page {
&__risk-search {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 8px;
margin-bottom: 16px;
:deep(.el-check-tag) {
font-weight: 500;
}
}
&__risk-label {
display: inline-flex;
align-items: center;
color: #606266;
font-weight: 600;
margin-right: 4px;
}
&__risk-header {
display: inline-flex;
align-items: center;
gap: 4px;
}
&__risk-help {
margin-left: 4px;
color: #a8abb2;
cursor: help;
font-size: 14px;
}
&__risk-none {
color: #c0c4cc;
}
&__risk-high {
color: #f56c6c;
}
&__risk-medium {
color: #e6a23c;
}
}
.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-end;
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>