Files
mai-bot/dashboard/src/routes/config/modelProvider/ProviderForm.tsx
DrSmoothl e1f9936561 refactor(config): split modelProvider.tsx into modular directory
- 拆分为 7 个文件:index.ts (barrel), types.ts, utils.ts, 3 个组件, index.tsx (主页面 895行)
- 所有子组件 < 500 行
- 构建零错误
- 功能完全等价
2026-03-01 19:58:18 +08:00

459 lines
19 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 { 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>
)
}