feat:重构UI

This commit is contained in:
2025-12-26 16:03:12 +08:00
parent 1429e0e66a
commit abcbe3cddc
67 changed files with 12170 additions and 515 deletions

View File

@@ -1,7 +1,15 @@
'use client'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { usePathname, useRouter } from 'next/navigation'
import { useEffect, useMemo, useState } from 'react'
import { BarChart3, Bot, LayoutDashboard, LogOut, Settings } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { ADMIN_UNAUTHORIZED_EVENT } from '@/lib/admin-api'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
export default function AdminLayout({
children,
@@ -9,43 +17,166 @@ export default function AdminLayout({
children: React.ReactNode
}) {
const router = useRouter()
const pathname = usePathname()
const [authChecked, setAuthChecked] = useState(false)
const [adminName, setAdminName] = useState<string>('')
const navItems = useMemo(
() => [
{ href: '/admin', label: '概览', icon: LayoutDashboard },
{ href: '/admin/providers', label: 'AI 配置', icon: Bot },
{ href: '/admin/stats', label: '统计', icon: BarChart3 },
{ href: '/admin/settings', label: '设置', icon: Settings },
],
[]
)
const getToken = () => localStorage.getItem('admin_token') || ''
const fetchMe = async () => {
const token = getToken()
if (!token) return
try {
const res = await fetch(`${API_BASE}/api/v1/admin/me`, {
headers: { Authorization: `Bearer ${token}` },
})
if (res.status === 401) throw new Error('unauthorized')
if (!res.ok) return
const data = await res.json()
setAdminName(data.username || data.sub || '')
} catch {
localStorage.removeItem('admin_token')
router.replace('/login')
}
}
useEffect(() => {
const token = getToken()
if (!token) {
router.replace('/login')
return
}
setAuthChecked(true)
fetchMe()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
const onUnauthorized = () => {
localStorage.removeItem('admin_token')
router.replace('/login')
}
window.addEventListener(ADMIN_UNAUTHORIZED_EVENT, onUnauthorized)
return () => window.removeEventListener(ADMIN_UNAUTHORIZED_EVENT, onUnauthorized)
}, [router])
const handleLogout = () => {
localStorage.removeItem('admin_token')
router.push('/login')
router.replace('/login')
}
if (!authChecked) {
return (
<div className="min-h-screen flex items-center justify-center bg-background">
<div className="text-sm text-muted-foreground">...</div>
</div>
)
}
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 className="min-h-screen bg-background">
<div className="flex min-h-screen">
{/* Sidebar */}
<aside className="hidden md:flex w-64 flex-col border-r bg-card">
<div className="h-14 px-4 flex items-center border-b">
<Link href="/" className="font-semibold tracking-tight">
AI Translator
</Link>
<span className="ml-2 text-xs text-muted-foreground">Admin</span>
</div>
<nav className="flex-1 p-2 space-y-1">
{navItems.map((item) => {
const Icon = item.icon
const active =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
return (
<Link
key={item.href}
href={item.href}
className={cn(
'flex items-center gap-2 rounded-md px-3 py-2 text-sm transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
<Icon className="h-4 w-4" />
{item.label}
</Link>
)
})}
</nav>
<div className="p-2 border-t">
<Button variant="ghost" className="w-full justify-start" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
退
</Button>
</div>
</aside>
{/* Main */}
<div className="flex-1 flex flex-col">
<header className="h-14 border-b bg-card/50 backdrop-blur-sm">
<div className="h-full container flex items-center justify-between">
<div className="md:hidden flex items-center gap-3 overflow-x-auto">
<div className="font-semibold shrink-0"></div>
<div className="flex items-center gap-1">
{navItems.map((item) => {
const active =
pathname === item.href ||
(item.href !== '/admin' && pathname.startsWith(item.href))
return (
<Link
key={item.href}
href={item.href}
className={cn(
'rounded-md px-2 py-1 text-sm transition-colors',
active
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground'
)}
>
{item.label}
</Link>
)
})}
</div>
</div>
<div className="flex items-center gap-3">
{adminName && (
<div className="text-sm text-muted-foreground">
{adminName}
</div>
)}
<div className="md:hidden">
<Button variant="ghost" size="sm" onClick={handleLogout}>
<LogOut className="mr-2 h-4 w-4" />
退
</Button>
</div>
</div>
</div>
</header>
<main className="flex-1">
<div className="container py-6">{children}</div>
</main>
</div>
</nav>
<main className="max-w-7xl mx-auto py-6 px-4">
{children}
</main>
</div>
</div>
)
}

View File

@@ -1,23 +1,375 @@
'use client'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { Activity, BarChart3, Bot, RefreshCcw } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AdminUnauthorizedError, API_BASE, adminFetchJson } from '@/lib/admin-api'
interface Provider {
id: number
name: string
model_id: string
base_url: string | null
is_active: boolean
is_default: boolean
}
interface HourlyStat {
hour: number
request_count: number
input_tokens: number
output_tokens: number
cached_count: number
error_count: number
}
interface DailyStats {
date: string
request_count: number
input_tokens: number
output_tokens: number
cached_count: number
error_count: number
hourly: HourlyStat[]
}
interface RealtimeStats {
rpm: number
tpm: number
}
function formatNumber(value: number) {
return new Intl.NumberFormat('zh-CN').format(value)
}
function formatPercent(value: number) {
return `${(value * 100).toFixed(1)}%`
}
export default function AdminPage() {
const router = useRouter()
const [providers, setProviders] = useState<Provider[]>([])
const [providerId, setProviderId] = useState<string>('')
const [daily, setDaily] = useState<DailyStats | null>(null)
const [realtime, setRealtime] = useState<RealtimeStats>({ rpm: 0, tpm: 0 })
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const selectedProvider = useMemo(
() => providers.find((p) => String(p.id) === providerId) || null,
[providers, providerId]
)
const cacheHitRate = useMemo(() => {
if (!daily || daily.request_count <= 0) return 0
return daily.cached_count / daily.request_count
}, [daily])
const errorRate = useMemo(() => {
if (!daily || daily.request_count <= 0) return 0
return daily.error_count / daily.request_count
}, [daily])
const totalTokens = useMemo(() => {
if (!daily) return 0
return daily.input_tokens + daily.output_tokens
}, [daily])
const fetchProviders = async () => {
setIsLoading(true)
setError('')
try {
const data = await adminFetchJson<Provider[]>('/api/v1/admin/providers')
setProviders(data)
const defaultOne = data.find((p) => p.is_default) || data[0] || null
if (defaultOne) setProviderId(String(defaultOne.id))
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载失败')
} finally {
setIsLoading(false)
}
}
const fetchDaily = async (id: string) => {
try {
const data = await adminFetchJson<DailyStats>(`/api/v1/admin/stats/daily/${id}`)
setDaily(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载统计失败')
}
}
const fetchRealtime = async (id: string) => {
try {
const data = await adminFetchJson<RealtimeStats>(`/api/v1/admin/stats/realtime/${id}`)
setRealtime(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
}
}
const fetchHealth = async () => {
try {
const res = await fetch(`${API_BASE}/health`)
setHealthOk(res.ok)
} catch {
setHealthOk(false)
}
}
useEffect(() => {
fetchProviders()
fetchHealth()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!providerId) return
fetchDaily(providerId)
fetchRealtime(providerId)
const timer = setInterval(() => fetchRealtime(providerId), 5000)
return () => clearInterval(timer)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerId])
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 className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-muted-foreground">
Provider
</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 className="flex items-center gap-2">
<Button variant="outline" onClick={() => fetchHealth()}>
<Activity className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={fetchProviders} disabled={isLoading}>
<RefreshCcw className="mr-2 h-4 w-4" />
</Button>
<Button asChild>
<Link href="/admin/providers">
<Bot className="mr-2 h-4 w-4" />
</Link>
</Button>
<Button variant="secondary" asChild>
<Link href="/admin/stats">
<BarChart3 className="mr-2 h-4 w-4" />
</Link>
</Button>
</div>
</div>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle> Provider</CardTitle>
<CardDescription> Provider </CardDescription>
</div>
<div className="w-full sm:w-[320px]">
<Select value={providerId} onValueChange={setProviderId}>
<SelectTrigger>
<SelectValue placeholder="选择 Provider" />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name} ({p.model_id}){p.is_default ? ' · 默认' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>API </span>
{healthOk === null ? (
<Badge variant="outline"></Badge>
) : healthOk ? (
<Badge variant="success">OK</Badge>
) : (
<Badge variant="destructive"></Badge>
)}
{selectedProvider && (
<>
<span className="ml-4"></span>
{selectedProvider.is_active ? (
<Badge variant="secondary"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
{selectedProvider.is_default && <Badge variant="success"></Badge>}
</>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>RPM</CardDescription>
<CardTitle className="text-3xl">{formatNumber(realtime.rpm)}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>TPM Token</CardDescription>
<CardTitle className="text-3xl">{formatNumber(realtime.tpm)}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{daily ? formatNumber(daily.request_count) : '--'}
</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription> Token</CardDescription>
<CardTitle className="text-3xl">
{daily ? formatNumber(totalTokens) : '--'}
</CardTitle>
</CardHeader>
</Card>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{daily ? formatPercent(cacheHitRate) : '--'}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{daily ? formatNumber(daily.cached_count) : '--'}
</CardContent>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{daily ? formatPercent(errorRate) : '--'}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{daily ? formatNumber(daily.error_count) : '--'}
</CardContent>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>/ Token</CardDescription>
<CardTitle className="text-3xl">
{daily ? formatNumber(daily.input_tokens) : '--'} / {daily ? formatNumber(daily.output_tokens) : '--'}
</CardTitle>
</CardHeader>
</Card>
</div>
</CardContent>
</Card>
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader>
<CardTitle>Provider </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{providers.length === 0 ? (
<TableRow>
<TableCell colSpan={3} className="text-center text-muted-foreground">
ProviderAI
</TableCell>
</TableRow>
) : (
providers.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">
<div className="flex items-center gap-2">
{p.name}
{p.is_default && <Badge variant="success"></Badge>}
</div>
</TableCell>
<TableCell className="font-mono text-xs">{p.model_id}</TableCell>
<TableCell>
{p.is_active ? (
<Badge variant="secondary"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,8 +1,31 @@
'use client'
import { useState, useEffect } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Pencil, Plus, RefreshCcw, Star, Trash2, Wand2, ZapOff } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Textarea } from '@/components/ui/textarea'
import { AdminUnauthorizedError, adminFetchJson } from '@/lib/admin-api'
interface Provider {
id: number
@@ -11,28 +34,51 @@ interface Provider {
base_url: string | null
is_active: boolean
is_default: boolean
created_at?: string
}
export default function ProvidersPage() {
const router = useRouter()
const [providers, setProviders] = useState<Provider[]>([])
const [showForm, setShowForm] = useState(false)
const [editId, setEditId] = useState<number | null>(null)
const [query, setQuery] = useState('')
const [onlyActive, setOnlyActive] = useState(false)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const [dialogOpen, setDialogOpen] = useState(false)
const [editing, setEditing] = useState<Provider | null>(null)
const [isSaving, setIsSaving] = useState(false)
const [testResult, setTestResult] = useState<{
ok: boolean
latency_ms?: number
message?: string
} | null>(null)
const [form, setForm] = useState({
name: '',
model_id: '',
base_url: '',
api_key: '',
is_active: true,
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())
setIsLoading(true)
setError('')
try {
const data = await adminFetchJson<Provider[]>('/api/v1/admin/providers')
setProviders(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载失败')
} finally {
setIsLoading(false)
}
}
@@ -40,166 +86,437 @@ export default function ProvidersPage() {
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),
const filteredProviders = useMemo(() => {
const q = query.trim().toLowerCase()
return providers.filter((p) => {
if (onlyActive && !p.is_active) return false
if (!q) return true
return (
p.name.toLowerCase().includes(q) ||
p.model_id.toLowerCase().includes(q) ||
(p.base_url || '').toLowerCase().includes(q)
)
})
}, [providers, query, onlyActive])
setShowForm(false)
setEditId(null)
setForm({ name: '', model_id: '', base_url: '', api_key: '', is_default: false })
fetchProviders()
const openCreate = () => {
setEditing(null)
setTestResult(null)
setForm({
name: '',
model_id: '',
base_url: '',
api_key: '',
is_active: true,
is_default: false,
})
setDialogOpen(true)
}
const handleDelete = async (id: number) => {
if (!confirm('确定删除?')) return
await fetch(`${API_BASE}/api/v1/admin/providers/${id}`, {
method: 'DELETE',
headers: { Authorization: `Bearer ${getToken()}` },
const openEdit = (provider: Provider) => {
setEditing(provider)
setTestResult(null)
setForm({
name: provider.name,
model_id: provider.model_id,
base_url: provider.base_url || '',
api_key: '',
is_active: provider.is_active,
is_default: provider.is_default,
})
fetchProviders()
setDialogOpen(true)
}
const handleSubmit = async () => {
setIsSaving(true)
setError('')
try {
if (editing) {
const payload: Record<string, any> = {
name: form.name,
model_id: form.model_id,
base_url: form.base_url.trim() ? form.base_url.trim() : null,
is_active: form.is_active,
is_default: form.is_default,
}
if (form.api_key.trim()) payload.api_key = form.api_key.trim()
await adminFetchJson(`/api/v1/admin/providers/${editing.id}`, {
method: 'PUT',
body: JSON.stringify(payload),
})
} else {
await adminFetchJson(`/api/v1/admin/providers`, {
method: 'POST',
body: JSON.stringify({
name: form.name,
model_id: form.model_id,
base_url: form.base_url.trim() ? form.base_url.trim() : null,
api_key: form.api_key.trim(),
is_active: form.is_active,
is_default: form.is_default,
}),
})
}
setDialogOpen(false)
setEditing(null)
setForm({
name: '',
model_id: '',
base_url: '',
api_key: '',
is_active: true,
is_default: false,
})
await fetchProviders()
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '保存失败')
} finally {
setIsSaving(false)
}
}
const handleDelete = async (provider: Provider) => {
if (!confirm('确定删除?')) return
setError('')
try {
await adminFetchJson(`/api/v1/admin/providers/${provider.id}`, { method: 'DELETE' })
await fetchProviders()
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '删除失败')
}
}
const setDefault = async (provider: Provider) => {
setError('')
try {
await adminFetchJson(`/api/v1/admin/providers/${provider.id}`, {
method: 'PUT',
body: JSON.stringify({ is_default: true }),
})
await fetchProviders()
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '操作失败')
}
}
const toggleActive = async (provider: Provider) => {
setError('')
try {
await adminFetchJson(`/api/v1/admin/providers/${provider.id}`, {
method: 'PUT',
body: JSON.stringify({ is_active: !provider.is_active }),
})
await fetchProviders()
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '操作失败')
}
}
const testProvider = async (provider: Provider) => {
setTestResult(null)
setError('')
try {
const data = await adminFetchJson<{
ok: boolean
latency_ms?: number
message?: string
}>(`/api/v1/admin/providers/${provider.id}/test`, { method: 'POST' })
setTestResult(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setTestResult({ ok: false, message: err?.message || '测试失败' })
}
}
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>
<div className="space-y-6">
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle>AI </CardTitle>
<CardDescription> AI ProviderBase URLAPI Key/</CardDescription>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={fetchProviders} disabled={isLoading}>
<RefreshCcw className="mr-2 h-4 w-4" />
</Button>
<Button onClick={openCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
</div>
</CardHeader>
<CardContent className="space-y-4">
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</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>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索:名称 / 模型 / Base URL"
/>
</div>
<label className="flex items-center gap-2 text-sm text-muted-foreground">
<input
type="checkbox"
checked={onlyActive}
onChange={(e) => setOnlyActive(e.target.checked)}
className="h-4 w-4 rounded border-input text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</label>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Base URL</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{isLoading ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : filteredProviders.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
filteredProviders.map((p) => (
<TableRow key={p.id}>
<TableCell>
<div className="flex items-center gap-2">
<div className="font-medium">{p.name}</div>
{p.is_default && <Badge variant="success"></Badge>}
</div>
{p.created_at && (
<div className="text-xs text-muted-foreground mt-1">
{new Date(p.created_at).toLocaleString()}
</div>
)}
</TableCell>
<TableCell className="font-mono text-xs">{p.model_id}</TableCell>
<TableCell className="max-w-[340px]">
<div className="truncate text-sm text-muted-foreground">
{p.base_url || '-'}
</div>
</TableCell>
<TableCell>
{p.is_active ? (
<Badge variant="secondary"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2 flex-wrap">
{!p.is_default && (
<Button
size="sm"
variant="secondary"
onClick={() => setDefault(p)}
>
<Star className="mr-2 h-4 w-4" />
</Button>
)}
<Button size="sm" variant="outline" onClick={() => toggleActive(p)}>
{p.is_active ? (
<>
<ZapOff className="mr-2 h-4 w-4" />
</>
) : (
<>
<Wand2 className="mr-2 h-4 w-4" />
</>
)}
</Button>
<Button size="sm" variant="outline" onClick={() => openEdit(p)}>
<Pencil className="mr-2 h-4 w-4" />
</Button>
<Button size="sm" variant="outline" onClick={() => testProvider(p)}>
</Button>
<Button size="sm" variant="destructive" onClick={() => handleDelete(p)}>
<Trash2 className="mr-2 h-4 w-4" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
{dialogOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 backdrop-blur-sm p-4">
<Card className="w-full max-w-2xl border-muted shadow-lg">
<CardHeader>
<CardTitle>{editing ? '编辑' : '添加'} AI </CardTitle>
<CardDescription>
{editing
? '更新配置项API Key 留空则不修改)'
: '添加新的 Provider 配置'}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{testResult && (
<div
className={
testResult.ok
? 'rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300'
: 'rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive'
}
>
{testResult.ok ? (
<div>
{typeof testResult.latency_ms === 'number' ? `${testResult.latency_ms}ms` : ''}
</div>
) : (
<div>{testResult.message || '测试失败'}</div>
)}
</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>
</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="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-1.5">
<Label></Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="OpenAI / Azure / 自建代理..."
/>
</div>
<div className="space-y-1.5">
<Label> ID</Label>
<Input
value={form.model_id}
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
placeholder="gpt-4o-mini"
/>
</div>
</div>
<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
<div className="space-y-1.5">
<Label>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 className="text-xs text-muted-foreground">
使 OpenAI Base URL
</div>
</div>
<div>
<label className="block text-sm mb-1">API Key</label>
<input
type="password"
<div className="space-y-1.5">
<Label>API Key</Label>
<Textarea
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
className="w-full p-2 border rounded"
placeholder={editing ? '留空则不修改' : 'sk-***'}
className="min-h-[80px] font-mono text-xs"
/>
</div>
<div>
<label className="flex items-center">
<div className="flex flex-wrap gap-6">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_active}
onChange={(e) => setForm({ ...form, is_active: e.target.checked })}
className="h-4 w-4 rounded border-input text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</label>
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.is_default}
onChange={(e) => setForm({ ...form, is_default: e.target.checked })}
className="mr-2"
className="h-4 w-4 rounded border-input text-primary focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
/>
</label>
</div>
</div>
<div className="flex justify-end gap-2 mt-6">
<button
onClick={() => setShowForm(false)}
className="px-4 py-2 border rounded"
</CardContent>
<CardFooter className="justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setDialogOpen(false)
setEditing(null)
setTestResult(null)
}}
disabled={isSaving}
>
</button>
<button
onClick={handleSubmit}
className="px-4 py-2 bg-blue-600 text-white rounded"
>
</button>
</div>
</div>
</Button>
{editing && (
<Button
variant="secondary"
onClick={() => testProvider({ ...editing, ...form, base_url: form.base_url || null })}
disabled={isSaving}
>
</Button>
)}
<Button onClick={handleSubmit} disabled={isSaving}>
{isSaving ? '保存中...' : '保存'}
</Button>
</CardFooter>
</Card>
</div>
)}
</div>
)
}
}

View File

@@ -0,0 +1,226 @@
'use client'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { KeyRound, Shield, Terminal } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { AdminUnauthorizedError, API_BASE, adminFetchJson, clearAdminToken } from '@/lib/admin-api'
export default function SettingsPage() {
const router = useRouter()
const [username, setUsername] = useState<string>('')
const [tokenExp, setTokenExp] = useState<number | null>(null)
const [healthOk, setHealthOk] = useState<boolean | null>(null)
const [oldPassword, setOldPassword] = useState('')
const [newPassword, setNewPassword] = useState('')
const [confirmPassword, setConfirmPassword] = useState('')
const [isSaving, setIsSaving] = useState(false)
const [error, setError] = useState('')
const [success, setSuccess] = useState('')
const fetchMe = async () => {
setError('')
try {
const data = await adminFetchJson<{ username?: string; exp?: number }>('/api/v1/admin/me')
setUsername(data.username || '')
setTokenExp(typeof data.exp === 'number' ? data.exp : null)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载失败')
}
}
const fetchHealth = async () => {
try {
const res = await fetch(`${API_BASE}/health`)
setHealthOk(res.ok)
} catch {
setHealthOk(false)
}
}
useEffect(() => {
fetchMe()
fetchHealth()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleChangePassword = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setSuccess('')
if (!oldPassword.trim() || !newPassword.trim()) {
setError('请输入原密码和新密码')
return
}
if (newPassword.trim().length < 8) {
setError('新密码至少 8 位')
return
}
if (newPassword !== confirmPassword) {
setError('两次输入的新密码不一致')
return
}
setIsSaving(true)
try {
await adminFetchJson('/api/v1/admin/change-password', {
method: 'POST',
body: JSON.stringify({
old_password: oldPassword,
new_password: newPassword,
}),
})
setSuccess('密码已更新,请重新登录')
clearAdminToken()
router.replace('/login')
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '更新失败')
} finally {
setIsSaving(false)
}
}
const expText = tokenExp ? new Date(tokenExp * 1000).toLocaleString() : '未知'
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-sm text-muted-foreground"></p>
</div>
{(error || success) && (
<div
className={
success
? 'rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-700 dark:text-emerald-300'
: 'rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive'
}
>
{success || error}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-background p-4 space-y-2">
<div className="text-sm text-muted-foreground"></div>
<div className="flex items-center gap-2">
<div className="text-lg font-semibold">{username || '—'}</div>
{username && <Badge variant="secondary"></Badge>}
</div>
<div className="text-xs text-muted-foreground">Token {expText}</div>
</div>
<form onSubmit={handleChangePassword} className="space-y-4">
<div className="space-y-1.5">
<Label></Label>
<Input
type="password"
value={oldPassword}
onChange={(e) => setOldPassword(e.target.value)}
autoComplete="current-password"
/>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
autoComplete="new-password"
/>
<div className="text-xs text-muted-foreground"> 8 </div>
</div>
<div className="space-y-1.5">
<Label></Label>
<Input
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
autoComplete="new-password"
/>
</div>
<Button type="submit" disabled={isSaving} className="w-full">
<KeyRound className="mr-2 h-4 w-4" />
{isSaving ? '更新中...' : '更新密码'}
</Button>
</form>
</CardContent>
</Card>
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Terminal className="h-5 w-5" />
</CardTitle>
<CardDescription> API Base </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-md border bg-background p-4 space-y-2">
<div className="text-sm text-muted-foreground">API Base URL</div>
<div className="font-mono text-xs break-all">{API_BASE}</div>
</div>
<div className="rounded-md border bg-background p-4 space-y-2">
<div className="text-sm text-muted-foreground">API </div>
<div className="flex items-center gap-2">
{healthOk === null ? (
<Badge variant="outline"></Badge>
) : healthOk ? (
<Badge variant="success">OK</Badge>
) : (
<Badge variant="destructive"></Badge>
)}
<Button variant="outline" size="sm" onClick={fetchHealth}>
</Button>
</div>
</div>
<div className="text-xs text-muted-foreground">
访/Redis
</div>
<Button
variant="destructive"
onClick={() => {
clearAdminToken()
router.replace('/login')
}}
>
退
</Button>
</CardContent>
</Card>
</div>
</div>
)
}

View File

@@ -1,8 +1,36 @@
'use client'
import { useState, useEffect } from 'react'
import { useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
import { BarChart3, RefreshCcw } from 'lucide-react'
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8000'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { AdminUnauthorizedError, adminFetchJson } from '@/lib/admin-api'
interface Stats {
date: string
@@ -11,6 +39,14 @@ interface Stats {
output_tokens: number
cached_count: number
error_count: number
hourly: {
hour: number
request_count: number
input_tokens: number
output_tokens: number
cached_count: number
error_count: number
}[]
}
interface Realtime {
@@ -19,79 +55,288 @@ interface Realtime {
}
export default function StatsPage() {
const [providerId, setProviderId] = useState(1)
const router = useRouter()
const [providers, setProviders] = useState<{ id: number; name: string; model_id: string; is_default: boolean }[]>([])
const [providerId, setProviderId] = useState<string>('')
const [date, setDate] = useState(() => new Date().toISOString().slice(0, 10))
const [stats, setStats] = useState<Stats | null>(null)
const [realtime, setRealtime] = useState<Realtime>({ rpm: 0, tpm: 0 })
const [error, setError] = useState('')
const [isLoading, setIsLoading] = useState(false)
const getToken = () => localStorage.getItem('admin_token') || ''
const totalTokens = useMemo(() => {
if (!stats) return 0
return stats.input_tokens + stats.output_tokens
}, [stats])
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 cacheHitRate = useMemo(() => {
if (!stats || stats.request_count <= 0) return 0
return stats.cached_count / stats.request_count
}, [stats])
const errorRate = useMemo(() => {
if (!stats || stats.request_count <= 0) return 0
return stats.error_count / stats.request_count
}, [stats])
const maxHourlyRequest = useMemo(() => {
if (!stats) return 0
return Math.max(0, ...stats.hourly.map(h => h.request_count))
}, [stats])
const fetchProviders = async () => {
setError('')
try {
const data = await adminFetchJson<{
id: number
name: string
model_id: string
is_default: boolean
}[]>('/api/v1/admin/providers')
setProviders(data)
const defaultOne = data.find(p => p.is_default) || data[0] || null
if (defaultOne) setProviderId(String(defaultOne.id))
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载 Provider 失败')
}
}
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())
if (!providerId) return
try {
const data = await adminFetchJson<Realtime>(`/api/v1/admin/stats/realtime/${providerId}`)
setRealtime(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
}
}
const fetchStats = async () => {
if (!providerId) return
setIsLoading(true)
setError('')
try {
const data = await adminFetchJson<Stats>(`/api/v1/admin/stats/daily/${providerId}?date=${date}`)
setStats(data)
} catch (err: any) {
if (err instanceof AdminUnauthorizedError) {
router.replace('/login')
return
}
setError(err?.message || '加载统计失败')
} finally {
setIsLoading(false)
}
}
useEffect(() => {
fetchProviders()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
useEffect(() => {
if (!providerId) return
fetchStats()
fetchRealtime()
const interval = setInterval(fetchRealtime, 5000)
return () => clearInterval(interval)
}, [providerId])
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [providerId, date])
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 className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">使</h1>
<p className="text-sm text-muted-foreground">
RPM/TPM +
</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 className="flex items-center gap-2">
<Button variant="outline" onClick={fetchStats} disabled={isLoading || !providerId}>
<RefreshCcw className="mr-2 h-4 w-4" />
</Button>
<Button asChild variant="secondary">
<Link href="/admin/providers">
<BarChart3 className="mr-2 h-4 w-4" />
Provider
</Link>
</Button>
</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>
{error && (
<div className="rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between sm:space-y-0">
<div>
<CardTitle></CardTitle>
<CardDescription> Provider </CardDescription>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2 w-full sm:w-[520px]">
<Select value={providerId} onValueChange={setProviderId}>
<SelectTrigger>
<SelectValue placeholder="选择 Provider" />
</SelectTrigger>
<SelectContent>
{providers.map(p => (
<SelectItem key={p.id} value={String(p.id)}>
{p.name} ({p.model_id}){p.is_default ? ' · 默认' : ''}
</SelectItem>
))}
</SelectContent>
</Select>
<Input type="date" value={date} onChange={(e) => setDate(e.target.value)} />
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>RPM</CardDescription>
<CardTitle className="text-3xl">{realtime.rpm}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription>TPM</CardDescription>
<CardTitle className="text-3xl">{realtime.tpm}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{stats ? `${(cacheHitRate * 100).toFixed(1)}%` : '--'}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{stats ? `${stats.cached_count} / ${stats.request_count}` : ''}
</CardContent>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">
{stats ? `${(errorRate * 100).toFixed(1)}%` : '--'}
</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
{stats ? `${stats.error_count} / ${stats.request_count}` : ''}
</CardContent>
</Card>
</div>
{stats && (
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription></CardDescription>
<CardTitle className="text-3xl">{stats.request_count}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription> Token</CardDescription>
<CardTitle className="text-3xl">{totalTokens}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription> Token</CardDescription>
<CardTitle className="text-3xl">{stats.input_tokens}</CardTitle>
</CardHeader>
</Card>
<Card className="border-muted">
<CardHeader className="pb-2">
<CardDescription> Token</CardDescription>
<CardTitle className="text-3xl">{stats.output_tokens}</CardTitle>
</CardHeader>
</Card>
</div>
)}
</CardContent>
</Card>
<Card className="border-muted bg-background/50 backdrop-blur-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription>0-23 </CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{!stats ? (
<div className="text-sm text-muted-foreground"></div>
) : (
<>
<div className="flex items-end gap-1 h-28 overflow-x-auto rounded-md border bg-background p-3">
{stats.hourly.map((h) => {
const pct = maxHourlyRequest ? h.request_count / maxHourlyRequest : 0
return (
<div key={h.hour} className="flex flex-col items-center gap-1 min-w-[18px]">
<div
className="w-3 rounded-sm bg-primary/70"
style={{ height: `${Math.max(2, Math.round(pct * 96))}px` }}
title={`${h.hour}:00 请求:${h.request_count} 缓存:${h.cached_count} 错误:${h.error_count}`}
/>
<div className="text-[10px] text-muted-foreground">{h.hour}</div>
</div>
)
})}
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Token</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{stats.hourly.map((h) => (
<TableRow key={h.hour}>
<TableCell className="font-mono text-xs">{String(h.hour).padStart(2, '0')}:00</TableCell>
<TableCell>{h.request_count}</TableCell>
<TableCell>{h.input_tokens + h.output_tokens}</TableCell>
<TableCell>{h.cached_count}</TableCell>
<TableCell>{h.error_count}</TableCell>
<TableCell>
{h.error_count > 0 ? (
<Badge variant="warning"></Badge>
) : h.request_count > 0 ? (
<Badge variant="success"></Badge>
) : (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</CardContent>
</Card>
</div>
)
}
}

View File

@@ -2,6 +2,82 @@
@tailwind components;
@tailwind utilities;
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
@layer base {
:root {
/* Base - Cool Gray/Slate tone for clean look */
--background: 0 0% 100%;
--foreground: 222 47% 11%;
--card: 0 0% 100%;
--card-foreground: 222 47% 11%;
--popover: 0 0% 100%;
--popover-foreground: 222 47% 11%;
/* Primary - Professional Deep Blue (Inter/Stripe style) */
--primary: 221.2 83.2% 53.3%;
--primary-foreground: 210 40% 98%;
/* Secondary - Subtle Gray */
--secondary: 210 40% 96.1%;
--secondary-foreground: 222 47% 11%;
/* Muted */
--muted: 210 40% 96.1%;
--muted-foreground: 215 16% 47%;
/* Accent */
--accent: 210 40% 96.1%;
--accent-foreground: 222 47% 11%;
/* Destructive */
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 210 40% 98%;
--border: 214.3 31.8% 91.4%;
--input: 214.3 31.8% 91.4%;
--ring: 221.2 83.2% 53.3%;
--radius: 0.5rem;
}
.dark {
--background: 222 47% 11%;
--foreground: 210 40% 98%;
--card: 222 47% 11%;
--card-foreground: 210 40% 98%;
--popover: 222 47% 11%;
--popover-foreground: 210 40% 98%;
--primary: 217.2 91.2% 59.8%;
--primary-foreground: 222 47% 11%;
--secondary: 217.2 32.6% 17.5%;
--secondary-foreground: 210 40% 98%;
--muted: 217.2 32.6% 17.5%;
--muted-foreground: 215 20.2% 65.1%;
--accent: 217.2 32.6% 17.5%;
--accent-foreground: 210 40% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 210 40% 98%;
--border: 217.2 32.6% 17.5%;
--input: 217.2 32.6% 17.5%;
--ring: 212.7 26.8% 83.9%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -1,76 +1,96 @@
'use client'
import { useState } from 'react'
import { useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
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 [isLoading, setIsLoading] = useState(false)
const router = useRouter()
useEffect(() => {
const token = localStorage.getItem('admin_token')
if (token) router.replace('/admin')
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
const handleLogin = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
setIsLoading(true)
try {
const res = await fetch(
`${API_BASE}/api/v1/admin/login?username=${username}&password=${password}`,
{ method: 'POST' }
)
const res = await fetch(`${API_BASE}/api/v1/admin/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password }),
})
if (!res.ok) {
throw new Error('登录失败')
const data = await res.json().catch(() => null)
throw new Error(data?.detail || '登录失败')
}
const data = await res.json()
localStorage.setItem('admin_token', data.token)
router.push('/admin')
} catch {
setError('用户名或密码错误')
router.replace('/admin')
} catch (err: any) {
setError(err?.message || '用户名或密码错误')
} finally {
setIsLoading(false)
}
}
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>
<div className="min-h-screen bg-background flex items-center justify-center px-4">
<Card className="w-full max-w-md border-muted bg-background/50 backdrop-blur-sm">
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="mb-4 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-sm text-destructive">
{error}
</div>
)}
{error && (
<div className="mb-4 p-3 bg-red-50 text-red-600 rounded">
{error}
</div>
)}
<form onSubmit={handleLogin} className="space-y-4">
<div className="space-y-1.5">
<label className="text-sm font-medium"></label>
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="admin"
autoComplete="username"
/>
</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="space-y-1.5">
<label className="text-sm font-medium"></label>
<input
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
placeholder="••••••••"
autoComplete="current-password"
/>
</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>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading ? '登录中...' : '登录'}
</Button>
</form>
</CardContent>
</Card>
</div>
)
}

View File

@@ -2,12 +2,22 @@ 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>
<main className="min-h-screen bg-background">
<div className="container mx-auto px-4 py-8 md:py-16">
<div className="flex flex-col items-center mb-12 space-y-4">
<h1 className="text-4xl md:text-5xl font-extrabold tracking-tight text-foreground">
AI
</h1>
<p className="text-muted-foreground text-lg text-center max-w-2xl">
</p>
</div>
<TranslatorForm />
<footer className="mt-16 text-center text-sm text-muted-foreground">
<p>&copy; {new Date().getFullYear()} AI Translator. Powered by Next.js & FastAPI.</p>
</footer>
</div>
</main>
)