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

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>
)
}