Files
aiops/frontend/src/components/deploy/PipelineManager.tsx
T
attacker 01967bc52b feat(i18n): deploy 模块(Pipeline/Credential)国际化增量迁移 — 翻译键 deploy 命名空间(455键) + CredentialManager 补全 3 处 UI 文案
- PipelineManager.tsx: 流水线全量翻译(标题/搜索/CRUD/阶段/任务/运行/横幅)
- CredentialManager.tsx: 操作按钮 title 属性补全(编辑/禁用启用/删除)
- zh-CN.json + en-US.json: deploy 命名空间 455 键完全对齐
- 总翻译键: 1019(zh) = 1019(en)
- tsc -b 零错误, npm build 成功(17.42s)
2026-06-30 22:10:26 +08:00

281 lines
12 KiB
TypeScript

import { useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import {
Plus,
Search,
GitBranch,
Clock,
CheckCircle,
Loader2,
Rocket,
Container,
ArrowRight,
Layers,
ShieldCheck,
Globe,
Code,
Workflow,
Server,
Network,
} from 'lucide-react';
import { pipelineApi } from '@/api';
import { usePipelines, usePipelineMutations } from '@/hooks/api';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { toast } from 'sonner';
import { PipelineCard } from './pipeline-manager/PipelineCard';
import { PipelineCreateDialog } from './pipeline-manager/PipelineCreateDialog';
// 流水线列表页编排器 // Logan.Li
export function PipelineManager() {
const { t } = useTranslation();
const [searchQuery, setSearchQuery] = useState('');
const { data: pipelines = [], isLoading: loading, refetch: refetchPipelines } = usePipelines();
const { triggerMutation, deleteMutation, stopRunMutation } = usePipelineMutations();
const [expanded, setExpanded] = useState<Record<number, boolean>>({});
const [runs, setRuns] = useState<Record<number, Record<string, unknown>[]>>({});
const [loadingRuns, setLoadingRuns] = useState<Record<number, boolean>>({});
const [envFilter, setEnvFilter] = useState<string>('all');
const [runningRunMap, setRunningRunMap] = useState<Record<number, Record<string, unknown>>>({});
const [newOpen, setNewOpen] = useState(false);
const confirm = useConfirm();
const ENV_TYPES = [
{ key: 'all', label: t('deploy.env.all'), icon: Layers, color: '#6B7280' },
{ key: 'production', label: t('deploy.env.production'), icon: ShieldCheck, color: '#EF4444' },
{ key: 'testing', label: t('deploy.env.testing'), icon: CheckCircle, color: '#F59E0B' },
{ key: 'staging', label: t('deploy.env.staging'), icon: Globe, color: '#8B5CF6' },
{ key: 'development', label: t('deploy.env.development'), icon: Code, color: '#3B82F6' },
];
// 同步运行中的任务 ID // Logan.Li
useEffect(() => {
pipelines.forEach((p) => {
const id = p.id as number;
if (p.last_status === 'running' && !runningRunMap[id]) {
pipelineApi.getRuns(p.code as string).then((res: Record<string, unknown>) => {
const runsList = (res?.list as Record<string, unknown>[]) || [];
const running = runsList.find((r) => r.status === 'running' || r.status === 'pending');
if (running) setRunningRunMap((prev) => ({ ...prev, [id]: running }));
}).catch(() => {});
}
});
}, [pipelines, runningRunMap]);
const loadRuns = async (pipelineId: number, code: string) => {
setLoadingRuns((p) => ({ ...p, [pipelineId]: true }));
try {
const res = await pipelineApi.getRuns(code);
setRuns((p) => ({ ...p, [pipelineId]: (res as Record<string, unknown>)?.list as Record<string, unknown>[] || [] }));
} catch {
setRuns((p) => ({ ...p, [pipelineId]: [] }));
} finally {
setLoadingRuns((p) => ({ ...p, [pipelineId]: false }));
}
};
const toggleExpand = (pipelineId: number, code: string) => {
if (!expanded[pipelineId]) {
loadRuns(pipelineId, code);
setExpanded((p) => ({ ...p, [pipelineId]: true }));
} else {
setExpanded((p) => ({ ...p, [pipelineId]: false }));
}
};
const handleTrigger = async (pipelineId: number, code: string) => {
try {
await triggerMutation.mutateAsync(code);
toast.success(t('deploy.toast.triggered'));
loadRuns(pipelineId, code);
refetchPipelines();
} catch {
toast.error(t('deploy.toast.triggerFailed'));
}
};
const handleStop = async (pipelineId: number, code: string, runId: number | string) => {
const ok = await confirm({ title: t('deploy.run.stopRun'), description: t('deploy.run.stopConfirm'), variant: 'destructive' });
if (!ok) return;
try {
await stopRunMutation.mutateAsync({ code, runId });
toast.success(t('deploy.toast.stopped'));
loadRuns(pipelineId, code);
setRunningRunMap((prev) => {
const n = { ...prev };
delete n[pipelineId];
return n;
});
} catch {
toast.error(t('deploy.toast.stopFailed'));
}
};
const handleStopRunning = async (pipelineId: number, code: string) => {
const runningRun = runningRunMap[pipelineId];
if (!runningRun) {
try {
const res: Record<string, unknown> = await pipelineApi.getRuns(code);
const running = ((res?.list as Record<string, unknown>[]) || []).find(
(r) => r.status === 'running' || r.status === 'pending',
);
if (running) await handleStop(pipelineId, code, running.id as number | string);
else toast.error(t('deploy.pipeline.noRunningTask'));
} catch {
toast.error(t('deploy.toast.stopFailed'));
}
return;
}
await handleStop(pipelineId, code, runningRun.id as number | string);
};
const handleDelete = async (pipeline: Record<string, unknown>) => {
const ok = await confirm({
title: t('deploy.pipeline.deletePipeline'),
description: t('deploy.pipeline.deleteConfirm', { name: pipeline.name }),
variant: 'destructive',
});
if (!ok) return;
try {
await deleteMutation.mutateAsync(pipeline.id as number | string);
toast.success(t('deploy.toast.deleted'));
refetchPipelines();
} catch {
toast.error(t('deploy.toast.deleteFailed'));
}
};
const filtered = pipelines.filter((p) => {
const matchSearch = String(p.name || '').toLowerCase().includes(searchQuery.toLowerCase()) ||
String(p.description || '').toLowerCase().includes(searchQuery.toLowerCase());
const env = p.environment as Record<string, unknown> | undefined;
const matchEnv = envFilter === 'all' || (env?.type || '') === envFilter;
return matchSearch && matchEnv;
});
return (
<div className="space-y-5">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-[var(--dg-text-primary)]">{t('deploy.pipeline.title')}</h1>
<p className="text-sm text-[var(--dg-text-secondary)] mt-1">{t('deploy.pipeline.subtitle')}</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]" onClick={() => refetchPipelines()}>
<Clock className={`h-4 w-4 mr-2 ${loading ? 'animate-spin' : ''}`} />{t('common.refresh')}
</Button>
<Button onClick={() => setNewOpen(true)}><Plus className="h-4 w-4 mr-2" />{t('deploy.pipeline.newPipeline')}</Button>
</div>
</div>
{/* 全流程可视化横幅 — 双路径 // Logan.Li */}
<div className="flex items-stretch gap-3 px-4 py-3 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-indigo-400" />
<span className="text-[10px] font-semibold text-indigo-400">{t('deploy.banner.build')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Rocket className="h-3 w-3 text-red-400" />
<span className="text-[10px] font-semibold text-red-400">{t('deploy.banner.deploy')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1 px-2 py-0.5 rounded bg-blue-400/10 border border-blue-400/20">
<Server className="h-3 w-3 text-blue-400" />
<span className="text-[10px] font-medium text-blue-400">{t('deploy.banner.sshHost')}</span>
</div>
</div>
<p className="text-[9px] text-[var(--dg-text-secondary)] opacity-40 pl-1">{t('deploy.banner.sshDesc')}</p>
</div>
<div className="w-px bg-[var(--dg-border)]" />
<div className="flex-1 space-y-1.5">
<div className="flex items-center gap-1.5">
<GitBranch className="h-3 w-3 text-indigo-400" />
<span className="text-[10px] font-semibold text-indigo-400">{t('deploy.banner.build')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Container className="h-3 w-3 text-cyan-400" />
<span className="text-[10px] font-semibold text-cyan-400">{t('deploy.banner.imageRegistry')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<Rocket className="h-3 w-3 text-red-400" />
<span className="text-[10px] font-semibold text-red-400">{t('deploy.banner.deploy')}</span>
<ArrowRight className="h-2.5 w-2.5 text-[var(--dg-text-secondary)] opacity-30" />
<div className="flex items-center gap-1 px-2 py-0.5 rounded bg-emerald-400/10 border border-emerald-400/20">
<Network className="h-3 w-3 text-emerald-400" />
<span className="text-[10px] font-medium text-emerald-400">{t('deploy.banner.kubernetes')}</span>
</div>
</div>
<p className="text-[9px] text-[var(--dg-text-secondary)] opacity-40 pl-1">{t('deploy.banner.k8sDesc')}</p>
</div>
<span className="self-center ml-2 text-[10px] text-[var(--dg-text-secondary)] opacity-30 tracking-wide whitespace-nowrap">{t('deploy.banner.fullFlow')}</span>
</div>
<div className="flex items-center gap-4">
<div className="relative max-w-md flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-secondary)]" />
<Input
placeholder={t('deploy.pipeline.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]"
/>
</div>
<div className="flex items-center gap-1 bg-[var(--dg-bg-secondary)] rounded-lg p-1 border border-[var(--dg-border)]">
{ENV_TYPES.map((et) => {
const active = envFilter === et.key;
const Icon = et.icon;
return (
<button
key={et.key}
onClick={() => setEnvFilter(et.key)}
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-all ${
active ? 'text-white shadow-sm' : 'text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)]'
}`}
style={active ? { backgroundColor: et.color } : {}}
>
<Icon className="h-3.5 w-3.5" />{et.label}
</button>
);
})}
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-12">
<Loader2 className="h-6 w-6 text-[var(--dg-text-secondary)] animate-spin" />
</div>
) : filtered.length === 0 ? (
<div className="text-center py-16 text-[var(--dg-text-secondary)]">
<Workflow className="h-10 w-10 mx-auto mb-3 opacity-30" />
<p className="text-sm">{t('deploy.pipeline.noData')}</p>
<Button variant="outline" className="mt-4" onClick={() => setNewOpen(true)}>
<Plus className="h-4 w-4 mr-1" />{t('deploy.pipeline.createFirst')}
</Button>
</div>
) : (
<div className="space-y-3">
{filtered.map((pipeline) => {
const id = pipeline.id as number;
const code = (pipeline.code || pipeline.id) as string;
return (
<PipelineCard
key={id}
pipeline={pipeline}
isExpanded={!!expanded[id]}
isLoadingRuns={!!loadingRuns[id]}
pipelineRuns={runs[id] || []}
onToggleExpand={() => toggleExpand(id, code)}
onTrigger={() => handleTrigger(id, code)}
onStopRunning={() => handleStopRunning(id, code)}
onDelete={() => handleDelete(pipeline)}
onStopRun={(runId) => handleStop(id, code, runId)}
/>
);
})}
</div>
)}
<PipelineCreateDialog open={newOpen} onOpenChange={setNewOpen} onCreated={refetchPipelines} />
</div>
);
}