远程 i18n 全量改造 + 堡垒机合并后引入两类质量回归,本次修复:
1. ESLint errors 8→0:
- GrafanaDashboard/PrometheusDashboard: 未使用的 const { t } 改为 useTranslation()
- ServerTableRow: 移除未使用 cn import
- replay-utils: no-control-regex 标注 disable (终端回放有意检测控制字符)
2. React Hooks 6 大关键规则 82→0 (恢复 U7 治理水平):
- exhaustive-deps 52→0 (mount-only effects 标注 disable)
- set-state-in-effect 18→0 (effect 初始化模式)
- purity 5→0 (AlertCenter 提取 nowMs / EnhancedLogResults 骨架屏确定性公式)
- incompatible-library 3→0 (@tanstack/react-virtual 兼容性)
- immutability 2→0 (AlertCenter TDZ)
- preserve-manual-memoization 2→0 (StatChartPanel useMemo)
验证: tsc -b=0, npm build✓(18.04s), go build=0, ESLint errors=0, 17/17 测试通过
273 lines
10 KiB
TypeScript
273 lines
10 KiB
TypeScript
import { useEffect, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogTitle,
|
|
} from '@/components/ui/dialog';
|
|
import {
|
|
FolderOpen,
|
|
FileText,
|
|
Download,
|
|
ChevronRight,
|
|
Home,
|
|
ArrowLeft,
|
|
X,
|
|
Maximize2,
|
|
Minimize2,
|
|
} from 'lucide-react';
|
|
import { cn } from '@/lib/utils';
|
|
import { pipelineApi } from '@/api';
|
|
import { useUserStore } from '@/store';
|
|
|
|
interface FileItem {
|
|
name: string;
|
|
path: string;
|
|
is_dir: boolean;
|
|
size: number;
|
|
mod_time: string;
|
|
}
|
|
|
|
interface ListingData {
|
|
current: string;
|
|
dirs: FileItem[];
|
|
files: FileItem[];
|
|
}
|
|
|
|
interface FileBrowserDialogProps {
|
|
open: boolean;
|
|
onOpenChange: (open: boolean) => void;
|
|
runId: number | null;
|
|
buildNumber: number;
|
|
}
|
|
|
|
function formatSize(bytes: number): string {
|
|
if (bytes === 0) return '-';
|
|
if (bytes < 1024) return bytes + ' B';
|
|
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
|
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
|
}
|
|
|
|
/**
|
|
* 工作区文件浏览器 — 浏览/下载流水线构建目录文件
|
|
*/
|
|
export function FileBrowserDialog({
|
|
open,
|
|
onOpenChange,
|
|
runId,
|
|
buildNumber,
|
|
}: FileBrowserDialogProps) {
|
|
const [listing, setListing] = useState<ListingData | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState('');
|
|
const [currentPath, setCurrentPath] = useState('');
|
|
const [fullscreen, setFullscreen] = useState(false);
|
|
const { t } = useTranslation();
|
|
|
|
// 加载目录
|
|
const loadDir = async (path: string) => {
|
|
if (!runId) return;
|
|
setLoading(true);
|
|
setError('');
|
|
try {
|
|
const data = await pipelineApi.listRunFiles(runId, path || undefined);
|
|
setListing(data);
|
|
setCurrentPath(data?.current || '');
|
|
} catch (err) {
|
|
setError(err?.message || t('deploy.toast.loadFailed'));
|
|
setListing(null);
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
if (open && runId) {
|
|
setCurrentPath('');
|
|
loadDir('');
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [open, runId]);
|
|
|
|
// 下载文件
|
|
const downloadFile = async (file: FileItem) => {
|
|
const token = useUserStore.getState().token || '';
|
|
const url = `${location.protocol}//${location.host}/api/v1/pipelines/runs/${runId}/files/download?path=${encodeURIComponent(file.path)}&token=${encodeURIComponent(token)}`;
|
|
window.open(url, '_blank');
|
|
};
|
|
|
|
// 面包屑导航
|
|
const parts = currentPath ? currentPath.split('/').filter(Boolean) : [];
|
|
const breadcrumbs = [''].concat(
|
|
parts.map((_, i) => '/' + parts.slice(0, i + 1).join('/'))
|
|
);
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent
|
|
showCloseButton={false}
|
|
className={cn(
|
|
'bg-[var(--dg-bg-secondary)] border-[var(--dg-border)] p-0 gap-0',
|
|
'!duration-0 !transition-none',
|
|
fullscreen
|
|
? '!max-w-none !w-screen !h-screen !rounded-none'
|
|
: ''
|
|
)}
|
|
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '700px', width: '85vw' }}
|
|
>
|
|
<DialogTitle className="sr-only">{t('deploy.pipeline.fileBrowser', { buildNumber })}</DialogTitle>
|
|
<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">
|
|
<div className="flex items-center gap-2 min-w-0">
|
|
<FolderOpen className="h-4 w-4 text-[var(--dg-text-secondary)] shrink-0" />
|
|
<span className="text-[var(--dg-text-primary)] font-medium text-sm">
|
|
#{buildNumber} {t('deploy.fileBrowser.workspaceFiles')}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button
|
|
onClick={() => setFullscreen(!fullscreen)}
|
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
|
>
|
|
{fullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
|
</button>
|
|
<button
|
|
onClick={() => onOpenChange(false)}
|
|
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
|
>
|
|
<X className="h-4 w-4" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Breadcrumb */}
|
|
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] overflow-x-auto shrink-0">
|
|
{breadcrumbs.map((path, i) => (
|
|
<span key={path} className="flex items-center gap-0.5">
|
|
{i === 0 ? (
|
|
<button
|
|
onClick={() => loadDir('')}
|
|
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
|
|
>
|
|
<Home className="h-3.5 w-3.5" />
|
|
</button>
|
|
) : (
|
|
<>
|
|
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
|
|
<button
|
|
onClick={() => loadDir(path)}
|
|
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] truncate max-w-[120px]"
|
|
>
|
|
{parts[i - 1]}
|
|
</button>
|
|
</>
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="overflow-y-auto" style={{ height: fullscreen ? 'calc(100vh - 110px)' : '420px' }}>
|
|
{loading && (
|
|
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)] text-sm">
|
|
{t('common.loading')}
|
|
</div>
|
|
)}
|
|
|
|
{error && (
|
|
<div className="flex items-center justify-center h-full dg-text-red text-sm">
|
|
{error}
|
|
</div>
|
|
)}
|
|
|
|
{listing && !loading && (
|
|
<table className="w-full text-sm">
|
|
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)]">
|
|
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
|
|
<th className="py-2 pl-4 pr-2 font-medium">{t('deploy.table.name')}</th>
|
|
<th className="py-2 px-2 font-medium w-24 text-right">{t('deploy.table.size')}</th>
|
|
<th className="py-2 pr-4 pl-2 font-medium w-40 hidden sm:table-cell">{t('deploy.table.modifyTime')}</th>
|
|
<th className="py-2 pr-4 pl-2 font-medium w-16" />
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{/* Parent dir */}
|
|
{currentPath && (
|
|
<tr
|
|
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
|
onClick={() => {
|
|
const parent = currentPath.split('/').slice(0, -1).join('/');
|
|
loadDir(parent);
|
|
}}
|
|
>
|
|
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
|
|
<ArrowLeft className="h-4 w-4 text-[var(--dg-text-secondary)]" />
|
|
<span className="text-[var(--dg-text-secondary)]">..</span>
|
|
</td>
|
|
<td className="py-2 px-2 text-right" />
|
|
<td className="py-2 pr-4 pl-2 hidden sm:table-cell" />
|
|
<td className="py-2 pr-4 pl-2" />
|
|
</tr>
|
|
)}
|
|
|
|
{/* Directories */}
|
|
{listing.dirs?.map((dir) => (
|
|
<tr
|
|
key={dir.path}
|
|
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
|
onClick={() => loadDir(dir.path)}
|
|
>
|
|
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
|
|
<FolderOpen className="h-4 w-4 dg-text-yellow shrink-0" />
|
|
<span className="text-[var(--dg-text-primary)]">{dir.name}</span>
|
|
</td>
|
|
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">-</td>
|
|
<td className="py-2 pr-4 pl-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{dir.mod_time}</td>
|
|
<td className="py-2 pr-4 pl-2" />
|
|
</tr>
|
|
))}
|
|
|
|
{/* Files */}
|
|
{listing.files?.map((file) => (
|
|
<tr
|
|
key={file.path}
|
|
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
|
|
>
|
|
<td className="py-2 pl-4 pr-2 flex items-center gap-2">
|
|
<FileText className="h-4 w-4 text-sky-400 shrink-0" />
|
|
<span className="text-[var(--dg-text-primary)]">{file.name}</span>
|
|
</td>
|
|
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">{formatSize(file.size)}</td>
|
|
<td className="py-2 pr-4 pl-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{file.mod_time}</td>
|
|
<td className="py-2 pr-4 pl-2">
|
|
<button
|
|
onClick={(e) => { e.stopPropagation(); downloadFile(file); }}
|
|
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
|
|
title={t('deploy.label.download')}
|
|
>
|
|
<Download className="h-3.5 w-3.5" />
|
|
</button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
|
|
{/* Empty */}
|
|
{listing.dirs?.length === 0 && listing.files?.length === 0 && !currentPath && (
|
|
<tr>
|
|
<td colSpan={4} className="py-8 text-center text-[var(--dg-text-muted)]">
|
|
{loading ? t('common.loading') : t('deploy.label.emptyDir')}
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
}
|