forked from EduCraft/curriculum-project-hub
feat(filelib-web): 文件面板支持资源管理器式文件夹导航与图标视图切换
This commit is contained in:
@@ -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<void> {
|
||||
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<string>();
|
||||
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<void> {
|
||||
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 @@
|
||||
<div class="section-title">项目文件({files?.length ?? 0})</div>
|
||||
{#if canEdit}
|
||||
<div class="flex gap-1.5">
|
||||
<button class="btn" onclick={() => (showNewFile = true)}><Icon name="plus" size={13} /> 新建文件</button>
|
||||
<button class="btn" onclick={() => { newPath = currentDir; showNewFile = true; }}><Icon name="plus" size={13} /> 新建文件</button>
|
||||
<button class="btn btn-primary" onclick={() => uploadInput?.click()}>上传文件</button>
|
||||
<input bind:this={uploadInput} type="file" class="hidden" onchange={pickFile} />
|
||||
</div>
|
||||
@@ -148,20 +227,91 @@
|
||||
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
|
||||
{:else if files && files.length === 0}
|
||||
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
|
||||
{:else if files}
|
||||
<table class="list">
|
||||
<tbody>
|
||||
{#each files as f (f.path)}
|
||||
<tr
|
||||
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
|
||||
{:else if rows}
|
||||
<!-- 地址栏:上级 + 面包屑,与视图切换同一行,windows 资源管理器的标准布局 -->
|
||||
<div class="mb-2 flex items-center justify-between gap-2 border-b border-line-soft pb-2">
|
||||
<div class="flex min-w-0 items-center gap-0.5 overflow-x-auto text-[12.5px] text-ink-3">
|
||||
<button
|
||||
class="mr-0.5 flex h-6 w-6 shrink-0 items-center justify-center rounded-md text-ink-3 disabled:opacity-30 {currentDir !== '' ? 'hover:bg-hover hover:text-ink' : ''}"
|
||||
onclick={goUp}
|
||||
disabled={currentDir === ""}
|
||||
title="返回上级"
|
||||
aria-label="返回上级"
|
||||
><Icon name="arrowUp" size={13} /></button>
|
||||
<button class="shrink-0 rounded-md px-1.5 py-0.5 hover:bg-hover hover:text-ink" onclick={() => gotoBreadcrumb(-1)}>根目录</button>
|
||||
{#each breadcrumbSegs as seg, i (i)}
|
||||
<span class="shrink-0 text-line">/</span>
|
||||
<button class="shrink-0 truncate rounded-md px-1.5 py-0.5 hover:bg-hover hover:text-ink" onclick={() => gotoBreadcrumb(i)}>{seg}</button>
|
||||
{/each}
|
||||
</div>
|
||||
<div class="flex shrink-0 gap-1">
|
||||
<button
|
||||
class="flex h-[26px] w-[26px] items-center justify-center rounded-md {viewMode === 'list' ? 'bg-selected text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink'}"
|
||||
onclick={() => setViewMode("list")}
|
||||
title="列表视图"
|
||||
aria-label="列表视图"
|
||||
aria-pressed={viewMode === "list"}
|
||||
><Icon name="viewList" size={14} /></button>
|
||||
<button
|
||||
class="flex h-[26px] w-[26px] items-center justify-center rounded-md {viewMode === 'grid' ? 'bg-selected text-ink' : 'text-ink-3 hover:bg-hover hover:text-ink'}"
|
||||
onclick={() => setViewMode("grid")}
|
||||
title="大图标视图"
|
||||
aria-label="大图标视图"
|
||||
aria-pressed={viewMode === "grid"}
|
||||
><Icon name="viewGrid" size={14} /></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if rows.folders.length === 0 && rows.files.length === 0}
|
||||
<div class="quiet py-5 text-center">此文件夹为空</div>
|
||||
{:else if viewMode === "list"}
|
||||
<table class="list">
|
||||
<tbody>
|
||||
{#each rows.folders as folder (folder.path)}
|
||||
<tr
|
||||
class="cursor-pointer hover:bg-hover"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
onclick={() => enterFolder(folder.path)}
|
||||
onkeydown={(e) => e.key === "Enter" && enterFolder(folder.path)}
|
||||
>
|
||||
<td><span class="inline-flex items-center gap-2 text-[12.5px] text-ink"><span class="text-ink-3"><Icon name="folder" size={15} /></span>{folder.name}</span></td>
|
||||
<td class="file-meta text-right">—</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{#each rows.files as f (f.path)}
|
||||
<tr
|
||||
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
|
||||
onclick={() => selectedFilePath.set(f.path)}
|
||||
>
|
||||
<td><span class="inline-flex items-center gap-2 font-mono text-[12.5px] text-ink"><span class="text-ink-3"><Icon name="file" size={14} /></span>{f.name}</span></td>
|
||||
<td class="file-meta text-right">{f.size} B</td>
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
{:else}
|
||||
<div class="grid grid-cols-[repeat(auto-fill,minmax(84px,1fr))] gap-1 py-1">
|
||||
{#each rows.folders as folder (folder.path)}
|
||||
<button
|
||||
class="flex flex-col items-center gap-1.5 rounded-lg p-2.5 text-center hover:bg-hover"
|
||||
onclick={() => enterFolder(folder.path)}
|
||||
>
|
||||
<span class="text-ink-3"><Icon name="folder" size={34} /></span>
|
||||
<span class="line-clamp-2 w-full break-all text-[11.5px] text-ink">{folder.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
{#each rows.files as f (f.path)}
|
||||
<button
|
||||
class="flex flex-col items-center gap-1.5 rounded-lg p-2.5 text-center {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
|
||||
onclick={() => selectedFilePath.set(f.path)}
|
||||
>
|
||||
<td class="font-mono text-[12.5px] text-ink">{f.path}</td>
|
||||
<td class="file-meta text-right">{f.size} B</td>
|
||||
</tr>
|
||||
<span class="text-ink-3"><Icon name="file" size={34} /></span>
|
||||
<span class="line-clamp-2 w-full break-all text-[11.5px] text-ink">{f.name}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user