Vue + Quill:富文本的添加、传输、展示逻辑,以及 csReplyQuill 组件封装

在工单回复、用户反馈、FAQ 问答这类后台系统里,普通 textarea 很快就不够用了:用户希望能插入图片,客服希望能上传附件,FAQ 需要支持图文混排,视频内容还要能预览。本文结合一个 Vue + vue-quill-editor + Quill 1.3.7 的实际封装,介绍富文本从"添加"到"传输"再到"展示"的完整逻辑,并给出 csReplyQuill 组件代码和使用方式。

一、整体思路

富文本不是把一段字符串直接塞给后端那么简单,它通常有三条链路:

  1. 添加链路:用户在编辑器里输入文字,或选择图片、附件、视频。组件负责上传文件,拿到服务端返回的地址后,再把图片或自定义附件节点插入 Quill。
  2. 传输链路 :父组件通过 v-model 拿到 HTML 字符串。提交前可以把公网地址转成服务端相对路径,减少环境耦合。
  3. 展示链路 :详情页通过 v-html 渲染 HTML。展示前可以把相对路径补成完整访问地址,同时补齐 .ql-attachment、视频等自定义节点样式。

也就是说,数据库里保存的核心字段可以就是一段 HTML:

html 复制代码
<p>您好,问题已处理。</p>
<p><img src="/upload/image/demo.png"></p>
<span class="ql-attachment" data-url="/upload/file/demo.pdf" data-name="demo.pdf">
  <a class="ql-attachment__name" href="/upload/file/demo.pdf">demo.pdf</a>
</span>

前端编辑时把文件上传为 URL,提交时保存 HTML,展示时再把 HTML 还原为可访问、可点击、样式一致的内容。

二、为什么要封装 csReplyQuill

项目里如果每个页面都直接写 quill-editor,很快会遇到重复代码:

  • 每个页面都要写 toolbar 配置。
  • 图片上传、附件上传、视频上传逻辑重复。
  • Quill 默认没有"附件"这种格式,需要自定义 blot。
  • 每个父组件都要判断空内容、字数、上传状态。
  • 回显时图片和附件的 URL 处理逻辑容易不一致。

所以这里把"编辑器能力"收敛到 csReplyQuill

  • 对外只暴露 v-modelplaceholderdisabledmaxLength
  • 内部负责 Quill 初始化、toolbar、上传、插入节点。
  • 通过 state-change 通知父组件当前字数和是否有有效内容。
  • 图片使用 Quill 内置 image embed。
  • 附件、视频、视频上传进度使用自定义 blot。

三、完整组件代码:csReplyQuill.vue

依赖:quill@1.3.7vue-quill-editor@3.xaxios

上传接口示例:图片上传 /file/upload/image,附件和视频上传 /file/upload。接口返回约定为 { code: 200, data: { url, name, ext, fileId, size } },如果你的接口字段不同,只需要调整 getUploadField 里的字段优先级。

vue 复制代码
<template>
  <div class="cs-reply-quill">
    <quill-editor
      ref="editor"
      v-model="innerValue"
      :options="editorOption"
      @change="handleEditorChange"
    />

    <input
      ref="imageInput"
      type="file"
      accept=".jpg,.jpeg,.png,image/png,image/jpg,image/jpeg"
      class="cs-reply-quill__input"
      @change="handleImageSelect"
    />

    <input
      ref="attachmentInput"
      type="file"
      accept=".pdf,.xls,.xlsx,.doc,.docx"
      class="cs-reply-quill__input"
      @change="handleAttachmentSelect"
    />

    <input
      ref="videoInput"
      type="file"
      accept=".mp4,.mov,.avi,.wmv,.flv,.mkv,video/mp4,video/quicktime,video/x-msvideo,video/x-ms-wmv,video/x-flv,video/x-matroska"
      class="cs-reply-quill__input"
      @change="handleVideoSelect"
    />
  </div>
</template>

<script>
import axios from "axios";
import Quill from "quill";
import "quill/dist/quill.core.css";
import "quill/dist/quill.snow.css";
import "quill/dist/quill.bubble.css";
import { quillEditor } from "vue-quill-editor";
import { getToken } from "@/utils/auth";

const Embed = Quill.import("blots/embed");
const BlockEmbed = Quill.import("blots/block/embed");

function escapeHtml(value) {
  return String(value || "").replace(/[&<>"']/g, char => {
    const map = {
      "&": "&amp;",
      "<": "&lt;",
      ">": "&gt;",
      '"': "&quot;",
      "'": "&#39;"
    };
    return map[char];
  });
}

function getUploadField(uploadData, keys = []) {
  if (typeof uploadData === "string") {
    return uploadData;
  }
  if (!uploadData || typeof uploadData !== "object") {
    return "";
  }
  for (const key of keys) {
    if (uploadData[key]) {
      return uploadData[key];
    }
  }
  return "";
}

class AttachmentBlot extends Embed {
  static create(value) {
    const node = super.create();
    const fileName = value.name || "附件";
    const fileUrl = value.url || "";

    node.setAttribute("contenteditable", "false");
    node.setAttribute("data-url", fileUrl);
    node.setAttribute("data-name", fileName);
    node.setAttribute("data-ext", value.ext || "");
    node.setAttribute("data-file-id", value.fileId || "");
    node.setAttribute("data-size", value.size ? String(value.size) : "");
    node.innerHTML =
      '<span class="ql-attachment__icon"><i class="el-icon-document"></i></span>' +
      `<a class="ql-attachment__name" target="_blank" rel="noopener noreferrer" href="${fileUrl || "javascript:void(0)"}">${escapeHtml(fileName)}</a>`;

    return node;
  }

  static value(node) {
    return {
      url: node.getAttribute("data-url") || "",
      name: node.getAttribute("data-name") || "",
      ext: node.getAttribute("data-ext") || "",
      fileId: node.getAttribute("data-file-id") || "",
      size: node.getAttribute("data-size") || ""
    };
  }
}

AttachmentBlot.blotName = "attachment";
AttachmentBlot.tagName = "span";
AttachmentBlot.className = "ql-attachment";

class VideoAttachmentBlot extends BlockEmbed {
  static create(value) {
    const node = super.create();
    const fileName = value.name || "视频";
    const fileUrl = value.url || "";

    node.setAttribute("contenteditable", "false");
    node.setAttribute("data-url", fileUrl);
    node.setAttribute("data-name", fileName);
    node.setAttribute("data-ext", value.ext || "");
    node.setAttribute("data-file-id", value.fileId || "");
    node.setAttribute("data-size", value.size ? String(value.size) : "");
    node.innerHTML =
      `<video class="ql-video-attachment__player" controls src="${fileUrl}"></video>` +
      `<a class="ql-video-attachment__name" target="_blank" rel="noopener noreferrer" href="${fileUrl || "javascript:void(0)"}">${escapeHtml(fileName)}</a>`;

    return node;
  }

  static value(node) {
    return {
      url: node.getAttribute("data-url") || "",
      name: node.getAttribute("data-name") || "",
      ext: node.getAttribute("data-ext") || "",
      fileId: node.getAttribute("data-file-id") || "",
      size: node.getAttribute("data-size") || ""
    };
  }
}

VideoAttachmentBlot.blotName = "videoAttachment";
VideoAttachmentBlot.tagName = "div";
VideoAttachmentBlot.className = "ql-video-attachment";

class VideoUploadBlot extends BlockEmbed {
  static create(value) {
    const node = super.create();
    const fileName = value.name || "视频";
    const uploadId = value.uploadId || "";
    const progress = Number(value.progress || 0);

    node.setAttribute("contenteditable", "false");
    node.setAttribute("data-upload-id", uploadId);
    node.setAttribute("data-name", fileName);
    node.setAttribute("data-progress", String(progress));
    node.innerHTML = VideoUploadBlot.render(escapeHtml(fileName), progress, uploadId);

    return node;
  }

  static render(safeName, progress, uploadId) {
    return (
      '<div class="ql-video-upload__head">' +
      '<span class="ql-video-upload__icon"><i class="el-icon-video-camera"></i></span>' +
      '<span class="ql-video-upload__name">' +
      safeName +
      "</span>" +
      `<button class="ql-video-upload__cancel" type="button" data-upload-id="${uploadId}">取消</button>` +
      "</div>" +
      '<div class="ql-video-upload__bar">' +
      `<span class="ql-video-upload__bar-inner" style="width: ${progress}%"></span>` +
      "</div>" +
      `<div class="ql-video-upload__meta">视频上传中 ${progress}%</div>`
    );
  }

  static value(node) {
    return {
      uploadId: node.getAttribute("data-upload-id") || "",
      name: node.getAttribute("data-name") || "",
      progress: node.getAttribute("data-progress") || 0
    };
  }
}

VideoUploadBlot.blotName = "videoUpload";
VideoUploadBlot.tagName = "div";
VideoUploadBlot.className = "ql-video-upload";

Quill.register(
  {
    "formats/attachment": AttachmentBlot,
    "formats/videoAttachment": VideoAttachmentBlot,
    "formats/videoUpload": VideoUploadBlot
  },
  true
);

export default {
  name: "CsReplyQuill",
  components: {
    quillEditor
  },
  props: {
    value: {
      type: String,
      default: ""
    },
    placeholder: {
      type: String,
      default: ""
    },
    disabled: {
      type: Boolean,
      default: false
    },
    maxLength: {
      type: Number,
      default: 500
    }
  },
  data() {
    return {
      innerValue: "",
      imageUploadUrl: import.meta.env.VITE_APP_BASE_API + "/file/upload/image",
      fileUploadUrl: import.meta.env.VITE_APP_BASE_API + "/file/upload",
      videoUploadUrl: import.meta.env.VITE_APP_BASE_API + "/file/upload",
      videoUploadTasks: {},
      editorOption: {
        theme: "snow",
        placeholder: this.placeholder,
        readOnly: this.disabled,
        modules: {
          toolbar: {
            container: [
              ["bold", "italic", "underline"],
              [{ list: "ordered" }, { list: "bullet" }],
              ["link", "image", "attachment", "videoAttachment"],
              ["clean"]
            ],
            handlers: {
              image: () => this.triggerImageSelect(),
              attachment: () => this.triggerAttachmentSelect(),
              videoAttachment: () => this.triggerVideoSelect()
            }
          }
        }
      }
    };
  },
  watch: {
    value: {
      immediate: true,
      handler(value) {
        const normalizedValue = this.normalizeHtml(value);
        if (normalizedValue !== this.normalizeHtml(this.innerValue)) {
          this.innerValue = normalizedValue;
        }
      }
    },
    disabled(value) {
      const quill = this.getQuill();
      if (quill) {
        quill.enable(!value);
      }
    }
  },
  mounted() {
    const quill = this.getQuill();
    if (!quill) {
      return;
    }

    quill.enable(!this.disabled);
    quill.root.addEventListener("paste", this.handlePaste);
    quill.root.addEventListener("click", this.handleEditorClick);
    quill.on("text-change", this.handleTextChange);
    this.emitState();
  },
  beforeDestroy() {
    const quill = this.getQuill();
    if (quill) {
      quill.root.removeEventListener("paste", this.handlePaste);
      quill.root.removeEventListener("click", this.handleEditorClick);
      quill.off("text-change", this.handleTextChange);
    }

    Object.keys(this.videoUploadTasks).forEach(uploadId => {
      const task = this.videoUploadTasks[uploadId];
      if (task && task.cancel) {
        task.cancel("组件销毁,取消视频上传");
      }
    });
  },
  methods: {
    getQuill() {
      return this.$refs.editor ? this.$refs.editor.quill : null;
    },
    normalizeHtml(html) {
      if (!html || html === "<p><br></p>") {
        return "";
      }
      return html;
    },
    getPlainTextLength() {
      const quill = this.getQuill();
      if (!quill) {
        return 0;
      }

      return (quill.getContents().ops || []).reduce((total, op) => {
        if (typeof op.insert !== "string") {
          return total;
        }
        return total + op.insert.replace(/\n/g, "").length;
      }, 0);
    },
    hasContent() {
      const quill = this.getQuill();
      if (!quill) {
        return false;
      }

      return (quill.getContents().ops || []).some(op => {
        if (typeof op.insert === "string") {
          return !!op.insert.replace(/\s|\n/g, "");
        }
        return !!(
          op.insert &&
          (op.insert.image || op.insert.attachment || op.insert.videoAttachment || op.insert.videoUpload)
        );
      });
    },
    emitState() {
      const html = this.normalizeHtml(this.innerValue);
      this.$emit("state-change", {
        html,
        textLength: this.getPlainTextLength(),
        hasContent: this.hasContent()
      });
    },
    handleEditorChange({ html }) {
      const normalizedHtml = this.normalizeHtml(html);
      this.innerValue = normalizedHtml;
      this.$emit("input", normalizedHtml);
      this.emitState();
    },
    handleTextChange(delta, oldDelta, source) {
      if (source !== "user") {
        this.emitState();
        return;
      }

      if (this.getPlainTextLength() > this.maxLength) {
        const quill = this.getQuill();
        if (quill) {
          quill.history.undo();
          this.$message.warning(`文字内容最多输入 ${this.maxLength} 字`);
        }
      }

      this.emitState();
    },
    triggerImageSelect() {
      if (!this.disabled) {
        this.$refs.imageInput.click();
      }
    },
    triggerAttachmentSelect() {
      if (!this.disabled) {
        this.$refs.attachmentInput.click();
      }
    },
    triggerVideoSelect() {
      if (!this.disabled) {
        this.$refs.videoInput.click();
      }
    },
    async handlePaste(event) {
      const clipboardItems = Array.from((event.clipboardData && event.clipboardData.items) || []);
      const imageItems = clipboardItems.filter(item => item.type && item.type.indexOf("image/") === 0);

      if (!imageItems.length) {
        return;
      }

      event.preventDefault();
      for (const item of imageItems) {
        const file = item.getAsFile();
        if (file) {
          await this.insertImage(file);
        }
      }
    },
    async handleImageSelect(event) {
      const file = event.target.files && event.target.files[0];
      if (file) {
        await this.insertImage(file);
      }
      event.target.value = "";
    },
    async handleAttachmentSelect(event) {
      const file = event.target.files && event.target.files[0];
      if (file) {
        await this.insertAttachment(file);
      }
      event.target.value = "";
    },
    async handleVideoSelect(event) {
      const file = event.target.files && event.target.files[0];
      if (file) {
        await this.insertVideo(file);
      }
      event.target.value = "";
    },
    async uploadFile(file, url, options = {}) {
      const formData = new FormData();
      formData.append("file", file);

      const response = await axios({
        method: "post",
        baseURL: import.meta.env.VITE_APP_BASE_API,
        url: url.replace(import.meta.env.VITE_APP_BASE_API, ""),
        headers: {
          Authorization: "Bearer " + getToken(),
          "Content-Type": "multipart/form-data"
        },
        data: formData,
        cancelToken: options.cancelToken,
        onUploadProgress: options.onProgress
      });

      const result = response.data || {};
      if (result.code !== 200) {
        throw new Error(result.msg || "上传失败");
      }

      return result.data || {};
    },
    handleEditorClick(event) {
      const cancelButton = event.target.closest && event.target.closest(".ql-video-upload__cancel");
      if (!cancelButton) {
        return;
      }

      this.cancelVideoUpload(cancelButton.getAttribute("data-upload-id"));
    },
    createUploadId() {
      return `video-upload-${Date.now()}-${Math.random().toString(16).slice(2)}`;
    },
    insertVideoUploadPlaceholder(file) {
      const quill = this.getQuill();
      if (!quill) {
        return "";
      }

      const uploadId = this.createUploadId();
      const range = quill.getSelection(true);
      const index = range ? range.index : quill.getLength();
      quill.insertEmbed(index, "videoUpload", { uploadId, name: file.name, progress: 0 }, "user");
      quill.insertText(index + 1, "\n", "user");
      quill.setSelection(index + 2, 0, "silent");
      this.emitState();

      return uploadId;
    },
    getVideoUploadNode(uploadId) {
      const quill = this.getQuill();
      if (!quill || !uploadId) {
        return null;
      }
      return quill.root.querySelector(`.ql-video-upload[data-upload-id="${uploadId}"]`);
    },
    getVideoUploadIndex(uploadId) {
      const quill = this.getQuill();
      const node = this.getVideoUploadNode(uploadId);
      if (!quill || !node) {
        return -1;
      }

      const blot = Quill.find(node);
      return blot ? quill.getIndex(blot) : -1;
    },
    updateVideoUploadProgress(uploadId, progress) {
      const node = this.getVideoUploadNode(uploadId);
      if (!node) {
        return;
      }

      const safeProgress = Math.max(0, Math.min(99, Math.round(progress)));
      const bar = node.querySelector(".ql-video-upload__bar-inner");
      const meta = node.querySelector(".ql-video-upload__meta");
      node.setAttribute("data-progress", String(safeProgress));
      if (bar) {
        bar.style.width = `${safeProgress}%`;
      }
      if (meta) {
        meta.textContent = `视频上传中 ${safeProgress}%`;
      }
    },
    removeVideoUploadPlaceholder(uploadId) {
      const quill = this.getQuill();
      const index = this.getVideoUploadIndex(uploadId);
      if (!quill || index < 0) {
        return;
      }

      quill.deleteText(index, 1, "user");
      this.emitState();
    },
    replaceVideoUploadPlaceholder(uploadId, videoData) {
      const quill = this.getQuill();
      if (!quill) {
        return;
      }

      const index = this.getVideoUploadIndex(uploadId);
      const insertIndex = index >= 0 ? index : quill.getLength();
      if (index >= 0) {
        quill.deleteText(index, 1, "silent");
      }
      quill.insertEmbed(insertIndex, "videoAttachment", videoData, "user");
      quill.setSelection(insertIndex + 1, 0, "silent");
      this.emitState();
    },
    cancelVideoUpload(uploadId) {
      const task = this.videoUploadTasks[uploadId];
      if (!task) {
        return;
      }

      task.cancel("视频上传已取消");
      this.removeVideoUploadPlaceholder(uploadId);
      this.$delete(this.videoUploadTasks, uploadId);
      this.$message.info("已取消视频上传");
    },
    resolveAssetUrl(url) {
      const rawUrl = String(url || "").trim();
      if (!rawUrl) {
        return "";
      }
      if (/^(https?:|blob:|data:)/.test(rawUrl)) {
        return rawUrl;
      }

      const baseUrl = import.meta.env.VITE_APP_BASE_API || "";
      if (!baseUrl) {
        return rawUrl;
      }
      if (/^https?:/.test(baseUrl)) {
        return new URL(rawUrl, baseUrl.endsWith("/") ? baseUrl : baseUrl + "/").href;
      }
      return window.location.origin + baseUrl.replace(/\/+$/, "/") + rawUrl.replace(/^\/+/, "");
    },
    async insertImage(file) {
      try {
        const uploadData = await this.uploadFile(file, this.imageUploadUrl);
        const imageUrl = this.resolveAssetUrl(getUploadField(uploadData, ["imgUrl", "url", "fileUrl"]));
        if (!imageUrl) {
          throw new Error("图片地址无效");
        }

        const quill = this.getQuill();
        const range = quill.getSelection(true);
        const index = range ? range.index : quill.getLength();
        quill.insertEmbed(index, "image", imageUrl, "user");
        quill.setSelection(index + 1, 0, "silent");
        this.emitState();
      } catch (error) {
        this.$message.error(error.message || "图片上传失败");
      }
    },
    async insertAttachment(file) {
      try {
        const uploadData = await this.uploadFile(file, this.fileUploadUrl);
        const fileUrl = this.resolveAssetUrl(getUploadField(uploadData, ["url", "fileUrl", "imgUrl"]));
        if (!fileUrl) {
          throw new Error("附件地址无效");
        }

        const quill = this.getQuill();
        const range = quill.getSelection(true);
        const index = range ? range.index : quill.getLength();
        quill.insertEmbed(
          index,
          "attachment",
          {
            name: uploadData.name || file.name,
            url: fileUrl,
            ext: uploadData.ext || (file.name.split(".").pop() || ""),
            fileId: uploadData.fileId || uploadData.id || "",
            size: uploadData.size || file.size || ""
          },
          "user"
        );
        quill.insertText(index + 1, "\n", "user");
        quill.setSelection(index + 2, 0, "silent");
        this.emitState();
      } catch (error) {
        this.$message.error(error.message || "附件上传失败");
      }
    },
    async insertVideo(file) {
      const uploadId = this.insertVideoUploadPlaceholder(file);
      if (!uploadId) {
        return;
      }

      const source = axios.CancelToken.source();
      this.$set(this.videoUploadTasks, uploadId, { cancel: source.cancel });

      try {
        const uploadData = await this.uploadFile(file, this.videoUploadUrl, {
          cancelToken: source.token,
          onProgress: event => {
            if (event.total) {
              this.updateVideoUploadProgress(uploadId, (event.loaded / event.total) * 100);
            }
          }
        });
        const videoUrl = this.resolveAssetUrl(getUploadField(uploadData, ["url", "fileUrl", "imgUrl"]));
        if (!videoUrl) {
          throw new Error("视频地址无效");
        }

        this.replaceVideoUploadPlaceholder(uploadId, {
          name: uploadData.name || file.name,
          url: videoUrl,
          ext: uploadData.ext || (file.name.split(".").pop() || ""),
          fileId: uploadData.fileId || uploadData.id || "",
          size: uploadData.size || file.size || ""
        });
      } catch (error) {
        if (!axios.isCancel(error)) {
          this.removeVideoUploadPlaceholder(uploadId);
          this.$message.error(error.message || "视频上传失败");
        }
      } finally {
        this.$delete(this.videoUploadTasks, uploadId);
      }
    }
  }
};
</script>

<style lang="scss" scoped>
.cs-reply-quill__input {
  display: none;
}

::v-deep .ql-toolbar.ql-snow {
  border: 1px solid #dbe6f7;
  border-radius: 12px 12px 0 0;
  background: #f8fbff;
}

::v-deep .ql-container.ql-snow {
  border: 1px solid #dbe6f7;
  border-top: 0;
  border-radius: 0 0 12px 12px;
  background: #fff;
}

::v-deep .ql-editor {
  min-height: 120px;
  font-size: 14px;
  line-height: 1.8;
  color: #44536d;
  white-space: pre-wrap;
  word-break: break-word;
  overflow-wrap: anywhere;
}

::v-deep .ql-editor img {
  max-width: 180px;
  border-radius: 12px;
}

::v-deep .ql-toolbar .ql-attachment,
::v-deep .ql-toolbar .ql-videoAttachment {
  width: 28px;
  padding: 0;
}

::v-deep .ql-toolbar .ql-attachment::before {
  content: "📎";
}

::v-deep .ql-toolbar .ql-videoAttachment::before {
  content: "🎬";
}

::v-deep .ql-attachment {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  margin: 4px 0;
  padding: 6px 10px;
  border-radius: 10px;
  background: #f3f7fd;
  color: #466089;
}

::v-deep .ql-attachment__name {
  color: inherit;
  text-decoration: none;
  word-break: break-all;
}

::v-deep .ql-video-attachment {
  display: grid;
  gap: 8px;
  max-width: 360px;
  margin: 8px 0;
  padding: 10px;
  border-radius: 12px;
  background: #f3f7fd;
  color: #466089;
}

::v-deep .ql-video-attachment__player {
  width: 100%;
  max-height: 220px;
  border-radius: 10px;
  background: #0f172a;
}

::v-deep .ql-video-attachment__name {
  color: inherit;
  text-decoration: none;
  word-break: break-all;
}

::v-deep .ql-video-upload {
  display: grid;
  gap: 10px;
  max-width: 360px;
  margin: 8px 0;
  padding: 12px;
  border: 1px solid #dbe6f7;
  border-radius: 12px;
  background: #f8fbff;
  color: #466089;
}

::v-deep .ql-video-upload__head {
  display: grid;
  grid-template-columns: 20px minmax(0, 1fr) auto;
  align-items: center;
  gap: 8px;
}

::v-deep .ql-video-upload__name {
  overflow: hidden;
  color: #24364f;
  font-weight: 500;
  text-overflow: ellipsis;
  white-space: nowrap;
}

::v-deep .ql-video-upload__cancel {
  height: 24px;
  padding: 0 8px;
  border: 1px solid #d6dfed;
  border-radius: 6px;
  background: #fff;
  color: #6b7890;
  cursor: pointer;
  font-size: 12px;
}

::v-deep .ql-video-upload__bar {
  height: 6px;
  overflow: hidden;
  border-radius: 999px;
  background: #e7edf6;
}

::v-deep .ql-video-upload__bar-inner {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: linear-gradient(90deg, #2f80ed 0%, #17c3b2 100%);
  transition: width 0.2s ease;
}

::v-deep .ql-video-upload__meta {
  color: #8a97ad;
  font-size: 12px;
  line-height: 1;
}
</style>

四、父组件如何使用

1. 引入并注册组件

js 复制代码
import csReplyQuill from "@/components/biz/csReplyQuill.vue";

export default {
  components: {
    csReplyQuill
  }
};

2. 编辑区使用 v-model

vue 复制代码
<cs-reply-quill
  ref="replyEditor"
  v-model="replyForm.content"
  placeholder="输入回复内容..."
  :max-length="500"
  @state-change="handleReplyEditorState"
/>

<div class="reply-actions">
  <span>正文内容 {{ replyTextLength }}/500 字</span>
  <el-button type="primary" @click="submitReply">发送回复</el-button>
</div>

3. 父组件状态和提交逻辑

js 复制代码
export default {
  data() {
    return {
      replyForm: {
        content: ""
      },
      replyTextLength: 0,
      replyHasContent: false
    };
  },
  methods: {
    handleReplyEditorState(state) {
      this.replyTextLength = state && state.textLength ? state.textLength : 0;
      this.replyHasContent = !!(state && state.hasContent);
    },
    submitReply() {
      if (!this.replyHasContent) {
        this.$message.warning("请输入回复内容后再发送");
        return;
      }

      const payload = {
        content: this.transformReplyContentForSubmit(this.replyForm.content)
      };

      // 调接口保存
      // await answerFeedback(payload)

      this.replyForm.content = "";
      this.replyTextLength = 0;
      this.replyHasContent = false;
      this.$message.success("回复已发送");
    }
  }
};

五、提交前和展示前的 HTML 转换

实际项目经常分开发环境、测试环境、生产环境。编辑器里插入的图片可能是完整地址:

html 复制代码
<img src="https://example.com/prod-api/upload/image/a.png">

但保存到数据库时,我们更希望保存成相对路径:

html 复制代码
<img src="/upload/image/a.png">

这样部署域名或网关前缀变化时,历史数据不用迁移。

js 复制代码
methods: {
  resolveAssetUrl(url) {
    const rawUrl = String(url || "").trim();
    if (!rawUrl) {
      return "";
    }
    if (/^(https?:|blob:|data:|javascript:)/.test(rawUrl)) {
      return rawUrl;
    }

    const assetBaseUrl = String(this.imgUrl || "").trim();
    if (!assetBaseUrl) {
      return rawUrl;
    }

    return assetBaseUrl.replace(/\/+$/, "/") + rawUrl.replace(/^\/+/, "");
  },
  extractAssetPath(url) {
    const rawUrl = String(url || "").trim();
    if (!rawUrl) {
      return "";
    }
    if (/^(blob:|data:|javascript:)/.test(rawUrl)) {
      return rawUrl;
    }

    const assetBaseUrl = String(this.imgUrl || "").trim().replace(/\/+$/, "/");
    if (assetBaseUrl && rawUrl.startsWith(assetBaseUrl)) {
      return rawUrl.slice(assetBaseUrl.length).replace(/^\/+/, "");
    }

    return rawUrl;
  },
  transformReplyContentForSubmit(content) {
    const container = document.createElement("div");
    container.innerHTML = content || "";

    container.querySelectorAll("img").forEach(image => {
      image.setAttribute("src", this.extractAssetPath(image.getAttribute("src")));
    });

    container.querySelectorAll(".ql-attachment").forEach(attachment => {
      const dataUrl = attachment.getAttribute("data-url");
      attachment.setAttribute("data-url", this.extractAssetPath(dataUrl));

      const link = attachment.querySelector(".ql-attachment__name");
      if (link) {
        link.setAttribute("href", this.extractAssetPath(link.getAttribute("href")));
      }
    });

    return container.innerHTML;
  },
  transformReplyContentForDisplay(content) {
    const container = document.createElement("div");
    container.innerHTML = content || "";

    container.querySelectorAll("img").forEach(image => {
      image.setAttribute("src", this.resolveAssetUrl(image.getAttribute("src")));
    });

    container.querySelectorAll(".ql-attachment").forEach(attachment => {
      const dataUrl = attachment.getAttribute("data-url");
      attachment.setAttribute("data-url", this.resolveAssetUrl(dataUrl));

      const link = attachment.querySelector(".ql-attachment__name");
      if (link) {
        link.setAttribute("href", this.resolveAssetUrl(link.getAttribute("href")));
      }
    });

    return container.innerHTML;
  },
  renderReplyContent(content) {
    return this.transformReplyContentForDisplay(content);
  }
}

展示时使用:

vue 复制代码
<div class="reply-rich-text" v-html="renderReplyContent(reply.content)"></div>

六、展示区样式

编辑器里的样式是 scoped 在组件内部的,详情页用 v-html 渲染时不会自动继承,所以展示容器也需要补一份富文本样式。

scss 复制代码
.reply-rich-text {
  word-break: break-word;
}

::v-deep .reply-rich-text p {
  margin: 0 0 8px;
}

::v-deep .reply-rich-text p:last-child {
  margin-bottom: 0;
}

::v-deep .reply-rich-text img {
  display: block;
  max-width: 180px;
  border-radius: 12px;
}

::v-deep .reply-rich-text .ql-attachment {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  max-width: 100%;
  padding: 6px 10px;
  border-radius: 10px;
  background: #f3f7fd;
  color: #466089;
}

::v-deep .reply-rich-text .ql-attachment__name {
  color: inherit;
  text-decoration: none;
  word-break: break-all;
}

::v-deep .reply-rich-text .ql-video-attachment {
  display: grid;
  gap: 8px;
  max-width: 360px;
  margin: 8px 0;
  padding: 10px;
  border-radius: 12px;
  background: #f3f7fd;
}

::v-deep .reply-rich-text .ql-video-attachment__player {
  width: 100%;
  max-height: 220px;
  border-radius: 10px;
  background: #0f172a;
}

七、关键点总结

csReplyQuill 的核心不是"套一层 Quill",而是把富文本的业务闭环封装起来:

  • 输入文字时,组件通过 text-change 统计纯文本长度,超长就 history.undo()
  • 上传图片后,使用 quill.insertEmbed(index, "image", imageUrl, "user") 插入图片。
  • 上传附件时,通过自定义 AttachmentBlot 把文件渲染成可点击节点。
  • 上传视频时,先插入 VideoUploadBlot 占位节点显示进度,上传成功后替换成 VideoAttachmentBlot
  • 父组件只关心 v-model 里的 HTML 和 state-change 返回的状态。
  • 提交前处理 URL,展示前还原 URL,保证多环境下数据稳定。

最后提醒一句:如果内容来自用户输入,使用 v-html 前最好在后端或前端做 HTML 白名单清洗,避免 XSS。富文本的本质是 HTML,体验做起来很顺手,安全边界也要一起补上。

相关推荐
XS0301061 小时前
Java Web实现简易CRUD操作笔记
java·前端·笔记
Shadow(⊙o⊙)1 小时前
qt内详解信号和槽的基本概念+实例演示
开发语言·前端·c++·qt·学习
qq_381338501 小时前
Vue3 组合式函数设计模式:从基础封装到高级复用实战
前端·vue.js·设计模式
步十人1 小时前
【CSS】基础一篇过
前端·css
回眸一笑吟离歌1 小时前
edge浏览器更新后打开局域网服务报错:ERR_ADDRESS_UNREACHABLE
前端·edge
幽络源小助理1 小时前
在线图片处理工具源码, 多功能编辑格式转换HTML单文件版
前端·html
humcomm1 小时前
AI编程时代前端架构师的机遇和挑战
前端·架构·ai编程
adminwolf1 小时前
自研企业微信SCRM系统源码独立部署(Golang+Vue.js)
前端·vue.js·企业微信
小短腿的代码世界1 小时前
QwtPolar 与实时示波器级渲染优化:雷达图到示波器曲线的极限性能调优
前端·qt·架构·交互