Files
mai-bot/dashboard/src/routes/config/modelProvider/ProviderList.tsx

354 lines
13 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.
import { useCallback, useMemo, useState } from 'react'
import type { TestConnectionResult } from '@/lib/config-api'
import { AlertCircle, CheckCircle2, ChevronLeft, ChevronRight, ChevronsLeft, ChevronsRight, Loader2, Pencil, Search, Trash2, XCircle, Zap } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { ProviderCard } from './ProviderCard'
import type { APIProvider } from './types'
interface ProviderListProps {
providers: APIProvider[]
testingProviders: Set<string>
testResults: Map<string, TestConnectionResult>
selectedProviders: Set<number>
onEdit: (provider: APIProvider, index: number) => void
onDelete: (index: number) => void
onTest: (name: string) => void
onToggleSelect: (index: number) => void
onToggleSelectAll: () => void
}
export function ProviderList({
providers,
testingProviders,
testResults,
selectedProviders,
onEdit,
onDelete,
onTest,
onToggleSelect,
onToggleSelectAll,
}: ProviderListProps) {
const [searchQuery, setSearchQuery] = useState('')
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [jumpToPage, setJumpToPage] = useState('')
const filteredProviders = useMemo(() => {
if (!searchQuery) return providers
const query = searchQuery.toLowerCase()
return providers.filter((provider) => (
provider.name.toLowerCase().includes(query) ||
provider.base_url.toLowerCase().includes(query) ||
provider.client_type.toLowerCase().includes(query)
))
}, [providers, searchQuery])
const { totalPages, paginatedProviders } = useMemo(() => {
const total = Math.ceil(filteredProviders.length / pageSize)
const paginated = filteredProviders.slice(
(page - 1) * pageSize,
page * pageSize
)
return { totalPages: total, paginatedProviders: paginated }
}, [filteredProviders, page, pageSize])
const handleJumpToPage = useCallback(() => {
const targetPage = parseInt(jumpToPage)
if (targetPage >= 1 && targetPage <= totalPages) {
setPage(targetPage)
setJumpToPage('')
}
}, [jumpToPage, totalPages])
const renderTestStatus = (providerName: string) => {
const isTesting = testingProviders.has(providerName)
const result = testResults.get(providerName)
if (isTesting) {
return (
<Badge variant="secondary" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
</Badge>
)
}
if (!result) {
return (
<Badge variant="outline" className="text-muted-foreground">
</Badge>
)
}
if (result.network_ok) {
if (result.api_key_valid === true) {
return (
<Badge className="gap-1 bg-green-600 hover:bg-green-700">
<CheckCircle2 className="h-3 w-3" />
</Badge>
)
} else if (result.api_key_valid === false) {
return (
<Badge variant="destructive" className="gap-1">
<AlertCircle className="h-3 w-3" />
Key无效
</Badge>
)
} else {
return (
<Badge className="gap-1 bg-blue-600 hover:bg-blue-700">
<CheckCircle2 className="h-3 w-3" />
访
</Badge>
)
}
} else {
return (
<Badge variant="destructive" className="gap-1">
<XCircle className="h-3 w-3" />
线
</Badge>
)
}
}
return (
<>
{/* 搜索框 */}
<div className="flex flex-col sm:flex-row items-start sm:items-center gap-2 mb-4">
<div className="relative w-full sm:flex-1 sm:max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索提供商名称、URL 或类型..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-9"
/>
</div>
{searchQuery && (
<p className="text-sm text-muted-foreground whitespace-nowrap">
{filteredProviders.length}
</p>
)}
</div>
{/* 移动端卡片视图 */}
<div className="md:hidden space-y-3">
{filteredProviders.length === 0 ? (
<div className="text-center text-muted-foreground py-8 rounded-lg border bg-card">
{searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'}
</div>
) : (
paginatedProviders.map((provider, displayIndex) => {
const actualIndex = providers.findIndex(p => p === provider)
return (
<ProviderCard
key={displayIndex}
provider={provider}
actualIndex={actualIndex}
testingProviders={testingProviders}
testResults={testResults}
onEdit={onEdit}
onDelete={onDelete}
onTest={onTest}
/>
)
})
)}
</div>
{/* 桌面端表格视图 */}
<div className="hidden md:block rounded-lg border bg-card overflow-hidden">
<div className="overflow-x-auto">
<Table aria-label="AI 模型提供商列表">
<TableHeader>
<TableRow>
<TableHead className="w-12">
<Checkbox
checked={selectedProviders.size === filteredProviders.length && filteredProviders.length > 0}
onCheckedChange={onToggleSelectAll}
/>
</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>URL</TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">()</TableHead>
<TableHead className="text-right">()</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{paginatedProviders.length === 0 ? (
<TableRow>
<TableCell colSpan={9} className="text-center text-muted-foreground py-8">
{searchQuery ? '未找到匹配的提供商' : '暂无提供商配置,点击"添加提供商"开始配置'}
</TableCell>
</TableRow>
) : (
paginatedProviders.map((provider, displayIndex) => {
const actualIndex = providers.findIndex(p => p === provider)
return (
<TableRow key={displayIndex}>
<TableCell>
<Checkbox
checked={selectedProviders.has(actualIndex)}
onCheckedChange={() => onToggleSelect(actualIndex)}
/>
</TableCell>
<TableCell>
{renderTestStatus(provider.name)}
</TableCell>
<TableCell className="font-medium">{provider.name}</TableCell>
<TableCell className="max-w-xs truncate" title={provider.base_url}>
{provider.base_url}
</TableCell>
<TableCell>{provider.client_type}</TableCell>
<TableCell className="text-right">{provider.max_retry}</TableCell>
<TableCell className="text-right">{provider.timeout}</TableCell>
<TableCell className="text-right">{provider.retry_interval}</TableCell>
<TableCell className="text-right">
<div className="flex justify-end gap-2">
<Button
variant="outline"
size="sm"
onClick={() => onTest(provider.name)}
disabled={testingProviders.has(provider.name)}
title="测试连接"
>
{testingProviders.has(provider.name) ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Zap className="h-4 w-4" />
)}
</Button>
<Button
variant="default"
size="sm"
onClick={() => onEdit(provider, actualIndex)}
>
<Pencil className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
</Button>
<Button
size="sm"
onClick={() => onDelete(actualIndex)}
className="bg-red-600 hover:bg-red-700 text-white"
>
<Trash2 className="h-4 w-4 mr-1" strokeWidth={2} fill="none" />
</Button>
</div>
</TableCell>
</TableRow>
)
})
)}
</TableBody>
</Table>
</div>
</div>
{/* 分页 */}
{filteredProviders.length > 0 && (
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 mt-4">
<div className="flex items-center gap-2">
<Label htmlFor="page-size-provider" className="text-sm whitespace-nowrap"></Label>
<Select
value={pageSize.toString()}
onValueChange={(value) => {
setPageSize(parseInt(value))
setPage(1)
}}
>
<SelectTrigger id="page-size-provider" className="w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10</SelectItem>
<SelectItem value="20">20</SelectItem>
<SelectItem value="50">50</SelectItem>
<SelectItem value="100">100</SelectItem>
</SelectContent>
</Select>
<span className="text-sm text-muted-foreground">
{(page - 1) * pageSize + 1} {' '}
{Math.min(page * pageSize, filteredProviders.length)} {filteredProviders.length}
</span>
</div>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => setPage(1)}
disabled={page === 1}
className="hidden sm:flex"
>
<ChevronsLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page === 1}
>
<ChevronLeft className="h-4 w-4 sm:mr-1" />
<span className="hidden sm:inline"></span>
</Button>
<div className="flex items-center gap-2">
<Input
type="number"
value={jumpToPage}
onChange={(e) => setJumpToPage(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleJumpToPage()}
placeholder={page.toString()}
className="w-16 h-8 text-center"
min={1}
max={totalPages}
/>
<Button
variant="outline"
size="sm"
onClick={handleJumpToPage}
disabled={!jumpToPage}
className="h-8"
>
</Button>
</div>
<Button
variant="outline"
size="sm"
onClick={() => setPage((p) => p + 1)}
disabled={page >= totalPages}
>
<span className="hidden sm:inline"></span>
<ChevronRight className="h-4 w-4 sm:ml-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={() => setPage(totalPages)}
disabled={page >= totalPages}
className="hidden sm:flex"
>
<ChevronsRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)
}