fix(i18n): 回退到稳定的 32% 覆盖率状态 (tsc + Vite 构建通过)
批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换: - 翻译词典: 22 namespaces, 3600+ keys - 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy) - 语言切换器在 Header 中可用 (🇨🇳/🇺🇸) - tsc 零错误, Vite 构建成功
This commit is contained in:
1 parent
ecec73989b
commit
72bfb0e12d
191 files changed
+5057
-7592
No files matched your search
@@ -14,7 +14,6 @@ import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } f
|
||||
import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { buildNodeApi, serverApi, type BuildNode } from '@/api';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ServerItem {
|
||||
id: number;
|
||||
@@ -24,7 +23,6 @@ interface ServerItem {
|
||||
|
||||
// 构建节点管理(Jenkins Agent 风格 SSH 节点) // Logan.Li
|
||||
export default function BuildNodesManager({ embedded = false }: { embedded?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const confirm = useConfirm();
|
||||
const [nodes, setNodes] = useState<BuildNode[]>([]);
|
||||
const [master, setMaster] = useState({ name: 'Master', max_executors: 2, running_count: 0 });
|
||||
@@ -60,7 +58,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
const srvList = (srvRes as { list?: ServerItem[] })?.list || [];
|
||||
setServers(srvList);
|
||||
} catch {
|
||||
toast.error(t('deploy.buildnodesmanager.sb9faa2'));
|
||||
toast.error('加载构建节点失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -92,7 +90,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim() || !serverId) {
|
||||
toast.error(t('deploy.buildnodesmanager.sda51a7'));
|
||||
toast.error('请填写名称并选择服务器');
|
||||
return;
|
||||
}
|
||||
setSubmitting(true);
|
||||
@@ -107,29 +105,29 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
};
|
||||
if (editNode) {
|
||||
await buildNodeApi.update(editNode.id, payload);
|
||||
toast.success(t('deploy.toast.updated'));
|
||||
toast.success('已更新');
|
||||
} else {
|
||||
await buildNodeApi.create(payload);
|
||||
toast.success(t('deploy.toast.created'));
|
||||
toast.success('已创建');
|
||||
}
|
||||
setDialogOpen(false);
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('common.operationFailed'));
|
||||
toast.error('操作失败');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (node: BuildNode) => {
|
||||
const ok = await confirm({ title: t('k8s.topology.deleteNode'), description: t('deploy.buildnodesmanager.s359e74', { node.name: node.name }) });
|
||||
const ok = await confirm({ title: '删除节点', description: `确定删除构建节点「${node.name}」?` });
|
||||
if (!ok) return;
|
||||
try {
|
||||
await buildNodeApi.delete(node.id);
|
||||
toast.success(t('alert.deleted'));
|
||||
toast.success('已删除');
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('deploy.buildnodesmanager.s577b18'));
|
||||
toast.error('删除失败,请确认无流水线绑定');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -138,12 +136,12 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
try {
|
||||
const res = await buildNodeApi.test(node.id) as { success?: boolean; message?: string };
|
||||
if (res?.success) {
|
||||
toast.success(t('deploy.buildnodesmanager.s1c5ca7', { res.message || '': res.message || '' }));
|
||||
toast.success(`连通成功: ${res.message || ''}`);
|
||||
} else {
|
||||
toast.error(res?.message || t('aiops.llmTestFailed'));
|
||||
toast.error(res?.message || '连接失败');
|
||||
}
|
||||
} catch {
|
||||
toast.error(t('deploy.buildnodesmanager.s9710d9'));
|
||||
toast.error('测试失败');
|
||||
} finally {
|
||||
setTestingId(null);
|
||||
}
|
||||
@@ -154,14 +152,14 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
await buildNodeApi.update(node.id, { enabled: !node.enabled });
|
||||
load();
|
||||
} catch {
|
||||
toast.error(t('alert.updateFailed'));
|
||||
toast.error('更新失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveMaster = async () => {
|
||||
const n = Number(masterMax);
|
||||
if (!n || n < 1) {
|
||||
toast.error(t('deploy.buildnodesmanager.s5f25f4'));
|
||||
toast.error('并发数至少为 1');
|
||||
return;
|
||||
}
|
||||
setSavingMaster(true);
|
||||
@@ -173,9 +171,9 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
max_executors: m?.max_executors ?? n,
|
||||
running_count: m?.running_count ?? prev.running_count,
|
||||
}));
|
||||
toast.success(t('deploy.buildnodesmanager.scb1ab6'));
|
||||
toast.success('Master 并发已更新');
|
||||
} catch {
|
||||
toast.error(t('alert.updateFailed'));
|
||||
toast.error('更新失败');
|
||||
} finally {
|
||||
setSavingMaster(false);
|
||||
}
|
||||
@@ -186,7 +184,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
<ContentPanel
|
||||
toolbar={(
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('pipeline.buildNode')}</span>
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">构建节点</span>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" size="sm" onClick={load} disabled={loading}>
|
||||
<RefreshCw className={`h-3.5 w-3.5 mr-1${loading ? ' animate-spin' : ''}`} />刷新
|
||||
@@ -202,14 +200,14 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Cpu className="h-4 w-4 text-[var(--dg-text-muted)]" />
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('deploy.buildnodesmanager.sae7536')}</span>
|
||||
<Badge variant="outline" className="text-[10px]">{t('deploy.buildnodesmanager.sa419fe')}</Badge>
|
||||
<span className="text-sm font-medium text-[var(--dg-text-primary)]">Master(内置)</span>
|
||||
<Badge variant="outline" className="text-[10px]">默认调度</Badge>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">
|
||||
占用 {master.running_count}/{master.max_executors}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="text-xs text-[var(--dg-text-muted)] shrink-0">{t('deploy.buildnodesmanager.s8e408e')}</Label>
|
||||
<Label className="text-xs text-[var(--dg-text-muted)] shrink-0">最大并发</Label>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
@@ -219,7 +217,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
className="h-7 w-16 text-xs dg-input"
|
||||
/>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={handleSaveMaster} disabled={savingMaster}>
|
||||
{savingMaster ? t('deploy.buildnodesmanager.s7cbf2d') : t('common.save')}
|
||||
{savingMaster ? '保存中' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,9 +228,9 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
|
||||
{nodes.length === 0 ? (
|
||||
<PageEmpty
|
||||
title=t('deploy.buildnodesmanager.s2795bd')
|
||||
description=t('deploy.buildnodesmanager.s90fb5e')
|
||||
action={<Button size="sm" onClick={openCreate}><Plus className="h-3.5 w-3.5 mr-1" />{t('k8s.topology.addNode')}</Button>}
|
||||
title="暂无远程构建节点"
|
||||
description="关联服务器资产添加 SSH 节点,将重型构建任务调度到高性能机器"
|
||||
action={<Button size="sm" onClick={openCreate}><Plus className="h-3.5 w-3.5 mr-1" />添加节点</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -245,7 +243,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="font-medium text-sm text-[var(--dg-text-primary)]">{node.name}</span>
|
||||
<Badge variant={node.enabled ? 'default' : 'secondary'} className="text-[10px]">
|
||||
{node.enabled ? t('common.enable') : t('common.disable')}
|
||||
{node.enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
<span className="text-[10px] text-[var(--dg-text-muted)] font-mono">
|
||||
{node.hostname || node.ip || `server#${node.server_id}`}
|
||||
@@ -259,10 +257,10 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
</div>
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" disabled={testingId === node.id} onClick={() => handleTest(node)}>
|
||||
{testingId === node.id ? t('deploy.buildnodesmanager.sf85549') : t('common.test')}
|
||||
{testingId === node.id ? '测试中' : '测试'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggle(node)}>
|
||||
{node.enabled ? t('common.disable') : t('common.enable')}
|
||||
{node.enabled ? '禁用' : '启用'}
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(node)}>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
@@ -283,22 +281,22 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-md bg-[var(--dg-bg-primary)] border-[var(--dg-border)]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editNode ? t('k8s.topology.editNode') : t('k8s.topology.addNode')}</DialogTitle>
|
||||
<DialogTitle>{editNode ? '编辑节点' : '添加节点'}</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)]">
|
||||
基于服务器 SSH 凭据注册构建 Agent,流水线可绑定或自动调度
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('k8s.topology.nodeName')}</Label>
|
||||
<Label className="text-xs">节点名称</Label>
|
||||
<Input value={name} onChange={(e) => setName(e.target.value)} className="h-8 text-xs dg-input" placeholder="build-node-01" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('deploy.buildnodesmanager.sdfdaf1')}</Label>
|
||||
<Label className="text-xs">关联服务器</Label>
|
||||
<Select value={serverId || '__none'} onValueChange={(v) => setServerId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder=t('deploy.buildnodesmanager.s030235') /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="选择服务器" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">{t('common.selectPlaceholder')}</SelectItem>
|
||||
<SelectItem value="__none">请选择</SelectItem>
|
||||
{servers.map((s) => (
|
||||
<SelectItem key={s.id} value={String(s.id)}>
|
||||
{s.hostname} ({s.ip})
|
||||
@@ -309,25 +307,25 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('deploy.buildnodesmanager.s8e408e')}</Label>
|
||||
<Label className="text-xs">最大并发</Label>
|
||||
<Input value={maxExecutors} onChange={(e) => setMaxExecutors(e.target.value)} type="number" min={1} className="h-8 text-xs dg-input" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('deploy.buildnodesmanager.sbcfcd0')}</Label>
|
||||
<Label className="text-xs">工作区路径</Label>
|
||||
<Input value={workspacePath} onChange={(e) => setWorkspacePath(e.target.value)} className="h-8 text-xs font-mono dg-input" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('common.tag')} <span className="text-[var(--dg-text-muted)]">{t('deploy.buildnodesmanager.s6c9a78')}</span></Label>
|
||||
<Label className="text-xs">标签 <span className="text-[var(--dg-text-muted)]">(可选)</span></Label>
|
||||
<Input value={labels} onChange={(e) => setLabels(e.target.value)} className="h-8 text-xs dg-input" placeholder="linux,docker,high-mem" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('common.description')}</Label>
|
||||
<Label className="text-xs">描述</Label>
|
||||
<Input value={description} onChange={(e) => setDescription(e.target.value)} className="h-8 text-xs dg-input" />
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? i18n.t('deploy.buildnodesmanager.s7cbf2d') : i18n.t('common.save')}</Button>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '保存中' : '保存'}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
@@ -344,7 +342,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
|
||||
}
|
||||
|
||||
return (
|
||||
<PageShell title=i18n.t('pipeline.buildNode') subtitle=i18n.t('deploy.buildnodesmanager.sb2b825')>
|
||||
<PageShell title="构建节点" subtitle="Master 与远程 SSH 构建节点">
|
||||
{panel}
|
||||
{dialog}
|
||||
</PageShell>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
@@ -10,7 +11,6 @@ import { Plus, Key, Globe, Container } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { credentialApi, deployApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Repo {
|
||||
id: number; name: string; url: string; credential_id: number | null;
|
||||
@@ -89,7 +89,7 @@ export default function CredentialFormDialog({ open, onOpenChange, type, editId,
|
||||
}
|
||||
onOpenChange(false);
|
||||
onSave();
|
||||
} catch { toast.error(t('common.saveFailed')); }
|
||||
} catch { toast.error(t('deploy.toast.saveFailed')); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
@@ -101,20 +101,20 @@ export default function CredentialFormDialog({ open, onOpenChange, type, editId,
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<CredIcon className="h-5 w-5 text-[var(--dg-blue)]" />
|
||||
{editId ? t('common.edit') : t('deploy.credentialformdialog.s66ab5e')}{isRepo ? t('deploy.credentialformdialog.s0b7cb4') : t('deploy.credentialformdialog.s7f34a8')}
|
||||
{editId ? t('common.edit') : t('common.add')} {isRepo ? t('deploy.credForm.gitRepo') : t('deploy.credForm.imageRegistry')}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="space-y-1"><Label>{t('deploy.form.nameStar')}</Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder={isRepo ? t('deploy.credentialformdialog.se9548a') : t('deploy.credentialformdialog.sa4d9ef')} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder={isRepo ? t('deploy.credForm.repoNamePlaceholder') : t('deploy.credForm.registryNamePlaceholder')} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
|
||||
<div className="space-y-1"><Label>{isRepo ? t('deploy.form.repoUrlStar') : 'Registry URL *'}</Label>
|
||||
<Input value={url} onChange={e => setUrl(e.target.value)} placeholder={isRepo ? 'http://10.10.10.72:9000/group/repo.git' : 'http://10.10.10.72'} className="font-mono text-sm bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
|
||||
{!isRepo && <div className="space-y-1"><Label>{t('deploy.form.defaultNamespace')}</Label>
|
||||
<Input value={namespace} onChange={e => setNamespace(e.target.value)} placeholder=t('deploy.credentialformdialog.s2a8bb1') className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>}
|
||||
<Input value={namespace} onChange={e => setNamespace(e.target.value)} placeholder={t('deploy.credForm.namespacePlaceholder')} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>}
|
||||
|
||||
{/* 关联凭证 */}
|
||||
<div className="space-y-1">
|
||||
<Label>{t('deploy.cred.linkedCred')} <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">{t('deploy.credentialformdialog.s30220a')}</span></Label>
|
||||
<Label>{t('deploy.cred.linkedCred')} <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">{t('deploy.credForm.credHint')}</span></Label>
|
||||
<Select value={String(credentialId)} onValueChange={v => setCredentialId(v === '__none' ? '' : v)}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue placeholder={isRepo ? t('deploy.cred.selectApiCred') : t('deploy.cred.selectDockerPwd')} />
|
||||
@@ -134,14 +134,14 @@ export default function CredentialFormDialog({ open, onOpenChange, type, editId,
|
||||
{filteredCredentials.length === 0 && (
|
||||
<p className="text-xs dg-text-yellow flex items-center gap-1 mt-1">
|
||||
<Plus className="h-3 w-3" />
|
||||
暂无{isRepo ? '' : t('pipeline.dockerPassword')}类型凭证,请在凭证配置页面新增
|
||||
{t('deploy.credForm.noCredHint', { type: isRepo ? '' : t('deploy.credForm.dockerPwd') })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? i18n.t('k8s.topology.saving') : i18n.t('common.save')}</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? t('deploy.pipeline.saving') : t('common.save')}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Plus, Pencil, Trash2, RefreshCw, GitBranch, Container, Globe, Settings2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -18,7 +19,6 @@ import { deployApi } from '@/api';
|
||||
import CredentialFormDialog from './CredentialFormDialog';
|
||||
import ToolConfigTab from './ToolConfigTab';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Repo {
|
||||
id: number; name: string; description: string; url: string;
|
||||
@@ -67,31 +67,31 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
|
||||
toast.success(editData ? t('deploy.toast.updated') : t('deploy.toast.created'));
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch { toast.error(t('common.operationFailed')); }
|
||||
} catch { toast.error(t('deploy.toast.operationFailed')); }
|
||||
finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onClose}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{editData ? t('pipeline.editEnv') : t('pipeline.addEnv')}</DialogTitle></DialogHeader>
|
||||
<DialogHeader><DialogTitle>{editData ? t('deploy.env.editEnv') : t('deploy.env.addEnv')}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1"><Label>{t('pipeline.envLabel')}</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
|
||||
<div className="space-y-1"><Label>{t('system.userDisplayName')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder=t('pipeline.prodEnv') /></div>
|
||||
<div className="space-y-1"><Label>{t('pipeline.envType')}</Label>
|
||||
<div className="space-y-1"><Label>{t('deploy.env.envLabel')}</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
|
||||
<div className="space-y-1"><Label>{t('deploy.table.displayName')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={t('deploy.env.prodEnv')} /></div>
|
||||
<div className="space-y-1"><Label>{t('deploy.env.envType')}</Label>
|
||||
<Select value={envType} onValueChange={setEnvType}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="production">{t('pipeline.prodEnv')}</SelectItem>
|
||||
<SelectItem value="staging">{t('pipeline.staging')}</SelectItem>
|
||||
<SelectItem value="testing">{t('pipeline.testEnv')}</SelectItem>
|
||||
<SelectItem value="development">{t('pipeline.devEnv')}</SelectItem>
|
||||
<SelectItem value="production">{t('deploy.env.prodEnv')}</SelectItem>
|
||||
<SelectItem value="staging">{t('deploy.env.staging')}</SelectItem>
|
||||
<SelectItem value="testing">{t('deploy.env.testEnv')}</SelectItem>
|
||||
<SelectItem value="development">{t('deploy.env.devEnv')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1"><Label>{t('deploy.deploycredentials.sc06ac4')}</Label><Input value={color} onChange={e => setColor(e.target.value)} type="color" className="w-16 h-9 p-1" /></div>
|
||||
<div className="space-y-1"><Label>{t('deploy.deploycredentials.s65dd48')}</Label><Input value={sortOrder} onChange={e => setSortOrder(Number(e.target.value))} type="number" /></div>
|
||||
<div className="space-y-1"><Label>{t('common.description')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} placeholder=t('common.optional') /></div>
|
||||
<div className="space-y-1"><Label>{t('deploy.table.color')}</Label><Input value={color} onChange={e => setColor(e.target.value)} type="color" className="w-16 h-9 p-1" /></div>
|
||||
<div className="space-y-1"><Label>{t('deploy.table.sortOrder')}</Label><Input value={sortOrder} onChange={e => setSortOrder(Number(e.target.value))} type="number" /></div>
|
||||
<div className="space-y-1"><Label>{t('deploy.form.desc')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} placeholder={t('common.optional')} /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
@@ -136,32 +136,32 @@ export default function DeployCredentials() {
|
||||
useEffect(() => { fetchAll(); }, []);
|
||||
|
||||
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: JsonData) => {
|
||||
const ok = await _confirm({ title: t('common.delete'), description: t('deploy.deploycredentials.s999d97', { item.name || item.display_name: item.name || item.display_name }), confirmText: t('common.delete'), variant: 'destructive' });
|
||||
const ok = await _confirm({ title: t('common.delete'), description: t('deploy.toast.deleteConfirm', { name: item.name || item.display_name }), confirmText: t('common.delete'), variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try {
|
||||
if (type === 'repo') await deployApi.deleteRepo(item.id);
|
||||
else if (type === 'registry') await deployApi.deleteRegistry(item.id);
|
||||
else await deployApi.deleteEnvironment(item.id);
|
||||
toast.success(t('alert.deleted'));
|
||||
toast.success(t('deploy.toast.deleted'));
|
||||
fetchAll();
|
||||
} catch { toast.error(t('common.deleteFailed')); }
|
||||
} catch { toast.error(t('deploy.toast.deleteFailed')); }
|
||||
};
|
||||
|
||||
const envTypeLabel: Record<string, string> = { production: t('pipeline.production'), staging: t('pipeline.staging'), testing: t('common.test'), development: t('pipeline.development') };
|
||||
const envTypeLabel: Record<string, string> = { production: t('deploy.env.production'), staging: t('deploy.env.staging'), testing: t('deploy.env.testing'), development: t('deploy.env.development') };
|
||||
|
||||
const pageTabs = [
|
||||
{ id: 'repos', label: t('deploy.deploycredentials.sd71d03'), badge: repos.length || undefined },
|
||||
{ id: 'registries', label: t('pipeline.registry'), badge: registries.length || undefined },
|
||||
{ id: 'environments', label: t('pipeline.envManage'), badge: environments.length || undefined },
|
||||
{ id: 'build-nodes', label: t('pipeline.buildNode') },
|
||||
{ id: 'repos', label: t('deploy.credForm.gitRepo'), badge: repos.length || undefined },
|
||||
{ id: 'registries', label: t('deploy.credForm.imageRegistry'), badge: registries.length || undefined },
|
||||
{ id: 'environments', label: t('deploy.label.envManage'), badge: environments.length || undefined },
|
||||
{ id: 'build-nodes', label: t('deploy.deployNav.buildNodes') },
|
||||
{ id: 'tools', label: t('deploy.tool.title') },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title=t('menu.deployConfig')
|
||||
subtitle=t('deploy.deploycredentials.s15a730')
|
||||
title={t('deploy.label.releaseConfig')}
|
||||
subtitle={t('deploy.credForm.pageSubtitle')}
|
||||
icon={<Settings2 className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
tabs={pageTabs}
|
||||
@@ -170,13 +170,13 @@ export default function DeployCredentials() {
|
||||
actions={activeTab === 'build-nodes' ? undefined : (
|
||||
<>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />刷新
|
||||
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
|
||||
</Button>
|
||||
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增 Git 仓库
|
||||
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRepo')}
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增镜像仓库
|
||||
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRegistry')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -184,17 +184,17 @@ export default function DeployCredentials() {
|
||||
>
|
||||
{activeTab === 'repos' && (
|
||||
repos.length === 0 ? (
|
||||
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title=t('deploy.deploycredentials.sb19733') />
|
||||
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title={t('deploy.credForm.noRepo')} />
|
||||
) : (
|
||||
<ContentPanel noPadding>
|
||||
<div className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.name')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('deploy.table.repoUrl')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -224,18 +224,18 @@ export default function DeployCredentials() {
|
||||
|
||||
{activeTab === 'registries' && (
|
||||
registries.length === 0 ? (
|
||||
<PageEmpty icon={<Container className="h-10 w-10" />} title=t('deploy.deploycredentials.sdf33f0') />
|
||||
<PageEmpty icon={<Container className="h-10 w-10" />} title={t('deploy.credForm.noRegistry')} />
|
||||
) : (
|
||||
<ContentPanel noPadding>
|
||||
<div className="overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.name')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">Registry URL</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">{t('k8s.column.namespace')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.deploycredentials.sb7158a')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">{t('deploy.credForm.namespace')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -274,8 +274,8 @@ export default function DeployCredentials() {
|
||||
{environments.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Globe className="h-10 w-10" />}
|
||||
title=t('pipeline.noEnv')
|
||||
description=t('deploy.deploycredentials.sac4fb6')
|
||||
title="暂无环境配置"
|
||||
description='点击"新增环境"创建'
|
||||
action={(
|
||||
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新增环境
|
||||
@@ -288,13 +288,13 @@ export default function DeployCredentials() {
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('pipeline.envLabel')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('system.userDisplayName')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.type')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.color')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.sort')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">环境标识</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">显示名称</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">颜色</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">排序</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -311,7 +311,7 @@ export default function DeployCredentials() {
|
||||
<td className="px-3 py-2"><div className="w-6 h-6 rounded border" style={{ backgroundColor: env.color }} /></td>
|
||||
<td className="px-3 py-2 text-[var(--dg-text-secondary)]">{env.sort_order}</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge className={env.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{env.enabled ? t('common.enable') : t('common.disable')}</Badge>
|
||||
<Badge className={env.enabled ? 'bg-emerald-500/20 dg-text-green border-emerald-500/30' : 'bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)]'}>{env.enabled ? '启用' : '禁用'}</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
|
||||
@@ -17,8 +17,6 @@ import UserGroupsManager from './UserGroupsManager';
|
||||
import { JobRoleCreateDialog } from './deploy-rbac/JobRoleCreateDialog';
|
||||
import { matchPipelines, pipelineDisplayLabel, type PipelineMatchItem } from './deploy-rbac/matchPipeline';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
interface ItemRole {
|
||||
id: number;
|
||||
@@ -39,16 +37,15 @@ interface AssignedUser {
|
||||
}
|
||||
|
||||
const ROLE_LABEL: Record<string, string> = {
|
||||
owner: i18n.t('common.owner'),
|
||||
editor: i18n.t('pipeline.editor'),
|
||||
viewer: i18n.t('pipeline.viewer'),
|
||||
owner: '拥有者',
|
||||
editor: '编辑者',
|
||||
viewer: '查看者',
|
||||
};
|
||||
|
||||
const RBAC_TABS = ['roles', 'users', 'preview', 'groups'] as const;
|
||||
|
||||
// 发布 Job 权限 — 正则匹配 code / job_name,Jenkins Role Strategy 风格 // Logan.Li
|
||||
export default function DeployRBAC() {
|
||||
const { t } = useTranslation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const tabFromUrl = searchParams.get('tab');
|
||||
const [roles, setRoles] = useState<ItemRole[]>([]);
|
||||
@@ -113,7 +110,7 @@ export default function DeployRBAC() {
|
||||
|
||||
const handleCreate = async () => {
|
||||
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
|
||||
toast.error(t('deploy.deployrbac.sf6e80b')); return;
|
||||
toast.error('请填写名称、标识与正则'); return;
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -124,84 +121,84 @@ export default function DeployRBAC() {
|
||||
role: newRole,
|
||||
description: newDesc,
|
||||
});
|
||||
toast.success(t('deploy.toast.created'));
|
||||
toast.success('已创建');
|
||||
setCreateOpen(false);
|
||||
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
|
||||
loadData();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || t('monitor.quickSetupFailed'));
|
||||
toast.error((e as Error)?.message || '创建失败');
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleUpdate = async (id: number) => {
|
||||
if (!editPattern.trim()) { toast.error(t('pipeline.regexEmpty')); return; }
|
||||
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
await deployApi.updateItemRole(id, { pattern: editPattern, role: editRole });
|
||||
toast.success(t('deploy.toast.updated'));
|
||||
toast.success('已更新');
|
||||
setEditing(null);
|
||||
loadData();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || t('alert.updateFailed'));
|
||||
toast.error((e as Error)?.message || '更新失败');
|
||||
} finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (role: ItemRole) => {
|
||||
const ok = await _confirm({
|
||||
title: t('deploy.deployrbac.s5d79e4'),
|
||||
description: t('deploy.deployrbac.s2c1ef5', { role.name: role.name, matchCount[role.id]?.length || 0: matchCount[role.id]?.length || 0 }),
|
||||
title: '删除权限',
|
||||
description: `确定删除「${role.name}」?将影响 ${matchCount[role.id]?.length || 0} 个发布任务`,
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
try { await deployApi.deleteItemRole(role.id); toast.success(t('alert.deleted')); loadData(); }
|
||||
catch { toast.error(t('common.deleteFailed')); }
|
||||
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleToggleStatus = async (role: ItemRole) => {
|
||||
try {
|
||||
await deployApi.updateItemRole(role.id, { status: role.status ? 0 : 1 });
|
||||
toast.success(role.status ? t('alert.disabled') : t('alert.enabled'));
|
||||
toast.success(role.status ? '已禁用' : '已启用');
|
||||
loadData();
|
||||
} catch { toast.error(t('common.operationFailed')); }
|
||||
} catch { toast.error('操作失败'); }
|
||||
};
|
||||
|
||||
const handleAssign = async () => {
|
||||
if (!selectedUserId || !assignRoleId) { toast.error(t('deploy.deployrbac.s0fc539')); return; }
|
||||
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和权限'); return; }
|
||||
setAssignLoading(true);
|
||||
try {
|
||||
await deployApi.assignItemRole(Number(selectedUserId), {
|
||||
user_id: Number(selectedUserId),
|
||||
item_role_id: Number(assignRoleId),
|
||||
});
|
||||
toast.success(t('common.assigned'));
|
||||
toast.success('已分配');
|
||||
setAssignRoleId('');
|
||||
loadAssignments();
|
||||
} catch (e: unknown) {
|
||||
toast.error((e as Error)?.message || t('deploy.rbac.assignFail'));
|
||||
toast.error((e as Error)?.message || '分配失败');
|
||||
} finally { setAssignLoading(false); }
|
||||
};
|
||||
|
||||
const handleRevoke = async (id: number) => {
|
||||
try {
|
||||
await deployApi.removeItemRole(Number(selectedUserId), id);
|
||||
toast.success(t('deploy.toast.revoked'));
|
||||
toast.success('已撤销');
|
||||
loadAssignments();
|
||||
} catch { toast.error(t('deploy.toast.revokeFailed')); }
|
||||
} catch { toast.error('撤销失败'); }
|
||||
};
|
||||
|
||||
const selectedUser = users.find((u) => String(u.id) === selectedUserId);
|
||||
const pageTabs = [
|
||||
{ id: 'roles', label: t('deploy.deployrbac.s93ef07'), badge: roles.length || undefined },
|
||||
{ id: 'users', label: t('deploy.deployrbac.s2de895') },
|
||||
{ id: 'groups', label: t('pipeline.userGroup') },
|
||||
{ id: 'preview', label: t('pipeline.matchPreview') },
|
||||
{ id: 'roles', label: '权限策略', badge: roles.length || undefined },
|
||||
{ id: 'users', label: '用户授权' },
|
||||
{ id: 'groups', label: '用户组' },
|
||||
{ id: 'preview', label: '命中预览' },
|
||||
];
|
||||
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title=t('deploy.deployNav.jobPermission')
|
||||
subtitle=t('deploy.deployrbac.sa596d6')
|
||||
title="Job 权限"
|
||||
subtitle="按流水线 code / Job 名称正则分配发布权限"
|
||||
icon={<Shield className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
tabs={pageTabs}
|
||||
@@ -218,9 +215,9 @@ export default function DeployRBAC() {
|
||||
roles.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Shield className="h-10 w-10" />}
|
||||
title=t('deploy.deployrbac.sca578c')
|
||||
description=t('deploy.deployrbac.s5ac382')
|
||||
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" />{t('deploy.deployrbac.s605884')}</Button>}
|
||||
title="暂无 Job 权限策略"
|
||||
description="创建策略后,用户将按正则自动获得匹配流水线的访问权限"
|
||||
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" />新建权限</Button>}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
@@ -234,7 +231,7 @@ export default function DeployRBAC() {
|
||||
<Badge variant="outline" className="text-[10px] border-[var(--dg-border)]">
|
||||
{ROLE_LABEL[role.role] || role.role}
|
||||
</Badge>
|
||||
{role.status === 0 && <Badge variant="outline" className="text-[10px]">{t('alert.disabled')}</Badge>}
|
||||
{role.status === 0 && <Badge variant="outline" className="text-[10px]">已禁用</Badge>}
|
||||
<Badge
|
||||
variant="outline"
|
||||
className="text-[10px] cursor-pointer border-[var(--dg-border)]"
|
||||
@@ -255,9 +252,9 @@ export default function DeployRBAC() {
|
||||
<Select value={editRole} onValueChange={setEditRole}>
|
||||
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="owner">{t('common.owner')}</SelectItem>
|
||||
<SelectItem value="editor">{t('pipeline.editor')}</SelectItem>
|
||||
<SelectItem value="viewer">{t('pipeline.viewer')}</SelectItem>
|
||||
<SelectItem value="owner">拥有者</SelectItem>
|
||||
<SelectItem value="editor">编辑者</SelectItem>
|
||||
<SelectItem value="viewer">查看者</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button size="icon" variant="ghost" className="h-7 w-7" onClick={() => handleUpdate(role.id)}><Check className="w-3.5 h-3.5" /></Button>
|
||||
@@ -265,8 +262,8 @@ export default function DeployRBAC() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}>{t('common.edit')}</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? t('common.disable') : t('common.enable')}</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => { setEditing(role.id); setEditPattern(role.pattern); setEditRole(role.role); }}>编辑</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-[var(--dg-red)]" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
||||
</>
|
||||
)}
|
||||
@@ -284,9 +281,9 @@ export default function DeployRBAC() {
|
||||
<CardContent className="p-4 space-y-4">
|
||||
<div className="flex flex-wrap gap-3 items-end">
|
||||
<div className="space-y-1 flex-1 min-w-[200px]">
|
||||
<Label className="text-xs">{t('system.roleUsers')}</Label>
|
||||
<Label className="text-xs">用户</Label>
|
||||
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder=t('deploy.rbac.selectUser') /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择用户" /></SelectTrigger>
|
||||
<SelectContent className="max-h-60">
|
||||
{users.map((u) => (
|
||||
<SelectItem key={u.id as number} value={String(u.id)}>
|
||||
@@ -297,9 +294,9 @@ export default function DeployRBAC() {
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1 w-48">
|
||||
<Label className="text-xs">{t('deploy.deployrbac.s93ef07')}</Label>
|
||||
<Label className="text-xs">权限策略</Label>
|
||||
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder=t('kubernetes.hpa.selectPolicy') /></SelectTrigger>
|
||||
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择策略" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{roles.filter((r) => r.status === 1).map((r) => (
|
||||
<SelectItem key={r.id} value={String(r.id)}>{r.name} ({ROLE_LABEL[r.role]})</SelectItem>
|
||||
@@ -314,7 +311,7 @@ export default function DeployRBAC() {
|
||||
</div>
|
||||
{selectedUser ? (
|
||||
assignments.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">{t('deploy.deployrbac.sef9548')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)]">该用户尚未分配 Job 权限</p>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
{assignments.map((a) => {
|
||||
@@ -335,7 +332,7 @@ export default function DeployRBAC() {
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" />{t('deploy.deployrbac.s9be00c')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] flex items-center gap-1"><Users className="w-3.5 h-3.5" />选择用户后查看已分配权限</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -370,7 +367,7 @@ export default function DeployRBAC() {
|
||||
</Badge>
|
||||
))}
|
||||
{previewRoleId !== null && previewMatches.length === 0 && (
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{t('deploy.deployrbac.s64096f')}</span>
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">无匹配 — 请调整正则</span>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -19,7 +20,6 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { pipelineApi } from '@/api';
|
||||
import { useUserStore } from '@/store';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface FileItem {
|
||||
name: string;
|
||||
@@ -58,12 +58,12 @@ export function FileBrowserDialog({
|
||||
runId,
|
||||
buildNumber,
|
||||
}: FileBrowserDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [listing, setListing] = useState<ListingData | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [currentPath, setCurrentPath] = useState('');
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const { t } = useTranslation();
|
||||
|
||||
// 加载目录
|
||||
const loadDir = async (path: string) => {
|
||||
@@ -75,7 +75,7 @@ export function FileBrowserDialog({
|
||||
setListing(data);
|
||||
setCurrentPath(data?.current || '');
|
||||
} catch (err) {
|
||||
setError(err?.message || t('common.loadFailed'));
|
||||
setError(err?.message || t('deploy.toast.loadFailed'));
|
||||
setListing(null);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
@@ -115,7 +115,7 @@ export function FileBrowserDialog({
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '700px', width: '85vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">文件浏览器 #{buildNumber}</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t('deploy.pipeline.fileBrowser', { buildNumber })}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{t('deploy.pipeline.buildDirFiles')}</DialogDescription>
|
||||
|
||||
{/* Header */}
|
||||
@@ -123,7 +123,7 @@ export function FileBrowserDialog({
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium text-sm">
|
||||
#{buildNumber} 工作区文件
|
||||
#{buildNumber} {t('deploy.fileBrowser.workspaceFiles')}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
@@ -172,7 +172,7 @@ export function FileBrowserDialog({
|
||||
<div className="overflow-y-auto" style={{ height: fullscreen ? 'calc(100vh - 110px)' : '420px' }}>
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)] text-sm">
|
||||
加载中...
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -186,7 +186,7 @@ export function FileBrowserDialog({
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)]">
|
||||
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
|
||||
<th className="py-2 pl-4 pr-2 font-medium">{t('common.name')}</th>
|
||||
<th className="py-2 pl-4 pr-2 font-medium">{t('deploy.table.name')}</th>
|
||||
<th className="py-2 px-2 font-medium w-24 text-right">{t('deploy.table.size')}</th>
|
||||
<th className="py-2 pr-4 pl-2 font-medium w-40 hidden sm:table-cell">{t('deploy.table.modifyTime')}</th>
|
||||
<th className="py-2 pr-4 pl-2 font-medium w-16" />
|
||||
@@ -245,7 +245,7 @@ export function FileBrowserDialog({
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); downloadFile(file); }}
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
|
||||
title=t('common.download')
|
||||
title={t('deploy.label.download')}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
|
||||
@@ -77,7 +77,7 @@ export function PipelineDetailDialog({
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-[var(--dg-text-secondary)] w-6">{index + 1}</span>
|
||||
{getStageStatusIcon(stage.status)}
|
||||
{getStageStatusIcon(stage.status, t)}
|
||||
<span className="text-[var(--dg-text-primary)]">{stage.name}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -144,7 +144,7 @@ export function PipelineDetailDialog({
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{getStatusBadge(run.status)}
|
||||
{getStatusBadge(run.status, t)}
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)] text-xs">
|
||||
<GitBranch className="h-3 w-3 mr-1" />
|
||||
{run.branch || 'main'}
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { pipelineTemplates } from './pipeline-helpers';
|
||||
import { getPipelineTemplates } from './pipeline-helpers';
|
||||
|
||||
// 表单数据结构 // Logan.Li
|
||||
export type PipelineFormData = {
|
||||
@@ -91,7 +91,7 @@ export function PipelineFormDialog({
|
||||
<div>
|
||||
<Label className="text-[var(--dg-text-secondary)]">{t('deploy.template.selectTemplate')}</Label>
|
||||
<div className="grid grid-cols-2 gap-3 mt-1">
|
||||
{pipelineTemplates.map((template) => (
|
||||
{getPipelineTemplates(t).map((template) => (
|
||||
<Card
|
||||
key={template.id}
|
||||
className={`cursor-pointer transition-all ${
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import React from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import {
|
||||
Play, Square, RefreshCw, Clock, User, AlertCircle, CheckCircle2,
|
||||
@@ -15,17 +16,15 @@ import { FileBrowserDialog } from './FileBrowserDialog';
|
||||
import { PageShell } from '@/components/layout/PageShell';
|
||||
import { StatStrip } from '@/components/ui/stat-strip';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// ═══════════ 阶段定义 (与 PipelineManager 共用) ═══════════
|
||||
const STAGE_DEFS = [
|
||||
{ key: 'checkout', label: i18n.t('pipeline.checkout'), icon: GitBranch, color: '#6366F1', order: 1 },
|
||||
{ key: 'build', label: i18n.t('pipeline.build'), icon: Box, color: '#F59E0B', order: 2 },
|
||||
{ key: 'test', label: i18n.t('common.test'), icon: CheckCircle2,color: '#10B981', order: 3 },
|
||||
{ key: 'image', label: i18n.t('deploy.stage.image'), icon: Container, color: '#06B6D4', order: 4 },
|
||||
{ key: 'deploy', label: i18n.t('pipeline.deploy'), icon: Rocket, color: '#EF4444', order: 5 },
|
||||
{ key: 'verify', label: i18n.t('common.validate'), icon: ShieldCheck, color: '#3B82F6', order: 6 },
|
||||
{ key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1 },
|
||||
{ key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2 },
|
||||
{ key: 'test', label: '测试', icon: CheckCircle2,color: '#10B981', order: 3 },
|
||||
{ key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4 },
|
||||
{ key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5 },
|
||||
{ key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6 },
|
||||
].sort((a, b) => a.order - b.order);
|
||||
|
||||
interface PipelineRun {
|
||||
@@ -76,10 +75,10 @@ const STATUS_ICONS: JsonData = {
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
if (!seconds) return '-';
|
||||
if (seconds < 60) return i18n.t('deploy.pipelinerundetail.sbe383c', { seconds: seconds });
|
||||
if (seconds < 60) return `${seconds}秒`;
|
||||
const m = Math.floor(seconds / 60);
|
||||
const s = seconds % 60;
|
||||
return i18n.t('deploy.pipelinerundetail.s084409', { m: m, s: s });
|
||||
return `${m}分${s}秒`;
|
||||
}
|
||||
|
||||
function formatTime(ts: string): string {
|
||||
@@ -134,6 +133,19 @@ function getStageLogSummary(logs: LogEntry[], stageKey: string): string {
|
||||
export default function PipelineRunDetail() {
|
||||
const { t } = useTranslation();
|
||||
const { code: pipelineCode } = useParams<{ code: string }>();
|
||||
|
||||
// 阶段名称国际化映射
|
||||
const stageLabel = (key: string) => {
|
||||
const map: Record<string, string> = {
|
||||
checkout: t('deploy.stage.checkout'),
|
||||
build: t('deploy.stage.build'),
|
||||
test: t('deploy.stage.test'),
|
||||
image: t('deploy.stage.image'),
|
||||
deploy: t('deploy.stage.deploy'),
|
||||
verify: t('common.verify'),
|
||||
};
|
||||
return map[key] || key;
|
||||
};
|
||||
const [pipeline, setPipeline] = useState<JsonData>(null);
|
||||
const [runs, setRuns] = useState<PipelineRun[]>([]);
|
||||
const [selectedRun, setSelectedRun] = useState<PipelineRun | null>(null);
|
||||
@@ -168,7 +180,7 @@ export default function PipelineRunDetail() {
|
||||
if (!selectedRun && list.length > 0) {
|
||||
setSelectedRun(list[0]);
|
||||
}
|
||||
} catch { toast.error(t('deploy.pipelinerundetail.sa4e14e')); }
|
||||
} catch { toast.error(t('deploy.runDetail.fetchRunsFailed')); }
|
||||
finally { setLoading(false); }
|
||||
}, [pipelineCode, pageSize]);
|
||||
|
||||
@@ -239,18 +251,18 @@ export default function PipelineRunDetail() {
|
||||
const triggerRun = async () => {
|
||||
try {
|
||||
await pipelineApi.triggerByCode(pipelineCode);
|
||||
toast.success(t('automation.triggered'));
|
||||
toast.success(t('deploy.toast.triggered'));
|
||||
fetchRuns();
|
||||
} catch { toast.error(t('automation.triggerFailed')); }
|
||||
} catch { toast.error(t('deploy.toast.triggerFailed')); }
|
||||
};
|
||||
|
||||
// 停止
|
||||
const stopRun = async (runId: number) => {
|
||||
try {
|
||||
await pipelineApi.stopRunByCode(pipelineCode, runId);
|
||||
toast.success(t('common.stopped'));
|
||||
toast.success(t('deploy.toast.stopped'));
|
||||
fetchRuns();
|
||||
} catch { toast.error(t('pipeline.stopFailed')); }
|
||||
} catch { toast.error(t('deploy.toast.stopFailed')); }
|
||||
};
|
||||
|
||||
// 批量删除
|
||||
@@ -259,10 +271,10 @@ export default function PipelineRunDetail() {
|
||||
setDeleting(true);
|
||||
try {
|
||||
await pipelineApi.batchDeleteRuns(pipelineCode, Array.from(selectedIds));
|
||||
toast.success(t('deploy.pipelinerundetail.s4b340c', { selectedIds.size: selectedIds.size }));
|
||||
toast.success(t('deploy.runDetail.deletedCount', { count: selectedIds.size }));
|
||||
setSelectedIds(new Set());
|
||||
fetchRuns();
|
||||
} catch { toast.error(t('cloud.batchDeleteFailed')); }
|
||||
} catch { toast.error(t('deploy.toast.batchDeleteFailed')); }
|
||||
finally { setDeleting(false); }
|
||||
};
|
||||
|
||||
@@ -302,15 +314,15 @@ export default function PipelineRunDetail() {
|
||||
<PageShell
|
||||
variant="workspace"
|
||||
title={pipeline?.name || t('deploy.pipeline.detail')}
|
||||
subtitle={selectedRun ? t('deploy.pipelinerundetail.se9940a', { selectedRun.build_number: selectedRun.build_number }) : (pipeline?.code ? `code: ${pipeline.code}` : t('deploy.pipelinerundetail.sefc52b'))}
|
||||
subtitle={selectedRun ? t('deploy.pipeline.release', { number: selectedRun.build_number }) : (pipeline?.code ? `code: ${pipeline.code}` : t('deploy.runDetail.recordsAndLogs'))}
|
||||
icon={<History className="h-5 w-5" />}
|
||||
contentClassName="space-y-4 h-full flex flex-col"
|
||||
metrics={(
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: t('deploy.pipelinerundetail.s9b409b'), value: runs.length, color: 'blue' },
|
||||
{ label: t('common.success'), value: successCount, color: 'emerald' },
|
||||
{ label: t('common.executing'), value: runningCount, color: 'amber' },
|
||||
{ label: t('deploy.runDetail.execRecords'), value: runs.length, color: 'blue' },
|
||||
{ label: t('deploy.status.success'), value: successCount, color: 'emerald' },
|
||||
{ label: t('deploy.runDetail.executing'), value: runningCount, color: 'amber' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
@@ -326,7 +338,7 @@ export default function PipelineRunDetail() {
|
||||
)}
|
||||
{selectedIds.size > 0 && (
|
||||
<Button variant="destructive" size="sm" onClick={batchDelete} disabled={deleting}>
|
||||
<Trash2 className="w-4 h-4 mr-1" />删除 ({selectedIds.size})
|
||||
<Trash2 className="w-4 h-4 mr-1" />{t('common.delete')} ({selectedIds.size})
|
||||
</Button>
|
||||
)}
|
||||
<select
|
||||
@@ -334,15 +346,15 @@ export default function PipelineRunDetail() {
|
||||
onChange={e => setPageSize(Number(e.target.value))}
|
||||
className="h-8 px-2 text-xs rounded border border-[var(--dg-border)] dg-input"
|
||||
>
|
||||
<option value={10}>{t('deploy.pipelinerundetail.s8776a1')}</option>
|
||||
<option value={20}>{t('deploy.pipelinerundetail.s96dbf3')}</option>
|
||||
<option value={30}>{t('deploy.pipelinerundetail.s074a81')}</option>
|
||||
<option value={10}>10{t('deploy.runDetail.perPage')}</option>
|
||||
<option value={20}>20{t('deploy.runDetail.perPage')}</option>
|
||||
<option value={30}>30{t('deploy.runDetail.perPage')}</option>
|
||||
</select>
|
||||
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchRuns}>
|
||||
<RefreshCw className="w-4 h-4 mr-1" />刷新
|
||||
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
|
||||
</Button>
|
||||
<Button size="sm" className="dg-btn-primary" onClick={triggerRun}>
|
||||
<Play className="w-4 h-4 mr-1" />立即执行
|
||||
<Play className="w-4 h-4 mr-1" />{t('deploy.pipeline.runNow')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
@@ -383,7 +395,7 @@ export default function PipelineRunDetail() {
|
||||
<div key={def.key} className="flex items-center">
|
||||
<button
|
||||
onClick={() => setSelectedStage(selectedStage === def.key ? null : def.key)}
|
||||
title={`${def.label}${status ? ` (${status})t('deploy.pipelinerundetail.sdaf2bb')}
|
||||
title={`${stageLabel(def.key)}${status ? ` (${status})` : ''} — ${t('deploy.runDetail.clickToViewLog')}`}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-[12px] font-semibold border shadow-sm transition-all cursor-pointer
|
||||
${isActive ? 'ring-1 ring-offset-0 scale-[1.02]' : 'hover:brightness-110'}`}
|
||||
style={{
|
||||
@@ -394,7 +406,7 @@ export default function PipelineRunDetail() {
|
||||
}}
|
||||
>
|
||||
{icon}
|
||||
<span>{def.label}</span>
|
||||
<span>{stageLabel(def.key)}</span>
|
||||
</button>
|
||||
{!isLast && (
|
||||
<div className="flex items-center justify-center w-5 h-5 -mx-px z-10">
|
||||
@@ -406,7 +418,7 @@ export default function PipelineRunDetail() {
|
||||
})}
|
||||
{selectedRun && (
|
||||
<span className="ml-auto text-[10px] text-[var(--dg-text-secondary)] opacity-40 shrink-0 pl-3 whitespace-nowrap">
|
||||
{selectedStage ? STAGE_DEFS.find(d => d.key === selectedStage)?.label : t('pipeline.allStages')}
|
||||
{selectedStage ? stageLabel(selectedStage) : t('deploy.pipeline.allStages')}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@@ -418,7 +430,7 @@ export default function PipelineRunDetail() {
|
||||
{loading && runs.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-text-muted)]" />
|
||||
加载中...
|
||||
{t('common.loading')}
|
||||
</div>
|
||||
) : runs.length === 0 ? (
|
||||
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg m-4">
|
||||
@@ -433,12 +445,12 @@ export default function PipelineRunDetail() {
|
||||
<input type="checkbox" checked={selectedIds.size === runs.length && runs.length > 0}
|
||||
onChange={toggleAll} className="rounded" />
|
||||
</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">{t('pipeline.build')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">{t('common.status')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-16">{t('deploy.pipeline.buildNumber')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">{t('deploy.table.status')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.trigger')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('system.roleUsers')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.elapsed')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('monitor.logTime')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.user')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.duration')}</th>
|
||||
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('common.createdAt')}</th>
|
||||
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -460,10 +472,10 @@ export default function PipelineRunDetail() {
|
||||
<td className="px-3 py-2.5">
|
||||
<Badge className={`text-xs ${STATUS_BADGES[run.status] || ''} flex items-center gap-1 w-fit`}>
|
||||
<StatusIcon className={`w-3 h-3 ${run.status === 'running' ? 'animate-spin' : ''}`} />
|
||||
{run.status === 'success' ? t('common.success')
|
||||
: run.status === 'failed' ? t('common.failed')
|
||||
: run.status === 'running' ? t('common.executing')
|
||||
: run.status === 'cancelled' ? t('deploy.toast.cancelled')
|
||||
{run.status === 'success' ? t('deploy.status.success')
|
||||
: run.status === 'failed' ? t('deploy.status.failed')
|
||||
: run.status === 'running' ? t('deploy.runDetail.executing')
|
||||
: run.status === 'cancelled' ? t('deploy.status.cancelled')
|
||||
: t('deploy.status.pending')}
|
||||
</Badge>
|
||||
</td>
|
||||
@@ -477,7 +489,7 @@ export default function PipelineRunDetail() {
|
||||
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] font-mono text-xs">
|
||||
{run.status === 'running' ? (
|
||||
<span className="flex items-center gap-1 dg-text-blue">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />执行中...
|
||||
<Loader2 className="w-3 h-3 animate-spin" />{t('deploy.pipeline.executing')}
|
||||
</span>
|
||||
) : formatDuration(run.duration)}
|
||||
</td>
|
||||
@@ -488,11 +500,11 @@ export default function PipelineRunDetail() {
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => setSelectedRun(run)}>
|
||||
日志
|
||||
{t('deploy.label.log')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs"
|
||||
onClick={() => { setBrowserRun({ id: run.id, buildNumber: run.build_number }); setBrowserOpen(true); }}>
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />浏览
|
||||
<FolderOpen className="w-3.5 h-3.5 mr-1" />{t('deploy.label.browse')}
|
||||
</Button>
|
||||
{run.status === 'running' && (
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
|
||||
@@ -520,19 +532,19 @@ export default function PipelineRunDetail() {
|
||||
</CardTitle>
|
||||
{selectedRun.status === 'running' && (
|
||||
<Badge className="bg-blue-500/20 dg-text-blue border-blue-500/30 text-xs">
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />实时
|
||||
<Loader2 className="w-3 h-3 animate-spin mr-1" />{t('deploy.runDetail.realtime')}
|
||||
</Badge>
|
||||
)}
|
||||
{selectedStage && (
|
||||
<Badge className="text-xs border-[var(--dg-border)]" variant="outline">
|
||||
{STAGE_DEFS.find(d => d.key === selectedStage)?.label || selectedStage}
|
||||
{stageLabel(selectedStage)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--dg-text-muted)]">
|
||||
<span>触发: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
||||
<span>{t('deploy.runDetail.trigger')}: {triggerLabel(selectedRun)} ({selectedRun.triggered_by || 'admin'})</span>
|
||||
<span className="opacity-40">|</span>
|
||||
<span>分支: {selectedRun.branch || '-'}</span>
|
||||
<span>{t('deploy.run.branch')}: {selectedRun.branch || '-'}</span>
|
||||
{selectedRun.commit_id && (
|
||||
<>
|
||||
<span className="opacity-40">|</span>
|
||||
@@ -557,7 +569,7 @@ export default function PipelineRunDetail() {
|
||||
<div className="px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)]/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[11px] font-semibold text-[var(--dg-text-secondary)]">
|
||||
[{STAGE_DEFS.find(d => d.key === selectedStage)?.label || selectedStage}]
|
||||
[{stageLabel(selectedStage)}]
|
||||
</span>
|
||||
{(() => {
|
||||
const st = stageStats.get(selectedStage);
|
||||
|
||||
@@ -64,11 +64,11 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
|
||||
commit_msg: form.commitMsg.trim(),
|
||||
description: form.description.trim(),
|
||||
});
|
||||
toast.success(t('deploy.release.submitFail'));
|
||||
toast.success(t('deploy.toast.releaseSubmitted'));
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
toast.error(t('monitor.quickSetupFailed'));
|
||||
toast.error(t('deploy.toast.createFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -78,22 +78,22 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('pipeline.newRelease')}</DialogTitle>
|
||||
<DialogTitle>{t('deploy.release.newRelease')}</DialogTitle>
|
||||
<DialogDescription>{t('deploy.release.createNew')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div>
|
||||
<Label>{t('pipeline.releaseApp')}</Label>
|
||||
<Label>{t('deploy.release.appName')}</Label>
|
||||
<Input
|
||||
value={form.appName}
|
||||
onChange={(e) => setForm({ ...form, appName: e.target.value })}
|
||||
placeholder=t('deploy.releaseCreate.appPlaceholder')
|
||||
placeholder={t('deploy.releaseCreate.appPlaceholder')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<Label>{t('pipeline.releaseVersion')}</Label>
|
||||
<Label>{t('deploy.release.versionNo')}</Label>
|
||||
<Input
|
||||
value={form.version}
|
||||
onChange={(e) => setForm({ ...form, version: e.target.value })}
|
||||
@@ -102,7 +102,7 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('pipeline.targetEnv')}</Label>
|
||||
<Label>{t('deploy.release.targetEnv')}</Label>
|
||||
<Select
|
||||
value={form.environment}
|
||||
onValueChange={(value) => setForm({ ...form, environment: value })}
|
||||
@@ -111,10 +111,10 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="dev">{t('pipeline.devEnv')}</SelectItem>
|
||||
<SelectItem value="test">{t('pipeline.testEnv')}</SelectItem>
|
||||
<SelectItem value="staging">{t('pipeline.stagingEnv')}</SelectItem>
|
||||
<SelectItem value="prod">{t('pipeline.prodEnv')}</SelectItem>
|
||||
<SelectItem value="dev">{t('deploy.env.devEnv')}</SelectItem>
|
||||
<SelectItem value="test">{t('deploy.env.testEnv')}</SelectItem>
|
||||
<SelectItem value="staging">{t('deploy.env.stagingEnv')}</SelectItem>
|
||||
<SelectItem value="prod">{t('deploy.env.prodEnv')}</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -134,28 +134,28 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
|
||||
<Input
|
||||
value={form.commitMsg}
|
||||
onChange={(e) => setForm({ ...form, commitMsg: e.target.value })}
|
||||
placeholder=t('deploy.releaseCreate.commitMsgPlaceholder')
|
||||
placeholder={t('deploy.releaseCreate.commitMsgPlaceholder')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<Label>{t('pipeline.releaseDesc')}</Label>
|
||||
<Label>{t('deploy.release.releaseDesc')}</Label>
|
||||
<Textarea
|
||||
value={form.description}
|
||||
onChange={(e) => setForm({ ...form, description: e.target.value })}
|
||||
placeholder=t('deploy.releaseCreate.descPlaceholder')
|
||||
placeholder={t('deploy.releaseCreate.descPlaceholder')}
|
||||
className="mt-1"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : null}
|
||||
提交申请
|
||||
{t('deploy.release.submitApp')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 发布管理 - 审批/驳回弹窗(自包含提交逻辑) // Logan.Li
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,7 +14,6 @@ import { AlertCircle, X, Check, Loader2 } from 'lucide-react';
|
||||
import { releaseApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ReleaseApproveDialogProps {
|
||||
release: JsonData | null;
|
||||
@@ -37,7 +37,7 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
|
||||
try {
|
||||
if (approved) {
|
||||
await releaseApi.approveRelease(release.id);
|
||||
toast.success(t('approval.flow.approveTitle'));
|
||||
toast.success(t('deploy.release.approvePassed'));
|
||||
} else {
|
||||
await releaseApi.rejectRelease(release.id);
|
||||
toast.success(t('deploy.toast.rejected'));
|
||||
@@ -45,7 +45,7 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
|
||||
onOpenChange(false);
|
||||
onSuccess?.();
|
||||
} catch {
|
||||
toast.error(approved ? t('approval.msg.approveFailed') : t('deploy.release.rejectFail'));
|
||||
toast.error(approved ? t('deploy.toast.approveFailed') : t('deploy.toast.rejectFailed'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
@@ -55,7 +55,7 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('pipeline.approve')}</DialogTitle>
|
||||
<DialogTitle>{t('deploy.release.approve')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{release?.app_name} - {release?.version}
|
||||
</DialogDescription>
|
||||
@@ -63,13 +63,13 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
|
||||
<div className="py-4">
|
||||
{release?.description && (
|
||||
<div className="p-3 rounded-lg bg-[var(--dg-bg-tertiary)] mb-4">
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mb-1">{t('pipeline.releaseDesc')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-secondary)] mb-1">{t('deploy.release.releaseDesc')}</p>
|
||||
<p className="text-sm text-[var(--dg-text-primary)]">{release.description}</p>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 text-sm text-[var(--dg-text-secondary)]">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
确认审批后将进入部署就绪状态
|
||||
{t('deploy.release.approveHint')}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
@@ -80,7 +80,7 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
|
||||
onClick={() => handleApprove(false)}
|
||||
>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
驳回
|
||||
{t('deploy.release.reject')}
|
||||
</Button>
|
||||
<Button
|
||||
className="bg-emerald-600 hover:bg-emerald-700"
|
||||
@@ -88,7 +88,7 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
|
||||
onClick={() => handleApprove(true)}
|
||||
>
|
||||
{submitting ? <Loader2 className="h-4 w-4 mr-2 animate-spin" /> : <Check className="h-4 w-4 mr-2" />}
|
||||
通过
|
||||
{t('common.approve')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { RefreshCw, Loader2, Download, CheckCircle, XCircle, Clock, Settings2, ChevronDown, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
@@ -8,7 +9,6 @@ import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import { deployApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface ToolConfig {
|
||||
id: number; name: string; display_name: string; description: string;
|
||||
@@ -79,7 +79,7 @@ export default function ToolConfigTab() {
|
||||
}
|
||||
setVersionDialog({ tool, versions });
|
||||
const preferred = versions.find((v) => v.version === 'lts')?.version
|
||||
|| versions.find((v) => v.label.includes(t('common.recommended')))?.version
|
||||
|| versions.find((v) => v.label.includes('推荐'))?.version
|
||||
|| tool.default_version
|
||||
|| versions[0]?.version
|
||||
|| '';
|
||||
@@ -95,21 +95,21 @@ export default function ToolConfigTab() {
|
||||
try {
|
||||
const v = version || tool.default_version;
|
||||
await deployApi.installTool(tool.id, v);
|
||||
toast.success(`${tool.display_name}${v ? ` (${v})t('deploy.toolconfigtab.s5002c0'));
|
||||
toast.success(t('deploy.tool.installTriggered', { name: tool.display_name, version: v || '' }));
|
||||
setVersionDialog(null);
|
||||
setInstalling(null);
|
||||
void fetchTools();
|
||||
} catch {
|
||||
toast.error(t('deploy.toolconfigtab.se80fdc'));
|
||||
toast.error(t('deploy.tool.installTriggerFailed'));
|
||||
setInstalling(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUninstall = async (tool: ToolConfig) => {
|
||||
const ok = await confirm({
|
||||
title: t('agent.uninstallTool'),
|
||||
description: t('deploy.toolconfigtab.s8d9b47', { tool.display_name: tool.display_name }),
|
||||
confirmText: t('agent.uninstall'),
|
||||
title: t('deploy.tool.uninstallTool'),
|
||||
description: t('deploy.tool.uninstallConfirm', { name: tool.display_name }),
|
||||
confirmText: t('deploy.tool.uninstall'),
|
||||
variant: 'destructive',
|
||||
});
|
||||
if (!ok) return;
|
||||
@@ -117,10 +117,10 @@ export default function ToolConfigTab() {
|
||||
setUninstalling(tool.id);
|
||||
try {
|
||||
await deployApi.uninstallTool(tool.id);
|
||||
toast.success(t('deploy.toolconfigtab.sda0cb3', { tool.display_name: tool.display_name }));
|
||||
toast.success(t('deploy.tool.uninstalled', { name: tool.display_name }));
|
||||
void fetchTools();
|
||||
} catch {
|
||||
toast.error(t('agent.uninstallFailed'));
|
||||
toast.error(t('deploy.tool.uninstallFailed'));
|
||||
} finally {
|
||||
setUninstalling(null);
|
||||
}
|
||||
@@ -129,9 +129,9 @@ export default function ToolConfigTab() {
|
||||
const handleCheckStatus = async (tool: ToolConfig) => {
|
||||
try {
|
||||
const res: JsonData = await deployApi.checkTool(tool.id);
|
||||
toast.success(res?.available ? t('deploy.toolconfigtab.sa5b175', { tool.display_name: tool.display_name }) : t('deploy.toolconfigtab.sf13096', { tool.display_name: tool.display_name }));
|
||||
toast.success(res?.available ? t('deploy.tool.available', { name: tool.display_name }) : t('deploy.tool.notAvailable', { name: tool.display_name }));
|
||||
fetchTools();
|
||||
} catch { toast.error(t('deploy.toolconfigtab.s6bc0fb')); }
|
||||
} catch { toast.error(t('deploy.tool.detectFailed')); }
|
||||
};
|
||||
|
||||
// 检测工具安装状态(磁盘验证)// Logan.Li
|
||||
@@ -142,14 +142,14 @@ export default function ToolConfigTab() {
|
||||
const res: JsonData = await deployApi.checkTool(tool.id);
|
||||
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
|
||||
if (res?.available) {
|
||||
toast.success(t('deploy.toolconfigtab.s0ac554', { tool.display_name: tool.display_name }) (${res.version})` : ''} (${elapsed}s)`);
|
||||
toast.success(t('deploy.tool.ready', { name: tool.display_name, version: res.version || '', elapsed }));
|
||||
} else {
|
||||
const errMsg = (res?.tool as ToolConfig | undefined)?.error_msg;
|
||||
toast(t('deploy.toolconfigtab.s258f2b', { tool.display_name: tool.display_name }), { description: errMsg || t('deploy.toolconfigtab.s6f0fef') });
|
||||
toast(t('deploy.tool.notInstalledOrAvail', { name: tool.display_name }), { description: errMsg || t('deploy.tool.clickInstallHint') });
|
||||
}
|
||||
fetchTools();
|
||||
} catch {
|
||||
toast.error(t('deploy.toolconfigtab.s8c74ac', { tool.display_name: tool.display_name }));
|
||||
toast.error(t('deploy.tool.detectFailed', { name: tool.display_name }));
|
||||
}
|
||||
setTesting(null);
|
||||
};
|
||||
@@ -158,29 +158,29 @@ export default function ToolConfigTab() {
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="text-xs text-[var(--dg-text-secondary)]">
|
||||
管理 DevOps 工具的安装策略:预装在镜像中、首次使用自动下载(懒加载)、或禁用不安装
|
||||
{t('deploy.tool.subtitle')}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={fetchTools}>
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />刷新
|
||||
<RefreshCw className="w-3.5 h-3.5 mr-1" />{t('common.refresh')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />加载中...
|
||||
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2" />{t('common.loading')}
|
||||
</div>
|
||||
) : tools.length === 0 ? (
|
||||
<div className="text-center py-12 text-[var(--dg-text-secondary)]">
|
||||
暂无工具配置,请检查后端种子数据
|
||||
{t('deploy.tool.noConfig')}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('pipeline.tool')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">{t('common.category')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.tool.title')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">{t('deploy.table.category')}</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
|
||||
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -204,25 +204,25 @@ export default function ToolConfigTab() {
|
||||
</td>
|
||||
<td className="px-3 py-2 hidden sm:table-cell">
|
||||
<Badge className={`text-xs ${categoryColor[tool.category] || categoryColor.other}`}>
|
||||
{categoryLabelKey[tool.category] || tool.category}
|
||||
{categoryLabelKey[tool.category] ? t(categoryLabelKey[tool.category]) : tool.category}
|
||||
</Badge>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
{tool.status === 'installed' ? (
|
||||
<Badge className="bg-emerald-500/20 dg-text-green border-emerald-500/30 text-xs gap-1">
|
||||
<CheckCircle className="w-3 h-3" />已安装
|
||||
<CheckCircle className="w-3 h-3" />{t('deploy.tool.installed')}
|
||||
</Badge>
|
||||
) : tool.status === 'installing' ? (
|
||||
<Badge className="bg-amber-500/20 dg-text-yellow border-amber-500/30 text-xs gap-1">
|
||||
<Loader2 className="w-3 h-3 animate-spin" />安装中
|
||||
<Loader2 className="w-3 h-3 animate-spin" />{t('deploy.tool.installing')}
|
||||
</Badge>
|
||||
) : tool.status === 'failed' ? (
|
||||
<Badge className="bg-red-500/20 dg-text-red border-red-500/30 text-xs gap-1">
|
||||
<XCircle className="w-3 h-3" />失败
|
||||
<XCircle className="w-3 h-3" />{t('deploy.status.failed')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge className="bg-[var(--dg-bg-active)]/20 dg-text-secondary border-[var(--dg-border)] text-xs gap-1">
|
||||
<Clock className="w-3 h-3" />未安装
|
||||
<Clock className="w-3 h-3" />{t('deploy.tool.notInstalled')}
|
||||
</Badge>
|
||||
)}
|
||||
</td>
|
||||
@@ -239,7 +239,7 @@ export default function ToolConfigTab() {
|
||||
) : (
|
||||
<Trash2 className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
卸载
|
||||
{t('deploy.tool.uninstall')}
|
||||
</Button>
|
||||
)}
|
||||
{tool.install_method === 'lazy' && tool.status !== 'installed' && tool.status !== 'installing' && (
|
||||
@@ -254,7 +254,7 @@ export default function ToolConfigTab() {
|
||||
) : (
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
安装
|
||||
{t('deploy.tool.install')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
@@ -267,7 +267,7 @@ export default function ToolConfigTab() {
|
||||
) : (
|
||||
<Download className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
安装
|
||||
{t('deploy.tool.install')}
|
||||
</Button>
|
||||
)
|
||||
)}
|
||||
@@ -282,10 +282,10 @@ export default function ToolConfigTab() {
|
||||
) : (
|
||||
<ChevronDown className="w-3 h-3 mr-1" />
|
||||
)}
|
||||
测试
|
||||
{t('deploy.tool.test')}
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCheckStatus(tool)} title=t('common.detect')>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCheckStatus(tool)} title={t('deploy.tool.detect')}>
|
||||
<Settings2 className="w-3.5 h-3.5" />
|
||||
</Button>
|
||||
</div>
|
||||
@@ -304,8 +304,8 @@ export default function ToolConfigTab() {
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('deploy.tool.selectVersion')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{versionDialog?.tool.display_name} — 请选择要安装的版本
|
||||
{versionDialog?.tool.name === 'nodejs' && t('deploy.toolconfigtab.se0142a')}
|
||||
{t('deploy.tool.versionPrompt', { name: versionDialog?.tool.display_name })}
|
||||
{versionDialog?.tool.name === 'nodejs' && t('deploy.tool.nodejsLtsHint')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
@@ -337,7 +337,7 @@ export default function ToolConfigTab() {
|
||||
) : (
|
||||
<Download className="w-3.5 h-3.5 mr-1" />
|
||||
)}
|
||||
安装
|
||||
{t('deploy.tool.install')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,7 +13,6 @@ import { toast } from 'sonner';
|
||||
import { useConfirm } from '@/components/ui/confirm-dialog';
|
||||
import { deployApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface Group {
|
||||
id: number; name: string; description: string;
|
||||
@@ -24,7 +23,6 @@ interface GroupMember {
|
||||
}
|
||||
|
||||
export default function UserGroupsManager({ embedded = false }: { embedded?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
const _confirm = useConfirm();
|
||||
const [groups, setGroups] = useState<Group[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -43,43 +41,43 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
|
||||
const loadGroups = async () => {
|
||||
setLoading(true);
|
||||
try { const res = await deployApi.getGroups(); setGroups((res as JsonData)?.list || []); }
|
||||
catch { toast.error(t('common.loadFailed')); }
|
||||
catch { toast.error('加载失败'); }
|
||||
finally { setLoading(false); }
|
||||
};
|
||||
|
||||
useEffect(() => { loadGroups(); }, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!formName.trim()) { toast.error(t('deploy.rbac.groupPlaceholder')); return; }
|
||||
if (!formName.trim()) { toast.error('请输入组名'); return; }
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editId) await deployApi.updateGroup(editId, { name: formName, description: formDesc });
|
||||
else await deployApi.createGroup({ name: formName, description: formDesc });
|
||||
toast.success(editId ? t('deploy.toast.updated') : t('deploy.toast.created'));
|
||||
toast.success(editId ? '已更新' : '已创建');
|
||||
setFormOpen(false); loadGroups();
|
||||
} catch { toast.error(t('common.operationFailed')); }
|
||||
} catch { toast.error('操作失败'); }
|
||||
finally { setSaving(false); }
|
||||
};
|
||||
|
||||
const handleDelete = async (g: Group) => {
|
||||
const ok = await _confirm({ title: t('deploy.rbac.deleteGroup'), description: t('deploy.usergroupsmanager.se76125', { g.name: g.name }), variant: 'destructive' });
|
||||
const ok = await _confirm({ title: '删除用户组', description: `确定删除「${g.name}」?所有成员和流水线授权将同时移除。`, variant: 'destructive' });
|
||||
if (!ok) return;
|
||||
try { await deployApi.deleteGroup(g.id); toast.success(t('alert.deleted')); loadGroups(); }
|
||||
catch { toast.error(t('common.deleteFailed')); }
|
||||
try { await deployApi.deleteGroup(g.id); toast.success('已删除'); loadGroups(); }
|
||||
catch { toast.error('删除失败'); }
|
||||
};
|
||||
|
||||
const handleAddMember = async (groupId: number) => {
|
||||
const username = (memberUsers[groupId] || '').trim();
|
||||
if (!username) { toast.error(t('pipeline.usernamePlaceholder')); return; }
|
||||
try { await deployApi.addGroupMember(groupId, { username }); toast.success(t('deploy.toast.added')); loadGroups(); setMemberUsers(prev => ({ ...prev, [groupId]: '' })); }
|
||||
catch { toast.error(t('deploy.toast.addFailed')); }
|
||||
if (!username) { toast.error('输入用户名'); return; }
|
||||
try { await deployApi.addGroupMember(groupId, { username }); toast.success('已添加'); loadGroups(); setMemberUsers(prev => ({ ...prev, [groupId]: '' })); }
|
||||
catch { toast.error('添加失败'); }
|
||||
};
|
||||
|
||||
const handleRemoveMember = async (groupId: number, memberId: number, username: string) => {
|
||||
const ok = await _confirm({ title: t('pipeline.removeMember'), description: t('deploy.usergroupsmanager.s9270e4', { username: username }) });
|
||||
const ok = await _confirm({ title: '移除成员', description: `确定从组中移除「${username}」?` });
|
||||
if (!ok) return;
|
||||
try { await deployApi.removeGroupMember(groupId, memberId); toast.success(t('deploy.rbac.removed')); loadGroups(); }
|
||||
catch { toast.error(t('deploy.rbac.removeFail')); }
|
||||
try { await deployApi.removeGroupMember(groupId, memberId); toast.success('已移除'); loadGroups(); }
|
||||
catch { toast.error('移除失败'); }
|
||||
};
|
||||
|
||||
const toggleExpand = (gid: number) => setExpanded(p => ({ ...p, [gid]: !p[gid] }));
|
||||
@@ -100,8 +98,8 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
|
||||
const listBody = !loading && groups.length === 0 ? (
|
||||
<PageEmpty
|
||||
icon={<Users className="h-10 w-10" />}
|
||||
title=t('deploy.usergroupsmanager.sd1edd8')
|
||||
description=t('deploy.usergroupsmanager.scf9865')
|
||||
title="暂无用户组"
|
||||
description="点击「新建用户组」创建第一个用户组"
|
||||
action={(
|
||||
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
|
||||
<Plus className="w-4 h-4 mr-1" />新建用户组
|
||||
@@ -117,7 +115,7 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
|
||||
<Shield className="w-5 h-5 dg-text-blue" />
|
||||
<div>
|
||||
<div className="font-medium text-[var(--dg-text-primary)]">{g.name}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || t('pipeline.desc2')} · {(g.members || []).length} 位成员</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || '暂无描述'} · {(g.members || []).length} 位成员</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
@@ -134,14 +132,14 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
|
||||
<div className="border-t border-[var(--dg-border)] px-4 py-3 space-y-3">
|
||||
{/* Add member */}
|
||||
<div className="flex items-end gap-2">
|
||||
<div className="flex-1 space-y-1"><Label className="text-xs">{t('system.addUser')}</Label>
|
||||
<Input value={memberUsers[g.id] || ''} onChange={e => setMemberUsers(prev => ({ ...prev, [g.id]: e.target.value }))} placeholder=t('pipeline.usernamePlaceholder') className="h-8 text-sm" onKeyDown={e => e.key === 'Enter' && handleAddMember(g.id)} />
|
||||
<div className="flex-1 space-y-1"><Label className="text-xs">添加用户</Label>
|
||||
<Input value={memberUsers[g.id] || ''} onChange={e => setMemberUsers(prev => ({ ...prev, [g.id]: e.target.value }))} placeholder="输入用户名" className="h-8 text-sm" onKeyDown={e => e.key === 'Enter' && handleAddMember(g.id)} />
|
||||
</div>
|
||||
<Button size="sm" className="h-8" onClick={() => handleAddMember(g.id)}><UserPlus className="w-3.5 h-3.5 mr-1" />{t('common.add')}</Button>
|
||||
<Button size="sm" className="h-8" onClick={() => handleAddMember(g.id)}><UserPlus className="w-3.5 h-3.5 mr-1" />添加</Button>
|
||||
</div>
|
||||
{/* Members list */}
|
||||
{(g.members || []).length === 0 ? (
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] py-2">{t('deploy.rbac.noMembers')}</div>
|
||||
<div className="text-xs text-[var(--dg-text-secondary)] py-2">暂无成员</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(g.members || []).map(m => (
|
||||
@@ -164,14 +162,14 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
|
||||
const formDialog = (
|
||||
<Dialog open={formOpen} onOpenChange={setFormOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader><DialogTitle>{editId ? t('deploy.rbac.groupEditTitle') : t('pipeline.newGroup')}</DialogTitle></DialogHeader>
|
||||
<DialogHeader><DialogTitle>{editId ? '编辑用户组' : '新建用户组'}</DialogTitle></DialogHeader>
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1"><Label>{t('monitor.groupName')}</Label><Input value={formName} onChange={e => setFormName(e.target.value)} placeholder=t('deploy.usergroupsmanager.s5a5632') /></div>
|
||||
<div className="space-y-1"><Label>{t('common.description')}</Label><Input value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder=t('common.optional') /></div>
|
||||
<div className="space-y-1"><Label>组名</Label><Input value={formName} onChange={e => setFormName(e.target.value)} placeholder="例如: 后端开发组" /></div>
|
||||
<div className="space-y-1"><Label>描述</Label><Input value={formDesc} onChange={e => setFormDesc(e.target.value)} placeholder="可选" /></div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-4 pt-4 border-t">
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)}>{t('common.cancel')}</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? '...' : t('common.save')}</Button>
|
||||
<Button variant="outline" onClick={() => setFormOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>{saving ? '...' : '保存'}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
@@ -190,15 +188,15 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
|
||||
return (
|
||||
<PageShell
|
||||
variant="list"
|
||||
title=t('menu.deploy')
|
||||
subtitle=t('deploy.usergroupsmanager.sf80ecf')
|
||||
title="应用发布"
|
||||
subtitle="用户组 — 批量授权流水线访问权限"
|
||||
icon={<Users className="h-5 w-5" />}
|
||||
loading={loading}
|
||||
metrics={(
|
||||
<StatStrip
|
||||
items={[
|
||||
{ label: t('pipeline.userGroup'), value: groups.length, color: 'blue' },
|
||||
{ label: t('deploy.usergroupsmanager.sdf2b42'), value: memberCount, color: 'purple' },
|
||||
{ label: '用户组', value: groups.length, color: 'blue' },
|
||||
{ label: '成员总数', value: memberCount, color: 'purple' },
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useRef, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -13,7 +14,6 @@ import { cn } from '@/lib/utils';
|
||||
import { useUserStore } from '@/store';
|
||||
import { pipelineApi } from '@/api';
|
||||
import type { JsonData } from '@/types/common';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface WorkspaceTermDialogProps {
|
||||
open: boolean;
|
||||
@@ -104,11 +104,11 @@ export function WorkspaceTermDialog({
|
||||
|
||||
ws.onclose = () => {
|
||||
setConnected(false);
|
||||
term.write(t('deploy.workspacetermdialog.safab20'));
|
||||
term.write(`\r\n\x1b[31m${t('deploy.runDetail.connectionClosed')}\x1b[0m\r\n`);
|
||||
};
|
||||
|
||||
ws.onerror = () => {
|
||||
term.write(t('deploy.workspacetermdialog.sc81077'));
|
||||
term.write(`\r\n\x1b[31m${t('deploy.runDetail.connectionError')}\x1b[0m\r\n`);
|
||||
};
|
||||
|
||||
return () => {
|
||||
@@ -160,14 +160,14 @@ export function WorkspaceTermDialog({
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">Workspace 终端 #{buildNumber}</DialogTitle>
|
||||
<DialogTitle className="sr-only">{t('deploy.pipeline.workspaceTerminal', { buildNumber })}</DialogTitle>
|
||||
<DialogDescription className="sr-only">{t('deploy.pipeline.buildDir')}</DialogDescription>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<div className={cn('w-2 h-2 rounded-full shrink-0', connected ? 'bg-emerald-400' : 'bg-red-400')} />
|
||||
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">
|
||||
#{buildNumber} 构建目录
|
||||
#{buildNumber} {t('deploy.runDetail.buildDir')}
|
||||
</span>
|
||||
{workspace && (
|
||||
<span className="text-[var(--dg-text-muted)] text-xs truncate hidden sm:inline">
|
||||
|
||||
@@ -11,7 +11,6 @@ import { toast } from 'sonner';
|
||||
import { useCredentialDetail } from '@/hooks/api';
|
||||
import type { SecretType } from './types';
|
||||
import { TYPE_CONFIG, TYPE_BADGE_CLASS } from './constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface CredDetailDialogProps {
|
||||
open: boolean;
|
||||
@@ -21,14 +20,13 @@ export interface CredDetailDialogProps {
|
||||
|
||||
// 凭据详情弹窗 // Logan.Li
|
||||
export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const [showMap, setShowMap] = useState<Record<string, boolean>>({});
|
||||
const { data: detail, isLoading: loading } = useCredentialDetail(credId, open);
|
||||
|
||||
if (!open || !credId) return null;
|
||||
|
||||
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
const copyValue = (val: string) => { navigator.clipboard.writeText(val); toast.success(t('common.copied')); };
|
||||
const copyValue = (val: string) => { navigator.clipboard.writeText(val); toast.success('已复制'); };
|
||||
|
||||
const st = detail?.secret_type as SecretType;
|
||||
const cfg = TYPE_CONFIG[st];
|
||||
@@ -79,39 +77,39 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
{loading ? (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">{t('common.loading')}</div>
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">加载中...</div>
|
||||
) : detail ? (
|
||||
<div className="space-y-4">
|
||||
{/* 基本信息 // Logan.Li */}
|
||||
<div className="grid grid-cols-2 gap-3 text-sm">
|
||||
<div><span className="text-[var(--dg-text-muted)]">{t('deploy.creddetaildialog.s5b4786')}</span><span className="text-[var(--dg-text-primary)] font-medium">{detailRecord?.name as string}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">{t('deploy.creddetaildialog.se91f5a')}</span>
|
||||
<div><span className="text-[var(--dg-text-muted)]">名称:</span><span className="text-[var(--dg-text-primary)] font-medium">{detailRecord?.name as string}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">类型:</span>
|
||||
<Badge variant="outline" className={`text-xs ml-1 ${TYPE_BADGE_CLASS[st] || ''}`}>{cfg?.label || st}</Badge>
|
||||
</div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">{t('approval.detail.statusLabel')}</span>
|
||||
<div><span className="text-[var(--dg-text-muted)]">状态:</span>
|
||||
{detailRecord?.is_active
|
||||
? <Badge className="bg-green-500/20 dg-text-green text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" />{t('common.enable')}</Badge>
|
||||
: <Badge className="bg-red-500/20 dg-text-red text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />{t('common.disable')}</Badge>}
|
||||
? <Badge className="bg-green-500/20 dg-text-green text-xs ml-1"><CheckCircle2 className="h-3 w-3 mr-0.5" />启用</Badge>
|
||||
: <Badge className="bg-red-500/20 dg-text-red text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />禁用</Badge>}
|
||||
{detailRecord?.expires_at && new Date(detailRecord.expires_at as string) < new Date() && (
|
||||
<Badge className="bg-yellow-500/20 dg-text-yellow text-xs ml-1"><AlertTriangle className="h-3 w-3 mr-0.5" />{t('monitor.sslExpired')}</Badge>
|
||||
<Badge className="bg-yellow-500/20 dg-text-yellow text-xs ml-1"><AlertTriangle className="h-3 w-3 mr-0.5" />已过期</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-span-2 cursor-pointer" onClick={() => copyValue((detailRecord?.env_prefix as string) || '')}>
|
||||
<span className="text-[var(--dg-text-muted)]">{t('deploy.cred.refIdLabel')}</span>
|
||||
<code className="text-[var(--dg-blue)] font-mono text-xs">{detailRecord?.env_prefix as string || t('common.none')}</code>
|
||||
<span className="text-[var(--dg-text-muted)]">引用ID:</span>
|
||||
<code className="text-[var(--dg-blue)] font-mono text-xs">{detailRecord?.env_prefix as string || '无'}</code>
|
||||
<Copy className="h-3 w-3 inline ml-1 text-[var(--dg-text-muted)]" />
|
||||
</div>
|
||||
{(detailRecord?.tags as string[])?.length > 0 && (
|
||||
<div className="col-span-2">
|
||||
<span className="text-[var(--dg-text-muted)]">{t('deploy.creddetaildialog.s1655dd')}</span>
|
||||
<span className="text-[var(--dg-text-muted)]">标签:</span>
|
||||
{(detailRecord?.tags as string[]).map((t: string) => (
|
||||
<Badge key={t} variant="outline" className="text-xs ml-1 border-[var(--dg-border)]">{t}</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="col-span-2"><span className="text-[var(--dg-text-muted)]">{t('deploy.creddetaildialog.s4a4e3b')}</span><span className="text-[var(--dg-text-secondary)]">{(detailRecord?.description as string) || t('common.none')}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">{t('deploy.creddetaildialog.s76d768')}</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.created_at as string).toLocaleString()}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">{t('deploy.creddetaildialog.s3820b7')}</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.updated_at as string).toLocaleString()}</span></div>
|
||||
<div className="col-span-2"><span className="text-[var(--dg-text-muted)]">描述:</span><span className="text-[var(--dg-text-secondary)]">{(detailRecord?.description as string) || '无'}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">创建:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.created_at as string).toLocaleString()}</span></div>
|
||||
<div><span className="text-[var(--dg-text-muted)]">更新:</span><span className="text-[var(--dg-text-secondary)] text-xs">{new Date(detailRecord?.updated_at as string).toLocaleString()}</span></div>
|
||||
</div>
|
||||
|
||||
{/* 密钥数据 // Logan.Li */}
|
||||
@@ -120,41 +118,41 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
|
||||
<Key className="h-4 w-4 text-yellow-500" />密钥数据
|
||||
</div>
|
||||
<div className="p-3 rounded-md bg-yellow-500/5 border border-yellow-500/20 mb-3">
|
||||
<p className="text-xs dg-text-yellow/80">{t('deploy.creddetaildialog.s03b218')}</p>
|
||||
<p className="text-xs dg-text-yellow/80">以下内容包含敏感信息,请妥善保管,不要泄露给他人。</p>
|
||||
</div>
|
||||
|
||||
{st === 'ssh' && <>
|
||||
<Field label=t('deploy.creddetaildialog.s2272f5') value={content.ssh_user} />
|
||||
<Field label=t('deploy.creddetaildialog.s3f284a') value={content.ssh_port} />
|
||||
<Field label=t('system.sshPassword') value={content.password} sensitive fieldKey="ssh_password" />
|
||||
<Field label=t('system.sshPrivateKey') value={content.private_key} sensitive fieldKey="ssh_private_key" />
|
||||
<Field label=t('system.sshPublicKey') value={content.public_key} fieldKey="ssh_public_key" />
|
||||
<Field label="SSH 用户" value={content.ssh_user} />
|
||||
<Field label="SSH 端口" value={content.ssh_port} />
|
||||
<Field label="SSH 密码" value={content.password} sensitive fieldKey="ssh_password" />
|
||||
<Field label="私钥" value={content.private_key} sensitive fieldKey="ssh_private_key" />
|
||||
<Field label="公钥" value={content.public_key} fieldKey="ssh_public_key" />
|
||||
</>}
|
||||
{st === 'docker' && <>
|
||||
<Field label=t('deploy.form.registryAddr') value={content.server} />
|
||||
<Field label=t('login.username') value={content.username} />
|
||||
<Field label=t('login.password') value={content.password} sensitive fieldKey="docker_password" />
|
||||
<Field label="Registry 地址" value={content.server} />
|
||||
<Field label="用户名" value={content.username} />
|
||||
<Field label="密码" value={content.password} sensitive fieldKey="docker_password" />
|
||||
</>}
|
||||
{st === 'tls' && <>
|
||||
<Field label=t('deploy.creddetaildialog.sd40b51') value={content.cert} sensitive fieldKey="tls_cert" />
|
||||
<Field label=t('system.sshPrivateKey') value={content.key} sensitive fieldKey="tls_key" />
|
||||
<Field label=t('deploy.creddetaildialog.sa24356') value={content.ca} sensitive fieldKey="tls_ca" />
|
||||
<Field label="证书" value={content.cert} sensitive fieldKey="tls_cert" />
|
||||
<Field label="私钥" value={content.key} sensitive fieldKey="tls_key" />
|
||||
<Field label="CA 证书" value={content.ca} sensitive fieldKey="tls_ca" />
|
||||
</>}
|
||||
{st === 'aksk' && <>
|
||||
<Field label="Access Key ID" value={content.access_key} />
|
||||
<Field label="Secret Access Key" value={content.secret_key} sensitive fieldKey="aksk_secret_key" />
|
||||
</>}
|
||||
{st === 'database' && <>
|
||||
<Field label=t('deploy.creddetaildialog.s84b916') value={String(content.db_type || '').toUpperCase()} />
|
||||
<Field label=t('agent.agentHost') value={content.host} />
|
||||
<Field label=t('server.csv.port') value={content.port} />
|
||||
<Field label=t('deploy.creddetaildialog.s5ccbbd') value={content.db_name} />
|
||||
<Field label=t('login.username') value={content.username} />
|
||||
<Field label=t('login.password') value={content.password} sensitive fieldKey="db_password" />
|
||||
<Field label="数据库类型" value={String(content.db_type || '').toUpperCase()} />
|
||||
<Field label="主机" value={content.host} />
|
||||
<Field label="端口" value={content.port} />
|
||||
<Field label="数据库名" value={content.db_name} />
|
||||
<Field label="用户名" value={content.username} />
|
||||
<Field label="密码" value={content.password} sensitive fieldKey="db_password" />
|
||||
</>}
|
||||
{st === 'api' && <>
|
||||
<Field label="API Key" value={content.api_key} sensitive fieldKey="api_key" />
|
||||
<Field label=t('deploy.creddetaildialog.s48ec34') value={content.endpoint} />
|
||||
<Field label="API 端点" value={content.endpoint} />
|
||||
</>}
|
||||
{st === 'generic' && <>
|
||||
<Field label="Key" value={content.generic_key} />
|
||||
@@ -164,17 +162,17 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
|
||||
|
||||
{/* 引用提示 // Logan.Li */}
|
||||
<div className="border-t border-[var(--dg-border)] pt-3">
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">{t('deploy.cred.refHint')}</div>
|
||||
<div className="text-xs text-[var(--dg-text-muted)] mb-2">在流水线/脚本中引用此凭据:</div>
|
||||
<code className="text-xs bg-[var(--dg-bg-tertiary)] px-3 py-1.5 rounded block text-[var(--dg-blue)]">
|
||||
{'${'}{String(detailRecord?.variable_name || String(detailRecord?.name || '').toUpperCase().replace(/[^A-Z0-9]/g, '_'))}{'}'}
|
||||
</code>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">{t('deploy.cred.noCredData')}</div>
|
||||
<div className="text-center py-8 text-[var(--dg-text-muted)]">未找到凭据数据</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.close')}</Button>
|
||||
<Button variant="outline" onClick={onClose}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { toast } from 'sonner';
|
||||
import { credentialApi } from '@/api';
|
||||
import type { Credential, SecretType } from './types';
|
||||
import { TYPE_CONFIG } from './constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface CredFormDialogProps {
|
||||
open: boolean;
|
||||
@@ -20,7 +19,6 @@ export interface CredFormDialogProps {
|
||||
|
||||
// 凭据新增/编辑弹窗 // Logan.Li
|
||||
export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const isEdit = !!editData;
|
||||
const [name, setName] = useState('');
|
||||
const [varName, setVarName] = useState('');
|
||||
@@ -97,7 +95,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
const toggleShow = (key: string) => setShowMap(prev => ({ ...prev, [key]: !prev[key] }));
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!name.trim()) { toast.error(t('deploy.cred.nameRequired')); return; }
|
||||
if (!name.trim()) { toast.error('请填写凭据名称'); return; }
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const payload: Record<string, unknown> = {
|
||||
@@ -143,16 +141,16 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
|
||||
if (isEdit) {
|
||||
await credentialApi.update(editData!.id, payload);
|
||||
toast.success(t('deploy.toast.updated'));
|
||||
toast.success('已更新');
|
||||
} else {
|
||||
await credentialApi.create(payload);
|
||||
toast.success(t('deploy.toast.created'));
|
||||
toast.success('已创建');
|
||||
}
|
||||
onSaved();
|
||||
onClose();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { response?: { data?: { message?: string } } };
|
||||
toast.error(err?.response?.data?.message || t('common.operationFailed'));
|
||||
toast.error(err?.response?.data?.message || '操作失败');
|
||||
} finally { setSubmitting(false); }
|
||||
};
|
||||
|
||||
@@ -192,25 +190,25 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-[var(--dg-text-primary)] flex items-center gap-2">
|
||||
<TIicon className={`h-5 w-5 ${TI?.color || ''}`} />
|
||||
{isEdit ? t('pipeline.editCred') : t('pipeline.addCred')}
|
||||
{isEdit ? '编辑凭据' : '新增凭据'}
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-[var(--dg-text-muted)] text-xs">
|
||||
{TI?.desc || t('deploy.cred.descDefault')}
|
||||
{TI?.desc || '管理敏感凭据信息'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-2">
|
||||
{/* 基本信息 // Logan.Li */}
|
||||
<div className="border-b border-[var(--dg-border)] pb-3">
|
||||
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">{t('deploy.label.basicInfo')}</div>
|
||||
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">基本信息</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('pipeline.credName')} <span className="dg-text-red">*</span></Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder=t('deploy.credformdialog.s97afec')
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">凭据名称 <span className="dg-text-red">*</span></Label>
|
||||
<Input value={name} onChange={e => setName(e.target.value)} placeholder="如:生产环境 SSH"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('pipeline.varAlias')}</Label>
|
||||
<Input value={varName} onChange={e => setVarName(e.target.value)} placeholder=t('deploy.credformdialog.s92c3a5')
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">变量别名</Label>
|
||||
<Input value={varName} onChange={e => setVarName(e.target.value)} placeholder="如:MYSQL_PROD"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono text-sm" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
@@ -229,14 +227,14 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('common.tag')}</Label>
|
||||
<Input value={tags} onChange={e => setTags(e.target.value)} placeholder=t('deploy.credformdialog.s29e67e')
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">标签</Label>
|
||||
<Input value={tags} onChange={e => setTags(e.target.value)} placeholder="生产环境, 阿里云"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('common.description')}</Label>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder=t('deploy.credformdialog.s9216f1')
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">描述</Label>
|
||||
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选备注信息"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -245,32 +243,32 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
<div>
|
||||
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2 flex items-center gap-2">
|
||||
<TIicon className="h-3.5 w-3.5" />{TI?.label || secretType} 配置
|
||||
{isEdit && <span className="text-[var(--dg-text-muted)]">{t('deploy.credformdialog.s088965')}</span>}
|
||||
{isEdit && <span className="text-[var(--dg-text-muted)]">(仅填写需要修改的字段)</span>}
|
||||
</div>
|
||||
|
||||
{secretType === 'ssh' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s2272f5')}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 用户</Label>
|
||||
<Input value={sshUser} onChange={e => setSshUser(e.target.value)} placeholder="root"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s3f284a')}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">SSH 端口</Label>
|
||||
<Input value={sshPort} onChange={e => setSshPort(e.target.value)} placeholder="22" type="number"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FieldWithToggle label=t('system.sshPassword') value={sshPassword} onChange={setSshPassword}
|
||||
placeholder=t('deploy.credformdialog.se98d4e') fieldKey="ssh_pwd" />
|
||||
<FieldWithToggle label="SSH 密码" value={sshPassword} onChange={setSshPassword}
|
||||
placeholder="密码(或留空用密钥)" fieldKey="ssh_pwd" />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FieldWithToggle label=t('deploy.credformdialog.sfcef97') value={sshPrivateKey} onChange={setSshPrivateKey}
|
||||
placeholder=t('deploy.credformdialog.s824277') fieldKey="ssh_pk" isLong />
|
||||
<FieldWithToggle label="私钥内容" value={sshPrivateKey} onChange={setSshPrivateKey}
|
||||
placeholder="粘贴 SSH 私钥..." fieldKey="ssh_pk" isLong />
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FieldWithToggle label=t('deploy.credformdialog.s6fc01d') value={sshPublicKey} onChange={setSshPublicKey}
|
||||
placeholder=t('deploy.credformdialog.s5eccd4') fieldKey="ssh_pub" isLong />
|
||||
<FieldWithToggle label="公钥内容" value={sshPublicKey} onChange={setSshPublicKey}
|
||||
placeholder="粘贴 SSH 公钥..." fieldKey="ssh_pub" isLong />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -278,28 +276,28 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
{secretType === 'docker' && (
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="col-span-2 space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.form.registryAddr')} <span className="dg-text-red">*</span></Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">Registry 地址 <span className="dg-text-red">*</span></Label>
|
||||
<Input value={dockerServer} onChange={e => setDockerServer(e.target.value)} placeholder="registry.example.com"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('login.username')}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
|
||||
<Input value={dockerUsername} onChange={e => setDockerUsername(e.target.value)} placeholder="admin"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div><FieldWithToggle label=t('login.password') value={dockerPassword} onChange={setDockerPassword}
|
||||
placeholder=t('deploy.credformdialog.s929297') fieldKey="docker_pwd" required /></div>
|
||||
<div><FieldWithToggle label="密码" value={dockerPassword} onChange={setDockerPassword}
|
||||
placeholder="Registry 密码" fieldKey="docker_pwd" required /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secretType === 'tls' && (
|
||||
<div className="space-y-3">
|
||||
<FieldWithToggle label=t('deploy.credformdialog.s3391e4') value={tlsCert} onChange={setTlsCert}
|
||||
<FieldWithToggle label="证书 (PEM)" value={tlsCert} onChange={setTlsCert}
|
||||
placeholder="-----BEGIN CERTIFICATE-----" fieldKey="tls_cert" isLong required />
|
||||
<FieldWithToggle label=t('deploy.credformdialog.s517151') value={tlsKey} onChange={setTlsKey}
|
||||
<FieldWithToggle label="私钥 (PEM)" value={tlsKey} onChange={setTlsKey}
|
||||
placeholder="-----BEGIN PRIVATE KEY-----" fieldKey="tls_key" isLong required />
|
||||
<FieldWithToggle label=t('deploy.credformdialog.sa24356') value={tlsCa} onChange={setTlsCa}
|
||||
placeholder=t('deploy.credformdialog.s7db02d') fieldKey="tls_ca" isLong />
|
||||
<FieldWithToggle label="CA 证书" value={tlsCa} onChange={setTlsCa}
|
||||
placeholder="CA 证书内容..." fieldKey="tls_ca" isLong />
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -312,7 +310,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
</div>
|
||||
<div className="col-span-2">
|
||||
<FieldWithToggle label="Secret Access Key" value={akskSecretKey} onChange={setAkskSecretKey}
|
||||
placeholder=t('deploy.credformdialog.sdef8f6') fieldKey="aksk_sk" required />
|
||||
placeholder="输入 Secret Key" fieldKey="aksk_sk" required />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@@ -320,7 +318,7 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
{secretType === 'database' && (
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s84b916')}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库类型</Label>
|
||||
<Select value={dbType} onValueChange={setDbType}>
|
||||
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
|
||||
<SelectValue />
|
||||
@@ -336,34 +334,34 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('agent.agentHost')} <span className="dg-text-red">*</span></Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">主机 <span className="dg-text-red">*</span></Label>
|
||||
<Input value={dbHost} onChange={e => setDbHost(e.target.value)} placeholder="10.0.0.1"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('server.csv.port')}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">端口</Label>
|
||||
<Input value={dbPort} onChange={e => setDbPort(e.target.value)} placeholder="3306" type="number"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s5ccbbd')}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">数据库名</Label>
|
||||
<Input value={dbName} onChange={e => setDbName(e.target.value)} placeholder="aiops"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('login.username')}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">用户名</Label>
|
||||
<Input value={dbUsername} onChange={e => setDbUsername(e.target.value)} placeholder="root"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
|
||||
</div>
|
||||
<div><FieldWithToggle label=t('login.password') value={dbPassword} onChange={setDbPassword}
|
||||
placeholder=t('deploy.credformdialog.s58b49c') fieldKey="db_pwd" /></div>
|
||||
<div><FieldWithToggle label="密码" value={dbPassword} onChange={setDbPassword}
|
||||
placeholder="数据库密码" fieldKey="db_pwd" /></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{secretType === 'api' && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s48ec34')}</Label>
|
||||
<Label className="text-[var(--dg-text-secondary)] text-xs">API 端点</Label>
|
||||
<Input value={apiEndpoint} onChange={e => setApiEndpoint(e.target.value)} placeholder="https://api.example.com"
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
|
||||
</div>
|
||||
@@ -380,15 +378,15 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
|
||||
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] font-mono" />
|
||||
</div>
|
||||
<FieldWithToggle label="Value" value={genericValue} onChange={setGenericValue}
|
||||
placeholder=t('deploy.credformdialog.s63ec0a') fieldKey="generic_val" required />
|
||||
placeholder="密钥值" fieldKey="generic_val" required />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/80">
|
||||
{submitting ? i18n.t('k8s.topology.saving') : (isEdit ? i18n.t('common.update') : i18n.t('common.create'))}
|
||||
{submitting ? '保存中...' : (isEdit ? '更新' : '创建')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -4,16 +4,15 @@ import {
|
||||
Key, Server, Container, Shield, Cloud, Database, FileText, Globe,
|
||||
} from 'lucide-react';
|
||||
import type { SecretType } from './types';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
export const TYPE_CONFIG: Record<SecretType, { label: string; icon: typeof Key; color: string; desc: string }> = {
|
||||
ssh: { label: i18n.t('system.sshKey'), icon: Server, color: 'dg-text-yellow', desc: i18n.t('deploy.constants.sedc7f9') },
|
||||
docker: { label: i18n.t('pipeline.dockerCred'), icon: Container, color: 'dg-text-cyan', desc: i18n.t('deploy.constants.sbd6cf1') },
|
||||
tls: { label: i18n.t('deploy.constants.sfa5a64'), icon: Shield, color: 'dg-text-green', desc: i18n.t('deploy.constants.s43f00c') },
|
||||
aksk: { label: i18n.t('deploy.constants.sb82698'), icon: Cloud, color: 'dg-text-purple', desc: i18n.t('deploy.constants.s564e98') },
|
||||
database: { label: i18n.t('system.dbCredential'), icon: Database, color: 'dg-text-blue', desc: i18n.t('deploy.constants.se47498') },
|
||||
api: { label: i18n.t('system.apiKeys'), icon: Globe, color: 'dg-text-orange', desc: i18n.t('deploy.constants.sb3edf0') },
|
||||
generic: { label: i18n.t('deploy.constants.s1d319b'), icon: FileText, color: 'dg-text-secondary', desc: i18n.t('deploy.constants.s51b5b2') },
|
||||
ssh: { label: 'SSH 密钥', icon: Server, color: 'dg-text-yellow', desc: 'SSH 私钥/公钥/密码' },
|
||||
docker: { label: 'Docker 凭证', icon: Container, color: 'dg-text-cyan', desc: 'Docker Registry 登录凭据' },
|
||||
tls: { label: 'TLS 证书', icon: Shield, color: 'dg-text-green', desc: 'TLS/SSL 证书与私钥' },
|
||||
aksk: { label: 'AKSK 密钥', icon: Cloud, color: 'dg-text-purple', desc: '云服务 AccessKey/SecretKey' },
|
||||
database: { label: '数据库凭证', icon: Database, color: 'dg-text-blue', desc: '数据库连接信息' },
|
||||
api: { label: 'API 密钥', icon: Globe, color: 'dg-text-orange', desc: 'API Token/Key + 端点' },
|
||||
generic: { label: '通用凭证', icon: FileText, color: 'dg-text-secondary', desc: '自定义 Key/Value 键值对' },
|
||||
};
|
||||
|
||||
export const TYPE_BADGE_CLASS: Record<SecretType, string> = {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { TFunction } from 'i18next';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
|
||||
import i18n from '@/i18n';
|
||||
|
||||
// 流水线阶段定义 // Logan.Li
|
||||
export interface PipelineStage {
|
||||
@@ -12,12 +12,58 @@ export interface PipelineStage {
|
||||
}
|
||||
|
||||
// 流水线模板(新建时快捷选择) // Logan.Li
|
||||
export const pipelineTemplates = [
|
||||
{ id: 'template-1', name: i18n.t('deploy.template.nodejs'), description: i18n.t('deploy.template.nodejsDesc'), stages: [i18n.t('pipeline.checkout'), i18n.t('pipeline.depInstall'), i18n.t('pipeline.testDesc'), i18n.t('pipeline.buildDesc'), i18n.t('pipeline.deploy')] },
|
||||
{ id: 'template-2', name: i18n.t('deploy.template.go'), description: i18n.t('deploy.template.goDesc'), stages: [i18n.t('pipeline.checkout'), i18n.t('pipeline.unitTest'), i18n.t('pipeline.build'), i18n.t('deploy.releaseHelpers.pipelineStep.buildImage'), i18n.t('pipeline.deploy')] },
|
||||
{ id: 'template-3', name: i18n.t('deploy.template.java'), description: i18n.t('deploy.template.javaDesc'), stages: [i18n.t('pipeline.checkout'), i18n.t('deploy.template.mavenBuild'), i18n.t('pipeline.unitTest'), i18n.t('pipeline.package'), i18n.t('pipeline.deploy')] },
|
||||
{ id: 'template-4', name: i18n.t('deploy.template.python'), description: i18n.t('deploy.template.pythonDesc'), stages: [i18n.t('pipeline.checkout'), i18n.t('pipeline.depInstall'), i18n.t('pipeline.testDesc'), i18n.t('common.test'), i18n.t('pipeline.deploy')] },
|
||||
];
|
||||
export function getPipelineTemplates(t: TFunction) {
|
||||
return [
|
||||
{
|
||||
id: 'template-1',
|
||||
name: t('deploy.template.nodejs'),
|
||||
description: t('deploy.template.nodejsDesc'),
|
||||
stages: [
|
||||
t('deploy.stage.checkout'),
|
||||
t('deploy.template.dependencyInstall'),
|
||||
t('deploy.releaseHelpers.pipelineStep.codeCheck'),
|
||||
t('deploy.stage.buildDesc'),
|
||||
t('deploy.stage.deploy'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'template-2',
|
||||
name: t('deploy.template.go'),
|
||||
description: t('deploy.template.goDesc'),
|
||||
stages: [
|
||||
t('deploy.stage.checkout'),
|
||||
t('deploy.template.unitTest'),
|
||||
t('deploy.stage.build'),
|
||||
t('deploy.releaseHelpers.pipelineStep.buildImage'),
|
||||
t('deploy.stage.deploy'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'template-3',
|
||||
name: t('deploy.template.java'),
|
||||
description: t('deploy.template.javaDesc'),
|
||||
stages: [
|
||||
t('deploy.stage.checkout'),
|
||||
t('deploy.template.mavenBuild'),
|
||||
t('deploy.template.unitTest'),
|
||||
t('deploy.template.package'),
|
||||
t('deploy.stage.deploy'),
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'template-4',
|
||||
name: t('deploy.template.python'),
|
||||
description: t('deploy.template.pythonDesc'),
|
||||
stages: [
|
||||
t('deploy.stage.checkout'),
|
||||
t('deploy.template.dependencyInstall'),
|
||||
t('deploy.releaseHelpers.pipelineStep.codeCheck'),
|
||||
t('deploy.stage.test'),
|
||||
t('deploy.stage.deploy'),
|
||||
],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
// 解析 stages 字段(兼容 JSON 数组 / 逗号分隔字符串) // Logan.Li
|
||||
export function parseStages( stages: string): PipelineStage[] {
|
||||
@@ -43,40 +89,40 @@ export function nameToCode(name: string): string {
|
||||
}
|
||||
|
||||
// 状态徽章(使用后端 last_status 字段) // Logan.Li
|
||||
export function getStatusBadge( status: string) {
|
||||
export function getStatusBadge(status: string, t: TFunction) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return (
|
||||
<Badge variant="outline" className="border-emerald-500/30 dg-text-green bg-emerald-500/10">
|
||||
<CheckCircle className="h-3 w-3 mr-1" />
|
||||
成功
|
||||
{t('deploy.status.success')}
|
||||
</Badge>
|
||||
);
|
||||
case 'failed':
|
||||
return (
|
||||
<Badge variant="outline" className="border-red-500/30 dg-text-red bg-red-500/10">
|
||||
<XCircle className="h-3 w-3 mr-1" />
|
||||
失败
|
||||
{t('deploy.status.failed')}
|
||||
</Badge>
|
||||
);
|
||||
case 'running':
|
||||
return (
|
||||
<Badge variant="outline" className="border-blue-500/30 dg-text-blue bg-blue-500/10 animate-pulse">
|
||||
<Loader2 className="h-3 w-3 mr-1 animate-spin" />
|
||||
运行中
|
||||
{t('deploy.status.running')}
|
||||
</Badge>
|
||||
);
|
||||
default:
|
||||
return (
|
||||
<Badge variant="outline" className="border-[var(--dg-border)] text-[var(--dg-text-secondary)]">
|
||||
从未运行
|
||||
{t('deploy.pipeline.neverRun')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 阶段状态图标 // Logan.Li
|
||||
export function getStageStatusIcon(status: PipelineStage['status']) {
|
||||
export function getStageStatusIcon(status: PipelineStage['status'], t: TFunction) {
|
||||
switch (status) {
|
||||
case 'success':
|
||||
return <CheckCircle className="h-4 w-4 dg-text-green" />;
|
||||
@@ -85,8 +131,8 @@ export function getStageStatusIcon(status: PipelineStage['status']) {
|
||||
case 'running':
|
||||
return <Loader2 className="h-4 w-4 dg-text-blue animate-spin" />;
|
||||
case 'skipped':
|
||||
return <span className="text-[var(--dg-text-secondary)] text-xs">{i18n.t('deploy.pipelineHelpers.s92636e')}</span>;
|
||||
return <span className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.stageStatus.skipped')}</span>;
|
||||
default:
|
||||
return <span className="text-[var(--dg-text-secondary)] opacity-60 text-xs">{i18n.t('deploy.status.pending')}</span>;
|
||||
return <span className="text-[var(--dg-text-secondary)] opacity-60 text-xs">{t('deploy.status.pending')}</span>;
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,6 @@ import { Search, Server, Network, Loader2, Check } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { serverApi, kubernetesApi } from '@/api';
|
||||
import { getPublicApiBase } from '@/utils/request';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export interface DeployTargetValue {
|
||||
deployTarget: 'ssh' | 'k8s';
|
||||
@@ -50,7 +49,6 @@ interface DeploymentItem {
|
||||
|
||||
// 发布目标选择:SSH 关联主机 / K8s 关联 Deployment // Logan.Li
|
||||
export function DeployTargetFields({ value, onChange, requireTarget = true }: DeployTargetFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const [servers, setServers] = useState<ServerItem[]>([]);
|
||||
const [clusters, setClusters] = useState<ClusterItem[]>([]);
|
||||
const [deployments, setDeployments] = useState<DeploymentItem[]>([]);
|
||||
@@ -150,8 +148,8 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1 p-1 rounded-lg bg-[var(--dg-bg-secondary)] border border-[var(--dg-border)]">
|
||||
{([
|
||||
{ key: 'ssh' as const, label: t('deploy.deploytargetfields.secf819'), icon: Server },
|
||||
{ key: 'k8s' as const, label: t('deploy.deploytargetfields.s7ff062'), icon: Network },
|
||||
{ key: 'ssh' as const, label: '主机发布', icon: Server },
|
||||
{ key: 'k8s' as const, label: 'Kubernetes 发布', icon: Network },
|
||||
]).map((item) => {
|
||||
const active = value.deployTarget === item.key;
|
||||
const Icon = item.icon;
|
||||
@@ -201,7 +199,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
<Input
|
||||
value={serverSearch}
|
||||
onChange={(e) => setServerSearch(e.target.value)}
|
||||
placeholder=t('deploy.deploytargetfields.sc76249')
|
||||
placeholder="搜索主机名或 IP"
|
||||
className="pl-8 h-8 text-xs dg-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -211,7 +209,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />加载中
|
||||
</div>
|
||||
) : filteredServers.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8">{t('deploy.deploytargetfields.sb42401')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8">暂无可用主机</p>
|
||||
) : (
|
||||
<div className="p-1.5 space-y-1">
|
||||
{filteredServers.map((srv) => {
|
||||
@@ -243,10 +241,10 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">{t('deploy.deploytargetfields.sb2b6db')}</p>
|
||||
<p className="text-[10px] text-[var(--dg-text-muted)]">构建完成后 SCP 上传 jar/tar.gz 到远程目录并执行部署命令</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('deploy.deploytargetfields.s5ca05a')}</Label>
|
||||
<Label className="text-xs">远程目录</Label>
|
||||
<Input
|
||||
value={value.deployRemotePath}
|
||||
onChange={(e) => onChange({ deployRemotePath: e.target.value })}
|
||||
@@ -255,7 +253,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('deploy.deploytargetfields.s65b20b')}</Label>
|
||||
<Label className="text-xs">部署命令</Label>
|
||||
<Input
|
||||
value={value.deployScript}
|
||||
onChange={(e) => onChange({ deployScript: e.target.value })}
|
||||
@@ -282,10 +280,10 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
})}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-xs dg-input">
|
||||
<SelectValue placeholder=t('k8s.topology.selectCluster') />
|
||||
<SelectValue placeholder="选择集群" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">{t('common.selectPlaceholder')}</SelectItem>
|
||||
<SelectItem value="__none">请选择</SelectItem>
|
||||
{clusters.map((c) => (
|
||||
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
|
||||
))}
|
||||
@@ -293,11 +291,11 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('deploy.deploytargetfields.s806e4e')}</Label>
|
||||
<Label className="text-xs">已选 Deployment</Label>
|
||||
<Input
|
||||
readOnly
|
||||
value={value.k8sDeployment ? `${value.k8sNamespace}/${value.k8sDeployment}` : ''}
|
||||
placeholder=t('deploy.deploytargetfields.sbb1671')
|
||||
placeholder="从下方列表选择"
|
||||
className="h-8 text-xs dg-input font-mono"
|
||||
/>
|
||||
</div>
|
||||
@@ -309,7 +307,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
<Input
|
||||
value={depSearch}
|
||||
onChange={(e) => setDepSearch(e.target.value)}
|
||||
placeholder=t('deploy.deploytargetfields.saa98b2')
|
||||
placeholder="搜索 Deployment 或命名空间"
|
||||
className="pl-8 h-8 text-xs dg-input"
|
||||
/>
|
||||
</div>
|
||||
@@ -319,7 +317,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-1.5" />加载 Deployment
|
||||
</div>
|
||||
) : filteredDeployments.length === 0 ? (
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8">{t('deploy.deploytargetfields.s5689d3')}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8">暂无 Deployment</p>
|
||||
) : (
|
||||
<div className="p-1.5 space-y-1">
|
||||
{filteredDeployments.map((dep) => {
|
||||
@@ -350,11 +348,11 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
|
||||
)}
|
||||
</ScrollArea>
|
||||
<div className="space-y-1">
|
||||
<Label className="text-xs">{t('kubernetes.pod.containerName')} <span className="text-[var(--dg-text-muted)]">{t('deploy.deploytargetfields.s7f5a1e')}</span></Label>
|
||||
<Label className="text-xs">容器名 <span className="text-[var(--dg-text-muted)]">(多容器时填写)</span></Label>
|
||||
<Input
|
||||
value={value.k8sContainerName}
|
||||
onChange={(e) => onChange({ k8sContainerName: e.target.value })}
|
||||
placeholder=t('deploy.deploytargetfields.s389076')
|
||||
placeholder="默认首个容器"
|
||||
className="h-8 text-xs font-mono dg-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function PipelineCard({
|
||||
#{String(pipeline.run_count)}
|
||||
</Badge>
|
||||
)}
|
||||
{getStatusBadge(String(pipeline.last_status))}
|
||||
{getStatusBadge(String(pipeline.last_status), t)}
|
||||
{environment && (
|
||||
<Badge
|
||||
style={{ backgroundColor: `${environment.color}20`, color: environment.color as string }}
|
||||
|
||||
@@ -13,7 +13,6 @@ import {
|
||||
import { pipelineApi } from '@/api';
|
||||
import { toast } from 'sonner';
|
||||
import { fmtDuration, type LogEntry } from './constants';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface PipelineRunPanelProps {
|
||||
pipeline: Record<string, unknown>;
|
||||
@@ -24,7 +23,6 @@ interface PipelineRunPanelProps {
|
||||
|
||||
// 流水线队列面板(Jenkins 风格) // Logan.Li
|
||||
function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> }) {
|
||||
const { t } = useTranslation();
|
||||
const [queue, setQueue] = useState<Record<string, unknown> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const code = (pipeline.code || pipeline.id) as string;
|
||||
@@ -51,10 +49,10 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
|
||||
const handleCancel = async (runId: number) => {
|
||||
try {
|
||||
await pipelineApi.removeFromQueue(code, runId);
|
||||
toast.success(t('deploy.toast.cancelled'));
|
||||
toast.success('已取消');
|
||||
loadQueue();
|
||||
} catch {
|
||||
toast.error(t('deploy.toast.cancelFailed'));
|
||||
toast.error('取消失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -70,7 +68,7 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
|
||||
return (
|
||||
<div className="px-4 py-2 space-y-2">
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-[var(--dg-text-secondary)]">{t('deploy.run.buildStatus')}</span>
|
||||
<span className="text-[var(--dg-text-secondary)]">构建执行状态</span>
|
||||
<span className="font-bold text-[var(--dg-text-primary)]">{running}/{max}</span>
|
||||
{queued > 0 && (
|
||||
<span className="text-[var(--dg-text-secondary)] ml-2">
|
||||
@@ -118,7 +116,6 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
|
||||
|
||||
// 展开后的运行列表面板 // Logan.Li
|
||||
export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStopRun }: PipelineRunPanelProps) {
|
||||
const { t } = useTranslation();
|
||||
const [logOpen, setLogOpen] = useState(false);
|
||||
const [logRun, setLogRun] = useState<Record<string, unknown> | null>(null);
|
||||
const [logs, setLogs] = useState<LogEntry[]>([]);
|
||||
@@ -144,17 +141,17 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
|
||||
<Loader2 className="h-5 w-5 animate-spin text-[var(--dg-text-secondary)]" />
|
||||
</div>
|
||||
) : pipelineRuns.length === 0 ? (
|
||||
<div className="py-6 text-center text-[var(--dg-text-secondary)] text-sm">{t('pipeline.noRecords')}</div>
|
||||
<div className="py-6 text-center text-[var(--dg-text-secondary)] text-sm">暂无执行记录</div>
|
||||
) : (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="border-b border-[var(--dg-border)]">
|
||||
<tr className="bg-[var(--dg-bg-tertiary)]">
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">#</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.status')}</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('pipeline.triggerBy')}</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.elapsed')}</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">{t('monitor.logTime')}</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">状态</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">触发人</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">耗时</th>
|
||||
<th className="px-4 py-2 text-left text-xs font-medium text-[var(--dg-text-secondary)]">时间</th>
|
||||
<th className="px-4 py-2 text-right text-xs font-medium text-[var(--dg-text-secondary)]">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -220,7 +217,7 @@ export function PipelineRunPanel({ pipeline, pipelineRuns, isLoadingRuns, onStop
|
||||
</div>
|
||||
<div className="bg-[var(--dg-bg-primary)] rounded-md p-3 max-h-[50vh] overflow-y-auto font-mono text-xs">
|
||||
{logs.length === 0 ? (
|
||||
<div className="dg-text-muted py-4 text-center">{t('pipeline.noLog')}</div>
|
||||
<div className="dg-text-muted py-4 text-center">暂无日志</div>
|
||||
) : (
|
||||
logs.map((l, i) => (
|
||||
<div key={i} className="flex gap-2 py-0.5">
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {
|
||||
import i18n from '@/i18n';
|
||||
GitBranch,
|
||||
Box,
|
||||
CheckCircle,
|
||||
@@ -10,12 +9,12 @@ import i18n from '@/i18n';
|
||||
|
||||
// 流水线阶段定义(固定顺序) // Logan.Li
|
||||
export const STAGE_DEFS = [
|
||||
{ key: 'checkout', label: i18n.t('pipeline.checkout'), icon: GitBranch, color: '#6366F1', order: 1, needs: ['repo'], desc: i18n.t('deploy.constants.s4769ce') },
|
||||
{ key: 'build', label: i18n.t('pipeline.build'), icon: Box, color: '#F59E0B', order: 2, needs: [], desc: i18n.t('deploy.constants.s3318af') },
|
||||
{ key: 'test', label: i18n.t('common.test'), icon: CheckCircle, color: '#10B981', order: 3, needs: [], desc: i18n.t('deploy.constants.s350a98') },
|
||||
{ key: 'image', label: i18n.t('deploy.stage.image'), icon: Container, color: '#06B6D4', order: 4, needs: ['registry'], desc: i18n.t('deploy.constants.s98ea11') },
|
||||
{ key: 'deploy', label: i18n.t('pipeline.deploy'), icon: Rocket, color: '#EF4444', order: 5, needs: [], desc: i18n.t('deploy.constants.s45f0eb') },
|
||||
{ key: 'verify', label: i18n.t('common.validate'), icon: ShieldCheck, color: '#3B82F6', order: 6, needs: [], desc: i18n.t('deploy.constants.seb164c') },
|
||||
{ key: 'checkout', label: '代码检出', icon: GitBranch, color: '#6366F1', order: 1, needs: ['repo'], desc: '从 Git 仓库拉取代码' },
|
||||
{ key: 'build', label: '构建', icon: Box, color: '#F59E0B', order: 2, needs: [], desc: '编译/打包项目' },
|
||||
{ key: 'test', label: '测试', icon: CheckCircle, color: '#10B981', order: 3, needs: [], desc: '运行单元/集成测试' },
|
||||
{ key: 'image', label: '镜像打包', icon: Container, color: '#06B6D4', order: 4, needs: ['registry'], desc: 'Docker 构建 & 推送' },
|
||||
{ key: 'deploy', label: '部署', icon: Rocket, color: '#EF4444', order: 5, needs: [], desc: '部署到目标环境' },
|
||||
{ key: 'verify', label: '验证', icon: ShieldCheck, color: '#3B82F6', order: 6, needs: [], desc: '部署后健康检查' },
|
||||
].sort((a, b) => a.order - b.order);
|
||||
|
||||
// 运行日志条目 // Logan.Li
|
||||
@@ -32,7 +31,7 @@ export function fmtDuration(s: number | undefined): string {
|
||||
if (s < 60) return `${s}s`;
|
||||
const m = Math.floor(s / 60);
|
||||
const sec = s % 60;
|
||||
return sec > 0 ? i18n.t('deploy.constants.s3ae00c', { m: m, sec: sec }) : i18n.t('deploy.constants.s45c7e1', { m: m });
|
||||
return sec > 0 ? `${m}分${sec}秒` : `${m}分`;
|
||||
}
|
||||
|
||||
// 解析 stages 字段 // Logan.Li
|
||||
|
||||
Reference in new issue
Block a user