From eeb8f56742441a7e2fdc4ade55c8a0cc693ea466 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=99=BD?= <3401797899@qq.com> Date: Sun, 26 Jul 2026 20:19:30 +0800 Subject: [PATCH] =?UTF-8?q?fix(filelib-web):=20=E8=A1=A5=E9=BD=90=20Group?= =?UTF-8?q?=20=E7=AE=A1=E7=90=86=E9=9D=A2=E6=9D=BF,=E4=B8=8E=E6=97=A7?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E9=9D=A2=E6=9D=BF=E9=80=90=E6=9D=A1=E5=AF=B9?= =?UTF-8?q?=E9=BD=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 迁移时误把分支上一个早先存在的简易 GroupAdmin(281 行)当成迁移产物, 它与旧 renderGroupsPanel(747 行)从来不是同一个东西,于是后端 8 个 group 端点前端只调了 5 个。 补上的功能(端点一直可用,只是没有入口): PATCH /groups/:id 重命名 / 改描述 GET /groups?includeArchived=1 列出已归档组 POST /groups/:id/restore 恢复(连带恢复已归档祖先链,子树仍归档) GET /users/search 成员选择器,不再手敲 userId 影响最实际的是恢复:软删的组此前在界面上无法恢复。 补上的交互:折叠树、组名过滤(命中项保留整条祖先链,过滤态强制展开)、 右键菜单(归档组只给「恢复」)、面包屑、统计条、树底部计数、成员表的 头像/openId/加入时间三列。 types.ts 之前也是截断的:MemberGroupNode 少 archivedAt, MemberGroupMember 少 feishuOpenId/avatarUrl/joinedAt —— 类型里没有, UI 自然渲染不出来。 一处实现偏离:折叠状态用数组而非 Set。Svelte 5 的 $state 深层代理不 跟踪 Set 变更,用 Set 会点了没反应。 groups tab 外框补 padding:20px/overflow:hidden,对齐旧 #tab-groups, 否则面板贴着侧边栏。 --- hub/filelib-web/src/lib/GroupAdmin.svelte | 766 ++++++++++++++++++---- hub/filelib-web/src/lib/types.ts | 64 ++ 2 files changed, 688 insertions(+), 142 deletions(-) diff --git a/hub/filelib-web/src/lib/GroupAdmin.svelte b/hub/filelib-web/src/lib/GroupAdmin.svelte index 8eaea46..4577c88 100644 --- a/hub/filelib-web/src/lib/GroupAdmin.svelte +++ b/hub/filelib-web/src/lib/GroupAdmin.svelte @@ -1,78 +1,174 @@ -
- - - -
+
{loaded ? treeFoot : ""}
+
+ + +
{#if selected === null} -
从左侧选择一个 Group 查看成员
+
+ + 从左侧选择一个 Group 查看成员 +
{:else} -
{selected.name}
- {#if selected.description} -

{selected.description}

- {:else} -
+ {#if isArchived} + +
+ + + 此 Group 已删除于 {fmtDate(selected.archivedAt ?? "")} · 成员只读,不再授予任何权限 + + +
{/if} -
-
- - { if (e.key === "Enter") void addMember(); }} - /> +
+
+ {#each chain as c, i (c.id)} + {#if i > 0}/{/if} + {c.name} + {/each} +
+ +
+ + + +
+
{selected.name}
+ {#if selected.description !== null && selected.description !== ""} +
{selected.description}
+ {:else} +
无描述
+ {/if} +
+ + {#if !isArchived} + + + {/if} +
+ +
+ {members.length} 名成员 + 层级 {selected.depth} + {childCount} 个子组
-
-
-
- 成员 {members === null ? "" : `(${members.length})`} +
+
+ + + +
- {#if members === null} -
加载中…
- {:else if membersError} -
{membersError}
+ + {memberFilter.trim() === "" ? "" : `${shownMembers.length} / ${members.length}`} + +
+ +
+ {#if !membersLoaded} +
加载中…
+ {:else if membersError !== null} +
{membersError}
{:else if members.length === 0} -
暂无成员
+
+ + {isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」"} +
+ {:else if shownMembers.length === 0} +
无匹配成员
{:else} - {#each members as m (m.userId)} -
- {m.displayName} - {m.userId} - -
- {/each} + + + + + + + + {#if !isArchived}{/if} + + + + {#each shownMembers as m (m.userId)} + + + + + + {#if !isArchived} + + {/if} + + {/each} + +
成员userId飞书 openId加入时间操作
+ + + {m.displayName || "(未命名)"} + + {m.userId}{m.feishuOpenId || "—"}{fmtDate(m.joinedAt)} + +
{/if}
{/if} - +
+ +{#if menu !== null} + +{/if} + {#if showCreate} - (showCreate = false)}> -
- - + (showCreate = false)} + > +
+ +
-
- - +
+ +
- - + + +
+ +{/if} + +{#if showRename && renameTarget !== null} + (showRename = false)}> +
+ + +
+
+ + +
+
+ + +
+
+{/if} + +{#if showAdd && selected !== null} + (showAdd = false)}> +
+ +
+ { + if (e.key === "Enter") void searchUsers(); + }} + /> + +
+
已在本组的成员不会出现在结果里。
+
+ +
+ {#if addSearching} +
搜索中…
+ {:else if addResults.length === 0} +
无候选用户 · 先点「搜索」
+ {:else} + {#each addResults as u (u.userId)} +
+ +
+
{u.displayName || "(未命名)"}
+
{u.feishuOpenId || u.userId}
+
+ +
+ {/each} + {/if} +
+ +
+
{/if} diff --git a/hub/filelib-web/src/lib/types.ts b/hub/filelib-web/src/lib/types.ts index 22e5179..f0bffd6 100644 --- a/hub/filelib-web/src/lib/types.ts +++ b/hub/filelib-web/src/lib/types.ts @@ -36,6 +36,9 @@ export interface NodeDetail { export interface MeResponse { readonly userId: string; readonly isWebsiteAdmin: boolean; + /** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */ + readonly displayName: string; + readonly avatarUrl: string | null; } export interface FileEntry { @@ -92,9 +95,70 @@ export interface MemberGroupNode { readonly description: string | null; readonly depth: number; readonly memberCount: number; + /** 软删标记(ADR-0028 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 + * 仅在 ?includeArchived=1 时可能非 null。ISO 串(后端 JSON 序列化后不再是 Date)。 */ + readonly archivedAt: string | null; } export interface MemberGroupMember { readonly userId: string; readonly displayName: string; + readonly feishuOpenId: string; + readonly avatarUrl: string | null; + /** 加入本组时间;ISO 串。 */ + readonly joinedAt: string; +} + +/** 节点授权(GET /database/api/nodes/:id/grants)。 */ +export interface Grant { + readonly id: string; + readonly principalType: "USER" | "GROUP"; + readonly principalId: string; + readonly role: Role; + /** 创建者授权不可收回、不可改(契约 8.1)。 */ + readonly isCreatorGrant: boolean; + readonly createdAt: string; +} + +/** Group 选择器候选(GET /database/api/groups/search)。 */ +export interface MemberGroupSearchResult { + readonly id: string; + readonly name: string; + /** 祖先链(根在前,自身在末),用 " / " 连接。 */ + readonly breadcrumb: string; +} + +/** 成员选择器候选(GET /database/api/users/search)。 */ +export interface UserSearchResult { + readonly userId: string; + readonly displayName: string; + readonly feishuOpenId: string; + readonly avatarUrl: string | null; +} + +/** 管理后台概览统计(GET /database/api/stats)。 */ +export interface DashboardStats { + readonly folders: number; + readonly projects: number; + readonly files: number; + readonly grants: number; + readonly recent: ReadonlyArray<{ + readonly action: string; + readonly actor: string; + readonly label: string; + /** ISO 串;后端 JSON 序列化后不再是 Date。 */ + readonly when: string; + }>; +} + +/** org 成员(GET /api/org/:slug/members);用户管理面板消费。 */ +export type OrgRole = "OWNER" | "ADMIN" | "MEMBER"; + +export interface OrgMember { + readonly userId: string; + readonly feishuOpenId: string; + readonly displayName: string; + readonly avatarUrl: string | null; + readonly role: OrgRole; + readonly createdAt: string; }