1439 lines
41 KiB
Vue
1439 lines
41 KiB
Vue
<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>
|