forked from bai/curriculum-project-hub
165 lines
5.9 KiB
Svelte
165 lines
5.9 KiB
Svelte
<script lang="ts">
|
|
import { api } from "./api.js";
|
|
import { currentNode, breadcrumb, bumpTree, clearSelectedFile } from "./browser.js";
|
|
import { toastOk, toastErr } from "./stores.js";
|
|
import { ROLE_LABEL } from "./labels.js";
|
|
import OverviewPanel from "./OverviewPanel.svelte";
|
|
import FilesPanel from "./FilesPanel.svelte";
|
|
import GrantsPanel from "./GrantsPanel.svelte";
|
|
import Modal from "./Modal.svelte";
|
|
import Icon from "./Icon.svelte";
|
|
|
|
type Tab = "detail" | "files" | "grants";
|
|
let tab = $state<Tab>("detail");
|
|
let showCreateChild = $state(false);
|
|
let newName = $state("");
|
|
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
|
|
let newDesc = $state("");
|
|
|
|
const node = $derived($currentNode);
|
|
const crumbs = $derived($breadcrumb);
|
|
const canManage = $derived(node?.role === "MANAGE");
|
|
const canEdit = $derived(canManage || node?.role === "EDIT");
|
|
|
|
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;授权仅 MANAGE
|
|
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
|
|
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
|
|
const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
|
|
if (node?.kind === "PROJECT") out.push(["files", "文件"]);
|
|
if (canManage) out.push(["grants", "授权"]);
|
|
return out;
|
|
});
|
|
|
|
$effect(() => {
|
|
void node?.id;
|
|
tab = "detail";
|
|
clearSelectedFile();
|
|
});
|
|
|
|
async function createChild(): Promise<void> {
|
|
const name = newName.trim();
|
|
if (name === "" || node === null) return;
|
|
try {
|
|
await api("/database/api/nodes", {
|
|
method: "POST",
|
|
body: {
|
|
parentId: node.id,
|
|
kind: newKind,
|
|
name,
|
|
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
|
|
},
|
|
});
|
|
toastOk("已创建");
|
|
showCreateChild = false;
|
|
newName = ""; newKind = "FOLDER"; newDesc = "";
|
|
bumpTree();
|
|
} catch (e) {
|
|
toastErr(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
|
|
async function renameNode(): Promise<void> {
|
|
if (node === null) return;
|
|
const name = prompt("新名称", node.name);
|
|
if (name === null) return;
|
|
try {
|
|
await api(`/database/api/nodes/${node.id}`, { method: "PATCH", body: { name } });
|
|
toastOk("已重命名");
|
|
bumpTree();
|
|
currentNode.update((n) => (n ? { ...n, name } : n));
|
|
} catch (e) {
|
|
toastErr(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
|
|
async function deleteNode(): Promise<void> {
|
|
if (node === null || !confirm(`确认删除「${node.name}」?软删除后不可见。`)) return;
|
|
try {
|
|
await api(`/database/api/nodes/${node.id}`, { method: "DELETE" });
|
|
toastOk("已删除");
|
|
currentNode.set(null);
|
|
bumpTree();
|
|
} catch (e) {
|
|
toastErr(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
</script>
|
|
|
|
{#if node === null}
|
|
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个文件夹或项目</div>
|
|
{:else}
|
|
<div class="mx-auto max-w-[1400px] px-9 py-9">
|
|
<div class="mb-2 text-[12.5px] text-ink-3">
|
|
{#each crumbs as c, i (i)}
|
|
{#if i > 0}<span class="mx-1 text-line">/</span>{/if}
|
|
<span>{c.name ?? "…"}</span>
|
|
{/each}
|
|
</div>
|
|
|
|
<div class="mb-5 flex items-center justify-between">
|
|
<div class="flex items-center gap-2 text-[17px] font-semibold text-ink">
|
|
{node.name}
|
|
<span class="tag">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
|
|
<span class="tag !border-line !text-ink-2">{ROLE_LABEL[node.role]}</span>
|
|
</div>
|
|
<div class="flex gap-1.5">
|
|
{#if canEdit && node.kind === "FOLDER"}
|
|
<button class="btn" onclick={() => (showCreateChild = true)}>
|
|
<Icon name="plus" size={13} /> 新建子节点
|
|
</button>
|
|
{/if}
|
|
{#if canManage}
|
|
<button class="btn" onclick={renameNode}><Icon name="pencil" size={13} /> 重命名</button>
|
|
<button class="btn btn-danger" onclick={deleteNode}><Icon name="trash" size={13} /> 删除</button>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="mb-[18px] flex gap-0.5 border-b border-line-soft">
|
|
{#each tabs as [id, label] (id)}
|
|
<button
|
|
class="-mb-px border-b-2 px-3.5 py-2 text-[13px] transition {tab === id
|
|
? 'border-accent font-semibold text-ink'
|
|
: 'border-transparent text-ink-3 hover:text-ink'}"
|
|
onclick={() => (tab = id)}
|
|
>{label}</button>
|
|
{/each}
|
|
</div>
|
|
|
|
{#if tab === "grants"}
|
|
<GrantsPanel {node} />
|
|
{:else if tab === "files" && node.kind === "PROJECT"}
|
|
<FilesPanel {node} />
|
|
{:else}
|
|
<OverviewPanel {node} />
|
|
{#if node.kind === "FOLDER"}
|
|
<div class="quiet mt-3.5">文件夹是透明组织节点,点左侧树展开以浏览子内容。</div>
|
|
{/if}
|
|
{/if}
|
|
</div>
|
|
{/if}
|
|
|
|
{#if showCreateChild && node}
|
|
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
|
|
<div class="form-row">
|
|
<label class="form-label" for="child-name">名称</label>
|
|
<input id="child-name" class="input" bind:value={newName} placeholder="例如:物理必修一" />
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="form-label" for="child-kind">类型</label>
|
|
<select id="child-kind" class="select" bind:value={newKind}>
|
|
<option value="FOLDER">文件夹</option>
|
|
<option value="PROJECT">项目(课程资源库)</option>
|
|
</select>
|
|
</div>
|
|
<div class="form-row">
|
|
<label class="form-label" for="child-desc">简介(可选)</label>
|
|
<textarea id="child-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
|
|
</div>
|
|
<div class="mt-4 flex justify-end gap-2">
|
|
<button class="btn" onclick={() => (showCreateChild = false)}>取消</button>
|
|
<button class="btn btn-primary" onclick={createChild}>创建</button>
|
|
</div>
|
|
</Modal>
|
|
{/if}
|