diff --git a/AI_Translator_Website_Dev_Doc.md b/AI_Translator_Website_Dev_Doc.md
index 3cf2353..3840f4c 100644
--- a/AI_Translator_Website_Dev_Doc.md
+++ b/AI_Translator_Website_Dev_Doc.md
@@ -546,7 +546,7 @@ key = hash(
后端 `.env`:
- `APP_ENV=dev`
- `API_HOST=0.0.0.0`
-- `API_PORT=8000`
+- `API_PORT=8030`
- `DATABASE_URL=postgresql+asyncpg://user:pass@db:5432/app`
- `REDIS_URL=redis://redis:6379/0`
- `LLM_PROVIDER=openai|...`
@@ -557,7 +557,7 @@ key = hash(
- `RATE_LIMIT_PER_MINUTE=60`
前端 `.env.local`:
-- `NEXT_PUBLIC_API_BASE_URL=http://localhost:8000`
+- `NEXT_PUBLIC_API_BASE_URL=http://localhost:8030`
---
diff --git a/CLAUDE.md b/CLAUDE.md
index 20a690a..e666214 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -14,7 +14,7 @@ AI 翻译网站 - Monorepo 架构,前端 Next.js + 后端 FastAPI,支持流
cd apps/api
pip install -e . # 安装依赖
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 [用户名] [密码] # 初始化数据库管理员账户
# 测试与代码检查
@@ -28,7 +28,7 @@ ruff format . # 代码格式化
```bash
cd apps/web
npm install # 安装依赖
-npm run dev # 启动开发服务器 (localhost:3000)
+npm run dev # 启动开发服务器 (localhost:3030)
npm run build # 构建生产版本
npm run lint # ESLint 检查
```
diff --git a/README.md b/README.md
index d9dfe12..b8da1a3 100644
--- a/README.md
+++ b/README.md
@@ -21,7 +21,7 @@ cd apps/api
pip install -e .
cp .env.example .env
# 编辑 .env 配置 LLM_API_KEY
-uvicorn app.main:app --reload
+uvicorn app.main:app --reload --port 8030
```
2. 前端
@@ -48,7 +48,7 @@ cp .env.example .env
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:3000/admin
+- 登录页: http://localhost:3030/login
+- 后台首页: http://localhost:3030/admin
### 后台功能
diff --git a/Redis-x64-5.0.14.1/dump.rdb b/Redis-x64-5.0.14.1/dump.rdb
index fea444d..c81d293 100644
Binary files a/Redis-x64-5.0.14.1/dump.rdb and b/Redis-x64-5.0.14.1/dump.rdb differ
diff --git a/apps/api/.env.example b/apps/api/.env.example
index 1bf8723..449c0fc 100644
--- a/apps/api/.env.example
+++ b/apps/api/.env.example
@@ -1,7 +1,7 @@
# App
APP_ENV=dev
API_HOST=0.0.0.0
-API_PORT=8000
+API_PORT=8030
DEBUG=true
# Database
diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile
index 5336793..f5a0403 100644
--- a/apps/api/Dockerfile
+++ b/apps/api/Dockerfile
@@ -7,6 +7,6 @@ RUN pip install --no-cache-dir .
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"]
diff --git a/apps/api/app/core/config.py b/apps/api/app/core/config.py
index 2406a87..d51e564 100644
--- a/apps/api/app/core/config.py
+++ b/apps/api/app/core/config.py
@@ -6,7 +6,7 @@ class Settings(BaseSettings):
# App
app_env: str = "dev"
api_host: str = "0.0.0.0"
- api_port: int = 8000
+ api_port: int = 8030
debug: bool = True
# Database
diff --git a/apps/web/.env.example b/apps/web/.env.example
index 0e9268b..7f2ac14 100644
--- a/apps/web/.env.example
+++ b/apps/web/.env.example
@@ -1,2 +1,2 @@
# API Base URL
-NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
+NEXT_PUBLIC_API_BASE_URL=http://localhost:8030
diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile
index 7de06ac..921cde5 100644
--- a/apps/web/Dockerfile
+++ b/apps/web/Dockerfile
@@ -16,6 +16,7 @@ COPY --from=builder /app/.next-prod/standalone ./
COPY --from=builder /app/.next-prod/static ./.next/static
COPY --from=builder /app/public ./public
-EXPOSE 3000
+ENV PORT=3030
+EXPOSE 3030
CMD ["node", "server.js"]
diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx
index 003c141..dab22fe 100644
--- a/apps/web/app/admin/layout.tsx
+++ b/apps/web/app/admin/layout.tsx
@@ -9,7 +9,7 @@ import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
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({
children,
diff --git a/apps/web/app/login/page.tsx b/apps/web/app/login/page.tsx
index 2efbae5..02b9d0e 100644
--- a/apps/web/app/login/page.tsx
+++ b/apps/web/app/login/page.tsx
@@ -6,7 +6,7 @@ import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
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() {
const [username, setUsername] = useState('')
diff --git a/apps/web/components/LanguageSelect.tsx b/apps/web/components/LanguageSelect.tsx
index 6eb15e6..ce6c3b3 100644
--- a/apps/web/components/LanguageSelect.tsx
+++ b/apps/web/components/LanguageSelect.tsx
@@ -1,6 +1,7 @@
'use client'
import { useEffect, useMemo, useRef, useState } from 'react'
+import { createPortal } from 'react-dom'
import { ChevronDown, Search, X } from 'lucide-react'
import { cn } from '@/lib/utils'
@@ -51,6 +52,8 @@ export default function LanguageSelect({
return allOptions.find((o) => o.code === value) || null
}, [allOptions, value])
+ const languageCount = options.length
+
const filtered = useMemo(() => {
const q = query.trim().toLowerCase()
if (!q) return allOptions
@@ -85,7 +88,9 @@ export default function LanguageSelect({
disabled={disabled}
onClick={() => setOpen(true)}
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',
'disabled:cursor-not-allowed disabled:opacity-50',
triggerClassName
@@ -97,103 +102,111 @@ export default function LanguageSelect({
- {open && (
-
{
- if (e.target === e.currentTarget) setOpen(false)
- }}
- >
-
-
-
-
-
选择语言
-
支持 200+ 语言,支持搜索
+ {open &&
+ typeof document !== 'undefined' &&
+ createPortal(
+
{
+ if (e.target === e.currentTarget) setOpen(false)
+ }}
+ >
+
+
+
+
+ 选择语言
+
+ 支持 {languageCount} 种语言{allowAuto ? ' + 自动检测' : ''},支持搜索
+
-
-
-
-
setQuery(e.target.value)}
- placeholder={searchPlaceholder}
- className="pl-9"
- />
+
+
+
+ setQuery(e.target.value)}
+ placeholder={searchPlaceholder}
+ className="pl-9"
+ />
+
+ {query && (
+
+ )}
- {query && (
-
- )}
-
- {!query.trim() && commonOptions && commonOptions.length > 0 && (
-
- {commonOptions.map((o) => (
-
- ))}
-
- )}
-
-
-
-
- {filtered.length === 0 ? (
-
无匹配语言
- ) : (
-
- {filtered.map((o) => (
+ {!query.trim() && commonOptions && commonOptions.length > 0 && (
+
+ {commonOptions.map((o) => (
))}
)}
-
-
-
-
- )}
+
+
+
+
+ {filtered.length === 0 ? (
+
+ 无匹配语言
+
+ ) : (
+
+ {filtered.map((o) => (
+
+ ))}
+
+ )}
+
+
+
+ ,
+ document.body
+ )}
>
)
}
-
diff --git a/apps/web/components/TranslatorForm.tsx b/apps/web/components/TranslatorForm.tsx
index 2bf4c1e..df5a1ea 100644
--- a/apps/web/components/TranslatorForm.tsx
+++ b/apps/web/components/TranslatorForm.tsx
@@ -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(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()
}
@@ -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 (
-
+
{/* Controls Bar */}
-
-
-
-
-
+
+
+
+
-
+
+ {STYLES.map((s) => (
+
+ ))}
+
-
-
+
+
+ {isLoading ? (
+
+ ) : (
+
+ )}
-
- {STYLES.map((s) => (
-
- ))}
-
-
-
- {isLoading ? (
-
- ) : (
-
- )}
-
+ {(isLoading || lastOutcome != null) && (
+
+
+
+ {isLoading
+ ? '翻译中…'
+ : lastOutcome === 'aborted'
+ ? '已停止'
+ : lastOutcome === 'error'
+ ? '翻译失败'
+ : '翻译完成'}
+
+ {formatDuration(isLoading ? elapsedMs : (lastDurationMs ?? 0))}
+
+
+
+ )}
{/* Main Input/Output Area */}
{/* Source Text */}
-
+
{/* Translation Result */}
-
+