Files
stamp-ant/src/views/contract/launch/components/SealPositionSignModal.vue
T
2026-07-10 17:03:55 +08:00

1439 lines
41 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>
<a-modal
v-model:open="visible"
:title="modalTitle"
width="96%"
:footer="null"
:mask-closable="false"
:destroy-on-close="false"
wrap-class-name="seal-position-sign-wrap"
:bodyStyle="{ padding: 0, height: 'calc(100vh - 180px)', overflow: 'hidden' }"
>
<div class="seal-position-sign-layout">
<div ref="previewPaneRef" class="preview-pane">
<div class="preview-toolbar">
<a-space wrap>
<a-tag :color="pageState.sealContractId ? 'processing' : 'default'">
签章平台合同ID{{ pageState.sealContractId || '未初始化' }}
</a-tag>
<a-tag color="blue">我的可签方{{ activePartyLabel }}</a-tag>
<a-tag color="gold">必签未完成{{ currentRequiredMissingCount }}</a-tag>
<a-button :loading="loadingPage" @click="loadPageState(true)">刷新状态</a-button>
</a-space>
</div>
<div class="preview-scroll">
<div
v-for="(imgUrl, pageIndex) in pdfImages"
:key="`page_${pageIndex}`"
:ref="(el) => setPageRef(el, pageIndex + 1)"
class="pdf-page-card"
:class="{ 'is-active': currentPageNum === pageIndex + 1 }"
@click="currentPageNum = pageIndex + 1"
>
<div class="page-card-header">
<span> {{ pageIndex + 1 }} </span>
<a-space size="small">
<a-tag color="processing">{{ getPageBoxCount(pageIndex + 1) }} 个签章位</a-tag>
<a-tag v-if="currentPageNum === pageIndex + 1" color="success">当前页</a-tag>
</a-space>
</div>
<div
class="pdf-page-shell"
:style="{ width: `${pageDisplayWidth}px`, height: `${pageDisplayHeight}px` }"
>
<img
:src="imgUrl"
class="pdf-page-image"
:style="{ width: `${pageDisplayWidth}px`, height: `${pageDisplayHeight}px` }"
draggable="false"
/>
<div
v-for="box in getPageBoxes(pageIndex + 1)"
:key="box.id"
class="seal-position-box"
:class="getBoxClass(box)"
:style="getBoxStyle(box)"
@click.stop="focusBox(box.id, box.pageNum)"
@dragover.prevent="handleBoxDragOver(box)"
@drop.prevent="handleDropSealOnBox($event, box)"
>
<div class="box-head">
<span class="box-title">{{ box.label || '未命名签章位' }}</span>
<a-tag v-if="box.required === 'Y'" color="red">必签</a-tag>
</div>
<div class="box-party">{{ getPartyLabel(box.partyId) }}</div>
<div class="box-status">{{ getStatusText(box.signedStatus) }}</div>
<div v-if="box.sealName" class="box-seal-name">印章{{ box.sealName }}</div>
<div v-if="box.signedByName" class="box-signed-by">签署人{{ box.signedByName }}</div>
<div
v-if="isBoxMine(box) && selectedSealMap[box.id]?.pictureUrl"
class="box-seal-preview"
:style="getSealPreviewStyle(box)"
>
<img :src="selectedSealMap[box.id].pictureUrl" class="box-seal-image" />
</div>
<div v-if="box.id === focusedBoxId" class="box-focus-ring" />
</div>
</div>
</div>
<a-empty
v-if="!loadingPage && !pdfImages.length"
class="empty-preview"
description="暂无签署预览,请先由管理端配置签章位"
/>
</div>
</div>
<div class="operation-pane">
<div class="side-card">
<div class="side-card-title">签署信息</div>
<div class="meta-item">
<span>合同名称</span>
<strong>{{ contractDetail?.contractName || '-' }}</strong>
</div>
<div class="meta-item">
<span>签署状态</span>
<strong>{{ contractDetail?.status || '-' }}</strong>
</div>
<div class="meta-item">
<span>当前签署方</span>
<strong>{{ activePartyLabel }}</strong>
</div>
<a-form-item v-if="partySelectorVisible" label="切换代表签署方" class="party-selector-item">
<a-select
v-model:value="selectedPartyId"
:options="availablePartyOptions"
placeholder="请选择当前代表的签署方"
@change="handlePartyChanged"
/>
</a-form-item>
<div class="meta-note">
本页面采用预设框内盖章”。只能对属于当前签署方的框放章放章后系统会吸附到这个框不允许在其他位置自由拖盖
</div>
</div>
<div class="side-card">
<div class="side-card-title">获取印章</div>
<div class="query-grid">
<a-form-item label="姓名">
<a-input v-model:value="queryName" disabled />
</a-form-item>
<a-form-item label="手机号">
<a-input v-model:value="queryMobile" disabled />
</a-form-item>
<a-form-item label="公司名称">
<a-input v-model:value="queryEntName" allow-clear placeholder="可选,筛选印章所属公司" />
</a-form-item>
</div>
<div class="seal-type-wrap">
<a-checkbox-group v-model:value="querySealTypes">
<a-row :gutter="8">
<a-col v-for="type in sealTypeOptions" :key="type.value" :span="12">
<a-checkbox :value="type.value">{{ type.label }}</a-checkbox>
</a-col>
</a-row>
</a-checkbox-group>
</div>
<a-button type="primary" block :loading="fetchingSeals" @click="fetchSeals">获取印章</a-button>
</div>
<div class="side-card side-card-grow">
<div class="side-card-title">拖动印章到待签框</div>
<div v-if="sealList.length" class="seal-list">
<div
v-for="seal in sealList"
:key="seal.sealId"
class="seal-card"
draggable="true"
@dragstart="handleSealDragStart($event, seal)"
>
<img :src="seal.pictureUrl" class="seal-thumb" />
<div class="seal-name">{{ seal.sealName }}</div>
</div>
</div>
<a-empty v-else description="请先获取印章" />
</div>
<div class="side-card">
<div class="side-card-title">当前签章处理</div>
<div class="pending-list">
<div
v-for="box in myActionBoxes"
:key="box.id"
class="pending-item"
@click="focusBox(box.id, box.pageNum)"
>
<div>
<div class="pending-title">{{ box.label || '未命名签章位' }}</div>
<div class="pending-sub"> {{ box.pageNum }} · {{ getStatusText(box.signedStatus) }} · {{ box.sealName || '未选择印章' }}</div>
</div>
<a-button
v-if="Number(box.signedStatus) === STATUS_SELECTED"
type="link"
danger
size="small"
@click.stop="clearBoxSelection(box)"
>
移除
</a-button>
</div>
</div>
</div>
<div class="footer-actions">
<a-space direction="vertical" style="width: 100%" size="middle">
<a-button type="primary" block :loading="preSigning" @click="handlePreSignSubmit">
确认预盖章
</a-button>
<a-button block :loading="checkingComplete" @click="handleCompleteSigning">
签署完成
</a-button>
</a-space>
</div>
</div>
</div>
<a-modal
v-model:open="qrVisible"
title="扫码确认预盖章"
width="520px"
:footer="null"
:mask-closable="false"
>
<div class="qr-body">
<img v-if="qrCode" :src="qrCode" class="qr-image" />
<a-empty v-else description="二维码未生成" />
<div class="qr-meta">requestId{{ qrRequestId || '-' }}</div>
<div class="qr-meta">剩余时间{{ qrText }}</div>
<div class="qr-actions">
<a-space wrap>
<a-button :loading="mockingCallback" type="primary" @click="handleMockSuccess">
扫码成功模拟
</a-button>
<a-button @click="handleCloseQr">关闭</a-button>
</a-space>
</div>
</div>
</a-modal>
</a-modal>
</template>
<script setup>
import { computed, nextTick, onBeforeUnmount, onMounted, reactive, ref } from 'vue'
import { message } from 'ant-design-vue'
import {
batchUpdateLaunchSealPositionStatus,
checkLaunchSealPositions,
finishLaunchSeal,
getLaunchSealPositionPage,
} from '@/api/launch'
import {
completeContract,
downloadContract,
getPreSignQrCode,
getSealBySignerId,
getUserUniqueId,
mockPreSignSuccessCallback,
pollCallback,
} from '@/api/seal'
const emit = defineEmits(['done'])
const visible = ref(false)
const loadingPage = ref(false)
const fetchingSeals = ref(false)
const preSigning = ref(false)
const checkingComplete = ref(false)
const mockingCallback = ref(false)
const previewPaneRef = ref(null)
const pageRefs = reactive({})
const contractDetail = ref(null)
const pageState = reactive({
sealContractId: '',
pdfToImageList: [],
positions: [],
availablePartyIds: [],
preferredPartyId: null,
imageWidth: 793,
imageHeight: 1122,
})
const DEFAULT_IMAGE_WIDTH = 793
const DEFAULT_IMAGE_HEIGHT = 1122
const CALLBACK_HOST = 'http://47.110.50.12:7005'
const DEFAULT_SEAL_BASE_SIZE = 170
const DEFAULT_SEAL_PADDING_RATIO = 0.82
const STATUS_EMPTY = 0
const STATUS_SELECTED = 1
const STATUS_SIGNING = 2
const STATUS_SIGNED = 3
const STATUS_FAILED = 4
const queryName = ref('周小锋')
const queryMobile = ref('17681520782')
const queryEntName = ref('')
const querySealTypes = ref([0, 20, 10, 30, 40, 50, 60])
const sealList = ref([])
const selectedPartyId = ref(null)
const focusedBoxId = ref(null)
const currentPageNum = ref(1)
const pageDisplayWidth = ref(760)
const qrVisible = ref(false)
const qrCode = ref('')
const qrRequestId = ref('')
const qrExpiresIn = ref(300)
const qrCount = ref(300)
const currentSignerId = ref('')
let dragSealData = null
let qrTimer = null
let pollTimer = null
const selectedSealMap = reactive({})
const sealTypeOptions = [
{ label: '个人章', value: 0 },
{ label: '公章', value: 20 },
{ label: '法人章', value: 10 },
{ label: '财务章', value: 30 },
{ label: '合同章', value: 40 },
{ label: '发票章', value: 50 },
{ label: '其他章', value: 60 },
]
const modalTitle = computed(() => {
const name = contractDetail.value?.contractName || '合同'
return `在线签订 - ${name}`
})
const pdfImages = computed(() => normalizePdfImages(pageState.pdfToImageList))
const imageWidth = computed(() => Number(pageState.imageWidth || DEFAULT_IMAGE_WIDTH))
const imageHeight = computed(() => Number(pageState.imageHeight || DEFAULT_IMAGE_HEIGHT))
const scaleRatio = computed(() => pageDisplayWidth.value / imageWidth.value)
const pageDisplayHeight = computed(() => Math.round(imageHeight.value * scaleRatio.value))
const availablePartyOptions = computed(() => {
const sourcePartyIds = [...new Set((pageState.availablePartyIds || [])
.map((item) => Number(item))
.filter((item) => !Number.isNaN(item)))]
return sourcePartyIds
.map((partyId) => {
const party = findPartyById(partyId)
if (!party) return null
return {
value: party.id,
label: `${party.roleName || party.roleCode || '签署方'} - ${party.companyName || party.subjectCode || party.id}`,
}
})
.filter(Boolean)
})
const partySelectorVisible = computed(() => availablePartyOptions.value.length !== 1)
const activePartyLabel = computed(() => {
if (!selectedPartyId.value) {
return availablePartyOptions.value.length ? '请选择签署方' : '当前用户未匹配到可签署方'
}
return getPartyLabel(selectedPartyId.value)
})
const myBoxes = computed(() =>
pageState.positions.filter((item) => Number(item.partyId) === Number(selectedPartyId.value)),
)
const currentRequiredMissingCount = computed(() =>
myBoxes.value.filter((item) => isRequiredBoxUnfinished(item)).length,
)
const myPendingSubmitBoxes = computed(() =>
myBoxes.value.filter((item) => Number(item.signedStatus) === STATUS_SELECTED && !!selectedSealMap[item.id]),
)
const myActionBoxes = computed(() =>
myBoxes.value.filter((item) => {
const status = Number(item.signedStatus)
return status === STATUS_SELECTED || status === STATUS_SIGNING
}),
)
const qrText = computed(() => {
const minute = Math.floor(qrCount.value / 60)
const second = qrCount.value % 60
return `${minute}${String(second).padStart(2, '0')}秒`
})
function isSuccessResponse(res) {
return String(res?.code || '') === '0000'
}
function getResult(res) {
return res?.result || null
}
function normalizePdfImages(list = []) {
return (Array.isArray(list) ? list : [])
.map((item) => {
if (typeof item === 'string') return item
if (!item || typeof item !== 'object') return ''
return item.url || item.imageUrl || item.previewUrl || item.fileUrl || item.path || ''
})
.filter(Boolean)
}
function roundValue(value) {
return Math.round(Number(value || 0) * 100) / 100
}
function wait(ms) {
return new Promise((resolve) => {
window.setTimeout(resolve, ms)
})
}
function findPartyById(partyId) {
return (contractDetail.value?.partyList || []).find((item) => Number(item.id) === Number(partyId)) || null
}
function getPartyLabel(partyId) {
const party = findPartyById(partyId)
if (!party) {
return '未识别签署方'
}
return `${party.roleName || party.roleCode || '签署方'} - ${party.companyName || party.subjectCode || party.id}`
}
function getStatusText(status) {
const value = Number(status)
if (value === STATUS_SELECTED) return '已选章待提交'
if (value === STATUS_SIGNING) return '待扫码确认'
if (value === STATUS_SIGNED) return '已签署'
if (value === STATUS_FAILED) return '失败待重试'
return '待签署'
}
function isBoxMine(box) {
return Number(box.partyId) === Number(selectedPartyId.value)
}
function getPageBoxes(pageNum) {
return pageState.positions.filter((item) => Number(item.pageNum) === Number(pageNum))
}
function getPageBoxCount(pageNum) {
return getPageBoxes(pageNum).length
}
function getBoxClass(box) {
return {
'is-mine': isBoxMine(box),
'is-other': !isBoxMine(box),
'is-signed': Number(box.signedStatus) === STATUS_SIGNED,
'is-selected-box': focusedBoxId.value === box.id,
'is-missing': isBoxMissing(box),
}
}
function getBoxStyle(box) {
return {
left: `${Number(box.x || 0) * scaleRatio.value}px`,
top: `${Number(box.y || 0) * scaleRatio.value}px`,
width: `${Number(box.width || 0) * scaleRatio.value}px`,
height: `${Number(box.height || 0) * scaleRatio.value}px`,
}
}
function getSealPlacement(box) {
const boxX = Number(box.x || 0)
const boxY = Number(box.y || 0)
const boxWidth = Math.max(0, Number(box.width || 0))
const boxHeight = Math.max(0, Number(box.height || 0))
const innerSize = Math.max(48, Math.min(boxWidth, boxHeight) * DEFAULT_SEAL_PADDING_RATIO)
const scale = roundValue(Math.max(0.3, innerSize / DEFAULT_SEAL_BASE_SIZE))
const renderWidth = roundValue(DEFAULT_SEAL_BASE_SIZE * scale)
const renderHeight = renderWidth
const offsetX = roundValue(Math.max(0, (boxWidth - renderWidth) / 2))
const offsetY = roundValue(Math.max(0, (boxHeight - renderHeight) / 2))
return {
x: roundValue(boxX + offsetX),
y: roundValue(boxY + offsetY),
width: renderWidth,
height: renderHeight,
offsetX,
offsetY,
scale,
}
}
function getSealPreviewStyle(box) {
const placement = getSealPlacement(box)
return {
width: `${placement.width * scaleRatio.value}px`,
height: `${placement.height * scaleRatio.value}px`,
left: `${placement.offsetX * scaleRatio.value}px`,
top: `${placement.offsetY * scaleRatio.value}px`,
}
}
function setPageRef(el, pageNum) {
if (el) {
pageRefs[pageNum] = el
}
}
function resetSelectionMap() {
Object.keys(selectedSealMap).forEach((key) => {
delete selectedSealMap[key]
})
}
function hydrateSelectedSealMap() {
const preservedPictureMap = {}
Object.keys(selectedSealMap).forEach((key) => {
preservedPictureMap[key] = selectedSealMap[key]?.pictureUrl || ''
})
resetSelectionMap()
pageState.positions.forEach((item) => {
if (item.sealId && item.sealName && Number(item.signedStatus) !== STATUS_EMPTY) {
selectedSealMap[item.id] = {
sealId: item.sealId,
sealName: item.sealName,
pictureUrl: preservedPictureMap[item.id] || '',
}
}
})
}
function clearQrState() {
qrVisible.value = false
qrCode.value = ''
qrRequestId.value = ''
qrExpiresIn.value = 300
qrCount.value = 300
stopQrCountdown()
stopPolling()
}
function markLocalBoxesAsSigning(boxes, requestId) {
boxes.forEach((box) => {
const localBox = pageState.positions.find((item) => item.id === box.id)
if (!localBox) return
localBox.signedStatus = STATUS_SIGNING
localBox.requestId = requestId || ''
localBox.sealId = selectedSealMap[box.id]?.sealId || box.sealId || ''
localBox.sealName = selectedSealMap[box.id]?.sealName || box.sealName || ''
localBox.signedByName = ''
localBox.signedById = ''
localBox.signedTime = null
})
}
async function refreshPageStateAfterCallback(requestId) {
if (!requestId) {
await loadPageState(false)
return
}
for (let attempt = 0; attempt < 4; attempt += 1) {
await loadPageState(false)
const stillSigning = pageState.positions.some((item) => item.requestId === requestId && Number(item.signedStatus) === STATUS_SIGNING)
if (!stillSigning) {
return
}
await wait(250)
}
}
function resetState() {
loadingPage.value = false
fetchingSeals.value = false
preSigning.value = false
checkingComplete.value = false
mockingCallback.value = false
sealList.value = []
selectedPartyId.value = null
focusedBoxId.value = null
currentPageNum.value = 1
pageDisplayWidth.value = 760
pageState.sealContractId = ''
pageState.pdfToImageList = []
pageState.positions = []
pageState.availablePartyIds = []
pageState.preferredPartyId = null
pageState.imageWidth = DEFAULT_IMAGE_WIDTH
pageState.imageHeight = DEFAULT_IMAGE_HEIGHT
currentSignerId.value = ''
dragSealData = null
clearQrState()
resetSelectionMap()
Object.keys(pageRefs).forEach((key) => delete pageRefs[key])
}
function refreshPreviewSize() {
nextTick(() => {
const paneWidth = previewPaneRef.value?.clientWidth || 980
const availableWidth = Math.max(360, paneWidth - 48)
pageDisplayWidth.value = Math.min(imageWidth.value, availableWidth)
})
}
function hydratePageState(page) {
pageState.sealContractId = page?.sealContractId || ''
pageState.pdfToImageList = Array.isArray(page?.pdfToImageList) ? page.pdfToImageList : []
pageState.positions = Array.isArray(page?.positions)
? page.positions.map((item) => ({
...item,
pageNum: Number(item.pageNum || 1),
x: roundValue(item.x),
y: roundValue(item.y),
width: roundValue(item.width),
height: roundValue(item.height),
signedStatus: Number(item.signedStatus ?? STATUS_EMPTY),
required: item.required === 'N' ? 'N' : 'Y',
}))
: []
pageState.availablePartyIds = Array.isArray(page?.availablePartyIds) ? page.availablePartyIds : []
pageState.preferredPartyId = page?.preferredPartyId ?? null
pageState.imageWidth = Number(page?.imageWidth || DEFAULT_IMAGE_WIDTH)
pageState.imageHeight = Number(page?.imageHeight || DEFAULT_IMAGE_HEIGHT)
const selectablePartyIds = pageState.availablePartyIds.length
? [...new Set(pageState.availablePartyIds.map((item) => Number(item)).filter((item) => !Number.isNaN(item)))]
: []
if (!selectedPartyId.value) {
selectedPartyId.value = pageState.preferredPartyId || selectablePartyIds[0] || null
} else if (
selectedPartyId.value
&& !selectablePartyIds.some((item) => Number(item) === Number(selectedPartyId.value))
) {
selectedPartyId.value = pageState.preferredPartyId || selectablePartyIds[0] || null
}
hydrateSelectedSealMap()
}
async function loadPageState(showMessage = false) {
if (!contractDetail.value?.id) return
loadingPage.value = true
try {
const res = await getLaunchSealPositionPage(contractDetail.value.id)
if (!isSuccessResponse(res) || !getResult(res)) {
message.error(res?.message || res?.msg || '获取签章位失败')
return
}
hydratePageState(getResult(res))
refreshPreviewSize()
if (!focusedBoxId.value && myBoxes.value.length) {
focusBox(myBoxes.value[0].id, myBoxes.value[0].pageNum)
}
if (showMessage) {
message.success('签署状态已刷新')
}
} catch (error) {
message.error(error?.message || '获取签章位失败')
} finally {
loadingPage.value = false
}
}
function focusBox(boxId, pageNum) {
focusedBoxId.value = boxId
currentPageNum.value = Number(pageNum || 1)
nextTick(() => {
const pageRef = pageRefs[pageNum]
pageRef?.scrollIntoView?.({ behavior: 'smooth', block: 'center' })
})
}
function handlePartyChanged() {
const firstBox = myBoxes.value[0] || null
if (firstBox) {
focusBox(firstBox.id, firstBox.pageNum)
}
}
function handleSealDragStart(event, seal) {
dragSealData = {
sealId: seal.sealId,
sealName: seal.sealName,
pictureUrl: seal.pictureUrl,
}
event.dataTransfer.effectAllowed = 'copy'
event.dataTransfer.setData('text/plain', seal.sealId || 'seal')
}
function handleBoxDragOver(box) {
if (!isBoxMine(box) || Number(box.signedStatus) === STATUS_SIGNED) {
return
}
}
async function handleDropSealOnBox(event, box) {
event.preventDefault()
if (!dragSealData) return
if (!isBoxMine(box)) {
message.warning('只能给当前签署方的签章位放章')
dragSealData = null
return
}
if (Number(box.signedStatus) === STATUS_SIGNING) {
message.warning('该签章位正在等待扫码确认,暂时不能更换印章')
dragSealData = null
return
}
if (Number(box.signedStatus) === STATUS_SIGNED) {
message.warning('该签章位已签署,不能重复放章')
dragSealData = null
return
}
selectedSealMap[box.id] = { ...dragSealData }
box.sealId = dragSealData.sealId
box.sealName = dragSealData.sealName
box.signedStatus = STATUS_SELECTED
focusBox(box.id, box.pageNum)
dragSealData = null
try {
await batchUpdateLaunchSealPositionStatus(contractDetail.value.id, {
partyId: selectedPartyId.value,
signedStatus: STATUS_SELECTED,
items: [{ id: box.id, sealId: box.sealId, sealName: box.sealName }],
})
} catch {
// 请求失败时保留前端选择态,方便用户重试提交
}
}
function clearBoxSelection(box) {
if (Number(box.signedStatus) === STATUS_SIGNED) {
message.warning('已签署的签章位不能清空')
return
}
if (Number(box.signedStatus) === STATUS_SIGNING) {
message.warning('该签章位正在等待扫码确认,请先关闭二维码或等待回调完成')
return
}
delete selectedSealMap[box.id]
box.sealId = null
box.sealName = null
box.signedStatus = STATUS_EMPTY
batchUpdateLaunchSealPositionStatus(contractDetail.value.id, {
partyId: selectedPartyId.value,
signedStatus: STATUS_EMPTY,
items: [{ id: box.id }],
}).catch(() => {})
}
function isBoxMissing(box) {
return Number(box.partyId) === Number(selectedPartyId.value)
&& box.required === 'Y'
&& ![STATUS_SELECTED, STATUS_SIGNING, STATUS_SIGNED].includes(Number(box.signedStatus))
&& !selectedSealMap[box.id]
}
function isRequiredBoxUnfinished(box) {
return Number(box.partyId) === Number(selectedPartyId.value)
&& box.required === 'Y'
&& Number(box.signedStatus) !== STATUS_SIGNED
}
async function fetchSeals() {
if (!queryName.value || !queryMobile.value) {
message.warning('请先确认测试签署人信息')
return
}
fetchingSeals.value = true
try {
const signerRes = await getUserUniqueId({
name: queryName.value,
mobile: queryMobile.value,
})
if (!isSuccessResponse(signerRes) || !getResult(signerRes)?.signerId) {
message.error(signerRes?.message || signerRes?.msg || '获取签署人身份失败')
return
}
currentSignerId.value = getResult(signerRes).signerId
const sealRes = await getSealBySignerId({
signerId: currentSignerId.value,
sealTypes: querySealTypes.value,
entName: queryEntName.value,
})
if (!isSuccessResponse(sealRes)) {
message.error(sealRes?.message || sealRes?.msg || '获取印章失败')
return
}
const result = getResult(sealRes)
sealList.value = Array.isArray(result) ? result : []
if (sealList.value.length) {
message.success(`成功获取 ${sealList.value.length} 个印章`)
} else {
message.warning('未获取到可用印章')
}
} catch (error) {
message.error(error?.message || '获取印章失败')
} finally {
fetchingSeals.value = false
}
}
function validateBeforePresign() {
if (!selectedPartyId.value) {
return '请先选择当前代表的签署方'
}
if (!myBoxes.value.length) {
return '当前签署方没有可签章位'
}
const signingBox = myBoxes.value.find((item) => Number(item.signedStatus) === STATUS_SIGNING)
if (signingBox) {
focusBox(signingBox.id, signingBox.pageNum)
return '当前还有待扫码确认的签章位,请先完成或关闭当前二维码流程'
}
const firstMissing = myBoxes.value.find((item) => isBoxMissing(item))
if (firstMissing) {
focusBox(firstMissing.id, firstMissing.pageNum)
return '当前签署方还有必签位置未放章,请先完成所有必签框'
}
if (!myPendingSubmitBoxes.value.length) {
return '请先拖动印章到待签框'
}
if (!pageState.sealContractId) {
return '签章平台合同ID为空,请先刷新签署文件'
}
return null
}
function buildSealConfigs() {
return myPendingSubmitBoxes.value.map((box) => ({
sealId: selectedSealMap[box.id]?.sealId || box.sealId,
x: getSealPlacement(box).x,
y: getSealPlacement(box).y,
scale: getSealPlacement(box).scale,
pageNum: Number(box.pageNum || 1),
}))
}
function startQrCountdown() {
stopQrCountdown()
qrCount.value = qrExpiresIn.value || 300
qrTimer = setInterval(() => {
qrCount.value -= 1
if (qrCount.value <= 0) {
stopQrCountdown()
if (qrVisible.value) {
handleCloseQr()
}
}
}, 1000)
}
function stopQrCountdown() {
if (qrTimer) {
clearInterval(qrTimer)
qrTimer = null
}
}
function stopPolling() {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
function startPolling(requestId) {
stopPolling()
const maxAttempts = Math.ceil((qrExpiresIn.value || 300) / 2) + 10
let attempts = 0
pollTimer = setInterval(async () => {
attempts += 1
if (attempts > maxAttempts || !qrVisible.value) {
stopPolling()
return
}
try {
const res = await pollCallback(requestId)
if (isSuccessResponse(res) && getResult(res)) {
stopPolling()
await handlePresignCallbackResult(getResult(res), requestId)
}
} catch {
// ignore poll errors
}
}, 2000)
}
function isPresignFailed(data) {
const contractStatus = String(data?.contractStatus || '').trim()
if (contractStatus) {
if (contractStatus === '20' || contractStatus === '2000') {
return false
}
if (contractStatus === '0' || contractStatus.startsWith('-')) {
return true
}
}
const successValue = data?.isSuccess ?? data?.success
if (successValue !== undefined && successValue !== null) {
return String(successValue).toLowerCase() !== 'true'
}
const statusText = `${data?.status || ''} ${data?.msg || ''}`.toLowerCase()
return ['fail', 'cancel', 'timeout', 'reject'].some((keyword) => statusText.includes(keyword))
}
async function handlePreSignSubmit() {
const validationMessage = validateBeforePresign()
if (validationMessage) {
message.warning(validationMessage)
return
}
preSigning.value = true
try {
const submitBoxes = [...myPendingSubmitBoxes.value]
const sealConfigs = buildSealConfigs()
const res = await getPreSignQrCode({
contractId: pageState.sealContractId,
notifyUrl: `${CALLBACK_HOST}/seal/callback/presign`,
isNeedUrlLink: true,
sealConfigs,
pagingSealConfigs: null,
timestampSealConfigs: null,
})
if (!isSuccessResponse(res) || !getResult(res)) {
message.error(res?.message || res?.msg || '获取二维码失败')
return
}
const data = getResult(res)
await batchUpdateLaunchSealPositionStatus(contractDetail.value.id, {
partyId: selectedPartyId.value,
signedStatus: STATUS_SIGNING,
requestId: data.requestId || '',
items: submitBoxes.map((box) => ({
id: box.id,
sealId: selectedSealMap[box.id]?.sealId || box.sealId,
sealName: selectedSealMap[box.id]?.sealName || box.sealName,
})),
})
markLocalBoxesAsSigning(submitBoxes, data.requestId || '')
qrCode.value = data.qrCode || ''
qrRequestId.value = data.requestId || ''
qrExpiresIn.value = Number(data.expiresIn || 300)
qrVisible.value = true
startQrCountdown()
startPolling(qrRequestId.value)
await loadPageState(false)
} catch (error) {
message.error(error?.message || '提交预盖章失败')
} finally {
preSigning.value = false
}
}
async function handlePresignCallbackResult(data, requestId = '') {
const currentRequestId = requestId || qrRequestId.value || String(data?.requestId || '')
clearQrState()
await refreshPageStateAfterCallback(currentRequestId)
if (isPresignFailed(data)) {
message.warning(data?.msg || '预盖章未完成,签章位已恢复为可重试状态')
return
}
const nextUnfinishedBox = myBoxes.value.find((item) => isRequiredBoxUnfinished(item))
if (nextUnfinishedBox) {
focusBox(nextUnfinishedBox.id, nextUnfinishedBox.pageNum)
}
message.success(`预盖章确认成功,签署人:${data?.signer || queryName.value || '未知'}`)
}
async function handleMockSuccess() {
if (!qrRequestId.value || !pageState.sealContractId) {
message.warning('当前没有待模拟的扫码请求')
return
}
stopPolling()
mockingCallback.value = true
try {
const res = await mockPreSignSuccessCallback({
requestId: qrRequestId.value,
contractId: pageState.sealContractId,
signer: queryName.value,
signerId: currentSignerId.value || '',
contractStatus: 20,
})
if (!isSuccessResponse(res)) {
message.error(res?.message || res?.msg || '模拟扫码成功失败')
return
}
await handlePresignCallbackResult({ signer: queryName.value, contractStatus: 20, requestId: qrRequestId.value }, qrRequestId.value)
} catch (error) {
message.error(error?.message || '模拟扫码成功失败')
} finally {
mockingCallback.value = false
}
}
async function handleCloseQr() {
const currentRequestId = qrRequestId.value
const rollbackItems = pageState.positions
.filter((item) => Number(item.partyId) === Number(selectedPartyId.value))
.filter((item) => Number(item.signedStatus) === STATUS_SIGNING && item.requestId === currentRequestId)
.map((item) => ({ id: item.id, sealId: item.sealId, sealName: item.sealName }))
clearQrState()
if (!rollbackItems.length) {
return
}
try {
await batchUpdateLaunchSealPositionStatus(contractDetail.value.id, {
partyId: selectedPartyId.value,
signedStatus: STATUS_SELECTED,
items: rollbackItems,
})
await loadPageState(false)
message.info('已关闭扫码窗口,本次预盖章已恢复为待提交状态')
} catch {
// ignore rollback failures in UI close action
}
}
function buildSignedFileInfo(downloadData = {}) {
const signedUrl = downloadData.downloadUrl || downloadData.url || ''
const signedName = downloadData.fileName || `${contractDetail.value?.contractName || '已签署合同'}.pdf`
return {
signedFileName: signedName,
signedFilePath: signedUrl || '',
signedFileType: signedName.split('.').pop()?.toLowerCase() || 'pdf',
signedFileSize: downloadData.fileSize || downloadData.size || null,
}
}
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
}
async function writeBackLaunchSealFinished() {
let signedFileInfo = {}
try {
const downloadRes = await downloadContract(pageState.sealContractId)
if (isSuccessResponse(downloadRes) && getResult(downloadRes)) {
signedFileInfo = buildSignedFileInfo(getResult(downloadRes))
}
} catch {
// ignore download errors here
}
const res = await finishLaunchSeal(contractDetail.value.id, {
sealContractId: pageState.sealContractId,
sealContractStatus: 2000,
signDate: formatDateTimeValue(todayString()),
sealDate: formatDateTimeValue(todayString()),
activeDate: formatDateTimeValue(todayString()),
...signedFileInfo,
})
if (!isSuccessResponse(res) || !getResult(res)) {
throw new Error(res?.message || res?.msg || '回写签署结果失败')
}
emit('done', getResult(res))
}
async function handleCompleteSigning() {
if (!selectedPartyId.value) {
message.warning('请先选择当前代表的签署方')
return
}
checkingComplete.value = true
try {
const res = await checkLaunchSealPositions(contractDetail.value.id, {})
if (!isSuccessResponse(res) || !getResult(res)) {
message.error(res?.message || res?.msg || '校验签章位失败')
return
}
const result = getResult(res)
if (!result.passed) {
const firstMissing = Array.isArray(result.missingPositions) ? result.missingPositions[0] : null
if (firstMissing) {
focusBox(firstMissing.id, firstMissing.pageNum)
}
message.warning(`还有 ${result.missingCount || 0} 个必签位置未完成签署,请先处理后再点签署完成`)
return
}
const completeRes = await completeContract(pageState.sealContractId)
if (!isSuccessResponse(completeRes)) {
message.error(completeRes?.message || completeRes?.msg || '完成签署失败')
return
}
await writeBackLaunchSealFinished()
await loadPageState(false)
message.success('合同签署已完成')
visible.value = false
} catch (error) {
message.error(error?.message || '完成签署失败')
} finally {
checkingComplete.value = false
}
}
async function showModal(detail) {
resetState()
contractDetail.value = detail ? { ...detail } : null
visible.value = true
await nextTick()
refreshPreviewSize()
await loadPageState(false)
}
function handleWindowResize() {
if (visible.value) {
refreshPreviewSize()
}
}
onMounted(() => {
window.addEventListener('resize', handleWindowResize)
})
onBeforeUnmount(() => {
window.removeEventListener('resize', handleWindowResize)
clearQrState()
})
defineExpose({ showModal })
</script>
<style scoped>
.seal-position-sign-layout {
height: 100%;
display: flex;
background: #f4f7fb;
}
.preview-pane {
flex: 1 1 auto;
min-width: 0;
display: flex;
flex-direction: column;
border-right: 1px solid #e5e7eb;
}
.preview-toolbar {
padding: 14px 18px;
background: #fff;
border-bottom: 1px solid #e5e7eb;
}
.preview-scroll {
flex: 1 1 auto;
overflow: auto;
padding: 18px;
}
.pdf-page-card {
margin: 0 auto 18px;
padding: 14px;
border-radius: 18px;
border: 1px solid #dbe4f0;
background: #ffffff;
width: fit-content;
box-shadow: 0 12px 28px rgba(15, 23, 42, 0.06);
}
.pdf-page-card.is-active {
border-color: #60a5fa;
box-shadow: 0 16px 30px rgba(37, 99, 235, 0.12);
}
.page-card-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
font-size: 13px;
color: #475569;
font-weight: 600;
}
.pdf-page-shell {
position: relative;
overflow: hidden;
border-radius: 12px;
background: #f8fafc;
}
.pdf-page-image {
display: block;
user-select: none;
}
.seal-position-box {
position: absolute;
box-sizing: border-box;
border-radius: 12px;
padding: 8px;
overflow: hidden;
border: 2px dashed #94a3b8;
background: rgba(148, 163, 184, 0.12);
color: #334155;
transition: all 0.2s ease;
}
.seal-position-box.is-mine {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.12);
color: #1d4ed8;
}
.seal-position-box.is-other {
border-color: #cbd5e1;
background: rgba(203, 213, 225, 0.25);
}
.seal-position-box.is-selected-box {
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.14);
}
.seal-position-box.is-missing {
border-color: #ef4444;
background: rgba(239, 68, 68, 0.14);
color: #b91c1c;
}
.seal-position-box.is-signed {
border-style: solid;
}
.box-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.box-title {
font-size: 12px;
font-weight: 700;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.box-party,
.box-status,
.box-seal-name,
.box-signed-by {
margin-top: 4px;
font-size: 11px;
line-height: 1.4;
}
.box-seal-preview {
position: absolute;
pointer-events: none;
opacity: 0.92;
}
.box-seal-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.box-focus-ring {
position: absolute;
inset: 0;
border-radius: 10px;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.85);
}
.operation-pane {
width: 380px;
flex: 0 0 380px;
display: flex;
flex-direction: column;
gap: 12px;
padding: 16px;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
background: linear-gradient(180deg, #fbfdff 0%, #f5f8fc 100%);
}
.side-card {
border-radius: 16px;
border: 1px solid #e2e8f0;
background: rgba(255, 255, 255, 0.96);
padding: 14px;
}
.side-card-grow {
flex: 1 1 auto;
min-height: 0;
display: flex;
flex-direction: column;
}
.side-card-title {
margin-bottom: 12px;
font-size: 14px;
font-weight: 700;
color: #0f172a;
}
.meta-item {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
font-size: 13px;
color: #475569;
}
.meta-item strong {
flex: 1;
text-align: right;
color: #0f172a;
word-break: break-word;
}
.meta-note {
margin-top: 10px;
padding: 10px 12px;
border-radius: 12px;
background: #eff6ff;
font-size: 12px;
line-height: 1.6;
color: #1d4ed8;
}
.party-selector-item {
margin-top: 10px;
margin-bottom: 0;
}
.query-grid :deep(.ant-form-item) {
margin-bottom: 10px;
}
.seal-type-wrap {
margin: 8px 0 12px;
}
.seal-list {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.seal-card {
border: 1px solid #dbe4f0;
border-radius: 14px;
padding: 10px;
background: #fff;
cursor: grab;
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
}
.seal-card:active {
cursor: grabbing;
}
.seal-thumb {
max-width: 100%;
height: 72px;
object-fit: contain;
}
.seal-name {
font-size: 12px;
text-align: center;
color: #334155;
word-break: break-word;
}
.pending-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: 150px;
overflow: auto;
}
.pending-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 12px;
border-radius: 12px;
border: 1px solid #dbe4f0;
cursor: pointer;
}
.pending-title {
font-size: 13px;
font-weight: 700;
color: #0f172a;
}
.pending-sub {
margin-top: 4px;
font-size: 12px;
color: #64748b;
}
.footer-actions {
margin-top: auto;
position: sticky;
bottom: 0;
padding-top: 12px;
padding-bottom: 4px;
background: linear-gradient(180deg, rgba(245, 248, 252, 0) 0%, rgba(245, 248, 252, 0.96) 24%, rgba(245, 248, 252, 1) 100%);
}
.qr-body {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 12px 0 4px;
}
.qr-image {
width: 260px;
height: 260px;
object-fit: contain;
border-radius: 18px;
border: 1px solid #e2e8f0;
background: #fff;
}
.qr-meta {
font-size: 13px;
color: #475569;
}
.empty-preview {
margin-top: 48px;
}
@media (max-width: 1100px) {
.seal-position-sign-layout {
flex-direction: column;
}
.operation-pane {
width: 100%;
flex-basis: auto;
}
}
</style>