feat:初版
This commit is contained in:
2
apps/web/.env.example
Normal file
2
apps/web/.env.example
Normal file
@@ -0,0 +1,2 @@
|
||||
# API Base URL
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8000
|
||||
21
apps/web/Dockerfile
Normal file
21
apps/web/Dockerfile
Normal 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"]
|
||||
51
apps/web/app/admin/layout.tsx
Normal file
51
apps/web/app/admin/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
23
apps/web/app/admin/page.tsx
Normal file
23
apps/web/app/admin/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
205
apps/web/app/admin/providers/page.tsx
Normal file
205
apps/web/app/admin/providers/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
97
apps/web/app/admin/stats/page.tsx
Normal file
97
apps/web/app/admin/stats/page.tsx
Normal 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
7
apps/web/app/globals.css
Normal 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
19
apps/web/app/layout.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
76
apps/web/app/login/page.tsx
Normal file
76
apps/web/app/login/page.tsx
Normal 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
14
apps/web/app/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
221
apps/web/components/TranslatorForm.tsx
Normal file
221
apps/web/components/TranslatorForm.tsx
Normal 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
6
apps/web/next.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
}
|
||||
|
||||
module.exports = nextConfig
|
||||
25
apps/web/package.json
Normal file
25
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
11
apps/web/tailwind.config.js
Normal file
11
apps/web/tailwind.config.js
Normal 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
20
apps/web/tsconfig.json
Normal 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"]
|
||||
}
|
||||
Reference in New Issue
Block a user