首页 > 其他分享 >Vue 3 + wangEditor 5 封装并使用富文本编辑器组件

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

时间:2024-08-26 09:51:25浏览次数:8  
标签:saveFile 文本编辑 Vue const String wangEditor value import response

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
      :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: 500px; overflow-y: hidden;"
    />
  </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: '' }
  })

  // 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 实例,重要!
  }

  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 组件

<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>

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

标签:saveFile,文本编辑,Vue,const,String,wangEditor,value,import,response
From: https://blog.csdn.net/qq_58159506/article/details/141501423

相关文章

  • 115基于springboot+vue的超市进销存系统
     开发语言:Java框架:springbootJDK版本:JDK1.8服务器:tomcat7数据库:mysql5.7(一定要5.7版本)数据库工具:Navicat11开发软件:eclipse/myeclipse/ideaMaven包:Maven3.3.9系统展示登录界面注册界面管理员功能界面员工管理界面客户管理界面供应商管理界面承运商管理界面仓......
  • Vue 项目实战1-学习计划表
    Vue项目实战1-学习计划表一、大致实现思路1.页面结构设计  使用ElementUI的`el-card`、`el-form`、`el-table`等组件来构建页面的基本结构。  使用`el-row`和`el-col`来实现水平布局。2.数据模型  使用Vue的数据模型来存储学习计划的信息,......
  • java毕业设计-基于springboot+vue的在线付费自习室管理系统,基于SpringBoot+Vue的自习
    文章目录前言系统功能演示视频项目架构和内容获取(文末获取)具体实现截图用户前台管理后台架构设计MVC的设计模式基于B/S的架构技术栈具体功能模块设计系统需求分析可行性分析系统测试为什么我?关于我我自己的网站项目开发案例项目相关文件前言博主介绍:✌️码农一枚......
  • 【VUE声明式导航跳转如何传参】router-link查询参数传参&动态路由传参
    VUE声明式导航跳转如何传参文章目录VUE声明式导航跳转如何传参前言一、查询参数传参语法实现步骤1.实现【首页】和【搜索页】的基础点击功能2.实现【首页】向【搜索页】跳转时的传参功能JS中如何获取传值二、动态路由传参语法实现步骤1.实现首页和搜索页的基础功能2.......
  • 第8篇 vue开发环境搭建
    window系统上部署vue的开发环境1.安装nodejs1.1下载并安装node.js在浏览器中打开nodejs官网https://nodejs.org/zh-cn/,选择需要的版本直接点击即可下载,可以选择长期支持的版本【自由选择】然后就是一系列的“下一步”2.检查nodejs是否安装成功打开cmd,输入命令n......
  • 基于java+springboot+vue的刷题系统微信小程序
    ......
  • Chapter 03 Vue指令(下)
    欢迎大家订阅【Vue2+Vue3】入门到实践专栏,开启你的Vue学习之旅!文章目录前言一、v-on指令二、v-for指令三、v-bind指令四、v-model指令前言在Vue.js中,指令是带有v-前缀的特殊属性,不同属性对应不同的功能。通过学习不同的指令,我们能够灵活应对多种业务场景......
  • Django后台管理Xadmin使用DjangoUeditor富文本编辑器
    Django后台管理Xadmin使用DjangoUeditor富文本编辑器一、下载点击github下载https://github.com/twz915/DjangoUeditor31、下载完后解压到跟xadmin同一层级目录:2、解压后名称可能为DjangoUeditor3-master,需要改为DjangoUeditor3、进入DjangoUeditor目录,把DjangoUedit......
  • 基于springboot+vue.js的牙科就诊管理系统附带文章源码部署视频讲解等
    文章目录前言详细视频演示具体实现截图核心技术介绍后端框架SpringBoot前端框架Vue持久层框架MyBaits为什么选择我代码参考数据库参考测试用例参考源码获取前言......
  • 基于ssm+vue.js的附学费管理系统带文章源码部署视频讲解等
    文章目录前言详细视频演示具体实现截图核心技术介绍后端框架SSM前端框架Vue持久层框架MyBaits为什么选择我代码参考数据库参考测试用例参考源码获取前言......