在日常前端开发中,经常遇到需要为项目添加文档翻译功能的场景。与其让用户跳转到第三方网站,不如直接在应用内集成一个优雅的 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,
};
}
组件扩展方向
这个基础组件可以朝以下方向扩展:
- 批量翻译:支持同时上传多个文件,队列处理
- 翻译历史:配合 localStorage 或后端保存翻译记录
- 预览模式:翻译完成后在组件内预览 PDF 内容
- 术语库:允许用户上传自定义术语表,提升专业领域翻译准确度
- 深色模式:通过 Tailwind 的 dark: 前缀快速适配
总结
本文演示了如何用 TypeScript + React 封装一个可复用的 PDF 翻译组件。关键点在于:
- 类型先行:完善的 TypeScript 类型定义让组件更健壮
- 职责分离:API 层、UI 层、状态层各司其职
- 用户体验:进度反馈、错误处理、拖拽交互一个都不能少
完整代码已开源在 GitHub,欢迎 Star 和 PR。
标签:TypeScript、React、PDF翻译、前端组件、AI翻译、效率工具
354

被折叠的 条评论
为什么被折叠?



