fix: K8s 集群添加缺少 code 必填字段
- KubernetesClusterDialog: 新增 code 表单字段 - 名称变更自动生成 code(小写+连字符) - handleSubmit 增加 code 校验 + toast 提示 - payload 增加 code 字段(后端 binding:required)
This commit is contained in:
1 parent
f24fe43ac0
commit
ce438c9f05
18 files changed
+3941
-144
No files matched your search
@@ -0,0 +1,552 @@
|
||||
import { useState, useRef, useEffect, useCallback, useMemo } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Folder, FolderOpen, FileText, FileCode, FileArchive, FileImage,
|
||||
File, Download, Eye, ChevronRight, ArrowLeft, ArrowRight, ArrowUp,
|
||||
RotateCw, Loader2, Maximize2, Minimize2, X, Box, Home, HardDrive,
|
||||
AlertCircle, FileType, Binary,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { kubernetesApi } from '@/services/api';
|
||||
import { useToast } from '@/hooks/use-toast';
|
||||
|
||||
/**
|
||||
* 容器文件浏览器 — 浏览 Pod 内文件系统
|
||||
*
|
||||
* - 调 POST .../pods/:pod/files/list {path:"/"} 获取目录
|
||||
* - 调 GET .../pods/:pod/files/download?path=/xxx 下载文件
|
||||
* - 面包屑导航 + 返回上级
|
||||
* - 文件类型图标
|
||||
* - 下载 / 查看文本(小文件预览)
|
||||
* - 浏览器风格历史栈(前进 / 后退)
|
||||
*
|
||||
* 注:后端文件浏览器 API 并行开发中,UI 与 API 层先行;
|
||||
* 返回结构兼容 {entries:[{name,isDir,size,modTime}]} 或数组。
|
||||
*
|
||||
* @author Logan <admin@attacker.club>
|
||||
*/
|
||||
interface PodFileBrowserProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
clusterCode: string;
|
||||
namespace: string;
|
||||
podName: string;
|
||||
containers?: string[];
|
||||
initialContainer?: string;
|
||||
}
|
||||
|
||||
interface FileEntry {
|
||||
name: string;
|
||||
isDir: boolean;
|
||||
size?: number;
|
||||
modTime?: string;
|
||||
mode?: string;
|
||||
}
|
||||
|
||||
// 文本预览大小阈值(超过则提示下载)
|
||||
const TEXT_PREVIEW_MAX = 256 * 1024; // 256KB
|
||||
// 可预览的扩展名
|
||||
const TEXT_EXTENSIONS = new Set([
|
||||
'.txt', '.log', '.conf', '.cfg', '.ini', '.yaml', '.yml', '.json',
|
||||
'.xml', '.md', '.sh', '.bash', '.env', '.properties', '.toml',
|
||||
'.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.rs', '.c',
|
||||
'.cpp', '.h', '.hpp', '.css', '.html', '.sql', '.csv', '.gitignore',
|
||||
'.dockerfile', '.makefile',
|
||||
]);
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
if (!p) return '/';
|
||||
// 去除重复斜杠
|
||||
let s = p.replace(/\/+/g, '/');
|
||||
if (!s.startsWith('/')) s = '/' + s;
|
||||
if (s.length > 1 && s.endsWith('/')) s = s.slice(0, -1);
|
||||
return s;
|
||||
}
|
||||
|
||||
function joinPath(base: string, name: string): string {
|
||||
return normalizePath(base === '/' ? `/${name}` : `${base}/${name}`);
|
||||
}
|
||||
|
||||
function parentPath(p: string): string {
|
||||
const n = normalizePath(p);
|
||||
if (n === '/') return '/';
|
||||
const idx = n.lastIndexOf('/');
|
||||
return idx <= 0 ? '/' : n.slice(0, idx);
|
||||
}
|
||||
|
||||
function pathSegments(p: string): { name: string; path: string }[] {
|
||||
const n = normalizePath(p);
|
||||
const segs = [{ name: '/', path: '/' }];
|
||||
if (n === '/') return segs;
|
||||
const parts = n.split('/').filter(Boolean);
|
||||
let cur = '';
|
||||
for (const part of parts) {
|
||||
cur = cur === '' ? `/${part}` : `${cur}/${part}`;
|
||||
segs.push({ name: part, path: cur });
|
||||
}
|
||||
return segs;
|
||||
}
|
||||
|
||||
function getExt(name: string): string {
|
||||
const idx = name.lastIndexOf('.');
|
||||
if (idx === -1) return '';
|
||||
// 处理 .gitignore 这类
|
||||
if (idx === 0) return name.toLowerCase();
|
||||
return name.slice(idx).toLowerCase();
|
||||
}
|
||||
|
||||
function getFileIcon(name: string, isDir: boolean) {
|
||||
if (isDir) return Folder;
|
||||
const ext = getExt(name);
|
||||
if (['.yaml', '.yml', '.json', '.xml', '.toml', '.ini', '.conf', '.cfg', '.env', '.properties'].includes(ext))
|
||||
return FileCode;
|
||||
if (['.js', '.ts', '.jsx', '.tsx', '.py', '.go', '.java', '.rs', '.c', '.cpp', '.h', '.hpp', '.sh', '.bash', '.sql'].includes(ext))
|
||||
return FileCode;
|
||||
if (['.log', '.txt', '.md', '.csv'].includes(ext)) return FileText;
|
||||
if (['.tar', '.gz', '.zip', '.tgz', '.bz2', '.xz', '.7z', '.rar'].includes(ext)) return FileArchive;
|
||||
if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.bmp', '.ico'].includes(ext)) return FileImage;
|
||||
if (name.toLowerCase() === 'dockerfile' || ext === '.dockerfile') return FileType;
|
||||
return File;
|
||||
}
|
||||
|
||||
function formatSize(bytes?: number): string {
|
||||
if (bytes === undefined || bytes === null) return '-';
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`;
|
||||
}
|
||||
|
||||
export function PodFileBrowser({
|
||||
open,
|
||||
onOpenChange,
|
||||
clusterCode,
|
||||
namespace,
|
||||
podName,
|
||||
containers = [],
|
||||
initialContainer,
|
||||
}: PodFileBrowserProps) {
|
||||
const { toast } = useToast();
|
||||
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
const [container, setContainer] = useState<string>(initialContainer || containers[0] || '');
|
||||
|
||||
const [entries, setEntries] = useState<FileEntry[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
// 浏览器风格历史栈
|
||||
const [historyStack, setHistoryStack] = useState<string[]>(['/']);
|
||||
const [historyIndex, setHistoryIndex] = useState(0);
|
||||
const currentPath = historyStack[historyIndex] || '/';
|
||||
|
||||
// 预览
|
||||
const [preview, setPreview] = useState<{ path: string; content: string; loading: boolean } | null>(null);
|
||||
|
||||
// 选中的条目(用于高亮)
|
||||
const [selected, setSelected] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setFullscreen(false);
|
||||
}, [open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (open && !container && containers.length > 0) {
|
||||
setContainer(initialContainer || containers[0]);
|
||||
}
|
||||
}, [open, containers, initialContainer, container]);
|
||||
|
||||
// 加载目录
|
||||
const loadPath = useCallback(async (path: string) => {
|
||||
if (!podName) return;
|
||||
const n = normalizePath(path);
|
||||
setLoading(true);
|
||||
setError('');
|
||||
setSelected('');
|
||||
try {
|
||||
const res: any = await kubernetesApi.listPodFiles(clusterCode, namespace, podName, n, container || undefined);
|
||||
// 兼容多种返回结构
|
||||
let arr: any[] = [];
|
||||
if (Array.isArray(res)) arr = res;
|
||||
else if (Array.isArray(res?.entries)) arr = res.entries;
|
||||
else if (Array.isArray(res?.items)) arr = res.items;
|
||||
else if (Array.isArray(res?.files)) arr = res.files;
|
||||
else if (Array.isArray(res?.data)) arr = res.data;
|
||||
else if (res?.data && Array.isArray(res.data.entries)) arr = res.data.entries;
|
||||
|
||||
const list: FileEntry[] = arr.map((it: any) => ({
|
||||
name: it.name ?? it.Name ?? it.path?.split('/').pop() ?? '',
|
||||
isDir: Boolean(it.isDir ?? it.IsDir ?? it.dir ?? it.directory ?? (it.mode?.startsWith('d'))),
|
||||
size: it.size ?? it.Size,
|
||||
modTime: it.modTime ?? it.ModTime ?? it.mtime ?? it.modified,
|
||||
mode: it.mode ?? it.Mode,
|
||||
})).filter((e) => e.name && e.name !== '.');
|
||||
|
||||
// 排序:目录优先,再按名称
|
||||
list.sort((a, b) => {
|
||||
if (a.isDir !== b.isDir) return a.isDir ? -1 : 1;
|
||||
return a.name.localeCompare(b.name);
|
||||
});
|
||||
|
||||
setEntries(list);
|
||||
} catch (e: any) {
|
||||
setError(e?.message || String(e));
|
||||
setEntries([]);
|
||||
// 后端 API 并行开发中,错误时给出友好提示
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [clusterCode, namespace, podName, container]);
|
||||
|
||||
// 打开 / 路径变化时加载
|
||||
useEffect(() => {
|
||||
if (open && podName) {
|
||||
loadPath(currentPath);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [open, podName, currentPath, container]);
|
||||
|
||||
// 浏览器风格导航
|
||||
const navigateTo = useCallback((path: string) => {
|
||||
const n = normalizePath(path);
|
||||
setHistoryStack((stack) => {
|
||||
const next = stack.slice(0, historyIndex + 1);
|
||||
// 避免重复
|
||||
if (next[next.length - 1] !== n) next.push(n);
|
||||
return next;
|
||||
});
|
||||
setHistoryIndex((i) => i + 1);
|
||||
}, [historyIndex]);
|
||||
|
||||
const goBack = useCallback(() => {
|
||||
if (historyIndex > 0) {
|
||||
setHistoryIndex(historyIndex - 1);
|
||||
}
|
||||
}, [historyIndex]);
|
||||
|
||||
const goForward = useCallback(() => {
|
||||
if (historyIndex < historyStack.length - 1) {
|
||||
setHistoryIndex(historyIndex + 1);
|
||||
}
|
||||
}, [historyIndex, historyStack.length]);
|
||||
|
||||
const goUp = useCallback(() => {
|
||||
navigateTo(parentPath(currentPath));
|
||||
}, [currentPath, navigateTo]);
|
||||
|
||||
const canGoBack = historyIndex > 0;
|
||||
const canGoForward = historyIndex < historyStack.length - 1;
|
||||
|
||||
// 进入目录 / 预览文件
|
||||
const handleEntryClick = useCallback((entry: FileEntry) => {
|
||||
setSelected(entry.name);
|
||||
const fullPath = joinPath(currentPath, entry.name);
|
||||
if (entry.isDir) {
|
||||
navigateTo(fullPath);
|
||||
} else {
|
||||
// 双击或点击预览文本文件
|
||||
const ext = getExt(entry.name);
|
||||
const isText = TEXT_EXTENSIONS.has(ext) || (entry.size ?? 0) <= TEXT_PREVIEW_MAX && !Binary;
|
||||
if (isText && (entry.size ?? 0) <= TEXT_PREVIEW_MAX) {
|
||||
previewFile(fullPath);
|
||||
} else if ((entry.size ?? 0) > TEXT_PREVIEW_MAX && isText) {
|
||||
toast({ title: '文件较大', description: `超过 ${formatSize(TEXT_PREVIEW_MAX)},建议下载查看`, variant: 'default' });
|
||||
}
|
||||
}
|
||||
}, [currentPath, navigateTo, toast]);
|
||||
|
||||
// 预览文本文件
|
||||
const previewFile = useCallback(async (path: string) => {
|
||||
setPreview({ path, content: '', loading: true });
|
||||
try {
|
||||
const text = await kubernetesApi.readPodFileText(clusterCode, namespace, podName, path, container || undefined);
|
||||
setPreview({ path, content: text, loading: false });
|
||||
} catch (e: any) {
|
||||
setPreview({ path, content: `[预览失败: ${e?.message || String(e)}]`, loading: false });
|
||||
toast({ title: '预览失败', description: e?.message || String(e), variant: 'destructive' });
|
||||
}
|
||||
}, [clusterCode, namespace, podName, container, toast]);
|
||||
|
||||
// 下载文件
|
||||
const handleDownload = useCallback((path: string) => {
|
||||
const url = kubernetesApi.buildPodFileDownloadUrl(clusterCode, namespace, podName, path, container || undefined);
|
||||
window.open(url, '_blank');
|
||||
toast({ title: '已开始下载', description: path });
|
||||
}, [clusterCode, namespace, podName, container, toast]);
|
||||
|
||||
const breadcrumbs = useMemo(() => pathSegments(currentPath), [currentPath]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
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 !translate-x-0 !translate-y-0 !left-0 !top-0'
|
||||
: ''
|
||||
)}
|
||||
style={fullscreen ? { maxWidth: '100vw', width: '100vw', height: '100vh' } : { maxWidth: '1000px', width: '94vw' }}
|
||||
>
|
||||
<DialogTitle className="sr-only">文件 — {podName}</DialogTitle>
|
||||
<DialogDescription className="sr-only">浏览 Pod 容器文件系统</DialogDescription>
|
||||
|
||||
{/* 头部 */}
|
||||
<div className="flex items-center justify-between gap-2 px-4 py-2 border-b border-[var(--dg-border)] flex-wrap">
|
||||
<div className="flex items-center gap-2 text-sm min-w-0">
|
||||
<HardDrive className="h-4 w-4 text-blue-400 shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] font-medium truncate">文件浏览器 — {podName}</span>
|
||||
{loading && <Loader2 className="h-3 w-3 animate-spin text-[var(--dg-text-muted)]" />}
|
||||
<span className="text-xs text-[var(--dg-text-muted)]">{entries.length} 项</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
{containers.length > 0 && (
|
||||
<Select value={container} onValueChange={setContainer}>
|
||||
<SelectTrigger className="h-8 w-[150px] text-xs bg-[var(--dg-bg-hover)] border-[var(--dg-border)]">
|
||||
<Box className="h-3 w-3 mr-1 text-[var(--dg-text-muted)]" />
|
||||
<SelectValue placeholder="容器" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{containers.map((c) => (
|
||||
<SelectItem key={c} value={c}>{c}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" onClick={() => loadPath(currentPath)} title="刷新">
|
||||
<RotateCw className={cn('h-3.5 w-3.5', loading && 'animate-spin')} />
|
||||
</Button>
|
||||
<button
|
||||
onClick={() => setFullscreen(!fullscreen)}
|
||||
className="p-1.5 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
title={fullscreen ? '退出全屏' : '全屏'}
|
||||
>
|
||||
{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)]"
|
||||
title="关闭"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 工具栏:导航按钮 + 面包屑 */}
|
||||
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] flex-wrap">
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={!canGoBack} onClick={goBack} title="后退">
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={!canGoForward} onClick={goForward} title="前进">
|
||||
<ArrowRight className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" disabled={currentPath === '/'} onClick={goUp} title="返回上级">
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" className="h-8 w-8 p-0" onClick={() => navigateTo('/')} title="根目录">
|
||||
<Home className="h-4 w-4" />
|
||||
</Button>
|
||||
|
||||
{/* 面包屑 */}
|
||||
<div className="flex items-center gap-0.5 ml-2 flex-1 min-w-0 overflow-x-auto">
|
||||
{breadcrumbs.map((seg, i) => (
|
||||
<div key={seg.path} className="flex items-center shrink-0">
|
||||
{i > 0 && <ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] mx-0.5" />}
|
||||
<button
|
||||
className={cn(
|
||||
'px-1.5 py-0.5 rounded text-xs hover:bg-[var(--dg-bg-hover)]',
|
||||
i === breadcrumbs.length - 1
|
||||
? 'text-[var(--dg-text-primary)] font-medium'
|
||||
: 'text-[var(--dg-text-secondary)]'
|
||||
)}
|
||||
onClick={() => navigateTo(seg.path)}
|
||||
>
|
||||
{seg.name === '/' ? <Home className="h-3 w-3" /> : seg.name}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-xs text-[var(--dg-text-muted)] font-mono shrink-0">{currentPath}</span>
|
||||
</div>
|
||||
|
||||
{/* 主体:左列表 / 右预览 */}
|
||||
<div className="flex" style={{ height: fullscreen ? 'calc(100vh - 98px)' : '480px' }}>
|
||||
{/* 文件列表 */}
|
||||
<div className={cn('flex-1 overflow-auto', preview && 'border-r border-[var(--dg-border)]')}>
|
||||
{error && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-6">
|
||||
<AlertCircle className="h-8 w-8 text-amber-400 mb-2" />
|
||||
<p className="text-sm text-[var(--dg-text-primary)] mb-1">加载失败</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3 max-w-md">{error}</p>
|
||||
<p className="text-xs text-[var(--dg-text-muted)] mb-3 max-w-md">
|
||||
文件浏览器 API 仍在并行开发中,请确认后端 <code className="px-1 bg-[var(--dg-bg-hover)] rounded">POST .../files/list</code> 已就绪。
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => loadPath(currentPath)}>
|
||||
<RotateCw className="h-3.5 w-3.5 mr-1" />重试
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && loading && entries.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && !loading && entries.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center h-full text-[var(--dg-text-muted)]">
|
||||
<FolderOpen className="h-8 w-8 mb-2 opacity-50" />
|
||||
<span className="text-sm">空目录</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && entries.length > 0 && (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-[var(--dg-bg-secondary)] border-b border-[var(--dg-border)]">
|
||||
<tr className="text-xs text-[var(--dg-text-muted)]">
|
||||
<th className="text-left font-normal px-3 py-1.5">名称</th>
|
||||
<th className="text-right font-normal px-3 py-1.5 w-28">大小</th>
|
||||
<th className="text-left font-normal px-3 py-1.5 w-40">修改时间</th>
|
||||
<th className="text-right font-normal px-3 py-1.5 w-28">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{currentPath !== '/' && (
|
||||
<tr
|
||||
className="border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-hover)] cursor-pointer"
|
||||
onClick={goUp}
|
||||
>
|
||||
<td className="px-3 py-1.5 text-[var(--dg-text-secondary)] flex items-center gap-2">
|
||||
<ArrowUp className="h-4 w-4" />
|
||||
<span className="italic">..</span>
|
||||
</td>
|
||||
<td className="px-3 py-1.5"></td>
|
||||
<td className="px-3 py-1.5"></td>
|
||||
<td className="px-3 py-1.5"></td>
|
||||
</tr>
|
||||
)}
|
||||
{entries.map((entry) => {
|
||||
const Icon = getFileIcon(entry.name, entry.isDir);
|
||||
const fullPath = joinPath(currentPath, entry.name);
|
||||
const isSelected = selected === entry.name;
|
||||
return (
|
||||
<tr
|
||||
key={entry.name}
|
||||
className={cn(
|
||||
'border-b border-[var(--dg-border)]/50 hover:bg-[var(--dg-bg-hover)] cursor-pointer',
|
||||
isSelected && 'bg-[var(--dg-bg-hover)]'
|
||||
)}
|
||||
onClick={() => handleEntryClick(entry)}
|
||||
>
|
||||
<td className="px-3 py-1.5">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<Icon className={cn(
|
||||
'h-4 w-4 shrink-0',
|
||||
entry.isDir ? 'text-blue-400' : 'text-[var(--dg-text-muted)]'
|
||||
)} />
|
||||
<span className={cn(
|
||||
'truncate',
|
||||
entry.isDir ? 'text-[var(--dg-text-primary)] font-medium' : 'text-[var(--dg-text-secondary)]'
|
||||
)} title={entry.name}>
|
||||
{entry.name}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right text-xs text-[var(--dg-text-muted)] font-mono">
|
||||
{entry.isDir ? '-' : formatSize(entry.size)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-xs text-[var(--dg-text-muted)] font-mono">
|
||||
{entry.modTime || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-1.5 text-right" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="flex items-center justify-end gap-0.5">
|
||||
{!entry.isDir && (
|
||||
<>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-blue-400"
|
||||
title="预览文本"
|
||||
onClick={() => previewFile(fullPath)}
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-emerald-400"
|
||||
title={entry.isDir ? '目录不支持下载' : '下载'}
|
||||
disabled={entry.isDir}
|
||||
onClick={() => !entry.isDir && handleDownload(fullPath)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 预览面板 */}
|
||||
{preview && (
|
||||
<div className="flex flex-col w-[45%] min-w-[300px] bg-[#0f172a]">
|
||||
<div className="flex items-center justify-between px-3 py-1.5 border-b border-[var(--dg-border)]">
|
||||
<div className="flex items-center gap-2 text-xs min-w-0">
|
||||
<Eye className="h-3.5 w-3.5 text-blue-400 shrink-0" />
|
||||
<span className="text-[var(--dg-text-primary)] truncate font-mono" title={preview.path}>
|
||||
{preview.path}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5 shrink-0">
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-muted)] hover:text-emerald-400"
|
||||
title="下载"
|
||||
onClick={() => handleDownload(preview.path)}
|
||||
>
|
||||
<Download className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)]"
|
||||
title="关闭预览"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto p-3">
|
||||
{preview.loading ? (
|
||||
<div className="flex items-center justify-center h-full text-[var(--dg-text-muted)]">
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />加载中…
|
||||
</div>
|
||||
) : (
|
||||
<pre className="text-xs text-slate-300 font-mono whitespace-pre-wrap break-all">
|
||||
{preview.content || '(空文件)'}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user