组件封装:el-upload支持图片粘贴上传并添加预览

42 阅读3分钟

juejin.cn/post/755545…

这次直接升级成了一个组件,复制即可使用,效果:

image.png

paste the picture here 支持粘贴图片 upload image here 手动上传图片

<template>
  <div class="paste-upload-container">
    <div class="upload-areas">
      <!-- 粘贴区域 -->
      <div 
        class="upload-area paste-area"
        :class="{ active: isPasteActive }"
        @click="focusPasteArea"
        @paste="handlePaste"
        tabindex="0"
        ref="pasteArea"
      >
        <div class="upload-icon">
          <!-- <el-icon size="24"><DocumentCopy /></el-icon> -->
           <img src="@/assets/promotePlus/myShare/paste.png" alt="">
        </div>
        <div class="upload-text">Paste the picture here</div>
      </div>

      <!-- 上传区域 -->
      <div class="upload-area upload-area-right">
        <el-upload
          ref="uploadRef"
          :auto-upload="false"
          :on-change="handleFileChange"
          :show-file-list="false"
          accept="image/*"
          :limit="maxFiles"
          :before-upload="beforeUpload"
        >
          <div class="upload-icon">
            <!-- <el-icon size="24"><Plus /></el-icon> -->
            <img src="@/assets/promotePlus/myShare/upload.png" alt="">
          </div>
          <div class="upload-text">Upload image here</div>
        </el-upload>
      </div>
    </div>

    <!-- 文件列表 -->
    <div v-if="fileList.length > 0" class="file-list">
      <div 
        v-for="(file, index) in fileList" 
        :key="index"
        class="file-item"
      >
        <div class="file-preview">
          <el-image
            :src="file.url"
            :preview-src-list="previewList"
            :initial-index="index"
            fit="cover"
            class="preview-image"
          />
        </div>
        <div class="file-info">
          <div class="file-name">{{ file.name }}</div>
        </div>
        <div class="file-actions">
          <el-button
            type="danger"
            size="small"
            :icon="Delete"
            circle
            @click="removeFile(index)"
          />
        </div>
      </div>
    </div>


    <el-tooltip
      :content="tooltipContent"
      placement="top"
      :disabled="!tooltipContent"
    >
    <div 
      class="example-section"
      ref="exampleRef"
      @mouseenter="showPreviewAtCursor"
      @mouseleave="hidePreview"
    >
      <el-button 
        type="primary" 
        link 
        class="example-btn"
      >
        Example
      </el-button>

      <!-- 悬浮预览图 -->
      <transition name="fade">
        <div
          v-if="showPreview"
          class="hover-preview"
          :style="previewStyle"
        >
          <img :src="url" alt="Example Preview"  @load="onImageLoad" />
        </div>
      </transition>
    </div>
    </el-tooltip>
    
  </div>
</template>

<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { ElMessage, ElMessageBox } from 'element-plus';
import { DocumentCopy, Plus, Delete } from '@element-plus/icons-vue';
import { uploadImage } from '@/api/myShare';

interface FileItem {
  name: string;
  url: string;
  file: File;
}

const props = defineProps<{
  maxFiles?: number;
  maxSize?: number; // MB
}>();

const emit = defineEmits<{
  'update:modelValue': [files: FileItem[]];
  'change': [files: FileItem[]];
}>();

const uploadRef = ref();
const pasteArea = ref();
const fileList = ref<FileItem[]>([]);
const isPasteActive = ref(false);

const maxFiles = props.maxFiles || 10;
const maxSize = props.maxSize || 5; // 5MB

const previewList = computed(() => fileList.value.map(file => file.url));

// 文件验证
const validateFile = (file: File): boolean => {
  // 检查文件类型
  if (!file.type.startsWith('image/')) {
    ElMessage.error('只能上传图片文件');
    return false;
  }

  // 检查文件大小
  if (file.size > maxSize * 1024 * 1024) {
    ElMessage.error(`文件大小不能超过 ${maxSize}MB`);
    return false;
  }

  // 检查文件数量
  if (fileList.value.length >= maxFiles) {
    ElMessage.error(`最多只能上传 ${maxFiles} 张图片`);
    return false;
  }

  return true;
};

// 处理文件上传
const handleFileUpload = async (file: File) => {
  if (!validateFile(file)) return;

  try {
    // 这里应该调用真实的上传API
    // const response = await uploadImage(file);
    // const fileUrl = response.data.url;
    
    // 临时使用本地URL
    const fileUrl = URL.createObjectURL(file);
    
    const fileItem: FileItem = {
      name: file.name,
      url: fileUrl,
      file: file
    };

    fileList.value.push(fileItem);
    emit('update:modelValue', fileList.value);
    emit('change', fileList.value);
    
    ElMessage.success('文件上传成功');
  } catch (error) {
    ElMessage.error('文件上传失败');
    console.error('Upload error:', error);
  }
};

// 处理粘贴
const handlePaste = async (event: ClipboardEvent) => {
  const items = event.clipboardData?.items;
  if (!items) return;

  for (let i = 0; i < items.length; i++) {
    const item = items[i];
    if (item.type.startsWith('image/')) {
      const file = item.getAsFile();
      if (file) {
        await handleFileUpload(file);
      }
    }
  }
};

// 处理文件选择
const handleFileChange = (file: any) => {
  handleFileUpload(file.raw);
};

// 上传前验证
const beforeUpload = (file: File) => {
  return validateFile(file);
};

// 移除文件
const removeFile = (index: number) => {
  fileList.value.splice(index, 1);
  emit('update:modelValue', fileList.value);
  emit('change', fileList.value);
};

// 聚焦粘贴区域
const focusPasteArea = () => {
  isPasteActive.value = true;
  pasteArea.value?.focus();
};

// 显示示例
const showExample = () => {
  ElMessageBox.alert(
    '这里可以显示示例图片,帮助用户了解如何正确截图',
    '示例',
    {
      confirmButtonText: '确定',
      type: 'info'
    }
  );
};

// 监听键盘事件
const handleKeydown = (event: KeyboardEvent) => {
  if (event.ctrlKey && event.key === 'v') {
    focusPasteArea();
  }
};

onMounted(() => {
  document.addEventListener('keydown', handleKeydown);
});

onUnmounted(() => {
  document.removeEventListener('keydown', handleKeydown);
  // 清理本地URL
  fileList.value.forEach(file => {
    if (file.url.startsWith('blob:')) {
      URL.revokeObjectURL(file.url);
    }
  });
});

// 悬浮预览图
const url = "https://picsum.photos/seed/paper1/320/480";
const showPreview = ref(false);
const previewStyle = ref<Record<string, string>>({});
const exampleRef = ref<HTMLElement | null>(null);
const tooltipContent = ref('')
const showPreviewAtCursor = () => {
  if (!exampleRef.value) return;
  tooltipContent.value = 'Loading...'
  const rect = exampleRef.value.getBoundingClientRect();
  const padding = 10; // 与Example的间距
  const previewWidth = 320; // 预览图的宽度(与实际图片宽度保持一致)
  // 固定定位在Example右侧中间
  previewStyle.value = {
    position: "fixed",
    // top: `${rect.top + window.scrollY}px`,
    // left: `${rect.right + padding}px`,
    top: `${rect.top + window.scrollY}px`,
    left: `${rect.left - previewWidth - padding}px`,
  };

  showPreview.value = true;
};


// 图片加载完成
const onImageLoad = () => {
  tooltipContent.value = '' // 图片加载后,隐藏 tooltip
}

const hidePreview = () => {
  showPreview.value = false;
  tooltipContent.value = ''
};


// 暴露方法给父组件
defineExpose({
  clearFiles: () => {
    fileList.value = [];
    emit('update:modelValue', fileList.value);
    emit('change', fileList.value);
  }
});
</script>

<style lang="scss" scoped>
.paste-upload-container {
  .upload-areas {
    display: flex;
    gap: 16px;
    margin-bottom: 16px;
    background-color: #F2F3F5;
    width: 500px;
    height: 160px;
    align-items: center;
    padding: 0 30px;

    .upload-area {
      flex: 1;
      height: 120px;
      border: 2px dashed #d9d9d9;
      border-radius: 8px;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      transition: all 0.3s;
      background: #fafafa;

      &:hover {
        border-color: #6658BB;
        background: #f0f9ff;
      }

      &.active {
        border-color: #409eff;
        background: #f0f9ff;
      }

      .upload-icon {
        color: #6658BB;
      }

      .upload-text {
        color: #666;
        font-size: 14px;
      }
    }

    .upload-area-right {
      :deep(.el-upload) {
        width: 100%;
        height: 100%;
        display: flex;
        flex-direction: column;
        align-items: center;
        justify-content: center;
      }
    }
  }

  .file-list {
    margin-bottom: 16px;

    .file-item {
      display: flex;
      align-items: center;
      padding: 8px;
      border: 1px solid #e4e7ed;
      border-radius: 6px;
      margin-bottom: 8px;
      background: #fff;

      .file-preview {
        width: 40px;
        height: 40px;
        margin-right: 12px;

        .preview-image {
          width: 100%;
          height: 100%;
          border-radius: 4px;
        }
      }

      .file-info {
        flex: 1;

        .file-name {
          font-size: 14px;
          color: #303133;
          word-break: break-all;
        }
      }

      .file-actions {
        margin-left: 8px;
      }
    }
  }

  .example-section {
    position: absolute;
    top: -35px;
    right: 5px;
    

    .example-btn {
      font-family: Arial, Arial;
    font-weight: 400;
    font-size: 14px;
    color: #6658BB;
    line-height: 22px;
    font-style: normal;
    text-transform: none;
    }
  }
}

.hover-preview{
  z-index: 2;
}
</style>