TypeScript + React 封装 PDF 翻译组件:前端直连 API 方案

在日常前端开发中,经常遇到需要为项目添加文档翻译功能的场景。与其让用户跳转到第三方网站,不如直接在应用内集成一个优雅的 PDF 翻译组件。本文将带你从零封装一个可复用的 React + TypeScript PDF 翻译组件,前端直连翻译 API,无需后端中转。

前言

本文实现的目标是:

  • 拖拽上传 PDF 文件
  • 选择目标语言
  • 前端直接调用翻译 API
  • 显示翻译进度和下载链接
  • 完整的 TypeScript 类型支持

技术栈:React 18 + TypeScript 5 + Tailwind CSS

环境准备

npx create-react-app pdf-translator --template typescript
cd pdf-translator
npm install lucide-react

如果你使用 Vite:

npm create vite@latest pdf-translator -- --template react-ts
cd pdf-translator && npm install

核心类型定义

首先定义组件需要的数据结构:

// types/pdf-translator.ts

export interface TranslationResponse {
  success: boolean;
  downloadUrl?: string;
  error?: string;
  pages?: number;
}

export interface LanguageOption {
  code: string;
  label: string;
  flag: string;
}

export type TranslationStatus = 'idle' | 'uploading' | 'translating' | 'completed' | 'error';

export const SUPPORTED_LANGUAGES: LanguageOption[] = [
  { code: 'zh', label: '简体中文', flag: '🇨🇳' },
  { code: 'en', label: 'English', flag: '🇬🇧' },
  { code: 'ja', label: '日本語', flag: '🇯🇵' },
  { code: 'ko', label: '한국어', flag: '🇰🇷' },
  { code: 'es', label: 'Español', flag: '🇪🇸' },
  { code: 'fr', label: 'Français', flag: '🇫🇷' },
  { code: 'de', label: 'Deutsch', flag: '🇩🇪' },
  { code: 'ru', label: 'Русский', flag: '🇷🇺' },
];

API 服务层

封装一个独立的 API 服务模块,方便后续替换不同的翻译后端:

// services/translationApi.ts

import { TranslationResponse } from '../types/pdf-translator';

const API_BASE_URL = 'https://api.pdftranslator.org/v1';

export class TranslationService {
  /**
   * 上传并翻译 PDF 文件
   * @param file PDF 文件
   * @param targetLang 目标语言代码
   * @param onProgress 进度回调 (0-100)
   */
  static async translatePDF(
    file: File,
    targetLang: string,
    onProgress?: (progress: number) => void
  ): Promise<TranslationResponse> {
    const formData = new FormData();
    formData.append('file', file);
    formData.append('target_lang', targetLang);
    formData.append('preserve_layout', 'true');

    return new Promise((resolve, reject) => {
      const xhr = new XMLHttpRequest();

      xhr.upload.addEventListener('progress', (event) => {
        if (event.lengthComputable && onProgress) {
          const percent = Math.round((event.loaded / event.total) * 50); // 上传占50%
          onProgress(percent);
        }
      });

      xhr.addEventListener('load', () => {
        if (xhr.status === 200) {
          try {
            const data = JSON.parse(xhr.responseText);
            onProgress?.(100);
            resolve({
              success: true,
              downloadUrl: data.download_url,
              pages: data.pages,
            });
          } catch {
            reject(new Error('Invalid response format'));
          }
        } else {
          reject(new Error(`HTTP ${xhr.status}: ${xhr.statusText}`));
        }
      });

      xhr.addEventListener('error', () => reject(new Error('Network error')));
      xhr.addEventListener('abort', () => reject(new Error('Request aborted')));

      xhr.open('POST', `${API_BASE_URL}/translate`);
      xhr.send(formData);
    });
  }

  /**
   * 检查文件是否有效
   */
  static validateFile(file: File): { valid: boolean; error?: string } {
    const MAX_SIZE = 20 * 1024 * 1024; // 20MB
    const ALLOWED_TYPES = ['application/pdf'];

    if (!ALLOWED_TYPES.includes(file.type)) {
      return { valid: false, error: '仅支持 PDF 文件' };
    }

    if (file.size > MAX_SIZE) {
      return { valid: false, error: `文件大小超过 20MB 限制 (${(file.size / 1024 / 1024).toFixed(1)}MB)` };
    }

    return { valid: true };
  }
}

UI 组件实现

1. 文件上传区域

// components/FileDropZone.tsx

import React, { useCallback } from 'react';
import { Upload, FileText, X } from 'lucide-react';

interface FileDropZoneProps {
  file: File | null;
  onFileSelect: (file: File | null) => void;
  disabled?: boolean;
}

export const FileDropZone: React.FC<FileDropZoneProps> = ({
  file,
  onFileSelect,
  disabled = false,
}) => {
  const handleDrop = useCallback(
    (e: React.DragEvent) => {
      e.preventDefault();
      if (disabled) return;

      const droppedFile = e.dataTransfer.files[0];
      if (droppedFile?.type === 'application/pdf') {
        onFileSelect(droppedFile);
      }
    },
    [disabled, onFileSelect]
  );

  const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
    const selectedFile = e.target.files?.[0];
    if (selectedFile) {
      onFileSelect(selectedFile);
    }
  };

  if (file) {
    return (
      <div className="flex items-center justify-between p-4 bg-blue-50 border border-blue-200 rounded-lg">
        <div className="flex items-center gap-3">
          <FileText className="w-8 h-8 text-blue-600" />
          <div>
            <p className="font-medium text-gray-900">{file.name}</p>
            <p className="text-sm text-gray-500">
              {(file.size / 1024 / 1024).toFixed(2)} MB
            </p>
          </div>
        </div>
        {!disabled && (
          <button
            onClick={() => onFileSelect(null)}
            className="p-1 hover:bg-blue-100 rounded-full transition-colors"
          >
            <X className="w-5 h-5 text-gray-500" />
          </button>
        )}
      </div>
    );
  }

  return (
    <label
      onDragOver={(e) => e.preventDefault()}
      onDrop={handleDrop}
      className={`flex flex-col items-center justify-center w-full h-40 border-2 border-dashed 
        rounded-lg cursor-pointer transition-colors
        ${disabled 
          ? 'border-gray-200 bg-gray-50 cursor-not-allowed' 
          : 'border-gray-300 bg-gray-50 hover:bg-gray-100 hover:border-gray-400'
        }`}
    >
      <div className="flex flex-col items-center justify-center pt-5 pb-6">
        <Upload className={`w-10 h-10 mb-3 ${disabled ? 'text-gray-300' : 'text-gray-400'}`} />
        <p className="mb-2 text-sm text-gray-500">
          <span className="font-semibold">点击上传</span> 或拖拽 PDF 到此处
        </p>
        <p className="text-xs text-gray-400">支持最大 20MB 的 PDF 文件</p>
      </div>
      <input
        type="file"
        className="hidden"
        accept=".pdf"
        onChange={handleInputChange}
        disabled={disabled}
      />
    </label>
  );
};

2. 语言选择器

// components/LanguageSelector.tsx

import React from 'react';
import { SUPPORTED_LANGUAGES, LanguageOption } from '../types/pdf-translator';
import { Globe } from 'lucide-react';

interface LanguageSelectorProps {
  selected: string;
  onChange: (code: string) => void;
  disabled?: boolean;
}

export const LanguageSelector: React.FC<LanguageSelectorProps> = ({
  selected,
  onChange,
  disabled = false,
}) => {
  return (
    <div className="space-y-2">
      <label className="flex items-center gap-2 text-sm font-medium text-gray-700">
        <Globe className="w-4 h-4" />
        目标语言
      </label>
      <div className="grid grid-cols-4 gap-2">
        {SUPPORTED_LANGUAGES.map((lang: LanguageOption) => (
          <button
            key={lang.code}
            onClick={() => !disabled && onChange(lang.code)}
            disabled={disabled}
            className={`flex items-center justify-center gap-1.5 px-3 py-2 rounded-lg text-sm 
              border transition-all
              ${selected === lang.code
                ? 'border-blue-500 bg-blue-50 text-blue-700 font-medium'
                : 'border-gray-200 hover:border-gray-300 text-gray-600'
              }
              ${disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer'}
            `}
          >
            <span>{lang.flag}</span>
            <span className="hidden sm:inline">{lang.label}</span>
          </button>
        ))}
      </div>
    </div>
  );
};

3. 主组件整合

// components/PDFTranslator.tsx

import React, { useState, useCallback } from 'react';
import { FileDropZone } from './FileDropZone';
import { LanguageSelector } from './LanguageSelector';
import { TranslationService } from '../services/translationApi';
import { TranslationStatus, TranslationResponse } from '../types/pdf-translator';
import { Loader2, Download, AlertCircle, CheckCircle, Languages } from 'lucide-react';

export const PDFTranslator: React.FC = () => {
  const [file, setFile] = useState<File | null>(null);
  const [targetLang, setTargetLang] = useState('zh');
  const [status, setStatus] = useState<TranslationStatus>('idle');
  const [progress, setProgress] = useState(0);
  const [result, setResult] = useState<TranslationResponse | null>(null);
  const [error, setError] = useState('');

  const handleTranslate = useCallback(async () => {
    if (!file) return;

    // 文件校验
    const validation = TranslationService.validateFile(file);
    if (!validation.valid) {
      setError(validation.error || '文件校验失败');
      setStatus('error');
      return;
    }

    setStatus('uploading');
    setProgress(0);
    setError('');
    setResult(null);

    try {
      setStatus('translating');
      const response = await TranslationService.translatePDF(
        file,
        targetLang,
        (p) => setProgress(p)
      );

      if (response.success) {
        setResult(response);
        setStatus('completed');
      } else {
        throw new Error(response.error || '翻译失败');
      }
    } catch (err) {
      setError(err instanceof Error ? err.message : '未知错误');
      setStatus('error');
    }
  }, [file, targetLang]);

  const isProcessing = status === 'uploading' || status === 'translating';

  return (
    <div className="w-full max-w-2xl mx-auto p-6 bg-white rounded-xl shadow-lg">
      <div className="flex items-center gap-3 mb-6">
        <Languages className="w-7 h-7 text-blue-600" />
        <h2 className="text-xl font-bold text-gray-900">PDF 智能翻译</h2>
      </div>

      {/* 文件上传 */}
      <div className="mb-6">
        <FileDropZone
          file={file}
          onFileSelect={setFile}
          disabled={isProcessing}
        />
      </div>

      {/* 语言选择 */}
      {file && (
        <div className="mb-6">
          <LanguageSelector
            selected={targetLang}
            onChange={setTargetLang}
            disabled={isProcessing}
          />
        </div>
      )}

      {/* 操作按钮 */}
      {file && (
        <button
          onClick={handleTranslate}
          disabled={isProcessing}
          className={`w-full py-3 px-4 rounded-lg font-medium text-white transition-all
            ${isProcessing
              ? 'bg-gray-400 cursor-not-allowed'
              : 'bg-blue-600 hover:bg-blue-700 active:bg-blue-800'
            }`}
        >
          {isProcessing ? (
            <span className="flex items-center justify-center gap-2">
              <Loader2 className="w-5 h-5 animate-spin" />
              {status === 'uploading' ? '上传中...' : '翻译中...'}
            </span>
          ) : (
            '开始翻译'
          )}
        </button>
      )}

      {/* 进度条 */}
      {isProcessing && (
        <div className="mt-4">
          <div className="w-full bg-gray-200 rounded-full h-2.5">
            <div
              className="bg-blue-600 h-2.5 rounded-full transition-all duration-300"
              style={{ width: `${progress}%` }}
            />
          </div>
          <p className="text-sm text-gray-500 mt-2 text-center">{progress}%</p>
        </div>
      )}

      {/* 错误提示 */}
      {status === 'error' && (
        <div className="mt-4 p-4 bg-red-50 border border-red-200 rounded-lg flex items-start gap-3">
          <AlertCircle className="w-5 h-5 text-red-500 flex-shrink-0 mt-0.5" />
          <div>
            <p className="font-medium text-red-800">翻译失败</p>
            <p className="text-sm text-red-600 mt-1">{error}</p>
          </div>
        </div>
      )}

      {/* 成功结果 */}
      {status === 'completed' && result?.downloadUrl && (
        <div className="mt-4 p-4 bg-green-50 border border-green-200 rounded-lg">
          <div className="flex items-center gap-3 mb-3">
            <CheckCircle className="w-5 h-5 text-green-500" />
            <p className="font-medium text-green-800">翻译完成</p>
          </div>
          {result.pages && (
            <p className="text-sm text-green-600 mb-3">
              共 {result.pages} 页,格式已保留
            </p>
          )}
          <a
            href={result.downloadUrl}
            target="_blank"
            rel="noopener noreferrer"
            className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white 
              rounded-lg hover:bg-green-700 transition-colors text-sm font-medium"
          >
            <Download className="w-4 h-4" />
            下载翻译结果
          </a>
        </div>
      )}
    </div>
  );
};

在 App 中使用

// App.tsx

import React from 'react';
import { PDFTranslator } from './components/PDFTranslator';

function App() {
  return (
    <div className="min-h-screen bg-gray-100 py-12 px-4">
      <div className="text-center mb-8">
        <h1 className="text-3xl font-bold text-gray-900 mb-2">
          PDF 翻译组件 Demo
        </h1>
        <p className="text-gray-600">
          基于 React + TypeScript 的前端直连方案
        </p>
      </div>
      <PDFTranslator />
    </div>
  );
}

export default App;

进阶:接入真实 API

上面的代码使用了模拟的 API 地址。如果你要接入 PDFTranslator 的真实 API,需要修改 translationApi.ts 中的 API_BASE_URL 和请求格式。

根据 PDFTranslator 的 API 设计,典型的调用方式如下:

// 修改后的 translatePDF 方法核心逻辑
static async translatePDF(
  file: File,
  targetLang: string,
  onProgress?: (progress: number) => void
): Promise<TranslationResponse> {
  const formData = new FormData();
  formData.append('file', file);
  formData.append('target_lang', targetLang);

  const response = await fetch('https://pdftranslator.org/api/translate', {
    method: 'POST',
    body: formData,
  });

  if (!response.ok) {
    throw new Error(`HTTP ${response.status}`);
  }

  const data = await response.json();
  return {
    success: true,
    downloadUrl: data.result_url,
    pages: data.page_count,
  };
}

组件扩展方向

这个基础组件可以朝以下方向扩展:

  1. 批量翻译:支持同时上传多个文件,队列处理
  2. 翻译历史:配合 localStorage 或后端保存翻译记录
  3. 预览模式:翻译完成后在组件内预览 PDF 内容
  4. 术语库:允许用户上传自定义术语表,提升专业领域翻译准确度
  5. 深色模式:通过 Tailwind 的 dark: 前缀快速适配

总结

本文演示了如何用 TypeScript + React 封装一个可复用的 PDF 翻译组件。关键点在于:

  • 类型先行:完善的 TypeScript 类型定义让组件更健壮
  • 职责分离:API 层、UI 层、状态层各司其职
  • 用户体验:进度反馈、错误处理、拖拽交互一个都不能少

完整代码已开源在 GitHub,欢迎 Star 和 PR。

标签:TypeScript、React、PDF翻译、前端组件、AI翻译、效率工具

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值