Files
AI_Translator/apps/web/components/TranslatorForm.tsx
2025-12-25 18:41:09 +08:00

221 lines
6.4 KiB
TypeScript

'use client'
import { useState, useRef } from 'react'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
const LANGUAGES = [
{ code: 'auto', name: '自动检测' },
{ code: 'zh', name: '中文' },
{ code: 'en', name: '英语' },
{ code: 'ja', name: '日语' },
{ code: 'ko', name: '韩语' },
{ code: 'fr', name: '法语' },
{ code: 'de', name: '德语' },
{ code: 'es', name: '西班牙语' },
]
const STYLES = [
{ value: 'literal', name: '直译' },
{ value: 'fluent', name: '意译' },
{ value: 'casual', name: '口语化' },
]
export default function TranslatorForm() {
const [sourceText, setSourceText] = useState('')
const [translation, setTranslation] = useState('')
const [sourceLang, setSourceLang] = useState('auto')
const [targetLang, setTargetLang] = useState('zh')
const [style, setStyle] = useState('literal')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const abortRef = useRef<AbortController | null>(null)
const handleTranslate = async () => {
if (!sourceText.trim()) return
if (abortRef.current) {
abortRef.current.abort()
}
abortRef.current = new AbortController()
setIsLoading(true)
setError('')
setTranslation('')
try {
const res = await fetch(`${API_BASE}/api/v1/translate/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_text: sourceText,
source_lang: sourceLang,
target_lang: targetLang,
style,
}),
signal: abortRef.current.signal,
})
if (!res.ok) {
throw new Error('翻译请求失败')
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (reader) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() ?? ''
for (const p of parts) {
const lines = p.split('\n')
const dataLine = lines.find(l => l.startsWith('data:'))
if (dataLine) {
const data = JSON.parse(dataLine.slice(5).trim())
if (data.delta) {
setTranslation(prev => prev + data.delta)
}
}
}
}
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message || '翻译失败')
}
} finally {
setIsLoading(false)
}
}
const handleStop = () => {
if (abortRef.current) {
abortRef.current.abort()
setIsLoading(false)
}
}
const handleCopy = () => {
navigator.clipboard.writeText(translation)
}
return (
<div className="max-w-4xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* 源语言选择 */}
<div>
<label className="block text-sm font-medium mb-1"></label>
<select
value={sourceLang}
onChange={(e) => setSourceLang(e.target.value)}
className="w-full p-2 border rounded"
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
</div>
{/* 目标语言选择 */}
<div>
<label className="block text-sm font-medium mb-1"></label>
<select
value={targetLang}
onChange={(e) => setTargetLang(e.target.value)}
className="w-full p-2 border rounded"
>
{LANGUAGES.filter((l) => l.code !== 'auto').map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
</div>
</div>
{/* 风格选择 */}
<div className="mb-4">
<label className="block text-sm font-medium mb-1"></label>
<div className="flex gap-4">
{STYLES.map((s) => (
<label key={s.value} className="flex items-center">
<input
type="radio"
name="style"
value={s.value}
checked={style === s.value}
onChange={(e) => setStyle(e.target.value)}
className="mr-1"
/>
{s.name}
</label>
))}
</div>
</div>
{/* 输入输出区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<textarea
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder="请输入要翻译的文本..."
className="w-full h-48 p-3 border rounded resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<div className="relative">
<textarea
value={translation}
readOnly
placeholder="翻译结果将显示在这里..."
className="w-full h-48 p-3 border rounded resize-none bg-gray-50"
/>
{translation && (
<button
onClick={handleCopy}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-white border rounded hover:bg-gray-100"
>
</button>
)}
</div>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-600 rounded">
{error}
</div>
)}
{/* 按钮 */}
<div className="flex gap-4">
<button
onClick={handleTranslate}
disabled={isLoading || !sourceText.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? '翻译中...' : '翻译'}
</button>
{isLoading && (
<button
onClick={handleStop}
className="px-6 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
</button>
)}
</div>
</div>
)
}