首次提交
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(npm install *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
.DS_Store
|
||||
node_modules/
|
||||
dist/
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
**/*.log
|
||||
|
||||
**/dist.zip
|
||||
|
||||
tests/**/coverage/
|
||||
tests/e2e/reports
|
||||
selenium-debug.log
|
||||
|
||||
# Editor directories and files
|
||||
.idea
|
||||
.vscode
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.local
|
||||
|
||||
package-lock.json
|
||||
yarn.lock
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>盖章</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,31 @@
|
||||
{
|
||||
"name": "antd-vue3-project",
|
||||
"private": true,
|
||||
"version": "1.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"ant-design-vue": "^4.2.6",
|
||||
"axios": "^1.16.0",
|
||||
"dayjs": "^1.11.20",
|
||||
"docx-preview": "^0.3.7",
|
||||
"docxtemplater": "^3.68.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"pinia": "^2.3.1",
|
||||
"pizzip": "^3.2.0",
|
||||
"vue": "^3.5.13",
|
||||
"vue-router": "^4.5.0",
|
||||
"vxe-table": "^4.18.13",
|
||||
"xe-utils": "^4.0.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^5.2.3",
|
||||
"docx": "^9.6.1",
|
||||
"vite": "^6.3.5"
|
||||
}
|
||||
}
|
||||
Generated
+1569
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,213 @@
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import {
|
||||
AlignmentType,
|
||||
BorderStyle,
|
||||
Document,
|
||||
Footer,
|
||||
HeadingLevel,
|
||||
Packer,
|
||||
Paragraph,
|
||||
Table,
|
||||
TableCell,
|
||||
TableRow,
|
||||
TextRun,
|
||||
WidthType,
|
||||
} from 'docx'
|
||||
|
||||
const outputDir = path.resolve(process.cwd(), 'public/assets/templates')
|
||||
|
||||
function createInfoTable() {
|
||||
const rows = [
|
||||
['合同名称', '{contractName}'],
|
||||
['合同编码', '{contractCode}'],
|
||||
['合同类型', '{contractType}'],
|
||||
['合同金额', '人民币 {contractAmountDisplay}'],
|
||||
['有效期', '{effectivePeriod}'],
|
||||
['付款方式', '{paymentMethod}'],
|
||||
['合同相对方', '{counterpartySummary}'],
|
||||
['附件情况', '{attachmentSummary}'],
|
||||
]
|
||||
|
||||
return new Table({
|
||||
width: {
|
||||
size: 100,
|
||||
type: WidthType.PERCENTAGE,
|
||||
},
|
||||
rows: rows.map(
|
||||
([label, value]) =>
|
||||
new TableRow({
|
||||
children: [
|
||||
new TableCell({
|
||||
width: {
|
||||
size: 22,
|
||||
type: WidthType.PERCENTAGE,
|
||||
},
|
||||
children: [
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: label,
|
||||
bold: true,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new TableCell({
|
||||
width: {
|
||||
size: 78,
|
||||
type: WidthType.PERCENTAGE,
|
||||
},
|
||||
children: [
|
||||
new Paragraph({
|
||||
text: value,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
),
|
||||
borders: {
|
||||
top: { style: BorderStyle.SINGLE, size: 1, color: 'D9DDE7' },
|
||||
bottom: { style: BorderStyle.SINGLE, size: 1, color: 'D9DDE7' },
|
||||
left: { style: BorderStyle.SINGLE, size: 1, color: 'D9DDE7' },
|
||||
right: { style: BorderStyle.SINGLE, size: 1, color: 'D9DDE7' },
|
||||
insideHorizontal: { style: BorderStyle.SINGLE, size: 1, color: 'E5E7EB' },
|
||||
insideVertical: { style: BorderStyle.SINGLE, size: 1, color: 'E5E7EB' },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function sectionTitle(text) {
|
||||
return new Paragraph({
|
||||
text,
|
||||
heading: HeadingLevel.HEADING_2,
|
||||
spacing: {
|
||||
before: 280,
|
||||
after: 160,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function clause(text) {
|
||||
return new Paragraph({
|
||||
text,
|
||||
spacing: {
|
||||
after: 160,
|
||||
line: 360,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function writeTemplate(filename, config) {
|
||||
const doc = new Document({
|
||||
sections: [
|
||||
{
|
||||
properties: {},
|
||||
footers: {
|
||||
default: new Footer({
|
||||
children: [
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: `${config.title} - 模板演示文档`,
|
||||
color: '6B7280',
|
||||
size: 18,
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
}),
|
||||
},
|
||||
children: [
|
||||
new Paragraph({
|
||||
text: config.title,
|
||||
heading: HeadingLevel.TITLE,
|
||||
alignment: AlignmentType.CENTER,
|
||||
spacing: {
|
||||
after: 240,
|
||||
},
|
||||
}),
|
||||
new Paragraph({
|
||||
alignment: AlignmentType.CENTER,
|
||||
children: [
|
||||
new TextRun({
|
||||
text: '用于演示合同发起模块模板填充与导出',
|
||||
italics: true,
|
||||
color: '64748B',
|
||||
}),
|
||||
],
|
||||
spacing: {
|
||||
after: 280,
|
||||
},
|
||||
}),
|
||||
sectionTitle('一、合同基础信息'),
|
||||
createInfoTable(),
|
||||
sectionTitle('二、合同正文示例'),
|
||||
...config.clauses.map(clause),
|
||||
sectionTitle('三、补充说明'),
|
||||
clause('合同标的说明:{contractSubject}'),
|
||||
clause('业务备注:{businessRemark}'),
|
||||
clause('模板来源:{templateLabel}'),
|
||||
new Paragraph({
|
||||
spacing: {
|
||||
before: 360,
|
||||
after: 180,
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: '甲方(业务方):__________________',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
spacing: {
|
||||
after: 180,
|
||||
},
|
||||
children: [
|
||||
new TextRun({
|
||||
text: '乙方(相对方):__________________',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
new Paragraph({
|
||||
children: [
|
||||
new TextRun({
|
||||
text: '签署日期:{signDate}',
|
||||
}),
|
||||
],
|
||||
}),
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
const buffer = await Packer.toBuffer(doc)
|
||||
await writeFile(path.join(outputDir, filename), buffer)
|
||||
}
|
||||
|
||||
await mkdir(outputDir, { recursive: true })
|
||||
|
||||
await writeTemplate('purchase-contract-template.docx', {
|
||||
title: '采购合同模板',
|
||||
clauses: [
|
||||
'甲乙双方经友好协商,就 {contractName} 项下采购合作事宜达成一致,并共同遵守本合同。',
|
||||
'第一条 合作内容:乙方根据甲方需求提供 {contractSubject},具体交付内容以双方确认的技术资料和补充协议为准。',
|
||||
'第二条 合同金额:本合同总金额为人民币 {contractAmountDisplay},付款方式采用 {paymentMethod}。',
|
||||
'第三条 合同期限:本合同自 {effectiveStart} 起生效,至 {effectiveEnd} 止。',
|
||||
],
|
||||
})
|
||||
|
||||
await writeTemplate('sales-contract-template.docx', {
|
||||
title: '销售合同模板',
|
||||
clauses: [
|
||||
'为明确双方权利义务,甲乙双方就 {contractName} 相关销售事项签订本合同。',
|
||||
'第一条 销售范围:甲方向乙方提供 {contractSubject},合同编码为 {contractCode}。',
|
||||
'第二条 金额与结算:本合同金额合计人民币 {contractAmountDisplay},采用 {paymentMethod} 完成结算。',
|
||||
'第三条 生效与终止:本合同有效期为 {effectivePeriod},双方应按约履约。',
|
||||
],
|
||||
})
|
||||
|
||||
console.log(`Generated templates in ${outputDir}`)
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<router-view />
|
||||
</template>
|
||||
@@ -0,0 +1,73 @@
|
||||
import request from "@/utils/request";
|
||||
|
||||
function isSuccessResponse(res) {
|
||||
return String(res?.code || "") === "0000";
|
||||
}
|
||||
|
||||
function getResult(res) {
|
||||
return res?.result;
|
||||
}
|
||||
|
||||
export function queryManifestList(data) {
|
||||
return request({
|
||||
url: "/manifest/manage/list",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function queryPublishedManifestList() {
|
||||
return request({
|
||||
url: "/manifest/manage/published",
|
||||
method: "get",
|
||||
}).then((res) => (isSuccessResponse(res) ? getResult(res) || [] : []));
|
||||
}
|
||||
|
||||
export function queryManifestOrgOptions() {
|
||||
return request({
|
||||
url: "/manifest/manage/org/options",
|
||||
method: "get",
|
||||
}).then((res) => (isSuccessResponse(res) ? getResult(res) || [] : []));
|
||||
}
|
||||
|
||||
export function getManifestDetail(templateId) {
|
||||
return request({
|
||||
url: `/manifest/manage/${templateId}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function saveManifest(record, mode = "create") {
|
||||
return request({
|
||||
url: "/manifest/manage",
|
||||
method: mode === "edit" ? "put" : "post",
|
||||
data: record,
|
||||
});
|
||||
}
|
||||
|
||||
export function changeManifestStatus(templateId, status) {
|
||||
return request({
|
||||
url: `/manifest/manage/${templateId}/status/${status}`,
|
||||
method: "put",
|
||||
});
|
||||
}
|
||||
|
||||
export function uploadTemplateFile(formData) {
|
||||
return request({
|
||||
url: "/trans/minio/file/uploadOutie",
|
||||
method: "post",
|
||||
data: formData,
|
||||
headers: {
|
||||
"Content-Type": "multipart/form-data",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export function downloadTemplateFile(fileName) {
|
||||
return request({
|
||||
url: "/trans/minio/file/download",
|
||||
method: "get",
|
||||
params: { fileName },
|
||||
responseType: "arraybuffer",
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
import request from '@/utils/request'
|
||||
export function queryPartnerList(params) {
|
||||
return request({
|
||||
url: "/partner/manage/list",
|
||||
method: "post",
|
||||
data: params,
|
||||
});
|
||||
}
|
||||
|
||||
export function getPartnerInfo(id) {
|
||||
return request({
|
||||
url: `/partner/manage/${id}`,
|
||||
method: "get",
|
||||
});
|
||||
}
|
||||
|
||||
export function createPartner(data) {
|
||||
return request({
|
||||
url: "/partner/manage",
|
||||
method: "post",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function updatePartner(data) {
|
||||
return request({
|
||||
url: "/partner/manage",
|
||||
method: "put",
|
||||
data,
|
||||
});
|
||||
}
|
||||
|
||||
export function deletePartnerByIds(ids) {
|
||||
return request({
|
||||
url: `/partner/manage/${ids.join(",")}`,
|
||||
method: "delete",
|
||||
});
|
||||
}
|
||||
|
||||
export function mockSyncPartner(id) {
|
||||
return request({
|
||||
url: `/partner/manage/${id}/sync`,
|
||||
method: "post",
|
||||
});
|
||||
}
|
||||
+185
@@ -0,0 +1,185 @@
|
||||
import request from '@/utils/request'
|
||||
|
||||
// ========================================
|
||||
// Token管理
|
||||
// ========================================
|
||||
export function refreshToken() {
|
||||
return request({
|
||||
url: '/seal/api/token/getAccessToken',
|
||||
method: 'post',
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 身份认证
|
||||
// ========================================
|
||||
export function getLoginQrCode(notifyUrl) {
|
||||
return request({
|
||||
url: '/seal/api/qrcode/getLoginQrCode',
|
||||
method: 'post',
|
||||
data: { notifyUrl },
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 签署流程管理
|
||||
// ========================================
|
||||
export function startContract(formData) {
|
||||
return request({
|
||||
url: '/seal/api/contract/start',
|
||||
method: 'post',
|
||||
data: formData,
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})
|
||||
}
|
||||
|
||||
export function completeContract(contractId) {
|
||||
return request({
|
||||
url: '/seal/api/contract/complete',
|
||||
method: 'post',
|
||||
data: { contractId },
|
||||
})
|
||||
}
|
||||
|
||||
export function cancelContract(contractId) {
|
||||
return request({
|
||||
url: '/seal/api/contract/cancel',
|
||||
method: 'post',
|
||||
data: { contractId },
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 标准签章
|
||||
// ========================================
|
||||
export function getStandardSignQrCode(params) {
|
||||
return request({
|
||||
url: '/seal/api/qrcode/getStandardSignQrCode',
|
||||
method: 'post',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 预盖章
|
||||
// ========================================
|
||||
export function getPreSignQrCode(params) {
|
||||
return request({
|
||||
url: '/seal/api/qrcode/getPreSignQrCode',
|
||||
method: 'post',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 托管签章
|
||||
// ========================================
|
||||
export function getTrusteeshipSealQrCode(notifyUrl) {
|
||||
return request({
|
||||
url: '/seal/api/qrcode/getTrusteeshipSealQrCode',
|
||||
method: 'post',
|
||||
data: { notifyUrl },
|
||||
})
|
||||
}
|
||||
|
||||
export function confirmSign(params) {
|
||||
return request({
|
||||
url: '/seal/api/contract/confirmSign',
|
||||
method: 'post',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 时间签章
|
||||
// ========================================
|
||||
export function getTimestamp(formatPattern = 'yyyy-MM-dd') {
|
||||
return request({
|
||||
url: '/seal/api/seal/getTimestamp',
|
||||
method: 'post',
|
||||
data: { formatPattern },
|
||||
})
|
||||
}
|
||||
|
||||
export function signTimestamp(params) {
|
||||
return request({
|
||||
url: '/seal/api/contract/signTimestamp',
|
||||
method: 'post',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 批注签章
|
||||
// ========================================
|
||||
export function getCommentSeal(params) {
|
||||
return request({
|
||||
url: '/seal/api/seal/getCommentSeal',
|
||||
method: 'post',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
export function signCommentSeal(params) {
|
||||
return request({
|
||||
url: '/seal/api/contract/signCommentSeal',
|
||||
method: 'post',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 印章信息
|
||||
// ========================================
|
||||
export function getUserUniqueId(params) {
|
||||
return request({
|
||||
url: '/seal/api/user/getUserUniqueId',
|
||||
method: 'post',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
export function getSealBySignerId(params) {
|
||||
return request({
|
||||
url: '/seal/api/seal/getSealBySignerId',
|
||||
method: 'post',
|
||||
data: params,
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 文件信息
|
||||
// ========================================
|
||||
export function getContractDetail(contractId) {
|
||||
return request({
|
||||
url: '/seal/api/contract/getDetail',
|
||||
method: 'post',
|
||||
data: { contractId },
|
||||
})
|
||||
}
|
||||
|
||||
export function previewContract(contractId) {
|
||||
return request({
|
||||
url: '/seal/api/contract/preview',
|
||||
method: 'post',
|
||||
data: { contractId },
|
||||
})
|
||||
}
|
||||
|
||||
export function downloadContract(contractId) {
|
||||
return request({
|
||||
url: '/seal/api/contract/download',
|
||||
method: 'post',
|
||||
data: { contractId },
|
||||
})
|
||||
}
|
||||
|
||||
// ========================================
|
||||
// 回调轮询
|
||||
// ========================================
|
||||
export function pollCallback(requestId) {
|
||||
return request({
|
||||
url: '/seal/callback/poll/' + requestId,
|
||||
method: 'get',
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,194 @@
|
||||
<template>
|
||||
<a-layout class="contract-layout">
|
||||
<a-layout-sider width="244" class="contract-sider">
|
||||
<div class="sider-brand">
|
||||
<div class="brand-badge">合同管理</div>
|
||||
<h2>业务示例控制台</h2>
|
||||
<p>模拟业务系统中的发起合同与模板管理菜单结构。</p>
|
||||
</div>
|
||||
|
||||
<a-menu
|
||||
mode="inline"
|
||||
:selected-keys="[selectedKey]"
|
||||
class="contract-menu"
|
||||
@click="handleMenuClick"
|
||||
>
|
||||
<a-menu-item key="/contract-console/launch">
|
||||
<form-outlined />
|
||||
<span>发起合同</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/contract-console/manifest">
|
||||
<file-text-outlined />
|
||||
<span>模板管理</span>
|
||||
</a-menu-item>
|
||||
<a-menu-item key="/contract-console/partner">
|
||||
<team-outlined />
|
||||
<span>相关方管理</span>
|
||||
</a-menu-item>
|
||||
</a-menu>
|
||||
|
||||
<div class="sider-footer">
|
||||
<a-button block @click="router.push('/')">返回首页</a-button>
|
||||
</div>
|
||||
</a-layout-sider>
|
||||
|
||||
<a-layout>
|
||||
<a-layout-header class="contract-header">
|
||||
<div>
|
||||
<div class="header-title">{{ currentTitle }}</div>
|
||||
<div class="header-desc">
|
||||
按需求文档模拟“业务人员发起合同、合同管理员维护模板”的真实菜单入口。
|
||||
</div>
|
||||
</div>
|
||||
<a-tag color="blue">Mock + 本地测试</a-tag>
|
||||
</a-layout-header>
|
||||
|
||||
<a-layout-content class="contract-content">
|
||||
<router-view />
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
</a-layout>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { FileTextOutlined, FormOutlined, TeamOutlined } from '@ant-design/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
|
||||
const selectedKey = computed(() => {
|
||||
if (route.path.startsWith('/contract-console/manifest')) {
|
||||
return '/contract-console/manifest'
|
||||
}
|
||||
|
||||
if (route.path.startsWith('/contract-console/partner')) {
|
||||
return '/contract-console/partner'
|
||||
}
|
||||
|
||||
return '/contract-console/launch'
|
||||
})
|
||||
|
||||
const currentTitle = computed(() => route.meta?.title || '合同管理')
|
||||
|
||||
function handleMenuClick({ key }) {
|
||||
router.push(key)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.contract-layout {
|
||||
min-height: 100vh;
|
||||
background: #eef2f7;
|
||||
}
|
||||
|
||||
.contract-sider {
|
||||
background:
|
||||
radial-gradient(circle at top left, rgba(59, 130, 246, 0.18), transparent 34%),
|
||||
linear-gradient(180deg, #ffffff 0%, #f7f9fc 100%);
|
||||
border-right: 1px solid rgba(226, 232, 240, 0.88);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.sider-brand {
|
||||
padding: 24px 20px 18px;
|
||||
}
|
||||
|
||||
.brand-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border-radius: 999px;
|
||||
background: rgba(37, 99, 235, 0.1);
|
||||
color: #1d4ed8;
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.sider-brand h2 {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.sider-brand p {
|
||||
color: #64748b;
|
||||
line-height: 1.8;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.contract-menu {
|
||||
border-inline-end: none;
|
||||
background: transparent;
|
||||
padding: 8px 12px;
|
||||
}
|
||||
|
||||
.contract-menu :deep(.ant-menu-item) {
|
||||
height: 48px;
|
||||
line-height: 48px;
|
||||
border-radius: 12px;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.contract-menu :deep(.ant-menu-item-selected) {
|
||||
background: linear-gradient(90deg, rgba(37, 99, 235, 0.14), rgba(59, 130, 246, 0.06));
|
||||
color: #1d4ed8;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.sider-footer {
|
||||
margin-top: auto;
|
||||
padding: 16px 20px 20px;
|
||||
}
|
||||
|
||||
.contract-header {
|
||||
height: auto;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
border-bottom: 1px solid rgba(226, 232, 240, 0.88);
|
||||
padding: 20px 28px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.header-title {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: #0f172a;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.header-desc {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.contract-content {
|
||||
padding: 20px;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.contract-layout {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.contract-sider {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.contract-header {
|
||||
padding: 18px 20px;
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+18
@@ -0,0 +1,18 @@
|
||||
import { createApp } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import Antd from 'ant-design-vue'
|
||||
import VxeUITable from 'vxe-table'
|
||||
import 'ant-design-vue/dist/reset.css'
|
||||
import 'vxe-table/lib/style.css'
|
||||
import App from './App.vue'
|
||||
import router from './router'
|
||||
import './styles/global.css'
|
||||
|
||||
const app = createApp(App)
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.use(Antd)
|
||||
app.use(VxeUITable)
|
||||
|
||||
app.mount('#app')
|
||||
@@ -0,0 +1,67 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
path: '/',
|
||||
name: 'Home',
|
||||
component: () => import('@/views/Home.vue'),
|
||||
},
|
||||
{
|
||||
path: '/contract-console',
|
||||
component: () => import('@/layouts/ContractManageLayout.vue'),
|
||||
redirect: '/contract-console/launch',
|
||||
children: [
|
||||
{
|
||||
path: 'launch',
|
||||
name: 'ContractInitiateManage',
|
||||
component: () => import('@/views/contract/launch/index.vue'),
|
||||
meta: {
|
||||
title: '发起合同',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'manifest',
|
||||
name: 'ContractTemplateManage',
|
||||
component: () => import('@/views/contract/manifest/index.vue'),
|
||||
meta: {
|
||||
title: '模板管理',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'partner',
|
||||
name: 'ContractPartnerManage',
|
||||
component: () => import('@/views/contract/partner/index.vue'),
|
||||
meta: {
|
||||
title: '相关方管理',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/standardSign',
|
||||
name: 'StandardSign',
|
||||
component: () => import('@/views/seal/standardSign.vue'),
|
||||
},
|
||||
{
|
||||
path: '/preSign',
|
||||
name: 'PreSign',
|
||||
component: () => import('@/views/seal/preSign.vue'),
|
||||
},
|
||||
{
|
||||
path: '/trusteeshipSign',
|
||||
name: 'TrusteeshipSign',
|
||||
component: () => import('@/views/seal/trusteeshipSign.vue'),
|
||||
},
|
||||
{
|
||||
path: '/external-sign/:contractId?',
|
||||
name: 'ExternalSign',
|
||||
component: () => import('@/views/seal/externalSign.vue'),
|
||||
},
|
||||
]
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
routes,
|
||||
})
|
||||
|
||||
export default router
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useCounterStore = defineStore('counter', () => {
|
||||
const count = ref(0)
|
||||
const doubleCount = computed(() => count.value * 2)
|
||||
|
||||
function increment() {
|
||||
count.value++
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
count.value--
|
||||
}
|
||||
|
||||
return { count, doubleCount, increment, decrement }
|
||||
})
|
||||
@@ -0,0 +1,12 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto,
|
||||
'Helvetica Neue', Arial, 'Noto Sans', sans-serif;
|
||||
background: #f0f2f5;
|
||||
color: rgba(0, 0, 0, 0.88);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import axios from 'axios'
|
||||
import { message, Modal, notification } from 'ant-design-vue'
|
||||
|
||||
const service = axios.create({
|
||||
baseURL: import.meta.env.VITE_APP_BASE_API || '/',
|
||||
timeout: 30000,
|
||||
})
|
||||
|
||||
service.interceptors.request.use(
|
||||
(config) => {
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
config.headers['Authorization'] = 'Bearer ' + token
|
||||
}
|
||||
return config
|
||||
},
|
||||
(error) => Promise.reject(error),
|
||||
)
|
||||
|
||||
service.interceptors.response.use(
|
||||
(res) => {
|
||||
if (
|
||||
res.request.responseType === 'blob' ||
|
||||
res.request.responseType === 'arraybuffer'
|
||||
) {
|
||||
return res.data
|
||||
}
|
||||
const code = res.data?.code
|
||||
const normalizedCode =
|
||||
code === undefined || code === null ? undefined : String(code)
|
||||
const msg = res.data?.message || res.data?.msg || '系统错误'
|
||||
|
||||
if (normalizedCode === '401') {
|
||||
Modal.confirm({
|
||||
title: '系统提示',
|
||||
content: '登录状态已过期,请重新登录',
|
||||
okText: '重新登录',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
localStorage.removeItem('token')
|
||||
location.reload()
|
||||
},
|
||||
})
|
||||
return Promise.reject('无效的会话,或者会话已过期。')
|
||||
} else if (normalizedCode === '500') {
|
||||
message.error(msg)
|
||||
return Promise.reject(new Error(msg))
|
||||
} else if (
|
||||
normalizedCode !== undefined &&
|
||||
normalizedCode !== '200' &&
|
||||
normalizedCode !== '0000'
|
||||
) {
|
||||
notification.error({ message: '提示', description: msg })
|
||||
return Promise.reject('error')
|
||||
}
|
||||
return Promise.resolve(res.data)
|
||||
},
|
||||
(error) => {
|
||||
let msg = error.message
|
||||
if (msg === 'Network Error') {
|
||||
msg = '后端接口连接异常'
|
||||
} else if (msg.includes('timeout')) {
|
||||
msg = '系统接口请求超时'
|
||||
} else if (msg.includes('Request failed with status code')) {
|
||||
msg = '系统接口' + msg.substr(msg.length - 3) + '异常'
|
||||
}
|
||||
message.error(msg)
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export default service
|
||||
@@ -0,0 +1,311 @@
|
||||
<template>
|
||||
<div class="home">
|
||||
<div class="home-content">
|
||||
<h1 class="home-title">签章平台</h1>
|
||||
<p class="home-desc">请选择签章模式</p>
|
||||
<div class="demo-entry">
|
||||
<div class="demo-entry-actions">
|
||||
<a-button
|
||||
type="primary"
|
||||
size="large"
|
||||
class="template-demo-btn"
|
||||
@click="openTemplateFillDemo"
|
||||
>
|
||||
<file-word-outlined />
|
||||
模板填充示例
|
||||
</a-button>
|
||||
<a-button
|
||||
size="large"
|
||||
class="contract-console-btn"
|
||||
@click="$router.push('/contract-console/launch')"
|
||||
>
|
||||
<profile-outlined />
|
||||
合同管理菜单示例
|
||||
</a-button>
|
||||
</div>
|
||||
<span class="demo-entry-tip">
|
||||
合同发起 Demo:基础信息录入、模板变量填充、Word 预览与导出,以及菜单化台账页面
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 四模式卡片 -->
|
||||
<a-row :gutter="[24, 24]" justify="center">
|
||||
<a-col :xs="24" :sm="6">
|
||||
<a-card hoverable class="mode-card">
|
||||
<div class="mode-icon">
|
||||
<form-outlined :style="{ fontSize: '48px', color: '#3b82f6' }" />
|
||||
</div>
|
||||
<a-card-meta title="标准模式">
|
||||
<template #description>
|
||||
一印一扫,在指定位置添加印章,扫码签署每个印章
|
||||
</template>
|
||||
</a-card-meta>
|
||||
<template #actions>
|
||||
<span @click="$router.push('/standardSign')">
|
||||
<export-outlined /> 打开页面
|
||||
</span>
|
||||
<span @click="openStandardSign">
|
||||
<appstore-outlined /> 弹窗模式
|
||||
</span>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="6">
|
||||
<a-card hoverable class="mode-card">
|
||||
<div class="mode-icon">
|
||||
<check-circle-outlined :style="{ fontSize: '48px', color: '#10b981' }" />
|
||||
</div>
|
||||
<a-card-meta title="预盖章模式">
|
||||
<template #description>
|
||||
一文一授,获取用户印章后一次扫码确认全部印章
|
||||
</template>
|
||||
</a-card-meta>
|
||||
<template #actions>
|
||||
<span @click="$router.push('/preSign')">
|
||||
<export-outlined /> 打开页面
|
||||
</span>
|
||||
<span @click="openPreSign">
|
||||
<appstore-outlined /> 弹窗模式
|
||||
</span>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="6">
|
||||
<a-card hoverable class="mode-card">
|
||||
<div class="mode-icon">
|
||||
<safety-outlined :style="{ fontSize: '48px', color: '#e6a23c' }" />
|
||||
</div>
|
||||
<a-card-meta title="托管盖章模式">
|
||||
<template #description>
|
||||
托管印章,直接签署无需扫码,支持自动签章
|
||||
</template>
|
||||
</a-card-meta>
|
||||
<template #actions>
|
||||
<span @click="$router.push('/trusteeshipSign')">
|
||||
<export-outlined /> 打开页面
|
||||
</span>
|
||||
<span @click="openTrusteeshipSign">
|
||||
<appstore-outlined /> 弹窗模式
|
||||
</span>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="6">
|
||||
<a-card hoverable class="mode-card">
|
||||
<div class="mode-icon">
|
||||
<merge-cells-outlined :style="{ fontSize: '48px', color: '#8b5cf6' }" />
|
||||
</div>
|
||||
<a-card-meta title="混合模式">
|
||||
<template #description>
|
||||
三合一,在同一页面自由切换标准/预盖/托管三种签署模式
|
||||
</template>
|
||||
</a-card-meta>
|
||||
<template #actions>
|
||||
<span @click="openMixedSign">
|
||||
<export-outlined /> 打开弹窗
|
||||
</span>
|
||||
<span style="visibility:hidden">占位</span>
|
||||
</template>
|
||||
</a-card>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 通过 contractId 打开已有合约 -->
|
||||
<div class="contract-id-section">
|
||||
<div class="contract-id-title">或通过合约ID打开已有合约</div>
|
||||
<a-space direction="vertical" :style="{ width: '100%', maxWidth: '520px' }">
|
||||
<a-radio-group v-model:value="contractMode" button-style="solid" size="small">
|
||||
<a-radio-button value="standard">标准模式</a-radio-button>
|
||||
<a-radio-button value="presign">预盖章模式</a-radio-button>
|
||||
<a-radio-button value="trusteeship">托管盖章模式</a-radio-button>
|
||||
<a-radio-button value="mixed">混合模式</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-input-search
|
||||
v-model:value="inputContractId"
|
||||
placeholder="输入合约ID"
|
||||
enter-button="打开合约"
|
||||
size="large"
|
||||
@search="openByContractId"
|
||||
/>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 四模式 Modal -->
|
||||
<StandardSignModal ref="standardSignModalRef" />
|
||||
<PreSignModal ref="preSignModalRef" />
|
||||
<TrusteeshipSignModal ref="trusteeshipSignModalRef" />
|
||||
<MixedSignModal ref="mixedSignModalRef" />
|
||||
<ContractTemplateFillDemoModal ref="templateFillDemoModalRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { FormOutlined, CheckCircleOutlined, SafetyOutlined, ExportOutlined, AppstoreOutlined, MergeCellsOutlined, FileWordOutlined, ProfileOutlined } from '@ant-design/icons-vue'
|
||||
import StandardSignModal from '@/views/seal/standardSignModal.vue'
|
||||
import PreSignModal from '@/views/seal/preSignModal.vue'
|
||||
import TrusteeshipSignModal from '@/views/seal/trusteeshipSignModal.vue'
|
||||
import MixedSignModal from '@/views/seal/mixedSignModal.vue'
|
||||
import ContractTemplateFillDemoModal from './contract/launch/components/ContractTemplateFillDemoModal.vue'
|
||||
|
||||
const standardSignModalRef = ref(null)
|
||||
const preSignModalRef = ref(null)
|
||||
const trusteeshipSignModalRef = ref(null)
|
||||
const mixedSignModalRef = ref(null)
|
||||
const templateFillDemoModalRef = ref(null)
|
||||
const inputContractId = ref('')
|
||||
const contractMode = ref('standard')
|
||||
|
||||
function openStandardSign() {
|
||||
standardSignModalRef.value?.showModal()
|
||||
}
|
||||
function openPreSign() {
|
||||
preSignModalRef.value?.showModal()
|
||||
}
|
||||
function openTrusteeshipSign() {
|
||||
trusteeshipSignModalRef.value?.showModal()
|
||||
}
|
||||
function openMixedSign() {
|
||||
mixedSignModalRef.value?.showModal()
|
||||
}
|
||||
function openTemplateFillDemo() {
|
||||
templateFillDemoModalRef.value?.showModal()
|
||||
}
|
||||
function openByContractId(cid) {
|
||||
if (!cid) return
|
||||
if (contractMode.value === 'standard') {
|
||||
standardSignModalRef.value?.showModal(cid)
|
||||
} else if (contractMode.value === 'presign') {
|
||||
preSignModalRef.value?.showModal(cid)
|
||||
} else if (contractMode.value === 'trusteeship') {
|
||||
trusteeshipSignModalRef.value?.showModal(cid)
|
||||
} else if (contractMode.value === 'mixed') {
|
||||
mixedSignModalRef.value?.showModal(cid)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
min-height: 100vh;
|
||||
background: linear-gradient(135deg, #f5f7fa 0%, #eef2f7 100%);
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.home-content {
|
||||
width: 100%;
|
||||
max-width: 1150px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.home-title {
|
||||
font-size: 32px;
|
||||
font-weight: 700;
|
||||
color: #1e293b;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.home-desc {
|
||||
font-size: 16px;
|
||||
color: #64748b;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.demo-entry {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
margin-bottom: 36px;
|
||||
}
|
||||
|
||||
.demo-entry-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.template-demo-btn {
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
border-radius: 999px;
|
||||
box-shadow: 0 14px 28px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.contract-console-btn {
|
||||
height: 48px;
|
||||
padding: 0 24px;
|
||||
border-radius: 999px;
|
||||
border-color: #bfdbfe;
|
||||
color: #1d4ed8;
|
||||
box-shadow: 0 10px 20px rgba(148, 163, 184, 0.14);
|
||||
}
|
||||
|
||||
.demo-entry-tip {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.mode-card {
|
||||
border-radius: 16px;
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
|
||||
.mode-card:hover {
|
||||
transform: translateY(-4px);
|
||||
box-shadow: 0 12px 40px rgba(15, 23, 42, 0.12);
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.mode-card :deep(.ant-card-meta-title) {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.mode-card :deep(.ant-card-meta-description) {
|
||||
font-size: 13px;
|
||||
color: #64748b;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.mode-card :deep(.ant-card-actions) {
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
|
||||
.mode-card :deep(.ant-card-actions span) {
|
||||
cursor: pointer;
|
||||
color: #3b82f6;
|
||||
font-size: 13px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.mode-card :deep(.ant-card-actions span:hover) {
|
||||
color: #2563eb;
|
||||
}
|
||||
|
||||
.contract-id-section {
|
||||
margin-top: 40px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.contract-id-title {
|
||||
font-size: 14px;
|
||||
color: #94a3b8;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="contractName || '预盖章模式'"
|
||||
width="95%"
|
||||
:style="{ top: '30px', paddingBottom: 0 }"
|
||||
:bodyStyle="{ height: 'calc(100vh - 180px)', padding: 0, overflow: 'hidden' }"
|
||||
:destroy-on-close="false"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
wrap-class-name="seal-full-modal"
|
||||
>
|
||||
<div class="sign-platform" @click="hideCtxMenu">
|
||||
<div class="platform-layout platform-layout-empty" v-if="!contractId">
|
||||
<div class="preview-area empty-preview">
|
||||
<div class="preview-placeholder">
|
||||
<cloud-upload-outlined :style="{ fontSize: '48px', color: '#c0c4cc' }" />
|
||||
<p>上传文件后此处将显示文件预览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">1</span><span class="step-title">发起合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="upload-dragger" :class="{ 'is-dragover': dragOver }"
|
||||
@click="triggerFileInput" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="handleFileDrop">
|
||||
<input ref="fileInputRef" type="file" :accept="acceptTypes" style="display:none" @change="handleFileSelect" />
|
||||
<div class="upload-content" v-if="!uploadFile">
|
||||
<cloud-upload-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="upload-text">点击或将文件拖拽到这里上传</p>
|
||||
<p class="upload-hint">支持 xls、xlsx、pdf、docx、doc 文件,且小于10M</p>
|
||||
</div>
|
||||
<div class="upload-content" v-else>
|
||||
<file-text-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="file-name">{{ uploadFile.name }}</p>
|
||||
<p class="file-size">{{ formatFileSize(uploadFile.size) }}</p>
|
||||
<div class="btn btn-danger-text" @click.stop="removeFile">移除文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contract-name-row"><a-input v-model:value="contractName" placeholder="请输入合同/文件名称" allow-clear /></div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !uploadFile || !contractName || creating }" @click="handleCreate">
|
||||
<a-spin v-if="creating" size="small" /><span v-if="creating"> 发起中...</span><span v-else>发起签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span></div><div class="step-card-body"><p class="step-hint">上传文件后即可获取印章并拖拽至指定位置</p></div></div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div><div class="step-card-body"><p class="step-hint">可在此获取并添加时间戳</p></div></div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div><div class="step-card-body"><p class="step-hint">签署完成后可在此结束合约并下载文件</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-layout platform-layout-full" v-else>
|
||||
<div class="left-info-sidebar">
|
||||
<div class="info-card">
|
||||
<div class="info-title">合同信息</div>
|
||||
<div class="info-item"><span>合同名称</span><strong>{{ contractName }}</strong></div>
|
||||
<div class="info-item"><span>合同状态</span><strong>{{ contractStatus === 2000 ? '已完成' : contractStatus === 20 ? '签署中' : '待签署' }}</strong></div>
|
||||
<div class="info-item"><span>文件页数</span><strong>{{ pdfImages.length }} 页</strong></div>
|
||||
<div class="info-item"><span>印章数量</span><strong>{{ placedSeals.length }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-area" ref="pdfScrollRef">
|
||||
<div v-for="(imgUrl, idx) in pdfImages" :key="'p'+idx" class="pdf-page-wrapper"
|
||||
:style="{ width: dw + 'px', height: dh + 'px' }" @dragover.prevent @drop.prevent="onDropSeal($event, idx + 1)"
|
||||
@contextmenu.prevent.stop="showCtxMenu($event, idx + 1)">
|
||||
<img :src="imgUrl" class="pdf-page-image" :style="{ width: dw + 'px', height: dh + 'px' }" draggable="false" />
|
||||
<div v-for="seal in getPageSeals(idx + 1)" :key="'s'+seal.id" class="placed-seal" :class="{ 'seal-signed': seal.status === 'signed' }"
|
||||
:style="getPlacedStyle(seal)" @mousedown.stop="seal.status !== 'signed' && startDragSeal($event, seal)">
|
||||
<img v-if="seal.pictureUrl" :src="seal.pictureUrl" class="placed-seal-img" />
|
||||
<div v-else class="placed-seal-fallback">{{ seal.sealName || '印章' }}</div>
|
||||
<close-outlined v-if="seal.status !== 'signed'" class="placed-delete" @click.stop="removePlacedSeal(seal.id)" />
|
||||
</div>
|
||||
<div v-for="ts in getPageTimestamps(idx + 1)" :key="'t'+ts.id" class="placed-seal placed-ts"
|
||||
:class="{ 'seal-signed': ts.status === 'signed' }" :style="getPlacedStyle(ts)"
|
||||
@mousedown.stop="ts.status !== 'signed' && startDragSeal($event, ts)">
|
||||
<img v-if="ts._pictureUrl" :src="ts._pictureUrl" class="placed-seal-img" />
|
||||
<div v-else class="placed-seal-fallback">时间戳</div>
|
||||
<div class="ts-actions" v-if="ts.status !== 'signed'">
|
||||
<div class="btn btn-primary btn-sm" :class="{ 'btn-loading': ts.status === 'signing' }" @click.stop="confirmTimestamp(ts)">
|
||||
{{ ts.status === 'signing' ? '签署中...' : '确认' }}
|
||||
</div>
|
||||
<close-outlined class="placed-delete" @click.stop="removePlacedSeal(ts.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="paging-seal-wrapper" :class="{ 'paging-empty': !pagingSeal.sealId, 'paging-signed': pagingSigned }" @click.stop="handlePagingSealClick">
|
||||
<template v-if="!pagingSeal.sealId">
|
||||
<div class="paging-empty-box"><div class="paging-empty-text">添加骑缝章</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="paging-seal-real">
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-img" />
|
||||
<div class="paging-seal-real-text">{{ pagingSeal.sealName || '骑缝章' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span><span class="step-required">(必选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="seal-query-section">
|
||||
<div class="section-subtitle">1、获取签章</div>
|
||||
<div class="form-item"><label>姓名 <span class="required">*</span></label><a-input v-model:value="queryName" placeholder="请输入姓名" size="small" /></div>
|
||||
<div class="form-item"><label>手机号 / 身份证 <span class="required">*</span></label><a-input v-model:value="queryIdOrMobile" placeholder="手机号或身份证号" size="small" /></div>
|
||||
<div class="form-item"><label>公司名称</label><a-input v-model:value="queryEntName" placeholder="选填" size="small" /></div>
|
||||
<div class="form-item"><label>印章类型</label></div>
|
||||
<a-checkbox-group v-model:value="querySealTypes" class="seal-type-checkboxes">
|
||||
<a-checkbox :value="20">公章</a-checkbox><a-checkbox :value="10">法人章</a-checkbox>
|
||||
<a-checkbox :value="30">财务章</a-checkbox><a-checkbox :value="40">合同章</a-checkbox>
|
||||
<a-checkbox :value="50">发票章</a-checkbox><a-checkbox :value="60">其他章</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !queryName || !queryIdOrMobile || fetchingSeals }" @click="fetchSeals" style="width:100%">
|
||||
<a-spin v-if="fetchingSeals" size="small" /><span v-if="fetchingSeals"> 获取中...</span><span v-else>获取印章</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal-drag-section" v-if="sealList.length > 0">
|
||||
<div class="section-subtitle">2、拖动签章至指定签署位置({{ sealList.length }}个)</div>
|
||||
<div class="seal-drag-list">
|
||||
<div v-for="seal in sealList" :key="seal.sealId" class="seal-drag-item" draggable="true" @dragstart="onDragSealStart($event, seal)">
|
||||
<img :src="seal.pictureUrl" class="drag-seal-img" /><span class="drag-seal-name">{{ seal.sealName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paging-section" v-if="sealList.length > 0">
|
||||
<div class="section-subtitle">骑缝章(拖拽印章到下方区域)</div>
|
||||
<div class="paging-drop-zone" @dragover.prevent @drop.prevent="onDropPaging($event)">
|
||||
<span v-if="!pagingSeal.sealId">拖拽印章到这里添加骑缝章</span>
|
||||
<template v-else>
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-thumb" />
|
||||
<span>{{ pagingSeal.sealName }}</span>
|
||||
<close-outlined class="del-icon" @click="clearPaging" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': preSigning || (placedSeals.filter(s => s.status === 'pending' && s._type === 'seal').length === 0 && !pagingSeal.sealId) }"
|
||||
@click="handlePreSign" style="width:100%;margin-top:10px">
|
||||
<a-spin v-if="preSigning" size="small" /><span v-if="preSigning"> 签署中...</span>
|
||||
<span v-else>确认签署({{ placedSeals.filter(s => s.status === 'pending' && s._type === 'seal').length }}印章{{ pagingSeal.sealId ? ' + 骑缝章' : '' }})</span>
|
||||
</div>
|
||||
<p class="sign-hint" v-if="placedSeals.length > 0 || pagingSeal.sealId">提示:请先签章预授权,一次扫码确认全部印章</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="form-item"><label>时间格式</label>
|
||||
<a-select v-model:value="tsFormat" size="small" style="width:100%">
|
||||
<a-select-option value="yyyy-MM-dd">yyyy-MM-dd</a-select-option>
|
||||
<a-select-option value="yyyy年MM月dd日">yyyy年MM月dd日</a-select-option>
|
||||
<a-select-option value="yyyy/MM/dd">yyyy/MM/dd</a-select-option>
|
||||
<a-select-option value="yyyy-MM-dd HH:mm:ss">yyyy-MM-dd HH:mm:ss</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': tsLoading }" @click="fetchTimestamp" style="width:100%">
|
||||
<a-spin v-if="tsLoading" size="small" /><span v-if="tsLoading"> 获取中...</span><span v-else>获取时间戳</span>
|
||||
</div>
|
||||
<div class="ts-drag-area" v-if="tsImageUrl">
|
||||
<div class="ts-drag-item" draggable="true" @dragstart="onDragTsStart"><img :src="tsImageUrl" class="drag-seal-img" /><span>拖拽时间戳到文档上</span></div>
|
||||
<p class="ts-hint">也可在文档上右键 → 添加时间戳</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': contractStatus === 2000 || completing }" @click="handleComplete">
|
||||
<check-circle-outlined /><span v-if="completing">结束中...</span><span v-else>结束合约</span>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': contractStatus !== 2000 }" @click="handleDownload" style="margin-top:10px;margin-left:10px">
|
||||
<download-outlined /> 下载已签署文件
|
||||
</div>
|
||||
<div class="contract-status-tag">
|
||||
<span v-if="contractStatus === 2000" class="tag-success">合约已完成</span>
|
||||
<span v-else-if="contractStatus === 20" class="tag-warning">签署进行中</span>
|
||||
<span v-else class="tag-info">等待签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="ctxVisible" class="ctx-menu-mask" @click="hideCtxMenu" @contextmenu.prevent="hideCtxMenu"></div>
|
||||
<div v-show="ctxVisible" class="ctx-menu-panel" :style="{ left: ctxX + 'px', top: ctxY + 'px' }">
|
||||
<div class="ctx-menu-item" @click.stop="ctxAddTimestamp"><clock-circle-outlined /> 添加时间戳</div>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="qrVisible" title="扫码签署" width="420px" :mask-closable="false" :footer="null" :closable="false">
|
||||
<div class="qr-dialog-body">
|
||||
<div class="qr-img-box" v-if="qrCode"><img :src="qrCode" class="qr-img" /></div>
|
||||
<div class="qr-img-box qr-loading" v-else><a-spin size="large" /><p>二维码加载中...</p></div>
|
||||
<a-alert message="请使用微信扫描二维码完成签署" type="success" :closable="false" show-icon style="margin-top:12px" />
|
||||
<div class="qr-countdown" v-if="qrCount > 0">
|
||||
<a-progress :percent="qrPct" :stroke-color="qrClr" :stroke-width="6" :show-info="false" />
|
||||
<p class="countdown-text">有效期剩余:<span :style="{ color: qrClr }">{{ qrTxt }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;margin-top:16px">
|
||||
<a-button @click="closeQr" style="margin-right:8px">关闭</a-button>
|
||||
<a-button type="primary" @click="refreshQr" :loading="qrRefreshing">刷新二维码</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
CloudUploadOutlined, FileTextOutlined, ClockCircleOutlined, CloseOutlined,
|
||||
CheckCircleOutlined, DownloadOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
startContract, getPreSignQrCode, completeContract, downloadContract,
|
||||
pollCallback, getTimestamp, signTimestamp, getContractDetail, previewContract,
|
||||
} from '@/api/seal'
|
||||
|
||||
// ==================== 对外暴露 ====================
|
||||
const visible = ref(false)
|
||||
|
||||
async function showModal(contractIdParam) {
|
||||
visible.value = true
|
||||
if (contractIdParam) {
|
||||
await loadContract(contractIdParam)
|
||||
if (contractId.value) {
|
||||
await nextTick()
|
||||
nextTick(calcSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContract(cid) {
|
||||
try {
|
||||
const [detailRes, previewRes] = await Promise.all([
|
||||
getContractDetail(cid),
|
||||
previewContract(cid),
|
||||
])
|
||||
const detail = detailRes?.code === 200 ? detailRes.data : null
|
||||
const preview = previewRes?.code === 200 ? previewRes.data : null
|
||||
if (detail || preview) {
|
||||
contractId.value = cid
|
||||
contractName.value = detail?.contractName || ''
|
||||
contractStatus.value = detail?.contractStatus ?? detail?.status ?? null
|
||||
pdfImages.value = preview?.pdfToImageList || []
|
||||
imageWidth.value = Number(preview?.imageWidth) || 793
|
||||
imageHeight.value = Number(preview?.imageHeight) || 1122
|
||||
saveContractState()
|
||||
}
|
||||
} catch {
|
||||
const saved = JSON.parse(localStorage.getItem('presign_contract') || '{}')
|
||||
if (saved.contractId === cid) {
|
||||
contractId.value = saved.contractId; contractName.value = saved.contractName || ''
|
||||
contractStatus.value = saved.contractStatus; pdfImages.value = saved.pdfImages || []
|
||||
imageWidth.value = saved.imageWidth || 793; imageHeight.value = saved.imageHeight || 1122
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ showModal })
|
||||
|
||||
// ==================== 文件上传 ====================
|
||||
const fileInputRef = ref(null), uploadFile = ref(null), contractName = ref(''), creating = ref(false), dragOver = ref(false), acceptTypes = '.xls,.xlsx,.pdf,.docx,.doc'
|
||||
function triggerFileInput() { fileInputRef.value?.click() }
|
||||
function handleFileSelect(e) { const f = e.target.files?.[0]; if (f) validateFile(f); e.target.value = '' }
|
||||
function handleFileDrop(e) { dragOver.value = false; const f = e.dataTransfer.files?.[0]; if (f) validateFile(f) }
|
||||
function validateFile(f) {
|
||||
const ext = f.name.split('.').pop()?.toLowerCase()
|
||||
if (!['xls','xlsx','pdf','docx','doc'].includes(ext)) { message.error(`不支持 "${ext}"`); return }
|
||||
if (f.size > 10485760) { message.error('文件不能超过10MB'); return }
|
||||
uploadFile.value = f; if (!contractName.value) contractName.value = f.name.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
function removeFile() { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = '' }
|
||||
function formatFileSize(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB' }
|
||||
|
||||
// ==================== localStorage ====================
|
||||
const STORAGE_KEY = 'presign_contract'
|
||||
function saveContractState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ contractId: contractId.value, contractName: contractName.value, contractStatus: contractStatus.value, pdfImages: pdfImages.value, imageWidth: imageWidth.value, imageHeight: imageHeight.value })) } catch {} }
|
||||
function clearContractState() { try { localStorage.removeItem(STORAGE_KEY) } catch {} }
|
||||
|
||||
// ==================== 签署流程 ====================
|
||||
const contractId = ref(''), contractStatus = ref(null), pdfImages = ref([]), imageWidth = ref(793), imageHeight = ref(1122), completing = ref(false)
|
||||
async function handleCreate() {
|
||||
if (!uploadFile.value || !contractName.value) { message.warning('请先选择文件并输入合同名称'); return }
|
||||
creating.value = true
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', uploadFile.value); fd.append('contractName', contractName.value)
|
||||
const res = await startContract(fd)
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data; contractId.value = d.contractId; pdfImages.value = d.pdfToImageList || []
|
||||
imageWidth.value = d.imageWidth || 793; imageHeight.value = d.imageHeight || 1122; contractStatus.value = 10
|
||||
saveContractState(); message.success('签署流程创建成功!'); nextTick(calcSize)
|
||||
} else { message.error(res.msg || '创建失败') }
|
||||
} catch { message.error('创建失败') } finally { creating.value = false }
|
||||
}
|
||||
|
||||
// ==================== PDF 显示 ====================
|
||||
const dw = ref(700), dh = ref(990), sr = ref(1), pdfScrollRef = ref(null)
|
||||
function calcSize() {
|
||||
if (!pdfScrollRef.value) return
|
||||
const cw = pdfScrollRef.value.clientWidth - 40; const mw = Math.min(cw, 800)
|
||||
if (imageWidth.value > 0) { sr.value = mw / imageWidth.value; dw.value = mw; dh.value = Math.round(imageHeight.value * sr.value) }
|
||||
}
|
||||
onMounted(() => { window.addEventListener('resize', calcSize); nextTick(calcSize) })
|
||||
onUnmounted(() => { window.removeEventListener('resize', calcSize) })
|
||||
watch(pdfImages, () => nextTick(calcSize))
|
||||
|
||||
// ==================== 印章查询 ====================
|
||||
const queryName = ref(''), queryIdOrMobile = ref(''), queryEntName = ref(''), querySealTypes = ref([20,10,30,40,50,60]), sealList = ref([]), fetchingSeals = ref(false)
|
||||
function generateMockSealSvg(text, bgColor, borderColor) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
|
||||
<circle cx="60" cy="60" r="55" fill="${bgColor}" stroke="${borderColor}" stroke-width="3"/>
|
||||
<circle cx="60" cy="60" r="48" fill="none" stroke="${borderColor}" stroke-width="1.5" opacity="0.5"/>
|
||||
<text x="60" y="66" text-anchor="middle" font-size="14" font-weight="bold" fill="${borderColor}" font-family="serif">${text}</text></svg>`
|
||||
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)))
|
||||
}
|
||||
async function fetchSeals() {
|
||||
if (!queryName.value || !queryIdOrMobile.value) { message.warning('请填写姓名和手机号/身份证号'); return }
|
||||
fetchingSeals.value = true; await new Promise(r => setTimeout(r, 500))
|
||||
sealList.value = [
|
||||
{ sealId: 'mock_seal_001', sealName: '测试公章', pictureUrl: generateMockSealSvg('公章', 'rgba(220,60,60,0.08)', '#dc3c3c'), owner: queryName.value },
|
||||
{ sealId: 'mock_seal_002', sealName: '测试合同章', pictureUrl: generateMockSealSvg('合同章', 'rgba(37,99,235,0.08)', '#2563eb'), owner: queryName.value },
|
||||
]
|
||||
message.success(`获取到 ${sealList.value.length} 个模拟印章,请拖拽到文档上`)
|
||||
fetchingSeals.value = false
|
||||
}
|
||||
|
||||
// ==================== 印章放置 ====================
|
||||
const placedSeals = ref([]); let sealPlacedId = 0
|
||||
const pagingSigned = ref(false)
|
||||
const pagingSeal = reactive({ sealId: '', sealName: '', pictureUrl: '' })
|
||||
function handlePagingSealClick() { if (pagingSigned.value) { message.warning('骑缝章已签署'); return } if (!pagingSeal.sealId) message.info('请从印章列表拖拽印章到骑缝章区域') }
|
||||
function onDropPaging(e) { if (!dragSealData || dragSealData._isTimestamp) return; pagingSeal.sealId = dragSealData.sealId; pagingSeal.sealName = dragSealData.sealName; pagingSeal.pictureUrl = dragSealData.pictureUrl; message.success(`已设置骑缝章:${dragSealData.sealName}`); dragSealData = null }
|
||||
function clearPaging() { Object.assign(pagingSeal, { sealId: '', sealName: '', pictureUrl: '' }) }
|
||||
function getPageSeals(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type !== 'timestamp') }
|
||||
function getPageTimestamps(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type === 'timestamp') }
|
||||
function getPlacedStyle(s) { return { left: (s.x * sr.value) + 'px', top: (s.y * sr.value) + 'px' } }
|
||||
let dragSealData = null
|
||||
function onDragSealStart(e, seal) { dragSealData = seal; e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', seal.sealId || 'seal') }
|
||||
const tsImageUrl = ref(''), tsFormat = ref('yyyy-MM-dd'), tsLoading = ref(false), tsData = ref(null)
|
||||
async function fetchTimestamp() {
|
||||
tsLoading.value = true
|
||||
try { const res = await getTimestamp(tsFormat.value); if (res.code === 200 && res.data?.pictureUrl) { tsImageUrl.value = res.data.pictureUrl; tsData.value = { pictureUrl: res.data.pictureUrl }; message.success('已获取,请拖拽到文档上') } else message.error(res.msg || '获取失败') } catch { message.error('获取失败') } finally { tsLoading.value = false }
|
||||
}
|
||||
function onDragTsStart(e) { dragSealData = { ...tsData.value, _isTimestamp: true }; e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', 'timestamp') }
|
||||
function onDropSeal(e, pageNum) {
|
||||
if (!dragSealData) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const ox = Math.round(((e.clientX - rect.left) / sr.value) * 100) / 100, oy = Math.round(((e.clientY - rect.top) / sr.value) * 100) / 100
|
||||
const isTs = dragSealData._isTimestamp
|
||||
placedSeals.value.push({ id: ++sealPlacedId, _type: isTs ? 'timestamp' : 'seal', sealId: isTs ? null : dragSealData.sealId, sealName: isTs ? (dragSealData.sealName || '时间戳') : dragSealData.sealName, pictureUrl: dragSealData.pictureUrl, _pictureUrl: dragSealData.pictureUrl, owner: dragSealData.owner || '', pageNum, x: ox, y: oy, scale: 1, status: 'pending' })
|
||||
if (!isTs) dragSealData = null
|
||||
}
|
||||
function removePlacedSeal(id) { const s = placedSeals.value.find(v => v.id === id); if (s?.status === 'signed') { message.warning('已签署的印章不可删除'); return } placedSeals.value = placedSeals.value.filter(v => v.id !== id) }
|
||||
|
||||
// ==================== 拖拽调整 ====================
|
||||
let ds = null, dsx = 0, dsy = 0, dox = 0, doy = 0
|
||||
function startDragSeal(e, seal) {
|
||||
e.preventDefault(); ds = seal; dsx = e.clientX; dsy = e.clientY; dox = seal.x; doy = seal.y
|
||||
const mv = (ev) => { if (!ds) return; ds.x = Math.round((dox + (ev.clientX - dsx) / sr.value) * 100) / 100; ds.y = Math.round((doy + (ev.clientY - dsy) / sr.value) * 100) / 100 }
|
||||
const up = () => { ds = null; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up) }
|
||||
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up)
|
||||
}
|
||||
|
||||
// ==================== 确认签署 ====================
|
||||
const preSigning = ref(false), qrVisible = ref(false), qrCode = ref(''), qrRid = ref(''), qrExp = ref(300), qrRefreshing = ref(false), qrCount = ref(0)
|
||||
let qrTimer = null, pollTimer = null
|
||||
const qrPct = computed(() => qrExp.value ? Math.round((qrCount.value / qrExp.value) * 100) : 0)
|
||||
const qrClr = computed(() => qrCount.value <= 30 ? '#f56c6c' : qrCount.value <= 60 ? '#e6a23c' : '#67c23a')
|
||||
const qrTxt = computed(() => { const m = Math.floor(qrCount.value / 60); const s = qrCount.value % 60; return `${m}分${String(s).padStart(2, '0')}秒` })
|
||||
async function handlePreSign() {
|
||||
const pending = placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal')
|
||||
if (!pending.length) { message.warning('请先拖拽印章到文档上'); return }
|
||||
preSigning.value = true
|
||||
try {
|
||||
const host = window.location.protocol + '//' + window.location.host
|
||||
const sealConfigs = pending.map(s => ({ sealId: s.sealId, x: s.x, y: s.y, scale: s.scale || 1, pageNum: s.pageNum }))
|
||||
const pagingSealConfigs = pagingSeal.sealId ? [{ sealId: pagingSeal.sealId, scale: 1, startPage: 1, endPage: pdfImages.value.length, coordinateY: Math.round(dh.value / 2) }] : null
|
||||
const res = await getPreSignQrCode({ contractId: contractId.value, notifyUrl: host + '/seal/callback/presign', isNeedUrlLink: true, sealConfigs, pagingSealConfigs, timestampSealConfigs: null })
|
||||
if (res.code === 200 && res.data) { const d = res.data; qrCode.value = d.qrCode || ''; qrRid.value = d.requestId || ''; qrExp.value = d.expiresIn || 300; qrVisible.value = true; startQrCt(); startPoll(d.requestId); pending.forEach(s => s.status = 'signing') }
|
||||
else { message.error(res.msg || '获取二维码失败') }
|
||||
} catch { message.error('获取二维码失败') } finally { preSigning.value = false }
|
||||
}
|
||||
function startQrCt() { stopQrCt(); qrCount.value = qrExp.value; qrTimer = setInterval(() => { qrCount.value--; if (qrCount.value <= 0) stopQrCt() }, 1000) }
|
||||
function stopQrCt() { if (qrTimer) { clearInterval(qrTimer); qrTimer = null } }
|
||||
function closeQr() { stopQrCt(); stopPoll(); qrVisible.value = false; placedSeals.value.filter(s => s.status === 'signing').forEach(s => s.status = 'pending') }
|
||||
function startPoll(rid) { stopPoll(); const max = Math.ceil(qrExp.value / 2) + 10; let n = 0; pollTimer = setInterval(async () => { n++; if (n > max || !qrVisible.value) { stopPoll(); return } try { const r = await pollCallback(rid); if (r.code === 200 && r.data) { stopPoll(); onCallback(r.data) } } catch {} }, 2000) }
|
||||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
|
||||
function onCallback(data) { if (data.contractStatus !== undefined) { contractStatus.value = data.contractStatus; if (data.contractStatus === 2000) clearContractState(); else saveContractState() } placedSeals.value.filter(s => s.status === 'signing').forEach(s => { s.status = 'signed'; s._signer = data.signer || '' }); pagingSigned.value = true; if (data.contractStatus === 2000) message.success('合约签署已完结!'); else message.success(`签署成功!签署人: ${data.signer || '未知'}`); if (qrVisible.value) { stopQrCt(); qrVisible.value = false } }
|
||||
function refreshQr() { stopPoll(); handlePreSign() }
|
||||
|
||||
// ==================== 时间戳签署 ====================
|
||||
async function confirmTimestamp(ts) {
|
||||
if (ts.status !== 'pending') return; ts.status = 'signing'
|
||||
try { const res = await signTimestamp({ contractId: contractId.value, sealConfigs: [{ formatPattern: tsFormat.value, x: ts.x, y: ts.y, pageNum: ts.pageNum, scale: ts.scale || 1 }] }); if (res.code === 200 && res.data) { ts.status = 'signed'; ts._signTime = res.data.signatureTime || ''; if (res.data.pdfToImageList?.length) pdfImages.value = res.data.pdfToImageList; if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus; message.success('时间戳签署成功') } else { message.error(res.msg || '失败'); ts.status = 'pending' } } catch { message.error('失败'); ts.status = 'pending' }
|
||||
}
|
||||
|
||||
// ==================== 完成 & 下载 ====================
|
||||
async function handleComplete() {
|
||||
if (contractStatus.value === 2000) { message.warning('合约已结束'); return }
|
||||
Modal.confirm({ title: '确认结束', content: '确认结束合约?', okType: 'danger', onOk: async () => { completing.value = true; try { const res = await completeContract(contractId.value); if (res.code === 200) { contractStatus.value = 2000; clearContractState(); message.success('合约已完成') } else message.error(res.msg || '结束失败') } catch { message.error('结束失败') } finally { completing.value = false } } })
|
||||
}
|
||||
async function handleDownload() { if (contractStatus.value !== 2000) { message.warning('请先结束合约'); return } try { const res = await downloadContract(contractId.value); if (res.code === 200 && res.data?.downloadUrl) window.open(res.data.downloadUrl, '_blank'); else message.error('获取链接失败') } catch { message.error('下载失败') } }
|
||||
|
||||
// ==================== 右键菜单 ====================
|
||||
const ctxVisible = ref(false), ctxX = ref(0), ctxY = ref(0), ctxPn = ref(1), ctxOx = ref(0), ctxOy = ref(0)
|
||||
function showCtxMenu(e, pn) { const rect = e.currentTarget.getBoundingClientRect(); ctxOx.value = Math.round(((e.clientX - rect.left) / sr.value) * 100) / 100; ctxOy.value = Math.round(((e.clientY - rect.top) / sr.value) * 100) / 100; ctxVisible.value = true; ctxX.value = e.clientX; ctxY.value = e.clientY; ctxPn.value = pn }
|
||||
function hideCtxMenu() { ctxVisible.value = false }
|
||||
function ctxAddTimestamp() { if (!tsImageUrl.value) { message.warning('请先在第三步获取时间戳'); hideCtxMenu(); return }; placedSeals.value.push({ id: ++sealPlacedId, _type: 'timestamp', sealId: null, sealName: '时间戳', pictureUrl: tsData.value?.pictureUrl, _pictureUrl: tsData.value?.pictureUrl, owner: '', pageNum: ctxPn.value, x: ctxOx.value, y: ctxOy.value, scale: 1, status: 'pending' }); hideCtxMenu() }
|
||||
onUnmounted(() => { stopPoll(); stopQrCt() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sign-platform { width:100%;height:100%;background:#f5f7fa;font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#2c3e50;overflow:hidden;display:flex;flex-direction:column }
|
||||
.platform-layout { flex:1;overflow:hidden }
|
||||
.platform-layout-empty { display:grid;grid-template-columns:minmax(0,1fr) 380px }
|
||||
.platform-layout-full { display:grid;grid-template-columns:260px minmax(0,1fr) 380px }
|
||||
.preview-area { background:linear-gradient(to bottom,#f8fafc,#eef2f7);overflow-y:auto;padding:30px 50px;display:flex;flex-direction:column;align-items:center;min-width:0;gap:28px }
|
||||
.empty-preview { justify-content:center;align-items:center;background:#f0f2f5;min-width:0 }
|
||||
.preview-placeholder { width:420px;padding:60px 40px;border-radius:20px;background:rgba(255,255,255,.7);backdrop-filter:blur(10px);box-shadow:0 10px 40px rgba(15,23,42,.06);border:1px solid rgba(255,255,255,.8);display:flex;flex-direction:column;align-items:center }
|
||||
.preview-placeholder p { margin-top:14px;font-size:15px;color:#64748b }
|
||||
.pdf-page-wrapper { position:relative;border-radius:12px;overflow:visible;background:white;box-shadow:0 12px 40px rgba(15,23,42,.08),0 2px 8px rgba(15,23,42,.04);transition:all .2s ease }
|
||||
.pdf-page-wrapper:hover { transform:translateY(-2px) }
|
||||
.pdf-page-image { display:block;user-select:none;pointer-events:none }
|
||||
.placed-seal { position:absolute;z-index:10;cursor:move;user-select:none }
|
||||
.placed-seal .placed-seal-img { width:80px;height:auto;opacity:.85;display:block;pointer-events:none }
|
||||
.placed-seal .placed-seal-fallback { width:80px;height:40px;border:2px dashed #409eff;background:rgba(64,158,255,.1);display:flex;align-items:center;justify-content:center;font-size:12px;color:#409eff }
|
||||
.placed-delete { position:absolute;top:-8px;right:-8px;background:#f56c6c;color:#fff;border-radius:50%;cursor:pointer;font-size:14px;padding:2px }
|
||||
.placed-delete:hover { background:#e03838 }
|
||||
.placed-seal.seal-signed { cursor:default }
|
||||
.placed-seal.seal-signed .placed-seal-img { opacity:1 }
|
||||
.placed-seal.seal-signed .placed-delete { display:none }
|
||||
.placed-seal.placed-ts .placed-seal-img { width:130px }
|
||||
.placed-seal.placed-ts .ts-actions { display:flex;align-items:center;gap:4px;margin-top:4px;background:#fff;padding:2px 6px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.1) }
|
||||
.paging-seal-wrapper { position:absolute;top:50%;right:-18px;transform:translateY(-50%);width:52px;height:fit-content;display:flex;align-items:center;justify-content:center;z-index:50;cursor:pointer;transition:all .25s ease }
|
||||
.paging-seal-wrapper:hover { transform:translateY(-50%) scale(1.03) }
|
||||
.paging-empty-box { width:100%;border:2px dashed #cbd5e1;border-radius:12px;background:rgba(248,250,252,.95);display:flex;align-items:center;justify-content:center;color:#94a3b8;transition:all .2s ease;padding:10px }
|
||||
.paging-empty-text { writing-mode:vertical-rl;text-orientation:upright;font-size:16px;letter-spacing:4px;font-weight:600;line-height:1.4 }
|
||||
.paging-empty-box:hover { border-color:#3b82f6;color:#3b82f6;background:#eff6ff }
|
||||
.paging-seal-real { width:100%;border:2px solid rgba(210,50,50,.65);background:radial-gradient(circle at center,rgba(255,255,255,.04),rgba(255,0,0,.03));border-radius:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:.9;padding:8px 4px;box-shadow:0 0 12px rgba(210,50,50,.2),inset 0 0 8px rgba(210,50,50,.1) }
|
||||
.paging-seal-img { width:32px;height:32px;object-fit:contain;margin-bottom:4px }
|
||||
.paging-seal-real-text { writing-mode:vertical-rl;text-orientation:upright;font-size:14px;letter-spacing:2px;color:rgba(210,50,50,.72);font-family:"STFangsong","FangSong","SimSun",serif;user-select:none }
|
||||
.left-info-sidebar { background:#fff;border-right:1px solid #ebeef5;padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:20px }
|
||||
.info-card { background:#fff;border-radius:16px;padding:20px;box-shadow:0 4px 20px rgba(15,23,42,.05);border:1px solid #eef2f6 }
|
||||
.info-title { font-size:16px;font-weight:700;color:#1e293b;margin-bottom:18px }
|
||||
.info-item { display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px dashed #eef2f6;font-size:14px }
|
||||
.info-item:last-child { border-bottom:none } .info-item span { color:#64748b } .info-item strong { color:#0f172a }
|
||||
.operation-sidebar { width:380px;background:#fff;border-left:1px solid #e2e6ed;display:flex;flex-direction:column;padding:20px;gap:16px;overflow-y:auto;box-shadow:-2px 0 12px rgba(0,0,0,.04) }
|
||||
.step-card { background:#fff;border:1px solid #eef1f5;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.04);flex-shrink:0 }
|
||||
.step-card-disabled { opacity:.6 }
|
||||
.step-card-header { padding:14px 16px;background:#f8f9fb;border-bottom:1px solid #f0f2f5;display:flex;align-items:center;gap:10px;flex-wrap:wrap }
|
||||
.step-badge { display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#3b6fa0;color:#fff;border-radius:6px;font-size:14px;font-weight:600;flex-shrink:0 }
|
||||
.step-title { font-size:15px;font-weight:600;color:#2c3e50 }
|
||||
.step-required { font-size:12px;color:#c88a2e;margin-left:4px }
|
||||
.step-card-body { padding:16px } .step-hint { color:#8b95a5;font-size:13px;margin:0 }
|
||||
.seal-query-section { margin-bottom:14px } .section-subtitle { font-size:13px;font-weight:600;color:#2c3e50;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #f0f2f5 }
|
||||
.form-item { margin-bottom:10px } .form-item label { display:block;margin-bottom:4px;font-size:13px;color:#606266 } .form-item .required { color:#f56c6c }
|
||||
.seal-type-checkboxes { display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px }
|
||||
.seal-drag-section { margin-bottom:14px } .seal-drag-list { display:flex;flex-wrap:wrap;gap:8px }
|
||||
.seal-drag-item { width:70px;cursor:grab;text-align:center;border:1px solid #e4e7ed;border-radius:6px;padding:6px;transition:all .2s }
|
||||
.seal-drag-item:hover { border-color:#409eff;box-shadow:0 2px 8px rgba(64,158,255,.2) }
|
||||
.seal-drag-item:active { cursor:grabbing } .drag-seal-img { width:60px;height:auto;display:block;margin:0 auto 4px }
|
||||
.drag-seal-name { font-size:10px;color:#606266;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block }
|
||||
.paging-section { margin-bottom:14px }
|
||||
.paging-drop-zone { border:2px dashed #e6a23c;border-radius:6px;padding:16px;text-align:center;color:#e6a23c;font-size:13px;min-height:50px;display:flex;align-items:center;justify-content:center;gap:8px;background:rgba(230,162,60,.04);cursor:pointer;transition:all .2s }
|
||||
.paging-drop-zone:hover { border-color:#e6a23c;background:rgba(230,162,60,.1) }
|
||||
.paging-seal-thumb { width:40px;height:40px;object-fit:contain } .del-icon { cursor:pointer;color:#f56c6c;font-size:14px } .del-icon:hover { color:#e03838 }
|
||||
.ts-drag-area { margin-top:10px;padding:10px;background:#f5f7fa;border-radius:6px;border:1px dashed #dcdfe6 }
|
||||
.ts-drag-item { width:130px;cursor:grab;text-align:center;padding:6px;border:1px solid #e4e7ed;border-radius:6px;background:#fff }
|
||||
.ts-drag-item:hover { border-color:#67c23a } .ts-drag-item:active { cursor:grabbing }
|
||||
.ts-drag-item .drag-seal-img { width:120px;height:auto;display:block;margin:0 auto 4px } .ts-drag-item span { font-size:11px;color:#909399 }
|
||||
.ts-hint { font-size:11px;color:#909399;margin:6px 0 0;text-align:center }
|
||||
.upload-dragger { border:2px dashed #dcdfe6;border-radius:10px;padding:24px 16px;text-align:center;cursor:pointer;transition:all .25s;background:#fafbfc }
|
||||
.upload-dragger:hover,.is-dragover { border-color:#3b6fa0;background:#eaf2f9 }
|
||||
.upload-content .anticon { font-size:36px;color:#b0b8c5;margin-bottom:8px }
|
||||
.upload-text,.file-name { font-size:14px;color:#2c3e50;margin:0 0 6px } .file-name { font-weight:500 }
|
||||
.upload-hint,.file-size { font-size:12px;color:#8b95a5;margin:0 0 8px } .contract-name-row { margin-top:14px }
|
||||
.sign-hint { font-size:12px;color:#909399;text-align:center;margin:6px 0 0 }
|
||||
.btn { display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 20px;height:38px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;user-select:none;border:none;background:#f0f2f5;color:#2c3e50 }
|
||||
.btn-primary { background:linear-gradient(135deg,#3b82f6,#2563eb);color:white;border:none;box-shadow:0 8px 20px rgba(37,99,235,.18);margin-top:10px }
|
||||
.btn-primary:hover { transform:translateY(-1px);box-shadow:0 10px 24px rgba(37,99,235,.28) }
|
||||
.btn-success { background:linear-gradient(135deg,#10b981,#059669);color:white;border:none;box-shadow:0 8px 20px rgba(16,185,129,.18) }
|
||||
.btn-success:hover { transform:translateY(-1px);box-shadow:0 10px 24px rgba(16,185,129,.28) }
|
||||
.btn-danger-text { background:transparent;color:#c0392b;padding:0;height:auto;font-size:13px }
|
||||
.btn-disabled { opacity:.5;cursor:not-allowed;pointer-events:none }
|
||||
.btn-sm { height:30px;padding:0 12px;font-size:12px } .btn-loading { pointer-events:none;opacity:.8 }
|
||||
.contract-status-tag { margin-top:12px;font-size:13px } .tag-success { color:#4a9c5d;font-weight:500 } .tag-warning { color:#c88a2e;font-weight:500 } .tag-info { color:#8b95a5;font-weight:500 }
|
||||
.ctx-menu-mask { position:fixed;top:0;left:0;right:0;bottom:0;z-index:9998;background:transparent }
|
||||
.ctx-menu-panel { position:fixed;z-index:9999;background:#fff;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.12);padding:6px 0;min-width:200px }
|
||||
.ctx-menu-item { display:flex;align-items:center;gap:10px;padding:10px 20px;cursor:pointer;font-size:14px;color:#2c3e50;transition:background .15s }
|
||||
.ctx-menu-item:hover { background:#f0f5ff;color:#3b6fa0 }
|
||||
.qr-dialog-body { text-align:center } .qr-img-box { display:flex;justify-content:center;align-items:center;min-height:200px }
|
||||
.qr-loading { flex-direction:column;gap:12px;color:#8b95a5 }
|
||||
.qr-img { width:200px;height:200px;border:1px solid #eee;border-radius:10px;padding:8px } .qr-countdown { margin-top:14px } .countdown-text { margin-top:6px;font-size:13px;color:#5a6b7c }
|
||||
@media (max-width:900px) { .operation-sidebar { width:320px } }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="contractName || '标准签章模式'"
|
||||
width="95%"
|
||||
:style="{ top: '30px', paddingBottom: 0 }"
|
||||
:bodyStyle="{ height: 'calc(100vh - 180px)', padding: 0, overflow: 'hidden' }"
|
||||
:destroy-on-close="false"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
wrap-class-name="seal-full-modal"
|
||||
>
|
||||
<div class="sign-platform" @click="onGlobalClick">
|
||||
<!-- ==================== 空状态 ==================== -->
|
||||
<div class="platform-layout platform-layout-empty" v-if="!contractId">
|
||||
<div class="preview-area empty-preview">
|
||||
<div class="preview-placeholder">
|
||||
<cloud-upload-outlined :style="{ fontSize: '48px', color: '#c0c4cc' }" />
|
||||
<p>上传文件后此处将显示文件预览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">1</span>
|
||||
<span class="step-title">发起合约</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="upload-dragger" :class="{ 'is-dragover': dragOver }"
|
||||
@click="triggerFileInput" @dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false" @drop.prevent="handleFileDrop">
|
||||
<input ref="fileInputRef" type="file" :accept="acceptFileTypes" style="display:none" @change="handleFileSelect" />
|
||||
<div class="upload-content" v-if="!uploadFile">
|
||||
<cloud-upload-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="upload-text">点击或将文件拖拽到这里上传</p>
|
||||
<p class="upload-hint">支持 xls、xlsx、pdf、docx、doc 文件,且小于10M</p>
|
||||
</div>
|
||||
<div class="upload-content" v-else>
|
||||
<file-text-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="file-name">{{ uploadFile.name }}</p>
|
||||
<p class="file-size">{{ formatFileSize(uploadFile.size) }}</p>
|
||||
<div class="btn btn-danger-text" @click.stop="removeFile">移除文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contract-name-row">
|
||||
<a-input v-model:value="contractName" placeholder="请输入合同/文件名称" allow-clear />
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !uploadFile || !contractName || creatingContract }" @click="handleCreateContract">
|
||||
<a-spin v-if="creatingContract" size="small" />
|
||||
<span v-if="creatingContract"> 发起中...</span>
|
||||
<span v-else>发起签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled">
|
||||
<div class="step-card-header"><span class="step-badge">2</span><span class="step-title">指定签署位置</span></div>
|
||||
<div class="step-card-body"><p class="step-hint">上传文件后即可在预览区指定签署位置</p></div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled">
|
||||
<div class="step-card-header"><span class="step-badge">3</span><span class="step-title">结束合约</span></div>
|
||||
<div class="step-card-body"><p class="step-hint">签署完成后可在此结束合约并下载文件</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 合约已创建 ==================== -->
|
||||
<div class="platform-layout platform-layout-full" v-else>
|
||||
<div class="left-info-sidebar">
|
||||
<div class="info-card">
|
||||
<div class="info-title">合同信息</div>
|
||||
<div class="info-item"><span>合同名称</span><strong>{{ contractName }}</strong></div>
|
||||
<div class="info-item"><span>合同状态</span><strong>{{ contractStatus === 2000 ? '已完成' : contractStatus === 20 ? '签署中' : '待签署' }}</strong></div>
|
||||
<div class="info-item"><span>文件页数</span><strong>{{ pdfImages.length }} 页</strong></div>
|
||||
<div class="info-item"><span>印章数量</span><strong>{{ allSeals.length }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-area" ref="pdfScrollRef">
|
||||
<div v-for="(imgUrl, idx) in pdfImages" :key="'p'+idx" class="pdf-page-wrapper"
|
||||
:style="{ width: displayWidth + 'px', height: displayHeight + 'px' }"
|
||||
@contextmenu.prevent.stop="showContextMenu($event, idx + 1)">
|
||||
<img :src="imgUrl" class="pdf-page-image" :style="{ width: displayWidth + 'px', height: displayHeight + 'px' }" draggable="false" />
|
||||
<template v-for="seal in getPageSeals(idx + 1)" :key="'s' + seal.id">
|
||||
<div class="seal-placeholder" :class="['seal-type-' + seal.sealType, { 'seal-signed': seal.status === 'signed', 'seal-batch': !!seal.batchId }]"
|
||||
:style="getSealDisplayStyle(seal)" @mousedown.stop="seal.status !== 'signed' && onSealMouseDown($event, seal)">
|
||||
<template v-if="seal.status !== 'signed'">
|
||||
<div class="seal-shape" v-if="seal.sealType === 'round'"><div class="shape-round-dashed">圆章</div></div>
|
||||
<div class="seal-shape" v-else-if="seal.sealType === 'oval'"><div class="shape-oval-dashed">椭圆章</div></div>
|
||||
<div class="seal-shape" v-else-if="seal.sealType === 'square'"><div class="shape-square-dashed">方章</div></div>
|
||||
<div class="seal-shape" v-else-if="seal.sealType === 'signature'"><div class="shape-signature-dashed">签名</div></div>
|
||||
<div class="seal-shape" v-else-if="seal.sealType === 'timestamp'">
|
||||
<img v-if="seal._pictureUrl" :src="seal._pictureUrl" class="timestamp-preview-img" />
|
||||
<div v-else class="shape-timestamp-dashed">时间戳</div>
|
||||
</div>
|
||||
<div class="seal-ops">
|
||||
<div class="btn btn-primary btn-sm" :class="{ 'btn-loading': seal.status === 'signing' }" @click.stop="confirmSignSingle(seal)">
|
||||
{{ seal.status === 'signing' ? '等待扫码...' : '确认签署' }}
|
||||
</div>
|
||||
<div v-if="seal.batchId" :class="['btn btn-sm', seal._batchLocked !== false ? 'btn-warning' : 'btn-default']" @click.stop="toggleBatchLock(seal)" title="锁定后拖动一个印章其他同批次印章也会同步移动">
|
||||
<lock-outlined v-if="seal._batchLocked !== false" /><unlock-outlined v-else />
|
||||
</div>
|
||||
<close-outlined class="delete-icon" @click.stop="removeSeal(seal.id)" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<img v-if="seal.sealType === 'timestamp' && seal._pictureUrl" :src="seal._pictureUrl" class="timestamp-signed-img" />
|
||||
<div v-else class="seal-stamp-box"><div class="stamp-content">{{ seal._signer || '已签署' }}</div></div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<div class="paging-seal-wrapper" :class="{ 'paging-empty': !pagingEnabled, 'paging-signed': pagingSigned, 'paging-signing': pagingSigning }" @click.stop="handlePagingSealClick">
|
||||
<template v-if="!pagingEnabled">
|
||||
<div class="paging-empty-box"><div class="paging-empty-text">添加骑缝章</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="paging-seal-real"><div class="paging-seal-real-text">已添加骑缝章</div></div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">2</span><span class="step-title">指定签署位置,扫码签署</span><span class="step-required">(必选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="step-tips">
|
||||
<div class="tip-item">1. 右键选择合约任意需要盖章的位置,添加印章占位符;</div>
|
||||
<div class="tip-item">2. 使用安全签小程序扫一扫;</div>
|
||||
<div class="tip-item">3. 一章一扫,扫码确认后即代表完成一次签署。</div>
|
||||
</div>
|
||||
<div class="signature-summary" v-if="allSeals.length > 0">已配置 {{ allSeals.length }} 个印章,其中 {{ signedCount }} 个已签署</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">3</span><span class="step-title">结束合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': contractStatus === 2000 || completing }" @click="handleComplete">
|
||||
<check-circle-outlined /><span v-if="completing">结束中...</span><span v-else>结束合约</span>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': contractStatus !== 2000 }" @click="handleDownload" style="margin-top:10px;margin-left:10px">
|
||||
<download-outlined /> 下载已签署文件
|
||||
</div>
|
||||
<div class="contract-status-tag">
|
||||
<span v-if="contractStatus === 2000" class="tag-success">合约已完成</span>
|
||||
<span v-else-if="contractStatus === 20" class="tag-warning">签署进行中</span>
|
||||
<span v-else class="tag-info">等待签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 全局弹层 ==================== -->
|
||||
<div v-show="ctxMenu.visible" class="ctx-menu-mask" @click="hideContextMenu" @contextmenu.prevent="hideContextMenu"></div>
|
||||
<div v-show="ctxMenu.visible" class="ctx-menu-panel" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
|
||||
<div class="ctx-menu-item" @click.stop="ctxAddRound"><idcard-outlined /> 圆章</div>
|
||||
<div class="ctx-menu-item" @click.stop="ctxAddOval"><idcard-outlined /> 椭圆章</div>
|
||||
<div class="ctx-menu-item" @click.stop="ctxAddSquare"><idcard-outlined /> 方章</div>
|
||||
<div class="ctx-menu-item" @click.stop="ctxAddSignature"><edit-outlined /> 签名</div>
|
||||
<div class="ctx-menu-divider"></div>
|
||||
<div class="ctx-menu-item" @click.stop="ctxBatchApply"><appstore-outlined /> 批量应用</div>
|
||||
<div class="ctx-menu-divider"></div>
|
||||
<div class="ctx-menu-item" @click.stop="ctxAddTimestamp"><clock-circle-outlined /> 时间戳</div>
|
||||
</div>
|
||||
|
||||
<!-- 批量应用弹窗 -->
|
||||
<a-modal v-model:open="batchDialogVisible" title="批量应用签章" width="480px" :mask-closable="false"
|
||||
@ok="confirmBatchApply" @cancel="batchDialogVisible = false">
|
||||
<div class="batch-dialog-body">
|
||||
<div class="batch-row"><label>选择页面范围:</label>
|
||||
<a-radio-group v-model:value="batchPageMode" style="margin-top:4px">
|
||||
<a-radio value="odd">奇数页</a-radio><a-radio value="even">偶数页</a-radio><a-radio value="custom">自定义页数</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div class="batch-row" v-if="batchPageMode === 'custom'">
|
||||
<label>页码(逗号分隔,支持范围如 1,3,5-8):</label>
|
||||
<a-input v-model:value="batchCustomPages" placeholder="例如:1,3,5-8" />
|
||||
</div>
|
||||
<div class="batch-row"><label>印章类型:</label>
|
||||
<a-radio-group v-model:value="batchSelectedType" style="margin-top:4px">
|
||||
<a-radio value="round">圆章</a-radio><a-radio value="oval">椭圆章</a-radio><a-radio value="square">方章</a-radio><a-radio value="signature">签名</a-radio>
|
||||
</a-radio-group>
|
||||
</div>
|
||||
<div class="batch-info">印章将放置在右键位置(第 {{ ctxMenu.pageNum }} 页,坐标 {{ ctxMenu.originalX }}, {{ ctxMenu.originalY }})</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 二维码弹窗 -->
|
||||
<a-modal v-model:open="qrDialogVisible" title="扫码签署" width="420px" :mask-closable="false" :footer="null" :closable="false">
|
||||
<div class="qr-dialog-body">
|
||||
<div class="qr-img-box" v-if="qrCodeData"><img :src="qrCodeData" class="qr-img" /></div>
|
||||
<div class="qr-img-box qr-loading" v-else><a-spin size="large" /><p>二维码加载中...</p></div>
|
||||
<a-alert message="请使用微信扫描二维码完成签署" type="success" :closable="false" show-icon style="margin-top:12px" />
|
||||
<div class="qr-countdown" v-if="qrCountdown > 0">
|
||||
<a-progress :percent="qrPercent" :stroke-color="qrColor" :stroke-width="6" :show-info="false" />
|
||||
<p class="countdown-text">有效期剩余:<span :style="{ color: qrColor }">{{ qrCountdownText }}</span></p>
|
||||
</div>
|
||||
<div class="qr-link-box" v-if="qrUrlLink">
|
||||
<a-input :value="qrUrlLink" readonly size="small">
|
||||
<template #addonAfter><a-button size="small" @click="copyUrlLink">复制</a-button></template>
|
||||
</a-input>
|
||||
<p class="link-tip">将链接发送到手机端,可在浏览器中直接跳转微信小程序签署</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: right; margin-top: 16px">
|
||||
<a-button @click="closeQrDialog" style="margin-right:8px">关闭</a-button>
|
||||
<a-button type="primary" @click="refreshQrCode" :loading="qrRefreshing">刷新二维码</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
CloudUploadOutlined, FileTextOutlined, IdcardOutlined, AppstoreOutlined,
|
||||
ClockCircleOutlined, CloseOutlined, EditOutlined, CheckCircleOutlined,
|
||||
DownloadOutlined, LockOutlined, UnlockOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
startContract, getStandardSignQrCode, completeContract, downloadContract,
|
||||
pollCallback, getTimestamp, signTimestamp, getContractDetail, previewContract,
|
||||
} from '@/api/seal'
|
||||
|
||||
// ==================== 对外暴露 ====================
|
||||
const visible = ref(false)
|
||||
|
||||
async function showModal(contractIdParam) {
|
||||
visible.value = true
|
||||
if (contractIdParam) {
|
||||
await loadContract(contractIdParam)
|
||||
if (contractId.value) {
|
||||
await nextTick()
|
||||
nextTick(calcDisplaySize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContract(cid) {
|
||||
try {
|
||||
const [detailRes, previewRes] = await Promise.all([
|
||||
getContractDetail(cid),
|
||||
previewContract(cid),
|
||||
])
|
||||
const detail = detailRes?.code === 200 ? detailRes.data : null
|
||||
const preview = previewRes?.code === 200 ? previewRes.data : null
|
||||
if (detail || preview) {
|
||||
contractId.value = cid
|
||||
contractName.value = detail?.contractName || ''
|
||||
contractStatus.value = detail?.contractStatus ?? detail?.status ?? null
|
||||
pdfImages.value = preview?.pdfToImageList || []
|
||||
imageWidth.value = Number(preview?.imageWidth) || 793
|
||||
imageHeight.value = Number(preview?.imageHeight) || 1122
|
||||
saveContractState()
|
||||
}
|
||||
} catch {
|
||||
const saved = JSON.parse(localStorage.getItem('standard_sign_contract') || '{}')
|
||||
if (saved.contractId === cid) {
|
||||
contractId.value = saved.contractId
|
||||
contractName.value = saved.contractName || ''
|
||||
contractStatus.value = saved.contractStatus
|
||||
pdfImages.value = saved.pdfImages || []
|
||||
imageWidth.value = saved.imageWidth || 793
|
||||
imageHeight.value = saved.imageHeight || 1122
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ showModal })
|
||||
|
||||
// ==================== 文件上传 ====================
|
||||
const fileInputRef = ref(null), uploadFile = ref(null), contractName = ref('')
|
||||
const creatingContract = ref(false), dragOver = ref(false)
|
||||
const acceptFileTypes = '.xls,.xlsx,.pdf,.docx,.doc'
|
||||
|
||||
function triggerFileInput() { fileInputRef.value?.click() }
|
||||
function handleFileSelect(e) { const f = e.target.files?.[0]; if (f) validateAndSetFile(f); e.target.value = '' }
|
||||
function handleFileDrop(e) { dragOver.value = false; const f = e.dataTransfer.files?.[0]; if (f) validateAndSetFile(f) }
|
||||
function validateAndSetFile(file) {
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (!['xls', 'xlsx', 'pdf', 'docx', 'doc'].includes(ext)) { message.error(`不支持的文件格式 "${ext}"`); return }
|
||||
if (file.size > 10485760) { message.error('文件大小不能超过10MB'); return }
|
||||
uploadFile.value = file
|
||||
if (!contractName.value) contractName.value = file.name.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
function removeFile() { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = '' }
|
||||
function formatFileSize(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB' }
|
||||
|
||||
// ==================== localStorage ====================
|
||||
const STORAGE_KEY = 'standard_sign_contract'
|
||||
|
||||
function saveContractState() {
|
||||
try { localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
contractId: contractId.value, contractName: contractName.value, contractStatus: contractStatus.value,
|
||||
pdfImages: pdfImages.value, imageWidth: imageWidth.value, imageHeight: imageHeight.value,
|
||||
})) } catch {}
|
||||
}
|
||||
function clearContractState() { try { localStorage.removeItem(STORAGE_KEY) } catch {} }
|
||||
|
||||
// ==================== 签署流程 ====================
|
||||
const contractId = ref(''), contractStatus = ref(null), pdfImages = ref([])
|
||||
const imageWidth = ref(793), imageHeight = ref(1122), completing = ref(false)
|
||||
|
||||
async function handleCreateContract() {
|
||||
if (!uploadFile.value || !contractName.value) { message.warning('请先选择文件并输入合同名称'); return }
|
||||
creatingContract.value = true
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', uploadFile.value); fd.append('contractName', contractName.value)
|
||||
const res = await startContract(fd)
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data; contractId.value = d.contractId; pdfImages.value = d.pdfToImageList || []
|
||||
imageWidth.value = d.imageWidth || 793; imageHeight.value = d.imageHeight || 1122; contractStatus.value = 10
|
||||
saveContractState()
|
||||
message.success('签署流程创建成功!右键点击页面添加印章')
|
||||
nextTick(calcDisplaySize)
|
||||
} else { message.error(res.msg || '创建失败') }
|
||||
} catch { message.error('创建签署流程失败') }
|
||||
finally { creatingContract.value = false }
|
||||
}
|
||||
|
||||
// ==================== PDF 显示 ====================
|
||||
const displayWidth = ref(700), displayHeight = ref(990), scaleRatio = ref(1), pdfScrollRef = ref(null)
|
||||
function calcDisplaySize() {
|
||||
if (!pdfScrollRef.value) return
|
||||
const cw = pdfScrollRef.value.clientWidth - 40; const mw = Math.min(cw, 800)
|
||||
if (imageWidth.value > 0) { scaleRatio.value = mw / imageWidth.value; displayWidth.value = mw; displayHeight.value = Math.round(imageHeight.value * scaleRatio.value) }
|
||||
}
|
||||
onMounted(() => { window.addEventListener('resize', calcDisplaySize); nextTick(calcDisplaySize) })
|
||||
onUnmounted(() => { window.removeEventListener('resize', calcDisplaySize) })
|
||||
watch(pdfImages, () => nextTick(calcDisplaySize))
|
||||
|
||||
// ==================== 印章占位符 ====================
|
||||
let sealIdCounter = 0; const allSeals = ref([])
|
||||
const signedCount = computed(() => allSeals.value.filter(s => s.status === 'signed').length)
|
||||
function getPageSeals(pn) { return allSeals.value.filter(s => s.pageNum === pn) }
|
||||
function getSealDisplayStyle(seal) { return { left: (seal.x * scaleRatio.value) + 'px', top: (seal.y * scaleRatio.value) + 'px' } }
|
||||
function createSeal(pageNum, x, y, sealType, batchId = null) {
|
||||
return { id: ++sealIdCounter, sealType, pageNum, x, y, scale: 1, status: 'pending', requestId: null, batchId, _batchLocked: true }
|
||||
}
|
||||
function removeSeal(id) { const s = allSeals.value.find(v => v.id === id); if (s?.status === 'signed') { message.warning('已签署的印章不可删除'); return } allSeals.value = allSeals.value.filter(v => v.id !== id) }
|
||||
|
||||
// ==================== 右键菜单 ====================
|
||||
const ctxMenu = reactive({ visible: false, x: 0, y: 0, pageNum: 1, originalX: 0, originalY: 0 })
|
||||
function showContextMenu(e, pageNum) {
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
ctxMenu.originalX = Math.round(((e.clientX - rect.left) / scaleRatio.value) * 100) / 100
|
||||
ctxMenu.originalY = Math.round(((e.clientY - rect.top) / scaleRatio.value) * 100) / 100
|
||||
ctxMenu.visible = true; ctxMenu.x = e.clientX; ctxMenu.y = e.clientY; ctxMenu.pageNum = pageNum
|
||||
}
|
||||
function hideContextMenu() { ctxMenu.visible = false }
|
||||
function onGlobalClick() { if (ctxMenu.visible) hideContextMenu() }
|
||||
function ctxAddRound() { addSealFromMenu('round') }
|
||||
function ctxAddOval() { addSealFromMenu('oval') }
|
||||
function ctxAddSquare() { addSealFromMenu('square') }
|
||||
function ctxAddSignature() { addSealFromMenu('signature') }
|
||||
function ctxAddTimestamp() { addSealFromMenu('timestamp') }
|
||||
const lastSealType = ref('round')
|
||||
function addSealFromMenu(type) {
|
||||
lastSealType.value = type
|
||||
const s = createSeal(ctxMenu.pageNum, ctxMenu.originalX, ctxMenu.originalY, type)
|
||||
allSeals.value.push(s); hideContextMenu()
|
||||
if (type === 'timestamp') fetchTimestampPreview(s)
|
||||
message.success(`已添加${sealTypeName(type)}占位符`)
|
||||
}
|
||||
async function fetchTimestampPreview(seal) {
|
||||
try { const res = await getTimestamp('yyyy-MM-dd'); if (res.code === 200 && res.data?.pictureUrl) { seal._pictureUrl = res.data.pictureUrl; seal.formatPattern = 'yyyy-MM-dd' } } catch {}
|
||||
}
|
||||
function ctxBatchApply() { batchDialogVisible.value = true; batchPageMode.value = 'odd'; batchCustomPages.value = ''; batchSelectedType.value = lastSealType.value }
|
||||
|
||||
// ==================== 印章拖拽 ====================
|
||||
let dragSeal = null, dragSX = 0, dragSY = 0, dragOX = 0, dragOY = 0
|
||||
function onSealMouseDown(e, seal) {
|
||||
if (seal.status === 'signed') return; e.preventDefault()
|
||||
dragSeal = seal; dragSX = e.clientX; dragSY = e.clientY; dragOX = seal.x; dragOY = seal.y
|
||||
const move = (ev) => {
|
||||
if (!dragSeal) return
|
||||
const dx = (ev.clientX - dragSX) / scaleRatio.value, dy = (ev.clientY - dragSY) / scaleRatio.value
|
||||
const nx = Math.round((dragOX + dx) * 100) / 100, ny = Math.round((dragOY + dy) * 100) / 100
|
||||
if (dragSeal.batchId && dragSeal._batchLocked !== false) { allSeals.value.filter(s => s.batchId === dragSeal.batchId && s.status !== 'signed').forEach(s => { s.x = nx; s.y = ny }) }
|
||||
else { dragSeal.x = nx; dragSeal.y = ny }
|
||||
}
|
||||
const up = () => { dragSeal = null; document.removeEventListener('mousemove', move); document.removeEventListener('mouseup', up) }
|
||||
document.addEventListener('mousemove', move); document.addEventListener('mouseup', up)
|
||||
}
|
||||
function toggleBatchLock(seal) { seal._batchLocked = !seal._batchLocked; message.info(seal._batchLocked ? '已锁定' : '已解锁') }
|
||||
|
||||
// ==================== 批量应用 ====================
|
||||
const batchDialogVisible = ref(false), batchPageMode = ref('odd'), batchCustomPages = ref(''), batchSelectedType = ref('round')
|
||||
function parsePages(mode, custom) {
|
||||
const t = pdfImages.value.length; let pages = []
|
||||
if (mode === 'odd') { for (let i = 1; i <= t; i += 2) pages.push(i) }
|
||||
else if (mode === 'even') { for (let i = 2; i <= t; i += 2) pages.push(i) }
|
||||
else {
|
||||
custom.split(',').map(p => p.trim()).filter(Boolean).forEach(p => {
|
||||
if (p.includes('-')) { const [s, e] = p.split('-').map(Number); if (!isNaN(s) && !isNaN(e)) for (let i = Math.max(1, s); i <= Math.min(t, e); i++) pages.push(i) }
|
||||
else { const n = Number(p); if (!isNaN(n) && n >= 1 && n <= t) pages.push(n) }
|
||||
})
|
||||
}
|
||||
return [...new Set(pages)].sort((a, b) => a - b)
|
||||
}
|
||||
function confirmBatchApply() {
|
||||
const pages = parsePages(batchPageMode.value, batchCustomPages.value)
|
||||
if (!pages.length) { message.warning('请选择有效的页面范围'); return }
|
||||
const bid = 'b_' + Date.now()
|
||||
pages.forEach(pn => allSeals.value.push(createSeal(pn, ctxMenu.originalX, ctxMenu.originalY, batchSelectedType.value, bid)))
|
||||
batchDialogVisible.value = false; hideContextMenu()
|
||||
message.success(`已在 ${pages.length} 个页面添加批量印章占位符`)
|
||||
}
|
||||
|
||||
// ==================== 确认签署 & 二维码 ====================
|
||||
const qrDialogVisible = ref(false), qrCodeData = ref(''), qrUrlLink = ref('')
|
||||
const qrRequestId = ref(''), qrExpiresIn = ref(300), qrRefreshing = ref(false), qrCountdown = ref(0)
|
||||
let qrTimer = null, curSignSeal = null, pollTimer = null
|
||||
const qrPercent = computed(() => qrExpiresIn.value ? Math.round((qrCountdown.value / qrExpiresIn.value) * 100) : 0)
|
||||
const qrColor = computed(() => qrCountdown.value <= 30 ? '#f56c6c' : qrCountdown.value <= 60 ? '#e6a23c' : '#67c23a')
|
||||
const qrCountdownText = computed(() => { const m = Math.floor(qrCountdown.value / 60); const s = qrCountdown.value % 60; return `${m}分${String(s).padStart(2, '0')}秒` })
|
||||
|
||||
async function confirmSignSingle(seal) {
|
||||
if (seal.status !== 'pending') return
|
||||
if (seal.sealType === 'timestamp') { await signTimestampSeal(seal); return }
|
||||
if (seal.batchId) { curSignSeal = allSeals.value.filter(s => s.batchId === seal.batchId && s.status === 'pending'); await fetchQrCode(curSignSeal, null) }
|
||||
else { curSignSeal = [seal]; await fetchQrCode([seal], null) }
|
||||
}
|
||||
async function signTimestampSeal(seal) {
|
||||
seal.status = 'signing'
|
||||
try {
|
||||
const res = await signTimestamp({ contractId: contractId.value, sealConfigs: [{ formatPattern: seal.formatPattern || 'yyyy-MM-dd', x: seal.x, y: seal.y, pageNum: seal.pageNum, scale: seal.scale || 1 }] })
|
||||
if (res.code === 200 && res.data) {
|
||||
seal.status = 'signed'; seal._signTime = res.data.signatureTime || new Date().toLocaleString()
|
||||
if (res.data.pdfToImageList?.length) pdfImages.value = res.data.pdfToImageList
|
||||
if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus
|
||||
message.success('时间戳签署成功')
|
||||
} else { message.error(res.msg || '时间戳签署失败'); seal.status = 'pending' }
|
||||
} catch { message.error('时间戳签署失败'); seal.status = 'pending' }
|
||||
}
|
||||
async function fetchQrCode(seals, pagingConfig) {
|
||||
qrRefreshing.value = true; if (seals) seals.forEach(s => s.status = 'signing')
|
||||
try {
|
||||
const host = 'http://47.110.50.12:7005'
|
||||
const params = { contractId: contractId.value, notifyUrl: host + '/seal/callback/sign', isNeedUrlLink: true, sealConfigs: seals ? seals.map(s => ({ x: s.x, y: s.y, scale: s.scale || 1, pageNum: s.pageNum })) : [], pagingSealConfig: pagingConfig || null }
|
||||
const res = await getStandardSignQrCode(params)
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data; qrCodeData.value = d.qrCode || ''; qrUrlLink.value = d.urlLink || ''; qrRequestId.value = d.requestId || ''; qrExpiresIn.value = d.expiresIn || 300
|
||||
qrDialogVisible.value = true; startQrCountdown(); startPolling(d.requestId); subscribeWs(d.requestId)
|
||||
} else { message.error(res.msg || '获取二维码失败'); if (seals) seals.forEach(s => s.status = 'pending'); curSignSeal = null }
|
||||
} catch { message.error('获取二维码失败'); if (seals) seals.forEach(s => s.status = 'pending'); curSignSeal = null }
|
||||
finally { qrRefreshing.value = false }
|
||||
}
|
||||
function startQrCountdown() { stopQrCountdown(); qrCountdown.value = qrExpiresIn.value; qrTimer = setInterval(() => { qrCountdown.value--; if (qrCountdown.value <= 0) stopQrCountdown() }, 1000) }
|
||||
function stopQrCountdown() { if (qrTimer) { clearInterval(qrTimer); qrTimer = null } }
|
||||
function closeQrDialog() { stopQrCountdown(); stopPolling(); qrDialogVisible.value = false; if (curSignSeal) { (Array.isArray(curSignSeal) ? curSignSeal : [curSignSeal]).forEach(s => { if (s.status === 'signing') s.status = 'pending' }); curSignSeal = null } }
|
||||
function refreshQrCode() { stopPolling(); if (curSignSeal) fetchQrCode(Array.isArray(curSignSeal) ? curSignSeal : [curSignSeal], null); else if (pagingSigning.value) handlePagingSign() }
|
||||
function copyUrlLink() { try { navigator.clipboard.writeText(qrUrlLink.value); message.success('已复制') } catch { message.success(qrUrlLink.value) } }
|
||||
function startPolling(requestId) {
|
||||
stopPolling(); const maxAttempts = Math.ceil(qrExpiresIn.value / 2) + 10; let attempts = 0
|
||||
pollTimer = setInterval(async () => { attempts++; if (attempts > maxAttempts || !qrDialogVisible.value) { stopPolling(); return } try { const res = await pollCallback(requestId); if (res.code === 200 && res.data) { stopPolling(); processCallback(res.data) } } catch {} }, 2000)
|
||||
}
|
||||
function stopPolling() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
|
||||
|
||||
let ws = null, wsRt = null
|
||||
function connectWs() {
|
||||
try { const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'; ws = new WebSocket(proto + '//' + location.host + '/seal/ws/callback'); ws.onmessage = (ev) => { try { const m = JSON.parse(ev.data); if (m.type === 'callback') { stopPolling(); processCallback({ callbackType: m.callbackType, ...m.data }) } } catch {} }; ws.onclose = () => { wsRt = setTimeout(connectWs, 5000) } } catch {}
|
||||
}
|
||||
function subscribeWs(rid) { if (ws?.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ action: 'subscribe', requestId: rid })) }
|
||||
function processCallback(data) {
|
||||
const cStatus = data.contractStatus; if (cStatus !== undefined) { contractStatus.value = cStatus; if (cStatus === 2000) clearContractState(); else saveContractState() }
|
||||
if (curSignSeal) { (Array.isArray(curSignSeal) ? curSignSeal : [curSignSeal]).forEach(s => { if (s.status === 'signing') { s.status = 'signed'; s._signer = data.signer || ''; s._signTime = data.signatureTime || new Date().toLocaleString() } }); curSignSeal = null }
|
||||
if (pagingSigning.value) { pagingSigning.value = false; pagingEnabled.value = true; pagingSigned.value = true }
|
||||
if (cStatus === 2000) { message.success('合约签署已完结!'); pagingSigned.value = true }
|
||||
else if (cStatus === 20) message.success(`签署成功!签署人: ${data.signer || '未知'}`)
|
||||
if (qrDialogVisible.value) { stopQrCountdown(); qrDialogVisible.value = false }
|
||||
}
|
||||
|
||||
// ==================== 骑缝章 ====================
|
||||
const pagingSigning = ref(false), pagingSigned = ref(false), pagingEnabled = ref(false), pagingScale = ref(1)
|
||||
function handlePagingSealClick() { if (pagingSigned.value) { message.warning('骑缝章已签署'); return } handlePagingSign() }
|
||||
function handlePagingSign() {
|
||||
if (pagingSigned.value || !pdfImages.value.length) { message.warning(pagingSigned.value ? '骑缝章已签署' : '没有可签署的页面'); return }
|
||||
pagingSigning.value = true; curSignSeal = null
|
||||
fetchQrCode(null, { scale: pagingScale.value, startPage: 1, endPage: pdfImages.value.length, coordinateY: Math.round(displayHeight.value / 2) })
|
||||
}
|
||||
|
||||
// ==================== 完成合约 & 下载 ====================
|
||||
async function handleComplete() {
|
||||
if (contractStatus.value === 2000) { message.warning('合约已结束'); return }
|
||||
Modal.confirm({
|
||||
title: '确认结束', content: '确认结束合约?结束后将无法再添加印章。', okType: 'danger',
|
||||
onOk: async () => { completing.value = true; try { const res = await completeContract(contractId.value); if (res.code === 200) { contractStatus.value = 2000; clearContractState(); message.success('合约已完成') } else message.error(res.msg || '结束失败') } catch { message.error('结束失败') } finally { completing.value = false } },
|
||||
})
|
||||
}
|
||||
async function handleDownload() {
|
||||
if (contractStatus.value !== 2000) { message.warning('请先结束合约'); return }
|
||||
try { const res = await downloadContract(contractId.value); if (res.code === 200 && res.data?.downloadUrl) window.open(res.data.downloadUrl, '_blank'); else message.error('获取下载链接失败') }
|
||||
catch { message.error('下载失败') }
|
||||
}
|
||||
function sealTypeName(t) { const m = { round: '圆章', oval: '椭圆章', square: '方章', signature: '签名', timestamp: '时间戳' }; return m[t] || t }
|
||||
|
||||
onMounted(() => { connectWs(); nextTick(calcDisplaySize) })
|
||||
onUnmounted(() => { if (wsRt) clearTimeout(wsRt); if (ws) ws.close(); stopPolling(); stopQrCountdown() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.seal-full-modal :deep(.ant-modal) { padding-bottom: 0 }
|
||||
.sign-platform { width: 100%; height: 100%; background: #f5f7fa; font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #2c3e50; overflow: hidden; display:flex; flex-direction:column }
|
||||
.platform-layout { flex: 1; overflow: hidden }
|
||||
.platform-layout-empty { display: grid; grid-template-columns: minmax(0, 1fr) 380px }
|
||||
.platform-layout-full { display: grid; grid-template-columns: 260px minmax(0, 1fr) 380px }
|
||||
.preview-area { background: linear-gradient(to bottom, #f8fafc, #eef2f7); overflow-y: auto; padding: 30px 50px; display: flex; flex-direction: column; align-items: center; min-width: 0; gap: 28px }
|
||||
.empty-preview { justify-content: center; align-items: center; background: #f0f2f5; min-width: 0 }
|
||||
.preview-placeholder { width: 420px; padding: 60px 40px; border-radius: 20px; background: rgba(255,255,255,.7); backdrop-filter: blur(10px); box-shadow: 0 10px 40px rgba(15,23,42,.06); border: 1px solid rgba(255,255,255,.8); display: flex; flex-direction: column; align-items: center }
|
||||
.preview-placeholder p { margin-top: 14px; font-size: 15px; color: #64748b }
|
||||
.pdf-page-wrapper { position: relative; border-radius: 12px; overflow: visible; background: white; box-shadow: 0 12px 40px rgba(15,23,42,.08), 0 2px 8px rgba(15,23,42,.04); transition: all .2s ease }
|
||||
.pdf-page-wrapper:hover { transform: translateY(-2px) }
|
||||
.pdf-page-image { display: block; user-select: none; pointer-events: none }
|
||||
.seal-placeholder { position: absolute; z-index: 10; display: flex; flex-direction: column; align-items: center; gap: 4px; cursor: move; user-select: none }
|
||||
.seal-shape .shape-round-dashed { width: 120px; height: 120px; border-radius: 50%; border: 2px dashed #409eff; background: rgba(64,158,255,.08); display: flex; align-items: center; justify-content: center; font-size: 12px; color: #409eff }
|
||||
.shape-oval-dashed { width: 120px; height: 70px; border-radius: 50%; border: 2px dashed #409eff; background: rgba(64,158,255,.08); display: flex; align-items: center; justify-content: center; font-size: 12px; color: #409eff }
|
||||
.shape-square-dashed { width: 100px; height: 100px; border-radius: 4px; border: 2px dashed #409eff; background: rgba(64,158,255,.08); display: flex; align-items: center; justify-content: center; font-size: 12px; color: #409eff }
|
||||
.shape-signature-dashed { width: 100px; height: 40px; border-bottom: 3px solid #e6a23c; background: rgba(230,162,60,.05); display: flex; align-items: flex-end; justify-content: center; padding-bottom: 2px; font-size: 11px; color: #e6a23c; font-style: italic }
|
||||
.shape-timestamp-dashed { width: 120px; height: 36px; border: 2px dashed #67c23a; background: rgba(103,194,58,.08); border-radius: 4px; display: flex; align-items: center; justify-content: center; font-size: 12px; color: #67c23a }
|
||||
.seal-ops { display: flex; align-items: center; gap: 4px; background: #fff; padding: 4px 8px; border-radius: 6px; box-shadow: 0 2px 10px rgba(0,0,0,.1); white-space: nowrap }
|
||||
.delete-icon { cursor: pointer; color: #f56c6c; font-size: 16px } .delete-icon:hover { color: #d43030 }
|
||||
.seal-signed { cursor: default; opacity: 1 }
|
||||
.seal-stamp-box { display: inline-flex; align-items: center; justify-content: center; min-width: 120px; min-height: 72px; padding: 6px 14px; border: 2px solid rgba(220,70,70,.75); border-radius: 2px; background: rgba(255,245,245,.15); opacity: .85; box-shadow: 0 0 2px rgba(212,48,48,.35), inset 0 0 1px rgba(212,48,48,.2) }
|
||||
.stamp-content { font-size: 46px; font-family: "STFangsong","FangSong","SimSun",serif; font-weight: 300; color: rgba(212,48,48,.68); white-space: nowrap; line-height: 1; text-shadow: 0 0 1px rgba(212,48,48,.45), 0 0 2px rgba(212,48,48,.25); filter: blur(.3px); letter-spacing: 2px; user-select: none }
|
||||
.timestamp-preview-img, .timestamp-signed-img { width: 120px; height: auto; opacity: .9 }
|
||||
.paging-seal-wrapper { position: absolute; top: 50%; right: -18px; transform: translateY(-50%); width: 52px; height: fit-content; display: flex; align-items: center; justify-content: center; z-index: 50; cursor: pointer; transition: all .25s ease }
|
||||
.paging-seal-wrapper:hover { transform: translateY(-50%) scale(1.03) }
|
||||
.paging-seal-real { width: 100%; height: 100%; border: 2px solid rgba(210,50,50,.65); background: radial-gradient(circle at center, rgba(255,255,255,.04), rgba(255,0,0,.03)); border-radius: 12px; display: flex; align-items: center; justify-content: center; opacity: .9; box-shadow: 0 0 12px rgba(210,50,50,.2), inset 0 0 8px rgba(210,50,50,.1) }
|
||||
.paging-seal-real-text { writing-mode: vertical-rl; text-orientation: upright; font-size: 22px; letter-spacing: 4px; color: rgba(210,50,50,.72); font-family: "STFangsong","FangSong","SimSun",serif; text-shadow: 0 0 2px rgba(210,50,50,.2), 0 0 4px rgba(210,50,50,.12); filter: blur(.2px); user-select: none }
|
||||
.paging-empty-box { width: 100%; height: 100%; border: 2px dashed #cbd5e1; border-radius: 12px; background: rgba(248,250,252,.95); display: flex; align-items: center; justify-content: center; color: #94a3b8; transition: all .2s ease; padding: 10px }
|
||||
.paging-empty-text { writing-mode: vertical-rl; text-orientation: upright; font-size: 16px; letter-spacing: 4px; font-weight: 600; line-height: 1.4 }
|
||||
.paging-empty-box:hover { border-color: #3b82f6; color: #3b82f6; background: #eff6ff }
|
||||
.operation-sidebar { width: 380px; background: #fff; border-left: 1px solid #e2e6ed; display: flex; flex-direction: column; padding: 20px; gap: 16px; overflow-y: auto; box-shadow: -2px 0 12px rgba(0,0,0,.04) }
|
||||
.step-card { background: #fff; border: 1px solid #eef1f5; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.04) }
|
||||
.step-card-disabled { opacity: .6 }
|
||||
.step-card-header { padding: 14px 16px; background: #f8f9fb; border-bottom: 1px solid #f0f2f5; display: flex; align-items: center; gap: 10px; flex-wrap: wrap }
|
||||
.step-badge { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; background: #3b6fa0; color: #fff; border-radius: 6px; font-size: 14px; font-weight: 600; flex-shrink: 0 }
|
||||
.step-title { font-size: 15px; font-weight: 600; color: #2c3e50 }
|
||||
.step-required { font-size: 12px; color: #c88a2e; margin-left: 4px }
|
||||
.step-card-body { padding: 16px }
|
||||
.step-hint { color: #8b95a5; font-size: 13px; margin: 0 }
|
||||
.step-tips { font-size: 13px; color: #5a6b7c; line-height: 1.8 }
|
||||
.tip-item { margin-bottom: 8px }
|
||||
.upload-dragger { border: 2px dashed #dcdfe6; border-radius: 10px; padding: 24px 16px; text-align: center; cursor: pointer; transition: all .25s; background: #fafbfc }
|
||||
.upload-dragger:hover, .is-dragover { border-color: #3b6fa0; background: #eaf2f9 }
|
||||
.upload-content .anticon { font-size: 36px; color: #b0b8c5; margin-bottom: 8px }
|
||||
.upload-text, .file-name { font-size: 14px; color: #2c3e50; margin: 0 0 6px } .file-name { font-weight: 500 }
|
||||
.upload-hint, .file-size { font-size: 12px; color: #8b95a5; margin: 0 0 8px } .contract-name-row { margin-top: 14px }
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 20px; height: 38px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all .2s; user-select: none; border: none; background: #f0f2f5; color: #2c3e50 }
|
||||
.btn-primary { background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; border: none; box-shadow: 0 8px 20px rgba(37,99,235,.18); margin-top: 10px }
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(37,99,235,.28) }
|
||||
.btn-success { background: linear-gradient(135deg, #10b981, #059669); color: white; border: none; box-shadow: 0 8px 20px rgba(16,185,129,.18) }
|
||||
.btn-success:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(16,185,129,.28) }
|
||||
.btn-warning { background: #f0a04b; color: #fff } .btn-default { background: #fff; border: 1px solid #dcdfe6 }
|
||||
.btn-danger-text { background: transparent; color: #c0392b; padding: 0; height: auto; font-size: 13px }
|
||||
.btn-disabled { opacity: .5; cursor: not-allowed; pointer-events: none }
|
||||
.btn-sm { height: 30px; padding: 0 12px; font-size: 12px } .btn-loading { pointer-events: none; opacity: .8 }
|
||||
.contract-status-tag { margin-top: 12px; font-size: 13px } .tag-success { color: #4a9c5d; font-weight: 500 } .tag-warning { color: #c88a2e; font-weight: 500 } .tag-info { color: #8b95a5; font-weight: 500 }
|
||||
.signature-summary { margin-top: 14px; font-size: 13px; color: #5a6b7c; background: #f8f9fb; padding: 8px 12px; border-radius: 6px }
|
||||
.ctx-menu-mask { position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 9998; background: transparent }
|
||||
.ctx-menu-panel { position: fixed; z-index: 9999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.12); padding: 6px 0; min-width: 200px }
|
||||
.ctx-menu-item { display: flex; align-items: center; gap: 10px; padding: 10px 20px; cursor: pointer; font-size: 14px; color: #2c3e50; transition: background .15s }
|
||||
.ctx-menu-item:hover { background: #f0f5ff; color: #3b6fa0 } .ctx-menu-divider { height: 1px; background: #eef1f5; margin: 4px 12px }
|
||||
.batch-dialog-body { padding: 4px 0 } .batch-row { margin-bottom: 18px } .batch-row label { display: block; margin-bottom: 8px; font-size: 14px; color: #2c3e50 }
|
||||
.batch-info { padding: 12px; background: #f8f9fb; border-radius: 6px; font-size: 13px; color: #5a6b7c }
|
||||
.qr-dialog-body { text-align: center } .qr-img-box { display: flex; justify-content: center; align-items: center; min-height: 200px }
|
||||
.qr-loading { flex-direction: column; gap: 12px; color: #8b95a5 }
|
||||
.qr-img { width: 200px; height: 200px; border: 1px solid #eee; border-radius: 10px; padding: 8px } .qr-countdown { margin-top: 14px } .countdown-text { margin-top: 6px; font-size: 13px; color: #5a6b7c }
|
||||
.qr-link-box { margin-top: 18px; text-align: left } .link-tip { font-size: 12px; color: #b0b8c5; margin-top: 4px }
|
||||
.left-info-sidebar { background: #fff; border-right: 1px solid #ebeef5; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px }
|
||||
.info-card { background: #fff; border-radius: 16px; padding: 20px; box-shadow: 0 4px 20px rgba(15,23,42,.05); border: 1px solid #eef2f6 }
|
||||
.info-title { font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 18px }
|
||||
.info-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px dashed #eef2f6; font-size: 14px }
|
||||
.info-item:last-child { border-bottom: none } .info-item span { color: #64748b } .info-item strong { color: #0f172a }
|
||||
@media (max-width: 900px) { .operation-sidebar { width: 320px } }
|
||||
</style>
|
||||
@@ -0,0 +1,769 @@
|
||||
<template>
|
||||
<div class="sign-platform">
|
||||
<!-- ==================== 空状态 ==================== -->
|
||||
<div class="platform-layout platform-layout-empty" v-if="!contractId">
|
||||
<div class="preview-area empty-preview">
|
||||
<div class="preview-placeholder">
|
||||
<cloud-upload-outlined :style="{ fontSize: '48px', color: '#c0c4cc' }" />
|
||||
<p>上传文件后此处将显示文件预览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">1</span>
|
||||
<span class="step-title">发起合约</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="upload-dragger" :class="{ 'is-dragover': dragOver }"
|
||||
@click="triggerFileInput" @dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false" @drop.prevent="handleFileDrop">
|
||||
<input ref="fileInputRef" type="file" :accept="acceptTypes" style="display:none" @change="handleFileSelect" />
|
||||
<div class="upload-content" v-if="!uploadFile">
|
||||
<cloud-upload-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="upload-text">点击或将文件拖拽到这里上传</p>
|
||||
<p class="upload-hint">支持 xls、xlsx、pdf、docx、doc 文件,且小于10M</p>
|
||||
</div>
|
||||
<div class="upload-content" v-else>
|
||||
<file-text-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="file-name">{{ uploadFile.name }}</p>
|
||||
<p class="file-size">{{ formatSize(uploadFile.size) }}</p>
|
||||
<div class="btn btn-danger-text" @click.stop="removeFile">移除文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contract-name-row">
|
||||
<a-input v-model:value="contractName" placeholder="请输入合同/文件名称" allow-clear />
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !uploadFile || !contractName || creating }" @click="handleCreate">
|
||||
<a-spin v-if="creating" size="small" />
|
||||
<span v-if="creating"> 发起中...</span>
|
||||
<span v-else>发起签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled">
|
||||
<div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span></div>
|
||||
<div class="step-card-body"><p class="step-hint">上传文件后即可新增托管印章并拖拽至指定位置</p></div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled">
|
||||
<div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div>
|
||||
<div class="step-card-body"><p class="step-hint">可在此获取并添加时间戳</p></div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled">
|
||||
<div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div>
|
||||
<div class="step-card-body"><p class="step-hint">签署完成后可在此结束合约并下载文件</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 合约已创建后的三栏布局 ==================== -->
|
||||
<div class="platform-layout platform-layout-full" v-else>
|
||||
<!-- 左栏:合同信息 -->
|
||||
<div class="left-info-sidebar">
|
||||
<div class="info-card">
|
||||
<div class="info-title">合同信息</div>
|
||||
<div class="info-item"><span>合同名称</span><strong>{{ contractName }}</strong></div>
|
||||
<div class="info-item"><span>合同状态</span><strong>{{ contractStatus === 2000 ? '已完成' : contractStatus === 20 ? '签署中' : '待签署' }}</strong></div>
|
||||
<div class="info-item"><span>文件页数</span><strong>{{ pdfImages.length }} 页</strong></div>
|
||||
<div class="info-item"><span>托管印章</span><strong>{{ hostedSeals.length }} 个</strong></div>
|
||||
<div class="info-item"><span>自动签章</span><strong>{{ autoSealCount }} 个</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中栏:文件预览区域 -->
|
||||
<div class="preview-area" ref="pdfScrollRef">
|
||||
<div v-for="(imgUrl, idx) in pdfImages" :key="'p' + idx" class="pdf-page-wrapper"
|
||||
:style="{ width: dw + 'px', height: dh + 'px' }"
|
||||
@dragover.prevent @drop.prevent="onDropSeal($event, idx + 1)">
|
||||
<img :src="imgUrl" class="pdf-page-image" :style="{ width: dw + 'px', height: dh + 'px' }" draggable="false" />
|
||||
|
||||
<!-- 已放置印章 -->
|
||||
<div v-for="seal in getPagePlaced(idx + 1)" :key="'s' + seal.id" class="placed-seal"
|
||||
:class="{ 'seal-signed': seal.status === 'signed' }"
|
||||
:style="getPlacedStyle(seal)"
|
||||
@mousedown.stop="seal.status !== 'signed' && startDragSeal($event, seal)">
|
||||
<img v-if="seal.pictureUrl" :src="seal.pictureUrl" class="placed-img" />
|
||||
<div v-else class="placed-fallback">{{ seal.sealName }}</div>
|
||||
<close-outlined v-if="seal.status !== 'signed'" class="placed-del" @click.stop="removePlaced(seal.id)" />
|
||||
</div>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<div v-for="ts in getPagePlacedTs(idx + 1)" :key="'t' + ts.id" class="placed-seal placed-ts"
|
||||
:class="{ 'seal-signed': ts.status === 'signed' }"
|
||||
:style="getPlacedStyle(ts)"
|
||||
@mousedown.stop="ts.status !== 'signed' && startDragSeal($event, ts)">
|
||||
<img v-if="ts._pictureUrl" :src="ts._pictureUrl" class="placed-img" />
|
||||
<div v-else class="placed-fallback">时间戳</div>
|
||||
<div class="ts-actions" v-if="ts.status !== 'signed'">
|
||||
<div class="btn btn-primary btn-sm" :class="{ 'btn-loading': ts.status === 'signing' }" @click.stop="confirmTs(ts)">
|
||||
{{ ts.status === 'signing' ? '签署中...' : '确认' }}
|
||||
</div>
|
||||
<close-outlined class="placed-del" @click.stop="removePlaced(ts.id)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 骑缝章占位条 -->
|
||||
<div class="paging-seal-wrapper"
|
||||
:class="{ 'paging-empty': !pagingSeal.sealId, 'paging-signed': pagingSigned }"
|
||||
@click.stop="handlePagingSealClick">
|
||||
<template v-if="!pagingSeal.sealId">
|
||||
<div class="paging-empty-box"><div class="paging-empty-text">添加骑缝章</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="paging-seal-real">
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-img" />
|
||||
<div class="paging-seal-real-text">{{ pagingSeal.sealName || '骑缝章' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:操作区 -->
|
||||
<div class="operation-sidebar">
|
||||
<!-- 步骤2:添加签章 -->
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">2</span><span class="step-title">添加签章</span><span class="step-required">(必选项)</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="seal-section">
|
||||
<div class="section-subtitle">1、托管印章</div>
|
||||
<div v-if="hostedSeals.length === 0" class="hosted-empty">
|
||||
<p class="hosted-empty-text">尚未获取托管印章</p>
|
||||
<div class="btn btn-primary" @click="handleHostSeal" style="width:100%"><plus-outlined /> 新增托管</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="seal in hostedSeals" :key="seal.sealId" class="hosted-item"
|
||||
draggable="true" @dragstart="onDragHosted($event, seal)">
|
||||
<img :src="seal.pictureUrl" class="hosted-img" />
|
||||
<div class="hosted-info">
|
||||
<span class="hosted-name">{{ seal.sealName }}</span>
|
||||
<span class="hosted-type">{{ sealTypeLabel(seal.sealType) }}</span>
|
||||
</div>
|
||||
<div class="hosted-actions">
|
||||
<span class="hosted-mode-tag" :class="seal._sealMode === 'auto' ? 'tag-auto' : 'tag-manual'"
|
||||
@click.stop="toggleSealSetting(seal.sealId)">
|
||||
{{ seal._sealMode === 'auto' ? '自动' : '手动' }}
|
||||
</span>
|
||||
<setting-outlined class="hosted-setting" @click.stop="toggleSealSetting(seal.sealId)" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 印章设置面板 -->
|
||||
<div v-if="settingSealId" class="seal-setting-panel">
|
||||
<div class="setting-header">
|
||||
<span>印章设置 — {{ getSealName(settingSealId) }}</span>
|
||||
<close-outlined class="del-icon" @click="settingSealId = ''" />
|
||||
</div>
|
||||
<a-radio-group v-model:value="settingSealMode" size="small" style="margin-bottom:8px">
|
||||
<a-radio value="manual">手动拖拽</a-radio>
|
||||
<a-radio value="auto">自动签章</a-radio>
|
||||
</a-radio-group>
|
||||
<template v-if="settingSealMode === 'auto'">
|
||||
<div class="form-item"><label>自动签章位置(上传文档后自动放置)</label></div>
|
||||
<div class="coord-row">
|
||||
<span>第</span>
|
||||
<a-input-number v-model:value="settingAutoPage" :min="1" :max="pdfImages.length" size="small" style="width:70px" />
|
||||
<span>页 X:</span>
|
||||
<a-input-number v-model:value="settingAutoX" :min="1" :max="imageWidth" size="small" style="width:70px" />
|
||||
<span>Y:</span>
|
||||
<a-input-number v-model:value="settingAutoY" :min="1" :max="imageHeight" size="small" style="width:70px" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="btn btn-primary btn-sm" @click="saveSealSetting" style="width:100%;margin-top:6px">保存设置</div>
|
||||
</div>
|
||||
<div class="btn btn-primary" @click="handleHostSeal" style="width:100%;margin-top:8px"><plus-outlined /> 新增托管</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 骑缝章 -->
|
||||
<div class="seal-section" v-if="hostedSeals.length > 0">
|
||||
<div class="section-subtitle">2、骑缝章(拖拽印章到下方,直接签署)</div>
|
||||
<div class="paging-drop-zone" @dragover.prevent @drop.prevent="onDropPaging($event)">
|
||||
<span v-if="!pagingSeal.sealId">拖拽印章到这里,自动签署骑缝章</span>
|
||||
<template v-else>
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-thumb" />
|
||||
<span>{{ pagingSeal.sealName }}</span>
|
||||
<close-outlined class="del-icon" @click="clearPaging" />
|
||||
</template>
|
||||
</div>
|
||||
<p class="paging-auto-hint" v-if="pagingSeal.sealId">骑缝章拖入后自动签署,无需再点确认按钮</p>
|
||||
</div>
|
||||
|
||||
<!-- 确认签署 -->
|
||||
<div v-if="pendingCount > 0" class="seal-section">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': confirming }" @click="handleConfirmSign" style="width:100%">
|
||||
<span v-if="confirming"><a-spin size="small" /> 签署中...</span>
|
||||
<span v-else>确认签署({{ pendingCount }} 个普通印章)</span>
|
||||
</div>
|
||||
<p class="sign-hint">使用已托管证书直接签署,无需扫码</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3:添加时间戳 -->
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="form-item"><label>时间格式</label>
|
||||
<a-select v-model:value="tsFormat" size="small" style="width:100%">
|
||||
<a-select-option value="yyyy-MM-dd">yyyy-MM-dd</a-select-option>
|
||||
<a-select-option value="yyyy年MM月dd日">yyyy年MM月dd日</a-select-option>
|
||||
<a-select-option value="yyyy/MM/dd">yyyy/MM/dd</a-select-option>
|
||||
<a-select-option value="yyyy-MM-dd HH:mm:ss">yyyy-MM-dd HH:mm:ss</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': tsLoading }" @click="fetchTs" style="width:100%">
|
||||
<span v-if="tsLoading"><a-spin size="small" /> 获取中...</span>
|
||||
<span v-else>获取时间戳</span>
|
||||
</div>
|
||||
<div class="ts-drag-area" v-if="tsImageUrl">
|
||||
<div class="ts-drag-item" draggable="true" @dragstart="onDragTsStart">
|
||||
<img :src="tsImageUrl" class="drag-seal-img" /><span>拖拽时间戳到文档上</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤4:结束合约 -->
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">4</span><span class="step-title">结束合约</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': contractStatus === 2000 || completing }" @click="handleComplete">
|
||||
<check-circle-outlined /><span v-if="completing">结束中...</span><span v-else>结束合约</span>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': contractStatus !== 2000 }" @click="handleDownload" style="margin-top:10px;margin-left:10px">
|
||||
<download-outlined /> 下载已签署文件
|
||||
</div>
|
||||
<div class="contract-status-tag">
|
||||
<span v-if="contractStatus === 2000" class="tag-success">合约已完成</span>
|
||||
<span v-else-if="contractStatus === 20" class="tag-warning">签署进行中</span>
|
||||
<span v-else class="tag-info">等待签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 托管二维码弹窗 ==================== -->
|
||||
<a-modal
|
||||
v-model:open="hostQrVisible"
|
||||
title="新增托管印章"
|
||||
width="420px"
|
||||
:mask-closable="false"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="qr-dialog-body">
|
||||
<div class="qr-img-box" v-if="hostQrCode"><img :src="hostQrCode" class="qr-img" /></div>
|
||||
<div class="qr-img-box qr-loading" v-else><a-spin size="large" /><p>二维码加载中...</p></div>
|
||||
<a-alert message="请使用微信扫描二维码托管印章" type="warning" :closable="false" show-icon style="margin-top:12px" />
|
||||
<div class="qr-countdown" v-if="hostQrCount > 0">
|
||||
<a-progress :percent="hostQrPct" :stroke-color="hostQrClr" :stroke-width="6" :show-info="false" />
|
||||
<p class="countdown-text">有效期剩余:<span :style="{color:hostQrClr}">{{ hostQrTxt }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: right; margin-top: 16px">
|
||||
<a-button @click="closeHostQr">关闭</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- ==================== 签署结果弹窗 ==================== -->
|
||||
<a-modal
|
||||
v-model:open="resultVisible"
|
||||
title="签署结果"
|
||||
width="480px"
|
||||
:mask-closable="false"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="result-body" v-if="signResult">
|
||||
<a-alert :message="'签署' + (signResult.contractStatus === 2000 ? '已完成' : '进行中')" :type="signResult.contractStatus === 2000 ? 'success' : 'warning'" :closable="false" show-icon />
|
||||
<div class="sig-list" v-if="signResult.signatureList?.length">
|
||||
<div v-for="sig in signResult.signatureList" :key="sig.signatureId" class="sig-item">
|
||||
<span>{{ sig.owner }}</span><span>{{ sig.signer }}</span><span>{{ sig.signatureTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: right; margin-top: 16px">
|
||||
<a-button @click="resultVisible = false">关闭</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { CloudUploadOutlined, FileTextOutlined, ClockCircleOutlined, CloseOutlined, CheckCircleOutlined, DownloadOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
startContract, completeContract, downloadContract, pollCallback,
|
||||
getTrusteeshipSealQrCode, confirmSign, getTimestamp, signTimestamp
|
||||
} from '@/api/seal'
|
||||
|
||||
// ======================================================================
|
||||
// localStorage 持久化托管印章
|
||||
// ======================================================================
|
||||
const STORAGE_SEALS = 'trusteeship_seals'
|
||||
|
||||
function saveSeals(seals) {
|
||||
const plain = seals.map(s => ({
|
||||
sealId: s.sealId, sealName: s.sealName, pictureUrl: s.pictureUrl,
|
||||
owner: s.owner, ownerSubAccountId: s.ownerSubAccountId, sealType: s.sealType,
|
||||
_sealMode: s._sealMode || 'manual', _autoPage: s._autoPage || 1,
|
||||
_autoX: s._autoX || 100, _autoY: s._autoY || 100
|
||||
}))
|
||||
try { localStorage.setItem(STORAGE_SEALS, JSON.stringify(plain)) } catch {}
|
||||
}
|
||||
function loadSeals() {
|
||||
try { const r = localStorage.getItem(STORAGE_SEALS); return r ? JSON.parse(r) : [] } catch { return [] }
|
||||
}
|
||||
function deleteSealLocally(sealId) {
|
||||
const seals = loadSeals().filter(s => s.sealId !== sealId)
|
||||
saveSeals(seals)
|
||||
return seals
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 文件上传
|
||||
// ======================================================================
|
||||
const fileInputRef = ref(null), uploadFile = ref(null), contractName = ref(''), creating = ref(false), dragOver = ref(false)
|
||||
const acceptTypes = '.xls,.xlsx,.pdf,.docx,.doc'
|
||||
|
||||
function triggerFileInput() { fileInputRef.value?.click() }
|
||||
function handleFileSelect(e) { const f = e.target.files?.[0]; if (f) validateFile(f); e.target.value = '' }
|
||||
function handleFileDrop(e) { dragOver.value = false; const f = e.dataTransfer.files?.[0]; if (f) validateFile(f) }
|
||||
function validateFile(f) {
|
||||
const ext = f.name.split('.').pop()?.toLowerCase()
|
||||
if (!['xls', 'xlsx', 'pdf', 'docx', 'doc'].includes(ext)) { message.error(`不支持 "${ext}"`); return }
|
||||
if (f.size > 10485760) { message.error('文件不能超过10MB'); return }
|
||||
uploadFile.value = f; if (!contractName.value) contractName.value = f.name.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
function removeFile() { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = '' }
|
||||
function formatSize(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB' }
|
||||
|
||||
// ======================================================================
|
||||
// 签署流程
|
||||
// ======================================================================
|
||||
const contractId = ref(''), contractStatus = ref(null), pdfImages = ref([])
|
||||
const imageWidth = ref(793), imageHeight = ref(1122), completing = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', calcSize)
|
||||
hostedSeals.value = loadSeals()
|
||||
})
|
||||
|
||||
async function handleCreate() {
|
||||
if (!uploadFile.value || !contractName.value) { message.warning('请先选择文件并输入合同名称'); return }
|
||||
creating.value = true
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', uploadFile.value); fd.append('contractName', contractName.value)
|
||||
const res = await startContract(fd)
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data; contractId.value = d.contractId; pdfImages.value = d.pdfToImageList || []
|
||||
imageWidth.value = d.imageWidth || 793; imageHeight.value = d.imageHeight || 1122; contractStatus.value = 10
|
||||
hostedSeals.value = loadSeals()
|
||||
autoPlaceSeals()
|
||||
message.success('签署流程创建成功!')
|
||||
nextTick(calcSize)
|
||||
} else { message.error(res.msg || '创建失败') }
|
||||
} catch { message.error('创建失败') }
|
||||
finally { creating.value = false }
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// PDF 显示
|
||||
// ======================================================================
|
||||
const dw = ref(700), dh = ref(990), sr = ref(1), pdfScrollRef = ref(null)
|
||||
function calcSize() {
|
||||
if (!pdfScrollRef.value) return
|
||||
const cw = pdfScrollRef.value.clientWidth - 40; const mw = Math.min(cw, 800)
|
||||
if (imageWidth.value > 0) { sr.value = mw / imageWidth.value; dw.value = mw; dh.value = Math.round(imageHeight.value * sr.value) }
|
||||
}
|
||||
watch(pdfImages, () => nextTick(calcSize))
|
||||
onUnmounted(() => { window.removeEventListener('resize', calcSize) })
|
||||
|
||||
// ======================================================================
|
||||
// 托管印章
|
||||
// ======================================================================
|
||||
const hostedSeals = ref([])
|
||||
|
||||
const hostQrVisible = ref(false), hostQrCode = ref(''), hostQrRid = ref(''), hostQrExp = ref(300), hostQrCount = ref(0)
|
||||
let hostQrTimer = null, hostPollTimer = null
|
||||
const hostQrPct = computed(() => hostQrExp.value ? Math.round(hostQrCount.value / hostQrExp.value * 100) : 0)
|
||||
const hostQrClr = computed(() => hostQrCount.value <= 30 ? '#f56c6c' : hostQrCount.value <= 60 ? '#e6a23c' : '#67c23a')
|
||||
const hostQrTxt = computed(() => { const m = Math.floor(hostQrCount.value / 60); const s = hostQrCount.value % 60; return `${m}分${String(s).padStart(2, '0')}秒` })
|
||||
|
||||
async function handleHostSeal() {
|
||||
try {
|
||||
const host = 'http://47.110.50.12:7005'
|
||||
const res = await getTrusteeshipSealQrCode(host + '/seal/callback/trusteeship')
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data; hostQrCode.value = d.qrCode || ''; hostQrRid.value = d.requestId || ''; hostQrExp.value = d.expiresIn || 300
|
||||
hostQrVisible.value = true; startHostQrCt(); startHostPoll(d.requestId)
|
||||
} else { message.error(res.msg || '获取失败') }
|
||||
} catch { message.error('获取托管二维码失败') }
|
||||
}
|
||||
function startHostQrCt() { stopHostQrCt(); hostQrCount.value = hostQrExp.value; hostQrTimer = setInterval(() => { hostQrCount.value--; if (hostQrCount.value <= 0) stopHostQrCt() }, 1000) }
|
||||
function stopHostQrCt() { if (hostQrTimer) { clearInterval(hostQrTimer); hostQrTimer = null } }
|
||||
function closeHostQr() { stopHostQrCt(); stopHostPoll(); hostQrVisible.value = false }
|
||||
function startHostPoll(rid) {
|
||||
stopHostPoll(); const max = Math.ceil(hostQrExp.value / 2) + 10; let n = 0
|
||||
hostPollTimer = setInterval(async () => {
|
||||
n++; if (n > max || !hostQrVisible.value) { stopHostPoll(); return }
|
||||
try { const r = await pollCallback(rid); if (r.code === 200 && r.data) { stopHostPoll(); onHostCallback(r.data) } } catch {}
|
||||
}, 2000)
|
||||
}
|
||||
function stopHostPoll() { if (hostPollTimer) { clearInterval(hostPollTimer); hostPollTimer = null } }
|
||||
|
||||
function onHostCallback(data) {
|
||||
if (data.sealList?.length) {
|
||||
const newSeals = data.sealList.map(s => ({
|
||||
...s, _sealMode: 'manual', _autoPage: 1, _autoX: 100, _autoY: 100
|
||||
}))
|
||||
hostedSeals.value = [...hostedSeals.value, ...newSeals]
|
||||
const seen = new Set()
|
||||
hostedSeals.value = hostedSeals.value.filter(s => { const k = s.sealId; if (seen.has(k)) return false; seen.add(k); return true })
|
||||
saveSeals(hostedSeals.value)
|
||||
message.success(`已托管 ${data.sealList.length} 个印章`)
|
||||
}
|
||||
if (hostQrVisible.value) { stopHostQrCt(); hostQrVisible.value = false }
|
||||
}
|
||||
|
||||
function sealTypeLabel(t) {
|
||||
const m = { 0: '个人章', 10: '法人章', 20: '公章', 30: '财务章', 40: '合同章', 50: '发票章', 60: '其他章' }
|
||||
return m[t] || '未知'
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 印章设置
|
||||
// ======================================================================
|
||||
const settingSealId = ref('')
|
||||
const settingSealMode = ref('manual')
|
||||
const settingAutoPage = ref(1), settingAutoX = ref(100), settingAutoY = ref(100)
|
||||
|
||||
const autoSealCount = computed(() => hostedSeals.value.filter(s => s._sealMode === 'auto').length)
|
||||
|
||||
function toggleSealSetting(sealId) {
|
||||
if (settingSealId.value === sealId) { settingSealId.value = ''; return }
|
||||
const seal = hostedSeals.value.find(s => s.sealId === sealId)
|
||||
if (!seal) return
|
||||
settingSealId.value = sealId
|
||||
settingSealMode.value = seal._sealMode || 'manual'
|
||||
settingAutoPage.value = seal._autoPage || 1
|
||||
settingAutoX.value = seal._autoX || 100
|
||||
settingAutoY.value = seal._autoY || 100
|
||||
}
|
||||
|
||||
function saveSealSetting() {
|
||||
const seal = hostedSeals.value.find(s => s.sealId === settingSealId.value)
|
||||
if (!seal) return
|
||||
seal._sealMode = settingSealMode.value
|
||||
seal._autoPage = settingAutoPage.value
|
||||
seal._autoX = settingAutoX.value
|
||||
seal._autoY = settingAutoY.value
|
||||
saveSeals(hostedSeals.value)
|
||||
message.success(`印章 "${seal.sealName}" 已设为${settingSealMode.value === 'auto' ? '自动签章' : '手动拖拽'}`)
|
||||
settingSealId.value = ''
|
||||
}
|
||||
|
||||
function getSealName(sid) { const s = hostedSeals.value.find(v => v.sealId === sid); return s?.sealName || sid }
|
||||
|
||||
function autoPlaceSeals() {
|
||||
if (!pdfImages.value.length) return
|
||||
hostedSeals.value.filter(s => s._sealMode === 'auto').forEach(seal => {
|
||||
const alreadyPlaced = placedSeals.value.some(p => p.sealId === seal.sealId && p._type === 'seal')
|
||||
if (alreadyPlaced) return
|
||||
placedSeals.value.push({
|
||||
id: ++sealPlacedId, _type: 'seal',
|
||||
sealId: seal.sealId, sealName: seal.sealName, pictureUrl: seal.pictureUrl,
|
||||
owner: seal.owner || '', pageNum: seal._autoPage || 1,
|
||||
x: seal._autoX || 100, y: seal._autoY || 100, scale: 1, status: 'pending'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 印章放置
|
||||
// ======================================================================
|
||||
const placedSeals = ref([])
|
||||
let sealPlacedId = 0
|
||||
|
||||
const pagingSigned = ref(false)
|
||||
const pagingSeal = reactive({ sealId: '', sealName: '', pictureUrl: '' })
|
||||
|
||||
function handlePagingSealClick() {
|
||||
if (pagingSigned.value) { message.warning('骑缝章已签署'); return }
|
||||
if (!pagingSeal.sealId) { message.info('请从托管印章列表拖拽印章到骑缝章区域') }
|
||||
}
|
||||
|
||||
async function onDropPaging(e) {
|
||||
if (!dragSealData || dragSealData._isTimestamp) return
|
||||
pagingSeal.sealId = dragSealData.sealId
|
||||
pagingSeal.sealName = dragSealData.sealName
|
||||
pagingSeal.pictureUrl = dragSealData.pictureUrl
|
||||
dragSealData = null
|
||||
message.success(`已设置骑缝章:${pagingSeal.sealName},正在自动签署...`)
|
||||
await signPagingOnly()
|
||||
}
|
||||
|
||||
async function signPagingOnly() {
|
||||
if (!pagingSeal.sealId) return
|
||||
confirming.value = true
|
||||
try {
|
||||
const pagingSealConfigs = [{ sealId: pagingSeal.sealId, scale: 1, startPage: 1, endPage: pdfImages.value.length, coordinateY: Math.round(dh.value / 2) }]
|
||||
const res = await confirmSign({ contractId: contractId.value, sealConfigs: [], pagingSealConfigs })
|
||||
if (res.code === 200 && res.data) {
|
||||
if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus
|
||||
pagingSigned.value = true
|
||||
message.success('骑缝章签署成功')
|
||||
} else {
|
||||
if (res.code === 500) {
|
||||
hostedSeals.value = deleteSealLocally(pagingSeal.sealId)
|
||||
clearPaging()
|
||||
message.error(res.msg || '托管授权已失效,相关印章已移除')
|
||||
} else { message.error(res.msg || '骑缝章签署失败') }
|
||||
}
|
||||
} catch { message.error('骑缝章签署失败') }
|
||||
finally { confirming.value = false }
|
||||
}
|
||||
|
||||
function clearPaging() {
|
||||
Object.assign(pagingSeal, { sealId: '', sealName: '', pictureUrl: '' })
|
||||
}
|
||||
|
||||
function getPagePlaced(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type !== 'timestamp') }
|
||||
function getPagePlacedTs(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type === 'timestamp') }
|
||||
function getPlacedStyle(s) { return { left: (s.x * sr.value) + 'px', top: (s.y * sr.value) + 'px' } }
|
||||
|
||||
let dragSealData = null
|
||||
function onDragHosted(e, seal) {
|
||||
dragSealData = seal
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
e.dataTransfer.setData('text/plain', seal.sealId || 'seal')
|
||||
}
|
||||
|
||||
function onDropSeal(e, pageNum) {
|
||||
if (!dragSealData) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const ox = Math.round((e.clientX - rect.left) / sr.value * 100) / 100
|
||||
const oy = Math.round((e.clientY - rect.top) / sr.value * 100) / 100
|
||||
const isTs = dragSealData._isTimestamp
|
||||
placedSeals.value.push({
|
||||
id: ++sealPlacedId, _type: isTs ? 'timestamp' : 'seal',
|
||||
sealId: isTs ? null : dragSealData.sealId,
|
||||
sealName: isTs ? (dragSealData.sealName || '时间戳') : dragSealData.sealName,
|
||||
pictureUrl: dragSealData.pictureUrl, _pictureUrl: dragSealData.pictureUrl,
|
||||
owner: dragSealData.owner || '', pageNum, x: ox, y: oy, scale: 1, status: 'pending'
|
||||
})
|
||||
if (!isTs) dragSealData = null
|
||||
}
|
||||
|
||||
function removePlaced(id) { const s = placedSeals.value.find(v => v.id === id); if (s?.status === 'signed') { message.warning('已签署不可删除'); return } placedSeals.value = placedSeals.value.filter(v => v.id !== id) }
|
||||
|
||||
let ds2 = null, ds2x = 0, ds2y = 0, d2ox = 0, d2oy = 0
|
||||
function startDragSeal(e, seal) {
|
||||
e.preventDefault(); ds2 = seal; ds2x = e.clientX; ds2y = e.clientY; d2ox = seal.x; d2oy = seal.y
|
||||
const mv = (ev) => { if (!ds2) return; ds2.x = Math.round((d2ox + (ev.clientX - ds2x) / sr.value) * 100) / 100; ds2.y = Math.round((d2oy + (ev.clientY - ds2y) / sr.value) * 100) / 100 }
|
||||
const up = () => { ds2 = null; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up) }
|
||||
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up)
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 确认签署
|
||||
// ======================================================================
|
||||
const confirming = ref(false), resultVisible = ref(false), signResult = ref(null)
|
||||
const pendingCount = computed(() => placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal').length)
|
||||
|
||||
async function handleConfirmSign() {
|
||||
const pending = placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal')
|
||||
if (!pending.length) { message.warning('请先拖拽印章到文档上'); return }
|
||||
confirming.value = true
|
||||
try {
|
||||
const sealConfigs = pending.map(s => ({ sealId: s.sealId, x: s.x, y: s.y, scale: s.scale || 1, pageNum: s.pageNum }))
|
||||
const res = await confirmSign({ contractId: contractId.value, sealConfigs, pagingSealConfigs: null })
|
||||
if (res.code === 200 && res.data) {
|
||||
signResult.value = res.data
|
||||
if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus
|
||||
pending.forEach(s => s.status = 'signed')
|
||||
resultVisible.value = true
|
||||
message.success('签署成功')
|
||||
} else {
|
||||
if (res.code === 500) {
|
||||
const sealIds = pending.map(s => s.sealId).filter(Boolean)
|
||||
sealIds.forEach(sid => { hostedSeals.value = deleteSealLocally(sid) })
|
||||
message.error(res.msg || '托管授权已失效,相关印章已移除,请重新新增托管')
|
||||
} else { message.error(res.msg || '签署失败') }
|
||||
}
|
||||
} catch { message.error('签署失败') }
|
||||
finally { confirming.value = false }
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 时间戳
|
||||
// ======================================================================
|
||||
const tsFormat = ref('yyyy-MM-dd'), tsLoading = ref(false), tsImageUrl = ref(''), tsData = ref(null)
|
||||
async function fetchTs() {
|
||||
tsLoading.value = true
|
||||
try {
|
||||
const res = await getTimestamp(tsFormat.value)
|
||||
if (res.code === 200 && res.data?.pictureUrl) { tsImageUrl.value = res.data.pictureUrl; tsData.value = { pictureUrl: res.data.pictureUrl }; message.success('已获取,请拖拽到文档上') }
|
||||
else message.error(res.msg || '获取失败')
|
||||
} catch { message.error('获取失败') }
|
||||
finally { tsLoading.value = false }
|
||||
}
|
||||
function onDragTsStart(e) {
|
||||
dragSealData = { _isTimestamp: true, pictureUrl: tsData.value?.pictureUrl, _pictureUrl: tsData.value?.pictureUrl, sealName: '时间戳', owner: '' }
|
||||
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', 'timestamp')
|
||||
}
|
||||
async function confirmTs(ts) {
|
||||
if (ts.status !== 'pending') return; ts.status = 'signing'
|
||||
try {
|
||||
const res = await signTimestamp({ contractId: contractId.value, sealConfigs: [{ formatPattern: tsFormat.value, x: ts.x, y: ts.y, pageNum: ts.pageNum, scale: 1 }] })
|
||||
if (res.code === 200 && res.data) {
|
||||
ts.status = 'signed'; ts._signTime = res.data.signatureTime || ''
|
||||
if (res.data.pdfToImageList?.length) pdfImages.value = res.data.pdfToImageList
|
||||
if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus
|
||||
message.success('时间戳签署成功')
|
||||
} else { message.error(res.msg || '失败'); ts.status = 'pending' }
|
||||
} catch { message.error('失败'); ts.status = 'pending' }
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 完成 & 下载
|
||||
// ======================================================================
|
||||
async function handleComplete() {
|
||||
if (contractStatus.value === 2000) { message.warning('合约已结束'); return }
|
||||
Modal.confirm({
|
||||
title: '确认结束',
|
||||
content: '确认结束合约?结束后将无法再添加印章。',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
completing.value = true
|
||||
try {
|
||||
const res = await completeContract(contractId.value)
|
||||
if (res.code === 200) { contractStatus.value = 2000; message.success('合约已完成') }
|
||||
else message.error(res.msg || '结束失败')
|
||||
} catch { message.error('结束失败') }
|
||||
finally { completing.value = false }
|
||||
},
|
||||
})
|
||||
}
|
||||
async function handleDownload() {
|
||||
if (contractStatus.value !== 2000) { message.warning('请先结束合约'); return }
|
||||
try { const res = await downloadContract(contractId.value); if (res.code === 200 && res.data?.downloadUrl) window.open(res.data.downloadUrl, '_blank'); else message.error('获取链接失败') }
|
||||
catch { message.error('下载失败') }
|
||||
}
|
||||
|
||||
onUnmounted(() => { stopHostQrCt(); stopHostPoll() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sign-platform { width: 100%; height: 100vh; background: #f5f7fa; font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #2c3e50; overflow: hidden; }
|
||||
.platform-layout { height: 100vh; overflow: hidden; }
|
||||
.platform-layout-empty { display: grid; grid-template-columns: minmax(0, 1fr) 380px; }
|
||||
.platform-layout-full { display: grid; grid-template-columns: 260px minmax(0, 1fr) 380px; }
|
||||
|
||||
.preview-area { background: linear-gradient(to bottom, #f8fafc, #eef2f7); overflow-y: auto; padding: 30px 50px; display: flex; flex-direction: column; align-items: center; min-width: 0; gap: 28px; }
|
||||
.empty-preview { justify-content: center; align-items: center; background: #f0f2f5; min-width: 0; }
|
||||
.preview-placeholder { width: 420px; padding: 60px 40px; border-radius: 20px; background: rgba(255,255,255,.7); backdrop-filter: blur(10px); box-shadow: 0 10px 40px rgba(15,23,42,.06); border: 1px solid rgba(255,255,255,.8); display: flex; flex-direction: column; align-items: center; }
|
||||
.preview-placeholder p { margin-top: 14px; font-size: 15px; color: #64748b; }
|
||||
|
||||
.pdf-page-wrapper { position: relative; border-radius: 12px; overflow: visible; background: white; box-shadow: 0 12px 40px rgba(15,23,42,.08), 0 2px 8px rgba(15,23,42,.04); transition: all .2s ease; }
|
||||
.pdf-page-wrapper:hover { transform: translateY(-2px); }
|
||||
.pdf-page-image { display: block; user-select: none; pointer-events: none; }
|
||||
|
||||
.placed-seal { position: absolute; z-index: 10; cursor: move; user-select: none; }
|
||||
.placed-seal .placed-img { width: 80px; height: auto; opacity: .85; display: block; pointer-events: none; }
|
||||
.placed-seal .placed-fallback { width: 80px; height: 40px; border: 2px dashed #409eff; background: rgba(64,158,255,.1); display: flex; align-items: center; justify-content: center; font-size: 12px; color: #409eff; }
|
||||
.placed-del { position: absolute; top: -8px; right: -8px; background: #f56c6c; color: #fff; border-radius: 50%; cursor: pointer; font-size: 14px; padding: 2px; }
|
||||
.placed-del:hover { background: #e03838; }
|
||||
.placed-seal.seal-signed { cursor: default; } .placed-seal.seal-signed .placed-img { opacity: 1; } .placed-seal.seal-signed .placed-del { display: none; }
|
||||
.placed-seal.placed-ts .placed-img { width: 130px; }
|
||||
.placed-seal.placed-ts .ts-actions { display: flex; align-items: center; gap: 4px; margin-top: 4px; background: #fff; padding: 2px 6px; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
|
||||
.paging-seal-wrapper { position: absolute; top: 50%; right: -18px; transform: translateY(-50%); width: 52px; height: fit-content; display: flex; align-items: center; justify-content: center; z-index: 50; cursor: pointer; transition: all .25s ease; }
|
||||
.paging-seal-wrapper:hover { transform: translateY(-50%) scale(1.03); }
|
||||
.paging-empty-box { width: 100%; border: 2px dashed #cbd5e1; border-radius: 12px; background: rgba(248,250,252,.95); display: flex; align-items: center; justify-content: center; color: #94a3b8; transition: all .2s ease; padding: 10px; }
|
||||
.paging-empty-text { writing-mode: vertical-rl; text-orientation: upright; font-size: 16px; letter-spacing: 4px; font-weight: 600; line-height: 1.4; }
|
||||
.paging-empty-box:hover { border-color: #3b82f6; color: #3b82f6; background: #eff6ff; }
|
||||
.paging-seal-real { width: 100%; border: 2px solid rgba(210,50,50,.65); background: radial-gradient(circle at center, rgba(255,255,255,.04), rgba(255,0,0,.03)); border-radius: 12px; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: .9; padding: 8px 4px; box-shadow: 0 0 12px rgba(210,50,50,.2), inset 0 0 8px rgba(210,50,50,.1); }
|
||||
.paging-seal-img { width: 32px; height: 32px; object-fit: contain; margin-bottom: 4px; }
|
||||
.paging-seal-real-text { writing-mode: vertical-rl; text-orientation: upright; font-size: 14px; letter-spacing: 2px; color: rgba(210,50,50,.72); font-family: "STFangsong","FangSong","SimSun",serif; user-select: none; }
|
||||
|
||||
.left-info-sidebar { background: #fff; border-right: 1px solid #ebeef5; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
.info-card { background: #fff; border-radius: 16px; padding: 20px; box-shadow: 0 4px 20px rgba(15,23,42,.05); border: 1px solid #eef2f6; }
|
||||
.info-title { font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 18px; }
|
||||
.info-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px dashed #eef2f6; font-size: 14px; }
|
||||
.info-item:last-child { border-bottom: none; } .info-item span { color: #64748b; } .info-item strong { color: #0f172a; }
|
||||
|
||||
.operation-sidebar { width: 380px; background: #fff; border-left: 1px solid #e2e6ed; display: flex; flex-direction: column; padding: 20px; gap: 16px; overflow-y: auto; box-shadow: -2px 0 12px rgba(0,0,0,.04); }
|
||||
.step-card { background: #fff; border: 1px solid #eef1f5; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.04); flex-shrink: 0; }
|
||||
.step-card-disabled { opacity: .6; }
|
||||
.step-card-header { padding: 14px 16px; background: #f8f9fb; border-bottom: 1px solid #f0f2f5; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.step-badge { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; background: #3b6fa0; color: #fff; border-radius: 6px; font-size: 14px; font-weight: 600; flex-shrink: 0; }
|
||||
.step-title { font-size: 15px; font-weight: 600; color: #2c3e50; }
|
||||
.step-required { font-size: 12px; color: #c88a2e; margin-left: 4px; }
|
||||
.step-card-body { padding: 16px; } .step-hint { color: #8b95a5; font-size: 13px; margin: 0; }
|
||||
|
||||
.seal-section { margin-bottom: 14px; }
|
||||
.section-subtitle { font-size: 13px; font-weight: 600; color: #2c3e50; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #f0f2f5; }
|
||||
.form-item { margin-bottom: 10px; } .form-item label { display: block; margin-bottom: 4px; font-size: 13px; color: #606266; } .form-item .required { color: #f56c6c; }
|
||||
|
||||
.hosted-empty { text-align: center; } .hosted-empty-text { color: #909399; font-size: 13px; margin: 0 0 10px; }
|
||||
.hosted-item { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #e4e7ed; border-radius: 6px; margin-bottom: 6px; cursor: grab; }
|
||||
.hosted-item:active { cursor: grabbing; } .hosted-item:hover { border-color: #409eff; }
|
||||
.hosted-img { width: 36px; height: 36px; object-fit: contain; flex-shrink: 0; }
|
||||
.hosted-info { flex: 1; min-width: 0; } .hosted-name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } .hosted-type { font-size: 10px; color: #909399; }
|
||||
|
||||
.hosted-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
.hosted-mode-tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; cursor: pointer; font-weight: 500; }
|
||||
.tag-manual { background: #f0f2f5; color: #606266; } .tag-auto { background: #e6f7ff; color: #1890ff; }
|
||||
.hosted-setting { cursor: pointer; color: #909399; font-size: 14px; } .hosted-setting:hover { color: #409eff; }
|
||||
|
||||
.seal-setting-panel { padding: 10px; background: #f8f9fb; border: 1px solid #e4e7ed; border-radius: 8px; margin-bottom: 8px; }
|
||||
.setting-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 13px; font-weight: 600; color: #2c3e50; }
|
||||
.coord-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 12px; color: #606266; margin-bottom: 4px; }
|
||||
.del-icon { cursor: pointer; color: #f56c6c; font-size: 14px; } .del-icon:hover { color: #e03838; }
|
||||
|
||||
.paging-drop-zone { border: 2px dashed #e6a23c; border-radius: 6px; padding: 16px; text-align: center; color: #e6a23c; font-size: 13px; min-height: 50px; display: flex; align-items: center; justify-content: center; gap: 8px; background: rgba(230,162,60,.04); cursor: pointer; transition: all .2s; }
|
||||
.paging-drop-zone:hover { border-color: #e6a23c; background: rgba(230,162,60,.1); }
|
||||
.paging-seal-thumb { width: 40px; height: 40px; object-fit: contain; }
|
||||
.paging-auto-hint { font-size: 11px; color: #e6a23c; margin-top: 6px; text-align: center; }
|
||||
.sign-hint { font-size: 12px; color: #909399; text-align: center; margin: 6px 0 0; }
|
||||
|
||||
.upload-dragger { border: 2px dashed #dcdfe6; border-radius: 10px; padding: 24px 16px; text-align: center; cursor: pointer; transition: all .25s; background: #fafbfc; }
|
||||
.upload-dragger:hover, .is-dragover { border-color: #3b6fa0; background: #eaf2f9; }
|
||||
.upload-content .anticon { font-size: 36px; color: #b0b8c5; margin-bottom: 8px; }
|
||||
.upload-text, .file-name { font-size: 14px; color: #2c3e50; margin: 0 0 6px; } .file-name { font-weight: 500; }
|
||||
.upload-hint, .file-size { font-size: 12px; color: #8b95a5; margin: 0 0 8px; } .contract-name-row { margin-top: 14px; }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 20px; height: 38px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all .2s; user-select: none; border: none; background: #f0f2f5; color: #2c3e50; }
|
||||
.btn-primary { background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; border: none; box-shadow: 0 8px 20px rgba(37,99,235,.18); margin-top: 10px; }
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(37,99,235,.28); }
|
||||
.btn-success { background: linear-gradient(135deg, #10b981, #059669); color: white; border: none; box-shadow: 0 8px 20px rgba(16,185,129,.18); }
|
||||
.btn-success:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(16,185,129,.28); }
|
||||
.btn-danger-text { background: transparent; color: #c0392b; padding: 0; height: auto; font-size: 13px; }
|
||||
.btn-disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }
|
||||
.btn-sm { height: 30px; padding: 0 12px; font-size: 12px; } .btn-loading { pointer-events: none; opacity: .8; }
|
||||
|
||||
.contract-status-tag { margin-top: 12px; font-size: 13px; }
|
||||
.tag-success { color: #4a9c5d; font-weight: 500; } .tag-warning { color: #c88a2e; font-weight: 500; } .tag-info { color: #8b95a5; font-weight: 500; }
|
||||
|
||||
.ts-drag-area { margin-top: 10px; padding: 10px; background: #f5f7fa; border-radius: 6px; border: 1px dashed #dcdfe6; }
|
||||
.ts-drag-item { width: 130px; cursor: grab; text-align: center; padding: 6px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fff; }
|
||||
.ts-drag-item:hover { border-color: #67c23a; } .ts-drag-item:active { cursor: grabbing; }
|
||||
.ts-drag-item .drag-seal-img { width: 120px; height: auto; display: block; margin: 0 auto 4px; } .ts-drag-item span { font-size: 11px; color: #909399; }
|
||||
|
||||
.qr-dialog-body { text-align: center; } .qr-img-box { display: flex; justify-content: center; align-items: center; min-height: 200px; }
|
||||
.qr-loading { flex-direction: column; gap: 12px; color: #8b95a5; }
|
||||
.qr-img { width: 200px; height: 200px; border: 1px solid #eee; border-radius: 10px; padding: 8px; }
|
||||
.qr-countdown { margin-top: 14px; } .countdown-text { margin-top: 6px; font-size: 13px; color: #5a6b7c; }
|
||||
|
||||
.result-body { text-align: center; } .sig-list { margin-top: 12px; }
|
||||
.sig-item { display: flex; justify-content: space-between; padding: 8px; background: #f5f7fa; border-radius: 4px; margin-bottom: 4px; font-size: 13px; }
|
||||
|
||||
@media (max-width: 900px) { .operation-sidebar { width: 320px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,449 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="contractName || '托管盖章模式'"
|
||||
width="95%"
|
||||
:style="{ top: '30px', paddingBottom: 0 }"
|
||||
:bodyStyle="{ height: 'calc(100vh - 180px)', padding: 0, overflow: 'hidden' }"
|
||||
:destroy-on-close="false"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
wrap-class-name="seal-full-modal"
|
||||
>
|
||||
<div class="sign-platform">
|
||||
<div class="platform-layout platform-layout-empty" v-if="!contractId">
|
||||
<div class="preview-area empty-preview">
|
||||
<div class="preview-placeholder">
|
||||
<cloud-upload-outlined :style="{ fontSize: '48px', color: '#c0c4cc' }" />
|
||||
<p>上传文件后此处将显示文件预览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">1</span><span class="step-title">发起合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="upload-dragger" :class="{ 'is-dragover': dragOver }"
|
||||
@click="triggerFileInput" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="handleFileDrop">
|
||||
<input ref="fileInputRef" type="file" :accept="acceptTypes" style="display:none" @change="handleFileSelect" />
|
||||
<div class="upload-content" v-if="!uploadFile">
|
||||
<cloud-upload-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="upload-text">点击或将文件拖拽到这里上传</p>
|
||||
<p class="upload-hint">支持 xls、xlsx、pdf、docx、doc 文件,且小于10M</p>
|
||||
</div>
|
||||
<div class="upload-content" v-else>
|
||||
<file-text-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="file-name">{{ uploadFile.name }}</p>
|
||||
<p class="file-size">{{ formatSize(uploadFile.size) }}</p>
|
||||
<div class="btn btn-danger-text" @click.stop="removeFile">移除文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contract-name-row"><a-input v-model:value="contractName" placeholder="请输入合同/文件名称" allow-clear /></div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !uploadFile || !contractName || creating }" @click="handleCreate">
|
||||
<a-spin v-if="creating" size="small" /><span v-if="creating"> 发起中...</span><span v-else>发起签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span></div><div class="step-card-body"><p class="step-hint">上传文件后即可新增托管印章并拖拽至指定位置</p></div></div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div><div class="step-card-body"><p class="step-hint">可在此获取并添加时间戳</p></div></div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div><div class="step-card-body"><p class="step-hint">签署完成后可在此结束合约并下载文件</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-layout platform-layout-full" v-else>
|
||||
<div class="left-info-sidebar">
|
||||
<div class="info-card">
|
||||
<div class="info-title">合同信息</div>
|
||||
<div class="info-item"><span>合同名称</span><strong>{{ contractName }}</strong></div>
|
||||
<div class="info-item"><span>合同状态</span><strong>{{ contractStatus === 2000 ? '已完成' : contractStatus === 20 ? '签署中' : '待签署' }}</strong></div>
|
||||
<div class="info-item"><span>文件页数</span><strong>{{ pdfImages.length }} 页</strong></div>
|
||||
<div class="info-item"><span>托管印章</span><strong>{{ hostedSeals.length }} 个</strong></div>
|
||||
<div class="info-item"><span>自动签章</span><strong>{{ autoSealCount }} 个</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-area" ref="pdfScrollRef">
|
||||
<div v-for="(imgUrl, idx) in pdfImages" :key="'p'+idx" class="pdf-page-wrapper"
|
||||
:style="{ width: dw + 'px', height: dh + 'px' }" @dragover.prevent @drop.prevent="onDropSeal($event, idx + 1)">
|
||||
<img :src="imgUrl" class="pdf-page-image" :style="{ width: dw + 'px', height: dh + 'px' }" draggable="false" />
|
||||
<div v-for="seal in getPagePlaced(idx + 1)" :key="'s'+seal.id" class="placed-seal"
|
||||
:class="{ 'seal-signed': seal.status === 'signed' }" :style="getPlacedStyle(seal)"
|
||||
@mousedown.stop="seal.status !== 'signed' && startDragSeal($event, seal)">
|
||||
<img v-if="seal.pictureUrl" :src="seal.pictureUrl" class="placed-img" />
|
||||
<div v-else class="placed-fallback">{{ seal.sealName }}</div>
|
||||
<close-outlined v-if="seal.status !== 'signed'" class="placed-del" @click.stop="removePlaced(seal.id)" />
|
||||
</div>
|
||||
<div v-for="ts in getPagePlacedTs(idx + 1)" :key="'t'+ts.id" class="placed-seal placed-ts"
|
||||
:class="{ 'seal-signed': ts.status === 'signed' }" :style="getPlacedStyle(ts)"
|
||||
@mousedown.stop="ts.status !== 'signed' && startDragSeal($event, ts)">
|
||||
<img v-if="ts._pictureUrl" :src="ts._pictureUrl" class="placed-img" />
|
||||
<div v-else class="placed-fallback">时间戳</div>
|
||||
<div class="ts-actions" v-if="ts.status !== 'signed'">
|
||||
<div class="btn btn-primary btn-sm" :class="{ 'btn-loading': ts.status === 'signing' }" @click.stop="confirmTs(ts)">
|
||||
{{ ts.status === 'signing' ? '签署中...' : '确认' }}
|
||||
</div>
|
||||
<close-outlined class="placed-del" @click.stop="removePlaced(ts.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="paging-seal-wrapper" :class="{ 'paging-empty': !pagingSeal.sealId, 'paging-signed': pagingSigned }" @click.stop="handlePagingSealClick">
|
||||
<template v-if="!pagingSeal.sealId">
|
||||
<div class="paging-empty-box"><div class="paging-empty-text">添加骑缝章</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="paging-seal-real">
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-img" />
|
||||
<div class="paging-seal-real-text">{{ pagingSeal.sealName || '骑缝章' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span><span class="step-required">(必选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="seal-section">
|
||||
<div class="section-subtitle">1、托管印章</div>
|
||||
<div v-if="hostedSeals.length === 0" class="hosted-empty">
|
||||
<p class="hosted-empty-text">尚未获取托管印章</p>
|
||||
<div class="btn btn-primary" @click="handleHostSeal" style="width:100%"><plus-outlined /> 新增托管</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="seal in hostedSeals" :key="seal.sealId" class="hosted-item" draggable="true" @dragstart="onDragHosted($event, seal)">
|
||||
<img :src="seal.pictureUrl" class="hosted-img" />
|
||||
<div class="hosted-info"><span class="hosted-name">{{ seal.sealName }}</span><span class="hosted-type">{{ sealTypeLabel(seal.sealType) }}</span></div>
|
||||
<div class="hosted-actions">
|
||||
<span class="hosted-mode-tag" :class="seal._sealMode === 'auto' ? 'tag-auto' : 'tag-manual'" @click.stop="toggleSealSetting(seal.sealId)">{{ seal._sealMode === 'auto' ? '自动' : '手动' }}</span>
|
||||
<setting-outlined class="hosted-setting" @click.stop="toggleSealSetting(seal.sealId)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settingSealId" class="seal-setting-panel">
|
||||
<div class="setting-header"><span>印章设置 — {{ getSealName(settingSealId) }}</span><close-outlined class="del-icon" @click="settingSealId = ''" /></div>
|
||||
<a-radio-group v-model:value="settingSealMode" size="small" style="margin-bottom:8px">
|
||||
<a-radio value="manual">手动拖拽</a-radio><a-radio value="auto">自动签章</a-radio>
|
||||
</a-radio-group>
|
||||
<template v-if="settingSealMode === 'auto'">
|
||||
<div class="form-item"><label>自动签章位置(上传文档后自动放置)</label></div>
|
||||
<div class="coord-row">
|
||||
<span>第</span><a-input-number v-model:value="settingAutoPage" :min="1" :max="pdfImages.length" size="small" style="width:70px" />
|
||||
<span>页 X:</span><a-input-number v-model:value="settingAutoX" :min="1" :max="imageWidth" size="small" style="width:70px" />
|
||||
<span>Y:</span><a-input-number v-model:value="settingAutoY" :min="1" :max="imageHeight" size="small" style="width:70px" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="btn btn-primary btn-sm" @click="saveSealSetting" style="width:100%;margin-top:6px">保存设置</div>
|
||||
</div>
|
||||
<div class="btn btn-primary" @click="handleHostSeal" style="width:100%;margin-top:8px"><plus-outlined /> 新增托管</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal-section" v-if="hostedSeals.length > 0">
|
||||
<div class="section-subtitle">2、骑缝章(拖拽印章到下方,直接签署)</div>
|
||||
<div class="paging-drop-zone" @dragover.prevent @drop.prevent="onDropPaging($event)">
|
||||
<span v-if="!pagingSeal.sealId">拖拽印章到这里,自动签署骑缝章</span>
|
||||
<template v-else>
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-thumb" />
|
||||
<span>{{ pagingSeal.sealName }}</span>
|
||||
<close-outlined class="del-icon" @click="clearPaging" />
|
||||
</template>
|
||||
</div>
|
||||
<p class="paging-auto-hint" v-if="pagingSeal.sealId">骑缝章拖入后自动签署,无需再点确认按钮</p>
|
||||
</div>
|
||||
<div v-if="pendingCount > 0" class="seal-section">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': confirming }" @click="handleConfirmSign" style="width:100%">
|
||||
<a-spin v-if="confirming" size="small" /><span v-if="confirming"> 签署中...</span>
|
||||
<span v-else>确认签署({{ pendingCount }} 个普通印章)</span>
|
||||
</div>
|
||||
<p class="sign-hint">使用已托管证书直接签署,无需扫码</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="form-item"><label>时间格式</label>
|
||||
<a-select v-model:value="tsFormat" size="small" style="width:100%">
|
||||
<a-select-option value="yyyy-MM-dd">yyyy-MM-dd</a-select-option>
|
||||
<a-select-option value="yyyy年MM月dd日">yyyy年MM月dd日</a-select-option>
|
||||
<a-select-option value="yyyy/MM/dd">yyyy/MM/dd</a-select-option>
|
||||
<a-select-option value="yyyy-MM-dd HH:mm:ss">yyyy-MM-dd HH:mm:ss</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': tsLoading }" @click="fetchTs" style="width:100%">
|
||||
<a-spin v-if="tsLoading" size="small" /><span v-if="tsLoading"> 获取中...</span><span v-else>获取时间戳</span>
|
||||
</div>
|
||||
<div class="ts-drag-area" v-if="tsImageUrl">
|
||||
<div class="ts-drag-item" draggable="true" @dragstart="onDragTsStart"><img :src="tsImageUrl" class="drag-seal-img" /><span>拖拽时间戳到文档上</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': contractStatus === 2000 || completing }" @click="handleComplete">
|
||||
<check-circle-outlined /><span v-if="completing">结束中...</span><span v-else>结束合约</span>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': contractStatus !== 2000 }" @click="handleDownload" style="margin-top:10px;margin-left:10px">
|
||||
<download-outlined /> 下载已签署文件
|
||||
</div>
|
||||
<div class="contract-status-tag">
|
||||
<span v-if="contractStatus === 2000" class="tag-success">合约已完成</span>
|
||||
<span v-else-if="contractStatus === 20" class="tag-warning">签署进行中</span>
|
||||
<span v-else class="tag-info">等待签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 托管二维码弹窗 -->
|
||||
<a-modal v-model:open="hostQrVisible" title="新增托管印章" width="420px" :mask-closable="false" :footer="null">
|
||||
<div class="qr-dialog-body">
|
||||
<div class="qr-img-box" v-if="hostQrCode"><img :src="hostQrCode" class="qr-img" /></div>
|
||||
<div class="qr-img-box qr-loading" v-else><a-spin size="large" /><p>二维码加载中...</p></div>
|
||||
<a-alert message="请使用微信扫描二维码托管印章" type="warning" :closable="false" show-icon style="margin-top:12px" />
|
||||
<div class="qr-countdown" v-if="hostQrCount > 0">
|
||||
<a-progress :percent="hostQrPct" :stroke-color="hostQrClr" :stroke-width="6" :show-info="false" />
|
||||
<p class="countdown-text">有效期剩余:<span :style="{color:hostQrClr}">{{ hostQrTxt }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;margin-top:16px"><a-button @click="closeHostQr">关闭</a-button></div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 签署结果弹窗 -->
|
||||
<a-modal v-model:open="resultVisible" title="签署结果" width="480px" :mask-closable="false" :footer="null">
|
||||
<div class="result-body" v-if="signResult">
|
||||
<a-alert :message="'签署' + (signResult.contractStatus === 2000 ? '已完成' : '进行中')" :type="signResult.contractStatus === 2000 ? 'success' : 'warning'" :closable="false" show-icon />
|
||||
<div class="sig-list" v-if="signResult.signatureList?.length">
|
||||
<div v-for="sig in signResult.signatureList" :key="sig.signatureId" class="sig-item"><span>{{ sig.owner }}</span><span>{{ sig.signer }}</span><span>{{ sig.signatureTime }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;margin-top:16px"><a-button @click="resultVisible = false">关闭</a-button></div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { CloudUploadOutlined, FileTextOutlined, ClockCircleOutlined, CloseOutlined, CheckCircleOutlined, DownloadOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||||
import { startContract, completeContract, downloadContract, pollCallback, getTrusteeshipSealQrCode, confirmSign, getTimestamp, signTimestamp, getContractDetail, previewContract } from '@/api/seal'
|
||||
|
||||
// ==================== 对外暴露 ====================
|
||||
const visible = ref(false)
|
||||
|
||||
async function showModal(contractIdParam) {
|
||||
visible.value = true
|
||||
if (contractIdParam) {
|
||||
await loadContract(contractIdParam)
|
||||
if (contractId.value) {
|
||||
await nextTick()
|
||||
nextTick(calcSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContract(cid) {
|
||||
try {
|
||||
const [detailRes, previewRes] = await Promise.all([
|
||||
getContractDetail(cid),
|
||||
previewContract(cid),
|
||||
])
|
||||
const detail = detailRes?.code === 200 ? detailRes.data : null
|
||||
const preview = previewRes?.code === 200 ? previewRes.data : null
|
||||
if (detail || preview) {
|
||||
contractId.value = cid
|
||||
contractName.value = detail?.contractName || ''
|
||||
contractStatus.value = detail?.contractStatus ?? detail?.status ?? null
|
||||
pdfImages.value = preview?.pdfToImageList || []
|
||||
imageWidth.value = Number(preview?.imageWidth) || 793
|
||||
imageHeight.value = Number(preview?.imageHeight) || 1122
|
||||
saveContractState()
|
||||
}
|
||||
} catch {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_CONTRACT) || '{}')
|
||||
if (saved.contractId === cid) {
|
||||
contractId.value = saved.contractId; contractName.value = saved.contractName || ''
|
||||
contractStatus.value = saved.contractStatus; pdfImages.value = saved.pdfImages || []
|
||||
imageWidth.value = saved.imageWidth || 793; imageHeight.value = saved.imageHeight || 1122
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ showModal })
|
||||
|
||||
// ==================== localStorage ====================
|
||||
const STORAGE_SEALS = 'trusteeship_seals'
|
||||
const STORAGE_CONTRACT = 'trusteeship_contract'
|
||||
function saveSeals(seals) { const plain = seals.map(s => ({ sealId: s.sealId, sealName: s.sealName, pictureUrl: s.pictureUrl, owner: s.owner, ownerSubAccountId: s.ownerSubAccountId, sealType: s.sealType, _sealMode: s._sealMode || 'manual', _autoPage: s._autoPage || 1, _autoX: s._autoX || 100, _autoY: s._autoY || 100 })); try { localStorage.setItem(STORAGE_SEALS, JSON.stringify(plain)) } catch {} }
|
||||
function loadSeals() { try { const r = localStorage.getItem(STORAGE_SEALS); return r ? JSON.parse(r) : [] } catch { return [] } }
|
||||
function deleteSealLocally(sealId) { const seals = loadSeals().filter(s => s.sealId !== sealId); saveSeals(seals); return seals }
|
||||
function saveContractState() { try { localStorage.setItem(STORAGE_CONTRACT, JSON.stringify({ contractId: contractId.value, contractName: contractName.value, contractStatus: contractStatus.value, pdfImages: pdfImages.value, imageWidth: imageWidth.value, imageHeight: imageHeight.value })) } catch {} }
|
||||
function clearContractState() { try { localStorage.removeItem(STORAGE_CONTRACT) } catch {} }
|
||||
|
||||
// ==================== 文件上传 ====================
|
||||
const fileInputRef = ref(null), uploadFile = ref(null), contractName = ref(''), creating = ref(false), dragOver = ref(false), acceptTypes = '.xls,.xlsx,.pdf,.docx,.doc'
|
||||
function triggerFileInput() { fileInputRef.value?.click() }
|
||||
function handleFileSelect(e) { const f = e.target.files?.[0]; if (f) validateFile(f); e.target.value = '' }
|
||||
function handleFileDrop(e) { dragOver.value = false; const f = e.dataTransfer.files?.[0]; if (f) validateFile(f) }
|
||||
function validateFile(f) { const ext = f.name.split('.').pop()?.toLowerCase(); if (!['xls','xlsx','pdf','docx','doc'].includes(ext)) { message.error(`不支持 "${ext}"`); return } if (f.size > 10485760) { message.error('文件不能超过10MB'); return } uploadFile.value = f; if (!contractName.value) contractName.value = f.name.replace(/\.[^.]+$/, '') }
|
||||
function removeFile() { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = '' }
|
||||
function formatSize(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB' }
|
||||
|
||||
// ==================== 签署流程 ====================
|
||||
const contractId = ref(''), contractStatus = ref(null), pdfImages = ref([]), imageWidth = ref(793), imageHeight = ref(1122), completing = ref(false)
|
||||
onMounted(() => { window.addEventListener('resize', calcSize); hostedSeals.value = loadSeals() })
|
||||
async function handleCreate() {
|
||||
if (!uploadFile.value || !contractName.value) { message.warning('请先选择文件并输入合同名称'); return }
|
||||
creating.value = true
|
||||
try { const fd = new FormData(); fd.append('file', uploadFile.value); fd.append('contractName', contractName.value); const res = await startContract(fd); if (res.code === 200 && res.data) { const d = res.data; contractId.value = d.contractId; pdfImages.value = d.pdfToImageList || []; imageWidth.value = d.imageWidth || 793; imageHeight.value = d.imageHeight || 1122; contractStatus.value = 10; saveContractState(); hostedSeals.value = loadSeals(); autoPlaceSeals(); message.success('签署流程创建成功!'); nextTick(calcSize) } else { message.error(res.msg || '创建失败') } } catch { message.error('创建失败') } finally { creating.value = false }
|
||||
}
|
||||
|
||||
// ==================== PDF 显示 ====================
|
||||
const dw = ref(700), dh = ref(990), sr = ref(1), pdfScrollRef = ref(null)
|
||||
function calcSize() { if (!pdfScrollRef.value) return; const cw = pdfScrollRef.value.clientWidth - 40; const mw = Math.min(cw, 800); if (imageWidth.value > 0) { sr.value = mw / imageWidth.value; dw.value = mw; dh.value = Math.round(imageHeight.value * sr.value) } }
|
||||
watch(pdfImages, () => nextTick(calcSize)); onUnmounted(() => { window.removeEventListener('resize', calcSize) })
|
||||
|
||||
// ==================== 托管印章 ====================
|
||||
const hostedSeals = ref([])
|
||||
const hostQrVisible = ref(false), hostQrCode = ref(''), hostQrRid = ref(''), hostQrExp = ref(300), hostQrCount = ref(0)
|
||||
let hostQrTimer = null, hostPollTimer = null
|
||||
const hostQrPct = computed(() => hostQrExp.value ? Math.round(hostQrCount.value / hostQrExp.value * 100) : 0)
|
||||
const hostQrClr = computed(() => hostQrCount.value <= 30 ? '#f56c6c' : hostQrCount.value <= 60 ? '#e6a23c' : '#67c23a')
|
||||
const hostQrTxt = computed(() => { const m = Math.floor(hostQrCount.value / 60); const s = hostQrCount.value % 60; return `${m}分${String(s).padStart(2, '0')}秒` })
|
||||
async function handleHostSeal() {
|
||||
try { const host = 'http://47.110.50.12:7005'; const res = await getTrusteeshipSealQrCode(host + '/seal/callback/trusteeship'); if (res.code === 200 && res.data) { const d = res.data; hostQrCode.value = d.qrCode || ''; hostQrRid.value = d.requestId || ''; hostQrExp.value = d.expiresIn || 300; hostQrVisible.value = true; startHostQrCt(); startHostPoll(d.requestId) } else { message.error(res.msg || '获取失败') } } catch { message.error('获取托管二维码失败') }
|
||||
}
|
||||
function startHostQrCt() { stopHostQrCt(); hostQrCount.value = hostQrExp.value; hostQrTimer = setInterval(() => { hostQrCount.value--; if (hostQrCount.value <= 0) stopHostQrCt() }, 1000) }
|
||||
function stopHostQrCt() { if (hostQrTimer) { clearInterval(hostQrTimer); hostQrTimer = null } }
|
||||
function closeHostQr() { stopHostQrCt(); stopHostPoll(); hostQrVisible.value = false }
|
||||
function startHostPoll(rid) { stopHostPoll(); const max = Math.ceil(hostQrExp.value / 2) + 10; let n = 0; hostPollTimer = setInterval(async () => { n++; if (n > max || !hostQrVisible.value) { stopHostPoll(); return } try { const r = await pollCallback(rid); if (r.code === 200 && r.data) { stopHostPoll(); onHostCallback(r.data) } } catch {} }, 2000) }
|
||||
function stopHostPoll() { if (hostPollTimer) { clearInterval(hostPollTimer); hostPollTimer = null } }
|
||||
function onHostCallback(data) { if (data.sealList?.length) { const newSeals = data.sealList.map(s => ({ ...s, _sealMode: 'manual', _autoPage: 1, _autoX: 100, _autoY: 100 })); hostedSeals.value = [...hostedSeals.value, ...newSeals]; const seen = new Set(); hostedSeals.value = hostedSeals.value.filter(s => { const k = s.sealId; if (seen.has(k)) return false; seen.add(k); return true }); saveSeals(hostedSeals.value); message.success(`已托管 ${data.sealList.length} 个印章`) } if (hostQrVisible.value) { stopHostQrCt(); hostQrVisible.value = false } }
|
||||
function sealTypeLabel(t) { const m = { 0:'个人章',10:'法人章',20:'公章',30:'财务章',40:'合同章',50:'发票章',60:'其他章' }; return m[t] || '未知' }
|
||||
|
||||
// ==================== 印章设置 ====================
|
||||
const settingSealId = ref(''), settingSealMode = ref('manual'), settingAutoPage = ref(1), settingAutoX = ref(100), settingAutoY = ref(100)
|
||||
const autoSealCount = computed(() => hostedSeals.value.filter(s => s._sealMode === 'auto').length)
|
||||
function toggleSealSetting(sealId) { if (settingSealId.value === sealId) { settingSealId.value = ''; return } const seal = hostedSeals.value.find(s => s.sealId === sealId); if (!seal) return; settingSealId.value = sealId; settingSealMode.value = seal._sealMode || 'manual'; settingAutoPage.value = seal._autoPage || 1; settingAutoX.value = seal._autoX || 100; settingAutoY.value = seal._autoY || 100 }
|
||||
function saveSealSetting() { const seal = hostedSeals.value.find(s => s.sealId === settingSealId.value); if (!seal) return; seal._sealMode = settingSealMode.value; seal._autoPage = settingAutoPage.value; seal._autoX = settingAutoX.value; seal._autoY = settingAutoY.value; saveSeals(hostedSeals.value); message.success(`印章 "${seal.sealName}" 已设为${settingSealMode.value === 'auto' ? '自动签章' : '手动拖拽'}`); settingSealId.value = '' }
|
||||
function getSealName(sid) { const s = hostedSeals.value.find(v => v.sealId === sid); return s?.sealName || sid }
|
||||
function autoPlaceSeals() { if (!pdfImages.value.length) return; hostedSeals.value.filter(s => s._sealMode === 'auto').forEach(seal => { const alreadyPlaced = placedSeals.value.some(p => p.sealId === seal.sealId && p._type === 'seal'); if (alreadyPlaced) return; placedSeals.value.push({ id: ++sealPlacedId, _type: 'seal', sealId: seal.sealId, sealName: seal.sealName, pictureUrl: seal.pictureUrl, owner: seal.owner || '', pageNum: seal._autoPage || 1, x: seal._autoX || 100, y: seal._autoY || 100, scale: 1, status: 'pending' }) }) }
|
||||
|
||||
// ==================== 印章放置 ====================
|
||||
const placedSeals = ref([]); let sealPlacedId = 0
|
||||
const pagingSigned = ref(false); const pagingSeal = reactive({ sealId: '', sealName: '', pictureUrl: '' })
|
||||
function handlePagingSealClick() { if (pagingSigned.value) { message.warning('骑缝章已签署'); return } if (!pagingSeal.sealId) message.info('请从托管印章列表拖拽印章到骑缝章区域') }
|
||||
async function onDropPaging(e) { if (!dragSealData || dragSealData._isTimestamp) return; pagingSeal.sealId = dragSealData.sealId; pagingSeal.sealName = dragSealData.sealName; pagingSeal.pictureUrl = dragSealData.pictureUrl; dragSealData = null; message.success(`已设置骑缝章:${pagingSeal.sealName},正在自动签署...`); await signPagingOnly() }
|
||||
async function signPagingOnly() { if (!pagingSeal.sealId) return; confirming.value = true; try { const pagingSealConfigs = [{ sealId: pagingSeal.sealId, scale: 1, startPage: 1, endPage: pdfImages.value.length, coordinateY: Math.round(dh.value / 2) }]; const res = await confirmSign({ contractId: contractId.value, sealConfigs: [], pagingSealConfigs }); if (res.code === 200 && res.data) { if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus; pagingSigned.value = true; message.success('骑缝章签署成功') } else { if (res.code === 500) { hostedSeals.value = deleteSealLocally(pagingSeal.sealId); clearPaging(); message.error(res.msg || '托管授权已失效,相关印章已移除') } else { message.error(res.msg || '骑缝章签署失败') } } } catch { message.error('骑缝章签署失败') } finally { confirming.value = false } }
|
||||
function clearPaging() { Object.assign(pagingSeal, { sealId: '', sealName: '', pictureUrl: '' }) }
|
||||
function getPagePlaced(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type !== 'timestamp') }
|
||||
function getPagePlacedTs(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type === 'timestamp') }
|
||||
function getPlacedStyle(s) { return { left: (s.x * sr.value) + 'px', top: (s.y * sr.value) + 'px' } }
|
||||
let dragSealData = null
|
||||
function onDragHosted(e, seal) { dragSealData = seal; e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', seal.sealId || 'seal') }
|
||||
function onDropSeal(e, pageNum) { if (!dragSealData) return; const rect = e.currentTarget.getBoundingClientRect(); const ox = Math.round((e.clientX - rect.left) / sr.value * 100) / 100; const oy = Math.round((e.clientY - rect.top) / sr.value * 100) / 100; const isTs = dragSealData._isTimestamp; placedSeals.value.push({ id: ++sealPlacedId, _type: isTs ? 'timestamp' : 'seal', sealId: isTs ? null : dragSealData.sealId, sealName: isTs ? (dragSealData.sealName || '时间戳') : dragSealData.sealName, pictureUrl: dragSealData.pictureUrl, _pictureUrl: dragSealData.pictureUrl, owner: dragSealData.owner || '', pageNum, x: ox, y: oy, scale: 1, status: 'pending' }); if (!isTs) dragSealData = null }
|
||||
function removePlaced(id) { const s = placedSeals.value.find(v => v.id === id); if (s?.status === 'signed') { message.warning('已签署不可删除'); return } placedSeals.value = placedSeals.value.filter(v => v.id !== id) }
|
||||
let ds2 = null, ds2x = 0, ds2y = 0, d2ox = 0, d2oy = 0
|
||||
function startDragSeal(e, seal) { e.preventDefault(); ds2 = seal; ds2x = e.clientX; ds2y = e.clientY; d2ox = seal.x; d2oy = seal.y; const mv = (ev) => { if (!ds2) return; ds2.x = Math.round((d2ox + (ev.clientX - ds2x) / sr.value) * 100) / 100; ds2.y = Math.round((d2oy + (ev.clientY - ds2y) / sr.value) * 100) / 100 }; const up = () => { ds2 = null; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up) }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up) }
|
||||
|
||||
// ==================== 确认签署 ====================
|
||||
const confirming = ref(false), resultVisible = ref(false), signResult = ref(null)
|
||||
const pendingCount = computed(() => placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal').length)
|
||||
async function handleConfirmSign() { const pending = placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal'); if (!pending.length) { message.warning('请先拖拽印章到文档上'); return } confirming.value = true; try { const sealConfigs = pending.map(s => ({ sealId: s.sealId, x: s.x, y: s.y, scale: s.scale || 1, pageNum: s.pageNum })); const res = await confirmSign({ contractId: contractId.value, sealConfigs, pagingSealConfigs: null }); if (res.code === 200 && res.data) { signResult.value = res.data; if (res.data.contractStatus !== undefined) { contractStatus.value = res.data.contractStatus; if (res.data.contractStatus === 2000) clearContractState(); else saveContractState() } pending.forEach(s => s.status = 'signed'); resultVisible.value = true; message.success('签署成功') } else { if (res.code === 500) { const sealIds = pending.map(s => s.sealId).filter(Boolean); sealIds.forEach(sid => { hostedSeals.value = deleteSealLocally(sid) }); message.error(res.msg || '托管授权已失效,相关印章已移除,请重新新增托管') } else { message.error(res.msg || '签署失败') } } } catch { message.error('签署失败') } finally { confirming.value = false } }
|
||||
|
||||
// ==================== 时间戳 ====================
|
||||
const tsFormat = ref('yyyy-MM-dd'), tsLoading = ref(false), tsImageUrl = ref(''), tsData = ref(null)
|
||||
async function fetchTs() { tsLoading.value = true; try { const res = await getTimestamp(tsFormat.value); if (res.code === 200 && res.data?.pictureUrl) { tsImageUrl.value = res.data.pictureUrl; tsData.value = { pictureUrl: res.data.pictureUrl }; message.success('已获取,请拖拽到文档上') } else message.error(res.msg || '获取失败') } catch { message.error('获取失败') } finally { tsLoading.value = false } }
|
||||
function onDragTsStart(e) { dragSealData = { _isTimestamp: true, pictureUrl: tsData.value?.pictureUrl, _pictureUrl: tsData.value?.pictureUrl, sealName: '时间戳', owner: '' }; e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', 'timestamp') }
|
||||
async function confirmTs(ts) { if (ts.status !== 'pending') return; ts.status = 'signing'; try { const res = await signTimestamp({ contractId: contractId.value, sealConfigs: [{ formatPattern: tsFormat.value, x: ts.x, y: ts.y, pageNum: ts.pageNum, scale: 1 }] }); if (res.code === 200 && res.data) { ts.status = 'signed'; ts._signTime = res.data.signatureTime || ''; if (res.data.pdfToImageList?.length) pdfImages.value = res.data.pdfToImageList; if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus; message.success('时间戳签署成功') } else { message.error(res.msg || '失败'); ts.status = 'pending' } } catch { message.error('失败'); ts.status = 'pending' } }
|
||||
|
||||
// ==================== 完成 & 下载 ====================
|
||||
async function handleComplete() { if (contractStatus.value === 2000) { message.warning('合约已结束'); return } Modal.confirm({ title: '确认结束', content: '确认结束合约?', okType: 'danger', onOk: async () => { completing.value = true; try { const res = await completeContract(contractId.value); if (res.code === 200) { contractStatus.value = 2000; clearContractState(); message.success('合约已完成') } else message.error(res.msg || '结束失败') } catch { message.error('结束失败') } finally { completing.value = false } } }) }
|
||||
async function handleDownload() { if (contractStatus.value !== 2000) { message.warning('请先结束合约'); return } try { const res = await downloadContract(contractId.value); if (res.code === 200 && res.data?.downloadUrl) window.open(res.data.downloadUrl, '_blank'); else message.error('获取链接失败') } catch { message.error('下载失败') } }
|
||||
onUnmounted(() => { stopHostQrCt(); stopHostPoll() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sign-platform { width:100%;height:100%;background:#f5f7fa;font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#2c3e50;overflow:hidden;display:flex;flex-direction:column }
|
||||
.platform-layout { flex:1;overflow:hidden }
|
||||
.platform-layout-empty { display:grid;grid-template-columns:minmax(0,1fr) 380px }
|
||||
.platform-layout-full { display:grid;grid-template-columns:260px minmax(0,1fr) 380px }
|
||||
.preview-area { background:linear-gradient(to bottom,#f8fafc,#eef2f7);overflow-y:auto;padding:30px 50px;display:flex;flex-direction:column;align-items:center;min-width:0;gap:28px }
|
||||
.empty-preview { justify-content:center;align-items:center;background:#f0f2f5;min-width:0 }
|
||||
.preview-placeholder { width:420px;padding:60px 40px;border-radius:20px;background:rgba(255,255,255,.7);backdrop-filter:blur(10px);box-shadow:0 10px 40px rgba(15,23,42,.06);border:1px solid rgba(255,255,255,.8);display:flex;flex-direction:column;align-items:center }
|
||||
.preview-placeholder p { margin-top:14px;font-size:15px;color:#64748b }
|
||||
.pdf-page-wrapper { position:relative;border-radius:12px;overflow:visible;background:white;box-shadow:0 12px 40px rgba(15,23,42,.08),0 2px 8px rgba(15,23,42,.04);transition:all .2s ease }
|
||||
.pdf-page-wrapper:hover { transform:translateY(-2px) }
|
||||
.pdf-page-image { display:block;user-select:none;pointer-events:none }
|
||||
.placed-seal { position:absolute;z-index:10;cursor:move;user-select:none }
|
||||
.placed-seal .placed-img { width:80px;height:auto;opacity:.85;display:block;pointer-events:none }
|
||||
.placed-seal .placed-fallback { width:80px;height:40px;border:2px dashed #409eff;background:rgba(64,158,255,.1);display:flex;align-items:center;justify-content:center;font-size:12px;color:#409eff }
|
||||
.placed-del { position:absolute;top:-8px;right:-8px;background:#f56c6c;color:#fff;border-radius:50%;cursor:pointer;font-size:14px;padding:2px }
|
||||
.placed-del:hover { background:#e03838 }
|
||||
.placed-seal.seal-signed { cursor:default } .placed-seal.seal-signed .placed-img { opacity:1 } .placed-seal.seal-signed .placed-del { display:none }
|
||||
.placed-seal.placed-ts .placed-img { width:130px }
|
||||
.placed-seal.placed-ts .ts-actions { display:flex;align-items:center;gap:4px;margin-top:4px;background:#fff;padding:2px 6px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.1) }
|
||||
.paging-seal-wrapper { position:absolute;top:50%;right:-18px;transform:translateY(-50%);width:52px;height:fit-content;display:flex;align-items:center;justify-content:center;z-index:50;cursor:pointer;transition:all .25s ease }
|
||||
.paging-seal-wrapper:hover { transform:translateY(-50%) scale(1.03) }
|
||||
.paging-empty-box { width:100%;border:2px dashed #cbd5e1;border-radius:12px;background:rgba(248,250,252,.95);display:flex;align-items:center;justify-content:center;color:#94a3b8;transition:all .2s ease;padding:10px }
|
||||
.paging-empty-text { writing-mode:vertical-rl;text-orientation:upright;font-size:16px;letter-spacing:4px;font-weight:600;line-height:1.4 }
|
||||
.paging-empty-box:hover { border-color:#3b82f6;color:#3b82f6;background:#eff6ff }
|
||||
.paging-seal-real { width:100%;border:2px solid rgba(210,50,50,.65);background:radial-gradient(circle at center,rgba(255,255,255,.04),rgba(255,0,0,.03));border-radius:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:.9;padding:8px 4px;box-shadow:0 0 12px rgba(210,50,50,.2),inset 0 0 8px rgba(210,50,50,.1) }
|
||||
.paging-seal-img { width:32px;height:32px;object-fit:contain;margin-bottom:4px }
|
||||
.paging-seal-real-text { writing-mode:vertical-rl;text-orientation:upright;font-size:14px;letter-spacing:2px;color:rgba(210,50,50,.72);font-family:"STFangsong","FangSong","SimSun",serif;user-select:none }
|
||||
.left-info-sidebar { background:#fff;border-right:1px solid #ebeef5;padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:20px }
|
||||
.info-card { background:#fff;border-radius:16px;padding:20px;box-shadow:0 4px 20px rgba(15,23,42,.05);border:1px solid #eef2f6 }
|
||||
.info-title { font-size:16px;font-weight:700;color:#1e293b;margin-bottom:18px }
|
||||
.info-item { display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px dashed #eef2f6;font-size:14px }
|
||||
.info-item:last-child { border-bottom:none } .info-item span { color:#64748b } .info-item strong { color:#0f172a }
|
||||
.operation-sidebar { width:380px;background:#fff;border-left:1px solid #e2e6ed;display:flex;flex-direction:column;padding:20px;gap:16px;overflow-y:auto;box-shadow:-2px 0 12px rgba(0,0,0,.04) }
|
||||
.step-card { background:#fff;border:1px solid #eef1f5;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.04);flex-shrink:0 }
|
||||
.step-card-disabled { opacity:.6 }
|
||||
.step-card-header { padding:14px 16px;background:#f8f9fb;border-bottom:1px solid #f0f2f5;display:flex;align-items:center;gap:10px;flex-wrap:wrap }
|
||||
.step-badge { display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#3b6fa0;color:#fff;border-radius:6px;font-size:14px;font-weight:600;flex-shrink:0 }
|
||||
.step-title { font-size:15px;font-weight:600;color:#2c3e50 }
|
||||
.step-required { font-size:12px;color:#c88a2e;margin-left:4px }
|
||||
.step-card-body { padding:16px } .step-hint { color:#8b95a5;font-size:13px;margin:0 }
|
||||
.seal-section { margin-bottom:14px } .section-subtitle { font-size:13px;font-weight:600;color:#2c3e50;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #f0f2f5 }
|
||||
.form-item { margin-bottom:10px } .form-item label { display:block;margin-bottom:4px;font-size:13px;color:#606266 } .form-item .required { color:#f56c6c }
|
||||
.hosted-empty { text-align:center } .hosted-empty-text { color:#909399;font-size:13px;margin:0 0 10px }
|
||||
.hosted-item { display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #e4e7ed;border-radius:6px;margin-bottom:6px;cursor:grab }
|
||||
.hosted-item:active { cursor:grabbing } .hosted-item:hover { border-color:#409eff }
|
||||
.hosted-img { width:36px;height:36px;object-fit:contain;flex-shrink:0 }
|
||||
.hosted-info { flex:1;min-width:0 } .hosted-name { font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block } .hosted-type { font-size:10px;color:#909399 }
|
||||
.hosted-actions { display:flex;align-items:center;gap:4px;flex-shrink:0 }
|
||||
.hosted-mode-tag { font-size:10px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:500 }
|
||||
.tag-manual { background:#f0f2f5;color:#606266 } .tag-auto { background:#e6f7ff;color:#1890ff }
|
||||
.hosted-setting { cursor:pointer;color:#909399;font-size:14px } .hosted-setting:hover { color:#409eff }
|
||||
.seal-setting-panel { padding:10px;background:#f8f9fb;border:1px solid #e4e7ed;border-radius:8px;margin-bottom:8px }
|
||||
.setting-header { display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:13px;font-weight:600;color:#2c3e50 }
|
||||
.coord-row { display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:12px;color:#606266;margin-bottom:4px }
|
||||
.del-icon { cursor:pointer;color:#f56c6c;font-size:14px } .del-icon:hover { color:#e03838 }
|
||||
.paging-drop-zone { border:2px dashed #e6a23c;border-radius:6px;padding:16px;text-align:center;color:#e6a23c;font-size:13px;min-height:50px;display:flex;align-items:center;justify-content:center;gap:8px;background:rgba(230,162,60,.04);cursor:pointer;transition:all .2s }
|
||||
.paging-drop-zone:hover { border-color:#e6a23c;background:rgba(230,162,60,.1) }
|
||||
.paging-seal-thumb { width:40px;height:40px;object-fit:contain }
|
||||
.paging-auto-hint { font-size:11px;color:#e6a23c;margin-top:6px;text-align:center }
|
||||
.sign-hint { font-size:12px;color:#909399;text-align:center;margin:6px 0 0 }
|
||||
.upload-dragger { border:2px dashed #dcdfe6;border-radius:10px;padding:24px 16px;text-align:center;cursor:pointer;transition:all .25s;background:#fafbfc }
|
||||
.upload-dragger:hover,.is-dragover { border-color:#3b6fa0;background:#eaf2f9 }
|
||||
.upload-content .anticon { font-size:36px;color:#b0b8c5;margin-bottom:8px }
|
||||
.upload-text,.file-name { font-size:14px;color:#2c3e50;margin:0 0 6px } .file-name { font-weight:500 }
|
||||
.upload-hint,.file-size { font-size:12px;color:#8b95a5;margin:0 0 8px } .contract-name-row { margin-top:14px }
|
||||
.btn { display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 20px;height:38px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;user-select:none;border:none;background:#f0f2f5;color:#2c3e50 }
|
||||
.btn-primary { background:linear-gradient(135deg,#3b82f6,#2563eb);color:white;border:none;box-shadow:0 8px 20px rgba(37,99,235,.18);margin-top:10px }
|
||||
.btn-primary:hover { transform:translateY(-1px);box-shadow:0 10px 24px rgba(37,99,235,.28) }
|
||||
.btn-success { background:linear-gradient(135deg,#10b981,#059669);color:white;border:none;box-shadow:0 8px 20px rgba(16,185,129,.18) }
|
||||
.btn-success:hover { transform:translateY(-1px);box-shadow:0 10px 24px rgba(16,185,129,.28) }
|
||||
.btn-danger-text { background:transparent;color:#c0392b;padding:0;height:auto;font-size:13px }
|
||||
.btn-disabled { opacity:.5;cursor:not-allowed;pointer-events:none }
|
||||
.btn-sm { height:30px;padding:0 12px;font-size:12px } .btn-loading { pointer-events:none;opacity:.8 }
|
||||
.contract-status-tag { margin-top:12px;font-size:13px } .tag-success { color:#4a9c5d;font-weight:500 } .tag-warning { color:#c88a2e;font-weight:500 } .tag-info { color:#8b95a5;font-weight:500 }
|
||||
.qr-dialog-body { text-align:center } .qr-img-box { display:flex;justify-content:center;align-items:center;min-height:200px }
|
||||
.qr-loading { flex-direction:column;gap:12px;color:#8b95a5 }
|
||||
.qr-img { width:200px;height:200px;border:1px solid #eee;border-radius:10px;padding:8px } .qr-countdown { margin-top:14px } .countdown-text { margin-top:6px;font-size:13px;color:#5a6b7c }
|
||||
.ts-drag-area { margin-top:10px;padding:10px;background:#f5f7fa;border-radius:6px;border:1px dashed #dcdfe6 }
|
||||
.ts-drag-item { width:130px;cursor:grab;text-align:center;padding:6px;border:1px solid #e4e7ed;border-radius:6px;background:#fff }
|
||||
.ts-drag-item:hover { border-color:#67c23a } .ts-drag-item:active { cursor:grabbing }
|
||||
.ts-drag-item .drag-seal-img { width:120px;height:auto;display:block;margin:0 auto 4px } .ts-drag-item span { font-size:11px;color:#909399 }
|
||||
.result-body { text-align:center } .sig-list { margin-top:12px }
|
||||
.sig-item { display:flex;justify-content:space-between;padding:8px;background:#f5f7fa;border-radius:4px;margin-bottom:4px;font-size:13px }
|
||||
@media (max-width:900px) { .operation-sidebar { width:320px } }
|
||||
</style>
|
||||
@@ -0,0 +1,309 @@
|
||||
/**
|
||||
* 合同发起模块 - API 层
|
||||
*
|
||||
* 当前使用 mock 数据模拟接口返回。
|
||||
* 【接入后端】:直接替换每个函数内部实现为 axios 请求即可,函数签名不改。
|
||||
*/
|
||||
|
||||
// ========== 合同发起列表 mock 数据 ==========
|
||||
|
||||
const mockLaunchRecords = [
|
||||
{
|
||||
id: 'INIT-202605-001',
|
||||
// 合同编码
|
||||
contractCode: 'HT-2026-0008',
|
||||
// 合同名称
|
||||
contractName: '智慧园区设备采购合同',
|
||||
// 合同类型
|
||||
contractType: '采购合同',
|
||||
// 合同金额(元)
|
||||
contractAmount: 368000,
|
||||
// 有效期起 YYYY-MM-DD
|
||||
effectiveStart: '2026-05-10',
|
||||
// 有效期止 YYYY-MM-DD
|
||||
effectiveEnd: '2027-05-09',
|
||||
// 付款方式
|
||||
paymentMethod: '银行转账(30%预付款,70%验收后支付)',
|
||||
// 所选模板标识
|
||||
templateKey: 'purchase',
|
||||
// 所选模板名称
|
||||
templateName: '采购合同模板',
|
||||
// 合同标的
|
||||
contractSubject: '园区门禁一体机、访客终端及部署实施服务',
|
||||
// 业务备注
|
||||
businessRemark: '首次发起,等待业务确认后提交法务。',
|
||||
// 发起人
|
||||
initiator: '张晓明',
|
||||
// 所属部门
|
||||
initiatorDept: '供应链运营部',
|
||||
// 发起人公司
|
||||
companyName: '浙港物流平台有限公司',
|
||||
// 状态: draft/reviewing/rejected/pending_sign/signing/completed
|
||||
status: 'draft',
|
||||
// 查询级别: personal/company/admin
|
||||
queryScope: 'personal',
|
||||
// 创建时间
|
||||
createTime: '2026-05-10 09:20:00',
|
||||
// 更新时间
|
||||
updateTime: '2026-05-13 14:30:00',
|
||||
// 附件列表
|
||||
attachmentList: [
|
||||
{ uid: 'a1', name: '技术方案V1.pdf', size: 283011, status: 'done' },
|
||||
{ uid: 'a2', name: '补充协议草案.docx', size: 125880, status: 'done' },
|
||||
],
|
||||
// 合同方信息(详见下方 partyState 结构)
|
||||
partyState: {
|
||||
isThreePartyContract: false,
|
||||
partyA: {
|
||||
companyName: '浙港物流平台有限公司',
|
||||
contactPhone: '',
|
||||
registeredAddress: '',
|
||||
bankAccount: '',
|
||||
bankName: '',
|
||||
contactName: '',
|
||||
legalRepresentative: '',
|
||||
creditCode: '',
|
||||
},
|
||||
partyB: {
|
||||
companyName: '宁波港船务货运代理有限公司',
|
||||
companyType: '供应商',
|
||||
contactPhone: '15757460062',
|
||||
registeredAddress: '',
|
||||
bankAccount: '',
|
||||
bankName: '',
|
||||
contactName: '周经理',
|
||||
legalRepresentative: '',
|
||||
creditCode: '',
|
||||
},
|
||||
partyC: { companyName: '' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'INIT-202605-002',
|
||||
contractCode: 'XS-2026-0015',
|
||||
contractName: '保税燃料油销售合同',
|
||||
contractType: '销售合同',
|
||||
contractAmount: 1580000,
|
||||
effectiveStart: '2026-05-01',
|
||||
effectiveEnd: '2026-12-31',
|
||||
paymentMethod: '银行转账(按月结算)',
|
||||
templateKey: 'sales',
|
||||
templateName: '销售合同模板',
|
||||
contractSubject: '保税燃料油销售及船舶供油服务',
|
||||
businessRemark: '已提交法务,等待审批中。',
|
||||
initiator: '王倩',
|
||||
initiatorDept: '国际贸易事业部',
|
||||
companyName: '浙港物流平台有限公司',
|
||||
status: 'reviewing',
|
||||
queryScope: 'company',
|
||||
createTime: '2026-05-08 16:15:00',
|
||||
updateTime: '2026-05-14 08:30:00',
|
||||
attachmentList: [
|
||||
{ uid: 'a3', name: '报价确认函.pdf', size: 90211, status: 'done' },
|
||||
],
|
||||
partyState: {
|
||||
isThreePartyContract: false,
|
||||
partyA: {
|
||||
companyName: '宁波浙港供应链科技有限公司',
|
||||
contactPhone: '13900000088',
|
||||
contactName: '陈经理',
|
||||
},
|
||||
partyB: {
|
||||
companyName: '上海远洋能源有限公司',
|
||||
companyType: '客户',
|
||||
legalRepresentative: '孙先生',
|
||||
contactName: '吴经理',
|
||||
contactPhone: '13600000015',
|
||||
contactEmail: 'sales@oceanenergy.com',
|
||||
bankName: '招商银行上海分行',
|
||||
bankAccount: '621483000000000015',
|
||||
},
|
||||
partyC: { companyName: '' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'INIT-202605-003',
|
||||
contractCode: 'FW-2026-0021',
|
||||
contractName: '数字化平台运维服务合同',
|
||||
contractType: '服务合同',
|
||||
contractAmount: 520000,
|
||||
effectiveStart: '2026-04-15',
|
||||
effectiveEnd: '2027-04-14',
|
||||
paymentMethod: '银行转账(一次性结清)',
|
||||
templateKey: 'purchase',
|
||||
templateName: '采购合同模板',
|
||||
contractSubject: '合同管理板块运维与驻场实施服务',
|
||||
businessRemark: '法务要求补充服务SLA附件后重新提交。',
|
||||
initiator: '李娜',
|
||||
initiatorDept: '数字化办公室',
|
||||
companyName: '浙港物流平台有限公司',
|
||||
status: 'rejected',
|
||||
queryScope: 'company',
|
||||
createTime: '2026-04-15 10:25:00',
|
||||
updateTime: '2026-05-07 18:05:00',
|
||||
attachmentList: [
|
||||
{ uid: 'a4', name: '实施范围说明.pdf', size: 70123, status: 'done' },
|
||||
],
|
||||
partyState: {
|
||||
isThreePartyContract: false,
|
||||
partyA: { companyName: '浙港物流平台有限公司' },
|
||||
partyB: {
|
||||
companyName: '杭州智云实施服务有限公司',
|
||||
companyType: '供应商',
|
||||
contactName: '刘经理',
|
||||
contactPhone: '13888886666',
|
||||
contactEmail: 'liu@cloudservice.com',
|
||||
},
|
||||
partyC: { companyName: '' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'INIT-202605-004',
|
||||
contractCode: 'KJ-2026-0011',
|
||||
contractName: '港口智能设备年度框架合同',
|
||||
contractType: '框架合同',
|
||||
contractAmount: 980000,
|
||||
effectiveStart: '2026-03-01',
|
||||
effectiveEnd: '2027-02-28',
|
||||
paymentMethod: '银行转账(按月结算)',
|
||||
templateKey: 'purchase',
|
||||
templateName: '采购合同模板',
|
||||
contractSubject: '港口智能硬件、集成服务及年度维保合作',
|
||||
businessRemark: '审批完成,待发起线上签署。',
|
||||
initiator: '周立',
|
||||
initiatorDept: '装备采购中心',
|
||||
companyName: '浙港物流平台有限公司',
|
||||
status: 'pending_sign',
|
||||
queryScope: 'admin',
|
||||
createTime: '2026-03-01 11:12:00',
|
||||
updateTime: '2026-05-11 09:45:00',
|
||||
attachmentList: [
|
||||
{ uid: 'a5', name: '年度采购清单.xlsx', size: 154933, status: 'done' },
|
||||
],
|
||||
partyState: {
|
||||
isThreePartyContract: true,
|
||||
partyA: { companyName: '浙港物流平台有限公司' },
|
||||
partyB: {
|
||||
companyName: '宁波保税区港航设备有限公司',
|
||||
companyType: '合作伙伴',
|
||||
contactName: '赵主管',
|
||||
contactPhone: '13500000021',
|
||||
},
|
||||
partyC: {
|
||||
companyName: '浙江临港数智科技有限公司',
|
||||
companyType: '合作伙伴',
|
||||
creditCode: '91330000MAABCDE123',
|
||||
legalRepresentative: '钱先生',
|
||||
registeredAddress: '浙江省杭州市滨江区江南大道2888号',
|
||||
contactName: '何经理',
|
||||
contactPhone: '13700000078',
|
||||
contactAddress: '浙江省杭州市滨江区江南大道2888号',
|
||||
contactEmail: 'hz@example.com',
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'INIT-202605-005',
|
||||
contractCode: 'XS-2026-0022',
|
||||
contractName: '沿海航线供应服务合同',
|
||||
contractType: '销售合同',
|
||||
contractAmount: 760000,
|
||||
effectiveStart: '2026-02-18',
|
||||
effectiveEnd: '2026-11-30',
|
||||
paymentMethod: '银行转账(按月结算)',
|
||||
templateKey: 'sales',
|
||||
templateName: '销售合同模板',
|
||||
contractSubject: '沿海航线供应链服务与结算支持',
|
||||
businessRemark: '甲方已签章,等待乙方签署。',
|
||||
initiator: '徐晨',
|
||||
initiatorDept: '客户服务中心',
|
||||
companyName: '浙港物流平台有限公司',
|
||||
status: 'signing',
|
||||
queryScope: 'personal',
|
||||
createTime: '2026-02-18 13:40:00',
|
||||
updateTime: '2026-05-12 17:16:00',
|
||||
attachmentList: [],
|
||||
partyState: {
|
||||
isThreePartyContract: false,
|
||||
partyA: {
|
||||
companyName: '宁波浙港供应链科技有限公司',
|
||||
contactPhone: '13900000088',
|
||||
contactName: '陈经理',
|
||||
},
|
||||
partyB: {
|
||||
companyName: '上海远洋能源有限公司',
|
||||
companyType: '客户',
|
||||
legalRepresentative: '孙先生',
|
||||
contactName: '吴经理',
|
||||
contactPhone: '13600000015',
|
||||
contactEmail: 'sales@oceanenergy.com',
|
||||
bankName: '招商银行上海分行',
|
||||
bankAccount: '621483000000000015',
|
||||
},
|
||||
partyC: { companyName: '' },
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'INIT-202605-006',
|
||||
contractCode: 'CG-2026-0033',
|
||||
contractName: '港区安防升级采购合同',
|
||||
contractType: '采购合同',
|
||||
contractAmount: 2860000,
|
||||
effectiveStart: '2026-01-10',
|
||||
effectiveEnd: '2026-12-31',
|
||||
paymentMethod: '银行转账(30%预付款,70%验收后支付)',
|
||||
templateKey: 'purchase',
|
||||
templateName: '采购合同模板',
|
||||
contractSubject: '港区安防摄像头、门禁和施工实施采购',
|
||||
businessRemark: '合同已签署完毕并推送法务归档。',
|
||||
initiator: '高翔',
|
||||
initiatorDept: '工程保障部',
|
||||
companyName: '浙港物流平台有限公司',
|
||||
status: 'completed',
|
||||
queryScope: 'admin',
|
||||
createTime: '2026-01-10 08:45:00',
|
||||
updateTime: '2026-05-09 16:28:00',
|
||||
attachmentList: [
|
||||
{ uid: 'a6', name: '验收报告.pdf', size: 188221, status: 'done' },
|
||||
],
|
||||
partyState: {
|
||||
isThreePartyContract: false,
|
||||
partyA: { companyName: '浙港物流平台有限公司' },
|
||||
partyB: {
|
||||
companyName: '宁波港船务货运代理有限公司',
|
||||
companyType: '供应商',
|
||||
contactPhone: '15757460062',
|
||||
contactName: '周经理',
|
||||
},
|
||||
partyC: { companyName: '' },
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
// ========== API 函数(接入后端时替换内部实现) ==========
|
||||
|
||||
/** 查询合同发起列表 */
|
||||
export function queryLaunchList() {
|
||||
return Promise.resolve(mockLaunchRecords)
|
||||
}
|
||||
|
||||
/** 查询合同发起详情 */
|
||||
export function getLaunchDetail(id) {
|
||||
const record = mockLaunchRecords.find((item) => item.id === id) || null
|
||||
return Promise.resolve(record)
|
||||
}
|
||||
|
||||
/** 保存合同发起 */
|
||||
export function saveLaunchRecord(record) {
|
||||
return Promise.resolve(record)
|
||||
}
|
||||
|
||||
/** 提交合同到法务 */
|
||||
export function submitLaunchRecord(record) {
|
||||
return Promise.resolve(record)
|
||||
}
|
||||
|
||||
/** 删除合同发起记录 */
|
||||
export function deleteLaunchRecord(idList) {
|
||||
return Promise.resolve(idList)
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,18 @@
|
||||
<template>
|
||||
<BaseMixedSignModal ref="innerRef" />
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import BaseMixedSignModal from '../../../seal/mixedSignModal.vue'
|
||||
|
||||
const innerRef = ref(null)
|
||||
|
||||
function showModal(...args) {
|
||||
return innerRef.value?.showModal(...args)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showModal,
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,577 @@
|
||||
<template>
|
||||
<section class="section-container">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
class="query-card"
|
||||
:body-style="queryCardBodyStyle"
|
||||
>
|
||||
<div ref="queryContainer" class="section-query-container">
|
||||
<a-form ref="formRef" :model="queryParam" layout="inline">
|
||||
<a-row :gutter="16" style="width: 100%">
|
||||
<a-col :md="6" :sm="24">
|
||||
<a-form-item label="合同名称">
|
||||
<a-input
|
||||
size="small"
|
||||
v-model:value="queryParam.contractName"
|
||||
allow-clear
|
||||
placeholder="请输入合同名称"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :md="6" :sm="24">
|
||||
<a-form-item label="合同编码">
|
||||
<a-input
|
||||
size="small"
|
||||
v-model:value="queryParam.contractCode"
|
||||
allow-clear
|
||||
placeholder="请输入合同编码"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="6" :sm="24">
|
||||
<a-form-item label="当前状态">
|
||||
<a-select
|
||||
v-model:value="queryParam.status"
|
||||
allow-clear
|
||||
placeholder="请选择状态"
|
||||
:options="contractRecordStatusOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="12" :sm="24">
|
||||
<a-form-item label="发起日期">
|
||||
<a-range-picker
|
||||
v-model:value="queryParam.dateRange"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card
|
||||
:bordered="false"
|
||||
class="table-card"
|
||||
:body-style="tableCardBodyStyle"
|
||||
>
|
||||
<vxe-toolbar ref="toolbarRef" custom>
|
||||
<template #buttons>
|
||||
<a-space wrap>
|
||||
<a-button
|
||||
type="primary"
|
||||
:icon="h(SearchOutlined)"
|
||||
@click="handleQuery"
|
||||
>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button :icon="h(RedoOutlined)" @click="handleReset">
|
||||
重置
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
:icon="h(PlusOutlined)"
|
||||
@click="handleCreate"
|
||||
>
|
||||
发起合同
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:icon="h(DeleteOutlined)"
|
||||
:disabled="!selectedRowKeys.length"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</vxe-toolbar>
|
||||
|
||||
<vxe-table
|
||||
ref="tableRef"
|
||||
border
|
||||
:height="tableHeight"
|
||||
:row-config="{ isHover: true, height: 44 }"
|
||||
:column-config="{ resizable: true, minWidth: 120 }"
|
||||
:custom-config="{ storage: true }"
|
||||
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
|
||||
:checkbox-config="{ range: true }"
|
||||
:data="tableData"
|
||||
:loading="loading"
|
||||
:loading-config="{
|
||||
icon: 'vxe-icon-indicator roll',
|
||||
text: '正在拼命加载中...',
|
||||
}"
|
||||
@checkbox-range-change="selectChangeEvent"
|
||||
@checkbox-change="selectChangeEvent"
|
||||
@checkbox-all="selectChangeEvent"
|
||||
>
|
||||
<vxe-column type="checkbox" width="56" />
|
||||
<vxe-column type="seq" title="序号" width="68" />
|
||||
<vxe-column
|
||||
field="contractCode"
|
||||
title="合同编码"
|
||||
width="150"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="contractName"
|
||||
title="合同名称"
|
||||
width="220"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="contractType"
|
||||
title="合同类型"
|
||||
width="110"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="templateName"
|
||||
title="所选模板"
|
||||
width="150"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="counterpartySummary"
|
||||
title="合同相对方"
|
||||
width="260"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="status"
|
||||
title="状态"
|
||||
width="120"
|
||||
show-overflow="title"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
<a-tag :color="getStatusColor(row.status)">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</vxe-column>
|
||||
|
||||
<vxe-column
|
||||
field="initiator"
|
||||
title="发起人"
|
||||
width="100"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="initiatorDept"
|
||||
title="所属部门"
|
||||
width="140"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="createTime"
|
||||
title="发起时间"
|
||||
width="170"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="updateTime"
|
||||
title="最后更新时间"
|
||||
width="170"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column title="操作" width="260" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<a @click="handleView(row)">查看</a>
|
||||
<a v-if="canEdit(row)" @click="handleEdit(row)">编辑</a>
|
||||
<a v-if="canOnlineSign(row)" @click="handleOnlineSign(row)">
|
||||
{{ row.status === "completed" ? "查看签署" : "在线签订" }}
|
||||
</a>
|
||||
<a v-if="canDelete(row)" @click="handleDelete(row)">删除</a>
|
||||
</div>
|
||||
</template>
|
||||
</vxe-column>
|
||||
</vxe-table>
|
||||
|
||||
<a-pagination
|
||||
id="pageWrapper"
|
||||
ref="pageWrapperRef"
|
||||
v-model:pageSize="queryParam.pageSize"
|
||||
v-model:current="queryParam.pageNo"
|
||||
class="table-pagination"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
:total="total"
|
||||
:show-total="(value) => `共 ${value} 条`"
|
||||
@change="onShowSizeChange"
|
||||
@show-size-change="onShowSizeChange"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<ContractPartyPlaceholderModal ref="contractModalRef" />
|
||||
<MixedSignModal ref="mixedSignModalRef" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
RedoOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import ContractPartyPlaceholderModal from "./components/ContractPartyPlaceholderModal.vue";
|
||||
import MixedSignModal from "./components/mixedSignModal.vue";
|
||||
import { deleteLaunchRecord, queryLaunchList } from "./api.js";
|
||||
|
||||
// 页面内字典(后续可由后端接口替换)
|
||||
const contractRecordStatusOptions = [
|
||||
{ label: "草稿", value: "draft" },
|
||||
{ label: "审批中", value: "reviewing" },
|
||||
{ label: "审批驳回", value: "rejected" },
|
||||
{ label: "待签署", value: "pending_sign" },
|
||||
{ label: "签署中", value: "signing" },
|
||||
{ label: "已完成", value: "completed" },
|
||||
]
|
||||
|
||||
const formRef = ref(null);
|
||||
const queryContainer = ref(null);
|
||||
const pageWrapperRef = ref(null);
|
||||
const tableRef = ref(null);
|
||||
const toolbarRef = ref(null);
|
||||
const contractModalRef = ref(null);
|
||||
const mixedSignModalRef = ref(null);
|
||||
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const tableHeight = ref(420);
|
||||
const tableData = ref([]);
|
||||
const selectedRows = ref([]);
|
||||
const selectedRowKeys = ref([]);
|
||||
const allRecords = ref([]);
|
||||
|
||||
const queryParam = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
contractName: undefined,
|
||||
contractCode: undefined,
|
||||
contractType: undefined,
|
||||
status: undefined,
|
||||
queryScope: undefined,
|
||||
templateKey: undefined,
|
||||
dateRange: [],
|
||||
});
|
||||
|
||||
const queryCardBodyStyle = {
|
||||
padding: "20px 24px 6px",
|
||||
};
|
||||
|
||||
const tableCardBodyStyle = {
|
||||
display: "flex",
|
||||
flexFlow: "column nowrap",
|
||||
minHeight: "0",
|
||||
padding: "8px 12px 6px",
|
||||
};
|
||||
|
||||
const templateQueryOptions = [
|
||||
{ label: "采购合同模板", value: "purchase" },
|
||||
{ label: "销售合同模板", value: "sales" },
|
||||
];
|
||||
|
||||
const statusLabelMap = {
|
||||
draft: "草稿",
|
||||
reviewing: "审批中",
|
||||
rejected: "审批驳回",
|
||||
pending_sign: "待签署",
|
||||
signing: "签署中",
|
||||
completed: "已完成",
|
||||
};
|
||||
|
||||
const statusColorMap = {
|
||||
draft: "default",
|
||||
reviewing: "processing",
|
||||
rejected: "error",
|
||||
pending_sign: "warning",
|
||||
signing: "purple",
|
||||
completed: "success",
|
||||
};
|
||||
|
||||
const scopeLabelMap = {
|
||||
personal: "个人级",
|
||||
company: "公司级",
|
||||
admin: "管理员级",
|
||||
};
|
||||
|
||||
function connectToolbar() {
|
||||
const table = tableRef.value;
|
||||
const toolbar = toolbarRef.value;
|
||||
|
||||
if (table && toolbar && table.connect) {
|
||||
table.connect(toolbar);
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
return statusLabelMap[status] || status;
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
return statusColorMap[status] || "default";
|
||||
}
|
||||
|
||||
function getScopeLabel(scope) {
|
||||
return scopeLabelMap[scope] || scope;
|
||||
}
|
||||
|
||||
function canEdit(row) {
|
||||
return ["draft", "rejected"].includes(row.status);
|
||||
}
|
||||
|
||||
function canDelete(row) {
|
||||
return ["draft", "rejected"].includes(row.status);
|
||||
}
|
||||
|
||||
function canOnlineSign(row) {
|
||||
return ["pending_sign", "signing", "completed"].includes(row.status);
|
||||
}
|
||||
|
||||
function selectChangeEvent({ records }) {
|
||||
selectedRows.value = records;
|
||||
selectedRowKeys.value = records.map((item) => item.id);
|
||||
}
|
||||
|
||||
function filterRecords(records) {
|
||||
return records.filter((item) => {
|
||||
const matchName =
|
||||
!queryParam.value.contractName ||
|
||||
item.contractName.includes(queryParam.value.contractName);
|
||||
|
||||
const matchCode =
|
||||
!queryParam.value.contractCode ||
|
||||
item.contractCode.includes(queryParam.value.contractCode);
|
||||
|
||||
const matchType =
|
||||
!queryParam.value.contractType ||
|
||||
item.contractType === queryParam.value.contractType;
|
||||
|
||||
const matchStatus =
|
||||
!queryParam.value.status || item.status === queryParam.value.status;
|
||||
|
||||
const matchScope =
|
||||
!queryParam.value.queryScope ||
|
||||
item.queryScope === queryParam.value.queryScope;
|
||||
|
||||
const matchTemplate =
|
||||
!queryParam.value.templateKey ||
|
||||
item.templateKey === queryParam.value.templateKey;
|
||||
|
||||
const matchDate = (() => {
|
||||
const range = queryParam.value.dateRange || [];
|
||||
if (!range.length) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const day = item.createTime.slice(0, 10);
|
||||
return day >= range[0] && day <= range[1];
|
||||
})();
|
||||
|
||||
return (
|
||||
matchName &&
|
||||
matchCode &&
|
||||
matchType &&
|
||||
matchStatus &&
|
||||
matchScope &&
|
||||
matchTemplate &&
|
||||
matchDate
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getList() {
|
||||
loading.value = true;
|
||||
|
||||
queryLaunchList().then((records) => {
|
||||
// TODO: 这里后续直接使用合同发起列表接口返回值
|
||||
allRecords.value = records;
|
||||
const filtered = filterRecords(allRecords.value);
|
||||
const start = (queryParam.value.pageNo - 1) * queryParam.value.pageSize;
|
||||
const end = start + queryParam.value.pageSize;
|
||||
|
||||
total.value = filtered.length;
|
||||
tableData.value = filtered.slice(start, end);
|
||||
loading.value = false;
|
||||
|
||||
nextTick(() => {
|
||||
connectToolbar();
|
||||
syncTableHeight();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
queryParam.value.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
formRef.value?.resetFields?.();
|
||||
Object.assign(queryParam.value, {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
contractName: undefined,
|
||||
contractCode: undefined,
|
||||
contractType: undefined,
|
||||
status: undefined,
|
||||
queryScope: undefined,
|
||||
templateKey: undefined,
|
||||
dateRange: [],
|
||||
});
|
||||
getList();
|
||||
}
|
||||
|
||||
function onShowSizeChange(current, pageSize) {
|
||||
queryParam.value.pageNo = current;
|
||||
queryParam.value.pageSize = pageSize;
|
||||
getList();
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
contractModalRef.value?.showModal({}, "create");
|
||||
}
|
||||
|
||||
function handleView(row) {
|
||||
contractModalRef.value?.showModal(row, "view");
|
||||
}
|
||||
|
||||
function handleEdit(row) {
|
||||
contractModalRef.value?.showModal(row, "edit");
|
||||
}
|
||||
|
||||
function handleOnlineSign(row) {
|
||||
if (!row.sealContractId && !row.signContractId && !row.contractId) {
|
||||
message.info(
|
||||
"当前未接入签署详情接口,将进入在线签署弹窗并保留合同业务信息,你可以继续上传文件后发起签署。",
|
||||
);
|
||||
}
|
||||
mixedSignModalRef.value?.showModal(row);
|
||||
}
|
||||
|
||||
function deleteRowsByIds(ids) {
|
||||
// TODO: 这里后续替换为删除接口后再刷新列表
|
||||
allRecords.value = allRecords.value.filter((item) => !ids.includes(item.id));
|
||||
selectedRows.value = [];
|
||||
selectedRowKeys.value = [];
|
||||
getList();
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
Modal.confirm({
|
||||
title: `确认删除合同【${row.contractName}】吗?`,
|
||||
content: "当前仅删除本地 mock 数据,便于后续替换真实接口。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okType: "danger",
|
||||
onOk: () => {
|
||||
deleteLaunchRecord([row.id]).then(() => {
|
||||
deleteRowsByIds([row.id]);
|
||||
message.success("已删除发起记录");
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleBatchDelete() {
|
||||
const undeletable = selectedRows.value.filter((item) => !canDelete(item));
|
||||
if (undeletable.length) {
|
||||
message.warning("仅草稿或审批驳回状态支持删除,请重新选择");
|
||||
return;
|
||||
}
|
||||
|
||||
Modal.confirm({
|
||||
title: `确认删除选中的 ${selectedRowKeys.value.length} 条记录吗?`,
|
||||
content: "当前仅演示本地数据删除,后续接入真实接口即可替换。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okType: "danger",
|
||||
onOk: () => {
|
||||
deleteLaunchRecord(selectedRowKeys.value).then(() => {
|
||||
deleteRowsByIds(selectedRowKeys.value);
|
||||
message.success("批量删除成功");
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function syncTableHeight() {
|
||||
nextTick(() => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const queryHeight = queryContainer.value?.offsetHeight || 0;
|
||||
const pageHeight = pageWrapperRef.value?.$el?.offsetHeight || 56;
|
||||
tableHeight.value = Math.max(
|
||||
320,
|
||||
viewportHeight - queryHeight - pageHeight - 320,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleQuery();
|
||||
nextTick(() => {
|
||||
connectToolbar();
|
||||
syncTableHeight();
|
||||
});
|
||||
window.addEventListener("resize", syncTableHeight);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", syncTableHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-container {
|
||||
height: calc(100vh - 116px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.query-card {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-query-container :deep(.ant-form-item) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-query-container :deep(.ant-form-item .ant-form-item-control) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 4px 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.section-container {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,422 @@
|
||||
<template>
|
||||
<section class="section-container">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
class="query-card"
|
||||
:body-style="queryCardBodyStyle"
|
||||
>
|
||||
<div ref="queryContainer" class="section-query-container">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="queryParam"
|
||||
layout="inline"
|
||||
>
|
||||
<a-row :gutter="16" style="width: 100%">
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-form-item label="模板名称">
|
||||
<a-input
|
||||
v-model:value="queryParam.templateName"
|
||||
allow-clear
|
||||
placeholder="请输入模板名称"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-form-item label="合同类型">
|
||||
<a-select
|
||||
v-model:value="queryParam.contractType"
|
||||
allow-clear
|
||||
placeholder="请选择合同类型"
|
||||
:options="contractTypeOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
<a-col :md="8" :sm="24">
|
||||
<a-form-item label="模板状态">
|
||||
<a-select
|
||||
v-model:value="queryParam.status"
|
||||
allow-clear
|
||||
placeholder="请选择模板状态"
|
||||
:options="templateStatusOptions"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card
|
||||
:bordered="false"
|
||||
class="table-card"
|
||||
:body-style="tableCardBodyStyle"
|
||||
>
|
||||
<vxe-toolbar ref="toolbarRef" custom>
|
||||
<template #buttons>
|
||||
<a-space wrap>
|
||||
<a-button type="primary" :icon="h(SearchOutlined)" @click="handleQuery">
|
||||
查询
|
||||
</a-button>
|
||||
<a-button :icon="h(RedoOutlined)" @click="handleReset">
|
||||
重置
|
||||
</a-button>
|
||||
<a-button type="primary" ghost :icon="h(PlusOutlined)" @click="handleCreate">
|
||||
新增模板
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</vxe-toolbar>
|
||||
|
||||
<vxe-table
|
||||
ref="tableRef"
|
||||
border
|
||||
:height="tableHeight"
|
||||
:row-config="{ isHover: true, height: 44 }"
|
||||
:column-config="{ resizable: true, minWidth: 120 }"
|
||||
:custom-config="{ storage: true }"
|
||||
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
|
||||
:data="tableData"
|
||||
:loading="loading"
|
||||
:loading-config="{ icon: 'vxe-icon-indicator roll', text: '正在加载模板数据...' }"
|
||||
>
|
||||
<vxe-column type="seq" title="序号" width="68" />
|
||||
<vxe-column field="templateCode" title="模板编码" width="150" show-overflow="title" />
|
||||
<vxe-column field="templateName" title="模板名称" width="180" show-overflow="title" />
|
||||
<vxe-column field="contractType" title="合同类型" width="120" show-overflow="title" />
|
||||
<vxe-column field="source" title="模板来源" width="110" show-overflow="title" />
|
||||
<vxe-column field="version" title="版本" width="90" />
|
||||
<vxe-column field="status" title="状态" width="110">
|
||||
<template #default="{ row }">
|
||||
<a-tag :color="getStatusColor(row.status)">
|
||||
{{ getStatusLabel(row.status) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="variableCount" title="变量数" width="90" />
|
||||
<vxe-column field="publishScopeText" title="发布范围" width="220" show-overflow="title" />
|
||||
<vxe-column field="templateFileName" title="模板文件" width="220" show-overflow="title" />
|
||||
<vxe-column field="updatedBy" title="最后修改人" width="120" show-overflow="title" />
|
||||
<vxe-column field="updatedTime" title="最后修改时间" width="170" show-overflow="title" />
|
||||
<vxe-column field="description" title="说明" width="260" show-overflow="title" />
|
||||
<vxe-column title="操作" width="220" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<a @click="handleView(row)">查看</a>
|
||||
<a @click="handleEdit(row)">编辑</a>
|
||||
<a @click="toggleStatus(row)">
|
||||
{{ row.status === 'published' ? '停用' : '发布' }}
|
||||
</a>
|
||||
</div>
|
||||
</template>
|
||||
</vxe-column>
|
||||
</vxe-table>
|
||||
|
||||
<a-pagination
|
||||
ref="pageWrapperRef"
|
||||
v-model:pageSize="queryParam.pageSize"
|
||||
v-model:current="queryParam.pageNo"
|
||||
class="table-pagination"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
:total="total"
|
||||
:show-total="(value) => `共 ${value} 条`"
|
||||
@change="onShowSizeChange"
|
||||
@show-size-change="onShowSizeChange"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<ContractTemplateManageModal
|
||||
ref="templateModalRef"
|
||||
@submit="handleTemplateSubmit"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { PlusOutlined, RedoOutlined, SearchOutlined } from '@ant-design/icons-vue'
|
||||
import { message } from 'ant-design-vue'
|
||||
import ContractTemplateManageModal from './components/ContractTemplateManageModal.vue'
|
||||
import {
|
||||
changeManifestStatus,
|
||||
getManifestDetail,
|
||||
queryManifestList,
|
||||
saveManifest,
|
||||
} from '@/api/manifest.js'
|
||||
|
||||
function isSuccessResponse(res) {
|
||||
return String(res?.code || '') === '0000'
|
||||
}
|
||||
|
||||
function getPageResult(res) {
|
||||
return res?.result || {}
|
||||
}
|
||||
|
||||
function getDataResult(res) {
|
||||
return res?.result || null
|
||||
}
|
||||
|
||||
function getResponseMessage(res, fallback = '操作失败') {
|
||||
return res?.message || res?.msg || fallback
|
||||
}
|
||||
|
||||
// 页面内字典(后续可由后端接口替换)
|
||||
const contractTypeOptions = [
|
||||
{ label: '买卖合同', value: '买卖合同' },
|
||||
{ label: '采购合同', value: '采购合同' },
|
||||
{ label: '销售合同', value: '销售合同' },
|
||||
{ label: '服务合同', value: '服务合同' },
|
||||
{ label: '框架合同', value: '框架合同' },
|
||||
{ label: '供用电、水、气、热力合同', value: '供用电、水、气、热力合同' },
|
||||
{ label: '赠与合同', value: '赠与合同' },
|
||||
{ label: '借款合同', value: '借款合同' },
|
||||
{ label: '保证合同', value: '保证合同' },
|
||||
{ label: '租赁合同', value: '租赁合同' },
|
||||
{ label: '融资租赁合同', value: '融资租赁合同' },
|
||||
{ label: '保理合同', value: '保理合同' },
|
||||
{ label: '承揽合同', value: '承揽合同' },
|
||||
{ label: '建设工程合同', value: '建设工程合同' },
|
||||
{ label: '运输合同', value: '运输合同' },
|
||||
{ label: '技术合同', value: '技术合同' },
|
||||
{ label: '保管合同', value: '保管合同' },
|
||||
{ label: '仓储合同', value: '仓储合同' },
|
||||
{ label: '委托合同', value: '委托合同' },
|
||||
{ label: '物业服务合同', value: '物业服务合同' },
|
||||
{ label: '行纪合同', value: '行纪合同' },
|
||||
{ label: '中介合同', value: '中介合同' },
|
||||
{ label: '合伙合同', value: '合伙合同' },
|
||||
{ label: '用工合同', value: '用工合同' },
|
||||
{ label: '业务合同', value: '业务合同' },
|
||||
{ label: '其他合同', value: '其他合同' },
|
||||
]
|
||||
|
||||
const templateStatusOptions = [
|
||||
{ label: '已发布', value: 'published' },
|
||||
{ label: '草稿', value: 'draft' },
|
||||
{ label: '已停用', value: 'disabled' },
|
||||
]
|
||||
|
||||
const formRef = ref(null)
|
||||
const queryContainer = ref(null)
|
||||
const pageWrapperRef = ref(null)
|
||||
const tableRef = ref(null)
|
||||
const toolbarRef = ref(null)
|
||||
const templateModalRef = ref(null)
|
||||
|
||||
const total = ref(0)
|
||||
const loading = ref(false)
|
||||
const tableHeight = ref(420)
|
||||
const tableData = ref([])
|
||||
|
||||
const queryParam = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
templateName: undefined,
|
||||
contractType: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
|
||||
const queryCardBodyStyle = {
|
||||
padding: '20px 24px 6px',
|
||||
}
|
||||
|
||||
const tableCardBodyStyle = {
|
||||
display: 'flex',
|
||||
flexFlow: 'column nowrap',
|
||||
minHeight: '0',
|
||||
padding: '8px 12px 6px',
|
||||
}
|
||||
|
||||
const templateStatusLabelMap = {
|
||||
published: '已发布',
|
||||
draft: '草稿',
|
||||
disabled: '已停用',
|
||||
}
|
||||
|
||||
const templateStatusColorMap = {
|
||||
published: 'success',
|
||||
draft: 'gold',
|
||||
disabled: 'default',
|
||||
}
|
||||
|
||||
function connectToolbar() {
|
||||
const table = tableRef.value
|
||||
const toolbar = toolbarRef.value
|
||||
|
||||
if (table && toolbar && table.connect) {
|
||||
table.connect(toolbar)
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusLabel(status) {
|
||||
return templateStatusLabelMap[status] || status
|
||||
}
|
||||
|
||||
function getStatusColor(status) {
|
||||
return templateStatusColorMap[status] || 'default'
|
||||
}
|
||||
|
||||
function getList() {
|
||||
loading.value = true
|
||||
|
||||
queryManifestList(queryParam.value)
|
||||
.then((res) => {
|
||||
if (!isSuccessResponse(res)) {
|
||||
tableData.value = []
|
||||
total.value = 0
|
||||
message.error(getResponseMessage(res, '模板列表查询失败'))
|
||||
return
|
||||
}
|
||||
const pageResult = getPageResult(res)
|
||||
tableData.value = pageResult.records || []
|
||||
total.value = pageResult.total || 0
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false
|
||||
nextTick(() => {
|
||||
connectToolbar()
|
||||
syncTableHeight()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
queryParam.value.pageNo = 1
|
||||
getList()
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
formRef.value?.resetFields?.()
|
||||
Object.assign(queryParam.value, {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
templateName: undefined,
|
||||
contractType: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
getList()
|
||||
}
|
||||
|
||||
function onShowSizeChange(current, pageSize) {
|
||||
queryParam.value.pageNo = current
|
||||
queryParam.value.pageSize = pageSize
|
||||
getList()
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
templateModalRef.value?.showModal({}, 'create')
|
||||
}
|
||||
|
||||
function handleEdit(row) {
|
||||
getManifestDetail(row.id).then((res) => {
|
||||
if (!isSuccessResponse(res) || !getDataResult(res)) {
|
||||
message.error(getResponseMessage(res, '获取模板详情失败'))
|
||||
return
|
||||
}
|
||||
templateModalRef.value?.showModal(getDataResult(res), 'edit')
|
||||
})
|
||||
}
|
||||
|
||||
function handleView(row) {
|
||||
getManifestDetail(row.id).then((res) => {
|
||||
if (!isSuccessResponse(res) || !getDataResult(res)) {
|
||||
message.error(getResponseMessage(res, '获取模板详情失败'))
|
||||
return
|
||||
}
|
||||
templateModalRef.value?.showModal(getDataResult(res), 'view')
|
||||
})
|
||||
}
|
||||
|
||||
function handleTemplateSubmit({ mode, record, successMessage }) {
|
||||
saveManifest(record, mode).then((res) => {
|
||||
if (!isSuccessResponse(res)) {
|
||||
message.error(getResponseMessage(res, '模板保存失败'))
|
||||
return
|
||||
}
|
||||
message.success(successMessage || '保存成功')
|
||||
getList()
|
||||
})
|
||||
}
|
||||
|
||||
function toggleStatus(row) {
|
||||
const nextStatus = row.status === 'published' ? 'disabled' : 'published'
|
||||
changeManifestStatus(row.id, nextStatus).then((res) => {
|
||||
if (!isSuccessResponse(res)) {
|
||||
message.error(getResponseMessage(res, '状态修改失败'))
|
||||
return
|
||||
}
|
||||
message.success(`模板已${nextStatus === 'disabled' ? '停用' : '发布'}:${row.templateName}`)
|
||||
getList()
|
||||
})
|
||||
}
|
||||
|
||||
function syncTableHeight() {
|
||||
nextTick(() => {
|
||||
const viewportHeight = window.innerHeight
|
||||
const queryHeight = queryContainer.value?.offsetHeight || 0
|
||||
const pageHeight = pageWrapperRef.value?.$el?.offsetHeight || 56
|
||||
tableHeight.value = Math.max(320, viewportHeight - queryHeight - pageHeight - 320)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleQuery()
|
||||
nextTick(() => {
|
||||
connectToolbar()
|
||||
syncTableHeight()
|
||||
})
|
||||
window.addEventListener('resize', syncTableHeight)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener('resize', syncTableHeight)
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-container {
|
||||
height: calc(100vh - 116px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.query-card {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-query-container :deep(.ant-form-item) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-query-container :deep(.ant-form-item .ant-form-item-control) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 4px 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.section-container {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,628 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="modalTitle"
|
||||
width="1100px"
|
||||
:mask-closable="false"
|
||||
:footer="null"
|
||||
:body-style="{
|
||||
padding: 0,
|
||||
maxHeight: 'calc(100vh - 180px)',
|
||||
overflow: 'hidden',
|
||||
}"
|
||||
centered
|
||||
>
|
||||
<div class="partner-modal-layout">
|
||||
<a-form layout="vertical" :disabled="isViewMode" class="partner-form">
|
||||
<a-card size="small" title="基础信息" class="section-card">
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
class="section-alert"
|
||||
message="填写说明"
|
||||
description="境内组织需要填写统一社会信用代码、法定代表人、注册地址、注册资本;境外组织需要填写 TIN 号、国家、注册地址、注册资本;组织类型联系人必填。"
|
||||
/>
|
||||
<div class="grid-two">
|
||||
<a-form-item label="相对方编码">
|
||||
<a-input :value="formState.oppositeCode || '保存后自动生成'" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item label="法务相对方ID">
|
||||
<a-input
|
||||
:value="formState.oppositeId || '未同步前为空'"
|
||||
readonly
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="公司名称" required>
|
||||
<a-input
|
||||
v-model:value="formState.customerNameC"
|
||||
placeholder="请输入公司名称"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="公司类型" required>
|
||||
<a-select
|
||||
v-model:value="formState.relativeType"
|
||||
:options="optionSets.relativeTypeOptions"
|
||||
placeholder="请选择公司类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="公司性质" required>
|
||||
<a-select
|
||||
v-model:value="formState.oppositeCharacter"
|
||||
:options="optionSets.oppositeCharacterOptions"
|
||||
placeholder="请选择公司性质"
|
||||
@change="handleOppositeCharacterChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="组织性质" required>
|
||||
<a-select
|
||||
v-model:value="formState.natureOfEnterpriseOrg"
|
||||
:options="optionSets.natureOfEnterpriseOrgOptions"
|
||||
placeholder="请选择组织性质"
|
||||
@change="handleNatureOfEnterpriseOrgChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="showEnterpriseNatureLev"
|
||||
label="组织性质企业二级"
|
||||
required
|
||||
>
|
||||
<a-select
|
||||
v-model:value="formState.natureOfEnterpriseOrgLev"
|
||||
:options="optionSets.natureOfEnterpriseOrgLevOptions"
|
||||
placeholder="请选择组织性质企业二级"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="showGatFlag"
|
||||
label="是否涉港澳台"
|
||||
>
|
||||
<a-radio-group v-model:value="formState.gatFlag">
|
||||
<a-radio value="Y">是</a-radio>
|
||||
<a-radio value="N">否</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="是否集团内" required>
|
||||
<a-radio-group v-model:value="formState.groupFlag">
|
||||
<a-radio value="Y">是</a-radio>
|
||||
<a-radio value="N">否</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="showCustomerTaxno"
|
||||
label="统一社会信用代码"
|
||||
required
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formState.customerTaxno"
|
||||
placeholder="请输入统一社会信用代码"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="showCertificateFields"
|
||||
label="自然人证件类型"
|
||||
:required="isDomesticPerson"
|
||||
>
|
||||
<a-select
|
||||
v-model:value="formState.certificateType"
|
||||
:options="optionSets.certificateTypeOptions"
|
||||
placeholder="请选择证件类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="showCertificateFields"
|
||||
label="自然人证件号码"
|
||||
:required="isDomesticPerson"
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formState.certificateNum"
|
||||
placeholder="请输入证件号码"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item v-if="showTinCode" label="TIN号" required>
|
||||
<a-input v-model:value="formState.tinCode" placeholder="请输入TIN号" />
|
||||
</a-form-item>
|
||||
<a-form-item v-if="showCountry" label="国家" required>
|
||||
<a-select
|
||||
v-model:value="formState.country"
|
||||
:options="optionSets.countryOptions"
|
||||
placeholder="请选择国家"
|
||||
show-search
|
||||
option-filter-prop="label"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="showLegalPersonName"
|
||||
label="法定代表人或负责人"
|
||||
required
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formState.legalPersonName"
|
||||
placeholder="请输入法定代表人或负责人"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="showCustomerRegaddr"
|
||||
class="field-span-2"
|
||||
label="注册地址"
|
||||
required
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formState.customerRegaddr"
|
||||
placeholder="请输入注册地址"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item
|
||||
v-if="showRegCapital"
|
||||
label="注册资本"
|
||||
required
|
||||
>
|
||||
<a-input
|
||||
v-model:value="formState.regCapital"
|
||||
placeholder="请输入注册资本"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="邮箱地址">
|
||||
<a-input
|
||||
v-model:value="formState.emailAddress"
|
||||
placeholder="请输入邮箱地址"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card size="small" class="section-card">
|
||||
<template #title>
|
||||
<div class="card-title-row">
|
||||
<span>联系人信息</span>
|
||||
<a-tag v-if="needContacts" color="orange">组织类型必填</a-tag>
|
||||
<a-tag v-else color="default">个人类型可选</a-tag>
|
||||
</div>
|
||||
</template>
|
||||
<div class="contact-toolbar" v-if="!isViewMode">
|
||||
<a-button type="dashed" @click="addContact">新增联系人</a-button>
|
||||
</div>
|
||||
<div v-if="!formState.contacts.length" class="empty-contact">
|
||||
暂无联系人信息
|
||||
</div>
|
||||
<div
|
||||
v-for="(contact, index) in formState.contacts"
|
||||
:key="contact.uid"
|
||||
class="contact-card"
|
||||
>
|
||||
<div class="contact-card-header">
|
||||
<strong>联系人{{ index + 1 }}</strong>
|
||||
<a-button
|
||||
v-if="!isViewMode"
|
||||
type="link"
|
||||
danger
|
||||
@click="removeContact(contact.uid)"
|
||||
>
|
||||
删除
|
||||
</a-button>
|
||||
</div>
|
||||
<div class="grid-two">
|
||||
<a-form-item label="姓名" :required="needContacts">
|
||||
<a-input v-model:value="contact.contactName" placeholder="请输入姓名" />
|
||||
</a-form-item>
|
||||
<a-form-item label="联系电话" :required="needContacts">
|
||||
<a-input
|
||||
v-model:value="contact.contactPhone"
|
||||
placeholder="请输入联系电话"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item class="field-span-2" label="通讯地址" :required="needContacts">
|
||||
<a-input
|
||||
v-model:value="contact.postalAddress"
|
||||
placeholder="请输入通讯地址"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item class="field-span-2" label="电子邮箱" :required="needContacts">
|
||||
<a-input
|
||||
v-model:value="contact.email"
|
||||
placeholder="请输入电子邮箱"
|
||||
/>
|
||||
</a-form-item>
|
||||
</div>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card size="small" title="同步信息" class="section-card">
|
||||
<div class="grid-two">
|
||||
<a-form-item label="同步状态">
|
||||
<a-input :value="syncStatusLabel" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item label="创建时间">
|
||||
<a-input :value="formState.createTime || '-'" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item label="同步时间">
|
||||
<a-input :value="formState.syncTime || '-'" readonly />
|
||||
</a-form-item>
|
||||
<a-form-item label="同步说明">
|
||||
<a-input :value="formState.syncMessage || '-'" readonly />
|
||||
</a-form-item>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-form>
|
||||
|
||||
<div class="modal-footer">
|
||||
<a-space>
|
||||
<a-button v-if="!isViewMode" @click="resetForm">重置</a-button>
|
||||
<a-button @click="visible = false">{{ isViewMode ? "关闭" : "取消" }}</a-button>
|
||||
<a-button v-if="!isViewMode" type="primary" @click="handleSave">
|
||||
保存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from "vue";
|
||||
import { message } from "ant-design-vue";
|
||||
|
||||
const props = defineProps({
|
||||
optionSets: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(["save"]);
|
||||
|
||||
function createContact() {
|
||||
return {
|
||||
uid: `contact_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`,
|
||||
id: null,
|
||||
partnerRecordId: null,
|
||||
contactName: "",
|
||||
contactPhone: "",
|
||||
postalAddress: "",
|
||||
email: "",
|
||||
sortOrder: 1,
|
||||
delFlag: "0",
|
||||
remark: "",
|
||||
};
|
||||
}
|
||||
|
||||
function createFormData() {
|
||||
return {
|
||||
customerSysid: null,
|
||||
oppositeCode: "",
|
||||
oppositeId: "",
|
||||
customerNameC: "",
|
||||
relativeType: undefined,
|
||||
oppositeCharacter: undefined,
|
||||
natureOfEnterpriseOrg: undefined,
|
||||
natureOfEnterpriseOrgLev: undefined,
|
||||
gatFlag: "N",
|
||||
groupFlag: "N",
|
||||
customerTaxno: "",
|
||||
certificateType: undefined,
|
||||
certificateNum: "",
|
||||
tinCode: "",
|
||||
country: undefined,
|
||||
countryName: "",
|
||||
legalPersonName: "",
|
||||
customerRegaddr: "",
|
||||
registeredCapital: undefined,
|
||||
regCapital: "",
|
||||
oppositeStatus: "EFFECTUAL",
|
||||
isSgat: "0",
|
||||
isJtn: "0",
|
||||
isDeleted: "N",
|
||||
comments: "",
|
||||
remark: "",
|
||||
emailAddress: "",
|
||||
contacts: [],
|
||||
syncStatus: "unsynced",
|
||||
createTime: "",
|
||||
syncTime: "",
|
||||
syncMessage: "",
|
||||
};
|
||||
}
|
||||
|
||||
function copyFormData(data = {}) {
|
||||
const source = data || {};
|
||||
return {
|
||||
...createFormData(),
|
||||
...source,
|
||||
contacts: Array.isArray(source.contacts)
|
||||
? source.contacts.map((item) => ({
|
||||
...createContact(),
|
||||
...item,
|
||||
}))
|
||||
: [],
|
||||
};
|
||||
}
|
||||
|
||||
function isBlankContact(contact = {}) {
|
||||
return !contact.contactName?.trim() &&
|
||||
!contact.contactPhone?.trim() &&
|
||||
!contact.postalAddress?.trim() &&
|
||||
!contact.email?.trim();
|
||||
}
|
||||
|
||||
const visible = ref(false);
|
||||
const modalMode = ref("create");
|
||||
const originalState = ref(createFormData());
|
||||
const formState = ref(createFormData());
|
||||
|
||||
const isViewMode = computed(() => modalMode.value === "view");
|
||||
|
||||
const modalTitle = computed(() => {
|
||||
if (modalMode.value === "view") return "查看相对方";
|
||||
if (modalMode.value === "edit") return "编辑相对方";
|
||||
return "新增相对方";
|
||||
});
|
||||
|
||||
const isDomesticOrg = computed(() => formState.value.oppositeCharacter === "1");
|
||||
const isDomesticPerson = computed(() => formState.value.oppositeCharacter === "2");
|
||||
const isForeignOrg = computed(() => formState.value.oppositeCharacter === "3");
|
||||
const isForeignPerson = computed(() => formState.value.oppositeCharacter === "4");
|
||||
|
||||
const showGatFlag = computed(() =>
|
||||
["1", "2"].includes(formState.value.oppositeCharacter),
|
||||
);
|
||||
const showEnterpriseNatureLev = computed(
|
||||
() => formState.value.natureOfEnterpriseOrg === "QY",
|
||||
);
|
||||
const showCustomerTaxno = computed(() => isDomesticOrg.value);
|
||||
const showCertificateFields = computed(
|
||||
() => isDomesticPerson.value || isForeignPerson.value,
|
||||
);
|
||||
const showTinCode = computed(() => isForeignOrg.value);
|
||||
const showCountry = computed(
|
||||
() => isForeignOrg.value || isForeignPerson.value,
|
||||
);
|
||||
const showLegalPersonName = computed(() => isDomesticOrg.value);
|
||||
const showCustomerRegaddr = computed(() => isDomesticOrg.value || isForeignOrg.value);
|
||||
const showRegCapital = computed(() => isDomesticOrg.value || isForeignOrg.value);
|
||||
const needContacts = computed(() => isDomesticOrg.value || isForeignOrg.value);
|
||||
|
||||
const syncStatusLabel = computed(() => {
|
||||
const list = props.optionSets.syncStatusOptions || [];
|
||||
return (
|
||||
list.find((item) => item.value === formState.value.syncStatus)?.label ||
|
||||
formState.value.syncStatus ||
|
||||
"未同步"
|
||||
);
|
||||
});
|
||||
|
||||
function addContact() {
|
||||
formState.value.contacts.push(createContact());
|
||||
}
|
||||
|
||||
function removeContact(uid) {
|
||||
formState.value.contacts = formState.value.contacts.filter(
|
||||
(item) => item.uid !== uid,
|
||||
);
|
||||
if (!formState.value.contacts.length) {
|
||||
formState.value.contacts = needContacts.value ? [createContact()] : [];
|
||||
}
|
||||
}
|
||||
|
||||
function handleOppositeCharacterChange() {
|
||||
formState.value.customerTaxno = "";
|
||||
formState.value.certificateType = undefined;
|
||||
formState.value.certificateNum = "";
|
||||
formState.value.tinCode = "";
|
||||
formState.value.country = undefined;
|
||||
formState.value.countryName = "";
|
||||
formState.value.legalPersonName = "";
|
||||
formState.value.customerRegaddr = "";
|
||||
formState.value.registeredCapital = undefined;
|
||||
formState.value.regCapital = "";
|
||||
formState.value.gatFlag = "N";
|
||||
if (needContacts.value && !formState.value.contacts.length) {
|
||||
formState.value.contacts = [createContact()];
|
||||
}
|
||||
if (!needContacts.value) {
|
||||
formState.value.contacts = formState.value.contacts.filter(
|
||||
(item) => !isBlankContact(item),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNatureOfEnterpriseOrgChange() {
|
||||
if (formState.value.natureOfEnterpriseOrg !== "QY") {
|
||||
formState.value.natureOfEnterpriseOrgLev = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function validateContacts() {
|
||||
if (!needContacts.value) {
|
||||
return "";
|
||||
}
|
||||
if (!formState.value.contacts.length) {
|
||||
return "请至少维护一个联系人";
|
||||
}
|
||||
for (const contact of formState.value.contacts) {
|
||||
if (!contact.contactName?.trim()) return "请填写联系人姓名";
|
||||
if (!contact.contactPhone?.trim()) return "请填写联系人联系电话";
|
||||
if (!contact.postalAddress?.trim()) return "请填写联系人通讯地址";
|
||||
if (!contact.email?.trim()) return "请填写联系人电子邮箱";
|
||||
if (!/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(contact.email.trim())) {
|
||||
return "联系人电子邮箱格式不正确";
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
function validateForm() {
|
||||
if (!formState.value.customerNameC?.trim()) return "请填写公司名称";
|
||||
if (!formState.value.relativeType) return "请选择公司类型";
|
||||
if (!formState.value.oppositeCharacter) return "请选择公司性质";
|
||||
if (!formState.value.natureOfEnterpriseOrg) return "请选择组织性质";
|
||||
if (showEnterpriseNatureLev.value && !formState.value.natureOfEnterpriseOrgLev)
|
||||
return "请选择组织性质企业二级";
|
||||
if (!formState.value.groupFlag) return "请选择是否集团内";
|
||||
if (isDomesticOrg.value && !formState.value.customerTaxno?.trim())
|
||||
return "请填写统一社会信用代码";
|
||||
if (isDomesticPerson.value && !formState.value.certificateType)
|
||||
return "请选择自然人证件类型";
|
||||
if (isDomesticPerson.value && !formState.value.certificateNum?.trim())
|
||||
return "请填写自然人证件号码";
|
||||
if (isForeignOrg.value && !formState.value.tinCode?.trim()) return "请填写TIN号";
|
||||
if (isForeignOrg.value && !formState.value.country) return "请选择国家";
|
||||
if (isForeignPerson.value && !formState.value.country) return "请选择国家";
|
||||
if (showLegalPersonName.value && !formState.value.legalPersonName?.trim())
|
||||
return "请填写法定代表人或负责人";
|
||||
if (showCustomerRegaddr.value && !formState.value.customerRegaddr?.trim())
|
||||
return "请填写注册地址";
|
||||
if (showRegCapital.value && !formState.value.regCapital?.trim())
|
||||
return "请填写注册资本";
|
||||
return validateContacts();
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
formState.value = copyFormData(originalState.value);
|
||||
}
|
||||
|
||||
function normalizeFormBeforeSave() {
|
||||
formState.value.customerNameC = formState.value.customerNameC?.trim?.() || "";
|
||||
formState.value.customerTaxno = formState.value.customerTaxno?.trim?.() || "";
|
||||
formState.value.certificateNum = formState.value.certificateNum?.trim?.() || "";
|
||||
formState.value.tinCode = formState.value.tinCode?.trim?.() || "";
|
||||
formState.value.legalPersonName = formState.value.legalPersonName?.trim?.() || "";
|
||||
formState.value.customerRegaddr = formState.value.customerRegaddr?.trim?.() || "";
|
||||
formState.value.regCapital = formState.value.regCapital?.trim?.() || "";
|
||||
formState.value.comments = formState.value.comments?.trim?.() || "";
|
||||
formState.value.remark = formState.value.remark?.trim?.() || "";
|
||||
formState.value.emailAddress = formState.value.emailAddress?.trim?.() || "";
|
||||
formState.value.countryName =
|
||||
(props.optionSets.countryOptions || []).find(
|
||||
(item) => item.value === formState.value.country,
|
||||
)?.label || "";
|
||||
formState.value.isSgat = formState.value.gatFlag === "Y" ? "1" : "0";
|
||||
formState.value.isJtn = formState.value.groupFlag === "Y" ? "1" : "0";
|
||||
|
||||
formState.value.contacts = (formState.value.contacts || []).map((item) => ({
|
||||
...item,
|
||||
contactName: item.contactName?.trim?.() || "",
|
||||
contactPhone: item.contactPhone?.trim?.() || "",
|
||||
postalAddress: item.postalAddress?.trim?.() || "",
|
||||
email: item.email?.trim?.() || "",
|
||||
remark: item.remark?.trim?.() || "",
|
||||
}));
|
||||
}
|
||||
|
||||
function handleSave() {
|
||||
normalizeFormBeforeSave();
|
||||
const errorMessage = validateForm();
|
||||
if (errorMessage) {
|
||||
message.warning(errorMessage);
|
||||
return;
|
||||
}
|
||||
const payload = copyFormData(formState.value);
|
||||
payload.contacts = needContacts.value
|
||||
? payload.contacts
|
||||
: payload.contacts.filter((item) => !isBlankContact(item));
|
||||
emit("save", payload);
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function showModal(record = {}, mode = "create") {
|
||||
modalMode.value = mode || "create";
|
||||
const source =
|
||||
modalMode.value === "create" ? createFormData() : copyFormData(record);
|
||||
originalState.value = copyFormData(source);
|
||||
formState.value = copyFormData(source);
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
showModal,
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.partner-modal-layout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: calc(100vh - 180px);
|
||||
}
|
||||
|
||||
.partner-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
padding: 16px 20px 0;
|
||||
}
|
||||
|
||||
.section-card {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.section-alert {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 10px 16px;
|
||||
}
|
||||
|
||||
.field-span-2 {
|
||||
grid-column: span 2;
|
||||
}
|
||||
|
||||
.card-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.contact-toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.contact-card {
|
||||
border: 1px solid #e5e7eb;
|
||||
border-radius: 10px;
|
||||
padding: 14px;
|
||||
background: #fafcff;
|
||||
}
|
||||
|
||||
.contact-card + .contact-card {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.contact-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.empty-contact {
|
||||
padding: 24px 0;
|
||||
text-align: center;
|
||||
color: #94a3b8;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
flex: 0 0 auto;
|
||||
padding: 12px 20px 20px;
|
||||
background: #fff;
|
||||
border-top: 1px solid #f0f0f0;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.grid-two {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-span-2 {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,579 @@
|
||||
<template>
|
||||
<section class="section-container">
|
||||
<a-card
|
||||
:bordered="false"
|
||||
class="query-card"
|
||||
:body-style="queryCardBodyStyle"
|
||||
>
|
||||
<div ref="queryContainer" class="section-query-container">
|
||||
<a-form ref="formRef" :model="queryParam" layout="inline">
|
||||
<a-row :gutter="16" style="width: 100%">
|
||||
<a-col :md="6" :sm="24">
|
||||
<a-form-item label="公司名称">
|
||||
<a-input
|
||||
v-model:value="queryParam.customerNameC"
|
||||
allow-clear
|
||||
placeholder="请输入公司名称"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="6" :sm="24">
|
||||
<a-form-item label="公司类型">
|
||||
<a-select
|
||||
v-model:value="queryParam.relativeType"
|
||||
:options="optionSets.relativeTypeOptions"
|
||||
allow-clear
|
||||
placeholder="请选择公司类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="6" :sm="24">
|
||||
<a-form-item label="公司性质">
|
||||
<a-select
|
||||
v-model:value="queryParam.oppositeCharacter"
|
||||
:options="optionSets.oppositeCharacterOptions"
|
||||
allow-clear
|
||||
placeholder="请选择公司性质"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="6" :sm="24">
|
||||
<a-form-item label="是否集团内">
|
||||
<a-select
|
||||
v-model:value="queryParam.groupFlag"
|
||||
:options="optionSets.booleanOptions"
|
||||
allow-clear
|
||||
placeholder="请选择"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :md="6" :sm="24">
|
||||
<a-form-item label="同步状态">
|
||||
<a-select
|
||||
v-model:value="queryParam.syncStatus"
|
||||
:options="optionSets.syncStatusOptions"
|
||||
allow-clear
|
||||
placeholder="请选择同步状态"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
</div>
|
||||
</a-card>
|
||||
|
||||
<a-card
|
||||
:bordered="false"
|
||||
class="table-card"
|
||||
:body-style="tableCardBodyStyle"
|
||||
>
|
||||
<vxe-toolbar ref="toolbarRef" custom>
|
||||
<template #buttons>
|
||||
<a-space wrap>
|
||||
<a-button
|
||||
type="primary"
|
||||
:icon="h(SearchOutlined)"
|
||||
@click="handleQuery"
|
||||
>
|
||||
查询
|
||||
</a-button>
|
||||
<a-button :icon="h(RedoOutlined)" @click="handleReset">
|
||||
重置
|
||||
</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
ghost
|
||||
:icon="h(PlusOutlined)"
|
||||
@click="handleCreate"
|
||||
>
|
||||
新增相对方
|
||||
</a-button>
|
||||
<a-button
|
||||
danger
|
||||
:icon="h(DeleteOutlined)"
|
||||
:disabled="!selectedRowKeys.length"
|
||||
@click="handleBatchDelete"
|
||||
>
|
||||
批量删除
|
||||
</a-button>
|
||||
</a-space>
|
||||
</template>
|
||||
</vxe-toolbar>
|
||||
|
||||
<vxe-table
|
||||
ref="tableRef"
|
||||
border
|
||||
:height="tableHeight"
|
||||
:row-config="{ isHover: true, height: 44 }"
|
||||
:column-config="{ resizable: true, minWidth: 120 }"
|
||||
:custom-config="{ storage: true }"
|
||||
:scroll-y="{ enabled: true, gt: 0, mode: 'wheel' }"
|
||||
:checkbox-config="{ range: true }"
|
||||
:data="tableData"
|
||||
:loading="loading"
|
||||
:loading-config="{
|
||||
icon: 'vxe-icon-indicator roll',
|
||||
text: '正在拼命加载中...',
|
||||
}"
|
||||
@checkbox-range-change="selectChangeEvent"
|
||||
@checkbox-change="selectChangeEvent"
|
||||
@checkbox-all="selectChangeEvent"
|
||||
>
|
||||
<vxe-column type="checkbox" width="56" />
|
||||
<vxe-column type="seq" title="序号" width="68" />
|
||||
<vxe-column
|
||||
field="oppositeCode"
|
||||
title="相对方编码"
|
||||
width="170"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column
|
||||
field="oppositeId"
|
||||
title="法务相对方ID"
|
||||
width="150"
|
||||
show-overflow="title"
|
||||
>
|
||||
<template #default="{ row }">
|
||||
{{ row.oppositeId || "未同步" }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column
|
||||
field="customerNameC"
|
||||
title="公司名称"
|
||||
width="220"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column field="relativeType" title="公司类型" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ getLabel(optionSets.relativeTypeOptions, row.relativeType) }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="oppositeCharacter" title="公司性质" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ getLabel(optionSets.oppositeCharacterOptions, row.oppositeCharacter) }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="natureOfEnterpriseOrg" title="组织性质" width="140">
|
||||
<template #default="{ row }">
|
||||
{{ getLabel(optionSets.natureOfEnterpriseOrgOptions, row.natureOfEnterpriseOrg) }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="groupFlag" title="是否集团内" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.groupFlag === "Y" ? "是" : "否" }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="gatFlag" title="涉港澳台" width="110">
|
||||
<template #default="{ row }">
|
||||
{{ row.gatFlag === "Y" ? "是" : "否" }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="identityNo" title="信用代码 / 证件号" width="220" show-overflow="title">
|
||||
<template #default="{ row }">
|
||||
{{ row.customerTaxno || row.certificateNum || row.tinCode || "-" }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="country" title="国家" width="120">
|
||||
<template #default="{ row }">
|
||||
{{ getLabel(optionSets.countryOptions, row.country) || "-" }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="contactSummary" title="联系人信息" width="220" show-overflow="title">
|
||||
<template #default="{ row }">
|
||||
{{ getContactSummary(row.contacts) }}
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column field="syncStatus" title="同步状态" width="120">
|
||||
<template #default="{ row }">
|
||||
<a-tag :color="getSyncStatusColor(row.syncStatus)">
|
||||
{{ getLabel(optionSets.syncStatusOptions, row.syncStatus) }}
|
||||
</a-tag>
|
||||
</template>
|
||||
</vxe-column>
|
||||
<vxe-column
|
||||
field="createTime"
|
||||
title="创建时间"
|
||||
width="170"
|
||||
show-overflow="title"
|
||||
/>
|
||||
<vxe-column title="操作" width="240" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="table-actions">
|
||||
<a @click="handleView(row)">查看</a>
|
||||
<a @click="handleEdit(row)">编辑</a>
|
||||
<a @click="handleMockSync(row)">模拟同步</a>
|
||||
<a @click="handleDelete(row)">删除</a>
|
||||
</div>
|
||||
</template>
|
||||
</vxe-column>
|
||||
</vxe-table>
|
||||
|
||||
<a-pagination
|
||||
id="pageWrapper"
|
||||
ref="pageWrapperRef"
|
||||
v-model:pageSize="queryParam.pageSize"
|
||||
v-model:current="queryParam.pageNo"
|
||||
class="table-pagination"
|
||||
show-size-changer
|
||||
:page-size-options="['10', '20', '50', '100']"
|
||||
:total="total"
|
||||
:show-total="(value) => `共 ${value} 条`"
|
||||
@change="onShowSizeChange"
|
||||
@show-size-change="onShowSizeChange"
|
||||
/>
|
||||
</a-card>
|
||||
|
||||
<PartnerModal
|
||||
ref="partnerModalRef"
|
||||
:option-sets="optionSets"
|
||||
@save="handleModalSave"
|
||||
/>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { h, nextTick, onBeforeUnmount, onMounted, ref } from "vue";
|
||||
import {
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
RedoOutlined,
|
||||
SearchOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { message, Modal } from "ant-design-vue";
|
||||
import PartnerModal from "./components/PartnerModal.vue";
|
||||
import {queryPartnerList,getPartnerInfo,createPartner,updatePartner,deletePartnerByIds,mockSyncPartner} from '@/api/partner.js'
|
||||
|
||||
function isSuccessResponse(res) {
|
||||
return String(res?.code || "") === "0000";
|
||||
}
|
||||
|
||||
function getPageResult(res) {
|
||||
return res?.result || {};
|
||||
}
|
||||
|
||||
function getDataResult(res) {
|
||||
return res?.result || null;
|
||||
}
|
||||
|
||||
const optionSets = {
|
||||
relativeTypeOptions: [
|
||||
{ label: "客户", value: "client" },
|
||||
{ label: "供应商", value: "supplier" },
|
||||
{ label: "合作伙伴", value: "Partner" },
|
||||
{ label: "客户及供应商", value: "clientAndSup" },
|
||||
{ label: "其他", value: "other" },
|
||||
],
|
||||
oppositeCharacterOptions: [
|
||||
{ label: "境内组织", value: "1" },
|
||||
{ label: "境内个人", value: "2" },
|
||||
{ label: "境外组织", value: "3" },
|
||||
{ label: "境外个人", value: "4" },
|
||||
],
|
||||
natureOfEnterpriseOrgOptions: [
|
||||
{ label: "政府", value: "ZY" },
|
||||
{ label: "企业", value: "QY" },
|
||||
{ label: "其他组织", value: "QT" },
|
||||
],
|
||||
natureOfEnterpriseOrgLevOptions: [
|
||||
{ label: "国有企业", value: "GNQY" },
|
||||
{ label: "民营企业", value: "MYQY" },
|
||||
{ label: "外资企业", value: "WZQY" },
|
||||
{ label: "合资企业", value: "HZQY" },
|
||||
{ label: "国有全资和控股企业", value: "GYQZHKGQY" },
|
||||
{ label: "其他", value: "QT" },
|
||||
],
|
||||
certificateTypeOptions: [
|
||||
{ label: "身份证", value: "idCard" },
|
||||
{ label: "护照", value: "passport" },
|
||||
{ label: "其他", value: "other" },
|
||||
],
|
||||
countryOptions: [
|
||||
{ label: "中国", value: "GJ156" },
|
||||
{ label: "日本", value: "GJ392" },
|
||||
{ label: "芬兰", value: "GJ246" },
|
||||
{ label: "美国", value: "GJ840" },
|
||||
{ label: "加拿大", value: "GJ124" },
|
||||
{ label: "英国", value: "GJ826" },
|
||||
],
|
||||
booleanOptions: [
|
||||
{ label: "是", value: "Y" },
|
||||
{ label: "否", value: "N" },
|
||||
],
|
||||
syncStatusOptions: [
|
||||
{ label: "待同步", value: "unsynced" },
|
||||
{ label: "同步成功", value: "synced" },
|
||||
{ label: "同步失败", value: "failed" },
|
||||
],
|
||||
};
|
||||
|
||||
function copyRecord(record) {
|
||||
return {
|
||||
...record,
|
||||
contacts: (record.contacts || []).map((item) => ({ ...item })),
|
||||
};
|
||||
}
|
||||
|
||||
function getLabel(options, value) {
|
||||
return options.find((item) => item.value === value)?.label || value || "";
|
||||
}
|
||||
|
||||
function getContactSummary(contacts = []) {
|
||||
if (!contacts.length) return "暂无";
|
||||
const first = contacts[0];
|
||||
return `${first.contactName || "-"} / ${first.contactPhone || "-"}`;
|
||||
}
|
||||
|
||||
const formRef = ref(null);
|
||||
const queryContainer = ref(null);
|
||||
const pageWrapperRef = ref(null);
|
||||
const tableRef = ref(null);
|
||||
const toolbarRef = ref(null);
|
||||
const partnerModalRef = ref(null);
|
||||
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const tableHeight = ref(420);
|
||||
const tableData = ref([]);
|
||||
const selectedRows = ref([]);
|
||||
const selectedRowKeys = ref([]);
|
||||
const currentModalMode = ref("create");
|
||||
|
||||
const queryParam = ref({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
customerNameC: undefined,
|
||||
relativeType: undefined,
|
||||
oppositeCharacter: undefined,
|
||||
groupFlag: undefined,
|
||||
syncStatus: undefined,
|
||||
});
|
||||
|
||||
const queryCardBodyStyle = {
|
||||
padding: "20px 24px 6px",
|
||||
};
|
||||
|
||||
const tableCardBodyStyle = {
|
||||
display: "flex",
|
||||
flexFlow: "column nowrap",
|
||||
minHeight: "0",
|
||||
padding: "8px 12px 6px",
|
||||
};
|
||||
|
||||
function connectToolbar() {
|
||||
const table = tableRef.value;
|
||||
const toolbar = toolbarRef.value;
|
||||
if (table && toolbar && table.connect) {
|
||||
table.connect(toolbar);
|
||||
}
|
||||
}
|
||||
|
||||
function getSyncStatusColor(status) {
|
||||
if (status === "synced") return "success";
|
||||
if (status === "failed") return "error";
|
||||
return "default";
|
||||
}
|
||||
|
||||
function selectChangeEvent({ records }) {
|
||||
selectedRows.value = records;
|
||||
selectedRowKeys.value = records.map((item) => item.customerSysid);
|
||||
}
|
||||
|
||||
function getList() {
|
||||
loading.value = true;
|
||||
queryPartnerList(queryParam.value)
|
||||
.then((res) => {
|
||||
if (!isSuccessResponse(res)) {
|
||||
tableData.value = [];
|
||||
total.value = 0;
|
||||
return;
|
||||
}
|
||||
const pageResult = getPageResult(res);
|
||||
tableData.value = (pageResult.records || []).map((item) => copyRecord(item));
|
||||
total.value = pageResult.total || 0;
|
||||
})
|
||||
.finally(() => {
|
||||
loading.value = false;
|
||||
nextTick(() => {
|
||||
connectToolbar();
|
||||
syncTableHeight();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function handleQuery() {
|
||||
queryParam.value.pageNo = 1;
|
||||
getList();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
formRef.value?.resetFields?.();
|
||||
Object.assign(queryParam.value, {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
customerNameC: undefined,
|
||||
relativeType: undefined,
|
||||
oppositeCharacter: undefined,
|
||||
groupFlag: undefined,
|
||||
syncStatus: undefined,
|
||||
});
|
||||
getList();
|
||||
}
|
||||
|
||||
function onShowSizeChange(current, pageSize) {
|
||||
queryParam.value.pageNo = current;
|
||||
queryParam.value.pageSize = pageSize;
|
||||
getList();
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
currentModalMode.value = "create";
|
||||
partnerModalRef.value?.showModal({}, "create");
|
||||
}
|
||||
|
||||
function handleView(row) {
|
||||
currentModalMode.value = "view";
|
||||
getPartnerInfo(row.customerSysid).then((res) => {
|
||||
partnerModalRef.value?.showModal(copyRecord(getDataResult(res) || row), "view");
|
||||
});
|
||||
}
|
||||
|
||||
function handleEdit(row) {
|
||||
currentModalMode.value = "edit";
|
||||
getPartnerInfo(row.customerSysid).then((res) => {
|
||||
partnerModalRef.value?.showModal(copyRecord(getDataResult(res) || row), "edit");
|
||||
});
|
||||
}
|
||||
|
||||
function handleMockSync(row) {
|
||||
mockSyncPartner(row.customerSysid).then(() => {
|
||||
message.success(`已模拟同步:${row.customerNameC}`);
|
||||
getList();
|
||||
});
|
||||
}
|
||||
|
||||
function handleDelete(row) {
|
||||
Modal.confirm({
|
||||
title: `确认删除【${row.customerNameC}】吗?`,
|
||||
content: "删除后不可恢复,请确认是否继续。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okType: "danger",
|
||||
onOk: () => {
|
||||
deletePartnerByIds([row.customerSysid]).then(() => {
|
||||
selectedRows.value = [];
|
||||
selectedRowKeys.value = [];
|
||||
message.success("删除成功");
|
||||
getList();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleBatchDelete() {
|
||||
Modal.confirm({
|
||||
title: `确认删除选中的 ${selectedRowKeys.value.length} 条记录吗?`,
|
||||
content: "删除后不可恢复,请确认是否继续。",
|
||||
okText: "删除",
|
||||
cancelText: "取消",
|
||||
okType: "danger",
|
||||
onOk: () => {
|
||||
deletePartnerByIds(selectedRowKeys.value).then(() => {
|
||||
selectedRows.value = [];
|
||||
selectedRowKeys.value = [];
|
||||
message.success("批量删除成功");
|
||||
getList();
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleModalSave(payload) {
|
||||
if (currentModalMode.value === "create") {
|
||||
createPartner(payload).then(() => {
|
||||
message.success("新增成功");
|
||||
getList();
|
||||
});
|
||||
} else {
|
||||
updatePartner(payload).then(() => {
|
||||
message.success("保存成功");
|
||||
getList();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function syncTableHeight() {
|
||||
nextTick(() => {
|
||||
const viewportHeight = window.innerHeight;
|
||||
const queryHeight = queryContainer.value?.offsetHeight || 0;
|
||||
const pageHeight = pageWrapperRef.value?.$el?.offsetHeight || 56;
|
||||
tableHeight.value = Math.max(
|
||||
320,
|
||||
viewportHeight - queryHeight - pageHeight - 320,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
handleQuery();
|
||||
nextTick(() => {
|
||||
connectToolbar();
|
||||
syncTableHeight();
|
||||
});
|
||||
window.addEventListener("resize", syncTableHeight);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
window.removeEventListener("resize", syncTableHeight);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.section-container {
|
||||
height: calc(100vh - 116px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.query-card {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.table-card {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.section-query-container :deep(.ant-form-item) {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.section-query-container :deep(.ant-form-item .ant-form-item-control) {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.table-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.table-actions a {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.table-pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding: 12px 4px 2px;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.section-container {
|
||||
height: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,42 @@
|
||||
<template>
|
||||
<div class="external-sign-page">
|
||||
<MixedSignModal ref="mixedSignModalRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { nextTick, onMounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import MixedSignModal from '@/views/seal/mixedSignModal.vue'
|
||||
|
||||
const route = useRoute()
|
||||
const mixedSignModalRef = ref(null)
|
||||
|
||||
function getRouteContractId() {
|
||||
return String(route.params.contractId || route.query.contractId || '')
|
||||
}
|
||||
|
||||
async function openSignModalFromRoute() {
|
||||
await nextTick()
|
||||
mixedSignModalRef.value?.showModal(getRouteContractId())
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
openSignModalFromRoute()
|
||||
})
|
||||
|
||||
watch(
|
||||
() => [route.params.contractId, route.query.contractId],
|
||||
() => {
|
||||
openSignModalFromRoute()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.external-sign-page {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
background: #f5f7fa;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,554 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="contractName || '预盖章模式'"
|
||||
width="95%"
|
||||
:style="{ top: '30px', paddingBottom: 0 }"
|
||||
:bodyStyle="{ height: 'calc(100vh - 180px)', padding: 0, overflow: 'hidden' }"
|
||||
:destroy-on-close="false"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
wrap-class-name="seal-full-modal"
|
||||
>
|
||||
<div class="sign-platform" @click="hideCtxMenu">
|
||||
<div class="platform-layout platform-layout-empty" v-if="!contractId">
|
||||
<div class="preview-area empty-preview">
|
||||
<div class="preview-placeholder">
|
||||
<cloud-upload-outlined :style="{ fontSize: '48px', color: '#c0c4cc' }" />
|
||||
<p>上传文件后此处将显示文件预览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">1</span><span class="step-title">发起合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="upload-dragger" :class="{ 'is-dragover': dragOver }"
|
||||
@click="triggerFileInput" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="handleFileDrop">
|
||||
<input ref="fileInputRef" type="file" :accept="acceptTypes" style="display:none" @change="handleFileSelect" />
|
||||
<div class="upload-content" v-if="!uploadFile">
|
||||
<cloud-upload-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="upload-text">点击或将文件拖拽到这里上传</p>
|
||||
<p class="upload-hint">支持 xls、xlsx、pdf、docx、doc 文件,且小于10M</p>
|
||||
</div>
|
||||
<div class="upload-content" v-else>
|
||||
<file-text-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="file-name">{{ uploadFile.name }}</p>
|
||||
<p class="file-size">{{ formatFileSize(uploadFile.size) }}</p>
|
||||
<div class="btn btn-danger-text" @click.stop="removeFile">移除文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contract-name-row"><a-input v-model:value="contractName" placeholder="请输入合同/文件名称" allow-clear /></div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !uploadFile || !contractName || creating }" @click="handleCreate">
|
||||
<a-spin v-if="creating" size="small" /><span v-if="creating"> 发起中...</span><span v-else>发起签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span></div><div class="step-card-body"><p class="step-hint">上传文件后即可获取印章并拖拽至指定位置</p></div></div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div><div class="step-card-body"><p class="step-hint">可在此获取并添加时间戳</p></div></div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div><div class="step-card-body"><p class="step-hint">签署完成后可在此结束合约并下载文件</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-layout platform-layout-full" v-else>
|
||||
<div class="left-info-sidebar">
|
||||
<div class="info-card">
|
||||
<div class="info-title">合同信息</div>
|
||||
<div class="info-item"><span>合同名称</span><strong>{{ contractName }}</strong></div>
|
||||
<div class="info-item"><span>合同状态</span><strong>{{ contractStatus === 2000 ? '已完成' : contractStatus === 20 ? '签署中' : '待签署' }}</strong></div>
|
||||
<div class="info-item"><span>文件页数</span><strong>{{ pdfImages.length }} 页</strong></div>
|
||||
<div class="info-item"><span>印章数量</span><strong>{{ placedSeals.length }}</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-area" ref="pdfScrollRef">
|
||||
<div v-for="(imgUrl, idx) in pdfImages" :key="'p'+idx" class="pdf-page-wrapper"
|
||||
:style="{ width: dw + 'px', height: dh + 'px' }" @dragover.prevent @drop.prevent="onDropSeal($event, idx + 1)"
|
||||
@contextmenu.prevent.stop="showCtxMenu($event, idx + 1)">
|
||||
<img :src="imgUrl" class="pdf-page-image" :style="{ width: dw + 'px', height: dh + 'px' }" draggable="false" />
|
||||
<div v-for="seal in getPageSeals(idx + 1)" :key="'s'+seal.id" class="placed-seal" :class="{ 'seal-signed': seal.status === 'signed' }"
|
||||
:style="getPlacedStyle(seal)" @mousedown.stop="seal.status !== 'signed' && startDragSeal($event, seal)">
|
||||
<img v-if="seal.pictureUrl" :src="seal.pictureUrl" class="placed-seal-img" />
|
||||
<div v-else class="placed-seal-fallback">{{ seal.sealName || '印章' }}</div>
|
||||
<close-outlined v-if="seal.status !== 'signed'" class="placed-delete" @click.stop="removePlacedSeal(seal.id)" />
|
||||
</div>
|
||||
<div v-for="ts in getPageTimestamps(idx + 1)" :key="'t'+ts.id" class="placed-seal placed-ts"
|
||||
:class="{ 'seal-signed': ts.status === 'signed' }" :style="getPlacedStyle(ts)"
|
||||
@mousedown.stop="ts.status !== 'signed' && startDragSeal($event, ts)">
|
||||
<img v-if="ts._pictureUrl" :src="ts._pictureUrl" class="placed-seal-img" />
|
||||
<div v-else class="placed-seal-fallback">时间戳</div>
|
||||
<div class="ts-actions" v-if="ts.status !== 'signed'">
|
||||
<div class="btn btn-primary btn-sm" :class="{ 'btn-loading': ts.status === 'signing' }" @click.stop="confirmTimestamp(ts)">
|
||||
{{ ts.status === 'signing' ? '签署中...' : '确认' }}
|
||||
</div>
|
||||
<close-outlined class="placed-delete" @click.stop="removePlacedSeal(ts.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="paging-seal-wrapper" :class="{ 'paging-empty': !pagingSeal.sealId, 'paging-signed': pagingSigned }" @click.stop="handlePagingSealClick">
|
||||
<template v-if="!pagingSeal.sealId">
|
||||
<div class="paging-empty-box"><div class="paging-empty-text">添加骑缝章</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="paging-seal-real">
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-img" />
|
||||
<div class="paging-seal-real-text">{{ pagingSeal.sealName || '骑缝章' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span><span class="step-required">(必选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="seal-query-section">
|
||||
<div class="section-subtitle">1、获取签章</div>
|
||||
<div class="form-item"><label>姓名 <span class="required">*</span></label><a-input v-model:value="queryName" placeholder="请输入姓名" size="small" /></div>
|
||||
<div class="form-item"><label>手机号 / 身份证 <span class="required">*</span></label><a-input v-model:value="queryIdOrMobile" placeholder="手机号或身份证号" size="small" /></div>
|
||||
<div class="form-item"><label>公司名称</label><a-input v-model:value="queryEntName" placeholder="选填" size="small" /></div>
|
||||
<div class="form-item"><label>印章类型</label></div>
|
||||
<a-checkbox-group v-model:value="querySealTypes" class="seal-type-checkboxes">
|
||||
<a-checkbox :value="0">个人章</a-checkbox>
|
||||
<a-checkbox :value="20">公章</a-checkbox><a-checkbox :value="10">法人章</a-checkbox>
|
||||
<a-checkbox :value="30">财务章</a-checkbox><a-checkbox :value="40">合同章</a-checkbox>
|
||||
<a-checkbox :value="50">发票章</a-checkbox><a-checkbox :value="60">其他章</a-checkbox>
|
||||
</a-checkbox-group>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !queryName || !queryIdOrMobile || fetchingSeals }" @click="fetchSeals" style="width:100%">
|
||||
<a-spin v-if="fetchingSeals" size="small" /><span v-if="fetchingSeals"> 获取中...</span><span v-else>获取印章</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal-drag-section" v-if="sealList.length > 0">
|
||||
<div class="section-subtitle">2、拖动签章至指定签署位置({{ sealList.length }}个)</div>
|
||||
<div class="seal-drag-list">
|
||||
<div v-for="seal in sealList" :key="seal.sealId" class="seal-drag-item" draggable="true" @dragstart="onDragSealStart($event, seal)">
|
||||
<img :src="seal.pictureUrl" class="drag-seal-img" /><span class="drag-seal-name">{{ seal.sealName }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="paging-section" v-if="sealList.length > 0">
|
||||
<div class="section-subtitle">骑缝章(拖拽印章到下方区域)</div>
|
||||
<div class="paging-drop-zone" @dragover.prevent @drop.prevent="onDropPaging($event)">
|
||||
<span v-if="!pagingSeal.sealId">拖拽印章到这里添加骑缝章</span>
|
||||
<template v-else>
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-thumb" />
|
||||
<span>{{ pagingSeal.sealName }}</span>
|
||||
<close-outlined class="del-icon" @click="clearPaging" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': preSigning || (placedSeals.filter(s => s.status === 'pending' && s._type === 'seal').length === 0 && !pagingSeal.sealId) }"
|
||||
@click="handlePreSign" style="width:100%;margin-top:10px">
|
||||
<a-spin v-if="preSigning" size="small" /><span v-if="preSigning"> 签署中...</span>
|
||||
<span v-else>确认签署({{ placedSeals.filter(s => s.status === 'pending' && s._type === 'seal').length }}印章{{ pagingSeal.sealId ? ' + 骑缝章' : '' }})</span>
|
||||
</div>
|
||||
<p class="sign-hint" v-if="placedSeals.length > 0 || pagingSeal.sealId">提示:请先签章预授权,一次扫码确认全部印章</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="form-item"><label>时间格式</label>
|
||||
<a-select v-model:value="tsFormat" size="small" style="width:100%">
|
||||
<a-select-option value="yyyy-MM-dd">yyyy-MM-dd</a-select-option>
|
||||
<a-select-option value="yyyy年MM月dd日">yyyy年MM月dd日</a-select-option>
|
||||
<a-select-option value="yyyy/MM/dd">yyyy/MM/dd</a-select-option>
|
||||
<a-select-option value="yyyy-MM-dd HH:mm:ss">yyyy-MM-dd HH:mm:ss</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': tsLoading }" @click="fetchTimestamp" style="width:100%">
|
||||
<a-spin v-if="tsLoading" size="small" /><span v-if="tsLoading"> 获取中...</span><span v-else>获取时间戳</span>
|
||||
</div>
|
||||
<div class="ts-drag-area" v-if="tsImageUrl">
|
||||
<div class="ts-drag-item" draggable="true" @dragstart="onDragTsStart"><img :src="tsImageUrl" class="drag-seal-img" /><span>拖拽时间戳到文档上</span></div>
|
||||
<p class="ts-hint">也可在文档上右键 → 添加时间戳</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': contractStatus === 2000 || completing }" @click="handleComplete">
|
||||
<check-circle-outlined /><span v-if="completing">结束中...</span><span v-else>结束合约</span>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': contractStatus !== 2000 }" @click="handleDownload" style="margin-top:10px;margin-left:10px">
|
||||
<download-outlined /> 下载已签署文件
|
||||
</div>
|
||||
<div class="contract-status-tag">
|
||||
<span v-if="contractStatus === 2000" class="tag-success">合约已完成</span>
|
||||
<span v-else-if="contractStatus === 20" class="tag-warning">签署进行中</span>
|
||||
<span v-else class="tag-info">等待签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-show="ctxVisible" class="ctx-menu-mask" @click="hideCtxMenu" @contextmenu.prevent="hideCtxMenu"></div>
|
||||
<div v-show="ctxVisible" class="ctx-menu-panel" :style="{ left: ctxX + 'px', top: ctxY + 'px' }">
|
||||
<div class="ctx-menu-item" @click.stop="ctxAddTimestamp"><clock-circle-outlined /> 添加时间戳</div>
|
||||
</div>
|
||||
|
||||
<a-modal v-model:open="qrVisible" title="扫码签署" width="420px" :mask-closable="false" :footer="null" :closable="false">
|
||||
<div class="qr-dialog-body">
|
||||
<div class="qr-img-box" v-if="qrCode"><img :src="qrCode" class="qr-img" /></div>
|
||||
<div class="qr-img-box qr-loading" v-else><a-spin size="large" /><p>二维码加载中...</p></div>
|
||||
<a-alert message="请使用微信扫描二维码完成签署" type="success" :closable="false" show-icon style="margin-top:12px" />
|
||||
<div class="qr-countdown" v-if="qrCount > 0">
|
||||
<a-progress :percent="qrPct" :stroke-color="qrClr" :stroke-width="6" :show-info="false" />
|
||||
<p class="countdown-text">有效期剩余:<span :style="{ color: qrClr }">{{ qrTxt }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;margin-top:16px">
|
||||
<a-button @click="closeQr" style="margin-right:8px">关闭</a-button>
|
||||
<a-button type="primary" @click="refreshQr" :loading="qrRefreshing">刷新二维码</a-button>
|
||||
<!-- DEBUG: 模拟回调按钮 - 正式环境请删除 -->
|
||||
<a-button type="danger" @click="manualCallbackSuccess" :danger="true">模拟回调成功</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import {
|
||||
CloudUploadOutlined, FileTextOutlined, ClockCircleOutlined, CloseOutlined,
|
||||
CheckCircleOutlined, DownloadOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
import {
|
||||
startContract, getPreSignQrCode, completeContract, downloadContract,
|
||||
pollCallback, getTimestamp, signTimestamp, getContractDetail, previewContract,
|
||||
getUserUniqueId, getSealBySignerId,
|
||||
} from '@/api/seal'
|
||||
|
||||
// ==================== 对外暴露 ====================
|
||||
const visible = ref(false)
|
||||
|
||||
async function showModal(contractIdParam) {
|
||||
visible.value = true
|
||||
if (contractIdParam) {
|
||||
await loadContract(contractIdParam)
|
||||
if (contractId.value) {
|
||||
await nextTick()
|
||||
nextTick(calcSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContract(cid) {
|
||||
try {
|
||||
const [detailRes, previewRes] = await Promise.all([
|
||||
getContractDetail(cid),
|
||||
previewContract(cid),
|
||||
])
|
||||
const detail = detailRes?.code === 200 ? detailRes.data : null
|
||||
const preview = previewRes?.code === 200 ? previewRes.data : null
|
||||
if (detail || preview) {
|
||||
contractId.value = cid
|
||||
contractName.value = detail?.contractName || ''
|
||||
contractStatus.value = detail?.contractStatus ?? detail?.status ?? null
|
||||
pdfImages.value = preview?.pdfToImageList || []
|
||||
imageWidth.value = Number(preview?.imageWidth) || 793
|
||||
imageHeight.value = Number(preview?.imageHeight) || 1122
|
||||
saveContractState()
|
||||
}
|
||||
} catch {
|
||||
const saved = JSON.parse(localStorage.getItem('presign_contract') || '{}')
|
||||
if (saved.contractId === cid) {
|
||||
contractId.value = saved.contractId; contractName.value = saved.contractName || ''
|
||||
contractStatus.value = saved.contractStatus; pdfImages.value = saved.pdfImages || []
|
||||
imageWidth.value = saved.imageWidth || 793; imageHeight.value = saved.imageHeight || 1122
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ showModal })
|
||||
|
||||
// ==================== 文件上传 ====================
|
||||
const fileInputRef = ref(null), uploadFile = ref(null), contractName = ref(''), creating = ref(false), dragOver = ref(false), acceptTypes = '.xls,.xlsx,.pdf,.docx,.doc'
|
||||
function triggerFileInput() { fileInputRef.value?.click() }
|
||||
function handleFileSelect(e) { const f = e.target.files?.[0]; if (f) validateFile(f); e.target.value = '' }
|
||||
function handleFileDrop(e) { dragOver.value = false; const f = e.dataTransfer.files?.[0]; if (f) validateFile(f) }
|
||||
function validateFile(f) {
|
||||
const ext = f.name.split('.').pop()?.toLowerCase()
|
||||
if (!['xls','xlsx','pdf','docx','doc'].includes(ext)) { message.error(`不支持 "${ext}"`); return }
|
||||
if (f.size > 10485760) { message.error('文件不能超过10MB'); return }
|
||||
uploadFile.value = f; if (!contractName.value) contractName.value = f.name.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
function removeFile() { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = '' }
|
||||
function formatFileSize(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB' }
|
||||
|
||||
// ==================== localStorage ====================
|
||||
const STORAGE_KEY = 'presign_contract'
|
||||
function saveContractState() { try { localStorage.setItem(STORAGE_KEY, JSON.stringify({ contractId: contractId.value, contractName: contractName.value, contractStatus: contractStatus.value, pdfImages: pdfImages.value, imageWidth: imageWidth.value, imageHeight: imageHeight.value })) } catch {} }
|
||||
function clearContractState() { try { localStorage.removeItem(STORAGE_KEY) } catch {} }
|
||||
|
||||
// ==================== 签署流程 ====================
|
||||
const contractId = ref(''), contractStatus = ref(null), pdfImages = ref([]), imageWidth = ref(793), imageHeight = ref(1122), completing = ref(false)
|
||||
async function handleCreate() {
|
||||
if (!uploadFile.value || !contractName.value) { message.warning('请先选择文件并输入合同名称'); return }
|
||||
creating.value = true
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', uploadFile.value); fd.append('contractName', contractName.value)
|
||||
const res = await startContract(fd)
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data; contractId.value = d.contractId; pdfImages.value = d.pdfToImageList || []
|
||||
imageWidth.value = d.imageWidth || 793; imageHeight.value = d.imageHeight || 1122; contractStatus.value = 10
|
||||
saveContractState(); message.success('签署流程创建成功!'); nextTick(calcSize)
|
||||
} else { message.error(res.msg || '创建失败') }
|
||||
} catch { message.error('创建失败') } finally { creating.value = false }
|
||||
}
|
||||
|
||||
// ==================== PDF 显示 ====================
|
||||
const dw = ref(700), dh = ref(990), sr = ref(1), pdfScrollRef = ref(null)
|
||||
function calcSize() {
|
||||
if (!pdfScrollRef.value) return
|
||||
const cw = pdfScrollRef.value.clientWidth - 40; const mw = Math.min(cw, 800)
|
||||
if (imageWidth.value > 0) { sr.value = mw / imageWidth.value; dw.value = mw; dh.value = Math.round(imageHeight.value * sr.value) }
|
||||
}
|
||||
onMounted(() => { window.addEventListener('resize', calcSize); nextTick(calcSize) })
|
||||
onUnmounted(() => { window.removeEventListener('resize', calcSize) })
|
||||
watch(pdfImages, () => nextTick(calcSize))
|
||||
|
||||
// ==================== 印章查询 ====================
|
||||
const queryName = ref(''), queryIdOrMobile = ref(''), queryEntName = ref(''), querySealTypes = ref([0,20,10,30,40,50,60]), sealList = ref([]), fetchingSeals = ref(false)
|
||||
function generateMockSealSvg(text, bgColor, borderColor) {
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="120" height="120" viewBox="0 0 120 120">
|
||||
<circle cx="60" cy="60" r="55" fill="${bgColor}" stroke="${borderColor}" stroke-width="3"/>
|
||||
<circle cx="60" cy="60" r="48" fill="none" stroke="${borderColor}" stroke-width="1.5" opacity="0.5"/>
|
||||
<text x="60" y="66" text-anchor="middle" font-size="14" font-weight="bold" fill="${borderColor}" font-family="serif">${text}</text></svg>`
|
||||
return 'data:image/svg+xml;base64,' + btoa(unescape(encodeURIComponent(svg)))
|
||||
}
|
||||
// ==================== 印章查询(Mock版本 - 当前使用) ====================
|
||||
// async function fetchSeals() {
|
||||
// if (!queryName.value || !queryIdOrMobile.value) { message.warning('请填写姓名和手机号/身份证号'); return }
|
||||
// fetchingSeals.value = true; await new Promise(r => setTimeout(r, 500))
|
||||
// sealList.value = [
|
||||
// { sealId: 'mock_seal_001', sealName: '测试公章', pictureUrl: generateMockSealSvg('公章', 'rgba(220,60,60,0.08)', '#dc3c3c'), owner: queryName.value },
|
||||
// { sealId: 'mock_seal_002', sealName: '测试合同章', pictureUrl: generateMockSealSvg('合同章', 'rgba(37,99,235,0.08)', '#2563eb'), owner: queryName.value },
|
||||
// ]
|
||||
// message.success(`获取到 ${sealList.value.length} 个模拟印章,请拖拽到文档上`)
|
||||
// fetchingSeals.value = false
|
||||
// }
|
||||
|
||||
// ==================== 印章查询(真实接口版本 - 取消注释即可使用) ====================
|
||||
// 使用说明:
|
||||
// 1. 注释掉上方的 Mock 版本 fetchSeals 函数
|
||||
// 2. 取消下面真实版本的注释
|
||||
// 3. queryIdOrMobile 输入框会自动识别:身份证号走 idCode 参数,手机号走 mobile 参数
|
||||
async function fetchSeals() {
|
||||
if (!queryName.value || !queryIdOrMobile.value) { message.warning('请填写姓名和手机号/身份证号'); return }
|
||||
fetchingSeals.value = true
|
||||
try {
|
||||
// 第一步:获取用户身份编码(signerId)
|
||||
const isIdCard = /^(\d{17}[\dXx])$/.test(queryIdOrMobile.value)
|
||||
const userParams = {
|
||||
name: queryName.value,
|
||||
mobile: isIdCard ? undefined : queryIdOrMobile.value,
|
||||
idCode: isIdCard ? queryIdOrMobile.value : undefined,
|
||||
}
|
||||
const userRes = await getUserUniqueId(userParams)
|
||||
if (userRes.code !== 200 || !userRes.data?.signerId) {
|
||||
message.error(userRes.msg || '获取用户身份编码失败')
|
||||
return
|
||||
}
|
||||
const signerId = userRes.data.signerId
|
||||
|
||||
// 第二步:根据身份编码获取印章列表
|
||||
const sealParams = {
|
||||
signerId,
|
||||
sealTypes: querySealTypes.value,
|
||||
}
|
||||
if (queryEntName.value) {
|
||||
sealParams.entName = queryEntName.value
|
||||
}
|
||||
const sealRes = await getSealBySignerId(sealParams)
|
||||
if (sealRes.code === 200 && sealRes.data) {
|
||||
sealList.value = Array.isArray(sealRes.data) ? sealRes.data : []
|
||||
message.success(`获取到 ${sealList.value.length} 个印章,请拖拽到文档上`)
|
||||
} else {
|
||||
message.error(sealRes.msg || '获取印章失败')
|
||||
}
|
||||
} catch {
|
||||
message.error('获取印章失败,请检查网络')
|
||||
} finally {
|
||||
fetchingSeals.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== 印章放置 ====================
|
||||
const placedSeals = ref([]); let sealPlacedId = 0
|
||||
const pagingSigned = ref(false)
|
||||
const pagingSeal = reactive({ sealId: '', sealName: '', pictureUrl: '' })
|
||||
function handlePagingSealClick() { if (pagingSigned.value) { message.warning('骑缝章已签署'); return } if (!pagingSeal.sealId) message.info('请从印章列表拖拽印章到骑缝章区域') }
|
||||
function onDropPaging(e) { if (!dragSealData || dragSealData._isTimestamp) return; pagingSeal.sealId = dragSealData.sealId; pagingSeal.sealName = dragSealData.sealName; pagingSeal.pictureUrl = dragSealData.pictureUrl; message.success(`已设置骑缝章:${dragSealData.sealName}`); dragSealData = null }
|
||||
function clearPaging() { Object.assign(pagingSeal, { sealId: '', sealName: '', pictureUrl: '' }) }
|
||||
function getPageSeals(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type !== 'timestamp') }
|
||||
function getPageTimestamps(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type === 'timestamp') }
|
||||
function getPlacedStyle(s) { return { left: (s.x * sr.value) + 'px', top: (s.y * sr.value) + 'px' } }
|
||||
let dragSealData = null
|
||||
function onDragSealStart(e, seal) { dragSealData = seal; e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', seal.sealId || 'seal') }
|
||||
const tsImageUrl = ref(''), tsFormat = ref('yyyy-MM-dd'), tsLoading = ref(false), tsData = ref(null)
|
||||
async function fetchTimestamp() {
|
||||
tsLoading.value = true
|
||||
try { const res = await getTimestamp(tsFormat.value); if (res.code === 200 && res.data?.pictureUrl) { tsImageUrl.value = res.data.pictureUrl; tsData.value = { pictureUrl: res.data.pictureUrl }; message.success('已获取,请拖拽到文档上') } else message.error(res.msg || '获取失败') } catch { message.error('获取失败') } finally { tsLoading.value = false }
|
||||
}
|
||||
function onDragTsStart(e) { dragSealData = { ...tsData.value, _isTimestamp: true }; e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', 'timestamp') }
|
||||
function onDropSeal(e, pageNum) {
|
||||
if (!dragSealData) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const ox = Math.round(((e.clientX - rect.left) / sr.value) * 100) / 100, oy = Math.round(((e.clientY - rect.top) / sr.value) * 100) / 100
|
||||
const isTs = dragSealData._isTimestamp
|
||||
placedSeals.value.push({ id: ++sealPlacedId, _type: isTs ? 'timestamp' : 'seal', sealId: isTs ? null : dragSealData.sealId, sealName: isTs ? (dragSealData.sealName || '时间戳') : dragSealData.sealName, pictureUrl: dragSealData.pictureUrl, _pictureUrl: dragSealData.pictureUrl, owner: dragSealData.owner || '', pageNum, x: ox, y: oy, scale: 1, status: 'pending' })
|
||||
if (!isTs) dragSealData = null
|
||||
}
|
||||
function removePlacedSeal(id) { const s = placedSeals.value.find(v => v.id === id); if (s?.status === 'signed') { message.warning('已签署的印章不可删除'); return } placedSeals.value = placedSeals.value.filter(v => v.id !== id) }
|
||||
|
||||
// ==================== 拖拽调整 ====================
|
||||
let ds = null, dsx = 0, dsy = 0, dox = 0, doy = 0
|
||||
function startDragSeal(e, seal) {
|
||||
e.preventDefault(); ds = seal; dsx = e.clientX; dsy = e.clientY; dox = seal.x; doy = seal.y
|
||||
const mv = (ev) => { if (!ds) return; ds.x = Math.round((dox + (ev.clientX - dsx) / sr.value) * 100) / 100; ds.y = Math.round((doy + (ev.clientY - dsy) / sr.value) * 100) / 100 }
|
||||
const up = () => { ds = null; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up) }
|
||||
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up)
|
||||
}
|
||||
|
||||
// ==================== 确认签署 ====================
|
||||
const preSigning = ref(false), qrVisible = ref(false), qrCode = ref(''), qrRid = ref(''), qrExp = ref(300), qrRefreshing = ref(false), qrCount = ref(0)
|
||||
let qrTimer = null, pollTimer = null
|
||||
const qrPct = computed(() => qrExp.value ? Math.round((qrCount.value / qrExp.value) * 100) : 0)
|
||||
const qrClr = computed(() => qrCount.value <= 30 ? '#f56c6c' : qrCount.value <= 60 ? '#e6a23c' : '#67c23a')
|
||||
const qrTxt = computed(() => { const m = Math.floor(qrCount.value / 60); const s = qrCount.value % 60; return `${m}分${String(s).padStart(2, '0')}秒` })
|
||||
async function handlePreSign() {
|
||||
const pending = placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal')
|
||||
if (!pending.length) { message.warning('请先拖拽印章到文档上'); return }
|
||||
preSigning.value = true
|
||||
try {
|
||||
const host = 'http://47.110.50.12:7005'
|
||||
// const host = window.location.protocol + '//' + window.location.host
|
||||
const sealConfigs = pending.map(s => ({ sealId: s.sealId, x: s.x, y: s.y, scale: s.scale || 1, pageNum: s.pageNum }))
|
||||
const pagingSealConfigs = pagingSeal.sealId ? [{ sealId: pagingSeal.sealId, scale: 1, startPage: 1, endPage: pdfImages.value.length, coordinateY: Math.round(dh.value / 2) }] : null
|
||||
const res = await getPreSignQrCode({ contractId: contractId.value, notifyUrl: host + '/seal/callback/presign', isNeedUrlLink: true, sealConfigs, pagingSealConfigs, timestampSealConfigs: null })
|
||||
if (res.code === 200 && res.data) { const d = res.data; qrCode.value = d.qrCode || ''; qrRid.value = d.requestId || ''; qrExp.value = d.expiresIn || 300; qrVisible.value = true; startQrCt(); startPoll(d.requestId); pending.forEach(s => s.status = 'signing') }
|
||||
else { message.error(res.msg || '获取二维码失败') }
|
||||
} catch { message.error('获取二维码失败') } finally { preSigning.value = false }
|
||||
}
|
||||
function startQrCt() { stopQrCt(); qrCount.value = qrExp.value; qrTimer = setInterval(() => { qrCount.value--; if (qrCount.value <= 0) stopQrCt() }, 1000) }
|
||||
function stopQrCt() { if (qrTimer) { clearInterval(qrTimer); qrTimer = null } }
|
||||
function closeQr() { stopQrCt(); stopPoll(); qrVisible.value = false; placedSeals.value.filter(s => s.status === 'signing').forEach(s => s.status = 'pending') }
|
||||
function startPoll(rid) { stopPoll(); const max = Math.ceil(qrExp.value / 2) + 10; let n = 0; pollTimer = setInterval(async () => { n++; if (n > max || !qrVisible.value) { stopPoll(); return } try { const r = await pollCallback(rid); if (r.code === 200 && r.data) { stopPoll(); onCallback(r.data) } } catch {} }, 2000) }
|
||||
function stopPoll() { if (pollTimer) { clearInterval(pollTimer); pollTimer = null } }
|
||||
function onCallback(data) { if (data.contractStatus !== undefined) { contractStatus.value = data.contractStatus; if (data.contractStatus === 2000) clearContractState(); else saveContractState() } placedSeals.value.filter(s => s.status === 'signing').forEach(s => { s.status = 'signed'; s._signer = data.signer || '' }); pagingSigned.value = true; if (data.contractStatus === 2000) message.success('合约签署已完结!'); else message.success(`签署成功!签署人: ${data.signer || '未知'}`); if (qrVisible.value) { stopQrCt(); qrVisible.value = false } }
|
||||
function refreshQr() { stopPoll(); handlePreSign() }
|
||||
|
||||
// ==================== 模拟回调(调试用 - 正式环境请删除此函数及模板中的按钮) ====================
|
||||
function manualCallbackSuccess() {
|
||||
if (!qrRid.value) { message.warning('没有有效的请求ID'); return }
|
||||
onCallback({ contractStatus: 20, signer: '模拟签署人' })
|
||||
message.info('已模拟回调成功')
|
||||
}
|
||||
|
||||
// ==================== 时间戳签署 ====================
|
||||
async function confirmTimestamp(ts) {
|
||||
if (ts.status !== 'pending') return; ts.status = 'signing'
|
||||
try { const res = await signTimestamp({ contractId: contractId.value, sealConfigs: [{ formatPattern: tsFormat.value, x: ts.x, y: ts.y, pageNum: ts.pageNum, scale: ts.scale || 1 }] }); if (res.code === 200 && res.data) { ts.status = 'signed'; ts._signTime = res.data.signatureTime || ''; if (res.data.pdfToImageList?.length) pdfImages.value = res.data.pdfToImageList; if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus; message.success('时间戳签署成功') } else { message.error(res.msg || '失败'); ts.status = 'pending' } } catch { message.error('失败'); ts.status = 'pending' }
|
||||
}
|
||||
|
||||
// ==================== 完成 & 下载 ====================
|
||||
async function handleComplete() {
|
||||
if (contractStatus.value === 2000) { message.warning('合约已结束'); return }
|
||||
Modal.confirm({ title: '确认结束', content: '确认结束合约?', okType: 'danger', onOk: async () => { completing.value = true; try { const res = await completeContract(contractId.value); if (res.code === 200) { contractStatus.value = 2000; clearContractState(); message.success('合约已完成') } else message.error(res.msg || '结束失败') } catch { message.error('结束失败') } finally { completing.value = false } } })
|
||||
}
|
||||
async function handleDownload() { if (contractStatus.value !== 2000) { message.warning('请先结束合约'); return } try { const res = await downloadContract(contractId.value); if (res.code === 200 && res.data?.downloadUrl) window.open(res.data.downloadUrl, '_blank'); else message.error('获取链接失败') } catch { message.error('下载失败') } }
|
||||
|
||||
// ==================== 右键菜单 ====================
|
||||
const ctxVisible = ref(false), ctxX = ref(0), ctxY = ref(0), ctxPn = ref(1), ctxOx = ref(0), ctxOy = ref(0)
|
||||
function showCtxMenu(e, pn) { const rect = e.currentTarget.getBoundingClientRect(); ctxOx.value = Math.round(((e.clientX - rect.left) / sr.value) * 100) / 100; ctxOy.value = Math.round(((e.clientY - rect.top) / sr.value) * 100) / 100; ctxVisible.value = true; ctxX.value = e.clientX; ctxY.value = e.clientY; ctxPn.value = pn }
|
||||
function hideCtxMenu() { ctxVisible.value = false }
|
||||
function ctxAddTimestamp() { if (!tsImageUrl.value) { message.warning('请先在第三步获取时间戳'); hideCtxMenu(); return }; placedSeals.value.push({ id: ++sealPlacedId, _type: 'timestamp', sealId: null, sealName: '时间戳', pictureUrl: tsData.value?.pictureUrl, _pictureUrl: tsData.value?.pictureUrl, owner: '', pageNum: ctxPn.value, x: ctxOx.value, y: ctxOy.value, scale: 1, status: 'pending' }); hideCtxMenu() }
|
||||
onUnmounted(() => { stopPoll(); stopQrCt() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sign-platform { width:100%;height:100%;background:#f5f7fa;font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#2c3e50;overflow:hidden;display:flex;flex-direction:column }
|
||||
.platform-layout { flex:1;overflow:hidden }
|
||||
.platform-layout-empty { display:grid;grid-template-columns:minmax(0,1fr) 380px }
|
||||
.platform-layout-full { display:grid;grid-template-columns:260px minmax(0,1fr) 380px }
|
||||
.preview-area { background:linear-gradient(to bottom,#f8fafc,#eef2f7);overflow-y:auto;padding:30px 50px;display:flex;flex-direction:column;align-items:center;min-width:0;gap:28px }
|
||||
.empty-preview { justify-content:center;align-items:center;background:#f0f2f5;min-width:0 }
|
||||
.preview-placeholder { width:420px;padding:60px 40px;border-radius:20px;background:rgba(255,255,255,.7);backdrop-filter:blur(10px);box-shadow:0 10px 40px rgba(15,23,42,.06);border:1px solid rgba(255,255,255,.8);display:flex;flex-direction:column;align-items:center }
|
||||
.preview-placeholder p { margin-top:14px;font-size:15px;color:#64748b }
|
||||
.pdf-page-wrapper { position:relative;border-radius:12px;overflow:visible;background:white;box-shadow:0 12px 40px rgba(15,23,42,.08),0 2px 8px rgba(15,23,42,.04);transition:all .2s ease }
|
||||
.pdf-page-wrapper:hover { transform:translateY(-2px) }
|
||||
.pdf-page-image { display:block;user-select:none;pointer-events:none }
|
||||
.placed-seal { position:absolute;z-index:10;cursor:move;user-select:none }
|
||||
.placed-seal .placed-seal-img { width:80px;height:auto;opacity:.85;display:block;pointer-events:none }
|
||||
.placed-seal .placed-seal-fallback { width:80px;height:40px;border:2px dashed #409eff;background:rgba(64,158,255,.1);display:flex;align-items:center;justify-content:center;font-size:12px;color:#409eff }
|
||||
.placed-delete { position:absolute;top:-8px;right:-8px;background:#f56c6c;color:#fff;border-radius:50%;cursor:pointer;font-size:14px;padding:2px }
|
||||
.placed-delete:hover { background:#e03838 }
|
||||
.placed-seal.seal-signed { cursor:default }
|
||||
.placed-seal.seal-signed .placed-seal-img { opacity:1 }
|
||||
.placed-seal.seal-signed .placed-delete { display:none }
|
||||
.placed-seal.placed-ts .placed-seal-img { width:130px }
|
||||
.placed-seal.placed-ts .ts-actions { display:flex;align-items:center;gap:4px;margin-top:4px;background:#fff;padding:2px 6px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.1) }
|
||||
.paging-seal-wrapper { position:absolute;top:50%;right:-18px;transform:translateY(-50%);width:52px;height:fit-content;display:flex;align-items:center;justify-content:center;z-index:50;cursor:pointer;transition:all .25s ease }
|
||||
.paging-seal-wrapper:hover { transform:translateY(-50%) scale(1.03) }
|
||||
.paging-empty-box { width:100%;border:2px dashed #cbd5e1;border-radius:12px;background:rgba(248,250,252,.95);display:flex;align-items:center;justify-content:center;color:#94a3b8;transition:all .2s ease;padding:10px }
|
||||
.paging-empty-text { writing-mode:vertical-rl;text-orientation:upright;font-size:16px;letter-spacing:4px;font-weight:600;line-height:1.4 }
|
||||
.paging-empty-box:hover { border-color:#3b82f6;color:#3b82f6;background:#eff6ff }
|
||||
.paging-seal-real { width:100%;border:2px solid rgba(210,50,50,.65);background:radial-gradient(circle at center,rgba(255,255,255,.04),rgba(255,0,0,.03));border-radius:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:.9;padding:8px 4px;box-shadow:0 0 12px rgba(210,50,50,.2),inset 0 0 8px rgba(210,50,50,.1) }
|
||||
.paging-seal-img { width:32px;height:32px;object-fit:contain;margin-bottom:4px }
|
||||
.paging-seal-real-text { writing-mode:vertical-rl;text-orientation:upright;font-size:14px;letter-spacing:2px;color:rgba(210,50,50,.72);font-family:"STFangsong","FangSong","SimSun",serif;user-select:none }
|
||||
.left-info-sidebar { background:#fff;border-right:1px solid #ebeef5;padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:20px }
|
||||
.info-card { background:#fff;border-radius:16px;padding:20px;box-shadow:0 4px 20px rgba(15,23,42,.05);border:1px solid #eef2f6 }
|
||||
.info-title { font-size:16px;font-weight:700;color:#1e293b;margin-bottom:18px }
|
||||
.info-item { display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px dashed #eef2f6;font-size:14px }
|
||||
.info-item:last-child { border-bottom:none } .info-item span { color:#64748b } .info-item strong { color:#0f172a }
|
||||
.operation-sidebar { width:380px;background:#fff;border-left:1px solid #e2e6ed;display:flex;flex-direction:column;padding:20px;gap:16px;overflow-y:auto;box-shadow:-2px 0 12px rgba(0,0,0,.04) }
|
||||
.step-card { background:#fff;border:1px solid #eef1f5;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.04);flex-shrink:0 }
|
||||
.step-card-disabled { opacity:.6 }
|
||||
.step-card-header { padding:14px 16px;background:#f8f9fb;border-bottom:1px solid #f0f2f5;display:flex;align-items:center;gap:10px;flex-wrap:wrap }
|
||||
.step-badge { display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#3b6fa0;color:#fff;border-radius:6px;font-size:14px;font-weight:600;flex-shrink:0 }
|
||||
.step-title { font-size:15px;font-weight:600;color:#2c3e50 }
|
||||
.step-required { font-size:12px;color:#c88a2e;margin-left:4px }
|
||||
.step-card-body { padding:16px } .step-hint { color:#8b95a5;font-size:13px;margin:0 }
|
||||
.seal-query-section { margin-bottom:14px } .section-subtitle { font-size:13px;font-weight:600;color:#2c3e50;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #f0f2f5 }
|
||||
.form-item { margin-bottom:10px } .form-item label { display:block;margin-bottom:4px;font-size:13px;color:#606266 } .form-item .required { color:#f56c6c }
|
||||
.seal-type-checkboxes { display:flex;flex-wrap:wrap;gap:4px;margin-bottom:10px }
|
||||
.seal-drag-section { margin-bottom:14px } .seal-drag-list { display:flex;flex-wrap:wrap;gap:8px }
|
||||
.seal-drag-item { width:70px;cursor:grab;text-align:center;border:1px solid #e4e7ed;border-radius:6px;padding:6px;transition:all .2s }
|
||||
.seal-drag-item:hover { border-color:#409eff;box-shadow:0 2px 8px rgba(64,158,255,.2) }
|
||||
.seal-drag-item:active { cursor:grabbing } .drag-seal-img { width:60px;height:auto;display:block;margin:0 auto 4px }
|
||||
.drag-seal-name { font-size:10px;color:#606266;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block }
|
||||
.paging-section { margin-bottom:14px }
|
||||
.paging-drop-zone { border:2px dashed #e6a23c;border-radius:6px;padding:16px;text-align:center;color:#e6a23c;font-size:13px;min-height:50px;display:flex;align-items:center;justify-content:center;gap:8px;background:rgba(230,162,60,.04);cursor:pointer;transition:all .2s }
|
||||
.paging-drop-zone:hover { border-color:#e6a23c;background:rgba(230,162,60,.1) }
|
||||
.paging-seal-thumb { width:40px;height:40px;object-fit:contain } .del-icon { cursor:pointer;color:#f56c6c;font-size:14px } .del-icon:hover { color:#e03838 }
|
||||
.ts-drag-area { margin-top:10px;padding:10px;background:#f5f7fa;border-radius:6px;border:1px dashed #dcdfe6 }
|
||||
.ts-drag-item { width:130px;cursor:grab;text-align:center;padding:6px;border:1px solid #e4e7ed;border-radius:6px;background:#fff }
|
||||
.ts-drag-item:hover { border-color:#67c23a } .ts-drag-item:active { cursor:grabbing }
|
||||
.ts-drag-item .drag-seal-img { width:120px;height:auto;display:block;margin:0 auto 4px } .ts-drag-item span { font-size:11px;color:#909399 }
|
||||
.ts-hint { font-size:11px;color:#909399;margin:6px 0 0;text-align:center }
|
||||
.upload-dragger { border:2px dashed #dcdfe6;border-radius:10px;padding:24px 16px;text-align:center;cursor:pointer;transition:all .25s;background:#fafbfc }
|
||||
.upload-dragger:hover,.is-dragover { border-color:#3b6fa0;background:#eaf2f9 }
|
||||
.upload-content .anticon { font-size:36px;color:#b0b8c5;margin-bottom:8px }
|
||||
.upload-text,.file-name { font-size:14px;color:#2c3e50;margin:0 0 6px } .file-name { font-weight:500 }
|
||||
.upload-hint,.file-size { font-size:12px;color:#8b95a5;margin:0 0 8px } .contract-name-row { margin-top:14px }
|
||||
.sign-hint { font-size:12px;color:#909399;text-align:center;margin:6px 0 0 }
|
||||
.btn { display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 20px;height:38px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;user-select:none;border:none;background:#f0f2f5;color:#2c3e50 }
|
||||
.btn-primary { background:linear-gradient(135deg,#3b82f6,#2563eb);color:white;border:none;box-shadow:0 8px 20px rgba(37,99,235,.18);margin-top:10px }
|
||||
.btn-primary:hover { transform:translateY(-1px);box-shadow:0 10px 24px rgba(37,99,235,.28) }
|
||||
.btn-success { background:linear-gradient(135deg,#10b981,#059669);color:white;border:none;box-shadow:0 8px 20px rgba(16,185,129,.18) }
|
||||
.btn-success:hover { transform:translateY(-1px);box-shadow:0 10px 24px rgba(16,185,129,.28) }
|
||||
.btn-danger-text { background:transparent;color:#c0392b;padding:0;height:auto;font-size:13px }
|
||||
.btn-disabled { opacity:.5;cursor:not-allowed;pointer-events:none }
|
||||
.btn-sm { height:30px;padding:0 12px;font-size:12px } .btn-loading { pointer-events:none;opacity:.8 }
|
||||
.contract-status-tag { margin-top:12px;font-size:13px } .tag-success { color:#4a9c5d;font-weight:500 } .tag-warning { color:#c88a2e;font-weight:500 } .tag-info { color:#8b95a5;font-weight:500 }
|
||||
.ctx-menu-mask { position:fixed;top:0;left:0;right:0;bottom:0;z-index:9998;background:transparent }
|
||||
.ctx-menu-panel { position:fixed;z-index:9999;background:#fff;border-radius:12px;box-shadow:0 8px 30px rgba(0,0,0,.12);padding:6px 0;min-width:200px }
|
||||
.ctx-menu-item { display:flex;align-items:center;gap:10px;padding:10px 20px;cursor:pointer;font-size:14px;color:#2c3e50;transition:background .15s }
|
||||
.ctx-menu-item:hover { background:#f0f5ff;color:#3b6fa0 }
|
||||
.qr-dialog-body { text-align:center } .qr-img-box { display:flex;justify-content:center;align-items:center;min-height:200px }
|
||||
.qr-loading { flex-direction:column;gap:12px;color:#8b95a5 }
|
||||
.qr-img { width:200px;height:200px;border:1px solid #eee;border-radius:10px;padding:8px } .qr-countdown { margin-top:14px } .countdown-text { margin-top:6px;font-size:13px;color:#5a6b7c }
|
||||
@media (max-width:900px) { .operation-sidebar { width:320px } }
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,769 @@
|
||||
<template>
|
||||
<div class="sign-platform">
|
||||
<!-- ==================== 空状态 ==================== -->
|
||||
<div class="platform-layout platform-layout-empty" v-if="!contractId">
|
||||
<div class="preview-area empty-preview">
|
||||
<div class="preview-placeholder">
|
||||
<cloud-upload-outlined :style="{ fontSize: '48px', color: '#c0c4cc' }" />
|
||||
<p>上传文件后此处将显示文件预览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">1</span>
|
||||
<span class="step-title">发起合约</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="upload-dragger" :class="{ 'is-dragover': dragOver }"
|
||||
@click="triggerFileInput" @dragover.prevent="dragOver = true"
|
||||
@dragleave.prevent="dragOver = false" @drop.prevent="handleFileDrop">
|
||||
<input ref="fileInputRef" type="file" :accept="acceptTypes" style="display:none" @change="handleFileSelect" />
|
||||
<div class="upload-content" v-if="!uploadFile">
|
||||
<cloud-upload-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="upload-text">点击或将文件拖拽到这里上传</p>
|
||||
<p class="upload-hint">支持 xls、xlsx、pdf、docx、doc 文件,且小于10M</p>
|
||||
</div>
|
||||
<div class="upload-content" v-else>
|
||||
<file-text-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="file-name">{{ uploadFile.name }}</p>
|
||||
<p class="file-size">{{ formatSize(uploadFile.size) }}</p>
|
||||
<div class="btn btn-danger-text" @click.stop="removeFile">移除文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contract-name-row">
|
||||
<a-input v-model:value="contractName" placeholder="请输入合同/文件名称" allow-clear />
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !uploadFile || !contractName || creating }" @click="handleCreate">
|
||||
<a-spin v-if="creating" size="small" />
|
||||
<span v-if="creating"> 发起中...</span>
|
||||
<span v-else>发起签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled">
|
||||
<div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span></div>
|
||||
<div class="step-card-body"><p class="step-hint">上传文件后即可新增托管印章并拖拽至指定位置</p></div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled">
|
||||
<div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div>
|
||||
<div class="step-card-body"><p class="step-hint">可在此获取并添加时间戳</p></div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled">
|
||||
<div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div>
|
||||
<div class="step-card-body"><p class="step-hint">签署完成后可在此结束合约并下载文件</p></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 合约已创建后的三栏布局 ==================== -->
|
||||
<div class="platform-layout platform-layout-full" v-else>
|
||||
<!-- 左栏:合同信息 -->
|
||||
<div class="left-info-sidebar">
|
||||
<div class="info-card">
|
||||
<div class="info-title">合同信息</div>
|
||||
<div class="info-item"><span>合同名称</span><strong>{{ contractName }}</strong></div>
|
||||
<div class="info-item"><span>合同状态</span><strong>{{ contractStatus === 2000 ? '已完成' : contractStatus === 20 ? '签署中' : '待签署' }}</strong></div>
|
||||
<div class="info-item"><span>文件页数</span><strong>{{ pdfImages.length }} 页</strong></div>
|
||||
<div class="info-item"><span>托管印章</span><strong>{{ hostedSeals.length }} 个</strong></div>
|
||||
<div class="info-item"><span>自动签章</span><strong>{{ autoSealCount }} 个</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 中栏:文件预览区域 -->
|
||||
<div class="preview-area" ref="pdfScrollRef">
|
||||
<div v-for="(imgUrl, idx) in pdfImages" :key="'p' + idx" class="pdf-page-wrapper"
|
||||
:style="{ width: dw + 'px', height: dh + 'px' }"
|
||||
@dragover.prevent @drop.prevent="onDropSeal($event, idx + 1)">
|
||||
<img :src="imgUrl" class="pdf-page-image" :style="{ width: dw + 'px', height: dh + 'px' }" draggable="false" />
|
||||
|
||||
<!-- 已放置印章 -->
|
||||
<div v-for="seal in getPagePlaced(idx + 1)" :key="'s' + seal.id" class="placed-seal"
|
||||
:class="{ 'seal-signed': seal.status === 'signed' }"
|
||||
:style="getPlacedStyle(seal)"
|
||||
@mousedown.stop="seal.status !== 'signed' && startDragSeal($event, seal)">
|
||||
<img v-if="seal.pictureUrl" :src="seal.pictureUrl" class="placed-img" />
|
||||
<div v-else class="placed-fallback">{{ seal.sealName }}</div>
|
||||
<close-outlined v-if="seal.status !== 'signed'" class="placed-del" @click.stop="removePlaced(seal.id)" />
|
||||
</div>
|
||||
|
||||
<!-- 时间戳 -->
|
||||
<div v-for="ts in getPagePlacedTs(idx + 1)" :key="'t' + ts.id" class="placed-seal placed-ts"
|
||||
:class="{ 'seal-signed': ts.status === 'signed' }"
|
||||
:style="getPlacedStyle(ts)"
|
||||
@mousedown.stop="ts.status !== 'signed' && startDragSeal($event, ts)">
|
||||
<img v-if="ts._pictureUrl" :src="ts._pictureUrl" class="placed-img" />
|
||||
<div v-else class="placed-fallback">时间戳</div>
|
||||
<div class="ts-actions" v-if="ts.status !== 'signed'">
|
||||
<div class="btn btn-primary btn-sm" :class="{ 'btn-loading': ts.status === 'signing' }" @click.stop="confirmTs(ts)">
|
||||
{{ ts.status === 'signing' ? '签署中...' : '确认' }}
|
||||
</div>
|
||||
<close-outlined class="placed-del" @click.stop="removePlaced(ts.id)" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 骑缝章占位条 -->
|
||||
<div class="paging-seal-wrapper"
|
||||
:class="{ 'paging-empty': !pagingSeal.sealId, 'paging-signed': pagingSigned }"
|
||||
@click.stop="handlePagingSealClick">
|
||||
<template v-if="!pagingSeal.sealId">
|
||||
<div class="paging-empty-box"><div class="paging-empty-text">添加骑缝章</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="paging-seal-real">
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-img" />
|
||||
<div class="paging-seal-real-text">{{ pagingSeal.sealName || '骑缝章' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右栏:操作区 -->
|
||||
<div class="operation-sidebar">
|
||||
<!-- 步骤2:添加签章 -->
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">2</span><span class="step-title">添加签章</span><span class="step-required">(必选项)</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="seal-section">
|
||||
<div class="section-subtitle">1、托管印章</div>
|
||||
<div v-if="hostedSeals.length === 0" class="hosted-empty">
|
||||
<p class="hosted-empty-text">尚未获取托管印章</p>
|
||||
<div class="btn btn-primary" @click="handleHostSeal" style="width:100%"><plus-outlined /> 新增托管</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="seal in hostedSeals" :key="seal.sealId" class="hosted-item"
|
||||
draggable="true" @dragstart="onDragHosted($event, seal)">
|
||||
<img :src="seal.pictureUrl" class="hosted-img" />
|
||||
<div class="hosted-info">
|
||||
<span class="hosted-name">{{ seal.sealName }}</span>
|
||||
<span class="hosted-type">{{ sealTypeLabel(seal.sealType) }}</span>
|
||||
</div>
|
||||
<div class="hosted-actions">
|
||||
<span class="hosted-mode-tag" :class="seal._sealMode === 'auto' ? 'tag-auto' : 'tag-manual'"
|
||||
@click.stop="toggleSealSetting(seal.sealId)">
|
||||
{{ seal._sealMode === 'auto' ? '自动' : '手动' }}
|
||||
</span>
|
||||
<setting-outlined class="hosted-setting" @click.stop="toggleSealSetting(seal.sealId)" />
|
||||
</div>
|
||||
</div>
|
||||
<!-- 印章设置面板 -->
|
||||
<div v-if="settingSealId" class="seal-setting-panel">
|
||||
<div class="setting-header">
|
||||
<span>印章设置 — {{ getSealName(settingSealId) }}</span>
|
||||
<close-outlined class="del-icon" @click="settingSealId = ''" />
|
||||
</div>
|
||||
<a-radio-group v-model:value="settingSealMode" size="small" style="margin-bottom:8px">
|
||||
<a-radio value="manual">手动拖拽</a-radio>
|
||||
<a-radio value="auto">自动签章</a-radio>
|
||||
</a-radio-group>
|
||||
<template v-if="settingSealMode === 'auto'">
|
||||
<div class="form-item"><label>自动签章位置(上传文档后自动放置)</label></div>
|
||||
<div class="coord-row">
|
||||
<span>第</span>
|
||||
<a-input-number v-model:value="settingAutoPage" :min="1" :max="pdfImages.length" size="small" style="width:70px" />
|
||||
<span>页 X:</span>
|
||||
<a-input-number v-model:value="settingAutoX" :min="1" :max="imageWidth" size="small" style="width:70px" />
|
||||
<span>Y:</span>
|
||||
<a-input-number v-model:value="settingAutoY" :min="1" :max="imageHeight" size="small" style="width:70px" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="btn btn-primary btn-sm" @click="saveSealSetting" style="width:100%;margin-top:6px">保存设置</div>
|
||||
</div>
|
||||
<div class="btn btn-primary" @click="handleHostSeal" style="width:100%;margin-top:8px"><plus-outlined /> 新增托管</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 骑缝章 -->
|
||||
<div class="seal-section" v-if="hostedSeals.length > 0">
|
||||
<div class="section-subtitle">2、骑缝章(拖拽印章到下方,直接签署)</div>
|
||||
<div class="paging-drop-zone" @dragover.prevent @drop.prevent="onDropPaging($event)">
|
||||
<span v-if="!pagingSeal.sealId">拖拽印章到这里,自动签署骑缝章</span>
|
||||
<template v-else>
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-thumb" />
|
||||
<span>{{ pagingSeal.sealName }}</span>
|
||||
<close-outlined class="del-icon" @click="clearPaging" />
|
||||
</template>
|
||||
</div>
|
||||
<p class="paging-auto-hint" v-if="pagingSeal.sealId">骑缝章拖入后自动签署,无需再点确认按钮</p>
|
||||
</div>
|
||||
|
||||
<!-- 确认签署 -->
|
||||
<div v-if="pendingCount > 0" class="seal-section">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': confirming }" @click="handleConfirmSign" style="width:100%">
|
||||
<span v-if="confirming"><a-spin size="small" /> 签署中...</span>
|
||||
<span v-else>确认签署({{ pendingCount }} 个普通印章)</span>
|
||||
</div>
|
||||
<p class="sign-hint">使用已托管证书直接签署,无需扫码</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤3:添加时间戳 -->
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="form-item"><label>时间格式</label>
|
||||
<a-select v-model:value="tsFormat" size="small" style="width:100%">
|
||||
<a-select-option value="yyyy-MM-dd">yyyy-MM-dd</a-select-option>
|
||||
<a-select-option value="yyyy年MM月dd日">yyyy年MM月dd日</a-select-option>
|
||||
<a-select-option value="yyyy/MM/dd">yyyy/MM/dd</a-select-option>
|
||||
<a-select-option value="yyyy-MM-dd HH:mm:ss">yyyy-MM-dd HH:mm:ss</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': tsLoading }" @click="fetchTs" style="width:100%">
|
||||
<span v-if="tsLoading"><a-spin size="small" /> 获取中...</span>
|
||||
<span v-else>获取时间戳</span>
|
||||
</div>
|
||||
<div class="ts-drag-area" v-if="tsImageUrl">
|
||||
<div class="ts-drag-item" draggable="true" @dragstart="onDragTsStart">
|
||||
<img :src="tsImageUrl" class="drag-seal-img" /><span>拖拽时间戳到文档上</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 步骤4:结束合约 -->
|
||||
<div class="step-card">
|
||||
<div class="step-card-header">
|
||||
<span class="step-badge">4</span><span class="step-title">结束合约</span>
|
||||
</div>
|
||||
<div class="step-card-body">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': contractStatus === 2000 || completing }" @click="handleComplete">
|
||||
<check-circle-outlined /><span v-if="completing">结束中...</span><span v-else>结束合约</span>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': contractStatus !== 2000 }" @click="handleDownload" style="margin-top:10px;margin-left:10px">
|
||||
<download-outlined /> 下载已签署文件
|
||||
</div>
|
||||
<div class="contract-status-tag">
|
||||
<span v-if="contractStatus === 2000" class="tag-success">合约已完成</span>
|
||||
<span v-else-if="contractStatus === 20" class="tag-warning">签署进行中</span>
|
||||
<span v-else class="tag-info">等待签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== 托管二维码弹窗 ==================== -->
|
||||
<a-modal
|
||||
v-model:open="hostQrVisible"
|
||||
title="新增托管印章"
|
||||
width="420px"
|
||||
:mask-closable="false"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="qr-dialog-body">
|
||||
<div class="qr-img-box" v-if="hostQrCode"><img :src="hostQrCode" class="qr-img" /></div>
|
||||
<div class="qr-img-box qr-loading" v-else><a-spin size="large" /><p>二维码加载中...</p></div>
|
||||
<a-alert message="请使用微信扫描二维码托管印章" type="warning" :closable="false" show-icon style="margin-top:12px" />
|
||||
<div class="qr-countdown" v-if="hostQrCount > 0">
|
||||
<a-progress :percent="hostQrPct" :stroke-color="hostQrClr" :stroke-width="6" :show-info="false" />
|
||||
<p class="countdown-text">有效期剩余:<span :style="{color:hostQrClr}">{{ hostQrTxt }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: right; margin-top: 16px">
|
||||
<a-button @click="closeHostQr">关闭</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- ==================== 签署结果弹窗 ==================== -->
|
||||
<a-modal
|
||||
v-model:open="resultVisible"
|
||||
title="签署结果"
|
||||
width="480px"
|
||||
:mask-closable="false"
|
||||
:footer="null"
|
||||
>
|
||||
<div class="result-body" v-if="signResult">
|
||||
<a-alert :message="'签署' + (signResult.contractStatus === 2000 ? '已完成' : '进行中')" :type="signResult.contractStatus === 2000 ? 'success' : 'warning'" :closable="false" show-icon />
|
||||
<div class="sig-list" v-if="signResult.signatureList?.length">
|
||||
<div v-for="sig in signResult.signatureList" :key="sig.signatureId" class="sig-item">
|
||||
<span>{{ sig.owner }}</span><span>{{ sig.signer }}</span><span>{{ sig.signatureTime }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align: right; margin-top: 16px">
|
||||
<a-button @click="resultVisible = false">关闭</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { CloudUploadOutlined, FileTextOutlined, ClockCircleOutlined, CloseOutlined, CheckCircleOutlined, DownloadOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||||
import {
|
||||
startContract, completeContract, downloadContract, pollCallback,
|
||||
getTrusteeshipSealQrCode, confirmSign, getTimestamp, signTimestamp
|
||||
} from '@/api/seal'
|
||||
|
||||
// ======================================================================
|
||||
// localStorage 持久化托管印章
|
||||
// ======================================================================
|
||||
const STORAGE_SEALS = 'trusteeship_seals'
|
||||
|
||||
function saveSeals(seals) {
|
||||
const plain = seals.map(s => ({
|
||||
sealId: s.sealId, sealName: s.sealName, pictureUrl: s.pictureUrl,
|
||||
owner: s.owner, ownerSubAccountId: s.ownerSubAccountId, sealType: s.sealType,
|
||||
_sealMode: s._sealMode || 'manual', _autoPage: s._autoPage || 1,
|
||||
_autoX: s._autoX || 100, _autoY: s._autoY || 100
|
||||
}))
|
||||
try { localStorage.setItem(STORAGE_SEALS, JSON.stringify(plain)) } catch {}
|
||||
}
|
||||
function loadSeals() {
|
||||
try { const r = localStorage.getItem(STORAGE_SEALS); return r ? JSON.parse(r) : [] } catch { return [] }
|
||||
}
|
||||
function deleteSealLocally(sealId) {
|
||||
const seals = loadSeals().filter(s => s.sealId !== sealId)
|
||||
saveSeals(seals)
|
||||
return seals
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 文件上传
|
||||
// ======================================================================
|
||||
const fileInputRef = ref(null), uploadFile = ref(null), contractName = ref(''), creating = ref(false), dragOver = ref(false)
|
||||
const acceptTypes = '.xls,.xlsx,.pdf,.docx,.doc'
|
||||
|
||||
function triggerFileInput() { fileInputRef.value?.click() }
|
||||
function handleFileSelect(e) { const f = e.target.files?.[0]; if (f) validateFile(f); e.target.value = '' }
|
||||
function handleFileDrop(e) { dragOver.value = false; const f = e.dataTransfer.files?.[0]; if (f) validateFile(f) }
|
||||
function validateFile(f) {
|
||||
const ext = f.name.split('.').pop()?.toLowerCase()
|
||||
if (!['xls', 'xlsx', 'pdf', 'docx', 'doc'].includes(ext)) { message.error(`不支持 "${ext}"`); return }
|
||||
if (f.size > 10485760) { message.error('文件不能超过10MB'); return }
|
||||
uploadFile.value = f; if (!contractName.value) contractName.value = f.name.replace(/\.[^.]+$/, '')
|
||||
}
|
||||
function removeFile() { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = '' }
|
||||
function formatSize(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB' }
|
||||
|
||||
// ======================================================================
|
||||
// 签署流程
|
||||
// ======================================================================
|
||||
const contractId = ref(''), contractStatus = ref(null), pdfImages = ref([])
|
||||
const imageWidth = ref(793), imageHeight = ref(1122), completing = ref(false)
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('resize', calcSize)
|
||||
hostedSeals.value = loadSeals()
|
||||
})
|
||||
|
||||
async function handleCreate() {
|
||||
if (!uploadFile.value || !contractName.value) { message.warning('请先选择文件并输入合同名称'); return }
|
||||
creating.value = true
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', uploadFile.value); fd.append('contractName', contractName.value)
|
||||
const res = await startContract(fd)
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data; contractId.value = d.contractId; pdfImages.value = d.pdfToImageList || []
|
||||
imageWidth.value = d.imageWidth || 793; imageHeight.value = d.imageHeight || 1122; contractStatus.value = 10
|
||||
hostedSeals.value = loadSeals()
|
||||
autoPlaceSeals()
|
||||
message.success('签署流程创建成功!')
|
||||
nextTick(calcSize)
|
||||
} else { message.error(res.msg || '创建失败') }
|
||||
} catch { message.error('创建失败') }
|
||||
finally { creating.value = false }
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// PDF 显示
|
||||
// ======================================================================
|
||||
const dw = ref(700), dh = ref(990), sr = ref(1), pdfScrollRef = ref(null)
|
||||
function calcSize() {
|
||||
if (!pdfScrollRef.value) return
|
||||
const cw = pdfScrollRef.value.clientWidth - 40; const mw = Math.min(cw, 800)
|
||||
if (imageWidth.value > 0) { sr.value = mw / imageWidth.value; dw.value = mw; dh.value = Math.round(imageHeight.value * sr.value) }
|
||||
}
|
||||
watch(pdfImages, () => nextTick(calcSize))
|
||||
onUnmounted(() => { window.removeEventListener('resize', calcSize) })
|
||||
|
||||
// ======================================================================
|
||||
// 托管印章
|
||||
// ======================================================================
|
||||
const hostedSeals = ref([])
|
||||
|
||||
const hostQrVisible = ref(false), hostQrCode = ref(''), hostQrRid = ref(''), hostQrExp = ref(300), hostQrCount = ref(0)
|
||||
let hostQrTimer = null, hostPollTimer = null
|
||||
const hostQrPct = computed(() => hostQrExp.value ? Math.round(hostQrCount.value / hostQrExp.value * 100) : 0)
|
||||
const hostQrClr = computed(() => hostQrCount.value <= 30 ? '#f56c6c' : hostQrCount.value <= 60 ? '#e6a23c' : '#67c23a')
|
||||
const hostQrTxt = computed(() => { const m = Math.floor(hostQrCount.value / 60); const s = hostQrCount.value % 60; return `${m}分${String(s).padStart(2, '0')}秒` })
|
||||
|
||||
async function handleHostSeal() {
|
||||
try {
|
||||
const host = 'http://47.110.50.12:7005'
|
||||
const res = await getTrusteeshipSealQrCode(host + '/seal/callback/trusteeship')
|
||||
if (res.code === 200 && res.data) {
|
||||
const d = res.data; hostQrCode.value = d.qrCode || ''; hostQrRid.value = d.requestId || ''; hostQrExp.value = d.expiresIn || 300
|
||||
hostQrVisible.value = true; startHostQrCt(); startHostPoll(d.requestId)
|
||||
} else { message.error(res.msg || '获取失败') }
|
||||
} catch { message.error('获取托管二维码失败') }
|
||||
}
|
||||
function startHostQrCt() { stopHostQrCt(); hostQrCount.value = hostQrExp.value; hostQrTimer = setInterval(() => { hostQrCount.value--; if (hostQrCount.value <= 0) stopHostQrCt() }, 1000) }
|
||||
function stopHostQrCt() { if (hostQrTimer) { clearInterval(hostQrTimer); hostQrTimer = null } }
|
||||
function closeHostQr() { stopHostQrCt(); stopHostPoll(); hostQrVisible.value = false }
|
||||
function startHostPoll(rid) {
|
||||
stopHostPoll(); const max = Math.ceil(hostQrExp.value / 2) + 10; let n = 0
|
||||
hostPollTimer = setInterval(async () => {
|
||||
n++; if (n > max || !hostQrVisible.value) { stopHostPoll(); return }
|
||||
try { const r = await pollCallback(rid); if (r.code === 200 && r.data) { stopHostPoll(); onHostCallback(r.data) } } catch {}
|
||||
}, 2000)
|
||||
}
|
||||
function stopHostPoll() { if (hostPollTimer) { clearInterval(hostPollTimer); hostPollTimer = null } }
|
||||
|
||||
function onHostCallback(data) {
|
||||
if (data.sealList?.length) {
|
||||
const newSeals = data.sealList.map(s => ({
|
||||
...s, _sealMode: 'manual', _autoPage: 1, _autoX: 100, _autoY: 100
|
||||
}))
|
||||
hostedSeals.value = [...hostedSeals.value, ...newSeals]
|
||||
const seen = new Set()
|
||||
hostedSeals.value = hostedSeals.value.filter(s => { const k = s.sealId; if (seen.has(k)) return false; seen.add(k); return true })
|
||||
saveSeals(hostedSeals.value)
|
||||
message.success(`已托管 ${data.sealList.length} 个印章`)
|
||||
}
|
||||
if (hostQrVisible.value) { stopHostQrCt(); hostQrVisible.value = false }
|
||||
}
|
||||
|
||||
function sealTypeLabel(t) {
|
||||
const m = { 0: '个人章', 10: '法人章', 20: '公章', 30: '财务章', 40: '合同章', 50: '发票章', 60: '其他章' }
|
||||
return m[t] || '未知'
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 印章设置
|
||||
// ======================================================================
|
||||
const settingSealId = ref('')
|
||||
const settingSealMode = ref('manual')
|
||||
const settingAutoPage = ref(1), settingAutoX = ref(100), settingAutoY = ref(100)
|
||||
|
||||
const autoSealCount = computed(() => hostedSeals.value.filter(s => s._sealMode === 'auto').length)
|
||||
|
||||
function toggleSealSetting(sealId) {
|
||||
if (settingSealId.value === sealId) { settingSealId.value = ''; return }
|
||||
const seal = hostedSeals.value.find(s => s.sealId === sealId)
|
||||
if (!seal) return
|
||||
settingSealId.value = sealId
|
||||
settingSealMode.value = seal._sealMode || 'manual'
|
||||
settingAutoPage.value = seal._autoPage || 1
|
||||
settingAutoX.value = seal._autoX || 100
|
||||
settingAutoY.value = seal._autoY || 100
|
||||
}
|
||||
|
||||
function saveSealSetting() {
|
||||
const seal = hostedSeals.value.find(s => s.sealId === settingSealId.value)
|
||||
if (!seal) return
|
||||
seal._sealMode = settingSealMode.value
|
||||
seal._autoPage = settingAutoPage.value
|
||||
seal._autoX = settingAutoX.value
|
||||
seal._autoY = settingAutoY.value
|
||||
saveSeals(hostedSeals.value)
|
||||
message.success(`印章 "${seal.sealName}" 已设为${settingSealMode.value === 'auto' ? '自动签章' : '手动拖拽'}`)
|
||||
settingSealId.value = ''
|
||||
}
|
||||
|
||||
function getSealName(sid) { const s = hostedSeals.value.find(v => v.sealId === sid); return s?.sealName || sid }
|
||||
|
||||
function autoPlaceSeals() {
|
||||
if (!pdfImages.value.length) return
|
||||
hostedSeals.value.filter(s => s._sealMode === 'auto').forEach(seal => {
|
||||
const alreadyPlaced = placedSeals.value.some(p => p.sealId === seal.sealId && p._type === 'seal')
|
||||
if (alreadyPlaced) return
|
||||
placedSeals.value.push({
|
||||
id: ++sealPlacedId, _type: 'seal',
|
||||
sealId: seal.sealId, sealName: seal.sealName, pictureUrl: seal.pictureUrl,
|
||||
owner: seal.owner || '', pageNum: seal._autoPage || 1,
|
||||
x: seal._autoX || 100, y: seal._autoY || 100, scale: 1, status: 'pending'
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 印章放置
|
||||
// ======================================================================
|
||||
const placedSeals = ref([])
|
||||
let sealPlacedId = 0
|
||||
|
||||
const pagingSigned = ref(false)
|
||||
const pagingSeal = reactive({ sealId: '', sealName: '', pictureUrl: '' })
|
||||
|
||||
function handlePagingSealClick() {
|
||||
if (pagingSigned.value) { message.warning('骑缝章已签署'); return }
|
||||
if (!pagingSeal.sealId) { message.info('请从托管印章列表拖拽印章到骑缝章区域') }
|
||||
}
|
||||
|
||||
async function onDropPaging(e) {
|
||||
if (!dragSealData || dragSealData._isTimestamp) return
|
||||
pagingSeal.sealId = dragSealData.sealId
|
||||
pagingSeal.sealName = dragSealData.sealName
|
||||
pagingSeal.pictureUrl = dragSealData.pictureUrl
|
||||
dragSealData = null
|
||||
message.success(`已设置骑缝章:${pagingSeal.sealName},正在自动签署...`)
|
||||
await signPagingOnly()
|
||||
}
|
||||
|
||||
async function signPagingOnly() {
|
||||
if (!pagingSeal.sealId) return
|
||||
confirming.value = true
|
||||
try {
|
||||
const pagingSealConfigs = [{ sealId: pagingSeal.sealId, scale: 1, startPage: 1, endPage: pdfImages.value.length, coordinateY: Math.round(dh.value / 2) }]
|
||||
const res = await confirmSign({ contractId: contractId.value, sealConfigs: [], pagingSealConfigs })
|
||||
if (res.code === 200 && res.data) {
|
||||
if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus
|
||||
pagingSigned.value = true
|
||||
message.success('骑缝章签署成功')
|
||||
} else {
|
||||
if (res.code === 500) {
|
||||
hostedSeals.value = deleteSealLocally(pagingSeal.sealId)
|
||||
clearPaging()
|
||||
message.error(res.msg || '托管授权已失效,相关印章已移除')
|
||||
} else { message.error(res.msg || '骑缝章签署失败') }
|
||||
}
|
||||
} catch { message.error('骑缝章签署失败') }
|
||||
finally { confirming.value = false }
|
||||
}
|
||||
|
||||
function clearPaging() {
|
||||
Object.assign(pagingSeal, { sealId: '', sealName: '', pictureUrl: '' })
|
||||
}
|
||||
|
||||
function getPagePlaced(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type !== 'timestamp') }
|
||||
function getPagePlacedTs(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type === 'timestamp') }
|
||||
function getPlacedStyle(s) { return { left: (s.x * sr.value) + 'px', top: (s.y * sr.value) + 'px' } }
|
||||
|
||||
let dragSealData = null
|
||||
function onDragHosted(e, seal) {
|
||||
dragSealData = seal
|
||||
e.dataTransfer.effectAllowed = 'copy'
|
||||
e.dataTransfer.setData('text/plain', seal.sealId || 'seal')
|
||||
}
|
||||
|
||||
function onDropSeal(e, pageNum) {
|
||||
if (!dragSealData) return
|
||||
const rect = e.currentTarget.getBoundingClientRect()
|
||||
const ox = Math.round((e.clientX - rect.left) / sr.value * 100) / 100
|
||||
const oy = Math.round((e.clientY - rect.top) / sr.value * 100) / 100
|
||||
const isTs = dragSealData._isTimestamp
|
||||
placedSeals.value.push({
|
||||
id: ++sealPlacedId, _type: isTs ? 'timestamp' : 'seal',
|
||||
sealId: isTs ? null : dragSealData.sealId,
|
||||
sealName: isTs ? (dragSealData.sealName || '时间戳') : dragSealData.sealName,
|
||||
pictureUrl: dragSealData.pictureUrl, _pictureUrl: dragSealData.pictureUrl,
|
||||
owner: dragSealData.owner || '', pageNum, x: ox, y: oy, scale: 1, status: 'pending'
|
||||
})
|
||||
if (!isTs) dragSealData = null
|
||||
}
|
||||
|
||||
function removePlaced(id) { const s = placedSeals.value.find(v => v.id === id); if (s?.status === 'signed') { message.warning('已签署不可删除'); return } placedSeals.value = placedSeals.value.filter(v => v.id !== id) }
|
||||
|
||||
let ds2 = null, ds2x = 0, ds2y = 0, d2ox = 0, d2oy = 0
|
||||
function startDragSeal(e, seal) {
|
||||
e.preventDefault(); ds2 = seal; ds2x = e.clientX; ds2y = e.clientY; d2ox = seal.x; d2oy = seal.y
|
||||
const mv = (ev) => { if (!ds2) return; ds2.x = Math.round((d2ox + (ev.clientX - ds2x) / sr.value) * 100) / 100; ds2.y = Math.round((d2oy + (ev.clientY - ds2y) / sr.value) * 100) / 100 }
|
||||
const up = () => { ds2 = null; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up) }
|
||||
document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up)
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 确认签署
|
||||
// ======================================================================
|
||||
const confirming = ref(false), resultVisible = ref(false), signResult = ref(null)
|
||||
const pendingCount = computed(() => placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal').length)
|
||||
|
||||
async function handleConfirmSign() {
|
||||
const pending = placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal')
|
||||
if (!pending.length) { message.warning('请先拖拽印章到文档上'); return }
|
||||
confirming.value = true
|
||||
try {
|
||||
const sealConfigs = pending.map(s => ({ sealId: s.sealId, x: s.x, y: s.y, scale: s.scale || 1, pageNum: s.pageNum }))
|
||||
const res = await confirmSign({ contractId: contractId.value, sealConfigs, pagingSealConfigs: null })
|
||||
if (res.code === 200 && res.data) {
|
||||
signResult.value = res.data
|
||||
if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus
|
||||
pending.forEach(s => s.status = 'signed')
|
||||
resultVisible.value = true
|
||||
message.success('签署成功')
|
||||
} else {
|
||||
if (res.code === 500) {
|
||||
const sealIds = pending.map(s => s.sealId).filter(Boolean)
|
||||
sealIds.forEach(sid => { hostedSeals.value = deleteSealLocally(sid) })
|
||||
message.error(res.msg || '托管授权已失效,相关印章已移除,请重新新增托管')
|
||||
} else { message.error(res.msg || '签署失败') }
|
||||
}
|
||||
} catch { message.error('签署失败') }
|
||||
finally { confirming.value = false }
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 时间戳
|
||||
// ======================================================================
|
||||
const tsFormat = ref('yyyy-MM-dd'), tsLoading = ref(false), tsImageUrl = ref(''), tsData = ref(null)
|
||||
async function fetchTs() {
|
||||
tsLoading.value = true
|
||||
try {
|
||||
const res = await getTimestamp(tsFormat.value)
|
||||
if (res.code === 200 && res.data?.pictureUrl) { tsImageUrl.value = res.data.pictureUrl; tsData.value = { pictureUrl: res.data.pictureUrl }; message.success('已获取,请拖拽到文档上') }
|
||||
else message.error(res.msg || '获取失败')
|
||||
} catch { message.error('获取失败') }
|
||||
finally { tsLoading.value = false }
|
||||
}
|
||||
function onDragTsStart(e) {
|
||||
dragSealData = { _isTimestamp: true, pictureUrl: tsData.value?.pictureUrl, _pictureUrl: tsData.value?.pictureUrl, sealName: '时间戳', owner: '' }
|
||||
e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', 'timestamp')
|
||||
}
|
||||
async function confirmTs(ts) {
|
||||
if (ts.status !== 'pending') return; ts.status = 'signing'
|
||||
try {
|
||||
const res = await signTimestamp({ contractId: contractId.value, sealConfigs: [{ formatPattern: tsFormat.value, x: ts.x, y: ts.y, pageNum: ts.pageNum, scale: 1 }] })
|
||||
if (res.code === 200 && res.data) {
|
||||
ts.status = 'signed'; ts._signTime = res.data.signatureTime || ''
|
||||
if (res.data.pdfToImageList?.length) pdfImages.value = res.data.pdfToImageList
|
||||
if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus
|
||||
message.success('时间戳签署成功')
|
||||
} else { message.error(res.msg || '失败'); ts.status = 'pending' }
|
||||
} catch { message.error('失败'); ts.status = 'pending' }
|
||||
}
|
||||
|
||||
// ======================================================================
|
||||
// 完成 & 下载
|
||||
// ======================================================================
|
||||
async function handleComplete() {
|
||||
if (contractStatus.value === 2000) { message.warning('合约已结束'); return }
|
||||
Modal.confirm({
|
||||
title: '确认结束',
|
||||
content: '确认结束合约?结束后将无法再添加印章。',
|
||||
okType: 'danger',
|
||||
onOk: async () => {
|
||||
completing.value = true
|
||||
try {
|
||||
const res = await completeContract(contractId.value)
|
||||
if (res.code === 200) { contractStatus.value = 2000; message.success('合约已完成') }
|
||||
else message.error(res.msg || '结束失败')
|
||||
} catch { message.error('结束失败') }
|
||||
finally { completing.value = false }
|
||||
},
|
||||
})
|
||||
}
|
||||
async function handleDownload() {
|
||||
if (contractStatus.value !== 2000) { message.warning('请先结束合约'); return }
|
||||
try { const res = await downloadContract(contractId.value); if (res.code === 200 && res.data?.downloadUrl) window.open(res.data.downloadUrl, '_blank'); else message.error('获取链接失败') }
|
||||
catch { message.error('下载失败') }
|
||||
}
|
||||
|
||||
onUnmounted(() => { stopHostQrCt(); stopHostPoll() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sign-platform { width: 100%; height: 100vh; background: #f5f7fa; font-family: "Inter", -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; color: #2c3e50; overflow: hidden; }
|
||||
.platform-layout { height: 100vh; overflow: hidden; }
|
||||
.platform-layout-empty { display: grid; grid-template-columns: minmax(0, 1fr) 380px; }
|
||||
.platform-layout-full { display: grid; grid-template-columns: 260px minmax(0, 1fr) 380px; }
|
||||
|
||||
.preview-area { background: linear-gradient(to bottom, #f8fafc, #eef2f7); overflow-y: auto; padding: 30px 50px; display: flex; flex-direction: column; align-items: center; min-width: 0; gap: 28px; }
|
||||
.empty-preview { justify-content: center; align-items: center; background: #f0f2f5; min-width: 0; }
|
||||
.preview-placeholder { width: 420px; padding: 60px 40px; border-radius: 20px; background: rgba(255,255,255,.7); backdrop-filter: blur(10px); box-shadow: 0 10px 40px rgba(15,23,42,.06); border: 1px solid rgba(255,255,255,.8); display: flex; flex-direction: column; align-items: center; }
|
||||
.preview-placeholder p { margin-top: 14px; font-size: 15px; color: #64748b; }
|
||||
|
||||
.pdf-page-wrapper { position: relative; border-radius: 12px; overflow: visible; background: white; box-shadow: 0 12px 40px rgba(15,23,42,.08), 0 2px 8px rgba(15,23,42,.04); transition: all .2s ease; }
|
||||
.pdf-page-wrapper:hover { transform: translateY(-2px); }
|
||||
.pdf-page-image { display: block; user-select: none; pointer-events: none; }
|
||||
|
||||
.placed-seal { position: absolute; z-index: 10; cursor: move; user-select: none; }
|
||||
.placed-seal .placed-img { width: 80px; height: auto; opacity: .85; display: block; pointer-events: none; }
|
||||
.placed-seal .placed-fallback { width: 80px; height: 40px; border: 2px dashed #409eff; background: rgba(64,158,255,.1); display: flex; align-items: center; justify-content: center; font-size: 12px; color: #409eff; }
|
||||
.placed-del { position: absolute; top: -8px; right: -8px; background: #f56c6c; color: #fff; border-radius: 50%; cursor: pointer; font-size: 14px; padding: 2px; }
|
||||
.placed-del:hover { background: #e03838; }
|
||||
.placed-seal.seal-signed { cursor: default; } .placed-seal.seal-signed .placed-img { opacity: 1; } .placed-seal.seal-signed .placed-del { display: none; }
|
||||
.placed-seal.placed-ts .placed-img { width: 130px; }
|
||||
.placed-seal.placed-ts .ts-actions { display: flex; align-items: center; gap: 4px; margin-top: 4px; background: #fff; padding: 2px 6px; border-radius: 4px; box-shadow: 0 1px 4px rgba(0,0,0,.1); }
|
||||
|
||||
.paging-seal-wrapper { position: absolute; top: 50%; right: -18px; transform: translateY(-50%); width: 52px; height: fit-content; display: flex; align-items: center; justify-content: center; z-index: 50; cursor: pointer; transition: all .25s ease; }
|
||||
.paging-seal-wrapper:hover { transform: translateY(-50%) scale(1.03); }
|
||||
.paging-empty-box { width: 100%; border: 2px dashed #cbd5e1; border-radius: 12px; background: rgba(248,250,252,.95); display: flex; align-items: center; justify-content: center; color: #94a3b8; transition: all .2s ease; padding: 10px; }
|
||||
.paging-empty-text { writing-mode: vertical-rl; text-orientation: upright; font-size: 16px; letter-spacing: 4px; font-weight: 600; line-height: 1.4; }
|
||||
.paging-empty-box:hover { border-color: #3b82f6; color: #3b82f6; background: #eff6ff; }
|
||||
.paging-seal-real { width: 100%; border: 2px solid rgba(210,50,50,.65); background: radial-gradient(circle at center, rgba(255,255,255,.04), rgba(255,0,0,.03)); border-radius: 12px; display: flex; flex-direction: column; align-items: center; justify-content: center; opacity: .9; padding: 8px 4px; box-shadow: 0 0 12px rgba(210,50,50,.2), inset 0 0 8px rgba(210,50,50,.1); }
|
||||
.paging-seal-img { width: 32px; height: 32px; object-fit: contain; margin-bottom: 4px; }
|
||||
.paging-seal-real-text { writing-mode: vertical-rl; text-orientation: upright; font-size: 14px; letter-spacing: 2px; color: rgba(210,50,50,.72); font-family: "STFangsong","FangSong","SimSun",serif; user-select: none; }
|
||||
|
||||
.left-info-sidebar { background: #fff; border-right: 1px solid #ebeef5; padding: 20px; overflow-y: auto; display: flex; flex-direction: column; gap: 20px; }
|
||||
.info-card { background: #fff; border-radius: 16px; padding: 20px; box-shadow: 0 4px 20px rgba(15,23,42,.05); border: 1px solid #eef2f6; }
|
||||
.info-title { font-size: 16px; font-weight: 700; color: #1e293b; margin-bottom: 18px; }
|
||||
.info-item { display: flex; justify-content: space-between; align-items: center; padding: 12px 0; border-bottom: 1px dashed #eef2f6; font-size: 14px; }
|
||||
.info-item:last-child { border-bottom: none; } .info-item span { color: #64748b; } .info-item strong { color: #0f172a; }
|
||||
|
||||
.operation-sidebar { width: 380px; background: #fff; border-left: 1px solid #e2e6ed; display: flex; flex-direction: column; padding: 20px; gap: 16px; overflow-y: auto; box-shadow: -2px 0 12px rgba(0,0,0,.04); }
|
||||
.step-card { background: #fff; border: 1px solid #eef1f5; border-radius: 12px; overflow: hidden; box-shadow: 0 1px 4px rgba(0,0,0,.04); flex-shrink: 0; }
|
||||
.step-card-disabled { opacity: .6; }
|
||||
.step-card-header { padding: 14px 16px; background: #f8f9fb; border-bottom: 1px solid #f0f2f5; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.step-badge { display: inline-flex; align-items: center; justify-content: center; width: 26px; height: 26px; background: #3b6fa0; color: #fff; border-radius: 6px; font-size: 14px; font-weight: 600; flex-shrink: 0; }
|
||||
.step-title { font-size: 15px; font-weight: 600; color: #2c3e50; }
|
||||
.step-required { font-size: 12px; color: #c88a2e; margin-left: 4px; }
|
||||
.step-card-body { padding: 16px; } .step-hint { color: #8b95a5; font-size: 13px; margin: 0; }
|
||||
|
||||
.seal-section { margin-bottom: 14px; }
|
||||
.section-subtitle { font-size: 13px; font-weight: 600; color: #2c3e50; margin-bottom: 10px; padding-bottom: 6px; border-bottom: 1px solid #f0f2f5; }
|
||||
.form-item { margin-bottom: 10px; } .form-item label { display: block; margin-bottom: 4px; font-size: 13px; color: #606266; } .form-item .required { color: #f56c6c; }
|
||||
|
||||
.hosted-empty { text-align: center; } .hosted-empty-text { color: #909399; font-size: 13px; margin: 0 0 10px; }
|
||||
.hosted-item { display: flex; align-items: center; gap: 8px; padding: 8px; border: 1px solid #e4e7ed; border-radius: 6px; margin-bottom: 6px; cursor: grab; }
|
||||
.hosted-item:active { cursor: grabbing; } .hosted-item:hover { border-color: #409eff; }
|
||||
.hosted-img { width: 36px; height: 36px; object-fit: contain; flex-shrink: 0; }
|
||||
.hosted-info { flex: 1; min-width: 0; } .hosted-name { font-size: 12px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; display: block; } .hosted-type { font-size: 10px; color: #909399; }
|
||||
|
||||
.hosted-actions { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
|
||||
.hosted-mode-tag { font-size: 10px; padding: 2px 6px; border-radius: 4px; cursor: pointer; font-weight: 500; }
|
||||
.tag-manual { background: #f0f2f5; color: #606266; } .tag-auto { background: #e6f7ff; color: #1890ff; }
|
||||
.hosted-setting { cursor: pointer; color: #909399; font-size: 14px; } .hosted-setting:hover { color: #409eff; }
|
||||
|
||||
.seal-setting-panel { padding: 10px; background: #f8f9fb; border: 1px solid #e4e7ed; border-radius: 8px; margin-bottom: 8px; }
|
||||
.setting-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 13px; font-weight: 600; color: #2c3e50; }
|
||||
.coord-row { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; font-size: 12px; color: #606266; margin-bottom: 4px; }
|
||||
.del-icon { cursor: pointer; color: #f56c6c; font-size: 14px; } .del-icon:hover { color: #e03838; }
|
||||
|
||||
.paging-drop-zone { border: 2px dashed #e6a23c; border-radius: 6px; padding: 16px; text-align: center; color: #e6a23c; font-size: 13px; min-height: 50px; display: flex; align-items: center; justify-content: center; gap: 8px; background: rgba(230,162,60,.04); cursor: pointer; transition: all .2s; }
|
||||
.paging-drop-zone:hover { border-color: #e6a23c; background: rgba(230,162,60,.1); }
|
||||
.paging-seal-thumb { width: 40px; height: 40px; object-fit: contain; }
|
||||
.paging-auto-hint { font-size: 11px; color: #e6a23c; margin-top: 6px; text-align: center; }
|
||||
.sign-hint { font-size: 12px; color: #909399; text-align: center; margin: 6px 0 0; }
|
||||
|
||||
.upload-dragger { border: 2px dashed #dcdfe6; border-radius: 10px; padding: 24px 16px; text-align: center; cursor: pointer; transition: all .25s; background: #fafbfc; }
|
||||
.upload-dragger:hover, .is-dragover { border-color: #3b6fa0; background: #eaf2f9; }
|
||||
.upload-content .anticon { font-size: 36px; color: #b0b8c5; margin-bottom: 8px; }
|
||||
.upload-text, .file-name { font-size: 14px; color: #2c3e50; margin: 0 0 6px; } .file-name { font-weight: 500; }
|
||||
.upload-hint, .file-size { font-size: 12px; color: #8b95a5; margin: 0 0 8px; } .contract-name-row { margin-top: 14px; }
|
||||
|
||||
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px; padding: 0 20px; height: 38px; border-radius: 8px; font-size: 14px; font-weight: 500; cursor: pointer; transition: all .2s; user-select: none; border: none; background: #f0f2f5; color: #2c3e50; }
|
||||
.btn-primary { background: linear-gradient(135deg, #3b82f6, #2563eb); color: white; border: none; box-shadow: 0 8px 20px rgba(37,99,235,.18); margin-top: 10px; }
|
||||
.btn-primary:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(37,99,235,.28); }
|
||||
.btn-success { background: linear-gradient(135deg, #10b981, #059669); color: white; border: none; box-shadow: 0 8px 20px rgba(16,185,129,.18); }
|
||||
.btn-success:hover { transform: translateY(-1px); box-shadow: 0 10px 24px rgba(16,185,129,.28); }
|
||||
.btn-danger-text { background: transparent; color: #c0392b; padding: 0; height: auto; font-size: 13px; }
|
||||
.btn-disabled { opacity: .5; cursor: not-allowed; pointer-events: none; }
|
||||
.btn-sm { height: 30px; padding: 0 12px; font-size: 12px; } .btn-loading { pointer-events: none; opacity: .8; }
|
||||
|
||||
.contract-status-tag { margin-top: 12px; font-size: 13px; }
|
||||
.tag-success { color: #4a9c5d; font-weight: 500; } .tag-warning { color: #c88a2e; font-weight: 500; } .tag-info { color: #8b95a5; font-weight: 500; }
|
||||
|
||||
.ts-drag-area { margin-top: 10px; padding: 10px; background: #f5f7fa; border-radius: 6px; border: 1px dashed #dcdfe6; }
|
||||
.ts-drag-item { width: 130px; cursor: grab; text-align: center; padding: 6px; border: 1px solid #e4e7ed; border-radius: 6px; background: #fff; }
|
||||
.ts-drag-item:hover { border-color: #67c23a; } .ts-drag-item:active { cursor: grabbing; }
|
||||
.ts-drag-item .drag-seal-img { width: 120px; height: auto; display: block; margin: 0 auto 4px; } .ts-drag-item span { font-size: 11px; color: #909399; }
|
||||
|
||||
.qr-dialog-body { text-align: center; } .qr-img-box { display: flex; justify-content: center; align-items: center; min-height: 200px; }
|
||||
.qr-loading { flex-direction: column; gap: 12px; color: #8b95a5; }
|
||||
.qr-img { width: 200px; height: 200px; border: 1px solid #eee; border-radius: 10px; padding: 8px; }
|
||||
.qr-countdown { margin-top: 14px; } .countdown-text { margin-top: 6px; font-size: 13px; color: #5a6b7c; }
|
||||
|
||||
.result-body { text-align: center; } .sig-list { margin-top: 12px; }
|
||||
.sig-item { display: flex; justify-content: space-between; padding: 8px; background: #f5f7fa; border-radius: 4px; margin-bottom: 4px; font-size: 13px; }
|
||||
|
||||
@media (max-width: 900px) { .operation-sidebar { width: 320px; } }
|
||||
</style>
|
||||
@@ -0,0 +1,463 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="visible"
|
||||
:title="contractName || '托管盖章模式'"
|
||||
width="95%"
|
||||
:style="{ top: '30px', paddingBottom: 0 }"
|
||||
:bodyStyle="{ height: 'calc(100vh - 180px)', padding: 0, overflow: 'hidden' }"
|
||||
:destroy-on-close="false"
|
||||
:footer="null"
|
||||
:mask-closable="false"
|
||||
wrap-class-name="seal-full-modal"
|
||||
>
|
||||
<div class="sign-platform">
|
||||
<div class="platform-layout platform-layout-empty" v-if="!contractId">
|
||||
<div class="preview-area empty-preview">
|
||||
<div class="preview-placeholder">
|
||||
<cloud-upload-outlined :style="{ fontSize: '48px', color: '#c0c4cc' }" />
|
||||
<p>上传文件后此处将显示文件预览</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">1</span><span class="step-title">发起合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="upload-dragger" :class="{ 'is-dragover': dragOver }"
|
||||
@click="triggerFileInput" @dragover.prevent="dragOver = true" @dragleave.prevent="dragOver = false" @drop.prevent="handleFileDrop">
|
||||
<input ref="fileInputRef" type="file" :accept="acceptTypes" style="display:none" @change="handleFileSelect" />
|
||||
<div class="upload-content" v-if="!uploadFile">
|
||||
<cloud-upload-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="upload-text">点击或将文件拖拽到这里上传</p>
|
||||
<p class="upload-hint">支持 xls、xlsx、pdf、docx、doc 文件,且小于10M</p>
|
||||
</div>
|
||||
<div class="upload-content" v-else>
|
||||
<file-text-outlined :style="{ fontSize: '36px', color: '#b0b8c5' }" />
|
||||
<p class="file-name">{{ uploadFile.name }}</p>
|
||||
<p class="file-size">{{ formatSize(uploadFile.size) }}</p>
|
||||
<div class="btn btn-danger-text" @click.stop="removeFile">移除文件</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="contract-name-row"><a-input v-model:value="contractName" placeholder="请输入合同/文件名称" allow-clear /></div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': !uploadFile || !contractName || creating }" @click="handleCreate">
|
||||
<a-spin v-if="creating" size="small" /><span v-if="creating"> 发起中...</span><span v-else>发起签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span></div><div class="step-card-body"><p class="step-hint">上传文件后即可新增托管印章并拖拽至指定位置</p></div></div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div><div class="step-card-body"><p class="step-hint">可在此获取并添加时间戳</p></div></div>
|
||||
<div class="step-card step-card-disabled"><div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div><div class="step-card-body"><p class="step-hint">签署完成后可在此结束合约并下载文件</p></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="platform-layout platform-layout-full" v-else>
|
||||
<div class="left-info-sidebar">
|
||||
<div class="info-card">
|
||||
<div class="info-title">合同信息</div>
|
||||
<div class="info-item"><span>合同名称</span><strong>{{ contractName }}</strong></div>
|
||||
<div class="info-item"><span>合同状态</span><strong>{{ contractStatus === 2000 ? '已完成' : contractStatus === 20 ? '签署中' : '待签署' }}</strong></div>
|
||||
<div class="info-item"><span>文件页数</span><strong>{{ pdfImages.length }} 页</strong></div>
|
||||
<div class="info-item"><span>托管印章</span><strong>{{ hostedSeals.length }} 个</strong></div>
|
||||
<div class="info-item"><span>自动签章</span><strong>{{ autoSealCount }} 个</strong></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="preview-area" ref="pdfScrollRef">
|
||||
<div v-for="(imgUrl, idx) in pdfImages" :key="'p'+idx" class="pdf-page-wrapper"
|
||||
:style="{ width: dw + 'px', height: dh + 'px' }" @dragover.prevent @drop.prevent="onDropSeal($event, idx + 1)">
|
||||
<img :src="imgUrl" class="pdf-page-image" :style="{ width: dw + 'px', height: dh + 'px' }" draggable="false" />
|
||||
<div v-for="seal in getPagePlaced(idx + 1)" :key="'s'+seal.id" class="placed-seal"
|
||||
:class="{ 'seal-signed': seal.status === 'signed' }" :style="getPlacedStyle(seal)"
|
||||
@mousedown.stop="seal.status !== 'signed' && startDragSeal($event, seal)">
|
||||
<img v-if="seal.pictureUrl" :src="seal.pictureUrl" class="placed-img" />
|
||||
<div v-else class="placed-fallback">{{ seal.sealName }}</div>
|
||||
<close-outlined v-if="seal.status !== 'signed'" class="placed-del" @click.stop="removePlaced(seal.id)" />
|
||||
</div>
|
||||
<div v-for="ts in getPagePlacedTs(idx + 1)" :key="'t'+ts.id" class="placed-seal placed-ts"
|
||||
:class="{ 'seal-signed': ts.status === 'signed' }" :style="getPlacedStyle(ts)"
|
||||
@mousedown.stop="ts.status !== 'signed' && startDragSeal($event, ts)">
|
||||
<img v-if="ts._pictureUrl" :src="ts._pictureUrl" class="placed-img" />
|
||||
<div v-else class="placed-fallback">时间戳</div>
|
||||
<div class="ts-actions" v-if="ts.status !== 'signed'">
|
||||
<div class="btn btn-primary btn-sm" :class="{ 'btn-loading': ts.status === 'signing' }" @click.stop="confirmTs(ts)">
|
||||
{{ ts.status === 'signing' ? '签署中...' : '确认' }}
|
||||
</div>
|
||||
<close-outlined class="placed-del" @click.stop="removePlaced(ts.id)" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="paging-seal-wrapper" :class="{ 'paging-empty': !pagingSeal.sealId, 'paging-signed': pagingSigned }" @click.stop="handlePagingSealClick">
|
||||
<template v-if="!pagingSeal.sealId">
|
||||
<div class="paging-empty-box"><div class="paging-empty-text">添加骑缝章</div></div>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="paging-seal-real">
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-img" />
|
||||
<div class="paging-seal-real-text">{{ pagingSeal.sealName || '骑缝章' }}</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="operation-sidebar">
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">2</span><span class="step-title">添加签章</span><span class="step-required">(必选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="seal-section">
|
||||
<div class="section-subtitle">1、托管印章</div>
|
||||
<div v-if="hostedSeals.length === 0" class="hosted-empty">
|
||||
<p class="hosted-empty-text">尚未获取托管印章</p>
|
||||
<div class="btn btn-primary" @click="handleHostSeal" style="width:100%"><plus-outlined /> 新增托管</div>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div v-for="seal in hostedSeals" :key="seal.sealId" class="hosted-item" draggable="true" @dragstart="onDragHosted($event, seal)">
|
||||
<img :src="seal.pictureUrl" class="hosted-img" />
|
||||
<div class="hosted-info"><span class="hosted-name">{{ seal.sealName }}</span><span class="hosted-type">{{ sealTypeLabel(seal.sealType) }}</span></div>
|
||||
<div class="hosted-actions">
|
||||
<span class="hosted-mode-tag" :class="seal._sealMode === 'auto' ? 'tag-auto' : 'tag-manual'" @click.stop="toggleSealSetting(seal.sealId)">{{ seal._sealMode === 'auto' ? '自动' : '手动' }}</span>
|
||||
<setting-outlined class="hosted-setting" @click.stop="toggleSealSetting(seal.sealId)" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="settingSealId" class="seal-setting-panel">
|
||||
<div class="setting-header"><span>印章设置 — {{ getSealName(settingSealId) }}</span><close-outlined class="del-icon" @click="settingSealId = ''" /></div>
|
||||
<a-radio-group v-model:value="settingSealMode" size="small" style="margin-bottom:8px">
|
||||
<a-radio value="manual">手动拖拽</a-radio><a-radio value="auto">自动签章</a-radio>
|
||||
</a-radio-group>
|
||||
<template v-if="settingSealMode === 'auto'">
|
||||
<div class="form-item"><label>自动签章位置(上传文档后自动放置)</label></div>
|
||||
<div class="coord-row">
|
||||
<span>第</span><a-input-number v-model:value="settingAutoPage" :min="1" :max="pdfImages.length" size="small" style="width:70px" />
|
||||
<span>页 X:</span><a-input-number v-model:value="settingAutoX" :min="1" :max="imageWidth" size="small" style="width:70px" />
|
||||
<span>Y:</span><a-input-number v-model:value="settingAutoY" :min="1" :max="imageHeight" size="small" style="width:70px" />
|
||||
</div>
|
||||
</template>
|
||||
<div class="btn btn-primary btn-sm" @click="saveSealSetting" style="width:100%;margin-top:6px">保存设置</div>
|
||||
</div>
|
||||
<div class="btn btn-primary" @click="handleHostSeal" style="width:100%;margin-top:8px"><plus-outlined /> 新增托管</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="seal-section" v-if="hostedSeals.length > 0">
|
||||
<div class="section-subtitle">2、骑缝章(拖拽印章到下方,直接签署)</div>
|
||||
<div class="paging-drop-zone" @dragover.prevent @drop.prevent="onDropPaging($event)">
|
||||
<span v-if="!pagingSeal.sealId">拖拽印章到这里,自动签署骑缝章</span>
|
||||
<template v-else>
|
||||
<img v-if="pagingSeal.pictureUrl" :src="pagingSeal.pictureUrl" class="paging-seal-thumb" />
|
||||
<span>{{ pagingSeal.sealName }}</span>
|
||||
<close-outlined class="del-icon" @click="clearPaging" />
|
||||
</template>
|
||||
</div>
|
||||
<p class="paging-auto-hint" v-if="pagingSeal.sealId">骑缝章拖入后自动签署,无需再点确认按钮</p>
|
||||
</div>
|
||||
<div v-if="pendingCount > 0" class="seal-section">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': confirming }" @click="handleConfirmSign" style="width:100%">
|
||||
<a-spin v-if="confirming" size="small" /><span v-if="confirming"> 签署中...</span>
|
||||
<span v-else>确认签署({{ pendingCount }} 个普通印章)</span>
|
||||
</div>
|
||||
<p class="sign-hint">使用已托管证书直接签署,无需扫码</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">3</span><span class="step-title">添加时间戳(可选项)</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="form-item"><label>时间格式</label>
|
||||
<a-select v-model:value="tsFormat" size="small" style="width:100%">
|
||||
<a-select-option value="yyyy-MM-dd">yyyy-MM-dd</a-select-option>
|
||||
<a-select-option value="yyyy年MM月dd日">yyyy年MM月dd日</a-select-option>
|
||||
<a-select-option value="yyyy/MM/dd">yyyy/MM/dd</a-select-option>
|
||||
<a-select-option value="yyyy-MM-dd HH:mm:ss">yyyy-MM-dd HH:mm:ss</a-select-option>
|
||||
</a-select>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': tsLoading }" @click="fetchTs" style="width:100%">
|
||||
<a-spin v-if="tsLoading" size="small" /><span v-if="tsLoading"> 获取中...</span><span v-else>获取时间戳</span>
|
||||
</div>
|
||||
<div class="ts-drag-area" v-if="tsImageUrl">
|
||||
<div class="ts-drag-item" draggable="true" @dragstart="onDragTsStart"><img :src="tsImageUrl" class="drag-seal-img" /><span>拖拽时间戳到文档上</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="step-card">
|
||||
<div class="step-card-header"><span class="step-badge">4</span><span class="step-title">结束合约</span></div>
|
||||
<div class="step-card-body">
|
||||
<div class="btn btn-success" :class="{ 'btn-disabled': contractStatus === 2000 || completing }" @click="handleComplete">
|
||||
<check-circle-outlined /><span v-if="completing">结束中...</span><span v-else>结束合约</span>
|
||||
</div>
|
||||
<div class="btn btn-primary" :class="{ 'btn-disabled': contractStatus !== 2000 }" @click="handleDownload" style="margin-top:10px;margin-left:10px">
|
||||
<download-outlined /> 下载已签署文件
|
||||
</div>
|
||||
<div class="contract-status-tag">
|
||||
<span v-if="contractStatus === 2000" class="tag-success">合约已完成</span>
|
||||
<span v-else-if="contractStatus === 20" class="tag-warning">签署进行中</span>
|
||||
<span v-else class="tag-info">等待签署</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 托管二维码弹窗 -->
|
||||
<a-modal v-model:open="hostQrVisible" title="新增托管印章" width="420px" :mask-closable="false" :footer="null">
|
||||
<div class="qr-dialog-body">
|
||||
<div class="qr-img-box" v-if="hostQrCode"><img :src="hostQrCode" class="qr-img" /></div>
|
||||
<div class="qr-img-box qr-loading" v-else><a-spin size="large" /><p>二维码加载中...</p></div>
|
||||
<a-alert message="请使用微信扫描二维码托管印章" type="warning" :closable="false" show-icon style="margin-top:12px" />
|
||||
<div class="qr-countdown" v-if="hostQrCount > 0">
|
||||
<a-progress :percent="hostQrPct" :stroke-color="hostQrClr" :stroke-width="6" :show-info="false" />
|
||||
<p class="countdown-text">有效期剩余:<span :style="{color:hostQrClr}">{{ hostQrTxt }}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;margin-top:16px">
|
||||
<a-button @click="closeHostQr">关闭</a-button>
|
||||
<!-- DEBUG: 模拟回调按钮 - 正式环境请删除 -->
|
||||
<a-button type="danger" @click="mockHostCallback" :danger="true" style="margin-left:8px">模拟回调成功</a-button>
|
||||
</div>
|
||||
</a-modal>
|
||||
|
||||
<!-- 签署结果弹窗 -->
|
||||
<a-modal v-model:open="resultVisible" title="签署结果" width="480px" :mask-closable="false" :footer="null">
|
||||
<div class="result-body" v-if="signResult">
|
||||
<a-alert :message="'签署' + (signResult.contractStatus === 2000 ? '已完成' : '进行中')" :type="signResult.contractStatus === 2000 ? 'success' : 'warning'" :closable="false" show-icon />
|
||||
<div class="sig-list" v-if="signResult.signatureList?.length">
|
||||
<div v-for="sig in signResult.signatureList" :key="sig.signatureId" class="sig-item"><span>{{ sig.owner }}</span><span>{{ sig.signer }}</span><span>{{ sig.signatureTime }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="text-align:right;margin-top:16px"><a-button @click="resultVisible = false">关闭</a-button></div>
|
||||
</a-modal>
|
||||
</div>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted, onUnmounted, nextTick, watch } from 'vue'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { CloudUploadOutlined, FileTextOutlined, ClockCircleOutlined, CloseOutlined, CheckCircleOutlined, DownloadOutlined, PlusOutlined, SettingOutlined } from '@ant-design/icons-vue'
|
||||
import { startContract, completeContract, downloadContract, pollCallback, getTrusteeshipSealQrCode, confirmSign, getTimestamp, signTimestamp, getContractDetail, previewContract } from '@/api/seal'
|
||||
|
||||
// ==================== 对外暴露 ====================
|
||||
const visible = ref(false)
|
||||
|
||||
async function showModal(contractIdParam) {
|
||||
visible.value = true
|
||||
if (contractIdParam) {
|
||||
await loadContract(contractIdParam)
|
||||
if (contractId.value) {
|
||||
await nextTick()
|
||||
nextTick(calcSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function loadContract(cid) {
|
||||
try {
|
||||
const [detailRes, previewRes] = await Promise.all([
|
||||
getContractDetail(cid),
|
||||
previewContract(cid),
|
||||
])
|
||||
const detail = detailRes?.code === 200 ? detailRes.data : null
|
||||
const preview = previewRes?.code === 200 ? previewRes.data : null
|
||||
if (detail || preview) {
|
||||
contractId.value = cid
|
||||
contractName.value = detail?.contractName || ''
|
||||
contractStatus.value = detail?.contractStatus ?? detail?.status ?? null
|
||||
pdfImages.value = preview?.pdfToImageList || []
|
||||
imageWidth.value = Number(preview?.imageWidth) || 793
|
||||
imageHeight.value = Number(preview?.imageHeight) || 1122
|
||||
saveContractState()
|
||||
}
|
||||
} catch {
|
||||
const saved = JSON.parse(localStorage.getItem(STORAGE_CONTRACT) || '{}')
|
||||
if (saved.contractId === cid) {
|
||||
contractId.value = saved.contractId; contractName.value = saved.contractName || ''
|
||||
contractStatus.value = saved.contractStatus; pdfImages.value = saved.pdfImages || []
|
||||
imageWidth.value = saved.imageWidth || 793; imageHeight.value = saved.imageHeight || 1122
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ showModal })
|
||||
|
||||
// ==================== localStorage ====================
|
||||
const STORAGE_SEALS = 'trusteeship_seals'
|
||||
const STORAGE_CONTRACT = 'trusteeship_contract'
|
||||
function saveSeals(seals) { const plain = seals.map(s => ({ sealId: s.sealId, sealName: s.sealName, pictureUrl: s.pictureUrl, owner: s.owner, ownerSubAccountId: s.ownerSubAccountId, sealType: s.sealType, _sealMode: s._sealMode || 'manual', _autoPage: s._autoPage || 1, _autoX: s._autoX || 100, _autoY: s._autoY || 100 })); try { localStorage.setItem(STORAGE_SEALS, JSON.stringify(plain)) } catch {} }
|
||||
function loadSeals() { try { const r = localStorage.getItem(STORAGE_SEALS); return r ? JSON.parse(r) : [] } catch { return [] } }
|
||||
function deleteSealLocally(sealId) { const seals = loadSeals().filter(s => s.sealId !== sealId); saveSeals(seals); return seals }
|
||||
function saveContractState() { try { localStorage.setItem(STORAGE_CONTRACT, JSON.stringify({ contractId: contractId.value, contractName: contractName.value, contractStatus: contractStatus.value, pdfImages: pdfImages.value, imageWidth: imageWidth.value, imageHeight: imageHeight.value })) } catch {} }
|
||||
function clearContractState() { try { localStorage.removeItem(STORAGE_CONTRACT) } catch {} }
|
||||
|
||||
// ==================== 文件上传 ====================
|
||||
const fileInputRef = ref(null), uploadFile = ref(null), contractName = ref(''), creating = ref(false), dragOver = ref(false), acceptTypes = '.xls,.xlsx,.pdf,.docx,.doc'
|
||||
function triggerFileInput() { fileInputRef.value?.click() }
|
||||
function handleFileSelect(e) { const f = e.target.files?.[0]; if (f) validateFile(f); e.target.value = '' }
|
||||
function handleFileDrop(e) { dragOver.value = false; const f = e.dataTransfer.files?.[0]; if (f) validateFile(f) }
|
||||
function validateFile(f) { const ext = f.name.split('.').pop()?.toLowerCase(); if (!['xls','xlsx','pdf','docx','doc'].includes(ext)) { message.error(`不支持 "${ext}"`); return } if (f.size > 10485760) { message.error('文件不能超过10MB'); return } uploadFile.value = f; if (!contractName.value) contractName.value = f.name.replace(/\.[^.]+$/, '') }
|
||||
function removeFile() { uploadFile.value = null; if (fileInputRef.value) fileInputRef.value.value = '' }
|
||||
function formatSize(b) { return b < 1024 ? b + ' B' : b < 1048576 ? (b / 1024).toFixed(1) + ' KB' : (b / 1048576).toFixed(1) + ' MB' }
|
||||
|
||||
// ==================== 签署流程 ====================
|
||||
const contractId = ref(''), contractStatus = ref(null), pdfImages = ref([]), imageWidth = ref(793), imageHeight = ref(1122), completing = ref(false)
|
||||
onMounted(() => { window.addEventListener('resize', calcSize); hostedSeals.value = loadSeals() })
|
||||
async function handleCreate() {
|
||||
if (!uploadFile.value || !contractName.value) { message.warning('请先选择文件并输入合同名称'); return }
|
||||
creating.value = true
|
||||
try { const fd = new FormData(); fd.append('file', uploadFile.value); fd.append('contractName', contractName.value); const res = await startContract(fd); if (res.code === 200 && res.data) { const d = res.data; contractId.value = d.contractId; pdfImages.value = d.pdfToImageList || []; imageWidth.value = d.imageWidth || 793; imageHeight.value = d.imageHeight || 1122; contractStatus.value = 10; saveContractState(); hostedSeals.value = loadSeals(); autoPlaceSeals(); message.success('签署流程创建成功!'); nextTick(calcSize) } else { message.error(res.msg || '创建失败') } } catch { message.error('创建失败') } finally { creating.value = false }
|
||||
}
|
||||
|
||||
// ==================== PDF 显示 ====================
|
||||
const dw = ref(700), dh = ref(990), sr = ref(1), pdfScrollRef = ref(null)
|
||||
function calcSize() { if (!pdfScrollRef.value) return; const cw = pdfScrollRef.value.clientWidth - 40; const mw = Math.min(cw, 800); if (imageWidth.value > 0) { sr.value = mw / imageWidth.value; dw.value = mw; dh.value = Math.round(imageHeight.value * sr.value) } }
|
||||
watch(pdfImages, () => nextTick(calcSize)); onUnmounted(() => { window.removeEventListener('resize', calcSize) })
|
||||
|
||||
// ==================== 托管印章 ====================
|
||||
const hostedSeals = ref([])
|
||||
const hostQrVisible = ref(false), hostQrCode = ref(''), hostQrRid = ref(''), hostQrExp = ref(300), hostQrCount = ref(0)
|
||||
let hostQrTimer = null, hostPollTimer = null
|
||||
const hostQrPct = computed(() => hostQrExp.value ? Math.round(hostQrCount.value / hostQrExp.value * 100) : 0)
|
||||
const hostQrClr = computed(() => hostQrCount.value <= 30 ? '#f56c6c' : hostQrCount.value <= 60 ? '#e6a23c' : '#67c23a')
|
||||
const hostQrTxt = computed(() => { const m = Math.floor(hostQrCount.value / 60); const s = hostQrCount.value % 60; return `${m}分${String(s).padStart(2, '0')}秒` })
|
||||
async function handleHostSeal() {
|
||||
try { const host = 'http://47.110.50.12:7005'; const res = await getTrusteeshipSealQrCode(host + '/seal/callback/trusteeship'); if (res.code === 200 && res.data) { const d = res.data; hostQrCode.value = d.qrCode || ''; hostQrRid.value = d.requestId || ''; hostQrExp.value = d.expiresIn || 300; hostQrVisible.value = true; startHostQrCt(); startHostPoll(d.requestId) } else { message.error(res.msg || '获取失败') } } catch { message.error('获取托管二维码失败') }
|
||||
}
|
||||
function startHostQrCt() { stopHostQrCt(); hostQrCount.value = hostQrExp.value; hostQrTimer = setInterval(() => { hostQrCount.value--; if (hostQrCount.value <= 0) stopHostQrCt() }, 1000) }
|
||||
function stopHostQrCt() { if (hostQrTimer) { clearInterval(hostQrTimer); hostQrTimer = null } }
|
||||
function closeHostQr() { stopHostQrCt(); stopHostPoll(); hostQrVisible.value = false }
|
||||
// ==================== 模拟回调(调试用 - 正式环境请删除此函数及模板中的按钮) ====================
|
||||
function mockHostCallback() {
|
||||
if (!hostQrRid.value) { message.warning('没有有效的请求ID'); return }
|
||||
onHostCallback({
|
||||
sealList: [
|
||||
{ sealId: 'mock_host_' + Date.now(), sealName: '模拟托管公章', pictureUrl: '', owner: '模拟公司', sealType: 20 },
|
||||
],
|
||||
})
|
||||
message.info('已模拟托管回调成功')
|
||||
}
|
||||
function startHostPoll(rid) { stopHostPoll(); const max = Math.ceil(hostQrExp.value / 2) + 10; let n = 0; hostPollTimer = setInterval(async () => { n++; if (n > max || !hostQrVisible.value) { stopHostPoll(); return } try { const r = await pollCallback(rid); if (r.code === 200 && r.data) { stopHostPoll(); onHostCallback(r.data) } } catch {} }, 2000) }
|
||||
function stopHostPoll() { if (hostPollTimer) { clearInterval(hostPollTimer); hostPollTimer = null } }
|
||||
function onHostCallback(data) { if (data.sealList?.length) { const newSeals = data.sealList.map(s => ({ ...s, _sealMode: 'manual', _autoPage: 1, _autoX: 100, _autoY: 100 })); hostedSeals.value = [...hostedSeals.value, ...newSeals]; const seen = new Set(); hostedSeals.value = hostedSeals.value.filter(s => { const k = s.sealId; if (seen.has(k)) return false; seen.add(k); return true }); saveSeals(hostedSeals.value); message.success(`已托管 ${data.sealList.length} 个印章`) } if (hostQrVisible.value) { stopHostQrCt(); hostQrVisible.value = false } }
|
||||
function sealTypeLabel(t) { const m = { 0:'个人章',10:'法人章',20:'公章',30:'财务章',40:'合同章',50:'发票章',60:'其他章' }; return m[t] || '未知' }
|
||||
|
||||
// ==================== 印章设置 ====================
|
||||
const settingSealId = ref(''), settingSealMode = ref('manual'), settingAutoPage = ref(1), settingAutoX = ref(100), settingAutoY = ref(100)
|
||||
const autoSealCount = computed(() => hostedSeals.value.filter(s => s._sealMode === 'auto').length)
|
||||
function toggleSealSetting(sealId) { if (settingSealId.value === sealId) { settingSealId.value = ''; return } const seal = hostedSeals.value.find(s => s.sealId === sealId); if (!seal) return; settingSealId.value = sealId; settingSealMode.value = seal._sealMode || 'manual'; settingAutoPage.value = seal._autoPage || 1; settingAutoX.value = seal._autoX || 100; settingAutoY.value = seal._autoY || 100 }
|
||||
function saveSealSetting() { const seal = hostedSeals.value.find(s => s.sealId === settingSealId.value); if (!seal) return; seal._sealMode = settingSealMode.value; seal._autoPage = settingAutoPage.value; seal._autoX = settingAutoX.value; seal._autoY = settingAutoY.value; saveSeals(hostedSeals.value); message.success(`印章 "${seal.sealName}" 已设为${settingSealMode.value === 'auto' ? '自动签章' : '手动拖拽'}`); settingSealId.value = '' }
|
||||
function getSealName(sid) { const s = hostedSeals.value.find(v => v.sealId === sid); return s?.sealName || sid }
|
||||
function autoPlaceSeals() { if (!pdfImages.value.length) return; hostedSeals.value.filter(s => s._sealMode === 'auto').forEach(seal => { const alreadyPlaced = placedSeals.value.some(p => p.sealId === seal.sealId && p._type === 'seal'); if (alreadyPlaced) return; placedSeals.value.push({ id: ++sealPlacedId, _type: 'seal', sealId: seal.sealId, sealName: seal.sealName, pictureUrl: seal.pictureUrl, owner: seal.owner || '', pageNum: seal._autoPage || 1, x: seal._autoX || 100, y: seal._autoY || 100, scale: 1, status: 'pending' }) }) }
|
||||
|
||||
// ==================== 印章放置 ====================
|
||||
const placedSeals = ref([]); let sealPlacedId = 0
|
||||
const pagingSigned = ref(false); const pagingSeal = reactive({ sealId: '', sealName: '', pictureUrl: '' })
|
||||
function handlePagingSealClick() { if (pagingSigned.value) { message.warning('骑缝章已签署'); return } if (!pagingSeal.sealId) message.info('请从托管印章列表拖拽印章到骑缝章区域') }
|
||||
async function onDropPaging(e) { if (!dragSealData || dragSealData._isTimestamp) return; pagingSeal.sealId = dragSealData.sealId; pagingSeal.sealName = dragSealData.sealName; pagingSeal.pictureUrl = dragSealData.pictureUrl; dragSealData = null; message.success(`已设置骑缝章:${pagingSeal.sealName},正在自动签署...`); await signPagingOnly() }
|
||||
async function signPagingOnly() { if (!pagingSeal.sealId) return; confirming.value = true; try { const pagingSealConfigs = [{ sealId: pagingSeal.sealId, scale: 1, startPage: 1, endPage: pdfImages.value.length, coordinateY: Math.round(dh.value / 2) }]; const res = await confirmSign({ contractId: contractId.value, sealConfigs: [], pagingSealConfigs }); if (res.code === 200 && res.data) { if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus; pagingSigned.value = true; message.success('骑缝章签署成功') } else { if (res.code === 500) { hostedSeals.value = deleteSealLocally(pagingSeal.sealId); clearPaging(); message.error(res.msg || '托管授权已失效,相关印章已移除') } else { message.error(res.msg || '骑缝章签署失败') } } } catch { message.error('骑缝章签署失败') } finally { confirming.value = false } }
|
||||
function clearPaging() { Object.assign(pagingSeal, { sealId: '', sealName: '', pictureUrl: '' }) }
|
||||
function getPagePlaced(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type !== 'timestamp') }
|
||||
function getPagePlacedTs(pn) { return placedSeals.value.filter(s => s.pageNum === pn && s._type === 'timestamp') }
|
||||
function getPlacedStyle(s) { return { left: (s.x * sr.value) + 'px', top: (s.y * sr.value) + 'px' } }
|
||||
let dragSealData = null
|
||||
function onDragHosted(e, seal) { dragSealData = seal; e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', seal.sealId || 'seal') }
|
||||
function onDropSeal(e, pageNum) { if (!dragSealData) return; const rect = e.currentTarget.getBoundingClientRect(); const ox = Math.round((e.clientX - rect.left) / sr.value * 100) / 100; const oy = Math.round((e.clientY - rect.top) / sr.value * 100) / 100; const isTs = dragSealData._isTimestamp; placedSeals.value.push({ id: ++sealPlacedId, _type: isTs ? 'timestamp' : 'seal', sealId: isTs ? null : dragSealData.sealId, sealName: isTs ? (dragSealData.sealName || '时间戳') : dragSealData.sealName, pictureUrl: dragSealData.pictureUrl, _pictureUrl: dragSealData.pictureUrl, owner: dragSealData.owner || '', pageNum, x: ox, y: oy, scale: 1, status: 'pending' }); if (!isTs) dragSealData = null }
|
||||
function removePlaced(id) { const s = placedSeals.value.find(v => v.id === id); if (s?.status === 'signed') { message.warning('已签署不可删除'); return } placedSeals.value = placedSeals.value.filter(v => v.id !== id) }
|
||||
let ds2 = null, ds2x = 0, ds2y = 0, d2ox = 0, d2oy = 0
|
||||
function startDragSeal(e, seal) { e.preventDefault(); ds2 = seal; ds2x = e.clientX; ds2y = e.clientY; d2ox = seal.x; d2oy = seal.y; const mv = (ev) => { if (!ds2) return; ds2.x = Math.round((d2ox + (ev.clientX - ds2x) / sr.value) * 100) / 100; ds2.y = Math.round((d2oy + (ev.clientY - ds2y) / sr.value) * 100) / 100 }; const up = () => { ds2 = null; document.removeEventListener('mousemove', mv); document.removeEventListener('mouseup', up) }; document.addEventListener('mousemove', mv); document.addEventListener('mouseup', up) }
|
||||
|
||||
// ==================== 确认签署 ====================
|
||||
const confirming = ref(false), resultVisible = ref(false), signResult = ref(null)
|
||||
const pendingCount = computed(() => placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal').length)
|
||||
async function handleConfirmSign() { const pending = placedSeals.value.filter(s => s.status === 'pending' && s._type === 'seal'); if (!pending.length) { message.warning('请先拖拽印章到文档上'); return } confirming.value = true; try { const sealConfigs = pending.map(s => ({ sealId: s.sealId, x: s.x, y: s.y, scale: s.scale || 1, pageNum: s.pageNum })); const res = await confirmSign({ contractId: contractId.value, sealConfigs, pagingSealConfigs: null }); if (res.code === 200 && res.data) { signResult.value = res.data; if (res.data.contractStatus !== undefined) { contractStatus.value = res.data.contractStatus; if (res.data.contractStatus === 2000) clearContractState(); else saveContractState() } pending.forEach(s => s.status = 'signed'); resultVisible.value = true; message.success('签署成功') } else { if (res.code === 500) { const sealIds = pending.map(s => s.sealId).filter(Boolean); sealIds.forEach(sid => { hostedSeals.value = deleteSealLocally(sid) }); message.error(res.msg || '托管授权已失效,相关印章已移除,请重新新增托管') } else { message.error(res.msg || '签署失败') } } } catch { message.error('签署失败') } finally { confirming.value = false } }
|
||||
|
||||
// ==================== 时间戳 ====================
|
||||
const tsFormat = ref('yyyy-MM-dd'), tsLoading = ref(false), tsImageUrl = ref(''), tsData = ref(null)
|
||||
async function fetchTs() { tsLoading.value = true; try { const res = await getTimestamp(tsFormat.value); if (res.code === 200 && res.data?.pictureUrl) { tsImageUrl.value = res.data.pictureUrl; tsData.value = { pictureUrl: res.data.pictureUrl }; message.success('已获取,请拖拽到文档上') } else message.error(res.msg || '获取失败') } catch { message.error('获取失败') } finally { tsLoading.value = false } }
|
||||
function onDragTsStart(e) { dragSealData = { _isTimestamp: true, pictureUrl: tsData.value?.pictureUrl, _pictureUrl: tsData.value?.pictureUrl, sealName: '时间戳', owner: '' }; e.dataTransfer.effectAllowed = 'copy'; e.dataTransfer.setData('text/plain', 'timestamp') }
|
||||
async function confirmTs(ts) { if (ts.status !== 'pending') return; ts.status = 'signing'; try { const res = await signTimestamp({ contractId: contractId.value, sealConfigs: [{ formatPattern: tsFormat.value, x: ts.x, y: ts.y, pageNum: ts.pageNum, scale: 1 }] }); if (res.code === 200 && res.data) { ts.status = 'signed'; ts._signTime = res.data.signatureTime || ''; if (res.data.pdfToImageList?.length) pdfImages.value = res.data.pdfToImageList; if (res.data.contractStatus !== undefined) contractStatus.value = res.data.contractStatus; message.success('时间戳签署成功') } else { message.error(res.msg || '失败'); ts.status = 'pending' } } catch { message.error('失败'); ts.status = 'pending' } }
|
||||
|
||||
// ==================== 完成 & 下载 ====================
|
||||
async function handleComplete() { if (contractStatus.value === 2000) { message.warning('合约已结束'); return } Modal.confirm({ title: '确认结束', content: '确认结束合约?', okType: 'danger', onOk: async () => { completing.value = true; try { const res = await completeContract(contractId.value); if (res.code === 200) { contractStatus.value = 2000; clearContractState(); message.success('合约已完成') } else message.error(res.msg || '结束失败') } catch { message.error('结束失败') } finally { completing.value = false } } }) }
|
||||
async function handleDownload() { if (contractStatus.value !== 2000) { message.warning('请先结束合约'); return } try { const res = await downloadContract(contractId.value); if (res.code === 200 && res.data?.downloadUrl) window.open(res.data.downloadUrl, '_blank'); else message.error('获取链接失败') } catch { message.error('下载失败') } }
|
||||
onUnmounted(() => { stopHostQrCt(); stopHostPoll() })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.sign-platform { width:100%;height:100%;background:#f5f7fa;font-family:"Inter",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;color:#2c3e50;overflow:hidden;display:flex;flex-direction:column }
|
||||
.platform-layout { flex:1;overflow:hidden }
|
||||
.platform-layout-empty { display:grid;grid-template-columns:minmax(0,1fr) 380px }
|
||||
.platform-layout-full { display:grid;grid-template-columns:260px minmax(0,1fr) 380px }
|
||||
.preview-area { background:linear-gradient(to bottom,#f8fafc,#eef2f7);overflow-y:auto;padding:30px 50px;display:flex;flex-direction:column;align-items:center;min-width:0;gap:28px }
|
||||
.empty-preview { justify-content:center;align-items:center;background:#f0f2f5;min-width:0 }
|
||||
.preview-placeholder { width:420px;padding:60px 40px;border-radius:20px;background:rgba(255,255,255,.7);backdrop-filter:blur(10px);box-shadow:0 10px 40px rgba(15,23,42,.06);border:1px solid rgba(255,255,255,.8);display:flex;flex-direction:column;align-items:center }
|
||||
.preview-placeholder p { margin-top:14px;font-size:15px;color:#64748b }
|
||||
.pdf-page-wrapper { position:relative;border-radius:12px;overflow:visible;background:white;box-shadow:0 12px 40px rgba(15,23,42,.08),0 2px 8px rgba(15,23,42,.04);transition:all .2s ease }
|
||||
.pdf-page-wrapper:hover { transform:translateY(-2px) }
|
||||
.pdf-page-image { display:block;user-select:none;pointer-events:none }
|
||||
.placed-seal { position:absolute;z-index:10;cursor:move;user-select:none }
|
||||
.placed-seal .placed-img { width:80px;height:auto;opacity:.85;display:block;pointer-events:none }
|
||||
.placed-seal .placed-fallback { width:80px;height:40px;border:2px dashed #409eff;background:rgba(64,158,255,.1);display:flex;align-items:center;justify-content:center;font-size:12px;color:#409eff }
|
||||
.placed-del { position:absolute;top:-8px;right:-8px;background:#f56c6c;color:#fff;border-radius:50%;cursor:pointer;font-size:14px;padding:2px }
|
||||
.placed-del:hover { background:#e03838 }
|
||||
.placed-seal.seal-signed { cursor:default } .placed-seal.seal-signed .placed-img { opacity:1 } .placed-seal.seal-signed .placed-del { display:none }
|
||||
.placed-seal.placed-ts .placed-img { width:130px }
|
||||
.placed-seal.placed-ts .ts-actions { display:flex;align-items:center;gap:4px;margin-top:4px;background:#fff;padding:2px 6px;border-radius:4px;box-shadow:0 1px 4px rgba(0,0,0,.1) }
|
||||
.paging-seal-wrapper { position:absolute;top:50%;right:-18px;transform:translateY(-50%);width:52px;height:fit-content;display:flex;align-items:center;justify-content:center;z-index:50;cursor:pointer;transition:all .25s ease }
|
||||
.paging-seal-wrapper:hover { transform:translateY(-50%) scale(1.03) }
|
||||
.paging-empty-box { width:100%;border:2px dashed #cbd5e1;border-radius:12px;background:rgba(248,250,252,.95);display:flex;align-items:center;justify-content:center;color:#94a3b8;transition:all .2s ease;padding:10px }
|
||||
.paging-empty-text { writing-mode:vertical-rl;text-orientation:upright;font-size:16px;letter-spacing:4px;font-weight:600;line-height:1.4 }
|
||||
.paging-empty-box:hover { border-color:#3b82f6;color:#3b82f6;background:#eff6ff }
|
||||
.paging-seal-real { width:100%;border:2px solid rgba(210,50,50,.65);background:radial-gradient(circle at center,rgba(255,255,255,.04),rgba(255,0,0,.03));border-radius:12px;display:flex;flex-direction:column;align-items:center;justify-content:center;opacity:.9;padding:8px 4px;box-shadow:0 0 12px rgba(210,50,50,.2),inset 0 0 8px rgba(210,50,50,.1) }
|
||||
.paging-seal-img { width:32px;height:32px;object-fit:contain;margin-bottom:4px }
|
||||
.paging-seal-real-text { writing-mode:vertical-rl;text-orientation:upright;font-size:14px;letter-spacing:2px;color:rgba(210,50,50,.72);font-family:"STFangsong","FangSong","SimSun",serif;user-select:none }
|
||||
.left-info-sidebar { background:#fff;border-right:1px solid #ebeef5;padding:20px;overflow-y:auto;display:flex;flex-direction:column;gap:20px }
|
||||
.info-card { background:#fff;border-radius:16px;padding:20px;box-shadow:0 4px 20px rgba(15,23,42,.05);border:1px solid #eef2f6 }
|
||||
.info-title { font-size:16px;font-weight:700;color:#1e293b;margin-bottom:18px }
|
||||
.info-item { display:flex;justify-content:space-between;align-items:center;padding:12px 0;border-bottom:1px dashed #eef2f6;font-size:14px }
|
||||
.info-item:last-child { border-bottom:none } .info-item span { color:#64748b } .info-item strong { color:#0f172a }
|
||||
.operation-sidebar { width:380px;background:#fff;border-left:1px solid #e2e6ed;display:flex;flex-direction:column;padding:20px;gap:16px;overflow-y:auto;box-shadow:-2px 0 12px rgba(0,0,0,.04) }
|
||||
.step-card { background:#fff;border:1px solid #eef1f5;border-radius:12px;overflow:hidden;box-shadow:0 1px 4px rgba(0,0,0,.04);flex-shrink:0 }
|
||||
.step-card-disabled { opacity:.6 }
|
||||
.step-card-header { padding:14px 16px;background:#f8f9fb;border-bottom:1px solid #f0f2f5;display:flex;align-items:center;gap:10px;flex-wrap:wrap }
|
||||
.step-badge { display:inline-flex;align-items:center;justify-content:center;width:26px;height:26px;background:#3b6fa0;color:#fff;border-radius:6px;font-size:14px;font-weight:600;flex-shrink:0 }
|
||||
.step-title { font-size:15px;font-weight:600;color:#2c3e50 }
|
||||
.step-required { font-size:12px;color:#c88a2e;margin-left:4px }
|
||||
.step-card-body { padding:16px } .step-hint { color:#8b95a5;font-size:13px;margin:0 }
|
||||
.seal-section { margin-bottom:14px } .section-subtitle { font-size:13px;font-weight:600;color:#2c3e50;margin-bottom:10px;padding-bottom:6px;border-bottom:1px solid #f0f2f5 }
|
||||
.form-item { margin-bottom:10px } .form-item label { display:block;margin-bottom:4px;font-size:13px;color:#606266 } .form-item .required { color:#f56c6c }
|
||||
.hosted-empty { text-align:center } .hosted-empty-text { color:#909399;font-size:13px;margin:0 0 10px }
|
||||
.hosted-item { display:flex;align-items:center;gap:8px;padding:8px;border:1px solid #e4e7ed;border-radius:6px;margin-bottom:6px;cursor:grab }
|
||||
.hosted-item:active { cursor:grabbing } .hosted-item:hover { border-color:#409eff }
|
||||
.hosted-img { width:36px;height:36px;object-fit:contain;flex-shrink:0 }
|
||||
.hosted-info { flex:1;min-width:0 } .hosted-name { font-size:12px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:block } .hosted-type { font-size:10px;color:#909399 }
|
||||
.hosted-actions { display:flex;align-items:center;gap:4px;flex-shrink:0 }
|
||||
.hosted-mode-tag { font-size:10px;padding:2px 6px;border-radius:4px;cursor:pointer;font-weight:500 }
|
||||
.tag-manual { background:#f0f2f5;color:#606266 } .tag-auto { background:#e6f7ff;color:#1890ff }
|
||||
.hosted-setting { cursor:pointer;color:#909399;font-size:14px } .hosted-setting:hover { color:#409eff }
|
||||
.seal-setting-panel { padding:10px;background:#f8f9fb;border:1px solid #e4e7ed;border-radius:8px;margin-bottom:8px }
|
||||
.setting-header { display:flex;justify-content:space-between;align-items:center;margin-bottom:8px;font-size:13px;font-weight:600;color:#2c3e50 }
|
||||
.coord-row { display:flex;align-items:center;gap:4px;flex-wrap:wrap;font-size:12px;color:#606266;margin-bottom:4px }
|
||||
.del-icon { cursor:pointer;color:#f56c6c;font-size:14px } .del-icon:hover { color:#e03838 }
|
||||
.paging-drop-zone { border:2px dashed #e6a23c;border-radius:6px;padding:16px;text-align:center;color:#e6a23c;font-size:13px;min-height:50px;display:flex;align-items:center;justify-content:center;gap:8px;background:rgba(230,162,60,.04);cursor:pointer;transition:all .2s }
|
||||
.paging-drop-zone:hover { border-color:#e6a23c;background:rgba(230,162,60,.1) }
|
||||
.paging-seal-thumb { width:40px;height:40px;object-fit:contain }
|
||||
.paging-auto-hint { font-size:11px;color:#e6a23c;margin-top:6px;text-align:center }
|
||||
.sign-hint { font-size:12px;color:#909399;text-align:center;margin:6px 0 0 }
|
||||
.upload-dragger { border:2px dashed #dcdfe6;border-radius:10px;padding:24px 16px;text-align:center;cursor:pointer;transition:all .25s;background:#fafbfc }
|
||||
.upload-dragger:hover,.is-dragover { border-color:#3b6fa0;background:#eaf2f9 }
|
||||
.upload-content .anticon { font-size:36px;color:#b0b8c5;margin-bottom:8px }
|
||||
.upload-text,.file-name { font-size:14px;color:#2c3e50;margin:0 0 6px } .file-name { font-weight:500 }
|
||||
.upload-hint,.file-size { font-size:12px;color:#8b95a5;margin:0 0 8px } .contract-name-row { margin-top:14px }
|
||||
.btn { display:inline-flex;align-items:center;justify-content:center;gap:6px;padding:0 20px;height:38px;border-radius:8px;font-size:14px;font-weight:500;cursor:pointer;transition:all .2s;user-select:none;border:none;background:#f0f2f5;color:#2c3e50 }
|
||||
.btn-primary { background:linear-gradient(135deg,#3b82f6,#2563eb);color:white;border:none;box-shadow:0 8px 20px rgba(37,99,235,.18);margin-top:10px }
|
||||
.btn-primary:hover { transform:translateY(-1px);box-shadow:0 10px 24px rgba(37,99,235,.28) }
|
||||
.btn-success { background:linear-gradient(135deg,#10b981,#059669);color:white;border:none;box-shadow:0 8px 20px rgba(16,185,129,.18) }
|
||||
.btn-success:hover { transform:translateY(-1px);box-shadow:0 10px 24px rgba(16,185,129,.28) }
|
||||
.btn-danger-text { background:transparent;color:#c0392b;padding:0;height:auto;font-size:13px }
|
||||
.btn-disabled { opacity:.5;cursor:not-allowed;pointer-events:none }
|
||||
.btn-sm { height:30px;padding:0 12px;font-size:12px } .btn-loading { pointer-events:none;opacity:.8 }
|
||||
.contract-status-tag { margin-top:12px;font-size:13px } .tag-success { color:#4a9c5d;font-weight:500 } .tag-warning { color:#c88a2e;font-weight:500 } .tag-info { color:#8b95a5;font-weight:500 }
|
||||
.qr-dialog-body { text-align:center } .qr-img-box { display:flex;justify-content:center;align-items:center;min-height:200px }
|
||||
.qr-loading { flex-direction:column;gap:12px;color:#8b95a5 }
|
||||
.qr-img { width:200px;height:200px;border:1px solid #eee;border-radius:10px;padding:8px } .qr-countdown { margin-top:14px } .countdown-text { margin-top:6px;font-size:13px;color:#5a6b7c }
|
||||
.ts-drag-area { margin-top:10px;padding:10px;background:#f5f7fa;border-radius:6px;border:1px dashed #dcdfe6 }
|
||||
.ts-drag-item { width:130px;cursor:grab;text-align:center;padding:6px;border:1px solid #e4e7ed;border-radius:6px;background:#fff }
|
||||
.ts-drag-item:hover { border-color:#67c23a } .ts-drag-item:active { cursor:grabbing }
|
||||
.ts-drag-item .drag-seal-img { width:120px;height:auto;display:block;margin:0 auto 4px } .ts-drag-item span { font-size:11px;color:#909399 }
|
||||
.result-body { text-align:center } .sig-list { margin-top:12px }
|
||||
.sig-item { display:flex;justify-content:space-between;padding:8px;background:#f5f7fa;border-radius:4px;margin-bottom:4px;font-size:13px }
|
||||
@media (max-width:900px) { .operation-sidebar { width:320px } }
|
||||
</style>
|
||||
@@ -0,0 +1,220 @@
|
||||
文件信息
|
||||
获取合约签署信息
|
||||
请求方式 POST
|
||||
|
||||
请求接口 / saas/api/contract/getDetail
|
||||
|
||||
请求参数
|
||||
名称 位置 类型 必选 中文名 说明
|
||||
access_token header string 是 访问令牌 详见身份鉴权API
|
||||
Content-Type header string 是 内容类型 application/json
|
||||
body body object 是 Body 请求体
|
||||
» contractId body string 是 签署流程ID
|
||||
参数示例
|
||||
|
||||
{
|
||||
"contractId": "CCFSENSDKGIUOLK"
|
||||
}
|
||||
返回参数
|
||||
返回参数
|
||||
|
||||
名称 类型 必选 中文名 说明
|
||||
» isSuccess boolean true 成功标志 true 成功 false失败
|
||||
» msg string true 消息
|
||||
» code integer true 状态码
|
||||
» data object true 数据体
|
||||
»» contractId string true 签署流程ID
|
||||
»» contractName string true 合约名称
|
||||
»» status string true 合约状态 -1:已终⽌
|
||||
0:已取消
|
||||
10:等待签署
|
||||
20:签署中
|
||||
2000:已完成
|
||||
»» statusName string true 合约状态名称 已完成
|
||||
»» startTime string true 合约发起时间
|
||||
»» fileSize integer true 合约⼤⼩
|
||||
»» signatureList [object] true 签署列表
|
||||
»»» signatureId string true
|
||||
»»» signer string true
|
||||
»»»signatureTime string true
|
||||
»»» entName string true
|
||||
参数示例
|
||||
|
||||
{
|
||||
"isSuccess": true,
|
||||
"msg": "成功",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"contractId": "123",
|
||||
"contractName": "某某合同",
|
||||
"status": 20,
|
||||
"statusName": "签署中",
|
||||
"startTime": "2021-10-10 10:10:10",
|
||||
"fileSize": 23,
|
||||
"signatureList": [
|
||||
{
|
||||
"signatureId": "45642164816",
|
||||
"signer": "张三",
|
||||
"signatureTime": "2021-10-10 10:10:11",
|
||||
"entName": "ZJCA"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
状态码
|
||||
|
||||
状态码 状态码含义 说明
|
||||
200 OK 成功
|
||||
400 HTTP 消息不可读 系统内部错误
|
||||
500 请求方式有误,请检查 GET/POST
|
||||
401 Token失效
|
||||
查看合约文件
|
||||
请求方式 POST
|
||||
|
||||
请求接口 /saas/api/contract/preview
|
||||
|
||||
请求参数
|
||||
名称 位置 类型 必选 中文名 说明
|
||||
access_token header string 是 访问令牌 详见身份鉴权API
|
||||
Content-Type header string 是 内容类型 application/json
|
||||
body body object 是 Body 请求体
|
||||
» contractId body string 是 签署流程ID
|
||||
参数示例
|
||||
|
||||
{
|
||||
"contractId": "CCFSENSDKGIUOLK"
|
||||
}
|
||||
返回参数
|
||||
返回参数
|
||||
|
||||
名称 类型 必选 中文名 说明
|
||||
» isSuccess boolean true 成功标志 true 成功 false失败
|
||||
» msg string true 消息
|
||||
» code integer true 状态码
|
||||
» data object true 数据体
|
||||
»» contractId string true 签署流程ID
|
||||
»» imageHeight string true pdf转成图⽚的宽
|
||||
»» imageWidth string true pdf转成图⽚的⾼
|
||||
»» pdfUrl string true pdf预览地址
|
||||
»» pdfToImageList [string] true pdf转成图⽚的预览地址
|
||||
参数示例
|
||||
|
||||
{
|
||||
"isSuccess": true,
|
||||
"msg": "成功",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"contractId": "5cf5ab68024063bb2ccf4f055549ef55",
|
||||
"imageHeight": 1122,
|
||||
"imageWidth": 793,
|
||||
"pdfUrl": "https://testeseal.cert8.pdf",
|
||||
"pdfToImageList": ["https://t/abc",
|
||||
"https://t/dsd"]
|
||||
}
|
||||
}
|
||||
状态码
|
||||
|
||||
状态码 状态码含义 说明
|
||||
200 OK 成功
|
||||
400 HTTP 消息不可读 系统内部错误
|
||||
500 请求方式有误,请检查 GET/POST
|
||||
401 Token失效
|
||||
下载合约文件
|
||||
请求方式 POST
|
||||
|
||||
请求接口 /saas/api/contract/download
|
||||
|
||||
请求参数
|
||||
名称 位置 类型 必选 中文名 说明
|
||||
access_token header string 是 访问令牌 详见身份鉴权API
|
||||
Content-Type header string 是 内容类型 application/json
|
||||
body body object 是 Body 请求体
|
||||
» contractId body string 是 签署流程ID
|
||||
参数示例
|
||||
|
||||
{
|
||||
"contractId": "CCFSENSDKGIUOLK"
|
||||
}
|
||||
返回参数
|
||||
返回参数
|
||||
|
||||
名称 类型 必选 中文名 说明
|
||||
» isSuccess boolean true 成功标志 true 成功 false失败
|
||||
» msg string true 消息
|
||||
» code integer true 状态码
|
||||
» data object true 数据体
|
||||
»» downloadUrl string true 下载链接
|
||||
参数示例
|
||||
|
||||
{
|
||||
"isSuccess": true,
|
||||
"msg": "成功",
|
||||
"code": 200,
|
||||
"data": {
|
||||
"downloadUrl": "https://tdas08.pdf"
|
||||
}
|
||||
}
|
||||
状态码
|
||||
|
||||
状态码 状态码含义 说明
|
||||
200 OK 成功
|
||||
400 HTTP 消息不可读 系统内部错误
|
||||
500 请求方式有误,请检查 GET/POST
|
||||
401 Token失效
|
||||
隐私信息
|
||||
获取用户隐私数据
|
||||
请求方式 POST
|
||||
|
||||
请求接口 /api/user/getUserInfos
|
||||
|
||||
请求参数
|
||||
名称 位置 类型 必选 中文名 说明
|
||||
access_token header string 是 访问令牌 详见身份鉴权API
|
||||
Content-Type header string 是 内容类型 application/json
|
||||
body body object 是 Body 请求体
|
||||
» requestId body string 是 二维码请求ID
|
||||
参数示例
|
||||
|
||||
{
|
||||
"requestId": "8613F36DAF1B0B98"
|
||||
}
|
||||
返回参数
|
||||
返回参数
|
||||
|
||||
名称 类型 必选 中文名 说明
|
||||
» isSuccess boolean true 成功标志 true 成功 false失败
|
||||
» msg string true 消息
|
||||
» code integer true 状态码
|
||||
» data object true 数据体
|
||||
»» name string true 用户姓名
|
||||
»» mobile string true 手机号
|
||||
»» code string true 身份证号
|
||||
参数示例
|
||||
|
||||
{
|
||||
"isSuccess": true,
|
||||
"msg": "成功",
|
||||
"code": 200,
|
||||
"data":
|
||||
{
|
||||
"name":"张三",
|
||||
"mobile":"13888888888",
|
||||
"code":"330111111111111111"
|
||||
}
|
||||
}
|
||||
状态码
|
||||
|
||||
状态码 状态码含义 说明
|
||||
200 OK 成功
|
||||
400 HTTP 消息不可读 系统内部错误
|
||||
500 请求方式有误,请检查 GET/POST
|
||||
401 Token失效
|
||||
|
||||
|
||||
|
||||
目前输入合约id打开合约文件没有显示出来,可能需要 调用接口获取文件和合约信息,相关接口在上方,然后打开的时候同样支持三种模式,这个你看看Home.vue怎么设计好
|
||||
src\views\seal\preSignModal.vue
|
||||
src\views\seal\standardSignModal.vue
|
||||
src\views\seal\trusteeshipSignModal.vue
|
||||
src\views\Home.vue
|
||||
修改这几个文件,同时要注意初始化的问题
|
||||
@@ -0,0 +1,40 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
import vue from '@vitejs/plugin-vue'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [vue()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': fileURLToPath(new URL('./src', import.meta.url)),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 1088,
|
||||
host: true,
|
||||
open: true,
|
||||
proxy: {
|
||||
'/manifest': {
|
||||
target: 'http://127.0.0.1:8899',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/partner': {
|
||||
target: 'http://127.0.0.1:8899',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/trans': {
|
||||
target: 'http://127.0.0.1:8899',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/seal': {
|
||||
target: 'http://127.0.0.1:8899',
|
||||
changeOrigin: true,
|
||||
},
|
||||
'/seal/ws': {
|
||||
target: 'ws://127.0.0.1:8899',
|
||||
ws: true,
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user