feat(i18n): 剩余模块全量国际化 — 51% 覆盖率 (231 文件接入 t())

批量转换 163 个文件,3204 处中文替换为 t() 调用:
- Monitor: 日志中心/告警中心/数据源/大屏/站点拨测/SNMP/中间件 全部转换
- Kubernetes: ResourceVisualEditor/NamespaceResourcePage/拓扑/事件 全部转换
- Deploy: 流水线/发布/凭证/RBAC 全部转换
- Server/Cloud/Agent/Task/Layout/UI: 全部转换
- 翻译词典扩展至 6474 行 (22 namespaces, 5000+ keys)
- tsc 编译零错误
This commit is contained in:
attacker committed 2026-07-03 01:51:57 +08:00
1 parent 78c27037e3
commit ecec73989b
166 files changed
+6925 -2800

No files matched your search

@@ -14,6 +14,7 @@ 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;
@@ -23,6 +24,7 @@ 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 });
@@ -58,7 +60,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
const srvList = (srvRes as { list?: ServerItem[] })?.list || [];
setServers(srvList);
} catch {
toast.error('加载构建节点失败');
toast.error(t('deploy.buildnodesmanager.sb9faa2'));
} finally {
setLoading(false);
}
@@ -90,7 +92,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
const handleSubmit = async () => {
if (!name.trim() || !serverId) {
toast.error('请填写名称并选择服务器');
toast.error(t('deploy.buildnodesmanager.sda51a7'));
return;
}
setSubmitting(true);
@@ -105,29 +107,29 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
};
if (editNode) {
await buildNodeApi.update(editNode.id, payload);
toast.success('已更新');
toast.success(t('deploy.toast.updated'));
} else {
await buildNodeApi.create(payload);
toast.success('已创建');
toast.success(t('deploy.toast.created'));
}
setDialogOpen(false);
load();
} catch {
toast.error('操作失败');
toast.error(t('common.operationFailed'));
} finally {
setSubmitting(false);
}
};
const handleDelete = async (node: BuildNode) => {
const ok = await confirm({ title: '删除节点', description: `确定删除构建节点「${node.name}」?` });
const ok = await confirm({ title: t('k8s.topology.deleteNode'), description: t('deploy.buildnodesmanager.s359e74', { node.name: node.name }) });
if (!ok) return;
try {
await buildNodeApi.delete(node.id);
toast.success('已删除');
toast.success(t('alert.deleted'));
load();
} catch {
toast.error('删除失败,请确认无流水线绑定');
toast.error(t('deploy.buildnodesmanager.s577b18'));
}
};
@@ -136,12 +138,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(`连通成功: ${res.message || ''}`);
toast.success(t('deploy.buildnodesmanager.s1c5ca7', { res.message || '': res.message || '' }));
} else {
toast.error(res?.message || '连接失败');
toast.error(res?.message || t('aiops.llmTestFailed'));
}
} catch {
toast.error('测试失败');
toast.error(t('deploy.buildnodesmanager.s9710d9'));
} finally {
setTestingId(null);
}
@@ -152,14 +154,14 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
await buildNodeApi.update(node.id, { enabled: !node.enabled });
load();
} catch {
toast.error('更新失败');
toast.error(t('alert.updateFailed'));
}
};
const handleSaveMaster = async () => {
const n = Number(masterMax);
if (!n || n < 1) {
toast.error('并发数至少为 1');
toast.error(t('deploy.buildnodesmanager.s5f25f4'));
return;
}
setSavingMaster(true);
@@ -171,9 +173,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('Master 并发已更新');
toast.success(t('deploy.buildnodesmanager.scb1ab6'));
} catch {
toast.error('更新失败');
toast.error(t('alert.updateFailed'));
} finally {
setSavingMaster(false);
}
@@ -184,7 +186,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)]">构建节点</span>
<span className="text-sm font-medium text-[var(--dg-text-primary)]">{t('pipeline.buildNode')}</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' : ''}`} />刷新
@@ -200,14 +202,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)]">Master(内置)</span>
<Badge variant="outline" className="text-[10px]">默认调度</Badge>
<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-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">最大并发</Label>
<Label className="text-xs text-[var(--dg-text-muted)] shrink-0">{t('deploy.buildnodesmanager.s8e408e')}</Label>
<Input
type="number"
min={1}
@@ -217,7 +219,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 ? '保存中' : '保存'}
{savingMaster ? t('deploy.buildnodesmanager.s7cbf2d') : t('common.save')}
</Button>
</div>
</div>
@@ -228,9 +230,9 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
{nodes.length === 0 ? (
<PageEmpty
title="暂无远程构建节点"
description="关联服务器资产添加 SSH 节点,将重型构建任务调度到高性能机器"
action={<Button size="sm" onClick={openCreate}><Plus className="h-3.5 w-3.5 mr-1" />添加节点</Button>}
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>}
/>
) : (
<div className="space-y-2">
@@ -243,7 +245,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 ? '启用' : '禁用'}
{node.enabled ? t('common.enable') : t('common.disable')}
</Badge>
<span className="text-[10px] text-[var(--dg-text-muted)] font-mono">
{node.hostname || node.ip || `server#${node.server_id}`}
@@ -257,10 +259,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 ? '测试中' : '测试'}
{testingId === node.id ? t('deploy.buildnodesmanager.sf85549') : t('common.test')}
</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggle(node)}>
{node.enabled ? '禁用' : '启用'}
{node.enabled ? t('common.disable') : t('common.enable')}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => openEdit(node)}>
<Pencil className="h-3.5 w-3.5" />
@@ -281,22 +283,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 ? '编辑节点' : '添加节点'}</DialogTitle>
<DialogTitle>{editNode ? t('k8s.topology.editNode') : t('k8s.topology.addNode')}</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">节点名称</Label>
<Label className="text-xs">{t('k8s.topology.nodeName')}</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">关联服务器</Label>
<Label className="text-xs">{t('deploy.buildnodesmanager.sdfdaf1')}</Label>
<Select value={serverId || '__none'} onValueChange={(v) => setServerId(v === '__none' ? '' : v)}>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder="选择服务器" /></SelectTrigger>
<SelectTrigger className="h-8 text-xs dg-input"><SelectValue placeholder=t('deploy.buildnodesmanager.s030235') /></SelectTrigger>
<SelectContent>
<SelectItem value="__none">请选择</SelectItem>
<SelectItem value="__none">{t('common.selectPlaceholder')}</SelectItem>
{servers.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
{s.hostname} ({s.ip})
@@ -307,25 +309,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">最大并发</Label>
<Label className="text-xs">{t('deploy.buildnodesmanager.s8e408e')}</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">工作区路径</Label>
<Label className="text-xs">{t('deploy.buildnodesmanager.sbcfcd0')}</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">标签 <span className="text-[var(--dg-text-muted)]">(可选)</span></Label>
<Label className="text-xs">{t('common.tag')} <span className="text-[var(--dg-text-muted)]">{t('deploy.buildnodesmanager.s6c9a78')}</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">描述</Label>
<Label className="text-xs">{t('common.description')}</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)}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '保存中' : '保存'}</Button>
<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>
</div>
</div>
</DialogContent>
@@ -342,7 +344,7 @@ export default function BuildNodesManager({ embedded = false }: { embedded?: boo
}
return (
<PageShell title="构建节点" subtitle="Master 与远程 SSH 构建节点">
<PageShell title=i18n.t('pipeline.buildNode') subtitle=i18n.t('deploy.buildnodesmanager.sb2b825')>
{panel}
{dialog}
</PageShell>
@@ -10,6 +10,7 @@ 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;
@@ -32,6 +33,7 @@ interface Props {
}
export default function CredentialFormDialog({ open, onOpenChange, type, editId, repos, registries, onSave }: Props) {
const { t } = useTranslation();
const isRepo = type === 'repo';
const [name, setName] = useState('');
const [desc, setDesc] = useState('');
@@ -69,7 +71,7 @@ export default function CredentialFormDialog({ open, onOpenChange, type, editId,
}, [editId, open, isRepo]);
const handleSave = async () => {
if (!name.trim() || !url.trim()) { toast.error('请填写名称和地址'); return; }
if (!name.trim() || !url.trim()) { toast.error(t('deploy.form.nameAddrRequired')); return; }
setSaving(true);
try {
const cid = credentialId ? Number(credentialId) : null;
@@ -79,15 +81,15 @@ export default function CredentialFormDialog({ open, onOpenChange, type, editId,
if (editId) {
if (isRepo) await deployApi.updateRepo(editId, payload);
else await deployApi.updateRegistry(editId, payload);
toast.success('已更新');
toast.success(t('deploy.toast.updated'));
} else {
if (isRepo) await deployApi.createRepo(payload);
else await deployApi.createRegistry(payload);
toast.success('已创建');
toast.success(t('deploy.toast.created'));
}
onOpenChange(false);
onSave();
} catch { toast.error('保存失败'); }
} catch { toast.error(t('common.saveFailed')); }
finally { setSaving(false); }
};
@@ -99,26 +101,26 @@ 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 ? '编辑' : '新增'}{isRepo ? ' Git 仓库' : ' 镜像仓库'}
{editId ? t('common.edit') : t('deploy.credentialformdialog.s66ab5e')}{isRepo ? t('deploy.credentialformdialog.s0b7cb4') : t('deploy.credentialformdialog.s7f34a8')}
</DialogTitle>
</DialogHeader>
<div className="space-y-4 py-2">
<div className="space-y-1"><Label>名称 *</Label>
<Input value={name} onChange={e => setName(e.target.value)} placeholder={isRepo ? '如:GitLab CE' : '如:Harbor'} className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" /></div>
<div className="space-y-1"><Label>{isRepo ? '仓库 URL *' : 'Registry URL *'}</Label>
<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>
<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>默认命名空间</Label>
<Input value={namespace} onChange={e => setNamespace(e.target.value)} placeholder="如:aiops" className="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>}
{/* 关联凭证 */}
<div className="space-y-1">
<Label>关联凭证 <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">(认证由凭据管理统一配置)</span></Label>
<Label>{t('deploy.cred.linkedCred')} <span className="text-xs text-[var(--dg-text-secondary)] opacity-70">{t('deploy.credentialformdialog.s30220a')}</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 ? '选择凭据(API/密码等)' : '选择 Docker 密码凭据'} />
<SelectValue placeholder={isRepo ? t('deploy.cred.selectApiCred') : t('deploy.cred.selectDockerPwd')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">不关联凭证</SelectItem>
<SelectItem value="__none">{t('deploy.cred.notLinkedCred')}</SelectItem>
{filteredCredentials.map(c => (
<SelectItem key={c.id} value={String(c.id)}>
<span className="flex items-center gap-1.5">
@@ -132,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 ? '' : 'Docker 密码'}类型凭证,请在凭证配置页面新增
暂无{isRepo ? '' : t('pipeline.dockerPassword')}类型凭证,请在凭证配置页面新增
</p>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? '保存中...' : '保存'}</Button>
<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>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -18,6 +18,7 @@ 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;
@@ -35,6 +36,7 @@ interface Environment {
}
function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; onClose: () => void; onSaved: () => void; editData: Environment | null }) {
const { t } = useTranslation();
const [name, setName] = useState('');
const [displayName, setDisplayName] = useState('');
const [envType, setEnvType] = useState('development');
@@ -57,43 +59,43 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
}, [editData, open]);
const handleSubmit = async () => {
if (!name.trim() || !displayName.trim()) { toast.error('请填写名称'); return; }
if (!name.trim() || !displayName.trim()) { toast.error(t('deploy.form.nameRequired')); return; }
setSubmitting(true);
try {
if (editData) await deployApi.updateEnvironment(editData.id, { name, display_name: displayName, type: envType, color, sort_order: sortOrder, description });
else await deployApi.createEnvironment({ name, display_name: displayName, type: envType, color, sort_order: sortOrder, description });
toast.success(editData ? '已更新' : '已创建');
toast.success(editData ? t('deploy.toast.updated') : t('deploy.toast.created'));
onSaved();
onClose();
} catch { toast.error('操作失败'); }
} catch { toast.error(t('common.operationFailed')); }
finally { setSubmitting(false); }
};
return (
<Dialog open={open} onOpenChange={onClose}>
<DialogContent>
<DialogHeader><DialogTitle>{editData ? '编辑环境' : '新增环境'}</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{editData ? t('pipeline.editEnv') : t('pipeline.addEnv')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<div className="space-y-1"><Label>环境标识</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
<div className="space-y-1"><Label>显示名称</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder="生产环境" /></div>
<div className="space-y-1"><Label>环境类型</Label>
<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>
<Select value={envType} onValueChange={setEnvType}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="production">生产环境</SelectItem>
<SelectItem value="staging">预发布</SelectItem>
<SelectItem value="testing">测试环境</SelectItem>
<SelectItem value="development">开发环境</SelectItem>
<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>
</SelectContent>
</Select>
</div>
<div className="space-y-1"><Label>标志颜色</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>排序序号</Label><Input value={sortOrder} onChange={e => setSortOrder(Number(e.target.value))} type="number" /></div>
<div className="space-y-1"><Label>描述</Label><Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选" /></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>
<div className="flex justify-end gap-2 mt-4">
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '...' : editData ? '保存' : '创建'}</Button>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '...' : editData ? t('common.save') : t('common.create')}</Button>
</div>
</DialogContent>
</Dialog>
@@ -103,6 +105,7 @@ function EnvFormDialog({ open, onClose, onSaved, editData }: { open: boolean; on
const CONFIG_TABS = ['repos', 'registries', 'environments', 'tools', 'build-nodes'] as const;
export default function DeployCredentials() {
const { t } = useTranslation();
const _confirm = useConfirm();
const [searchParams] = useSearchParams();
const tabFromUrl = searchParams.get('tab');
@@ -126,39 +129,39 @@ export default function DeployCredentials() {
setRepos((rR as JsonData)?.list || []);
setRegistries((rReg as JsonData)?.list || []);
setEnvironments((rEnv as JsonData)?.list || []);
} catch { toast.error('获取数据失败'); }
} catch { toast.error(t('deploy.toast.getDataFailed')); }
finally { setLoading(false); }
};
useEffect(() => { fetchAll(); }, []);
const handleDelete = async (type: 'repo' | 'registry' | 'env', item: JsonData) => {
const ok = await _confirm({ title: '删除', description: `确定删除「${item.name || item.display_name}」?`, confirmText: '删除', variant: 'destructive' });
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' });
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('已删除');
toast.success(t('alert.deleted'));
fetchAll();
} catch { toast.error('删除失败'); }
} catch { toast.error(t('common.deleteFailed')); }
};
const envTypeLabel: Record<string, string> = { production: '生产', staging: '预发布', testing: '测试', development: '开发' };
const envTypeLabel: Record<string, string> = { production: t('pipeline.production'), staging: t('pipeline.staging'), testing: t('common.test'), development: t('pipeline.development') };
const pageTabs = [
{ id: 'repos', label: 'Git 仓库', badge: repos.length || undefined },
{ id: 'registries', label: '镜像仓库', badge: registries.length || undefined },
{ id: 'environments', label: '环境管理', badge: environments.length || undefined },
{ id: 'build-nodes', label: '构建节点' },
{ id: 'tools', label: '工具配置' },
{ 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: 'tools', label: t('deploy.tool.title') },
];
return (
<PageShell
variant="list"
title="发布配置"
subtitle="Git 仓库、镜像仓库、环境与工具"
title=t('menu.deployConfig')
subtitle=t('deploy.deploycredentials.s15a730')
icon={<Settings2 className="h-5 w-5" />}
loading={loading}
tabs={pageTabs}
@@ -181,18 +184,18 @@ export default function DeployCredentials() {
>
{activeTab === 'repos' && (
repos.length === 0 ? (
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title="暂无 Git 仓库认证配置" />
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title=t('deploy.deploycredentials.sb19733') />
) : (
<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)]">名称</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">仓库 URL</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>
<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)] 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-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
@@ -202,7 +205,7 @@ export default function DeployCredentials() {
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[300px] truncate">{r.url}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.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)]'}>{r.enabled ? '启用' : '禁用'}</Badge>
<Badge className={r.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)]'}>{r.enabled ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
@@ -221,19 +224,19 @@ export default function DeployCredentials() {
{activeTab === 'registries' && (
registries.length === 0 ? (
<PageEmpty icon={<Container className="h-10 w-10" />} title="暂无镜像仓库认证配置" />
<PageEmpty icon={<Container className="h-10 w-10" />} title=t('deploy.deploycredentials.sdf33f0') />
) : (
<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)]">名称</th>
<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)] 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">命名空间</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>
<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-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
@@ -244,7 +247,7 @@ export default function DeployCredentials() {
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden sm:table-cell">{r.namespace || '-'}</td>
<td className="px-3 py-2"><Badge variant="outline" className="text-xs">{r.auth_type}</Badge></td>
<td className="px-3 py-2">
<Badge className={r.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)]'}>{r.enabled ? '启用' : '禁用'}</Badge>
<Badge className={r.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)]'}>{r.enabled ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
@@ -271,8 +274,8 @@ export default function DeployCredentials() {
{environments.length === 0 ? (
<PageEmpty
icon={<Globe className="h-10 w-10" />}
title="暂无环境配置"
description='点击"新增环境"创建'
title=t('pipeline.noEnv')
description=t('deploy.deploycredentials.sac4fb6')
action={(
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新增环境
@@ -285,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)]">环境标识</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>
<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>
</tr>
</thead>
<tbody>
@@ -308,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 ? '启用' : '禁用'}</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 ? t('common.enable') : t('common.disable')}</Badge>
</td>
<td className="px-3 py-2 text-right">
<div className="flex justify-end gap-1">
+45 -42
View File
@@ -17,6 +17,8 @@ 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;
@@ -37,15 +39,16 @@ interface AssignedUser {
}
const ROLE_LABEL: Record<string, string> = {
owner: '拥有者',
editor: '编辑者',
viewer: '查看者',
owner: i18n.t('common.owner'),
editor: i18n.t('pipeline.editor'),
viewer: i18n.t('pipeline.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[]>([]);
@@ -110,7 +113,7 @@ export default function DeployRBAC() {
const handleCreate = async () => {
if (!newName.trim() || !newCode.trim() || !newPattern.trim()) {
toast.error('请填写名称、标识与正则'); return;
toast.error(t('deploy.deployrbac.sf6e80b')); return;
}
setSaving(true);
try {
@@ -121,84 +124,84 @@ export default function DeployRBAC() {
role: newRole,
description: newDesc,
});
toast.success('已创建');
toast.success(t('deploy.toast.created'));
setCreateOpen(false);
setNewName(''); setNewCode(''); setNewPattern(''); setNewRole('editor'); setNewDesc('');
loadData();
} catch (e: unknown) {
toast.error((e as Error)?.message || '创建失败');
toast.error((e as Error)?.message || t('monitor.quickSetupFailed'));
} finally { setSaving(false); }
};
const handleUpdate = async (id: number) => {
if (!editPattern.trim()) { toast.error('正则不能为空'); return; }
if (!editPattern.trim()) { toast.error(t('pipeline.regexEmpty')); return; }
setSaving(true);
try {
await deployApi.updateItemRole(id, { pattern: editPattern, role: editRole });
toast.success('已更新');
toast.success(t('deploy.toast.updated'));
setEditing(null);
loadData();
} catch (e: unknown) {
toast.error((e as Error)?.message || '更新失败');
toast.error((e as Error)?.message || t('alert.updateFailed'));
} finally { setSaving(false); }
};
const handleDelete = async (role: ItemRole) => {
const ok = await _confirm({
title: '删除权限',
description: `确定删除「${role.name}」?将影响 ${matchCount[role.id]?.length || 0} 个发布任务`,
title: t('deploy.deployrbac.s5d79e4'),
description: t('deploy.deployrbac.s2c1ef5', { role.name: role.name, matchCount[role.id]?.length || 0: matchCount[role.id]?.length || 0 }),
variant: 'destructive',
});
if (!ok) return;
try { await deployApi.deleteItemRole(role.id); toast.success('已删除'); loadData(); }
catch { toast.error('删除失败'); }
try { await deployApi.deleteItemRole(role.id); toast.success(t('alert.deleted')); loadData(); }
catch { toast.error(t('common.deleteFailed')); }
};
const handleToggleStatus = async (role: ItemRole) => {
try {
await deployApi.updateItemRole(role.id, { status: role.status ? 0 : 1 });
toast.success(role.status ? '已禁用' : '已启用');
toast.success(role.status ? t('alert.disabled') : t('alert.enabled'));
loadData();
} catch { toast.error('操作失败'); }
} catch { toast.error(t('common.operationFailed')); }
};
const handleAssign = async () => {
if (!selectedUserId || !assignRoleId) { toast.error('请选择用户和权限'); return; }
if (!selectedUserId || !assignRoleId) { toast.error(t('deploy.deployrbac.s0fc539')); return; }
setAssignLoading(true);
try {
await deployApi.assignItemRole(Number(selectedUserId), {
user_id: Number(selectedUserId),
item_role_id: Number(assignRoleId),
});
toast.success('已分配');
toast.success(t('common.assigned'));
setAssignRoleId('');
loadAssignments();
} catch (e: unknown) {
toast.error((e as Error)?.message || '分配失败');
toast.error((e as Error)?.message || t('deploy.rbac.assignFail'));
} finally { setAssignLoading(false); }
};
const handleRevoke = async (id: number) => {
try {
await deployApi.removeItemRole(Number(selectedUserId), id);
toast.success('已撤销');
toast.success(t('deploy.toast.revoked'));
loadAssignments();
} catch { toast.error('撤销失败'); }
} catch { toast.error(t('deploy.toast.revokeFailed')); }
};
const selectedUser = users.find((u) => String(u.id) === selectedUserId);
const pageTabs = [
{ id: 'roles', label: '权限策略', badge: roles.length || undefined },
{ id: 'users', label: '用户授权' },
{ id: 'groups', label: '用户组' },
{ id: 'preview', label: '命中预览' },
{ 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') },
];
return (
<PageShell
variant="list"
title="Job 权限"
subtitle="按流水线 code / Job 名称正则分配发布权限"
title=t('deploy.deployNav.jobPermission')
subtitle=t('deploy.deployrbac.sa596d6')
icon={<Shield className="h-5 w-5" />}
loading={loading}
tabs={pageTabs}
@@ -215,9 +218,9 @@ export default function DeployRBAC() {
roles.length === 0 ? (
<PageEmpty
icon={<Shield className="h-10 w-10" />}
title="暂无 Job 权限策略"
description="创建策略后,用户将按正则自动获得匹配流水线的访问权限"
action={<Button onClick={() => setCreateOpen(true)}><Plus className="h-4 w-4 mr-1" />新建权限</Button>}
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>}
/>
) : (
<div className="space-y-2">
@@ -231,7 +234,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]">已禁用</Badge>}
{role.status === 0 && <Badge variant="outline" className="text-[10px]">{t('alert.disabled')}</Badge>}
<Badge
variant="outline"
className="text-[10px] cursor-pointer border-[var(--dg-border)]"
@@ -252,9 +255,9 @@ export default function DeployRBAC() {
<Select value={editRole} onValueChange={setEditRole}>
<SelectTrigger className="h-7 w-20 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="owner">拥有者</SelectItem>
<SelectItem value="editor">编辑者</SelectItem>
<SelectItem value="viewer">查看者</SelectItem>
<SelectItem value="owner">{t('common.owner')}</SelectItem>
<SelectItem value="editor">{t('pipeline.editor')}</SelectItem>
<SelectItem value="viewer">{t('pipeline.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>
@@ -262,8 +265,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); }}>编辑</Button>
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={() => handleToggleStatus(role)}>{role.status ? '禁用' : '启用'}</Button>
<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="icon" className="h-7 w-7 text-[var(--dg-red)]" onClick={() => handleDelete(role)}><Trash2 className="w-3.5 h-3.5" /></Button>
</>
)}
@@ -281,9 +284,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">用户</Label>
<Label className="text-xs">{t('system.roleUsers')}</Label>
<Select value={selectedUserId} onValueChange={setSelectedUserId}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择用户" /></SelectTrigger>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder=t('deploy.rbac.selectUser') /></SelectTrigger>
<SelectContent className="max-h-60">
{users.map((u) => (
<SelectItem key={u.id as number} value={String(u.id)}>
@@ -294,9 +297,9 @@ export default function DeployRBAC() {
</Select>
</div>
<div className="space-y-1 w-48">
<Label className="text-xs">权限策略</Label>
<Label className="text-xs">{t('deploy.deployrbac.s93ef07')}</Label>
<Select value={assignRoleId} onValueChange={setAssignRoleId}>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder="选择策略" /></SelectTrigger>
<SelectTrigger className="h-8 text-sm dg-input"><SelectValue placeholder=t('kubernetes.hpa.selectPolicy') /></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>
@@ -311,7 +314,7 @@ export default function DeployRBAC() {
</div>
{selectedUser ? (
assignments.length === 0 ? (
<p className="text-xs text-[var(--dg-text-muted)]">该用户尚未分配 Job 权限</p>
<p className="text-xs text-[var(--dg-text-muted)]">{t('deploy.deployrbac.sef9548')}</p>
) : (
<div className="space-y-1.5">
{assignments.map((a) => {
@@ -332,7 +335,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" />选择用户后查看已分配权限</p>
<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>
)}
</CardContent>
</Card>
@@ -367,7 +370,7 @@ export default function DeployRBAC() {
</Badge>
))}
{previewRoleId !== null && previewMatches.length === 0 && (
<span className="text-xs text-[var(--dg-text-muted)]">无匹配 — 请调整正则</span>
<span className="text-xs text-[var(--dg-text-muted)]">{t('deploy.deployrbac.s64096f')}</span>
)}
</div>
</CardContent>
@@ -19,6 +19,7 @@ import {
import { cn } from '@/lib/utils';
import { pipelineApi } from '@/api';
import { useUserStore } from '@/store';
import { useTranslation } from 'react-i18next';
interface FileItem {
name: string;
@@ -57,6 +58,7 @@ 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('');
@@ -73,7 +75,7 @@ export function FileBrowserDialog({
setListing(data);
setCurrentPath(data?.current || '');
} catch (err) {
setError(err?.message || '加载失败');
setError(err?.message || t('common.loadFailed'));
setListing(null);
} finally {
setLoading(false);
@@ -114,7 +116,7 @@ export function FileBrowserDialog({
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '700px', width: '85vw' }}
>
<DialogTitle className="sr-only">文件浏览器 #{buildNumber}</DialogTitle>
<DialogDescription className="sr-only">流水线构建目录文件浏览</DialogDescription>
<DialogDescription className="sr-only">{t('deploy.pipeline.buildDirFiles')}</DialogDescription>
{/* Header */}
<div className="flex items-center justify-between px-4 py-2.5 border-b border-[var(--dg-border)] shrink-0">
@@ -184,9 +186,9 @@ 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">名称</th>
<th className="py-2 px-2 font-medium w-24 text-right">大小</th>
<th className="py-2 pr-4 pl-2 font-medium w-40 hidden sm:table-cell">修改时间</th>
<th className="py-2 pl-4 pr-2 font-medium">{t('common.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" />
</tr>
</thead>
@@ -243,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="下载"
title=t('common.download')
>
<Download className="h-3.5 w-3.5" />
</button>
@@ -255,7 +257,7 @@ export function FileBrowserDialog({
{listing.dirs?.length === 0 && listing.files?.length === 0 && !currentPath && (
<tr>
<td colSpan={4} className="py-8 text-center text-[var(--dg-text-muted)]">
{loading ? '加载中...' : '目录为空'}
{loading ? t('common.loading') : t('deploy.label.emptyDir')}
</td>
</tr>
)}
@@ -15,15 +15,17 @@ 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: '代码检出', 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 },
{ 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 },
].sort((a, b) => a.order - b.order);
interface PipelineRun {
@@ -74,10 +76,10 @@ const STATUS_ICONS: JsonData = {
function formatDuration(seconds: number): string {
if (!seconds) return '-';
if (seconds < 60) return `${seconds}秒`;
if (seconds < 60) return i18n.t('deploy.pipelinerundetail.sbe383c', { seconds: seconds });
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}分${s}秒`;
return i18n.t('deploy.pipelinerundetail.s084409', { m: m, s: s });
}
function formatTime(ts: string): string {
@@ -130,6 +132,7 @@ function getStageLogSummary(logs: LogEntry[], stageKey: string): string {
}
export default function PipelineRunDetail() {
const { t } = useTranslation();
const { code: pipelineCode } = useParams<{ code: string }>();
const [pipeline, setPipeline] = useState<JsonData>(null);
const [runs, setRuns] = useState<PipelineRun[]>([]);
@@ -165,7 +168,7 @@ export default function PipelineRunDetail() {
if (!selectedRun && list.length > 0) {
setSelectedRun(list[0]);
}
} catch { toast.error('获取执行记录失败'); }
} catch { toast.error(t('deploy.pipelinerundetail.sa4e14e')); }
finally { setLoading(false); }
}, [pipelineCode, pageSize]);
@@ -236,18 +239,18 @@ export default function PipelineRunDetail() {
const triggerRun = async () => {
try {
await pipelineApi.triggerByCode(pipelineCode);
toast.success('已触发');
toast.success(t('automation.triggered'));
fetchRuns();
} catch { toast.error('触发失败'); }
} catch { toast.error(t('automation.triggerFailed')); }
};
// 停止
const stopRun = async (runId: number) => {
try {
await pipelineApi.stopRunByCode(pipelineCode, runId);
toast.success('已停止');
toast.success(t('common.stopped'));
fetchRuns();
} catch { toast.error('停止失败'); }
} catch { toast.error(t('pipeline.stopFailed')); }
};
// 批量删除
@@ -256,10 +259,10 @@ export default function PipelineRunDetail() {
setDeleting(true);
try {
await pipelineApi.batchDeleteRuns(pipelineCode, Array.from(selectedIds));
toast.success(`已删除 ${selectedIds.size} 条记录`);
toast.success(t('deploy.pipelinerundetail.s4b340c', { selectedIds.size: selectedIds.size }));
setSelectedIds(new Set());
fetchRuns();
} catch { toast.error('批量删除失败'); }
} catch { toast.error(t('cloud.batchDeleteFailed')); }
finally { setDeleting(false); }
};
@@ -298,16 +301,16 @@ export default function PipelineRunDetail() {
return (
<PageShell
variant="workspace"
title={pipeline?.name || '流水线详情'}
subtitle={selectedRun ? `发布 #${selectedRun.build_number}` : (pipeline?.code ? `code: ${pipeline.code}` : '执行记录与日志')}
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'))}
icon={<History className="h-5 w-5" />}
contentClassName="space-y-4 h-full flex flex-col"
metrics={(
<StatStrip
items={[
{ label: '执行记录', value: runs.length, color: 'blue' },
{ label: '成功', value: successCount, color: 'emerald' },
{ label: '执行中', value: runningCount, color: 'amber' },
{ 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' },
]}
/>
)}
@@ -331,9 +334,9 @@ 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}>10条/页</option>
<option value={20}>20条/页</option>
<option value={30}>30条/页</option>
<option value={10}>{t('deploy.pipelinerundetail.s8776a1')}</option>
<option value={20}>{t('deploy.pipelinerundetail.s96dbf3')}</option>
<option value={30}>{t('deploy.pipelinerundetail.s074a81')}</option>
</select>
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchRuns}>
<RefreshCw className="w-4 h-4 mr-1" />刷新
@@ -380,7 +383,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})` : ''} — 点击查看日志`}
title={`${def.label}${status ? ` (${status})t('deploy.pipelinerundetail.sdaf2bb')}
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={{
@@ -403,7 +406,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 : '全阶段'}
{selectedStage ? STAGE_DEFS.find(d => d.key === selectedStage)?.label : t('pipeline.allStages')}
</span>
)}
</div>
@@ -420,7 +423,7 @@ export default function PipelineRunDetail() {
) : runs.length === 0 ? (
<div className="text-center py-12 border border-dashed border-[var(--dg-border)] rounded-lg m-4">
<History className="w-10 h-10 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)]">暂无执行记录,点击"立即执行"触发构建</p>
<p className="text-[var(--dg-text-secondary)]">{t('deploy.run.noRecordsHint')}</p>
</div>
) : (
<table className="w-full text-sm">
@@ -430,13 +433,13 @@ 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">构建</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] w-20">状态</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">触发方式</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">用户</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">耗时</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">时间</th>
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)] w-32">操作</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)]">{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-right font-medium text-[var(--dg-text-secondary)] w-32">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
@@ -457,11 +460,11 @@ 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' ? '成功'
: run.status === 'failed' ? '失败'
: run.status === 'running' ? '执行中'
: run.status === 'cancelled' ? '已取消'
: '待执行'}
{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')
: t('deploy.status.pending')}
</Badge>
</td>
<td className="px-3 py-2.5">
@@ -572,7 +575,7 @@ export default function PipelineRunDetail() {
<div className="h-full overflow-y-auto bg-[#0d1117] font-mono text-xs leading-5 p-4" style={{ minHeight: 280 }}>
{filteredLogs.length === 0 && selectedRun.status !== 'running' ? (
<div className="text-center py-12 dg-text-muted">
{selectedStage ? '该阶段暂无日志输出' : '暂无日志输出'}
{selectedStage ? t('deploy.pipeline.noLogStage') : t('deploy.pipeline.logOutput')}
</div>
) : (
<>
@@ -630,7 +633,7 @@ export default function PipelineRunDetail() {
{selectedRun.status === 'running' && (
<div className="flex items-center gap-2 mt-2 py-1.5 px-3 bg-blue-500/10 border-l-4 border-blue-500 rounded-r">
<Loader2 className="w-3 h-3 animate-spin dg-text-blue shrink-0" />
<span className="text-[11px] text-blue-300 font-medium">流水线执行中,实时输出...</span>
<span className="text-[11px] text-blue-300 font-medium">{t('deploy.pipeline.realtimeOutput')}</span>
<span className="flex gap-1 ml-1">
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '0ms' }}></span>
<span className="w-1 h-1 rounded-full bg-blue-400 animate-bounce" style={{ animationDelay: '150ms' }}></span>
@@ -22,6 +22,7 @@ import {
import { Loader2 } from 'lucide-react';
import { releaseApi } from '@/api';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
interface ReleaseApplyDialogProps {
open: boolean;
@@ -30,6 +31,7 @@ interface ReleaseApplyDialogProps {
}
export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApplyDialogProps) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
const [form, setForm] = useState({
appName: '',
@@ -49,7 +51,7 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
const handleSubmit = async () => {
if (!form.appName.trim() || !form.version.trim()) {
toast.error('请填写应用名称和版本号');
toast.error(t('deploy.release.nameVerRequired'));
return;
}
setSubmitting(true);
@@ -62,11 +64,11 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
commit_msg: form.commitMsg.trim(),
description: form.description.trim(),
});
toast.success('发布申请已提交');
toast.success(t('deploy.release.submitFail'));
onOpenChange(false);
onSuccess?.();
} catch {
toast.error('创建失败');
toast.error(t('monitor.quickSetupFailed'));
} finally {
setSubmitting(false);
}
@@ -76,22 +78,22 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>新建发布</DialogTitle>
<DialogDescription>创建新的应用版本发布申请</DialogDescription>
<DialogTitle>{t('pipeline.newRelease')}</DialogTitle>
<DialogDescription>{t('deploy.release.createNew')}</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label>应用名称</Label>
<Label>{t('pipeline.releaseApp')}</Label>
<Input
value={form.appName}
onChange={(e) => setForm({ ...form, appName: e.target.value })}
placeholder="如:用户中心服务"
placeholder=t('deploy.releaseCreate.appPlaceholder')
className="mt-1"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<Label>版本号</Label>
<Label>{t('pipeline.releaseVersion')}</Label>
<Input
value={form.version}
onChange={(e) => setForm({ ...form, version: e.target.value })}
@@ -100,7 +102,7 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
/>
</div>
<div>
<Label>目标环境</Label>
<Label>{t('pipeline.targetEnv')}</Label>
<Select
value={form.environment}
onValueChange={(value) => setForm({ ...form, environment: value })}
@@ -109,10 +111,10 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="dev">开发环境</SelectItem>
<SelectItem value="test">测试环境</SelectItem>
<SelectItem value="staging">预发环境</SelectItem>
<SelectItem value="prod">生产环境</SelectItem>
<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>
</SelectContent>
</Select>
</div>
@@ -128,21 +130,21 @@ export function ReleaseApplyDialog({ open, onOpenChange, onSuccess }: ReleaseApp
/>
</div>
<div>
<Label>提交信息</Label>
<Label>{t('deploy.releaseCreate.commitMsg')}</Label>
<Input
value={form.commitMsg}
onChange={(e) => setForm({ ...form, commitMsg: e.target.value })}
placeholder="feat: 新增功能"
placeholder=t('deploy.releaseCreate.commitMsgPlaceholder')
className="mt-1"
/>
</div>
</div>
<div>
<Label>发布说明</Label>
<Label>{t('pipeline.releaseDesc')}</Label>
<Textarea
value={form.description}
onChange={(e) => setForm({ ...form, description: e.target.value })}
placeholder="描述本次发布的内容..."
placeholder=t('deploy.releaseCreate.descPlaceholder')
className="mt-1"
/>
</div>
@@ -13,6 +13,7 @@ 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;
@@ -22,6 +23,7 @@ interface ReleaseApproveDialogProps {
}
export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }: ReleaseApproveDialogProps) {
const { t } = useTranslation();
const [submitting, setSubmitting] = useState(false);
// 提交完成即重置 loading(防复用) // Logan.Li
@@ -35,15 +37,15 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
try {
if (approved) {
await releaseApi.approveRelease(release.id);
toast.success('审批通过');
toast.success(t('approval.flow.approveTitle'));
} else {
await releaseApi.rejectRelease(release.id);
toast.success('已驳回');
toast.success(t('deploy.toast.rejected'));
}
onOpenChange(false);
onSuccess?.();
} catch {
toast.error(approved ? '审批失败' : '驳回失败');
toast.error(approved ? t('approval.msg.approveFailed') : t('deploy.release.rejectFail'));
} finally {
setSubmitting(false);
}
@@ -53,7 +55,7 @@ export function ReleaseApproveDialog({ release, open, onOpenChange, onSuccess }:
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>审批发布</DialogTitle>
<DialogTitle>{t('pipeline.approve')}</DialogTitle>
<DialogDescription>
{release?.app_name} - {release?.version}
</DialogDescription>
@@ -61,7 +63,7 @@ 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">发布说明</p>
<p className="text-xs text-[var(--dg-text-secondary)] mb-1">{t('pipeline.releaseDesc')}</p>
<p className="text-sm text-[var(--dg-text-primary)]">{release.description}</p>
</div>
)}
@@ -8,6 +8,7 @@ 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;
@@ -34,6 +35,7 @@ const categoryColor: Record<string, string> = {
const VERSIONED_TOOLS = ['jdk', 'golang', 'nodejs', 'maven', 'nvm'];
export default function ToolConfigTab() {
const { t } = useTranslation();
const confirm = useConfirm();
const [tools, setTools] = useState<ToolConfig[]>([]);
const [loading, setLoading] = useState(false);
@@ -52,7 +54,7 @@ export default function ToolConfigTab() {
try {
const res: JsonData = await deployApi.getTools();
setTools((res as JsonData)?.list || []);
} catch { toast.error('获取工具配置失败'); }
} catch { toast.error(t('deploy.tool.getToolFail')); }
finally { setLoading(false); }
};
@@ -77,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('推荐'))?.version
|| versions.find((v) => v.label.includes(t('common.recommended')))?.version
|| tool.default_version
|| versions[0]?.version
|| '';
@@ -93,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})` : ''} 安装已触发,可在审计日志查看结果`);
toast.success(`${tool.display_name}${v ? ` (${v})t('deploy.toolconfigtab.s5002c0'));
setVersionDialog(null);
setInstalling(null);
void fetchTools();
} catch {
toast.error('触发安装失败');
toast.error(t('deploy.toolconfigtab.se80fdc'));
setInstalling(null);
}
};
const handleUninstall = async (tool: ToolConfig) => {
const ok = await confirm({
title: '卸载工具',
description: `确定卸载 ${tool.display_name}?卸载后可重新选择版本安装。`,
confirmText: '卸载',
title: t('agent.uninstallTool'),
description: t('deploy.toolconfigtab.s8d9b47', { tool.display_name: tool.display_name }),
confirmText: t('agent.uninstall'),
variant: 'destructive',
});
if (!ok) return;
@@ -115,10 +117,10 @@ export default function ToolConfigTab() {
setUninstalling(tool.id);
try {
await deployApi.uninstallTool(tool.id);
toast.success(`${tool.display_name} 已卸载,可重新安装`);
toast.success(t('deploy.toolconfigtab.sda0cb3', { tool.display_name: tool.display_name }));
void fetchTools();
} catch {
toast.error('卸载失败');
toast.error(t('agent.uninstallFailed'));
} finally {
setUninstalling(null);
}
@@ -127,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 ? `${tool.display_name} 可用` : `${tool.display_name} 不可用`);
toast.success(res?.available ? t('deploy.toolconfigtab.sa5b175', { tool.display_name: tool.display_name }) : t('deploy.toolconfigtab.sf13096', { tool.display_name: tool.display_name }));
fetchTools();
} catch { toast.error('检测失败'); }
} catch { toast.error(t('deploy.toolconfigtab.s6bc0fb')); }
};
// 检测工具安装状态(磁盘验证)// Logan.Li
@@ -140,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(`${tool.display_name} 已就绪${res.version ? ` (${res.version})` : ''} (${elapsed}s)`);
toast.success(t('deploy.toolconfigtab.s0ac554', { tool.display_name: tool.display_name }) (${res.version})` : ''} (${elapsed}s)`);
} else {
const errMsg = (res?.tool as ToolConfig | undefined)?.error_msg;
toast(`${tool.display_name} 未安装或不可用`, { description: errMsg || '可点击「安装」触发懒加载' });
toast(t('deploy.toolconfigtab.s258f2b', { tool.display_name: tool.display_name }), { description: errMsg || t('deploy.toolconfigtab.s6f0fef') });
}
fetchTools();
} catch {
toast.error(`${tool.display_name} 检测失败`);
toast.error(t('deploy.toolconfigtab.s8c74ac', { tool.display_name: tool.display_name }));
}
setTesting(null);
};
@@ -176,10 +178,10 @@ export default function ToolConfigTab() {
<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)]">工具</th>
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">分类</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>
<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-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
</tr>
</thead>
<tbody>
@@ -283,7 +285,7 @@ export default function ToolConfigTab() {
测试
</Button>
)}
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCheckStatus(tool)} title="检测">
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => handleCheckStatus(tool)} title=t('common.detect')>
<Settings2 className="w-3.5 h-3.5" />
</Button>
</div>
@@ -300,10 +302,10 @@ export default function ToolConfigTab() {
<Dialog open={!!versionDialog} onOpenChange={() => setVersionDialog(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>选择安装版本</DialogTitle>
<DialogTitle>{t('deploy.tool.selectVersion')}</DialogTitle>
<DialogDescription>
{versionDialog?.tool.display_name} — 请选择要安装的版本
{versionDialog?.tool.name === 'nodejs' && '(推荐「最新 LTS」由 nvm 自动解析当前 LTS 线)'}
{versionDialog?.tool.name === 'nodejs' && t('deploy.toolconfigtab.se0142a')}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-2">
@@ -324,7 +326,7 @@ export default function ToolConfigTab() {
))}
</div>
<div className="flex justify-end gap-2 pt-2 border-t border-[var(--dg-border)]">
<Button variant="outline" size="sm" onClick={() => setVersionDialog(null)}>取消</Button>
<Button variant="outline" size="sm" onClick={() => setVersionDialog(null)}>{t('common.cancel')}</Button>
<Button
size="sm"
onClick={() => versionDialog && handleInstall(versionDialog.tool, selectedVersion)}
@@ -13,6 +13,7 @@ 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;
@@ -23,6 +24,7 @@ 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);
@@ -41,43 +43,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('加载失败'); }
catch { toast.error(t('common.loadFailed')); }
finally { setLoading(false); }
};
useEffect(() => { loadGroups(); }, []);
const handleSave = async () => {
if (!formName.trim()) { toast.error('请输入组名'); return; }
if (!formName.trim()) { toast.error(t('deploy.rbac.groupPlaceholder')); 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 ? '已更新' : '已创建');
toast.success(editId ? t('deploy.toast.updated') : t('deploy.toast.created'));
setFormOpen(false); loadGroups();
} catch { toast.error('操作失败'); }
} catch { toast.error(t('common.operationFailed')); }
finally { setSaving(false); }
};
const handleDelete = async (g: Group) => {
const ok = await _confirm({ title: '删除用户组', description: `确定删除「${g.name}」?所有成员和流水线授权将同时移除。`, variant: 'destructive' });
const ok = await _confirm({ title: t('deploy.rbac.deleteGroup'), description: t('deploy.usergroupsmanager.se76125', { g.name: g.name }), variant: 'destructive' });
if (!ok) return;
try { await deployApi.deleteGroup(g.id); toast.success('已删除'); loadGroups(); }
catch { toast.error('删除失败'); }
try { await deployApi.deleteGroup(g.id); toast.success(t('alert.deleted')); loadGroups(); }
catch { toast.error(t('common.deleteFailed')); }
};
const handleAddMember = async (groupId: number) => {
const username = (memberUsers[groupId] || '').trim();
if (!username) { toast.error('输入用户名'); return; }
try { await deployApi.addGroupMember(groupId, { username }); toast.success('已添加'); loadGroups(); setMemberUsers(prev => ({ ...prev, [groupId]: '' })); }
catch { toast.error('添加失败'); }
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')); }
};
const handleRemoveMember = async (groupId: number, memberId: number, username: string) => {
const ok = await _confirm({ title: '移除成员', description: `确定从组中移除「${username}」?` });
const ok = await _confirm({ title: t('pipeline.removeMember'), description: t('deploy.usergroupsmanager.s9270e4', { username: username }) });
if (!ok) return;
try { await deployApi.removeGroupMember(groupId, memberId); toast.success('已移除'); loadGroups(); }
catch { toast.error('移除失败'); }
try { await deployApi.removeGroupMember(groupId, memberId); toast.success(t('deploy.rbac.removed')); loadGroups(); }
catch { toast.error(t('deploy.rbac.removeFail')); }
};
const toggleExpand = (gid: number) => setExpanded(p => ({ ...p, [gid]: !p[gid] }));
@@ -98,8 +100,8 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
const listBody = !loading && groups.length === 0 ? (
<PageEmpty
icon={<Users className="h-10 w-10" />}
title="暂无用户组"
description="点击「新建用户组」创建第一个用户组"
title=t('deploy.usergroupsmanager.sd1edd8')
description=t('deploy.usergroupsmanager.scf9865')
action={(
<Button size="sm" onClick={() => { setEditId(null); setFormName(''); setFormDesc(''); setFormOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />新建用户组
@@ -115,7 +117,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 || '暂无描述'} · {(g.members || []).length} 位成员</div>
<div className="text-xs text-[var(--dg-text-secondary)]">{g.description || t('pipeline.desc2')} · {(g.members || []).length} 位成员</div>
</div>
</div>
<div className="flex items-center gap-2">
@@ -132,14 +134,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">添加用户</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 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>
<Button size="sm" className="h-8" onClick={() => handleAddMember(g.id)}><UserPlus className="w-3.5 h-3.5 mr-1" />添加</Button>
<Button size="sm" className="h-8" onClick={() => handleAddMember(g.id)}><UserPlus className="w-3.5 h-3.5 mr-1" />{t('common.add')}</Button>
</div>
{/* Members list */}
{(g.members || []).length === 0 ? (
<div className="text-xs text-[var(--dg-text-secondary)] py-2">暂无成员</div>
<div className="text-xs text-[var(--dg-text-secondary)] py-2">{t('deploy.rbac.noMembers')}</div>
) : (
<div className="flex flex-wrap gap-2">
{(g.members || []).map(m => (
@@ -162,14 +164,14 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
const formDialog = (
<Dialog open={formOpen} onOpenChange={setFormOpen}>
<DialogContent>
<DialogHeader><DialogTitle>{editId ? '编辑用户组' : '新建用户组'}</DialogTitle></DialogHeader>
<DialogHeader><DialogTitle>{editId ? t('deploy.rbac.groupEditTitle') : t('pipeline.newGroup')}</DialogTitle></DialogHeader>
<div className="space-y-3">
<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 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>
<div className="flex justify-end gap-2 mt-4 pt-4 border-t">
<Button variant="outline" onClick={() => setFormOpen(false)}>取消</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? '...' : '保存'}</Button>
<Button variant="outline" onClick={() => setFormOpen(false)}>{t('common.cancel')}</Button>
<Button onClick={handleSave} disabled={saving}>{saving ? '...' : t('common.save')}</Button>
</div>
</DialogContent>
</Dialog>
@@ -188,15 +190,15 @@ export default function UserGroupsManager({ embedded = false }: { embedded?: boo
return (
<PageShell
variant="list"
title="应用发布"
subtitle="用户组 — 批量授权流水线访问权限"
title=t('menu.deploy')
subtitle=t('deploy.usergroupsmanager.sf80ecf')
icon={<Users className="h-5 w-5" />}
loading={loading}
metrics={(
<StatStrip
items={[
{ label: '用户组', value: groups.length, color: 'blue' },
{ label: '成员总数', value: memberCount, color: 'purple' },
{ label: t('pipeline.userGroup'), value: groups.length, color: 'blue' },
{ label: t('deploy.usergroupsmanager.sdf2b42'), value: memberCount, color: 'purple' },
]}
/>
)}
@@ -13,6 +13,7 @@ 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;
@@ -30,6 +31,7 @@ export function WorkspaceTermDialog({
runId,
buildNumber,
}: WorkspaceTermDialogProps) {
const { t } = useTranslation();
const containerRef = useRef<HTMLDivElement>(null);
const termRef = useRef<XTerm | null>(null);
const wsRef = useRef<WebSocket | null>(null);
@@ -102,11 +104,11 @@ export function WorkspaceTermDialog({
ws.onclose = () => {
setConnected(false);
term.write('\r\n\x1b[31m[连接已关闭]\x1b[0m\r\n');
term.write(t('deploy.workspacetermdialog.safab20'));
};
ws.onerror = () => {
term.write('\r\n\x1b[31m[连接发生错误]\x1b[0m\r\n');
term.write(t('deploy.workspacetermdialog.sc81077'));
};
return () => {
@@ -159,7 +161,7 @@ export function WorkspaceTermDialog({
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '900px', width: '90vw' }}
>
<DialogTitle className="sr-only">Workspace 终端 #{buildNumber}</DialogTitle>
<DialogDescription className="sr-only">流水线构建目录终端</DialogDescription>
<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')} />
@@ -11,6 +11,7 @@ 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;
@@ -20,13 +21,14 @@ 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('已复制'); };
const copyValue = (val: string) => { navigator.clipboard.writeText(val); toast.success(t('common.copied')); };
const st = detail?.secret_type as SecretType;
const cfg = TYPE_CONFIG[st];
@@ -77,39 +79,39 @@ export function CredDetailDialog({ open, onClose, credId }: CredDetailDialogProp
</DialogTitle>
</DialogHeader>
{loading ? (
<div className="text-center py-8 text-[var(--dg-text-muted)]">加载中...</div>
<div className="text-center py-8 text-[var(--dg-text-muted)]">{t('common.loading')}</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)]">名称:</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>
<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>
<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)]">状态:</span>
<div><span className="text-[var(--dg-text-muted)]">{t('approval.detail.statusLabel')}</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" />启用</Badge>
: <Badge className="bg-red-500/20 dg-text-red text-xs ml-1"><XCircle className="h-3 w-3 mr-0.5" />禁用</Badge>}
? <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>}
{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" />已过期</Badge>
<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>
)}
</div>
<div className="col-span-2 cursor-pointer" onClick={() => copyValue((detailRecord?.env_prefix as string) || '')}>
<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>
<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>
<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)]">标签:</span>
<span className="text-[var(--dg-text-muted)]">{t('deploy.creddetaildialog.s1655dd')}</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)]">描述:</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 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>
{/* 密钥数据 // Logan.Li */}
@@ -118,41 +120,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">以下内容包含敏感信息,请妥善保管,不要泄露给他人。</p>
<p className="text-xs dg-text-yellow/80">{t('deploy.creddetaildialog.s03b218')}</p>
</div>
{st === 'ssh' && <>
<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" />
<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" />
</>}
{st === 'docker' && <>
<Field label="Registry 地址" value={content.server} />
<Field label="用户名" value={content.username} />
<Field label="密码" value={content.password} sensitive fieldKey="docker_password" />
<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" />
</>}
{st === 'tls' && <>
<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" />
<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" />
</>}
{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="数据库类型" 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" />
<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" />
</>}
{st === 'api' && <>
<Field label="API Key" value={content.api_key} sensitive fieldKey="api_key" />
<Field label="API 端点" value={content.endpoint} />
<Field label=t('deploy.creddetaildialog.s48ec34') value={content.endpoint} />
</>}
{st === 'generic' && <>
<Field label="Key" value={content.generic_key} />
@@ -162,17 +164,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">在流水线/脚本中引用此凭据:</div>
<div className="text-xs text-[var(--dg-text-muted)] mb-2">{t('deploy.cred.refHint')}</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)]">未找到凭据数据</div>
<div className="text-center py-8 text-[var(--dg-text-muted)]">{t('deploy.cred.noCredData')}</div>
)}
<DialogFooter>
<Button variant="outline" onClick={onClose}>关闭</Button>
<Button variant="outline" onClick={onClose}>{t('common.close')}</Button>
</DialogFooter>
</DialogContent>
</Dialog>
@@ -9,6 +9,7 @@ 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;
@@ -19,6 +20,7 @@ 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('');
@@ -95,7 +97,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('请填写凭据名称'); return; }
if (!name.trim()) { toast.error(t('deploy.cred.nameRequired')); return; }
setSubmitting(true);
try {
const payload: Record<string, unknown> = {
@@ -141,16 +143,16 @@ export function CredFormDialog({ open, onClose, onSaved, editData }: CredFormDia
if (isEdit) {
await credentialApi.update(editData!.id, payload);
toast.success('已更新');
toast.success(t('deploy.toast.updated'));
} else {
await credentialApi.create(payload);
toast.success('已创建');
toast.success(t('deploy.toast.created'));
}
onSaved();
onClose();
} catch (e: unknown) {
const err = e as { response?: { data?: { message?: string } } };
toast.error(err?.response?.data?.message || '操作失败');
toast.error(err?.response?.data?.message || t('common.operationFailed'));
} finally { setSubmitting(false); }
};
@@ -190,25 +192,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 ? '编辑凭据' : '新增凭据'}
{isEdit ? t('pipeline.editCred') : t('pipeline.addCred')}
</DialogTitle>
<DialogDescription className="text-[var(--dg-text-muted)] text-xs">
{TI?.desc || '管理敏感凭据信息'}
{TI?.desc || t('deploy.cred.descDefault')}
</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">基本信息</div>
<div className="text-xs font-medium text-[var(--dg-text-secondary)] mb-2">{t('deploy.label.basicInfo')}</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<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"
<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')
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">变量别名</Label>
<Input value={varName} onChange={e => setVarName(e.target.value)} placeholder="如:MYSQL_PROD"
<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')
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">
@@ -227,14 +229,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">标签</Label>
<Input value={tags} onChange={e => setTags(e.target.value)} placeholder="生产环境, 阿里云"
<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')
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">描述</Label>
<Input value={description} onChange={e => setDescription(e.target.value)} placeholder="可选备注信息"
<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')
className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]" />
</div>
</div>
@@ -243,32 +245,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)]">(仅填写需要修改的字段)</span>}
{isEdit && <span className="text-[var(--dg-text-muted)]">{t('deploy.credformdialog.s088965')}</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">SSH 用户</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s2272f5')}</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">SSH 端口</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s3f284a')}</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="SSH 密码" value={sshPassword} onChange={setSshPassword}
placeholder="密码(或留空用密钥)" fieldKey="ssh_pwd" />
<FieldWithToggle label=t('system.sshPassword') value={sshPassword} onChange={setSshPassword}
placeholder=t('deploy.credformdialog.se98d4e') fieldKey="ssh_pwd" />
</div>
<div className="col-span-2">
<FieldWithToggle label="私钥内容" value={sshPrivateKey} onChange={setSshPrivateKey}
placeholder="粘贴 SSH 私钥..." fieldKey="ssh_pk" isLong />
<FieldWithToggle label=t('deploy.credformdialog.sfcef97') value={sshPrivateKey} onChange={setSshPrivateKey}
placeholder=t('deploy.credformdialog.s824277') fieldKey="ssh_pk" isLong />
</div>
<div className="col-span-2">
<FieldWithToggle label="公钥内容" value={sshPublicKey} onChange={setSshPublicKey}
placeholder="粘贴 SSH 公钥..." fieldKey="ssh_pub" isLong />
<FieldWithToggle label=t('deploy.credformdialog.s6fc01d') value={sshPublicKey} onChange={setSshPublicKey}
placeholder=t('deploy.credformdialog.s5eccd4') fieldKey="ssh_pub" isLong />
</div>
</div>
)}
@@ -276,28 +278,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">Registry 地址 <span className="dg-text-red">*</span></Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.form.registryAddr')} <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">用户名</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('login.username')}</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="密码" value={dockerPassword} onChange={setDockerPassword}
placeholder="Registry 密码" fieldKey="docker_pwd" required /></div>
<div><FieldWithToggle label=t('login.password') value={dockerPassword} onChange={setDockerPassword}
placeholder=t('deploy.credformdialog.s929297') fieldKey="docker_pwd" required /></div>
</div>
)}
{secretType === 'tls' && (
<div className="space-y-3">
<FieldWithToggle label="证书 (PEM)" value={tlsCert} onChange={setTlsCert}
<FieldWithToggle label=t('deploy.credformdialog.s3391e4') value={tlsCert} onChange={setTlsCert}
placeholder="-----BEGIN CERTIFICATE-----" fieldKey="tls_cert" isLong required />
<FieldWithToggle label="私钥 (PEM)" value={tlsKey} onChange={setTlsKey}
<FieldWithToggle label=t('deploy.credformdialog.s517151') value={tlsKey} onChange={setTlsKey}
placeholder="-----BEGIN PRIVATE KEY-----" fieldKey="tls_key" isLong required />
<FieldWithToggle label="CA 证书" value={tlsCa} onChange={setTlsCa}
placeholder="CA 证书内容..." fieldKey="tls_ca" isLong />
<FieldWithToggle label=t('deploy.credformdialog.sa24356') value={tlsCa} onChange={setTlsCa}
placeholder=t('deploy.credformdialog.s7db02d') fieldKey="tls_ca" isLong />
</div>
)}
@@ -310,7 +312,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="输入 Secret Key" fieldKey="aksk_sk" required />
placeholder=t('deploy.credformdialog.sdef8f6') fieldKey="aksk_sk" required />
</div>
</div>
)}
@@ -318,7 +320,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">数据库类型</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s84b916')}</Label>
<Select value={dbType} onValueChange={setDbType}>
<SelectTrigger className="bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)]">
<SelectValue />
@@ -334,34 +336,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">主机 <span className="dg-text-red">*</span></Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('agent.agentHost')} <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">端口</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('server.csv.port')}</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">数据库名</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s5ccbbd')}</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">用户名</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('login.username')}</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="密码" value={dbPassword} onChange={setDbPassword}
placeholder="数据库密码" fieldKey="db_pwd" /></div>
<div><FieldWithToggle label=t('login.password') value={dbPassword} onChange={setDbPassword}
placeholder=t('deploy.credformdialog.s58b49c') 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">API 端点</Label>
<Label className="text-[var(--dg-text-secondary)] text-xs">{t('deploy.credformdialog.s48ec34')}</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>
@@ -378,15 +380,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="密钥值" fieldKey="generic_val" required />
placeholder=t('deploy.credformdialog.s63ec0a') fieldKey="generic_val" required />
</div>
)}
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
<Button onClick={handleSubmit} disabled={submitting} className="bg-[var(--dg-blue)] hover:bg-[var(--dg-blue)]/80">
{submitting ? '保存中...' : (isEdit ? '更新' : '创建')}
{submitting ? i18n.t('k8s.topology.saving') : (isEdit ? i18n.t('common.update') : i18n.t('common.create'))}
</Button>
</DialogFooter>
</DialogContent>
@@ -4,15 +4,16 @@ 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: '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 键值对' },
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') },
};
export const TYPE_BADGE_CLASS: Record<SecretType, string> = {
@@ -1,5 +1,6 @@
import { Badge } from '@/components/ui/badge';
import { CheckCircle, XCircle, Loader2 } from 'lucide-react';
import i18n from '@/i18n';
// 流水线阶段定义 // Logan.Li
export interface PipelineStage {
@@ -12,10 +13,10 @@ export interface PipelineStage {
// 流水线模板(新建时快捷选择) // Logan.Li
export const pipelineTemplates = [
{ id: 'template-1', name: 'Node.js 项目', description: '适用于 Node.js 前端项目', stages: ['代码检出', '依赖安装', '代码检查', '构建打包', '部署'] },
{ id: 'template-2', name: 'Go 项目', description: '适用于 Go 后端项目', stages: ['代码检出', '单元测试', '构建', '构建镜像', '部署'] },
{ id: 'template-3', name: 'Java 项目', description: '适用于 Java Spring Boot 项目', stages: ['代码检出', 'Maven构建', '单元测试', '打包', '部署'] },
{ id: 'template-4', name: 'Python 项目', description: '适用于 Python 项目', stages: ['代码检出', '依赖安装', '代码检查', '测试', '部署'] },
{ 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')] },
];
// 解析 stages 字段(兼容 JSON 数组 / 逗号分隔字符串) // Logan.Li
@@ -84,8 +85,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">跳过</span>;
return <span className="text-[var(--dg-text-secondary)] text-xs">{i18n.t('deploy.pipelineHelpers.s92636e')}</span>;
default:
return <span className="text-[var(--dg-text-secondary)] opacity-60 text-xs">待执行</span>;
return <span className="text-[var(--dg-text-secondary)] opacity-60 text-xs">{i18n.t('deploy.status.pending')}</span>;
}
}
@@ -8,6 +8,7 @@ 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';
@@ -49,6 +50,7 @@ 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[]>([]);
@@ -148,8 +150,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: '主机发布', icon: Server },
{ key: 'k8s' as const, label: 'Kubernetes 发布', icon: Network },
{ key: 'ssh' as const, label: t('deploy.deploytargetfields.secf819'), icon: Server },
{ key: 'k8s' as const, label: t('deploy.deploytargetfields.s7ff062'), icon: Network },
]).map((item) => {
const active = value.deployTarget === item.key;
const Icon = item.icon;
@@ -199,7 +201,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
<Input
value={serverSearch}
onChange={(e) => setServerSearch(e.target.value)}
placeholder="搜索主机名或 IP"
placeholder=t('deploy.deploytargetfields.sc76249')
className="pl-8 h-8 text-xs dg-input"
/>
</div>
@@ -209,7 +211,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">暂无可用主机</p>
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8">{t('deploy.deploytargetfields.sb42401')}</p>
) : (
<div className="p-1.5 space-y-1">
{filteredServers.map((srv) => {
@@ -241,10 +243,10 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
</div>
)}
</ScrollArea>
<p className="text-[10px] text-[var(--dg-text-muted)]">构建完成后 SCP 上传 jar/tar.gz 到远程目录并执行部署命令</p>
<p className="text-[10px] text-[var(--dg-text-muted)]">{t('deploy.deploytargetfields.sb2b6db')}</p>
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<Label className="text-xs">远程目录</Label>
<Label className="text-xs">{t('deploy.deploytargetfields.s5ca05a')}</Label>
<Input
value={value.deployRemotePath}
onChange={(e) => onChange({ deployRemotePath: e.target.value })}
@@ -253,7 +255,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
/>
</div>
<div className="space-y-1">
<Label className="text-xs">部署命令</Label>
<Label className="text-xs">{t('deploy.deploytargetfields.s65b20b')}</Label>
<Input
value={value.deployScript}
onChange={(e) => onChange({ deployScript: e.target.value })}
@@ -280,10 +282,10 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
})}
>
<SelectTrigger className="h-8 text-xs dg-input">
<SelectValue placeholder="选择集群" />
<SelectValue placeholder=t('k8s.topology.selectCluster') />
</SelectTrigger>
<SelectContent>
<SelectItem value="__none">请选择</SelectItem>
<SelectItem value="__none">{t('common.selectPlaceholder')}</SelectItem>
{clusters.map((c) => (
<SelectItem key={c.id} value={String(c.id)}>{c.name}</SelectItem>
))}
@@ -291,11 +293,11 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">已选 Deployment</Label>
<Label className="text-xs">{t('deploy.deploytargetfields.s806e4e')}</Label>
<Input
readOnly
value={value.k8sDeployment ? `${value.k8sNamespace}/${value.k8sDeployment}` : ''}
placeholder="从下方列表选择"
placeholder=t('deploy.deploytargetfields.sbb1671')
className="h-8 text-xs dg-input font-mono"
/>
</div>
@@ -307,7 +309,7 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
<Input
value={depSearch}
onChange={(e) => setDepSearch(e.target.value)}
placeholder="搜索 Deployment 或命名空间"
placeholder=t('deploy.deploytargetfields.saa98b2')
className="pl-8 h-8 text-xs dg-input"
/>
</div>
@@ -317,7 +319,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">暂无 Deployment</p>
<p className="text-xs text-[var(--dg-text-muted)] text-center py-8">{t('deploy.deploytargetfields.s5689d3')}</p>
) : (
<div className="p-1.5 space-y-1">
{filteredDeployments.map((dep) => {
@@ -348,11 +350,11 @@ export function DeployTargetFields({ value, onChange, requireTarget = true }: De
)}
</ScrollArea>
<div className="space-y-1">
<Label className="text-xs">容器名 <span className="text-[var(--dg-text-muted)]">(多容器时填写)</span></Label>
<Label className="text-xs">{t('kubernetes.pod.containerName')} <span className="text-[var(--dg-text-muted)]">{t('deploy.deploytargetfields.s7f5a1e')}</span></Label>
<Input
value={value.k8sContainerName}
onChange={(e) => onChange({ k8sContainerName: e.target.value })}
placeholder="默认首个容器"
placeholder=t('deploy.deploytargetfields.s389076')
className="h-8 text-xs font-mono dg-input"
/>
</div>
@@ -13,6 +13,7 @@ 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>;
@@ -23,6 +24,7 @@ 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;
@@ -49,10 +51,10 @@ function PipelineQueuePanel({ pipeline }: { pipeline: Record<string, unknown> })
const handleCancel = async (runId: number) => {
try {
await pipelineApi.removeFromQueue(code, runId);
toast.success('已取消');
toast.success(t('deploy.toast.cancelled'));
loadQueue();
} catch {
toast.error('取消失败');
toast.error(t('deploy.toast.cancelFailed'));
}
};
@@ -68,7 +70,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)]">构建执行状态</span>
<span className="text-[var(--dg-text-secondary)]">{t('deploy.run.buildStatus')}</span>
<span className="font-bold text-[var(--dg-text-primary)]">{running}/{max}</span>
{queued > 0 && (
<span className="text-[var(--dg-text-secondary)] ml-2">
@@ -116,6 +118,7 @@ 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[]>([]);
@@ -141,17 +144,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">暂无执行记录</div>
<div className="py-6 text-center text-[var(--dg-text-secondary)] text-sm">{t('pipeline.noRecords')}</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)]">状态</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>
<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>
</tr>
</thead>
<tbody>
@@ -217,7 +220,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">暂无日志</div>
<div className="dg-text-muted py-4 text-center">{t('pipeline.noLog')}</div>
) : (
logs.map((l, i) => (
<div key={i} className="flex gap-2 py-0.5">
@@ -1,4 +1,5 @@
import {
import i18n from '@/i18n';
GitBranch,
Box,
CheckCircle,
@@ -9,12 +10,12 @@ import {
// 流水线阶段定义(固定顺序) // Logan.Li
export const STAGE_DEFS = [
{ 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: '部署后健康检查' },
{ 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') },
].sort((a, b) => a.order - b.order);
// 运行日志条目 // Logan.Li
@@ -31,7 +32,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 ? `${m}分${sec}秒` : `${m}分`;
return sec > 0 ? i18n.t('deploy.constants.s3ae00c', { m: m, sec: sec }) : i18n.t('deploy.constants.s45c7e1', { m: m });
}
// 解析 stages 字段 // Logan.Li