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 */} - +