fix(database): 补回文件库的「授权」tab 与 /me 的显示名

迁移时整个授权 tab 连同四个端点一起漏掉了 —— 后端一直可用,前端零调用:
  GET/PUT/DELETE /nodes/:id/grants
  PUT /projects/:id/independent-permission
权限编辑是这个后台的核心用途,而它此前在界面上完全不可达。

tab 组装也修正为与旧 libraryBrowser 一致:概览恒有、文件仅 PROJECT、
授权仅 MANAGE。注意文件夹也有授权 tab —— 它虽是透明组织节点,授权仍
挂在节点上(ADR-0021);此前文件夹一个 tab 都没有。

GrantsPanel 的语义按契约 8.1:创建者授权不给收回入口;MANAGE 仅创建者
可授,前端不拦,后端 fail closed 的报错原样呈现;GROUP 主体走
/groups/search 下拉选,不手敲 id。

/database/api/me 加 displayName 与 avatarUrl:侧栏此前显示原始 userId。
旧页面是服务端渲染,handler 里查 Prisma 就有名字;页面不再服务端渲染后
(ADR-0029),模板闭包过的数据也是被迁移的契约的一部分,不是旧实现的
无关细节。

概览面板同时补回丢失的「类型」「更新时间」两行、导出 target 下拉、
节点标题旁的角色 tag,以及整块缺失的独立权限开关。
This commit is contained in:
2026-07-26 20:19:47 +08:00
parent eeb8f56742
commit cdeb29ccf2
4 changed files with 312 additions and 58 deletions
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts">
/**
* 节点授权面板。迁自已删除的 routes/libraryBrowser.ts `renderGrantsTab`(ADR-0029)。
*
* 迁移时整个「授权」tab 连同这四个端点一起漏掉了 —— 后端一直可用,只是前端没入口。
*
* 语义(契约 8.1 / ADR-0021):
* - 创建者授权(isCreatorGrant)不可收回、不可改;
* - MANAGE 仅创建者可授,这里不做前端拦截 —— 后端 fail closed,报错原样呈现;
* - GROUP 主体走 in-hub MemberGroup(ADR-0028),用 /groups/search 选,不手敲 id。
*/
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import type { Grant, MemberGroupSearchResult, NodeDetail, Role } from "./types.js";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
const ROLES: readonly Role[] = ["VIEW", "EDIT", "MANAGE"];
let grants = $state<Grant[] | null>(null);
let error = $state<string | null>(null);
let principalType = $state<"USER" | "GROUP">("USER");
let userIdInput = $state("");
let groupId = $state("");
let groupOptions = $state<MemberGroupSearchResult[] | null>(null);
let role = $state<Role>("VIEW");
let saving = $state(false);
const canManage = $derived(node.role === "MANAGE");
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
$effect(() => {
void node.id;
void load();
});
async function load(): Promise<void> {
grants = null;
error = null;
try {
const r = await api<{ grants: Grant[] }>(`/database/api/nodes/${node.id}/grants`);
grants = r.grants;
} catch (e) {
error = errText(e);
}
}
/** 切到 GROUP 时懒加载候选组(活跃组 + breadcrumb)。 */
async function onTypeChange(): Promise<void> {
if (principalType !== "GROUP" || groupOptions !== null) return;
try {
const r = await api<{ groups: MemberGroupSearchResult[] }>("/database/api/groups/search?q=");
groupOptions = r.groups;
if (r.groups.length > 0 && groupId === "") groupId = r.groups[0]!.id;
} catch (e) {
toastErr(errText(e));
}
}
async function addGrant(): Promise<void> {
const principalId = principalType === "GROUP" ? groupId : userIdInput.trim();
if (principalId === "") {
toastErr("请填写主体");
return;
}
saving = true;
try {
// PUT /grants 是增量语义(putGrants),不是整表替换。
await api(`/database/api/nodes/${node.id}/grants`, {
method: "PUT",
body: { grants: [{ principalType, principalId, role }] },
});
toastOk("已授予");
userIdInput = "";
await load();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function revoke(g: Grant): Promise<void> {
if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return;
try {
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
method: "DELETE",
});
toastOk("已收回");
await load();
} catch (e) {
toastErr(errText(e));
}
}
/** 独立权限开关(仅 PROJECT;关闭时只继承父级,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
await load();
} catch (e) {
toastErr(errText(e));
}
}
</script>
<div class="panel">
{#if error !== null}
<div class="py-2 text-[12.5px] text-danger">{error}</div>
{:else if grants === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else}
<table class="list">
<thead>
<tr><th>主体</th><th>级别</th><th></th></tr>
</thead>
<tbody>
{#if grants.length === 0}
<tr><td colspan="3" class="quiet !py-[18px] text-center">暂无显式授权</td></tr>
{:else}
{#each grants as g (g.id)}
<tr>
<td>
<span class="inline-flex items-center gap-2">
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
<span class="font-mono text-[12px]">{g.principalId}</span>
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
</span>
</td>
<td class="file-meta">{g.role}</td>
<td class="text-right">
<!-- 创建者授权不可动(契约 8.1);非 MANAGE 也不给收回入口。 -->
{#if !g.isCreatorGrant && canManage}
<button class="link-danger inline-flex items-center gap-1" onclick={() => void revoke(g)}>
<Icon name="minus" size={12} /> 收回
</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/if}
{#if canManage}
<div class="my-3.5 border-t border-line-soft"></div>
<div class="section-title mb-2.5">新增授权</div>
<div class="flex flex-wrap items-center gap-2">
<select class="select !w-[110px]" bind:value={principalType} onchange={onTypeChange}>
<option value="USER">用户</option>
<option value="GROUP">Group</option>
</select>
{#if principalType === "USER"}
<input class="input min-w-0 flex-1" placeholder="用户 id" bind:value={userIdInput} />
{:else if groupOptions === null}
<span class="quiet flex-1">加载 Group 列表…</span>
{:else if groupOptions.length === 0}
<span class="quiet flex-1">暂无可选 Group · 先到「Group 管理」建一个</span>
{:else}
<select class="select min-w-0 flex-1" bind:value={groupId}>
{#each groupOptions as g (g.id)}
<option value={g.id}>{g.breadcrumb}</option>
{/each}
</select>
{/if}
<select class="select !w-[110px]" bind:value={role}>
{#each ROLES as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addGrant} disabled={saving}>
{saving ? "授予中…" : "授予"}
</button>
</div>
<div class="section-note mt-1.5">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>
{/if}
</div>
+51 -32
View File
@@ -4,9 +4,12 @@
import { toastOk, toastErr } from "./stores.js"; import { toastOk, toastErr } from "./stores.js";
import OverviewPanel from "./OverviewPanel.svelte"; import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte"; import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let tab = $state<"detail" | "files">("detail"); type Tab = "detail" | "files" | "grants";
let tab = $state<Tab>("detail");
let showCreateChild = $state(false); let showCreateChild = $state(false);
let newName = $state(""); let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER"); let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
@@ -17,6 +20,15 @@
const canManage = $derived(node?.role === "MANAGE"); const canManage = $derived(node?.role === "MANAGE");
const canEdit = $derived(canManage || node?.role === "EDIT"); 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(() => { $effect(() => {
void node?.id; void node?.id;
tab = "detail"; tab = "detail";
@@ -84,36 +96,43 @@
</div> </div>
<div class="mb-5 flex items-center justify-between"> <div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-2.5 text-[19px] font-semibold text-ink"> <div class="flex items-center gap-2 text-[17px] font-semibold text-ink">
{node.name} {node.name}
<span class="rounded-full border border-line-soft bg-panel px-2 py-0.5 font-mono text-[10.5px] text-ink-3">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span> <span class="tag">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag !border-line !text-ink-2">{node.role}</span>
</div> </div>
<div class="flex gap-2"> <div class="flex gap-1.5">
{#if canEdit && node.kind === "FOLDER"} {#if canEdit && node.kind === "FOLDER"}
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateChild = true)}>+ 新建</button> <button class="btn" onclick={() => (showCreateChild = true)}>
<Icon name="plus" size={13} /> 新建子节点
</button>
{/if} {/if}
{#if canManage} {#if canManage}
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={renameNode}>重命名</button> <button class="btn" onclick={renameNode}><Icon name="pencil" size={13} /> 重命名</button>
<button class="rounded-lg border border-transparent px-3 py-1.5 text-[12.5px] font-medium text-danger transition hover:bg-[#A13A3312]" onclick={deleteNode}>删除</button> <button class="btn btn-danger" onclick={deleteNode}><Icon name="trash" size={13} /> 删除</button>
{/if} {/if}
</div> </div>
</div> </div>
{#if node.kind === "FOLDER"} <div class="mb-[18px] flex gap-0.5 border-b border-line-soft">
<div class="py-7 text-[13px] text-ink-3">点击左侧树展开以浏览子内容</div> {#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} {:else}
<div class="mb-5 flex gap-1 border-b border-line-soft"> <OverviewPanel {node} />
{#each [["detail", "概览"], ["files", "文件"]] as [id, label] (id)} {#if node.kind === "FOLDER"}
<button <div class="quiet mt-3.5">文件夹是透明组织节点,点左侧树展开以浏览子内容。</div>
class="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 as "detail" | "files")}
>{label}</button>
{/each}
</div>
{#if tab === "detail"}
<OverviewPanel {node} />
{:else}
<FilesPanel {node} />
{/if} {/if}
{/if} {/if}
</div> </div>
@@ -121,24 +140,24 @@
{#if showCreateChild && node} {#if showCreateChild && node}
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}> <Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
<div class="mb-3"> <div class="form-row">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-name">名称</label> <label class="form-label" for="child-name">名称</label>
<input id="child-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理必修一" /> <input id="child-name" class="input" bind:value={newName} placeholder="例如:物理必修一" />
</div> </div>
<div class="mb-3"> <div class="form-row">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-kind">类型</label> <label class="form-label" for="child-kind">类型</label>
<select id="child-kind" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newKind}> <select id="child-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option> <option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option> <option value="PROJECT">项目(课程资源库)</option>
</select> </select>
</div> </div>
<div class="mb-3"> <div class="form-row">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-desc">简介(可选)</label> <label class="form-label" for="child-desc">简介(可选)</label>
<textarea id="child-desc" rows="3" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newDesc} placeholder="简要说明用途…"></textarea> <textarea id="child-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div> </div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateChild = false)}>取消</button> <button class="btn" onclick={() => (showCreateChild = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={createChild}>创建</button> <button class="btn btn-primary" onclick={createChild}>创建</button>
</div> </div>
</Modal> </Modal>
{/if} {/if}
+57 -25
View File
@@ -12,8 +12,25 @@
let exportJob = $state<ExportJob | null>(null); let exportJob = $state<ExportJob | null>(null);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT"); const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
const roleLabel = $derived(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读"); const roleLabel = $derived(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读");
/** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
$effect(() => { $effect(() => {
void node.id; void node.id;
exportJob = null; exportJob = null;
@@ -67,7 +84,7 @@
} }
</script> </script>
<div class="rounded-xl border border-line-soft bg-panel p-5"> <div class="panel">
<div class="mb-4"> <div class="mb-4">
<div class="mb-1.5 text-[11.5px] text-ink-3">简介</div> <div class="mb-1.5 text-[11.5px] text-ink-3">简介</div>
<div class="text-[13.5px] leading-7 text-ink"> <div class="text-[13.5px] leading-7 text-ink">
@@ -77,7 +94,7 @@
<span class="italic text-ink-3">暂无简介</span> <span class="italic text-ink-3">暂无简介</span>
{/if} {/if}
{#if canEdit} {#if canEdit}
<button class="ml-2.5 rounded-lg border border-line bg-panel px-2.5 py-0.5 align-middle text-[11.5px] text-ink transition hover:bg-hover" onclick={openEditDesc}>编辑</button> <button class="btn ml-2.5 !px-2.5 !py-0.5 align-middle !text-[11.5px]" onclick={openEditDesc}>编辑</button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -85,39 +102,54 @@
<div class="my-4 border-t border-line-soft"></div> <div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-col gap-1.5 text-[13px] text-ink-2"> <div class="flex flex-col gap-1.5 text-[13px] text-ink-2">
<div>类型 <b class="font-semibold text-ink">{node.kind === "PROJECT" ? "项目" : "文件夹"}</b></div>
<div>我的角色 <b class="font-semibold text-ink">{roleLabel}</b></div> <div>我的角色 <b class="font-semibold text-ink">{roleLabel}</b></div>
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}</b></div> <div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleString("zh-CN")}</b></div>
<div>最近修改 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}</b></div> <div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
</div> </div>
<div class="my-4 border-t border-line-soft"></div> <!-- 独立权限与导出都只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
{#if node.kind === "PROJECT"}
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-wrap items-center gap-2.5">
<span class="quiet">独立权限</span>
<b class="text-[13px]">{node.independentPermission ? "开启" : "关闭"}</b>
{#if canManage}
<button class="btn" onclick={toggleIndependent}>{node.independentPermission ? "关闭" : "开启"}</button>
{/if}
<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>
</div>
<div class="mb-2 text-[12.5px] font-semibold">导出</div> <div class="my-4 border-t border-line-soft"></div>
<div class="flex items-center gap-2">
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={submitExport}>开始导出</button> <div class="section-title mb-2">导出</div>
{#if exportJob} <div class="flex items-center gap-2">
<span class="font-mono text-[11px] text-ink-3"> <select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
{#if exportJob.status === "DONE"} <button class="btn" onclick={submitExport}>开始导出</button>
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a> {#if exportJob}
{:else if exportJob.status === "FAILED"} <span class="file-meta">
失败:{exportJob.error ?? ""} {#if exportJob.status === "DONE"}
{:else} 完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
{exportJob.status} {:else if exportJob.status === "FAILED"}
{/if} 失败:{exportJob.error ?? ""}
</span> {:else}
{/if} {exportJob.status}
</div> {/if}
</span>
{/if}
</div>
{/if}
</div> </div>
{#if showEditDesc} {#if showEditDesc}
<Modal title="编辑简介" onclose={() => (showEditDesc = false)}> <Modal title="编辑简介" onclose={() => (showEditDesc = false)}>
<div class="mb-3"> <div class="form-row">
<label class="mb-1 block text-[11.5px] text-ink-3" for="desc-draft">简要说明这个项目的内容</label> <label class="form-label" for="desc-draft">简要说明这个项目的内容</label>
<textarea id="desc-draft" rows="5" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] leading-7 outline-none focus:border-accent" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea> <textarea id="desc-draft" rows="5" class="input !leading-7" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
</div> </div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showEditDesc = false)}>取消</button> <button class="btn" onclick={() => (showEditDesc = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={saveDesc}>保存</button> <button class="btn btn-primary" onclick={saveDesc}>保存</button>
</div> </div>
</Modal> </Modal>
{/if} {/if}
+13 -1
View File
@@ -44,10 +44,22 @@ export async function registerFileLibRoutes(
/* ------------------------------------------------------------ 身份 */ /* ------------------------------------------------------------ 身份 */
// 前端判断能力面用:是否网站管理员(root 创建按钮显隐)。 // 前端判断能力面用:是否网站管理员(root 创建按钮显隐)。
// displayName/avatarUrl 供侧栏身份区显示 —— 页面不再服务端渲染(ADR-0029),
// 旧 renderDashboard 在 handler 里查 Prisma 拿到的名字,现在必须由这里带出去,
// 否则前端只有 userId 可显示。
app.get("/database/api/me", async (request, reply) => { app.get("/database/api/me", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps); const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply; if (actor === null) return reply;
return { userId: actor.userId, isWebsiteAdmin: actor.isWebsiteAdmin }; const user = await deps.prisma.user.findUnique({
where: { id: actor.userId },
select: { displayName: true, avatarUrl: true },
});
return {
userId: actor.userId,
isWebsiteAdmin: actor.isWebsiteAdmin,
displayName: user?.displayName ?? actor.userId,
avatarUrl: user?.avatarUrl ?? null,
};
}); });
/* ------------------------------------------------------------ 树节点 */ /* ------------------------------------------------------------ 树节点 */