CodeReview/frontend/src/components/system/SystemConfig.tsx

778 lines
31 KiB
TypeScript
Raw Normal View History

import { useState, useEffect } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { Badge } from "@/components/ui/badge";
import {
Settings,
Save,
RotateCcw,
Eye,
EyeOff,
CheckCircle2,
AlertCircle,
Info,
Key,
Zap,
Globe,
Database
} from "lucide-react";
import { toast } from "sonner";
import { api } from "@/shared/api/database";
// LLM 提供商配置
const LLM_PROVIDERS = [
{ value: 'gemini', label: 'Google Gemini', icon: '🔵', category: 'international' },
{ value: 'openai', label: 'OpenAI GPT', icon: '🟢', category: 'international' },
{ value: 'claude', label: 'Anthropic Claude', icon: '🟣', category: 'international' },
{ value: 'deepseek', label: 'DeepSeek', icon: '🔷', category: 'international' },
{ value: 'qwen', label: '阿里云通义千问', icon: '🟠', category: 'domestic' },
{ value: 'zhipu', label: '智谱AI (GLM)', icon: '🔴', category: 'domestic' },
{ value: 'moonshot', label: 'Moonshot (Kimi)', icon: '🌙', category: 'domestic' },
{ value: 'baidu', label: '百度文心一言', icon: '🔵', category: 'domestic' },
{ value: 'minimax', label: 'MiniMax', icon: '⚡', category: 'domestic' },
{ value: 'doubao', label: '字节豆包', icon: '🎯', category: 'domestic' },
{ value: 'ollama', label: 'Ollama 本地模型', icon: '🖥️', category: 'local' },
];
// 默认模型配置
const DEFAULT_MODELS = {
gemini: 'gemini-1.5-flash',
openai: 'gpt-4o-mini',
claude: 'claude-3-5-sonnet-20241022',
qwen: 'qwen-turbo',
deepseek: 'deepseek-chat',
zhipu: 'glm-4-flash',
moonshot: 'moonshot-v1-8k',
baidu: 'ERNIE-3.5-8K',
minimax: 'abab6.5-chat',
doubao: 'doubao-pro-32k',
ollama: 'llama3',
};
interface SystemConfigData {
// LLM 配置
llmProvider: string;
llmApiKey: string;
llmModel: string;
llmBaseUrl: string;
llmTimeout: number;
llmTemperature: number;
llmMaxTokens: number;
llmCustomHeaders: string;
// 平台专用配置
geminiApiKey: string;
openaiApiKey: string;
claudeApiKey: string;
qwenApiKey: string;
deepseekApiKey: string;
zhipuApiKey: string;
moonshotApiKey: string;
baiduApiKey: string;
minimaxApiKey: string;
doubaoApiKey: string;
ollamaBaseUrl: string;
// GitHub 配置
githubToken: string;
// GitLab 配置
gitlabToken: string;
// 分析配置
maxAnalyzeFiles: number;
llmConcurrency: number;
llmGapMs: number;
outputLanguage: string;
}
export function SystemConfig() {
// 初始状态为空,等待从后端加载
const [config, setConfig] = useState<SystemConfigData | null>(null);
const [loading, setLoading] = useState(true);
const [showApiKeys, setShowApiKeys] = useState<Record<string, boolean>>({});
const [hasChanges, setHasChanges] = useState(false);
const [configSource, setConfigSource] = useState<'runtime' | 'build'>('build');
// 加载配置
useEffect(() => {
loadConfig();
}, []);
const loadConfig = async () => {
try {
setLoading(true);
// 先从后端获取默认配置
const defaultConfig = await api.getDefaultConfig();
if (!defaultConfig) {
throw new Error('Failed to get default config from backend');
}
// 从后端加载用户配置(已合并默认配置)
const backendConfig = await api.getUserConfig();
if (backendConfig) {
const mergedConfig: SystemConfigData = {
...defaultConfig.llmConfig,
...defaultConfig.otherConfig,
...(backendConfig.llmConfig || {}),
...(backendConfig.otherConfig || {}),
};
setConfig(mergedConfig);
setConfigSource('runtime');
console.log('已从后端加载用户配置(已合并默认配置)');
} else {
// 使用默认配置
const mergedConfig: SystemConfigData = {
...defaultConfig.llmConfig,
...defaultConfig.otherConfig,
};
setConfig(mergedConfig);
setConfigSource('build');
console.log('使用后端默认配置');
}
} catch (error) {
console.error('Failed to load config:', error);
// 如果后端加载失败,尝试从环境变量加载(后备方案)
const envConfig = loadFromEnv();
setConfig(envConfig);
setConfigSource('build');
} finally {
setLoading(false);
}
};
const loadFromEnv = (): SystemConfigData => {
// 从环境变量加载(后备方案,仅在无法从后端获取时使用)
return {
llmProvider: import.meta.env.VITE_LLM_PROVIDER || 'openai',
llmApiKey: import.meta.env.VITE_LLM_API_KEY || '',
llmModel: import.meta.env.VITE_LLM_MODEL || '',
llmBaseUrl: import.meta.env.VITE_LLM_BASE_URL || '',
llmTimeout: Number(import.meta.env.VITE_LLM_TIMEOUT) || 150000,
llmTemperature: Number(import.meta.env.VITE_LLM_TEMPERATURE) || 0.1,
llmMaxTokens: Number(import.meta.env.VITE_LLM_MAX_TOKENS) || 4096,
llmCustomHeaders: import.meta.env.VITE_LLM_CUSTOM_HEADERS || '',
geminiApiKey: import.meta.env.VITE_GEMINI_API_KEY || '',
openaiApiKey: import.meta.env.VITE_OPENAI_API_KEY || '',
claudeApiKey: import.meta.env.VITE_CLAUDE_API_KEY || '',
qwenApiKey: import.meta.env.VITE_QWEN_API_KEY || '',
deepseekApiKey: import.meta.env.VITE_DEEPSEEK_API_KEY || '',
zhipuApiKey: import.meta.env.VITE_ZHIPU_API_KEY || '',
moonshotApiKey: import.meta.env.VITE_MOONSHOT_API_KEY || '',
baiduApiKey: import.meta.env.VITE_BAIDU_API_KEY || '',
minimaxApiKey: import.meta.env.VITE_MINIMAX_API_KEY || '',
doubaoApiKey: import.meta.env.VITE_DOUBAO_API_KEY || '',
ollamaBaseUrl: import.meta.env.VITE_OLLAMA_BASE_URL || 'http://localhost:11434/v1',
githubToken: import.meta.env.VITE_GITHUB_TOKEN || '',
gitlabToken: import.meta.env.VITE_GITLAB_TOKEN || '',
maxAnalyzeFiles: Number(import.meta.env.VITE_MAX_ANALYZE_FILES) || 50,
llmConcurrency: Number(import.meta.env.VITE_LLM_CONCURRENCY) || 3,
llmGapMs: Number(import.meta.env.VITE_LLM_GAP_MS) || 2000,
outputLanguage: import.meta.env.VITE_OUTPUT_LANGUAGE || 'zh-CN',
};
};
const saveConfig = async () => {
if (!config) {
toast.error('配置未加载,请稍候再试');
return;
}
try {
// 保存到后端
const llmConfig = {
llmProvider: config.llmProvider,
llmApiKey: config.llmApiKey,
llmModel: config.llmModel,
llmBaseUrl: config.llmBaseUrl,
llmTimeout: config.llmTimeout,
llmTemperature: config.llmTemperature,
llmMaxTokens: config.llmMaxTokens,
llmCustomHeaders: config.llmCustomHeaders,
geminiApiKey: config.geminiApiKey,
openaiApiKey: config.openaiApiKey,
claudeApiKey: config.claudeApiKey,
qwenApiKey: config.qwenApiKey,
deepseekApiKey: config.deepseekApiKey,
zhipuApiKey: config.zhipuApiKey,
moonshotApiKey: config.moonshotApiKey,
baiduApiKey: config.baiduApiKey,
minimaxApiKey: config.minimaxApiKey,
doubaoApiKey: config.doubaoApiKey,
ollamaBaseUrl: config.ollamaBaseUrl,
};
const otherConfig = {
githubToken: config.githubToken,
gitlabToken: config.gitlabToken,
maxAnalyzeFiles: config.maxAnalyzeFiles,
llmConcurrency: config.llmConcurrency,
llmGapMs: config.llmGapMs,
outputLanguage: config.outputLanguage,
};
await api.updateUserConfig({
llmConfig,
otherConfig,
});
setHasChanges(false);
setConfigSource('runtime');
// 记录用户操作
import('@/shared/utils/logger').then(({ logger }) => {
logger.logUserAction('保存系统配置', {
provider: config.llmProvider,
hasApiKey: !!config.llmApiKey,
maxFiles: config.maxAnalyzeFiles,
concurrency: config.llmConcurrency,
language: config.outputLanguage,
});
}).catch(() => { });
toast.success("配置已保存到后端!刷新页面后生效");
// 提示用户刷新页面
setTimeout(() => {
if (window.confirm("配置已保存。是否立即刷新页面使配置生效?")) {
window.location.reload();
}
}, 1000);
} catch (error) {
console.error('Failed to save config:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
toast.error(`保存配置失败: ${errorMessage}`);
}
};
const resetConfig = async () => {
if (window.confirm("确定要重置为默认配置吗?这将清除所有用户配置。")) {
try {
// 删除后端配置
await api.deleteUserConfig();
// 重新加载配置(会使用后端默认配置)
await loadConfig();
setHasChanges(false);
// 记录用户操作
import('@/shared/utils/logger').then(({ logger }) => {
logger.logUserAction('重置系统配置', { action: 'reset_to_default' });
}).catch(() => { });
toast.success("已重置为默认配置");
} catch (error) {
console.error('Failed to reset config:', error);
const errorMessage = error instanceof Error ? error.message : '未知错误';
toast.error(`重置配置失败: ${errorMessage}`);
}
}
};
const updateConfig = (key: keyof SystemConfigData, value: any) => {
if (!config) return;
setConfig(prev => prev ? { ...prev, [key]: value } : null);
setHasChanges(true);
};
const toggleShowApiKey = (field: string) => {
setShowApiKeys(prev => ({ ...prev, [field]: !prev[field] }));
};
const getCurrentApiKey = () => {
if (!config) return '';
const provider = config.llmProvider.toLowerCase();
const keyMap: Record<string, string> = {
gemini: config.geminiApiKey,
openai: config.openaiApiKey,
claude: config.claudeApiKey,
qwen: config.qwenApiKey,
deepseek: config.deepseekApiKey,
zhipu: config.zhipuApiKey,
moonshot: config.moonshotApiKey,
baidu: config.baiduApiKey,
minimax: config.minimaxApiKey,
doubao: config.doubaoApiKey,
ollama: 'ollama',
};
return config.llmApiKey || keyMap[provider] || '';
};
const isConfigured = config ? getCurrentApiKey() !== '' : false;
// 如果正在加载或配置为 null显示加载状态
if (loading || !config) {
return (
<div className="flex items-center justify-center min-h-[400px]">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-gray-900 mx-auto mb-4"></div>
<p className="text-gray-600">...</p>
</div>
</div>
);
}
return (
<div className="space-y-6">
{/* 配置状态提示 */}
<Alert>
<Info className="h-4 w-4" />
<AlertDescription className="flex items-center justify-between">
<div>
<strong></strong>
{configSource === 'runtime' ? (
<Badge variant="default" className="ml-2"></Badge>
) : (
<Badge variant="outline" className="ml-2"></Badge>
)}
<span className="ml-4 text-sm">
{isConfigured ? (
<span className="text-green-600 flex items-center gap-1">
<CheckCircle2 className="h-3 w-3" /> LLM
</span>
) : (
<span className="text-orange-600 flex items-center gap-1">
<AlertCircle className="h-3 w-3" /> LLM
</span>
)}
</span>
</div>
<div className="flex gap-2">
{hasChanges && (
<Button onClick={saveConfig} size="sm">
<Save className="w-4 h-4 mr-2" />
</Button>
)}
{configSource === 'runtime' && (
<Button onClick={resetConfig} variant="outline" size="sm">
<RotateCcw className="w-4 h-4 mr-2" />
</Button>
)}
</div>
</AlertDescription>
</Alert>
<Tabs defaultValue="llm" className="w-full">
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="llm">
<Zap className="w-4 h-4 mr-2" />
LLM
</TabsTrigger>
<TabsTrigger value="platforms">
<Key className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="analysis">
<Settings className="w-4 h-4 mr-2" />
</TabsTrigger>
<TabsTrigger value="other">
<Globe className="w-4 h-4 mr-2" />
</TabsTrigger>
</TabsList>
{/* LLM 基础配置 */}
<TabsContent value="llm" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>LLM </CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>使 LLM </Label>
<Select
value={config.llmProvider}
onValueChange={(value) => updateConfig('llmProvider', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<div className="px-2 py-1.5 text-sm font-semibold text-muted-foreground"></div>
{LLM_PROVIDERS.filter(p => p.category === 'international').map(provider => (
<SelectItem key={provider.value} value={provider.value}>
{provider.icon} {provider.label}
</SelectItem>
))}
<div className="px-2 py-1.5 text-sm font-semibold text-muted-foreground mt-2"></div>
{LLM_PROVIDERS.filter(p => p.category === 'domestic').map(provider => (
<SelectItem key={provider.value} value={provider.value}>
{provider.icon} {provider.label}
</SelectItem>
))}
<div className="px-2 py-1.5 text-sm font-semibold text-muted-foreground mt-2"></div>
{LLM_PROVIDERS.filter(p => p.category === 'local').map(provider => (
<SelectItem key={provider.value} value={provider.value}>
{provider.icon} {provider.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> API Key</Label>
<div className="flex gap-2">
<Input
type={showApiKeys['llm'] ? 'text' : 'password'}
value={config.llmApiKey}
onChange={(e) => updateConfig('llmApiKey', e.target.value)}
placeholder="留空则使用平台专用 API Key"
/>
<Button
variant="outline"
size="icon"
onClick={() => toggleShowApiKey('llm')}
>
{showApiKeys['llm'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
使 API Key使 API Key
</p>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={config.llmModel}
onChange={(e) => updateConfig('llmModel', e.target.value)}
placeholder={`默认:${DEFAULT_MODELS[config.llmProvider as keyof typeof DEFAULT_MODELS] || '自动'}`}
/>
<p className="text-xs text-muted-foreground">
使
</p>
</div>
<div className="space-y-2">
<Label>API URL</Label>
<Input
value={config.llmBaseUrl}
onChange={(e) => updateConfig('llmBaseUrl', e.target.value)}
placeholder="例如https://api.example.com/v1"
/>
<div className="text-xs text-muted-foreground space-y-1">
<p>💡 <strong>使 API </strong>使</p>
<details className="cursor-pointer">
<summary className="text-primary hover:underline"> API </summary>
<div className="mt-2 p-3 bg-muted rounded space-y-1 text-xs">
<p><strong>OpenAI </strong></p>
<p> https://your-proxy.com/v1</p>
<p> https://api.openai-proxy.org/v1</p>
<p className="pt-2"><strong></strong></p>
<p> https://your-api-gateway.com/openai</p>
<p> https://custom-endpoint.com/api</p>
<p className="pt-2 text-orange-600"> LLM </p>
</div>
</details>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={config.llmTimeout}
onChange={(e) => updateConfig('llmTimeout', Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label>0-2</Label>
<Input
type="number"
step="0.1"
min="0"
max="2"
value={config.llmTemperature}
onChange={(e) => updateConfig('llmTemperature', Number(e.target.value))}
/>
</div>
<div className="space-y-2">
<Label> Tokens</Label>
<Input
type="number"
value={config.llmMaxTokens}
onChange={(e) => updateConfig('llmMaxTokens', Number(e.target.value))}
/>
</div>
</div>
<div className="space-y-2 pt-4 border-t">
<Label></Label>
<Input
value={config.llmCustomHeaders}
onChange={(e) => updateConfig('llmCustomHeaders', e.target.value)}
placeholder='{"X-Custom-Header": "value", "Another-Header": "value2"}'
/>
<p className="text-xs text-muted-foreground">
JSON <code className="bg-muted px-1 py-0.5 rounded">&#123;"X-API-Version": "v1"&#125;</code>
</p>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 平台专用密钥 */}
<TabsContent value="platforms" className="space-y-6">
<Alert>
<Key className="h-4 w-4" />
<AlertDescription>
<div className="space-y-1">
<p> API Key便 API Key使</p>
<p className="text-xs text-muted-foreground pt-1">
💡 <strong>使 API </strong><strong> API Key</strong> Key
LLM API URL
</p>
</div>
</AlertDescription>
</Alert>
{[
{ key: 'geminiApiKey', label: 'Google Gemini API Key', icon: '🔵', hint: '官方https://makersuite.google.com/app/apikey | 或使用中转站 Key' },
{ key: 'openaiApiKey', label: 'OpenAI API Key', icon: '🟢', hint: '官方https://platform.openai.com/api-keys | 或使用中转站 Key' },
{ key: 'claudeApiKey', label: 'Claude API Key', icon: '🟣', hint: '官方https://console.anthropic.com/ | 或使用中转站 Key' },
{ key: 'qwenApiKey', label: '通义千问 API Key', icon: '🟠', hint: '官方https://dashscope.console.aliyun.com/ | 或使用中转站 Key' },
{ key: 'deepseekApiKey', label: 'DeepSeek API Key', icon: '🔷', hint: '官方https://platform.deepseek.com/ | 或使用中转站 Key' },
{ key: 'zhipuApiKey', label: '智谱AI API Key', icon: '🔴', hint: '官方https://open.bigmodel.cn/ | 或使用中转站 Key' },
{ key: 'moonshotApiKey', label: 'Moonshot API Key', icon: '🌙', hint: '官方https://platform.moonshot.cn/ | 或使用中转站 Key' },
{ key: 'baiduApiKey', label: '百度文心 API Key', icon: '🔵', hint: '官方格式API_KEY:SECRET_KEY | 或使用中转站 Key' },
{ key: 'minimaxApiKey', label: 'MiniMax API Key', icon: '⚡', hint: '官方https://www.minimaxi.com/ | 或使用中转站 Key' },
{ key: 'doubaoApiKey', label: '字节豆包 API Key', icon: '🎯', hint: '官方https://console.volcengine.com/ark | 或使用中转站 Key' },
].map(({ key, label, icon, hint }) => (
<Card key={key}>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<span>{icon}</span>
{label}
</CardTitle>
<CardDescription className="text-xs">{hint}</CardDescription>
</CardHeader>
<CardContent>
<div className="flex gap-2">
<Input
type={showApiKeys[key] ? 'text' : 'password'}
value={config[key as keyof SystemConfigData] as string}
onChange={(e) => updateConfig(key as keyof SystemConfigData, e.target.value)}
placeholder={`输入 ${label}`}
/>
<Button
variant="outline"
size="icon"
onClick={() => toggleShowApiKey(key)}
>
{showApiKeys[key] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
</CardContent>
</Card>
))}
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<span>🖥</span>
Ollama URL
</CardTitle>
<CardDescription className="text-xs"> Ollama API </CardDescription>
</CardHeader>
<CardContent>
<Input
value={config.ollamaBaseUrl}
onChange={(e) => updateConfig('ollamaBaseUrl', e.target.value)}
placeholder="http://localhost:11434/v1"
/>
</CardContent>
</Card>
</TabsContent>
{/* 分析参数配置 */}
<TabsContent value="analysis" className="space-y-6">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={config.maxAnalyzeFiles}
onChange={(e) => updateConfig('maxAnalyzeFiles', Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
</p>
</div>
<div className="space-y-2">
<Label>LLM </Label>
<Input
type="number"
value={config.llmConcurrency}
onChange={(e) => updateConfig('llmConcurrency', Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
LLM
</p>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={config.llmGapMs}
onChange={(e) => updateConfig('llmGapMs', Number(e.target.value))}
/>
<p className="text-xs text-muted-foreground">
LLM
</p>
</div>
<div className="space-y-2">
<Label></Label>
<Select
value={config.outputLanguage}
onValueChange={(value) => updateConfig('outputLanguage', value)}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="zh-CN">🇨🇳 </SelectItem>
<SelectItem value="en-US">🇺🇸 English</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
</TabsContent>
{/* 其他配置 */}
<TabsContent value="other" className="space-y-6">
<Card>
<CardHeader>
<CardTitle>GitHub </CardTitle>
<CardDescription> GitHub Personal Access Token 访</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>GitHub Token</Label>
<div className="flex gap-2">
<Input
type={showApiKeys['github'] ? 'text' : 'password'}
value={config.githubToken}
onChange={(e) => updateConfig('githubToken', e.target.value)}
placeholder="ghp_xxxxxxxxxxxx"
/>
<Button
variant="outline"
size="icon"
onClick={() => toggleShowApiKey('github')}
>
{showApiKeys['github'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
https://github.com/settings/tokens
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>GitLab </CardTitle>
<CardDescription> GitLab Personal Access Token 访</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label>GitLab Token</Label>
<div className="flex gap-2">
<Input
type={showApiKeys['gitlab'] ? 'text' : 'password'}
value={config.gitlabToken}
onChange={(e) => updateConfig('gitlabToken', e.target.value)}
placeholder="glpat-xxxxxxxxxxxx"
/>
<Button
variant="outline"
size="icon"
onClick={() => toggleShowApiKey('gitlab')}
>
{showApiKeys['gitlab'] ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</Button>
</div>
<p className="text-xs text-muted-foreground">
https://gitlab.com/-/profile/personal_access_tokens
</p>
</div>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-3 text-sm text-muted-foreground">
<div className="flex items-start gap-3 p-3 bg-muted rounded-lg">
<Database className="h-5 w-5 text-primary mt-0.5" />
<div>
<p className="font-medium text-foreground"></p>
<p>
Docker
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-muted rounded-lg">
<Settings className="h-5 w-5 text-green-600 mt-0.5" />
<div>
<p className="font-medium text-foreground"></p>
<p>
&gt;
</p>
</div>
</div>
<div className="flex items-start gap-3 p-3 bg-muted rounded-lg">
<Key className="h-5 w-5 text-orange-600 mt-0.5" />
<div>
<p className="font-medium text-foreground"></p>
<p>
API Keys 访
</p>
</div>
</div>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 底部操作按钮 */}
{hasChanges && (
<div className="fixed bottom-6 right-6 flex gap-3 bg-background border rounded-lg shadow-lg p-4">
<Button onClick={saveConfig} size="lg">
<Save className="w-4 h-4 mr-2" />
</Button>
<Button onClick={loadConfig} variant="outline" size="lg">
</Button>
</div>
)}
</div>
);
}