'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(null) const [lastOutcome, setLastOutcome] = useState<'success' | 'aborted' | 'error' | null>(null) const abortRef = useRef(null) const startTimeRef = useRef(null) const progressTimerRef = useRef(null) const runIdRef = useRef(0) const translationBufferRef = useRef('') const flushRafRef = useRef(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 (
{/* Controls Bar */}
{STYLES.map((s) => ( ))}
{isLoading ? ( ) : ( )}
{(isLoading || lastOutcome != null) && (
{isLoading ? '翻译中…' : lastOutcome === 'aborted' ? '已停止' : lastOutcome === 'error' ? '翻译失败' : '翻译完成'} {formatDuration(isLoading ? elapsedMs : (lastDurationMs ?? 0))}
)}
{/* Main Input/Output Area */}
{/* Source Text */}