对齐 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 通过。
339 lines
13 KiB
TypeScript
339 lines
13 KiB
TypeScript
// 服务器文件浏览器 — 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>
|
||
);
|
||
}
|