首次提交

This commit is contained in:
2026-05-26 10:10:32 +08:00
parent 02e1486d39
commit a6f7ad12da
45 changed files with 27435 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"permissions": {
"allow": [
"Bash(npm install *)"
]
}
}
+25
View File
@@ -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
View File
@@ -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>
+31
View File
@@ -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"
}
}
+1569
View File
File diff suppressed because it is too large Load Diff
Binary file not shown.
+213
View File
@@ -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}`)
+3
View File
@@ -0,0 +1,3 @@
<template>
<router-view />
</template>
+73
View File
@@ -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",
});
}
+45
View File
@@ -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
View File
@@ -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',
})
}
+194
View File
@@ -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
View File
@@ -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')
+67
View File
@@ -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
+17
View File
@@ -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 }
})
+12
View File
@@ -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);
}
+72
View File
@@ -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
+311
View File
@@ -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
+496
View File
@@ -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">支持 xlsxlsxpdfdocxdoc 文件且小于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
+577
View File
@@ -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">支持 xlsxlsxpdfdocxdoc 文件且小于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>
+769
View File
@@ -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">支持 xlsxlsxpdfdocxdoc 文件且小于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>
+449
View File
@@ -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">支持 xlsxlsxpdfdocxdoc 文件且小于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>
+309
View File
@@ -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>
+577
View File
@@ -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
+422
View File
@@ -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>
+579
View File
@@ -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>
+42
View File
@@ -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
+554
View File
@@ -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">支持 xlsxlsxpdfdocxdoc 文件且小于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
+769
View File
@@ -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">支持 xlsxlsxpdfdocxdoc 文件且小于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>
+463
View File
@@ -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">支持 xlsxlsxpdfdocxdoc 文件且小于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>
+220
View File
@@ -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
修改这几个文件,同时要注意初始化的问题
+40
View File
@@ -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,
},
},
},
})