feta:优化流畅度

This commit is contained in:
2025-12-29 15:52:50 +08:00
parent abcbe3cddc
commit 67025a4865
20 changed files with 337 additions and 175 deletions

View File

@@ -546,7 +546,7 @@ key = hash(
后端 `.env` 后端 `.env`
- `APP_ENV=dev` - `APP_ENV=dev`
- `API_HOST=0.0.0.0` - `API_HOST=0.0.0.0`
- `API_PORT=8000` - `API_PORT=8030`
- `DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/app` - `DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/app`
- `REDIS_URL=redis://redis:6379/0` - `REDIS_URL=redis://redis:6379/0`
- `LLM_PROVIDER=openai|...` - `LLM_PROVIDER=openai|...`
@@ -557,7 +557,7 @@ key = hash(
- `RATE_LIMIT_PER_MINUTE=60` - `RATE_LIMIT_PER_MINUTE=60`
前端 `.env.local` 前端 `.env.local`
- `NEXT_PUBLIC_API_BASE_URL=http://localhost:8000` - `NEXT_PUBLIC_API_BASE_URL=http://localhost:8030`
--- ---

View File

@@ -14,7 +14,7 @@ AI 翻译网站 - Monorepo 架构,前端 Next.js + 后端 FastAPI支持流
cd apps/api cd apps/api
pip install -e . # 安装依赖 pip install -e . # 安装依赖
pip install -e ".[dev]" # 安装开发依赖 pip install -e ".[dev]" # 安装开发依赖
uvicorn app.main:app --reload # 启动开发服务器 (localhost:8000) uvicorn app.main:app --reload --port 8030 # 启动开发服务器 (localhost:8030)
python scripts/init_db.py [用户名] [密码] # 初始化数据库管理员账户 python scripts/init_db.py [用户名] [密码] # 初始化数据库管理员账户
# 测试与代码检查 # 测试与代码检查
@@ -28,7 +28,7 @@ ruff format . # 代码格式化
```bash ```bash
cd apps/web cd apps/web
npm install # 安装依赖 npm install # 安装依赖
npm run dev # 启动开发服务器 (localhost:3000) npm run dev # 启动开发服务器 (localhost:3030)
npm run build # 构建生产版本 npm run build # 构建生产版本
npm run lint # ESLint 检查 npm run lint # ESLint 检查
``` ```

View File

@@ -21,7 +21,7 @@ cd apps/api
pip install -e . pip install -e .
cp .env.example .env cp .env.example .env
# 编辑 .env 配置 LLM_API_KEY # 编辑 .env 配置 LLM_API_KEY
uvicorn app.main:app --reload uvicorn app.main:app --reload --port 8030
``` ```
2. 前端 2. 前端
@@ -48,7 +48,7 @@ cp .env.example .env
docker-compose up -d docker-compose up -d
``` ```
访问 http://localhost:3000 访问 http://localhost:3030
## 管理员后台 ## 管理员后台
@@ -62,8 +62,8 @@ python scripts/init_db.py [用户名] [密码]
### 访问后台 ### 访问后台
- 登录页: http://localhost:3000/login - 登录页: http://localhost:3030/login
- 后台首页: http://localhost:3000/admin - 后台首页: http://localhost:3030/admin
### 后台功能 ### 后台功能

Binary file not shown.

View File

@@ -1,7 +1,7 @@
# App # App
APP_ENV=dev APP_ENV=dev
API_HOST=0.0.0.0 API_HOST=0.0.0.0
API_PORT=8000 API_PORT=8030
DEBUG=true DEBUG=true
# Database # Database

View File

@@ -7,6 +7,6 @@ RUN pip install --no-cache-dir .
COPY app ./app COPY app ./app
EXPOSE 8000 EXPOSE 8030
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8030"]

View File

@@ -6,7 +6,7 @@ class Settings(BaseSettings):
# App # App
app_env: str = "dev" app_env: str = "dev"
api_host: str = "0.0.0.0" api_host: str = "0.0.0.0"
api_port: int = 8000 api_port: int = 8030
debug: bool = True debug: bool = True
# Database # Database

View File

@@ -1,2 +1,2 @@
# API Base URL # API Base URL
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 NEXT_PUBLIC_API_BASE_URL=http://localhost:8030

View File

@@ -16,6 +16,7 @@ COPY --from=builder /app/.next-prod/standalone ./
COPY --from=builder /app/.next-prod/static ./.next/static COPY --from=builder /app/.next-prod/static ./.next/static
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
EXPOSE 3000 ENV PORT=3030
EXPOSE 3030
CMD ["node", "server.js"] CMD ["node", "server.js"]

View File

@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { ADMIN_UNAUTHORIZED_EVENT } from '@/lib/admin-api' import { ADMIN_UNAUTHORIZED_EVENT } from '@/lib/admin-api'
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'
export default function AdminLayout({ export default function AdminLayout({
children, children,

View File

@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card' import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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'
export default function LoginPage() { export default function LoginPage() {
const [username, setUsername] = useState('') const [username, setUsername] = useState('')

View File

@@ -1,6 +1,7 @@
'use client' 'use client'
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { ChevronDown, Search, X } from 'lucide-react' import { ChevronDown, Search, X } from 'lucide-react'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
@@ -51,6 +52,8 @@ export default function LanguageSelect({
return allOptions.find((o) => o.code === value) || null return allOptions.find((o) => o.code === value) || null
}, [allOptions, value]) }, [allOptions, value])
const languageCount = options.length
const filtered = useMemo(() => { const filtered = useMemo(() => {
const q = query.trim().toLowerCase() const q = query.trim().toLowerCase()
if (!q) return allOptions if (!q) return allOptions
@@ -85,7 +88,9 @@ export default function LanguageSelect({
disabled={disabled} disabled={disabled}
onClick={() => setOpen(true)} onClick={() => setOpen(true)}
className={cn( className={cn(
'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-colors', 'flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background select-none will-change-transform',
'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out hover:shadow-sm active:translate-y-px active:scale-[0.98]',
'motion-reduce:transform-none motion-reduce:transition-none',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:cursor-not-allowed disabled:opacity-50', 'disabled:cursor-not-allowed disabled:opacity-50',
triggerClassName triggerClassName
@@ -97,103 +102,111 @@ export default function LanguageSelect({
<ChevronDown className="h-4 w-4 opacity-50" /> <ChevronDown className="h-4 w-4 opacity-50" />
</button> </button>
{open && ( {open &&
<div typeof document !== 'undefined' &&
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4" createPortal(
onMouseDown={(e) => { <div
if (e.target === e.currentTarget) setOpen(false) className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4 animate-in fade-in-0 duration-150 motion-reduce:animate-none"
}} onMouseDown={(e) => {
> if (e.target === e.currentTarget) setOpen(false)
<Card className="w-full max-w-3xl border-muted shadow-lg"> }}
<CardHeader className="space-y-3"> >
<div className="flex items-start justify-between gap-4"> <Card className="w-full max-w-3xl border-muted shadow-lg will-change-transform animate-in fade-in-0 zoom-in-95 duration-200 motion-reduce:animate-none">
<div> <CardHeader className="space-y-3">
<CardTitle></CardTitle> <div className="flex items-start justify-between gap-4">
<CardDescription> 200+ </CardDescription> <div>
<CardTitle></CardTitle>
<CardDescription>
{languageCount} {allowAuto ? ' + 自动检测' : ''}
</CardDescription>
</div> </div>
<Button variant="ghost" size="icon" onClick={() => setOpen(false)}> <Button variant="ghost" size="icon" onClick={() => setOpen(false)}>
<X className="h-4 w-4" /> <X className="h-4 w-4" />
</Button> </Button>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<div className="relative flex-1"> <div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" /> <Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input <Input
ref={inputRef} ref={inputRef}
value={query} value={query}
onChange={(e) => setQuery(e.target.value)} onChange={(e) => setQuery(e.target.value)}
placeholder={searchPlaceholder} placeholder={searchPlaceholder}
className="pl-9" className="pl-9"
/> />
</div>
{query && (
<Button variant="outline" onClick={() => setQuery('')}>
</Button>
)}
</div> </div>
{query && (
<Button variant="outline" onClick={() => setQuery('')}>
</Button>
)}
</div>
{!query.trim() && commonOptions && commonOptions.length > 0 && ( {!query.trim() && commonOptions && commonOptions.length > 0 && (
<div className="flex flex-wrap gap-2"> <div className="flex flex-wrap gap-2">
{commonOptions.map((o) => ( {commonOptions.map((o) => (
<button
key={o.code}
type="button"
onClick={() => handleSelect(o.code)}
className={cn(
'rounded-full border px-3 py-1 text-sm transition-colors',
o.code === value
? 'bg-primary text-primary-foreground border-transparent'
: 'hover:bg-accent hover:text-accent-foreground'
)}
>
{o.name}
</button>
))}
</div>
)}
</CardHeader>
<CardContent>
<div className="rounded-md border bg-background max-h-[60vh] overflow-y-auto">
{filtered.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground"></div>
) : (
<div className="divide-y">
{filtered.map((o) => (
<button <button
key={o.code} key={o.code}
type="button" type="button"
onClick={() => handleSelect(o.code)} onClick={() => handleSelect(o.code)}
className={cn( className={cn(
'w-full text-left px-4 py-3 hover:bg-accent transition-colors', 'rounded-full border px-3 py-1 text-sm select-none will-change-transform',
'flex items-center justify-between gap-4' 'transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out active:scale-[0.98]',
'motion-reduce:transform-none motion-reduce:transition-none',
o.code === value
? 'bg-primary text-primary-foreground border-transparent'
: 'hover:bg-accent hover:text-accent-foreground'
)} )}
> >
<div className="min-w-0"> {o.name}
<div className="font-medium truncate">{o.name}</div>
<div className="text-xs text-muted-foreground font-mono">{o.code}</div>
</div>
<div className="shrink-0">
{o.code === value ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</div>
</button> </button>
))} ))}
</div> </div>
)} )}
</div> </CardHeader>
</CardContent>
</Card> <CardContent>
</div> <div className="rounded-md border bg-background max-h-[60vh] overflow-y-auto">
)} {filtered.length === 0 ? (
<div className="p-6 text-center text-sm text-muted-foreground">
</div>
) : (
<div className="divide-y">
{filtered.map((o) => (
<button
key={o.code}
type="button"
onClick={() => handleSelect(o.code)}
className={cn(
'w-full text-left px-4 py-3 hover:bg-accent active:bg-accent/80 transition-colors',
'flex items-center justify-between gap-4'
)}
>
<div className="min-w-0">
<div className="font-medium truncate">{o.name}</div>
<div className="text-xs text-muted-foreground font-mono">{o.code}</div>
</div>
<div className="shrink-0">
{o.code === value ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</div>
</button>
))}
</div>
)}
</div>
</CardContent>
</Card>
</div>,
document.body
)}
</> </>
) )
} }

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import { useState, useRef } from 'react' import { startTransition, useEffect, useRef, useState } from 'react'
import { ArrowRightLeft, Copy, Sparkles, StopCircle, Check } from './icons' import { ArrowRightLeft, Copy, Sparkles, StopCircle, Check } from './icons'
import { Button } from './ui/button' import { Button } from './ui/button'
import { Textarea } from './ui/textarea' import { Textarea } from './ui/textarea'
@@ -14,7 +14,7 @@ import {
LANGUAGE_OPTIONS, LANGUAGE_OPTIONS,
} from '@/lib/languages' } 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 = [ const STYLES = [
{ value: 'literal', name: '直译', description: '保留原文结构' }, { value: 'literal', name: '直译', description: '保留原文结构' },
@@ -22,6 +22,14 @@ const STYLES = [
{ value: 'casual', 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() { export default function TranslatorForm() {
const [sourceText, setSourceText] = useState('') const [sourceText, setSourceText] = useState('')
const [translation, setTranslation] = useState('') const [translation, setTranslation] = useState('')
@@ -31,11 +39,56 @@ export default function TranslatorForm() {
const [isLoading, setIsLoading] = useState(false) const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('') const [error, setError] = useState('')
const [copied, setCopied] = useState(false) 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 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 () => { const handleTranslate = async () => {
if (!sourceText.trim()) return if (!sourceText.trim()) return
const runId = ++runIdRef.current
let outcome: 'success' | 'aborted' | 'error' = 'success'
if (abortRef.current) { if (abortRef.current) {
abortRef.current.abort() abortRef.current.abort()
} }
@@ -44,6 +97,17 @@ export default function TranslatorForm() {
setIsLoading(true) setIsLoading(true)
setError('') setError('')
setTranslation('') 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 { try {
const res = await fetch(`${API_BASE}/api/v1/translate/stream`, { const res = await fetch(`${API_BASE}/api/v1/translate/stream`, {
@@ -68,6 +132,16 @@ export default function TranslatorForm() {
const decoder = new TextDecoder() const decoder = new TextDecoder()
let buffer = '' 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) { while (reader) {
const { value, done } = await reader.read() const { value, done } = await reader.read()
if (done) break if (done) break
@@ -82,16 +156,42 @@ export default function TranslatorForm() {
if (dataLine) { if (dataLine) {
const data = JSON.parse(dataLine.slice(5).trim()) const data = JSON.parse(dataLine.slice(5).trim())
if (data.delta) { 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) { } catch (err: any) {
if (err.name !== 'AbortError') { if (err.name === 'AbortError') {
setError(err.message || '翻译失败') outcome = 'aborted'
} else {
outcome = 'error'
if (runIdRef.current === runId) setError(err.message || '翻译失败')
} }
} finally { } 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) 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 = () => { const handleCopy = () => {
navigator.clipboard.writeText(translation) navigator.clipboard.writeText(translation)
setCopied(true) setCopied(true)
@@ -118,82 +239,110 @@ export default function TranslatorForm() {
} }
return ( 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 */} {/* Controls Bar */}
<Card className="p-4 bg-background/50 backdrop-blur-sm border-muted"> <Card className="p-4 bg-background/50 backdrop-blur-sm border-muted hover:shadow-md">
<div className="flex flex-col md:flex-row gap-4 justify-between items-center"> <div className="space-y-3">
<div className="flex items-center gap-2 w-full md:w-auto"> <div className="flex flex-col md:flex-row gap-4 justify-between items-center">
<div className="w-[140px]"> <div className="flex items-center gap-2 w-full md:w-auto">
<LanguageSelect <div className="w-[140px]">
value={sourceLang} <LanguageSelect
onValueChange={setSourceLang} value={sourceLang}
options={LANGUAGE_OPTIONS} onValueChange={setSourceLang}
commonOptions={[AUTO_LANGUAGE, ...COMMON_LANGUAGE_OPTIONS]} options={LANGUAGE_OPTIONS}
placeholder="源语言" commonOptions={[AUTO_LANGUAGE, ...COMMON_LANGUAGE_OPTIONS]}
allowAuto placeholder="源语言"
autoOption={AUTO_LANGUAGE} 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>
<Button <div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-2 md:pb-0">
variant="ghost" {STYLES.map((s) => (
size="icon" <Button
onClick={handleSwapLanguages} key={s.value}
className="shrink-0" variant={style === s.value ? "secondary" : "ghost"}
disabled={sourceLang === 'auto'} size="sm"
> onClick={() => setStyle(s.value)}
<ArrowRightLeft className="h-4 w-4" /> className="whitespace-nowrap"
</Button> >
{s.name}
</Button>
))}
</div>
<div className="w-[140px]"> <div className="flex items-center gap-2 ml-auto">
<LanguageSelect <Button variant="outline" size="sm" onClick={handleClear} disabled={!canClear}>
value={targetLang}
onValueChange={setTargetLang} </Button>
options={LANGUAGE_OPTIONS} {isLoading ? (
commonOptions={COMMON_LANGUAGE_OPTIONS} <Button onClick={handleStop} variant="destructive" size="sm">
placeholder="目标语言" <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> </div>
<div className="flex items-center gap-2 w-full md:w-auto overflow-x-auto pb-2 md:pb-0"> {(isLoading || lastOutcome != null) && (
{STYLES.map((s) => ( <div className="w-full space-y-1.5">
<Button <div className="flex items-center justify-between text-xs text-muted-foreground">
key={s.value} <span>
variant={style === s.value ? "secondary" : "ghost"} {isLoading
size="sm" ? '翻译中…'
onClick={() => setStyle(s.value)} : lastOutcome === 'aborted'
className="whitespace-nowrap" ? '已停止'
> : lastOutcome === 'error'
{s.name} ? '翻译失败'
</Button> : '翻译完成'}
))} </span>
</div> <span>{formatDuration(isLoading ? elapsedMs : (lastDurationMs ?? 0))}</span>
</div>
<div className="flex items-center gap-2 ml-auto"> <div className="h-2 w-full overflow-hidden rounded-full bg-muted">
{isLoading ? ( <div
<Button onClick={handleStop} variant="destructive" size="sm"> className="h-full bg-primary transition-all duration-300"
<StopCircle className="mr-2 h-4 w-4" /> style={{ width: `${isLoading ? progress : lastOutcome === 'success' ? 100 : progress}%` }}
/>
</Button> </div>
) : ( </div>
<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>
</Card> </Card>
{/* Main Input/Output Area */} {/* Main Input/Output Area */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-[500px]"> <div className="grid grid-cols-1 md:grid-cols-2 gap-4 h-[500px]">
{/* Source Text */} {/* 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 <Textarea
value={sourceText} value={sourceText}
onChange={(e) => setSourceText(e.target.value)} onChange={(e) => setSourceText(e.target.value)}
@@ -206,7 +355,7 @@ export default function TranslatorForm() {
</Card> </Card>
{/* Translation Result */} {/* 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 <Textarea
value={translation} value={translation}
readOnly readOnly

View File

@@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "../../lib/utils" import { cn } from "../../lib/utils"
const buttonVariants = cva( const buttonVariants = cva(
"inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50", "inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium ring-offset-background select-none will-change-transform transition-[color,background-color,border-color,box-shadow,transform] duration-150 ease-out active:translate-y-px active:scale-[0.98] motion-reduce:transform-none motion-reduce:transition-none focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{ {
variants: { variants: {
variant: { variant: {

View File

@@ -9,7 +9,7 @@ const Card = React.forwardRef<
<div <div
ref={ref} ref={ref}
className={cn( className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm", "rounded-lg border bg-card text-card-foreground shadow-sm transition-shadow duration-200 ease-out motion-reduce:transition-none",
className className
)} )}
{...props} {...props}

View File

@@ -11,7 +11,7 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
<input <input
type={type} type={type}
className={cn( className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-[box-shadow,border-color] duration-150 ease-out motion-reduce:transition-none file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}
@@ -23,4 +23,3 @@ const Input = React.forwardRef<HTMLInputElement, InputProps>(
Input.displayName = "Input" Input.displayName = "Input"
export { Input } export { Input }

View File

@@ -10,7 +10,7 @@ const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
return ( return (
<textarea <textarea
className={cn( className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background transition-[box-shadow,border-color] duration-150 ease-out motion-reduce:transition-none placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
ref={ref} ref={ref}

View File

@@ -1,4 +1,4 @@
export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000' export const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8030'
export const ADMIN_UNAUTHORIZED_EVENT = 'admin:unauthorized' export const ADMIN_UNAUTHORIZED_EVENT = 'admin:unauthorized'

View File

@@ -4,9 +4,9 @@
"private": true, "private": true,
"scripts": { "scripts": {
"clean:next": "node -e \"const fs=require('fs'); for (const d of ['.next','.next-dev','.next-prod']) fs.rmSync(d,{recursive:true,force:true});\"", "clean:next": "node -e \"const fs=require('fs'); for (const d of ['.next','.next-dev','.next-prod']) fs.rmSync(d,{recursive:true,force:true});\"",
"dev": "next dev", "dev": "next dev -p 3030",
"build": "next build", "build": "next build",
"start": "next start", "start": "next start -p 3030",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {

View File

@@ -6,9 +6,9 @@ services:
context: ../apps/web context: ../apps/web
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "3000:3000" - "3030:3030"
environment: environment:
- NEXT_PUBLIC_API_BASE_URL=http://localhost:8000 - NEXT_PUBLIC_API_BASE_URL=http://localhost:8030
depends_on: depends_on:
- api - api
@@ -17,7 +17,7 @@ services:
context: ../apps/api context: ../apps/api
dockerfile: Dockerfile dockerfile: Dockerfile
ports: ports:
- "8000:8000" - "8030:8030"
environment: environment:
- APP_ENV=dev - APP_ENV=dev
- DATABASE_URL=mysql+aiomysql://root:root@db:3306/translator - DATABASE_URL=mysql+aiomysql://root:root@db:3306/translator