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