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" />
您还未登录, 登录 后可进行评论
发表
还没有评论哦,来抢个沙发吧!