From e194670d740394ba7d7971524792891663338ec0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <3401797899@qq.com> Date: Mon, 27 Jul 2026 20:48:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(filelib-web):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E6=94=AF=E6=8C=81=E8=B5=84=E6=BA=90=E7=AE=A1?= =?UTF-8?q?=E7=90=86=E5=99=A8=E5=BC=8F=E6=96=87=E4=BB=B6=E5=A4=B9=E5=AF=BC?= =?UTF-8?q?=E8=88=AA=E4=B8=8E=E5=9B=BE=E6=A0=87=E8=A7=86=E5=9B=BE=E5=88=87?= =?UTF-8?q?=E6=8D=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/filelib-web/src/lib/FilesPanel.svelte | 176 ++++++++++++++++++++-- hub/filelib-web/src/lib/Icon.svelte | 6 + 2 files changed, 169 insertions(+), 13 deletions(-) diff --git a/hub/filelib-web/src/lib/FilesPanel.svelte b/hub/filelib-web/src/lib/FilesPanel.svelte index a98aaed..839dfde 100644 --- a/hub/filelib-web/src/lib/FilesPanel.svelte +++ b/hub/filelib-web/src/lib/FilesPanel.svelte @@ -31,6 +31,31 @@ const canEdit = $derived(node.role !== "VIEW"); + /** + * 当前浏览目录("" = 根,否则以 "/" 结尾)。文件夹是从扁平 path 列表派生的 + * 虚拟层(ADR-0030 —— git 版本存储里只有文件,没有目录对象),不对应任何 + * 独立的后端资源,纯前端按 "/" 分段分组即可,无需新增接口。 + */ + let currentDir = $state(""); + let viewMode = $state<"list" | "grid">(loadViewMode()); + + function loadViewMode(): "list" | "grid" { + try { + return localStorage.getItem("filelib.viewMode") === "grid" ? "grid" : "list"; + } catch { + return "list"; + } + } + + function setViewMode(mode: "list" | "grid"): void { + viewMode = mode; + try { + localStorage.setItem("filelib.viewMode", mode); + } catch { + // 隐私模式等场景下 localStorage 可能不可用;视图切换仍在当前会话内生效,只是不跨会话记忆。 + } + } + async function loadFiles(): Promise { try { const r = await api<{ files: FileEntry[] }>(`/database/api/projects/${node.id}/files`); @@ -47,12 +72,66 @@ void loadFiles(); }); + // 切换项目时回到根目录 —— 不同项目的目录结构互不相干。 + $effect(() => { + void node.id; + currentDir = ""; + }); + $effect(() => { void loadConfig() .then((c) => (maxFileBytes = c.maxFileBytes)) .catch(() => (maxFileBytes = null)); }); + interface FolderRow { + readonly kind: "folder"; + readonly name: string; + readonly path: string; + } + interface FileRow { + readonly kind: "file"; + readonly name: string; + readonly path: string; + readonly size: number; + } + + /** 按当前目录分组:落在 currentDir 前缀下、第一段之后还有 "/" 的算子文件夹,否则是本层文件。 */ + const rows = $derived.by((): { folders: FolderRow[]; files: FileRow[] } | null => { + if (files === null) return null; + const folderNames = new Set(); + const fileRows: FileRow[] = []; + for (const f of files) { + if (!f.path.startsWith(currentDir)) continue; + const rest = f.path.slice(currentDir.length); + const slash = rest.indexOf("/"); + if (slash === -1) fileRows.push({ kind: "file", name: rest, path: f.path, size: f.size }); + else folderNames.add(rest.slice(0, slash)); + } + const folders = [...folderNames] + .sort((a, b) => a.localeCompare(b)) + .map((name): FolderRow => ({ kind: "folder", name, path: `${currentDir}${name}/` })); + fileRows.sort((a, b) => a.name.localeCompare(b.name)); + return { folders, files: fileRows }; + }); + + const breadcrumbSegs = $derived(currentDir === "" ? [] : currentDir.slice(0, -1).split("/")); + + function enterFolder(path: string): void { + currentDir = path; + } + + function goUp(): void { + if (currentDir === "") return; + const segs = currentDir.slice(0, -1).split("/"); + segs.pop(); + currentDir = segs.length === 0 ? "" : `${segs.join("/")}/`; + } + + function gotoBreadcrumb(index: number): void { + currentDir = index < 0 ? "" : `${breadcrumbSegs.slice(0, index + 1).join("/")}/`; + } + async function submitNewFile(): Promise { const path = newPath.trim(); if (path === "") return; @@ -94,7 +173,7 @@ return; } pendingFile = file; - uploadPath = `材料/${file.name}`; + uploadPath = `${currentDir}${file.name}`; uploadMessage = ""; } @@ -135,7 +214,7 @@
项目文件({files?.length ?? 0})
{#if canEdit}
- +
@@ -148,20 +227,91 @@
{loadError}
{:else if files && files.length === 0}
空仓库 · 可新建或上传文件
- {:else if files} - - - {#each files as f (f.path)} - +
+
+ + + {#each breadcrumbSegs as seg, i (i)} + / + + {/each} +
+
+ + +
+
+ + {#if rows.folders.length === 0 && rows.files.length === 0} +
此文件夹为空
+ {:else if viewMode === "list"} +
+ + {#each rows.folders as folder (folder.path)} + enterFolder(folder.path)} + onkeydown={(e) => e.key === "Enter" && enterFolder(folder.path)} + > + + + + {/each} + {#each rows.files as f (f.path)} + selectedFilePath.set(f.path)} + > + + + + {/each} + +
{folder.name}
{f.name}{f.size} B
+ {:else} +
+ {#each rows.folders as folder (folder.path)} + + {/each} + {#each rows.files as f (f.path)} + {/each} - - +
+ {/if} {/if} diff --git a/hub/filelib-web/src/lib/Icon.svelte b/hub/filelib-web/src/lib/Icon.svelte index 95e14ec..321d280 100644 --- a/hub/filelib-web/src/lib/Icon.svelte +++ b/hub/filelib-web/src/lib/Icon.svelte @@ -20,6 +20,12 @@ archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6", restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5", download: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3", + // Windows 资源管理器式文件浏览:文件夹/文件项与大小图标切换用。 + folder: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z", + file: "M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z M14 2v4a2 2 0 0 0 2 2h4", + viewList: "M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01", + viewGrid: "M4 4h7v7H4V4Zm9 0h7v7h-7V4ZM4 13h7v7H4v-7Zm9 0h7v7h-7v-7Z", + arrowUp: "M12 19V5M5 12l7-7 7 7", } as const; export type IconName = keyof typeof ICONS;