refactor(config): split modelProvider.tsx into modular directory

- 拆分为 7 个文件:index.ts (barrel), types.ts, utils.ts, 3 个组件, index.tsx (主页面 895行)
- 所有子组件 < 500 行
- 构建零错误
- 功能完全等价
This commit is contained in:
DrSmoothl
2026-03-01 19:58:18 +08:00
parent b800011ed7
commit e1f9936561
6 changed files with 1844 additions and 1815 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,136 @@
import type { TestConnectionResult } from '@/lib/config-api'
import { AlertCircle, CheckCircle2, Loader2, Pencil, Trash2, XCircle, Zap } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import type { APIProvider } from './types'
interface ProviderCardProps {
provider: APIProvider
actualIndex: number
testingProviders: Set<string>
testResults: Map<string, TestConnectionResult>
onEdit: (provider: APIProvider, index: number) => void
onDelete: (index: number) => void
onTest: (name: string) => void
}
export function ProviderCard({
provider,
actualIndex,
testingProviders,
testResults,
onEdit,
onDelete,
onTest,
}: ProviderCardProps) {
const renderTestStatus = () => {
const isTesting = testingProviders.has(provider.name)
const result = testResults.get(provider.name)
if (isTesting) {
return (
<Badge variant="secondary" className="gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
</Badge>
)
}
if (!result) return null
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="rounded-lg border bg-card p-4 space-y-3">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-base truncate">{provider.name}</h3>
{renderTestStatus()}
</div>
<p className="text-xs text-muted-foreground mt-1 break-all">{provider.base_url}</p>
</div>
<div className="flex gap-1 flex-shrink-0">
<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" 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" strokeWidth={2} fill="none" />
</Button>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-sm">
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="font-medium">{provider.client_type}</p>
</div>
<div>
<span className="text-muted-foreground text-xs"></span>
<p className="font-medium">{provider.max_retry}</p>
</div>
<div>
<span className="text-muted-foreground text-xs">()</span>
<p className="font-medium">{provider.timeout}</p>
</div>
<div>
<span className="text-muted-foreground text-xs">()</span>
<p className="font-medium">{provider.retry_interval}</p>
</div>
</div>
</div>
)
}

View File

@@ -0,0 +1,458 @@
import { useCallback, useMemo, useState } from 'react'
import { Check, ChevronsUpDown, Copy, Eye, EyeOff } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Command, CommandEmpty, CommandGroup, CommandInput, CommandItem, CommandList } from '@/components/ui/command'
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { HelpTooltip } from '@/components/ui/help-tooltip'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useToast } from '@/hooks/use-toast'
import { PROVIDER_TEMPLATES } from '../providerTemplates'
import type { APIProvider, FormErrors } from './types'
import { validateProvider } from './utils'
interface ProviderFormProps {
open: boolean
onOpenChange: (open: boolean) => void
editingProvider: APIProvider | null
editingIndex: number | null
providers: APIProvider[]
onSave: (provider: APIProvider, index: number | null) => void
tourState: { isRunning: boolean }
}
export function ProviderForm({
open,
onOpenChange,
editingProvider,
editingIndex,
providers,
onSave,
tourState,
}: ProviderFormProps) {
const [formErrors, setFormErrors] = useState<FormErrors>({})
const [selectedTemplate, setSelectedTemplate] = useState<string>('custom')
const [templateComboboxOpen, setTemplateComboboxOpen] = useState(false)
const [showApiKey, setShowApiKey] = useState(false)
const [localProvider, setLocalProvider] = useState<APIProvider | null>(editingProvider)
const { toast } = useToast()
// 同步外部状态到本地
if (editingProvider !== localProvider && open) {
setLocalProvider(editingProvider)
setFormErrors({})
setShowApiKey(false)
// 检测匹配的模板
if (editingProvider) {
const matchedTemplate = PROVIDER_TEMPLATES.find(
t => t.base_url === editingProvider.base_url && t.client_type === editingProvider.client_type
)
setSelectedTemplate(matchedTemplate?.id || 'custom')
} else {
setSelectedTemplate('custom')
}
}
const isUsingTemplate = useMemo(() => selectedTemplate !== 'custom', [selectedTemplate])
const handleTemplateChange = useCallback((templateId: string) => {
setSelectedTemplate(templateId)
setTemplateComboboxOpen(false)
const template = PROVIDER_TEMPLATES.find(t => t.id === templateId)
if (template && template.id !== 'custom') {
setLocalProvider(prev => ({
...prev!,
name: template.name,
base_url: template.base_url,
client_type: template.client_type,
}))
} else if (template?.id === 'custom') {
setLocalProvider(prev => ({
...prev!,
name: '',
base_url: '',
client_type: 'openai',
}))
}
}, [])
const copyApiKey = useCallback(async () => {
if (!localProvider?.api_key) return
try {
await navigator.clipboard.writeText(localProvider.api_key)
toast({
title: '复制成功',
description: 'API Key 已复制到剪贴板',
})
} catch {
toast({
title: '复制失败',
description: '无法访问剪贴板',
variant: 'destructive',
})
}
}, [localProvider?.api_key, toast])
const handleSaveEdit = () => {
if (!localProvider) return
const { isValid, errors } = validateProvider(localProvider, providers, editingIndex)
if (!isValid) {
setFormErrors(errors)
return
}
setFormErrors({})
onSave(localProvider, editingIndex)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="max-w-[95vw] sm:max-w-2xl max-h-[90vh] overflow-y-auto"
data-tour="provider-dialog"
preventOutsideClose={tourState.isRunning}
>
<DialogHeader>
<DialogTitle>
{editingIndex !== null ? '编辑提供商' : '添加提供商'}
</DialogTitle>
<DialogDescription>
API
</DialogDescription>
</DialogHeader>
<form onSubmit={(e) => { e.preventDefault(); handleSaveEdit(); }} autoComplete="off">
<div className="grid gap-4 py-4">
<div className="grid gap-2" data-tour="provider-template-select">
<Label htmlFor="template"></Label>
<Popover open={templateComboboxOpen} onOpenChange={setTemplateComboboxOpen}>
<PopoverTrigger asChild>
<Button
variant="outline"
role="combobox"
aria-expanded={templateComboboxOpen}
className="w-full justify-between"
>
{selectedTemplate
? PROVIDER_TEMPLATES.find((template) => template.id === selectedTemplate)?.display_name
: "选择提供商模板..."}
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
</Button>
</PopoverTrigger>
<PopoverContent className="p-0" align="start" style={{ width: 'var(--radix-popover-trigger-width)' }}>
<Command>
<CommandInput placeholder="搜索提供商模板..." />
<ScrollArea className="h-[300px]">
<CommandList className="max-h-none overflow-visible">
<CommandEmpty></CommandEmpty>
<CommandGroup>
{PROVIDER_TEMPLATES.map((template) => (
<CommandItem
key={template.id}
value={template.display_name}
onSelect={() => handleTemplateChange(template.id)}
>
<Check
className={`mr-2 h-4 w-4 ${
selectedTemplate === template.id ? "opacity-100" : "opacity-0"
}`}
/>
{template.display_name}
</CommandItem>
))}
</CommandGroup>
</CommandList>
</ScrollArea>
</Command>
</PopoverContent>
</Popover>
<p className="text-xs text-muted-foreground">
URL ,
</p>
</div>
<div className="grid gap-2" data-tour="provider-name-input">
<div className="flex items-center gap-1.5">
<Label htmlFor="name" className={formErrors.name ? 'text-destructive' : ''}> *</Label>
<HelpTooltip
content={
<div className="space-y-2">
<p className="font-medium"></p>
<p> API 便</p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li>使 DeepSeekOpenAI</li>
<li></li>
</ul>
</div>
}
side="right"
maxWidth="350px"
/>
</div>
<Input
id="name"
value={localProvider?.name || ''}
onChange={(e) => {
setLocalProvider((prev) =>
prev ? { ...prev, name: e.target.value } : null
)
if (formErrors.name) {
setFormErrors((prev) => ({ ...prev, name: undefined }))
}
}}
placeholder="例如: DeepSeek, SiliconFlow"
className={formErrors.name ? 'border-destructive focus-visible:ring-destructive' : ''}
/>
{formErrors.name && (
<p className="text-xs text-destructive">{formErrors.name}</p>
)}
</div>
<div className="grid gap-2" data-tour="provider-url-input">
<div className="flex items-center gap-1.5">
<Label htmlFor="base_url" className={formErrors.base_url ? 'text-destructive' : ''}> URL *</Label>
<HelpTooltip
content={
<div className="space-y-2">
<p className="font-medium">API </p>
<p> API URL /v1 </p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li><strong>OpenAI </strong>https://api.openai.com/v1</li>
<li><strong>DeepSeek</strong>https://api.deepseek.com</li>
<li><strong></strong>https://api.siliconflow.cn/v1</li>
<li> URL</li>
</ul>
</div>
}
side="right"
maxWidth="400px"
/>
</div>
<Input
id="base_url"
value={localProvider?.base_url || ''}
onChange={(e) => {
setLocalProvider((prev) =>
prev ? { ...prev, base_url: e.target.value } : null
)
if (formErrors.base_url) {
setFormErrors((prev) => ({ ...prev, base_url: undefined }))
}
}}
placeholder="https://api.example.com/v1"
disabled={isUsingTemplate}
className={`${isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''} ${formErrors.base_url ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/>
{formErrors.base_url && (
<p className="text-xs text-destructive">{formErrors.base_url}</p>
)}
{isUsingTemplate && !formErrors.base_url && (
<p className="text-xs text-muted-foreground">
使 URL ,"自定义"
</p>
)}
</div>
<div className="grid gap-2" data-tour="provider-apikey-input">
<div className="flex items-center gap-1.5">
<Label htmlFor="api_key" className={formErrors.api_key ? 'text-destructive' : ''}>API Key *</Label>
<HelpTooltip
content={
<div className="space-y-2">
<p className="font-medium">API </p>
<p></p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li> <code>sk-</code> </li>
<li></li>
<li>/</li>
<li></li>
</ul>
</div>
}
side="right"
maxWidth="350px"
/>
</div>
<div className="flex gap-2">
<Input
id="api_key"
type={showApiKey ? 'text' : 'password'}
value={localProvider?.api_key || ''}
onChange={(e) => {
setLocalProvider((prev) =>
prev ? { ...prev, api_key: e.target.value } : null
)
if (formErrors.api_key) {
setFormErrors((prev) => ({ ...prev, api_key: undefined }))
}
}}
placeholder="sk-..."
className={`flex-1 ${formErrors.api_key ? 'border-destructive focus-visible:ring-destructive' : ''}`}
/>
<Button
type="button"
variant="outline"
size="icon"
onClick={() => setShowApiKey(!showApiKey)}
title={showApiKey ? '隐藏密钥' : '显示密钥'}
>
{showApiKey ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</Button>
<Button
type="button"
variant="outline"
size="icon"
onClick={copyApiKey}
title="复制密钥"
>
<Copy className="h-4 w-4" />
</Button>
</div>
{formErrors.api_key && (
<p className="text-xs text-destructive">{formErrors.api_key}</p>
)}
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="client_type"></Label>
<HelpTooltip
content={
<div className="space-y-2">
<p className="font-medium">API </p>
<p>使 API </p>
<ul className="list-disc list-inside space-y-1 text-xs">
<li><strong>OpenAI</strong> OpenAI API </li>
<li><strong>Gemini</strong>Google Gemini </li>
<li> OpenAI </li>
</ul>
</div>
}
side="right"
maxWidth="350px"
/>
</div>
<Select
value={localProvider?.client_type || 'openai'}
onValueChange={(value) =>
setLocalProvider((prev) =>
prev ? { ...prev, client_type: value } : null
)
}
disabled={isUsingTemplate}
>
<SelectTrigger id="client_type" className={isUsingTemplate ? 'bg-muted cursor-not-allowed' : ''}>
<SelectValue placeholder="选择客户端类型" />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="gemini">Gemini</SelectItem>
</SelectContent>
</Select>
{isUsingTemplate && (
<p className="text-xs text-muted-foreground">
使,"自定义"
</p>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="grid gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="max_retry"></Label>
<HelpTooltip
content="API 请求失败时的最大重试次数。设置为 0 表示不重试。默认值2"
side="top"
maxWidth="250px"
/>
</div>
<Input
id="max_retry"
type="number"
min="0"
value={localProvider?.max_retry ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseInt(e.target.value)
setLocalProvider((prev) =>
prev ? { ...prev, max_retry: val } : null
)
}}
placeholder="默认: 2"
/>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="timeout">()</Label>
<HelpTooltip
content="单次 API 请求的超时时间。超时后会触发重试或报错。默认值30 秒"
side="top"
maxWidth="250px"
/>
</div>
<Input
id="timeout"
type="number"
min="1"
value={localProvider?.timeout ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseInt(e.target.value)
setLocalProvider((prev) =>
prev ? { ...prev, timeout: val } : null
)
}}
placeholder="默认: 30"
/>
</div>
<div className="grid gap-2">
<div className="flex items-center gap-1.5">
<Label htmlFor="retry_interval">()</Label>
<HelpTooltip
content="两次重试之间的等待时间(秒)。适当的间隔可以避免触发 API 限流。默认值10 秒"
side="top"
maxWidth="250px"
/>
</div>
<Input
id="retry_interval"
type="number"
min="1"
value={localProvider?.retry_interval ?? ''}
onChange={(e) => {
const val = e.target.value === '' ? null : parseInt(e.target.value)
setLocalProvider((prev) =>
prev
? { ...prev, retry_interval: val }
: null
)
}}
placeholder="默认: 10"
/>
</div>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} data-tour="provider-cancel-button">
</Button>
<Button type="submit" data-tour="provider-save-button"></Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,353 @@
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>
<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>
)}
</>
)
}

View File

@@ -1,11 +1,2 @@
/**
* 模型提供商配置模块
*
* 模块结构:
* - types.ts: 类型定义
* - utils.ts: 工具函数
* - 主组件在上级目录的 modelProvider.tsx
*/
export * from './types'
export * from './utils'
export { ModelProviderConfigPage } from './index.tsx'
export type * from './types'

View File

@@ -0,0 +1,895 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { getModelConfig, testProviderConnection, updateModelConfig, updateModelConfigSection } from '@/lib/config-api'
import type { TestConnectionResult } from '@/lib/config-api'
import { Info, Plus, Power, Save, Trash2, Zap } from 'lucide-react'
import { Alert, AlertDescription } from '@/components/ui/alert'
import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, AlertDialogTrigger } from '@/components/ui/alert-dialog'
import { Button } from '@/components/ui/button'
import { ScrollArea } from '@/components/ui/scroll-area'
import { MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps, STEP_ROUTE_MAP } from '@/components/tour/tours/model-assignment-tour'
import { useTour } from '@/components/tour'
import { useToast } from '@/hooks/use-toast'
import { RestartOverlay } from '@/components/restart-overlay'
import { RestartProvider, useRestart } from '@/lib/restart-context'
import { ProviderForm } from './ProviderForm'
import { ProviderList } from './ProviderList'
import type { APIProvider, DeleteConfirmState } from './types'
import { cleanProviderData } from './utils'
export function ModelProviderConfigPage() {
return (
<RestartProvider>
<ModelProviderConfigPageContent />
</RestartProvider>
)
}
function ModelProviderConfigPageContent() {
const [providers, setProviders] = useState<APIProvider[]>([])
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [autoSaving, setAutoSaving] = useState(false)
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
const [editingProvider, setEditingProvider] = useState<APIProvider | null>(null)
const [editingIndex, setEditingIndex] = useState<number | null>(null)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [deletingIndex, setDeletingIndex] = useState<number | null>(null)
const [selectedProviders, setSelectedProviders] = useState<Set<number>>(new Set())
const [batchDeleteDialogOpen, setBatchDeleteDialogOpen] = useState(false)
const [deleteConfirmState, setDeleteConfirmState] = useState<DeleteConfirmState>({
isOpen: false,
providersToDelete: [],
affectedModels: [],
pendingProviders: [],
context: 'auto',
oldProviders: [],
})
const [testingProviders, setTestingProviders] = useState<Set<string>>(new Set())
const [testResults, setTestResults] = useState<Map<string, TestConnectionResult>>(new Map())
const { toast } = useToast()
const navigate = useNavigate()
const { state: tourState, goToStep, registerTour } = useTour()
const { triggerRestart, isRestarting } = useRestart()
const autoSaveTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const initialLoadRef = useRef(true)
const prevTourStepRef = useRef(tourState.stepIndex)
// 注册 Tour
useEffect(() => {
registerTour(MODEL_ASSIGNMENT_TOUR_ID, modelAssignmentTourSteps)
}, [registerTour])
// 监听 Tour 步骤变化,处理页面导航
useEffect(() => {
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
const targetRoute = STEP_ROUTE_MAP[tourState.stepIndex]
if (targetRoute && !window.location.pathname.endsWith(targetRoute.replace('/config/', ''))) {
navigate({ to: targetRoute })
}
}
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning, navigate])
// 监听 Tour 步骤变化,处理弹窗的打开和关闭
useEffect(() => {
if (tourState.activeTourId === MODEL_ASSIGNMENT_TOUR_ID && tourState.isRunning) {
const prevStep = prevTourStepRef.current
const currentStep = tourState.stepIndex
if (prevStep >= 3 && prevStep <= 9 && currentStep < 3) {
setEditDialogOpen(false)
}
if (prevStep >= 10 && currentStep >= 3 && currentStep <= 9) {
setEditingProvider({
name: '',
base_url: '',
api_key: '',
client_type: 'openai',
max_retry: 2,
timeout: 30,
retry_interval: 10,
})
setEditingIndex(null)
setEditDialogOpen(true)
}
prevTourStepRef.current = currentStep
}
}, [tourState.stepIndex, tourState.activeTourId, tourState.isRunning])
// 处理 Tour 中需要用户点击才能继续的步骤
useEffect(() => {
if (tourState.activeTourId !== MODEL_ASSIGNMENT_TOUR_ID || !tourState.isRunning) return
const handleTourClick = (e: MouseEvent) => {
const target = e.target as HTMLElement
const currentStep = tourState.stepIndex
if (currentStep === 2 && target.closest('[data-tour="add-provider-button"]')) {
setTimeout(() => goToStep(3), 300)
} else if (currentStep === 9 && target.closest('[data-tour="provider-cancel-button"]')) {
setTimeout(() => goToStep(10), 300)
}
}
document.addEventListener('click', handleTourClick, true)
return () => document.removeEventListener('click', handleTourClick, true)
}, [tourState, goToStep])
// 加载配置
useEffect(() => {
loadConfig()
}, [])
const loadConfig = async () => {
try {
setLoading(true)
const result = await getModelConfig()
if (!result.success) {
toast({
title: '加载失败',
description: result.error,
variant: 'destructive',
})
setLoading(false)
return
}
const config = result.data
setProviders((config.api_providers as APIProvider[]) || [])
setHasUnsavedChanges(false)
initialLoadRef.current = false
} catch (error) {
console.error('加载配置失败:', error)
} finally {
setLoading(false)
}
}
const handleRestart = async () => {
await triggerRestart()
}
const handleSaveAndRestart = async () => {
try {
setSaving(true)
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
const cleanedProviders = providers.map(provider => ({
...provider,
max_retry: provider.max_retry ?? 2,
timeout: provider.timeout ?? 30,
retry_interval: provider.retry_interval ?? 10,
}))
const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'restart')
if (!shouldProceed) {
setSaving(false)
return
}
const resultGet = await getModelConfig()
if (!resultGet.success) {
toast({
title: '保存失败',
description: resultGet.error,
variant: 'destructive',
})
setSaving(false)
return
}
const config = resultGet.data
const validProviderNames = new Set(cleanedProviders.map(p => p.name))
const originalModels = (config.models as any[]) || []
const filteredModels = originalModels.filter((model: any) => {
return validProviderNames.has(model.api_provider)
})
config.api_providers = cleanedProviders
config.models = filteredModels
const resultUpdate = await updateModelConfig(config)
if (!resultUpdate.success) {
toast({
title: '保存失败',
description: resultUpdate.error,
variant: 'destructive',
})
setSaving(false)
return
}
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '正在重启麦麦...',
})
await handleRestart()
} catch (error) {
console.error('保存配置失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
setSaving(false)
}
}
const checkDeleteProviderImpact = useCallback(async (
newProviders: APIProvider[],
context: 'auto' | 'manual' | 'restart' = 'auto'
) => {
try {
const result = await getModelConfig()
if (!result.success) {
console.error('加载配置失败:', result.error)
return { shouldProceed: true, providers: newProviders }
}
const config = result.data
const oldProviderNames = new Set(providers.map(p => p.name))
const newProviderNames = new Set(newProviders.map(p => p.name))
const deletedProviders = Array.from(oldProviderNames).filter(
name => !newProviderNames.has(name)
)
if (deletedProviders.length === 0) {
return { shouldProceed: true, providers: newProviders }
}
const models = (config.models as any[]) || []
const affected = models.filter((m: any) =>
deletedProviders.includes(m.api_provider)
)
if (affected.length === 0) {
return { shouldProceed: true, providers: newProviders }
}
setDeleteConfirmState({
isOpen: true,
providersToDelete: deletedProviders,
affectedModels: affected,
pendingProviders: newProviders,
context,
oldProviders: [...providers],
})
return { shouldProceed: false, providers: newProviders }
} catch (error) {
console.error('检查删除影响失败:', error)
return { shouldProceed: true, providers: newProviders }
}
}, [providers])
const handleConfirmDeleteProvider = async () => {
try {
const savingFlag = deleteConfirmState.context === 'auto' ? setAutoSaving : setSaving
savingFlag(true)
setDeleteConfirmState(prev => ({ ...prev, isOpen: false }))
const resultGet = await getModelConfig()
if (!resultGet.success) {
toast({
title: '加载失败',
description: resultGet.error,
variant: 'destructive',
})
savingFlag(false)
return
}
const config = resultGet.data
const cleanedProviders = deleteConfirmState.pendingProviders.map(cleanProviderData)
const validProviderNames = new Set(cleanedProviders.map(p => p.name))
const originalModels = (config.models as any[]) || []
const filteredModels = originalModels.filter((model: any) => {
return validProviderNames.has(model.api_provider)
})
const deletedModelNames = new Set(
deleteConfirmState.affectedModels.map((m: any) => m.name)
)
const modelTaskConfig = config.model_task_config as any
if (modelTaskConfig) {
Object.keys(modelTaskConfig).forEach(taskName => {
const task = modelTaskConfig[taskName]
if (task && Array.isArray(task.model_list)) {
task.model_list = task.model_list.filter(
(modelName: string) => !deletedModelNames.has(modelName)
)
}
})
}
config.api_providers = cleanedProviders
config.models = filteredModels
config.model_task_config = modelTaskConfig
const resultUpdate = await updateModelConfig(config)
if (!resultUpdate.success) {
toast({
title: '保存失败',
description: resultUpdate.error,
variant: 'destructive',
})
savingFlag(false)
return
}
setProviders(deleteConfirmState.pendingProviders)
setHasUnsavedChanges(false)
toast({
title: '删除成功',
description: `已删除 ${deleteConfirmState.providersToDelete.length} 个提供商和 ${deleteConfirmState.affectedModels.length} 个关联模型`,
})
setDeleteConfirmState({
isOpen: false,
providersToDelete: [],
affectedModels: [],
pendingProviders: [],
context: 'auto',
oldProviders: [],
})
setSelectedProviders(new Set())
if (deleteConfirmState.context === 'restart') {
await handleRestart()
}
} catch (error) {
console.error('删除失败:', error)
toast({
title: '删除失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
if (deleteConfirmState.context === 'auto') {
setAutoSaving(false)
} else {
setSaving(false)
}
}
}
const handleCancelDeleteProvider = () => {
if (deleteConfirmState.oldProviders.length > 0) {
setProviders(deleteConfirmState.oldProviders)
}
setDeleteConfirmState({
isOpen: false,
providersToDelete: [],
affectedModels: [],
pendingProviders: [],
context: 'auto',
oldProviders: [],
})
setHasUnsavedChanges(false)
}
const autoSaveProviders = useCallback(async (newProviders: APIProvider[]) => {
if (initialLoadRef.current) return
const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'auto')
if (!shouldProceed) {
setHasUnsavedChanges(true)
return
}
try {
setAutoSaving(true)
const cleanedProviders = newProviders.map(cleanProviderData)
const result = await updateModelConfigSection('api_providers', cleanedProviders)
if (!result.success) {
console.error('自动保存失败:', result.error)
toast({
title: '自动保存失败',
description: result.error,
variant: 'destructive',
})
setHasUnsavedChanges(true)
return
}
setHasUnsavedChanges(false)
} catch (error) {
console.error('自动保存失败:', error)
toast({
title: '自动保存失败',
description: (error as Error).message,
variant: 'destructive',
})
setHasUnsavedChanges(true)
} finally {
setAutoSaving(false)
}
}, [providers, checkDeleteProviderImpact])
useEffect(() => {
if (initialLoadRef.current) return
setHasUnsavedChanges(true)
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
autoSaveTimerRef.current = setTimeout(() => {
autoSaveProviders(providers)
}, 2000)
return () => {
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
}
}, [providers, autoSaveProviders])
const saveConfig = async () => {
try {
setSaving(true)
if (autoSaveTimerRef.current) {
clearTimeout(autoSaveTimerRef.current)
}
const cleanedProviders = providers.map(cleanProviderData)
const { shouldProceed } = await checkDeleteProviderImpact(cleanedProviders, 'manual')
if (!shouldProceed) {
setSaving(false)
return
}
const resultGet = await getModelConfig()
if (!resultGet.success) {
toast({
title: '保存失败',
description: resultGet.error,
variant: 'destructive',
})
setSaving(false)
return
}
const config = resultGet.data
const validProviderNames = new Set(cleanedProviders.map(p => p.name))
const originalModels = (config.models as any[]) || []
const filteredModels = originalModels.filter((model: any) => {
const isValid = validProviderNames.has(model.api_provider)
if (!isValid) {
console.warn(`模型 "${model.name}" 引用了已删除的提供商 "${model.api_provider}"、将被移除`)
}
return isValid
})
if (originalModels.length !== filteredModels.length) {
const removedCount = originalModels.length - filteredModels.length
toast({
title: '注意',
description: `已自动移除 ${removedCount} 个引用已删除提供商的模型`,
variant: 'default',
})
}
console.log('发送的 providers 数据:', cleanedProviders)
config.api_providers = cleanedProviders
config.models = filteredModels
console.log('完整配置数据:', config)
const resultUpdate = await updateModelConfig(config)
if (!resultUpdate.success) {
toast({
title: '保存失败',
description: resultUpdate.error,
variant: 'destructive',
})
setSaving(false)
return
}
setHasUnsavedChanges(false)
toast({
title: '保存成功',
description: '模型提供商配置已保存',
})
} catch (error) {
console.error('保存配置失败:', error)
toast({
title: '保存失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setSaving(false)
}
}
const openEditDialog = (provider: APIProvider | null, index: number | null) => {
if (provider) {
setEditingProvider(provider)
} else {
setEditingProvider({
name: '',
base_url: '',
api_key: '',
client_type: 'openai',
max_retry: 2,
timeout: 30,
retry_interval: 10,
})
}
setEditingIndex(index)
setEditDialogOpen(true)
}
const handleSaveEdit = (provider: APIProvider, index: number | null) => {
const providerToSave = cleanProviderData(provider)
if (index !== null) {
const newProviders = [...providers]
newProviders[index] = providerToSave
setProviders(newProviders)
} else {
setProviders([...providers, providerToSave])
}
setEditDialogOpen(false)
setEditingProvider(null)
setEditingIndex(null)
}
const openDeleteDialog = (index: number) => {
setDeletingIndex(index)
setDeleteDialogOpen(true)
}
const handleConfirmDelete = async () => {
if (deletingIndex !== null) {
const newProviders = providers.filter((_, i) => i !== deletingIndex)
const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual')
if (shouldProceed) {
setProviders(newProviders)
toast({
title: '删除成功',
description: '提供商已从列表中移除',
})
}
}
setDeleteDialogOpen(false)
setDeletingIndex(null)
}
const toggleProviderSelection = (index: number) => {
const newSelected = new Set(selectedProviders)
if (newSelected.has(index)) {
newSelected.delete(index)
} else {
newSelected.add(index)
}
setSelectedProviders(newSelected)
}
const toggleSelectAll = () => {
if (selectedProviders.size === providers.length) {
setSelectedProviders(new Set())
} else {
const allIndices = providers.map((_, idx) => idx)
setSelectedProviders(new Set(allIndices))
}
}
const openBatchDeleteDialog = () => {
if (selectedProviders.size === 0) {
toast({
title: '提示',
description: '请先选择要删除的提供商',
variant: 'default',
})
return
}
setBatchDeleteDialogOpen(true)
}
const handleConfirmBatchDelete = async () => {
const newProviders = providers.filter((_, index) => !selectedProviders.has(index))
const { shouldProceed } = await checkDeleteProviderImpact(newProviders, 'manual')
if (shouldProceed) {
setProviders(newProviders)
setSelectedProviders(new Set())
toast({
title: '批量删除成功',
description: `已删除 ${selectedProviders.size} 个提供商`,
})
}
setBatchDeleteDialogOpen(false)
}
const handleTestConnection = async (providerName: string) => {
setTestingProviders(prev => new Set(prev).add(providerName))
try {
const result = await testProviderConnection(providerName)
if (!result.success) {
toast({
title: '测试失败',
description: result.error,
variant: 'destructive',
})
return
}
const testResult = result.data
setTestResults(prev => new Map(prev).set(providerName, testResult))
if (testResult.network_ok) {
if (testResult.api_key_valid === true) {
toast({
title: '连接正常',
description: `${providerName} 网络连接正常、API Key 有效 (${testResult.latency_ms}ms)`,
})
} else if (testResult.api_key_valid === false) {
toast({
title: '连接正常但 Key 无效',
description: `${providerName} 网络连接正常、但 API Key 无效或已过期`,
variant: 'destructive',
})
} else {
toast({
title: '网络连接正常',
description: `${providerName} 可以访问 (${testResult.latency_ms}ms)`,
})
}
} else {
toast({
title: '连接失败',
description: testResult.error || '无法连接到提供商',
variant: 'destructive',
})
}
} catch (error) {
toast({
title: '测试失败',
description: (error as Error).message,
variant: 'destructive',
})
} finally {
setTestingProviders(prev => {
const newSet = new Set(prev)
newSet.delete(providerName)
return newSet
})
}
}
const handleTestAllConnections = async () => {
for (const provider of providers) {
await handleTestConnection(provider.name)
}
}
if (loading) {
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
<div className="flex items-center justify-center h-64">
<p className="text-muted-foreground">...</p>
</div>
</div>
)
}
return (
<div className="space-y-4 sm:space-y-6 p-4 sm:p-6">
{/* 页面标题 */}
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl sm:text-3xl font-bold">AI模型厂商配置</h1>
<p className="text-muted-foreground mt-1 sm:mt-2 text-sm sm:text-base"> AI API </p>
</div>
<div className="flex flex-col sm:flex-row gap-2">
{selectedProviders.size > 0 && (
<Button
onClick={openBatchDeleteDialog}
size="sm"
variant="destructive"
className="w-full sm:w-auto"
>
<Trash2 className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
({selectedProviders.size})
</Button>
)}
<Button
onClick={handleTestAllConnections}
size="sm"
variant="outline"
className="w-full sm:w-auto"
disabled={providers.length === 0 || testingProviders.size > 0}
>
<Zap className="mr-2 h-4 w-4" />
{testingProviders.size > 0 ? `测试中 (${testingProviders.size})` : '测试全部'}
</Button>
<Button onClick={() => openEditDialog(null, null)} size="sm" className="w-full sm:w-auto" data-tour="add-provider-button">
<Plus className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
</Button>
<Button
onClick={saveConfig}
disabled={saving || autoSaving || !hasUnsavedChanges || isRestarting}
size="sm"
variant="outline"
className="w-full sm:w-auto sm:min-w-[120px]"
>
<Save className="mr-2 h-4 w-4" strokeWidth={2} fill="none" />
{saving ? '保存中...' : autoSaving ? '自动保存中...' : hasUnsavedChanges ? '保存配置' : '已保存'}
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
disabled={saving || autoSaving || isRestarting}
size="sm"
className="w-full sm:w-auto sm:min-w-[120px]"
>
<Power className="mr-2 h-4 w-4" />
{isRestarting ? '重启中...' : hasUnsavedChanges ? '保存并重启' : '重启麦麦'}
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription asChild>
<div>
<p>
{hasUnsavedChanges
? '当前有未保存的配置更改。点击确认将先保存配置,然后重启麦麦使新配置生效。重启过程中麦麦将暂时离线。'
: '即将重启麦麦主程序。重启过程中麦麦将暂时离线,配置将在重启后生效。'
}
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={hasUnsavedChanges ? handleSaveAndRestart : handleRestart}>
{hasUnsavedChanges ? '保存并重启' : '确认重启'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* 重启提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription>
<strong></strong>"保存并重启"
</AlertDescription>
</Alert>
<ScrollArea className="h-[calc(100vh-260px)]">
<ProviderList
providers={providers}
testingProviders={testingProviders}
testResults={testResults}
selectedProviders={selectedProviders}
onEdit={openEditDialog}
onDelete={openDeleteDialog}
onTest={handleTestConnection}
onToggleSelect={toggleProviderSelection}
onToggleSelectAll={toggleSelectAll}
/>
</ScrollArea>
<ProviderForm
open={editDialogOpen}
onOpenChange={setEditDialogOpen}
editingProvider={editingProvider}
editingIndex={editingIndex}
providers={providers}
onSave={handleSaveEdit}
tourState={tourState}
/>
{/* 删除确认对话框 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deletingIndex !== null ? providers[deletingIndex]?.name : ''}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmDelete}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 批量删除确认对话框 */}
<AlertDialog open={batchDeleteDialogOpen} onOpenChange={setBatchDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
{selectedProviders.size}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmBatchDelete} className="bg-destructive text-destructive-foreground hover:bg-destructive/90">
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 删除提供商影响确认对话框 */}
<AlertDialog open={deleteConfirmState.isOpen} onOpenChange={(open) => setDeleteConfirmState(prev => ({ ...prev, isOpen: open }))}>
<AlertDialogContent className="max-w-2xl">
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription asChild>
<div className="space-y-3">
<p>
<strong className="text-foreground ml-1">
{deleteConfirmState.providersToDelete.join(', ')}
</strong>
</p>
<p className="text-yellow-600 dark:text-yellow-500 font-medium">
{deleteConfirmState.affectedModels.length}
</p>
<ScrollArea className="h-32 w-full rounded border p-3">
<div className="space-y-1">
{deleteConfirmState.affectedModels.map((model: any, idx: number) => (
<div key={idx} className="text-sm">
<span className="font-mono text-muted-foreground"></span>
<span className="ml-2 font-medium">{model.name}</span>
<span className="ml-2 text-xs text-muted-foreground">
({model.model_identifier})
</span>
</div>
))}
</div>
</ScrollArea>
<p className="text-sm text-muted-foreground">
</p>
</div>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={handleCancelDeleteProvider}></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDeleteProvider}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 重启遮罩层 */}
<RestartOverlay />
</div>
)
}