Files
AI_Translator/apps/web/components/TranslatorForm.tsx
2025-12-29 15:52:50 +08:00

389 lines
13 KiB
TypeScript

'use client'
import { startTransition, useEffect, useRef, useState } from 'react'
import { ArrowRightLeft, Copy, Sparkles, StopCircle, Check } from './icons'
import { Button } from './ui/button'
import { Textarea } from './ui/textarea'
import { Card } from './ui/card'
import { cn } from '../lib/utils'
import LanguageSelect from './LanguageSelect'
import {
AUTO_LANGUAGE,
COMMON_LANGUAGE_OPTIONS,
getLanguageName,
LANGUAGE_OPTIONS,
} from '@/lib/languages'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8030'
const STYLES = [
{ value: 'literal', name: '直译', description: '保留原文结构' },
{ value: 'fluent', name: '意译', description: '自然流畅' },
{ value: 'casual', name: '口语', description: '轻松对话' },
]
function formatDuration(ms: number): string {
const totalSeconds = Math.max(0, ms) / 1000
if (totalSeconds < 60) return `${totalSeconds.toFixed(1)}s`
const minutes = Math.floor(totalSeconds / 60)
const seconds = Math.floor(totalSeconds % 60)
return `${minutes}m ${seconds}s`
}
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 [copied, setCopied] = useState(false)
const [progress, setProgress] = useState(0)
const [elapsedMs, setElapsedMs] = useState(0)
const [lastDurationMs, setLastDurationMs] = useState<number | null>(null)
const [lastOutcome, setLastOutcome] = useState<'success' | 'aborted' | 'error' | null>(null)
const abortRef = useRef<AbortController | null>(null)
const startTimeRef = useRef<number | null>(null)
const progressTimerRef = useRef<number | null>(null)
const runIdRef = useRef(0)
const translationBufferRef = useRef('')
const flushRafRef = useRef<number | null>(null)
const canClear = Boolean(sourceText || translation || error || lastOutcome || isLoading)
useEffect(() => {
return () => {
abortRef.current?.abort()
if (flushRafRef.current != null) {
window.cancelAnimationFrame(flushRafRef.current)
flushRafRef.current = null
}
}
}, [])
useEffect(() => {
if (!isLoading) return
const start = startTimeRef.current ?? performance.now()
progressTimerRef.current = window.setInterval(() => {
setElapsedMs(performance.now() - start)
setProgress((prev) => {
if (prev >= 90) return prev
const base = prev < 20 ? 7 : prev < 50 ? 3 : 1
const jitter = Math.random() * 1.5
return Math.min(90, prev + base + jitter)
})
}, 200)
return () => {
if (progressTimerRef.current) window.clearInterval(progressTimerRef.current)
progressTimerRef.current = null
}
}, [isLoading])
const handleTranslate = async () => {
if (!sourceText.trim()) return
const runId = ++runIdRef.current
let outcome: 'success' | 'aborted' | 'error' = 'success'
if (abortRef.current) {
abortRef.current.abort()
}
abortRef.current = new AbortController()
setIsLoading(true)
setError('')
setTranslation('')
setCopied(false)
setLastDurationMs(null)
setLastOutcome(null)
setProgress(5)
setElapsedMs(0)
startTimeRef.current = performance.now()
translationBufferRef.current = ''
if (flushRafRef.current != null) {
window.cancelAnimationFrame(flushRafRef.current)
flushRafRef.current = null
}
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,
source_lang_name: getLanguageName(sourceLang),
target_lang: targetLang,
target_lang_name: getLanguageName(targetLang),
style,
}),
signal: abortRef.current.signal,
})
if (!res.ok) {
throw new Error('翻译请求失败')
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ''
const scheduleFlush = () => {
if (flushRafRef.current != null) return
flushRafRef.current = window.requestAnimationFrame(() => {
flushRafRef.current = null
if (runIdRef.current !== runId) return
const next = translationBufferRef.current
startTransition(() => setTranslation(next))
})
}
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) {
translationBufferRef.current += data.delta
scheduleFlush()
}
}
}
}
if (runIdRef.current === runId) {
if (flushRafRef.current != null) {
window.cancelAnimationFrame(flushRafRef.current)
flushRafRef.current = null
}
const next = translationBufferRef.current
startTransition(() => setTranslation(next))
}
if (runIdRef.current === runId) setProgress(100)
} catch (err: any) {
if (err.name === 'AbortError') {
outcome = 'aborted'
} else {
outcome = 'error'
if (runIdRef.current === runId) setError(err.message || '翻译失败')
}
} finally {
if (runIdRef.current !== runId) return
const startedAt = startTimeRef.current
if (startedAt != null) {
const duration = performance.now() - startedAt
setElapsedMs(duration)
setLastDurationMs(duration)
}
setLastOutcome(outcome)
startTimeRef.current = null
abortRef.current = null
setIsLoading(false)
}
}
const handleStop = () => {
if (abortRef.current) {
abortRef.current.abort()
setIsLoading(false)
}
}
const handleClear = () => {
runIdRef.current += 1
abortRef.current?.abort()
abortRef.current = null
startTimeRef.current = null
if (flushRafRef.current != null) {
window.cancelAnimationFrame(flushRafRef.current)
flushRafRef.current = null
}
translationBufferRef.current = ''
setIsLoading(false)
setSourceText('')
setTranslation('')
setError('')
setCopied(false)
setProgress(0)
setElapsedMs(0)
setLastDurationMs(null)
setLastOutcome(null)
}
const handleCopy = () => {
navigator.clipboard.writeText(translation)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
const handleSwapLanguages = () => {
if (sourceLang === 'auto') return
setSourceLang(targetLang)
setTargetLang(sourceLang)
setSourceText(translation)
setTranslation(sourceText)
}
return (
<div className="w-full max-w-5xl mx-auto space-y-6 animate-in fade-in-0 slide-in-from-bottom-2 duration-500 motion-reduce:animate-none">
{/* Controls Bar */}
<Card className="p-4 bg-background/50 backdrop-blur-sm border-muted hover:shadow-md">
<div className="space-y-3">
<div className="flex flex-col md:flex-row gap-4 justify-between items-center">
<div className="flex items-center gap-2 w-full md:w-auto">
<div className="w-[140px]">
<LanguageSelect
value={sourceLang}
onValueChange={setSourceLang}
options={LANGUAGE_OPTIONS}
commonOptions={[AUTO_LANGUAGE, ...COMMON_LANGUAGE_OPTIONS]}
placeholder="源语言"
allowAuto
autoOption={AUTO_LANGUAGE}
/>
</div>
<Button
variant="ghost"
size="icon"
onClick={handleSwapLanguages}
className="shrink-0"
disabled={sourceLang === 'auto'}
>
<ArrowRightLeft className="h-4 w-4" />
</Button>
<div className="w-[140px]">
<LanguageSelect
value={targetLang}
onValueChange={setTargetLang}
options={LANGUAGE_OPTIONS}
commonOptions={COMMON_LANGUAGE_OPTIONS}
placeholder="目标语言"
/>
</div>
</div>
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-2 md:pb-0">
{STYLES.map((s) => (
<Button
key={s.value}
variant={style === s.value ? "secondary" : "ghost"}
size="sm"
onClick={() => setStyle(s.value)}
className="whitespace-nowrap"
>
{s.name}
</Button>
))}
</div>
<div className="flex items-center gap-2 ml-auto">
<Button variant="outline" size="sm" onClick={handleClear} disabled={!canClear}>
</Button>
{isLoading ? (
<Button onClick={handleStop} variant="destructive" size="sm">
<StopCircle className="mr-2 h-4 w-4" />
</Button>
) : (
<Button
onClick={handleTranslate}
disabled={!sourceText.trim()}
className="bg-primary hover:bg-primary/90 text-white shadow-lg shadow-primary/20"
>
<Sparkles className="mr-2 h-4 w-4" />
</Button>
)}
</div>
</div>
{(isLoading || lastOutcome != null) && (
<div className="w-full space-y-1.5">
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
{isLoading
? '翻译中…'
: lastOutcome === 'aborted'
? '已停止'
: lastOutcome === 'error'
? '翻译失败'
: '翻译完成'}
</span>
<span>{formatDuration(isLoading ? elapsedMs : (lastDurationMs ?? 0))}</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-muted">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${isLoading ? progress : lastOutcome === 'success' ? 100 : progress}%` }}
/>
</div>
</div>
)}
</div>
</Card>
{/* Main Input/Output Area */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-[500px]">
{/* Source Text */}
<Card className="relative p-0 overflow-hidden border-muted hover:shadow-md focus-within:ring-2 focus-within:ring-primary/20 transition-shadow">
<Textarea
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder="在此输入要翻译的文本..."
className="w-full h-full min-h-[400px] p-6 text-lg border-0 focus-visible:ring-0 resize-none bg-transparent"
/>
<div className="absolute bottom-4 right-4 text-xs text-muted-foreground">
{sourceText.length}
</div>
</Card>
{/* Translation Result */}
<Card className="relative p-0 overflow-hidden border-muted bg-muted/30 hover:shadow-md focus-within:ring-2 focus-within:ring-primary/10 transition-shadow">
<Textarea
value={translation}
readOnly
placeholder="翻译结果..."
className="w-full h-full min-h-[400px] p-6 text-lg border-0 focus-visible:ring-0 resize-none bg-transparent cursor-default"
/>
{translation && (
<div className="absolute bottom-4 right-4 flex gap-2">
<Button
variant="secondary"
size="icon"
className="h-8 w-8 shadow-sm"
onClick={handleCopy}
>
{copied ? <Check className="h-4 w-4 text-green-500" /> : <Copy className="h-4 w-4" />}
</Button>
</div>
)}
{error && (
<div className="absolute bottom-4 left-4 right-14 p-2 bg-red-100 dark:bg-red-900/30 text-red-600 text-sm rounded border border-red-200 dark:border-red-900">
{error}
</div>
)}
</Card>
</div>
</div>
)
}