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; } }
复制