Files
tms-erp-web/src/views/business/contract-manage.vue
T
gxwebsoft f7eea88945 feat(login): 更换登录验证码为图形字符验证码
- 配置文件 website.js 中将 captchaType 从 'behavior' 改为 'image'
- 修改 userlogin.vue,图形验证码图片由输入框内联改为输入框旁独立显示
- 重写 login.scss 样式,实现验证码图片区域带边框和高亮效果
- 确保验证码图片点击可刷新,且支持从后端接口获取数据
- 登录页仍默认显示 IAM 登录,需切换「账号密码登录」显示验证码区域

feat(contract-manage): 优化所属组织搜索栏为树形下拉选择框

- 引入并应用 organization-search mixin 统一管理所属组织搜索
- contract-manage.js、driver.js、transport-ship.js、transport-vehicle.js 中所属组织搜索改为 searchslot 模式
- contract-manage.vue 中新增 renderOrganizationSearch 方法,渲染基于 ElTreeSelect 的树形下拉组件
- 组织树数据加载时自动排除「外部组织」节点,保持搜索选项清晰
- 所有相关所属组织搜索统一样式与交互,提升用户体验

feat(system): 用户和部门视图样式及操作改进

- user.vue 和 userinfo.vue 中 el-tabs 组件添加 border-card 类型,提升标签页视觉效果
- dept.vue 中添加查看、编辑、删除操作入口,绑定对应方法,通过权限控制显示权限按钮
- dept.vue 菜单宽度、对话框宽度及行为配置优化

style(login): 调整登录样式及验证码布局细节

- 调整 .login-code-wrap 及内部元素的 flex 布局,输入框与验证码图片并排显示
- 设置验证码图片容器尺寸、边框及 hover 高亮样式
- 确保验证码图片撑满容器并禁止用户选中

refactor(mixins): 新增 organization-search 统一搜索 mixin

- 实现组织树数据加载、排除外部节点、归一化及扁平化处理
- 提供 renderOrganizationSearch 方法用于渲染统一树形选择搜索框
- 减少重复代码,方便各页面快速接入和统一维护

chore(vehicle): 车辆页面引入 organization-search mixin

- 在车辆视图 vehicle.vue 中混入 organization-search,增强所属组织搜索功能一致性
2026-08-28 15:41:01 +08:00

2459 lines
84 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
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="['contract-manage-page', { 'contract-manage-page--form': isFormPage }]">
<template v-if="!isFormPage">
<avue-crud
ref="crud"
v-model:page="page"
:data="data"
:option="tableOption"
:permission="permissionList"
:table-loading="loading"
@search-change="searchChange"
@search-reset="searchReset"
@selection-change="selectionChange"
@current-change="currentChange"
@size-change="sizeChange"
@refresh-change="refreshChange"
@search-icon-change="searchExpanded = $event"
@on-load="onLoad"
>
<template #menu-left>
<el-button v-if="canCreate" type="primary" icon="el-icon-plus" @click="openForm('add')">
新增
</el-button>
<el-button
v-if="config.exportUrl && hasPermission(`${config.permission}_export`)"
type="primary"
icon="el-icon-download"
plain
@click="handleExport"
>
批量导出
</el-button>
</template>
<template #search-menu>
<div class="contract-manage-page__expiry-search">
<el-check-tag
v-for="item in expiryTagOptions"
:key="item.value"
:checked="contractExpiryScope === item.value"
@change="changeExpiryScope(item.value)"
>
{{ item.label }}{{ contractExpiryStats[item.statKey] || 0 }}
</el-check-tag>
</div>
</template>
<template #contractName="{ row }">
<el-link v-if="row.contractName" type="primary" class="contract-name-cell" @click.stop="openDetail(row)">
{{ row.contractName }}
</el-link>
<span v-else>-</span>
</template>
<template #contractStage="{ row }">
<el-tag :type="stageTagType(row.contractStage)" class="status-text">
{{ displayStatus(row, 'contractStage') }}
</el-tag>
</template>
<template #approvalStatus="{ row }">
<el-tag :type="statusTagType(row.approvalStatus)" class="status-text">
{{ displayStatus(row, 'approvalStatus') }}
</el-tag>
</template>
<template #menu="{ row }">
<el-link
v-if="hasPermission(`${config.permission}_view`)"
type="primary"
@click="openDetail(row)"
>
查看
</el-link>
<el-link v-if="canEdit(row)" type="primary" @click="openForm('edit', row)">
{{ editLabel(row) }}
</el-link>
<el-link v-if="canCopy(row)" type="primary" @click="handleCopy(row)">复制</el-link>
<el-link
v-for="operation in customOperations(row)"
:key="`${operation.action}-${operation.label}`"
:type="operation.type || 'primary'"
@click="handleOperation(operation, row)"
>
{{ operation.label }}
</el-link>
<el-link v-if="canDelete(row)" type="danger" @click="removeRow(row)">删除</el-link>
</template>
</avue-crud>
<empty-pagination
:page="page"
@size-change="sizeChange"
@current-change="currentChange"
@load="onLoad(page, query)"
/>
</template>
<template v-else>
<div class="archive-page-form__title">{{ formPageTitle }}</div>
<el-form
ref="contractForm"
:model="form"
:rules="formRules"
label-position="right"
label-width="auto"
class="contract-manage-form"
>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">基本信息</div>
<div class="contract-manage-form__grid">
<el-form-item label="签约类型" prop="signType">
<el-select v-model="form.signType" placeholder="请选择签约类型">
<el-option
v-for="item in signTypeOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="合同编号">
<el-input
v-model="form.contractNo"
:disabled="formMode === 'edit'"
placeholder="不填写,则系统自动生成"
/>
</el-form-item>
<el-form-item label="合同名称" prop="contractName">
<el-input
v-model="form.contractName"
maxlength="100"
show-word-limit
placeholder="请输入合同名称"
/>
</el-form-item>
<el-form-item label="合同类型" prop="contractCategory">
<el-select v-model="form.contractCategory" placeholder="请选择合同类型">
<el-option
v-for="item in contractCategoryOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="项目" prop="projectName">
<el-select
v-model="selectedProjectId"
placeholder="请选择"
filterable
clearable
:loading="projectLoading"
@visible-change="visible => visible && loadProjectOptions()"
@change="handleProjectChange"
>
<el-option
v-for="item in projectOptions"
:key="item.id"
:label="item.projectName"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="甲方" prop="partyA">
<el-select
v-model="form.partyA"
placeholder="请选择甲方"
filterable
clearable
:loading="contractPartyLoading"
@visible-change="visible => visible && loadContractPartyOptions()"
>
<el-option
v-for="item in contractPartyAOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="乙方" prop="partyB">
<el-select
v-model="form.partyB"
placeholder="请选择乙方"
filterable
clearable
:loading="contractPartyLoading"
@visible-change="visible => visible && loadContractPartyOptions()"
>
<el-option
v-for="item in contractPartyBOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="合同期限" prop="startDate">
<div class="contract-manage-form__date-range">
<el-date-picker
v-model="form.startDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="YYYY-MM-DD"
/>
<span>至</span>
<el-date-picker
v-model="form.endDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="YYYY-MM-DD"
/>
</div>
</el-form-item>
<el-form-item label="所属组织" prop="organizationName">
<el-tree-select
v-model="selectedOrganizationId"
:data="organizationOptions"
:props="organizationTreeSelectProps"
node-key="id"
placeholder="请选择"
check-strictly
clearable
filterable
:render-after-expand="false"
@visible-change="visible => visible && loadOrganizationOptions()"
@change="handleOrganizationChange"
/>
</el-form-item>
<el-form-item label="经办人">
<el-input v-model="form.handlerUserName" disabled />
</el-form-item>
<el-form-item label="签订日期" prop="signDate">
<el-date-picker
v-model="form.signDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择签订日期"
/>
</el-form-item>
<el-form-item label="结算类型" prop="settlementMode">
<el-select v-model="form.settlementMode" placeholder="请选择结算类型">
<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="contractFormat">
<el-select v-model="form.contractFormat" placeholder="请选择合同格式">
<el-option
v-for="item in contractFormatOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item label="是否需要加盖法人章" prop="legalSealFlag">
<el-select v-model="form.legalSealFlag" placeholder="请选择">
<el-option label="" :value="0" />
<el-option label="" :value="1" />
</el-select>
</el-form-item>
<el-form-item label="一式">
<el-input
v-model="form.copyCount"
placeholder="请输入"
@input="value => integerInput('copyCount', value)"
/>
</el-form-item>
<el-form-item label="回款账期">
<el-input
v-model="form.paymentDays"
placeholder="请输入"
@input="value => integerInput('paymentDays', value)"
/>
</el-form-item>
</div>
<el-form-item label="备注" prop="remark" class="contract-manage-form__remark">
<el-input
v-model="form.remark"
type="textarea"
:rows="2"
maxlength="200"
show-word-limit
placeholder="请输入备注"
/>
</el-form-item>
</section>
<attachment-section
title="合同文件"
:rows="contractFileRows"
:preview="previewAttachment"
@update:rows="contractFileRows = $event"
/>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">计费信息</div>
<div class="contract-manage-form__billing-head">
<div>
<span>费用生成模式</span>
<el-radio-group v-model="form.feeGenerationMode">
<el-radio label="system">系统生成</el-radio>
<el-radio label="manual">手动生成</el-radio>
</el-radio-group>
</div>
<el-button type="primary" @click="openBillingPlan()">添加</el-button>
</div>
<el-table :data="billingPlanRows" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="planName" label="方案名称" min-width="220" align="center" />
<el-table-column label="运输方式" min-width="180" align="center">
<template #default="{ row }">{{
row.transportModeLabel || row.transportMode || '-'
}}</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row, $index }">
<el-link type="primary" @click="openBillingPlan(row, $index)">编辑</el-link>
<el-link type="danger" @click="removeBillingPlan($index)">删除</el-link>
</template>
</el-table-column>
</el-table>
</section>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">结算单规则</div>
<el-tabs v-model="settlementConfigTab">
<el-tab-pane label="预结算配置" name="pre" />
<el-tab-pane label="正式结算配置" name="formal" />
</el-tabs>
<div class="contract-manage-form__settlement-switch">
<span>自动生成结算单</span>
<el-radio-group v-model="settlementRuleForm.autoGenerate">
<el-radio :label="1">开启</el-radio>
<el-radio :label="0">关闭</el-radio>
</el-radio-group>
</div>
<div
v-if="settlementRuleEnabled"
class="contract-manage-form__grid contract-manage-form__settlement-grid"
>
<el-form-item label="账单起始日期" required>
<el-date-picker
v-model="settlementRuleForm.billStartDate"
type="date"
format="YYYY-MM-DD"
value-format="YYYY-MM-DD"
placeholder="请选择账单起始日期"
/>
</el-form-item>
<el-form-item label="结算类型" required>
<el-select
v-model="settlementRuleForm.settlementType"
placeholder="请选择结算类型"
@change="handleSettlementTypeChange"
>
<el-option
v-for="item in settlementTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item v-if="showBillCycleType" label="账单周期类型" required>
<el-select
v-model="settlementRuleForm.billCycleType"
placeholder="请选择账单周期类型"
@change="handleBillCycleTypeChange"
>
<el-option
v-for="item in billCycleTypeOptions"
:key="item"
:label="item"
:value="item"
/>
</el-select>
</el-form-item>
<el-form-item v-if="showBillCutoffDay" label="账单截单日" required>
<el-select v-model="settlementRuleForm.billCutoffDay" placeholder="请选择账单截单日">
<el-option
v-for="item in billCutoffDayOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
<el-form-item v-if="showCycleDays" label="周期天数" required>
<el-select v-model="settlementRuleForm.cycleDays" placeholder="请选择周期天数">
<el-option
v-for="item in cycleDayOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
</section>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="contract-manage-form__section-head">
<div class="dialog-section-title">付款比例设置</div>
<el-button type="primary" plain @click="addPaymentRatio">添加</el-button>
</div>
<p class="contract-manage-form__tip">非必填;配置付款比例时,合计必须等于100%。</p>
<el-table :data="paymentRatioRows" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="paymentTerm" label="付款笔数" min-width="180" align="center" />
<el-table-column label="付款比例上限%" min-width="220" align="center">
<template #default="{ row }">
<el-input
v-model="row.ratioLimit"
placeholder="请输入"
@input="value => ratioInput(row, value)"
>
<template #append>%</template>
</el-input>
</template>
</el-table-column>
<el-table-column label="备注" min-width="220">
<template #default="{ row }">
<el-input v-model="row.remark" maxlength="200" />
</template>
</el-table-column>
<el-table-column label="操作" width="100" align="center">
<template #default="{ $index }">
<el-link type="danger" @click="paymentRatioRows.splice($index, 1)">删除</el-link>
</template>
</el-table-column>
</el-table>
</section>
<attachment-section
title="其它附件"
description
:rows="attachmentRows"
:preview="previewAttachment"
@update:rows="attachmentRows = $event"
/>
<section
v-if="formMode === 'edit'"
class="contract-manage-form__section contract-manage-form__section--panel"
>
<div class="dialog-section-title">变更记录</div>
<el-table :data="changeRecordRows" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
prop="changeDate"
label="变更日期"
min-width="150"
align="center"
sortable
/>
<el-table-column prop="handlerUserName" label="经办人" min-width="140" align="center" />
<el-table-column prop="changeType" label="变更类型" min-width="160" align="center" />
<el-table-column
prop="changeReason"
label="变更原因"
min-width="240"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="statusName" label="状态" min-width="140" align="center" />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<el-link type="primary" @click="openFlow(row)">流程</el-link>
</template>
</el-table-column>
</el-table>
</section>
</el-form>
<div class="contract-manage-page__footer">
<el-button @click="closeForm">取消</el-button>
<template v-if="formMode === 'add' || isDraftEdit">
<el-button
type="primary"
plain
:loading="submitAction === 'draft'"
:disabled="Boolean(submitAction)"
@click="submitCreate('draft')"
>
暂存
</el-button>
<el-button
type="primary"
plain
:loading="submitAction === 'temporary'"
:disabled="Boolean(submitAction)"
@click="submitCreate('temporary')"
>
提交临时合同
</el-button>
<el-button
type="primary"
:loading="submitAction === 'formal'"
:disabled="Boolean(submitAction)"
@click="submitCreate('formal')"
>
提交正式合同
</el-button>
</template>
<el-button v-else type="primary" :loading="submitAction === 'edit'" @click="submitEdit">
提交
</el-button>
</div>
</template>
<billing-plan-editor
ref="billingPlanEditor"
v-model="billingPlanBox"
:value="billingPlanForm"
:index="billingPlanIndex"
@save="saveBillingPlan"
/>
<el-dialog
v-model="attachmentDocumentPreviewVisible"
:title="attachmentPreviewFile.name || '附件预览'"
append-to-body
destroy-on-close
width="90%"
top="4vh"
>
<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="detailBox"
title="合同详情"
append-to-body
destroy-on-close
class="contract-manage-detail-dialog"
width="92%"
top="4vh"
>
<div v-loading="detailLoading" class="contract-manage-form contract-manage-detail">
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">基本信息</div>
<el-descriptions :column="3" class="contract-manage-detail__descriptions">
<el-descriptions-item label="签约类型">{{
detailValue('signType')
}}</el-descriptions-item>
<el-descriptions-item label="合同编号">{{
detailValue('contractNo')
}}</el-descriptions-item>
<el-descriptions-item label="合同名称">{{
detailValue('contractName')
}}</el-descriptions-item>
<el-descriptions-item label="合同类型">{{
detailValue('contractCategory')
}}</el-descriptions-item>
<el-descriptions-item label="项目">{{
detailValue('projectName')
}}</el-descriptions-item>
<el-descriptions-item label="甲方">{{ detailValue('partyA') }}</el-descriptions-item>
<el-descriptions-item label="乙方">{{ detailValue('partyB') }}</el-descriptions-item>
<el-descriptions-item label="合同期限">{{ detailContractPeriod }}</el-descriptions-item>
<el-descriptions-item label="所属组织">{{
detailValue('organizationName')
}}</el-descriptions-item>
<el-descriptions-item label="经办人">{{
detailValue('handlerUserName')
}}</el-descriptions-item>
<el-descriptions-item label="签订日期">{{
detailValue('signDate')
}}</el-descriptions-item>
<el-descriptions-item label="结算类型">{{
detailValue('settlementMode')
}}</el-descriptions-item>
<el-descriptions-item label="合同格式">{{
detailValue('contractFormat')
}}</el-descriptions-item>
<el-descriptions-item label="是否需要加盖法人章">{{
detailLegalSealText
}}</el-descriptions-item>
<el-descriptions-item label="一式">{{
detailUnitValue('copyCount', '份')
}}</el-descriptions-item>
<el-descriptions-item label="回款账期">{{
detailUnitValue('paymentDays', '天')
}}</el-descriptions-item>
<el-descriptions-item label="备注" :span="3">{{
detailValue('remark')
}}</el-descriptions-item>
</el-descriptions>
</section>
<attachment-section
title="合同文件"
readonly
:rows="detailContractFileRows"
:preview="previewAttachment"
/>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">计费信息</div>
<el-descriptions :column="3" class="contract-manage-detail__descriptions">
<el-descriptions-item label="费用生成模式">{{
detailFeeGenerationMode
}}</el-descriptions-item>
</el-descriptions>
<el-table :data="detailBillingPlanRows" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="planName" label="方案名称" min-width="220" align="center" />
<el-table-column label="运输方式" min-width="180" align="center">
<template #default="{ row }">{{
row.transportModeLabel || row.transportMode || '-'
}}</template>
</el-table-column>
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row, $index }">
<el-link type="primary" @click="openDetailBillingPlan(row, $index)"
>查看详情</el-link
>
</template>
</el-table-column>
</el-table>
</section>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">结算单规则</div>
<el-tabs v-model="detailSettlementConfigTab">
<el-tab-pane label="预结算配置" name="pre" />
<el-tab-pane label="正式结算配置" name="formal" />
</el-tabs>
<el-descriptions :column="3" class="contract-manage-detail__descriptions">
<el-descriptions-item label="自动生成结算单">{{
Number(detailSettlementRule.autoGenerate) === 1 ? '开启' : '关闭'
}}</el-descriptions-item>
<template v-if="Number(detailSettlementRule.autoGenerate) === 1">
<el-descriptions-item label="账单起始日期">{{
displayValue(detailSettlementRule.billStartDate)
}}</el-descriptions-item>
<el-descriptions-item label="结算类型">{{
displayValue(detailSettlementRule.settlementType)
}}</el-descriptions-item>
<el-descriptions-item
v-if="detailSettlementRule.settlementType === '月结'"
label="账单周期类型"
>{{ displayValue(detailSettlementRule.billCycleType) }}</el-descriptions-item
>
<el-descriptions-item
v-if="
detailSettlementRule.settlementType === '月结' &&
detailSettlementRule.billCycleType === '固定截单日'
"
label="账单截单日"
>{{
detailObjectUnitValue(detailSettlementRule, 'billCutoffDay', '日')
}}</el-descriptions-item
>
<el-descriptions-item
v-if="detailSettlementRule.settlementType === '固定天数周期结算'"
label="周期天数"
>{{
detailObjectUnitValue(detailSettlementRule, 'cycleDays', '天')
}}</el-descriptions-item
>
</template>
</el-descriptions>
</section>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">付款比例设置</div>
<el-table :data="detailPaymentRatioRows" border>
<el-table-column type="index" label="序号" width="80" align="center" />
<el-table-column prop="paymentTerm" label="付款笔数" min-width="180" align="center" />
<el-table-column label="付款比例上限%" min-width="220" align="center">
<template #default="{ row }">{{
detailObjectUnitValue(row, 'ratioLimit', '%')
}}</template>
</el-table-column>
<el-table-column prop="remark" label="备注" min-width="220" />
</el-table>
</section>
<attachment-section
title="其它附件"
description
readonly
:rows="detailAttachmentRows"
:preview="previewAttachment"
/>
<section class="contract-manage-form__section contract-manage-form__section--panel">
<div class="dialog-section-title">变更记录</div>
<el-table :data="detailChangeRecordRows" border>
<el-table-column type="index" label="序号" width="70" align="center" />
<el-table-column
prop="changeDate"
label="变更日期"
min-width="150"
align="center"
sortable
/>
<el-table-column prop="handlerUserName" label="经办人" min-width="140" align="center" />
<el-table-column prop="changeType" label="变更类型" min-width="160" align="center" />
<el-table-column
prop="changeReason"
label="变更原因"
min-width="240"
align="center"
show-overflow-tooltip
/>
<el-table-column prop="statusName" label="状态" min-width="140" align="center" />
<el-table-column label="操作" width="120" align="center" fixed="right">
<template #default="{ row }"
><el-link type="primary" @click="openFlow(row)">流程</el-link></template
>
</el-table-column>
</el-table>
</section>
</div>
<template #footer
><el-button type="primary" @click="detailBox = false">关闭</el-button></template
>
</el-dialog>
<billing-plan-editor
v-model="detailBillingPlanBox"
:value="detailBillingPlanForm"
:index="detailBillingPlanIndex"
readonly
/>
<flow-design
v-if="website.design.designMode"
is-dialog
v-model:is-display="flowBox"
:process-instance-id="processInstanceId"
/>
<el-dialog v-else v-model="flowBox" title="流程图" append-to-body fullscreen>
<iframe class="contract-manage-page__flow" :src="flowUrl" />
<template #footer><el-button @click="flowBox = false">关闭</el-button></template>
</el-dialog>
</basic-container>
</template>
<script>
import { defineComponent, h, resolveComponent } from 'vue';
import { mapGetters } from 'vuex';
import NProgress from 'nprogress';
import 'nprogress/nprogress.css';
import { exportBlob } from '@/api/common';
import * as api from '@/api/business/contract-manage';
import { getList as getProjectList } from '@/api/business/project-apply';
import { getList as getCustomerArchiveList } from '@/api/vehicle/customer-archive';
import { getDeptTree } from '@/api/system/dept';
import { config, option } from '@/option/business/contract-manage';
import { getToken } from '@/utils/auth';
import { downloadFileByUrl, downloadXls } from '@/utils/util';
import BillingPlanEditor from './components/billing-plan-editor.vue';
import PdfPreview from '@/components/pdf-preview/main.vue';
import { ElImageViewer, ElTreeSelect } from 'element-plus';
import { OpenFileViewer } from '@open-file-viewer/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';
const clone = value => JSON.parse(JSON.stringify(value));
const normalizeOptionalInteger = (value, emptyValue = null) => {
if (value === undefined || value === null || value === '' || Number(value) < 0) {
return emptyValue;
}
const number = Number(value);
return Number.isFinite(number) ? Math.trunc(number) : emptyValue;
};
const defaultSettlementRule = () => ({
autoGenerate: 1,
billStartDate: '',
settlementType: '月结',
billCycleType: '固定截单日',
billCutoffDay: 25,
cycleDays: '',
});
const defaultForm = () => ({
contractCategory: '客户合同',
contractNo: '',
contractName: '',
projectId: '',
projectName: '',
organizationId: '',
organizationName: '',
signType: '',
settlementMode: '',
partyA: '',
partyB: '',
handlerUserName: '',
signDate: '',
contractFormat: '',
legalSealFlag: 0,
copyCount: '',
paymentDays: '',
startDate: '',
endDate: '',
temporaryStartDate: '',
temporaryEndDate: '',
effectiveType: '',
feeGenerationMode: 'system',
remark: '',
});
const parseArray = value => {
if (Array.isArray(value)) return value;
if (!value) return [];
try {
const data = JSON.parse(value);
return Array.isArray(data) ? data : [];
} catch (error) {
return [];
}
};
const parseObject = value => {
if (value && typeof value === 'object' && !Array.isArray(value)) return value;
if (!value) return {};
try {
const data = JSON.parse(value);
return data && typeof data === 'object' && !Array.isArray(data) ? data : {};
} catch (error) {
return {};
}
};
const extractRecords = res => {
const data = res?.data?.data || res?.data || {};
return Array.isArray(data) ? data : data.records || [];
};
const attachmentName = row => row.originalName || row.name || row.fileName || '附件';
const attachmentUrl = row =>
row.url || row.link || row.fileUrl || row.downloadUrl || row.domain || '';
const attachmentExtension = row => {
const source = String(attachmentName(row) || attachmentUrl(row)).split('?')[0];
const index = source.lastIndexOf('.');
return index > -1 ? source.slice(index + 1).toLowerCase() : '';
};
const isAttachmentImage = row =>
['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp'].includes(attachmentExtension(row));
const isAttachmentPdf = row => {
const mimeType = String(row.mimeType || row.contentType || '').toLowerCase();
return mimeType.includes('application/pdf') || attachmentExtension(row) === 'pdf';
};
const attachmentFileTypes = [
'pdf',
'bmp',
'jpeg',
'png',
'jpg',
'doc',
'docx',
'ppt',
'pptx',
'xlsx',
'xls',
'eml',
'msg',
'zip',
];
const attachmentViewerPlugins = [
imagePlugin(),
officePlugin({ pdf: { workerSrc: pdfWorkerSrc, useFetchData: true } }),
textPlugin(),
fallbackPlugin(),
];
const AttachmentSection = defineComponent({
name: 'AttachmentSection',
props: {
title: { type: String, required: true },
rows: { type: Array, default: () => [] },
description: Boolean,
readonly: Boolean,
preview: { type: Function, default: null },
},
emits: ['update:rows'],
data() {
return { selected: [], attachmentFileTypes };
},
methods: {
update(rows) {
this.$emit('update:rows', rows);
},
handleChange(rows) {
const uploadUserName = this.$store.getters.userInfo?.realName || '';
const uploadTime = this.$dayjs().format('YYYY-MM-DD HH:mm:ss');
this.update(
(rows || []).map(row => ({
...row,
description: row.description || '',
uploadUserName: row.uploadUserName || uploadUserName,
uploadTime: row.uploadTime || uploadTime,
}))
);
},
remove(index) {
const rows = [...this.rows];
rows.splice(index, 1);
this.update(rows);
},
download(row) {
const url = attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法下载');
return;
}
downloadFileByUrl(url, attachmentName(row));
},
batchDownload() {
(this.selected.length ? this.selected : this.rows).forEach(this.download);
},
formatSize(size) {
const value = Number(size);
if (!value) return '';
if (value < 1024) return `${value}B`;
if (value < 1024 * 1024) return `${(value / 1024).toFixed(1)}KB`;
return `${(value / 1024 / 1024).toFixed(1)}MB`;
},
},
render() {
const ElButton = resolveComponent('el-button');
const ElInput = resolveComponent('el-input');
const ElLink = resolveComponent('el-link');
const ElTable = resolveComponent('el-table');
const ElTableColumn = resolveComponent('el-table-column');
const VehicleAttachmentUpload = resolveComponent('vehicle-attachment-upload');
const columns = [
...(!this.readonly
? [h(ElTableColumn, { type: 'selection', width: 55, align: 'center' })]
: []),
h(ElTableColumn, { type: 'index', label: '序号', width: 70, align: 'center' }),
h(
ElTableColumn,
{
label: '文件名',
minWidth: 240,
align: 'left',
headerAlign: 'left',
showOverflowTooltip: true,
},
{
default: ({ row }) =>
h(
ElLink,
{
type: 'primary',
onClick: () => this.preview?.(row, this.rows),
},
() => attachmentName(row)
),
}
),
];
if (this.description) {
columns.push(
h(
ElTableColumn,
{ label: '附件描述', minWidth: 220 },
{
default: ({ row }) =>
this.readonly
? row.description || '-'
: h(ElInput, {
modelValue: row.description,
maxlength: 200,
'onUpdate:modelValue': value => {
row.description = value;
this.update([...this.rows]);
},
}),
}
)
);
}
columns.push(
h(
ElTableColumn,
{ label: '文件大小', width: 120, align: 'center' },
{
default: ({ row }) => this.formatSize(row.size),
}
),
h(ElTableColumn, { prop: 'uploadUserName', label: '上传人', width: 140, align: 'center' }),
h(ElTableColumn, {
prop: 'uploadTime',
label: '上传时间',
width: 180,
align: 'center',
sortable: true,
})
);
if (!this.readonly) {
columns.push(
h(
ElTableColumn,
{ label: '操作', width: 100, align: 'center', fixed: 'right' },
{
default: ({ $index }) =>
h(ElLink, { type: 'danger', onClick: () => this.remove($index) }, () => '删除'),
}
)
);
}
return h(
'section',
{
class: 'contract-manage-form__section contract-manage-form__section--panel',
},
[
h('div', { class: 'dialog-section-title' }, this.title),
h('div', { class: 'contract-manage-form__attachment-head' }, [
h(
ElButton,
{ type: 'primary', disabled: !this.rows.length, onClick: this.batchDownload },
() => '批量下载'
),
]),
h(
ElTable,
{
data: this.rows,
border: true,
class: 'contract-manage-form__attachment-table',
onSelectionChange: rows => {
this.selected = rows;
},
},
() => columns
),
...(!this.readonly
? [
h('div', { class: 'contract-manage-form__attachment-upload' }, [
h(VehicleAttachmentUpload, {
modelValue: this.rows,
fileTypes: this.attachmentFileTypes,
maxSize: 500,
showTip: true,
tip: '支持pdf、bmp、jpeg、png、jpg、doc、docx、ppt、pptx、xlsx、xls、eml、msg、zip的文件格式,单个文件不超过500M',
showFileList: false,
buttonText: '上传附件',
'onUpdate:modelValue': this.handleChange,
onChange: this.handleChange,
}),
]),
]
: []),
]
);
},
});
export default {
name: 'ContractManage',
components: { AttachmentSection, BillingPlanEditor, ElImageViewer, OpenFileViewer, PdfPreview },
data() {
return {
api,
config,
tableOption: this.buildTableOption(),
loading: true,
data: [],
query: {},
page: { currentPage: 1, pageSize: 10, pageSizes: [10, 20, 50, 100], total: 0 },
selectionList: [],
searchExpanded: false,
contractExpiryScope: '',
contractExpiryStats: { all: 0, within30: 0, within90: 0, over90: 0, expired: 0 },
expiryTagOptions: [
{ label: '全部', value: '', statKey: 'all' },
{ label: '30天内到期', value: 'within30', statKey: 'within30' },
{ label: '90天内到期', value: 'within90', statKey: 'within90' },
{ label: '90天以上到期', value: 'over90', statKey: 'over90' },
{ label: '已到期', value: 'expired', statKey: 'expired' },
],
form: defaultForm(),
formRules: {
contractName: [{ required: true, message: '请输入合同名称', trigger: 'blur' }],
contractCategory: [{ required: true, message: '请选择合同类别', trigger: 'change' }],
signType: [{ required: true, message: '请选择签约类型', trigger: 'change' }],
projectName: [{ required: true, message: '请选择所属项目', trigger: 'change' }],
organizationName: [{ required: true, message: '请选择所属组织', trigger: 'change' }],
partyA: [{ required: true, message: '请选择甲方', trigger: 'change' }],
partyB: [{ required: true, message: '请选择乙方', trigger: 'change' }],
signDate: [{ required: true, message: '请选择签订日期', trigger: 'change' }],
settlementMode: [{ required: true, message: '请选择结算类型', trigger: 'change' }],
contractFormat: [{ required: true, message: '请选择合同格式', trigger: 'change' }],
legalSealFlag: [{ required: true, message: '请选择是否需要加盖法人章', trigger: 'change' }],
startDate: [{ required: true, message: '请选择开始日期', trigger: 'change' }],
endDate: [{ required: true, message: '请选择结束日期', trigger: 'change' }],
},
submitAction: '',
projectOptions: [],
projectLoading: false,
selectedProjectId: '',
contractPartyAOptions: [],
contractPartyBOptions: [],
contractPartyLoading: false,
contractPartyRequestId: 0,
organizationOptions: [],
organizationFlatOptions: [],
selectedOrganizationId: '',
organizationLoading: false,
contractFileRows: [],
attachmentRows: [],
attachmentImagePreviewVisible: false,
attachmentImagePreviewUrls: [],
attachmentImagePreviewIndex: 0,
attachmentDocumentPreviewVisible: false,
attachmentPreviewFile: {},
attachmentViewerPlugins,
attachmentViewerToolbar: {
download: true,
fullscreen: true,
print: true,
rotate: true,
zoom: true,
},
billingPlanRows: [],
billingPlanBox: false,
billingPlanIndex: -1,
billingPlanForm: {},
settlementConfigTab: 'pre',
settlementRuleForm: defaultSettlementRule(),
preSettlementRuleForm: defaultSettlementRule(),
formalSettlementRuleForm: defaultSettlementRule(),
paymentRatioRows: [],
changeRecordRows: [],
settlementTypeOptions: ['月结', '日结', '周结', '半月结', '固定天数周期结算'],
billCycleTypeOptions: ['固定截单日', '自然月'],
detailBox: false,
detailLoading: false,
detailRow: {},
detailContractFileRows: [],
detailAttachmentRows: [],
detailBillingPlanRows: [],
detailBillingPlanBox: false,
detailBillingPlanIndex: -1,
detailBillingPlanForm: {},
detailSettlementConfigTab: 'pre',
detailPreSettlementRuleForm: defaultSettlementRule(),
detailFormalSettlementRuleForm: defaultSettlementRule(),
detailPaymentRatioRows: [],
detailChangeRecordRows: [],
flowBox: false,
flowUrl: '',
processInstanceId: '',
};
},
computed: {
...mapGetters(['permission', 'userInfo']),
permissionList() {
return { addBtn: this.canCreate };
},
isAdmin() {
const authority = this.userInfo?.authority;
return Array.isArray(authority)
? authority.includes('admin')
: String(authority || '').includes('admin');
},
canCreate() {
return this.hasPermission(`${this.config.permission}_add`);
},
isFormPage() {
return this.$route.path === '/business/contract-manage/form';
},
formMode() {
return this.$route.query.mode === 'edit' ? 'edit' : 'add';
},
// 草稿(approvalStatus=draft)编辑页:按钮与新增页一致(暂存/提交临时/提交正式)
isDraftEdit() {
return this.formMode === 'edit' && this.form.approvalStatus === 'draft';
},
formPageTitle() {
return this.$route.query.name || `${this.formMode === 'edit' ? '编辑' : '新增'}合同管理`;
},
ids() {
return this.selectionList.map(item => item.id).join(',');
},
contractCategoryOptions() {
return this.columnOptions('contractCategory');
},
signTypeOptions() {
return this.columnOptions('signType');
},
effectiveTypeOptions() {
return this.columnOptions('effectiveType');
},
settlementModeOptions() {
return this.columnOptions('settlementMode');
},
contractFormatOptions() {
return this.columnOptions('contractFormat');
},
organizationTreeSelectProps() {
return {
label: 'label',
value: 'id',
children: 'children',
};
},
settlementRuleEnabled() {
return Number(this.settlementRuleForm.autoGenerate) === 1;
},
showBillCycleType() {
return this.settlementRuleForm.settlementType === '月结';
},
showBillCutoffDay() {
return this.showBillCycleType && this.settlementRuleForm.billCycleType === '固定截单日';
},
showCycleDays() {
return this.settlementRuleForm.settlementType === '固定天数周期结算';
},
billCutoffDayOptions() {
return Array.from({ length: 31 }, (_, index) => ({
label: `${index + 1}日`,
value: index + 1,
}));
},
cycleDayOptions() {
return Array.from({ length: 365 }, (_, index) => ({
label: `${index + 1}天`,
value: index + 1,
}));
},
detailContractPeriod() {
const startDate = this.detailRow.startDate || '-';
const endDate = this.detailRow.endDate || '-';
return `${startDate} 至 ${endDate}`;
},
detailLegalSealText() {
const value = this.detailRow.legalSealFlag;
if (value === undefined || value === null || value === '') return '-';
return Number(value) === 1 ? '是' : '否';
},
detailFeeGenerationMode() {
const value =
this.detailRow.feeGenerationMode ||
(Number(this.detailRow.billingEnabled) === 0 ? 'manual' : 'system');
return value === 'manual' ? '手动生成' : '系统生成';
},
detailSettlementRule() {
return this.detailSettlementConfigTab === 'formal'
? this.detailFormalSettlementRuleForm
: this.detailPreSettlementRuleForm;
},
},
watch: {
'$route.fullPath'() {
if (this.isFormPage) this.initFormPage();
},
settlementConfigTab(tab, oldTab) {
if (tab === oldTab) return;
if (oldTab === 'formal') this.formalSettlementRuleForm = { ...this.settlementRuleForm };
else this.preSettlementRuleForm = { ...this.settlementRuleForm };
this.settlementRuleForm = {
...(tab === 'formal' ? this.formalSettlementRuleForm : this.preSettlementRuleForm),
};
},
},
created() {
this.loadProjectOptions();
this.loadOrganizationOptions();
if (this.isFormPage) this.initFormPage();
},
methods: {
buildTableOption() {
return {
...option,
addBtn: false,
viewBtn: false,
editBtn: false,
delBtn: false,
menuWidth: 320,
column: (option.column || []).map(column => ({ ...column, display: false })),
};
},
columnOptions(prop) {
return this.findColumn(this.tableOption.column, prop)?.dicData || [];
},
hasPermission(code) {
return this.isAdmin || this.validData(this.permission?.[code], false);
},
displayStatus(row, prop) {
const textProp = prop === this.config.statusProp ? this.config.statusTextProp : '';
if (textProp && row[textProp]) return row[textProp];
const item = this.columnOptions(prop).find(dic => String(dic.value) === String(row[prop]));
return item?.label || row[prop] || '未知';
},
statusTagType(status) {
if (['approved', 'change_approved'].includes(status)) return 'success';
if (['draft', 'withdrawn'].includes(status)) return 'info';
if (['rejected', 'change_rejected'].includes(status)) return 'danger';
return 'warning';
},
stageTagType(stage) {
if (stage === 'formal') return 'success';
if (stage === 'temporary') return 'warning';
if (stage === 'terminated') return 'danger';
return 'info';
},
canEdit(row) {
return (
this.hasPermission(`${this.config.permission}_edit`) &&
!row.readonly &&
(this.config.editStatus || []).includes(row.approvalStatus)
);
},
canDelete(row) {
return (
this.hasPermission(`${this.config.permission}_delete`) &&
!row.readonly &&
(this.config.deleteStatus || []).includes(row.approvalStatus) &&
(this.config.deleteStage || []).includes(row.contractStage)
);
},
canCopy(row) {
return (
this.hasPermission(`${this.config.permission}_copy`) &&
!row.readonly &&
typeof this.api.copy === 'function'
);
},
editLabel(row) {
return this.config.editLabelStatusMap?.[row.approvalStatus] || '编辑';
},
customOperations(row) {
return (this.config.operations || []).filter(operation => {
const permission = operation.permission || `${this.config.permission}_${operation.action}`;
const statusProp = operation.statusProp || this.config.statusProp || 'status';
return (
this.hasPermission(permission) &&
!row.readonly &&
(!operation.status?.length || operation.status.includes(row[statusProp])) &&
(!operation.stage?.length || operation.stage.includes(row.contractStage))
);
});
},
normalizeSearch(params = {}) {
const query = { ...params };
if (query.organizationName) {
const id = Array.isArray(query.organizationName)
? query.organizationName.at(-1)
: query.organizationName;
const organization = this.findOrganization(id);
query.organizationId = id;
if (organization) query.organizationName = organization.rawLabel || organization.label;
}
return query;
},
onLoad(page, params = {}) {
this.loading = true;
const query = this.normalizeSearch({
...params,
...this.query,
...(this.contractExpiryScope ? { expireScope: this.contractExpiryScope } : {}),
});
this.api
.getList(page.currentPage, page.pageSize, query)
.then(res => {
const result = res.data?.data || {};
this.page.total = result.total || 0;
this.data = result.records || [];
this.selectionList = [];
this.refreshExpiryStats(query);
})
.finally(() => {
this.loading = false;
});
},
searchChange(params, done) {
this.query = { ...params };
this.page.currentPage = 1;
this.onLoad(this.page);
done();
},
searchReset() {
this.query = {};
this.contractExpiryScope = '';
this.page.currentPage = 1;
this.onLoad(this.page);
},
selectionChange(list) {
this.selectionList = list;
},
currentChange(currentPage) {
this.page.currentPage = currentPage;
},
sizeChange(pageSize) {
this.page.pageSize = pageSize;
},
refreshChange() {
this.onLoad(this.page, this.query);
},
changeExpiryScope(value) {
this.contractExpiryScope = value;
this.page.currentPage = 1;
this.onLoad(this.page, this.query);
},
refreshExpiryStats(query) {
const params = { ...query };
delete params.expireScope;
this.api.expireStats(params).then(res => {
this.contractExpiryStats = { ...this.contractExpiryStats, ...(res.data?.data || {}) };
});
},
openForm(mode, row = {}) {
this.$router.push({
path: '/business/contract-manage/form',
query:
mode === 'edit'
? { mode, id: row.id, name: '编辑合同管理' }
: { mode, name: '新增合同管理' },
});
},
initFormPage() {
this.resetFormState();
if (this.formMode === 'add') {
this.loadContractPartyOptions();
return;
}
const id = this.$route.query.id;
if (!id) return;
this.api.getDetail(id).then(res => this.applyDetail(res.data?.data || {}));
},
resetFormState() {
this.form = {
...defaultForm(),
handlerUserName:
this.userInfo?.realName || this.userInfo?.userName || this.userInfo?.account || '',
};
this.selectedProjectId = '';
this.selectedOrganizationId = '';
this.contractPartyAOptions = [];
this.contractPartyBOptions = [];
this.contractFileRows = [];
this.attachmentRows = [];
this.billingPlanRows = [];
this.preSettlementRuleForm = defaultSettlementRule();
this.formalSettlementRuleForm = defaultSettlementRule();
this.settlementConfigTab = 'pre';
this.settlementRuleForm = this.preSettlementRuleForm;
this.paymentRatioRows = [];
this.changeRecordRows = [];
this.$nextTick(() => this.$refs.contractForm?.clearValidate());
},
applyDetail(detail) {
this.form = {
...defaultForm(),
...detail,
copyCount: normalizeOptionalInteger(detail.copyCount, ''),
paymentDays: normalizeOptionalInteger(detail.paymentDays, ''),
feeGenerationMode:
detail.feeGenerationMode || (Number(detail.billingEnabled) === 0 ? 'manual' : 'system'),
};
this.selectedProjectId = detail.projectId || '';
this.selectedOrganizationId = detail.organizationId || '';
this.contractFileRows = parseArray(detail.contractFileJson);
this.attachmentRows = parseArray(detail.attachmentsJson);
this.billingPlanRows = parseArray(detail.billingPlanJson);
this.paymentRatioRows = parseArray(detail.paymentRatioJson);
this.changeRecordRows = parseArray(detail.changeRecordJson);
const payload = parseObject(detail.settlementRuleJson);
const legacy = Object.keys(payload).some(
key => !['preSettlementConfig', 'formalSettlementConfig'].includes(key)
)
? payload
: {};
const preConfig = parseObject(detail.preSettlementConfigJson);
const formalConfig = parseObject(detail.formalSettlementConfigJson);
this.preSettlementRuleForm = this.normalizeSettlementRule(
payload.preSettlementConfig || (Object.keys(preConfig).length ? preConfig : legacy)
);
this.formalSettlementRuleForm = this.normalizeSettlementRule(
payload.formalSettlementConfig || (Object.keys(formalConfig).length ? formalConfig : legacy)
);
this.settlementConfigTab = 'pre';
this.settlementRuleForm = { ...this.preSettlementRuleForm };
this.syncCurrentProjectOption();
this.syncCurrentOrganizationOption();
this.loadContractPartyOptions();
},
closeForm() {
this.$router.$avueRouter?.closeTag?.();
this.$router.push('/business/contract-manage');
},
validateBase(action) {
if (!this.form.contractName?.trim()) {
this.$message.warning('请输入合同名称');
return false;
}
if (!this.form.contractCategory) {
this.$message.warning('请选择合同类别');
return false;
}
if (['temporary', 'formal', 'edit'].includes(action) && !this.form.signType) {
this.$message.warning('请选择签约类型');
return false;
}
if (action !== 'formal' && action !== 'edit') return true;
const required = [
['projectName', '所属项目'],
['organizationName', '所属组织'],
['partyA', '甲方'],
['partyB', '乙方'],
['startDate', '合同开始日期'],
['endDate', '合同结束日期'],
['signDate', '签订日期'],
['settlementMode', '结算类型'],
['contractFormat', '合同格式'],
];
const empty = required.find(([prop]) => !this.form[prop]);
if (empty) {
this.$message.warning(`请选择或输入${empty[1]}`);
return false;
}
return (
this.validateBillingPlanDefault() &&
this.validateSettlementRules() &&
this.validatePaymentRatios()
);
},
normalizeSubmit() {
this.syncSettlementConfig();
return {
...this.form,
copyCount: normalizeOptionalInteger(this.form.copyCount),
paymentDays: normalizeOptionalInteger(this.form.paymentDays),
billingEnabled: this.form.feeGenerationMode === 'manual' ? 0 : 1,
contractFileJson: JSON.stringify(this.contractFileRows),
attachmentsJson: JSON.stringify(this.attachmentRows),
billingPlanJson: JSON.stringify(this.billingPlanRows),
settlementRuleJson: JSON.stringify({
preSettlementConfig: this.normalizeSettlementRule(this.preSettlementRuleForm),
formalSettlementConfig: this.normalizeSettlementRule(this.formalSettlementRuleForm),
}),
preSettlementConfigJson: JSON.stringify(
this.normalizeSettlementRule(this.preSettlementRuleForm)
),
formalSettlementConfigJson: JSON.stringify(
this.normalizeSettlementRule(this.formalSettlementRuleForm)
),
paymentRatioJson: JSON.stringify(this.paymentRatioRows),
changeRecordJson: JSON.stringify(this.changeRecordRows),
};
},
submitCreate(action) {
if (this.submitAction || !this.validateBase(action)) return;
this.submitAction = action;
const submit = {
...this.normalizeSubmit(),
contractStage: action === 'draft' ? 'draft' : 'temporary',
approvalStatus: 'draft',
};
this.api
.saveDraft(submit)
.then(res => {
const saved = res.data?.data || {};
const id = saved.id || this.form.id;
if (action === 'formal') {
if (!id) throw new Error('合同保存成功但未返回主键,无法提交正式合同');
return this.api.submitFormal(id);
}
return null;
})
.then(() => {
const message = {
draft: '保存成功',
temporary: '临时合同提交成功',
formal: '正式合同提交成功',
}[action];
this.$message.success(message);
this.closeForm();
})
.finally(() => {
this.submitAction = '';
});
},
submitEdit() {
if (this.submitAction || !this.validateBase('edit')) return;
this.submitAction = 'edit';
this.api
.submit(this.normalizeSubmit())
.then(() => {
this.$message.success('修改成功');
this.closeForm();
})
.finally(() => {
this.submitAction = '';
});
},
loadProjectOptions() {
if (this.projectLoading) return;
this.projectLoading = true;
getProjectList(1, 9999, this.config.projectQueryParams || {})
.then(res => {
this.projectOptions = extractRecords(res);
this.syncCurrentProjectOption();
})
.finally(() => {
this.projectLoading = false;
});
},
syncCurrentProjectOption() {
if (!this.form.projectId || !this.form.projectName) return;
if (this.projectOptions.some(item => String(item.id) === String(this.form.projectId))) return;
this.projectOptions.unshift({ id: this.form.projectId, projectName: this.form.projectName });
},
handleProjectChange(projectId) {
const project = this.projectOptions.find(item => String(item.id) === String(projectId));
if (!project) {
Object.assign(this.form, { projectId: '', projectName: '' });
return;
}
Object.assign(this.form, {
projectId: project.id,
projectName: project.projectName || '',
});
},
loadContractPartyOptions() {
if (this.contractPartyLoading) return;
const requestId = ++this.contractPartyRequestId;
this.contractPartyLoading = true;
getCustomerArchiveList(1, 9999, { status: 1, approvalStatus: 'approved' })
.then(res => {
if (requestId !== this.contractPartyRequestId) return;
const names = extractRecords(res)
.map(item => item.fullName || item.shortName || item.customerName || item.customerCode)
.map(name => String(name || '').trim())
.filter(Boolean);
[this.form.partyA, this.form.partyB].filter(Boolean).forEach(name => names.unshift(name));
const options = [...new Set(names)].map(name => ({ label: name, value: name }));
this.contractPartyAOptions = options;
this.contractPartyBOptions = options;
})
.finally(() => {
if (requestId === this.contractPartyRequestId) this.contractPartyLoading = false;
});
},
loadOrganizationOptions() {
if (this.organizationLoading) return;
this.organizationLoading = true;
getDeptTree(this.userInfo?.tenantId)
.then(res => {
const organizationTree = this.excludeExternalOrganization(res.data?.data || []);
this.organizationOptions = this.normalizeOrganizationTree(organizationTree);
this.organizationFlatOptions = this.flattenOrganizationTree(this.organizationOptions);
const column = this.findColumn(this.tableOption.column, 'organizationName');
if (column) {
column.dicData = this.organizationOptions;
column.renderSearch = scope => this.renderOrganizationSearch(scope);
}
this.syncCurrentOrganizationOption();
})
.finally(() => {
this.organizationLoading = false;
});
},
excludeExternalOrganization(tree = []) {
return tree.reduce((result, item) => {
const organizationName = item.title || item.deptName || item.name || item.label || '';
if (String(organizationName).trim() === '外部组织') return result;
result.push({
...item,
children: this.excludeExternalOrganization(item.children || []),
});
return result;
}, []);
},
normalizeOrganizationTree(tree) {
return (tree || []).map(item => {
const label = item.title || item.deptName || item.name || item.label || '';
const children = this.normalizeOrganizationTree(item.children || []);
return {
...item,
id: String(item.id),
label,
rawLabel: label,
children: children.length ? children : undefined,
};
});
},
flattenOrganizationTree(tree) {
return (tree || []).flatMap(item => [
{ id: String(item.id), label: item.label, rawLabel: item.rawLabel },
...this.flattenOrganizationTree(item.children || []),
]);
},
findOrganization(id) {
const value = Array.isArray(id) ? id.at(-1) : id;
return this.organizationFlatOptions.find(item => String(item.id) === String(value));
},
handleOrganizationChange(id) {
const organization = this.findOrganization(id);
Object.assign(
this.form,
organization
? {
organizationId: organization.id,
organizationName: organization.rawLabel || organization.label,
}
: { organizationId: '', organizationName: '' }
);
},
syncCurrentOrganizationOption() {
if (!this.form.organizationId || !this.form.organizationName) return;
if (this.findOrganization(this.form.organizationId)) return;
const item = {
id: String(this.form.organizationId),
label: this.form.organizationName,
rawLabel: this.form.organizationName,
};
this.organizationOptions.unshift(item);
this.organizationFlatOptions.unshift(item);
},
renderOrganizationSearch(scope) {
// 搜索区所属组织:树形下拉(与客户档案统一)
return h(ElTreeSelect, {
modelValue: scope.row?.organizationName || '',
'onUpdate:modelValue': value => {
if (scope.row) scope.row.organizationName = value;
},
data: this.organizationOptions,
'node-key': 'id',
'check-strictly': true,
filterable: true,
clearable: true,
'render-after-expand': false,
style: 'width: 100%',
placeholder: '请选择 所属组织',
props: { label: 'label', value: 'id', children: 'children' },
});
},
integerInput(prop, value) {
this.form[prop] = String(value || '').replace(/\D/g, '');
},
previewAttachment(row, rows = []) {
const url = attachmentUrl(row);
if (!url) {
this.$message.warning('附件地址为空,无法预览');
return;
}
if (isAttachmentImage(row)) {
this.attachmentImagePreviewUrls = (rows || [])
.filter(item => isAttachmentImage(item) && attachmentUrl(item))
.map(item => attachmentUrl(item));
this.attachmentImagePreviewIndex = Math.max(
this.attachmentImagePreviewUrls.indexOf(url),
0
);
this.attachmentImagePreviewVisible = true;
return;
}
const isPdf = isAttachmentPdf(row);
this.attachmentPreviewFile = {
name: attachmentName(row),
url,
mimeType: isPdf ? 'application/pdf' : row.mimeType || row.contentType || '',
isPdf,
};
this.attachmentDocumentPreviewVisible = true;
},
handleAttachmentPreviewUnsupported() {
this.$message.warning('当前文件暂不支持在线预览');
},
handleAttachmentPreviewError() {
this.$message.error('附件预览失败');
},
openBillingPlan(row = {}, index = -1) {
this.billingPlanIndex = index;
this.billingPlanForm =
index < 0
? { planName: '', transportMode: '', defaultPlan: false, remark: '', rules: [] }
: clone(row);
this.billingPlanBox = true;
this.$nextTick(() => this.$refs.billingPlanEditor?.ensureTransportModes?.());
},
saveBillingPlan(plan, index) {
const nextPlan = clone(plan);
if (this.isDefaultBillingPlan(nextPlan)) {
this.billingPlanRows.forEach((item, itemIndex) => {
if (itemIndex !== index && item.transportMode === nextPlan.transportMode) {
item.defaultPlan = false;
}
});
}
if (index < 0) this.billingPlanRows.push(nextPlan);
else this.billingPlanRows.splice(index, 1, nextPlan);
this.billingPlanBox = false;
},
removeBillingPlan(index) {
this.$confirm('确定删除该计费方案?', '提示', { type: 'warning' }).then(() => {
this.billingPlanRows.splice(index, 1);
});
},
isDefaultBillingPlan(plan) {
return (
plan?.defaultPlan === true ||
plan?.defaultPlan === 1 ||
['true', '1'].includes(String(plan?.defaultPlan).toLowerCase())
);
},
validateBillingPlanDefault() {
if (!this.billingPlanRows.length) return true;
const defaultCountByTransportMode = this.billingPlanRows.reduce((result, plan) => {
const transportMode = plan.transportMode || '';
result[transportMode] =
(result[transportMode] || 0) + Number(this.isDefaultBillingPlan(plan));
return result;
}, {});
const invalidTransportMode = Object.entries(defaultCountByTransportMode).find(
([, count]) => count > 1
);
if (invalidTransportMode) {
const [transportMode, count] = invalidTransportMode;
this.$message.warning(`运输方式“${transportMode}”仅支持配置一个默认计费方案`);
return false;
}
return true;
},
normalizeSettlementRule(rule = {}) {
const next = { ...defaultSettlementRule(), ...rule };
if (next.settlementType !== '月结') {
next.billCycleType = '';
next.billCutoffDay = '';
} else if (next.billCycleType !== '固定截单日') next.billCutoffDay = '';
if (next.settlementType !== '固定天数周期结算') next.cycleDays = '';
return next;
},
syncSettlementConfig() {
if (this.settlementConfigTab === 'formal')
this.formalSettlementRuleForm = { ...this.settlementRuleForm };
else this.preSettlementRuleForm = { ...this.settlementRuleForm };
},
handleSettlementTypeChange(value) {
if (value === '月结') {
this.settlementRuleForm.billCycleType ||= '固定截单日';
if (this.settlementRuleForm.billCycleType === '固定截单日')
this.settlementRuleForm.billCutoffDay ||= 25;
this.settlementRuleForm.cycleDays = '';
return;
}
this.settlementRuleForm.billCycleType = '';
this.settlementRuleForm.billCutoffDay = '';
if (value !== '固定天数周期结算') this.settlementRuleForm.cycleDays = '';
},
handleBillCycleTypeChange(value) {
this.settlementRuleForm.billCutoffDay =
value === '固定截单日' ? this.settlementRuleForm.billCutoffDay || 25 : '';
},
validateSettlementRule(rule, label) {
if (Number(rule.autoGenerate) !== 1) return true;
if (!rule.billStartDate || !rule.settlementType) {
this.$message.warning(`${label}:请完整填写账单起始日期和结算类型`);
return false;
}
if (rule.settlementType === '月结' && !rule.billCycleType) {
this.$message.warning(`${label}:请选择账单周期类型`);
return false;
}
if (
rule.settlementType === '月结' &&
rule.billCycleType === '固定截单日' &&
!rule.billCutoffDay
) {
this.$message.warning(`${label}:请选择账单截单日`);
return false;
}
if (rule.settlementType === '固定天数周期结算' && !rule.cycleDays) {
this.$message.warning(`${label}:请选择周期天数`);
return false;
}
return true;
},
validateSettlementRules() {
this.syncSettlementConfig();
return (
this.validateSettlementRule(this.preSettlementRuleForm, '预结算配置') &&
this.validateSettlementRule(this.formalSettlementRuleForm, '正式结算配置')
);
},
addPaymentRatio() {
this.paymentRatioRows.push({
paymentTerm: `第${this.paymentRatioRows.length + 1}笔`,
ratioLimit: '',
remark: '',
});
},
validatePaymentRatios() {
const total = this.paymentRatioRows.reduce(
(sum, row) => sum + Number(row.ratioLimit || 0),
0
);
if (this.paymentRatioRows.length && Math.abs(total - 100) > 0.0001) {
this.$message.warning('付款比例上限合计必须等于100%');
return false;
}
return true;
},
ratioInput(row, value) {
let nextValue = String(value ?? '').replace(/[^\d.]/g, '');
const parts = nextValue.split('.');
if (parts.length > 2) nextValue = `${parts.shift()}.${parts.join('')}`;
const [integerPart, decimalPart] = nextValue.split('.');
nextValue =
decimalPart === undefined ? integerPart : `${integerPart}.${decimalPart.slice(0, 2)}`;
if (Number(nextValue) > 100) nextValue = '100';
row.ratioLimit = nextValue;
},
openDetail(row) {
this.detailBox = true;
this.detailLoading = true;
this.applyDetailState(row);
this.api
.getDetail(row.id)
.then(res => {
this.applyDetailState(res.data?.data || row);
})
.finally(() => {
this.detailLoading = false;
});
},
applyDetailState(detail = {}) {
this.detailRow = {
...detail,
copyCount: normalizeOptionalInteger(detail.copyCount),
paymentDays: normalizeOptionalInteger(detail.paymentDays),
};
this.detailContractFileRows = parseArray(detail.contractFileJson);
this.detailAttachmentRows = parseArray(detail.attachmentsJson);
this.detailBillingPlanRows = parseArray(detail.billingPlanJson);
this.detailPaymentRatioRows = parseArray(detail.paymentRatioJson);
this.detailChangeRecordRows = parseArray(detail.changeRecordJson);
const payload = parseObject(detail.settlementRuleJson);
const legacy = Object.keys(payload).some(
key => !['preSettlementConfig', 'formalSettlementConfig'].includes(key)
)
? payload
: {};
const preConfig = parseObject(detail.preSettlementConfigJson);
const formalConfig = parseObject(detail.formalSettlementConfigJson);
this.detailPreSettlementRuleForm = this.normalizeSettlementRule(
payload.preSettlementConfig || (Object.keys(preConfig).length ? preConfig : legacy)
);
this.detailFormalSettlementRuleForm = this.normalizeSettlementRule(
payload.formalSettlementConfig || (Object.keys(formalConfig).length ? formalConfig : legacy)
);
this.detailSettlementConfigTab = 'pre';
},
openDetailBillingPlan(row, index) {
this.detailBillingPlanForm = clone(row);
this.detailBillingPlanIndex = index;
this.detailBillingPlanBox = true;
},
displayValue(value) {
return value === undefined || value === null || value === '' ? '-' : value;
},
detailUnitValue(prop, unit) {
const value = this.detailRow[prop];
return value === undefined || value === null || value === '' || Number(value) < 0
? ''
: `${value}${unit}`;
},
detailObjectUnitValue(row, prop, unit) {
const value = row?.[prop];
return value === undefined || value === null || value === '' ? '-' : `${value}${unit}`;
},
detailValue(prop) {
const value = this.detailRow[prop];
if (
prop === 'contractStage' ||
prop === 'approvalStatus' ||
prop === 'contractCategory' ||
prop === 'signType' ||
prop === 'effectiveType'
) {
return this.displayStatus(this.detailRow, prop);
}
if (prop === 'billingPlanJson') return `${parseArray(value).length}个计费方案`;
if (prop === 'settlementRuleJson') return value ? '已配置' : '-';
if (prop === 'paymentRatioJson') return `${parseArray(value).length}项付款比例`;
if (prop === 'feeGenerationMode') return value === 'manual' ? '手动生成' : '系统生成';
return this.displayValue(value);
},
handleOperation(operation, row) {
if (operation.action === 'startChange') {
this.$router.push({ path: '/business/contract-manage/change', query: { id: row.id } });
return;
}
if (operation.action === 'flow') {
this.openFlow(row);
return;
}
if (operation.action === 'attachmentUpload') {
this.openForm('edit', row);
return;
}
const run = value => {
const args = operation.prompt ? [row.id, value] : [row.id];
this.api[operation.action](...args).then(() => {
this.$message.success(operation.successMessage || `${operation.label}成功`);
this.onLoad(this.page, this.query);
});
};
if (operation.prompt) {
this.$prompt(operation.prompt, operation.label, {
confirmButtonText: '确定',
cancelButtonText: '取消',
inputPattern: /\S+/,
inputErrorMessage: '请输入内容',
}).then(({ value }) => run(value));
} else {
this.$confirm(operation.confirmMessage || `确定${operation.label}该合同?`, '提示', {
type: 'warning',
}).then(() => run());
}
},
removeRow(row) {
this.$confirm('确定删除该合同?', '提示', { type: 'warning' })
.then(() => this.api.remove(row.id))
.then(() => {
this.$message.success('删除成功');
this.onLoad(this.page, this.query);
});
},
handleCopy(row) {
this.$confirm('确定复制该合同?', '提示', { type: 'warning' })
.then(() => this.api.copy(row.id))
.then(() => {
this.$message.success('复制成功');
this.onLoad(this.page, this.query);
});
},
openFlow(row, loaded = false) {
const processInstanceId = row.processInstanceId || row.processInstanceID || row.procInstId;
if (!processInstanceId && !loaded && row.id) {
this.api.getDetail(row.id).then(res => this.openFlow(res.data?.data || {}, true));
return;
}
if (!processInstanceId) {
this.$message.warning('流程实例为空,无法查看流程');
return;
}
if (this.website.design.designMode) this.processInstanceId = processInstanceId;
else
this.flowUrl = `/api/blade-flow/process/diagram-view?processInstanceId=${processInstanceId}`;
this.flowBox = true;
},
handleExport() {
this.$confirm(`是否导出${this.config.exportName}?`, '提示', { type: 'warning' }).then(() => {
NProgress.start();
exportBlob(
this.config.exportUrl,
{
...this.normalizeSearch(this.query),
ids: this.ids,
[this.website.tokenHeader]: getToken(),
},
{ feedback: true }
)
.then(res =>
downloadXls(
res.data,
`${this.config.exportName}${this.$dayjs().format('YYYY-MM-DD HH:mm:ss')}.xlsx`
)
)
.finally(() => NProgress.done());
});
},
},
};
</script>
<style lang="scss" scoped>
.contract-name-cell {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
vertical-align: bottom;
}
.contract-manage-page {
&__expiry-search {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-right: 8px;
}
// 将 #search-menu slot 容器(即"时间段快捷筛选"排到查询 / 重置 / 收起之前
// .avue-form__menu 本身就是 flex + flex-end,整组内容自然靠右,无需额外推挤
:deep(.avue-crud__search .avue-form__menu > .contract-manage-page__expiry-search) {
order: -1;
}
&__footer {
position: fixed;
z-index: 20;
right: 0;
bottom: 0;
left: 230px;
display: flex;
align-items: center;
justify-content: flex-end;
min-height: 64px;
box-sizing: border-box;
padding: 12px 24px;
background: #fff;
border-top: 1px solid #eff1f7;
box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.06);
// 统一 12px 间距,与全站底部操作栏(去 gap,由相邻按钮 margin 提供)一致
.el-button + .el-button {
margin-left: 12px;
}
}
&__flow {
width: 100%;
height: calc(100vh - 150px);
border: 0;
}
:deep(.avue-crud__header) {
margin-top: 12px;
}
:deep(.avue-crud__menu) {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
// 搜索区域白底卡片包裹
:deep(.avue-crud__search) {
background-color: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
:deep(.avue-crud__search:not(.el-card)) {
padding: 12px 12px 4px;
}
&--form {
min-height: 100%;
padding-bottom: 72px;
background: #f5f6fa;
:deep(.basic-container__card) {
border: 0;
background: transparent;
box-shadow: none;
}
}
}
:global(.avue--collapse .contract-manage-page__footer) {
left: 60px;
}
:global(.avue-layout--horizontal .contract-manage-page__footer) {
left: 0;
}
.contract-manage-form {
//padding: 12px 16px 24px;
background: #f5f6fa;
&__section {
margin-bottom: 12px;
&--panel {
padding: 14px 16px 16px;
overflow: hidden;
background: #fff;
border-radius: 6px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
}
> .dialog-section-title,
:deep(.dialog-section-title) {
margin-bottom: 20px;
color: #303133;
font-size: 16px;
font-weight: 600;
}
}
&__section-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 20px;
.dialog-section-title {
color: #303133;
font-size: 16px;
font-weight: 600;
}
}
&__grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
column-gap: 48px;
}
&__remark {
width: 100%;
margin-bottom: 0 !important;
}
&__date-range {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 8px;
width: 360px;
max-width: 100%;
span {
color: #606266;
}
:deep(.el-date-editor) {
width: auto;
min-width: 0;
}
}
&__billing-head,
&__attachment-head {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 16px;
}
&__settlement-switch {
display: flex;
align-items: center;
gap: 20px;
margin-bottom: 16px;
}
&__settlement-grid {
:deep(.el-form-item__label) {
flex-basis: 120px !important;
width: 120px !important;
white-space: nowrap;
word-break: normal;
}
}
&__billing-head > div {
display: flex;
align-items: center;
gap: 20px;
}
&__tip {
margin: 0 0 8px;
color: #303133;
}
&__ratio-unit {
margin-left: 8px;
}
:deep(.el-form-item) {
align-items: center;
margin-bottom: 14px;
}
:deep(.el-form-item__label) {
flex: 0 0 96px !important;
width: 96px !important;
height: auto;
padding-right: 12px;
white-space: normal;
word-break: break-all;
line-height: 18px;
}
:deep(.el-form-item__content) {
min-width: 0;
}
:deep(.el-input),
:deep(.el-select),
:deep(.el-cascader),
:deep(.el-tree-select),
:deep(.el-input-number),
:deep(.el-date-editor) {
width: 360px;
max-width: 100%;
}
:deep(.el-tabs__header) {
margin-bottom: 16px;
}
:deep(.el-table) {
--el-table-border-color: #eff1f7;
}
:deep(.el-table th.el-table__cell) {
height: 42px;
padding: 8px 0;
background: #fafafa;
}
:deep(.el-table td.el-table__cell) {
height: 52px;
padding: 8px 0;
}
:deep(.el-link + .el-link) {
margin-left: 8px;
}
:deep(.vehicle-attachment-upload) {
width: auto;
}
}
:deep(.contract-manage-form__attachment-head) {
display: flex;
align-items: center;
justify-content: flex-end;
margin-bottom: 16px;
}
:deep(.contract-manage-form__attachment-upload) {
display: flex;
justify-content: flex-start;
margin-top: 12px;
:deep(.vehicle-attachment-upload) {
display: flex;
align-items: center;
width: auto;
}
: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;
text-align: left;
}
}
.contract-manage-detail {
max-height: 74vh;
overflow-y: auto;
&__descriptions {
:deep(.el-descriptions__label) {
display: inline-block;
width: 96px;
min-width: 96px;
max-width: 96px;
box-sizing: border-box;
padding-right: 12px;
color: #606266;
line-height: 18px;
text-align: right;
white-space: normal;
word-break: break-all;
}
:deep(.el-descriptions__content) {
color: #303133;
word-break: break-word;
}
:deep(.el-descriptions__cell) {
padding-bottom: 16px;
}
}
}
@media (max-width: 1200px) {
.contract-manage-form__grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
column-gap: 32px;
}
}
@media (max-width: 768px) {
.contract-manage-form {
padding: 12px;
&__grid {
grid-template-columns: 1fr;
}
}
}
</style>
<style>
.el-dialog .el-form-item__label {
white-space: normal;
line-height: 1.2;
}
.contract-manage-form__tip {
color: #ff0000 !important;
font-size: 12px;
}
</style>