feat:初版
This commit is contained in:
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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user