审批完成的合同,在合同签署界面进行在线签署。合同发起界面不能在线签署。

This commit is contained in:
2026-07-09 15:12:34 +08:00
parent e03e019f58
commit 6f20fec8de
4 changed files with 855 additions and 26 deletions
+6 -3
View File
@@ -228,7 +228,7 @@
<a v-if="canMockReject(row)" @click="handleMockReject(row)">{{
isSingleAgreementRecord(row) ? "审批驳回" : "测试驳回"
}}</a>
<a v-if="canOnlineSign(row)" @click="handleOnlineSign(row)">{{
<a v-if="SHOW_SIGN_ACTIONS_IN_LAUNCH && canOnlineSign(row)" @click="handleOnlineSign(row)">{{
isSingleAgreementRecord(row) ? "发起盖章" : "在线签订"
}}</a>
<a
@@ -236,11 +236,11 @@
@click="handleDownloadSigned(row)"
>下载签署文件</a
>
<a v-if="canUploadSigned(row)" @click="handleUploadSigned(row)">{{
<a v-if="SHOW_SIGN_ACTIONS_IN_LAUNCH && canUploadSigned(row)" @click="handleUploadSigned(row)">{{
isSingleAgreementRecord(row) ? "上传盖章版" : "上传已签署版本"
}}</a>
<a
v-if="canMockFinishSign(row)"
v-if="SHOW_SIGN_ACTIONS_IN_LAUNCH && canMockFinishSign(row)"
@click="handleMockFinishSign(row)"
>{{
isSingleAgreementRecord(row) ? "测试盖章完成" : "测试签署完成"
@@ -346,6 +346,9 @@ const contractRecordStatusOptions = [
{ label: "已终止", value: "terminated" },
];
// 合同签署已拆到独立菜单,这里只隐藏入口,保留原有逻辑方便后续恢复。
const SHOW_SIGN_ACTIONS_IN_LAUNCH = false;
const formRef = ref(null);
const queryContainer = ref(null);
const pageWrapperRef = ref(null);
+826
View File
@@ -0,0 +1,826 @@
<template>
<section class="section-container">
<a-card
:bordered="false"
class="query-card"
:body-style="queryCardBodyStyle"
>
<div ref="queryContainer" class="section-query-container">
<a-form :model="queryParam" layout="inline">
<a-row :gutter="16" style="width: 100%">
<a-col :md="6" :sm="24">
<a-form-item label="合同名称">
<a-input
v-model:value="queryParam.contractName"
allow-clear
placeholder="请输入合同名称"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="合同编码">
<a-input
v-model:value="queryParam.contractCode"
allow-clear
placeholder="请输入合同编码"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="合同类型">
<a-select
v-model:value="queryParam.contractType"
allow-clear
placeholder="请选择合同类型"
:options="contractTypeOptions"
/>
</a-form-item>
</a-col>
<a-col :md="6" :sm="24">
<a-form-item label="签署状态">
<a-select
v-model:value="queryParam.status"
allow-clear
placeholder="全部签署阶段"
:options="signStatusOptions"
/>
</a-form-item>
</a-col>
<a-col :md="12" :sm="24">
<a-form-item label="发起日期">
<a-range-picker
v-model:value="queryParam.dateRange"
value-format="YYYY-MM-DD"
style="width: 100%"
/>
</a-form-item>
</a-col>
</a-row>
</a-form>
</div>
</a-card>
<a-card
:bordered="false"
class="table-card"
:body-style="tableCardBodyStyle"
>
<vxe-toolbar ref="toolbarRef" custom>
<template #buttons>
<a-space wrap>
<a-button type="primary" :icon="h(SearchOutlined)" @click="handleQuery"
>查询</a-button
>
<a-button :icon="h(RedoOutlined)" @click="handleReset">重置</a-button>
</a-space>
</template>
</vxe-toolbar>
<vxe-table
ref="tableRef"
border
:height="tableHeight"
:row-config="{ isHover: true, height: 44 }"
:column-config="{ resizable: true, minWidth: 120 }"
:custom-config="{ storage: true }"
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
:data="tableData"
:loading="loading"
:loading-config="{
icon: 'vxe-icon-indicator roll',
text: '正在拼命加载中...',
}"
>
<vxe-column type="seq" title="序号" width="68" />
<vxe-column
field="contractCode"
title="合同编码"
width="160"
show-overflow="title"
/>
<vxe-column
field="lawContractCode"
title="法务合同编号"
width="160"
show-overflow="title"
/>
<vxe-column
field="contractName"
title="合同名称"
width="220"
show-overflow="title"
/>
<vxe-column
field="contractType"
title="合同类型"
width="160"
show-overflow="title"
>
<template #default="{ row }">
{{ getContractTypeSimpleLabel(row.contractTypeCode || row.contractType) }}
</template>
</vxe-column>
<vxe-column field="isElectron" title="签署方式" width="120">
<template #default="{ row }">
<a-tag :color="String(row.isElectron || '1') === '1' ? 'processing' : 'default'">
{{ String(row.isElectron || '1') === '1' ? '电子合同' : '纸质合同' }}
</a-tag>
</template>
</vxe-column>
<vxe-column
field="counterpartySummary"
title="合同相对方"
width="260"
show-overflow="title"
/>
<vxe-column field="status" title="状态" width="120">
<template #default="{ row }">
<a-tag :color="getStatusColor(row.status)">{{ getStatusLabelByRow(row) }}</a-tag>
</template>
</vxe-column>
<vxe-column
field="approveMessage"
title="审批说明"
width="220"
show-overflow="title"
/>
<vxe-column
field="initiator"
title="发起人"
width="100"
show-overflow="title"
/>
<vxe-column
field="initiatorDept"
title="所属部门"
width="140"
show-overflow="title"
/>
<vxe-column
field="createTime"
title="发起时间"
width="170"
show-overflow="title"
/>
<vxe-column
field="updateTime"
title="最后更新时间"
width="170"
show-overflow="title"
/>
<vxe-column title="操作" width="660" fixed="right">
<template #default="{ row }">
<div class="table-actions">
<a @click="handleView(row)">查看</a>
<a v-if="canViewApprove(row)" @click="handleViewApprove(row)">审批记录</a>
<a v-if="canViewLawCallback(row)" @click="handleViewLawCallback(row)">回调data</a>
<a v-if="canOnlineSign(row)" @click="handleOnlineSign(row)">
{{ isSingleAgreementRecord(row) ? '发起盖章' : '在线签订' }}
</a>
<a v-if="canUploadSigned(row)" @click="handleUploadSigned(row)">
{{ isSingleAgreementRecord(row) ? '上传盖章版' : '上传已签署版本' }}
</a>
<a v-if="canMockFinishSign(row)" @click="handleMockFinishSign(row)">
{{ isSingleAgreementRecord(row) ? '测试盖章完成' : '测试签署完成' }}
</a>
<a v-if="canDownloadSigned(row)" @click="handleDownloadSigned(row)">下载签署文件</a>
</div>
</template>
</vxe-column>
</vxe-table>
<a-pagination
id="pageWrapper"
ref="pageWrapperRef"
v-model:pageSize="queryParam.pageSize"
v-model:current="queryParam.pageNo"
class="table-pagination"
show-size-changer
:page-size-options="['10', '20', '50', '100']"
:total="total"
:show-total="(value) => `${value}`"
@change="onShowSizeChange"
@show-size-change="onShowSizeChange"
/>
</a-card>
<ContractPartyPlaceholderModal ref="contractModalRef" />
<MixedSignModal
ref="mixedSignModalRef"
@business-updated="handleSignBusinessUpdated"
/>
<UploadSignedModal
ref="uploadSignedModalRef"
@done="handleSignBusinessUpdated"
/>
</section>
</template>
<script setup>
import { h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { RedoOutlined, SearchOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
import ContractPartyPlaceholderModal from '../launch/components/ContractPartyPlaceholderModal.vue'
import MixedSignModal from '../launch/components/mixedSignModal.vue'
import UploadSignedModal from '../launch/components/UploadSignedModal.vue'
import {
contractTypeOptions,
getContractTypeSimpleLabel,
} from '../launch/contractLaunchOptions.js'
import {
downloadLaunchFile,
finishLaunchSeal,
getLaunchDetail,
getLawApproveCallbackSample,
queryApproveViewUrl,
queryLaunchList,
uploadWatermarkFile,
} from '@/api/launch.js'
const SIGN_STAGE_STATUSES = ['pending_sign', 'signing']
const SIGN_LIST_QUERY_PAGE_SIZE = 200
const signStatusOptions = [
{ label: '待签署', value: 'pending_sign' },
{ label: '签署中', value: 'signing' },
]
const contractModalRef = ref(null)
const mixedSignModalRef = ref(null)
const uploadSignedModalRef = ref(null)
const queryContainer = ref(null)
const pageWrapperRef = ref(null)
const tableRef = ref(null)
const toolbarRef = ref(null)
const loading = ref(false)
const total = ref(0)
const tableHeight = ref(420)
const tableData = ref([])
const allRecords = ref([])
const queryParam = ref({
pageNo: 1,
pageSize: 10,
contractName: undefined,
contractCode: undefined,
contractType: undefined,
status: undefined,
dateRange: [],
})
const queryCardBodyStyle = {
padding: '20px 24px 6px',
}
const tableCardBodyStyle = {
display: 'flex',
flexFlow: 'column nowrap',
minHeight: '0',
padding: '8px 12px 6px',
}
const statusLabelMap = {
pending_sign: '待签署',
signing: '签署中',
pending_collect: '待采集',
pending_archive: '待归档',
completed: '已完成',
}
const statusColorMap = {
pending_sign: 'warning',
signing: 'processing',
pending_collect: 'cyan',
pending_archive: 'orange',
completed: 'success',
}
function isSuccessResponse(res) {
return String(res?.code || '') === '0000'
}
function getPageResult(res) {
return res?.result || {}
}
function getDataResult(res) {
return res?.result || null
}
function getResponseMessage(res, fallback = '操作失败') {
return res?.message || res?.msg || fallback
}
function sliceDateValue(value) {
return value ? String(value).slice(0, 10) : ''
}
function todayString() {
const now = new Date()
const month = String(now.getMonth() + 1).padStart(2, '0')
const day = String(now.getDate()).padStart(2, '0')
return `${now.getFullYear()}-${month}-${day}`
}
function formatDateTimeValue(value) {
return value ? `${value} 00:00:00` : null
}
function connectToolbar() {
const table = tableRef.value
const toolbar = toolbarRef.value
if (table && toolbar && table.connect) {
table.connect(toolbar)
}
}
function getStatusLabel(status) {
return statusLabelMap[status] || status
}
function getStatusColor(status) {
return statusColorMap[status] || 'default'
}
function getStatusLabelByRow(row) {
if (isSingleAgreementRecord(row) && row?.status === 'pending_sign') {
return '待盖章'
}
return getStatusLabel(row?.status)
}
function isSingleAgreementRecord(row) {
return String(row?.isSingleAgreement || '0') === '1'
}
function canOnlineSign(row) {
return (
!isSingleAgreementRecord(row) &&
String(row.isElectron || '1') === '1' &&
SIGN_STAGE_STATUSES.includes(row.status)
)
}
function canUploadSigned(row) {
return String(row.isElectron || '1') === '0' && SIGN_STAGE_STATUSES.includes(row.status)
}
function canMockFinishSign(row) {
if (isSingleAgreementRecord(row)) {
return row.status === 'pending_sign'
}
return String(row.isElectron || '1') === '1' && SIGN_STAGE_STATUSES.includes(row.status)
}
function canDownloadSigned(row) {
return Boolean(row.signedFilePath)
}
function canViewApprove(row) {
return !isSingleAgreementRecord(row) && Boolean(row.lawContractId)
}
function canViewLawCallback(row) {
return !isSingleAgreementRecord(row)
}
function buildQueryPayload(status, pageNo = 1, pageSize = SIGN_LIST_QUERY_PAGE_SIZE) {
return {
pageNo,
pageSize,
contractName: queryParam.value.contractName,
contractCode: queryParam.value.contractCode,
contractType: queryParam.value.contractType,
status,
dateStart: queryParam.value.dateRange?.[0],
dateEnd: queryParam.value.dateRange?.[1],
}
}
async function fetchRecordsByStatus(status) {
let pageNo = 1
let totalCount = 0
const records = []
while (true) {
const res = await queryLaunchList(buildQueryPayload(status, pageNo))
if (!isSuccessResponse(res)) {
throw new Error(getResponseMessage(res, '合同签署列表查询失败'))
}
const pageResult = getPageResult(res)
const data = Array.isArray(pageResult?.data) ? pageResult.data : []
totalCount = Number(pageResult?.totalSize ?? pageResult?.total ?? 0)
records.push(...data)
if (!data.length) {
break
}
if (totalCount > 0 && records.length >= totalCount) {
break
}
if (data.length < SIGN_LIST_QUERY_PAGE_SIZE) {
break
}
pageNo += 1
}
return records
}
function compareRecord(a, b) {
const aTime = new Date(a?.createTime || a?.updateTime || 0).getTime()
const bTime = new Date(b?.createTime || b?.updateTime || 0).getTime()
if (aTime !== bTime) {
return bTime - aTime
}
return Number(b?.id || 0) - Number(a?.id || 0)
}
function refreshTableData() {
total.value = allRecords.value.length
const pageNo = Number(queryParam.value.pageNo || 1)
const pageSize = Number(queryParam.value.pageSize || 10)
const startIndex = (pageNo - 1) * pageSize
tableData.value = allRecords.value.slice(startIndex, startIndex + pageSize)
}
async function getList() {
loading.value = true
try {
const statuses = queryParam.value.status ? [queryParam.value.status] : SIGN_STAGE_STATUSES
const mergedMap = new Map()
for (const status of statuses) {
const records = await fetchRecordsByStatus(status)
records.forEach((item) => {
if (!mergedMap.has(item.id)) {
mergedMap.set(item.id, item)
}
})
}
allRecords.value = Array.from(mergedMap.values()).sort(compareRecord)
refreshTableData()
} catch (error) {
tableData.value = []
allRecords.value = []
total.value = 0
message.error(error?.message || '合同签署列表查询失败')
} finally {
loading.value = false
}
}
function handleQuery() {
queryParam.value.pageNo = 1
getList()
}
function handleReset() {
queryParam.value = {
pageNo: 1,
pageSize: 10,
contractName: undefined,
contractCode: undefined,
contractType: undefined,
status: undefined,
dateRange: [],
}
getList()
}
function onShowSizeChange(current, pageSize) {
queryParam.value.pageNo = current
queryParam.value.pageSize = pageSize
refreshTableData()
}
function handleView(row) {
getLaunchDetail(row.id).then((res) => {
if (!isSuccessResponse(res) || !getDataResult(res)) {
message.error(getResponseMessage(res, '获取合同详情失败'))
return
}
contractModalRef.value?.showModal(getDataResult(res), 'view')
})
}
function handleViewApprove(row) {
if (!row.lawContractId) {
message.warning('当前暂无法务合同ID,审批记录地址尚未生成')
return
}
queryApproveViewUrl({
contractId: String(row.lawContractId),
ivUser: row.initiator || 'admin',
}).then((res) => {
if (!isSuccessResponse(res)) {
message.error(getResponseMessage(res, '获取审批记录查看地址失败'))
return
}
const approveViewUrl = res?.result?.approveViewUrl
if (!approveViewUrl) {
message.warning('审批记录地址为空')
return
}
window.open(approveViewUrl, '_blank')
})
}
function buildLawCallbackModalContent(sample) {
const approvedJson = JSON.stringify({ data: sample?.approvedData || {} }, null, 2)
const rejectedJson = JSON.stringify({ data: sample?.rejectedData || {} }, null, 2)
return h(
'div',
{ style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
[
h('div', [
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '回调地址'),
h(
'div',
{ style: { color: '#666', wordBreak: 'break-all' } },
`${sample?.callbackMethod || 'POST'} ${sample?.callbackUrl || '/contract/launch/callback/law/approve'}`,
),
]),
h('div', [
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '审批通过 data'),
h(
'pre',
{
style: {
margin: 0,
maxHeight: '260px',
overflow: 'auto',
padding: '12px',
borderRadius: '8px',
background: '#f6f8fa',
fontSize: '12px',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
},
approvedJson,
),
]),
h('div', [
h('div', { style: { marginBottom: '6px', fontWeight: 600 } }, '审批驳回 data'),
h(
'pre',
{
style: {
margin: 0,
maxHeight: '220px',
overflow: 'auto',
padding: '12px',
borderRadius: '8px',
background: '#f6f8fa',
fontSize: '12px',
lineHeight: '1.6',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
},
},
rejectedJson,
),
]),
],
)
}
function handleViewLawCallback(row) {
getLawApproveCallbackSample(row.id).then((res) => {
if (!isSuccessResponse(res) || !getDataResult(res)) {
message.error(getResponseMessage(res, '获取法务回调样例失败'))
return
}
const sample = getDataResult(res)
Modal.info({
title: `法务回调 data 结构:${row.contractName}`,
width: 900,
okText: '知道了',
content: buildLawCallbackModalContent(sample),
})
})
}
function handleMockFinishSign(row) {
const singleAgreement = isSingleAgreementRecord(row)
Modal.confirm({
title: singleAgreement ? '测试操作:模拟盖章完成' : '测试操作:模拟签署完成',
content: singleAgreement
? `模拟单方协议【${row.contractName}】内部业务章加盖完成,状态将变为「已完成」。`
: `模拟合同【${row.contractName}】签署完成,状态将变为「待采集」。`,
okText: '确认模拟',
cancelText: '取消',
onOk: () => {
const signDate = sliceDateValue(row.signDate) || todayString()
const sealDate = sliceDateValue(row.sealDate) || signDate
const activeDate =
sliceDateValue(row.activeDate) || sliceDateValue(row.effectiveStart) || signDate
return finishLaunchSeal(row.id, {
sealContractId: row.sealContractId || `TEST-SEAL-${row.id}`,
sealContractStatus: 2000,
signedFileName: `${row.contractName || '合同'}-测试已签署.pdf`,
signedFilePath: row.contractTextFilePath || `mock-signed/${row.id}.pdf`,
signedFileType: 'pdf',
signedFileSize: row.contractTextFileSize || null,
signedOfflineReason: singleAgreement
? '测试按钮模拟内部业务章加盖完成'
: '测试按钮模拟签署完成,真实签章接入后可删除此按钮',
signDate: formatDateTimeValue(signDate),
sealDate: formatDateTimeValue(sealDate),
activeDate: formatDateTimeValue(activeDate),
}).then((res) => {
if (!isSuccessResponse(res)) {
message.error(
getResponseMessage(res, singleAgreement ? '测试盖章完成失败' : '测试签署完成失败'),
)
return
}
message.success(
singleAgreement ? '测试盖章完成,单方协议已办结' : '测试签署完成,合同已流转到待采集',
)
getList()
})
},
icon: null,
})
}
function handleDownloadSigned(row) {
const filePath = row.signedFilePath
const fileName = row.signedFileName || `${row.contractName || '合同'}-已签署版.pdf`
if (!filePath) {
message.warning('暂无签署文件可下载')
return
}
downloadLaunchFile(filePath)
.then((res) => {
const arrayBuffer = res?.data ?? res
const blob = new Blob([arrayBuffer])
const url = window.URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = fileName
document.body.appendChild(link)
link.click()
document.body.removeChild(link)
window.URL.revokeObjectURL(url)
})
.catch(() => {
message.error('签署文件下载失败')
})
}
function handleUploadSigned(row) {
uploadSignedModalRef.value?.showModal(row)
}
function handleOnlineSign(row) {
getLaunchDetail(row.id).then(async (res) => {
if (!isSuccessResponse(res) || !getDataResult(res)) {
message.error(getResponseMessage(res, '获取合同详情失败'))
return
}
const detail = getDataResult(res)
if (!detail.contractTextFilePath) {
await handleWatermarkUpload(row)
return
}
mixedSignModalRef.value?.showModal({
...detail,
contractId: detail.sealContractId,
})
})
}
async function handleWatermarkUpload(row) {
let file
const ok = await new Promise((resolve) => {
Modal.confirm({
title: '法务水印文件缺失',
content: h('div', [
h(
'p',
{ style: 'margin-bottom: 12px' },
'法务审批回调未带回带水印的合同文件,请手动上传带水印的合同文件后继续在线签订。',
),
h('input', {
type: 'file',
accept: '.pdf,.doc,.docx',
style: 'margin-top: 8px; width: 100%',
onChange(e) {
file = e.target.files?.[0]
},
}),
]),
okText: '上传并继续',
cancelText: '取消',
async onOk() {
if (!file) {
message.warning('请先选择文件')
return false
}
const formData = new FormData()
formData.append('file', file)
try {
const res = await uploadWatermarkFile(row.id, formData)
if (!isSuccessResponse(res)) {
message.error(getResponseMessage(res, '上传水印文件失败'))
return false
}
message.success('水印文件上传成功')
resolve(true)
} catch {
message.error('上传水印文件失败')
return false
}
},
onCancel() {
resolve(false)
},
})
})
if (!ok) return
getLaunchDetail(row.id).then((res) => {
if (!isSuccessResponse(res) || !getDataResult(res)) return
const detail = getDataResult(res)
mixedSignModalRef.value?.showModal({
...detail,
contractId: detail.sealContractId,
})
})
}
function handleSignBusinessUpdated() {
getList()
}
function syncTableHeight() {
nextTick(() => {
const viewportHeight = window.innerHeight
const queryHeight = queryContainer.value?.offsetHeight || 0
const pageHeight = pageWrapperRef.value?.$el?.offsetHeight || 56
tableHeight.value = Math.max(320, viewportHeight - queryHeight - pageHeight - 320)
})
}
onMounted(() => {
handleQuery()
nextTick(() => {
connectToolbar()
syncTableHeight()
})
window.addEventListener('resize', syncTableHeight)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', syncTableHeight)
})
</script>
<style scoped>
.section-container {
height: calc(100vh - 116px);
display: flex;
flex-direction: column;
gap: 12px;
}
.query-card {
flex: 0 0 auto;
}
.table-card {
flex: 1 1 auto;
min-height: 0;
}
.section-query-container :deep(.ant-form-item) {
display: flex;
width: 100%;
margin-bottom: 14px;
}
.section-query-container :deep(.ant-form-item .ant-form-item-control) {
flex: 1;
}
.table-actions {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.table-pagination {
display: flex;
justify-content: flex-end;
padding: 12px 4px 2px;
}
@media (max-width: 960px) {
.section-container {
height: auto;
}
}
</style>