StarFire_xm
  • 文章
  • 粉丝
  • 评论

QuillEditor富文本图文复制自定义上传

2025-09-29 09:20:190 次浏览0 次评论技能类型: quilleditor
组件

<template>
  <div>
    <!-- 隐藏的图片上传组件 -->
    <el-upload
      ref="imageUploader"
      class="hidden-uploader"
      :headers="upload.headers"
      :before-upload="beforeUpload"
      :http-request="customUpload"
      :on-success="handleUploadSuccess"
      :on-error="handleUploadError"
      :file-list="fileList"
      :on-exceed="handleExceed"
      list-type="picture-card"
      :multiple="true"
    >
      <el-button type="primary" style="display: none">点击上传</el-button>
    </el-upload>

    <!-- 富文本编辑器 -->
    <div class="editor">
      <quill-editor
        ref="quillEditorRef"
        v-model:content="content"
        content-type="html"
        :options="options"
        :style="styles"
        @text-change="(e) => $emit('update:modelValue', content)"
      />
    </div>
  </div>
</template>

<script setup lang="ts">
import { ref, computed, watch, toRaw, onMounted, getCurrentInstance } from 'vue';
import { QuillEditor, Quill } from '@vueup/vue-quill';
import { ElMessage } from 'element-plus';
import '@vueup/vue-quill/dist/vue-quill.snow.css';
import { globalHeaders } from '@/utils/request'; // 假设你有全局 headers 的工具函数
import type { ComponentInternalInstance } from 'vue';

// 定义上传结果类型
interface UploadResult {
  originalSrc: string;
  newSrc: string | null;
  success: boolean;
  error?: any;
}

const emit = defineEmits(['update:modelValue']);

const props = defineProps({
  modelValue: String,
  height: { type: Number, default: 100 },
  minHeight: { type: Number, default: 400 },
  readOnly: { type: Boolean, default: false },
  fileSize: { type: Number, default: 5 }
});

const { proxy } = getCurrentInstance() as ComponentInternalInstance;

const upload = {
  headers: globalHeaders(),
  url: import.meta.env.VITE_APP_BASE_API + '/aaaaaaaa' // 使用预签名 URL 接口
};

const quillEditorRef = ref();
const imageUploader = ref(null); // 引用隐藏的上传组件
const fileList = ref([]);

// 监听粘贴事件(自动上传图片)
const setupPasteHandler = () => {
  const editor = quillEditorRef.value.getQuill().root;
  editor.addEventListener('paste', async (e) => {
    const items = (e.clipboardData || (window as any).clipboardData).items;
    let hasImage = false;

    // 检查剪贴板中是否有图片文件
    for (const item of items) {
      if (item.type.startsWith('image/')) {
        e.preventDefault();
        hasImage = true;
        const file = item.getAsFile();
        if (file) {
          try {
            // 插入临时占位
            const quill = quillEditorRef.value.getQuill();
            const range = quill.getSelection();
            const placeholderIndex = range?.index || quill.getLength();
            quill.insertEmbed(placeholderIndex, 'image', '[上传中...]');
            quill.setSelection(placeholderIndex + 1);
            // 上传到 OSS(直接使用基础上传,避免队列延迟)
            const result:any = await baseUpload(file);
            if (result.url) {
              // 替换占位为真实 URL
              quill.deleteText(placeholderIndex, 1);
              quill.insertEmbed(placeholderIndex, 'image', result.url);
            } else {
              throw new Error('上传失败');
            }
          } catch (error: any) {
            ElMessage.error('图片上传失败: ' + error.message);
            removeUploadPlaceholder();
          }
        }
        break;
      }
    }

    // 如果没有直接的文件图片,允许正常粘贴,然后检查粘贴后的内容
    if (!hasImage) {
      // 延迟执行,等待粘贴内容插入完成
      setTimeout(async () => {
        await processPastedImages();
        emit('update:modelValue', quillEditorRef.value.getHTML());
      }, 100);
    }
  });
};

// 处理粘贴后的图片(base64或外部URL)
const processPastedImages = async () => {
  const quill = quillEditorRef.value.getQuill();
  // 获取HTML内容
  let htmlContent = quill.root.innerHTML;
  // 用正则找到所有图片的src
  const imgRegex = /<img[^>]+src="([^"]+)"[^>]*>/g;
  const matches = [...htmlContent.matchAll(imgRegex)];

  // 收集需要上传的图片
  const uploadTasks = [];
  for (const match of matches) {
    const originalSrc = match[1];
    // 跳过已经上传的图片
    if (isInternalImageUrl(originalSrc)) {
      continue;
    }
    // 检查是否需要上传
    if (originalSrc.startsWith('data:image/') ||
        ((originalSrc.startsWith('http') || originalSrc.startsWith('https')) && !isInternalImageUrl(originalSrc)) ||
        originalSrc.startsWith('blob:')) {
      // 创建上传任务(使用基础上传逻辑)
      const uploadTask = async () => {
        try {
          // 将图片转换为File对象
          const file = await imageUrlToFile(originalSrc);
          // 上传图片(使用基础上传逻辑)
          const result: any = await baseUpload(file);
          if (result.url) {
            return { originalSrc, newSrc: result.url, success: true };
          } else {
            throw new Error('上传失败');
          }
        } catch (error: any) {
          return { originalSrc, newSrc: null, error, success: false };
        }
      };
      uploadTasks.push(uploadTask);
    }
  }
 
  // 使用并发队列执行上传任务
  if (uploadTasks.length > 0) {
    let completedCount = 0;
    let successCount = 0;
    let failedCount = 0;
   
    // 显示全屏加载遮罩
    const loadingInstance = ElLoading.service({
      lock: true,
      text: `正在上传图片 (0/${uploadTasks.length}),并发数: ${concurrentUploadQueue.maxConcurrent}`,
      background: 'rgba(0, 0, 0, 0.1)',
      customClass: 'image-upload-loading'
    });
   
    // 更新加载文本的函数
    const updateLoadingText = () => {
      const status = concurrentUploadQueue.getStatus();
      loadingInstance.setText(
        `正在上传图片 (${completedCount}/${uploadTasks.length}),` +
        `成功: ${successCount},失败: ${failedCount},` +
        `并发: ${status.running}/${status.maxConcurrent}`
      );
    };
   
    try {
      // 将所有任务添加到并发队列
      const uploadPromises = uploadTasks.map(async (task) => {
        try {
          const result = await concurrentUploadQueue.addTask(task);
          completedCount++;
          if (result && (result as any).success) {
            successCount++;
          } else {
            failedCount++;
          }
          updateLoadingText();
          return result as UploadResult;
        } catch (error) {
          completedCount++;
          failedCount++;
          updateLoadingText();
          return { originalSrc: '', newSrc: null, error, success: false } as UploadResult;
        }
      });
     
      // 等待所有任务完成
      const results = await Promise.allSettled(uploadPromises);
     
      // 替换HTML中的图片URL
      for (const result of results) {
        if (result.status === 'fulfilled' && result.value && result.value.success && result.value.newSrc) {
          htmlContent = htmlContent.replace(result.value.originalSrc, result.value.newSrc);
        }
      }
     
      // 关闭加载遮罩并显示完成提示
      loadingInstance.close();
     
      if (failedCount === 0) {
        ElMessage.success(`图片上传完成!成功上传 ${successCount} 张图片`);
      } else if (successCount > 0) {
        ElMessage.warning(`图片上传完成!成功 ${successCount} 张,失败 ${failedCount} 张`);
      } else {
        ElMessage.error(`图片上传失败!所有 ${failedCount} 张图片上传失败`);
      }
    } catch (error) {
      // 如果出现错误,也要关闭加载遮罩
      loadingInstance.close();
      ElMessage.error('图片上传过程中出现错误');
      console.error('批量上传错误:', error);
    }
  }
 
  // 更新Quill内容
  quill.root.innerHTML = htmlContent;
  emit('update:modelValue', htmlContent);
};

// 检查是否是内部图片URL(已经通过自定义上传的图片)
const isInternalImageUrl = (url: string): boolean => {
  // 检查是否是已经上传到CDN的图片
  // 这里可以根据你的CDN域名进行配置
  const internalDomains = [
    'oss.pre.liangjiucs.com',
    'cdn.liangjiutuangou.com'
  ];
 
  try {
    const urlObj = new URL(url);
    return internalDomains.some(domain => urlObj.hostname.includes(domain));
  } catch {
    return false;
  }
};

// 将图片URL转换为File对象
const imageUrlToFile = async (imageUrl: string): Promise<File> => {
  let blob: Blob;
 
  if (imageUrl.startsWith('data:')) {
    // 处理base64图片
    const response = await fetch(imageUrl);
    blob = await response.blob();
  } else {
    // 处理外部URL图片
    const response = await fetch(imageUrl);
    if (!response.ok) {
      throw new Error('无法获取图片');
    }
    blob = await response.blob();
  }
 
  // 生成文件名
  const timestamp = Date.now();
  const extension = blob.type.split('/')[1] || 'png';
  const fileName = `pasted-image-${timestamp}.${extension}`;
 
  return new File([blob], fileName, { type: blob.type });
};

// 移除上传失败的占位
const removeUploadPlaceholder = () => {
  const quill = quillEditorRef.value.getQuill();
  const contents = quill.getContents();
  const ops = contents.ops;
  for (let i = 0; i < ops.length; i++) {
    if (ops[i].insert?.image === '[上传中...]') {
      quill.deleteText(i, 1);
      break;
    }
  }
};

// 顺序上传队列(用于选择图片上传,保持原有功能)
const uploadQueue = {
  tasks: [],
  isProcessing: false,

  addTask(task) {
    return new Promise((resolve, reject) => {
      this.tasks.push({ task, resolve, reject });
      this.processNext();
    });
  },
 
  async processNext() {
    if (this.isProcessing || this.tasks.length === 0) return;
   
    this.isProcessing = true;
    const { task, resolve, reject } = this.tasks.shift();
   
    try {
      const result = await task();
      resolve(result);
    } catch (error) {
      reject(error);
    } finally {
      this.isProcessing = false;
      this.processNext();
    }
  }
};

// 并发上传队列(专门用于**粘贴的批量上传)
const concurrentUploadQueue = {
  tasks: [] as Array<{
    task: () => Promise<any>;
    resolve: (value: any) => void;
    reject: (reason?: any) => void;
    retryCount: number;
    id: number;
  }>,
  runningTasks: new Set<number>(),
  maxConcurrent: 5, // 最大并发数
  retryCount: 2, // 重试次数

  addTask(task: () => Promise<any>, retryCount: number = 2) {
    return new Promise((resolve, reject) => {
      this.tasks.push({
        task,
        resolve,
        reject,
        retryCount,
        id: Date.now() + Math.random() // 唯一ID
      });
      this.processNext();
    });
  },
 
  async processNext() {
    // 如果已达到最大并发数或没有待处理任务,则返回
    if (this.runningTasks.size >= this.maxConcurrent || this.tasks.length === 0) {
      return;
    }
   
    const taskItem = this.tasks.shift();
    if (!taskItem) return;
   
    const { task, resolve, reject, retryCount, id } = taskItem;
    this.runningTasks.add(id);
   
    try {
      const result = await task();
      resolve(result);
    } catch (error) {
      // 如果还有重试次数,重新加入队列
      if (retryCount > 0) {
        console.warn(`上传失败,剩余重试次数: ${retryCount - 1}`, error);
        this.tasks.push({
          ...taskItem,
          retryCount: retryCount - 1
        });
        // 延迟后重试,避免立即重试
        setTimeout(() => this.processNext(), 1000);
      } else {
        console.error('上传最终失败,已用尽重试次数', error);
        reject(error);
      }
    } finally {
      this.runningTasks.delete(id);
      // 继续处理下一个任务
      this.processNext();
    }
  },

  // 获取队列状态
  getStatus() {
    return {
      pending: this.tasks.length,
      running: this.runningTasks.size,
      maxConcurrent: this.maxConcurrent
    };
  }
};

const options = ref({
  theme: 'snow',
  bounds: document.body,
  debug: 'warn',
  modules: {
    toolbar: {
      container: [
        ['bold', 'italic', 'underline', 'strike'],
        ['blockquote'],
        [{ list: 'ordered' }, { list: 'bullet' }],
        [{ indent: '-1' }, { indent: '+1' }],
        [{ size: [false, 'large'] }],
        [{ color: [] }, { background: [] }],
        [{ align: [] }],
        ['image', 'video']
      ],
      handlers: {
        image: (value: boolean) => {
          if (value) {
            // 触发隐藏的图片上传组件
            imageUploader.value.$el.querySelector('input[type="file"]').click();
          } else {
            Quill.format('image', false);
          }
        },
        video: (value: boolean) => {
          if (value) {
            uploadVideo();
          } else {
            Quill.format('video', false);
          }
        }
      }
    }
  },
  placeholder: '请输入内容',
  readOnly: props.readOnly
});

const styles = computed(() => {
  let style: any = { width: '100%' };
  if (props.minHeight) {
    style.minHeight = `${props.minHeight}px`;
  }
  if (props.height) {
    style.height = `${props.height}px`;
  }
  return style;
});

const content = ref('');
watch(
  () => props.modelValue,
  (v: string) => {
    if (v !== content.value) {
      content.value = v || '<p></p>';
    }
  },
  { immediate: true }
);

// 图片上传成功回调
const handleUploadSuccess = (response, file, fileList) => {
  console.log(JSON.stringify(response) + '---->');
  if (response.code === 200 || response.url) {
    const quill = toRaw(quillEditorRef.value).getQuill();
    const length = quill.selection.savedRange.index;
    quill.insertEmbed(length, 'image', response.cdnUrl || response.url); // 插入图片
    quill.setSelection(length + 1);
    ElMessage.success('图片上传成功');
  } else {
    ElMessage.error('图片上传失败:' + response.msg);
  }
};

// 图片上传前校验
const beforeUpload = (file) => {
  const isImage = file.type.startsWith('image/');
  const isLt5M = file.size / 1024 / 1024 < props.fileSize;

  if (!isImage) {
    ElMessage.error('只能上传图片格式!');
    return false;
  }
  if (!isLt5M) {
    ElMessage.error(`上传文件大小不能超过 ${props.fileSize} MB!`);
    return false;
  }
  return true;
};

// 基础图片上传逻辑
const baseUpload = async (file) => {
  const formData = { originalFileName: file.name };
 
  try {
    const response = await fetch(upload.url, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify(formData)
    });

    const result = await response.json();
    if (result.code === 200) {
      const signedUrl = result.data.preSigneUrl;
      const uploadResponse = await fetch(signedUrl, {
        method: 'PUT',
        body: file,
        headers: { 'Content-Type': file.type }
      });

      if (uploadResponse.ok) {
        return { url: result.data.cdnUrl, storageId: result.data.objectName };
      } else {
        throw new Error(`图片上传失败:${uploadResponse.statusText}`);
      }
    } else {
      throw new Error(`获取预签名 URL 失败:${result.msg}`);
    }
  } catch (error: any) {
    throw error;
  }
};

/**
 * 顺序图片上传(用于选择图片上传,保持原有功能)
 * 通过工具栏图片按钮或拖拽选择的图片会按顺序逐个上传
 */
const customUpload = async (file) => {
  file = file.file || file;
  if (!file) return;
  return uploadQueue.addTask(async () => {
    try {
      return await baseUpload(file);
    } catch (error: any) {
      ElMessage.error(`上传过程中出现错误:${error.message}`);
      throw error;
    }
  });
};

/**
 * 并发图片上传(专门用于**粘贴的批量上传)
 * **粘贴的图片会使用并发队列,同时最多上传 5 张图片
 * 支持失败重试和进度显示
 */
const customUploadConcurrent = async (file) => {
  file = file.file || file;
  if (!file) return;
  return concurrentUploadQueue.addTask(async () => {
    return await baseUpload(file);
  });
};

// 图片上传超出限制回调
const handleExceed = (files: File[]) => {
  ElMessage.error('最多只能上传20张图片');
};

// 图片上传失败回调
const handleUploadError = () => {
  ElMessage.error('图片上传失败,请稍后再试!');
};

// 视频上传逻辑
const uploadVideo = async () => {
  const input = document.createElement('input');
  input.type = 'file';
  input.accept = 'video/*';
  input.onchange = async (e: any) => {
    const file = e.target.files[0];
    if (!file) return;

    const isVideo = file.type.startsWith('video/');
    const isLt50M = file.size / 1024 / 1024 < 50;

    if (!isVideo) {
      ElMessage.error('只能上传视频格式!');
      return;
    }
    if (!isLt50M) {
      ElMessage.error('上传文件大小不能超过 50MB!');
      return;
    }

    proxy?.$modal.loading('正在上传视频,请稍候...');
    const formData = { originalFileName: file.name };

    try {
      const response = await fetch(upload.url, {
        method: 'POST',
        headers: {
          'Content-Type': 'application/json'
        },
        body: JSON.stringify(formData)
      });

      const result = await response.json();
      if (result.code === 200) {
        const signedUrl = result.data.preSigneUrl;
        const uploadResponse = await fetch(signedUrl, {
          method: 'PUT',
          body: file,
          headers: {
            'Content-Type': file.type
          }
        });

        if (uploadResponse.ok) {
          const quill = toRaw(quillEditorRef.value).getQuill();
          const length = quill.selection.savedRange.index;
          quill.insertEmbed(length, 'video', result.data.cdnUrl); // 插入视频
          quill.setSelection(length + 1);
          proxy?.$modal.closeLoading();
        } else {
          ElMessage.error('视频上传失败:' + uploadResponse.statusText);
          proxy?.$modal.closeLoading();
        }
      } else {
        ElMessage.error('获取预签名 URL 失败:' + result.msg);
        proxy?.$modal.closeLoading();
      }
    } catch (error: any) {
      ElMessage.error('上传过程中出现错误:' + error.message);
      proxy?.$modal.closeLoading();
    }
  };
  input.click();
};

onMounted(() => {
  setupPasteHandler();
});
</script>

<style scoped>
.editor,
.ql-toolbar {
  width: 100%;
  white-space: pre-wrap;
  line-height: normal;
}
.hidden-uploader {
  display: none; /* 隐藏上传组件 */
}
.ql-video {
  max-width: 100%;
  height: auto;
  display: block;
  margin: 10px 0;
}
::v-deep .ql-snow .ql-editor{
  overscroll-behavior:contain;
}
::v-deep .ql-snow .ql-editor img{
  display: block;
  max-width: 200px;
  margin: 0 auto;
}
::v-deep .ql-video{
  margin: 0 auto;
}

/* 图片上传加载遮罩样式 */
::v-deep .image-upload-loading .el-loading-spinner {
  margin-top: -25px;
}

::v-deep .image-upload-loading .el-loading-text {
  color: #fff;
  font-size: 16px;
  margin-top: 15px;
}

::v-deep .image-upload-loading .el-loading-spinner .circular {
  width: 50px;
  height: 50px;
}

::v-deep .image-upload-loading .el-loading-spinner .path {
  stroke: #409eff;
  stroke-width: 3;
}
</style>



页面: <editor :key="isSaveButtonVisible" :readOnly="!isSaveButtonVisible" v-model="user.deliveryRules" :min-height="192" />


    发表

    还没有评论哦,来抢个沙发吧!