forked from EduCraft/curriculum-project-hub
361 lines
14 KiB
Svelte
361 lines
14 KiB
Svelte
<script lang="ts">
|
|
import { api } from "./api.js";
|
|
import { toastOk, toastErr } from "./stores.js";
|
|
import { selectedFilePath, filesVersion } from "./browser.js";
|
|
import { loadConfig } from "./config.js";
|
|
import type { FileEntry, NodeDetail } from "./types.js";
|
|
import Modal from "./Modal.svelte";
|
|
import Icon from "./Icon.svelte";
|
|
|
|
let { node }: { node: NodeDetail } = $props();
|
|
|
|
let files = $state<FileEntry[] | null>(null);
|
|
let loadError = $state<string | null>(null);
|
|
let showNewFile = $state(false);
|
|
let newPath = $state("");
|
|
let newContent = $state("");
|
|
let newMessage = $state("");
|
|
// 上传弹窗:选完文件先暂存,等用户确认路径与 commit 信息再传。
|
|
let pendingFile = $state<File | null>(null);
|
|
let uploadPath = $state("");
|
|
let uploadMessage = $state("");
|
|
let uploading = $state(false);
|
|
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
|
|
let uploadInput = $state<HTMLInputElement | null>(null);
|
|
/** 上传上限由 /database/config 下发(后端 HUB_FILELIB_MAX_FILE_BYTES)。 */
|
|
let maxFileBytes = $state<number | null>(null);
|
|
|
|
const maxLabel = $derived(
|
|
maxFileBytes === null ? "" : `${(maxFileBytes / 1024 / 1024).toFixed(maxFileBytes % (1024 * 1024) === 0 ? 0 : 1)}MB`,
|
|
);
|
|
|
|
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`);
|
|
files = r.files;
|
|
loadError = null;
|
|
} catch (e) {
|
|
loadError = e instanceof Error ? e.message : String(e);
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
void node.id;
|
|
void $filesVersion;
|
|
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;
|
|
const message = newMessage.trim();
|
|
try {
|
|
await api(`/database/api/projects/${node.id}/file`, {
|
|
method: "PUT",
|
|
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
|
|
body: message === "" ? { path, content: newContent } : { path, content: newContent, message },
|
|
});
|
|
toastOk("已创建");
|
|
showNewFile = false;
|
|
newPath = ""; newContent = ""; newMessage = "";
|
|
await loadFiles();
|
|
} catch (e) {
|
|
toastErr(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
|
|
function u8ToBase64(bytes: Uint8Array): string {
|
|
let bin = "";
|
|
const CHUNK = 0x8000;
|
|
for (let i = 0; i < bytes.length; i += CHUNK) {
|
|
bin += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + CHUNK)) as unknown as number[]);
|
|
}
|
|
return btoa(bin);
|
|
}
|
|
|
|
/** 选文件只负责暂存与预填;真正上传在弹窗确认后。 */
|
|
function pickFile(e: Event): void {
|
|
const input = e.target as HTMLInputElement;
|
|
const file = input.files?.[0];
|
|
input.value = "";
|
|
if (!file) return;
|
|
// 上限取后端值;拉不到就不在前端拦 —— 后端横竖会以 413 file_too_large 兼底,
|
|
// 前端这道只是省一次往返。
|
|
if (maxFileBytes !== null && file.size > maxFileBytes) {
|
|
toastErr(`文件超过 ${maxLabel} 上限`);
|
|
return;
|
|
}
|
|
pendingFile = file;
|
|
uploadPath = `${currentDir}${file.name}`;
|
|
uploadMessage = "";
|
|
}
|
|
|
|
function cancelUpload(): void {
|
|
pendingFile = null;
|
|
uploadPath = "";
|
|
uploadMessage = "";
|
|
}
|
|
|
|
async function submitUpload(): Promise<void> {
|
|
const file = pendingFile;
|
|
const targetPath = uploadPath.trim();
|
|
if (file === null || targetPath === "") return;
|
|
uploading = true;
|
|
try {
|
|
const bytes = new Uint8Array(await file.arrayBuffer());
|
|
const isBinary = bytes.includes(0);
|
|
const message = uploadMessage.trim();
|
|
const body: Record<string, string> = isBinary
|
|
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
|
|
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
|
|
// message 缺失时不传 —— 后端回退到【用户名】修改了【路径】。
|
|
if (message !== "") body["message"] = message;
|
|
await api(`/database/api/projects/${node.id}/file`, { method: "PUT", body });
|
|
toastOk(`已上传 ${file.name}`);
|
|
cancelUpload();
|
|
await loadFiles();
|
|
} catch (err) {
|
|
toastErr(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
uploading = false;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<div class="panel">
|
|
<div class="mb-1.5 flex items-center justify-between">
|
|
<div class="section-title">项目文件({files?.length ?? 0})</div>
|
|
{#if canEdit}
|
|
<div class="flex gap-1.5">
|
|
<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>
|
|
{/if}
|
|
</div>
|
|
|
|
{#if files === null && loadError === null}
|
|
<div class="quiet py-5 text-center">加载中…</div>
|
|
{:else if loadError}
|
|
<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 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)}
|
|
>
|
|
<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}
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
|
|
{#if showNewFile}
|
|
<Modal title="新建文件" onclose={() => (showNewFile = false)}>
|
|
<div class="form-row">
|
|
<label class="form-label" for="nf-path">路径</label>
|
|
<input id="nf-path" class="input font-mono" bind:value={newPath} placeholder="docs/intro.md" />
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="form-label" for="nf-content">内容</label>
|
|
<textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="form-label" for="nf-msg">提交信息(可选)</label>
|
|
<input id="nf-msg" class="input" bind:value={newMessage} placeholder="留空则自动生成" />
|
|
</div>
|
|
<div class="mt-4 flex justify-end gap-2">
|
|
<button class="btn" onclick={() => (showNewFile = false)}>取消</button>
|
|
<button class="btn btn-primary" onclick={submitNewFile}>创建</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|
|
|
|
{#if pendingFile}
|
|
<Modal title="上传文件" onclose={cancelUpload}>
|
|
<div class="form-row">
|
|
<span class="form-label">已选文件</span>
|
|
<p class="font-mono text-[12.5px] text-ink">{pendingFile.name}<span class="quiet"> · {pendingFile.size} B</span></p>
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="form-label" for="up-path">保存到路径</label>
|
|
<input id="up-path" class="input font-mono" bind:value={uploadPath} placeholder="材料/课件.pptx" />
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="form-label" for="up-msg">提交信息(可选)</label>
|
|
<input id="up-msg" class="input" bind:value={uploadMessage} placeholder="留空则自动生成" />
|
|
</div>
|
|
<div class="mt-4 flex justify-end gap-2">
|
|
<button class="btn" onclick={cancelUpload} disabled={uploading}>取消</button>
|
|
<button class="btn btn-primary" onclick={submitUpload} disabled={uploading || uploadPath.trim() === ""}>
|
|
{uploading ? "上传中…" : "上传"}
|
|
</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|