Files
vps-switch/frontend/src/components/FileBrowserPanel.vue
T

1245 lines
42 KiB
Vue
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.
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import type { FileEntry, PublicHost, TransferTask } from '../types';
interface DirCacheItem {
path: string;
parent: string | null;
items: FileEntry[];
ts: number;
}
interface BreadcrumbSegment {
label: string;
path: string;
}
const ARCHIVE_SUFFIXES = [
'.tar.gz',
'.tgz',
'.tar.xz',
'.txz',
'.tar.bz2',
'.tbz2',
'.tar',
'.zip',
'.gz',
];
function isArchiveFile(item: FileEntry): boolean {
if (item.isDir) return false;
const lower = item.name.toLowerCase();
return ARCHIVE_SUFFIXES.some((s) => lower.endsWith(s));
}
// 全局内存目录缓存
const globalDirCache = new Map<string, DirCacheItem>();
const inflightPrefetch = new Set<string>();
function makeCacheKey(hostId: string, path: string): string {
return `${hostId}::${path || '~'}`;
}
const props = defineProps<{
host: PublicHost;
compact?: boolean;
terminalCwd?: string | null;
rightWing?: 'detail' | 'files' | null;
}>();
const emit = defineEmits<{
close: [];
expandFull: [];
toggleDetail: [];
toast: [text: string, kind?: 'ok' | 'err'];
}>();
const currentPath = ref('');
const pathInput = ref('');
const editingPath = ref(false);
const pathInputEl = ref<HTMLInputElement | null>(null);
const parentPath = ref<string | null>(null);
const items = ref<FileEntry[]>([]);
const loading = ref(false);
const busyActionPath = ref<string | null>(null);
const highlightedPath = ref<string | null>(null);
const listContainerRef = ref<HTMLDivElement | null>(null);
const search = ref('');
const showHidden = ref(true);
const dragActive = ref(false);
const fileInputRef = ref<HTMLInputElement | null>(null);
let navSeq = 0;
let activeAbort: AbortController | null = null;
let hoverTimer: ReturnType<typeof setTimeout> | null = null;
let highlightTimer: ReturnType<typeof setTimeout> | null = null;
function focusAndHighlightItem(targetPath: string | null | undefined) {
if (!targetPath) return;
const norm = targetPath.replace(/\\/g, '/');
highlightedPath.value = norm;
if (highlightTimer) clearTimeout(highlightTimer);
highlightTimer = setTimeout(() => {
if (highlightedPath.value === norm) {
highlightedPath.value = null;
}
}, 4000);
void nextTick(() => {
const container = listContainerRef.value;
if (!container) return;
const rows = container.querySelectorAll<HTMLElement>('[data-entry-path]');
for (const el of rows) {
if (el.dataset.entryPath === norm) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
break;
}
}
});
}
// 在线文件编辑状态
const editorOpen = ref(false);
const editorPath = ref('');
const editorName = ref('');
const editorContent = ref('');
const editorLoading = ref(false);
const editorSaving = ref(false);
// 新建/重命名/删除确认条
const inlinePrompt = ref<{
kind: 'mkdir' | 'touch' | 'rename' | 'delete';
title: string;
value: string;
target?: FileEntry;
} | null>(null);
// 实时传输队列
const transfers = ref<TransferTask[]>([]);
let pollTimer: ReturnType<typeof setInterval> | null = null;
// 可点击的面包屑分段:如 /root/123 -> [/] [root] [123];本机 Windows -> [此电脑] [E:] [1shell4.7]
const breadcrumbs = computed<BreadcrumbSegment[]>(() => {
const p = currentPath.value.trim();
const isLocalWin = props.host.kind === 'local';
if (isLocalWin) {
const crumbs: BreadcrumbSegment[] = [{ label: '此电脑', path: 'drives' }];
if (!p || p === 'drives') return crumbs;
const norm = p.replace(/\\/g, '/').replace(/\/+$/, '');
const parts = norm.split('/').filter(Boolean);
let acc = '';
for (let i = 0; i < parts.length; i++) {
const seg = parts[i];
if (i === 0 && seg.endsWith(':')) {
acc = `${seg}/`;
crumbs.push({ label: seg, path: acc });
} else {
acc = acc.endsWith('/') ? `${acc}${seg}` : `${acc}/${seg}`;
crumbs.push({ label: seg, path: acc });
}
}
return crumbs;
}
// Linux / POSIX 路径
const crumbs: BreadcrumbSegment[] = [{ label: '/', path: '/' }];
if (!p || p === '/') return crumbs;
const parts = p.split('/').filter(Boolean);
let acc = '';
for (const seg of parts) {
acc = `${acc}/${seg}`;
crumbs.push({ label: seg, path: acc });
}
return crumbs;
});
const visibleItems = computed(() => {
const q = search.value.trim().toLowerCase();
return items.value.filter((item) => {
if (!showHidden.value && item.name.startsWith('.')) return false;
if (!q) return true;
return item.name.toLowerCase().includes(q);
});
});
function formatBytes(bytes: number): string {
if (!bytes || bytes <= 0) return '0 B';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
const val = bytes / Math.pow(1024, i);
return `${val >= 100 || i === 0 ? val.toFixed(0) : val.toFixed(1)} ${units[i]}`;
}
function formatSpeed(bps: number): string {
if (!bps || bps <= 0) return '计算中...';
return `${formatBytes(bps)}/s`;
}
function formatTime(ms: number): string {
if (!ms) return '';
const d = new Date(ms);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function joinPath(dir: string, name: string): string {
const cleanDir = dir.replace(/\/+$/, '');
if (!cleanDir || cleanDir === '/') return `/${name}`;
return `${cleanDir}/${name}`;
}
function computeOptimisticParent(p: string): string | null {
if (!p || p === '/' || p === 'drives') return null;
if (/^[A-Za-z]:\/?$/.test(p)) return 'drives';
const idx = p.lastIndexOf('/');
if (idx < 0) return null;
if (idx === 0) return '/';
const sub = p.slice(0, idx);
return /^[A-Za-z]:$/.test(sub) ? `${sub}/` : sub;
}
function storeDirCache(hostId: string, requestedPath: string, data: DirCacheItem) {
globalDirCache.set(makeCacheKey(hostId, data.path), data);
if (requestedPath !== data.path) {
globalDirCache.set(makeCacheKey(hostId, requestedPath), data);
}
}
async function prefetchDir(targetPath: string | null | undefined) {
if (!targetPath) return;
const hostId = props.host.id;
const key = makeCacheKey(hostId, targetPath);
const existing = globalDirCache.get(key);
if (existing && Date.now() - existing.ts < 30_000) return;
if (inflightPrefetch.has(key)) return;
inflightPrefetch.add(key);
try {
const url = `/api/files/list?hostId=${encodeURIComponent(hostId)}&path=${encodeURIComponent(targetPath)}`;
const res = await fetch(url);
const data = await res.json();
if (data.ok) {
storeDirCache(hostId, targetPath, {
path: data.path,
parent: data.parent || null,
items: data.items || [],
ts: Date.now(),
});
}
} catch {
// ignore
} finally {
inflightPrefetch.delete(key);
}
}
function onRowMouseEnter(item: FileEntry) {
if (!item.isDir) return;
if (hoverTimer) clearTimeout(hoverTimer);
hoverTimer = setTimeout(() => {
void prefetchDir(item.path);
}, 45);
}
function onRowMouseLeave() {
if (hoverTimer) {
clearTimeout(hoverTimer);
hoverTimer = null;
}
}
function invalidateCache(hostId: string, path: string) {
globalDirCache.delete(makeCacheKey(hostId, path));
}
async function startEditPath() {
pathInput.value = currentPath.value === 'drives' ? '' : currentPath.value;
editingPath.value = true;
await nextTick();
pathInputEl.value?.focus();
pathInputEl.value?.select();
}
function submitPathInput() {
editingPath.value = false;
const target = pathInput.value.trim();
if (target && target !== currentPath.value) {
void loadDir(target);
}
}
async function loadDir(
targetPath?: string,
opts: { forceRefresh?: boolean; silentKeepList?: boolean } = {}
) {
const hostId = props.host.id;
const reqPath = (targetPath !== undefined ? targetPath : currentPath.value).trim();
const isSameDir = reqPath === currentPath.value && items.value.length > 0;
const seq = ++navSeq;
if (activeAbort) {
activeAbort.abort();
activeAbort = null;
}
editingPath.value = false;
if (!isSameDir) {
search.value = '';
highlightedPath.value = null;
}
inlinePrompt.value = null;
if (reqPath && !isSameDir) {
currentPath.value = reqPath;
pathInput.value = reqPath;
parentPath.value = computeOptimisticParent(reqPath);
}
const cached = !opts.forceRefresh ? globalDirCache.get(makeCacheKey(hostId, reqPath)) : undefined;
if (cached) {
currentPath.value = cached.path;
pathInput.value = cached.path;
parentPath.value = cached.parent;
items.value = cached.items;
loading.value = false;
if (Date.now() - cached.ts < 15_000) {
if (cached.parent) void prefetchDir(cached.parent);
return;
}
} else if (!isSameDir && !opts.silentKeepList) {
// 只有跨目录跳转且无缓存时才清空显示骨架屏;同目录刷新(如压缩/解压/新建/改名后)保持原列表原地无感更新,绝不闪屏!
items.value = [];
loading.value = true;
} else {
loading.value = true;
}
const controller = new AbortController();
activeAbort = controller;
try {
const refreshParam = opts.forceRefresh ? '&refresh=true' : '';
const url = `/api/files/list?hostId=${encodeURIComponent(hostId)}&path=${encodeURIComponent(reqPath)}${refreshParam}`;
const res = await fetch(url, { signal: controller.signal });
const data = await res.json();
if (seq !== navSeq) return;
if (!data.ok) {
emit('toast', data.error || '读取目录失败', 'err');
loading.value = false;
return;
}
const entry: DirCacheItem = {
path: data.path,
parent: data.parent || null,
items: data.items || [],
ts: Date.now(),
};
storeDirCache(hostId, reqPath, entry);
currentPath.value = entry.path;
pathInput.value = entry.path;
parentPath.value = entry.parent;
items.value = entry.items;
loading.value = false;
if (entry.parent) {
void prefetchDir(entry.parent);
}
} catch (e) {
if ((e as Error).name === 'AbortError') return;
if (seq !== navSeq) return;
loading.value = false;
emit('toast', `读取目录失败: ${(e as Error).message}`, 'err');
}
}
// 压缩文件或文件夹
async function handleCompress(item: FileEntry) {
if (busyActionPath.value) return;
busyActionPath.value = item.path;
emit('toast', `正在压缩 ${item.name}...`);
try {
const res = await fetch('/api/files/compress', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostId: props.host.id,
path: item.path,
}),
});
const data = await res.json();
if (!data.ok) {
emit('toast', data.error || '压缩失败', 'err');
return;
}
invalidateCache(props.host.id, currentPath.value);
await loadDir(currentPath.value, { forceRefresh: true, silentKeepList: true });
focusAndHighlightItem(data.path);
emit('toast', `已压缩生成 ${data.path.split('/').pop()}`);
} catch (e) {
emit('toast', `压缩失败: ${(e as Error).message}`, 'err');
} finally {
busyActionPath.value = null;
}
}
// 解压归档文件
async function handleExtract(item: FileEntry) {
if (busyActionPath.value) return;
busyActionPath.value = item.path;
emit('toast', `正在解压 ${item.name}...`);
try {
const res = await fetch('/api/files/extract', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostId: props.host.id,
path: item.path,
}),
});
const data = await res.json();
if (!data.ok) {
emit('toast', data.error || '解压失败', 'err');
return;
}
invalidateCache(props.host.id, currentPath.value);
await loadDir(currentPath.value, { forceRefresh: true, silentKeepList: true });
focusAndHighlightItem(data.path);
emit('toast', `已解压至 ${data.path.split('/').pop()}`);
} catch (e) {
emit('toast', `解压失败: ${(e as Error).message}`, 'err');
} finally {
busyActionPath.value = null;
}
}
async function fetchTransfers() {
try {
const res = await fetch(`/api/transfers?hostId=${encodeURIComponent(props.host.id)}`);
const data = await res.json();
if (data.ok && Array.isArray(data.tasks)) {
transfers.value = data.tasks;
const hasRunning = data.tasks.some((t: TransferTask) => t.state === 'running');
if (!hasRunning && pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
}
} catch {
// ignore
}
}
function ensureTransferPolling() {
void fetchTransfers();
if (pollTimer) return;
pollTimer = setInterval(() => {
void fetchTransfers();
}, 350);
}
async function clearCompletedTransfers() {
await fetch('/api/transfers', { method: 'DELETE' });
await fetchTransfers();
}
function onEntryClick(item: FileEntry) {
if (item.isDir) {
void loadDir(item.path);
} else {
void openFileEditor(item);
}
}
async function openFileEditor(item: FileEntry) {
editorOpen.value = true;
editorPath.value = item.path;
editorName.value = item.name;
editorContent.value = '';
editorLoading.value = true;
try {
const url = `/api/files/read?hostId=${encodeURIComponent(props.host.id)}&path=${encodeURIComponent(item.path)}`;
const res = await fetch(url);
const data = await res.json();
if (!data.ok) {
emit('toast', data.error || '打开文件失败', 'err');
editorOpen.value = false;
return;
}
editorContent.value = data.content || '';
} catch (e) {
emit('toast', `读取文件失败: ${(e as Error).message}`, 'err');
editorOpen.value = false;
} finally {
editorLoading.value = false;
}
}
async function saveFileEditor() {
if (!editorPath.value || editorSaving.value) return;
editorSaving.value = true;
try {
const res = await fetch('/api/files/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostId: props.host.id,
path: editorPath.value,
content: editorContent.value,
}),
});
const data = await res.json();
if (!data.ok) {
emit('toast', data.error || '保存失败', 'err');
return;
}
emit('toast', `已保存 ${editorName.value}`);
invalidateCache(props.host.id, currentPath.value);
void loadDir(currentPath.value, { forceRefresh: true });
} catch (e) {
emit('toast', `保存失败: ${(e as Error).message}`, 'err');
} finally {
editorSaving.value = false;
}
}
function triggerDownload(item: FileEntry) {
if (item.isDir) return;
ensureTransferPolling();
const url = `/api/files/download?hostId=${encodeURIComponent(props.host.id)}&path=${encodeURIComponent(item.path)}`;
const a = document.createElement('a');
a.href = url;
a.download = item.name;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
}
function copyEntryPath(path: string) {
void navigator.clipboard.writeText(path);
emit('toast', `已复制路径: ${path}`);
}
function triggerUploadPicker() {
fileInputRef.value?.click();
}
async function uploadFiles(fileList: FileList | File[]) {
const arr = Array.from(fileList);
if (!arr.length || !currentPath.value || currentPath.value === 'drives') return;
ensureTransferPolling();
for (const file of arr) {
const q = new URLSearchParams({
hostId: props.host.id,
dirPath: currentPath.value,
filename: file.name,
size: String(file.size),
});
try {
const res = await fetch(`/api/files/upload?${q.toString()}`, {
method: 'POST',
body: file,
});
const data = await res.json();
if (!data.ok) {
emit('toast', `${file.name} 上传失败: ${data.error}`, 'err');
} else {
emit('toast', `${file.name} 上传完成`);
}
} catch (e) {
emit('toast', `${file.name} 上传失败: ${(e as Error).message}`, 'err');
}
}
invalidateCache(props.host.id, currentPath.value);
await fetchTransfers();
await loadDir(currentPath.value, { forceRefresh: true });
}
function onFileInputChange(e: Event) {
const input = e.target as HTMLInputElement;
if (input.files && input.files.length) {
void uploadFiles(input.files);
input.value = '';
}
}
function onDropFiles(e: DragEvent) {
e.preventDefault();
dragActive.value = false;
if (e.dataTransfer?.files?.length) {
void uploadFiles(e.dataTransfer.files);
}
}
function openInlineAction(
kind: 'mkdir' | 'touch' | 'rename' | 'delete',
target?: FileEntry
) {
if (kind === 'mkdir') {
inlinePrompt.value = { kind, title: '新建文件夹名称', value: '' };
} else if (kind === 'touch') {
inlinePrompt.value = { kind, title: '新建文件名称', value: '' };
} else if (kind === 'rename' && target) {
inlinePrompt.value = { kind, title: `重命名 ${target.name}`, value: target.name, target };
} else if (kind === 'delete' && target) {
inlinePrompt.value = {
kind,
title: `确认删除${target.isDir ? '目录' : '文件'}「${target.name}」?`,
value: target.name,
target,
};
}
}
async function submitInlineAction() {
const act = inlinePrompt.value;
if (!act) return;
const val = act.value.trim();
let highlightTarget: string | null = null;
try {
if (act.kind === 'mkdir') {
if (!val) return;
const target = joinPath(currentPath.value, val);
const res = await fetch('/api/files/mkdir', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostId: props.host.id,
path: target,
}),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
highlightTarget = target;
emit('toast', `已创建目录 ${val}`);
} else if (act.kind === 'touch') {
if (!val) return;
const newPath = joinPath(currentPath.value, val);
const res = await fetch('/api/files/write', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostId: props.host.id,
path: newPath,
content: '',
}),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
highlightTarget = newPath;
emit('toast', `已创建文件 ${val}`);
} else if (act.kind === 'rename' && act.target) {
if (!val || val === act.target.name) {
inlinePrompt.value = null;
return;
}
const newPath = joinPath(currentPath.value, val);
const res = await fetch('/api/files/rename', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostId: props.host.id,
oldPath: act.target.path,
newPath,
}),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
highlightTarget = newPath;
emit('toast', `已重命名为 ${val}`);
} else if (act.kind === 'delete' && act.target) {
const res = await fetch('/api/files/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
hostId: props.host.id,
path: act.target.path,
isDir: act.target.isDir,
}),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error);
if (editorPath.value === act.target.path) {
editorOpen.value = false;
}
emit('toast', `已删除 ${act.target.name}`);
}
inlinePrompt.value = null;
invalidateCache(props.host.id, currentPath.value);
await loadDir(currentPath.value, { forceRefresh: true, silentKeepList: true });
if (highlightTarget) {
focusAndHighlightItem(highlightTarget);
}
} catch (e) {
emit('toast', (e as Error).message || '操作失败', 'err');
}
}
watch(
() => props.host.id,
() => {
editorOpen.value = false;
items.value = [];
currentPath.value = props.terminalCwd || '';
pathInput.value = currentPath.value;
void loadDir(currentPath.value);
void fetchTransfers();
}
);
onMounted(() => {
currentPath.value = props.terminalCwd || '';
pathInput.value = currentPath.value;
void loadDir(currentPath.value);
void fetchTransfers();
});
onBeforeUnmount(() => {
if (activeAbort) activeAbort.abort();
if (hoverTimer) clearTimeout(hoverTimer);
if (pollTimer) {
clearInterval(pollTimer);
pollTimer = null;
}
});
</script>
<template>
<section
class="relative flex h-full w-full flex-col overflow-hidden rounded-2xl border border-zinc-200/80 bg-white/90 shadow-[0_4px_20px_rgba(0,0,0,0.03)] backdrop-blur-xl dark:border-zinc-800 dark:bg-zinc-900/90 [&_div.bg-white]:dark:bg-zinc-900 [&_input.bg-white]:dark:bg-zinc-800 [&_input.bg-white]:dark:border-zinc-700 [&_input.bg-white]:dark:text-zinc-100 [&_button.bg-white]:dark:bg-zinc-800 [&_button.bg-white]:dark:border-zinc-700 [&_button.bg-white]:dark:text-zinc-200 [&_span.text-zinc-800]:dark:text-zinc-200 [&_span.text-zinc-900]:dark:text-zinc-100"
@dragover.prevent="dragActive = true"
@dragleave.self="dragActive = false"
@drop="onDropFiles"
>
<input
ref="fileInputRef"
type="file"
multiple
class="hidden"
@change="onFileInputChange"
/>
<!-- 拖拽上传高亮遮罩 -->
<div
v-if="dragActive"
class="pointer-events-none absolute inset-2 z-40 flex flex-col items-center justify-center rounded-xl border-2 border-dashed border-zinc-900/40 bg-white/90 backdrop-blur-sm"
>
<p class="text-sm font-semibold text-zinc-900">松开鼠标直接满速上传至当前目录</p>
<p class="mt-1 font-mono text-xs text-zinc-500">{{ currentPath }}</p>
</div>
<!-- 顶部栏 -->
<div class="flex shrink-0 items-center justify-between gap-2 border-b border-zinc-100 px-3.5 py-2.5">
<div class="flex min-w-0 items-center gap-2">
<svg class="h-4 w-4 shrink-0 text-zinc-700" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
</svg>
<span class="truncate text-sm font-semibold text-zinc-900">
{{ compact ? `${host.name} · 文件` : `${host.name} · 文件管理器` }}
</span>
</div>
<div class="flex shrink-0 items-center gap-1">
<button
v-if="terminalCwd && terminalCwd !== currentPath"
type="button"
class="rounded-lg border border-zinc-200/80 bg-zinc-50 px-2 py-1 text-[11px] font-medium text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"
:title="`跳转到终端当前目录: ${terminalCwd}`"
@click="loadDir(terminalCwd)"
>
同步终端目录
</button>
<button
v-if="compact"
type="button"
class="rounded-lg px-2 py-1 text-xs font-medium text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900"
title="在主舞台全宽展开文件浏览器"
@click="emit('expandFull')"
>
全屏展开
</button>
<button
v-if="!compact"
type="button"
class="inline-flex items-center gap-1.5 rounded-xl border px-2.5 py-1 text-xs font-medium transition"
:class="
rightWing === 'detail'
? 'border-zinc-900 bg-zinc-900 text-white shadow-sm'
: 'border-zinc-200/80 bg-white text-zinc-600 hover:bg-zinc-100 hover:text-zinc-900'
"
@click="emit('toggleDetail')"
>
<span>详情图表</span>
</button>
<button
type="button"
class="rounded-lg p-1 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700"
@click="emit('close')"
>
✕
</button>
</div>
</div>
<!-- 工具条:上级按钮 + 刷新 + 可点击面包屑路径栏 + 上传 -->
<div class="flex shrink-0 flex-col gap-2 border-b border-zinc-100 bg-zinc-50/50 px-3 py-2">
<div class="flex items-center gap-1.5">
<button
type="button"
class="rounded-lg border border-zinc-200/80 bg-white p-1.5 text-zinc-600 hover:bg-zinc-100 disabled:opacity-40"
:disabled="!parentPath"
title="返回上级目录"
@mouseenter="prefetchDir(parentPath)"
@click="parentPath && loadDir(parentPath)"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 15l-6-6-6 6" />
</svg>
</button>
<button
type="button"
class="rounded-lg border border-zinc-200/80 bg-white p-1.5 text-zinc-600 hover:bg-zinc-100"
title="强制刷新当前目录"
@click="loadDir(currentPath, { forceRefresh: true })"
>
<svg
class="h-3.5 w-3.5"
:class="{ 'animate-spin': loading }"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M21 12a9 9 0 1 1-3-6.7" />
<path d="M21 3v6h-6" />
</svg>
</button>
<!-- 可点击的面包屑路径栏(如 / › root › 123,点任意一级直接跳转;点右侧空白处切换为手输路径) -->
<form
v-if="editingPath"
class="min-w-0 flex-1"
@submit.prevent="submitPathInput"
>
<input
ref="pathInputEl"
v-model="pathInput"
type="text"
class="w-full rounded-lg border border-zinc-400 bg-white px-2.5 py-1 font-mono text-xs text-zinc-900 outline-none"
placeholder="输入完整路径回车跳转..."
@blur="editingPath = false"
@keydown.esc.stop="editingPath = false"
/>
</form>
<div
v-else
class="flex min-w-0 flex-1 items-center gap-0.5 overflow-x-auto rounded-lg border border-zinc-200/80 bg-white px-2 py-1 font-mono text-xs text-zinc-700 cursor-text"
title="点击路径分段直接跳转,点击空白处手动输入路径"
@click.self="startEditPath"
>
<template v-for="(crumb, idx) in breadcrumbs" :key="crumb.path">
<span v-if="idx > 0" class="px-0.5 text-zinc-300 select-none">/</span>
<button
type="button"
class="shrink-0 rounded px-1 py-0.2 transition hover:bg-zinc-100 hover:text-zinc-950"
:class="idx === breadcrumbs.length - 1 ? 'font-semibold text-zinc-900' : 'text-zinc-600'"
@mouseenter="prefetchDir(crumb.path)"
@click.stop="loadDir(crumb.path)"
>
{{ crumb.label }}
</button>
</template>
<span class="min-w-[16px] flex-1 self-stretch" @click="startEditPath" />
</div>
<button
v-if="currentPath !== 'drives'"
type="button"
class="inline-flex shrink-0 items-center gap-1 rounded-lg bg-zinc-900 px-2.5 py-1 text-xs font-medium text-white shadow-sm hover:bg-zinc-800"
title="选择文件上传(或直接拖拽文件到此面板)"
@click="triggerUploadPicker"
>
<svg class="h-3.5 w-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="17 8 12 3 7 8" />
<line x1="12" y1="3" x2="12" y2="15" />
</svg>
<span v-if="!compact">上传</span>
</button>
</div>
<div v-if="currentPath !== 'drives'" class="flex items-center gap-1.5">
<div class="relative min-w-0 flex-1">
<input
v-model="search"
type="text"
placeholder="过滤当前目录文件..."
class="w-full rounded-lg border border-zinc-200/70 bg-white px-2.5 py-1 text-xs text-zinc-800 outline-none focus:border-zinc-400"
/>
</div>
<button
type="button"
class="rounded-lg border border-zinc-200/80 bg-white px-2 py-1 text-[11px] font-medium text-zinc-600 hover:bg-zinc-100"
@click="openInlineAction('mkdir')"
>
+目录
</button>
<button
type="button"
class="rounded-lg border border-zinc-200/80 bg-white px-2 py-1 text-[11px] font-medium text-zinc-600 hover:bg-zinc-100"
@click="openInlineAction('touch')"
>
+文件
</button>
<button
type="button"
class="rounded-lg border px-2 py-1 text-[11px] font-medium transition"
:class="
showHidden
? 'border-zinc-300 bg-zinc-200/70 text-zinc-900'
: 'border-zinc-200/80 bg-white text-zinc-400'
"
@click="showHidden = !showHidden"
>
.*
</button>
</div>
<!-- 行内新建 / 重命名 / 删除确认条 -->
<form
v-if="inlinePrompt"
class="flex items-center gap-1.5 rounded-xl border border-zinc-300 bg-white p-2 shadow-sm"
@submit.prevent="submitInlineAction"
>
<span class="shrink-0 text-xs font-medium text-zinc-700">{{ inlinePrompt.title }}</span>
<input
v-if="inlinePrompt.kind !== 'delete'"
v-model="inlinePrompt.value"
type="text"
autofocus
class="min-w-0 flex-1 rounded-lg border border-zinc-200 px-2 py-1 font-mono text-xs outline-none focus:border-zinc-400"
/>
<div class="ml-auto flex shrink-0 items-center gap-1">
<button
type="submit"
class="rounded-lg px-2.5 py-1 text-xs font-medium text-white"
:class="inlinePrompt.kind === 'delete' ? 'bg-red-600 hover:bg-red-700' : 'bg-zinc-900 hover:bg-zinc-800'"
>
确认
</button>
<button
type="button"
class="rounded-lg px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100"
@click="inlinePrompt = null"
>
取消
</button>
</div>
</form>
</div>
<!-- 主体区:文件列表 + 内联编辑器 -->
<div class="flex min-h-0 flex-1 divide-zinc-100 overflow-hidden" :class="!compact && editorOpen ? 'divide-x' : ''">
<!-- 文件列表 -->
<div
ref="listContainerRef"
v-show="!(compact && editorOpen)"
class="flex min-h-0 flex-1 flex-col overflow-y-auto px-2 py-1.5"
>
<!-- 加载中骨架屏 -->
<div v-if="loading && !items.length" class="space-y-1.5 p-2">
<div
v-for="n in 8"
:key="n"
class="flex items-center justify-between rounded-xl px-2.5 py-2"
>
<div class="flex items-center gap-2.5">
<div class="h-4 w-4 animate-pulse rounded bg-zinc-200/80" />
<div class="h-3.5 w-36 animate-pulse rounded bg-zinc-200/70" />
</div>
<div class="h-3 w-12 animate-pulse rounded bg-zinc-100" />
</div>
</div>
<template v-else>
<!-- 顶部第一行固定:.. / ... 返回上一级目录(包括从 /root 回到 /,从 C:/ 回到 此电脑盘符列表) -->
<div
v-if="parentPath"
class="group flex items-center justify-between gap-2 rounded-xl px-2.5 py-1.5 text-xs font-medium text-zinc-600 transition cursor-pointer hover:bg-zinc-100/80 hover:text-zinc-900"
@mouseenter="prefetchDir(parentPath)"
@click="loadDir(parentPath)"
>
<div class="flex items-center gap-2">
<svg
class="h-4 w-4 shrink-0 text-zinc-500"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
>
<path d="M9 14l-4-4 4-4" />
<path d="M5 10h11a4 4 0 1 1 0 8h-1" />
</svg>
<span class="font-mono font-semibold">..</span>
<span class="text-[11px] text-zinc-400">
{{ parentPath === 'drives' ? '(返回此电脑盘符列表)' : `(上级: ${parentPath})` }}
</span>
</div>
</div>
<!-- 目录与文件条目 -->
<div
v-for="item in visibleItems"
:key="item.path"
:data-entry-path="item.path"
class="group flex items-center justify-between gap-2 rounded-xl px-2.5 py-1.5 text-xs transition-all duration-300 cursor-pointer"
:class="[
highlightedPath === item.path
? 'bg-emerald-50/90 ring-1 ring-emerald-400/70 shadow-sm'
: editorPath === item.path && editorOpen
? 'bg-zinc-100 font-medium'
: 'hover:bg-zinc-100/80',
]"
@mouseenter="onRowMouseEnter(item)"
@mouseleave="onRowMouseLeave"
@click="onEntryClick(item)"
>
<div class="flex min-w-0 items-center gap-2">
<!-- 文件夹图标 -->
<svg
v-if="item.isDir"
class="h-4 w-4 shrink-0 text-zinc-700"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
</svg>
<!-- 压缩包图标 -->
<svg
v-else-if="isArchiveFile(item)"
class="h-4 w-4 shrink-0 text-amber-600"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M21 8v13H3V8" />
<path d="M1 3h22v5H1z" />
<path d="M10 12h4" />
</svg>
<!-- 普通文件图标 -->
<svg
v-else
class="h-4 w-4 shrink-0 text-zinc-400"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
>
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
<span class="truncate text-zinc-800">{{ item.name }}</span>
<span
v-if="highlightedPath === item.path"
class="shrink-0 rounded-md bg-emerald-500 px-1.5 py-0.2 text-[10px] font-medium text-white"
>
新生成
</span>
<span
v-else-if="busyActionPath === item.path"
class="shrink-0 rounded-md bg-zinc-900 px-1.5 py-0.2 text-[10px] font-medium text-white animate-pulse"
>
处理中...
</span>
</div>
<!-- 右侧:文件大小/修改时间 与 悬停动作(下载 / 解压 / 压缩 / 改名 / 删除) -->
<div v-if="currentPath !== 'drives'" class="flex shrink-0 items-center gap-2" @click.stop>
<span v-if="!item.isDir" class="font-mono text-[11px] text-zinc-400 group-hover:hidden">
{{ formatBytes(item.size) }}
</span>
<span v-if="!compact && item.mtime" class="hidden lg:inline font-mono text-[11px] text-zinc-300 group-hover:hidden">
{{ formatTime(item.mtime) }}
</span>
<div class="hidden items-center gap-1 group-hover:flex">
<button
v-if="isArchiveFile(item)"
type="button"
class="rounded-md bg-amber-500 px-1.5 py-0.5 text-[11px] font-medium text-white shadow-sm hover:bg-amber-600 disabled:opacity-50"
:disabled="busyActionPath === item.path"
title="解压到当前目录"
@click="handleExtract(item)"
>
解压
</button>
<button
v-if="!item.isDir"
type="button"
class="rounded-md bg-white px-1.5 py-0.5 text-[11px] font-medium text-zinc-700 shadow-sm ring-1 ring-zinc-200 hover:bg-zinc-900 hover:text-white"
title="满速流式下载"
@click="triggerDownload(item)"
>
下载
</button>
<button
type="button"
class="rounded-md bg-white px-1.5 py-0.5 text-[11px] text-zinc-600 shadow-sm ring-1 ring-zinc-200 hover:text-zinc-900 disabled:opacity-50"
:disabled="busyActionPath === item.path"
title="打包压缩"
@click="handleCompress(item)"
>
压缩
</button>
<button
type="button"
class="rounded-md bg-white px-1.5 py-0.5 text-[11px] text-zinc-600 shadow-sm ring-1 ring-zinc-200 hover:text-zinc-900"
title="复制完整路径"
@click="copyEntryPath(item.path)"
>
路径
</button>
<button
type="button"
class="rounded-md bg-white px-1.5 py-0.5 text-[11px] text-zinc-600 shadow-sm ring-1 ring-zinc-200 hover:text-zinc-900"
title="重命名"
@click="openInlineAction('rename', item)"
>
改名
</button>
<button
type="button"
class="rounded-md bg-white px-1.5 py-0.5 text-[11px] text-red-600 shadow-sm ring-1 ring-zinc-200 hover:bg-red-50"
title="删除"
@click="openInlineAction('delete', item)"
>
删除
</button>
</div>
</div>
</div>
<div
v-if="!loading && !visibleItems.length"
class="flex flex-1 items-center justify-center py-10 text-xs text-zinc-400"
>
当前目录为空
</div>
</template>
</div>
<!-- 在线文本编辑器面板 -->
<div
v-if="editorOpen"
class="flex min-h-0 flex-1 flex-col overflow-hidden bg-white"
>
<div class="flex items-center justify-between border-b border-zinc-100 bg-zinc-50/70 px-3 py-2">
<div class="min-w-0">
<p class="truncate font-mono text-xs font-semibold text-zinc-800">{{ editorName }}</p>
<p class="truncate font-mono text-[10px] text-zinc-400">{{ editorPath }}</p>
</div>
<div class="flex shrink-0 items-center gap-1.5">
<button
type="button"
class="rounded-lg bg-zinc-900 px-3 py-1 text-xs font-medium text-white hover:bg-zinc-800 disabled:opacity-50"
:disabled="editorSaving || editorLoading"
@click="saveFileEditor"
>
{{ editorSaving ? '保存中...' : '保存 (Ctrl+S)' }}
</button>
<button
type="button"
class="rounded-lg px-2 py-1 text-xs text-zinc-500 hover:bg-zinc-100"
@click="editorOpen = false"
>
关闭
</button>
</div>
</div>
<div v-if="editorLoading" class="flex flex-1 items-center justify-center text-xs text-zinc-400">
正在读取文件内容...
</div>
<textarea
v-else
v-model="editorContent"
spellcheck="false"
class="h-full w-full flex-1 resize-none bg-white p-3 font-mono text-xs leading-relaxed text-zinc-800 outline-none dark:bg-zinc-950 dark:text-zinc-100"
@keydown.ctrl.s.prevent="saveFileEditor"
@keydown.meta.s.prevent="saveFileEditor"
/>
</div>
</div>
<!-- 底部:高速传输队列面板 -->
<div
v-if="transfers.length"
class="shrink-0 border-t border-zinc-200/80 bg-zinc-50/90 px-3 py-2"
>
<div class="mb-1.5 flex items-center justify-between text-[11px]">
<span class="font-semibold text-zinc-600">
传输队列 ({{ transfers.length }})
</span>
<button
type="button"
class="text-zinc-400 hover:text-zinc-700"
@click="clearCompletedTransfers"
>
清理已完成
</button>
</div>
<div class="max-h-32 space-y-1.5 overflow-y-auto">
<div
v-for="t in transfers.slice(0, 6)"
:key="t.id"
class="rounded-xl border border-zinc-200/80 bg-white px-2.5 py-1.5 text-[11px]"
>
<div class="flex items-center justify-between gap-2">
<span class="truncate font-medium text-zinc-800">
{{ t.direction === 'upload' ? '⬆' : '⬇' }} {{ t.filename }}
</span>
<span class="shrink-0 font-mono text-[10px] text-zinc-500">
<template v-if="t.state === 'running'">
{{ formatSpeed(t.speedBps) }} · {{ formatBytes(t.transferredBytes) }} / {{ formatBytes(t.totalBytes) }}
</template>
<template v-else-if="t.state === 'completed'">
<span class="text-emerald-600">✓ 完成 ({{ formatSpeed(t.speedBps) }})</span>
</template>
<template v-else>
<span class="text-red-600" :title="t.error || ''">✗ 失败</span>
</template>
</span>
</div>
<div class="mt-1 h-1 w-full overflow-hidden rounded-full bg-zinc-100">
<div
class="h-full rounded-full transition-all duration-200"
:class="
t.state === 'failed'
? 'bg-red-500'
: t.state === 'completed'
? 'bg-emerald-500'
: 'bg-zinc-900'
"
:style="{
width:
t.totalBytes > 0
? `${Math.min(100, Math.round((t.transferredBytes * 100) / t.totalBytes))}%`
: t.state === 'completed'
? '100%'
: '30%',
}"
/>
</div>
</div>
</div>
</div>
</section>
</template>