- PipelineManager.tsx: 流水线全量翻译(标题/搜索/CRUD/阶段/任务/运行/横幅) - CredentialManager.tsx: 操作按钮 title 属性补全(编辑/禁用启用/删除) - zh-CN.json + en-US.json: deploy 命名空间 455 键完全对齐 - 总翻译键: 1019(zh) = 1019(en) - tsc -b 零错误, npm build 成功(17.42s)
281 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|