+
+ 测试确认签署
+
关闭
@@ -689,6 +740,7 @@ import {
completeContract,
downloadContract,
pollCallback,
+ mockPreSignSuccessCallback,
getTimestamp,
signTimestamp,
getContractDetail,
@@ -702,6 +754,9 @@ import {
saveLaunchSealInfo,
getCounterpartyConfirm,
confirmCounterparty,
+ getLaunchSealPositionPage,
+ batchUpdateLaunchSealPositionStatus,
+ checkLaunchSealPositions,
} from "@/api/launch.js";
const SUCCESS_CODE = "0000";
@@ -732,6 +787,12 @@ const completeSignDate = ref("");
const completeSealDate = ref("");
const completeActiveDate = ref("");
+const SEAL_POS_STATUS_EMPTY = 0;
+const SEAL_POS_STATUS_SELECTED = 1;
+const SEAL_POS_STATUS_SIGNING = 2;
+const SEAL_POS_STATUS_SIGNED = 3;
+const SEAL_POS_STATUS_FAILED = 4;
+
const businessStatusLabelMap = {
draft: "草稿",
reviewing: "审批中",
@@ -790,6 +851,45 @@ const businessInfoRows = computed(() => {
.map(([label, value]) => ({ label, value: String(value) }));
});
+const sealPositionEnabled = computed(() => {
+ return !!businessRecord.value?.id && sealPositionState.enabled && sealPositionState.positions.length > 0;
+});
+
+const selectedPartyOptions = computed(() => {
+ const sourcePartyIds = [...new Set((sealPositionState.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 sealPositionPartySelectorVisible = computed(() => selectedPartyOptions.value.length > 1);
+const activeSealPositionPartyLabel = computed(() => {
+ if (!selectedPartyId.value) {
+ return selectedPartyOptions.value.length ? "请选择签署方" : "当前用户未匹配到可签署方";
+ }
+ return getPartyLabel(selectedPartyId.value);
+});
+
+const mySealPositionBoxes = computed(() => {
+ return sealPositionState.positions.filter((item) => Number(item.partyId) === Number(selectedPartyId.value));
+});
+
+const currentRequiredMissingCount = computed(() => {
+ if (!sealPositionEnabled.value) {
+ return 0;
+ }
+ return mySealPositionBoxes.value.filter((item) => isRequiredSealPositionUnfinished(item)).length;
+});
+
function formatBusinessAmount(value) {
if (value === undefined || value === null || value === "") return "";
return `${new Intl.NumberFormat("zh-CN", {
@@ -830,6 +930,132 @@ function normalizePdfPreviewImages(list = []) {
.filter(Boolean);
}
+function wait(ms) {
+ return new Promise((resolve) => {
+ window.setTimeout(resolve, ms);
+ });
+}
+
+function roundValue(value) {
+ return Math.round(Number(value || 0) * 100) / 100;
+}
+
+function findPartyById(partyId) {
+ return (businessRecord.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 normalizeSealPositionRequired(required) {
+ return required === "N" ? "N" : "Y";
+}
+
+function getSealPositionStatusText(status) {
+ const value = Number(status);
+ if (value === SEAL_POS_STATUS_SELECTED) return "待签署";
+ if (value === SEAL_POS_STATUS_SIGNING) return "待签署";
+ if (value === SEAL_POS_STATUS_SIGNED) return "已签署";
+ if (value === SEAL_POS_STATUS_FAILED) return "待签署";
+ return "待签署";
+}
+
+function getSealPositionBoxStyle(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 getPageSealPositionBoxes(pageNum) {
+ return sealPositionState.positions.filter((item) => Number(item.pageNum) === Number(pageNum));
+}
+
+function isMySealPositionBox(box) {
+ return Number(box.partyId) === Number(selectedPartyId.value);
+}
+
+function isRequiredSealPositionUnfinished(box) {
+ return isMySealPositionBox(box)
+ && normalizeSealPositionRequired(box.required) === "Y"
+ && Number(box.signedStatus) !== SEAL_POS_STATUS_SIGNED;
+}
+
+function getSealPositionBoxClass(box) {
+ return {
+ "seal-position-box": true,
+ "is-mine": isMySealPositionBox(box),
+ "is-other": !isMySealPositionBox(box),
+ "is-signed": Number(box.signedStatus) === SEAL_POS_STATUS_SIGNED,
+ "is-missing": isRequiredSealPositionUnfinished(box) && Number(box.signedStatus) !== SEAL_POS_STATUS_SELECTED && Number(box.signedStatus) !== SEAL_POS_STATUS_SIGNING && !findSealItemByBoxId(box.id),
+ };
+}
+
+function buildSealPositionPlacement(box, scale = 1) {
+ 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 renderWidth = roundValue(170 * 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),
+ scale,
+ };
+}
+
+function findSealItemByBoxId(boxId) {
+ return sealItems.value.find((item) => Number(item._sealPositionBoxId) === Number(boxId)) || null;
+}
+
+function getBoxById(boxId) {
+ return sealPositionState.positions.find((item) => Number(item.id) === Number(boxId)) || null;
+}
+
+function buildSealItemFromBox(box) {
+ const scale = 1;
+ const placement = buildSealPositionPlacement(box, scale);
+ return {
+ id: ++sealPlacedId,
+ _type: "seal",
+ _sealPositionBoxId: box.id,
+ sealId: box.sealId || "",
+ sealName: box.sealName || box.label || "印章",
+ pictureUrl: "",
+ _pictureUrl: "",
+ owner: "",
+ pageNum: Number(box.pageNum || 1),
+ x: placement.x,
+ y: placement.y,
+ scale,
+ status: "pending",
+ _signer: box.signedByName || "",
+ };
+}
+
+function syncSealItemsFromSealPositions() {
+ if (!sealPositionEnabled.value) {
+ return;
+ }
+ const timestampItems = sealItems.value.filter((item) => item._type === "timestamp");
+ const localPendingSeals = sealItems.value.filter((item) => {
+ if (item._type !== "seal") return false;
+ const box = getBoxById(item._sealPositionBoxId);
+ return !!box && Number(box.signedStatus) !== SEAL_POS_STATUS_SIGNED;
+ });
+ sealItems.value = [...timestampItems, ...localPendingSeals];
+}
+
function syncCompletionDates(record = {}) {
completeSignDate.value = sliceDateValue(record?.signDate) || todayString();
completeSealDate.value = sliceDateValue(record?.sealDate) || completeSignDate.value;
@@ -982,7 +1208,7 @@ function buildSignedFileInfo(downloadData = {}) {
`${businessRecord.value?.contractName || contractName.value || "已签署合同"}.pdf`;
return {
signedFileName: signedName,
- signedFilePath: signedUrl || contractId.value || "",
+ signedFilePath: signedUrl || "",
signedFileType: signedName.split(".").pop()?.toLowerCase() || "pdf",
signedFileSize: downloadData.fileSize || downloadData.size || null,
};
@@ -1040,6 +1266,7 @@ function resetModalState(record = null) {
fetchingSeals.value = false;
sealList.value = [];
resetSealPlacementState();
+ resetSealPositionState();
tsImageUrl.value = "";
tsData.value = null;
tsFormat.value = "yyyy-MM-dd";
@@ -1079,6 +1306,7 @@ async function showModal(contractIdParam, businessRecordParam) {
await loadContract(contractIdToLoad);
await nextTick();
calcDisplaySize();
+ await loadSealPositionState();
await loadCounterpartyConfirm();
modalLoading.value = false;
return;
@@ -1098,9 +1326,13 @@ async function showModal(contractIdParam, businessRecordParam) {
restoreContractState(saved);
await nextTick();
calcDisplaySize();
+ await loadSealPositionState();
}
} catch {}
}
+ if (contextRecord?.id && !sealPositionState.positions.length) {
+ await loadSealPositionState();
+ }
await loadCounterpartyConfirm();
modalLoading.value = false;
}
@@ -1165,6 +1397,15 @@ const imageHeight = ref(1122);
const completing = ref(false);
const counterpartyConfirmMap = ref({});
const counterpartyConfirmLoading = ref(false);
+const sealPositionState = reactive({
+ enabled: false,
+ loading: false,
+ sealContractId: "",
+ positions: [],
+ availablePartyIds: [],
+ preferredPartyId: null,
+});
+const selectedPartyId = ref(null);
async function loadContract(cid) {
try {
@@ -1190,6 +1431,9 @@ async function loadContract(cid) {
imageHeight.value = Number(preview?.imageHeight) || 1122;
updateBusinessStatusByContractStatus(contractStatus.value);
saveContractState();
+ if (detail?.sealContractId) {
+ contractId.value = detail.sealContractId;
+ }
}
} catch {
try {
@@ -1314,12 +1558,136 @@ async function loadCounterpartyConfirm() {
counterpartyConfirmLoading.value = false;
}
+function resetSealPositionState() {
+ sealPositionState.enabled = false;
+ sealPositionState.loading = false;
+ sealPositionState.sealContractId = "";
+ sealPositionState.positions = [];
+ sealPositionState.availablePartyIds = [];
+ sealPositionState.preferredPartyId = null;
+ selectedPartyId.value = null;
+}
+
+async function loadSealPositionState() {
+ if (!businessRecord.value?.id) {
+ resetSealPositionState();
+ return;
+ }
+ sealPositionState.loading = true;
+ try {
+ const res = await getLaunchSealPositionPage(businessRecord.value.id);
+ if (!isSuccessResponse(res) || !getResponseResult(res)) {
+ resetSealPositionState();
+ return;
+ }
+ const page = getResponseResult(res);
+ sealPositionState.sealContractId = page?.sealContractId || businessRecord.value?.sealContractId || contractId.value || "";
+ sealPositionState.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 ?? SEAL_POS_STATUS_EMPTY),
+ required: normalizeSealPositionRequired(item.required),
+
+ }))
+ : [];
+ sealPositionState.availablePartyIds = Array.isArray(page?.availablePartyIds) ? page.availablePartyIds : [];
+ sealPositionState.preferredPartyId = page?.preferredPartyId ?? null;
+ sealPositionState.enabled = String(businessRecord.value?.sealPosConfigured || "N") === "Y" && sealPositionState.positions.length > 0;
+ if (sealPositionState.enabled) {
+ const availableIds = [...new Set(sealPositionState.availablePartyIds.map((item) => Number(item)).filter((item) => !Number.isNaN(item)))];
+ if (!selectedPartyId.value || !availableIds.includes(Number(selectedPartyId.value))) {
+ selectedPartyId.value = sealPositionState.preferredPartyId || availableIds[0] || null;
+ }
+ syncSealItemsFromSealPositions();
+ } else {
+ selectedPartyId.value = null;
+ }
+ } catch {
+ resetSealPositionState();
+ } finally {
+ sealPositionState.loading = false;
+ }
+}
+
+async function refreshSealPositionStateAfterCallback(requestId = "") {
+ if (!businessRecord.value?.id) {
+ return;
+ }
+ for (let attempt = 0; attempt < 4; attempt += 1) {
+ await loadSealPositionState();
+ if (!requestId) {
+ return;
+ }
+ const stillSigning = sealPositionState.positions.some((item) => item.requestId === requestId && Number(item.signedStatus) === SEAL_POS_STATUS_SIGNING);
+ if (!stillSigning) {
+ return;
+ }
+ await wait(250);
+ }
+}
+
+function isRequiredSealPositionMissingPlacement(box) {
+ return isMySealPositionBox(box)
+ && normalizeSealPositionRequired(box.required) === "Y"
+ && ![SEAL_POS_STATUS_SELECTED, SEAL_POS_STATUS_SIGNING, SEAL_POS_STATUS_SIGNED].includes(Number(box.signedStatus))
+ && !findSealItemByBoxId(box.id);
+}
+
+function findDroppableSealPositionBox(pageNum, x, y) {
+ return getPageSealPositionBoxes(pageNum).find((box) => {
+ if (!isMySealPositionBox(box)) return false;
+ const boxX = Number(box.x || 0);
+ const boxY = Number(box.y || 0);
+ const boxWidth = Number(box.width || 0);
+ const boxHeight = Number(box.height || 0);
+ return x >= boxX && x <= boxX + boxWidth && y >= boxY && y <= boxY + boxHeight;
+ }) || null;
+}
+
+function scrollToSealPositionBox(box) {
+ if (!box || !pdfScrollRef.value) return;
+ const boxEl = pdfScrollRef.value.querySelector(`[data-seal-box-id='${box.id}']`);
+ const pageEl = pdfScrollRef.value.querySelector(`[data-page-num='${box.pageNum}']`);
+ if (boxEl?.scrollIntoView) {
+ boxEl.scrollIntoView({ behavior: "smooth", block: "center", inline: "nearest" });
+ return;
+ }
+ pageEl?.scrollIntoView?.({ behavior: "smooth", block: "center", inline: "nearest" });
+}
async function handleConfirmCounterparty(item = {}) {
if (!businessRecord.value?.id) return;
if (!item.partyId) {
message.error("缺少相对方标识,无法确认");
return;
}
+ if (sealPositionEnabled.value) {
+ try {
+ const checkRes = await checkLaunchSealPositions(businessRecord.value.id, {
+ partyId: item.partyId,
+ });
+ if (!isSuccessResponse(checkRes) || !getResponseResult(checkRes)) {
+ message.error(getResponseMessage(checkRes, "校验相对方签章位失败"));
+ return;
+ }
+ const checkResult = getResponseResult(checkRes);
+ if (!checkResult.passed) {
+ const firstMissing = Array.isArray(checkResult.missingPositions) ? checkResult.missingPositions[0] : null;
+ if (firstMissing) {
+ scrollToSealPositionBox(firstMissing);
+ }
+ message.warning(`该相对方还有 ${checkResult.missingCount || 0} 个必签位置未完成签署,请先完成签章`);
+ return;
+ }
+ } catch {
+ message.error("校验相对方签章位失败");
+ return;
+ }
+ }
try {
const res = await confirmCounterparty(businessRecord.value.id, {
partyId: String(item.partyId),
@@ -1398,15 +1766,21 @@ const pagingSeal = reactive({
});
const pendingSealCount = computed(() => {
- return sealItems.value.filter(
- (item) => item._type === "seal" && item.status === "pending",
- ).length;
+ return sealItems.value.filter((item) => {
+ if (item._type !== "seal" || item.status !== "pending") return false;
+ if (!sealPositionEnabled.value) return true;
+ const box = getBoxById(item._sealPositionBoxId);
+ return !!box && Number(box.partyId) === Number(selectedPartyId.value);
+ }).length;
});
const signedSealCount = computed(() => {
- return sealItems.value.filter(
- (item) => item._type === "seal" && item.status === "signed",
- ).length;
+ return sealItems.value.filter((item) => {
+ if (item._type !== "seal" || item.status !== "signed") return false;
+ if (!sealPositionEnabled.value) return true;
+ const box = getBoxById(item._sealPositionBoxId);
+ return !!box && Number(box.partyId) === Number(selectedPartyId.value);
+ }).length;
});
const canStartPreSign = computed(() => {
@@ -1420,6 +1794,7 @@ function onDragSealStart(e, seal) {
sealName: seal.sealName,
pictureUrl: seal.pictureUrl,
owner: seal.owner || "",
+ scale: sealDefaultScale.value,
};
e.dataTransfer.effectAllowed = "copy";
e.dataTransfer.setData("text/plain", seal.sealId || "seal");
@@ -1445,6 +1820,30 @@ function onDropSeal(e, pageNum) {
Math.round(((e.clientX - rect.left) / scaleRatio.value) * 100) / 100;
const y = Math.round(((e.clientY - rect.top) / scaleRatio.value) * 100) / 100;
+ if (dragSealData._type === "timestamp") {
+ sealItems.value.push({
+ id: ++sealPlacedId,
+ _type: "timestamp",
+ sealId: null,
+ sealName: "时间戳",
+ pictureUrl: dragSealData.pictureUrl,
+ _pictureUrl: dragSealData._pictureUrl || dragSealData.pictureUrl,
+ owner: "",
+ pageNum,
+ x,
+ y,
+ scale: 1,
+ status: "pending",
+ });
+ dragSealData = null;
+ return;
+ }
+
+ if (sealPositionEnabled.value) {
+ handleDropSealOnSealPosition(e, pageNum);
+ return;
+ }
+
sealItems.value.push({
id: ++sealPlacedId,
_type: dragSealData._type,
@@ -1456,7 +1855,7 @@ function onDropSeal(e, pageNum) {
pageNum,
x,
y,
- scale: sealDefaultScale.value,
+ scale: dragSealData.scale || sealDefaultScale.value,
status: "pending",
});
@@ -1469,6 +1868,85 @@ function getPageSealItems(pageNum) {
return sealItems.value.filter((item) => item.pageNum === pageNum);
}
+async function handleDropSealOnSealPosition(e, pageNum) {
+ if (!dragSealData || dragSealData._type !== "seal") return;
+ if (!selectedPartyId.value) {
+ message.warning("当前用户未匹配到可签署方,不能放章");
+ dragSealData = null;
+ return;
+ }
+ const rect = e.currentTarget.getBoundingClientRect();
+ const x = Math.round(((e.clientX - rect.left) / scaleRatio.value) * 100) / 100;
+ const y = Math.round(((e.clientY - rect.top) / scaleRatio.value) * 100) / 100;
+ const targetBox = findDroppableSealPositionBox(pageNum, x, y);
+ if (!targetBox) {
+ message.warning("请把印章拖到当前签署方的指定签章框内");
+ dragSealData = null;
+ return;
+ }
+ if (Number(targetBox.signedStatus) === SEAL_POS_STATUS_SIGNING) {
+ const rolledBack = qrVisible.value && targetBox.requestId === qrRequestId.value
+ ? await rollbackCurrentQrSigning("已自动关闭当前二维码流程,你可以继续调整印章位置")
+ : false;
+ if (!rolledBack) {
+ message.warning("该签章位正在等待扫码确认,暂时不能更换印章");
+ dragSealData = null;
+ return;
+ }
+ targetBox.requestId = "";
+ targetBox.signedStatus = SEAL_POS_STATUS_SELECTED;
+ }
+ if (Number(targetBox.signedStatus) === SEAL_POS_STATUS_SIGNED) {
+ message.warning("该签章位已签署,不能重复放章");
+ dragSealData = null;
+ return;
+ }
+ const existingItem = findSealItemByBoxId(targetBox.id);
+ const targetScale = dragSealData.scale || sealDefaultScale.value || 1;
+ const placement = buildSealPositionPlacement(targetBox, targetScale);
+ if (existingItem) {
+ existingItem.sealId = dragSealData.sealId;
+ existingItem.sealName = dragSealData.sealName;
+ existingItem.pictureUrl = dragSealData.pictureUrl;
+ existingItem._pictureUrl = dragSealData.pictureUrl;
+ existingItem.x = placement.x;
+ existingItem.y = placement.y;
+ existingItem.pageNum = Number(targetBox.pageNum || pageNum);
+ existingItem.scale = placement.scale;
+ existingItem.status = "pending";
+ } else {
+ sealItems.value.push({
+ id: ++sealPlacedId,
+ _type: "seal",
+ _sealPositionBoxId: targetBox.id,
+ sealId: dragSealData.sealId,
+ sealName: dragSealData.sealName,
+ pictureUrl: dragSealData.pictureUrl,
+ _pictureUrl: dragSealData.pictureUrl,
+ owner: dragSealData.owner || "",
+ pageNum: Number(targetBox.pageNum || pageNum),
+ x: placement.x,
+ y: placement.y,
+ scale: placement.scale,
+ status: "pending",
+ });
+ }
+ targetBox.sealId = dragSealData.sealId;
+ targetBox.sealName = dragSealData.sealName;
+ targetBox.signedStatus = SEAL_POS_STATUS_SELECTED;
+ try {
+ await batchUpdateLaunchSealPositionStatus(businessRecord.value.id, {
+ partyId: selectedPartyId.value,
+ signedStatus: SEAL_POS_STATUS_SELECTED,
+ items: [{ id: targetBox.id, sealId: targetBox.sealId, sealName: targetBox.sealName }],
+ });
+ } catch {
+ // 保留本地已选章状态,方便继续提交
+ }
+ scrollToSealPositionBox(targetBox);
+ dragSealData = null;
+}
+
function getPlacedStyle(seal) {
const s = seal.scale || sealDefaultScale.value;
const baseWidth = 170;
@@ -1479,12 +1957,36 @@ function getPlacedStyle(seal) {
};
}
-function removePlacedSeal(id) {
+async function removePlacedSeal(id) {
const target = sealItems.value.find((item) => item.id === id);
if (target?.status === "signed") {
message.warning("已签署的内容不可删除");
return;
}
+ if (sealPositionEnabled.value && target?._sealPositionBoxId) {
+ const box = getBoxById(target._sealPositionBoxId);
+ if (box && Number(box.signedStatus) === SEAL_POS_STATUS_SIGNING) {
+ const rolledBack = qrVisible.value && box.requestId === qrRequestId.value
+ ? await rollbackCurrentQrSigning("已自动关闭当前二维码流程,你可以继续删除或重放印章")
+ : false;
+ if (!rolledBack) {
+ message.warning("该签章位正在等待扫码确认,请先关闭二维码或等待回调完成");
+ return;
+ }
+ box.requestId = "";
+ box.signedStatus = SEAL_POS_STATUS_SELECTED;
+ }
+ if (box) {
+ box.sealId = "";
+ box.sealName = "";
+ box.signedStatus = SEAL_POS_STATUS_EMPTY;
+ batchUpdateLaunchSealPositionStatus(businessRecord.value.id, {
+ partyId: selectedPartyId.value,
+ signedStatus: SEAL_POS_STATUS_EMPTY,
+ items: [{ id: box.id }],
+ }).catch(() => {});
+ }
+ }
sealItems.value = sealItems.value.filter((item) => item.id !== id);
}
@@ -1516,6 +2018,10 @@ let originX = 0;
let originY = 0;
function startDragSeal(e, seal) {
+ if (sealPositionEnabled.value && seal._sealPositionBoxId) {
+ message.warning("签章位模式下,印章位置由预设框控制,不能自由拖动");
+ return;
+ }
e.preventDefault();
draggingSeal = seal;
dragStartX = e.clientX;
@@ -1550,6 +2056,7 @@ const qrCode = ref("");
const qrRequestId = ref("");
const qrExpiresIn = ref(300);
const qrRefreshing = ref(false);
+const mockingPresignSuccess = ref(false);
const qrCount = ref(0);
const preSigning = ref(false);
let qrTimer = null;
@@ -1589,7 +2096,33 @@ function stopQrCountdown() {
}
}
-function closeQrDialog() {
+async function rollbackCurrentQrSigning(reason = "") {
+ if (!sealPositionEnabled.value || !businessRecord.value?.id || !qrRequestId.value) {
+ return false;
+ }
+ const rollbackItems = mySealPositionBoxes.value
+ .filter((item) => Number(item.signedStatus) === SEAL_POS_STATUS_SIGNING && item.requestId === qrRequestId.value)
+ .map((item) => ({ id: item.id, sealId: item.sealId, sealName: item.sealName }));
+ if (!rollbackItems.length) {
+ return false;
+ }
+ try {
+ await batchUpdateLaunchSealPositionStatus(businessRecord.value.id, {
+ partyId: selectedPartyId.value,
+ signedStatus: SEAL_POS_STATUS_SELECTED,
+ items: rollbackItems,
+ });
+ await loadSealPositionState();
+ if (reason) {
+ message.info(reason);
+ }
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+async function closeQrDialog() {
stopQrCountdown();
stopPolling();
qrVisible.value = false;
@@ -1598,6 +2131,7 @@ function closeQrDialog() {
.forEach((item) => {
item.status = "pending";
});
+ await rollbackCurrentQrSigning("已关闭二维码,本次预盖章已恢复为待提交状态");
}
function startPolling(requestId) {
@@ -1616,7 +2150,7 @@ function startPolling(requestId) {
const res = await pollCallback(requestId);
if (isSuccessResponse(res) && getResponseResult(res)) {
stopPolling();
- handlePreSignCallback(getResponseResult(res));
+ handlePreSignCallback(getResponseResult(res), requestId);
}
} catch {}
}, 2000);
@@ -1630,9 +2164,21 @@ function stopPolling() {
}
async function handlePreSign() {
- const pendingSeals = sealItems.value.filter(
- (item) => item._type === "seal" && item.status === "pending",
- );
+ const pendingSeals = sealItems.value.filter((item) => {
+ if (item._type !== "seal" || item.status !== "pending") return false;
+ if (!sealPositionEnabled.value) return true;
+ const box = getBoxById(item._sealPositionBoxId);
+ return !!box && Number(box.partyId) === Number(selectedPartyId.value);
+ });
+
+ if (sealPositionEnabled.value) {
+ const firstMissing = mySealPositionBoxes.value.find((item) => isRequiredSealPositionMissingPlacement(item));
+ if (firstMissing) {
+ scrollToSealPositionBox(firstMissing);
+ message.warning("当前签署方还有必签位置未放章,请先把印章拖到所有必签框");
+ return;
+ }
+ }
if (!pendingSeals.length) {
message.warning("请先拖拽印章到文档上");
@@ -1676,6 +2222,26 @@ async function handlePreSign() {
}
const data = getResponseResult(res);
+ if (sealPositionEnabled.value) {
+ await batchUpdateLaunchSealPositionStatus(businessRecord.value.id, {
+ partyId: selectedPartyId.value,
+ signedStatus: SEAL_POS_STATUS_SIGNING,
+ requestId: data.requestId || "",
+ items: pendingSeals.map((seal) => ({
+ id: seal._sealPositionBoxId,
+ sealId: seal.sealId,
+ sealName: seal.sealName,
+ })),
+ });
+ sealPositionState.positions.forEach((box) => {
+ if (pendingSeals.some((seal) => Number(seal._sealPositionBoxId) === Number(box.id))) {
+ box.signedStatus = SEAL_POS_STATUS_SIGNING;
+ box.requestId = data.requestId || "";
+ }
+ });
+ // 不展示“待扫码确认”的中间覆盖物,二维码拉起后本地预放章立即隐藏。
+ sealItems.value = sealItems.value.filter((item) => !pendingSeals.some((seal) => Number(seal.id) === Number(item.id)));
+ }
qrCode.value = data.qrCode || "";
qrRequestId.value = data.requestId || "";
qrExpiresIn.value = data.expiresIn || 300;
@@ -1683,9 +2249,11 @@ async function handlePreSign() {
startQrCountdown();
startPolling(data.requestId);
- pendingSeals.forEach((seal) => {
- seal.status = "signing";
- });
+ if (!sealPositionEnabled.value) {
+ pendingSeals.forEach((seal) => {
+ seal.status = "signing";
+ });
+ }
} catch {
message.error("获取二维码失败");
} finally {
@@ -1693,7 +2261,7 @@ async function handlePreSign() {
}
}
-function handlePreSignCallback(data) {
+async function handlePreSignCallback(data, requestId = "") {
if (data.contractStatus !== undefined) {
contractStatus.value = data.contractStatus;
updateBusinessStatusByContractStatus(data.contractStatus);
@@ -1710,6 +2278,12 @@ function handlePreSignCallback(data) {
item.status = "signed";
item._signer = data.signer || "";
});
+ // 预盖章成功后只保留签章框状态,隐藏覆盖物。
+ sealItems.value = sealItems.value.filter((item) => {
+ if (item._type !== "seal") return true;
+ const box = getBoxById(item._sealPositionBoxId);
+ return !box || Number(box.signedStatus) !== SEAL_POS_STATUS_SIGNED;
+ });
if (pagingSeal.sealId) {
pagingSigned.value = true;
@@ -1717,13 +2291,18 @@ function handlePreSignCallback(data) {
stopQrCountdown();
qrVisible.value = false;
-
+ if (sealPositionEnabled.value) {
+ await refreshSealPositionStateAfterCallback(requestId || qrRequestId.value || String(data?.requestId || ""));
+ }
if (data.contractStatus === 2000) {
message.success("合约签署已完成,可直接下载文件");
} else {
+ const nextMissingBox = mySealPositionBoxes.value.find((item) => isRequiredSealPositionUnfinished(item));
+ if (nextMissingBox) {
+ scrollToSealPositionBox(nextMissingBox);
+ }
message.success(`预盖章确认成功,签署人:${data.signer || "未知"}`);
}
-
if (businessRecord.value?.id && data.contractStatus === 2000) {
writeBackLaunchSealFinished(data.contractStatus);
}
@@ -1737,6 +2316,42 @@ function refreshQrCode() {
});
}
+async function handleMockPresignSuccess() {
+ if (!qrRequestId.value || !contractId.value) {
+ message.warning("当前没有待确认的预盖章请求");
+ return;
+ }
+
+ stopPolling();
+ mockingPresignSuccess.value = true;
+ try {
+ const res = await mockPreSignSuccessCallback({
+ requestId: qrRequestId.value,
+ contractId: contractId.value,
+ signer: queryName.value || "测试签署人",
+ contractStatus: 20,
+ });
+ if (!isSuccessResponse(res)) {
+ message.error(getResponseMessage(res, "测试确认签署失败"));
+ startPolling(qrRequestId.value);
+ return;
+ }
+ await handlePreSignCallback(
+ {
+ signer: queryName.value || "测试签署人",
+ contractStatus: 20,
+ requestId: qrRequestId.value,
+ },
+ qrRequestId.value,
+ );
+ } catch {
+ message.error("测试确认签署失败");
+ startPolling(qrRequestId.value);
+ } finally {
+ mockingPresignSuccess.value = false;
+ }
+}
+
const tsFormat = ref("yyyy-MM-dd");
const tsLoading = ref(false);
const tsImageUrl = ref("");
@@ -1814,6 +2429,27 @@ async function handleCompleteContract() {
message.warning(validateMessage);
return;
}
+ if (sealPositionEnabled.value) {
+ try {
+ const checkRes = await checkLaunchSealPositions(businessRecord.value.id, {});
+ if (!isSuccessResponse(checkRes) || !getResponseResult(checkRes)) {
+ message.error(getResponseMessage(checkRes, "校验签章位失败"));
+ return;
+ }
+ const result = getResponseResult(checkRes);
+ if (!result.passed) {
+ const firstMissing = Array.isArray(result.missingPositions) ? result.missingPositions[0] : null;
+ if (firstMissing) {
+ scrollToSealPositionBox(firstMissing);
+ }
+ message.warning(`还有 ${result.missingCount || 0} 个必签位置未完成签署,请先处理后再结束合约`);
+ return;
+ }
+ } catch {
+ message.error("校验签章位失败");
+ return;
+ }
+ }
// 检查相对方签署确认情况
await loadCounterpartyConfirm();
@@ -2194,8 +2830,9 @@ watch(visible, (value) => {
}
.placed-seal .placed-seal-fallback {
- width: 80px;
- height: 40px;
+ width: 100%;
+ min-width: 80px;
+ min-height: 40px;
border: 2px dashed #409eff;
background: rgba(64, 158, 255, 0.1);
display: flex;
@@ -2203,6 +2840,10 @@ watch(visible, (value) => {
justify-content: center;
font-size: 12px;
color: #409eff;
+ padding: 8px 10px;
+ box-sizing: border-box;
+ text-align: center;
+ line-height: 1.4;
}
.placed-delete {
@@ -2343,6 +2984,69 @@ watch(visible, (value) => {
padding: 16px;
}
+.seal-position-party-select {
+ margin-top: 14px;
+}
+
+.seal-position-note {
+ margin-top: 14px;
+}
+
+.seal-position-box {
+ position: absolute;
+ z-index: 6;
+ box-sizing: border-box;
+ border: 2px dashed #94a3b8;
+ background: rgba(148, 163, 184, 0.14);
+ border-radius: 12px;
+ padding: 8px;
+ color: #334155;
+ overflow: hidden;
+ pointer-events: none;
+}
+
+.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.24);
+}
+
+.seal-position-box.is-signed {
+ border-style: solid;
+}
+
+.seal-position-box.is-missing {
+ border-color: #ef4444;
+ background: rgba(239, 68, 68, 0.14);
+ color: #b91c1c;
+}
+
+.seal-position-box-head {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 8px;
+}
+
+.seal-position-box-title {
+ font-size: 12px;
+ font-weight: 700;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.seal-position-box-meta {
+ margin-top: 4px;
+ font-size: 11px;
+ line-height: 1.4;
+}
+
.step-hint {
color: #8b95a5;
font-size: 13px;
diff --git a/src/views/contract/shared/templateDocxRenderer.js b/src/views/contract/shared/templateDocxRenderer.js
index 2580688..cfb6ba9 100644
--- a/src/views/contract/shared/templateDocxRenderer.js
+++ b/src/views/contract/shared/templateDocxRenderer.js
@@ -12,6 +12,8 @@ const TEXT_NODE = 3
const DEFAULT_WORD_TABLE_WIDTH_TWIPS = 9000
const DEFAULT_WORD_TABLE_WIDTH_PERCENT = 5000
const DEFAULT_TABLE_CELL_PADDING_TWIPS = 120
+const DEFAULT_TABLE_BORDER_SIZE = 6
+const DEFAULT_TABLE_BORDER_COLOR = '000000'
const DEFAULT_PARAGRAPH_SPACING_BEFORE = 40
const DEFAULT_PARAGRAPH_SPACING_AFTER = 40
const DEFAULT_PARAGRAPH_LINE = 300
@@ -344,49 +346,22 @@ function buildParagraphXmlBySegments(segments = [], paragraphStyle = {}) {
}
function buildTableXml(tableElement) {
- const directRows = getDirectChildElements(tableElement, 'tr')
- const rows = directRows.length ? directRows : Array.from(tableElement.getElementsByTagName('tr'))
+ const rows = extractTableRows(tableElement)
+ const tableLayout = buildTableLayout(rows)
const columnWidths = extractTableColumnWidths(tableElement)
const tableWidth = resolveTableWordWidth(readElementWidth(tableElement))
- const normalizedColumnWidths = normalizeTableColumnWidths(rows, columnWidths, tableWidth)
+ const normalizedColumnWidths = normalizeTableColumnWidths(
+ tableLayout.rows,
+ columnWidths,
+ tableWidth,
+ )
- const rowXml = rows
- .map((row) => {
- const cells = getDirectCellElements(row)
- let logicalColumnIndex = 0
- const cellXml = cells
- .map((cell) => {
- const isHeader = isHeaderCell(cell)
- const cellStyle = parseNodeStyleInfo(cell)
- const defaultCellAlign = cellStyle.align || 'center'
- const blocks = []
- collectBlocksFromChildren(cell.childNodes, blocks, {
- bold: isHeader,
- color: cellStyle.color || '',
- bgColor: '',
- align: defaultCellAlign,
- })
- const contentXml =
- blocks.filter(Boolean).join('') || buildParagraphXml('', { align: defaultCellAlign })
- const gridSpan = parseSpanValue(cell.getAttribute?.('colspan'))
- const tcWidth =
- buildCellWidthFromGrid(normalizedColumnWidths, logicalColumnIndex, gridSpan) || {
- value: 0,
- type: 'auto',
- }
- logicalColumnIndex += gridSpan
- const tcProps = [
- `
`,
- '
',
- cellStyle.bgColor
- ? `
`
- : '',
- gridSpan > 1 ? `
` : '',
- ]
- .filter(Boolean)
- .join('')
- return `
${tcProps}${contentXml}`
- })
+ const rowXml = tableLayout.rows
+ .map((rowLayout) => {
+ const cellXml = rowLayout.renderCells
+ .map((cellEntry) =>
+ buildWordTableCellXml(cellEntry, rowLayout, tableLayout.rows, normalizedColumnWidths),
+ )
.join('')
return `
${cellXml}`
})
@@ -394,7 +369,315 @@ function buildTableXml(tableElement) {
const normalizedTableWidth = buildNormalizedTableWidth(tableWidth, normalizedColumnWidths)
const tableGridXml = buildTableGridXml(normalizedColumnWidths)
- return `
${tableGridXml}${rowXml}`
+ return `
${tableGridXml}${rowXml}`
+}
+
+function extractTableRows(tableElement) {
+ const directRows = getDirectChildElements(tableElement, 'tr')
+ if (directRows.length) {
+ return directRows
+ }
+
+ const sectionRows = ['thead', 'tbody', 'tfoot'].flatMap((tagName) =>
+ getDirectChildElements(tableElement, tagName).flatMap((section) =>
+ getDirectChildElements(section, 'tr'),
+ ),
+ )
+ if (sectionRows.length) {
+ return sectionRows
+ }
+
+ return Array.from(tableElement.getElementsByTagName('tr'))
+}
+
+function buildTableLayout(rows = []) {
+ const grid = []
+ let columnCount = 0
+ let nextCellId = 1
+
+ rows.forEach((row, rowIndex) => {
+ const rowSlots = grid[rowIndex] || []
+ grid[rowIndex] = rowSlots
+ const cells = getDirectCellElements(row)
+ let searchColumnIndex = 0
+
+ cells.forEach((cell) => {
+ const startColumn = findNextAvailableTableColumn(rowSlots, searchColumnIndex)
+ const gridSpan = parseSpanValue(cell.getAttribute?.('colspan'))
+ const rowSpan = parseSpanValue(cell.getAttribute?.('rowspan'))
+ const entry = {
+ kind: 'cell',
+ id: nextCellId,
+ cell,
+ rowIndex,
+ startColumn,
+ gridSpan,
+ rowSpan,
+ }
+ nextCellId += 1
+
+ for (let offset = 0; offset < gridSpan; offset += 1) {
+ rowSlots[startColumn + offset] = entry
+ }
+
+ if (rowSpan > 1) {
+ for (let nextRowIndex = rowIndex + 1; nextRowIndex < rowIndex + rowSpan; nextRowIndex += 1) {
+ const nextRowSlots = grid[nextRowIndex] || []
+ grid[nextRowIndex] = nextRowSlots
+ const continuationEntry = {
+ kind: 'continuation',
+ id: entry.id,
+ cell,
+ rowIndex: nextRowIndex,
+ startColumn,
+ gridSpan,
+ rowSpan,
+ }
+ for (let offset = 0; offset < gridSpan; offset += 1) {
+ nextRowSlots[startColumn + offset] = continuationEntry
+ }
+ }
+ }
+
+ searchColumnIndex = startColumn + gridSpan
+ columnCount = Math.max(columnCount, startColumn + gridSpan)
+ })
+
+ columnCount = Math.max(columnCount, rowSlots.length)
+ })
+
+ return {
+ columnCount,
+ rows: rows.map((row, rowIndex) => {
+ const slots = Array.from(
+ { length: columnCount },
+ (_, columnIndex) => grid[rowIndex]?.[columnIndex] || null,
+ )
+ const renderCells = buildRenderableRowEntries(slots, rowIndex, columnCount)
+ const renderSlots = buildTableRowRenderSlots(renderCells, columnCount)
+ return { row, rowIndex, slots, renderCells, renderSlots }
+ }),
+ }
+}
+
+function buildRenderableRowEntries(slots = [], rowIndex, columnCount) {
+ const actualEntries = []
+ for (let columnIndex = 0; columnIndex < columnCount; ) {
+ const entry = slots[columnIndex]
+ if (!entry) {
+ columnIndex += 1
+ continue
+ }
+ if (entry.startColumn === columnIndex) {
+ actualEntries.push(entry)
+ columnIndex += entry.gridSpan
+ continue
+ }
+ columnIndex += 1
+ }
+
+ const hasBlank = slots.some((entry) => !entry)
+ const canRedistribute =
+ hasBlank &&
+ actualEntries.length > 0 &&
+ actualEntries.every(
+ (entry) =>
+ entry.kind === 'cell' &&
+ Number(entry.gridSpan || 1) === 1 &&
+ Number(entry.rowSpan || 1) === 1,
+ )
+
+ if (canRedistribute) {
+ return buildEvenlyDistributedRowEntries(actualEntries, rowIndex, columnCount)
+ }
+
+ const renderCells = []
+ for (let columnIndex = 0; columnIndex < columnCount; ) {
+ const entry = slots[columnIndex]
+ if (!entry) {
+ renderCells.push({
+ kind: 'blank',
+ rowIndex,
+ startColumn: columnIndex,
+ gridSpan: 1,
+ rowSpan: 1,
+ })
+ columnIndex += 1
+ continue
+ }
+ if (entry.startColumn !== columnIndex) {
+ columnIndex += 1
+ continue
+ }
+ renderCells.push(entry)
+ columnIndex += entry.gridSpan
+ }
+ return renderCells
+}
+
+function buildEvenlyDistributedRowEntries(entries = [], rowIndex, columnCount) {
+ const totalEntries = entries.length
+ const baseSpan = Math.max(1, Math.floor(columnCount / totalEntries))
+ let remainder = Math.max(0, columnCount - baseSpan * totalEntries)
+ let startColumn = 0
+
+ return entries.map((entry, index) => {
+ let gridSpan = baseSpan
+ if (remainder > 0) {
+ gridSpan += 1
+ remainder -= 1
+ }
+ if (index === totalEntries - 1) {
+ gridSpan = Math.max(1, columnCount - startColumn)
+ }
+
+ const nextEntry = {
+ ...entry,
+ rowIndex,
+ startColumn,
+ gridSpan,
+ }
+ startColumn += gridSpan
+ return nextEntry
+ })
+}
+
+function buildTableRowRenderSlots(renderCells = [], columnCount) {
+ const slots = Array.from({ length: columnCount }, () => null)
+ renderCells.forEach((entry) => {
+ const gridSpan = Math.max(1, Number(entry?.gridSpan) || 1)
+ const startColumn = Math.max(0, Number(entry?.startColumn) || 0)
+ for (let offset = 0; offset < gridSpan; offset += 1) {
+ if (startColumn + offset < columnCount) {
+ slots[startColumn + offset] = entry
+ }
+ }
+ })
+ return slots
+}
+
+function findNextAvailableTableColumn(rowSlots = [], startIndex = 0) {
+ let columnIndex = Math.max(0, startIndex)
+ while (rowSlots[columnIndex]) {
+ columnIndex += 1
+ }
+ return columnIndex
+}
+
+function buildWordTableCellXml(
+ cellEntry,
+ rowLayout,
+ tableRows = [],
+ normalizedColumnWidths = [],
+) {
+ const gridSpan = Math.max(1, Number(cellEntry?.gridSpan) || 1)
+ const startColumn = Math.max(0, Number(cellEntry?.startColumn) || 0)
+ const tcWidth = buildCellWidthFromGrid(normalizedColumnWidths, startColumn, gridSpan) || {
+ value: 0,
+ type: 'auto',
+ }
+ const borderXml = buildWordTableCellBorderXml(cellEntry, rowLayout, tableRows)
+ const baseCellProps = [
+ `
`,
+ gridSpan > 1 ? `
` : '',
+ borderXml,
+ ]
+
+ if (cellEntry?.kind === 'blank') {
+ const blankProps = [...baseCellProps, '
'].filter(Boolean).join('')
+ return `
${blankProps}${buildParagraphXml('', { align: 'left' })}`
+ }
+
+ const cell = cellEntry?.cell
+ const isHeader = isHeaderCell(cell)
+ const cellStyle = parseNodeStyleInfo(cell)
+ const defaultCellAlign = cellStyle.align || (isHeader ? 'center' : 'left')
+ const verticalAlign = normalizeWordVerticalAlign(cellStyle.verticalAlign) || 'center'
+ const tcProps = [
+ ...baseCellProps,
+ `
`,
+ cellStyle.bgColor
+ ? `
`
+ : '',
+ ]
+
+ if (cellEntry?.kind === 'continuation') {
+ tcProps.push('
')
+ return `
${tcProps.filter(Boolean).join('')}${buildParagraphXml('', { align: defaultCellAlign })}`
+ }
+
+ const blocks = []
+ collectBlocksFromChildren(cell.childNodes, blocks, {
+ bold: isHeader,
+ color: cellStyle.color || '',
+ bgColor: '',
+ align: defaultCellAlign,
+ })
+ const contentXml =
+ blocks.filter(Boolean).join('') || buildParagraphXml('', { align: defaultCellAlign })
+
+ if ((Number(cellEntry?.rowSpan) || 1) > 1) {
+ tcProps.push('
')
+ }
+
+ return `
${tcProps.filter(Boolean).join('')}${contentXml}`
+}
+
+function buildWordTableCellBorderXml(cellEntry, rowLayout, tableRows = []) {
+ const startColumn = Math.max(0, Number(cellEntry?.startColumn) || 0)
+ const endColumn = startColumn + Math.max(1, Number(cellEntry?.gridSpan) || 1) - 1
+ const currentSlots = resolveRenderedTableSlots(rowLayout)
+ const previousSlots = resolveRenderedTableSlots(tableRows?.[rowLayout?.rowIndex - 1])
+ const nextSlots = resolveRenderedTableSlots(tableRows?.[rowLayout?.rowIndex + 1])
+ const borders = []
+
+ if (!isSameCellEntry(currentSlots[startColumn - 1], cellEntry)) {
+ borders.push(buildWordBorderSideXml('left'))
+ }
+ if (!isSameCellEntry(currentSlots[endColumn + 1], cellEntry)) {
+ borders.push(buildWordBorderSideXml('right'))
+ }
+ if (!isSameCellEntryRange(previousSlots, startColumn, endColumn, cellEntry)) {
+ borders.push(buildWordBorderSideXml('top'))
+ }
+ if (!isSameCellEntryRange(nextSlots, startColumn, endColumn, cellEntry)) {
+ borders.push(buildWordBorderSideXml('bottom'))
+ }
+
+ return borders.length ? `
${borders.join('')}` : ''
+}
+
+function resolveRenderedTableSlots(rowLayout) {
+ if (Array.isArray(rowLayout?.renderSlots) && rowLayout.renderSlots.length) {
+ return rowLayout.renderSlots
+ }
+ return Array.isArray(rowLayout?.slots) ? rowLayout.slots : []
+}
+
+function isSameCellEntryRange(slots = [], startColumn, endColumn, cellEntry) {
+ if (!cellEntry || cellEntry.kind === 'blank') {
+ return false
+ }
+ for (let columnIndex = startColumn; columnIndex <= endColumn; columnIndex += 1) {
+ if (!isSameCellEntry(slots[columnIndex], cellEntry)) {
+ return false
+ }
+ }
+ return true
+}
+
+function isSameCellEntry(slotEntry, cellEntry) {
+ if (!slotEntry || !cellEntry) {
+ return false
+ }
+ if (slotEntry.kind === 'blank' || cellEntry.kind === 'blank') {
+ return false
+ }
+ return Number(slotEntry.id) === Number(cellEntry.id)
+}
+
+function buildWordBorderSideXml(sideName) {
+ return `
`
}
function getDirectChildElements(parent, tagName) {
@@ -423,8 +706,8 @@ function extractTableColumnWidths(tableElement) {
return cols.map((col) => resolveCellWordWidth(readElementWidth(col))).filter(Boolean)
}
-function normalizeTableColumnWidths(rows, initialColumnWidths, tableWidth) {
- const columnCount = getTableColumnCount(rows)
+function normalizeTableColumnWidths(tableRows, initialColumnWidths, tableWidth) {
+ const columnCount = getTableColumnCount(tableRows)
if (!columnCount) {
return []
}
@@ -435,21 +718,22 @@ function normalizeTableColumnWidths(rows, initialColumnWidths, tableWidth) {
return Number.isFinite(existingWidth) && existingWidth > 0 ? existingWidth : 0
})
- rows.forEach((row) => {
- let logicalColumnIndex = 0
- getDirectCellElements(row).forEach((cell) => {
- const gridSpan = parseSpanValue(cell.getAttribute?.('colspan'))
- const cellWidth = resolveCellWordWidth(readElementWidth(cell))
+ tableRows.forEach((rowLayout) => {
+ rowLayout.renderCells.forEach((cellEntry) => {
+ if (cellEntry?.kind !== 'cell') {
+ return
+ }
+ const gridSpan = Math.max(1, Number(cellEntry.gridSpan) || 1)
+ const cellWidth = resolveCellWordWidth(readElementWidth(cellEntry.cell))
if (cellWidth?.type === 'dxa' && cellWidth.value > 0) {
const distributedWidth = Math.max(300, Math.round(cellWidth.value / gridSpan))
for (let offset = 0; offset < gridSpan; offset += 1) {
- const columnIndex = logicalColumnIndex + offset
+ const columnIndex = cellEntry.startColumn + offset
if (columnIndex < columnCount) {
widths[columnIndex] = Math.max(widths[columnIndex] || 0, distributedWidth)
}
}
}
- logicalColumnIndex += gridSpan
})
})
@@ -478,14 +762,11 @@ function normalizeTableColumnWidths(rows, initialColumnWidths, tableWidth) {
return scaledWidths.map((width) => Math.max(300, Math.round(width)))
}
-function getTableColumnCount(rows) {
- return rows.reduce((maxCount, row) => {
- const count = getDirectCellElements(row).reduce(
- (sum, cell) => sum + parseSpanValue(cell.getAttribute?.('colspan')),
- 0,
- )
- return Math.max(maxCount, count)
- }, 0)
+function getTableColumnCount(tableRows) {
+ return tableRows.reduce(
+ (maxCount, rowLayout) => Math.max(maxCount, rowLayout?.renderSlots?.length || 0),
+ 0,
+ )
}
function resolveTableTargetWidthTwips(tableWidth, columnCount) {
@@ -627,16 +908,33 @@ function parseSpanValue(rawValue) {
function parseNodeStyleInfo(node) {
const tag = String(node?.tagName || '').toLowerCase()
const styleText = String(node?.getAttribute?.('style') || '')
+ const fontWeight = String(readStyleProperty(styleText, 'font-weight') || '').trim().toLowerCase()
+ const fontStyle = String(readStyleProperty(styleText, 'font-style') || '').trim().toLowerCase()
+ const textDecoration = String(readStyleProperty(styleText, 'text-decoration') || '')
+ .trim()
+ .toLowerCase()
const color = normalizeCssColor(readStyleProperty(styleText, 'color'))
- const bgColor = normalizeCssColor(readStyleProperty(styleText, 'background-color'))
- const textAlign = normalizeTextAlign(readStyleProperty(styleText, 'text-align'))
+ const bgColor = normalizeCssColor(
+ readStyleProperty(styleText, 'background-color') || node?.getAttribute?.('bgcolor') || '',
+ )
+ const textAlign = normalizeTextAlign(
+ readStyleProperty(styleText, 'text-align') || node?.getAttribute?.('align') || '',
+ )
+ const verticalAlign = normalizeVerticalAlign(
+ readStyleProperty(styleText, 'vertical-align') || node?.getAttribute?.('valign') || '',
+ )
return {
- bold: tag === 'strong' || tag === 'b',
- italic: tag === 'em' || tag === 'i',
- underline: tag === 'u',
+ bold:
+ tag === 'strong' ||
+ tag === 'b' ||
+ fontWeight === 'bold' ||
+ ['600', '700', '800', '900'].includes(fontWeight),
+ italic: tag === 'em' || tag === 'i' || fontStyle === 'italic' || fontStyle === 'oblique',
+ underline: tag === 'u' || textDecoration.includes('underline'),
color,
bgColor,
align: textAlign,
+ verticalAlign,
}
}
@@ -648,6 +946,7 @@ function mergeTextStyle(baseStyle = {}, overrideStyle = {}) {
color: overrideStyle.color || baseStyle.color || '',
bgColor: overrideStyle.bgColor || baseStyle.bgColor || '',
align: overrideStyle.align || baseStyle.align || '',
+ verticalAlign: overrideStyle.verticalAlign || baseStyle.verticalAlign || '',
}
}
@@ -704,12 +1003,26 @@ function normalizeCssColor(rawColor) {
function normalizeTextAlign(rawValue) {
const value = String(rawValue || '').trim().toLowerCase()
+ if (value === 'start') {
+ return 'left'
+ }
+ if (value === 'end') {
+ return 'right'
+ }
if (['left', 'center', 'right', 'justify'].includes(value)) {
return value
}
return ''
}
+function normalizeVerticalAlign(rawValue) {
+ const value = String(rawValue || '').trim().toLowerCase()
+ if (['top', 'middle', 'center', 'bottom'].includes(value)) {
+ return value
+ }
+ return ''
+}
+
function normalizeWordAlignment(value) {
if (value === 'left') return 'left'
if (value === 'center') return 'center'
@@ -718,6 +1031,13 @@ function normalizeWordAlignment(value) {
return ''
}
+function normalizeWordVerticalAlign(value) {
+ if (value === 'top') return 'top'
+ if (value === 'middle' || value === 'center') return 'center'
+ if (value === 'bottom') return 'bottom'
+ return ''
+}
+
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
}
diff --git a/src/views/contract/sign/index.vue b/src/views/contract/sign/index.vue
index fddc3dd..3518f67 100644
--- a/src/views/contract/sign/index.vue
+++ b/src/views/contract/sign/index.vue
@@ -1,29 +1,17 @@
-
+
-
+
-
+
@@ -60,17 +48,11 @@
-
+
- 查询
+ 查询
重置
@@ -86,36 +68,13 @@
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
:data="tableData"
:loading="loading"
- :loading-config="{
- icon: 'vxe-icon-indicator roll',
- text: '正在拼命加载中...',
- }"
+ :loading-config="{ icon: 'vxe-icon-indicator roll', text: '正在拼命加载中...' }"
>
-
-
-
-
+
+
+
+
{{ getContractTypeSimpleLabel(row.contractTypeCode || row.contractType) }}
@@ -127,62 +86,27 @@
-
+
{{ getStatusLabelByRow(row) }}
-
-
-
-
-
-
+
+
+
+
+
+
@@ -205,14 +129,9 @@
-
-
+
+
+
@@ -220,7 +139,9 @@
import { h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { RedoOutlined, SearchOutlined } from '@ant-design/icons-vue'
import { message, Modal } from 'ant-design-vue'
+import { getCurrentMockUser } from '@/api/mockUser'
import ContractPartyPlaceholderModal from '../launch/components/ContractPartyPlaceholderModal.vue'
+import SealPositionConfigModal from '../launch/components/SealPositionConfigModal.vue'
import MixedSignModal from '../launch/components/mixedSignModal.vue'
import UploadSignedModal from '../launch/components/UploadSignedModal.vue'
import {
@@ -231,6 +152,7 @@ import {
downloadLaunchFile,
finishLaunchSeal,
getLaunchDetail,
+ getLaunchSealPositionPage,
getLawApproveCallbackSample,
queryApproveViewUrl,
queryLaunchList,
@@ -245,6 +167,7 @@ const signStatusOptions = [
]
const contractModalRef = ref(null)
+const sealPositionConfigModalRef = ref(null)
const mixedSignModalRef = ref(null)
const uploadSignedModalRef = ref(null)
const queryContainer = ref(null)
@@ -253,6 +176,7 @@ const tableRef = ref(null)
const toolbarRef = ref(null)
const loading = ref(false)
+const currentMockUser = ref(null)
const total = ref(0)
const tableHeight = ref(420)
const tableData = ref([])
@@ -343,9 +267,6 @@ function getStatusColor(status) {
}
function getStatusLabelByRow(row) {
- if (isSingleAgreementRecord(row) && row?.status === 'pending_sign') {
- return '待盖章'
- }
return getStatusLabel(row?.status)
}
@@ -353,12 +274,34 @@ function isSingleAgreementRecord(row) {
return String(row?.isSingleAgreement || '0') === '1'
}
+function isManageOperator() {
+ if (currentMockUser.value?.superAdmin) {
+ return true
+ }
+ return String(currentMockUser.value?.systemType || '').toLowerCase() === 'manage'
+}
+
+function isSignOperator() {
+ if (currentMockUser.value?.superAdmin) {
+ return true
+ }
+ const systemType = String(currentMockUser.value?.systemType || '').toLowerCase()
+ return systemType === 'manage' || systemType === 'business'
+}
+
+function canConfigSealPosition(row) {
+ return isManageOperator()
+ && !isSingleAgreementRecord(row)
+ && String(row.isElectron || '1') === '1'
+ && row.status === 'pending_sign'
+}
+
function canOnlineSign(row) {
- return (
- !isSingleAgreementRecord(row) &&
- String(row.isElectron || '1') === '1' &&
- SIGN_STAGE_STATUSES.includes(row.status)
- )
+ return isSignOperator()
+ && !isSingleAgreementRecord(row)
+ && String(row.isElectron || '1') === '1'
+ && SIGN_STAGE_STATUSES.includes(row.status)
+ && String(row.sealPosConfigured || 'N') === 'Y'
}
function canUploadSigned(row) {
@@ -366,9 +309,6 @@ function canUploadSigned(row) {
}
function canMockFinishSign(row) {
- if (isSingleAgreementRecord(row)) {
- return row.status === 'pending_sign'
- }
return String(row.isElectron || '1') === '1' && SIGN_STAGE_STATUSES.includes(row.status)
}
@@ -411,15 +351,9 @@ async function fetchRecordsByStatus(status) {
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
- }
+ if (!data.length) break
+ if (totalCount > 0 && records.length >= totalCount) break
+ if (data.length < SIGN_LIST_QUERY_PAGE_SIZE) break
pageNo += 1
}
@@ -507,7 +441,6 @@ function handleViewApprove(row) {
message.warning('当前暂无法务合同ID,审批记录地址尚未生成')
return
}
-
queryApproveViewUrl({
contractId: String(row.lawContractId),
ivUser: row.initiator || 'admin',
@@ -528,62 +461,46 @@ function handleViewApprove(row) {
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,
- ),
- ]),
- ],
- )
+ 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) {
@@ -603,19 +520,15 @@ function handleViewLawCallback(row) {
}
function handleMockFinishSign(row) {
- const singleAgreement = isSingleAgreementRecord(row)
Modal.confirm({
- title: singleAgreement ? '测试操作:模拟盖章完成' : '测试操作:模拟签署完成',
- content: singleAgreement
- ? `模拟单方协议【${row.contractName}】内部业务章加盖完成,状态将变为「已完成」。`
- : `模拟合同【${row.contractName}】签署完成,状态将变为「待采集」。`,
+ title: '测试操作:模拟签署完成',
+ content: `模拟合同【${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
+ const activeDate = sliceDateValue(row.activeDate) || sliceDateValue(row.effectiveStart) || signDate
return finishLaunchSeal(row.id, {
sealContractId: row.sealContractId || `TEST-SEAL-${row.id}`,
sealContractStatus: 2000,
@@ -623,22 +536,16 @@ function handleMockFinishSign(row) {
signedFilePath: row.contractTextFilePath || `mock-signed/${row.id}.pdf`,
signedFileType: 'pdf',
signedFileSize: row.contractTextFileSize || null,
- signedOfflineReason: singleAgreement
- ? '测试按钮模拟内部业务章加盖完成'
- : '测试按钮模拟签署完成,真实签章接入后可删除此按钮',
+ signedOfflineReason: '测试按钮模拟签署完成,真实签章接入后可删除此按钮',
signDate: formatDateTimeValue(signDate),
sealDate: formatDateTimeValue(sealDate),
activeDate: formatDateTimeValue(activeDate),
}).then((res) => {
if (!isSuccessResponse(res)) {
- message.error(
- getResponseMessage(res, singleAgreement ? '测试盖章完成失败' : '测试签署完成失败'),
- )
+ message.error(getResponseMessage(res, '测试签署完成失败'))
return
}
- message.success(
- singleAgreement ? '测试盖章完成,单方协议已办结' : '测试签署完成,合同已流转到待采集',
- )
+ message.success('测试签署完成,合同已流转到待采集')
getList()
})
},
@@ -675,35 +582,69 @@ 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, '获取合同详情失败'))
+async function ensureDetailReady(row, actionLabel) {
+ const detailRes = await getLaunchDetail(row.id)
+ if (!isSuccessResponse(detailRes) || !getDataResult(detailRes)) {
+ message.error(getResponseMessage(detailRes, '获取合同详情失败'))
+ return null
+ }
+ let detail = getDataResult(detailRes)
+ if (detail.contractTextFilePath) {
+ return detail
+ }
+ const uploaded = await handleWatermarkUpload(row, actionLabel)
+ if (!uploaded) {
+ return null
+ }
+ const reloadRes = await getLaunchDetail(row.id)
+ if (!isSuccessResponse(reloadRes) || !getDataResult(reloadRes)) {
+ message.error(getResponseMessage(reloadRes, '获取合同详情失败'))
+ return null
+ }
+ detail = getDataResult(reloadRes)
+ return detail
+}
+
+async function handleConfigSealPosition(row) {
+ const detail = await ensureDetailReady(row, '配置签章位')
+ if (!detail) return
+ sealPositionConfigModalRef.value?.showModal(detail)
+}
+
+async function handleOnlineSign(row) {
+ const detail = await ensureDetailReady(row, '在线签订')
+ if (!detail) return
+ try {
+ const pageRes = await getLaunchSealPositionPage(row.id)
+ if (!isSuccessResponse(pageRes) || !getDataResult(pageRes)) {
+ message.error(getResponseMessage(pageRes, '获取签章位失败'))
return
}
- const detail = getDataResult(res)
- if (!detail.contractTextFilePath) {
- await handleWatermarkUpload(row)
+ const page = getDataResult(pageRes)
+ if (!Array.isArray(page.positions) || !page.positions.length) {
+ message.warning('当前合同还未配置签章位,请先点击“配置签章位”')
+ return
+ }
+ if (!Array.isArray(page.availablePartyIds) || !page.availablePartyIds.length) {
+ message.warning('当前用户未匹配到可签署方,暂时不能在线签订')
return
}
mixedSignModalRef.value?.showModal({
...detail,
contractId: detail.sealContractId,
})
- })
+ } catch {
+ message.error('获取签章位失败')
+ }
}
-async function handleWatermarkUpload(row) {
+async function handleWatermarkUpload(row, actionLabel = '在线签订') {
let file
const ok = await new Promise((resolve) => {
Modal.confirm({
title: '法务水印文件缺失',
content: h('div', [
- h(
- 'p',
- { style: 'margin-bottom: 12px' },
- '法务审批回调未带回带水印的合同文件,请手动上传带水印的合同文件后继续在线签订。',
- ),
+ h('p', { style: 'margin-bottom: 12px' }, `法务审批回调未带回带水印的合同文件,请先上传后再${actionLabel}。`),
h('input', {
type: 'file',
accept: '.pdf,.doc,.docx',
@@ -740,21 +681,25 @@ async function handleWatermarkUpload(row) {
},
})
})
- 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,
- })
- })
+ return ok
+}
+
+function handleSealPositionSaved() {
+ getList()
}
function handleSignBusinessUpdated() {
getList()
}
+async function loadCurrentMockUser() {
+ try {
+ currentMockUser.value = await getCurrentMockUser()
+ } catch {
+ currentMockUser.value = null
+ }
+}
+
function syncTableHeight() {
nextTick(() => {
const viewportHeight = window.innerHeight
@@ -765,6 +710,7 @@ function syncTableHeight() {
}
onMounted(() => {
+ loadCurrentMockUser()
handleQuery()
nextTick(() => {
connectToolbar()