feat(filelib-web): 授权面板表格化:搜索、添加弹窗、权限下拉与成员跳转

- 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 合并残留)。
This commit is contained in:
ymy
2026-07-27 15:13:57 +08:00
parent a4c07d1a5d
commit 0dd2ae347e
6 changed files with 335 additions and 99 deletions
@@ -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<OrgRole, string> = {
OWNER: "所有者",
@@ -23,6 +25,9 @@
let members = $state<OrgMember[] | null>(null);
let error = $state<string | null>(null);
// 列表过滤;授权面板跳转会带 ?q=<userId>,以此为初始过滤词。
let filterText = $state(page.url.searchParams.get("q") ?? "");
let newOpenId = $state("");
let newName = $state("");
let newRole = $state<OrgRole>("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<void> {
if (base === null) return;
try {
@@ -121,14 +139,28 @@
</div>
<div class="panel">
<div class="section-title mb-2.5">成员列表</div>
<div class="mb-2.5 flex items-center justify-between gap-2">
<div class="section-title">成员列表</div>
<div class="relative w-[260px] shrink-0">
<span class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input
class="input w-full !py-[5px] !pl-8 !text-[12.5px]"
placeholder="过滤:名称 / userId / openId"
bind:value={filterText}
/>
</div>
</div>
{#if error}
<div class="py-3 text-[12.5px] text-danger">{error}</div>
{:else if members === null}
{:else if shown === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else if members.length === 0}
<div class="quiet py-[18px] text-center">暂无成员</div>
{:else if shown.length === 0}
<div class="quiet py-[18px] text-center">
{filterText.trim() === "" ? "暂无成员" : `无匹配「${filterText.trim()}」的成员`}
</div>
{:else}
<table class="list">
<thead>
@@ -140,7 +172,7 @@
</tr>
</thead>
<tbody>
{#each members as m (m.userId)}
{#each shown as m (m.userId)}
<tr>
<td class="text-ink">{m.displayName || m.userId}</td>
<td class="file-meta">{m.userId}</td>