Files
aiops/frontend/src/components/deploy/FileBrowserDialog.tsx
T
attacker ecec73989b 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 编译零错误
2026-07-03 01:51:57 +08:00

272 lines
10 KiB
TypeScript

import { useEffect, useState } from 'react';
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';
import { useTranslation } from 'react-i18next';
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 { t } = useTranslation();
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 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('common.loadFailed'));
setListing(null);
} finally {
setLoading(false);
}
};
useEffect(() => {
if (open && runId) {
setCurrentPath('');
loadDir('');
}
}, [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">文件浏览器 #{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} 工作区文件
</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">
加载中...
</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('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>
<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('common.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>
);
}