Chat
Ask me anything
Ithy Logo

使用 React、JavaScript 和 React-Quill 实现支持文本、图片、PDF、视频混排的客服回复消息

全面指南:打造功能强大的客服回复编辑器

react quill editor interface

关键要点

  • 安装并配置 React-Quill:从安装依赖到设置基础编辑器功能。
  • 扩展编辑器功能:自定义工具栏和处理图片、PDF、视频的上传与嵌入。
  • 优化用户体验与安全性:确保文件上传的安全性和用户界面的友好性。

1. 安装与基础配置

1.1 安装所需依赖

首先,需要在项目中安装 React、JavaScript 和 React-Quill 相关的依赖包。可以使用 npm 或 yarn 进行安装:

npm install react react-dom react-quill quill quill-image-resize-module

或者使用 yarn:

yarn add react react-dom react-quill quill quill-image-resize-module

1.2 引入 React-Quill 并配置编辑器

在 React 组件中引入 React-Quill,并进行基础配置:

import React, { useState, useRef } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
import ImageResize from 'quill-image-resize-module';
import Quill from 'quill';

Quill.register('modules/imageResize', ImageResize);

const CustomerSupportEditor = () => {
  const [content, setContent] = useState('');
  const quillRef = useRef(null);

  const modules = {
    toolbar: {
      container: "#toolbar",
      handlers: {
        image: imageHandler,
        pdf: pdfHandler,
        video: videoHandler
      }
    },
    imageResize: {
      modules: ['Resize', 'DisplaySize']
    }
  };

  const formats = [
    'header', 'bold', 'italic', 'underline', 'strike', 'blockquote',
    'list', 'bullet', 'indent',
    'link', 'image', 'video', 'pdf', 'file'
  ];

  const handleChange = (value) => {
    setContent(value);
  };

  return (
    <div>
      <div id="toolbar">
        <button className="ql-bold"></button>
        <button className="ql-italic"></button>
        <button className="ql-underline"></button>
        <button className="ql-image"></button>
        <button className="ql-pdf">PDF</button>
        <button className="ql-video">Video</button>
      </div>
      <ReactQuill
        ref={quillRef}
        theme="snow"
        value={content}
        onChange={handleChange}
        modules={modules}
        formats={formats}
      />
      <button onClick={handleSubmit}>发送</button>
    </div>
  );
};

export default CustomerSupportEditor;

以上代码展示了如何基本引入 React-Quill,并设置自定义工具栏。接下来,将详细介绍各个功能的扩展。

2. 扩展编辑器功能

2.1 自定义工具栏

为了支持插入图片、PDF 和视频,需要在工具栏中添加对应的按钮,并为这些按钮定义处理函数。

2.1.1 定义自定义工具栏

在编辑器上方定义一个自定义工具栏:

<div id="toolbar">
  <button className="ql-bold"></button>
  <button className="ql-italic"></button>
  <button className="ql-underline"></button>
  <button className="ql-image"></button>
  <button className="ql-pdf">PDF</button>
  <button className="ql-video">Video</button>
</div>

2.1.2 实现自定义处理函数

为图片、PDF 和视频按钮分别定义处理函数:

function imageHandler() {
  const input = document.createElement('input');
  input.setAttribute('type', 'file');
  input.setAttribute('accept', 'image/*');
  input.click();
  
  input.onchange = async () => {
    const file = input.files[0];
    const url = await uploadFile(file);
    const editor = quillRef.current.getEditor();
    const range = editor.getSelection();
    editor.insertEmbed(range.index, 'image', url);
  };
}

function pdfHandler() {
  const input = document.createElement('input');
  input.setAttribute('type', 'file');
  input.setAttribute('accept', 'application/pdf');
  input.click();
  
  input.onchange = async () => {
    const file = input.files[0];
    const url = await uploadFile(file);
    const editor = quillRef.current.getEditor();
    const range = editor.getSelection();
    editor.insertEmbed(range.index, 'pdf', url);
  };
}

function videoHandler() {
  const input = document.createElement('input');
  input.setAttribute('type', 'file');
  input.setAttribute('accept', 'video/*');
  input.click();
  
  input.onchange = async () => {
    const file = input.files[0];
    const url = await uploadFile(file);
    const editor = quillRef.current.getEditor();
    const range = editor.getSelection();
    editor.insertEmbed(range.index, 'video', url);
  };
}

以上函数通过创建隐藏的文件输入框,让用户选择文件后上传,并将返回的 URL 嵌入到编辑器中。

2.2 文件上传处理

处理文件上传需要与后端服务器交互,将用户上传的文件存储并返回可访问的 URL。以下是一个示例上传函数:

async function uploadFile(file) {
  const formData = new FormData();
  formData.append('file', file);
  
  try {
    const response = await fetch('/upload', {
      method: 'POST',
      body: formData
    });
    if (!response.ok) {
      throw new Error('上传失败');
    }
    const data = await response.json();
    return data.url; // 假设后端返回 { url: 'uploaded_file_url' }
  } catch (error) {
    console.error('Error uploading file:', error);
    return '';
  }
}

请根据实际情况替换上传 URL 和返回数据结构。

2.3 自定义 Blot 以支持 PDF

Quill 默认不支持 PDF 的预览展示,可以通过自定义 Blot 来实现更好的集成:

import Quill from 'quill';

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

class PDFBlot extends BlockEmbed {
  static create(url) {
    const node = super.create();
    node.setAttribute('src', url);
    node.setAttribute('type', 'application/pdf');
    node.setAttribute('width', '100%');
    node.setAttribute('height', '500px');
    return node;
  }

  static value(node) {
    return node.getAttribute('src');
  }
}

PDFBlot.blotName = 'pdf';
PDFBlot.tagName = 'iframe';

Quill.register(PDFBlot);

通过上述代码,我们定义了一个名为 'pdf' 的 Blot,用于在编辑器中嵌入 PDF 文件。

3. 优化用户体验与安全性

3.1 文件类型和大小验证

在上传文件前,建议对文件类型和大小进行验证,以防止不必要的文件上传和安全风险:

function handleFileSelection(file, type) {
  const allowedTypes = {
    image: ['image/jpeg', 'image/png', 'image/gif'],
    pdf: ['application/pdf'],
    video: ['video/mp4', 'video/webm'],
  };

  const maxSize = {
    image: 5 * 1024 * 1024, // 5MB
    pdf: 10 * 1024 * 1024,  // 10MB
    video: 50 * 1024 * 1024, // 50MB
  };

  if (!allowedTypes[type].includes(file.type)) {
    alert(`不支持的文件类型:${file.type}`);
    return false;
  }

  if (file.size > maxSize[type]) {
    alert(`文件大小不能超过 ${maxSize[type] / (1024 * 1024)}MB`);
    return false;
  }

  return true;
}

在上传文件前调用此函数,确保只上传符合要求的文件。

3.2 提供上传进度反馈

为了改善用户体验,可以在上传过程中显示上传进度:

async function uploadFile(file, onProgress) {
  const formData = new FormData();
  formData.append('file', file);

  return new Promise((resolve, reject) => {
    const xhr = new XMLHttpRequest();
    xhr.open('POST', '/upload');

    xhr.upload.onprogress = (event) => {
      if (event.lengthComputable && onProgress) {
        const percent = (event.loaded / event.total) * 100;
        onProgress(percent);
      }
    };

    xhr.onload = () => {
      if (xhr.status === 200) {
        const response = JSON.parse(xhr.responseText);
        resolve(response.url);
      } else {
        reject(new Error('上传失败'));
      }
    };

    xhr.onerror = () => reject(new Error('上传失败'));

    xhr.send(formData);
  });
}

在调用上传函数时,可以传入一个回调函数来更新进度条。

3.3 防范 XSS 攻击

在将用户输入的 HTML 内容渲染到页面时,必须进行必要的过滤,以防止 XSS 攻击。可使用库如 DOMPurify 进行清理:

import DOMPurify from 'dompurify';

const sanitizedContent = DOMPurify.sanitize(content);

// 然后将 sanitizedContent 渲染到页面上

在发送消息到后端前,也应在后端进行相应的过滤和验证。

4. 功能完善与优化

4.1 图片大小调整

通过引入 quill-image-resize-module,用户可以在编辑器中调整图片大小,提高内容的灵活性:

import ImageResize from 'quill-image-resize-module';

Quill.register('modules/imageResize', ImageResize);

const modules = {
  toolbar: {
    container: "#toolbar",
    handlers: {
      image: imageHandler,
      pdf: pdfHandler,
      video: videoHandler
    }
  },
  imageResize: {
    modules: ['Resize', 'DisplaySize']
  }
};

4.2 附件预览与删除

在发送或展示消息时,提供附件的预览和删除功能,增强用户体验:

// 示例组件:显示已上传的附件
const AttachmentPreview = ({ attachments, onDelete }) => (
  <div className="attachment-preview">
    {attachments.map((attachment, index) => (
      <div key={index} className="attachment-item">
        {attachment.type === 'image' && <img src={attachment.url} alt="图片附件" />}
        {attachment.type === 'pdf' && <iframe src={attachment.url} title="PDF附件"></iframe>}
        {attachment.type === 'video' && <video src={attachment.url} controls></video>}
        <button onClick={() => onDelete(index)}>删除</button>
      </div>
    ))}
  </div>
);

5. 完整示例代码

以下是整合以上所有功能的完整示例代码:

import React, { useState, useRef } from 'react';
import ReactQuill from 'react-quill';
import 'react-quill/dist/quill.snow.css';
import ImageResize from 'quill-image-resize-module';
import Quill from 'quill';
import DOMPurify from 'dompurify';

Quill.register('modules/imageResize', ImageResize);

const PDFBlot = () => {
  const BlockEmbed = Quill.import('blots/block/embed');

  class PDFEmbed extends BlockEmbed {
    static create(url) {
      const node = super.create();
      node.setAttribute('src', url);
      node.setAttribute('type', 'application/pdf');
      node.setAttribute('width', '100%');
      node.setAttribute('height', '500px');
      return node;
    }

    static value(node) {
      return node.getAttribute('src');
    }
  }

  PDFEmbed.blotName = 'pdf';
  PDFEmbed.tagName = 'iframe';

  Quill.register(PDFEmbed);
};

PDFBlot();

const CustomerSupportEditor = () => {
  const [content, setContent] = useState('');
  const quillRef = useRef(null);
  const [attachments, setAttachments] = useState([]);

  const modules = {
    toolbar: {
      container: "#toolbar",
      handlers: {
        image: imageHandler,
        pdf: pdfHandler,
        video: videoHandler
      }
    },
    imageResize: {
      modules: ['Resize', 'DisplaySize']
    }
  };

  const formats = [
    'header', 'bold', 'italic', 'underline', 'strike', 'blockquote',
    'list', 'bullet', 'indent',
    'link', 'image', 'video', 'pdf'
  ];

  const handleChange = (value) => {
    setContent(value);
  };

  const uploadFile = async (file, type, onProgress) => {
    if (!handleFileSelection(file, type)) return '';

    try {
      const url = await uploadFileWithProgress(file, onProgress);
      return url;
    } catch (error) {
      alert('上传失败,请重试');
      return '';
    }
  };

  const uploadFileWithProgress = (file, onProgress) => {
    const formData = new FormData();
    formData.append('file', file);

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();
      xhr.open('POST', '/upload');

      xhr.upload.onprogress = (event) => {
        if (event.lengthComputable && onProgress) {
          const percent = (event.loaded / event.total) * 100;
          onProgress(percent);
        }
      };

      xhr.onload = () => {
        if (xhr.status === 200) {
          const response = JSON.parse(xhr.responseText);
          resolve(response.url);
        } else {
          reject(new Error('上传失败'));
        }
      };

      xhr.onerror = () => reject(new Error('上传失败'));

      xhr.send(formData);
    });
  };

  const handleFileSelection = (file, type) => {
    const allowedTypes = {
      image: ['image/jpeg', 'image/png', 'image/gif'],
      pdf: ['application/pdf'],
      video: ['video/mp4', 'video/webm'],
    };

    const maxSize = {
      image: 5 * 1024 * 1024, // 5MB
      pdf: 10 * 1024 * 1024,  // 10MB
      video: 50 * 1024 * 1024, // 50MB
    };

    if (!allowedTypes[type].includes(file.type)) {
      alert(`不支持的文件类型:${file.type}`);
      return false;
    }

    if (file.size > maxSize[type]) {
      alert(`文件大小不能超过 ${maxSize[type] / (1024 * 1024)}MB`);
      return false;
    }

    return true;
  };

  const imageHandler = () => {
    const input = document.createElement('input');
    input.setAttribute('type', 'file');
    input.setAttribute('accept', 'image/*');
    input.click();

    input.onchange = async () => {
      const file = input.files[0];
      if (!file) return;

      const url = await uploadFile(file, 'image', (percent) => {
        console.log(`图片上传进度:${percent}%`);
      });

      if (url) {
        const editor = quillRef.current.getEditor();
        const range = editor.getSelection();
        editor.insertEmbed(range.index, 'image', url);
        setAttachments([...attachments, { type: 'image', url }]);
      }
    };
  };

  const pdfHandler = () => {
    const input = document.createElement('input');
    input.setAttribute('type', 'file');
    input.setAttribute('accept', 'application/pdf');
    input.click();

    input.onchange = async () => {
      const file = input.files[0];
      if (!file) return;

      const url = await uploadFile(file, 'pdf', (percent) => {
        console.log(`PDF上传进度:${percent}%`);
      });

      if (url) {
        const editor = quillRef.current.getEditor();
        const range = editor.getSelection();
        editor.insertEmbed(range.index, 'pdf', url);
        setAttachments([...attachments, { type: 'pdf', url }]);
      }
    };
  };

  const videoHandler = () => {
    const input = document.createElement('input');
    input.setAttribute('type', 'file');
    input.setAttribute('accept', 'video/*');
    input.click();

    input.onchange = async () => {
      const file = input.files[0];
      if (!file) return;

      const url = await uploadFile(file, 'video', (percent) => {
        console.log(`视频上传进度:${percent}%`);
      });

      if (url) {
        const editor = quillRef.current.getEditor();
        const range = editor.getSelection();
        editor.insertEmbed(range.index, 'video', url);
        setAttachments([...attachments, { type: 'video', url }]);
      }
    };
  };

  const handleSubmit = () => {
    const sanitizedContent = DOMPurify.sanitize(content);
    // 将 sanitizedContent 发送到后端
    fetch('/api/messages', {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ content: sanitizedContent })
    })
    .then(response => response.json())
    .then(data => {
      alert('消息发送成功');
      setContent('');
      setAttachments([]);
    })
    .catch(error => {
      console.error('发送消息失败:', error);
      alert('发送消息失败,请重试');
    });
  };

  return (
    <div>
      <div id="toolbar">
        <button className="ql-bold"></button>
        <button className="ql-italic"></button>
        <button className="ql-underline"></button>
        <button className="ql-image"></button>
        <button className="ql-pdf">PDF</button>
        <button className="ql-video">Video</button>
      </div>
      <ReactQuill
        ref={quillRef}
        theme="snow"
        value={content}
        onChange={handleChange}
        modules={modules}
        formats={formats}
      />
      <button onClick={handleSubmit} style="margin-top: 10px;">发送</button>
      
      {attachments.length > 0 && (
        <AttachmentPreview attachments={attachments} onDelete={(index) => {
          const newAttachments = [...attachments];
          newAttachments.splice(index, 1);
          setAttachments(newAttachments);
        }} />
      )}
    </div>
  );
};

const AttachmentPreview = ({ attachments, onDelete }) => (
  <div className="attachment-preview" style="margin-top: 20px;">
    <h4 style="color: #7FA86E;">附件预览</h4>
    <table border="1" cellpadding="10" cellspacing="0">
      <thead>
        <tr>
          <th>类型</th>
          <th>预览</th>
          <th>操作</th>
        </tr>
      </thead>
      <tbody>
        {attachments.map((attachment, index) => (
          <tr key={index}>
            <td>{attachment.type.toUpperCase()}</td>
            <td>
              {attachment.type === 'image' && <img src={attachment.url} alt="图片" width="100" />}
              {attachment.type === 'pdf' && <a href={attachment.url} target="_blank">查看PDF</a>}
              {attachment.type === 'video' && <video src={attachment.url} controls width="100" />}
            </td>
            <td><button onClick={() => onDelete(index)}>删除</button></td>
          </tr>
        ))}
      </tbody>
    </table>
  </div>
);

export default CustomerSupportEditor;

6. 附加功能与优化建议

6.1 自动保存功能

为了防止用户意外关闭页面导致内容丢失,可以实现自动保存功能:

useEffect(() => {
  const savedContent = localStorage.getItem('customerSupportContent');
  if (savedContent) {
    setContent(savedContent);
  }
}, []);

useEffect(() => {
  const timer = setTimeout(() => {
    localStorage.setItem('customerSupportContent', content);
  }, 2000);

  return () => clearTimeout(timer);
}, [content]);

6.2 多语言支持

如果需要支持多语言,可以集成 i18next 或其他国际化库,动态切换工具栏按钮的文本。

6.3 响应式设计

确保编辑器在不同设备和屏幕尺寸下表现良好,可以使用 CSS 媒体查询和 Flexbox 等布局技术进行优化。

7. 常见问题与解决方案

7.1 文件上传失败

检查后端上传接口是否正常工作,确保跨域设置正确,并在前端捕获并处理上传错误。

7.2 编辑器样式问题

确保正确引入了 React-Quill 的 CSS 文件,并根据需要自定义样式覆盖默认设置。

7.3 PDF 无法预览

确保 PDF 文件的 URL 是可访问的,并且浏览器支持在 iframe 中渲染 PDF。如果需要更好的预览效果,可以集成 PDF.js 等库。


结论

通过以上步骤,您可以使用 React、JavaScript 和 React-Quill 创建一个功能强大的客服回复消息编辑器,支持文本、图片、PDF 和视频的混排。关键在于合理配置 React-Quill 的工具栏和模块,处理文件上传与嵌入,并优化用户体验与安全性。根据实际需求,您还可以进一步扩展和优化编辑器的功能,提升整体的使用效果。

参考资料


Last updated February 16, 2025
Ask Ithy AI
Download Article
Delete Article