Files
aiops/frontend/src/components/deploy/pipeline-manager/PipelineCreateDialog.tsx
T
attacker a9513c2056 feat(i18n): 全量中英文国际化改造
- 翻译 en-US.json 中 1006 条未翻译的中文条目为英文
- 提取 30+ 组件文件的硬编码中文到 locale JSON
- 新增 900+ 翻译键覆盖所有业务模块:
  - Layout (Sidebar, Header, Breadcrumb)
  - Server (ServerForm, CloudServerImport, WebShell, RdpTools)
  - Deploy (ReleaseManager, PipelineCreate, AppRelease)
  - Kubernetes (DeploymentEdit, PodExec, PodLogs, ResourceEditor)
  - Monitor (UnifiedQueryPanel, AlertCenter, Prometheus/Grafana Dashboard)
  - AIOps (predictUtils, PredictiveCostTab, AIPredictive)
  - System (useLdapManagement, AgentMonitor, AgentWizard)
- tsc --noEmit 类型检查通过
2026-07-04 03:42:27 +08:00

560 lines
23 KiB
TypeScript

import { useState, useEffect, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
import {
Rocket,
Plus,
GitBranch,
Globe,
Container,
Loader2,
Trash2,
ChevronDown,
Settings2,
} from 'lucide-react';
import { buildNodeApi, credentialApi, deployApi, pipelineApi, type BuildNode } from '@/api';
import { toast } from 'sonner';
import { cn } from '@/lib/utils';
import { nameToCode } from '../pipeline-helpers';
import { STAGE_DEFS } from './constants';
import { DeployTargetFields, type DeployTargetValue } from './DeployTargetFields';
interface PipelineCreateDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onCreated: () => void;
}
const PRESETS = [
{
key: 'ssh',
label: t('deploy.presetHost'),
desc: t('deploy.presetHostDesc'),
stages: ['checkout', 'build', 'deploy'],
deployTarget: 'ssh' as const,
},
{
key: 'k8s',
label: t('deploy.presetK8s'),
desc: t('deploy.presetK8sDesc'),
stages: ['checkout', 'build', 'image', 'deploy'],
deployTarget: 'k8s' as const,
},
];
const DEFAULT_TARGET: DeployTargetValue = {
deployTarget: 'ssh',
serverIds: [],
deployRemotePath: '',
deployScript: '',
k8sClusterId: '',
k8sNamespace: '',
k8sDeployment: '',
k8sContainerName: '',
};
// 新建流水线对话框 — 精简分步 + 双发布目标 // Logan.Li
export function PipelineCreateDialog({ open, onOpenChange, onCreated }: PipelineCreateDialogProps) {
const { t } = useTranslation();
const [step, setStep] = useState<1 | 2>(1);
const [preset, setPreset] = useState('ssh');
const [newName, setNewName] = useState('');
const [newDesc, setNewDesc] = useState('');
const [newSource, setNewSource] = useState('');
const [newBranch, setNewBranch] = useState('main');
const [newDockerfile, setNewDockerfile] = useState('');
const [selectedStages, setSelectedStages] = useState<string[]>(['checkout', 'build', 'deploy']);
const [deployTarget, setDeployTarget] = useState<DeployTargetValue>(DEFAULT_TARGET);
const [newEnvironmentId, setNewEnvironmentId] = useState<number | string>('');
const [saving, setSaving] = useState(false);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [gitCredentials, setGitCredentials] = useState<Record<string, unknown>[]>([]);
const [dockerCredentials, setDockerCredentials] = useState<Record<string, unknown>[]>([]);
const [newGitCredentialId, setNewGitCredentialId] = useState<number | string>('');
const [newRegistryCredentialId, setNewRegistryCredentialId] = useState<number | string>('');
const [newJobName, setNewJobName] = useState('');
const [newTriggerMode, setNewTriggerMode] = useState('manual');
const [newWatchBranches, setNewWatchBranches] = useState('');
const [newCronExpr, setNewCronExpr] = useState('');
const [newEnvVars, setNewEnvVars] = useState<{ key: string; value: string }[]>([]);
const [environments, setEnvironments] = useState<Record<string, unknown>[]>([]);
const [buildNodes, setBuildNodes] = useState<BuildNode[]>([]);
const [buildNodeId, setBuildNodeId] = useState('');
const loadRefs = useCallback(async () => {
try {
const [rEnv, rCred, rNodes] = await Promise.all([
deployApi.getEnvironments().catch(() => ({ list: [] })),
credentialApi.list({ page_size: 100 }).catch(() => ({ list: [] })),
buildNodeApi.list().catch(() => ({ list: [] })),
] as const);
setEnvironments(Array.isArray((rEnv as Record<string, unknown>)?.list)
? (rEnv as Record<string, unknown>).list as Record<string, unknown>[]
: []);
const allCreds: Record<string, unknown>[] = (rCred as Record<string, unknown>)?.list as Record<string, unknown>[] || [];
setGitCredentials(allCreds.filter((c) => c.secret_type === 'api'));
setDockerCredentials(allCreds.filter((c) => c.secret_type === 'docker'));
const nodeData = rNodes as { list?: BuildNode[] };
setBuildNodes(Array.isArray(nodeData?.list) ? nodeData.list.filter((n) => n.enabled) : []);
} catch { /* silent */ }
}, []);
useEffect(() => {
if (open) loadRefs();
}, [open, loadRefs]);
const applyPreset = (key: string) => {
const p = PRESETS.find((x) => x.key === key);
if (!p) return;
setPreset(key);
setSelectedStages(p.stages);
setDeployTarget({
...DEFAULT_TARGET,
deployTarget: p.deployTarget,
});
};
const resetForm = () => {
setStep(1);
setPreset('ssh');
setNewName('');
setNewDesc('');
setNewSource('');
setNewBranch('main');
setNewDockerfile('');
setSelectedStages(['checkout', 'build', 'deploy']);
setDeployTarget(DEFAULT_TARGET);
setNewEnvironmentId('');
setNewGitCredentialId('');
setNewRegistryCredentialId('');
setNewJobName('');
setNewTriggerMode('manual');
setNewWatchBranches('');
setNewCronExpr('');
setNewEnvVars([]);
setAdvancedOpen(false);
setBuildNodeId('');
};
const validateStep1 = () => {
if (!newName.trim()) {
toast.error(t('deploy.enterPipelineName'));
return false;
}
return true;
};
const validateStep2 = () => {
if (selectedStages.includes('checkout') && !newSource.trim()) {
toast.error(t('deploy.stage.checkoutNeedGit'));
return false;
}
if (selectedStages.includes('image') && !newRegistryCredentialId) {
toast.error(t('deploy.stage.imageNeedDocker'));
return false;
}
if (selectedStages.includes('deploy')) {
if (deployTarget.deployTarget === 'ssh' && deployTarget.serverIds.length === 0) {
toast.error(t('deploy.needHost'));
return false;
}
if (deployTarget.deployTarget === 'k8s' && (!deployTarget.k8sClusterId || !deployTarget.k8sDeployment)) {
toast.error(t('deploy.needK8s'));
return false;
}
}
return true;
};
const handleCreate = async () => {
if (!validateStep1() || !validateStep2()) return;
setSaving(true);
try {
const stageNames = selectedStages.map((k) => STAGE_DEFS.find((s) => s.key === k)?.label || k);
const stages = JSON.stringify(stageNames.map((name, i) => ({
id: `stage-${i + 1}`,
name,
type: selectedStages[i] || 'custom',
tasks: [],
})));
await pipelineApi.createPipeline({
name: newName,
code: nameToCode(newName),
job_name: newJobName || undefined,
description: newDesc,
source: newSource,
branch: newBranch,
dockerfile: newDockerfile,
type: 'deploy',
stages,
deploy_mode: 'branch',
deploy_target: deployTarget.deployTarget,
server_ids: deployTarget.serverIds.length > 0 ? JSON.stringify(deployTarget.serverIds) : undefined,
deploy_remote_path: deployTarget.deployRemotePath || undefined,
deploy_script: deployTarget.deployScript || undefined,
k8s_cluster_id: deployTarget.k8sClusterId ? Number(deployTarget.k8sClusterId) : null,
k8s_namespace: deployTarget.k8sNamespace || undefined,
k8s_deployment: deployTarget.k8sDeployment || undefined,
k8s_container_name: deployTarget.k8sContainerName || undefined,
environment_id: newEnvironmentId ? Number(newEnvironmentId) : null,
git_credential_id: newGitCredentialId ? Number(newGitCredentialId) : null,
registry_credential_id: newRegistryCredentialId ? Number(newRegistryCredentialId) : null,
trigger_mode: newTriggerMode,
watch_branches: newWatchBranches || undefined,
cron_expr: newCronExpr || undefined,
variables: newEnvVars.length > 0 ? JSON.stringify(newEnvVars) : undefined,
build_node_id: buildNodeId ? Number(buildNodeId) : null,
});
toast.success(t('deploy.pipeline.created'));
onOpenChange(false);
resetForm();
onCreated();
} catch {
toast.error(t('deploy.pipeline.createFailed'));
} finally {
setSaving(false);
}
};
const toggleStage = (key: string) => {
setSelectedStages((prev) => (
prev.includes(key) ? prev.filter((k) => k !== key) : [...prev, key]
));
};
return (
<Dialog open={open} onOpenChange={(v) => { if (!v) resetForm(); onOpenChange(v); }}>
<DialogContent className="max-w-lg max-h-[88vh] overflow-y-auto bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
<DialogHeader>
<DialogTitle className="text-[var(--dg-text-primary)]">{t('deploy.newPipelineTitle')}</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)]">
{t('deploy.newPipelineDesc')}
</DialogDescription>
</DialogHeader>
{/* 步骤指示 */}
<div className="flex items-center gap-2 text-xs">
<button
type="button"
onClick={() => setStep(1)}
className={cn(
'px-2.5 py-1 rounded-md transition-colors',
step === 1 ? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]',
)}
>
{t('deploy.step1')}
</button>
<span className="text-[var(--dg-border)]">/</span>
<button
type="button"
onClick={() => { if (validateStep1()) setStep(2); }}
className={cn(
'px-2.5 py-1 rounded-md transition-colors',
step === 2 ? 'bg-[var(--dg-bg-hover)] text-[var(--dg-text-primary)]' : 'text-[var(--dg-text-muted)]',
)}
>
{t('deploy.step2')}
</button>
</div>
{step === 1 ? (
<div className="space-y-4">
<div className="grid grid-cols-2 gap-2">
{PRESETS.map((p) => (
<button
key={p.key}
type="button"
onClick={() => applyPreset(p.key)}
className={cn(
'p-3 rounded-lg border text-left transition-colors',
preset === p.key
? 'border-[var(--dg-blue)] bg-[color-mix(in_srgb,var(--dg-blue)_8%,transparent)]'
: 'border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]',
)}
>
<div className="text-sm font-medium text-[var(--dg-text-primary)]">{p.label}</div>
<div className="text-[11px] text-[var(--dg-text-muted)] mt-0.5">{p.desc}</div>
</button>
))}
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.pipeName')} <span className="text-[var(--dg-red)]">*</span></Label>
<Input
value={newName}
onChange={(e) => setNewName(e.target.value)}
placeholder={t('deploy.pipeNamePlaceholder')}
className="h-9 dg-input"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.pipeDesc')}</Label>
<Textarea
value={newDesc}
onChange={(e) => setNewDesc(e.target.value)}
placeholder={t('deploy.pipeDescPlaceholder')}
rows={2}
className="dg-input text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.envGroup')}</Label>
<Select
value={String(newEnvironmentId)}
onValueChange={(v) => setNewEnvironmentId(v === '__none' ? '' : v)}
>
<SelectTrigger className="h-9 dg-input"><SelectValue placeholder={t('deploy.selectEnv')} /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('deploy.notSpecified')}</SelectItem>
{environments.map((env) => (
<SelectItem key={env.id as number} value={String(env.id)}>
{String(env.display_name)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={() => { if (validateStep1()) setStep(2); }}>下一步</Button>
</div>
</div>
) : (
<div className="space-y-4">
<DeployTargetFields
value={deployTarget}
onChange={(patch) => setDeployTarget((prev) => ({ ...prev, ...patch }))}
requireTarget={selectedStages.includes('deploy')}
/>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label className="text-xs">
{t('deploy.gitRepo')} {selectedStages.includes('checkout') && <span className="text-[var(--dg-red)]">*</span>}
</Label>
<Input
value={newSource}
onChange={(e) => setNewSource(e.target.value)}
placeholder="https://gitee.com/user/repo.git"
className="h-8 text-xs font-mono dg-input"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.pipeBranch')}</Label>
<Input value={newBranch} onChange={(e) => setNewBranch(e.target.value)} className="h-8 text-xs dg-input" />
</div>
</div>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.gitCredential')}</Label>
<Select value={String(newGitCredentialId)} onValueChange={(v) => setNewGitCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="选择凭证" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">{t('deploy.notLinked')}</SelectItem>
{gitCredentials.map((c) => (
<SelectItem key={c.id as number} value={String(c.id)}>
<span className="flex items-center gap-1"><Globe className="h-3 w-3" />{String(c.name)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{selectedStages.includes('image') && (
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.imageCredential')} <span className="text-[var(--dg-red)]">*</span></Label>
<Select value={String(newRegistryCredentialId)} onValueChange={(v) => setNewRegistryCredentialId(v === '__none' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="Docker 凭证" /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">不关联</SelectItem>
{dockerCredentials.map((c) => (
<SelectItem key={c.id as number} value={String(c.id)}>
<span className="flex items-center gap-1"><Container className="h-3 w-3" />{String(c.name)}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
{deployTarget.deployTarget === 'k8s' && (
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.dockerfilePath')}</Label>
<Input
value={newDockerfile}
onChange={(e) => setNewDockerfile(e.target.value)}
placeholder="Dockerfile"
className="h-8 text-xs font-mono dg-input"
/>
</div>
)}
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.ciStages')}</Label>
<div className="flex flex-wrap gap-1.5">
{STAGE_DEFS.map((def) => {
const active = selectedStages.includes(def.key);
return (
<Badge
key={def.key}
variant={active ? 'default' : 'outline'}
className={cn('cursor-pointer text-xs', !active && 'opacity-50')}
onClick={() => toggleStage(def.key)}
>
<def.icon className="h-3 w-3 mr-1" />
{def.label}
</Badge>
);
})}
</div>
{selectedStages.length > 0 && (
<p className="text-[10px] text-[var(--dg-text-muted)] flex items-center gap-1 flex-wrap">
<GitBranch className="h-3 w-3" />
{selectedStages.map((k) => STAGE_DEFS.find((d) => d.key === k)?.label || k).join(' → ')}
</p>
)}
</div>
<Collapsible open={advancedOpen} onOpenChange={setAdvancedOpen}>
<CollapsibleTrigger asChild>
<button
type="button"
className="flex items-center gap-1.5 text-xs text-[var(--dg-text-muted)] hover:text-[var(--dg-text-primary)]"
>
<Settings2 className="h-3.5 w-3.5" />
{t('deploy.advancedSettings')}
<ChevronDown className={cn('h-3.5 w-3.5 transition-transform', advancedOpen && 'rotate-180')} />
</button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-3 space-y-3 pt-3 border-t border-[var(--dg-border)]">
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.buildNode')}</Label>
<Select value={buildNodeId || '__auto'} onValueChange={(v) => setBuildNodeId(v === '__auto' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="__auto">{t('deploy.autoSchedule')}</SelectItem>
{buildNodes.map((n) => (
<SelectItem key={n.id} value={String(n.id)}>
{n.name} ({n.running_count || 0}/{n.max_executors})
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Job 名称 <span className="text-[var(--dg-text-muted)]">(Webhook)</span></Label>
<Input value={newJobName} onChange={(e) => setNewJobName(e.target.value)} className="h-8 text-xs font-mono dg-input" />
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t('deploy.triggerMethod')}</Label>
<Select value={newTriggerMode} onValueChange={setNewTriggerMode}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="manual">{t('deploy.triggerManual')}</SelectItem>
<SelectItem value="git_push">{t('deploy.codePush')}</SelectItem>
<SelectItem value="cron">{t('deploy.triggerCron')}</SelectItem>
<SelectItem value="webhook">Webhook</SelectItem>
</SelectContent>
</Select>
</div>
{newTriggerMode === 'git_push' && (
<Input
value={newWatchBranches}
onChange={(e) => setNewWatchBranches(e.target.value)}
placeholder="监听分支,如 main, release/*"
className="h-8 text-xs font-mono dg-input"
/>
)}
{newTriggerMode === 'cron' && (
<Input
value={newCronExpr}
onChange={(e) => setNewCronExpr(e.target.value)}
placeholder="0 2 * * *"
className="h-8 text-xs font-mono dg-input"
/>
)}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">{t('deploy.envVars')}</Label>
<Button
type="button"
variant="ghost"
size="sm"
className="h-6 text-[10px]"
onClick={() => setNewEnvVars([...newEnvVars, { key: '', value: '' }])}
>
<Plus className="h-3 w-3 mr-0.5" />{t('deploy.addVar')}
</Button>
</div>
{newEnvVars.map((v, i) => (
<div key={i} className="flex gap-1.5">
<Input
value={v.key}
onChange={(e) => {
const nv = [...newEnvVars];
nv[i] = { ...nv[i], key: e.target.value };
setNewEnvVars(nv);
}}
placeholder="KEY"
className="h-7 text-xs font-mono dg-input flex-1"
/>
<Input
value={v.value}
onChange={(e) => {
const nv = [...newEnvVars];
nv[i] = { ...nv[i], value: e.target.value };
setNewEnvVars(nv);
}}
placeholder="value"
className="h-7 text-xs font-mono dg-input flex-[2]"
/>
<Button
type="button"
variant="ghost"
size="icon"
className="h-7 w-7 shrink-0"
onClick={() => setNewEnvVars(newEnvVars.filter((_, idx) => idx !== i))}
>
<Trash2 className="h-3.5 w-3.5 text-[var(--dg-red)]" />
</Button>
</div>
))}
</div>
</CollapsibleContent>
</Collapsible>
<div className="flex justify-between gap-2 pt-2 border-t border-[var(--dg-border)]">
<Button variant="ghost" onClick={() => setStep(1)}>{t('deploy.prevStep')}</Button>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleCreate} disabled={saving}>
{saving ? <Loader2 className="h-4 w-4 animate-spin mr-1" /> : <Rocket className="h-4 w-4 mr-1" />}
{saving ? t('deploy.creating') : t('deploy.pipeCreate')}
</Button>
</div>
</div>
</div>
)}
</DialogContent>
</Dialog>
);
}