feta:优化流畅度
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useRef } from 'react'
|
||||
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'
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
LANGUAGE_OPTIONS,
|
||||
} from '@/lib/languages'
|
||||
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
|
||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8030'
|
||||
|
||||
const STYLES = [
|
||||
{ value: 'literal', name: '直译', description: '保留原文结构' },
|
||||
@@ -22,6 +22,14 @@ const STYLES = [
|
||||
{ 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('')
|
||||
@@ -31,11 +39,56 @@ export default function TranslatorForm() {
|
||||
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()
|
||||
}
|
||||
@@ -44,6 +97,17 @@ export default function TranslatorForm() {
|
||||
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`, {
|
||||
@@ -68,6 +132,16 @@ export default function TranslatorForm() {
|
||||
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
|
||||
@@ -82,16 +156,42 @@ export default function TranslatorForm() {
|
||||
if (dataLine) {
|
||||
const data = JSON.parse(dataLine.slice(5).trim())
|
||||
if (data.delta) {
|
||||
setTranslation(prev => prev + 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') {
|
||||
setError(err.message || '翻译失败')
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -103,6 +203,27 @@ export default function TranslatorForm() {
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
@@ -118,82 +239,110 @@ export default function TranslatorForm() {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="w-full max-w-5xl mx-auto space-y-6">
|
||||
<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">
|
||||
<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}
|
||||
/>
|
||||
<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>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleSwapLanguages}
|
||||
className="shrink-0"
|
||||
disabled={sourceLang === 'auto'}
|
||||
>
|
||||
<ArrowRightLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<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="w-[140px]">
|
||||
<LanguageSelect
|
||||
value={targetLang}
|
||||
onValueChange={setTargetLang}
|
||||
options={LANGUAGE_OPTIONS}
|
||||
commonOptions={COMMON_LANGUAGE_OPTIONS}
|
||||
placeholder="目标语言"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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">
|
||||
{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>
|
||||
{(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 focus-within:ring-2 focus-within:ring-primary/20 transition-all">
|
||||
<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)}
|
||||
@@ -206,7 +355,7 @@ export default function TranslatorForm() {
|
||||
</Card>
|
||||
|
||||
{/* Translation Result */}
|
||||
<Card className="relative p-0 overflow-hidden border-muted bg-muted/30">
|
||||
<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
|
||||
|
||||
Reference in New Issue
Block a user