vue-quill-editor 富文本上传图片时会向服务器发请求,但是在删除图片时并不会发请求,因此没用的图片会在服务器中会堆积。
我们就需要在删除图片时向服务器发请求,本人在网上搜索了许多资料,大家基本都是在失焦事件时发送请求,但本人的富文本镶嵌在弹窗中,尝试之后发现不适用,因此自己手写了需求。
由于本人技术有限,代码不是最佳方案,欢迎讨论。本文章篇幅有点长,点击这里可以直接跳转到github查看三个文件的代码。
本项目弹窗示例图
难点
富文本被镶嵌在弹窗中,则弹窗与富文本是父与子的关系,并且弹窗中有"确定" 与 "取消"操作,需要考虑到的情况有很多。需要父与子之间互相通信。
设计思路
先说总体思路
- 根据不同的操作情况,获取对应的 "要删除的图片的列表"以及"上传的图片的列表"。
- 在富文本的onEditorChange内容改变事件中,抛出这两个列表数据。
- 弹窗父组件接收数据,用户点击"确定"或"取消"按钮后发送删除请求。
- 富文本组件中,监听弹窗是否处于打开状态,来判断是否清空列表数据。
注意:粘贴的图片是以字节的形式出现的,不需要发送删除请求,在示例代码一 中会把粘贴的图片过滤掉。
1. 具体分析,获取列表数据
逻辑比较复杂,首先进行情况分析,需要考虑到的情况有:
i. "确定" 操作:
- 新建:存在 删除"当前上传的图片" 后确认的操作。
- 修改:存在 删除"之前已保存的图片"、删除"当前上传的图片" 后确认的操作。
此时,要删除的图片的列表 可以通俗地总结为 "弹窗中出现过的所有图片" 减去 "当前弹窗中存在的图片"(减去操作在示例代码四)。
示例代码一 :在富文本文件中(Editor/index.vue),监听富文本编辑器的内容。获取"弹窗中出现过的所有图片" 和 "当前弹窗中存在的图片" 两个列表数据。
watch:{
value:{
handler(val) {
// 内容为空时,清空编辑器数据
this.content = val ? val : '';
// currentImages :当前弹窗中存在的图片。 allImages :弹窗中出现过的所有图片
/** getSrc 方法:取出编辑器内所有img的src。(示例代码二)
* replace 方法:删除`${process.env.VUE_APP_BASE_API}/profile/upload`字符串(根据删除请求的参数进行修改的)
* filter 方法:过滤掉粘贴上去的图片。(粘贴的图片存在'data:image'字符串)
**/
this.currentImages = this.getSrc(val).map(item => item.replace(`${process.env.VUE_APP_BASE_API}/profile/upload`,'')).filter(item => item.indexOf('data:image') === -1)
if (this.currentImages.length !== 0){
this.currentImages.forEach(item => {
if (this.allImages.length !== 0){ // allImages不为空时,插入不存在的数据
if (this.allImages.indexOf(item) === -1) this.allImages.push(item)
}else {
this.allImages.push(item)
}
})
}
},
immediate: true
}
},
示例代码二 : getSrc 方法:取出区域内所有img的src
getSrc (html) {
let imgReg = /<img.*?(?:>|/>)/gi
// 匹配src属性
let srcReg = /src=[\"]?([^\"]*)[\"]?/i
let arr = html.match(imgReg)
let imgs = []
if (arr) {
for (let i = 0; i < arr.length; i++) {
var src = arr[i].match(srcReg)[1]
imgs.push(src)
}
}
return imgs
}
ii. "取消"操作:
取消操作相当于是撤销操作。
- 新建:存在 "删除当前上传的图片"、"上传了照片但不删除" 后取消的操作。
- 修改:存在 "删除之前已保存的图片"、"上传了照片但不删除" 后取消的操作。
此时,要删除的图片的列表 可以通俗地总结为 上传的图片的列表。
示例代码三 : 在富文本文件中(Editor/index.vue),在上传附件成功后(el-upload的on-success属性),获取上传的图片的列表
uploadSuccess(res, file){
// ......
// 本项目的删除请求的参数不需要字符串"/profile/upload",因此去除
this.uploadImages.push(res.fileName.replace('/profile/upload', ''))
// ......
}
2. 获取到列表数据后,进行父子通信
i. 富文本组件向弹窗父组件抛出数据
示例代码四: 在富文本文件中(Editor/index.vue),在 onEditorChange 事件中抛出数据。
// 内容改变事件
onEditorChange() {
this.$emit('input', this.content)
this.$nextTick(() => {
// 抛出事件,把要删除的列表数据 以及 上传的图片的列表数据 抛出。
// remove 结果即为 "弹窗中出现过的所有图片" 减去 "当前弹窗中存在的图片"
// remove 可以理解为是 “目前被删掉的图片”
let remove = this.allImages.filter(item => this.currentImages.every((e) => e !== item))
this.$emit('remove-images',remove,this.uploadImages)
})
},
ii. 父组件接收列表数据,根据操作发送删除请求。
由于多个页面引用了弹窗富文本组件,因此我把弹窗的方法写在了mixins中。
示例代码五:editor_mixin.js
import { deleteFile } from '@/api/editor/editor'
export default {
data(){
return{
removeList: [], // 需要删除的图片列表
cancelStatus: false, // 是否为取消状态
uploadList: [] // 上传图片的列表,用于点取消操作时删除图片。
}
},
methods:{
// 监听remove-images事件,获取图片列表数据
onRemoveImages(removeList, uploadList){
this.removeList = removeList
this.uploadList = uploadList
},
// 删除图片
onDeleteFile(){
if (this.cancelStatus) { // 取消操作
if (this.uploadList.length === 0){ // 修改时,没有上传照片,删除了原有的照片但取消操作。
this.removeList = []
}else{ // 修改或新建时,上传了照片
this.removeList = this.$clone(this.uploadList)
}
}
this.removeList = this.removeList.join(',') // 本项目的deleteFile删除接口,可以接受多个文件名,因此用逗号隔开
this.cancelStatus = false
if (this.removeList){
// 发送删除请求
deleteFile(this.removeList).then( response => {
console.log(response.msg)
})
}
}
}
}
示例代码六: 页面引用富文本组件的部分代码。
<template>
<el-dialog :title="title" :visible.sync="open" width="1280px" append-to-body :before-close="cancel">
<!-- ...... -->
<editor v-model="form.noticeContent" :min-height="192" @remove-images="onRemoveImages" :cancel-operate="open"/>
<!-- ......-->
</el-dialog>
</template>
<script>
import editor_mixin from '@/views/message/mixins/editor_mixin'
export default {
mixins:[editor_mixin],
// 取消按钮
cancel() {
this.open = false; // 关闭弹窗
this.cancelStatus = true // 取消操作状态
this.onDeleteFile() // 删除图片
this.reset();
},
/** 提交按钮 */
submitForm: function () {
this.$refs["form"].validate(valid => {
if (valid) {
if (this.form.noticeId != undefined) {
updateNotice(this.form).then(response => {
this.onDeleteFile() // 删除图片
this.$modal.msgSuccess("修改成功");
this.open = false;
this.getList();
});
} else {
addNotice(this.form).then(response => {
this.onDeleteFile() // 删除图片
this.$modal.msgSuccess("新增成功");
this.open = false;
this.getList();
});
}
}
});
},
}
</script>
iii. 最后,在富文本组件中,监听弹窗是否显示, 来判断是否清空图片列表数据
示例代码六:清空数据
computed:{
isOpen(){ // 根据弹窗是否显示, 来判断是否清空图片列表数据
return this.cancelOperate
}
},
watch:{
isOpen:{
handler(val){
if (!val) {
this.currentImages = []
this.allImages = []
this.uploadImages = []
}
}
}
}
结束语:本文章篇幅有点长,点击这里可以直接跳转到github查看三个文件的代码。
在这里放出富文本组件的完整代码:Editor/index.js
<template>
<div>
<!-- 图片上传组件:这里整合的是elementUI的组件-->
<el-upload
v-show="false"
:action="uploadUrl"
:headers="header"
:show-file-list="false"
:on-success="uploadSuccess"
:on-error="uploadError"
:before-upload="beforeUpload"
class="avatar-uploader"
accept="image/*"
name="file" />
<!-- 实际富文本显示区域 -->
<quill-editor
ref="myQuillEditor"
v-model="content"
:options="editorOption"
class="editor"
@blur="onEditorBlur($event)"
@focus="onEditorFocus($event)"
@change="onEditorChange($event)" />
</div>
</template>
<script>
// 需要引入的文件
import { quillEditor } from 'vue-quill-editor'
import 'quill/dist/quill.core.css'
import 'quill/dist/quill.snow.css'
import 'quill/dist/quill.bubble.css'
import { getToken } from "@/utils/auth";
// 工具栏配置
const toolbarOptions = [
['bold', 'italic', 'underline', 'strike'], // 加粗 斜体 下划线 删除线
['blockquote', 'code-block'], // 引用 代码块
[{ header: 1 }, { header: 2 }], // 1、2 级标题
[{ list: 'ordered' }, { list: 'bullet' }], // 有序、无序列表
[{ script: 'sub' }, { script: 'super' }], // 上标/下标
[{ indent: '-1' }, { indent: '+1' }], // 缩进
// [{'direction': 'rtl'}], // 文本方向
// [{ size: ['small', false, 'large', 'huge'] }], // 字体大小
[{ header: [1, 2, 3, 4, 5, 6, false] }], // 标题
[{ color: [] }, { background: [] }], // 字体颜色、字体背景颜色
[{ font: [] }], // 字体种类
[{ align: [] }], // 对齐方式
['clean'], // 清除文本格式
['link', 'image'] // 链接、图片、视频 ['link', 'image', 'video']
]
export default {
components: {
quillEditor
},
props: {
/* 编辑器的内容 */
value: {
type: String,
default: ''
},
/* 图片大小 */
maxSize: {
type: Number,
default: 1024 * 5 // kb
},
cancelOperate:{
type: Boolean
}
},
data() {
return {
uploadUrl: process.env.VUE_APP_BASE_API + "/common/upload", // 上传的图片服务器地址
content: this.value,
quillUpdateImg: false, // 根据图片上传状态来确定是否显示loading动画,刚开始是false,不显示
uploadImages: [], // 已上传的所有图片
currentImages:[], // 当前存在的图片
allImages:[], // 出现过的所有的图片
isCancel: false,
editorOption: {
theme: 'snow', // or 'bubble'
placeholder: '请在这里输入...',
modules: {
toolbar: {
container: toolbarOptions,
handlers: {
// 处理图片上传
image: function(value) {
if (value) {
// 触发input框选择图片文件
document.querySelector('.avatar-uploader input').click()
} else {
this.quill.format('image', false)
}
}
}
}
}
},
header: {
Authorization: "Bearer " + getToken()
// token: sessionStorage.token
}, // 有的图片服务器要求请求头需要有token
}
},
computed:{
isOpen(){ // 根据弹窗是否显示, 来判断是否清空图片列表数据
return this.cancelOperate
}
},
watch:{
isOpen:{
handler(val){
if (!val){
this.currentImages = []
this.allImages = []
this.uploadImages = []
}
}
},
value:{
handler(val){
this.content = val ? val : '';
// 当前弹窗所有的图片
this.currentImages = this.getSrc(val).map(item => item.replace(`${process.env.VUE_APP_BASE_API}/profile/upload`,'')).filter(item => item.indexOf('data:image') === -1)
if (this.currentImages.length !== 0){
this.currentImages.forEach(item => {
if (this.allImages.length !== 0){
if (this.allImages.indexOf(item) === -1) this.allImages.push(item)
}else {
this.allImages.push(item)
}
})
}
},
immediate: true
}
},
methods: {
// 失去焦点事件
onEditorBlur() {
},
onEditorFocus() {
// 获得焦点事件
},
// 内容改变事件
onEditorChange() {
this.$emit('input', this.content)
this.$nextTick(() => {
// 抛出事件,把要删除的列表数据抛出
let remove = this.allImages.filter(item => this.currentImages.every((e) => e !== item))
this.$emit('remove-images',remove,this.uploadImages)
})
},
// 富文本图片上传前
beforeUpload() {
// 显示loading动画
this.quillUpdateImg = true
},
uploadSuccess(res, file) {
// res为图片服务器返回的数据
// 获取富文本组件实例
const quill = this.$refs.myQuillEditor.quill
// 如果上传成功
if (res.code == 200) {
this.$message.success('图片上传成功')
// 获取光标所在位置
const length = quill.getSelection().index
// 插入图片 res.url为服务器返回的图片地址,需要拼接图片服务根地址
quill.insertEmbed(length, 'image', process.env.VUE_APP_BASE_API + res.fileName)
// 调整光标到最后
quill.setSelection(length + 1)
this.uploadImages.push(res.fileName.replace('/profile/upload',''))
} else {
this.$message.warning('图片插入失败')
}
// loading动画消失
this.quillUpdateImg = false
},
// 富文本图片上传失败
uploadError() {
// loading动画消失
this.quillUpdateImg = false
this.$message.warning('图片插入失败')
},
/**
* 取出区域内所有img的src
*/
getSrc (html) {
let imgReg = /<img.*?(?:>|/>)/gi
// 匹配src属性
let srcReg = /src=[\"]?([^\"]*)[\"]?/i
let arr = html.match(imgReg)
let imgs = []
if (arr) {
for (let i = 0; i < arr.length; i++) {
var src = arr[i].match(srcReg)[1]
imgs.push(src)
}
}
return imgs
},
}
}
</script>
<!-- 富文本样式,需要加上 -->
<style lang="scss">
.editor {
// width: 700px;
line-height: normal !important;
.ql-editor {
min-height: 300px !important;
height: auto;
}
.ql-snow .ql-tooltip[data-mode='link']::before {
content: '请输入链接地址:';
}
.ql-snow .ql-tooltip.ql-editing a.ql-action::after {
border-right: 0px;
content: '保存';
padding-right: 0px;
}
.ql-snow .ql-tooltip[data-mode='video']::before {
content: '请输入视频地址:';
}
.ql-snow .ql-picker.ql-size .ql-picker-label::before,
.ql-snow .ql-picker.ql-size .ql-picker-item::before {
content: '14px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='small']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='small']::before {
content: '10px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='large']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='large']::before {
content: '18px';
}
.ql-snow .ql-picker.ql-size .ql-picker-label[data-value='huge']::before,
.ql-snow .ql-picker.ql-size .ql-picker-item[data-value='huge']::before {
content: '32px';
}
.ql-snow .ql-picker.ql-header .ql-picker-label::before,
.ql-snow .ql-picker.ql-header .ql-picker-item::before {
content: '文本';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='1']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='1']::before {
content: '标题1';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='2']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='2']::before {
content: '标题2';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='3']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='3']::before {
content: '标题3';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='4']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='4']::before {
content: '标题4';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='5']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='5']::before {
content: '标题5';
}
.ql-snow .ql-picker.ql-header .ql-picker-label[data-value='6']::before,
.ql-snow .ql-picker.ql-header .ql-picker-item[data-value='6']::before {
content: '标题6';
}
.ql-snow .ql-picker.ql-font .ql-picker-label::before,
.ql-snow .ql-picker.ql-font .ql-picker-item::before {
content: '标准字体';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='serif']::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='serif']::before {
content: '衬线字体';
}
.ql-snow .ql-picker.ql-font .ql-picker-label[data-value='monospace']::before,
.ql-snow .ql-picker.ql-font .ql-picker-item[data-value='monospace']::before {
content: '等宽字体';
}
}
</style>