首页 前端知识 Vue 3 wangEditor 5 封装并使用富文本编辑器组件

Vue 3 wangEditor 5 封装并使用富文本编辑器组件

2025-03-09 15:03:16 前端知识 前端哥 560 126 我要收藏

1.实现效果

2.安装

官网:https://www.wangeditor.com

# Vue2 安装
yarn add @wangeditor/editor-for-vue
# 或者 npm install @wangeditor/editor-for-vue --save
# Vue3 安装
yarn add @wangeditor/editor-for-vue@next
# 或者 npm install @wangeditor/editor-for-vue@next --save
复制

3.封装组件(components -> MyEditor.vue)

<template>
<div style="border: 1px solid #ccc">
<Toolbar
v-if="showToolbarFlag"
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
style="border-bottom: 1px solid #ccc"
/>
<Editor
v-model="valueHtml"
:defaultConfig="editorConfig"
:mode="mode"
@onCreated="handleCreated"
@onChange="handleChange"
:style="{ height: editorHeight, overflowY: 'hidden' }"
:readOnly="readOnlyFlag"
/>
</div>
</template>
<script setup lang="ts">
import filesApi from '@/api/sys/files';
import { watch,onBeforeUnmount,nextTick, ref, shallowRef, onMounted,onBeforeMount } from 'vue'
//@ts-ignore
import { Editor, Toolbar } from '@wangeditor/editor-for-vue'
import '@wangeditor/editor/dist/css/style.css' // 引入 css
// Props:使用属性,子组件接收父组件传递的内容
const props = defineProps({
// 内容
content: { type: String, default: '' },
// 工具栏是否显示,默认显示
showToolbarFlag: { type: Boolean, default: true },
// 编辑器高度,默认500px
editorHeight: { type: String, default: '500px' },
// 编辑器是否只读,默认可编辑
readOnlyFlag: { type: Boolean, default: false }
})
// Emits:使用事件,将子组件内容传递给父组件。父组件使用 update(content: string)
const emit = defineEmits<{ (e: 'update', content: string): void }>()
const mode = ref('default')
// 编辑器实例,必须用 shallowRef
const editorRef = shallowRef()
// 内容 HTML
const valueHtml = ref('')
const toolbarConfig = {}
const editorConfig = {
placeholder: '请输入内容...' ,
MENU_CONF:{} as any
}
// 上传图片配置
editorConfig.MENU_CONF['uploadImage'] = {
// form-data fieldName ,默认值 'wangeditor-uploaded-image'。传给后端接口的参数名,重要!
fieldName: 'file',
server: 'http://localhost:8080/files/wangEditorUpload',
}
const handleCreated = (editor:any) => {
editorRef.value = editor // 记录 editor 实例,重要!
// 根据父组件传递的readOnlyFlag,设置编辑器为只读
if (props.readOnlyFlag) {
editorRef.value.disable();
} else {
editorRef.value.enable();
}
}
const handleChange = () => {
valueHtml.value = editorRef.value.getHtml()
emit('update', valueHtml.value)
}
// 监听 props 变化,监听父组件传来的content
watch(() => props.content, (newVal:string) => {
nextTick(() => {
if (editorRef.value) {
// console.log(" 当前编辑器的状态:", editorRef.value);
// 富文本编辑器按 html 格式回显
editorRef.value.setHtml(newVal)
valueHtml.value = newVal
}
})
}
)
onMounted(async() => {
await nextTick(); // 延迟渲染,确保 DOM 更新完成
if(props.content) {
valueHtml.value = props.content
}
})
// 组件销毁时,也及时销毁编辑器
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor == null) return
editor.destroy()
})
</script>
复制

4.引入并使用 MyEditor 组件

4.1 父组件使用默认props

<template>
<el-card class="container">
<template #header>
<div class="header">
<el-breadcrumb :separator-icon="ArrowRight">
<el-breadcrumb-item :to="{ path: '/home/index' }" class="title">首页</el-breadcrumb-item>
<el-breadcrumb-item class="title">文章管理</el-breadcrumb-item>
<el-breadcrumb-item class="title">文章</el-breadcrumb-item>
</el-breadcrumb>
<div>
<el-button type="primary" @click="addButton">新增文章</el-button>
<el-button type="danger" @click="batchRemove">批量删除</el-button>
</div>
</div>
</template>
<!-- 搜索表单 -->
<el-form inline>
<el-form-item label="标题:">
<el-input v-model="searchModel.title" placeholder="请输入文章标题" style="width: 150px"></el-input>
</el-form-item>
<el-form-item label="分类:">
<el-select placeholder="请选择" v-model="searchModel.categoryId" style="width: 150px">
<el-option v-for="item in categoryList" :key="item.id" :value="item.id" :label="item.categoryName"></el-option>
</el-select>
</el-form-item>
<el-form-item label="发布状态:">
<el-select placeholder="请选择" v-model="searchModel.status" style="width: 150px">
<el-option label="已发布" value="已发布"></el-option>
<el-option label="草稿" value="草稿"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="artcleListFuction">搜索</el-button>
<el-button @click="reset">重置</el-button>
</el-form-item>
</el-form>
<!-- 列表 -->
<el-table :data="articleList" ref="multipleTableRef" border stripe style="width: 100%" height="550" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="50" />
<el-table-column label="序号" type="index" width="100" :index="computeRowIndex" />
<el-table-column label="标题" width="200" prop="title"></el-table-column>
<el-table-column label="分类" prop="categoryName"></el-table-column>
<el-table-column label="状态" prop="status"></el-table-column>
<el-table-column label="发表时间" prop="createTime"> </el-table-column>
<el-table-column label="更新时间" prop="ts"> </el-table-column>
<el-table-column label="操作" width="120">
<template #default="{ row }">
<el-button :icon="Edit" circle plain type="primary" @click="edit(row)"></el-button>
<el-button :icon="Delete" circle plain type="danger" @click="remove(row)"></el-button>
</template>
</el-table-column>
<template #empty>
<el-empty description="没有数据" />
</template>
</el-table>
<!-- 分页 -->
<el-pagination
v-model:current-page="searchModel.currentPage"
v-model:page-size="searchModel.pageSize"
:page-sizes="[10, 30, 50, 100]"
layout="jumper, total, sizes, prev, pager, next"
:total="searchModel.total"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
background
style="margin: 10px 0; justify-content: flex-end"
/>
<!-- 添加文章 -->
<el-drawer v-model="visibleDrawer" size="50%" @close="close">
<template #header>
<h1>{{ title }}</h1>
</template>
<el-form :model="articleModel" ref="formRef" :rules="rules" label-width="100px">
<el-form-item label="文章标题" prop="title">
<el-input v-model="articleModel.title" placeholder="请输入标题"></el-input>
</el-form-item>
<el-form-item label="文章分类" prop="categoryId">
<el-select placeholder="请选择" v-model="articleModel.categoryId">
<el-option v-for="item in categoryList" :key="item.id" :value="item.id" :label="item.categoryName"></el-option>
</el-select>
</el-form-item>
<el-form-item label="文章封面">
<el-upload class="avatar-uploader" :auto-upload="false"
action="/api/upload"
name="file"
:headers="{'X-Token':useTokenStore().token}"
:on-preview="handlePictureCardPreview"
:on-remove="handleRemove"
:on-success="uploadSuccess"
>
<img v-if="articleModel.coverImg" :src="articleModel.coverImg" />
<el-icon v-else><Plus /></el-icon>
</el-upload>
<!-- 图片预览 -->
<el-dialog v-model="dialogVisible">
<el-image :src="dialogImageUrl"/>
</el-dialog>
</el-form-item>
<!-- vue-quill 富文本编辑器 -->
<!-- <el-form-item label="文章内容">
<div class="editor">
<TextEditor v-model:content="articleModel.content" />
</div>
</el-form-item> -->
<!-- wang-editor 富文本编辑器 -->
<!-- articleModel.content 没有双向数据绑定,所以通过 update() 更新其值,从而触发 content 变化,被 MyEditor 子组件监听 -->
<el-form-item label="文章内容">
<MyEditor :content="articleModel.content" @update="update" />
</el-form-item>
<el-form-item>
<el-button type="primary" @click="save('已发布')">发布</el-button>
<el-button type="info" @click="save('草稿')">保存草稿</el-button>
</el-form-item>
</el-form>
</el-drawer>
</el-card>
</template>
<script setup lang="ts">
import { ref,reactive,onMounted,watch,toRaw } from 'vue'
import { Edit,Delete,Plus,ArrowRight } from '@element-plus/icons-vue'
import articleApi from '@/api/article/article';
import { ElMessage, ElMessageBox,type UploadProps,type UploadUserFile } from 'element-plus'
import { useTokenStore } from '@/stores/token'
import '@vueup/vue-quill/dist/vue-quill.snow.css'
// import TextEditor from '@/components/TextEditor.vue';
import MyEditor from '@/components/MyEditor.vue';
const categoryList=ref()
const articleList=ref()
const articleModel=reactive({
id:'',
categoryId:'',
title:'',
content:'',
coverImg:'',
status:''
})
const initArticleModel={ ...articleModel }
// 批量删除的 id
const ids=ref<string[]>([])
const visibleDrawer=ref(false)
const dialogImageUrl = ref('')
const dialogVisible = ref(false)
// 新增或编辑
const title=ref();
const formRef=ref()
const multipleTableRef=ref()
// 分页&搜索模型
const searchModel=reactive({
currentPage:1,
pageSize:10,
total:0,
title:'',
status:'',
categoryId:''
})
const initSearchModel={ ...searchModel }
const rules = {
title:[
{ required: true, message:'请输入标题', trigger: 'blur'},
],
categoryId:[
{ required: true, message:'请选择分类', trigger: 'blur'},
],
}
// pageSize 变化时触发
const handleSizeChange = (val: number) => {
searchModel.pageSize=val;
artcleListFuction();
}
// currentPage 变化时触发
const handleCurrentChange = (val: number) => {
searchModel.currentPage=val;
artcleListFuction();
}
const categoryListFuction= async()=>{
const response= await articleApi.categoryGetAll();
categoryList.value=response.data;
}
const artcleListFuction= async()=>{
const response= await articleApi.articleList(searchModel);
articleList.value=response.data.records;
searchModel.currentPage=response.data.current;
searchModel.pageSize=response.data.size;
searchModel.total=response.data.total;
}
// 批量删除
const handleSelectionChange = (rows: any) => {
ids.value = rows.map((item:any) => item.id);
}
const remove= async(row:any)=>{
ElMessageBox.confirm(
`是否删除标题为 [ ${row.title} ] 的文章?`,
'温馨提示',
{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async() => {
ids.value.push(row.id);
await articleApi.articleRemove(ids.value);
ElMessage({
type: 'success',
message: '删除成功',
})
artcleListFuction();
})
}
// 批量删除
const batchRemove= ()=>{
if(ids.value.length > 0){
ElMessageBox.confirm( `是否批量删除?`, '温馨提示',{
confirmButtonText: '确认',
cancelButtonText: '取消',
type: 'warning',
}
)
.then(async() => {
await articleApi.articleRemove(ids.value);
ElMessage({ type: 'success',message: '删除成功' });
artcleListFuction();
})
}else{
ElMessage.warning('请选择删除项');
}
}
// 重置搜索框
const reset= ()=>{
Object.assign(searchModel, initSearchModel);
artcleListFuction();
}
// 重置新增、编辑抽屉
const resetDrawer= ()=>{
// 清除数据
Object.assign(articleModel, initArticleModel);
// 清除校验信息
formRef.value.clearValidate();
}
const close= ()=>{
// 清除数据
Object.assign(articleModel, initArticleModel);
// articleModel.content = ''
// 清除校验信息
formRef.value.clearValidate();
}
const uploadSuccess= (response:any)=>{
articleModel.coverImg=response.data;
}
const handleRemove: UploadProps['onRemove'] = (uploadFile, uploadFiles) => {
console.log(uploadFile, uploadFiles)
}
const handlePictureCardPreview: UploadProps['onPreview'] = (uploadFile) => {
dialogImageUrl.value = uploadFile.url!
dialogVisible.value = true
}
const save= async(value:string)=>{
formRef.value.validate(async(valid:any) => {
if (valid) {
articleModel.status=value;
const response=await articleApi.articleSaveOrUpdate(articleModel) as any;
ElMessage.success(response.msg);
reset();
visibleDrawer.value=false;
artcleListFuction();
} else {
ElMessage.error('请校验表单');
return false;
}
})
}
const edit= (row:any)=>{
visibleDrawer.value=true;
title.value="编辑文章";
// 复制属性
Object.assign(articleModel, row);
}
const addButton= ()=>{
visibleDrawer.value=true;
title.value="添加文章";
articleModel.content='';
resetDrawer();
}
// 计算序号
const computeRowIndex = (index:any) => {
return (searchModel.currentPage - 1) * searchModel.pageSize + index + 1;
}
// 更新富文本编辑器内容
const update = (content:string) => {
articleModel.content=content
};
onMounted(()=>{
artcleListFuction();
categoryListFuction();
})
</script>
<style scoped lang="less">
.container{
height: 100%;
box-sizing: border-box;
}
.header{
display: flex;
align-items: center;
justify-content: space-between;
}
.title{
font-size: large;
font-weight: 600;
}
</style>
复制

4.2 父组件使用自定义props

5.富文本上传图片,后端返回数据格式处理

5.1 文件上传下载后端实现(本文需关注download、wangEditorUpload接口)

package com.dragon.springboot3vue3.controller;
import cn.dev33.satoken.util.SaResult;
import cn.hutool.core.collection.CollUtil;
import cn.hutool.core.collection.CollectionUtil;
import cn.hutool.core.io.FileUtil;
import cn.hutool.core.lang.Dict;
import cn.hutool.core.util.IdUtil;
import cn.hutool.core.util.StrUtil;
import cn.hutool.crypto.SecureUtil;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.dragon.springboot3vue3.controller.dto.pageDto.FilesPageDto;
import com.dragon.springboot3vue3.entity.Files;
import com.dragon.springboot3vue3.service.IFilesService;
import com.dragon.springboot3vue3.utils.StringIdsDTO;
import com.github.yulichang.wrapper.MPJLambdaWrapper;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.ServletOutputStream;
import jakarta.servlet.http.HttpServletResponse;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* <p>
* 文件表 前端控制器
* </p>
*
* @author dragon
* @since 2024-05-25
*/
@Tag(name = "文件上传下载接口")
@RestController
@RequestMapping("/files")
public class FilesController {
@Autowired
private IFilesService filesService;
@Value("${files.upload.path}")
private String path;
@Value("${files.upload.address}")
private String address;
@Operation(summary = "文件上传")
@PostMapping("/upload")
public SaResult upload(@RequestParam MultipartFile file) throws IOException {
String originalFilename = file.getOriginalFilename();
String type = FileUtil.extName(originalFilename);
long size = file.getSize();
// 如果文件目录不存在,则新建
File uploadParentFile = new File(path);
if(!uploadParentFile.exists()){
uploadParentFile.mkdirs();
}
// 保证存储的文件名唯一
String fileName=IdUtil.fastSimpleUUID() + StrUtil.DOT + type;
File uploadFile = new File(path + fileName);
// 拼接下载的url
String url = address+fileName;
// 将文件存储到磁盘
file.transferTo(uploadFile);
// 生成文件唯一标识 md5,保证不会在磁盘存储重复的文件
String md5 = SecureUtil.md5(uploadFile);
List<Files> list = filesService.lambdaQuery().eq(Files::getMd5, md5).list();
if(CollectionUtil.isNotEmpty(list)){
url=list.getFirst().getUrl();
uploadFile.delete();
}
// 文件信息存储到数据库
Files saveFile = new Files();
saveFile.setName(originalFilename);
saveFile.setType(type);
// 单位转换 B -> KB
saveFile.setSize(size/1024);
saveFile.setUrl(url);
saveFile.setMd5(md5);
filesService.save(saveFile);
return SaResult.ok().setData(url);
}
@Operation(summary = "文件下载")
@GetMapping("/{fileName}")
public void download(@PathVariable String fileName, HttpServletResponse response) throws IOException {
// 在指定目录下,根据文件名查找文件
File file = new File(path + fileName);
ServletOutputStream outputStream = response.getOutputStream();
// 设置输出流格式
response.addHeader("Content-Disposition","attachment;filename=" + URLEncoder.encode(fileName, StandardCharsets.UTF_8));
response.setContentType("application/octet-stream");
// 读取文件字节流
outputStream.write(FileUtil.readBytes(file));
outputStream.flush();
outputStream.close();
}
@Operation(summary = "分页列表")
@PostMapping("/list")
public SaResult list(@RequestBody FilesPageDto pageDto){
// 创建分页对象
Page<Files> page=new Page<>(pageDto.getCurrentPage(), pageDto.getPageSize());
// 构造询条件
MPJLambdaWrapper<Files> qw=new MPJLambdaWrapper<Files>()
.like(StringUtils.isNotBlank(pageDto.getName()),Files::getName, pageDto.getName())
.like(StringUtils.isNotBlank(pageDto.getType()),Files::getType, pageDto.getType())
.orderByDesc(Files::getCreateTime);
// 根据查询条件,将结果封装到分页对象
Page<Files> response = filesService.page(page, qw);
return SaResult.ok().setData(response);
}
@Operation(summary = "删除")
@DeleteMapping("/remove")
public SaResult remove(@RequestBody @Validated StringIdsDTO stringIdsDTO){
filesService.removeByIds(stringIdsDTO.getIds());
return SaResult.ok();
}
@Operation(summary = "wang-editor 富文本编辑器文件上传")
@PostMapping("/wangEditorUpload")
public Map<String, Object> wangEditorUpload(@RequestParam MultipartFile file) throws IOException {
String originalFilename = file.getOriginalFilename();
String type = FileUtil.extName(originalFilename);
long size = file.getSize();
// 如果文件目录不存在,则新建
File uploadParentFile = new File(path);
if(!uploadParentFile.exists()){
uploadParentFile.mkdirs();
}
// 保证存储的文件名唯一
String fileName=IdUtil.fastSimpleUUID() + StrUtil.DOT + type;
File uploadFile = new File(path + fileName);
// 拼接下载的url
String url = address+fileName;
// 将文件存储到磁盘
file.transferTo(uploadFile);
// 生成文件唯一标识 md5,保证不会在磁盘存储重复的文件
String md5 = SecureUtil.md5(uploadFile);
List<Files> list = filesService.lambdaQuery().eq(Files::getMd5, md5).list();
if(CollectionUtil.isNotEmpty(list)){
url=list.getFirst().getUrl();
uploadFile.delete();
}
// 文件信息存储到数据库
Files saveFile = new Files();
saveFile.setName(originalFilename);
saveFile.setType(type);
// 单位转换 B -> KB
saveFile.setSize(size/1024);
saveFile.setUrl(url);
saveFile.setMd5(md5);
filesService.save(saveFile);
// 封装 wang-editor 数据返回格式
Map<String, Object> map =new HashMap<>();
map.put("errno",0);
map.put("data", CollUtil.newArrayList(Dict.create().set("url",url)));
return map;
}
}
复制

转载请注明出处或者链接地址:https://www.qianduange.cn//article/23058.html
标签
评论
会员中心 联系我 留言建议 回顶部
复制成功!