2025-05-31

Element Plus 动态表格单元格合并:span-method 方法精粹总结


目录

  1. 前言
  2. span-method 介绍
  3. API 详解与参数说明
  4. 动态合并场景讲解

    • 4.1 按某列相同值合并行
    • 4.2 多条件合并(行与列)
  5. 完整示例:基于“类别+状态”分组合并

    • 5.1 数据结构与需求分析
    • 5.2 代码实现(模板 + 脚本)
    • 5.3 运行效果图解
  6. 常见坑与优化建议
  7. 总结

前言

在使用 Element Plus 的 <el-table> 时,我们经常会遇到需要“合并单元格”以提升数据可读性或分组效果的场景。例如:当多条数据在“类别”或“组别”字段完全相同时,合并对应行,让表格更紧凑、更直观。Element Plus 提供了 span-method 属性,让我们以函数方式动态控制某个单元格的 rowspancolspan。本文将从原理、参数、示例、图解等多个角度讲解如何使用 span-method,并在一个“基于 类别 + 状态 分组合并”的综合示例中,手把手演示动态合并逻辑与实现方式。


span-method 介绍

在 Element Plus 的 <el-table> 中,设置 :span-method="yourMethod" 后,组件会在渲染每一个单元格时调用 yourMethod 方法,并通过它返回的 [rowspan, colspan] 数组,去控制当前单元格的合并状态。其最典型的用例是“相邻行相同值时,合并对应行单元格”。

典型语法示例:

<el-table
  :data="tableData"
  :span-method="spanMethod"
  style="width: 100%">
  <el-table-column
    prop="category"
    label="类别">
  </el-table-column>
  <el-table-column
    prop="name"
    label="名称">
  </el-table-column>
  <el-table-column
    prop="status"
    label="状态">
  </el-table-column>
</el-table>
methods: {
  spanMethod({ row, column, rowIndex, columnIndex }) {
    // 这里需要返回一个 [rowspan, colspan] 数组
    return [1, 1];
  }
}

上面例子中,spanMethod 会在渲染每个单元格时被调用,接收参数信息后,返回一个长度为 2 的数组,表示该单元格的 rowspancolspan。若都为 1 则表示不合并;若 rowspan > 1,则向下合并多行;若 colspan > 1,则向右合并多列;若任意一项为 0,则表示该单元格处于被“合并态”中,渲染时被省略不可见。


API 详解与参数说明

span-method 函数签名与参数:

type SpanMethodParams = {
  /** 当前行的数据对象 */
  row: Record<string, any>;
  /** 当前列的配置对象 */
  column: {
    property: string;
    label: string;
    [key: string]: any;
  };
  /** 当前行在 tableData 中的索引,从 0 开始 */
  rowIndex: number;
  /** 当前列在 columns 数组中的索引,从 0 开始 */
  columnIndex: number;
};

该方法必须返回一个 [rowspan, colspan] 数组,例如:

  • [2, 1]:表示此单元格向下合并 2 行、横向不合并。
  • [1, 3]:表示此单元格向下不合并、向右合并 3 列。
  • [0, 0]:表示此单元格已被上方或左方合并,不再渲染。
  • 未命中任何合并条件时,建议返回 [1, 1]

注意点:

  1. 只对单元格一级处理:无论是 rowspan 还是 colspan,都只针对当前层级进行合并,其它嵌套 header、复杂表头中的跨行合并要另行考虑。
  2. 覆盖默认的 rowspan:若你在 <el-table-column> 上同时设置了固定的 rowspanspan-method 会优先执行,并覆盖该属性。
  3. 返回 0 表示被合并单元格:当某单元格返回 [0, 0][0, n][m, 0],它都会处于“被合并状态”并被隐藏,不占据渲染空间。

动态合并场景讲解

常见的动态合并,主要有以下几种场景:

4.1 按某列相同值合并行

需求:
对某一列(如“类别”)序号相同的相邻行,自动将“类别”单元格合并成一格,避免在“类别”列中重复渲染相同文字。例如:

序号类别名称状态
1A任务 A1进行中
2A任务 A2已完成
3B任务 B1进行中
4B任务 B2已完成
5B任务 B3暂停
6C任务 C1进行中

上述表格中,如果按照 category 列做合并,那么行 1、2(同属 A)应合并成一个“类别”为 A 的单元格;行 3、4、5(三行同属 B)合并成一个“类别”为 B 的单元格;行 6(C)独立。

核心思路:

  • 遍历 tableData,统计每个“相同类别”连续出现的行数(“分组信息”);
  • 渲染合并时,将分组首行返回 rowspan = 分组行数,后续行返回 rowspan = 0

4.2 多条件合并(行与列)

在更复杂的场景下,可能需要同时:

  1. 按列 A 相同合并行;
  2. 同时按列 B 相同合并列(或多列);

例如:当“类别”和“状态”都连续相同时,先合并“类别”列,然后在“状态”列也合并。这时,需要根据 columnIndex 决定在哪些列使用哪套合并逻辑。


完整示例:基于“类别 + 状态”分组合并

假设我们有一份数据,需要在表格中:

  1. category(类别)列 完成大分组:同一类别连续的若干行合并。
  2. status(状态)列 完成小分组:在同一类别内,若相邻几行的状态相同,也要将状态单元格再合并。

表格样例效果(假设数据):

┌────┬──────────┬──────────┬────────┐
│ 序号 │ 类别     │ 名称       │ 状态   │
├────┼──────────┼──────────┼────────┤
│ 1  │ A        │ 任务 A1    │ 进行中 │
│ 2  │          │ 任务 A2    │ 进行中 │
│ 3  │          │ 任务 A3    │ 已完成 │
│ 4  │ B        │ 任务 B1    │ 暂停   │
│ 5  │          │ 任务 B2    │ 暂停   │
│ 6  │          │ 任务 B3    │ 进行中 │
│ 7  │ C        │ 任务 C1    │ 已完成 │
└────┴──────────┴──────────┴────────┘
  • 对“类别”列 (ColumnIndex=1):

    • 行 1–3 都属于 A,rowspan = 3
    • 行 4–6 都属于 B,rowspan = 3
    • 行 7 单独属于 C,rowspan = 1
  • 对“状态”列 (ColumnIndex=3):

    • 在 A 组(行 1–3)中,行 1、2 的状态都为“进行中”,合并为 rowspan=2;行 3 状态“已完成” rowspan=1
    • 在 B 组(行 4–6)中,行 4、5 都是“暂停”,合并 rowspan=2;行 6 是“进行中” rowspan=1
    • 在 C 组(行 7)只有一行,rowspan=1

下面我们详细实现该示例。

5.1 数据结构与需求分析

const tableData = [
  { id: 1, category: 'A', name: '任务 A1', status: '进行中' },
  { id: 2, category: 'A', name: '任务 A2', status: '进行中' },
  { id: 3, category: 'A', name: '任务 A3', status: '已完成' },
  { id: 4, category: 'B', name: '任务 B1', status: '暂停' },
  { id: 5, category: 'B', name: '任务 B2', status: '暂停' },
  { id: 6, category: 'B', name: '任务 B3', status: '进行中' },
  { id: 7, category: 'C', name: '任务 C1', status: '已完成' }
];
  • 第一步:遍历 tableData,计算类别分组信息,记录每个 category 从哪一行开始、共多少条。
  • 第二步:在同一类别内,再次遍历,计算状态分组信息,针对状态做分组:记录每个 status 从哪一行开始、共多少条。
  • span-method 中,先判断是否要合并“类别”列;否则,再判断是否要合并“状态”列;其余列统一返回 [1,1]

5.1.1 计算「类别分组」示意

遍历 tableData:
  idx=0, category=A: 新组 A,记录 A 从 0 开始,计数 count=1
  idx=1, category=A: 继续 A 组,count=2
  idx=2, category=A: 继续 A 组,count=3
  idx=3, category=B: 新组 B,从 idx=3,count=1
  idx=4, category=B: idx=4,count=2
  idx=5, category=B: idx=5,count=3
  idx=6, category=C: 新组 C,从 idx=6,count=1
结束后得到:
  categoryGroups = [
    { start: 0, length: 3, value: 'A' },
    { start: 3, length: 3, value: 'B' },
    { start: 6, length: 1, value: 'C' }
  ]

5.1.2 计算「状态分组」示意(在每个类别组内部再分组)

以 A 组 (0–2 行) 为例:

 idx=0, status=进行中: 从 0 开始,count=1
 idx=1, status=进行中: 继续,count=2
 idx=2, status=已完成: 新组,上一组存 { start:0, length:2, value:'进行中' }, 记录新组 { start:2, length:1, value:'已完成' }

生成 A 组中的状态分组:

statusGroupsInA = [
  { start: 0, length: 2, value: '进行中' },
  { start: 2, length: 1, value: '已完成' }
]

同理,对 B 组(3–5 行)、C 组(6 行)分别做同样分组。


5.2 代码实现(模板 + 脚本)

<template>
  <div>
    <h2>Element Plus 动态表格单元格合并示例</h2>
    <el-table
      :data="tableData"
      :span-method="spanMethod"
      border
      style="width: 100%">
      <!-- 序号列 -->
      <el-table-column
        prop="id"
        label="序号"
        width="80">
      </el-table-column>

      <!-- 类别列:按类别分组合并 -->
      <el-table-column
        prop="category"
        label="类别"
        width="150">
      </el-table-column>

      <!-- 名称列:不合并 -->
      <el-table-column
        prop="name"
        label="名称"
        width="200">
      </el-table-column>

      <!-- 状态列:在同一类别内按状态分组合并 -->
      <el-table-column
        prop="status"
        label="状态"
        width="150">
      </el-table-column>
    </el-table>
  </div>
</template>

<script setup>
import { reactive, computed } from 'vue';

/** 1. 模拟后端数据 */
const tableData = reactive([
  { id: 1, category: 'A', name: '任务 A1', status: '进行中' },
  { id: 2, category: 'A', name: '任务 A2', status: '进行中' },
  { id: 3, category: 'A', name: '任务 A3', status: '已完成' },
  { id: 4, category: 'B', name: '任务 B1', status: '暂停' },
  { id: 5, category: 'B', name: '任务 B2', status: '暂停' },
  { id: 6, category: 'B', name: '任务 B3', status: '进行中' },
  { id: 7, category: 'C', name: '任务 C1', status: '已完成' }
]);

/** 2. 计算“类别分组”信息 */
const categoryGroups = computed(() => {
  const groups = [];
  let lastValue = null;
  let startIndex = 0;
  let count = 0;

  tableData.forEach((row, idx) => {
    if (row.category !== lastValue) {
      // 先把上一组信息推入(跳过首个 push)
      if (count > 0) {
        groups.push({ value: lastValue, start: startIndex, length: count });
      }
      // 开启新一组
      lastValue = row.category;
      startIndex = idx;
      count = 1;
    } else {
      count++;
    }
    // 遍历到最后时需要收尾
    if (idx === tableData.length - 1) {
      groups.push({ value: lastValue, start: startIndex, length: count });
    }
  });

  return groups;
});

/** 3. 计算“状态分组”信息(嵌套在每个类别组内) */
const statusGroups = computed(() => {
  // 结果格式:{ [category]: [ { value, start, length }, ... ] }
  const result = {};
  categoryGroups.value.forEach(({ value: cat, start, length }) => {
    const arr = [];
    let lastStatus = null;
    let subStart = start;
    let subCount = 0;
    // 遍历当前类别范围内的行
    for (let i = start; i < start + length; i++) {
      const status = tableData[i].status;
      if (status !== lastStatus) {
        if (subCount > 0) {
          arr.push({ value: lastStatus, start: subStart, length: subCount });
        }
        lastStatus = status;
        subStart = i;
        subCount = 1;
      } else {
        subCount++;
      }
      // 到最后一行时收尾
      if (i === start + length - 1) {
        arr.push({ value: lastStatus, start: subStart, length: subCount });
      }
    }
    result[cat] = arr;
  });
  return result;
});

/** 4. span-method 方法:根据索引与分组信息动态返回 [rowspan, colspan] */
function spanMethod({ row, column, rowIndex, columnIndex }) {
  const colProp = column.property;
  // 列索引对应关系:0 → id, 1 → category, 2 → name, 3 → status
  // ① 对“类别”列 (columnIndex === 1) 做合并
  if (colProp === 'category') {
    // 在 categoryGroups 中找到所属组
    for (const group of categoryGroups.value) {
      if (rowIndex === group.start) {
        // 组首行,合并长度为 group.length
        return [group.length, 1];
      }
      if (rowIndex > group.start && rowIndex < group.start + group.length) {
        // 组内非首行
        return [0, 0];
      }
    }
  }

  // ② 对“状态”列 (columnIndex === 3) 在同一类别内做合并
  if (colProp === 'status') {
    const cat = row.category;
    const groupsInCat = statusGroups.value[cat] || [];
    for (const sub of groupsInCat) {
      if (rowIndex === sub.start) {
        return [sub.length, 1];
      }
      if (rowIndex > sub.start && rowIndex < sub.start + sub.length) {
        return [0, 0];
      }
    }
  }

  // ③ 其它列不合并
  return [1, 1];
}
</script>

<style scoped>
h2 {
  margin-bottom: 16px;
  color: #409eff;
}
</style>

5.3 运行效果图解

┌────┬───────┬────────┬───────┐
│ 序号 │ 类别  │ 名称    │ 状态  │
├────┼───────┼────────┼───────┤
│  1 │   A   │ 任务 A1 │ 进行中 │
│    │       │ 任务 A2 │ 进行中 │
│    │       │ 任务 A3 │ 已完成 │
│  4 │   B   │ 任务 B1 │ 暂停   │
│    │       │ 任务 B2 │ 暂停   │
│    │       │ 任务 B3 │ 进行中 │
│  7 │   C   │ 任务 C1 │ 已完成 │
└────┴───────┴────────┴───────┘
  • “类别”列

    • 行 1 – 3 属于 A,第一行 (rowIndex=0) 返回 [3,1],后两行返回 [0,0]
    • 行 4 – 6 属于 B,第一行 (rowIndex=3) 返回 [3,1],后两行返回 [0,0]
    • 行 7 属于 C,自身返回 [1,1]
  • “状态”列

    • 在 A 组内:行 1–2 都是“进行中”,第一行 (rowIndex=0) 返回 [2,1]、第二行 (rowIndex=1) 返回 [0,0];行 3 (rowIndex=2) 为“已完成”,返回 [1,1]
    • 在 B 组内:行 4–5 都是“暂停”,rowIndex=3 [2,1]rowIndex=4 [0,0];行 6 (进行中) [1,1]
    • C 组只有一行,rowIndex=6 [1,1]

常见坑与优化建议

  1. 遍历逻辑重复

    • 若直接在 span-method 中每次遍历整个 tableData 检索分组,会导致性能急剧下降。优化建议:在渲染前(如 computed 中)预先计算好分组信息,存储在内存中,span-method 直接查表即可,不要重复计算。
  2. 数据变动后的更新

    • 如果表格数据动态增删改,会打乱原有的行索引与分组结果。优化建议:在数据源发生变化时(如用 v-for 更新 tableData),要及时重新计算 categoryGroupsstatusGroups(由于用了 computed,Vue 会自动生效)。
  3. 跨页合并

    • 如果表格开启了分页,span-method 中的分组逻辑仅对当前页 tableData 有效;若需要“跨页”合并(很少用),需要把全部数据都放于同一个表格或自己编写更复杂的分页逻辑。
  4. 复杂表头与嵌套列

    • 如果表格有多级表头(带 children<el-table-column>),columnIndex 的值可能不如预期,需要配合 column.property 或其他自定义字段来精确判断当前到底是哪一列。
  5. 同时合并列与行

    • span-method 可以同时控制 rowspancolspan。如果要横向合并列,可以在返回 [rowspan, colspan] 时让 colspan > 1,并让后续列返回 [0,0]。使用此功能时要谨慎计算列索引与数据顺序。

总结

  • span-method 是 Element Plus <el-table> 提供的核心动态单元格合并方案,通过返回 [rowspan, colspan] 数组来控制行/列合并。
  • 关键步骤:预先计算分组信息,将“同组起始行索引”和“合并长度”用对象/数组缓存,避免在渲染时重复遍历。
  • 在多条件合并场景下,可以根据 column.propertycolumnIndex 分支处理不同列的合并逻辑。
  • 常见用例有:按单列相同合并、按多列多级分组合并,以及跨列合并。
  • 使用时要注意分页、动态数据更新等带来的索引失效问题,并结合 Vue 的 computed 响应式特点,保证分组信息实时更新。

通过上述示例与图解,相信你已经掌握了 Element Plus 动态表格合并单元格的核心方法与思路。下一步,可以结合实际业务需求,扩展出更多复杂场景的单元格合并逻辑。

2025-05-31

目录

  1. 项目环境与依赖安装
  2. 基础集成:Vue + Element-UI + Quill

    1. Vue 项目初始化
    2. 安装并引入 Element-UI 与 Quill
    3. 最简 Quill 编辑器示例
  3. 配置 Quill 工具栏与自定义上传按钮

    1. Quill 工具栏配置项说明
    2. 添加自定义“图片上传”与“视频上传”按钮
    3. 代码示例:自定义上传按钮集成
  4. 图片上传与缩放功能实现

    1. 使用 Element-UI 的 el-upload 组件进行文件选择
    2. 后台接口示例与上传流程
    3. 图片缩放:Quill Image Resize 模块集成
    4. 完整代码示例:图片上传并可缩放
    5. ASCII 流程图:图片上传 & 缩放
  5. 视频上传与插入实现

    1. Element-UI el-upload 配置与提示
    2. 插入 Quill 视频节点的逻辑
    3. 完整代码示例:视频上传并插入
    4. ASCII 流程图:视频上传 & 插入
  6. 富文本内容获取与保存

    1. 监听 Quill 内容变化
    2. 将富文本内容(HTML)提交到后端
    3. 后端示例接口:接收与存储
  7. 综合示例:完整页面源码
  8. 常见问题与注意事项
  9. 总结与扩展思路

1. 项目环境与依赖安装

在开始之前,假定你已经安装了以下环境:

  • Node.js v12+
  • Vue CLI v4+

接下来,我们创建一个新的 Vue 项目并安装所需依赖。

# 1. 使用 Vue CLI 创建项目
vue create vue-quill-element-uploader
# 选择“默认 (babel, eslint)”或其他你熟悉的配置

cd vue-quill-element-uploader

# 2. 安装 Element-UI
npm install element-ui --save

# 3. 安装 Quill 及相关依赖
npm install quill vue-quill-editor quill-image-resize-module --save

# 4. 安装 axios(用于上传请求)
npm install axios --save
  • element-ui:饿了么团队开源的组件库,用于各种 UI 控件(按钮、对话框、上传组件等)。
  • quill + vue-quill-editor:Quill 富文本编辑器及其 Vue 封装。
  • quill-image-resize-module:Quill 的一个插件,用于实现编辑器中图片的拖拽缩放。
  • axios:发送 HTTP 上传请求。

2. 基础集成:Vue + Element-UI + Quill

2.1 Vue 项目初始化

若你已经在上一步创建了 Vue 项目,则直接跳到下一步。否则可参考如下命令重新创建:

vue create vue-quill-element-uploader
# 选择需要的预设
cd vue-quill-element-uploader

项目目录结构示例(简化):

vue-quill-element-uploader/
├── node_modules/
├── public/
├── src/
│   ├── App.vue
│   ├── main.js
│   └── components/
│       └── RichEditor.vue  # 接下来将创建该组件
├── package.json
└── vue.config.js

2.2 安装并引入 Element-UI 与 Quill

src/main.js 中,引入并全局注册 Element-UI,及 Quill 样式:

// src/main.js
import Vue from 'vue';
import App from './App.vue';

// 引入 Element-UI
import ElementUI from 'element-ui';
import 'element-ui/lib/theme-chalk/index.css';

Vue.use(ElementUI);

// 引入 Quill 编辑器样式
import 'quill/dist/quill.core.css';
import 'quill/dist/quill.snow.css';
import 'quill/dist/quill.bubble.css';

Vue.config.productionTip = false;

new Vue({
  render: h => h(App),
}).$mount('#app');
  • Quill 有多种主题,这里同时引入三种样式(coresnowbubble),以便日后根据配置使用主题。
  • Element-UI 注册后即可在全局使用其组件(如:<el-upload><el-button> 等)。

2.3 最简 Quill 编辑器示例

src/components/RichEditor.vue 中创建一个最简富文本编辑器组件,先不考虑上传功能,只实现基本编辑:

<!-- src/components/RichEditor.vue -->
<template>
  <div class="rich-editor-container">
    <quill-editor
      v-model="content"
      :options="editorOptions"
      @blur="onEditorBlur"
      @focus="onEditorFocus"
      @change="onEditorChange"
    ></quill-editor>
    <div style="margin-top: 20px;">
      <h4>编辑内容预览:</h4>
      <div v-html="content" class="preview"></div>
    </div>
  </div>
</template>

<script>
// 引入 Vue Quill Editor
import { quillEditor } from 'vue-quill-editor';

export default {
  name: 'RichEditor',
  components: {
    quillEditor
  },
  data() {
    return {
      content: '', // 双向绑定的内容(HTML)
      editorOptions: {
        // 基础工具栏
        theme: 'snow',
        modules: {
          toolbar: [
            ['bold', 'italic', 'underline', 'strike'],
            [{ header: [1, 2, 3, false] }],
            [{ list: 'ordered' }, { list: 'bullet' }],
            [{ align: [] }],
            ['clean']
          ]
        }
      }
    };
  },
  methods: {
    onEditorBlur(editor) {
      console.log('Editor blur!', editor);
    },
    onEditorFocus(editor) {
      console.log('Editor focus!', editor);
    },
    onEditorChange({ editor, html, text }) {
      // html 为当前编辑器内容的 HTML
      // text 为纯文本
      // 可以在此处做实时保存或校验
      console.log('Editor content changed:', html);
    }
  }
};
</script>

<style scoped>
.rich-editor-container {
  margin: 20px;
}
.preview {
  border: 1px solid #ddd;
  padding: 10px;
  min-height: 100px;
}
</style>

src/App.vue 中引用该组件:

<!-- src/App.vue -->
<template>
  <div id="app">
    <h2>Vue + Quill + Element-UI 富文本编辑示例</h2>
    <rich-editor></rich-editor>
  </div>
</template>

<script>
import RichEditor from './components/RichEditor.vue';

export default {
  name: 'App',
  components: {
    RichEditor
  }
};
</script>

启动项目(npm run serve),即可看到最简的富文本编辑器与实时预览区。接下来我们逐步增强,加入图片/视频上传与缩放功能。


3. 配置 Quill 工具栏与自定义上传按钮

要在 Quill 的工具栏中添加“图片上传”和“视频上传”按钮,需要先了解 Quill 工具栏配置与自定义 Handler 的写法。

3.1 Quill 工具栏配置项说明

Quill 工具栏通过 modules.toolbar 配置项定义;常见项有:

toolbar: [
  ['bold', 'italic', 'underline'],        // 粗体、斜体、下划线
  [{ header: 1 }, { header: 2 }],         // 标题 1、2
  [{ list: 'ordered' }, { list: 'bullet' }], // 有序列表、无序列表
  ['link', 'image', 'video'],             // 链接、图片、视频(默认视频弹出 URL 输入框)
  ['clean']                               // 清除格式
]
  • 默认 Quill 提供的 imagevideo 按钮,会弹出 URL 对话框,让用户粘贴网络地址。为了实现本地上传,我们需要隐藏默认按钮,自定义一个上传图标并实现上传逻辑。

3.2 添加自定义“图片上传”与“视频上传”按钮

思路:

  1. toolbar 中添加一个自定义按钮 custom-imagecustom-video
  2. 在 Quill 初始化后,使用 quill.getModule('toolbar') 注册 handler,拦截点击事件并弹出 Element-UI 的上传对话框。

示例工具栏配置(中间添加两个自定义 class):

editorOptions: {
  theme: 'snow',
  modules: {
    toolbar: {
      container: [
        ['bold', 'italic', 'underline'],
        [{ header: 1 }, { header: 2 }],
        [{ list: 'ordered' }, { list: 'bullet' }],
        ['link'],
        ['custom-image', 'custom-video'], // 自定义按钮
        ['clean']
      ],
      handlers: {
        'custom-image': function () {}, // 先留空,后面注入
        'custom-video': function () {}
      }
    }
  }
}

此时 Quill 工具栏会渲染出两个空白按钮位置,接下来需要用 CSS 或 SVG 图标替换它们的默认样式,并在 mounted 中获取按钮节点绑定点击事件。

3.3 代码示例:自定义上传按钮集成

我们在 RichEditor.vue 中完善 Toolbar 配置与 handler:

<!-- src/components/RichEditor.vue -->
<template>
  <div class="rich-editor-container">
    <!-- 上传对话框:隐藏,点击自定义按钮时触发 -->
    <el-dialog title="上传图片" :visible.sync="imageDialogVisible" width="400px">
      <el-upload
        class="upload-demo"
        action=""                <!-- 不使用自动上传 -->
        :http-request="uploadImage"  <!-- 使用自定义 uploadImage 函数 -->
        :show-file-list="false"
        accept="image/*"
      >
        <el-button size="small" type="primary">选择图片</el-button>
      </el-upload>
      <div slot="footer" class="dialog-footer">
        <el-button @click="imageDialogVisible = false">取消</el-button>
      </div>
    </el-dialog>

    <el-dialog title="上传视频" :visible.sync="videoDialogVisible" width="400px">
      <el-upload
        class="upload-demo"
        action=""
        :http-request="uploadVideo"
        :show-file-list="false"
        accept="video/*"
      >
        <el-button size="small" type="primary">选择视频</el-button>
      </el-upload>
      <div slot="footer" class="dialog-footer">
        <el-button @click="videoDialogVisible = false">取消</el-button>
      </div>
    </el-dialog>

    <quill-editor
      ref="quillEditor"
      v-model="content"
      :options="editorOptions"
      @blur="onEditorBlur"
      @focus="onEditorFocus"
      @change="onEditorChange"
    ></quill-editor>
  </div>
</template>

<script>
import { quillEditor } from 'vue-quill-editor';
import 'quill/dist/quill.snow.css';
import axios from 'axios';

// 引入图片缩放模块(稍后在图片上传部分使用)
import ImageResize from 'quill-image-resize-module';

export default {
  name: 'RichEditor',
  components: { quillEditor },
  data() {
    return {
      content: '',
      imageDialogVisible: false,
      videoDialogVisible: false,
      quill: null, // quill 实例引用
      editorOptions: {
        theme: 'snow',
        modules: {
          imageResize: {}, // 注册图片缩放插件
          toolbar: {
            container: [
              ['bold', 'italic', 'underline'],
              [{ header: 1 }, { header: 2 }],
              [{ list: 'ordered' }, { list: 'bullet' }],
              ['link'],
              ['custom-image', 'custom-video'],
              ['clean']
            ],
            handlers: {
              'custom-image': function () {
                // 点击自定义图片按钮,打开上传对话框
                this.$emit('showImageDialog');
              },
              'custom-video': function () {
                this.$emit('showVideoDialog');
              }
            }
          }
        }
      }
    };
  },
  methods: {
    onEditorBlur(editor) {
      console.log('Editor blur!', editor);
    },
    onEditorFocus(editor) {
      console.log('Editor focus!', editor);
    },
    onEditorChange({ editor, html, text }) {
      console.log('Editor content changed:', html);
    },
    /**
     * 组件 mounted 时,获取 quill 实例并重写 handler emit
     */
    initQuill() {
      // quillEditor 实际渲染后,this.$refs.quillEditor.$el 存在
      const editorComponent = this.$refs.quillEditor;
      this.quill = editorComponent.quill;

      // 将 handler 触发改为触发组件方法
      const toolbar = this.quill.getModule('toolbar');
      toolbar.addHandler('custom-image', () => {
        this.imageDialogVisible = true;
      });
      toolbar.addHandler('custom-video', () => {
        this.videoDialogVisible = true;
      });
    },
    /**
     * 自定义图片上传接口
     */
    async uploadImage({ file }) {
      try {
        const formData = new FormData();
        formData.append('file', file);
        // 后端接口示例:/api/upload/image
        const resp = await axios.post('/api/upload/image', formData, {
          headers: { 'Content-Type': 'multipart/form-data' }
        });
        const imageUrl = resp.data.url; // 假设返回 { url: 'http://...' }

        // 在光标处插入图片
        const range = this.quill.getSelection();
        this.quill.insertEmbed(range.index, 'image', imageUrl);
        this.imageDialogVisible = false;
      } catch (err) {
        this.$message.error('图片上传失败');
        console.error(err);
      }
    },
    /**
     * 自定义视频上传接口
     */
    async uploadVideo({ file }) {
      try {
        const formData = new FormData();
        formData.append('file', file);
        // 后端接口示例:/api/upload/video
        const resp = await axios.post('/api/upload/video', formData, {
          headers: { 'Content-Type': 'multipart/form-data' }
        });
        const videoUrl = resp.data.url; // 假设返回 { url: 'http://...' }

        // 在光标处插入视频
        const range = this.quill.getSelection();
        this.quill.insertEmbed(range.index, 'video', videoUrl);
        this.videoDialogVisible = false;
      } catch (err) {
        this.$message.error('视频上传失败');
        console.error(err);
      }
    }
  },
  mounted() {
    // 注册图片缩放模块
    const Quill = require('quill');
    Quill.register('modules/imageResize', ImageResize);

    this.$nextTick(() => {
      this.initQuill();
    });
  }
};
</script>

<style scoped>
.rich-editor-container {
  margin: 20px;
}
.upload-demo {
  text-align: center;
}
</style>

关键点说明:

  1. 自定义 Toolbar 按钮

    • editorOptions.modules.toolbar.container 中添加 ['custom-image', 'custom-video']
    • 通过 toolbar.addHandler('custom-image', handlerFn) 动态绑定点击事件,调用 this.imageDialogVisible = true 打开 Element-UI 对话框。
  2. Element-UI Upload & Dialog

    • 两个 <el-dialog> 分别用于“图片上传”和“视频上传”,初始不可见(imageDialogVisible = falsevideoDialogVisible = false)。
    • <el-upload> 组件配置了 :http-request="uploadImage"(或 uploadVideo),即完全交由自定义方法处理文件上传,不走 Element-UI 自动上传
  3. uploadImageuploadVideo 方法

    • 使用 axios.post 将文件以 multipart/form-data 格式上传到后端接口(可配合后端如 koa-multermulter 等接收)。
    • 上传完成后拿到图片/视频 URL,通过 quill.insertEmbed(range.index, 'image', imageUrl) 将其插入到光标位置。Quill 支持 'image''video' embed。
  4. 图片缩放插件

    • 引入 quill-image-resize-module,并在 mountedQuill.register('modules/imageResize', ImageResize) 注册模块,编辑器配置 modules.imageResize: {} 即可支持缩放。

4. 图片上传与缩放功能实现

下面重点讲解“图片上传”与“图片缩放”两部分的实现细节。

4.1 使用 Element-UI 的 el-upload 组件进行文件选择

在弹出的图片上传对话框内,Element-UI 提供了十分方便的 el-upload 组件,可实现以下功能:

  • 文件选择:点击 “选择图片” 按钮,弹出本地文件选择。
  • accept="image/*":仅允许选择图片文件。
  • 自定义上传:通过 :http-request="uploadImage" 参数,将上传逻辑委托给开发者,可以自定义上传到任何后端接口。
<el-dialog title="上传图片" :visible.sync="imageDialogVisible" width="400px">
  <el-upload
    class="upload-demo"
    action=""                <!-- 不自动提交 -->
    :http-request="uploadImage"
    :show-file-list="false"
    accept="image/*"
  >
    <el-button size="small" type="primary">选择图片</el-button>
  </el-upload>
  <div slot="footer" class="dialog-footer">
    <el-button @click="imageDialogVisible = false">取消</el-button>
  </div>
</el-dialog>

当用户点击“选择图片”并选中文件后,会触发 uploadImage 方法的调用,回调参数中包含 file 对象。

4.2 后台接口示例与上传流程

以 Node.js 后端为例,使用 multer 中间件处理上传。假设后端框架为 Koa 或 Express,示例代码如下:

// 后端:Express + multer 示例 (server/upload.js)
const express = require('express');
const multer = require('multer');
const path = require('path');
const router = express.Router();

// 配置存储目录与文件名
const storage = multer.diskStorage({
  destination: function (req, file, cb) {
    cb(null, path.join(__dirname, 'uploads/images'));
  },
  filename: function (req, file, cb) {
    const ext = path.extname(file.originalname);
    const filename = `img_${Date.now()}${ext}`;
    cb(null, filename);
  }
});

const upload = multer({ storage });

router.post('/upload/image', upload.single('file'), (req, res) => {
  if (!req.file) {
    return res.status(400).json({ message: '未找到上传文件' });
  }
  // 返回可访问的 URL(假设静态托管在 /uploads 目录)
  const fileUrl = `http://your-domain.com/uploads/images/${req.file.filename}`;
  res.json({ url: fileUrl });
});

module.exports = router;
  • upload.single('file'):处理单文件上传,字段名必须与前端 formData.append('file', file) 中的 key 一致。
  • 返回格式{ url: 'http://...' },前端在接收到后直接将 URL 插入 Quill。

类似地,还可配置 /upload/video 路由,将视频文件保存并返回访问地址。

4.3 图片缩放:Quill Image Resize 模块集成

quill-image-resize-module 插件可为 Quill 编辑器中的图片元素添加拖拽缩放功能。集成方式:

  1. 安装插件:

    npm install quill-image-resize-module --save
  2. 在组件中导入并注册:

    import Quill from 'quill';
    import ImageResize from 'quill-image-resize-module';
    
    Quill.register('modules/imageResize', ImageResize);
  3. editorOptions.modules 中添加 imageResize: {}

    editorOptions: {
      theme: 'snow',
      modules: {
        imageResize: {}, // 启用缩放
        toolbar: { ... }
      }
    }

此时,编辑器中的图片被插入后,鼠标点击图片四周会出现拖拽柄,可拖动进行缩放。

4.4 完整代码示例:图片上传并可缩放

综合前面所有配置,以下为 RichEditor.vue 中图片上传与缩放相关部分的完整代码(已在上一节 §3.3 中给出)。在此补充并重点标注关键片段。

<!-- src/components/RichEditor.vue -->
<template>
  <div class="rich-editor-container">
    <!-- 图片上传对话框 -->
    <el-dialog title="上传图片" :visible.sync="imageDialogVisible" width="400px">
      <el-upload
        class="upload-demo"
        action=""
        :http-request="uploadImage"
        :show-file-list="false"
        accept="image/*"
      >
        <el-button size="small" type="primary">选择图片</el-button>
      </el-upload>
      <div slot="footer" class="dialog-footer">
        <el-button @click="imageDialogVisible = false">取消</el-button>
      </div>
    </el-dialog>

    <!-- 编辑器主体 -->
    <quill-editor
      ref="quillEditor"
      v-model="content"
      :options="editorOptions"
      @blur="onEditorBlur"
      @focus="onEditorFocus"
      @change="onEditorChange"
    ></quill-editor>
  </div>
</template>

<script>
import { quillEditor } from 'vue-quill-editor';
import 'quill/dist/quill.snow.css';
import axios from 'axios';
import Quill from 'quill';
import ImageResize from 'quill-image-resize-module';

export default {
  name: 'RichEditor',
  components: { quillEditor },
  data() {
    return {
      content: '',
      imageDialogVisible: false,
      quill: null,
      editorOptions: {
        theme: 'snow',
        modules: {
          // 注册图片缩放模块
          imageResize: {},
          toolbar: {
            container: [
              ['bold', 'italic', 'underline'],
              [{ header: 1 }, { header: 2 }],
              [{ list: 'ordered' }, { list: 'bullet' }],
              ['link'],
              ['custom-image'], // 自定义图片上传按钮
              ['clean']
            ],
            handlers: {
              'custom-image': function () {
                // 点击自定义图片按钮
                this.$emit('showImageDialog');
              }
            }
          }
        }
      }
    };
  },
  methods: {
    onEditorBlur(editor) {
      console.log('Editor blur!', editor);
    },
    onEditorFocus(editor) {
      console.log('Editor focus!', editor);
    },
    onEditorChange({ editor, html, text }) {
      console.log('Editor content changed:', html);
    },
    initQuill() {
      Quill.register('modules/imageResize', ImageResize);
      const editorComp = this.$refs.quillEditor;
      this.quill = editorComp.quill;
      const toolbar = this.quill.getModule('toolbar');
      // 绑定图片按钮触发
      toolbar.addHandler('custom-image', () => {
        this.imageDialogVisible = true;
      });
    },
    async uploadImage({ file }) {
      try {
        const formData = new FormData();
        formData.append('file', file);
        const resp = await axios.post('/api/upload/image', formData, {
          headers: { 'Content-Type': 'multipart/form-data' }
        });
        const imageUrl = resp.data.url;
        // 插入图片
        const range = this.quill.getSelection();
        this.quill.insertEmbed(range.index, 'image', imageUrl);
        this.imageDialogVisible = false;
      } catch (err) {
        this.$message.error('图片上传失败');
        console.error(err);
      }
    }
  },
  mounted() {
    this.$nextTick(this.initQuill);
  }
};
</script>

<style scoped>
.rich-editor-container {
  margin: 20px;
}
.upload-demo {
  text-align: center;
}
</style>

4.5 ASCII 流程图:图片上传 & 缩放

┌────────────────────────────────────────────────────┐
│             用户点击 Quill 工具栏 “图片” 按钮      │
└───────────────────┬────────────────────────────────┘
                    │
                    ▼
         ┌──────────────────────────────┐
         │ 触发 toolbar handler:       │
         │ this.imageDialogVisible = true │
         └───────────────┬──────────────┘
                         │
                         ▼
          ┌────────────────────────────────┐
          │ Element-UI el-dialog 弹出       │
          │ (包含 el-upload 选择按钮)      │
          └───────────────┬────────────────┘
                          │
          用户选择本地图片文件 file │
                          ▼
      ┌────────────────────────────────────────┐
      │ Element-UI 调用 uploadImage({file})   │
      └───────────────┬────────────────────────┘
                      │
                      ▼
        ┌────────────────────────────────┐
        │ axios.post('/api/upload/image',│
        │ formData) 发送 HTTP 上传请求   │
        └───────────────┬────────────────┘
                        │
                        ▼
        ┌────────────────────────────────┐
        │ 后端接收文件并返回 URL          │
        └───────────────┬────────────────┘
                        │
                        ▼
       ┌─────────────────────────────────┐
       │ 前端接收 { url: imageUrl }      │
       │ const range = quill.getSelection() │
       │ quill.insertEmbed(range.index,  │
       │   'image', imageUrl)            │
       └───────────────┬─────────────────┘
                       │
                       ▼
       ┌────────────────────────────────┐
       │ Quill 插入 <img src="...">    │
       │ 并自动启用缩放拖拽功能         │
       └────────────────────────────────┘

5. 视频上传与插入实现

接下来,演示如何为 Quill 编辑器添加“视频上传”按钮,并在上传成功后将视频以 <iframe><video> 形式插入编辑器。

5.1 Element-UI el-upload 配置与提示

与图片上传类似,我们为“视频上传”准备一个对话框,使用 <el-upload> 组件接收用户本地视频文件。示例如下(已在 §3.3 中给出):

<el-dialog title="上传视频" :visible.sync="videoDialogVisible" width="400px">
  <el-upload
    class="upload-demo"
    action=""
    :http-request="uploadVideo"
    :show-file-list="false"
    accept="video/*"
  >
    <el-button size="small" type="primary">选择视频</el-button>
  </el-upload>
  <div slot="footer" class="dialog-footer">
    <el-button @click="videoDialogVisible = false">取消</el-button>
  </div>
</el-dialog>
  • accept="video/*":仅允许选择视频文件。
  • :http-request="uploadVideo":完全由我们来控制上传逻辑。

5.2 插入 Quill 视频节点的逻辑

Quill 原生支持插入视频 Embed,调用方式为:

const range = this.quill.getSelection();
this.quill.insertEmbed(range.index, 'video', videoUrl);

其中 videoUrl 可以是 YouTube 地址,也可以是直接可访问的视频文件 URL。Quill 会根据 URL 渲染对应的 <iframe> 或 HTML5 <video> 标签。

5.3 完整代码示例:视频上传并插入

RichEditor.vue 中加入视频上传部分,完整代码如下(以方便阅读,仅补充与视频相关部分):

<template>
  <div class="rich-editor-container">
    <!-- 视频上传对话框 -->
    <el-dialog title="上传视频" :visible.sync="videoDialogVisible" width="400px">
      <el-upload
        class="upload-demo"
        action=""
        :http-request="uploadVideo"
        :show-file-list="false"
        accept="video/*"
      >
        <el-button size="small" type="primary">选择视频</el-button>
      </el-upload>
      <div slot="footer" class="dialog-footer">
        <el-button @click="videoDialogVisible = false">取消</el-button>
      </div>
    </el-dialog>

    <!-- 富文本编辑器主体 -->
    <quill-editor
      ref="quillEditor"
      v-model="content"
      :options="editorOptions"
      @blur="onEditorBlur"
      @focus="onEditorFocus"
      @change="onEditorChange"
    ></quill-editor>
  </div>
</template>

<script>
import { quillEditor } from 'vue-quill-editor';
import 'quill/dist/quill.snow.css';
import axios from 'axios';
import Quill from 'quill';

export default {
  name: 'RichEditor',
  components: { quillEditor },
  data() {
    return {
      content: '',
      videoDialogVisible: false,
      quill: null,
      editorOptions: {
        theme: 'snow',
        modules: {
          toolbar: {
            container: [
              ['bold', 'italic', 'underline'],
              ['link'],
              ['custom-video'],
              ['clean']
            ],
            handlers: {
              'custom-video': function () {
                this.$emit('showVideoDialog');
              }
            }
          }
        }
      }
    };
  },
  methods: {
    initQuill() {
      const editorComp = this.$refs.quillEditor;
      this.quill = editorComp.quill;
      const toolbar = this.quill.getModule('toolbar');
      toolbar.addHandler('custom-video', () => {
        this.videoDialogVisible = true;
      });
    },
    async uploadVideo({ file }) {
      try {
        const formData = new FormData();
        formData.append('file', file);
        // 后端接口:/api/upload/video
        const resp = await axios.post('/api/upload/video', formData, {
          headers: { 'Content-Type': 'multipart/form-data' }
        });
        const videoUrl = resp.data.url;
        const range = this.quill.getSelection();
        this.quill.insertEmbed(range.index, 'video', videoUrl);
        this.videoDialogVisible = false;
      } catch (err) {
        this.$message.error('视频上传失败');
        console.error(err);
      }
    },
    // ... 省略 onEditorBlur/Focus/Change 等方法
  },
  mounted() {
    this.$nextTick(this.initQuill);
  }
};
</script>
  • 与图片上传几乎一致,只需将 insertEmbed(..., 'video', videoUrl) 替换 image 即可。
  • Quill 会自动对 <video> 标签添加样式,使其在编辑器中可预览并可调整宽度。

5.4 ASCII 流程图:视频上传 & 插入

┌──────────────────────────────────────────┐
│ 用户点击 Quill 工具栏 “视频” 按钮         │
└─────────────────┬────────────────────────┘
                  │
                  ▼
    ┌──────────────────────────────────┐
    │ toolbar handler:videoDialog=true │
    └───────────────┬──────────────────┘
                    │
                    ▼
    ┌──────────────────────────────────┐
    │ Element-UI 弹出“上传视频” Dialog  │
    └───────────────┬──────────────────┘
                    │
       用户选择本地视频 file │
                    ▼
    ┌──────────────────────────────────┐
    │ el-upload 调用 uploadVideo(file) │
    └───────────────┬──────────────────┘
                    │
                    ▼
    ┌────────────────────────────────────┐
    │ axios.post('/api/upload/video',    │
    │   formData) 发送视频上传请求        │
    └───────────────┬────────────────────┘
                    │
                    ▼
    ┌────────────────────────────────────┐
    │ 后端接收视频并返回 { url:videoUrl } │
    └───────────────┬────────────────────┘
                    │
                    ▼
    ┌─────────────────────────────────┐
    │ 插入视频节点:                    │
    │ const range = quill.getSelection() │
    │ quill.insertEmbed(range.index,   │
    │   'video', videoUrl)             │
    └─────────────────────────────────┘

6. 富文本内容获取与保存

图片/视频插入后,最终需要将富文本内容(包含 <img><video>)以 HTML 形式提交给后端保存。

6.1 监听 Quill 内容变化

RichEditor.vue 中,已通过 @change="onEditorChange" 监听内容变化,将当前 HTML 通过 v-model="content" 绑定。若只需在某个时机(如保存按钮点击时)获取内容,也可直接调用 this.quill.root.innerHTML

示例:

<template>
  <div>
    <quill-editor
      ref="quillEditor"
      v-model="content"
      :options="editorOptions"
    ></quill-editor>
    <el-button type="primary" @click="saveContent">保存内容</el-button>
  </div>
</template>

<script>
export default {
  data() {
    return {
      content: ''
    };
  },
  methods: {
    saveContent() {
      const html = this.content; // 或 this.quill.root.innerHTML
      // 提交给后端
      axios.post('/api/save-article', { html });
    }
  }
};
</script>

6.2 将富文本内容(HTML)提交到后端

后端收到 HTML 后,可将其存入数据库(如 MySQL 的 TEXT 字段、MongoDB 的 String 字段等),或者进一步进行 XSS 过滤与 CDN 资源替换等操作。示例后端 Koa 接口代码:

// server/article.js (Koa 示例)
const Router = require('koa-router');
const bodyParser = require('koa-bodyparser');
const router = new Router();

// 假设使用 Mongoose 存储
const Article = require('./models/Article');

router.post('/api/save-article', bodyParser(), async (ctx) => {
  const { html } = ctx.request.body;
  if (!html) {
    ctx.status = 400;
    ctx.body = { message: '内容不能为空' };
    return;
  }
  // XSS 过滤、图片/视频 URL 替换等预处理(视情况而定)
  const article = new Article({ content: html, createdAt: new Date() });
  await article.save();
  ctx.body = { message: '保存成功', id: article._id };
});

module.exports = router;
  • 生产环境建议对 html白名单式 XSS 过滤,避免用户插入恶意脚本。可使用 sanitize-htmlxss 等库。

6.3 后端示例接口:接收与存储

以 Mongoose + MongoDB 为例,定义一个最简的 Article Schema:

// server/models/Article.js
const mongoose = require('mongoose');

const ArticleSchema = new mongoose.Schema({
  content: { type: String, required: true }, // 存储 HTML
  createdAt: { type: Date, default: Date.now }
});

module.exports = mongoose.model('Article', ArticleSchema);
  • 存储时,将 content 字段直接保存为 HTML 字符串。
  • 渲染时,在前端页面用 v-html 渲染该字段即可。

7. 综合示例:完整页面源码

下面提供一个功能齐全的 Vue 页面示例,将前文所有功能整合在同一个组件 RichEditor.vue 中(包括:图片上传+缩放、视频上传、富文本保存)。

<!-- src/components/RichEditor.vue -->
<template>
  <div class="rich-editor-container">
    <!-- 图片上传对话框 -->
    <el-dialog title="上传图片" :visible.sync="imageDialogVisible" width="400px">
      <el-upload
        class="upload-demo"
        action=""
        :http-request="uploadImage"
        :show-file-list="false"
        accept="image/*"
      >
        <el-button size="small" type="primary">选择图片</el-button>
      </el-upload>
      <div slot="footer" class="dialog-footer">
        <el-button @click="imageDialogVisible = false">取消</el-button>
      </div>
    </el-dialog>

    <!-- 视频上传对话框 -->
    <el-dialog title="上传视频" :visible.sync="videoDialogVisible" width="400px">
      <el-upload
        class="upload-demo"
        action=""
        :http-request="uploadVideo"
        :show-file-list="false"
        accept="video/*"
      >
        <el-button size="small" type="primary">选择视频</el-button>
      </el-upload>
      <div slot="footer" class="dialog-footer">
        <el-button @click="videoDialogVisible = false">取消</el-button>
      </div>
    </el-dialog>

    <!-- 富文本编辑器 -->
    <quill-editor
      ref="quillEditor"
      v-model="content"
      :options="editorOptions"
      @blur="onEditorBlur"
      @focus="onEditorFocus"
      @change="onEditorChange"
      style="min-height: 300px"
    ></quill-editor>

    <!-- 保存按钮 -->
    <div style="margin-top: 20px; text-align: right;">
      <el-button type="primary" @click="saveContent">保存内容</el-button>
    </div>
  </div>
</template>

<script>
import { quillEditor } from 'vue-quill-editor';
import 'quill/dist/quill.snow.css';
import axios from 'axios';
import Quill from 'quill';
import ImageResize from 'quill-image-resize-module';

export default {
  name: 'RichEditor',
  components: { quillEditor },
  data() {
    return {
      content: '', // 编辑器内容(HTML)
      imageDialogVisible: false,
      videoDialogVisible: false,
      quill: null,
      editorOptions: {
        theme: 'snow',
        modules: {
          // 图片缩放
          imageResize: {},
          toolbar: {
            container: [
              ['bold', 'italic', 'underline'],
              [{ header: 1 }, { header: 2 }],
              [{ list: 'ordered' }, { list: 'bullet' }],
              ['link'],
              ['custom-image', 'custom-video'],
              ['clean']
            ],
            handlers: {
              'custom-image': function () {
                this.$emit('showImageDialog');
              },
              'custom-video': function () {
                this.$emit('showVideoDialog');
              }
            }
          }
        }
      }
    };
  },
  methods: {
    // 初始化 Quill:注册图片缩放 + 绑定 toolbar handler
    initQuill() {
      Quill.register('modules/imageResize', ImageResize);
      const editorComp = this.$refs.quillEditor;
      this.quill = editorComp.quill;
      const toolbar = this.quill.getModule('toolbar');
      toolbar.addHandler('custom-image', () => {
        this.imageDialogVisible = true;
      });
      toolbar.addHandler('custom-video', () => {
        this.videoDialogVisible = true;
      });
    },
    onEditorBlur(editor) {
      console.log('Editor blur!', editor);
    },
    onEditorFocus(editor) {
      console.log('Editor focus!', editor);
    },
    onEditorChange({ editor, html, text }) {
      console.log('Editor content changed:', html);
    },
    // 图片上传接口
    async uploadImage({ file }) {
      try {
        const formData = new FormData();
        formData.append('file', file);
        const resp = await axios.post('/api/upload/image', formData, {
          headers: { 'Content-Type': 'multipart/form-data' }
        });
        const imageUrl = resp.data.url;
        const range = this.quill.getSelection();
        this.quill.insertEmbed(range.index, 'image', imageUrl);
        this.imageDialogVisible = false;
      } catch (err) {
        this.$message.error('图片上传失败');
        console.error(err);
      }
    },
    // 视频上传接口
    async uploadVideo({ file }) {
      try {
        const formData = new FormData();
        formData.append('file', file);
        const resp = await axios.post('/api/upload/video', formData, {
          headers: { 'Content-Type': 'multipart/form-data' }
        });
        const videoUrl = resp.data.url;
        const range = this.quill.getSelection();
        this.quill.insertEmbed(range.index, 'video', videoUrl);
        this.videoDialogVisible = false;
      } catch (err) {
        this.$message.error('视频上传失败');
        console.error(err);
      }
    },
    // 保存富文本内容到后端
    async saveContent() {
      try {
        const html = this.content;
        await axios.post('/api/save-article', { html });
        this.$message.success('保存成功');
      } catch (err) {
        this.$message.error('保存失败');
        console.error(err);
      }
    }
  },
  mounted() {
    this.$nextTick(this.initQuill);
  }
};
</script>

<style scoped>
.rich-editor-container {
  margin: 20px;
}
.upload-demo {
  text-align: center;
}
</style>

将上述组件放入 App.vue 并启动项目,即可体验图片上传+缩放、视频上传、富文本保存等一整套流程。


8. 常见问题与注意事项

  1. 跨域问题

    • 若前端与后端分离部署,需要在后端设置 Access-Control-Allow-Origin 或使用 Nginx 反向代理,以支持文件上传和保存接口的跨域访问。
  2. 光标位置管理

    • const range = this.quill.getSelection() 获取当前光标位置,若用户尚未点击编辑器,这里可能返回 null。为保险起见,可加判断:

      let range = this.quill.getSelection();
      if (!range) {
        // 如果没有选区,则将图片/视频插入到内容末尾
        range = { index: this.quill.getLength() };
      }
      this.quill.insertEmbed(range.index, 'image', imageUrl);
  3. 多实例编辑器

    • 若页面中存在多个编辑器,各自需要独立的 Dialog 与上传逻辑,可改造成可复用组件,并传入唯一的 editorIdref
  4. 图片尺寸与比例

    • quill-image-resize-module 默认支持拖拽缩放,但可定制最大/最小宽度或不同比例。在注册时可传入配置项:

      Quill.register('modules/imageResize', ImageResize);
      this.editorOptions.modules.imageResize = {
        modules: [ 'Resize', 'DisplaySize', 'Toolbar' ],
        handleStyles: {
          backgroundColor: 'black',
          border: 'none',
          color: 'white'
        }
      };
    • 详见 quill-image-resize-module 文档
  5. 视频格式与兼容性

    • 确保后端上传的视频文件可直接播放(如 MP4、WebM 等),并在 HTML 中有正确的 Content-Type,否则 Quill 可能无法正常渲染。
  6. 富文本安全

    • 前端直接使用 v-html 渲染 HTML,务必确保后端保存的 HTML 已经过 XSS 过滤。可使用 sanitize-htmlxss-clean 等库。

9. 总结与扩展思路

本文通过实战示例,完整展现了如何在 Vue 项目中集成 Quill 富文本编辑器与 Element-UI 组件,实现 视频/图片上传图片缩放富文本内容保存 等核心功能。核心思路如下:

  1. 自定义 Quill Toolbar:将默认的“插入图片/视频 URL”按钮替换为“本地文件上传”按钮,通过 toolbar.addHandler 绑定事件。
  2. Element-UI Upload 组件:借助其可自定义 http-request 的上传方式,实现无缝的上传流程与进度控制。
  3. Quill Embed 插入:上传成功后,调用 quill.insertEmbed(range.index, 'image'/'video', url) 将资源插入编辑器。
  4. Quill Image Resize 模块:直接注册后即可为图片添加拖拽缩放柄,提升用户体验。
  5. 内容持久化:编辑器内容通过 v-modelquill.root.innerHTML 获取,提交给后端并存储。

若要进一步扩展,还可以考虑:

  • 进度条显示:利用 Element-UI 的 file-upload onProgress 回调或 Axios 的 onUploadProgress 显示上传进度。
  • 多图/多视频批量上传:允许用户一次性选多张图片或多个视频,后台返回多个 URL 后批量插入。
  • 自定义样式与主题:使用 Quill 自有主题或定制 CSS 更改工具栏图标与样式。
  • 服务器端渲染(SSR)兼容:若使用 Nuxt.js 或 Vue SSR,需要注意 Quill 仅在浏览器环境中才能正常加载。

希望本文所提供的代码示例ASCII 图解详细说明,能够帮助你快速掌握 Vue+Quill+Element-UI 组合在富文本编辑场景下的图片/视频上传与缩放实现。

2024-09-09

这是一个家教管理系统的需求,它包含了前后端的技术栈。前端使用了Vue.js和Element UI,后端使用了Spring Boot和MyBatis。

首先,我们需要定义一些接口,这些接口将会被前端调用,并且需要与后端进行数据的交互。

例如,我们可以创建一个管理员登录的接口:




@RestController
@RequestMapping("/api/v1/admin")
public class AdminController {
 
    @Autowired
    private AdminService adminService;
 
    @PostMapping("/login")
    public ResponseResult login(@RequestBody Admin admin, HttpSession session) {
        return adminService.login(admin, session);
    }
}

在这个接口中,我们使用了@RestController@RequestMapping注解来定义控制器和路由信息,使用@PostMapping注解来定义一个POST请求的接口,并且使用@RequestBody注解来接收前端传递的数据。

然后,我们需要在Service层处理具体的业务逻辑:




@Service
public class AdminService {
 
    @Autowired
    private AdminMapper adminMapper;
 
    public ResponseResult login(Admin admin, HttpSession session) {
        Admin adminDB = adminMapper.selectByName(admin.getName());
        if (adminDB != null && adminDB.getPassword().equals(admin.getPassword())) {
            session.setAttribute("admin", adminDB);
            return ResponseResult.SUCCESS("登录成功");
        }
        return ResponseResult.FAILED("登录失败");
    }
}

在Service层,我们使用了@Service注解来定义一个服务,并且使用@Autowired注解来自动注入Mapper层的对象。

最后,我们需要在Mapper层定义数据库的操作:




@Mapper
public interface AdminMapper {
 
    @Select("SELECT * FROM admin WHERE name = #{name}")
    Admin selectByName(@Param("name") String name);
}

在Mapper层,我们使用了@Mapper注解来定义一个MyBatis的Mapper接口,并且使用@Select注解来定义SQL语句。

以上就是一个简单的登录接口的定义过程,其他的接口定义也可以参照这个方式来进行。

注意:这只是一个接口定义的例子,实际项目中可能会涉及到更多的接口和更复杂的业务逻辑。

2024-09-09

人人开源的renren-security是一个基于Spring Boot、Vue 3和Element Plus等框架开发的权限管理平台。以下是如何使用renren-security的基本步骤:

  1. 克隆代码仓库:



git clone https://github.com/daxianplay/renren-security.git
  1. 导入数据库:

    将项目中/db目录下的renren_security.sql文件导入到MySQL数据库中。

  2. 修改配置文件:

    renren-security-dev模块中的application-dev.yml配置文件中,修改数据库连接信息。

  3. 启动项目:

    使用IDE(如IntelliJ IDEA或Eclipse)打开项目后,启动RenrenApplication类。

  4. 访问项目:

    启动完成后,访问http://localhost:8080,使用默认账号密码admin/123456登录系统。

注意:确保你的开发环境中已安装了Java、MySQL、Node.js以及相关的构建工具。

由于renren-security是一个完整的项目,包含前后端代码,因此你可以直接运行并体验。如果你想了解其中的权限管理功能,可以参考其代码,学习其中的设计和实现。

2024-09-09

在ElementUI中,使用栅格布局时,可以通过el-rowel-col组件的span属性来实现不同的等分。例如,要实现一个包含五个等分的布局,每个等分占据24分之一,可以这样做:




<template>
  <el-row>
    <el-col :span="4" v-for="n in 5" :key="n">第{{ n }}部分</el-col>
  </el-row>
</template>

如果要实现七等分的布局,则每个部分占据24分之一的三分之一,可以这样做:




<template>
  <el-row>
    <el-col :span="8" v-for="n in 7" :key="n">第{{ n }}部分</el-col>
  </el-row>
</template>

在上述例子中,el-row表示一行,el-col表示一列。:span="4"表示每个el-col占据24分之一的四分之一,而v-for="n in 5"表示循环五次生成五个el-col组件。类似地,对于七等分的情况,每个el-col占据24分之一的三分之一,通过循环七次生成七个el-col组件。

2024-09-09

由于提供的信息较为模糊,并未提供具体的源代码或者查询问题,我将提供一个简单的使用Spring Boot和Vue.js创建多租户SaaS应用的示例。

技术栈:

  • Spring Boot
  • Vue.js
  • Vue-Element-Plus

简单的多租户SaaS架构示例:

  1. 认证服务(Auth Service): 用于处理用户注册、登录、权限验证等操作。
  2. 租户服务(Tenant Service): 管理租户数据,确保每个租户拥有独立的数据库或命名空间。
  3. 核心业务服务(Core Business Service): 提供核心业务逻辑,与租户数据分离。

代码示例:

认证服务(Auth Service):




@RestController
@RequestMapping("/api/auth")
public class AuthController {
    // 用户登录接口
    @PostMapping("/login")
    public ResponseEntity<?> login(@RequestBody UserLoginDto userLoginDto) {
        // 登录逻辑...
    }
 
    // 用户注册接口
    @PostMapping("/register")
    public ResponseEntity<?> register(@RequestBody UserRegisterDto userRegisterDto) {
        // 注册逻辑...
    }
}

租户服务(Tenant Service):




@Service
public class TenantService {
    // 为租户创建数据库或者命名空间
    public void createTenant(String tenantId) {
        // 创建租户逻辑...
    }
}

核心业务服务(Core Business Service):




@RestController
@RequestMapping("/api/core-business")
public class CoreBusinessController {
    // 核心业务接口
    @GetMapping("/data")
    public ResponseEntity<?> getCoreBusinessData() {
        // 核心业务逻辑...
    }
}

前端(Vue.js):




<!-- 登录页面 -->
<template>
    <div>
        <input type="text" v-model="loginForm.username" placeholder="Username" />
        <input type="password" v-model="loginForm.password" placeholder="Password" />
        <button @click="login">Login</button>
    </div>
</template>
 
<script>
export default {
    data() {
        return {
            loginForm: {
                username: '',
                password: ''
            }
        };
    },
    methods: {
        login() {
            // 发送登录请求...
        }
    }
};
</script>

以上代码仅为示例,实际应用中需要根据具体需求进行详细设计和编码。

注意: 实际SaaS应用还需要考虑更多因素,如租户数据隔离、权限管理、支付计费、部署管理等。

2024-09-09

Element UI是一款基于Vue.js的前端UI框架,如果你想要创建一个登录框,可以使用Element UI中的Form组件和Input组件来实现。以下是一个简单的登录框的示例代码:




<template>
  <el-form ref="loginForm" :model="loginForm" label-width="80px">
    <el-form-item label="用户名">
      <el-input v-model="loginForm.username" autocomplete="off"></el-input>
    </el-form-item>
    <el-form-item label="密码">
      <el-input type="password" v-model="loginForm.password" autocomplete="off"></el-input>
    </el-form-item>
    <el-form-item>
      <el-button type="primary" @click="submitForm('loginForm')">登录</el-button>
      <el-button @click="resetForm('loginForm')">重置</el-button>
    </el-form-item>
  </el-form>
</template>
 
<script>
export default {
  data() {
    return {
      loginForm: {
        username: '',
        password: ''
      }
    };
  },
  methods: {
    submitForm(formName) {
      this.$refs[formName].validate((valid) => {
        if (valid) {
          alert('登录成功!');
          // 这里应该是登录逻辑
        } else {
          alert('请输入正确的用户名和密码!');
          return false;
        }
      });
    },
    resetForm(formName) {
      this.$refs[formName].resetFields();
    }
  }
};
</script>

在这个例子中,我们定义了一个带有用户名和密码的loginForm对象,并通过el-formel-form-item组件来构建登录框。用户输入信息后点击登录按钮会触发submitForm方法,该方法会验证表单数据的合法性,如果通过验证则会弹出一个提示框显示登录成功,否则提示用户输入错误。重置按钮则会清空表单数据。

2024-09-09

在Vue中使用Element UI时,可以通过el-table组件来创建表格,并通过el-table-column来设置表格的列。以下是一个简单的例子,展示了如何使用Element UI的表格组件:




<template>
  <el-table :data="tableData" style="width: 100%">
    <el-table-column prop="date" label="日期" width="180"></el-table-column>
    <el-table-column prop="name" label="姓名" width="180"></el-table-column>
    <el-table-column prop="address" label="地址"></el-table-column>
  </el-table>
</template>
 
<script>
export default {
  data() {
    return {
      tableData: [{
        date: '2016-05-02',
        name: '王小虎',
        address: '上海市普陀区金沙江路 1518 弄'
      }, {
        date: '2016-05-04',
        name: '李小虎',
        address: '上海市普陀区金沙江路 1517 弄'
      }, {
        date: '2016-05-01',
        name: '赵小虎',
        address: '上海市普陀区金沙江路 1519 弄'
      }, {
        date: '2016-05-03',
        name: '孙小虎',
        address: '上海市普陀区金沙江路 1516 弄'
      }]
    };
  }
};
</script>

在这个例子中,el-table组件通过:data属性绑定了一个包含数据的数组tableData,数组中的每个对象代表表格中的一行。el-table-column组件定义了表格的列,prop属性指定了数据对象中的键名,用于显示每列的数据。

2024-09-09



#include <QSqlDatabase>
#include <QSqlQuery>
#include <QVariant>
#include <QDebug>
 
int main(int argc, char *argv[]) {
    // 在Qt中,数据库操作通常在QCoreApplication之后创建
    QCoreApplication app(argc, argv);
 
    // 添加SQLite数据库驱动
    QSqlDatabase db = QSqlDatabase::addDatabase("QSQLITE");
 
    // 设置数据库名称(文件路径)
    db.setDatabaseName(":memory:"); // 使用内存数据库,也可以是文件路径
 
    // 尝试打开数据库
    if (!db.open()) {
        qDebug() << "无法打开数据库";
        return -1;
    }
 
    // 创建一个表
    QSqlQuery query;
    bool success = query.exec("CREATE TABLE IF NOT EXISTS people ("
                              "id INTEGER PRIMARY KEY AUTOINCREMENT, "
                              "firstname VARCHAR, "
                              "lastname VARCHAR)");
 
    if (!success) {
        qDebug() << "创建表失败:" << query.lastError();
        return -2;
    }
 
    // 插入数据
    success = query.exec("INSERT INTO people (firstname, lastname) "
                         "VALUES ('John', 'Doe')");
 
    if (!success) {
        qDebug() << "插入数据失败:" << query.lastError();
        return -3;
    }
 
    // 查询数据
    success = query.exec("SELECT * FROM people");
 
    if (!success) {
        qDebug() << "查询数据失败:" << query.lastError();
        return -4;
    }
 
    while (query.next()) {
        int id = query.value(0).toInt();
        QString firstName = query.value(1).toString();
        QString lastName = query.value(2).toString();
        qDebug() << id << firstName << lastName;
    }
 
    // 关闭数据库
    db.close();
 
    return app.exec();
}

这段代码演示了如何在Qt框架中使用C++操作SQLite数据库。它创建了一个内存数据库,定义了一个表,插入了一条记录,并且执行了一个查询,打印出结果。这是学习如何在Qt中使用SQLite的一个很好的起点。

2024-09-09

在Element UI中,可以通过以下方法来改变el-tooltip的箭头颜色或者隐藏箭头:

  1. 改变箭头颜色:

    使用CSS选择器来覆盖Element UI的默认样式。




/* 改变箭头颜色 */
.el-tooltip__popper .popper__arrow {
  border-color: red; /* 这里设置你想要的颜色 */
}
  1. 隐藏箭头:

    同样使用CSS来隐藏箭头。




/* 隐藏箭头 */
.el-tooltip__popper .popper__arrow {
  display: none;
}

在Vue组件中,你可以通过添加style标签或者在你的全局样式文件中添加上述CSS代码。如果你想要在组件内部动态改变这些样式,可以使用内联样式或者CSS类绑定。

例如,使用内联样式改变箭头颜色:




<template>
  <el-tooltip
    content="这是一段内容"
    placement="top"
    :open-delay="500"
    :popper-options="{ boundariesElement: 'body' }"
    :popper-class="popperClass"
  >
    <div>悬停显示</div>
  </el-tooltip>
</template>
 
<script>
export default {
  data() {
    return {
      popperClass: 'custom-popper'
    };
  }
};
</script>
 
<style>
/* 改变箭头颜色 */
.custom-popper .popper__arrow {
  border-color: blue; /* 改变为蓝色 */
}
 
/* 隐藏箭头 */
/* .custom-popper .popper__arrow {
  display: none;
} */
</style>

在上面的例子中,我们通过绑定popper-class属性来动态改变箭头的颜色。如果你想隐藏箭头,只需要取消注释相应的CSS代码即可。