无限极服务
146.43MB · 2025-10-24
本文将介绍一个基于Vue2的PDF电子签章位置选择组件,模仿e签宝的业务逻辑,实现PDF文件加载、印章拖拽放置、位置坐标计算等功能。
<template>
<div>
<Vue2EsignStampPicker
:stamps="customStamps"
:initialPdfUrl="pdfUrl"
@positions-confirmed="handlePositionsConfirmed"
/>
</div>
</template>
<script>
export default {
data() {
return {
pdfUrl: 'https://example.com/document.pdf',
customStamps: [
{
id: 'company-seal',
name: '公司公章',
image: '/images/company-seal.png'
},
{
id: 'personal-seal',
name: '个人印章',
image: '/images/personal-seal.png'
}
]
};
},
methods: {
handlePositionsConfirmed(positions) {
console.log('确认的签署位置:', positions);
// 将位置数据发送到后端进行签章处理
}
}
};
</script>
| 属性 | 类型 | 默认值 | 说明 |
|---|---|---|---|
| stamps | Array | [] | 自定义印章数组 |
| defaultStampSize | Number | 80 | 印章显示尺寸 |
| initialPdfUrl | String | '' | 初始PDF文件URL |
positions-confirmed: 当用户确认签署位置时触发,返回位置数据数组组件使用PDF.js库进行PDF文件的渲染和解析:
javascript
async renderPage(page, index) {
const canvas = this.$refs[`pdfCanvas-${index}`]?.[0];
const ctx = canvas.getContext('2d');
const viewport = page.getViewport({ scale: this.scale });
canvas.width = viewport.width;
canvas.height = viewport.height;
const renderContext = {
canvasContext: ctx,
viewport: viewport
};
await page.render(renderContext).promise;
}
基于e签宝坐标规范,实现Canvas坐标到PDF坐标的转换:
calculateStampPosition(x, y, rect) {
// 边界约束
const padding = this.stampSize / 2;
const constrainedX = Math.max(padding, Math.min(x, rect.width - padding));
const constrainedY = Math.max(padding, Math.min(y, rect.height - padding));
// 转换为PDF坐标系统(原点在左下角)
const pdfX = constrainedX;
const pdfY = rect.height - constrainedY; // Y坐标翻转
// 计算视图坐标(用于显示)
const viewportX = constrainedX - this.stampSize / 2;
const viewportY = constrainedY - this.stampSize / 2;
return { viewportX, viewportY, pdfX, pdfY };
}
实现印章的拖拽放置和移动功能:
onCanvasDrop(event, pageNumber) {
event.preventDefault();
if (!this.checkDropBoundary(event, pageNumber)) return;
const canvas = event.currentTarget;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (this.isMovingExistingStamp && this.movingStampId) {
// 移动现有印章
this.moveExistingStamp(this.movingStampId, pageNumber, x, y, rect);
} else {
// 添加新印章
this.addNewStamp(pageNumber, x, y, rect);
}
}
组件输出的位置数据遵循e签宝API规范:
const signPositions = this.stampPositions.map(pos => ({
posPage: pos.posPage, // 页码
posX: pos.posX, // X坐标
posY: pos.posY, // Y坐标
sealId: pos.stampId, // 印章ID
signType: 'Single' // 签章类型
}));
组件采用响应式设计,支持桌面和移动端:
这个Vue2电子签章位置选择组件提供了完整的PDF签章位置选择解决方案,具有以下优势:
<template>
<div class="esign-stamp-container">
<!-- 文件上传区域 -->
<div class="upload-section">
<div class="upload-controls">
<input
type="file"
accept=".pdf"
@change="handleFileUpload"
ref="fileInput"
class="file-input"
/>
<button class="upload-btn" @click="$refs.fileInput.click()">
<span class="upload-icon"></span>
上传PDF文件
</button>
<div class="upload-tip">只能上传PDF文件,且不超过10MB</div>
</div>
<div class="url-upload-section">
<div class="url-input-group">
<input
type="text"
v-model="pdfUrlInput"
placeholder="输入PDF文件URL地址"
class="url-input"
/>
<button class="url-upload-btn" @click="loadPDFFromUrl">
加载URL文件
</button>
</div>
</div>
</div>
<!-- PDF预览和签章区域 -->
<div v-if="pdfUrl && pages.length > 0" class="preview-container">
<div class="toolbar">
<h3>拖拽印章到指定位置</h3>
<div class="controls">
<button @click="zoomOut" :disabled="scale <= 0.6" class="control-btn">缩小</button>
<span class="scale-info">缩放: {{ (scale * 100).toFixed(0) }}%</span>
<button @click="zoomIn" class="control-btn">放大</button>
<button @click="resetAll" class="control-btn">重置所有印章</button>
</div>
</div>
<!-- 印章选择 -->
<div class="stamp-selection">
<div
v-for="stamp in availableStamps"
:key="stamp.id"
class="stamp-item"
:class="{ active: selectedStamp?.id === stamp.id }"
@click="selectStamp(stamp)"
draggable="true"
@dragstart="onStampDragStart(stamp)"
>
<img :src="stamp.image" :alt="stamp.name" class="stamp-preview" />
<span class="stamp-name">{{ stamp.name }}</span>
</div>
</div>
<!-- PDF页面预览 -->
<div class="pdf-viewer">
<div
v-for="(page, index) in pages"
:key="index"
class="page-wrapper"
:data-page-number="page.pageNumber"
>
<div class="page-header">第 {{ page.pageNumber }} 页</div>
<canvas
:ref="`pdfCanvas-${index}`"
class="pdf-canvas"
@dragover.prevent
@drop="onCanvasDrop($event, page.pageNumber)"
></canvas>
<!-- 已放置的印章 -->
<div
v-for="position in getStampPositionsForPage(page.pageNumber)"
:key="position.id"
class="placed-stamp"
:style="{
left: `${position.viewportX}px`,
top: `${position.viewportY}px`,
width: `${stampSize}px`,
height: `${stampSize}px`
}"
draggable="true"
@dragstart="onPlacedStampDragStart(position, $event)"
@dragend="onPlacedStampDragEnd"
@click="selectPlacedStamp(position)"
>
<img :src="position.stampImage" class="stamp-image" />
<div class="stamp-overlay">
<span class="delete-icon" @click.stop="removeStamp(position.id)">×</span>
</div>
</div>
</div>
</div>
<!-- 坐标信息显示 -->
<div v-if="selectedPosition" class="position-card">
<div class="position-header">
<span>位置信息</span>
<button class="delete-btn" @click="removeStamp(selectedPosition.id)">
删除
</button>
</div>
<div class="position-info">
<p><strong>页码:</strong> {{ selectedPosition.posPage }}</p>
<p><strong>X坐标:</strong> {{ selectedPosition.posX.toFixed(2) }}</p>
<p><strong>Y坐标:</strong> {{ selectedPosition.posY.toFixed(2) }}</p>
<p><strong>印章:</strong> {{ selectedPosition.stampName }}</p>
</div>
</div>
<!-- 操作按钮 -->
<div class="action-buttons">
<button
class="primary-btn"
@click="confirmPositions"
:disabled="stampPositions.length === 0"
>
确认签署位置 ({{ stampPositions.length }})
</button>
<button
class="secondary-btn"
@click="downloadPositions"
:disabled="stampPositions.length === 0"
>
下载位置数据
</button>
</div>
</div>
<!-- 加载状态 -->
<div v-if="loading" class="loading-container">
<div class="loading-alert">
<span class="loading-icon">⏳</span>
PDF加载中...
</div>
</div>
<!-- 消息提示 -->
<div v-if="message.show" class="message-toast" :class="message.type">
{{ message.text }}
</div>
</div>
</template>
<script>
export default {
name: 'Vue2EsignStampPicker',
props: {
stamps: {
type: Array,
default: () => []
},
defaultStampSize: {
type: Number,
default: 80
},
// 新增:支持直接传入PDF URL
initialPdfUrl: {
type: String,
default: ''
}
},
data() {
return {
pdfDoc: null,
pages: [],
scale: 1.2,
pdfUrl: null,
loading: false,
selectedStamp: null,
stampPositions: [],
selectedPosition: null,
stampSize: this.defaultStampSize,
currentDraggingStamp: null,
isMovingExistingStamp: false,
movingStampId: null,
pdfUrlInput: '', // URL输入框的值
message: {
show: false,
text: '',
type: 'info' // info, success, error, warning
}
};
},
computed: {
availableStamps() {
const defaultStamps = [
{
id: 'default-seal',
name: '默认印章',
image: this.generateDefaultStamp()
}
];
return [...defaultStamps, ...this.stamps];
}
},
watch: {
// 监听初始PDF URL的变化
initialPdfUrl: {
immediate: true,
handler(newUrl) {
if (newUrl) {
this.pdfUrlInput = newUrl;
this.loadPDFFromUrl();
}
}
}
},
mounted() {
// 配置PDF.js
this.configurePDFjs();
if (this.availableStamps.length > 0) {
this.selectedStamp = this.availableStamps[0];
}
},
methods: {
configurePDFjs() {
// 确保PDF.js库已加载
if (typeof window !== 'undefined' && window['pdfjs-dist/build/pdf']) {
const pdfjsLib = window['pdfjs-dist/build/pdf'];
// 设置worker路径
pdfjsLib.GlobalWorkerOptions.workerSrc =
'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.4.120/pdf.worker.min.js';
}
},
// 显示消息提示
showMessage(text, type = 'info') {
this.message = {
show: true,
text,
type
};
// 3秒后自动隐藏
setTimeout(() => {
this.message.show = false;
}, 3000);
},
// 文件上传处理
handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
const isPDF = file.type === 'application/pdf';
const isLt10M = file.size / 1024 / 1024 < 10;
if (!isPDF) {
this.showMessage('只能上传PDF文件', 'error');
return;
}
if (!isLt10M) {
this.showMessage('PDF文件大小不能超过10MB', 'error');
return;
}
this.loadPDFFile(file);
},
// 从URL加载PDF
async loadPDFFromUrl() {
if (!this.pdfUrlInput) {
this.showMessage('请输入PDF文件URL', 'error');
return;
}
this.loading = true;
this.showMessage('正在加载PDF文件...', 'info');
try {
// 使用fetch获取PDF文件
const response = await fetch(this.pdfUrlInput);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const pdfBlob = await response.blob();
// 验证文件类型
if (pdfBlob.type !== 'application/pdf') {
throw new Error('URL指向的不是PDF文件');
}
// 使用Blob创建本地URL
const localPdfUrl = URL.createObjectURL(pdfBlob);
await this.loadPDFFromBlob(localPdfUrl);
this.showMessage('PDF加载成功', 'success');
} catch (error) {
console.error('从URL加载PDF失败:', error);
this.showMessage(`加载失败: ${error.message}`, 'error');
} finally {
this.loading = false;
}
},
async loadPDFFile(file) {
this.loading = true;
this.showMessage('正在加载PDF文件...', 'info');
this.resetAll(); // 清除之前的印章位置
// 释放之前的URL对象,防止内存泄漏
if (this.pdfUrl) {
URL.revokeObjectURL(this.pdfUrl);
}
const localPdfUrl = URL.createObjectURL(file);
await this.loadPDFFromBlob(localPdfUrl);
},
// 通用的PDF加载方法
async loadPDFFromBlob(localPdfUrl) {
try {
const pdfjsLib = window['pdfjs-dist/build/pdf'];
const loadingTask = pdfjsLib.getDocument(localPdfUrl);
this.pdfDoc = await loadingTask.promise;
this.pdfUrl = localPdfUrl;
await this.renderAllPages();
} catch (error) {
console.error('PDF加载失败:', error);
this.showMessage('PDF加载失败,请重试', 'error');
throw error;
}
},
// PDF渲染
async renderAllPages() {
this.pages = [];
for (let pageNum = 1; pageNum <= this.pdfDoc.numPages; pageNum++) {
const page = await this.pdfDoc.getPage(pageNum);
this.pages.push({
pageNumber: pageNum,
pdfPage: page,
viewport: null
});
}
this.$nextTick(() => {
this.pages.forEach(async (page, index) => {
await this.renderPage(page.pdfPage, index);
});
});
},
async renderPage(page, index) {
const canvas = this.$refs[`pdfCanvas-${index}`]?.[0];
if (!canvas) return;
const ctx = canvas.getContext('2d');
const viewport = page.getViewport({ scale: this.scale });
// 保存viewport信息到页面数据中
this.pages[index].viewport = viewport;
canvas.width = viewport.width;
canvas.height = viewport.height;
const renderContext = {
canvasContext: ctx,
viewport: viewport
};
await page.render(renderContext).promise;
},
// 印章拖拽处理
onStampDragStart(stamp) {
this.currentDraggingStamp = stamp;
this.isMovingExistingStamp = false;
},
// 已放置印章的拖拽开始
onPlacedStampDragStart(position, event) {
this.isMovingExistingStamp = true;
this.movingStampId = position.id;
// 设置拖拽图像
event.dataTransfer.setDragImage(event.target, this.stampSize/2, this.stampSize/2);
},
// 已放置印章的拖拽结束
onPlacedStampDragEnd() {
if (this.isMovingExistingStamp) {
this.isMovingExistingStamp = false;
this.movingStampId = null;
}
},
onCanvasDrop(event, pageNumber) {
event.preventDefault();
// 检查边界
if (!this.checkDropBoundary(event, pageNumber)) {
return;
}
const canvas = event.currentTarget;
const rect = canvas.getBoundingClientRect();
// 计算在Canvas内的相对位置
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
if (this.isMovingExistingStamp && this.movingStampId) {
// 移动现有印章
this.moveExistingStamp(this.movingStampId, pageNumber, x, y, rect);
} else {
// 添加新印章
this.addNewStamp(pageNumber, x, y, rect);
}
this.currentDraggingStamp = null;
this.isMovingExistingStamp = false;
this.movingStampId = null;
},
// 检查拖拽边界
checkDropBoundary(event, pageNumber) {
const canvas = event.currentTarget;
const rect = canvas.getBoundingClientRect();
const x = event.clientX - rect.left;
const y = event.clientY - rect.top;
// 边界检查:确保印章不会超出canvas边界
const padding = this.stampSize / 2;
if (x < padding || x > rect.width - padding ||
y < padding || y > rect.height - padding) {
this.showMessage('印章不能放置在页面边界外', 'warning');
return false;
}
if (!this.isMovingExistingStamp) {
const stamp = this.currentDraggingStamp || this.selectedStamp;
if (!stamp) {
this.showMessage('请先选择印章', 'warning');
return false;
}
}
return true;
},
// 移动现有印章
moveExistingStamp(stampId, pageNumber, x, y, rect) {
const position = this.stampPositions.find(pos => pos.id === stampId);
if (!position) return;
// 计算新的位置(考虑边界)
const { viewportX, viewportY, pdfX, pdfY } = this.calculateStampPosition(x, y, rect);
// 更新位置
position.posPage = pageNumber.toString();
position.posX = pdfX;
position.posY = pdfY;
position.viewportX = viewportX;
position.viewportY = viewportY;
this.selectedPosition = position;
this.showMessage(`已将印章移动到第${pageNumber}页`, 'success');
},
// 添加新印章
addNewStamp(pageNumber, x, y, rect) {
const stamp = this.currentDraggingStamp || this.selectedStamp;
if (!stamp) {
this.showMessage('请先选择印章', 'warning');
return;
}
// 计算位置(考虑边界)
const { viewportX, viewportY, pdfX, pdfY } = this.calculateStampPosition(x, y, rect);
this.addStampPosition({
pageNumber,
pdfX,
pdfY,
viewportX,
viewportY,
stamp
});
this.showMessage(`已在第${pageNumber}页添加印章`, 'success');
},
// 计算印章位置(考虑边界)- 基于e签宝坐标规范优化
calculateStampPosition(x, y, rect) {
// 边界约束
const padding = this.stampSize / 2;
const constrainedX = Math.max(padding, Math.min(x, rect.width - padding));
const constrainedY = Math.max(padding, Math.min(y, rect.height - padding));
// 转换为PDF坐标系统(原点在左下角)
// 根据e签宝文档,PDF坐标系原点在左下角,Y轴向上
const pdfX = constrainedX;
const pdfY = rect.height - constrainedY; // Y坐标翻转
// 计算视图坐标(用于显示)
const viewportX = constrainedX - this.stampSize / 2;
const viewportY = constrainedY - this.stampSize / 2;
return { viewportX, viewportY, pdfX, pdfY };
},
addStampPosition({ pageNumber, pdfX, pdfY, viewportX, viewportY, stamp }) {
const positionId = `pos-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const newPosition = {
id: positionId,
posPage: pageNumber.toString(),
posX: pdfX,
posY: pdfY,
viewportX,
viewportY,
stampId: stamp.id,
stampName: stamp.name,
stampImage: stamp.image
};
this.stampPositions.push(newPosition);
this.selectedPosition = newPosition;
},
// 位置管理
getStampPositionsForPage(pageNumber) {
return this.stampPositions.filter(pos => pos.posPage === pageNumber.toString());
},
selectPlacedStamp(position) {
this.selectedPosition = position;
},
removeStamp(positionId) {
this.stampPositions = this.stampPositions.filter(pos => pos.id !== positionId);
if (this.selectedPosition?.id === positionId) {
this.selectedPosition = null;
}
this.showMessage('印章已删除', 'info');
},
// 印章选择
selectStamp(stamp) {
this.selectedStamp = stamp;
this.showMessage(`已选择: ${stamp.name}`, 'info');
},
// 缩放控制
zoomIn() {
this.scale += 0.2;
this.renderAllPages();
},
zoomOut() {
if (this.scale > 0.6) {
this.scale -= 0.2;
this.renderAllPages();
}
},
// 确认位置
confirmPositions() {
if (this.stampPositions.length === 0) {
this.showMessage('请至少放置一个印章', 'warning');
return;
}
// 转换为e签宝需要的格式
const signPositions = this.stampPositions.map(pos => ({
posPage: pos.posPage,
posX: pos.posX,
posY: pos.posY,
sealId: pos.stampId,
signType: 'Single' // 单页签章
}));
this.$emit('positions-confirmed', signPositions);
this.showMessage(`已确认 ${signPositions.length} 个签署位置`, 'success');
},
// 下载位置数据
downloadPositions() {
const signPositions = this.stampPositions.map(pos => ({
posPage: pos.posPage,
posX: pos.posX,
posY: pos.posY,
sealId: pos.stampId,
signType: 'Single'
}));
const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(signPositions, null, 2));
const downloadAnchorNode = document.createElement('a');
downloadAnchorNode.setAttribute("href", dataStr);
downloadAnchorNode.setAttribute("download", "sign-positions.json");
document.body.appendChild(downloadAnchorNode);
downloadAnchorNode.click();
downloadAnchorNode.remove();
this.showMessage('位置数据已下载', 'success');
},
resetAll() {
this.stampPositions = [];
this.selectedPosition = null;
this.showMessage('已重置所有印章位置', 'info');
},
// 生成默认印章
generateDefaultStamp() {
const canvas = document.createElement('canvas');
canvas.width = 120;
canvas.height = 120;
const ctx = canvas.getContext('2d');
// 绘制红色圆形印章
ctx.strokeStyle = '#e60000';
ctx.lineWidth = 3;
ctx.beginPath();
ctx.arc(60, 60, 50, 0, 2 * Math.PI);
ctx.stroke();
// 绘制五角星
this.drawStar(ctx, 60, 60, 5, 20, 8, '#e60000');
// 绘制文字
ctx.font = 'bold 16px Microsoft YaHei';
ctx.fillStyle = '#e60000';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('电子签章', 60, 60);
return canvas.toDataURL();
},
drawStar(ctx, cx, cy, spikes, outerRadius, innerRadius, color) {
let rot = Math.PI / 2 * 3;
let x = cx;
let y = cy;
const step = Math.PI / spikes;
ctx.beginPath();
ctx.moveTo(cx, cy - outerRadius);
for (let i = 0; i < spikes; i++) {
x = cx + Math.cos(rot) * outerRadius;
y = cy + Math.sin(rot) * outerRadius;
ctx.lineTo(x, y);
rot += step;
x = cx + Math.cos(rot) * innerRadius;
y = cy + Math.sin(rot) * innerRadius;
ctx.lineTo(x, y);
rot += step;
}
ctx.lineTo(cx, cy - outerRadius);
ctx.closePath();
ctx.fillStyle = color;
ctx.fill();
}
},
beforeDestroy() {
if (this.pdfUrl) {
URL.revokeObjectURL(this.pdfUrl);
}
}
};
</script>
<style scoped>
.esign-stamp-container {
max-width: 100%;
margin: 0 auto;
font-family: 'Microsoft YaHei', Arial, sans-serif;
}
/* 上传区域样式 */
.upload-section {
margin-bottom: 20px;
padding: 20px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e8e8e8;
}
.upload-controls {
margin-bottom: 15px;
}
.file-input {
display: none;
}
.upload-btn {
display: inline-flex;
align-items: center;
padding: 10px 20px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.upload-btn:hover {
background: #66b1ff;
}
.upload-icon {
margin-right: 8px;
font-size: 16px;
}
.upload-tip {
margin-top: 8px;
font-size: 12px;
color: #666;
}
.url-upload-section {
margin-top: 15px;
}
.url-input-group {
display: flex;
gap: 10px;
max-width: 500px;
}
.url-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 14px;
}
.url-input:focus {
outline: none;
border-color: #409eff;
}
.url-upload-btn {
padding: 8px 16px;
background: #67c23a;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.url-upload-btn:hover {
background: #85ce61;
}
/* 工具栏样式 */
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
margin: 20px 0;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
flex-wrap: wrap;
}
.controls {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.control-btn {
padding: 6px 12px;
background: white;
border: 1px solid #ddd;
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
}
.control-btn:hover:not(:disabled) {
background: #f0f8ff;
border-color: #409eff;
}
.control-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.scale-info {
font-weight: bold;
color: #409eff;
padding: 0 10px;
}
/* 印章选择样式 */
.stamp-selection {
display: flex;
gap: 15px;
margin: 20px 0;
padding: 15px;
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow-x: auto;
background: #fafafa;
}
.stamp-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 10px;
border: 2px solid transparent;
border-radius: 6px;
cursor: pointer;
min-width: 90px;
transition: all 0.3s;
background: white;
}
.stamp-item:hover {
border-color: #409eff;
background: #f0f8ff;
transform: translateY(-2px);
}
.stamp-item.active {
border-color: #409eff;
background: #ecf5ff;
box-shadow: 0 2px 8px rgba(64, 158, 255, 0.2);
}
.stamp-preview {
width: 60px;
height: 60px;
object-fit: contain;
margin-bottom: 8px;
}
.stamp-name {
font-size: 12px;
color: #666;
text-align: center;
}
/* PDF查看器样式 */
.pdf-viewer {
border: 1px solid #e8e8e8;
border-radius: 8px;
padding: 20px;
background: #f9f9f9;
max-height: 70vh;
overflow-y: auto;
text-align: center;
}
.page-wrapper {
position: relative;
margin-bottom: 30px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.1);
background: white;
display: inline-block;
border-radius: 4px;
overflow: hidden;
}
.page-header {
background: #409eff;
color: white;
padding: 8px 12px;
font-size: 14px;
font-weight: bold;
}
.pdf-canvas {
border: 1px solid #ddd;
display: block;
cursor: crosshair;
max-width: 100%;
}
/* 已放置印章样式 */
.placed-stamp {
position: absolute;
pointer-events: auto;
cursor: move;
z-index: 10;
transition: transform 0.2s;
border-radius: 4px;
overflow: hidden;
}
.placed-stamp:hover {
transform: scale(1.05);
box-shadow: 0 0 0 2px #409eff;
}
.stamp-image {
width: 100%;
height: 100%;
object-fit: contain;
}
.stamp-overlay {
position: absolute;
top: 0;
right: 0;
background: rgba(0, 0, 0, 0.7);
color: white;
padding: 2px 6px;
border-radius: 0 0 0 4px;
opacity: 0;
transition: opacity 0.3s;
cursor: pointer;
}
.placed-stamp:hover .stamp-overlay {
opacity: 1;
}
.delete-icon {
font-size: 16px;
font-weight: bold;
}
/* 位置信息卡片样式 */
.position-card {
margin: 20px 0;
padding: 0;
background: white;
border: 1px solid #e8e8e8;
border-radius: 8px;
overflow: hidden;
}
.position-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 12px 16px;
background: #f5f7fa;
border-bottom: 1px solid #e8e8e8;
}
.delete-btn {
background: none;
border: none;
color: #f56c6c;
cursor: pointer;
font-size: 14px;
}
.delete-btn:hover {
color: #f78989;
}
.position-info {
padding: 16px;
}
.position-info p {
margin: 8px 0;
font-size: 14px;
}
/* 操作按钮样式 */
.action-buttons {
display: flex;
gap: 10px;
margin: 20px 0;
justify-content: center;
flex-wrap: wrap;
}
.primary-btn {
padding: 10px 20px;
background: #409eff;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: background 0.3s;
}
.primary-btn:hover:not(:disabled) {
background: #66b1ff;
}
.primary-btn:disabled {
background: #a0cfff;
cursor: not-allowed;
}
.secondary-btn {
padding: 10px 20px;
background: white;
color: #409eff;
border: 1px solid #409eff;
border-radius: 4px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.secondary-btn:hover:not(:disabled) {
background: #ecf5ff;
}
.secondary-btn:disabled {
color: #a0cfff;
border-color: #a0cfff;
cursor: not-allowed;
}
/* 加载状态样式 */
.loading-container {
margin: 20px 0;
}
.loading-alert {
display: inline-flex;
align-items: center;
padding: 12px 20px;
background: #f4f4f5;
color: #909399;
border-radius: 4px;
font-size: 14px;
}
.loading-icon {
margin-right: 8px;
font-size: 16px;
}
/* 消息提示样式 */
.message-toast {
position: fixed;
top: 20px;
right: 20px;
padding: 12px 20px;
border-radius: 4px;
font-size: 14px;
z-index: 1000;
animation: slideIn 0.3s ease;
max-width: 300px;
}
.message-toast.info {
background: #f4f4f5;
color: #909399;
border-left: 4px solid #909399;
}
.message-toast.success {
background: #f0f9ff;
color: #67c23a;
border-left: 4px solid #67c23a;
}
.message-toast.error {
background: #fef0f0;
color: #f56c6c;
border-left: 4px solid #f56c6c;
}
.message-toast.warning {
background: #fdf6ec;
color: #e6a23c;
border-left: 4px solid #e6a23c;
}
@keyframes slideIn {
from {
transform: translateX(100%);
opacity: 0;
}
to {
transform: translateX(0);
opacity: 1;
}
}
/* 响应式设计 */
@media (max-width: 768px) {
.toolbar {
flex-direction: column;
gap: 10px;
align-items: flex-start;
}
.url-input-group {
flex-direction: column;
}
.stamp-selection {
justify-content: flex-start;
}
.action-buttons {
flex-direction: column;
}
.message-toast {
left: 20px;
right: 20px;
max-width: none;
}
}
</style>