Files
zclaw_openfang/admin/src/app/(dashboard)/models/page.tsx
iven 5fdf96c3f5 chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
2026-03-29 10:46:41 +08:00

415 lines
14 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 { useState } from 'react'
import useSWR from 'swr'
import {
Plus,
Loader2,
ChevronLeft,
ChevronRight,
Pencil,
Trash2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { TableSkeleton } from '@/components/ui/skeleton'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatNumber } from '@/lib/utils'
import type { Model, Provider } from '@/lib/types'
const PAGE_SIZE = 20
interface ModelForm {
provider_id: string
model_id: string
alias: string
context_window: string
max_output_tokens: string
supports_streaming: boolean
supports_vision: boolean
enabled: boolean
pricing_input: string
pricing_output: string
}
const emptyForm: ModelForm = {
provider_id: '',
model_id: '',
alias: '',
context_window: '4096',
max_output_tokens: '4096',
supports_streaming: true,
supports_vision: false,
enabled: true,
pricing_input: '',
pricing_output: '',
}
export default function ModelsPage() {
const [page, setPage] = useState(1)
const [providerFilter, setProviderFilter] = useState<string>('all')
const [error, setError] = useState('')
// SWR for models list
const { data, isLoading, mutate } = useSWR(
['models', page, providerFilter],
() => {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (providerFilter !== 'all') params.provider_id = providerFilter
return api.models.list(params)
}
)
const models = data?.items ?? []
const total = data?.total ?? 0
// SWR for providers list (dropdown)
const { data: providersData } = useSWR(
['providers.all'],
() => api.providers.list({ page: 1, page_size: 100 })
)
const providers = providersData?.items ?? []
// Dialog
const [dialogOpen, setDialogOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Model | null>(null)
const [form, setForm] = useState<ModelForm>(emptyForm)
const [saving, setSaving] = useState(false)
// 删除
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
const [deleting, setDeleting] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
function openCreateDialog() {
setEditTarget(null)
setForm(emptyForm)
setDialogOpen(true)
}
function openEditDialog(model: Model) {
setEditTarget(model)
setForm({
provider_id: model.provider_id,
model_id: model.model_id,
alias: model.alias,
context_window: model.context_window.toString(),
max_output_tokens: model.max_output_tokens.toString(),
supports_streaming: model.supports_streaming,
supports_vision: model.supports_vision,
enabled: model.enabled,
pricing_input: model.pricing_input.toString(),
pricing_output: model.pricing_output.toString(),
})
setDialogOpen(true)
}
async function handleSave() {
if (!form.model_id.trim() || !form.provider_id) return
setSaving(true)
try {
const payload = {
provider_id: form.provider_id,
model_id: form.model_id.trim(),
alias: form.alias.trim(),
context_window: parseInt(form.context_window, 10) || 4096,
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
supports_streaming: form.supports_streaming,
supports_vision: form.supports_vision,
enabled: form.enabled,
pricing_input: parseFloat(form.pricing_input) || 0,
pricing_output: parseFloat(form.pricing_output) || 0,
}
if (editTarget) {
await api.models.update(editTarget.id, payload)
} else {
await api.models.create(payload)
}
setDialogOpen(false)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
await api.models.delete(deleteTarget.id)
setDeleteTarget(null)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setDeleting(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="按服务商筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
{isLoading ? (
<TableSkeleton rows={8} cols={9} hasToolbar={false} />
) : error ? null : models.length === 0 ? (
<EmptyState />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
<TableCell>{m.alias || '-'}</TableCell>
<TableCell className="text-muted-foreground">
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatNumber(m.context_window)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatNumber(m.max_output_tokens)}
</TableCell>
<TableCell>
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
{m.supports_streaming ? '是' : '否'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
{m.supports_vision ? '是' : '否'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={m.enabled ? 'success' : 'destructive'}>
{m.enabled ? '启用' : '禁用'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm">
<p className="text-muted-foreground">
{page} / {totalPages} ({total} )
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
{/* 创建/编辑 Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
<DialogDescription>
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
<div className="space-y-2">
<Label> *</Label>
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
<SelectTrigger>
<SelectValue placeholder="选择服务商" />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> ID *</Label>
<Input
value={form.model_id}
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
placeholder="gpt-4o"
disabled={!!editTarget}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.alias}
onChange={(e) => setForm({ ...form, alias: e.target.value })}
placeholder="GPT-4o"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={form.context_window}
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label> Tokens</Label>
<Input
type="number"
value={form.max_output_tokens}
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Input ($/1M tokens)</Label>
<Input
type="number"
step="0.01"
value={form.pricing_input}
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label>Output ($/1M tokens)</Label>
<Input
type="number"
step="0.01"
value={form.pricing_output}
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
placeholder="0"
/>
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
<Label></Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
<Label></Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
<Label></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
&quot;{deleteTarget?.alias || deleteTarget?.model_id}&quot;
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}