Files
aiops/frontend/src/components/server/ServerFileBrowser.tsx
T
Logan.Li d19a03d427 refactor(frontend): services/api 迁至 src/api,http 迁至 utils/request
对齐 Vue 后台惯例:
- src/api/          域 API(原 services/api)
- src/utils/request.ts  HTTP 封装(原 services/http.ts)
- src/utils/export.ts  客户端导出工具
- src/utils/index.ts   工具统一出口

全局导入改为 @/api 与 @/utils/request,tsc + build 通过。
2026-06-26 00:09:07 +08:00

339 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 服务器文件浏览器 — SFTP 浏览/上传/下载(参考 deployment FileBrowserDialog 设计)
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Dialog,
DialogContent,
DialogDescription,
DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Folder,
FolderOpen,
File,
Upload,
Download,
Trash2,
ArrowLeft,
Home,
RefreshCw,
Loader2,
FolderPlus,
ChevronRight,
X,
Maximize2,
Minimize2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { serverApi } from '@/api';
interface FileItem {
name: string;
path: string;
size: number;
is_dir: boolean;
mod_time: string;
mode: string;
}
interface Breadcrumb {
name: string;
path: string;
}
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1048576) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / 1048576).toFixed(2)} MB`;
};
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
serverId: number | null;
serverName: string;
}
export function ServerFileBrowser({ open, onOpenChange, serverId, serverName }: Props) {
const [items, setItems] = useState<FileItem[]>([]);
const [breadcrumbs, setBreadcrumbs] = useState<Breadcrumb[]>([]);
const [currentPath, setCurrentPath] = useState('/');
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [uploading, setUploading] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const fetchDir = useCallback(async (path: string) => {
if (!serverId) return;
setLoading(true);
setError('');
try {
const resp = await serverApi.listFiles(serverId, path) as any;
const data = resp?.data || resp;
setItems(data.items || []);
setBreadcrumbs(data.breadcrumbs || []);
setCurrentPath(data.path || path);
} catch (err: any) {
setError(err?.message || '加载目录失败');
setItems([]);
} finally {
setLoading(false);
}
}, [serverId]);
useEffect(() => {
if (open && serverId) {
setFullscreen(false);
fetchDir('/');
}
}, [open, serverId, fetchDir]);
const handleDownload = async (filePath: string) => {
if (!serverId) return;
try {
await serverApi.downloadFile(serverId, filePath);
} catch (err) {
setError('下载失败: ' + ((err as any)?.message || ''));
}
};
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !serverId) return;
setUploading(true);
try {
await serverApi.uploadFile(serverId, currentPath, file);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '上传失败');
} finally {
setUploading(false);
if (inputRef.current) inputRef.current.value = '';
}
};
const handleDelete = async (filePath: string, isDir: boolean) => {
if (!serverId || !confirm(`确认删除 ${isDir ? '目录' : '文件'} "${filePath}"?`)) return;
try {
await serverApi.deleteFile(serverId, filePath);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '删除失败');
}
};
const handleMkdir = async () => {
const name = prompt('输入目录名:');
if (!name || !serverId) return;
const newPath = currentPath === '/' ? `/${name}` : `${currentPath}/${name}`;
try {
await serverApi.mkdir(serverId, newPath);
await fetchDir(currentPath);
} catch (err: any) {
setError(err?.message || '创建失败');
}
};
const sortedItems = [...items].sort((a, b) => {
if (a.is_dir !== b.is_dir) return a.is_dir ? -1 : 1;
return a.name.localeCompare(b.name);
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
showCloseButton={false}
onInteractOutside={(e) => {
// 阻止点击外部关闭,上传中避免误关
if (uploading) 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'
: ''
)}
style={fullscreen
? { maxWidth: '100vw', width: '100vw', height: '100vh' }
: { maxWidth: '95vw', width: '1100px', maxHeight: '92vh' }
}
>
<DialogTitle className="sr-only">文件浏览 — {serverName}</DialogTitle>
<DialogDescription className="sr-only">SFTP 文件管理 · 上传 · 下载 · 删除</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-blue)] shrink-0" />
<span className="text-[var(--dg-text-primary)] font-medium text-sm truncate">
{serverName}
</span>
<span className="text-[var(--dg-text-muted)] text-xs">· SFTP 文件管理</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)]"
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)]"
>
<X className="h-4 w-4" />
</button>
</div>
</div>
{/* Toolbar */}
<div className="flex items-center gap-2 px-4 py-2 border-b border-[var(--dg-border)] bg-[var(--dg-bg-primary)] shrink-0">
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
onClick={() => fetchDir(currentPath)}>
<RefreshCw className="h-3.5 w-3.5 mr-1" />刷新
</Button>
<label>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8 cursor-pointer"
onClick={() => inputRef.current?.click()} disabled={uploading}>
{uploading ? <Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" /> : <Upload className="h-3.5 w-3.5 mr-1" />}
上传
</Button>
<input ref={inputRef} type="file" className="hidden" onChange={handleUpload} />
</label>
<Button variant="outline" size="sm" className="border-[var(--dg-border)] h-8"
onClick={handleMkdir}>
<FolderPlus className="h-3.5 w-3.5 mr-1" />新建目录
</Button>
{error && (
<Badge variant="outline" className="border-red-500/30 text-red-400 ml-auto">
<X className="h-3 w-3 mr-1 cursor-pointer" onClick={() => setError('')} />
{error}
</Badge>
)}
</div>
{/* Breadcrumbs */}
<div className="flex items-center gap-0.5 px-4 py-2 text-xs border-b border-[var(--dg-border)] bg-[var(--dg-bg-tertiary)] overflow-x-auto shrink-0">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-0.5">
{i === 0 ? (
<button
onClick={() => fetchDir('/')}
className="text-[var(--dg-text-secondary)] hover:text-[var(--dg-text-primary)] p-0.5"
>
<Home className="h-3.5 w-3.5" />
</button>
) : (
<button
onClick={() => fetchDir(crumb.path)}
className="text-[var(--dg-blue)] hover:underline truncate max-w-[150px]"
>
{crumb.name}
</button>
)}
{i < breadcrumbs.length - 1 && (
<ChevronRight className="h-3 w-3 text-[var(--dg-text-muted)] shrink-0" />
)}
</span>
))}
</div>
{/* File List */}
<div className="overflow-y-auto flex-1" style={{
height: fullscreen ? 'calc(100vh - 110px)' : 'min(55vh, 600px)'
}}>
{loading ? (
<div className="flex items-center justify-center h-full py-16">
<Loader2 className="h-6 w-6 animate-spin text-[var(--dg-blue)]" />
</div>
) : sortedItems.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-[var(--dg-text-muted)]">
<Folder className="h-10 w-10 mb-2 opacity-30" />
<p className="text-sm">空目录</p>
</div>
) : (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-[var(--dg-bg-primary)] border-b border-[var(--dg-border)] z-10">
<tr className="text-left text-[var(--dg-text-muted)] text-xs">
<th className="py-2 pl-4 w-8 font-medium"></th>
<th className="py-2 font-medium">名称</th>
<th className="py-2 px-2 font-medium w-28 text-right">大小</th>
<th className="py-2 px-2 font-medium w-44 hidden sm:table-cell">修改时间</th>
<th className="py-2 px-2 font-medium w-24 hidden md:table-cell">权限</th>
<th className="py-2 pr-4 pl-2 font-medium w-24 text-center">操作</th>
</tr>
</thead>
<tbody>
{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('/') || '/';
fetchDir(parent);
}}>
<td className="py-2 pl-4"><ArrowLeft className="h-4 w-4 text-[var(--dg-text-muted)]" /></td>
<td className="py-2 text-[var(--dg-text-secondary)]" colSpan={5}>..</td>
</tr>
)}
{sortedItems.map((item) => (
<tr key={item.path}
className="border-b border-[var(--dg-border)] hover:bg-[var(--dg-bg-hover)]"
>
<td className="py-2 pl-4">
{item.is_dir ? (
<Folder className="h-4 w-4 text-amber-400" />
) : (
<File className="h-4 w-4 text-sky-400" />
)}
</td>
<td className="py-2">
{item.is_dir ? (
<button
onClick={() => fetchDir(item.path)}
className="text-[var(--dg-text-primary)] hover:text-[var(--dg-blue)] hover:underline font-medium cursor-pointer"
>
{item.name}
</button>
) : (
<span className="text-[var(--dg-text-primary)]">{item.name}</span>
)}
</td>
<td className="py-2 px-2 text-right text-[var(--dg-text-muted)]">
{item.is_dir ? '-' : formatSize(item.size)}
</td>
<td className="py-2 px-2 text-[var(--dg-text-muted)] hidden sm:table-cell">{item.mod_time}</td>
<td className="py-2 px-2 text-[var(--dg-text-muted)] font-mono text-xs hidden md:table-cell">{item.mode}</td>
<td className="py-2 pr-4 pl-2">
<div className="flex items-center justify-center gap-1">
{!item.is_dir && (
<button
onClick={(e) => { e.stopPropagation(); handleDownload(item.path); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-[var(--dg-text-secondary)] hover:text-white"
title="下载"
>
<Download className="h-3.5 w-3.5" />
</button>
)}
<button
onClick={(e) => { e.stopPropagation(); handleDelete(item.path, item.is_dir); }}
className="p-1 rounded hover:bg-[var(--dg-bg-hover)] text-red-400 hover:text-red-300"
title="删除"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
</DialogContent>
</Dialog>
);
}