From 91afd3c1b1e34623ab80530d846520b86618a925 Mon Sep 17 00:00:00 2001 From: ymy Date: Mon, 27 Jul 2026 14:09:35 +0800 Subject: [PATCH 01/24] =?UTF-8?q?fix(filelib-web):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=BA=93=E6=A0=91=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5=E6=97=B6?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E9=94=99=E8=AF=AF=E8=80=8C=E9=9D=9E=E6=B0=B8?= =?UTF-8?q?=E4=B9=85=E5=8A=A0=E8=BD=BD=E4=B8=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/filelib-web/src/lib/LibraryView.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/hub/filelib-web/src/lib/LibraryView.svelte b/hub/filelib-web/src/lib/LibraryView.svelte index 5a65257..23ff26c 100644 --- a/hub/filelib-web/src/lib/LibraryView.svelte +++ b/hub/filelib-web/src/lib/LibraryView.svelte @@ -79,10 +79,10 @@
- {#if roots === null} -
加载中…
- {:else if treeError} + {#if treeError}
{treeError}
+ {:else if roots === null} +
加载中…
{:else if roots.length === 0}
{$me?.isWebsiteAdmin ? "空文件库 · 点上方「+ 根目录」开始" : "文件库为空,请联系管理员创建根目录"} From 0dd2ae347eb965098aa64c1f53d26ff87f019b54 Mon Sep 17 00:00:00 2001 From: ymy Date: Mon, 27 Jul 2026 15:13:57 +0800 Subject: [PATCH 02/24] =?UTF-8?q?feat(filelib-web):=20=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E9=9D=A2=E6=9D=BF=E8=A1=A8=E6=A0=BC=E5=8C=96:=E6=90=9C?= =?UTF-8?q?=E7=B4=A2=E3=80=81=E6=B7=BB=E5=8A=A0=E5=BC=B9=E7=AA=97=E3=80=81?= =?UTF-8?q?=E6=9D=83=E9=99=90=E4=B8=8B=E6=8B=89=E4=B8=8E=E6=88=90=E5=91=98?= =?UTF-8?q?=E8=B7=B3=E8=BD=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GrantsPanel 重写为表格:顶部左侧授权成员搜索框(名称/id/类型过滤), 右侧「添加授权」弹窗(类型 + 主体搜索选择 + 权限);行内权限下拉 直接改级(复用 PUT upsert),操作列删除;成员单元格跳转用户管理 (?q= 过滤)或 Group 管理(?select= 选中)。 - grantService: GrantDto 增加 principalName,list/put/force 三处统一 批量回填(用户 displayName / 组 name),前端不再只显示裸 id。 - 用户管理页加过滤框并从 ?q= 初始化;GroupAdmin 支持 ?select= 直达。 - 测试:resetDb 补 MemberGroup 三表清理(全局表不被 org/user 级联清到, 此前跨用例污染导致级联软删用例断言失败);cph_hub_test 补 migrate。 - 顺带合并 types.ts 里重复的 Grant 声明(interface 合并残留)。 --- hub/filelib-web/src/lib/GrantsPanel.svelte | 337 +++++++++++++----- hub/filelib-web/src/lib/GroupAdmin.svelte | 9 +- hub/filelib-web/src/lib/types.ts | 11 +- .../database/dashboard/users/+page.svelte | 42 ++- hub/src/database/filelib/grantService.ts | 29 +- hub/test/integration/helpers.ts | 6 + 6 files changed, 335 insertions(+), 99 deletions(-) diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte index 3199707..a380ca3 100644 --- a/hub/filelib-web/src/lib/GrantsPanel.svelte +++ b/hub/filelib-web/src/lib/GrantsPanel.svelte @@ -1,37 +1,67 @@
+ +
+
+ + + + +
+ {#if canManage} + + {/if} +
+ {#if error !== null}
{error}
- {:else if grants === null} + {:else if shown === null}
加载中…
{:else} - + - {#if grants.length === 0} - + {#if shown.length === 0} + + + {:else} - {#each grants as g (g.id)} + {#each shown as g (g.id)} - + + + @@ -152,40 +296,71 @@
主体级别
成员类型权限加入时间
暂无显式授权
+ {searchText.trim() === "" ? "暂无授权" : `无匹配「${searchText.trim()}」的授权`} +
- - - {g.principalId} - {#if g.isCreatorGrant}(创建者){/if} - + + + + + + + {g.principalName ?? g.principalId} + {#if g.isCreatorGrant}(创建者){/if} + + {g.principalId} + + {g.role}{g.principalType === "USER" ? "个人" : "Group"} + + {#if !g.isCreatorGrant && canManage} + + {:else} + {g.role} + {/if} + {fmtDate(g.createdAt)} - {#if !g.isCreatorGrant && canManage} {/if}
{/if} +
- {#if canManage} -
-
新增授权
-
- + - - {#if principalType === "USER"} - - {:else if groupOptions === null} - 加载 Group 列表… - {:else if groupOptions.length === 0} - 暂无可选 Group · 先到「Group 管理」建一个 - {:else} - - {/if} - - +
+ {#if selectedPrincipal !== null} +
+ 已选 + + {selectedPrincipal.label}({selectedPrincipal.id}) + +
+ {/if} + {#if principalOptions !== null && principalOptions.length > 0} +
+ {#each principalOptions as o (o.id)} + + {/each} +
+ {:else if searchUnavailable} +
+ + +
+ {:else if principalOptions !== null} +
无匹配结果
+ {/if} +
+ + +
+
MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)
+
+
-
MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)
- {/if} - -
+ +{/if} diff --git a/hub/filelib-web/src/lib/GroupAdmin.svelte b/hub/filelib-web/src/lib/GroupAdmin.svelte index 4577c88..22de714 100644 --- a/hub/filelib-web/src/lib/GroupAdmin.svelte +++ b/hub/filelib-web/src/lib/GroupAdmin.svelte @@ -5,6 +5,7 @@ * 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。 */ import { onMount } from "svelte"; + import { page } from "$app/state"; import { api } from "./api.js"; import { toastErr, toastOk } from "./stores.js"; import type { MemberGroupNode, MemberGroupMember, UserSearchResult } from "./types.js"; @@ -172,7 +173,13 @@ } } - onMount(loadGroups); + onMount(async () => { + await loadGroups(); + // 授权面板「成员」单元格跳转:?select= 直接选中该组。 + // 组不在列表(已归档且未开归档展示)时不动作,停留默认态。 + const target = page.url.searchParams.get("select"); + if (target !== null && groups.some((g) => g.id === target)) select(target); + }); function select(id: string): void { selectedId = id; diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index f0bffd6..3a92fdc 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -72,15 +72,6 @@ export interface ExportJob { readonly createdAt: string; } -export interface Grant { - readonly id: string; - readonly principalType: "USER" | "GROUP"; - readonly principalId: string; - readonly role: Role; - readonly isCreatorGrant: boolean; - readonly createdAt: string; -} - export interface GroupSearchResult { readonly id: string; readonly name: string; @@ -114,6 +105,8 @@ export interface Grant { readonly id: string; readonly principalType: "USER" | "GROUP"; readonly principalId: string; + /** 主体显示名(用户 displayName / 组 name);主体已删为 null,展示回落 principalId。 */ + readonly principalName: string | null; readonly role: Role; /** 创建者授权不可收回、不可改(契约 8.1)。 */ readonly isCreatorGrant: boolean; diff --git a/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte b/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte index fbaede7..e40e0ae 100644 --- a/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte +++ b/hub/filelib-web/src/routes/database/dashboard/users/+page.svelte @@ -7,10 +7,12 @@ * 这里管的是 org 成员与其角色。 */ import { onMount } from "svelte"; + import { page } from "$app/state"; import { api } from "$lib/api.js"; import { loadConfig } from "$lib/config.js"; import { toastOk, toastErr } from "$lib/stores.js"; import type { OrgMember, OrgRole } from "$lib/types.js"; + import Icon from "$lib/Icon.svelte"; const ROLE_LABEL: Record = { OWNER: "所有者", @@ -23,6 +25,9 @@ let members = $state(null); let error = $state(null); + // 列表过滤;授权面板跳转会带 ?q=,以此为初始过滤词。 + let filterText = $state(page.url.searchParams.get("q") ?? ""); + let newOpenId = $state(""); let newName = $state(""); let newRole = $state("MEMBER"); @@ -30,6 +35,19 @@ const base = $derived(orgSlug === null ? null : `/api/org/${encodeURIComponent(orgSlug)}`); + /** 按显示名 / userId / openId 过滤(纯前端;成员全量在手)。 */ + const shown = $derived.by((): OrgMember[] | null => { + if (members === null) return null; + const q = filterText.trim().toLowerCase(); + if (q === "") return members; + return members.filter( + (m) => + m.displayName.toLowerCase().includes(q) || + m.userId.toLowerCase().includes(q) || + m.feishuOpenId.toLowerCase().includes(q), + ); + }); + async function load(): Promise { if (base === null) return; try { @@ -121,14 +139,28 @@
-
成员列表
+
+
成员列表
+
+ + + + +
+
{#if error}
{error}
- {:else if members === null} + {:else if shown === null}
加载中…
- {:else if members.length === 0} -
暂无成员
+ {:else if shown.length === 0} +
+ {filterText.trim() === "" ? "暂无成员" : `无匹配「${filterText.trim()}」的成员`} +
{:else} @@ -140,7 +172,7 @@ - {#each members as m (m.userId)} + {#each shown as m (m.userId)} diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts index cbb9f05..e52ea09 100644 --- a/hub/src/database/filelib/grantService.ts +++ b/hub/src/database/filelib/grantService.ts @@ -25,6 +25,8 @@ export interface GrantDto { readonly id: string; readonly principalType: "USER" | "GROUP"; readonly principalId: string; + /** 主体显示名(用户 displayName / 组 name);主体已删时为 null,前端回落 principalId。 */ + readonly principalName: string | null; readonly role: FileLibRole; readonly isCreatorGrant: boolean; readonly createdAt: Date; @@ -35,12 +37,33 @@ function toDto(grant: FileLibGrant): GrantDto { id: grant.id, principalType: grant.principalType, principalId: grant.principalId, + principalName: null, role: grant.role, isCreatorGrant: grant.isCreatorGrant, createdAt: grant.createdAt, }; } +/** 批量回填主体显示名(两次查询,不做 per-row 往返)。可在事务内调用。 */ +async function withPrincipalNames( + prisma: Pick, + grants: readonly GrantDto[], +): Promise { + const userIds = [...new Set(grants.filter((g) => g.principalType === "USER").map((g) => g.principalId))]; + const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))]; + const users = userIds.length === 0 + ? [] + : await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true } }); + const groups = groupIds.length === 0 + ? [] + : await prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }); + const nameById = new Map([ + ...users.map((u) => [u.id, u.displayName] as const), + ...groups.map((g) => [g.id, g.name] as const), + ]); + return grants.map((g) => ({ ...g, principalName: nameById.get(g.principalId) ?? null })); +} + type Tx = Prisma.TransactionClient; type Deps = AccessDeps & { readonly prisma: PrismaClient }; @@ -66,7 +89,7 @@ export async function listGrants( where: { organizationId: deps.organizationId, nodeId, revokedAt: null }, orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], }); - return grants.map(toDto); + return withPrincipalNames(deps.prisma, grants.map(toDto)); } export interface PutGrantsResult { @@ -137,7 +160,7 @@ export async function putGrants( where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null }, orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], }); - return { granted, updated, grants: grants.map(toDto) }; + return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) }; }); } @@ -236,7 +259,7 @@ export async function forceAdjustGrants( where: { organizationId: deps.organizationId, nodeId: node.id, revokedAt: null }, orderBy: [{ isCreatorGrant: "desc" }, { createdAt: "asc" }], }); - return { granted, updated, grants: grants.map(toDto) }; + return { granted, updated, grants: await withPrincipalNames(tx, grants.map(toDto)) }; }); } diff --git a/hub/test/integration/helpers.ts b/hub/test/integration/helpers.ts index aca8e73..d69715b 100644 --- a/hub/test/integration/helpers.ts +++ b/hub/test/integration/helpers.ts @@ -45,6 +45,12 @@ export async function resetDb(): Promise { // two tables have no FK to Project and must be cleared explicitly. prisma.permissionGrant.deleteMany(), prisma.permissionSettings.deleteMany(), + // MemberGroup is global (ADR-0028): no FK to the org/user roots, so the + // cascade above never reaches it. Clear explicitly — closure/membership + // first (they FK into MemberGroup), groups last. + prisma.memberGroupClosure.deleteMany(), + prisma.memberGroupMembership.deleteMany(), + prisma.memberGroup.deleteMany(), prisma.user.deleteMany(), prisma.organization.deleteMany(), ]); From be17f74fc2468c8f965dbb33910c25ee8ea6874e Mon Sep 17 00:00:00 2001 From: ymy Date: Mon, 27 Jul 2026 15:57:41 +0800 Subject: [PATCH 03/24] =?UTF-8?q?chore(filelib-web):=20=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=BA=93=E6=9D=83=E9=99=90=E5=90=8D=E7=A7=B0=E6=B1=89=E5=8C=96?= =?UTF-8?q?(VIEW/EDIT/MANAGE=20=E2=86=92=20=E5=8F=AA=E8=AF=BB/=E5=8F=AF?= =?UTF-8?q?=E7=BC=96=E8=BE=91/=E5=8F=AF=E7=AE=A1=E7=90=86)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 统一走新增共享常量 labels.ts ROLE_LABEL(与 OverviewPanel 既有文案一致); 覆盖授权表格与弹窗下拉、详情头 tag、树节点角标;API 传参仍用英文枚举。 --- hub/filelib-web/src/lib/GrantsPanel.svelte | 9 +++++---- hub/filelib-web/src/lib/NodeDetailPanel.svelte | 3 ++- hub/filelib-web/src/lib/OverviewPanel.svelte | 3 ++- hub/filelib-web/src/lib/TreeNode.svelte | 3 ++- hub/filelib-web/src/lib/labels.ts | 10 ++++++++++ 5 files changed, 21 insertions(+), 7 deletions(-) create mode 100644 hub/filelib-web/src/lib/labels.ts diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte index a380ca3..6d1b6ba 100644 --- a/hub/filelib-web/src/lib/GrantsPanel.svelte +++ b/hub/filelib-web/src/lib/GrantsPanel.svelte @@ -15,6 +15,7 @@ */ import { api } from "./api.js"; import { toastOk, toastErr } from "./stores.js"; + import { ROLE_LABEL } from "./labels.js"; import type { Grant, MemberGroupSearchResult, NodeDetail, Role, UserSearchResult } from "./types.js"; import Icon from "./Icon.svelte"; import Modal from "./Modal.svelte"; @@ -199,7 +200,7 @@ } async function revoke(g: Grant): Promise { - if (!confirm(`删除「${g.principalName ?? g.principalId}」的 ${g.role} 授权?`)) return; + if (!confirm(`删除「${g.principalName ?? g.principalId}」的${ROLE_LABEL[g.role]}授权?`)) return; try { await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, { method: "DELETE", @@ -275,11 +276,11 @@ onchange={(e) => void changeRole(g, e.currentTarget.value as Role)} > {#each ROLES as r (r)} - + {/each} {:else} - {g.role} + {ROLE_LABEL[g.role]} {/if} @@ -351,7 +352,7 @@ diff --git a/hub/filelib-web/src/lib/NodeDetailPanel.svelte b/hub/filelib-web/src/lib/NodeDetailPanel.svelte index fe437c2..3570db7 100644 --- a/hub/filelib-web/src/lib/NodeDetailPanel.svelte +++ b/hub/filelib-web/src/lib/NodeDetailPanel.svelte @@ -2,6 +2,7 @@ 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"; @@ -99,7 +100,7 @@
{node.name} {node.kind === "PROJECT" ? "项目" : "文件夹"} - {node.role} + {ROLE_LABEL[node.role]}
{#if canEdit && node.kind === "FOLDER"} diff --git a/hub/filelib-web/src/lib/OverviewPanel.svelte b/hub/filelib-web/src/lib/OverviewPanel.svelte index 3c3b31d..326a536 100644 --- a/hub/filelib-web/src/lib/OverviewPanel.svelte +++ b/hub/filelib-web/src/lib/OverviewPanel.svelte @@ -2,6 +2,7 @@ import { api } from "./api.js"; import { toastOk, toastErr } from "./stores.js"; import { currentNode } from "./browser.js"; + import { ROLE_LABEL } from "./labels.js"; import type { ExportJob, NodeDetail } from "./types.js"; import Modal from "./Modal.svelte"; @@ -13,7 +14,7 @@ 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(ROLE_LABEL[node.role]); /** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */ async function toggleIndependent(): Promise { diff --git a/hub/filelib-web/src/lib/TreeNode.svelte b/hub/filelib-web/src/lib/TreeNode.svelte index cb46268..7d72f0e 100644 --- a/hub/filelib-web/src/lib/TreeNode.svelte +++ b/hub/filelib-web/src/lib/TreeNode.svelte @@ -3,6 +3,7 @@ import { api } from "./api.js"; import { expanded, currentNode, breadcrumb, toggleExpanded, treeVersion } from "./browser.js"; import { toastErr } from "./stores.js"; + import { ROLE_LABEL } from "./labels.js"; import type { BreadcrumbEntry, NodeChild, NodeDetail } from "./types.js"; let { node, depth }: { node: NodeChild; depth: number } = $props(); @@ -64,7 +65,7 @@ {node.name} {#if node.role !== "MANAGE"} - {node.role} + {ROLE_LABEL[node.role]} {/if}
diff --git a/hub/filelib-web/src/lib/labels.ts b/hub/filelib-web/src/lib/labels.ts new file mode 100644 index 0000000..3468e83 --- /dev/null +++ b/hub/filelib-web/src/lib/labels.ts @@ -0,0 +1,10 @@ +/** 展示层文案(与 API 枚举值解耦;传参仍用英文枚举)。 */ + +import type { Role } from "./types.js"; + +/** 文件库权限级(契约 8.1 MANAGE>EDIT>VIEW)的中文展示名。 */ +export const ROLE_LABEL: Record = { + VIEW: "只读", + EDIT: "可编辑", + MANAGE: "可管理", +}; From 12a1246a7a26b7e011ba62e12d99238c85d60feb Mon Sep 17 00:00:00 2001 From: ymy Date: Mon, 27 Jul 2026 16:16:16 +0800 Subject: [PATCH 04/24] =?UTF-8?q?chore(filelib-web):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E6=8E=88=E6=9D=83=E5=BC=B9=E7=AA=97=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E7=9A=84=208.1=20=E8=A7=84=E5=88=99=E6=8F=90=E7=A4=BA?= =?UTF-8?q?=E8=AF=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/filelib-web/src/lib/GrantsPanel.svelte | 1 - 1 file changed, 1 deletion(-) diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte index 6d1b6ba..ae9cced 100644 --- a/hub/filelib-web/src/lib/GrantsPanel.svelte +++ b/hub/filelib-web/src/lib/GrantsPanel.svelte @@ -356,7 +356,6 @@ {/each} -
MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)
{m.displayName || m.userId} {m.userId}{fmtDate(g.createdAt)}
- - - + {g.principalName ?? g.principalId} From cc4d9d907c388357225dc9a9d7d903fcdcecca9a Mon Sep 17 00:00:00 2001 From: ymy Date: Mon, 27 Jul 2026 16:40:25 +0800 Subject: [PATCH 06/24] =?UTF-8?q?chore(filelib-web):=20=E6=8E=88=E6=9D=83?= =?UTF-8?q?=E6=88=90=E5=91=98=E5=8D=95=E5=85=83=E6=A0=BC=E5=8F=AA=E7=95=99?= =?UTF-8?q?=E5=A4=B4=E5=83=8F+=E5=90=8D=E7=A7=B0,userId=20=E4=B8=8E?= =?UTF-8?q?=E9=A3=9E=E4=B9=A6=20ID=20=E6=8B=86=E4=B8=BA=E7=8B=AC=E7=AB=8B?= =?UTF-8?q?=E4=B8=A4=E5=88=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit GrantDto 增加 principalOpenId(USER 主体的 feishuOpenId),GROUP 行两列显示 —; 搜索过滤同步覆盖 openId。 --- hub/filelib-web/src/lib/GrantsPanel.svelte | 16 ++++++++-------- hub/filelib-web/src/lib/types.ts | 2 ++ hub/src/database/filelib/grantService.ts | 17 ++++++++++++++--- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte index 24fa650..80c7c7b 100644 --- a/hub/filelib-web/src/lib/GrantsPanel.svelte +++ b/hub/filelib-web/src/lib/GrantsPanel.svelte @@ -60,6 +60,7 @@ (g) => g.principalId.toLowerCase().includes(q) || (g.principalName ?? "").toLowerCase().includes(q) || + (g.principalOpenId ?? "").toLowerCase().includes(q) || (g.principalType === "USER" ? "个人" : "group").includes(q), ); }); @@ -241,12 +242,12 @@ {:else} - + {#if shown.length === 0} - @@ -256,15 +257,14 @@ + +
成员类型权限加入时间
成员userId飞书 ID类型权限加入时间
+ {searchText.trim() === "" ? "暂无授权" : `无匹配「${searchText.trim()}」的授权`}
- - - {g.principalName ?? g.principalId} - {#if g.isCreatorGrant}(创建者){/if} - - {g.principalId} + + {g.principalName ?? g.principalId} + {#if g.isCreatorGrant}(创建者){/if} {g.principalType === "USER" ? g.principalId : "—"}{g.principalOpenId ?? "—"} {g.principalType === "USER" ? "个人" : "Group"} diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 3a92fdc..b46e774 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -107,6 +107,8 @@ export interface Grant { readonly principalId: string; /** 主体显示名(用户 displayName / 组 name);主体已删为 null,展示回落 principalId。 */ readonly principalName: string | null; + /** USER 主体的飞书 openId;GROUP 或主体已删为 null。 */ + readonly principalOpenId: string | null; readonly role: Role; /** 创建者授权不可收回、不可改(契约 8.1)。 */ readonly isCreatorGrant: boolean; diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts index e52ea09..f7be820 100644 --- a/hub/src/database/filelib/grantService.ts +++ b/hub/src/database/filelib/grantService.ts @@ -27,6 +27,8 @@ export interface GrantDto { readonly principalId: string; /** 主体显示名(用户 displayName / 组 name);主体已删时为 null,前端回落 principalId。 */ readonly principalName: string | null; + /** USER 主体的飞书 openId;GROUP 或主体已删时为 null。 */ + readonly principalOpenId: string | null; readonly role: FileLibRole; readonly isCreatorGrant: boolean; readonly createdAt: Date; @@ -38,13 +40,14 @@ function toDto(grant: FileLibGrant): GrantDto { principalType: grant.principalType, principalId: grant.principalId, principalName: null, + principalOpenId: null, role: grant.role, isCreatorGrant: grant.isCreatorGrant, createdAt: grant.createdAt, }; } -/** 批量回填主体显示名(两次查询,不做 per-row 往返)。可在事务内调用。 */ +/** 批量回填主体显示名与飞书 openId(两次查询,不做 per-row 往返)。可在事务内调用。 */ async function withPrincipalNames( prisma: Pick, grants: readonly GrantDto[], @@ -53,7 +56,10 @@ async function withPrincipalNames( const groupIds = [...new Set(grants.filter((g) => g.principalType === "GROUP").map((g) => g.principalId))]; const users = userIds.length === 0 ? [] - : await prisma.user.findMany({ where: { id: { in: userIds } }, select: { id: true, displayName: true } }); + : await prisma.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, displayName: true, feishuOpenId: true }, + }); const groups = groupIds.length === 0 ? [] : await prisma.memberGroup.findMany({ where: { id: { in: groupIds } }, select: { id: true, name: true } }); @@ -61,7 +67,12 @@ async function withPrincipalNames( ...users.map((u) => [u.id, u.displayName] as const), ...groups.map((g) => [g.id, g.name] as const), ]); - return grants.map((g) => ({ ...g, principalName: nameById.get(g.principalId) ?? null })); + const openIdById = new Map(users.map((u) => [u.id, u.feishuOpenId] as const)); + return grants.map((g) => ({ + ...g, + principalName: nameById.get(g.principalId) ?? null, + principalOpenId: g.principalType === "USER" ? openIdById.get(g.principalId) ?? null : null, + })); } type Tx = Prisma.TransactionClient; From fc908eaf3b4ddab01dcc3235ba64ba6f6bbd8f8a Mon Sep 17 00:00:00 2001 From: ymy Date: Mon, 27 Jul 2026 16:47:05 +0800 Subject: [PATCH 07/24] =?UTF-8?q?chore(filelib-web):=20=E6=8B=89=E5=AE=BD?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E8=A1=A8=E6=A0=BC:=E5=AE=B9=E5=99=A8=20880?= =?UTF-8?q?=E2=86=921120px,=E5=8D=95=E5=85=83=E6=A0=BC=E7=95=99=E7=99=BD/?= =?UTF-8?q?=E7=A6=81=E6=8D=A2=E8=A1=8C,=E9=95=BF=20id=20=E6=88=AA=E6=96=AD?= =?UTF-8?q?+=E6=82=AC=E5=81=9C=E5=85=A8=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/filelib-web/src/lib/GrantsPanel.svelte | 28 +++++++++++++------ .../src/lib/NodeDetailPanel.svelte | 2 +- 2 files changed, 21 insertions(+), 9 deletions(-) diff --git a/hub/filelib-web/src/lib/GrantsPanel.svelte b/hub/filelib-web/src/lib/GrantsPanel.svelte index 80c7c7b..94fe25c 100644 --- a/hub/filelib-web/src/lib/GrantsPanel.svelte +++ b/hub/filelib-web/src/lib/GrantsPanel.svelte @@ -242,7 +242,15 @@ {:else} - + + + + + + + + + {#if shown.length === 0} @@ -254,7 +262,7 @@ {:else} {#each shown as g (g.id)} - - - - - + + + - - +
成员userId飞书 ID类型权限加入时间
成员userId飞书 ID类型权限加入时间
+ @@ -263,10 +271,14 @@ {g.principalType === "USER" ? g.principalId : "—"}{g.principalOpenId ?? "—"}{g.principalType === "USER" ? "个人" : "Group"} + + {g.principalType === "USER" ? g.principalId : "—"} + + {g.principalOpenId ?? "—"} + {g.principalType === "USER" ? "个人" : "Group"} {#if !g.isCreatorGrant && canManage} {fmtDate(g.createdAt)} + {fmtDate(g.createdAt)} {#if !g.isCreatorGrant && canManage}
+ {/if} From 947f96996757b1249355fbc6903aa735ad8a715d Mon Sep 17 00:00:00 2001 From: ymy Date: Mon, 27 Jul 2026 17:00:41 +0800 Subject: [PATCH 09/24] =?UTF-8?q?chore(filelib-web):=20=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=AE=B9=E5=99=A8=E5=8A=A0=E5=AE=BD=E5=88=B0=201400px,?= =?UTF-8?q?=E5=AE=BD=E5=B1=8F=E4=B8=8B=E6=8E=88=E6=9D=83=E8=A1=A8=E6=A0=BC?= =?UTF-8?q?=E5=85=A8=E5=88=97=E6=97=A0=E9=81=AE=E6=8C=A1;=E4=BB=85?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E9=A2=84=E8=A7=88=E6=8C=A4=E5=8E=8B=E6=97=B6?= =?UTF-8?q?=E5=87=BA=E6=A8=AA=E5=90=91=E6=BB=9A=E5=8A=A8=E6=9D=A1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/filelib-web/src/lib/NodeDetailPanel.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/filelib-web/src/lib/NodeDetailPanel.svelte b/hub/filelib-web/src/lib/NodeDetailPanel.svelte index 9a4f9f2..5c15c6e 100644 --- a/hub/filelib-web/src/lib/NodeDetailPanel.svelte +++ b/hub/filelib-web/src/lib/NodeDetailPanel.svelte @@ -88,7 +88,7 @@ {#if node === null}
从左侧选择一个文件夹或项目
{:else} -
+
{#each crumbs as c, i (i)} {#if i > 0}/{/if} From 39a2be6347774aca659910d79bbc16d2c7271823 Mon Sep 17 00:00:00 2001 From: ymy Date: Thu, 30 Jul 2026 22:27:45 +0800 Subject: [PATCH 10/24] =?UTF-8?q?feat(filelib):=20=E9=A1=B9=E7=9B=AE?= =?UTF-8?q?=E7=BA=A7=E6=8E=88=E6=9D=83=E6=81=92=E7=94=9F=E6=95=88,?= =?UTF-8?q?=E7=A7=BB=E9=99=A4=E7=8B=AC=E7=AB=8B=E6=9D=83=E9=99=90=E5=BC=80?= =?UTF-8?q?=E5=85=B3(ADR-0030)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - permission.ts: effectiveRole 删除 D11 冻结分支,输入不再含开关字段 - treeService: 停止读 FileLibProjectSettings;建项目不再写默认行 - grantService/routes: 删 setIndependentPermission 与 PUT 路由;节点详情 DTO 去掉 independentPermission - filelib-web: 概览 tab 移除开关;NodeDetail 类型同步 - 测试: 单测/集成改为断言恒生效语义;ADR-0030 废除契约 D11/P5 - FileLibProjectSettings 表保留(存量行忽略,不再读写),审计词表保留历史读取 --- .../ses_06d37cad5ffeB8d2unoGG7uv3L.json | 10 +++++ .../ses_06dc3e1e5ffeER5ROC55lzS6xX.json | 10 +++++ docs/adr/0030-project-grants-always-live.md | 43 +++++++++++++++++++ hub/filelib-web/src/lib/OverviewPanel.svelte | 29 +------------ hub/filelib-web/src/lib/types.ts | 1 - hub/src/database/filelib/grantService.ts | 39 ----------------- hub/src/database/filelib/permission.ts | 12 ++---- hub/src/database/filelib/treeService.ts | 33 +------------- hub/src/database/routes/filelibRoutes.ts | 20 --------- hub/test/integration/filelib-tree.test.ts | 13 ++---- hub/test/unit/filelib-permission.test.ts | 28 +++++------- 11 files changed, 83 insertions(+), 155 deletions(-) create mode 100644 .omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json create mode 100644 .omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json create mode 100644 docs/adr/0030-project-grants-always-live.md diff --git a/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json b/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json new file mode 100644 index 0000000..4573c9f --- /dev/null +++ b/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_06d37cad5ffeB8d2unoGG7uv3L", + "updatedAt": "2026-07-24T06:34:59.318Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-24T06:34:59.318Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json b/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json new file mode 100644 index 0000000..f65f3a7 --- /dev/null +++ b/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_06dc3e1e5ffeER5ROC55lzS6xX", + "updatedAt": "2026-07-24T06:08:43.845Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-07-24T06:08:43.845Z" + } + } +} \ No newline at end of file diff --git a/docs/adr/0030-project-grants-always-live.md b/docs/adr/0030-project-grants-always-live.md new file mode 100644 index 0000000..8e17408 --- /dev/null +++ b/docs/adr/0030-project-grants-always-live.md @@ -0,0 +1,43 @@ +# ADR 0030: Project Grants Are Always Live; The Independent-Permission Toggle Is Removed + +## Status + +Accepted. Supersedes the file-library contract rule **D11 / P5** (《文件库-接口契约.md》, +since deleted; recoverable from git history) which introduced the per-project +"独立权限" (independent permission) switch. + +## Context + +D11 gave each PROJECT a toggle (`FileLibProjectSettings.independentPermissionsEnabled`, +default off). While off, project-level non-creator grants were **frozen** — present in +`FileLibGrant` but excluded from `effectiveRole`; ancestor-chain grants and the creator's +auto-grant were unaffected. The intent was to support two workflows: "project follows the +folder's ACL" (off) vs "project has its own ACL" (on). + +In practice the toggle surprised operators twice: grants appeared to "not work" until +someone found and flipped a per-project switch buried in the 概览 tab, and the frozen state +was indistinguishable from missing grants in the UI. The product decision is that +project-level grants should simply always be live. + +## Decision + +- **Project-level grants always participate in `effectiveRole`.** The freeze branch in + `hub/src/database/filelib/permission.ts` is deleted; `EffectiveRoleInput` no longer + carries `independentPermissionsEnabled`. +- **The toggle surface is removed end-to-end**: `PUT /database/api/projects/:id/independent-permission`, + `grantService.setIndependentPermission`, the `independentPermission` field in the node + detail DTO, and the 概览 tab switch in `filelib-web`. +- **`FileLibProjectSettings` becomes vestigial.** The table stays (existing rows are + ignored, no data migration); new projects no longer get a default row. It may be dropped + in a future migration once nothing references it. +- Audit action vocabulary `independent_enable` / `independent_disable` is retained for + reading historical audit entries; no new entries are produced. + +Behavior change for existing deployments: projects whose toggle was off now have their +project-level grants effective immediately — this is the intended effect of the decision. + +## Consequences + +- Permission semantics shrink to the single P6 rule: `effective = max(grants on self ∪ + ancestors for user ∪ resolved groups)`, no exceptions by node kind. +- One less state dimension in tests and in the admin UI. diff --git a/hub/filelib-web/src/lib/OverviewPanel.svelte b/hub/filelib-web/src/lib/OverviewPanel.svelte index 326a536..694bf02 100644 --- a/hub/filelib-web/src/lib/OverviewPanel.svelte +++ b/hub/filelib-web/src/lib/OverviewPanel.svelte @@ -13,25 +13,8 @@ let exportJob = $state(null); const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT"); - const canManage = $derived(node.role === "MANAGE"); const roleLabel = $derived(ROLE_LABEL[node.role]); - /** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */ - async function toggleIndependent(): Promise { - 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(() => { void node.id; exportJob = null; @@ -109,18 +92,8 @@
更新时间 {new Date(node.updatedAt).toLocaleString("zh-CN")}
- + {#if node.kind === "PROJECT"} -
-
- 独立权限 - {node.independentPermission ? "开启" : "关闭"} - {#if canManage} - - {/if} - 关闭时仅继承父级权限(创建者除外) -
-
导出
diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index b46e774..74f472b 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -28,7 +28,6 @@ export interface NodeDetail { readonly description: string | null; readonly role: Role; readonly provisionStatus: "PROVISIONING" | "READY" | "FAILED"; - readonly independentPermission: boolean; readonly createdAt: string; readonly updatedAt: string; } diff --git a/hub/src/database/filelib/grantService.ts b/hub/src/database/filelib/grantService.ts index f7be820..3599062 100644 --- a/hub/src/database/filelib/grantService.ts +++ b/hub/src/database/filelib/grantService.ts @@ -274,45 +274,6 @@ export async function forceAdjustGrants( }); } -/** 项目独立权限开关(P5/D11):需 MANAGE;状态不变则空操作。 */ -export async function setIndependentPermission( - deps: Deps, - actor: FileLibActor, - nodeId: string, - enabled: boolean, -): Promise<{ readonly enabled: boolean }> { - return deps.prisma.$transaction(async (tx) => { - const { node } = await requireManage(deps, actor, nodeId, tx); - if (node.kind !== "PROJECT") { - throw new FileLibError(400, "invalid_node_kind", "independent permission applies to projects only"); - } - const current = await tx.fileLibProjectSettings.findUnique({ - where: { nodeId: node.id }, - select: { independentPermissionsEnabled: true }, - }); - if ((current?.independentPermissionsEnabled ?? false) === enabled) { - return { enabled }; // 状态未变:空操作,不产生审计 - } - await tx.fileLibProjectSettings.upsert({ - where: { nodeId: node.id }, - update: { independentPermissionsEnabled: enabled }, - create: { nodeId: node.id, independentPermissionsEnabled: enabled }, - }); - await writeFileLibAudit(tx, { - action: enabled - ? FILE_LIB_AUDIT_ACTIONS.independentEnable - : FILE_LIB_AUDIT_ACTIONS.independentDisable, - actorUserId: actor.userId, - organizationId: deps.organizationId, - objectType: "project", - objectId: node.id, - objectPath: node.pathIds, - detail: { enabled }, - }); - return { enabled }; - }); -} - function validateGrantItems(items: readonly InitialGrant[]): void { if (items.length === 0) throw new FileLibError(400, "invalid_request", "grants must not be empty"); const seen = new Set(); diff --git a/hub/src/database/filelib/permission.ts b/hub/src/database/filelib/permission.ts index c6bdba4..7724494 100644 --- a/hub/src/database/filelib/permission.ts +++ b/hub/src/database/filelib/permission.ts @@ -1,5 +1,5 @@ /** - * 纯权限 reducer(契约 P6 / D11 / D8)。 + * 纯权限 reducer(契约 P6 / D8)。 * * 设计约束(Metis 评审):本文件是纯函数层 —— 输入是"已解析好的" grant、祖先链 * 与用户组集合,不碰 DB / 网络。数据获取在 treeService。这样权限代数可以脱离 @@ -23,8 +23,6 @@ export interface EffectiveRoleInput { readonly nodeKind: "FOLDER" | "PROJECT"; /** 目标的全部祖先 id(不含 self,顺序无关)。 */ readonly ancestorIds: readonly string[]; - /** 项目独立权限开关(D11/P5);文件夹忽略此值。 */ - readonly independentPermissionsEnabled: boolean; readonly userId: string; /** C2 resolve 结果:用户直接所属 + 全部祖先 group 的 id 集合。 */ readonly groupIds: readonly string[]; @@ -37,20 +35,16 @@ export interface EffectiveRoleInput { * r ∈ {R} ∪ ancestors(R) };无匹配 → null(无任何权限)。 * "个人权限不能降权"在 max 语义下天然成立 —— 只取最高,不做减法。 * - * D11:目标为 PROJECT 且独立权限关闭时,项目级(挂在 self 上)非创建者 grant - * 冻结不参与计算;创建者的自动 grant(isCreatorGrant)始终生效。祖先链上的 - * grant 不受开关影响。 + * 项目级 grant 恒参与计算(ADR-0030):原 D11 独立权限开关已废除, + * FileLibProjectSettings 不再被读取。 */ export function effectiveRole(input: EffectiveRoleInput): FileLibRole | null { const onChain = new Set([input.nodeId, ...input.ancestorIds]); const groups = new Set(input.groupIds); - const freezeProjectGrants = - input.nodeKind === "PROJECT" && !input.independentPermissionsEnabled; let best: FileLibRole | null = null; for (const grant of input.grants) { if (!onChain.has(grant.nodeId)) continue; - if (freezeProjectGrants && grant.nodeId === input.nodeId && !grant.isCreatorGrant) continue; if (grant.principalType === "USER" && grant.principalId !== input.userId) continue; if (grant.principalType === "GROUP" && !groups.has(grant.principalId)) continue; if (best === null || ROLE_RANK[grant.role] > ROLE_RANK[best]) best = grant.role; diff --git a/hub/src/database/filelib/treeService.ts b/hub/src/database/filelib/treeService.ts index 75a1825..10322b4 100644 --- a/hub/src/database/filelib/treeService.ts +++ b/hub/src/database/filelib/treeService.ts @@ -94,7 +94,7 @@ async function loadVisibleChain( return { node, ancestors: ordered }; } -/** 数据获取层:把 chain、grants、groups、toggle 装配成纯 reducer 的输入。 */ +/** 数据获取层:把 chain、grants、groups 装配成纯 reducer 的输入。 */ async function resolveRole( tx: Tx, deps: AccessDeps, @@ -106,20 +106,11 @@ async function resolveRole( where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); - let independentPermissionsEnabled = false; - if (chain.node.kind === "PROJECT") { - const settings = await tx.fileLibProjectSettings.findUnique({ - where: { nodeId: chain.node.id }, - select: { independentPermissionsEnabled: true }, - }); - independentPermissionsEnabled = settings?.independentPermissionsEnabled ?? false; - } const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); return effectiveRole({ nodeId: chain.node.id, nodeKind: chain.node.kind, ancestorIds: chain.ancestors.map((a) => a.id), - independentPermissionsEnabled, userId: actor.userId, groupIds, grants, @@ -278,11 +269,6 @@ export async function createNode( }, }); } - if (input.kind === "PROJECT") { - await tx.fileLibProjectSettings.create({ - data: { nodeId: id, independentPermissionsEnabled: false }, - }); - } await writeFileLibAudit(tx, { action: nodeAction(input.kind, "Create"), @@ -482,20 +468,12 @@ export async function breadcrumb( where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: chainIds } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); - const settings = chain.node.kind === "PROJECT" - ? await tx.fileLibProjectSettings.findUnique({ - where: { nodeId: chain.node.id }, - select: { independentPermissionsEnabled: true }, - }) - : null; return chainNodes.map((current, depth) => { const role = effectiveRole({ nodeId: current.id, nodeKind: current.kind, ancestorIds: chainNodes.slice(0, depth).map((n) => n.id), - independentPermissionsEnabled: - current.id === chain.node.id ? settings?.independentPermissionsEnabled ?? false : false, userId: actor.userId, groupIds, grants: allGrants, @@ -545,14 +523,6 @@ export async function listChildren( where: { organizationId: deps.organizationId, revokedAt: null, nodeId: { in: idsToFetch } }, select: { nodeId: true, principalType: true, principalId: true, role: true, isCreatorGrant: true }, }); - const projectIds = children.filter((c) => c.kind === "PROJECT").map((c) => c.id); - const settingsRows = projectIds.length === 0 - ? [] - : await tx.fileLibProjectSettings.findMany({ - where: { nodeId: { in: projectIds } }, - select: { nodeId: true, independentPermissionsEnabled: true }, - }); - const toggleByNode = new Map(settingsRows.map((s) => [s.nodeId, s.independentPermissionsEnabled])); const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); const out: ChildNodeDto[] = []; @@ -561,7 +531,6 @@ export async function listChildren( nodeId: child.id, nodeKind: child.kind, ancestorIds: parentAncestorIds, - independentPermissionsEnabled: toggleByNode.get(child.id) ?? false, userId: actor.userId, groupIds, grants: allGrants, diff --git a/hub/src/database/routes/filelibRoutes.ts b/hub/src/database/routes/filelibRoutes.ts index 39cd339..9c6a2ac 100644 --- a/hub/src/database/routes/filelibRoutes.ts +++ b/hub/src/database/routes/filelibRoutes.ts @@ -21,7 +21,6 @@ import { listGrants, putGrants, revokeGrant, - setIndependentPermission, } from "../filelib/grantService.js"; import { FileLibError } from "../filelib/model.js"; import { @@ -114,9 +113,6 @@ export async function registerFileLibRoutes( where: { id, organizationId: deps.organizationId }, }); if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); - const settings = node.kind === "PROJECT" - ? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } }) - : null; return { node: { id: node.id, @@ -126,7 +122,6 @@ export async function registerFileLibRoutes( description: node.description, role, provisionStatus: node.provisionStatus, - independentPermission: settings?.independentPermissionsEnabled ?? false, createdAt: node.createdAt, updatedAt: node.updatedAt, }, @@ -258,21 +253,6 @@ export async function registerFileLibRoutes( } }); - app.put("/database/api/projects/:id/independent-permission", async (request, reply) => { - const actor = await actorOrNull(request, reply, deps); - if (actor === null) return reply; - try { - const { id } = request.params as { id: string }; - const body = bodyObject(request.body); - if (typeof body["enabled"] !== "boolean") { - throw new FileLibError(400, "invalid_request", "enabled must be a boolean"); - } - return await setIndependentPermission(grantDeps, actor, id, body["enabled"]); - } catch (error) { - return sendRouteError(reply, error); - } - }); - // Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub // MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。 } diff --git a/hub/test/integration/filelib-tree.test.ts b/hub/test/integration/filelib-tree.test.ts index 93d8621..b2605af 100644 --- a/hub/test/integration/filelib-tree.test.ts +++ b/hub/test/integration/filelib-tree.test.ts @@ -80,20 +80,15 @@ describe("treeService · 创建规则", () => { }); }); -describe("treeService · D11 独立权限开关", () => { - it("关闭时项目级非创建者 grant 冻结,创建者仍 MANAGE", async () => { +describe("treeService · 项目级 grant 恒生效(ADR-0030)", () => { + it("项目级非创建者 grant 创建即生效,创建者仍 MANAGE", async () => { const project = await createNode(deps(), ADMIN, { parentId: null, kind: "PROJECT", name: "TH-141", grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }], }); - await expect(getEffectiveRole(deps(), ALICE, project.id)) - .rejects.toMatchObject({ statusCode: 404 }); // 冻结 = 无权限 = D8 不可见 + // 无开关、无冻结:alice 的项目级 EDIT 立即可见。 + expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT"); expect(await getEffectiveRole(deps(), ADMIN, project.id)).toBe("MANAGE"); - await prisma.fileLibProjectSettings.update({ - where: { nodeId: project.id }, - data: { independentPermissionsEnabled: true }, - }); - expect(await getEffectiveRole(deps(), ALICE, project.id)).toBe("EDIT"); // 恢复 }); }); diff --git a/hub/test/unit/filelib-permission.test.ts b/hub/test/unit/filelib-permission.test.ts index c54069e..102cde7 100644 --- a/hub/test/unit/filelib-permission.test.ts +++ b/hub/test/unit/filelib-permission.test.ts @@ -1,6 +1,6 @@ /** - * 纯权限 reducer 单测(契约 P6 / D11 / 2.3)。 - * 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限/toggle 冻结; + * 纯权限 reducer 单测(契约 P6 / 2.3)。 + * 矩阵覆盖:个人/Group/祖先继承/max 取最高/不降权/空权限; * 外加确定性随机化不变量(单调性:任何可用 grant 都不超过 effective)。 */ import { describe, expect, it } from "vitest"; @@ -10,7 +10,6 @@ const base: EffectiveRoleInput = { nodeId: "N", nodeKind: "FOLDER", ancestorIds: ["A", "R"], // N ⊂ A ⊂ R - independentPermissionsEnabled: false, userId: "u1", groupIds: ["g1"], grants: [], @@ -75,33 +74,28 @@ describe("effectiveRole · 契约 P6 矩阵", () => { }); }); -describe("effectiveRole · D11 独立权限开关", () => { +describe("effectiveRole · 项目级 grant 恒生效(ADR-0030)", () => { const project: EffectiveRoleInput = { ...base, nodeKind: "PROJECT", nodeId: "P" }; - it("开关关闭:项目级非创建者 grant 冻结", () => { + it("项目级非创建者 grant 直接参与(无开关、无冻结)", () => { const grants = [grant({ nodeId: "P", role: "EDIT" })]; - expect(effectiveRole({ ...project, grants })).toBeNull(); + expect(effectiveRole({ ...project, grants })).toBe("EDIT"); }); - it("开关关闭:创建者 grant 仍生效", () => { + it("创建者 grant 照常生效", () => { const grants = [grant({ nodeId: "P", role: "MANAGE", isCreatorGrant: true })]; expect(effectiveRole({ ...project, grants })).toBe("MANAGE"); }); - it("开关关闭:祖先链 grant 不受影响", () => { + it("项目级与祖先链 grant 同取 max", () => { const grants = [ - grant({ nodeId: "P", role: "MANAGE" }), // 冻结 - grant({ nodeId: "A", role: "VIEW" }), // 生效 + grant({ nodeId: "P", role: "VIEW" }), + grant({ nodeId: "A", role: "EDIT" }), ]; - expect(effectiveRole({ ...project, grants })).toBe("VIEW"); + expect(effectiveRole({ ...project, grants })).toBe("EDIT"); }); - it("开关开启:项目级 grant 恢复参与", () => { - const grants = [grant({ nodeId: "P", role: "EDIT" })]; - expect(effectiveRole({ ...project, independentPermissionsEnabled: true, grants })).toBe("EDIT"); - }); - - it("文件夹忽略开关(self grant 照常参与)", () => { + it("文件夹与项目语义一致(self grant 照常参与)", () => { const grants = [grant({ nodeId: "N", role: "EDIT" })]; expect(effectiveRole({ ...base, grants })).toBe("EDIT"); }); From 834f4c380cc4b7a44255c976630a4e1a9edfcdd3 Mon Sep 17 00:00:00 2001 From: ymy Date: Thu, 30 Jul 2026 22:31:08 +0800 Subject: [PATCH 11/24] =?UTF-8?q?chore:=20=E8=AF=AF=E6=8F=90=E4=BA=A4=20.o?= =?UTF-8?q?mo=20=E4=BC=9A=E8=AF=9D=E6=96=87=E4=BB=B6,=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E8=B7=9F=E8=B8=AA=E5=B9=B6=E5=8A=A0=E5=85=A5=20.gitignore?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.gitignore b/.gitignore index 08f5590..633ed63 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ node_modules/ # OS / editor .DS_Store + +.omo/ From ab5e03823c4fc8dcbfc0958b4fd61209a48018e0 Mon Sep 17 00:00:00 2001 From: ymy Date: Thu, 30 Jul 2026 22:32:07 +0800 Subject: [PATCH 12/24] =?UTF-8?q?chore:=20=E8=AF=AF=E6=8F=90=E4=BA=A4=20.o?= =?UTF-8?q?mo=20=E4=BC=9A=E8=AF=9D=E6=96=87=E4=BB=B6,=E7=A7=BB=E5=87=BA?= =?UTF-8?q?=E8=B7=9F=E8=B8=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ses_06d37cad5ffeB8d2unoGG7uv3L.json | 10 ---------- .../ses_06dc3e1e5ffeER5ROC55lzS6xX.json | 10 ---------- 2 files changed, 20 deletions(-) delete mode 100644 .omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json delete mode 100644 .omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json diff --git a/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json b/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json deleted file mode 100644 index 4573c9f..0000000 --- a/.omo/run-continuation/ses_06d37cad5ffeB8d2unoGG7uv3L.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_06d37cad5ffeB8d2unoGG7uv3L", - "updatedAt": "2026-07-24T06:34:59.318Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-24T06:34:59.318Z" - } - } -} \ No newline at end of file diff --git a/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json b/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json deleted file mode 100644 index f65f3a7..0000000 --- a/.omo/run-continuation/ses_06dc3e1e5ffeER5ROC55lzS6xX.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "sessionID": "ses_06dc3e1e5ffeER5ROC55lzS6xX", - "updatedAt": "2026-07-24T06:08:43.845Z", - "sources": { - "background-task": { - "state": "idle", - "updatedAt": "2026-07-24T06:08:43.845Z" - } - } -} \ No newline at end of file From 0fd21e51f7c3df3775fae2138f0c0258e127dcee Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 12:02:23 +0800 Subject: [PATCH 13/24] =?UTF-8?q?feat(filelib-web):=20=E8=80=81=E5=B8=88?= =?UTF-8?q?=E7=AB=AF=E6=96=87=E4=BB=B6=E5=BA=93=E6=94=B9=E7=BD=91=E7=9B=98?= =?UTF-8?q?=E5=BC=8F=E7=BD=91=E6=A0=BC=E6=B5=8F=E8=A7=88(=E4=B8=8B?= =?UTF-8?q?=E9=92=BB=E5=AF=BC=E8=88=AA+=E5=8F=B3=E9=94=AE=E8=8F=9C?= =?UTF-8?q?=E5=8D=95)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新 GridLibraryView:双击下钻文件夹/项目,面包屑+返回跳级,项目内文件同网格, 双击进 FileEditor 预览;工具条按上下文出新建文件夹/项目/文件 - 右键菜单(ContextMenu):打开/新建子文件夹/重命名/授权管理/详情/删除, 按节点 role 动态显隐;文件菜单:打开预览/下载/删除;空白处右键出新建+刷新 - GridCard 大图标卡片(琥珀文件夹/立方体项目/文档文件);授权与详情复用 GrantsPanel/OverviewPanel(Modal 加 maxW prop);Icon 补 5 个图标 - 管理后台 /database 保留树状 LibraryView 不动;后端零改动 --- hub/filelib-web/src/lib/ContextMenu.svelte | 55 ++ hub/filelib-web/src/lib/GridCard.svelte | 47 ++ .../src/lib/GridLibraryView.svelte | 534 ++++++++++++++++++ hub/filelib-web/src/lib/Icon.svelte | 5 + hub/filelib-web/src/lib/Modal.svelte | 4 +- hub/filelib-web/src/routes/app/+page.svelte | 6 +- 6 files changed, 646 insertions(+), 5 deletions(-) create mode 100644 hub/filelib-web/src/lib/ContextMenu.svelte create mode 100644 hub/filelib-web/src/lib/GridCard.svelte create mode 100644 hub/filelib-web/src/lib/GridLibraryView.svelte diff --git a/hub/filelib-web/src/lib/ContextMenu.svelte b/hub/filelib-web/src/lib/ContextMenu.svelte new file mode 100644 index 0000000..97de58c --- /dev/null +++ b/hub/filelib-web/src/lib/ContextMenu.svelte @@ -0,0 +1,55 @@ + + + + + diff --git a/hub/filelib-web/src/lib/GridCard.svelte b/hub/filelib-web/src/lib/GridCard.svelte new file mode 100644 index 0000000..5880111 --- /dev/null +++ b/hub/filelib-web/src/lib/GridCard.svelte @@ -0,0 +1,47 @@ + + + diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte new file mode 100644 index 0000000..4e91a70 --- /dev/null +++ b/hub/filelib-web/src/lib/GridLibraryView.svelte @@ -0,0 +1,534 @@ + + +
+ +
+ {#if view === "files" || stack.length > 0} + + {/if} + + + {#if view === "nodes" && canCreateHere} + + + {/if} + {#if view === "files" && projectCanEdit} + + {/if} + + +
+ {initial} + {$me?.displayName ?? $me?.userId ?? ""} + +
+
+ + +
+
{ e.preventDefault(); menu = { x: e.clientX, y: e.clientY, items: bgMenuItems() }; }} + > + {#if view === "nodes"} + {#if nodesError !== null} +
{nodesError}
+ {:else if children === null} +
加载中…
+ {:else if children.length === 0} +
+ {currentFolder === null ? "空文件库" : "空文件夹"}{canCreateHere ? " · 右键或点上方按钮新建" : ""} +
+ {:else} +
+ {#each children as n (n.id)} + (selected = n.id)} + onopen={() => openNode(n)} + oncontextmenu={(x, y) => (menu = { x, y, items: nodeMenuItems(n) })} + /> + {/each} +
+ {/if} + {:else} + {#if filesError !== null} +
{filesError}
+ {:else if files === null} +
加载中…
+ {:else if files.length === 0} +
空仓库{projectCanEdit ? " · 右键或点上方按钮新建文件" : ""}
+ {:else} +
+ {#each files as f (f.path)} + (selected = f.path)} + onopen={() => selectedFilePath.set(f.path)} + oncontextmenu={(x, y) => (menu = { x, y, items: fileMenuItems(f) })} + /> + {/each} +
+ {/if} + {/if} +
+ + {#if view === "files" && $selectedFilePath && projectNode} +
+ void loadFiles()} + onclose={clearSelectedFile} + /> +
+ {/if} +
+
+ +{#if menu} + (menu = null)} /> +{/if} + +{#if modal === "create"} + (modal = null)}> +
+ + +
+
+ + +
+
+ + +
+
+{/if} + +{#if modal === "rename" && renameTarget} + (modal = null)}> +
+ + +
+
+ + +
+
+{/if} + +{#if modal === "grants" && detailNode} + (modal = null)}> + + +{/if} + +{#if modal === "detail" && detailNode} + (modal = null)}> + + +{/if} + +{#if modal === "newFile"} + (modal = null)}> +
+ + +
+
+ + +
+
+ + +
+
+{/if} diff --git a/hub/filelib-web/src/lib/Icon.svelte b/hub/filelib-web/src/lib/Icon.svelte index 7c98aed..ce7c8ec 100644 --- a/hub/filelib-web/src/lib/Icon.svelte +++ b/hub/filelib-web/src/lib/Icon.svelte @@ -16,6 +16,11 @@ layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5", clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2", minus: "M5 12h14", + download: "M12 3v12m0 0 4-4m-4 4-4-4M4 17v2a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-2", + refresh: "M21 12a9 9 0 1 1-2.64-6.36M21 3v6h-6", + arrowLeft: "M19 12H5m0 0 6 6m-6-6 6-6", + info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-10v6m0-11v.5", + shield: "M12 3l8 3v6c0 4.5-3.2 7.7-8 9-4.8-1.3-8-4.5-8-9V6l8-3Z", // 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。 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", diff --git a/hub/filelib-web/src/lib/Modal.svelte b/hub/filelib-web/src/lib/Modal.svelte index 233736a..025d742 100644 --- a/hub/filelib-web/src/lib/Modal.svelte +++ b/hub/filelib-web/src/lib/Modal.svelte @@ -1,7 +1,7 @@
{ if (e.target === e.currentTarget) onclose(); }} > -
+
{title}
{@render children()}
diff --git a/hub/filelib-web/src/routes/app/+page.svelte b/hub/filelib-web/src/routes/app/+page.svelte index 26fd7c1..30bb9bf 100644 --- a/hub/filelib-web/src/routes/app/+page.svelte +++ b/hub/filelib-web/src/routes/app/+page.svelte @@ -1,10 +1,10 @@ @@ -15,7 +15,7 @@
加载中…
{:else if $me}
- +
{:else} From d072e9ec1eb9a92330cbbd3dc8e225a10b7e3d61 Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 13:27:04 +0800 Subject: [PATCH 14/24] =?UTF-8?q?feat(filelib):=20=E8=80=81=E5=B8=88?= =?UTF-8?q?=E7=AB=AF=E5=B7=A6=E6=A0=8F=E5=AF=BC=E8=88=AA:=E5=9B=9E?= =?UTF-8?q?=E6=94=B6=E7=AB=99=20+=20=E6=9C=80=E8=BF=91=E6=89=93=E5=BC=80(A?= =?UTF-8?q?DR-0031)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 回收站:listBin(祖先全活跃的已删顶点;管理员/直连 MANAGE 可见)、 restore(与 D15 对称只清本节点,落审计)、purge(仅管理员,pathIds 枚举 子树按深度降序分批硬删,绕过 self-FK RESTRICT) - 最近打开:FileLibRecentVisit 表(filePath='' 兜底 PG 唯一索引),客户端 成功打开后上报(VIEW 门禁,upsert 刷新),列表 20 条,D8/D15 可见性过滤 - 前端:/app 左栏(文件库/最近打开/回收站);RecentView/BinView; GridLibraryView 埋点 + navTarget 跳转(breadcrumb 建栈,role 已捎带) - 测试:filelib-nav 集成 4 例;全套 79 例绿 --- ...1-filelib-recycle-bin-and-recent-visits.md | 58 ++++++ hub/filelib-web/src/lib/BinView.svelte | 103 ++++++++++ .../src/lib/GridLibraryView.svelte | 80 +++++++- hub/filelib-web/src/lib/Icon.svelte | 1 + hub/filelib-web/src/lib/RecentView.svelte | 67 +++++++ hub/filelib-web/src/lib/types.ts | 24 ++- hub/filelib-web/src/routes/app/+page.svelte | 47 ++++- .../migration.sql | 65 +++++++ hub/prisma/schema.prisma | 15 ++ hub/src/database/filelib/audit.ts | 4 + hub/src/database/filelib/binService.ts | 181 ++++++++++++++++++ hub/src/database/filelib/recentService.ts | 106 ++++++++++ hub/src/database/filelib/treeService.ts | 5 +- hub/src/database/routes/binRoutes.ts | 45 +++++ hub/src/database/routes/databaseRoutes.ts | 4 + hub/src/database/routes/recentRoutes.ts | 47 +++++ hub/test/integration/filelib-nav.test.ts | 163 ++++++++++++++++ 17 files changed, 998 insertions(+), 17 deletions(-) create mode 100644 docs/adr/0031-filelib-recycle-bin-and-recent-visits.md create mode 100644 hub/filelib-web/src/lib/BinView.svelte create mode 100644 hub/filelib-web/src/lib/RecentView.svelte create mode 100644 hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql create mode 100644 hub/src/database/filelib/binService.ts create mode 100644 hub/src/database/filelib/recentService.ts create mode 100644 hub/src/database/routes/binRoutes.ts create mode 100644 hub/src/database/routes/recentRoutes.ts create mode 100644 hub/test/integration/filelib-nav.test.ts diff --git a/docs/adr/0031-filelib-recycle-bin-and-recent-visits.md b/docs/adr/0031-filelib-recycle-bin-and-recent-visits.md new file mode 100644 index 0000000..53ce452 --- /dev/null +++ b/docs/adr/0031-filelib-recycle-bin-and-recent-visits.md @@ -0,0 +1,58 @@ +# ADR 0031: File Library Recycle Bin And Recent-Visit Tracking + +## Status + +Accepted. + +## Context + +The teacher app (`/app`) gains a left navigation rail with three entries: 文件库 / +最近打开 / 回收站. Two of them need semantics that no prior decision covers: + +- **回收站 (recycle bin)**: D15 defined soft delete (mark `deletedAt` on the node only; + a node is invisible when any ancestor is deleted) but never defined listing, restore, + or permanent deletion. +- **最近打开 (recent visits)**: nothing tracks opens. + +## Decision + +### Recycle bin + +- **List**: shows nodes with `deletedAt != null` whose **ancestors are all active** + (the topmost deleted node per branch; descendants of a deleted node are represented + by it and not listed separately). +- **Visibility/auth**: a bin entry is visible to (a) the website administrator, or + (b) any actor holding an active MANAGE grant **on the deleted node itself** + (grants stay live through soft delete, so this is a plain grant query — no chain + walk, no inheritance; the bin is a management surface, not a browsing surface). +- **Restore** clears `deletedAt` on that node only (D15 symmetry: delete marks one + node, restore unmarks one node). The subtree becomes visible again immediately. + Same auth as the list entry. Audited (`folder_restore` / `project_restore`). +- **Permanent delete (彻底删除)** is **website-administrator only**: hard-deletes the + node **and its whole subtree** (descendants enumerated via the `pathIds` materialized + path, deleted deepest-first because the self-FK is `ON DELETE RESTRICT`), in one + transaction, with one audit entry (`node_purge`, detail carries removed count). + Grants/settings/export-jobs cascade. There is no recovery; the UI must confirm + explicitly. + +### Recent visits + +- **Model**: `FileLibRecentVisit(organizationId, userId, nodeId, filePath, openedAt)`, + unique on `(organizationId, userId, nodeId, filePath)` with `filePath` defaulting to + `""` (Postgres unique indexes treat NULLs as distinct). `filePath = ""` means the + visit is the node itself (drill into folder/project); non-empty means a file preview + inside that project. +- **Recording is client-driven**: the teacher app POSTs after a successful open + (folder drill, project open, file preview). The endpoint requires VIEW on the node + (D8: no VIEW → 404, leaking nothing). Upsert semantics: re-opening refreshes + `openedAt`. No audit entries — this is a per-user read model, not a权限-sensitive + mutation. +- **List**: the actor's own most recent 20, `openedAt` desc. Entries whose node is + deleted **or has any deleted ancestor** are filtered out (D8/D15 visibility holds + on every surface). Names are read live from `FileLibNode` (no denormalization). + +## Consequences + +- No change to existing permission algebra; both features are additive surfaces. +- The bin deliberately does not offer per-owner bins or inherited-MANAGE visibility — + if real usage demands it, that is a new decision. diff --git a/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte new file mode 100644 index 0000000..bc99895 --- /dev/null +++ b/hub/filelib-web/src/lib/BinView.svelte @@ -0,0 +1,103 @@ + + +
+

回收站

+ + {#if error !== null} +
{error}
+ {:else if entries === null} +
加载中…
+ {:else if entries.length === 0} +
回收站是空的
+ {:else} +
+ {#each entries as e (e.id)} +
+ + + {e.name} + 删除于 {fmt(e.deletedAt)} + + + {#if $me?.isWebsiteAdmin} + + {/if} +
+ {/each} +
+ {/if} +
diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte index 4e91a70..3e064bf 100644 --- a/hub/filelib-web/src/lib/GridLibraryView.svelte +++ b/hub/filelib-web/src/lib/GridLibraryView.svelte @@ -23,14 +23,23 @@ import GrantsPanel from "./GrantsPanel.svelte"; import OverviewPanel from "./OverviewPanel.svelte"; + /** 最近打开上报的导航目标(ADR-0031):父组件传入后,本组件跳到对应节点并清除。 */ + export interface NavTarget { + readonly nodeId: string; + readonly filePath?: string | undefined; + } + + let { navTarget = null, onnavigated }: { navTarget?: NavTarget | null; onnavigated?: () => void } = $props(); + const RANK: Record = { VIEW: 1, EDIT: 2, MANAGE: 3 }; const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]; type View = "nodes" | "files"; + type StackItem = Pick; let view = $state("nodes"); /** 下钻栈(均为 FOLDER;根层为空栈)。 */ - let stack = $state([]); + let stack = $state([]); let children = $state(null); let nodesError = $state(null); @@ -48,7 +57,7 @@ let createParentId = $state(null); let formName = $state(""); let formDesc = $state(""); - let renameTarget = $state(null); + let renameTarget = $state(null); let detailNode = $state(null); let newPath = $state(""); let newContent = $state(""); @@ -98,6 +107,14 @@ onMount(loadChildren); + /** 最近打开上报(ADR-0031):fire-and-forget,失败静默,不阻塞浏览。 */ + function record(nodeId: string, filePath?: string): void { + void api("/database/api/recent", { + method: "POST", + body: { nodeId, ...(filePath !== undefined ? { filePath } : {}) }, + }).catch(() => undefined); + } + function refresh(): void { selected = null; menu = null; @@ -107,8 +124,9 @@ /* ------------------------------------------------------------ 导航 */ - function openNode(n: NodeChild): void { + function openNode(n: StackItem): void { selected = null; + record(n.id); if (n.kind === "FOLDER") { stack = [...stack, n]; void loadChildren(); @@ -156,6 +174,43 @@ void loadChildren(); } + /** 跳到任意节点(最近打开入口):breadcrumb 建栈,FOLDER 进子层,PROJECT 进文件视图。 */ + async function navigateTo(target: NavTarget): Promise { + try { + const r = await api<{ breadcrumb: Array<{ id: string | null; name: string | null; kind: "FOLDER" | "PROJECT"; role: Role | null }> }>( + `/database/api/nodes/${target.nodeId}/breadcrumb`, + ); + const visible = r.breadcrumb.filter( + (e): e is { id: string; name: string; kind: "FOLDER" | "PROJECT"; role: Role | null } => + e.id !== null && e.name !== null, + ); + if (visible.length === 0) return; + const self = visible[visible.length - 1]!; + selected = null; + if (self.kind === "FOLDER") { + view = "nodes"; + projectNode = null; + clearSelectedFile(); + stack = visible.map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" })); + await loadChildren(); + } else { + stack = visible.slice(0, -1).map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" })); + projectNode = await fetchDetail(self.id); + view = "files"; + await loadFiles(); + if (target.filePath !== undefined) selectedFilePath.set(target.filePath); + } + } catch (e) { + toastErr(errText(e)); + } + } + + $effect(() => { + if (navTarget === null) return; + const t = navTarget; + void navigateTo(t).finally(() => onnavigated?.()); + }); + /* ------------------------------------------------------------ 节点操作 */ function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void { @@ -190,7 +245,7 @@ } } - function openRename(n: NodeChild): void { + function openRename(n: StackItem): void { renameTarget = n; formName = n.name; modal = "rename"; @@ -213,7 +268,7 @@ } } - async function removeNode(n: NodeChild): Promise { + async function removeNode(n: StackItem): Promise { if (!confirm(`删除「${n.name}」?软删除后不可见。`)) return; try { await api(`/database/api/nodes/${n.id}`, { method: "DELETE" }); @@ -224,7 +279,7 @@ } } - async function openGrants(n: NodeChild): Promise { + async function openGrants(n: StackItem): Promise { try { detailNode = await fetchDetail(n.id); modal = "grants"; @@ -233,7 +288,7 @@ } } - async function openDetail(n: NodeChild): Promise { + async function openDetail(n: StackItem): Promise { try { detailNode = await fetchDetail(n.id); modal = "detail"; @@ -244,6 +299,11 @@ /* ------------------------------------------------------------ 文件操作 */ + function previewFile(f: FileEntry): void { + if (projectNode !== null) record(projectNode.id, f.path); + selectedFilePath.set(f.path); + } + async function submitNewFile(): Promise { if (projectNode === null) return; const path = newPath.trim(); @@ -285,7 +345,7 @@ /* ------------------------------------------------------------ 右键菜单 */ - function nodeMenuItems(n: NodeChild): MenuItem[] { + function nodeMenuItems(n: StackItem): MenuItem[] { const items: MenuItem[] = [{ label: "打开", icon: "chevron", onclick: () => openNode(n) }]; if (n.kind === "FOLDER" && atLeast(n.role, "EDIT")) { items.push({ label: "新建子文件夹", icon: "plus", onclick: () => openCreate("FOLDER", n.id) }); @@ -305,7 +365,7 @@ function fileMenuItems(f: FileEntry): MenuItem[] { const items: MenuItem[] = [ - { label: "打开预览", icon: "chevron", onclick: () => selectedFilePath.set(f.path) }, + { label: "打开预览", icon: "chevron", onclick: () => previewFile(f) }, { label: "下载", icon: "download", @@ -441,7 +501,7 @@ meta="{f.size} B" selected={selected === f.path} onselect={() => (selected = f.path)} - onopen={() => selectedFilePath.set(f.path)} + onopen={() => previewFile(f)} oncontextmenu={(x, y) => (menu = { x, y, items: fileMenuItems(f) })} /> {/each} diff --git a/hub/filelib-web/src/lib/Icon.svelte b/hub/filelib-web/src/lib/Icon.svelte index ce7c8ec..39aaff9 100644 --- a/hub/filelib-web/src/lib/Icon.svelte +++ b/hub/filelib-web/src/lib/Icon.svelte @@ -21,6 +21,7 @@ arrowLeft: "M19 12H5m0 0 6 6m-6-6 6-6", info: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-10v6m0-11v.5", shield: "M12 3l8 3v6c0 4.5-3.2 7.7-8 9-4.8-1.3-8-4.5-8-9V6l8-3Z", + 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", // 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。 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", diff --git a/hub/filelib-web/src/lib/RecentView.svelte b/hub/filelib-web/src/lib/RecentView.svelte new file mode 100644 index 0000000..7f2e33d --- /dev/null +++ b/hub/filelib-web/src/lib/RecentView.svelte @@ -0,0 +1,67 @@ + + +
+

最近打开

+ + {#if error !== null} +
{error}
+ {:else if entries === null} +
加载中…
+ {:else if entries.length === 0} +
还没有访问记录 · 去文件库逛逛
+ {:else} +
+ {#each entries as e (e.nodeId + "/" + e.filePath)} + + {/each} +
+ {/if} +
diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 74f472b..29a0e8f 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -18,6 +18,8 @@ export interface BreadcrumbEntry { readonly id: string | null; readonly name: string | null; readonly kind: NodeKind; + /** 该节点对调用者的 effective role;无 View 为 null。 */ + readonly role: Role | null; } export interface NodeDetail { @@ -130,9 +132,27 @@ export interface UserSearchResult { readonly avatarUrl: string | null; } +/** 最近打开条目(GET /database/api/recent)。 */ +export interface RecentEntry { + readonly nodeId: string; + readonly kind: NodeKind; + readonly name: string; + /** "" = 节点本身;非空 = 项目内文件路径。 */ + readonly filePath: string; + readonly openedAt: string; +} + +/** 回收站条目(GET /database/api/bin)。 */ +export interface BinEntry { + readonly id: string; + readonly parentId: string | null; + readonly kind: NodeKind; + readonly name: string; + readonly deletedAt: string; +} + /** 管理后台概览统计(GET /database/api/stats)。 */ -export interface DashboardStats { - readonly folders: number; +export interface DashboardStats { readonly folders: number; readonly projects: number; readonly files: number; readonly grants: number; diff --git a/hub/filelib-web/src/routes/app/+page.svelte b/hub/filelib-web/src/routes/app/+page.svelte index 30bb9bf..ac106b0 100644 --- a/hub/filelib-web/src/routes/app/+page.svelte +++ b/hub/filelib-web/src/routes/app/+page.svelte @@ -1,12 +1,30 @@ 文件库 @@ -14,8 +32,29 @@ {#if !$authChecked}
加载中…
{:else if $me} -
- +
+ + + + {#if view === "library"} + (navTarget = null)} /> + {:else if view === "recent"} + + {:else} + + {/if}
{:else} diff --git a/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql b/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql new file mode 100644 index 0000000..124ca09 --- /dev/null +++ b/hub/prisma/migrations/20260731050706_filelib_recent_visit/migration.sql @@ -0,0 +1,65 @@ +-- DropIndex +DROP INDEX "ProjectSearchDocument_normalizedBreadcrumb_trgm_idx"; + +-- DropIndex +DROP INDEX "ProjectSearchDocument_normalizedCode_trgm_idx"; + +-- DropIndex +DROP INDEX "ProjectSearchDocument_normalizedName_trgm_idx"; + +-- DropIndex +DROP INDEX "ProjectSearchDocument_normalizedSearchText_trgm_idx"; + +-- DropIndex +DROP INDEX "Team_archivedAt_idx"; + +-- AlterTable +ALTER TABLE "ExternalDirectoryConnection" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "Organization" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- AlterTable +ALTER TABLE "ProjectSearchDocument" ALTER COLUMN "updatedAt" DROP DEFAULT; + +-- CreateTable +CREATE TABLE "FileLibRecentVisit" ( + "id" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "nodeId" TEXT NOT NULL, + "filePath" TEXT NOT NULL DEFAULT '', + "openedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FileLibRecentVisit_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FileLibRecentVisit_organizationId_userId_openedAt_idx" ON "FileLibRecentVisit"("organizationId", "userId", "openedAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "FileLibRecentVisit_organizationId_userId_nodeId_filePath_key" ON "FileLibRecentVisit"("organizationId", "userId", "nodeId", "filePath"); + +-- RenameForeignKey +ALTER TABLE "OrganizationFeishuApplicationConnection" RENAME CONSTRAINT "OrganizationFeishuApplicationConnection_activeSecretVersionId_f" TO "OrganizationFeishuApplicationConnection_activeSecretVersio_fkey"; + +-- RenameIndex +ALTER INDEX "ExternalPrincipalMembership_principalType_principalId_revokedAt" RENAME TO "ExternalPrincipalMembership_principalType_principalId_revok_idx"; + +-- RenameIndex +ALTER INDEX "ExternalPrincipalMembership_userId_principalType_principalId_co" RENAME TO "ExternalPrincipalMembership_userId_principalType_principalI_key"; + +-- RenameIndex +ALTER INDEX "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortOrder" RENAME TO "OrganizationAgentRoleSkill_organizationId_agentRoleId_sortO_idx"; + +-- RenameIndex +ALTER INDEX "OrganizationCapabilityConnection_organizationId_capabilityId_ke" RENAME TO "OrganizationCapabilityConnection_organizationId_capabilityI_key"; + +-- RenameIndex +ALTER INDEX "OrganizationFeishuApplicationConnection_activeSecretVersionId_k" RENAME TO "OrganizationFeishuApplicationConnection_activeSecretVersion_key"; + +-- RenameIndex +ALTER INDEX "OrganizationFeishuApplicationConnection_appIdentityFingerprint_" RENAME TO "OrganizationFeishuApplicationConnection_appIdentityFingerpr_key"; + +-- RenameIndex +ALTER INDEX "TeamExternalBinding_teamId_principalType_principalId_revokedAt_" RENAME TO "TeamExternalBinding_teamId_principalType_principalId_revoke_key"; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index 0779fa9..ffd6828 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -1110,3 +1110,18 @@ model FileLibExportJob { @@index([organizationId, status]) } + +/// ADR-0031:最近打开。客户端在成功打开后上报;filePath="" 表示节点本身 +/// (文件夹/项目),非空表示项目内文件预览(PG 唯一索引视 NULL 互不相同, +/// 故用空串而非 null)。名称读取时 join FileLibNode 实时取,不做冗余。 +model FileLibRecentVisit { + id String @id @default(cuid()) + organizationId String + userId String + nodeId String + filePath String @default("") + openedAt DateTime + + @@unique([organizationId, userId, nodeId, filePath]) + @@index([organizationId, userId, openedAt]) +} diff --git a/hub/src/database/filelib/audit.ts b/hub/src/database/filelib/audit.ts index fe01d09..56f1267 100644 --- a/hub/src/database/filelib/audit.ts +++ b/hub/src/database/filelib/audit.ts @@ -19,6 +19,10 @@ export const FILE_LIB_AUDIT_ACTIONS = { projectRename: "project.rename", projectMove: "project.move", projectDelete: "project.delete", + // ADR-0031:回收站。restore 与 delete 对称(都只动本节点);purge 是整支硬删。 + folderRestore: "folder.restore", + projectRestore: "project.restore", + nodePurge: "node.purge", permissionGrant: "permission.grant", permissionUpdate: "permission.update", permissionRevoke: "permission.revoke", diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts new file mode 100644 index 0000000..2cb20cc --- /dev/null +++ b/hub/src/database/filelib/binService.ts @@ -0,0 +1,181 @@ +/** + * 回收站(ADR-0031)。 + * + * 列出:deletedAt != null 且**祖先全活跃**的节点(每支已删子树只露顶)。 + * 可见性:网站管理员,或在该已删节点上持活跃 MANAGE grant(直连 grant, + * 不走继承 —— 回收站是管理面,不是浏览面)。 + * 恢复:只清本节点 deletedAt(与 D15 删除对称),整支立即可见,落审计。 + * 彻底删除:仅网站管理员;按 pathIds 物化路径枚举子树,**自最深一层逐批 + * 向上删**(self-FK 是 ON DELETE RESTRICT,一次 deleteMany 不保证顺序), + * 同事务一条 node.purge 审计。 + */ + +import type { PrismaClient } from "@prisma/client"; +import { FileLibError } from "./model.js"; +import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; +import type { GroupResolver } from "./groupResolver.js"; +import type { FileLibActor } from "./treeService.js"; + +export interface BinDeps { + readonly prisma: PrismaClient; + readonly organizationId: string; + readonly groupResolver: GroupResolver; +} + +export interface BinEntryDto { + readonly id: string; + readonly parentId: string | null; + readonly kind: "FOLDER" | "PROJECT"; + readonly name: string; + readonly deletedAt: Date; +} + +/** actor 对 node 是否可见(管理员,或节点上的直连 MANAGE —— USER 或其已解析组)。 */ +async function canSeeEntry( + tx: Pick, + deps: BinDeps, + actor: FileLibActor, + groupIds: readonly string[], + nodeId: string, +): Promise { + if (actor.isWebsiteAdmin) return true; + const grant = await tx.fileLibGrant.findFirst({ + where: { + organizationId: deps.organizationId, + nodeId, + revokedAt: null, + role: "MANAGE", + OR: [ + { principalType: "USER", principalId: actor.userId }, + ...(groupIds.length > 0 + ? [{ principalType: "GROUP" as const, principalId: { in: [...groupIds] } }] + : []), + ], + }, + select: { id: true }, + }); + return grant !== null; +} + +/** 列出回收站(祖先全活跃的已删节点顶)。 */ +export async function listBin(deps: BinDeps, actor: FileLibActor): Promise { + const deleted = await deps.prisma.fileLibNode.findMany({ + where: { organizationId: deps.organizationId, deletedAt: { not: null } }, + orderBy: { deletedAt: "desc" }, + }); + if (deleted.length === 0) return []; + + // 祖先活跃性:收集所有 pathIds 里的祖先段,查哪些已删,做集合判定。 + const ancestorIds = new Set(); + for (const n of deleted) { + const segments = n.pathIds.split("/").filter((s) => s !== "" && s !== n.id); + for (const s of segments) ancestorIds.add(s); + } + const deletedAncestorIds = new Set( + ( + await deps.prisma.fileLibNode.findMany({ + where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } }, + select: { id: true }, + }) + ).map((r) => r.id), + ); + const tops = deleted.filter( + (n) => !n.pathIds.split("/").filter((s) => s !== "" && s !== n.id).some((s) => deletedAncestorIds.has(s)), + ); + + const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); + const out: BinEntryDto[] = []; + for (const n of tops) { + if (await canSeeEntry(deps.prisma, deps, actor, groupIds, n.id)) { + out.push({ id: n.id, parentId: n.parentId, kind: n.kind, name: n.name, deletedAt: n.deletedAt! }); + } + } + return out; +} + +/** 取回收站条目并做可见性门禁(D8:不可见即 404)。 */ +async function requireBinEntry( + tx: PrismaClient, + deps: BinDeps, + actor: FileLibActor, + groupIds: readonly string[], + nodeId: string, +): Promise<{ readonly id: string; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: string }> { + const node = await tx.fileLibNode.findFirst({ + where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } }, + }); + if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); + if (!(await canSeeEntry(tx, deps, actor, groupIds, node.id))) { + throw new FileLibError(404, "node_not_found", "node not found"); + } + return { id: node.id, kind: node.kind, name: node.name, pathIds: node.pathIds }; +} + +/** 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 */ +export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise { + const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); + await deps.prisma.$transaction(async (tx) => { + const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId); + await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } }); + await writeFileLibAudit(tx, { + action: node.kind === "PROJECT" + ? FILE_LIB_AUDIT_ACTIONS.projectRestore + : FILE_LIB_AUDIT_ACTIONS.folderRestore, + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: node.kind === "PROJECT" ? "project" : "folder", + objectId: node.id, + objectPath: node.pathIds, + detail: { name: node.name }, + }); + }); +} + +/** + * 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举, + * 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT, + * 父行必须晚于全部子孙行删除。 + */ +export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> { + if (!actor.isWebsiteAdmin) { + throw new FileLibError(404, "node_not_found", "node not found"); + } + return deps.prisma.$transaction(async (tx) => { + const node = await tx.fileLibNode.findFirst({ + where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } }, + }); + if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); + + const subtree = await tx.fileLibNode.findMany({ + where: { + organizationId: deps.organizationId, + OR: [{ id: node.id }, { pathIds: { startsWith: `${node.pathIds}/` } }], + }, + select: { id: true, pathIds: true }, + }); + const depthOf = (p: string): number => p.split("/").filter((s) => s !== "").length; + const byDepthDesc = [...subtree].sort((a, b) => depthOf(b.pathIds) - depthOf(a.pathIds)); + let removed = 0; + let cursor = 0; + while (cursor < byDepthDesc.length) { + const depth = depthOf(byDepthDesc[cursor]!.pathIds); + const batch: string[] = []; + while (cursor < byDepthDesc.length && depthOf(byDepthDesc[cursor]!.pathIds) === depth) { + batch.push(byDepthDesc[cursor]!.id); + cursor += 1; + } + removed += (await tx.fileLibNode.deleteMany({ where: { id: { in: batch } } })).count; + } + + await writeFileLibAudit(tx, { + action: FILE_LIB_AUDIT_ACTIONS.nodePurge, + actorUserId: actor.userId, + organizationId: deps.organizationId, + objectType: node.kind === "PROJECT" ? "project" : "folder", + objectId: node.id, + objectPath: node.pathIds, + detail: { name: node.name, removed }, + }); + return { removed }; + }); +} diff --git a/hub/src/database/filelib/recentService.ts b/hub/src/database/filelib/recentService.ts new file mode 100644 index 0000000..c494d94 --- /dev/null +++ b/hub/src/database/filelib/recentService.ts @@ -0,0 +1,106 @@ +/** + * 最近打开(ADR-0031)。 + * + * 记录:客户端在成功打开后上报;node 需 VIEW(D8,无权即 404 不泄露); + * upsert 语义 —— 重复打开只刷新 openedAt。不写审计(按用户的读模型, + * 非权限敏感写)。 + * 列表:本人最近 20 条,openedAt 倒序;节点已删或**任一祖先已删**的条目 + * 过滤掉(D8/D15 可见性在每个面都成立);名称 join FileLibNode 实时取。 + */ + +import type { PrismaClient } from "@prisma/client"; +import { requireAccessInTx, type AccessDeps, type FileLibActor } from "./treeService.js"; + +export type RecentDeps = AccessDeps & { readonly prisma: PrismaClient }; + +export interface RecentEntryDto { + readonly nodeId: string; + readonly kind: "FOLDER" | "PROJECT"; + readonly name: string; + /** "" = 节点本身;非空 = 项目内文件路径。 */ + readonly filePath: string; + readonly openedAt: Date; +} + +const RECENT_LIMIT = 20; + +export async function recordVisit( + deps: RecentDeps, + actor: FileLibActor, + nodeId: string, + filePath: string | undefined, +): Promise { + const path = filePath ?? ""; + await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, nodeId, "VIEW")); + await deps.prisma.fileLibRecentVisit.upsert({ + where: { + organizationId_userId_nodeId_filePath: { + organizationId: deps.organizationId, + userId: actor.userId, + nodeId, + filePath: path, + }, + }, + update: { openedAt: new Date() }, + create: { + organizationId: deps.organizationId, + userId: actor.userId, + nodeId, + filePath: path, + openedAt: new Date(), + }, + }); +} + +export async function listRecent(deps: RecentDeps, actor: FileLibActor): Promise { + // 可见性过滤会丢弃一部分,超取再截断。 + const rows = await deps.prisma.fileLibRecentVisit.findMany({ + where: { organizationId: deps.organizationId, userId: actor.userId }, + orderBy: { openedAt: "desc" }, + take: RECENT_LIMIT * 3, + }); + if (rows.length === 0) return []; + + const nodeIds = [...new Set(rows.map((r) => r.nodeId))]; + const nodes = await deps.prisma.fileLibNode.findMany({ + where: { id: { in: nodeIds } }, + select: { id: true, kind: true, name: true, pathIds: true, deletedAt: true }, + }); + const byId = new Map(nodes.map((n) => [n.id, n])); + + // 祖先活跃性:收集所有节点的祖先段,查已删集合。 + const ancestorIds = new Set(); + for (const n of nodes) { + for (const s of n.pathIds.split("/").filter((x) => x !== "" && x !== n.id)) ancestorIds.add(s); + } + const deletedAncestorIds = new Set( + ancestorIds.size === 0 + ? [] + : ( + await deps.prisma.fileLibNode.findMany({ + where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } }, + select: { id: true }, + }) + ).map((r) => r.id), + ); + + const out: RecentEntryDto[] = []; + for (const row of rows) { + if (out.length >= RECENT_LIMIT) break; + const node = byId.get(row.nodeId); + if (node === undefined || node.deletedAt !== null) continue; + const hidden = node.pathIds + .split("/") + .filter((s) => s !== "" && s !== node.id) + .some((s) => deletedAncestorIds.has(s)); + if (hidden) continue; + out.push({ + nodeId: node.id, + kind: node.kind, + name: node.name, + filePath: row.filePath, + openedAt: row.openedAt, + }); + } + return out; +} diff --git a/hub/src/database/filelib/treeService.ts b/hub/src/database/filelib/treeService.ts index 10322b4..3827cd1 100644 --- a/hub/src/database/filelib/treeService.ts +++ b/hub/src/database/filelib/treeService.ts @@ -443,10 +443,12 @@ export async function getEffectiveRole( export interface BreadcrumbEntry { readonly depth: number; - /** D17:无 View 的祖先 id/name 都为 null(不泄露)。 */ + /** D17:无 View 的祖先 id/name 置为 null(不泄露)。 */ readonly id: string | null; readonly name: string | null; readonly kind: "FOLDER" | "PROJECT"; + /** 该节点对调用者的 effective role;无 View 为 null。 */ + readonly role: FileLibRole | null; } /** D17 面包屑:需 self VIEW;链上每个节点单独算权限,无 View 只留占位。 */ @@ -484,6 +486,7 @@ export async function breadcrumb( id: visible ? current.id : null, name: visible ? current.name : null, kind: current.kind, + role, }; }); }); diff --git a/hub/src/database/routes/binRoutes.ts b/hub/src/database/routes/binRoutes.ts new file mode 100644 index 0000000..402503a --- /dev/null +++ b/hub/src/database/routes/binRoutes.ts @@ -0,0 +1,45 @@ +/** + * /database/api/bin/* 回收站端点(ADR-0031)。 + * 约定:绝对路径;actorOrNull 前置;业务全走 binService;错误统一 sendRouteError。 + */ + +import type { FastifyInstance } from "fastify"; +import { listBin, purgeBinEntry, restoreBinEntry } from "../filelib/binService.js"; +import { actorOrNull, sendRouteError, type FileLibRouteDeps } from "../filelib/routeShared.js"; + +export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise { + const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver }; + + app.get("/database/api/bin", async (request, reply) => { + const actor = await actorOrNull(request, reply, deps); + if (actor === null) return reply; + try { + return { entries: await listBin(svc, actor) }; + } catch (error) { + return sendRouteError(reply, error); + } + }); + + app.post("/database/api/bin/:id/restore", async (request, reply) => { + const actor = await actorOrNull(request, reply, deps); + if (actor === null) return reply; + try { + const { id } = request.params as { id: string }; + await restoreBinEntry(svc, actor, id); + return reply.status(204).send(); + } catch (error) { + return sendRouteError(reply, error); + } + }); + + app.delete("/database/api/bin/:id", async (request, reply) => { + const actor = await actorOrNull(request, reply, deps); + if (actor === null) return reply; + try { + const { id } = request.params as { id: string }; + return await purgeBinEntry(svc, actor, id); + } catch (error) { + return sendRouteError(reply, error); + } + }); +} diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index 9cfaf35..11b1ccb 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -28,6 +28,8 @@ import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js"; import { registerFileLibRoutes } from "./filelibRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js"; import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; +import { registerBinRoutes } from "./binRoutes.js"; +import { registerRecentRoutes } from "./recentRoutes.js"; import { registerTeacherApp } from "./teacherApp.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; @@ -161,6 +163,8 @@ export async function registerDatabaseRoutes( await registerFileLibRoutes(app, filelibDeps); await registerFileRoutes(app, filelibDeps); await registerMemberGroupRoutes(app, filelibDeps); + await registerBinRoutes(app, filelibDeps); + await registerRecentRoutes(app, filelibDeps); await registerTeacherApp(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, diff --git a/hub/src/database/routes/recentRoutes.ts b/hub/src/database/routes/recentRoutes.ts new file mode 100644 index 0000000..ebd6479 --- /dev/null +++ b/hub/src/database/routes/recentRoutes.ts @@ -0,0 +1,47 @@ +/** + * /database/api/recent 最近打开端点(ADR-0031)。 + * 约定:绝对路径;actorOrNull 前置;业务全走 recentService;错误统一 sendRouteError。 + */ + +import type { FastifyInstance } from "fastify"; +import { listRecent, recordVisit } from "../filelib/recentService.js"; +import { FileLibError } from "../filelib/model.js"; +import { + actorOrNull, + bodyObject, + optionalString, + requireString, + sendRouteError, + type FileLibRouteDeps, +} from "../filelib/routeShared.js"; + +export async function registerRecentRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise { + const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver }; + + app.get("/database/api/recent", async (request, reply) => { + const actor = await actorOrNull(request, reply, deps); + if (actor === null) return reply; + try { + return { entries: await listRecent(svc, actor) }; + } catch (error) { + return sendRouteError(reply, error); + } + }); + + app.post("/database/api/recent", async (request, reply) => { + const actor = await actorOrNull(request, reply, deps); + if (actor === null) return reply; + try { + const body = bodyObject(request.body); + const nodeId = requireString(body, "nodeId"); + const filePath = optionalString(body, "filePath"); + if (filePath !== undefined && filePath.trim() === "") { + throw new FileLibError(400, "invalid_request", "filePath must be non-empty when present"); + } + await recordVisit(svc, actor, nodeId, filePath); + return reply.status(204).send(); + } catch (error) { + return sendRouteError(reply, error); + } + }); +} diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts new file mode 100644 index 0000000..926da4e --- /dev/null +++ b/hub/test/integration/filelib-nav.test.ts @@ -0,0 +1,163 @@ +/** + * 回收站 + 最近打开集成测试(真实 Postgres,ADR-0031)。 + * 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义 + * 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)、recent 上报 VIEW 门禁 / + * upsert 刷新 / 删除与祖先删除的可见性过滤。 + * 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。 + */ +import { beforeEach, describe, expect, it } from "vitest"; +import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js"; +import { + createNode, + softDeleteNode, + listChildren, + getEffectiveRole, + type FileLibActor, + type TreeServiceDeps, +} from "../../src/database/filelib/treeService.js"; +import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js"; +import { listRecent, recordVisit, type RecentDeps } from "../../src/database/filelib/recentService.js"; +import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js"; +import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js"; +import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.js"; + +const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true }; +const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false }; +const BOB: FileLibActor = { userId: "u_bob", isWebsiteAdmin: false }; + +function treeDeps(): TreeServiceDeps { + return { + prisma, + groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }), + versionStore: createInMemoryVersionStore(), + organizationId: DEFAULT_ORG_ID, + storageRoot: "/tmp/filelib-test", + }; +} + +function binDeps(): BinDeps { + return { + prisma, + organizationId: DEFAULT_ORG_ID, + groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }), + }; +} + +function recentDeps(): RecentDeps { + return { + prisma, + organizationId: DEFAULT_ORG_ID, + groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }), + }; +} + +beforeEach(async () => { + await resetDb(); + for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) { + await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } }); + } +}); + +describe("binService · 列出与可见性", () => { + it("只露每支已删子树的顶;管理员全见,直连 MANAGE 可见,无关者不见", async () => { + const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); + const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" }); + await createNode(treeDeps(), ADMIN, { parentId: child.id, kind: "PROJECT", name: "TH-141" }); + // alice 在 child 上直连 MANAGE。 + const own = await createNode(treeDeps(), ADMIN, { + parentId: null, kind: "PROJECT", name: "alice 项目", + grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }], + }); + + await softDeleteNode(treeDeps(), ADMIN, child.id); // 删中间层:child 是顶,孙项目不单列 + await softDeleteNode(treeDeps(), ADMIN, own.id); + + const adminBin = await listBin(binDeps(), ADMIN); + expect(adminBin.map((e) => e.name).sort()).toEqual(["alice 项目", "必修一"]); + + const aliceBin = await listBin(binDeps(), ALICE); + expect(aliceBin.map((e) => e.name)).toEqual(["alice 项目"]); // 只见自己 MANAGE 的 + + const bobBin = await listBin(binDeps(), BOB); + expect(bobBin).toEqual([]); + + void root; + }); +}); + +describe("binService · 恢复", () => { + it("restore 只清本节点:整支立即可见,落 folder.restore 审计;无权者 404", async () => { + const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); + const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" }); + await softDeleteNode(treeDeps(), ADMIN, child.id); + + await expect(restoreBinEntry(binDeps(), BOB, child.id)).rejects.toMatchObject({ statusCode: 404 }); + await restoreBinEntry(binDeps(), ADMIN, child.id); + + const visible = await listChildren(treeDeps(), ADMIN, root.id); + expect(visible.map((c) => c.name)).toContain("必修一"); + + const audits = await prisma.auditEntry.findMany({ + where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore }, + }); + expect(audits).toHaveLength(1); + }); +}); + +describe("binService · 彻底删除", () => { + it("仅管理员;整支硬删(含子孙/授权),落 node.purge 审计", async () => { + const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); + const child = await createNode(treeDeps(), ADMIN, { + parentId: root.id, kind: "PROJECT", name: "TH-141", + grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }], + }); + await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶 + + await expect(purgeBinEntry(binDeps(), ALICE, root.id)).rejects.toMatchObject({ statusCode: 404 }); + const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id); + expect(removed).toBe(2); + + expect(await prisma.fileLibNode.count({ where: { id: { in: [root.id, child.id] } } })).toBe(0); + expect(await prisma.fileLibGrant.count({ where: { nodeId: child.id } })).toBe(0); + + const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } }); + expect(audits).toHaveLength(1); + }); +}); + +describe("recentService · 最近打开", () => { + it("上报需 VIEW(404);upsert 刷新 openedAt;删除/祖先删除的条目被过滤", async () => { + const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); + const proj = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141" }); + const secret = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "秘密" }); + + // bob 对 secret 无 VIEW → 404 + await expect(recordVisit(recentDeps(), BOB, secret.id, undefined)).rejects.toMatchObject({ statusCode: 404 }); + + // admin 上报:root、proj、proj 内文件 + await recordVisit(recentDeps(), ADMIN, root.id, undefined); + await recordVisit(recentDeps(), ADMIN, proj.id, undefined); + await recordVisit(recentDeps(), ADMIN, proj.id, "讲义/第一章.md"); + + let entries = await listRecent(recentDeps(), ADMIN); + expect(entries).toHaveLength(3); + expect(entries.map((e) => e.filePath)).toContain("讲义/第一章.md"); + + // upsert:重复打开 root 只刷新,不新增 + await recordVisit(recentDeps(), ADMIN, root.id, undefined); + entries = await listRecent(recentDeps(), ADMIN); + expect(entries).toHaveLength(3); + expect(entries[0]!.nodeId).toBe(root.id); // 最新在前 + + // 删祖先 → 整支条目消失 + await softDeleteNode(treeDeps(), ADMIN, root.id); + entries = await listRecent(recentDeps(), ADMIN); + expect(entries).toEqual([]); + + // 恢复后重新可见 + await restoreBinEntry(binDeps(), ADMIN, root.id); + entries = await listRecent(recentDeps(), ADMIN); + expect(entries).toHaveLength(3); + expect(await getEffectiveRole(treeDeps(), ADMIN, proj.id)).toBe("MANAGE"); + }); +}); From 83a6b012b79bcf31a80e69ea7a8b4543cf0fc557 Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 13:39:18 +0800 Subject: [PATCH 15/24] =?UTF-8?q?feat(filelib):=20=E7=A7=BB=E9=99=A4?= =?UTF-8?q?=E6=9C=80=E8=BF=91=E6=89=93=E5=BC=80=E6=A8=A1=E5=9D=97(ADR-0032?= =?UTF-8?q?,supersede=20ADR-0031=20=E5=AF=B9=E5=BA=94=E5=8D=8A=E9=83=A8)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FileLibRecentVisit 删表(手写迁移;表当日新建无生产数据) - recentService/recentRoutes/RecentView 删除;GridLibraryView 埋点与 navTarget 跳转一并移除;types 清 RecentEntry - 左栏保留 文件库/回收站(ADR-0031 回收站半部不受影响) - breadcrumb 的 role 字段保留(独立可用的增量字段) --- docs/adr/0032-remove-recent-visit-module.md | 30 +++++ .../src/lib/GridLibraryView.svelte | 55 --------- hub/filelib-web/src/lib/RecentView.svelte | 67 ----------- hub/filelib-web/src/lib/types.ts | 10 -- hub/filelib-web/src/routes/app/+page.svelte | 18 +-- .../migration.sql | 2 + hub/prisma/schema.prisma | 15 --- hub/src/database/filelib/recentService.ts | 106 ------------------ hub/src/database/routes/databaseRoutes.ts | 2 - hub/src/database/routes/recentRoutes.ts | 47 -------- hub/test/integration/filelib-nav.test.ts | 52 +-------- 11 files changed, 38 insertions(+), 366 deletions(-) create mode 100644 docs/adr/0032-remove-recent-visit-module.md delete mode 100644 hub/filelib-web/src/lib/RecentView.svelte create mode 100644 hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql delete mode 100644 hub/src/database/filelib/recentService.ts delete mode 100644 hub/src/database/routes/recentRoutes.ts diff --git a/docs/adr/0032-remove-recent-visit-module.md b/docs/adr/0032-remove-recent-visit-module.md new file mode 100644 index 0000000..6cd24a3 --- /dev/null +++ b/docs/adr/0032-remove-recent-visit-module.md @@ -0,0 +1,30 @@ +# ADR 0032: Remove The Recent-Visit Module + +## Status + +Accepted. **Supersedes the "Recent visits" half of ADR-0031** (the recycle-bin half +is unaffected and remains in force). + +## Context + +ADR-0031 (same day) introduced 最近打开: a `FileLibRecentVisit` table, client-driven +visit recording, and a rail entry in the teacher app. After seeing it live, the product +call is that the module is not wanted — it adds a tracking surface, a table, and rail +noise without a compelling teacher workflow behind it. + +## Decision + +The recent-visit module is removed end-to-end: + +- `FileLibRecentVisit` is dropped (hand-written migration + `20260731090000_drop_filelib_recent_visit`; the table was created the same day and + held no production data). +- `recentService` / `recentRoutes` (`/database/api/recent`) and the `RecentView` + component are deleted; the rail in `/app` keeps only 文件库 / 回收站. +- `GridLibraryView` visit recording and the `navTarget` navigation entry go with it. +- The `role` field added to breadcrumb entries for ADR-0031 is **kept** — it is a + cheap, additive field on an existing API and independent of the removed module. + +If recent-visit tracking comes back as a requirement, it is a new decision (and +should then define why client-driven tracking is worth its surface) rather than a +revival of this one. diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte index 3e064bf..8e9c90a 100644 --- a/hub/filelib-web/src/lib/GridLibraryView.svelte +++ b/hub/filelib-web/src/lib/GridLibraryView.svelte @@ -23,14 +23,6 @@ import GrantsPanel from "./GrantsPanel.svelte"; import OverviewPanel from "./OverviewPanel.svelte"; - /** 最近打开上报的导航目标(ADR-0031):父组件传入后,本组件跳到对应节点并清除。 */ - export interface NavTarget { - readonly nodeId: string; - readonly filePath?: string | undefined; - } - - let { navTarget = null, onnavigated }: { navTarget?: NavTarget | null; onnavigated?: () => void } = $props(); - const RANK: Record = { VIEW: 1, EDIT: 2, MANAGE: 3 }; const atLeast = (role: Role, min: Role): boolean => RANK[role] >= RANK[min]; @@ -107,14 +99,6 @@ onMount(loadChildren); - /** 最近打开上报(ADR-0031):fire-and-forget,失败静默,不阻塞浏览。 */ - function record(nodeId: string, filePath?: string): void { - void api("/database/api/recent", { - method: "POST", - body: { nodeId, ...(filePath !== undefined ? { filePath } : {}) }, - }).catch(() => undefined); - } - function refresh(): void { selected = null; menu = null; @@ -126,7 +110,6 @@ function openNode(n: StackItem): void { selected = null; - record(n.id); if (n.kind === "FOLDER") { stack = [...stack, n]; void loadChildren(); @@ -174,43 +157,6 @@ void loadChildren(); } - /** 跳到任意节点(最近打开入口):breadcrumb 建栈,FOLDER 进子层,PROJECT 进文件视图。 */ - async function navigateTo(target: NavTarget): Promise { - try { - const r = await api<{ breadcrumb: Array<{ id: string | null; name: string | null; kind: "FOLDER" | "PROJECT"; role: Role | null }> }>( - `/database/api/nodes/${target.nodeId}/breadcrumb`, - ); - const visible = r.breadcrumb.filter( - (e): e is { id: string; name: string; kind: "FOLDER" | "PROJECT"; role: Role | null } => - e.id !== null && e.name !== null, - ); - if (visible.length === 0) return; - const self = visible[visible.length - 1]!; - selected = null; - if (self.kind === "FOLDER") { - view = "nodes"; - projectNode = null; - clearSelectedFile(); - stack = visible.map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" })); - await loadChildren(); - } else { - stack = visible.slice(0, -1).map((e) => ({ id: e.id, name: e.name, kind: e.kind, role: e.role ?? "VIEW" })); - projectNode = await fetchDetail(self.id); - view = "files"; - await loadFiles(); - if (target.filePath !== undefined) selectedFilePath.set(target.filePath); - } - } catch (e) { - toastErr(errText(e)); - } - } - - $effect(() => { - if (navTarget === null) return; - const t = navTarget; - void navigateTo(t).finally(() => onnavigated?.()); - }); - /* ------------------------------------------------------------ 节点操作 */ function openCreate(kind: "FOLDER" | "PROJECT", parentId: string | null): void { @@ -300,7 +246,6 @@ /* ------------------------------------------------------------ 文件操作 */ function previewFile(f: FileEntry): void { - if (projectNode !== null) record(projectNode.id, f.path); selectedFilePath.set(f.path); } diff --git a/hub/filelib-web/src/lib/RecentView.svelte b/hub/filelib-web/src/lib/RecentView.svelte deleted file mode 100644 index 7f2e33d..0000000 --- a/hub/filelib-web/src/lib/RecentView.svelte +++ /dev/null @@ -1,67 +0,0 @@ - - -
-

最近打开

- - {#if error !== null} -
{error}
- {:else if entries === null} -
加载中…
- {:else if entries.length === 0} -
还没有访问记录 · 去文件库逛逛
- {:else} -
- {#each entries as e (e.nodeId + "/" + e.filePath)} - - {/each} -
- {/if} -
diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 29a0e8f..ad07726 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -132,16 +132,6 @@ export interface UserSearchResult { readonly avatarUrl: string | null; } -/** 最近打开条目(GET /database/api/recent)。 */ -export interface RecentEntry { - readonly nodeId: string; - readonly kind: NodeKind; - readonly name: string; - /** "" = 节点本身;非空 = 项目内文件路径。 */ - readonly filePath: string; - readonly openedAt: string; -} - /** 回收站条目(GET /database/api/bin)。 */ export interface BinEntry { readonly id: string; diff --git a/hub/filelib-web/src/routes/app/+page.svelte b/hub/filelib-web/src/routes/app/+page.svelte index ac106b0..9cb2e19 100644 --- a/hub/filelib-web/src/routes/app/+page.svelte +++ b/hub/filelib-web/src/routes/app/+page.svelte @@ -4,25 +4,17 @@ import { me, authChecked } from "$lib/stores.js"; import { loadSession } from "$lib/session.js"; import LoginView from "$lib/LoginView.svelte"; - import GridLibraryView, { type NavTarget } from "$lib/GridLibraryView.svelte"; - import RecentView from "$lib/RecentView.svelte"; + import GridLibraryView from "$lib/GridLibraryView.svelte"; import BinView from "$lib/BinView.svelte"; import Icon from "$lib/Icon.svelte"; onMount(loadSession); - type View = "library" | "recent" | "bin"; + type View = "library" | "bin"; let view = $state("library"); - let navTarget = $state(null); - function openFromRecent(target: NavTarget): void { - navTarget = target; - view = "library"; - } - - const tabs: ReadonlyArray = [ + const tabs: ReadonlyArray = [ ["library", "文件库", "layers"], - ["recent", "最近打开", "clock"], ["bin", "回收站", "trash"], ]; @@ -49,9 +41,7 @@ {#if view === "library"} - (navTarget = null)} /> - {:else if view === "recent"} - + {:else} {/if} diff --git a/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql b/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql new file mode 100644 index 0000000..d7a66d3 --- /dev/null +++ b/hub/prisma/migrations/20260731090000_drop_filelib_recent_visit/migration.sql @@ -0,0 +1,2 @@ +-- ADR-0032:最近打开模块移除,删表(今日新建,无生产数据)。 +DROP TABLE "FileLibRecentVisit"; diff --git a/hub/prisma/schema.prisma b/hub/prisma/schema.prisma index ffd6828..0779fa9 100644 --- a/hub/prisma/schema.prisma +++ b/hub/prisma/schema.prisma @@ -1110,18 +1110,3 @@ model FileLibExportJob { @@index([organizationId, status]) } - -/// ADR-0031:最近打开。客户端在成功打开后上报;filePath="" 表示节点本身 -/// (文件夹/项目),非空表示项目内文件预览(PG 唯一索引视 NULL 互不相同, -/// 故用空串而非 null)。名称读取时 join FileLibNode 实时取,不做冗余。 -model FileLibRecentVisit { - id String @id @default(cuid()) - organizationId String - userId String - nodeId String - filePath String @default("") - openedAt DateTime - - @@unique([organizationId, userId, nodeId, filePath]) - @@index([organizationId, userId, openedAt]) -} diff --git a/hub/src/database/filelib/recentService.ts b/hub/src/database/filelib/recentService.ts deleted file mode 100644 index c494d94..0000000 --- a/hub/src/database/filelib/recentService.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * 最近打开(ADR-0031)。 - * - * 记录:客户端在成功打开后上报;node 需 VIEW(D8,无权即 404 不泄露); - * upsert 语义 —— 重复打开只刷新 openedAt。不写审计(按用户的读模型, - * 非权限敏感写)。 - * 列表:本人最近 20 条,openedAt 倒序;节点已删或**任一祖先已删**的条目 - * 过滤掉(D8/D15 可见性在每个面都成立);名称 join FileLibNode 实时取。 - */ - -import type { PrismaClient } from "@prisma/client"; -import { requireAccessInTx, type AccessDeps, type FileLibActor } from "./treeService.js"; - -export type RecentDeps = AccessDeps & { readonly prisma: PrismaClient }; - -export interface RecentEntryDto { - readonly nodeId: string; - readonly kind: "FOLDER" | "PROJECT"; - readonly name: string; - /** "" = 节点本身;非空 = 项目内文件路径。 */ - readonly filePath: string; - readonly openedAt: Date; -} - -const RECENT_LIMIT = 20; - -export async function recordVisit( - deps: RecentDeps, - actor: FileLibActor, - nodeId: string, - filePath: string | undefined, -): Promise { - const path = filePath ?? ""; - await deps.prisma.$transaction(async (tx) => requireAccessInTx(tx, deps, actor, nodeId, "VIEW")); - await deps.prisma.fileLibRecentVisit.upsert({ - where: { - organizationId_userId_nodeId_filePath: { - organizationId: deps.organizationId, - userId: actor.userId, - nodeId, - filePath: path, - }, - }, - update: { openedAt: new Date() }, - create: { - organizationId: deps.organizationId, - userId: actor.userId, - nodeId, - filePath: path, - openedAt: new Date(), - }, - }); -} - -export async function listRecent(deps: RecentDeps, actor: FileLibActor): Promise { - // 可见性过滤会丢弃一部分,超取再截断。 - const rows = await deps.prisma.fileLibRecentVisit.findMany({ - where: { organizationId: deps.organizationId, userId: actor.userId }, - orderBy: { openedAt: "desc" }, - take: RECENT_LIMIT * 3, - }); - if (rows.length === 0) return []; - - const nodeIds = [...new Set(rows.map((r) => r.nodeId))]; - const nodes = await deps.prisma.fileLibNode.findMany({ - where: { id: { in: nodeIds } }, - select: { id: true, kind: true, name: true, pathIds: true, deletedAt: true }, - }); - const byId = new Map(nodes.map((n) => [n.id, n])); - - // 祖先活跃性:收集所有节点的祖先段,查已删集合。 - const ancestorIds = new Set(); - for (const n of nodes) { - for (const s of n.pathIds.split("/").filter((x) => x !== "" && x !== n.id)) ancestorIds.add(s); - } - const deletedAncestorIds = new Set( - ancestorIds.size === 0 - ? [] - : ( - await deps.prisma.fileLibNode.findMany({ - where: { id: { in: [...ancestorIds] }, deletedAt: { not: null } }, - select: { id: true }, - }) - ).map((r) => r.id), - ); - - const out: RecentEntryDto[] = []; - for (const row of rows) { - if (out.length >= RECENT_LIMIT) break; - const node = byId.get(row.nodeId); - if (node === undefined || node.deletedAt !== null) continue; - const hidden = node.pathIds - .split("/") - .filter((s) => s !== "" && s !== node.id) - .some((s) => deletedAncestorIds.has(s)); - if (hidden) continue; - out.push({ - nodeId: node.id, - kind: node.kind, - name: node.name, - filePath: row.filePath, - openedAt: row.openedAt, - }); - } - return out; -} diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index 11b1ccb..79a88fe 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -29,7 +29,6 @@ import { registerFileLibRoutes } from "./filelibRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js"; import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; import { registerBinRoutes } from "./binRoutes.js"; -import { registerRecentRoutes } from "./recentRoutes.js"; import { registerTeacherApp } from "./teacherApp.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; @@ -164,7 +163,6 @@ export async function registerDatabaseRoutes( await registerFileRoutes(app, filelibDeps); await registerMemberGroupRoutes(app, filelibDeps); await registerBinRoutes(app, filelibDeps); - await registerRecentRoutes(app, filelibDeps); await registerTeacherApp(app, { prisma: config.prisma, sessionSecret: config.sessionSecret, diff --git a/hub/src/database/routes/recentRoutes.ts b/hub/src/database/routes/recentRoutes.ts deleted file mode 100644 index ebd6479..0000000 --- a/hub/src/database/routes/recentRoutes.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * /database/api/recent 最近打开端点(ADR-0031)。 - * 约定:绝对路径;actorOrNull 前置;业务全走 recentService;错误统一 sendRouteError。 - */ - -import type { FastifyInstance } from "fastify"; -import { listRecent, recordVisit } from "../filelib/recentService.js"; -import { FileLibError } from "../filelib/model.js"; -import { - actorOrNull, - bodyObject, - optionalString, - requireString, - sendRouteError, - type FileLibRouteDeps, -} from "../filelib/routeShared.js"; - -export async function registerRecentRoutes(app: FastifyInstance, deps: FileLibRouteDeps): Promise { - const svc = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver }; - - app.get("/database/api/recent", async (request, reply) => { - const actor = await actorOrNull(request, reply, deps); - if (actor === null) return reply; - try { - return { entries: await listRecent(svc, actor) }; - } catch (error) { - return sendRouteError(reply, error); - } - }); - - app.post("/database/api/recent", async (request, reply) => { - const actor = await actorOrNull(request, reply, deps); - if (actor === null) return reply; - try { - const body = bodyObject(request.body); - const nodeId = requireString(body, "nodeId"); - const filePath = optionalString(body, "filePath"); - if (filePath !== undefined && filePath.trim() === "") { - throw new FileLibError(400, "invalid_request", "filePath must be non-empty when present"); - } - await recordVisit(svc, actor, nodeId, filePath); - return reply.status(204).send(); - } catch (error) { - return sendRouteError(reply, error); - } - }); -} diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts index 926da4e..c28d434 100644 --- a/hub/test/integration/filelib-nav.test.ts +++ b/hub/test/integration/filelib-nav.test.ts @@ -1,8 +1,7 @@ /** - * 回收站 + 最近打开集成测试(真实 Postgres,ADR-0031)。 + * 回收站集成测试(真实 Postgres,ADR-0031;最近打开已由 ADR-0032 移除)。 * 覆盖:bin 列出(祖先全活跃顶点/直连 MANAGE 可见性/管理员)、restore 对称语义 - * 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)、recent 上报 VIEW 门禁 / - * upsert 刷新 / 删除与祖先删除的可见性过滤。 + * 与审计、purge 仅管理员 + 整支硬删(RESTRICT 顺序)。 * 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。 */ import { beforeEach, describe, expect, it } from "vitest"; @@ -11,12 +10,10 @@ import { createNode, softDeleteNode, listChildren, - getEffectiveRole, type FileLibActor, type TreeServiceDeps, } from "../../src/database/filelib/treeService.js"; import { listBin, purgeBinEntry, restoreBinEntry, type BinDeps } from "../../src/database/filelib/binService.js"; -import { listRecent, recordVisit, type RecentDeps } from "../../src/database/filelib/recentService.js"; import { createStaticGroupResolver } from "../../src/database/filelib/groupResolver.js"; import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js"; import { FILE_LIB_AUDIT_ACTIONS } from "../../src/database/filelib/audit.js"; @@ -43,14 +40,6 @@ function binDeps(): BinDeps { }; } -function recentDeps(): RecentDeps { - return { - prisma, - organizationId: DEFAULT_ORG_ID, - groupResolver: createStaticGroupResolver({ u_bob: ["g_physics"] }), - }; -} - beforeEach(async () => { await resetDb(); for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) { @@ -124,40 +113,3 @@ describe("binService · 彻底删除", () => { expect(audits).toHaveLength(1); }); }); - -describe("recentService · 最近打开", () => { - it("上报需 VIEW(404);upsert 刷新 openedAt;删除/祖先删除的条目被过滤", async () => { - const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); - const proj = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141" }); - const secret = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "秘密" }); - - // bob 对 secret 无 VIEW → 404 - await expect(recordVisit(recentDeps(), BOB, secret.id, undefined)).rejects.toMatchObject({ statusCode: 404 }); - - // admin 上报:root、proj、proj 内文件 - await recordVisit(recentDeps(), ADMIN, root.id, undefined); - await recordVisit(recentDeps(), ADMIN, proj.id, undefined); - await recordVisit(recentDeps(), ADMIN, proj.id, "讲义/第一章.md"); - - let entries = await listRecent(recentDeps(), ADMIN); - expect(entries).toHaveLength(3); - expect(entries.map((e) => e.filePath)).toContain("讲义/第一章.md"); - - // upsert:重复打开 root 只刷新,不新增 - await recordVisit(recentDeps(), ADMIN, root.id, undefined); - entries = await listRecent(recentDeps(), ADMIN); - expect(entries).toHaveLength(3); - expect(entries[0]!.nodeId).toBe(root.id); // 最新在前 - - // 删祖先 → 整支条目消失 - await softDeleteNode(treeDeps(), ADMIN, root.id); - entries = await listRecent(recentDeps(), ADMIN); - expect(entries).toEqual([]); - - // 恢复后重新可见 - await restoreBinEntry(binDeps(), ADMIN, root.id); - entries = await listRecent(recentDeps(), ADMIN); - expect(entries).toHaveLength(3); - expect(await getEffectiveRole(treeDeps(), ADMIN, proj.id)).toBe("MANAGE"); - }); -}); From 9e38d1e0113262e186d357087aa5a9f1b86bae45 Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 13:52:23 +0800 Subject: [PATCH 16/24] =?UTF-8?q?fix(filelib):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E6=92=9E=E5=90=8D=E4=B8=8D=E5=86=8D=E6=AD=BB=E9=94=81,?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E6=94=B9=E5=90=8D=E3=80=8C(=E5=B7=B2?= =?UTF-8?q?=E6=81=A2=E5=A4=8D)=E3=80=8D(ADR-0033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - restore 前查活跃兄弟:撞名则恢复为「原名(已恢复[/ N])」(截断计入 128 长度预算),同事务落审计并记 renamedFrom;API 返回最终名 - BinView toast 提示改名;测试补撞名/二次恢复用例 --- docs/adr/0033-restore-deduplicates-name.md | 26 +++++++++++ hub/filelib-web/src/lib/BinView.svelte | 11 ++++- hub/src/database/filelib/binService.ts | 53 ++++++++++++++++++---- hub/src/database/routes/binRoutes.ts | 3 +- hub/test/integration/filelib-nav.test.ts | 26 +++++++++++ 5 files changed, 107 insertions(+), 12 deletions(-) create mode 100644 docs/adr/0033-restore-deduplicates-name.md diff --git a/docs/adr/0033-restore-deduplicates-name.md b/docs/adr/0033-restore-deduplicates-name.md new file mode 100644 index 0000000..579f12c --- /dev/null +++ b/docs/adr/0033-restore-deduplicates-name.md @@ -0,0 +1,26 @@ +# ADR 0033: Restore De-Duplicates The Node Name On Sibling Conflict + +## Status + +Accepted. + +## Context + +ADR-0031 defined restore as "clear `deletedAt` on that node only". It did not cover +the case where a same-name sibling was created **after** the deletion: D14's partial +unique index (active siblings, case-insensitive) then rejects the restore with a 409 +`conflict`, leaving the entry permanently stuck in the bin — unrecoverable for +non-admin users (who cannot purge) and cryptic for admins. + +## Decision + +Restore never fails on a name conflict. Before clearing `deletedAt`, the service +checks active siblings; if the node's name is taken, it restores as +`原名(已恢复)`, then `原名(已恢复 2)`, …, first free key wins (suffix is included +in the `NODE_NAME_MAX_LENGTH` budget by truncating the base). The rename is part of +the same transaction and is recorded in the restore audit entry as +`{ name, renamedFrom }`. The API returns the final name so the UI can tell the user. + +Rationale: the bin's purpose is recovery; a restore that can deadlock on naming is a +trap, not a safeguard. Users who care about the name can rename afterwards (they have +MANAGE by definition of bin visibility). diff --git a/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte index bc99895..91732c6 100644 --- a/hub/filelib-web/src/lib/BinView.svelte +++ b/hub/filelib-web/src/lib/BinView.svelte @@ -36,8 +36,15 @@ async function restore(e: BinEntry): Promise { busyId = e.id; try { - await api(`/database/api/bin/${encodeURIComponent(e.id)}/restore`, { method: "POST" }); - toastOk(`已恢复「${e.name}」`); + const r = await api<{ name: string; renamedFrom?: string }>( + `/database/api/bin/${encodeURIComponent(e.id)}/restore`, + { method: "POST" }, + ); + toastOk( + r.renamedFrom !== undefined && r.renamedFrom !== r.name + ? `已恢复为「${r.name}」(原名与现有节点冲突)` + : `已恢复「${r.name}」`, + ); await load(); } catch (err) { toastErr(err instanceof Error ? err.message : String(err)); diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts index 2cb20cc..dc702df 100644 --- a/hub/src/database/filelib/binService.ts +++ b/hub/src/database/filelib/binService.ts @@ -11,7 +11,7 @@ */ import type { PrismaClient } from "@prisma/client"; -import { FileLibError } from "./model.js"; +import { FileLibError, nameKey, NODE_NAME_MAX_LENGTH } from "./model.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; import type { GroupResolver } from "./groupResolver.js"; import type { FileLibActor } from "./treeService.js"; @@ -100,7 +100,7 @@ async function requireBinEntry( actor: FileLibActor, groupIds: readonly string[], nodeId: string, -): Promise<{ readonly id: string; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: string }> { +): Promise<{ readonly id: string; readonly parentId: string | null; readonly kind: "FOLDER" | "PROJECT"; readonly name: string; readonly pathIds: string }> { const node = await tx.fileLibNode.findFirst({ where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } }, }); @@ -108,15 +108,51 @@ async function requireBinEntry( if (!(await canSeeEntry(tx, deps, actor, groupIds, node.id))) { throw new FileLibError(404, "node_not_found", "node not found"); } - return { id: node.id, kind: node.kind, name: node.name, pathIds: node.pathIds }; + return { id: node.id, parentId: node.parentId, kind: node.kind, name: node.name, pathIds: node.pathIds }; } -/** 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 */ -export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise { +export interface RestoreResult { + readonly name: string; + readonly renamedFrom?: string | undefined; +} + +/** + * 恢复:只清本节点 deletedAt(子树随之可见);落 restore 审计。 + * ADR-0033:与活跃兄弟撞名时不失败,自动改成「原名(已恢复[/ N])」—— + * 恢复的意义就是找回,撞名死锁不是保护;审计 detail 记 renamedFrom。 + */ +export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise { const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); - await deps.prisma.$transaction(async (tx) => { + return deps.prisma.$transaction(async (tx) => { const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId); - await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } }); + + const siblings = await tx.fileLibNode.findMany({ + where: { + organizationId: deps.organizationId, + parentId: node.parentId, + deletedAt: null, + id: { not: node.id }, + }, + select: { nameLower: true }, + }); + const taken = new Set(siblings.map((s) => s.nameLower)); + + let name = node.name; + let renamedFrom: string | undefined; + if (taken.has(nameKey(name))) { + renamedFrom = node.name; + let n = 1; + do { + const suffix = n === 1 ? "(已恢复)" : `(已恢复 ${n})`; + name = node.name.slice(0, NODE_NAME_MAX_LENGTH - suffix.length) + suffix; + n += 1; + } while (taken.has(nameKey(name))); + } + + await tx.fileLibNode.update({ + where: { id: node.id }, + data: { deletedAt: null, name, nameLower: nameKey(name) }, + }); await writeFileLibAudit(tx, { action: node.kind === "PROJECT" ? FILE_LIB_AUDIT_ACTIONS.projectRestore @@ -126,8 +162,9 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId objectType: node.kind === "PROJECT" ? "project" : "folder", objectId: node.id, objectPath: node.pathIds, - detail: { name: node.name }, + detail: { name, ...(renamedFrom !== undefined ? { renamedFrom } : {}) }, }); + return { name, renamedFrom }; }); } diff --git a/hub/src/database/routes/binRoutes.ts b/hub/src/database/routes/binRoutes.ts index 402503a..2077155 100644 --- a/hub/src/database/routes/binRoutes.ts +++ b/hub/src/database/routes/binRoutes.ts @@ -25,8 +25,7 @@ export async function registerBinRoutes(app: FastifyInstance, deps: FileLibRoute if (actor === null) return reply; try { const { id } = request.params as { id: string }; - await restoreBinEntry(svc, actor, id); - return reply.status(204).send(); + return await restoreBinEntry(svc, actor, id); } catch (error) { return sendRouteError(reply, error); } diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts index c28d434..c66299b 100644 --- a/hub/test/integration/filelib-nav.test.ts +++ b/hub/test/integration/filelib-nav.test.ts @@ -91,6 +91,32 @@ describe("binService · 恢复", () => { }); expect(audits).toHaveLength(1); }); + + it("ADR-0033:与活跃兄弟撞名时恢复为「(已恢复)」,不死锁;审计记 renamedFrom", async () => { + const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); + const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" }); + await softDeleteNode(treeDeps(), ADMIN, child.id); + // 删除后同名新建 → 活跃兄弟占了名字 + await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" }); + + const result = await restoreBinEntry(binDeps(), ADMIN, child.id); + expect(result.renamedFrom).toBe("必修一"); + expect(result.name).toBe("必修一(已恢复)"); + + const visible = await listChildren(treeDeps(), ADMIN, root.id); + expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(已恢复)"]); + + // 再删再恢复:名字已是去重后的「(已恢复)」,不再冲突 → 保持,不二次改名 + await softDeleteNode(treeDeps(), ADMIN, child.id); + const second = await restoreBinEntry(binDeps(), ADMIN, child.id); + expect(second.name).toBe("必修一(已恢复)"); + expect(second.renamedFrom).toBeUndefined(); + + const audits = await prisma.auditEntry.findMany({ + where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore }, + }); + expect(audits).toHaveLength(2); + }); }); describe("binService · 彻底删除", () => { From beaa92de2e78d6f0c17b0189941f59b945c60891 Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 14:01:46 +0800 Subject: [PATCH 17/24] =?UTF-8?q?feat(filelib):=20=E5=BD=BB=E5=BA=95?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E6=94=B9=E4=B8=8E=E6=9D=A1=E7=9B=AE=E5=8F=AF?= =?UTF-8?q?=E8=A7=81=E6=80=A7=E5=90=8C=E6=9D=83(ADR-0034,supersede=20ADR-0?= =?UTF-8?q?031=20=E4=BB=85=E7=AE=A1=E7=90=86=E5=91=98=E6=9D=A1=E6=AC=BE)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 能删进回收站(MANAGE)的人就能清空;无关者 404(D8)。二次确认与 node.purge 审计不变;BinView 彻底删除按钮对全部可见条目开放。 --- docs/adr/0034-purge-follows-manage.md | 28 ++++++++++++++++++++++++ hub/filelib-web/src/lib/BinView.svelte | 19 ++++++++-------- hub/src/database/filelib/binService.ts | 12 ++++------ hub/test/integration/filelib-nav.test.ts | 20 +++++++++++++---- 4 files changed, 57 insertions(+), 22 deletions(-) create mode 100644 docs/adr/0034-purge-follows-manage.md diff --git a/docs/adr/0034-purge-follows-manage.md b/docs/adr/0034-purge-follows-manage.md new file mode 100644 index 0000000..e2f1bcb --- /dev/null +++ b/docs/adr/0034-purge-follows-manage.md @@ -0,0 +1,28 @@ +# ADR 0034: Permanent Delete Follows MANAGE, Not Website Administrator + +## Status + +Accepted. **Supersedes one clause of ADR-0031**: "Permanent delete (彻底删除) is +website-administrator only". + +## Context + +ADR-0031 gated 彻底删除 to the website administrator as a high-risk-operation +precaution. The product call is that this is inconsistent with the rest of the +permission model: soft delete already requires only MANAGE on the node, and a +MANAGE holder who can delete a node into the bin should also be able to purge it — +the authority that grants deletion grants destruction. Admin-only purge strands +non-admin managers with bins they cannot empty. + +## Decision + +Permanent delete uses **the same visibility rule as the bin entry itself**: website +administrator, or an actor with an active MANAGE grant on the deleted node (direct +grant, USER or resolved GROUP). Anyone else gets 404 (D8). The double confirmation +in the UI and the `node.purge` audit entry are unchanged. + +## Consequences + +- Purge auth = restore auth = bin-entry visibility: one rule, three surfaces. +- The operation remains irreversible and audited; no new capability is granted to + anyone who could not already delete the node (soft) and see it in the bin. diff --git a/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte index 91732c6..a74b71b 100644 --- a/hub/filelib-web/src/lib/BinView.svelte +++ b/hub/filelib-web/src/lib/BinView.svelte @@ -5,7 +5,7 @@ */ import { onMount } from "svelte"; import { api } from "./api.js"; - import { me, toastErr, toastOk } from "./stores.js"; + import { toastErr, toastOk } from "./stores.js"; import type { BinEntry } from "./types.js"; import Icon from "./Icon.svelte"; @@ -94,15 +94,14 @@ > 恢复 - {#if $me?.isWebsiteAdmin} - - {/if} + +
{/each}
diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts index dc702df..7aaadd2 100644 --- a/hub/src/database/filelib/binService.ts +++ b/hub/src/database/filelib/binService.ts @@ -169,19 +169,15 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId } /** - * 彻底删除(仅网站管理员):整支硬删。子树经 pathIds 前缀枚举, + * 彻底删除(ADR-0034:与回收站条目同一可见性 —— 管理员或节点直连 MANAGE; + * 能删进回收站的人就能清空)。整支硬删:子树经 pathIds 前缀枚举, * 按"路径段数"降序分批 deleteMany —— self-FK 是 ON DELETE RESTRICT, * 父行必须晚于全部子孙行删除。 */ export async function purgeBinEntry(deps: BinDeps, actor: FileLibActor, nodeId: string): Promise<{ readonly removed: number }> { - if (!actor.isWebsiteAdmin) { - throw new FileLibError(404, "node_not_found", "node not found"); - } + const groupIds = await deps.groupResolver.resolveMemberGroupIds(actor.userId); return deps.prisma.$transaction(async (tx) => { - const node = await tx.fileLibNode.findFirst({ - where: { id: nodeId, organizationId: deps.organizationId, deletedAt: { not: null } }, - }); - if (node === null) throw new FileLibError(404, "node_not_found", "node not found"); + const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId); const subtree = await tx.fileLibNode.findMany({ where: { diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts index c66299b..f8de9fd 100644 --- a/hub/test/integration/filelib-nav.test.ts +++ b/hub/test/integration/filelib-nav.test.ts @@ -120,15 +120,15 @@ describe("binService · 恢复", () => { }); describe("binService · 彻底删除", () => { - it("仅管理员;整支硬删(含子孙/授权),落 node.purge 审计", async () => { + it("ADR-0034:与条目可见性同权 —— 直连 MANAGE 可清空,无关者 404;整支硬删 + node.purge 审计", async () => { const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "PROJECT", name: "TH-141", - grants: [{ principalType: "USER", principalId: "u_alice", role: "EDIT" }], + grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }], }); - await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶 + await softDeleteNode(treeDeps(), ADMIN, root.id); // 连根删:root 是顶;alice 在 root 上无直连 MANAGE - await expect(purgeBinEntry(binDeps(), ALICE, root.id)).rejects.toMatchObject({ statusCode: 404 }); + await expect(purgeBinEntry(binDeps(), BOB, root.id)).rejects.toMatchObject({ statusCode: 404 }); const { removed } = await purgeBinEntry(binDeps(), ADMIN, root.id); expect(removed).toBe(2); @@ -138,4 +138,16 @@ describe("binService · 彻底删除", () => { const audits = await prisma.auditEntry.findMany({ where: { action: FILE_LIB_AUDIT_ACTIONS.nodePurge } }); expect(audits).toHaveLength(1); }); + + it("ADR-0034:非管理员的直连 MANAGE 持有者也能彻底删除", async () => { + const own = await createNode(treeDeps(), ADMIN, { + parentId: null, kind: "PROJECT", name: "alice 项目", + grants: [{ principalType: "USER", principalId: "u_alice", role: "MANAGE" }], + }); + await softDeleteNode(treeDeps(), ADMIN, own.id); + + const { removed } = await purgeBinEntry(binDeps(), ALICE, own.id); + expect(removed).toBe(1); + expect(await prisma.fileLibNode.count({ where: { id: own.id } })).toBe(0); + }); }); From 09338f355a19327961b027247b8abc1941356e9b Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 14:11:26 +0800 Subject: [PATCH 18/24] =?UTF-8?q?chore(filelib-web):=20=E8=AF=A6=E6=83=85?= =?UTF-8?q?=E5=BC=B9=E7=AA=97=E5=8A=A0=E5=88=A0=E9=99=A4=E6=8C=89=E9=92=AE?= =?UTF-8?q?(MANAGE=20=E4=B8=93=E5=B1=9E,=E8=BF=9B=E5=9B=9E=E6=94=B6?= =?UTF-8?q?=E7=AB=99=E5=8F=AF=E6=81=A2=E5=A4=8D),=E5=88=A0=E9=99=A4?= =?UTF-8?q?=E5=85=A5=E5=8F=A3=E4=B8=8D=E5=86=8D=E5=8F=AA=E5=9C=A8=E5=8F=B3?= =?UTF-8?q?=E9=94=AE=E8=8F=9C=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/GridLibraryView.svelte | 2 +- hub/filelib-web/src/lib/OverviewPanel.svelte | 30 ++++++++++++++++++- 2 files changed, 30 insertions(+), 2 deletions(-) diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte index 8e9c90a..4385d6a 100644 --- a/hub/filelib-web/src/lib/GridLibraryView.svelte +++ b/hub/filelib-web/src/lib/GridLibraryView.svelte @@ -515,7 +515,7 @@ {#if modal === "detail" && detailNode} (modal = null)}> - + { modal = null; refresh(); }} /> {/if} diff --git a/hub/filelib-web/src/lib/OverviewPanel.svelte b/hub/filelib-web/src/lib/OverviewPanel.svelte index 694bf02..82881e9 100644 --- a/hub/filelib-web/src/lib/OverviewPanel.svelte +++ b/hub/filelib-web/src/lib/OverviewPanel.svelte @@ -6,15 +6,32 @@ import type { ExportJob, NodeDetail } from "./types.js"; import Modal from "./Modal.svelte"; - let { node }: { node: NodeDetail } = $props(); + let { node, ondeleted }: { node: NodeDetail; ondeleted?: () => void } = $props(); let showEditDesc = $state(false); let descDraft = $state(""); let exportJob = $state(null); + let deleting = $state(false); const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT"); + const canManage = $derived(node.role === "MANAGE"); const roleLabel = $derived(ROLE_LABEL[node.role]); + /** 删除(进回收站,可恢复;ADR-0031)。MANAGE 专属,与右键菜单同语义。 */ + async function deleteNode(): Promise { + if (!confirm(`删除「${node.name}」?移入回收站,可在回收站恢复。`)) return; + deleting = true; + try { + await api(`/database/api/nodes/${node.id}`, { method: "DELETE" }); + toastOk("已移入回收站"); + ondeleted?.(); + } catch (e) { + toastErr(e instanceof Error ? e.message : String(e)); + } finally { + deleting = false; + } + } + $effect(() => { void node.id; exportJob = null; @@ -113,6 +130,17 @@ {/if}
{/if} + + {#if canManage} +
+ +
+ 删除后移入回收站,可恢复 + +
+ {/if}
{#if showEditDesc} From d39ebed62e3e039a9777b1cb1151a013390070d9 Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 14:16:20 +0800 Subject: [PATCH 19/24] =?UTF-8?q?fix(filelib-web):=20=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=8F=B3=E9=94=AE=E8=A1=A5=20stopPropagation,=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E8=A2=AB=E8=83=8C=E6=99=AF=E8=8F=9C=E5=8D=95=E8=A6=86?= =?UTF-8?q?=E7=9B=96(=E5=AF=BC=E8=87=B4=E7=9C=8B=E4=B8=8D=E5=88=B0?= =?UTF-8?q?=E5=88=A0=E9=99=A4=E7=AD=89=E8=8A=82=E7=82=B9=E6=93=8D=E4=BD=9C?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/filelib-web/src/lib/GridCard.svelte | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hub/filelib-web/src/lib/GridCard.svelte b/hub/filelib-web/src/lib/GridCard.svelte index 5880111..7583a5a 100644 --- a/hub/filelib-web/src/lib/GridCard.svelte +++ b/hub/filelib-web/src/lib/GridCard.svelte @@ -28,7 +28,7 @@ : 'hover:bg-hover'}" onclick={onselect} ondblclick={onopen} - oncontextmenu={(e) => { e.preventDefault(); oncontextmenu(e.clientX, e.clientY); }} + oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); oncontextmenu(e.clientX, e.clientY); }} title={name} > From d9cde19bdf0fc6a91bf8b4bd283319fb6c20ba81 Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 14:28:34 +0800 Subject: [PATCH 20/24] =?UTF-8?q?fix(filelib):=20=E6=81=A2=E5=A4=8D?= =?UTF-8?q?=E4=BF=9D=E7=95=99=E5=8E=9F=E5=90=8D,=E6=92=9E=E5=90=8D?= =?UTF-8?q?=E6=8A=A5=E6=B8=85=E6=99=B0=E9=94=99=E8=AF=AF=E8=80=8C=E9=9D=9E?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8A=A0=E5=90=8E=E7=BC=80(ADR-0035,supersed?= =?UTF-8?q?e=20ADR-0033)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 恢复不再改名;同名兄弟占位时抛 409 name_conflict_on_restore + 人话提示, 操作者自行重命名现有节点或彻底删除旧节点后再恢复。 --- docs/adr/0035-restore-keeps-original-name.md | 20 +++++++++++++ hub/filelib-web/src/lib/BinView.svelte | 8 ++--- hub/src/database/filelib/binService.ts | 31 ++++++-------------- hub/test/integration/filelib-nav.test.ts | 30 +++++++++---------- 4 files changed, 45 insertions(+), 44 deletions(-) create mode 100644 docs/adr/0035-restore-keeps-original-name.md diff --git a/docs/adr/0035-restore-keeps-original-name.md b/docs/adr/0035-restore-keeps-original-name.md new file mode 100644 index 0000000..a16c689 --- /dev/null +++ b/docs/adr/0035-restore-keeps-original-name.md @@ -0,0 +1,20 @@ +# ADR 0035: Restore Keeps The Original Name; Conflict Is A Clear Error + +## Status + +Accepted. **Supersedes ADR-0033** (restore de-duplicates the node name on sibling +conflict). + +## Context + +ADR-0033 made restore auto-rename to `原名(已恢复)` on sibling name conflict so +restore never fails. In practice the suffix is unwanted noise - operators expect the +original name back and prefer to resolve conflicts themselves. + +## Decision + +Restore clears `deletedAt` and keeps the node's **original name**. If an active +sibling now occupies the same name (D14 partial unique index), the service throws a +`409 name_conflict_on_restore` with a human-readable message ("同名节点已存在,请先 +重命名现有节点再恢复") - no silent renaming, no suffix. The restore audit records +the original name only. diff --git a/hub/filelib-web/src/lib/BinView.svelte b/hub/filelib-web/src/lib/BinView.svelte index a74b71b..0db73f4 100644 --- a/hub/filelib-web/src/lib/BinView.svelte +++ b/hub/filelib-web/src/lib/BinView.svelte @@ -36,15 +36,11 @@ async function restore(e: BinEntry): Promise { busyId = e.id; try { - const r = await api<{ name: string; renamedFrom?: string }>( + const r = await api<{ name: string }>( `/database/api/bin/${encodeURIComponent(e.id)}/restore`, { method: "POST" }, ); - toastOk( - r.renamedFrom !== undefined && r.renamedFrom !== r.name - ? `已恢复为「${r.name}」(原名与现有节点冲突)` - : `已恢复「${r.name}」`, - ); + toastOk(`已恢复「${r.name}」`); await load(); } catch (err) { toastErr(err instanceof Error ? err.message : String(err)); diff --git a/hub/src/database/filelib/binService.ts b/hub/src/database/filelib/binService.ts index 7aaadd2..15e0687 100644 --- a/hub/src/database/filelib/binService.ts +++ b/hub/src/database/filelib/binService.ts @@ -11,7 +11,7 @@ */ import type { PrismaClient } from "@prisma/client"; -import { FileLibError, nameKey, NODE_NAME_MAX_LENGTH } from "./model.js"; +import { FileLibError, nameKey } from "./model.js"; import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js"; import type { GroupResolver } from "./groupResolver.js"; import type { FileLibActor } from "./treeService.js"; @@ -113,7 +113,6 @@ async function requireBinEntry( export interface RestoreResult { readonly name: string; - readonly renamedFrom?: string | undefined; } /** @@ -126,33 +125,21 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId return deps.prisma.$transaction(async (tx) => { const node = await requireBinEntry(tx as PrismaClient, deps, actor, groupIds, nodeId); - const siblings = await tx.fileLibNode.findMany({ + const clash = await tx.fileLibNode.findFirst({ where: { organizationId: deps.organizationId, parentId: node.parentId, deletedAt: null, id: { not: node.id }, + nameLower: nameKey(node.name), }, - select: { nameLower: true }, + select: { id: true }, }); - const taken = new Set(siblings.map((s) => s.nameLower)); - - let name = node.name; - let renamedFrom: string | undefined; - if (taken.has(nameKey(name))) { - renamedFrom = node.name; - let n = 1; - do { - const suffix = n === 1 ? "(已恢复)" : `(已恢复 ${n})`; - name = node.name.slice(0, NODE_NAME_MAX_LENGTH - suffix.length) + suffix; - n += 1; - } while (taken.has(nameKey(name))); + if (clash !== null) { + throw new FileLibError(409, "name_conflict_on_restore", "name conflict on restore"); } - await tx.fileLibNode.update({ - where: { id: node.id }, - data: { deletedAt: null, name, nameLower: nameKey(name) }, - }); + await tx.fileLibNode.update({ where: { id: node.id }, data: { deletedAt: null } }); await writeFileLibAudit(tx, { action: node.kind === "PROJECT" ? FILE_LIB_AUDIT_ACTIONS.projectRestore @@ -162,9 +149,9 @@ export async function restoreBinEntry(deps: BinDeps, actor: FileLibActor, nodeId objectType: node.kind === "PROJECT" ? "project" : "folder", objectId: node.id, objectPath: node.pathIds, - detail: { name, ...(renamedFrom !== undefined ? { renamedFrom } : {}) }, + detail: { name: node.name }, }); - return { name, renamedFrom }; + return { name: node.name }; }); } diff --git a/hub/test/integration/filelib-nav.test.ts b/hub/test/integration/filelib-nav.test.ts index f8de9fd..5272981 100644 --- a/hub/test/integration/filelib-nav.test.ts +++ b/hub/test/integration/filelib-nav.test.ts @@ -10,6 +10,7 @@ import { createNode, softDeleteNode, listChildren, + renameNode, type FileLibActor, type TreeServiceDeps, } from "../../src/database/filelib/treeService.js"; @@ -92,30 +93,27 @@ describe("binService · 恢复", () => { expect(audits).toHaveLength(1); }); - it("ADR-0033:与活跃兄弟撞名时恢复为「(已恢复)」,不死锁;审计记 renamedFrom", async () => { + it("ADR-0035:撞名时恢复报 name_conflict_on_restore(不自动改名),清名后可恢复", async () => { const root = await createNode(treeDeps(), ADMIN, { parentId: null, kind: "FOLDER", name: "物理" }); const child = await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" }); await softDeleteNode(treeDeps(), ADMIN, child.id); - // 删除后同名新建 → 活跃兄弟占了名字 + // 删除后同名新建 -> 活跃兄弟占了名字 await createNode(treeDeps(), ADMIN, { parentId: root.id, kind: "FOLDER", name: "必修一" }); + await expect(restoreBinEntry(binDeps(), ADMIN, child.id)).rejects.toMatchObject({ + statusCode: 409, + code: "name_conflict_on_restore", + }); + + // 改名现有节点后恢复 -> 成功,保留原名 + const active = (await listChildren(treeDeps(), ADMIN, root.id)).find((c) => c.name === "必修一")!; + await renameNode(treeDeps(), ADMIN, active.id, "必修一(新)"); const result = await restoreBinEntry(binDeps(), ADMIN, child.id); - expect(result.renamedFrom).toBe("必修一"); - expect(result.name).toBe("必修一(已恢复)"); + expect(result.name).toBe("必修一"); + expect(result.renamedFrom).toBeUndefined(); const visible = await listChildren(treeDeps(), ADMIN, root.id); - expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(已恢复)"]); - - // 再删再恢复:名字已是去重后的「(已恢复)」,不再冲突 → 保持,不二次改名 - await softDeleteNode(treeDeps(), ADMIN, child.id); - const second = await restoreBinEntry(binDeps(), ADMIN, child.id); - expect(second.name).toBe("必修一(已恢复)"); - expect(second.renamedFrom).toBeUndefined(); - - const audits = await prisma.auditEntry.findMany({ - where: { action: FILE_LIB_AUDIT_ACTIONS.folderRestore }, - }); - expect(audits).toHaveLength(2); + expect(visible.map((c) => c.name).sort()).toEqual(["必修一", "必修一(新)"]); }); }); From e9cecbf0715c0303e898c92b32df26b6d92eefc1 Mon Sep 17 00:00:00 2001 From: ymy Date: Fri, 31 Jul 2026 14:51:55 +0800 Subject: [PATCH 21/24] =?UTF-8?q?chore(filelib-web):=20=E6=94=BE=E5=A4=A7?= =?UTF-8?q?=E7=BD=91=E6=A0=BC=E5=9B=BE=E6=A0=87(=E6=96=87=E4=BB=B6?= =?UTF-8?q?=E5=A4=B954->72/=E9=A1=B9=E7=9B=AE50->66/=E6=96=87=E4=BB=B646->?= =?UTF-8?q?60),=E5=88=97=E5=AE=BD118->132?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- hub/filelib-web/src/lib/GridCard.svelte | 8 ++++---- hub/filelib-web/src/lib/GridLibraryView.svelte | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/hub/filelib-web/src/lib/GridCard.svelte b/hub/filelib-web/src/lib/GridCard.svelte index 7583a5a..bc30b4e 100644 --- a/hub/filelib-web/src/lib/GridCard.svelte +++ b/hub/filelib-web/src/lib/GridCard.svelte @@ -31,13 +31,13 @@ oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); oncontextmenu(e.clientX, e.clientY); }} title={name} > - + {#if kind === "FOLDER"} - + {:else if kind === "PROJECT"} - + {:else} - + {/if} {name} diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte index 4385d6a..4edde8b 100644 --- a/hub/filelib-web/src/lib/GridLibraryView.svelte +++ b/hub/filelib-web/src/lib/GridLibraryView.svelte @@ -416,7 +416,7 @@ {currentFolder === null ? "空文件库" : "空文件夹"}{canCreateHere ? " · 右键或点上方按钮新建" : ""}
{:else} -
+
{#each children as n (n.id)} 空仓库{projectCanEdit ? " · 右键或点上方按钮新建文件" : ""}
{:else} -
+
{#each files as f (f.path)} Date: Fri, 31 Jul 2026 14:57:01 +0800 Subject: [PATCH 22/24] =?UTF-8?q?chore(filelib-web):=20=E5=B7=A6=E6=A0=8F?= =?UTF-8?q?=E5=8A=A0=E3=80=8C=E6=95=99=E7=A0=94=E6=95=B0=E6=8D=AE=E5=BA=93?= =?UTF-8?q?=E3=80=8D=E6=A0=87=E9=A2=98,=E7=94=A8=E6=88=B7=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD+=E9=80=80=E5=87=BA=E7=A7=BB=E8=87=B3=E6=A0=8F?= =?UTF-8?q?=E5=BA=95,=E9=A1=B6=E6=A0=8F=E5=8E=BB=E6=8E=89=E8=BA=AB?= =?UTF-8?q?=E4=BB=BD=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/lib/GridLibraryView.svelte | 12 ----- hub/filelib-web/src/routes/app/+page.svelte | 45 +++++++++++++------ 2 files changed, 32 insertions(+), 25 deletions(-) diff --git a/hub/filelib-web/src/lib/GridLibraryView.svelte b/hub/filelib-web/src/lib/GridLibraryView.svelte index 4edde8b..1dbcc80 100644 --- a/hub/filelib-web/src/lib/GridLibraryView.svelte +++ b/hub/filelib-web/src/lib/GridLibraryView.svelte @@ -11,7 +11,6 @@ import { onMount } from "svelte"; import { api } from "./api.js"; import { me, toastErr, toastOk } from "./stores.js"; - import { logout } from "./session.js"; import { selectedFilePath, clearSelectedFile } from "./browser.js"; import { ROLE_LABEL } from "./labels.js"; import type { FileEntry, NodeChild, NodeDetail, Role } from "./types.js"; @@ -61,7 +60,6 @@ currentFolder === null ? ($me?.isWebsiteAdmin ?? false) : atLeast(currentFolder.role, "EDIT"), ); const projectCanEdit = $derived(projectNode !== null && projectNode.role !== "VIEW"); - const initial = $derived(($me?.displayName ?? $me?.userId ?? "U").slice(0, 1).toUpperCase()); /* ------------------------------------------------------------ 数据加载 */ @@ -387,16 +385,6 @@ {/if} - -
- {initial} - {$me?.displayName ?? $me?.userId ?? ""} - -
diff --git a/hub/filelib-web/src/routes/app/+page.svelte b/hub/filelib-web/src/routes/app/+page.svelte index 9cb2e19..a1e071c 100644 --- a/hub/filelib-web/src/routes/app/+page.svelte +++ b/hub/filelib-web/src/routes/app/+page.svelte @@ -2,7 +2,7 @@ /** 老师端。未登录显示登录卡片;登录后是带左栏导航的文件库(ADR-0031)。 */ import { onMount } from "svelte"; import { me, authChecked } from "$lib/stores.js"; - import { loadSession } from "$lib/session.js"; + import { loadSession, logout } from "$lib/session.js"; import LoginView from "$lib/LoginView.svelte"; import GridLibraryView from "$lib/GridLibraryView.svelte"; import BinView from "$lib/BinView.svelte"; @@ -17,27 +17,46 @@ ["library", "文件库", "layers"], ["bin", "回收站", "trash"], ]; + + const initial = $derived(($me?.displayName ?? $me?.userId ?? "U").slice(0, 1).toUpperCase()); -文件库 +教研数据库 {#if !$authChecked}
加载中…
{:else if $me}
-