523 lines
18 KiB
TypeScript
523 lines
18 KiB
TypeScript
'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 Provider(模型、Base URL、API 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>
|
||
)
|
||
}
|