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

224 lines
12 KiB
TypeScript

import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Plus, Pencil, Trash2, RefreshCw, Key, Shield, Search,
Clock, CheckCircle2, XCircle, Zap,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { toast } from 'sonner';
import { useConfirm } from '@/components/ui/confirm-dialog';
import { useCredentials, useCredentialMutations } from '@/hooks/api';
import type { Credential, SecretType } from './credential-manager/types';
import { TYPE_CONFIG, TYPE_BADGE_CLASS } from './credential-manager/constants';
import { CredDetailDialog } from './credential-manager/CredDetailDialog';
import { CredFormDialog } from './credential-manager/CredFormDialog';
// 凭据管理页编排器 // Logan.Li
export default function CredentialManager() {
const { t } = useTranslation();
const confirm = useConfirm();
const [dialogOpen, setDialogOpen] = useState(false);
const [editing, setEditing] = useState<Credential | null>(null);
const [detailId, setDetailId] = useState<number | null>(null);
const [detailOpen, setDetailOpen] = useState(false);
// 筛选条件 // Logan.Li
const [filterType, setFilterType] = useState('all');
const [filterSearch, setFilterSearch] = useState('');
const listParams = {
page_size: 100,
...(filterSearch ? { search: filterSearch } : {}),
...(filterType && filterType !== 'all' ? { secret_type: filterType } : {}),
};
const { data: credentials = [], isLoading: loading, refetch } = useCredentials(listParams);
const { deleteMutation, toggleActiveMutation } = useCredentialMutations();
const handleDelete = async (cred: Credential) => {
const ok = await confirm({
title: t('deploy.cred.deleteCred'),
description: t('deploy.pipeline.deleteConfirm', { name: cred.name }),
confirmText: t('common.delete'),
variant: 'destructive',
});
if (!ok) return;
try {
await deleteMutation.mutateAsync(cred.id);
toast.success(t('deploy.toast.deleted'));
} catch { toast.error(t('deploy.toast.deleteFailed')); }
};
const handleToggleActive = async (cred: Credential) => {
try {
await toggleActiveMutation.mutateAsync({ id: cred.id, isActive: !cred.is_active });
toast.success(cred.is_active ? t('deploy.toast.disabled') : t('deploy.toast.enabled'));
} catch { toast.error(t('deploy.toast.operationFailed')); }
};
const handleCopyPrefix = (prefix: string) => {
navigator.clipboard.writeText(prefix);
toast.success(t('deploy.cred.refIdCopied'));
};
const typeOptions = Object.entries(TYPE_CONFIG) as [SecretType, typeof TYPE_CONFIG[SecretType]][];
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold text-[var(--dg-text-primary)]">{t('deploy.cred.title')}</h2>
<p className="text-sm text-[var(--dg-text-muted)] mt-0.5">
{t('deploy.cred.subtitle')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => refetch()}>
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
</Button>
<Button size="sm" onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.cred.addCredential')}
</Button>
</div>
</div>
{/* 搜索与筛选 // Logan.Li */}
<div className="flex items-center gap-3 flex-wrap">
<Select value={filterType} onValueChange={setFilterType}>
<SelectTrigger className="w-[140px] bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm">
<SelectValue placeholder={t('deploy.label.allType')} />
</SelectTrigger>
<SelectContent>
<SelectItem value="all">{t('deploy.label.allType')}</SelectItem>
{typeOptions.map(([k, v]) => (
<SelectItem key={k} value={k}>
<span className="flex items-center gap-2"><v.icon className="h-3.5 w-3.5" />{v.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
<div className="relative flex-1 max-w-xs">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-[var(--dg-text-muted)]" />
<Input value={filterSearch} onChange={e => setFilterSearch(e.target.value)}
placeholder={t('deploy.label.searchPlaceholder')}
className="pl-8 bg-[var(--dg-bg-tertiary)] border-[var(--dg-border)] text-[var(--dg-text-primary)] h-9 text-sm" />
</div>
<span className="text-xs text-[var(--dg-text-muted)]">{t('deploy.cred.credCount', { count: credentials.length })}</span>
</div>
{loading ? (
<div className="text-center py-16 text-[var(--dg-text-secondary)]">
<Zap className="w-6 h-6 animate-spin mx-auto mb-2 text-[var(--dg-blue)]" />{t('deploy.label.loading')}
</div>
) : credentials.length === 0 ? (
<div className="text-center py-16 border border-dashed border-[var(--dg-border)] rounded-lg">
<Shield className="w-12 h-12 mx-auto mb-3 text-[var(--dg-text-muted)]" />
<p className="text-[var(--dg-text-secondary)] mb-2">{t('deploy.cred.noCredentials')}</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">{t('deploy.cred.subtitle')}</p>
<Button onClick={() => { setEditing(null); setDialogOpen(true); }}>
<Plus className="w-4 h-4 mr-1" />{t('deploy.cred.createFirst')}
</Button>
</div>
) : (
<div className="rounded-md border border-[var(--dg-border)] overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
<tr>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.type')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('deploy.table.repoUrl')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden lg:table-cell">{t('deploy.table.tagLabel')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)] hidden xl:table-cell">{t('deploy.cred.refId')}</th>
<th className="px-3 py-2.5 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
<th className="px-3 py-2.5 text-right font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.actions')}</th>
</tr>
</thead>
<tbody>
{credentials.map(cred => {
const st = cred.secret_type as SecretType;
const cfg = TYPE_CONFIG[st];
const isExpired = cred.expires_at && new Date(cred.expires_at) < new Date();
return (
<tr key={cred.id}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50 cursor-pointer"
onClick={() => { setDetailId(cred.id); setDetailOpen(true); }}>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
{cfg ? <cfg.icon className={`h-4 w-4 ${cfg.color} shrink-0`} /> : <Key className="h-4 w-4 text-[var(--dg-text-muted)] shrink-0" />}
<div>
<div className="font-medium text-[var(--dg-text-primary)]">{cred.name}</div>
{cred.variable_name && (
<div className="text-xs text-[var(--dg-text-muted)] font-mono">{cred.variable_name}</div>
)}
</div>
</div>
</td>
<td className="px-3 py-2.5">
<Badge variant="outline" className={`text-xs ${TYPE_BADGE_CLASS[st] || ''}`}>
{cfg?.label || st}
</Badge>
</td>
<td className="px-3 py-2.5 text-[var(--dg-text-secondary)] text-xs hidden md:table-cell max-w-[180px] truncate font-mono">
{cred.summary || '-'}
</td>
<td className="px-3 py-2.5 hidden lg:table-cell">
<div className="flex gap-1 flex-wrap">
{(cred.tags || []).slice(0, 3).map(t => (
<Badge key={t} variant="outline" className="text-xs border-[var(--dg-border)] text-[var(--dg-text-muted)]">{t}</Badge>
))}
{(cred.tags || []).length > 3 && (
<span className="text-xs text-[var(--dg-text-muted)]">+{cred.tags.length - 3}</span>
)}
</div>
</td>
<td className="px-3 py-2.5 hidden xl:table-cell">
<code className="text-xs text-[var(--dg-blue)] cursor-pointer font-mono"
onClick={(e) => { e.stopPropagation(); handleCopyPrefix(cred.env_prefix); }}>
{cred.env_prefix}
</code>
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-1">
{cred.is_active ? (
<Badge className="bg-green-500/20 text-green-400 text-xs border-0">{t('common.enable')}</Badge>
) : (
<Badge className="bg-red-500/20 text-red-400 text-xs border-0">{t('common.disable')}</Badge>
)}
{isExpired && (
<Badge className="bg-yellow-500/20 text-yellow-400 text-xs border-0"><Clock className="h-2.5 w-2.5 mr-0.5" />{t('deploy.status.expired')}</Badge>
)}
</div>
</td>
<td className="px-3 py-2.5 text-right" onClick={(e) => e.stopPropagation()}>
<div className="flex justify-end gap-1">
<Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => { setEditing(cred as Credential); setDialogOpen(true); }} title={t('common.edit')}>
<Pencil className="w-3.5 h-3.5" />
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7"
onClick={() => handleToggleActive(cred as Credential)}
title={cred.is_active ? t('common.disable') : t('common.enable')}>
{cred.is_active ? <XCircle className="w-3.5 h-3.5 text-yellow-500" /> : <CheckCircle2 className="w-3.5 h-3.5 text-green-500" />}
</Button>
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500"
onClick={() => handleDelete(cred as Credential)} title={t('common.delete')}>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<CredFormDialog open={dialogOpen} onClose={() => setDialogOpen(false)} onSaved={() => refetch()} editData={editing} />
<CredDetailDialog open={detailOpen} onClose={() => setDetailOpen(false)} credId={detailId} />
</div>
);
}