PasteTextArea 智能文本域粘贴组件 - 完整实现指南

该文档由cursor基于组件代码生成

🎯 项目概述

PasteTextArea 是一个基于 React + TypeScript + Ant Design 开发的高级文本域组件,专门针对现代 Web 应用中的富文本输入场景设计。它能够智能识别用户粘贴的各种内容类型,自动处理文件上传,并提供流畅的用户交互体验。

✨ 核心特性

  • 🧠 智能内容识别 - 自动分析剪切板内容(纯文本、HTML、文件、图片URL)
  • 📁 全格式文件支持 - 图片、文档、压缩包等20+种文件类型
  • 🔗 HTML图片提取 - 从富文本中自动提取并上传图片资源
  • 多重获取策略 - 代理下载 → 直接请求 → Base64转换的渐进式方案
  • 📊 实时上传反馈 - 进度条、状态提示、错误处理一应俱全
  • 🎨 优雅用户界面 - 毛玻璃遮罩、加载动画、响应式布局
  • 🛡️ 安全可靠 - 文件类型验证、大小限制、XSS防护

🏗️ 技术架构设计

文件组织结构

bash 复制代码
PasteTextArea/
├── index.tsx          # 🎯 主组件 - UI渲染与事件处理
├── utils.tsx          # ⚙️ 业务逻辑 - 文件上传与内容处理  
├── clipboardUtils.ts  # 📋 剪切板分析 - 数据解析与验证
└── styles.less        # 🎨 样式定义 - UI视觉与动画效果

核心技术栈

  • React 18+ - 函数组件 + Hooks 状态管理
  • TypeScript 4.5+ - 严格类型检查与智能提示
  • Ant Design 4.x - 企业级 UI 组件库
  • XMLHttpRequest - 原生上传 API,支持进度监听
  • Canvas API - 图片格式转换与处理
  • DOM Parser - HTML内容解析与清理
  • CSS3 - 现代布局与动画效果

📋 模块一:剪切板数据分析 (clipboardUtils.ts)

这个模块负责解析和分析剪切板中的各种数据类型,是整个组件的数据处理基础。

🔍 完整实现代码

typescript 复制代码
/**
 * 剪切板数据处理工具函数
 * 负责解析、验证和转换剪切板中的各种数据格式
 * 提供跨浏览器兼容性和安全性保障
 */

/**
 * 剪切板数据信息接口
 * 定义了完整的剪切板内容结构
 */
export interface ClipboardDataInfo {
  hasText: boolean;      // 是否包含纯文本
  hasHtml: boolean;      // 是否包含HTML内容
  hasFiles: boolean;     // 是否包含文件
  hasImages: boolean;    // 是否包含图片文件
  textContent: string;   // 纯文本内容
  htmlContent: string;   // HTML内容
  files: File[];         // 文件列表
  imageUrls: string[];   // 从HTML中提取的图片URL
  linkUrls: string[];    // 从HTML中提取的链接URL
}

/**
 * 分析剪切板数据内容
 * 这是整个数据处理流程的入口函数
 * 
 * @param clipboardData - 浏览器剪切板数据对象
 * @returns 解析后的剪切板信息
 */
export const analyzeClipboardData = (
  clipboardData: DataTransfer,
): ClipboardDataInfo => {
  // 初始化数据结构
  const info: ClipboardDataInfo = {
    hasText: false,
    hasHtml: false,
    hasFiles: false,
    hasImages: false,
    textContent: '',
    htmlContent: '',
    files: [],
    imageUrls: [],
    linkUrls: [],
  };

  // 1. 检查纯文本内容
  // 使用标准的 MIME 类型获取文本数据
  const textContent = clipboardData.getData('text/plain');
  if (textContent) {
    info.hasText = true;
    info.textContent = textContent;
  }

  // 2. 检查HTML内容  
  // 从富文本编辑器或网页复制时通常包含HTML
  const htmlContent = clipboardData.getData('text/html');
  if (htmlContent) {
    info.hasHtml = true;
    info.htmlContent = htmlContent;
  }

  // 3. 检查文件内容
  // 使用现代浏览器的 DataTransferItemList API
  const items = clipboardData.items;
  if (items) {
    for (let i = 0; i < items.length; i++) {
      const item = items[i];
      
      // 只处理文件类型的数据项
      if (item.kind === 'file') {
        const file = item.getAsFile();
        if (file) {
          info.hasFiles = true;
          info.files.push(file);
          
          // 特别标识图片类型,用于后续特殊处理
          if (file.type.startsWith('image/')) {
            info.hasImages = true;
          }
        }
      }
    }
  }

  // 4. 备选方案:检查 clipboardData.files
  // 某些浏览器可能不支持 items API
  if (!info.hasFiles && clipboardData.files.length > 0) {
    info.hasFiles = true;
    info.files = Array.from(clipboardData.files);
    info.hasImages = info.files.some((file) => file.type.startsWith('image/'));
  }

  // 5. 从HTML内容中提取图片和链接URL
  // 处理富文本中嵌入的网络资源
  if (info.hasHtml) {
    const { imageUrls, links } = parseHtmlContent(info.htmlContent);
    info.imageUrls = imageUrls;
    info.linkUrls = links;
  }

  return info;
};

/**
 * 解析HTML内容,提取文本、图片URL和链接
 * 使用DOM Parser确保安全性,避免XSS攻击
 * 
 * @param html - 要解析的HTML字符串
 * @returns 解析结果对象
 */
export const parseHtmlContent = (
  html: string,
): { text: string; imageUrls: string[]; links: string[] } => {
  // 使用浏览器内置的DOM Parser,安全且高效
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');

  // 提取纯文本内容,去除所有HTML标签
  const text = doc.body.textContent || '';

  // 提取图片URL
  const images = doc.querySelectorAll('img');
  const imageUrls: string[] = [];
  images.forEach((img) => {
    const src = img.getAttribute('src');
    // 过滤掉内联图片和本地资源
    if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
      imageUrls.push(src);
    }
  });

  // 提取链接URL
  const links = doc.querySelectorAll('a[href]');
  const linkUrls: string[] = [];
  links.forEach((link) => {
    const href = link.getAttribute('href');
    // 过滤掉JavaScript伪协议和锚点链接
    if (href && !href.startsWith('javascript:') && !href.startsWith('#')) {
      linkUrls.push(href);
    }
  });

  return { text: text.trim(), imageUrls, links: linkUrls };
};

/**
 * 检测是否为图片URL
 * 通过多种策略判断URL是否指向图片资源
 * 
 * @param url - 要检测的URL
 * @returns 是否为图片URL
 */
export const isImageUrl = (url: string): boolean => {
  // 常见的图片文件扩展名
  const imageExtensions = [
    '.jpg', '.jpeg', '.png', '.gif', 
    '.webp', '.bmp', '.svg'
  ];
  const lowerUrl = url.toLowerCase();

  // 检查文件扩展名
  if (imageExtensions.some((ext) => lowerUrl.includes(ext))) {
    return true;
  }

  // 检查是否包含图片MIME类型标识
  if (lowerUrl.includes('image/')) {
    return true;
  }

  // 检查知名图片服务域名
  const imageDomains = [
    'imgur.com', 'flickr.com', '500px.com',
    'unsplash.com', 'pexels.com', 'pixabay.com',
    'cdn.jsdelivr.net', 'cdnjs.cloudflare.com',
  ];

  return imageDomains.some((domain) => lowerUrl.includes(domain));
};

/**
 * 清理和验证URL
 * 确保URL格式正确且安全
 * 
 * @param url - 要处理的URL字符串
 * @returns 清理后的URL或null(如果无效)
 */
export const cleanAndValidateUrl = (url: string): string | null => {
  try {
    // 移除前后空格
    const trimmedUrl = url.trim();

    // 使用URL构造函数验证格式
    new URL(trimmedUrl);

    // 过滤掉不安全的协议
    if (
      trimmedUrl.startsWith('javascript:') ||
      trimmedUrl.startsWith('data:')
    ) {
      return null;
    }

    return trimmedUrl;
  } catch {
    // URL格式错误
    return null;
  }
};

/**
 * 从URL中提取文件名
 * 智能推断或生成合适的文件名
 * 
 * @param url - 文件URL
 * @returns 提取或生成的文件名
 */
export const extractFileNameFromUrl = (url: string): string => {
  try {
    const urlObj = new URL(url);
    const pathname = urlObj.pathname;
    const fileName = pathname.split('/').pop();

    // 如果有有效的文件名且包含扩展名
    if (fileName && fileName.includes('.')) {
      return fileName;
    }

    // 生成带时间戳的文件名
    const timestamp = Date.now();
    const extension = getExtensionFromUrl(url);
    return `file_${timestamp}${extension}`;
  } catch {
    // URL解析失败,生成默认文件名
    const timestamp = Date.now();
    return `file_${timestamp}.txt`;
  }
};

/**
 * 从URL中推断文件扩展名
 * 基于URL特征智能判断文件类型
 * 
 * @param url - 文件URL
 * @returns 推断的文件扩展名
 */
export const getExtensionFromUrl = (url: string): string => {
  const lowerUrl = url.toLowerCase();

  // 图片格式检测
  if (lowerUrl.includes('.jpg') || lowerUrl.includes('.jpeg')) return '.jpg';
  if (lowerUrl.includes('.png')) return '.png';
  if (lowerUrl.includes('.gif')) return '.gif';
  if (lowerUrl.includes('.webp')) return '.webp';
  if (lowerUrl.includes('.bmp')) return '.bmp';
  if (lowerUrl.includes('.svg')) return '.svg';

  // 文档格式检测
  if (lowerUrl.includes('.pdf')) return '.pdf';
  if (lowerUrl.includes('.doc') || lowerUrl.includes('.docx')) return '.docx';
  if (lowerUrl.includes('.xls') || lowerUrl.includes('.xlsx')) return '.xlsx';
  if (lowerUrl.includes('.ppt') || lowerUrl.includes('.pptx')) return '.pptx';

  // 其他常见格式
  if (lowerUrl.includes('.txt')) return '.txt';
  if (lowerUrl.includes('.csv')) return '.csv';
  if (lowerUrl.includes('.zip')) return '.zip';
  if (lowerUrl.includes('.rar')) return '.rar';

  // 默认文本格式
  return '.txt';
};

/**
 * 检测文件类型分类
 * 将文件归类到不同的类型组
 * 
 * @param file - File对象
 * @returns 文件类型分类字符串
 */
export const detectFileType = (file: File): string => {
  if (file.type.startsWith('image/')) return 'image';
  if (file.type.startsWith('video/')) return 'video';
  if (file.type.startsWith('audio/')) return 'audio';
  if (file.type.includes('pdf')) return 'pdf';
  if (file.type.includes('word') || file.type.includes('document'))
    return 'document';
  if (file.type.includes('excel') || file.type.includes('spreadsheet'))
    return 'spreadsheet';
  if (file.type.includes('powerpoint') || file.type.includes('presentation'))
    return 'presentation';
  if (
    file.type.includes('zip') ||
    file.type.includes('rar') ||
    file.type.includes('7z')
  )
    return 'archive';
  if (file.type.startsWith('text/')) return 'text';

  return 'unknown';
};

/**
 * 格式化文件大小显示
 * 将字节数转换为人类可读的格式
 * 
 * @param bytes - 文件大小(字节)
 * @returns 格式化的大小字符串
 */
export const formatFileSize = (bytes: number): string => {
  if (bytes === 0) return '0 B';
  
  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

/**
 * 验证文件类型是否受支持
 * 基于MIME类型进行严格验证
 * 
 * @param file - 要验证的File对象
 * @returns 是否为支持的文件类型
 */
export const isSupportedFileType = (file: File): boolean => {
  const supportedTypes = [
    // 图片格式
    'image/jpeg', 'image/jpg', 'image/png', 'image/gif',
    'image/webp', 'image/bmp', 'image/svg+xml',
    
    // 文档格式
    'application/pdf',
    'application/msword',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'application/vnd.ms-excel',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    'application/vnd.ms-powerpoint',
    'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    'text/plain', 'text/csv', 'text/html', 'text/markdown',
    
    // 压缩格式
    'application/zip',
    'application/x-rar-compressed',
    'application/x-7z-compressed',
    'application/gzip',
  ];

  return supportedTypes.includes(file.type);
};

/**
 * 获取文件类型的中文描述
 * 为用户提供友好的类型说明
 * 
 * @param file - File对象
 * @returns 中文类型描述
 */
export const getFileTypeDescription = (file: File): string => {
  const fileType = detectFileType(file);

  const typeMap: Record<string, string> = {
    'image': '图片',
    'video': '视频',
    'audio': '音频',
    'pdf': 'PDF文档',
    'document': 'Word文档',
    'spreadsheet': 'Excel表格',
    'presentation': 'PowerPoint演示',
    'archive': '压缩包',
    'text': '文本文件',
  };

  return typeMap[fileType] || '文件';
};

// 导出所有工具函数
export default {
  analyzeClipboardData,
  parseHtmlContent,
  isImageUrl,
  cleanAndValidateUrl,
  extractFileNameFromUrl,
  getExtensionFromUrl,
  detectFileType,
  formatFileSize,
  isSupportedFileType,
  getFileTypeDescription,
};

⚙️ 模块二:核心业务逻辑 (utils.tsx)

这个模块是整个组件的核心,包含文件上传、图片处理、粘贴事件处理等关键功能。

📤 完整实现代码

typescript 复制代码
/**
 * 文件上传与粘贴处理核心模块
 * 实现文件上传、图片转换、多重获取策略等核心业务逻辑
 * 提供完整的错误处理和用户反馈机制
 */

import { message } from 'antd';

// ==================== 类型定义 ====================

/**
 * 文件信息接口
 * 定义上传成功后的文件信息结构
 */
export interface FileInfo {
  fileName: string;    // 文件名
  shareUrl: string;    // 分享链接
  fileType: string;    // 文件类型描述
  fileId?: string;     // 文件ID(可选)
  size?: number;       // 文件大小
  mimeType?: string;   // MIME类型
}

/**
 * 粘贴处理结果接口
 * 包含处理后的文本、文件URL和错误信息
 */
export interface PasteResult {
  text: string;              // 处理后的文本内容
  fileUrls: string[];        // 上传成功的文件URL列表
  uploadedFiles: FileInfo[]; // 详细文件信息
  hasError: boolean;         // 是否有错误
  errorMessage?: string;     // 错误信息
}

/**
 * 服务器上传响应接口
 * 与后端API接口保持一致
 */
export interface UploadResponse {
  flag: {
    retCode: string;    // 响应码,"0"表示成功
    retMsg: string;     // 响应消息
  };
  rows: Array<{
    fileId: string;     // 服务器生成的文件ID
    fileName: string;   // 文件名
    shareUrl: string;   // 可访问的文件URL
  }>;
}

// ==================== 配置常量 ====================

/**
 * 支持的文件类型配置
 * 按类别组织,便于管理和扩展
 */
export const SUPPORTED_FILE_TYPES = {
  images: [
    'image/jpeg', 'image/jpg', 'image/png', 'image/gif',
    'image/webp', 'image/bmp', 'image/svg+xml',
  ],
  documents: [
    'application/pdf',
    'application/msword',
    'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
    'application/vnd.ms-excel',
    'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
    'application/vnd.ms-powerpoint',
    'application/vnd.openxmlformats-officedocument.presentationml.presentation',
    'text/plain', 'text/csv', 'text/html', 'text/markdown',
  ],
  archives: [
    'application/zip',
    'application/x-rar-compressed',
    'application/x-7z-compressed',
    'application/gzip',
  ],
};

// 默认文件大小限制(10MB)
export const DEFAULT_FILE_SIZE_LIMIT = 10 * 1024 * 1024;

// ==================== 文件上传核心功能 ====================

/**
 * 上传文件到服务器
 * 使用 XMLHttpRequest 实现带进度的文件上传
 * 
 * @param file - 要上传的文件对象
 * @param catalogName - 文件存储目录名称
 * @param onProgress - 上传进度回调函数
 * @returns Promise<UploadResponse> - 上传结果
 */
export const uploadFile = async (
  file: File,
  catalogName: string = 'publicTcmTsk',
  onProgress?: (progress: number) => void,
): Promise<UploadResponse> => {
  // 1. 文件大小验证
  if (file.size > DEFAULT_FILE_SIZE_LIMIT) {
    throw new Error(
      `文件大小超过限制(${DEFAULT_FILE_SIZE_LIMIT / 1024 / 1024}MB)`,
    );
  }

  // 2. 构建表单数据
  const formData = new FormData();
  formData.append('file', file);

  // 3. 创建 XHR 对象(支持进度监听)
  const xhr = new XMLHttpRequest();

  return new Promise((resolve, reject) => {
    // 上传进度事件监听
    xhr.upload.addEventListener('progress', (event) => {
      if (event.lengthComputable && onProgress) {
        const progress = (event.loaded / event.total) * 100;
        onProgress(progress);
      }
    });

    // 请求完成事件监听
    xhr.addEventListener('load', () => {
      if (xhr.status === 200) {
        try {
          const result = JSON.parse(xhr.responseText);
          
          // 检查业务响应码
          if (result.flag?.retCode !== '0') {
            reject(new Error(result.flag?.retMsg || '文件上传失败'));
          } else {
            resolve(result);
          }
        } catch (error) {
          reject(new Error('响应解析失败'));
        }
      } else {
        reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
      }
    });

    // 网络错误监听
    xhr.addEventListener('error', () => {
      reject(new Error('网络错误'));
    });

    // 超时错误监听
    xhr.addEventListener('timeout', () => {
      reject(new Error('请求超时'));
    });

    // 配置并发送请求
    xhr.open(
      'POST',
      `/petrel/zuul/petrel-file-center-api/lesoon/oss/file/upload/RETAIL-PUBLIC?catalogName=${catalogName}`,
    );
    xhr.setRequestHeader('token', localStorage.getItem('token') || '');
    xhr.timeout = 30000; // 30秒超时
    xhr.send(formData);
  });
};

// ==================== 图片处理功能 ====================

/**
 * 将URL转换为base64格式
 * 优先尝试直接转换,失败则使用代理方案
 * 
 * @param url - 图片URL
 * @returns Promise<string> - base64字符串
 */
export const urlToBase64 = async (url: string): Promise<string> => {
  try {
    return await urlToBase64Direct(url);
  } catch (error) {
    // 这里可以实现代理方案
    console.error('直接转换失败,需要代理方案:', error);
    return '';
  }
};

/**
 * 直接转换URL为base64(适用于支持CORS的图片)
 * 使用Canvas API进行图片格式转换
 * 
 * @param url - 图片URL
 * @returns Promise<string> - base64数据URL
 */
export const urlToBase64Direct = async (url: string): Promise<string> => {
  return new Promise((resolve, reject) => {
    const img = new Image();
    
    // 设置跨域属性,尝试获取CORS权限
    img.crossOrigin = 'anonymous';
    
    img.onload = () => {
      // 创建Canvas进行图片处理
      const canvas = document.createElement('canvas');
      const ctx = canvas.getContext('2d');
      
      // 设置Canvas尺寸与图片一致
      canvas.width = img.width;
      canvas.height = img.height;
      
      // 绘制图片到Canvas
      ctx?.drawImage(img, 0, 0);
      
      // 转换为base64格式(PNG格式)
      const dataURL = canvas.toDataURL('image/png');
      resolve(dataURL);
    };
    
    img.onerror = () => {
      reject(new Error('图片加载失败'));
    };
    
    img.src = url;
  });
};

/**
 * 将base64字符串转换为File对象
 * 解析MIME类型并创建标准File对象
 * 
 * @param base64 - base64数据URL
 * @param fileName - 目标文件名
 * @returns File对象
 */
export const base64ToFile = (base64: string, fileName: string): File => {
  // 解析base64格式:data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAA...
  const arr = base64.split(',');
  const mime = arr[0].match(/:(.*?);/)?.[1] || 'image/png';
  const bstr = atob(arr[1]); // 解码base64
  
  let n = bstr.length;
  const u8arr = new Uint8Array(n);
  
  // 转换为字节数组
  while (n--) {
    u8arr[n] = bstr.charCodeAt(n);
  }
  
  return new File([u8arr], fileName, { type: mime });
};

/**
 * 多重策略获取网络图片为File对象
 * 这是核心的图片获取功能,采用渐进式降级策略
 * 
 * 策略优先级:
 * 1. 业务网关代理(避免CORS问题)
 * 2. 直接fetch请求(需要CORS支持)
 * 3. Canvas+base64方案(最后备选)
 * 
 * @param url - 图片URL
 * @param fileName - 可选的文件名
 * @returns Promise<File> - 图片File对象
 */
export const fetchImageAsFile = async (
  url: string,
  fileName?: string,
): Promise<File> => {
  /**
   * 智能文件名推断函数
   * 从URL中提取或生成合适的文件名
   */
  const inferFileName = (): string => {
    try {
      const u = new URL(url, window.location.href);
      const base = u.pathname.split('/').pop() || '';
      if (base) return base;
    } catch {}
    return fileName || `image_${Date.now()}.png`;
  };

  const finalName = fileName || inferFileName();

  // 策略1: 业务代理下载(推荐方案)
  // 通过后端代理避免浏览器CORS限制
  try {
    const tenant = localStorage.getItem('lesoon-tenant') || '1';
    const proxyUrl = `/lesoon/lesoon-tcm-tsk-api/tskCallBack/download?url=${encodeURIComponent(
      url,
    )}&name=${encodeURIComponent(finalName)}&lesoon-tenant=${tenant}`;
    
    const resp = await fetch(proxyUrl, {
      method: 'GET',
      headers: {
        token: localStorage.getItem('token') || '',
      },
    });
    
    if (resp.ok) {
      const blob = await resp.blob();
      const type = blob.type || 'application/octet-stream';
      return new File([blob], finalName, { type });
    }
  } catch (error) {
    console.log('代理下载失败,尝试直接获取:', error);
  }

  // 策略2: 直接抓取(需要目标源支持CORS)
  try {
    const resp = await fetch(url, { credentials: 'include' });
    if (resp.ok) {
      const blob = await resp.blob();
      const type = blob.type || 'application/octet-stream';
      return new File([blob], finalName, { type });
    }
  } catch (error) {
    console.log('直接获取失败,使用base64方案:', error);
  }

  // 策略3: 最后回退到base64方案
  // 适用于允许跨域显示但不允许fetch的图片
  const base64 = await urlToBase64Direct(url);
  return base64ToFile(base64, finalName);
};

// ==================== HTML内容解析 ====================

/**
 * 解析HTML内容,提取文本和媒体资源
 * 增强版本,支持更多HTML元素和属性
 * 
 * @param html - HTML字符串
 * @returns 解析结果对象
 */
export const parseHtmlContent = (
  html: string,
): { text: string; imageUrls: string[]; links: string[] } => {
  const parser = new DOMParser();
  const doc = parser.parseFromString(html, 'text/html');

  // 提取纯文本内容,保持基本格式
  const text = doc.body.textContent || '';

  // 提取图片URL(排除内联和本地资源)
  const images = doc.querySelectorAll('img');
  const imageUrls: string[] = [];
  images.forEach((img) => {
    const src = img.getAttribute('src');
    if (src && !src.startsWith('data:') && !src.startsWith('blob:')) {
      imageUrls.push(src);
    }
  });

  // 提取有效链接URL
  const links = doc.querySelectorAll('a[href]');
  const linkUrls: string[] = [];
  links.forEach((link) => {
    const href = link.getAttribute('href');
    if (href && !href.startsWith('javascript:') && !href.startsWith('#')) {
      linkUrls.push(href);
    }
  });

  return { text: text.trim(), imageUrls, links: linkUrls };
};

// ==================== 文件类型检查工具 ====================

/** 检查文件是否为图片类型 */
export const isImageFile = (file: File): boolean => {
  return SUPPORTED_FILE_TYPES.images.includes(file.type);
};

/** 检查文件是否为文档类型 */
export const isDocumentFile = (file: File): boolean => {
  return SUPPORTED_FILE_TYPES.documents.includes(file.type);
};

/** 检查文件是否为压缩包类型 */
export const isArchiveFile = (file: File): boolean => {
  return SUPPORTED_FILE_TYPES.archives.includes(file.type);
};

/**
 * 获取文件扩展名
 * @param fileName - 文件名
 * @returns 小写的扩展名
 */
export const getFileExtension = (fileName: string): string => {
  const parts = fileName.split('.');
  return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : '';
};

/**
 * 获取文件类型的中文描述
 * @param file - File对象
 * @returns 中文类型描述
 */
export const getFileTypeDescription = (file: File): string => {
  if (isImageFile(file)) return '图片';
  if (isDocumentFile(file)) return '文档';
  if (isArchiveFile(file)) return '压缩包';
  return '文件';
};

/**
 * 验证文件类型是否受支持
 * @param file - File对象
 * @returns 是否支持
 */
export const isSupportedFileType = (file: File): boolean => {
  const allSupportedTypes = [
    ...SUPPORTED_FILE_TYPES.images,
    ...SUPPORTED_FILE_TYPES.documents,
    ...SUPPORTED_FILE_TYPES.archives,
  ];
  return allSupportedTypes.includes(file.type);
};

/**
 * 格式化文件大小为可读字符串
 * @param bytes - 字节数
 * @returns 格式化的大小字符串
 */
export const formatFileSize = (bytes: number): string => {
  if (bytes === 0) return '0 B';
  const k = 1024;
  const sizes = ['B', 'KB', 'MB', 'GB'];
  const i = Math.floor(Math.log(bytes) / Math.log(k));
  return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};

// ==================== 粘贴事件处理核心逻辑 ====================

/**
 * 处理粘贴事件的核心业务逻辑
 * 这是整个组件最复杂的功能,需要处理多种数据类型
 * 
 * 处理流程:
 * 1. 分析剪切板内容类型
 * 2. 处理HTML中的图片URL
 * 3. 处理直接粘贴的文件
 * 4. 统一上传并返回结果
 * 
 * @param event - 剪切板事件
 * @param config - 配置参数
 * @returns Promise<PasteResult> - 处理结果
 */
export const handlePaste = async ({
  event,
  config,
}: {
  event: ClipboardEvent;
  config: any;
}): Promise<PasteResult> => {
  // 解析配置参数
  const enableFilePaste = config?.enableFilePaste ?? true;
  const fileCatalog = config?.fileCatalog || 'publicTcmTsk';
  const maxFileSize = config?.maxFileSize
    ? config?.maxFileSize * 1024 * 1024
    : DEFAULT_FILE_SIZE_LIMIT;

  const clipboardData = event.clipboardData;
  if (!clipboardData) {
    return { text: '', fileUrls: [], uploadedFiles: [], hasError: false };
  }

  // 初始化结果对象
  let text = '';
  const fileUrls: string[] = [];
  const uploadedFiles: FileInfo[] = [];
  let hasError = false;
  let errorMessage = '';

  try {
    // 阶段1: 处理HTML内容
    const htmlContent = clipboardData.getData('text/html');
    if (htmlContent) {
      console.log('检测到HTML内容:', htmlContent);
      const {
        text: htmlText,
        imageUrls,
        links,
      } = parseHtmlContent(htmlContent);

      // 处理HTML中的图片URL
      if (imageUrls.length > 0 && enableFilePaste) {
        text = htmlText;
        
        for (const imageUrl of imageUrls) {
          try {
            // 生成文件名
            const urlParts = imageUrl.split('/');
            const fileName =
              urlParts[urlParts.length - 1] || `image_${Date.now()}.png`;

            message.loading(`正在处理图片: ${fileName}`, 0);

            // 获取图片文件
            const file = await fetchImageAsFile(imageUrl, fileName);

            // 文件大小检查
            if (file.size > maxFileSize) {
              message.destroy();
              message.warning(`图片 ${fileName} 超过大小限制,已跳过`);
              continue;
            }

            // 上传文件
            const result = await uploadFile(file, fileCatalog);
            const fileUrl = result.rows[0]?.shareUrl;
            const uploadedName = result.rows[0]?.fileName || fileName;

            if (fileUrl) {
              fileUrls.push(fileUrl);
              uploadedFiles.push({
                fileName: uploadedName,
                shareUrl: fileUrl,
                fileType: '图片',
                fileId: result.rows[0]?.fileId,
                size: file.size,
                mimeType: file.type,
              });

              // 在文本中替换原图片URL为上传后的URL
              text = text.replace(imageUrl, fileUrl);
            }

            message.destroy();
            message.success(`图片 ${fileName} 上传成功`);
          } catch (error) {
            message.destroy();
            const errorMsg =
              error instanceof Error ? error.message : '未知错误';
            message.error(`图片处理失败: ${errorMsg}`);
            console.error('图片处理失败:', error);
          }
        }
      } else {
        text = htmlText;
      }
    } else {
      // 阶段2: 处理纯文本内容
      text = clipboardData.getData('text/plain') || '';
    }

    // 阶段3: 处理直接粘贴的文件
    if (enableFilePaste) {
      const items = clipboardData.items;
      if (items) {
        for (let i = 0; i < items.length; i++) {
          const item = items[i];
          console.log('剪贴板项目:', item.type, item);

          if (item.kind === 'file') {
            const file = item.getAsFile();
            if (file) {
              // 文件类型验证
              if (!isSupportedFileType(file)) {
                message.warning(`不支持的文件类型: ${file.name}`);
                continue;
              }

              // 文件大小验证
              if (file.size > maxFileSize) {
                message.warning(
                  `文件 ${file.name} 超过大小限制(${formatFileSize(
                    maxFileSize,
                  )}),已跳过`,
                );
                continue;
              }

              const fileType = getFileTypeDescription(file);
              try {
                message.loading(`正在上传${fileType}: ${file.name}`, 0);

                const result = await uploadFile(file, fileCatalog);
                const fileUrl = result.rows[0]?.shareUrl;
                const uploadedName = result.rows[0]?.fileName || file.name;

                if (fileUrl) {
                  fileUrls.push(fileUrl);
                  uploadedFiles.push({
                    fileName: uploadedName,
                    shareUrl: fileUrl,
                    fileType,
                    fileId: result.rows[0]?.fileId,
                    size: file.size,
                    mimeType: file.type,
                  });
                }

                message.destroy();
                message.success(`${fileType} ${file.name} 上传成功`);
              } catch (error) {
                message.destroy();
                const errorMsg =
                  error instanceof Error ? error.message : '未知错误';
                message.error(`${fileType} ${file.name} 上传失败: ${errorMsg}`);
                console.error('文件上传失败:', error);
              }
            }
          }
        }
      }

      // 阶段4: 备选方案 - 处理 clipboardData.files
      if (!fileUrls.length) {
        const files = Array.from(clipboardData.files || []);
        for (const file of files) {
          if (!isSupportedFileType(file)) {
            message.warning(`不支持的文件类型: ${file.name}`);
            continue;
          }

          if (file.size > maxFileSize) {
            message.warning(
              `文件 ${file.name} 超过大小限制(${formatFileSize(
                maxFileSize,
              )}),已跳过`,
            );
            continue;
          }

          const fileType = getFileTypeDescription(file);
          try {
            message.loading(`正在上传${fileType}: ${file.name}`, 0);

            const result = await uploadFile(file, fileCatalog);
            const fileUrl = result.rows[0]?.shareUrl;
            const uploadedName = result.rows[0]?.fileName || file.name;

            if (fileUrl) {
              fileUrls.push(fileUrl);
              uploadedFiles.push({
                fileName: uploadedName,
                shareUrl: fileUrl,
                fileType,
                fileId: result.rows[0]?.fileId,
                size: file.size,
                mimeType: file.type,
              });
            }

            message.destroy();
            message.success(`${fileType} ${file.name} 上传成功`);
          } catch (error) {
            message.destroy();
            const errorMsg =
              error instanceof Error ? error.message : '未知错误';
            message.error(`${fileType} ${file.name} 上传失败: ${errorMsg}`);
            console.error('文件上传失败:', error);
          }
        }
      }
    }
  } catch (error) {
    hasError = true;
    errorMessage =
      error instanceof Error ? error.message : '处理粘贴内容时发生未知错误';
    console.error('粘贴处理失败:', error);
  }

  return { text, fileUrls, uploadedFiles, hasError, errorMessage };
};

/**
 * React组件中的粘贴事件处理器
 * 封装了状态管理和用户交互逻辑
 * 
 * @param params - 处理参数对象
 */
export const handlePasteEvent = async ({
  event,
  config,
  setIsUploading,
  setUploadProgress,
  afterUrlChange,
  onChange,
}: {
  event: React.ClipboardEvent<HTMLTextAreaElement>;
  config: any;
  setIsUploading: (loading: boolean) => void;
  setUploadProgress: (progress: number) => void;
  isUploading: boolean;
  afterUrlChange?: (list: any) => void;
  onChange?: (value: string) => void;
}) => {
  // 保存当前输入框状态(避免React事件池失效)
  const textarea = event.currentTarget;
  const originalValue = textarea.value;
  const selectionStart = textarea.selectionStart;
  const selectionEnd = textarea.selectionEnd;

  // 设置加载状态
  setIsUploading(true);
  setUploadProgress(0);

  try {
    // 执行核心粘贴处理逻辑
    const { text, fileUrls, uploadedFiles, hasError, errorMessage } =
      await handlePaste({
        event: event.nativeEvent,
        config,
      });

    console.log('粘贴处理结果:', {
      uploadedFiles,
      fileUrls,
      text,
    });

    // 错误处理
    if (hasError && errorMessage) {
      message.error(errorMessage);
    }

    // 文本内容处理 - 保持光标位置
    if (text) {
      const newValue =
        originalValue.substring(0, selectionStart) +
        text +
        originalValue.substring(selectionEnd);
      
      console.log('更新文本内容:', newValue);
      onChange?.(newValue);
    }

    // 文件信息回调处理
    if (uploadedFiles?.length && afterUrlChange) {
      const appendList = uploadedFiles.map((f, idx) => ({
        uid: `${Date.now()}_${idx}`,
        status: 'done',
        name: f.fileName,
        fileName: f.fileName,
        url: f.shareUrl,
        fileType: f.fileType,
        type: f.fileType === '图片' ? 'image' : 'file',
        size: f.size,
        mimeType: f.mimeType,
      }));
      afterUrlChange(appendList);
    }

    // 成功反馈
    if (uploadedFiles?.length > 0) {
      message.success(`成功处理 ${uploadedFiles.length} 个文件`);
    }
  } catch (error) {
    console.error('处理粘贴事件失败:', error);
    const errorMsg = error instanceof Error ? error.message : '未知错误';
    message.error(`处理粘贴内容失败: ${errorMsg}`);
  } finally {
    // 重置状态
    setIsUploading(false);
    setUploadProgress(0);
  }
};

/**
 * 获取动态占位符文本
 * 根据当前状态显示不同的提示信息
 * 
 * @param config - 配置对象
 * @param isUploading - 是否正在上传
 * @param uploadProgress - 上传进度
 * @returns 占位符文本
 */
export const getPlaceholder = (
  config: any,
  isUploading: boolean,
  uploadProgress: number,
) => {
  // 如果禁用文件粘贴,显示基础占位符
  if (!config?.enableFilePaste) {
    return config?.placeholder || '';
  }
  
  // 上传状态显示进度信息
  if (isUploading) {
    return `正在处理粘贴内容... ${
      uploadProgress > 0 ? `${Math.round(uploadProgress)}%` : ''
    }`;
  }

  // 默认状态显示功能提示
  const basePlaceholder =
    config?.placeholder || '试试这里~ 粘贴图片至输入框后系统自动上传附件';
  
  return basePlaceholder;
};

// 导出所有功能函数
export default {
  uploadFile,
  urlToBase64,
  urlToBase64Direct,
  base64ToFile,
  fetchImageAsFile,
  parseHtmlContent,
  isImageFile,
  isDocumentFile,
  isArchiveFile,
  getFileExtension,
  getFileTypeDescription,
  isSupportedFileType,
  formatFileSize,
  handlePaste,
  handlePasteEvent,
  getPlaceholder,
  SUPPORTED_FILE_TYPES,
  DEFAULT_FILE_SIZE_LIMIT,
};

🎨 模块三:主组件实现 (index.tsx)

这是整个组件的UI层,负责渲染界面和处理用户交互。

🖼️ 完整实现代码

typescript 复制代码
/**
 * PasteTextArea 主组件
 * 基于 Ant Design TextArea 的增强版文本域
 * 支持智能粘贴处理和文件自动上传功能
 */

import { FormInstance, Input } from 'antd';
import { useState } from 'react';
import { getPlaceholder, handlePasteEvent } from './utils';
import './styles.less';

/**
 * 组件Props接口定义
 * 提供灵活的配置选项和回调机制
 */
interface PasteTextAreaProps {
  /** 组件类型标识,用于区分不同使用场景 */
  type?: 'lowCode' | 'component';
  
  /** 核心配置对象,包含所有功能选项 */
  formWrap: {
    targetFormField?: string;    // 关联的表单字段名
    enableFilePaste?: boolean;   // 是否启用文件粘贴功能
    fileCatalog?: string;        // 文件存储目录名
    maxFileSize?: number;        // 单文件最大大小(MB)
    placeholder?: string;        // 占位符文本
    maxLength?: number;          // 最大字符长度
    [key: string]: any;         // 支持 Ant Design TextArea 的所有属性
  };
  
  /** 输入值(受控组件) */
  value?: string;
  
  /** 值变更回调 */
  onChange?: (value: string) => void;
  
  /** 是否禁用 */
  disabled?: boolean;
  
  /** 额外的变更处理函数 */
  handleChange?: (value: string) => void;
  
  /** Ant Design 表单实例 */
  form?: FormInstance | null | undefined;
  
  /** 容器自定义样式 */
  showStyle?: React.CSSProperties;
  
  /** 文件上传完成回调 */
  afterUrlChange?: (list: any) => void;
}

/**
 * PasteTextArea 主组件
 * 
 * 组件设计原则:
 * 1. 保持与原生TextArea的API兼容性
 * 2. 通过formWrap统一管理所有配置
 * 3. 提供完整的生命周期钩子
 * 4. 支持受控和非受控两种模式
 */
export const PasteTextArea: React.FC<PasteTextAreaProps> = ({
  type = 'lowCode',
  formWrap,
  value,
  onChange,
  disabled,
  handleChange,
  form,
  showStyle,
  afterUrlChange,
}) => {
  // ==================== 状态管理 ====================
  
  /** 上传加载状态 */
  const [isUploading, setIsUploading] = useState(false);
  
  /** 上传进度(0-100) */
  const [uploadProgress, setUploadProgress] = useState<number>(0);

  // ==================== 事件处理函数 ====================

  /**
   * 组件内部onChange事件处理
   * 支持字符长度限制和双重回调
   * 
   * @param val - 新的输入值
   */
  const localOnChange = (val: string) => {
    // 应用字符长度限制
    const maxLength = formWrap?.maxLength;
    const finalValue =
      typeof maxLength === 'number' && maxLength >= 0
        ? (val || '').slice(0, maxLength)
        : val;
    
    // 触发外部回调
    onChange?.(finalValue);
    handleChange?.(finalValue);
  };

  /**
   * 粘贴事件处理器包装函数
   * 在这里进行权限检查和参数传递
   * 
   * @param event - React粘贴事件对象
   */
  const handlePasteEventWrapper = async (
    event: React.ClipboardEvent<HTMLTextAreaElement>,
  ) => {
    // 权限检查:是否启用文件粘贴 & 是否被禁用
    if (!formWrap?.enableFilePaste || !!disabled) {
      return;
    }

    // 调用核心处理逻辑
    await handlePasteEvent({
      event,
      config: formWrap,
      setIsUploading,
      setUploadProgress,
      isUploading,
      onChange: localOnChange,
      afterUrlChange: (appendList: any) => {
        // 文件上传完成后的回调处理
        if (afterUrlChange) {
          afterUrlChange(appendList);
          return;
        }
      },
    });
  };

  // ==================== 渲染层 ====================

  return (
    <div className="paste-textarea-container" style={showStyle}>
      {/* 主要的文本域组件 */}
      <Input.TextArea
        {...formWrap}                    // 展开所有配置属性
        disabled={disabled}              // 外部禁用状态优先级最高
        value={value}                    // 受控组件的值
        onChange={(event) => localOnChange(event.target.value)}  // 文本变更处理
        onPaste={handlePasteEventWrapper}  // 粘贴事件处理
        placeholder={getPlaceholder(formWrap, isUploading, uploadProgress)}  // 动态占位符
      />

      {/* 上传时的遮罩层 */}
      {isUploading && (
        <div className="upload-mask">
          <div className="upload-content">
            {/* 旋转加载图标 */}
            <div className="upload-spinner" />
            
            {/* 状态文本 */}
            <div className="upload-text">正在上传文件</div>
            
            {/* 进度显示 */}
            {uploadProgress > 0 && (
              <div className="upload-progress">
                {Math.round(uploadProgress)}%
              </div>
            )}
          </div>
        </div>
      )}
    </div>
  );
};

// 默认导出组件
export default PasteTextArea;

🎨 模块四:样式定义 (styles.less)

负责组件的视觉效果和交互动画,采用现代CSS特性实现优雅的用户界面。

🎭 完整样式代码

less 复制代码
/**
 * PasteTextArea 组件样式定义
 * 采用现代CSS特性,提供优雅的视觉效果和流畅的交互动画
 */

/* ==================== 主容器样式 ==================== */

/**
 * 组件根容器
 * 使用相对定位为遮罩层提供定位基准
 */
.paste-textarea-container {
  position: relative;
}

/* ==================== 上传遮罩层样式 ==================== */

/**
 * 上传时的遮罩层
 * 覆盖整个文本域,防止用户在上传期间进行输入操作
 * 
 * 设计要点:
 * - 使用半透明背景保持内容可见性
 * - 毛玻璃效果增强视觉层次
 * - 高z-index确保在最上层显示
 */
.upload-mask {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  bottom: 0;
  
  /* 半透明白色背景 */
  background-color: rgba(255, 255, 255, 0.8);
  
  /* 使用Flexbox居中对齐内容 */
  display: flex;
  align-items: center;
  justify-content: center;
  
  /* 确保在最上层显示 */
  z-index: 10;
  
  /* 圆角与TextArea保持一致 */
  border-radius: 6px;
  
  /* 毛玻璃效果(现代浏览器支持) */
  -webkit-backdrop-filter: blur(1px);
  backdrop-filter: blur(1px);
}

/**
 * 上传状态内容容器
 * 包含加载图标、文本和进度信息
 * 
 * 设计要点:
 * - 卡片式设计,提供清晰的信息层次
 * - 适当的内边距和间距
 * - 轻微阴影增强立体感
 */
.upload-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;                    /* 子元素间距 */
  padding: 16px;               /* 内边距 */
  
  /* 背景和边框 */
  background-color: #fff;
  border-radius: 8px;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15);
  
  /* 最小宽度确保内容不会过于紧凑 */
  min-width: 120px;
}

/* ==================== 加载动画样式 ==================== */

/**
 * 旋转加载图标
 * 使用纯CSS实现的spinner动画
 * 
 * 设计要点:
 * - 圆形边框,顶部高亮显示进度
 * - 平滑的旋转动画
 * - 与Ant Design主色调保持一致
 */
.upload-spinner {
  width: 20px;
  height: 20px;
  
  /* 边框样式 - 底色为浅灰 */
  border: 2px solid #f0f0f0;
  /* 顶部边框使用主色调 */
  border-top: 2px solid #1890ff;
  
  /* 圆形 */
  border-radius: 50%;
  
  /* 应用旋转动画 */
  animation: spin 1s linear infinite;
}

/* ==================== 文本样式 ==================== */

/**
 * 上传状态文本
 * 提供清晰的状态说明
 */
.upload-text {
  font-size: 14px;
  color: #666;
  text-align: center;
}

/**
 * 上传进度文本
 * 显示具体的上传百分比
 */
.upload-progress {
  font-size: 12px;
  color: #999;
}

/* ==================== 动画定义 ==================== */

/**
 * 旋转动画关键帧
 * 360度平滑旋转效果
 */
@keyframes spin {
  0% {
    transform: rotate(0deg);
  }
  100% {
    transform: rotate(360deg);
  }
}

/* ==================== 响应式设计 ==================== */

/**
 * 移动端适配
 * 在小屏幕设备上调整布局和字体大小
 */
@media (max-width: 768px) {
  .upload-content {
    min-width: 100px;
    padding: 12px;
  }
  
  .upload-text {
    font-size: 13px;
  }
  
  .upload-progress {
    font-size: 11px;
  }
}

/* ==================== 主题适配 ==================== */

/**
 * 深色主题支持(可选)
 * 当系统或应用使用深色主题时的样式调整
 */
@media (prefers-color-scheme: dark) {
  .upload-mask {
    background-color: rgba(0, 0, 0, 0.6);
  }
  
  .upload-content {
    background-color: #1f1f1f;
    color: #fff;
  }
  
  .upload-text {
    color: #ccc;
  }
  
  .upload-progress {
    color: #999;
  }
}

/* ==================== 无障碍支持 ==================== */

/**
 * 减少动画偏好支持
 * 为动作敏感用户提供静态版本
 */
@media (prefers-reduced-motion: reduce) {
  .upload-spinner {
    animation: none;
    /* 使用静态指示器替代动画 */
    border-top-color: #1890ff;
  }
}

/**
 * 高对比度支持
 * 确保在高对比度模式下仍然可见
 */
@media (forced-colors: active) {
  .upload-mask {
    background-color: Canvas;
    border: 1px solid ButtonText;
  }
  
  .upload-content {
    background-color: Canvas;
    color: ButtonText;
    border: 1px solid ButtonText;
  }
}

🚀 完整使用指南

📖 基础用法示例

1. 最简单的使用方式

typescript 复制代码
import React, { useState } from 'react';
import { PasteTextArea } from './components/PasteTextArea';

const BasicExample: React.FC = () => {
  const [content, setContent] = useState('');
  
  const formWrapConfig = {
    enableFilePaste: true,           // 启用文件粘贴功能
    fileCatalog: 'my-uploads',       // 指定上传目录
    maxFileSize: 20,                 // 最大20MB文件
    placeholder: '请输入内容,支持粘贴图片和文档...',
  };

  return (
    <PasteTextArea
      value={content}
      onChange={setContent}
      formWrap={formWrapConfig}
      afterUrlChange={(files) => {
        console.log('上传的文件:', files);
        // 这里可以处理上传完成的文件信息
      }}
    />
  );
};

export default BasicExample;

2. 与 Ant Design Form 集成

typescript 复制代码
import React from 'react';
import { Form, Button, Card } from 'antd';
import { PasteTextArea } from './components/PasteTextArea';

interface FormValues {
  description: string;
  attachments: any[];
}

const FormExample: React.FC = () => {
  const [form] = Form.useForm<FormValues>();
  
  const handleFinish = (values: FormValues) => {
    console.log('表单提交:', values);
  };

  return (
    <Card title="工单创建">
      <Form
        form={form}
        layout="vertical"
        onFinish={handleFinish}
        initialValues={{
          description: '',
          attachments: [],
        }}
      >
        <Form.Item
          name="description"
          label="问题描述"
          rules={[
            { required: true, message: '请输入问题描述' },
            { max: 2000, message: '描述不能超过2000个字符' },
          ]}
        >
          <PasteTextArea
            formWrap={{
              enableFilePaste: true,
              fileCatalog: 'support-tickets',
              maxFileSize: 50,
              maxLength: 2000,
              rows: 6,
              showCount: true,
              placeholder: '详细描述您遇到的问题,可以粘贴相关截图...',
            }}
            afterUrlChange={(uploadedFiles) => {
              // 将上传的文件添加到表单附件字段
              const currentAttachments = form.getFieldValue('attachments') || [];
              form.setFieldsValue({
                attachments: [...currentAttachments, ...uploadedFiles]
              });
            }}
          />
        </Form.Item>
        
        <Form.Item name="attachments" label="附件列表">
          {/* 这里可以显示已上传的附件列表 */}
        </Form.Item>
        
        <Form.Item>
          <Button type="primary" htmlType="submit">
            提交工单
          </Button>
        </Form.Item>
      </Form>
    </Card>
  );
};

export default FormExample;

3. 高级配置示例

typescript 复制代码
import React, { useState, useRef } from 'react';
import { message, Alert, Tag, Space } from 'antd';
import { PasteTextArea } from './components/PasteTextArea';

const AdvancedExample: React.FC = () => {
  const [content, setContent] = useState('');
  const [uploadedFiles, setUploadedFiles] = useState<any[]>([]);
  const [errors, setErrors] = useState<string[]>([]);
  
  // 高级配置
  const advancedConfig = {
    enableFilePaste: true,
    fileCatalog: 'advanced-demo',
    maxFileSize: 100,                    // 100MB 限制
    maxLength: 5000,                     // 5000字符限制
    
    // Ant Design TextArea 的标准配置
    rows: 8,
    autoSize: { minRows: 4, maxRows: 12 },
    showCount: true,
    allowClear: true,
    
    // 自定义占位符
    placeholder: `高级文本编辑器演示:
    
• 支持粘贴图片、文档、压缩包等文件
• 自动从网页内容中提取图片并上传
• 智能文件类型识别和大小检查
• 实时上传进度显示

试试粘贴一些内容吧!`,
  };

  // 文件上传完成处理
  const handleFilesUploaded = (files: any[]) => {
    setUploadedFiles(prev => [...prev, ...files]);
    
    // 显示上传成功的文件列表
    const fileNames = files.map(f => f.fileName).join(', ');
    message.success(`成功上传 ${files.length} 个文件: ${fileNames}`);
  };

  // 错误处理
  const handleError = (error: string) => {
    setErrors(prev => [...prev, error]);
    setTimeout(() => {
      setErrors(prev => prev.filter(e => e !== error));
    }, 5000);
  };

  // 清理已上传文件
  const removeFile = (index: number) => {
    setUploadedFiles(prev => prev.filter((_, i) => i !== index));
  };

  return (
    <div style={{ maxWidth: 800, margin: '0 auto', padding: 24 }}>
      <h2>PasteTextArea 高级功能演示</h2>
      
      {/* 错误信息显示 */}
      {errors.map((error, index) => (
        <Alert
          key={index}
          message={error}
          type="error"
          closable
          style={{ marginBottom: 16 }}
          onClose={() => setErrors(prev => prev.filter((_, i) => i !== index))}
        />
      ))}
      
      <div style={{ marginBottom: 24 }}>
        <PasteTextArea
          value={content}
          onChange={setContent}
          formWrap={advancedConfig}
          afterUrlChange={handleFilesUploaded}
          showStyle={{ minHeight: 200 }}
        />
      </div>
      
      {/* 已上传文件显示 */}
      {uploadedFiles.length > 0 && (
        <div style={{ marginTop: 16 }}>
          <h4>已上传文件 ({uploadedFiles.length})</h4>
          <Space wrap>
            {uploadedFiles.map((file, index) => (
              <Tag
                key={index}
                closable
                onClose={() => removeFile(index)}
                color={file.type === 'image' ? 'blue' : 'green'}
              >
                {file.fileType}: {file.fileName}
              </Tag>
            ))}
          </Space>
        </div>
      )}
      
      {/* 内容预览 */}
      {content && (
        <div style={{ marginTop: 24 }}>
          <h4>内容预览</h4>
          <div style={{ 
            padding: 16, 
            background: '#f5f5f5', 
            borderRadius: 4,
            whiteSpace: 'pre-wrap',
            maxHeight: 300,
            overflow: 'auto',
          }}>
            {content}
          </div>
        </div>
      )}
    </div>
  );
};

export default AdvancedExample;

📋 配置参数完整说明

FormWrap 配置对象

typescript 复制代码
interface FormWrapConfig {
  // ============ 核心功能配置 ============
  enableFilePaste?: boolean;     // 是否启用文件粘贴功能,默认 false
  fileCatalog?: string;          // 文件存储目录名,默认 'publicTcmTsk'
  maxFileSize?: number;          // 单文件最大大小(MB),默认 10MB
  targetFormField?: string;      // 关联的表单字段名
  
  // ============ 文本内容配置 ============
  placeholder?: string;          // 占位符文本
  maxLength?: number;            // 最大字符长度限制
  
  // ============ Ant Design TextArea 标准配置 ============
  rows?: number;                 // 文本域行数
  autoSize?: boolean | {         // 自适应高度
    minRows?: number;
    maxRows?: number;
  };
  showCount?: boolean;           // 显示字符计数
  allowClear?: boolean;          // 显示清除按钮
  disabled?: boolean;            // 是否禁用
  readOnly?: boolean;            // 是否只读
  
  // ============ 样式配置 ============
  size?: 'large' | 'middle' | 'small';  // 组件大小
  bordered?: boolean;            // 是否有边框
  
  // ============ 事件配置 ============
  onFocus?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
  onBlur?: (e: React.FocusEvent<HTMLTextAreaElement>) => void;
  onPressEnter?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
  
  // 支持所有其他 Ant Design TextArea 属性
  [key: string]: any;
}

支持的文件类型

typescript 复制代码
// 图片格式 (自动识别并优化处理)
const IMAGE_TYPES = [
  'image/jpeg', 'image/jpg', 'image/png', 
  'image/gif', 'image/webp', 'image/bmp', 
  'image/svg+xml'
];

// 文档格式 (Office、PDF、文本文件)
const DOCUMENT_TYPES = [
  'application/pdf',
  'application/msword',                          // .doc
  'application/vnd.openxmlformats-officedocument.wordprocessingml.document',  // .docx
  'application/vnd.ms-excel',                    // .xls
  'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',        // .xlsx
  'application/vnd.ms-powerpoint',               // .ppt
  'application/vnd.openxmlformats-officedocument.presentationml.presentation', // .pptx
  'text/plain', 'text/csv', 'text/html', 'text/markdown'
];

// 压缩包格式
const ARCHIVE_TYPES = [
  'application/zip',
  'application/x-rar-compressed',
  'application/x-7z-compressed',
  'application/gzip'
];

🛠️ 自定义扩展

1. 自定义文件类型验证

typescript 复制代码
const customFormWrap = {
  enableFilePaste: true,
  
  // 自定义文件验证函数
  validateFile: (file: File) => {
    // 禁止可执行文件
    const dangerousExtensions = ['.exe', '.bat', '.cmd', '.scr'];
    const fileName = file.name.toLowerCase();
    
    if (dangerousExtensions.some(ext => fileName.endsWith(ext))) {
      throw new Error('不允许上传可执行文件');
    }
    
    // 检查文件内容(可选)
    if (file.size === 0) {
      throw new Error('不允许上传空文件');
    }
    
    return true;
  },
  
  // 自定义文件名处理
  processFileName: (file: File) => {
    const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
    const extension = file.name.split('.').pop();
    return `upload_${timestamp}.${extension}`;
  }
};

2. 自定义上传进度处理

typescript 复制代码
const ProgressExample: React.FC = () => {
  const [uploadStatus, setUploadStatus] = useState<{
    isUploading: boolean;
    progress: number;
    currentFile: string;
  }>({
    isUploading: false,
    progress: 0,
    currentFile: '',
  });

  return (
    <div>
      <PasteTextArea
        formWrap={{
          enableFilePaste: true,
          onUploadStart: (fileName: string) => {
            setUploadStatus({
              isUploading: true,
              progress: 0,
              currentFile: fileName,
            });
          },
          onUploadProgress: (progress: number) => {
            setUploadStatus(prev => ({ ...prev, progress }));
          },
          onUploadComplete: () => {
            setUploadStatus({
              isUploading: false,
              progress: 100,
              currentFile: '',
            });
          },
        }}
      />
      
      {/* 自定义进度显示 */}
      {uploadStatus.isUploading && (
        <div style={{ marginTop: 8 }}>
          <div>正在上传: {uploadStatus.currentFile}</div>
          <div style={{ 
            width: '100%', 
            height: 6, 
            background: '#f0f0f0', 
            borderRadius: 3,
            overflow: 'hidden'
          }}>
            <div
              style={{
                width: `${uploadStatus.progress}%`,
                height: '100%',
                background: '#1890ff',
                transition: 'width 0.3s ease',
              }}
            />
          </div>
          <div style={{ textAlign: 'center', fontSize: 12, color: '#666' }}>
            {Math.round(uploadStatus.progress)}%
          </div>
        </div>
      )}
    </div>
  );
};

3. 批量文件处理优化

typescript 复制代码
const BatchProcessingExample: React.FC = () => {
  const [processingQueue, setProcessingQueue] = useState<File[]>([]);
  
  const processBatchFiles = async (files: File[]) => {
    const BATCH_SIZE = 3; // 同时处理3个文件
    const results = [];
    
    for (let i = 0; i < files.length; i += BATCH_SIZE) {
      const batch = files.slice(i, i + BATCH_SIZE);
      
      // 并行处理当前批次
      const batchPromises = batch.map(async (file) => {
        try {
          return await uploadFile(file);
        } catch (error) {
          console.error(`文件 ${file.name} 上传失败:`, error);
          return null;
        }
      });
      
      const batchResults = await Promise.allSettled(batchPromises);
      results.push(...batchResults);
      
      // 更新进度
      const completedCount = i + batch.length;
      const progress = (completedCount / files.length) * 100;
      console.log(`批处理进度: ${Math.round(progress)}%`);
    }
    
    return results;
  };

  return (
    <PasteTextArea
      formWrap={{
        enableFilePaste: true,
        batchProcessing: true,
        onBatchStart: (files: File[]) => {
          setProcessingQueue(files);
        },
        onBatchProcess: processBatchFiles,
        onBatchComplete: (results: any[]) => {
          setProcessingQueue([]);
          const successCount = results.filter(r => r !== null).length;
          message.success(`批量处理完成,成功上传 ${successCount} 个文件`);
        },
      }}
    />
  );
};

🔧 故障排除指南

常见问题及解决方案

  1. 文件上传失败

    typescript 复制代码
    // 检查配置
    const debugConfig = {
      enableFilePaste: true,
      fileCatalog: 'test-uploads',
      maxFileSize: 10, // 确认大小限制合理
      debug: true,     // 开启调试模式
      
      onUploadError: (error: Error, file: File) => {
        console.error('上传错误详情:', {
          error: error.message,
          fileName: file.name,
          fileSize: file.size,
          fileType: file.type,
        });
        
        // 根据错误类型提供具体解决建议
        if (error.message.includes('大小超过限制')) {
          message.error(`文件 ${file.name} 过大,请压缩后重试`);
        } else if (error.message.includes('网络错误')) {
          message.error('网络连接不稳定,请稍后重试');
        } else {
          message.error(`上传失败: ${error.message}`);
        }
      }
    };
  2. 粘贴图片无反应

    typescript 复制代码
    // 检查浏览器支持
    const checkBrowserSupport = () => {
      if (!navigator.clipboard) {
        console.warn('浏览器不支持 Clipboard API');
      }
      
      if (!window.DataTransfer) {
        console.warn('浏览器不支持 DataTransfer API');
      }
      
      if (!window.DOMParser) {
        console.warn('浏览器不支持 DOMParser API');
      }
    };
    
    // 添加兜底处理
    const fallbackConfig = {
      enableFilePaste: true,
      useFallbackMode: true, // 启用兼容模式
      onPasteError: (error: Error) => {
        console.error('粘贴处理失败:', error);
        message.warning('粘贴功能异常,请尝试直接拖拽文件');
      }
    };
  3. CORS 跨域问题

    typescript 复制代码
    const corsConfig = {
      enableFilePaste: true,
      // 优先使用代理接口
      useProxy: true,
      proxyEndpoint: '/api/proxy/download',
      
      // CORS 失败时的降级处理
      onCorsError: (url: string) => {
        console.warn(`图片 ${url} 存在跨域限制,尝试代理下载`);
        // 这里可以实现自定义的代理逻辑
      }
    };
  4. 性能优化配置

    typescript 复制代码
    const performanceConfig = {
      enableFilePaste: true,
      
      // 限制并发上传数量
      maxConcurrentUploads: 2,
      
      // 大文件分片上传
      enableChunkedUpload: true,
      chunkSize: 1024 * 1024, // 1MB 分片
      
      // 图片压缩配置
      imageCompression: {
        enabled: true,
        maxWidth: 1920,
        maxHeight: 1080,
        quality: 0.8,
      },
      
      // 缓存优化
      enableCache: true,
      cacheExpiration: 24 * 60 * 60 * 1000, // 24小时
    };

🎉 总结

PasteTextArea 组件通过智能的剪切板内容分析、多重文件获取策略和完善的错误处理机制,为现代 Web 应用提供了强大的富文本输入能力。

✨ 核心优势

  1. 开发者友好 - 完整的 TypeScript 类型定义,详细的代码注释
  2. 用户体验优秀 - 智能内容识别,流畅的上传反馈
  3. 功能强大完整 - 支持多种文件格式,多重获取策略
  4. 扩展性良好 - 模块化设计,易于定制和扩展
  5. 兼容性出色 - 跨浏览器兼容,渐进式增强

📚 学习价值

通过这个组件的实现,你可以学到:

  • 现代 React 开发模式 - Hooks、TypeScript、函数组件最佳实践
  • 文件处理技术 - 上传、转换、压缩、验证等核心技能
  • 用户体验设计 - 加载状态、错误处理、交互反馈
  • 浏览器 API 应用 - Clipboard API、Canvas API、File API 等
  • 性能优化策略 - 批处理、缓存、渐进式降级

这个组件不仅可以直接在项目中使用,更是学习现代前端开发技术的优秀案例。希望对您的开发工作有所帮助!


💡 提示:本文档包含了完整的实现代码,可以直接复制使用。如有问题或需要进一步定制,请参考代码注释或联系开发团队。

相关推荐
Spider_Man4 小时前
React 组件缓存与 KeepAlive 组件打造全攻略 😎
前端·react.js·typescript
GISer_Jing5 小时前
React Native核心技术深度解析_Trip Footprints
javascript·react native·react.js
默默地离开6 小时前
React当中受控组件与非受控组件的区别
前端·javascript·react.js
没烦恼3016 小时前
React快速入门(语法篇),一文带你了解react的基础知识,看完你就能上手react写东西了(最适合有vue基础的同学)
react.js
江城开朗的豌豆6 小时前
useCallback:从性能焦虑到精准优化的轻松之路
前端·javascript·react.js
江城开朗的豌豆6 小时前
Redux 与 React-Redux:从“全局变量”到“响应式状态”的优雅之道
前端·javascript·react.js
江城开朗的豌豆6 小时前
点击弹窗外部自动关闭?一个useRef Hook就搞定!
前端·javascript·react.js
海海思思21 小时前
React 虚拟列表中的 Hook 使用陷阱
react.js
车前端1 天前
React 18 核心新特性解析
react.js