Files
AI_Translator/apps/web/app/admin/providers/page.tsx
2025-12-26 16:03:12 +08:00

523 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useEffect, useMemo, useState } from 'react'
import { useRouter } from 'next/navigation'
import { Pencil, Plus, RefreshCcw, Star, Trash2, Wand2, ZapOff } from 'lucide-react'
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
name: string
model_id: string
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 [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 fetchProviders = async () => {
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)
}
}
useEffect(() => {
fetchProviders()
}, [])
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])
const openCreate = () => {
setEditing(null)
setTestResult(null)
setForm({
name: '',
model_id: '',
base_url: '',
api_key: '',
is_active: true,
is_default: false,
})
setDialogOpen(true)
}
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,
})
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 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>
)}
<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>
)}
</div>
)}
<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-1.5">
<Label>Base URL</Label>
<Input
value={form.base_url}
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
<div className="text-xs text-muted-foreground">
使 OpenAI Base URL
</div>
</div>
<div className="space-y-1.5">
<Label>API Key</Label>
<Textarea
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
placeholder={editing ? '留空则不修改' : 'sk-***'}
className="min-h-[80px] font-mono text-xs"
/>
</div>
<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="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>
</CardContent>
<CardFooter className="justify-end gap-2">
<Button
variant="outline"
onClick={() => {
setDialogOpen(false)
setEditing(null)
setTestResult(null)
}}
disabled={isSaving}
>
</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>
)
}