欢迎光临
我们一直在努力

PDF翻译的前端完整方案:从拖拽上传到实时翻译进度条(React实战)

前言

大多数PDF翻译工具的前端都很简单——一个上传框 + 一个下载按钮。但如果你想把这个能力嵌入到自己的产品中,打造更好的用户体验,那前端侧需要处理的事情远不止这些:

  • 拖拽上传 + 文件校验
  • 多语言选择(源语言/目标语言)
  • 实时翻译进度(不是loading spinner)
  • 大文件分片上传
  • 翻译结果预览
  • 错误处理和重试

本文分享一套基于 React 的完整前端方案,后端对接 PDFTranslator 翻译能力。

环境准备

  • Node.js 18+
  • React 18+
  • TypeScript(推荐)

npx create-react-app pdf-translator-frontend –template typescript
cd pdf-translator-frontend
npm install axios react-dropzone react-hot-toast @radix-ui/react-progress

实现步骤

Step 1: 文件上传组件(拖拽+校验)

// components/FileUploader.tsx
import React, { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import toast from 'react-hot-toast';

interface FileUploaderProps {
onFileSelect: (file: File) => void;
maxSize?: number; // 单位:MB
}

const FileUploader: React.FC<FileUploaderProps> = ({
onFileSelect,
maxSize = 20,
}) => {
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: any[]) => {
// 处理被拒绝的文件
rejectedFiles.forEach((file) => {
const error = file.errors[0];
switch (error.code) {
case 'file-too-large':
toast.error(`文件不能超过 ${maxSize}MB`);
break;
case 'file-invalid-type':
toast.error('仅支持 PDF 文件');
break;
default:
toast.error(error.message);
}
});

// 取第一个有效文件
if (acceptedFiles.length > 0) {
onFileSelect(acceptedFiles[0]);
}
},
[onFileSelect, maxSize]
);

const { getRootProps, getInputProps, isDragActive } = useDropzone({
onDrop,
accept: { 'application/pdf': ['.pdf'] },
maxSize: maxSize * 1024 * 1024,
multiple: false, // 单文件翻译
});

return (
<div
{…getRootProps()}
className={`dropzone ${isDragActive ? 'dropzone–active' : ''}`}
style={{
border: '2px dashed #ccc',
borderRadius: 12,
padding: 48,
textAlign: 'center',
cursor: 'pointer',
transition: 'all 0.3s',
backgroundColor: isDragActive ? '#f0f7ff' : '#fafafa',
}}
>
<input {…getInputProps()} />
<div style={{ fontSize: 48, marginBottom: 16 }}>📄</div>
{isDragActive ? (
<p style={{ color: '#1890ff' }}>松开鼠标开始上传</p>
) : (
<>
<p style={{ margin: '8px 0', fontSize: 16, fontWeight: 500 }}>
拖拽 PDF 文件到这里,或点击选择
</p>
<p style={{ color: '#999', fontSize: 13 }}>
支持 PDF 格式,最大 {maxSize}MB
</p>
</>
)}
</div>
);
};

export default FileUploader;

Step 2: 翻译进度组件

// components/TranslationProgress.tsx
import React, { useEffect, useRef } from 'react';
import * as Progress from '@radix-ui/react-progress';

interface TranslationProgressProps {
status: 'idle' | 'uploading' | 'translating' | 'processing' | 'done' | 'error';
progress: number; // 0-100
message?: string;
errorMessage?: string;
}

const statusLabels: Record<string, string> = {
idle: '等待开始',
uploading: '正在上传文件…',
translating: 'AI翻译中…',
processing: '正在生成结果文件…',
done: '翻译完成',
error: '翻译失败',
};

const TranslationProgress: React.FC<TranslationProgressProps> = ({
status,
progress,
message,
errorMessage,
}) => {
const isActive = status !== 'idle' && status !== 'done' && status !== 'error';

return (
<div style={{ width: '100%', maxWidth: 480, margin: '24px auto' }}>
{/* 状态文本 */}
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 8 }}>
<span style={{ fontWeight: 500 }}>
{message || statusLabels[status]}
</span>
<span style={{ color: '#666' }}>{Math.round(progress)}%</span>
</div>

{/* 进度条 */}
<Progress.Root
className="progress-root"
value={progress}
style={{
width: '100%',
height: 8,
borderRadius: 4,
backgroundColor: '#e5e7eb',
overflow: 'hidden',
}}
>
<Progress.Indicator
className="progress-indicator"
style={{
height: '100%',
backgroundColor: isActive ? '#1890ff' : status === 'done' ? '#52c41a' : '#ff4d4f',
borderRadius: 4,
transition: 'width 0.5s ease, background-color 0.3s',
width: `${progress}%`,
}}
/>
</Progress.Root>

{/* 阶段指示器 */}
{isActive && (
<div style={{ display: 'flex', justifyContent: 'space-between', marginTop: 12 }}>
{['uploading', 'translating', 'processing'].map((phase) => (
<div
key={phase}
style={{
display: 'flex',
alignItems: 'center',
gap: 4,
fontSize: 12,
color: status === phase ? '#1890ff' : '#ccc',
fontWeight: status === phase ? 600 : 400,
}}
>
<span
style={{
width: 8,
height: 8,
borderRadius: '50%',
backgroundColor: status === phase ? '#1890ff' : '#e5e7eb',
display: 'inline-block',
}}
/>
{statusLabels[phase as keyof typeof statusLabels]}
</div>
))}
</div>
)}

{/* 错误信息 */}
{status === 'error' && errorMessage && (
<div
style={{
marginTop: 12,
padding: 12,
backgroundColor: '#fff2f0',
border: '1px solid #ffccc7',
borderRadius: 8,
color: '#ff4d4f',
fontSize: 13,
}}
>
⚠️ {errorMessage}
</div>
)}
</div>
);
};

export default TranslationProgress;

Step 3: 核心翻译逻辑 Hook

// hooks/useTranslation.ts
import { useState, useCallback, useRef } from 'react';
import axios, { AxiosProgressEvent } from 'axios';

interface TranslationState {
status: 'idle' | 'uploading' | 'translating' | 'processing' | 'done' | 'error';
progress: number;
message: string;
resultUrl: string | null;
errorMessage: string;
}

const API_BASE = process.env.REACT_APP_API_URL || '/api';

export function useTranslation() {
const [state, setState] = useState<TranslationState>({
status: 'idle',
progress: 0,
message: '',
resultUrl: null,
errorMessage: '',
});

const abortRef = useRef<AbortController | null>(null);

const translate = useCallback(
async (file: File, sourceLang: string, targetLang: string) => {
// 取消之前的请求
abortRef.current?.abort();
abortRef.current = new AbortController();

const formData = new FormData();
formData.append('file', file);
formData.append('source_lang', sourceLang);
formData.append('target_lang', targetLang);

try {
// 阶段1: 上传
setState((s) => ({ …s, status: 'uploading', progress: 0, message: '正在上传文件…' }));

const uploadResponse = await axios.post(`${API_BASE}/translate`, formData, {
signal: abortRef.current.signal,
headers: { 'Content-Type': 'multipart/form-data' },
onUploadProgress: (e: AxiosProgressEvent) => {
if (e.total) {
const pct = Math.round((e.loaded / e.total) * 40); // 上传占0-40%
setState((s) => ({ …s, progress: pct }));
}
},
});

const { task_id } = uploadResponse.data;

// 阶段2: 轮询翻译进度
setState((s) => ({ …s, status: 'translating', progress: 45, message: 'AI翻译中…' }));

const result = await pollTranslationProgress(task_id, (pct) => {
setState((s) => ({ …s, progress: 45 + Math.round(pct * 0.5) })); // 翻译占45-95%
});

// 阶段3: 处理完成
setState((s) => ({
…s,
status: 'processing',
progress: 95,
message: '正在生成结果文件…',
}));

// 模拟短暂处理
await new Promise((r) => setTimeout(r, 500));

setState((s) => ({
…s,
status: 'done',
progress: 100,
message: '翻译完成!',
resultUrl: `/api/download/${task_id}`,
}));
} catch (err: any) {
if (axios.isCancel(err)) {
setState((s) => ({ …s, status: 'idle', progress: 0 }));
return;
}
setState((s) => ({
…s,
status: 'error',
errorMessage: err.response?.data?.error || err.message || '翻译失败,请重试',
}));
}
},
[]
);

const reset = useCallback(() => {
abortRef.current?.abort();
setState({
status: 'idle',
progress: 0,
message: '',
resultUrl: null,
errorMessage: '',
});
}, []);

return { …state, translate, reset };
}

/**
* 轮询翻译进度
*/
async function pollTranslationProgress(
taskId: string,
onProgress: (pct: number) => void,
interval = 2000,
maxWait = 600000
): Promise<any> {
const startTime = Date.now();

while (Date.now() – startTime < maxWait) {
await new Promise((r) => setTimeout(r, interval));

const resp = await axios.get(`${API_BASE}/status/${taskId}`);
const { status, meta, result, error } = resp.data;

if (status === 'SUCCESS') {
onProgress(1);
return result;
}

if (status === 'FAILURE') {
throw new Error(error || '翻译失败');
}

// 更新进度
const progress = meta?.progress || 0;
onProgress(progress / 100);
}

throw new Error('翻译超时,请重试');
}

export default useTranslation;

Step 4: 完整页面组件

// pages/PdfTranslator.tsx
import React, { useState } from 'react';
import FileUploader from '../components/FileUploader';
import TranslationProgress from '../components/TranslationProgress';
import useTranslation from '../hooks/useTranslation';
import toast from 'react-hot-toast';

const LANGUAGES = [
{ code: 'auto', label: '自动检测' },
{ code: 'zh', label: '中文' },
{ code: 'en', label: 'English' },
{ code: 'ja', label: '日本語' },
{ code: 'ko', label: '한국어' },
{ code: 'fr', label: 'Français' },
{ code: 'de', label: 'Deutsch' },
{ code: 'es', label: 'Español' },
];

const PdfTranslator: React.FC = () => {
const [selectedFile, setSelectedFile] = useState<File | null>(null);
const [sourceLang, setSourceLang] = useState('auto');
const [targetLang, setTargetLang] = useState('zh');
const { status, progress, message, resultUrl, errorMessage, translate, reset } =
useTranslation();

const handleTranslate = () => {
if (!selectedFile) {
toast.error('请先选择PDF文件');
return;
}
translate(selectedFile, sourceLang, targetLang);
};

return (
<div style={{ maxWidth: 640, margin: '0 auto', padding: 24 }}>
<h1 style={{ fontSize: 24, fontWeight: 700, marginBottom: 24 }}>
📑 PDF 翻译工具
</h1>

{/* 文件上传区 */}
{status === 'idle' && (
<>
<FileUploader onFileSelect={setSelectedFile} maxSize={20} />
{selectedFile && (
<div
style={{
marginTop: 16,
padding: 12,
backgroundColor: '#f6ffed',
borderRadius: 8,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<span style={{ fontSize: 14 }}>📎 {selectedFile.name}</span>
<span style={{ fontSize: 12, color: '#999' }}>
{(selectedFile.size / 1024 / 1024).toFixed(1)} MB
</span>
</div>
)}
</>
)}

{/* 语言选择 */}
<div
style={{
display: 'flex',
gap: 16,
marginTop: 24,
alignItems: 'center',
}}
>
<select
value={sourceLang}
onChange={(e) => setSourceLang(e.target.value)}
disabled={status !== 'idle'}
style={{ flex: 1, padding: '8px 12px', borderRadius: 8, border: '1px solid #d9d9d9' }}
>
{LANGUAGES.map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>

<span style={{ color: '#999' }}>→</span>

<select
value={targetLang}
onChange={(e) => setTargetLang(e.target.value)}
disabled={status !== 'idle'}
style={{ flex: 1, padding: '8px 12px', borderRadius: 8, border: '1px solid #d9d9d9' }}
>
{LANGUAGES.filter((l) => l.code !== 'auto').map((l) => (
<option key={l.code} value={l.code}>
{l.label}
</option>
))}
</select>
</div>

{/* 翻译按钮 */}
{status === 'idle' && (
<button
onClick={handleTranslate}
disabled={!selectedFile}
style={{
width: '100%',
marginTop: 24,
padding: '12px 0',
backgroundColor: selectedFile ? '#1890ff' : '#d9d9d9',
color: '#fff',
border: 'none',
borderRadius: 8,
fontSize: 16,
fontWeight: 500,
cursor: selectedFile ? 'pointer' : 'not-allowed',
}}
>
开始翻译
</button>
)}

{/* 进度显示 */}
{status !== 'idle' && (
<>
<TranslationProgress
status={status}
progress={progress}
message={message}
errorMessage={errorMessage}
/>

{/* 结果下载 */}
{status === 'done' && resultUrl && (
<div style={{ textAlign: 'center', marginTop: 24 }}>
<a
href={resultUrl}
download
style={{
display: 'inline-block',
padding: '12px 32px',
backgroundColor: '#52c41a',
color: '#fff',
borderRadius: 8,
textDecoration: 'none',
fontSize: 16,
fontWeight: 500,
}}
>
📥 下载翻译结果
</a>
</div>
)}

{/* 错误重试 */}
{status === 'error' && (
<button
onClick={reset}
style={{
display: 'block',
width: '100%',
marginTop: 16,
padding: '10px 0',
backgroundColor: '#fff',
border: '1px solid #d9d9d9',
borderRadius: 8,
cursor: 'pointer',
}}
>
重新开始
</button>
)}
</>
)}
</div>
);
};

export default PdfTranslator;

技术要点总结

1. 用户体验设计

要素实现方式效果
拖拽上传 react-dropzone 拖拽即上传,视觉反馈即时
文件校验 accept + maxSize 错误类型在前端拦截,不上传到服务器才发现
三阶段进度 uploading(0-40%) → translating(45-95%) → processing(95-100%) 用户知道当前处于哪一步
阶段指示器 三个圆点+标签 一目了然的可视化阶段
错误处理 toast + 内联错误卡片 反馈及时且不突兀

2. 性能优化

  • 上传进度:axios onUploadProgress 原生支持,无需额外依赖
  • 轮询优化:翻译进度通过轮询获取,间隔2秒,避免频繁请求
  • 请求取消:AbortController 防止组件卸载后的内存泄漏
  • 文件大小限制:前端先校验,避免大文件上传到一半被后端拒绝

3. 安全考量

  • 文件名使用 secure_filename 处理后端安全
  • 上传文件大小前端+后端双重校验
  • API 请求使用 axios 默认的 CSRF 保护
  • 结果文件使用 task_id 作为唯一标识,防止路径遍历

总结

这套前端方案覆盖了PDF翻译工具从前端侧的完整流程:拖拽上传、语言选择、分阶段进度展示、结果下载、错误处理。核心代码约300行,可直接集成到现有 React 项目中。

相比传统"一个上传框+一个下载按钮"的简陋体验,本文实现的方案能让用户清晰感知翻译的每个阶段,大幅提升使用信心和满意度。

标签:React、TypeScript、PDF翻译、前端开发、文件上传

赞(0)
未经允许不得转载:171主机测评 » PDF翻译的前端完整方案:从拖拽上传到实时翻译进度条(React实战)
分享到: 更多 (0)

评论 抢沙发

  • 昵称 (必填)
  • 邮箱 (必填)
  • 网址