From 67025a4865acee1133caa3a0dbc985891ecd75bd Mon Sep 17 00:00:00 2001 From: shihao <3127647737@qq.com> Date: Mon, 29 Dec 2025 15:52:50 +0800 Subject: [PATCH] =?UTF-8?q?feta:=E4=BC=98=E5=8C=96=E6=B5=81=E7=95=85?= =?UTF-8?q?=E5=BA=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AI_Translator_Website_Dev_Doc.md | 4 +- CLAUDE.md | 4 +- README.md | 8 +- Redis-x64-5.0.14.1/dump.rdb | Bin 28911 -> 29504 bytes apps/api/.env.example | 2 +- apps/api/Dockerfile | 4 +- apps/api/app/core/config.py | 2 +- apps/web/.env.example | 2 +- apps/web/Dockerfile | 3 +- apps/web/app/admin/layout.tsx | 2 +- apps/web/app/login/page.tsx | 2 +- apps/web/components/LanguageSelect.tsx | 171 ++++++++------- apps/web/components/TranslatorForm.tsx | 287 +++++++++++++++++++------ apps/web/components/ui/button.tsx | 2 +- apps/web/components/ui/card.tsx | 2 +- apps/web/components/ui/input.tsx | 3 +- apps/web/components/ui/textarea.tsx | 2 +- apps/web/lib/admin-api.ts | 2 +- apps/web/package.json | 4 +- infra/docker-compose.yml | 6 +- 20 files changed, 337 insertions(+), 175 deletions(-) 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 fea444df9d652da51fb7ec1acbee5ae03ff70042..c81d2935f638afda04d977a2b8509107eb1e2e60 100644 GIT binary patch delta 631 zcmZY5J!lj`6bJDAa0%fg<~Vi1kodSX9>m=4%zn(CdM-9WQ4qmGvvQf;ojDIKd&%Cd z5Je6w0)m3Fji7c`cCw9vg>oQR+S-XIX~aUTjL}IfjMz-^<~`o;|Gs{ZUwxMEVYPuT z+;>2k94~&N4YhdhuI|H|GcUi~c_>SgR2ifV#+ivdOgW<%6Jq$;jcUJ_r4eg&Y0|DX zpnCmkzUo$nr&uRx0}Z59}IC#V1{Z zP9JDjbT9S?X)kGCnXlg5y!Bw~FC%sg#wo>!d!7+tYvhmow}*FA@}qcaSPn9aJ4TQ> zVZ?*X_K@dmGC{sRX4^qV!jQqV9K}#l9OR1EMi}Y};W5-yi&+H9if2;4BpafqWEoMA zxel?m*%PwE0jVysTxiM;f>2B_%h#k3kYZ!kKXX!v98)ZaIcN}wdL5c$LB_dZia_5# z?h52$LL(BI)bj{4c{uV5|3wb?$nenr8G_6+cr5%&nkw83bXmA(cihwexFrK>B0&V& zghR0VN56A*sBZfkitxihRgfYm$*u{p;$ST5HYRXZwZpw31|6)WSw)!h*XTrXu1?gv z=|0LoIhTrc{f%DK%ZqA0ueYAH-c9D~=vZ-k{}V;qQhw}_@~11u^YaU*i|3cW9VqTD R%v8pI)i&lH&rLNi{syioy7d46 delta 113 zcmV-%0FM8_<^k{F0gy8Ta3@ZYHXs5G2D9Zg*buWbGFk((`9`AxlZ-WBvvV517qdPV zIRlftMm&? 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 */} - +