feat:初版

This commit is contained in:
2025-12-25 18:41:09 +08:00
commit 1429e0e66a
52 changed files with 2688 additions and 0 deletions

2
apps/web/.env.example Normal file
View File

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

21
apps/web/Dockerfile Normal file
View File

@@ -0,0 +1,21 @@
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm install
COPY . .
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]

View File

@@ -0,0 +1,51 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
export default function AdminLayout({
children,
}: {
children: React.ReactNode
}) {
const router = useRouter()
const handleLogout = () => {
localStorage.removeItem('admin_token')
router.push('/login')
}
return (
<div className="min-h-screen bg-gray-100">
<nav className="bg-white shadow">
<div className="max-w-7xl mx-auto px-4">
<div className="flex justify-between h-16">
<div className="flex items-center space-x-8">
<span className="text-xl font-bold"></span>
<Link href="/admin" className="text-gray-600 hover:text-gray-900">
</Link>
<Link href="/admin/providers" className="text-gray-600 hover:text-gray-900">
AI
</Link>
<Link href="/admin/stats" className="text-gray-600 hover:text-gray-900">
</Link>
</div>
<div className="flex items-center">
<button
onClick={handleLogout}
className="text-gray-600 hover:text-gray-900"
>
退
</button>
</div>
</div>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 px-4">
{children}
</main>
</div>
)
}

View File

@@ -0,0 +1,23 @@
'use client'
export default function AdminPage() {
return (
<div>
<h1 className="text-2xl font-bold mb-6"></h1>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<div className="bg-white p-6 rounded shadow">
<h3 className="text-gray-500 text-sm"></h3>
<p className="text-3xl font-bold">--</p>
</div>
<div className="bg-white p-6 rounded shadow">
<h3 className="text-gray-500 text-sm"> Token</h3>
<p className="text-3xl font-bold">--</p>
</div>
<div className="bg-white p-6 rounded shadow">
<h3 className="text-gray-500 text-sm"></h3>
<p className="text-3xl font-bold">--</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,205 @@
'use client'
import { useState, useEffect } from 'react'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
interface Provider {
id: number
name: string
model_id: string
base_url: string | null
is_active: boolean
is_default: boolean
}
export default function ProvidersPage() {
const [providers, setProviders] = useState<Provider[]>([])
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<number | null>(null)
const [form, setForm] = useState({
name: '',
model_id: '',
base_url: '',
api_key: '',
is_default: false,
})
const getToken = () => localStorage.getItem('admin_token') || ''
const fetchProviders = async () => {
const res = await fetch(`${API_BASE}/api/v1/admin/providers`, {
headers: { Authorization: `Bearer ${getToken()}` },
})
if (res.ok) {
setProviders(await res.json())
}
}
useEffect(() => {
fetchProviders()
}, [])
const handleSubmit = async () => {
const url = editId
? `${API_BASE}/api/v1/admin/providers/${editId}`
: `${API_BASE}/api/v1/admin/providers`
const method = editId ? 'PUT' : 'POST'
await fetch(url, {
method,
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${getToken()}`,
},
body: JSON.stringify(form),
})
setShowForm(false)
setEditId(null)
setForm({ name: '', model_id: '', base_url: '', api_key: '', is_default: false })
fetchProviders()
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除?')) return
await fetch(`${API_BASE}/api/v1/admin/providers/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${getToken()}` },
})
fetchProviders()
}
return (
<div>
<div className="flex justify-between items-center mb-6">
<h1 className="text-2xl font-bold">AI </h1>
<button
onClick={() => setShowForm(true)}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
</button>
</div>
{/* Provider 列表 */}
<div className="bg-white rounded shadow overflow-hidden">
<table className="w-full">
<thead className="bg-gray-50">
<tr>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"> ID</th>
<th className="px-4 py-3 text-left">Base URL</th>
<th className="px-4 py-3 text-left"></th>
<th className="px-4 py-3 text-left"></th>
</tr>
</thead>
<tbody>
{providers.map((p) => (
<tr key={p.id} className="border-t">
<td className="px-4 py-3">
{p.name}
{p.is_default && (
<span className="ml-2 text-xs bg-green-100 text-green-800 px-2 py-1 rounded">
</span>
)}
</td>
<td className="px-4 py-3">{p.model_id}</td>
<td className="px-4 py-3">{p.base_url || '-'}</td>
<td className="px-4 py-3">
<span className={p.is_active ? 'text-green-600' : 'text-gray-400'}>
{p.is_active ? '启用' : '禁用'}
</span>
</td>
<td className="px-4 py-3 space-x-2">
<button
onClick={() => handleDelete(p.id)}
className="text-red-600 hover:underline"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* 添加/编辑表单 */}
{showForm && (
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
<div className="bg-white p-6 rounded shadow-lg w-96">
<h2 className="text-xl font-bold mb-4">
{editId ? '编辑' : '添加'} AI
</h2>
<div className="space-y-4">
<div>
<label className="block text-sm mb-1"></label>
<input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
className="w-full p-2 border rounded"
/>
</div>
<div>
<label className="block text-sm mb-1"> ID</label>
<input
value={form.model_id}
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
className="w-full p-2 border rounded"
placeholder="gpt-4o-mini"
/>
</div>
<div>
<label className="block text-sm mb-1">Base URL</label>
<input
value={form.base_url}
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
className="w-full p-2 border rounded"
placeholder="https://api.openai.com/v1"
/>
</div>
<div>
<label className="block text-sm mb-1">API Key</label>
<input
type="password"
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
className="w-full p-2 border rounded"
/>
</div>
<div>
<label className="flex items-center">
<input
type="checkbox"
checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
className="mr-2"
/>
</label>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 border rounded"
>
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
</button>
</div>
</div>
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,97 @@
'use client'
import { useState, useEffect } from 'react'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
interface Stats {
date: string
request_count: number
input_tokens: number
output_tokens: number
cached_count: number
error_count: number
}
interface Realtime {
rpm: number
tpm: number
}
export default function StatsPage() {
const [providerId, setProviderId] = useState(1)
const [stats, setStats] = useState<Stats | null>(null)
const [realtime, setRealtime] = useState<Realtime>({ rpm: 0, tpm: 0 })
const getToken = () => localStorage.getItem('admin_token') || ''
const fetchStats = async () => {
const res = await fetch(
`${API_BASE}/api/v1/admin/stats/daily/${providerId}`,
{ headers: { Authorization: `Bearer ${getToken()}` } }
)
if (res.ok) setStats(await res.json())
}
const fetchRealtime = async () => {
const res = await fetch(
`${API_BASE}/api/v1/admin/stats/realtime/${providerId}`,
{ headers: { Authorization: `Bearer ${getToken()}` } }
)
if (res.ok) setRealtime(await res.json())
}
useEffect(() => {
fetchStats()
fetchRealtime()
const interval = setInterval(fetchRealtime, 5000)
return () => clearInterval(interval)
}, [providerId])
return (
<div>
<h1 className="text-2xl font-bold mb-6">使</h1>
{/* 实时指标 */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-6">
<div className="bg-white p-4 rounded shadow">
<h3 className="text-gray-500 text-sm">RPM ()</h3>
<p className="text-2xl font-bold">{realtime.rpm}</p>
</div>
<div className="bg-white p-4 rounded shadow">
<h3 className="text-gray-500 text-sm">TPM (Token)</h3>
<p className="text-2xl font-bold">{realtime.tpm}</p>
</div>
</div>
{/* 今日统计 */}
{stats && (
<div className="bg-white p-6 rounded shadow">
<h2 className="text-lg font-bold mb-4"> ({stats.date})</h2>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
<div>
<p className="text-gray-500 text-sm"></p>
<p className="text-xl font-bold">{stats.request_count}</p>
</div>
<div>
<p className="text-gray-500 text-sm"> Token</p>
<p className="text-xl font-bold">{stats.input_tokens}</p>
</div>
<div>
<p className="text-gray-500 text-sm"> Token</p>
<p className="text-xl font-bold">{stats.output_tokens}</p>
</div>
<div>
<p className="text-gray-500 text-sm"></p>
<p className="text-xl font-bold">{stats.cached_count}</p>
</div>
<div>
<p className="text-gray-500 text-sm"></p>
<p className="text-xl font-bold text-red-600">{stats.error_count}</p>
</div>
</div>
</div>
)}
</div>
)
}

7
apps/web/app/globals.css Normal file
View File

@@ -0,0 +1,7 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}

19
apps/web/app/layout.tsx Normal file
View File

@@ -0,0 +1,19 @@
import type { Metadata } from 'next'
import './globals.css'
export const metadata: Metadata = {
title: 'AI Translator',
description: 'AI-powered translation service',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh">
<body>{children}</body>
</html>
)
}

View File

@@ -0,0 +1,76 @@
'use client'
import { useState } from 'react'
import { useRouter } from 'next/navigation'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
export default function LoginPage() {
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [error, setError] = useState('')
const router = useRouter()
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
try {
const res = await fetch(
`${API_BASE}/api/v1/admin/login?username=${username}&password=${password}`,
{ method: 'POST' }
)
if (!res.ok) {
throw new Error('登录失败')
}
const data = await res.json()
localStorage.setItem('admin_token', data.token)
router.push('/admin')
} catch {
setError('用户名或密码错误')
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-gray-100">
<div className="bg-white p-8 rounded shadow-md w-96">
<h1 className="text-2xl font-bold mb-6 text-center"></h1>
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-600 rounded">
{error}
</div>
)}
<form onSubmit={handleLogin}>
<div className="mb-4">
<label className="block text-sm font-medium mb-1"></label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="w-full p-2 border rounded"
/>
</div>
<div className="mb-6">
<label className="block text-sm font-medium mb-1"></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="w-full p-2 border rounded"
/>
</div>
<button
type="submit"
className="w-full py-2 bg-blue-600 text-white rounded hover:bg-blue-700"
>
</button>
</form>
</div>
</div>
)
}

14
apps/web/app/page.tsx Normal file
View File

@@ -0,0 +1,14 @@
import TranslatorForm from '@/components/TranslatorForm'
export default function Home() {
return (
<main className="min-h-screen bg-gray-50">
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold text-center mb-8">
AI
</h1>
<TranslatorForm />
</div>
</main>
)
}

View File

@@ -0,0 +1,221 @@
'use client'
import { useState, useRef } from 'react'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
const LANGUAGES = [
{ code: 'auto', name: '自动检测' },
{ code: 'zh', name: '中文' },
{ code: 'en', name: '英语' },
{ code: 'ja', name: '日语' },
{ code: 'ko', name: '韩语' },
{ code: 'fr', name: '法语' },
{ code: 'de', name: '德语' },
{ code: 'es', name: '西班牙语' },
]
const STYLES = [
{ value: 'literal', name: '直译' },
{ value: 'fluent', name: '意译' },
{ value: 'casual', name: '口语化' },
]
export default function TranslatorForm() {
const [sourceText, setSourceText] = useState('')
const [translation, setTranslation] = useState('')
const [sourceLang, setSourceLang] = useState('auto')
const [targetLang, setTargetLang] = useState('zh')
const [style, setStyle] = useState('literal')
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const abortRef = useRef<AbortController | null>(null)
const handleTranslate = async () => {
if (!sourceText.trim()) return
if (abortRef.current) {
abortRef.current.abort()
}
abortRef.current = new AbortController()
setIsLoading(true)
setError('')
setTranslation('')
try {
const res = await fetch(`${API_BASE}/api/v1/translate/stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
source_text: sourceText,
source_lang: sourceLang,
target_lang: targetLang,
style,
}),
signal: abortRef.current.signal,
})
if (!res.ok) {
throw new Error('翻译请求失败')
}
const reader = res.body?.getReader()
const decoder = new TextDecoder()
let buffer = ''
while (reader) {
const { value, done } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() ?? ''
for (const p of parts) {
const lines = p.split('\n')
const dataLine = lines.find(l => l.startsWith('data:'))
if (dataLine) {
const data = JSON.parse(dataLine.slice(5).trim())
if (data.delta) {
setTranslation(prev => prev + data.delta)
}
}
}
}
} catch (err: unknown) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message || '翻译失败')
}
} finally {
setIsLoading(false)
}
}
const handleStop = () => {
if (abortRef.current) {
abortRef.current.abort()
setIsLoading(false)
}
}
const handleCopy = () => {
navigator.clipboard.writeText(translation)
}
return (
<div className="max-w-4xl mx-auto">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
{/* 源语言选择 */}
<div>
<label className="block text-sm font-medium mb-1"></label>
<select
value={sourceLang}
onChange={(e) => setSourceLang(e.target.value)}
className="w-full p-2 border rounded"
>
{LANGUAGES.map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
</div>
{/* 目标语言选择 */}
<div>
<label className="block text-sm font-medium mb-1"></label>
<select
value={targetLang}
onChange={(e) => setTargetLang(e.target.value)}
className="w-full p-2 border rounded"
>
{LANGUAGES.filter((l) => l.code !== 'auto').map((lang) => (
<option key={lang.code} value={lang.code}>
{lang.name}
</option>
))}
</select>
</div>
</div>
{/* 风格选择 */}
<div className="mb-4">
<label className="block text-sm font-medium mb-1"></label>
<div className="flex gap-4">
{STYLES.map((s) => (
<label key={s.value} className="flex items-center">
<input
type="radio"
name="style"
value={s.value}
checked={style === s.value}
onChange={(e) => setStyle(e.target.value)}
className="mr-1"
/>
{s.name}
</label>
))}
</div>
</div>
{/* 输入输出区域 */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 mb-4">
<div>
<label className="block text-sm font-medium mb-1"></label>
<textarea
value={sourceText}
onChange={(e) => setSourceText(e.target.value)}
placeholder="请输入要翻译的文本..."
className="w-full h-48 p-3 border rounded resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium mb-1"></label>
<div className="relative">
<textarea
value={translation}
readOnly
placeholder="翻译结果将显示在这里..."
className="w-full h-48 p-3 border rounded resize-none bg-gray-50"
/>
{translation && (
<button
onClick={handleCopy}
className="absolute top-2 right-2 px-2 py-1 text-xs bg-white border rounded hover:bg-gray-100"
>
</button>
)}
</div>
</div>
</div>
{/* 错误提示 */}
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-600 rounded">
{error}
</div>
)}
{/* 按钮 */}
<div className="flex gap-4">
<button
onClick={handleTranslate}
disabled={isLoading || !sourceText.trim()}
className="px-6 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50"
>
{isLoading ? '翻译中...' : '翻译'}
</button>
{isLoading && (
<button
onClick={handleStop}
className="px-6 py-2 bg-gray-600 text-white rounded hover:bg-gray-700"
>
</button>
)}
</div>
</div>
)
}

6
apps/web/next.config.js Normal file
View File

@@ -0,0 +1,6 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig

25
apps/web/package.json Normal file
View File

@@ -0,0 +1,25 @@
{
"name": "ai-translator-web",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"next": "14.2.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
},
"devDependencies": {
"@types/node": "^20.0.0",
"@types/react": "^18.2.0",
"@types/react-dom": "^18.2.0",
"autoprefixer": "^10.4.0",
"postcss": "^8.4.0",
"tailwindcss": "^3.4.0",
"typescript": "^5.0.0"
}
}

View File

@@ -0,0 +1,6 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -0,0 +1,11 @@
/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
'./app/**/*.{js,ts,jsx,tsx,mdx}',
'./components/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {},
},
plugins: [],
}

20
apps/web/tsconfig.json Normal file
View File

@@ -0,0 +1,20 @@
{
"compilerOptions": {
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}