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

778 lines
31 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { 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>
);
}