本文将介绍一个基于Vue2的PDF电子签章位置选择组件,模仿e签宝的业务逻辑,实现PDF文件加载、印章拖拽放置、位置坐标计算等功能。

功能特点

1. 多种PDF加载方式

  • 文件上传:支持本地PDF文件上传,限制10MB以内
  • URL加载:通过输入PDF文件URL地址远程加载
  • 初始URL:支持通过props传入初始PDF URL

2. 印章管理

  • 默认印章:自动生成红色圆形默认印章
  • 自定义印章:支持通过props传入自定义印章数组
  • 印章选择:可视化印章选择面板,支持点击和拖拽

3. 精准位置定位

  • 拖拽放置:通过拖拽方式将印章放置到PDF指定位置
  • 坐标计算:自动计算PDF坐标系中的精确位置(基于e签宝坐标规范)
  • 边界检测:防止印章超出页面边界
  • 位置调整:支持已放置印章的移动和删除

4. 可视化操作

  • PDF预览:完整渲染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>

Props 配置

属性类型默认值说明
stampsArray[]自定义印章数组
defaultStampSizeNumber80印章显示尺寸
initialPdfUrlString''初始PDF文件URL

事件

  • positions-confirmed: 当用户确认签署位置时触发,返回位置数据数组

核心实现解析

PDF渲染与坐标系统

组件使用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'       // 签章类型
}));

样式设计

组件采用响应式设计,支持桌面和移动端:

  • 现代化UI:使用渐变、阴影和动画效果
  • 直观交互:清晰的视觉反馈和状态指示
  • 自适应布局:在不同屏幕尺寸下保持良好的可用性

总结

这个Vue2电子签章位置选择组件提供了完整的PDF签章位置选择解决方案,具有以下优势:

  • 精准定位:基于PDF坐标系统的精确位置计算
  • 用户友好:直观的拖拽操作和实时视觉反馈
  • 灵活扩展:支持自定义印章和多种PDF加载方式
  • 标准兼容:输出数据符合e签宝等主流电子签章平台规范

源码

<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>
本站提供的所有下载资源均来自互联网,仅提供学习交流使用,版权归原作者所有。如需商业使用,请联系原作者获得授权。 如您发现有涉嫌侵权的内容,请联系我们 邮箱:[email protected]