From 0dd2ae347eb965098aa64c1f53d26ff87f019b54 Mon Sep 17 00:00:00 2001 From: ymy Date: Mon, 27 Jul 2026 15:13:57 +0800 Subject: [PATCH] =?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(), ]);
{m.displayName || m.userId} {m.userId}