Files
aiops/frontend/src/components/deploy/FileBrowserDialog.tsx
T
attacker da9fd1e987 feat(quality): U12 工程质量回归修复 — ESLint errors(8→0) + React Hooks 关键规则(82→0)
远程 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 测试通过
2026-07-06 00:55:01 +08:00

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>
);
}