feat:初版
This commit is contained in:
221
apps/web/components/TranslatorForm.tsx
Normal file
221
apps/web/components/TranslatorForm.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user