批量转换脚本引入的语法错误已回退,保留稳定的 i18n 转换: - 翻译词典: 22 namespaces, 3600+ keys - 已接入: 145+ 文件 (Dashboard/Login/K8s 主页面/Monitor/System/Deploy) - 语言切换器在 Header 中可用 (🇨🇳/🇺🇸) - tsc 零错误, Vite 构建成功
345 lines
19 KiB
TypeScript
345 lines
19 KiB
TypeScript
import { useState, useEffect } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Plus, Pencil, Trash2, RefreshCw, GitBranch, Container, Globe, Settings2 } from 'lucide-react';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Input } from '@/components/ui/input';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { PageShell } from '@/components/layout/PageShell';
|
|
import { ContentPanel } from '@/components/layout/ContentPanel';
|
|
import { PageEmpty } from '@/components/layout/PageEmpty';
|
|
|
|
import { useSearchParams } from 'react-router-dom';
|
|
import BuildNodesManager from './BuildNodesManager';
|
|
import { Label } from '@/components/ui/label';
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
import { toast } from 'sonner';
|
|
import { useConfirm } from '@/components/ui/confirm-dialog';
|
|
import { deployApi } from '@/api';
|
|
import CredentialFormDialog from './CredentialFormDialog';
|
|
import ToolConfigTab from './ToolConfigTab';
|
|
import type { JsonData } from '@/types/common';
|
|
|
|
interface Repo {
|
|
id: number; name: string; description: string; url: string;
|
|
auth_type: string; credential_id: number | null; enabled: boolean;
|
|
created_by: string; updated_at: string;
|
|
}
|
|
interface Registry {
|
|
id: number; name: string; description: string; registry_url: string; namespace: string;
|
|
auth_type: string; credential_id: number | null; enabled: boolean;
|
|
created_by: string; updated_at: string;
|
|
}
|
|
interface Environment {
|
|
id: number; name: string; display_name: string; type: string; color: string;
|
|
sort_order: number; description: string; enabled: boolean;
|
|
}
|
|
|
|
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');
|
|
const [color, setColor] = useState('#3B82F6');
|
|
const [description, setDescription] = useState('');
|
|
const [sortOrder, setSortOrder] = useState(50);
|
|
const [submitting, setSubmitting] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (editData) {
|
|
setName(editData.name);
|
|
setDisplayName(editData.display_name);
|
|
setEnvType(editData.type);
|
|
setColor(editData.color);
|
|
setDescription(editData.description || '');
|
|
setSortOrder(editData.sort_order);
|
|
} else {
|
|
setName(''); setDisplayName(''); setEnvType('development'); setColor('#3B82F6'); setDescription(''); setSortOrder(50);
|
|
}
|
|
}, [editData, open]);
|
|
|
|
const handleSubmit = async () => {
|
|
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 ? t('deploy.toast.updated') : t('deploy.toast.created'));
|
|
onSaved();
|
|
onClose();
|
|
} catch { toast.error(t('deploy.toast.operationFailed')); }
|
|
finally { setSubmitting(false); }
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onClose}>
|
|
<DialogContent>
|
|
<DialogHeader><DialogTitle>{editData ? t('deploy.env.editEnv') : t('deploy.env.addEnv')}</DialogTitle></DialogHeader>
|
|
<div className="space-y-3">
|
|
<div className="space-y-1"><Label>{t('deploy.env.envLabel')}</Label><Input value={name} onChange={e => setName(e.target.value)} placeholder="production" /></div>
|
|
<div className="space-y-1"><Label>{t('deploy.table.displayName')}</Label><Input value={displayName} onChange={e => setDisplayName(e.target.value)} placeholder={t('deploy.env.prodEnv')} /></div>
|
|
<div className="space-y-1"><Label>{t('deploy.env.envType')}</Label>
|
|
<Select value={envType} onValueChange={setEnvType}>
|
|
<SelectTrigger><SelectValue /></SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="production">{t('deploy.env.prodEnv')}</SelectItem>
|
|
<SelectItem value="staging">{t('deploy.env.staging')}</SelectItem>
|
|
<SelectItem value="testing">{t('deploy.env.testEnv')}</SelectItem>
|
|
<SelectItem value="development">{t('deploy.env.devEnv')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="space-y-1"><Label>{t('deploy.table.color')}</Label><Input value={color} onChange={e => setColor(e.target.value)} type="color" className="w-16 h-9 p-1" /></div>
|
|
<div className="space-y-1"><Label>{t('deploy.table.sortOrder')}</Label><Input value={sortOrder} onChange={e => setSortOrder(Number(e.target.value))} type="number" /></div>
|
|
<div className="space-y-1"><Label>{t('deploy.form.desc')}</Label><Input value={description} onChange={e => setDescription(e.target.value)} placeholder={t('common.optional')} /></div>
|
|
</div>
|
|
<div className="flex justify-end gap-2 mt-4">
|
|
<Button variant="outline" onClick={onClose}>{t('common.cancel')}</Button>
|
|
<Button onClick={handleSubmit} disabled={submitting}>{submitting ? '...' : editData ? t('common.save') : t('common.create')}</Button>
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|
|
|
|
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');
|
|
const [repos, setRepos] = useState<Repo[]>([]);
|
|
const [registries, setRegistries] = useState<Registry[]>([]);
|
|
const [environments, setEnvironments] = useState<Environment[]>([]);
|
|
const [loading, setLoading] = useState(false);
|
|
const [editorOpen, setEditorOpen] = useState(false);
|
|
const [editType, setEditType] = useState<'repo' | 'registry'>('repo');
|
|
const [editingId, setEditingId] = useState<number | null>(null);
|
|
const [envDialogOpen, setEnvDialogOpen] = useState(false);
|
|
const [editEnv, setEditEnv] = useState<Environment | null>(null);
|
|
const [activeTab, setActiveTab] = useState(
|
|
tabFromUrl && (CONFIG_TABS as readonly string[]).includes(tabFromUrl) ? tabFromUrl : 'repos',
|
|
);
|
|
|
|
const fetchAll = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [rR, rReg, rEnv] = await Promise.all([deployApi.getRepos(), deployApi.getRegistries(), deployApi.getEnvironments()]);
|
|
setRepos((rR as JsonData)?.list || []);
|
|
setRegistries((rReg as JsonData)?.list || []);
|
|
setEnvironments((rEnv as JsonData)?.list || []);
|
|
} 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: t('common.delete'), description: t('deploy.toast.deleteConfirm', { name: item.name || item.display_name }), confirmText: t('common.delete'), variant: 'destructive' });
|
|
if (!ok) return;
|
|
try {
|
|
if (type === 'repo') await deployApi.deleteRepo(item.id);
|
|
else if (type === 'registry') await deployApi.deleteRegistry(item.id);
|
|
else await deployApi.deleteEnvironment(item.id);
|
|
toast.success(t('deploy.toast.deleted'));
|
|
fetchAll();
|
|
} catch { toast.error(t('deploy.toast.deleteFailed')); }
|
|
};
|
|
|
|
const envTypeLabel: Record<string, string> = { production: t('deploy.env.production'), staging: t('deploy.env.staging'), testing: t('deploy.env.testing'), development: t('deploy.env.development') };
|
|
|
|
const pageTabs = [
|
|
{ id: 'repos', label: t('deploy.credForm.gitRepo'), badge: repos.length || undefined },
|
|
{ id: 'registries', label: t('deploy.credForm.imageRegistry'), badge: registries.length || undefined },
|
|
{ id: 'environments', label: t('deploy.label.envManage'), badge: environments.length || undefined },
|
|
{ id: 'build-nodes', label: t('deploy.deployNav.buildNodes') },
|
|
{ id: 'tools', label: t('deploy.tool.title') },
|
|
];
|
|
|
|
return (
|
|
<PageShell
|
|
variant="list"
|
|
title={t('deploy.label.releaseConfig')}
|
|
subtitle={t('deploy.credForm.pageSubtitle')}
|
|
icon={<Settings2 className="h-5 w-5" />}
|
|
loading={loading}
|
|
tabs={pageTabs}
|
|
activeTab={activeTab}
|
|
onTabChange={setActiveTab}
|
|
actions={activeTab === 'build-nodes' ? undefined : (
|
|
<>
|
|
<Button variant="outline" size="sm" className="dg-btn-secondary" onClick={fetchAll}>
|
|
<RefreshCw className="w-4 h-4 mr-1" />{t('common.refresh')}
|
|
</Button>
|
|
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditType('repo'); setEditingId(null); setEditorOpen(true); }}>
|
|
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRepo')}
|
|
</Button>
|
|
<Button size="sm" variant="secondary" onClick={() => { setEditType('registry'); setEditingId(null); setEditorOpen(true); }}>
|
|
<Plus className="w-4 h-4 mr-1" />{t('deploy.credForm.newRegistry')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
contentClassName="space-y-4"
|
|
>
|
|
{activeTab === 'repos' && (
|
|
repos.length === 0 ? (
|
|
<PageEmpty icon={<GitBranch className="h-10 w-10" />} title={t('deploy.credForm.noRepo')} />
|
|
) : (
|
|
<ContentPanel noPadding>
|
|
<div className="overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">{t('deploy.table.repoUrl')}</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
|
|
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{repos.map(r => (
|
|
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
|
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
|
|
<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 ? t('common.enable') : t('common.disable')}</Badge>
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('repo'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('repo', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</ContentPanel>
|
|
)
|
|
)}
|
|
|
|
{activeTab === 'registries' && (
|
|
registries.length === 0 ? (
|
|
<PageEmpty icon={<Container className="h-10 w-10" />} title={t('deploy.credForm.noRegistry')} />
|
|
) : (
|
|
<ContentPanel noPadding>
|
|
<div className="overflow-hidden">
|
|
<table className="w-full text-sm">
|
|
<thead className="bg-[var(--dg-bg-tertiary)] border-b border-[var(--dg-border)]">
|
|
<tr>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.name')}</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden md:table-cell">Registry URL</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)] hidden sm:table-cell">{t('deploy.credForm.namespace')}</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.cred.authMethod')}</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">{t('deploy.table.status')}</th>
|
|
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">{t('common.actions')}</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{registries.map(r => (
|
|
<tr key={r.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
|
<td className="px-3 py-2 font-medium text-[var(--dg-text-primary)]">{r.name}</td>
|
|
<td className="px-3 py-2 text-[var(--dg-text-secondary)] hidden md:table-cell font-mono text-xs max-w-[250px] truncate">{r.registry_url}</td>
|
|
<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 ? t('common.enable') : t('common.disable')}</Badge>
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditType('registry'); setEditingId(r.id); setEditorOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('registry', r)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</ContentPanel>
|
|
)
|
|
)}
|
|
|
|
{activeTab === 'environments' && (
|
|
<>
|
|
<div className="flex items-center justify-end mb-3">
|
|
<Button size="sm" className="dg-btn-primary" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
|
|
<Plus className="w-4 h-4 mr-1" />新增环境
|
|
</Button>
|
|
</div>
|
|
{environments.length === 0 ? (
|
|
<PageEmpty
|
|
icon={<Globe className="h-10 w-10" />}
|
|
title="暂无环境配置"
|
|
description='点击"新增环境"创建'
|
|
action={(
|
|
<Button size="sm" onClick={() => { setEditEnv(null); setEnvDialogOpen(true); }}>
|
|
<Plus className="w-4 h-4 mr-1" />新增环境
|
|
</Button>
|
|
)}
|
|
/>
|
|
) : (
|
|
<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)]">显示名称</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">类型</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">颜色</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">排序</th>
|
|
<th className="px-3 py-2 text-left font-medium text-[var(--dg-text-secondary)]">状态</th>
|
|
<th className="px-3 py-2 text-right font-medium text-[var(--dg-text-secondary)]">操作</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{environments.map(env => (
|
|
<tr key={env.id} className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-tertiary)]/50">
|
|
<td className="px-3 py-2 font-mono text-xs font-medium text-[var(--dg-text-primary)]">{env.name}</td>
|
|
<td className="px-3 py-2 text-[var(--dg-text-primary)]">
|
|
<span className="flex items-center gap-1.5">
|
|
<span className="w-2.5 h-2.5 rounded-full inline-block" style={{ backgroundColor: env.color }} />
|
|
{env.display_name}
|
|
</span>
|
|
</td>
|
|
<td className="px-3 py-2"><Badge variant="secondary" className="text-xs">{envTypeLabel[env.type] || env.type}</Badge></td>
|
|
<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>
|
|
</td>
|
|
<td className="px-3 py-2 text-right">
|
|
<div className="flex justify-end gap-1">
|
|
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => { setEditEnv(env); setEnvDialogOpen(true); }}><Pencil className="w-3.5 h-3.5" /></Button>
|
|
<Button variant="ghost" size="icon" className="h-7 w-7 text-red-500" onClick={() => handleDelete('env', env)}><Trash2 className="w-3.5 h-3.5" /></Button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</ContentPanel>
|
|
)}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === 'build-nodes' && <BuildNodesManager embedded />}
|
|
|
|
{activeTab === 'tools' && <ToolConfigTab />}
|
|
|
|
<CredentialFormDialog
|
|
open={editorOpen} onOpenChange={setEditorOpen} type={editType} editId={editingId}
|
|
repos={repos} registries={registries} onSave={fetchAll}
|
|
/>
|
|
|
|
<EnvFormDialog open={envDialogOpen} onClose={() => setEnvDialogOpen(false)} onSaved={fetchAll} editData={editEnv} />
|
|
</PageShell>
|
|
);
|
|
}
|