feta:优化流畅度
This commit is contained in:
@@ -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`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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 检查
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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.
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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('')
|
||||||
|
|||||||
@@ -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
|
||||||
|
)}
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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: {
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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'
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user