fix(filelib-web): 补齐 Group 管理面板,与旧后端面板逐条对齐

迁移时误把分支上一个早先存在的简易 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,
否则面板贴着侧边栏。
This commit is contained in:
2026-07-26 20:19:30 +08:00
parent 325b4fc137
commit eeb8f56742
2 changed files with 688 additions and 142 deletions
+633 -151
View File
@@ -1,78 +1,174 @@
<script lang="ts"> <script lang="ts">
/**
* Group 管理面板。从已删除的 routes/adminPanels.ts `renderGroupsPanel`(747 行)
* 迁来(ADR-0029),功能与视觉逐条对齐:折叠树 / 组名过滤(命中项保留祖先链)/
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。
*/
import { onMount } from "svelte"; import { onMount } from "svelte";
import { api } from "./api.js"; import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js"; import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember } from "./types.js"; import type { MemberGroupNode, MemberGroupMember, UserSearchResult } from "./types.js";
import Modal from "./Modal.svelte"; import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
import Avatar from "./Avatar.svelte";
// 后端返回活跃组扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。 // 后端返回扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[] | null>(null); let groups = $state<MemberGroupNode[]>([]);
let loaded = $state(false);
let listError = $state<string | null>(null); let listError = $state<string | null>(null);
let selectedId = $state<string | null>(null); let selectedId = $state<string | null>(null);
let members = $state<MemberGroupMember[] | null>(null); // 折叠的组 id(默认全展开)。用数组而非 Set:$state 的深层代理只跟踪普通对象/
// 数组,Set 的变更不会触发重渲染。
let collapsedIds = $state<string[]>([]);
const isCollapsed = (id: string): boolean => collapsedIds.includes(id);
const toggleCollapsed = (id: string): void => {
collapsedIds = isCollapsed(id) ? collapsedIds.filter((x) => x !== id) : [...collapsedIds, id];
};
let filterText = $state("");
let showArchived = $state(false);
let members = $state<MemberGroupMember[]>([]);
let membersLoaded = $state(false);
let membersError = $state<string | null>(null); let membersError = $state<string | null>(null);
let memberFilter = $state("");
// 新建组弹窗:parentId=null 建根,否则建子。 const selected = $derived(groups.find((g) => g.id === selectedId) ?? null);
let showCreate = $state(false); const isArchived = $derived(selected?.archivedAt != null);
let createParentId = $state<string | null>(null);
let createParentName = $state<string | null>(null);
let newName = $state("");
let newDesc = $state("");
let addValue = $state(""); interface Row {
readonly g: MemberGroupNode;
readonly hasKids: boolean;
/** 过滤态下:自身是否命中(祖先链上的非命中项半透明显示)。 */
readonly hit: boolean;
}
const selected = $derived(groups?.find((g) => g.id === selectedId) ?? null); /** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
const rows = $derived.by((): Row[] => {
/** 扁平列表按 parentId 排成先根遍历顺序(每项自带 depth,渲染时缩进)。 */
const ordered = $derived.by(() => {
if (groups === null) return [];
const byParent = new Map<string | null, MemberGroupNode[]>(); const byParent = new Map<string | null, MemberGroupNode[]>();
const byId = new Map<string, MemberGroupNode>();
for (const g of groups) { for (const g of groups) {
byId.set(g.id, g);
const arr = byParent.get(g.parentId) ?? []; const arr = byParent.get(g.parentId) ?? [];
arr.push(g); arr.push(g);
byParent.set(g.parentId, arr); byParent.set(g.parentId, arr);
} }
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name)); for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
const out: MemberGroupNode[] = [];
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
const q = filterText.trim().toLowerCase();
let keep: Set<string> | null = null;
if (q !== "") {
keep = new Set<string>();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur: MemberGroupNode | undefined = g;
while (cur !== undefined) {
keep.add(cur.id);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
}
}
const out: Row[] = [];
const walk = (parentId: string | null): void => { const walk = (parentId: string | null): void => {
for (const g of byParent.get(parentId) ?? []) { for (const g of byParent.get(parentId) ?? []) {
out.push(g); if (keep !== null && !keep.has(g.id)) continue;
walk(g.id); const kids = (byParent.get(g.id) ?? []).filter((k) => keep === null || keep.has(k.id));
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
// 过滤态下强制展开(否则命中项被折叠的祖先藏住)。
if (keep !== null || !isCollapsed(g.id)) walk(g.id);
} }
}; };
walk(null); walk(null);
// 兜底:孤儿(父已不在活跃列表,理论上级联软删不会出现)也列出 // 兜底:父不在列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"
const seen = new Set(out.map((g) => g.id)); const seen = new Set(out.map((r) => r.g.id));
for (const g of groups) if (!seen.has(g.id)) out.push(g); for (const g of groups) {
if (seen.has(g.id)) continue;
if (keep !== null && !keep.has(g.id)) continue;
out.push({ g, hasKids: false, hit: true });
}
return out; return out;
}); });
const treeFoot = $derived.by(() => {
const active = groups.filter((g) => g.archivedAt === null);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
return (
`${active.length} 个活跃组 · ${totalMembers} 条成员关系` +
(archivedN > 0 ? ` · ${archivedN} 个已删除` : "")
);
});
/** 面包屑:祖先链(根在前,自身在末)。 */
const chain = $derived.by((): MemberGroupNode[] => {
if (selected === null) return [];
const byId = new Map(groups.map((g) => [g.id, g]));
const out: MemberGroupNode[] = [];
for (let cur: MemberGroupNode | undefined = selected; cur !== undefined; ) {
out.unshift(cur);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
return out;
});
const childCount = $derived(groups.filter((g) => g.parentId === selectedId).length);
const shownMembers = $derived.by(() => {
const q = memberFilter.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),
);
});
function fmtDate(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
async function loadGroups(): Promise<void> { async function loadGroups(): Promise<void> {
try { try {
const r = await api<{ groups: MemberGroupNode[] }>("/database/api/groups"); const r = await api<{ groups: MemberGroupNode[] }>(
`/database/api/groups${showArchived ? "?includeArchived=1" : ""}`,
);
groups = r.groups; groups = r.groups;
listError = null; listError = null;
loaded = true;
if (selectedId !== null && !groups.some((g) => g.id === selectedId)) { if (selectedId !== null && !groups.some((g) => g.id === selectedId)) {
selectedId = null; selectedId = null;
members = null; members = [];
membersLoaded = false;
} }
} catch (e) { } catch (e) {
listError = e instanceof Error ? e.message : String(e); listError = errText(e);
loaded = true;
} }
} }
async function loadMembers(): Promise<void> { async function loadMembers(): Promise<void> {
if (selectedId === null) return; if (selectedId === null) return;
members = null; membersLoaded = false;
membersError = null; membersError = null;
try { try {
const r = await api<{ members: MemberGroupMember[] }>( const r = await api<{ members: MemberGroupMember[] }>(
`/database/api/groups/${encodeURIComponent(selectedId)}/members`, `/database/api/groups/${encodeURIComponent(selectedId)}/members`,
); );
members = r.members; members = r.members;
membersLoaded = true;
} catch (e) { } catch (e) {
membersError = e instanceof Error ? e.message : String(e); membersError = errText(e);
membersLoaded = true;
} }
} }
@@ -80,12 +176,77 @@
function select(id: string): void { function select(id: string): void {
selectedId = id; selectedId = id;
memberFilter = "";
void loadMembers(); void loadMembers();
} }
async function toggleArchived(): Promise<void> {
showArchived = !showArchived;
await loadGroups();
}
/* ---------------- 右键菜单 ---------------- */
interface MenuItem {
readonly label?: string;
readonly ic?: import("./Icon.svelte").IconName;
readonly danger?: boolean;
readonly sep?: boolean;
readonly fn?: () => void;
}
let menu = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
function openMenu(e: MouseEvent, target: MemberGroupNode | null): void {
e.preventDefault();
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items: MenuItem[] =
target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => openCreate(null) }]
: target.archivedAt !== null
? [
{ label: "查看成员(只读)", ic: "users", fn: () => select(target.id) },
{ label: "恢复此 Group", ic: "restore", fn: () => void restoreGroup(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
]
: [
{ label: "新建子 Group", ic: "plus", fn: () => openCreate(target) },
{ label: "添加成员", ic: "user", fn: () => { select(target.id); openAddMember(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => openRename(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => void deleteGroup(target) },
];
// 贴边翻转,避免菜单溢出视口(菜单宽 184、每项约 34)。
const w = 184;
const h = items.reduce((n, it) => n + (it.sep === true ? 9 : 34), 10);
menu = {
x: Math.min(e.clientX, window.innerWidth - w - 8),
y: Math.min(e.clientY, window.innerHeight - h - 8),
items,
};
}
/* ---------------- 弹窗 ---------------- */
let showCreate = $state(false);
let createParent = $state<MemberGroupNode | null>(null);
let newName = $state("");
let newDesc = $state("");
let showRename = $state(false);
let renameTarget = $state<MemberGroupNode | null>(null);
let editName = $state("");
let editDesc = $state("");
let showAdd = $state(false);
let addQuery = $state("");
let addResults = $state<UserSearchResult[]>([]);
let addSearching = $state(false);
function openCreate(parent: MemberGroupNode | null): void { function openCreate(parent: MemberGroupNode | null): void {
createParentId = parent?.id ?? null; createParent = parent;
createParentName = parent?.name ?? null;
newName = ""; newName = "";
newDesc = ""; newDesc = "";
showCreate = true; showCreate = true;
@@ -93,188 +254,509 @@
async function createGroup(): Promise<void> { async function createGroup(): Promise<void> {
const name = newName.trim(); const name = newName.trim();
if (name === "") return; if (name === "") {
toastErr("名称必填");
return;
}
const parentId = createParent?.id ?? null;
try { try {
await api("/database/api/groups", { await api("/database/api/groups", {
method: "POST", method: "POST",
body: { body: { name, parentId, ...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}) },
name,
parentId: createParentId,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
}); });
toastOk("已创建成员组");
showCreate = false; showCreate = false;
// 建完自动展开父节点,否则新子组藏在折叠的父下面看不见。
if (parentId !== null) collapsedIds = collapsedIds.filter((x) => x !== parentId);
toastOk("已创建成员组");
await loadGroups(); await loadGroups();
} catch (e) { } catch (e) {
toastErr(e instanceof Error ? e.message : String(e)); toastErr(errText(e));
}
}
function openRename(g: MemberGroupNode): void {
renameTarget = g;
editName = g.name;
editDesc = g.description ?? "";
showRename = true;
}
async function saveRename(): Promise<void> {
if (renameTarget === null) return;
const name = editName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
try {
// description 总是回传(含空串)—— 空串即清除描述(ADR-0028 决策6)。
await api(`/database/api/groups/${encodeURIComponent(renameTarget.id)}`, {
method: "PATCH",
body: { name, description: editDesc.trim() },
});
showRename = false;
toastOk("已保存");
await loadGroups();
} catch (e) {
toastErr(errText(e));
} }
} }
async function deleteGroup(g: MemberGroupNode): Promise<void> { async function deleteGroup(g: MemberGroupNode): Promise<void> {
if (!confirm(`删除「${g.name}」?其整棵子树将一并归档,相关授权立即失效。`)) return; if (
!confirm(
`删除「${g.name}」?\n\n软删除:整棵子树一并标记删除,相关授权立即失效,` +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。",
)
)
return;
try { try {
const r = await api<{ archivedCount: number }>( const r = await api<{ archivedCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}`, `/database/api/groups/${encodeURIComponent(g.id)}`,
{ method: "DELETE" }, { method: "DELETE" },
); );
toastOk(`已归档 ${r.archivedCount} 个组`); if (selectedId === g.id && !showArchived) {
if (selectedId === g.id) { selectedId = null; members = null; } selectedId = null;
members = [];
membersLoaded = false;
}
toastOk(`已删除 ${r.archivedCount} 个组(软删除,可恢复)`);
await loadGroups(); await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) { } catch (e) {
toastErr(e instanceof Error ? e.message : String(e)); toastErr(errText(e));
} }
} }
async function addMember(): Promise<void> { async function restoreGroup(g: MemberGroupNode): Promise<void> {
const v = addValue.trim(); // 恢复语义与删除不对称(ADR-0028 决策7):只回该组 + 已归档祖先链,子树仍归档。
if (v === "" || selectedId === null) return; if (
!confirm(
`恢复「${g.name}」?\n\n其已删除的上级会一并恢复(否则它在树上无路径);` +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。",
)
)
return;
try { try {
// ou_ 开头当飞书 openId,其余当 userId(与 hub 现有习惯一致)。 const r = await api<{ restoredCount: number }>(
const body = v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v }; `/database/api/groups/${encodeURIComponent(g.id)}/restore`,
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, { { method: "POST" },
method: "POST", );
body, toastOk(`已恢复 ${r.restoredCount} 个组`);
}); await loadGroups();
addValue = ""; if (selectedId === g.id) await loadMembers();
toastOk("已添加成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) { } catch (e) {
toastErr(e instanceof Error ? e.message : String(e)); toastErr(errText(e));
} }
} }
async function removeMember(userId: string): Promise<void> { function openAddMember(): void {
addQuery = "";
addResults = [];
showAdd = true;
}
/** 成员选择器:搜全局用户,excludeGroupId 过滤掉本组已有成员。 */
async function searchUsers(): Promise<void> {
if (selectedId === null) return;
addSearching = true;
try {
const r = await api<{ users: UserSearchResult[] }>(
`/database/api/users/search?q=${encodeURIComponent(addQuery.trim())}` +
`&excludeGroupId=${encodeURIComponent(selectedId)}`,
);
addResults = r.users;
} catch (e) {
toastErr(errText(e));
} finally {
addSearching = false;
}
}
async function addMember(userId: string): Promise<void> {
if (selectedId === null) return; if (selectedId === null) return;
try {
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body: { userId },
});
toastOk("已添加成员");
addResults = addResults.filter((u) => u.userId !== userId);
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
async function removeMember(m: MemberGroupMember): Promise<void> {
if (selectedId === null) return;
if (!confirm(`将「${m.displayName || m.userId}」移出本组?其经由本组获得的授权立即失效。`)) return;
try { try {
await api( await api(
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(userId)}`, `/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(m.userId)}`,
{ method: "DELETE" }, { method: "DELETE" },
); );
toastOk("已移除成员"); toastOk("已移除成员");
await Promise.all([loadMembers(), loadGroups()]); await Promise.all([loadMembers(), loadGroups()]);
} catch (e) { } catch (e) {
toastErr(e instanceof Error ? e.message : String(e)); toastErr(errText(e));
} }
} }
</script> </script>
<div class="flex h-full"> <svelte:window
<!-- 左栏:嵌套组树 --> onclick={() => (menu = null)}
<aside class="flex w-[340px] shrink-0 flex-col border-r border-line-soft bg-sidebar"> onkeydown={(e) => {
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5"> if (e.key === "Escape") menu = null;
<span class="text-[15px] font-semibold text-ink">Group 管理</span> }}
<button />
class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[11.5px] font-medium text-ink transition hover:bg-hover"
onclick={() => openCreate(null)} <div class="flex h-full min-h-0 items-stretch gap-3.5">
> <!-- 左:组树 -->
+ 根 Group <div class="panel flex w-[326px] shrink-0 flex-col !p-3.5" style="min-height:0">
<div class="mb-2.5 flex items-center gap-2">
<span class="flex text-accent"><Icon name="layers" size={17} /></span>
<div class="section-title flex-1">Group 树</div>
<button class="btn btn-sm" onclick={() => openCreate(null)}>
<Icon name="plus" size={13} /> 根组
</button> </button>
</div> </div>
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]"> <div class="relative mb-2">
{#if groups === null} <span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div> <Icon name="search" size={13} />
{:else if listError} </span>
<div class="px-3 py-6 text-center text-xs text-danger">{listError}</div> <input class="input !pl-7 !text-[12.5px]" placeholder="过滤组名…" bind:value={filterText} />
{:else if ordered.length === 0} </div>
<div class="px-3 py-6 text-center text-xs text-ink-3">暂无成员组 · 点上方「+ 根 Group」开始</div>
{:else} <label class="switch mb-2.5 text-[11.5px] text-ink-3">
{#each ordered as g (g.id)} <input type="checkbox" checked={showArchived} onchange={toggleArchived} />
<span></span>
显示已删除的组
</label>
<!-- 树空白处右键 = 建根组 -->
<div <div
class="group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 transition hover:bg-hover" class="-mx-1.5 min-h-0 flex-1 overflow-y-auto"
class:bg-selected={selectedId === g.id} role="tree"
style="padding-left: {8 + g.depth * 16}px" tabindex="-1"
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("[data-node]") !== null) return;
openMenu(e, null);
}}
> >
<button class="flex min-w-0 flex-1 items-center gap-2 text-left" onclick={() => select(g.id)}> {#if !loaded}
<span class="truncate font-medium text-ink">{g.name}</span> <div class="quiet px-3 py-6 text-center">加载中…</div>
<span class="shrink-0 text-[11px] text-ink-3">{g.memberCount}</span> {:else if listError !== null}
</button> <div class="px-3 py-6 text-center text-xs text-danger">{listError}</div>
<button {:else if groups.length === 0}
class="shrink-0 rounded px-1.5 py-0.5 text-[15px] leading-none text-ink-3 opacity-0 transition hover:bg-panel hover:text-ink group-hover:opacity-100" <div class="quiet flex flex-col items-center gap-2 px-3 py-[22px] text-center">
title="在此下新建子 Group" <span class="flex text-line"><Icon name="layers" size={30} /></span>
onclick={() => openCreate(g)} 暂无成员组 · 点上方「根组」开始
>+</button> </div>
<button {:else if rows.length === 0}
class="shrink-0 rounded px-1.5 py-0.5 text-[11px] leading-none text-ink-3 opacity-0 transition hover:text-danger group-hover:opacity-100" <div class="quiet px-3 py-[22px] text-center">无匹配的组</div>
title="删除(级联归档子树)" {:else}
onclick={() => deleteGroup(g)} {#each rows as { g, hasKids, hit } (g.id)}
>删除</button> {@const arch = g.archivedAt !== null}
<div
data-node
class="flex cursor-pointer select-none items-center gap-1.5 rounded-lg py-1.5 pr-2 text-[13px]"
class:bg-selected={selectedId === g.id}
class:opacity-50={!hit}
style="padding-left: {8 + g.depth * 15}px"
role="treeitem"
aria-selected={selectedId === g.id}
tabindex="-1"
onclick={() => select(g.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(g.id);
}
}}
oncontextmenu={(e) => openMenu(e, g)}
>
{#if hasKids}
<span
class="flex w-[15px] shrink-0 justify-center text-ink-3 transition-transform"
class:rotate-90={!(isCollapsed(g.id) && filterText.trim() === "")}
role="button"
tabindex="-1"
aria-label="折叠 / 展开"
onclick={(e) => {
e.stopPropagation();
toggleCollapsed(g.id);
}}
onkeydown={(e) => {
if (e.key === "Enter") toggleCollapsed(g.id);
}}
>
<Icon name="chevron" size={13} />
</span>
{:else}
<span class="inline-block w-[15px] shrink-0"></span>
{/if}
<span class="flex" class:text-accent={selectedId === g.id && !arch} class:text-ink-3={arch || selectedId !== g.id}>
<Icon name={arch ? "archive" : "group"} size={15} />
</span>
<span class="flex-1 truncate" class:text-ink-3={arch} class:line-through={arch}>{g.name}</span>
<span class="tag shrink-0" class:opacity-70={arch}>
<Icon name="user" size={10} />{g.memberCount}
</span>
{#if arch}
<span class="tag shrink-0 !text-[10px] opacity-85">已删除</span>
{/if}
</div> </div>
{/each} {/each}
{/if} {/if}
</div> </div>
</aside>
<!-- 右栏:成员管理 --> <div class="section-note mt-2 border-t border-line-soft pt-2">{loaded ? treeFoot : ""}</div>
<main class="flex-1 overflow-y-auto p-6"> </div>
<!-- 右:成员表 -->
<div class="panel flex min-h-0 min-w-0 flex-1 flex-col !p-0">
{#if selected === null} {#if selected === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个 Group 查看成员</div> <div class="quiet m-auto flex flex-col items-center gap-2.5 p-7 text-center">
<span class="flex text-line"><Icon name="users" size={40} /></span>
从左侧选择一个 Group 查看成员
</div>
{:else} {:else}
<div class="mb-1 text-[17px] font-semibold text-ink">{selected.name}</div> {#if isArchived}
{#if selected.description} <!-- 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。 -->
<p class="mb-4 text-[12.5px] text-ink-2">{selected.description}</p> <div
{:else} class="flex shrink-0 items-center gap-2.5 border-b border-line-soft bg-hover px-[18px] py-2.5 text-[12.5px]"
<div class="mb-4"></div> >
<span class="flex text-ink-3"><Icon name="archive" size={15} /></span>
<span class="flex-1">
此 Group 已删除于 {fmtDate(selected.archivedAt ?? "")} · 成员只读,不再授予任何权限
</span>
<button class="btn !text-xs" onclick={() => void restoreGroup(selected)}>
<Icon name="restore" size={13} /> 恢复
</button>
</div>
{/if} {/if}
<div class="mb-4 flex items-end gap-2"> <div class="shrink-0 border-b border-line-soft px-[18px] pb-3 pt-4">
<div class="flex-1"> <div class="mb-1.5 text-xs">
<label class="mb-1 block text-[11.5px] text-ink-3" for="add-member">添加成员(userId 或飞书 openId)</label> {#each chain as c, i (c.id)}
<input {#if i > 0}<span class="mx-[5px] text-ink-3">/</span>{/if}
id="add-member" <span class={i === chain.length - 1 ? "font-medium text-ink" : "text-ink-3"}>{c.name}</span>
class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent"
bind:value={addValue}
placeholder="userId ou_ 开头的 openId"
onkeydown={(e) => { if (e.key === "Enter") void addMember(); }}
/>
</div>
<button
class="rounded-lg bg-accent px-3.5 py-2 text-[12.5px] font-medium text-white transition hover:bg-accent-hover"
onclick={addMember}
>添加</button>
</div>
<div class="rounded-xl border border-line-soft bg-panel">
<div class="border-b border-line-soft px-4 py-2.5 text-[12.5px] font-semibold text-ink">
成员 {members === null ? "" : `(${members.length})`}
</div>
{#if members === null}
<div class="px-4 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if membersError}
<div class="px-4 py-6 text-center text-xs text-danger">{membersError}</div>
{:else if members.length === 0}
<div class="px-4 py-6 text-center text-xs text-ink-3">暂无成员</div>
{:else}
{#each members as m (m.userId)}
<div class="flex items-center gap-3 border-b border-line-soft px-4 py-2.5 text-[13px] last:border-b-0">
<span class="text-ink">{m.displayName}</span>
<span class="truncate font-mono text-[11px] text-ink-3">{m.userId}</span>
<button
class="ml-auto shrink-0 rounded px-1.5 py-0.5 text-[11.5px] text-ink-3 transition hover:text-danger"
onclick={() => removeMember(m.userId)}
>移除</button>
</div>
{/each} {/each}
</div>
<div class="flex items-center gap-2.5">
<span class="flex" class:text-ink-3={isArchived} class:text-accent={!isArchived}>
<Icon name={isArchived ? "archive" : "group"} size={20} />
</span>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold" class:text-ink-3={isArchived}>{selected.name}</div>
{#if selected.description !== null && selected.description !== ""}
<div class="section-note mt-0.5">{selected.description}</div>
{:else}
<div class="section-note mt-0.5 opacity-60">无描述</div>
{/if}
</div>
<!-- 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。 -->
{#if !isArchived}
<button class="btn !text-xs" onclick={() => openRename(selected)}>
<Icon name="pencil" size={13} /> 编辑
</button>
<button class="btn btn-primary !text-xs" onclick={openAddMember}>
<Icon name="plus" size={13} /> 添加成员
</button>
{/if}
</div>
<div class="mt-3 flex gap-4 text-xs text-ink-3">
<span class="inline-flex items-center gap-1"><Icon name="user" size={12} />{members.length} 名成员</span>
<span class="inline-flex items-center gap-1"><Icon name="layers" size={12} />层级 {selected.depth}</span>
<span class="inline-flex items-center gap-1"><Icon name="group" size={12} />{childCount} 个子组</span>
</div>
</div>
<div class="flex shrink-0 items-center gap-2.5 px-[18px] py-2.5">
<div class="relative max-w-[280px] flex-1">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="搜索成员…" bind:value={memberFilter} />
</div>
<span class="file-meta">
{memberFilter.trim() === "" ? "" : `${shownMembers.length} / ${members.length}`}
</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{#if !membersLoaded}
<div class="quiet px-3 py-9 text-center">加载中…</div>
{:else if membersError !== null}
<div class="px-3 py-9 text-center text-xs text-danger">{membersError}</div>
{:else if members.length === 0}
<div class="quiet flex flex-col items-center gap-2.5 px-3 py-9 text-center">
<span class="flex text-line"><Icon name="users" size={34} /></span>
{isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」"}
</div>
{:else if shownMembers.length === 0}
<div class="quiet px-3 py-[30px] text-center">无匹配成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>飞书 openId</th>
<th>加入时间</th>
{#if !isArchived}<th class="!text-right">操作</th>{/if}
</tr>
</thead>
<tbody>
{#each shownMembers as m (m.userId)}
<tr>
<td>
<span class="inline-flex items-center gap-2.5">
<Avatar displayName={m.displayName} userId={m.userId} avatarUrl={m.avatarUrl} size={28} />
<span class="font-medium">{m.displayName || "(未命名)"}</span>
</span>
</td>
<td class="file-meta">{m.userId}</td>
<td class="file-meta">{m.feishuOpenId || "—"}</td>
<td class="file-meta">{fmtDate(m.joinedAt)}</td>
{#if !isArchived}
<td class="text-right">
<button class="link-danger inline-flex items-center gap-1" onclick={() => void removeMember(m)}>
<Icon name="minus" size={12} /> 移除
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if} {/if}
</div> </div>
{/if} {/if}
</main> </div>
</div> </div>
{#if showCreate} <!-- 右键菜单 -->
<Modal title={createParentName === null ? "新建根 Group" : `在「${createParentName}」下新建子 Group`} onclose={() => (showCreate = false)}> {#if menu !== null}
<div class="mb-3"> <div
<label class="mb-1 block text-[11.5px] text-ink-3" for="g-name">名称</label> class="fixed z-[60] min-w-[184px] rounded-[10px] border border-line bg-panel p-[5px] text-[13px] shadow-[0_4px_20px_rgba(26,26,24,.07)]"
<input id="g-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理教研组" /> style="left:{menu.x}px;top:{menu.y}px"
role="menu"
tabindex="-1"
>
{#each menu.items as it, i (i)}
{#if it.sep === true}
<div class="mx-1.5 my-1 h-px bg-line-soft"></div>
{:else}
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-[11px] py-[7px] hover:bg-hover"
class:text-danger={it.danger === true}
role="menuitem"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
menu = null;
it.fn?.();
}}
onkeydown={(e) => {
if (e.key === "Enter") {
menu = null;
it.fn?.();
}
}}
>
{#if it.ic !== undefined}<span class="flex opacity-75"><Icon name={it.ic} size={14} /></span>{/if}
{it.label}
</div> </div>
<div class="mb-3"> {/if}
<label class="mb-1 block text-[11.5px] text-ink-3" for="g-desc">描述(可选)</label> {/each}
<textarea id="g-desc" rows="2" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newDesc} placeholder="一句话说明…"></textarea> </div>
{/if}
{#if showCreate}
<Modal
title={createParent === null ? "新建根 Group" : `在「${createParent.name}」下新建子 Group`}
onclose={() => (showCreate = false)}
>
<div class="form-row">
<label class="form-label" for="gc-name">名称</label>
<input id="gc-name" class="input" bind:value={newName} placeholder="例如:物理教研组" />
</div>
<div class="form-row">
<label class="form-label" for="gc-desc">描述(可选)</label>
<input id="gc-desc" class="input" bind:value={newDesc} placeholder="一句话说明" />
</div> </div>
<div class="mt-4 flex justify-end gap-2"> <div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreate = false)}>取消</button> <button class="btn" onclick={() => (showCreate = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={createGroup}>创建</button> <button class="btn btn-primary" onclick={createGroup}>创建</button>
</div>
</Modal>
{/if}
{#if showRename && renameTarget !== null}
<Modal title="重命名 / 改描述" onclose={() => (showRename = false)}>
<div class="form-row">
<label class="form-label" for="gr-name">名称</label>
<input id="gr-name" class="input" bind:value={editName} />
</div>
<div class="form-row">
<label class="form-label" for="gr-desc">描述</label>
<input id="gr-desc" class="input" bind:value={editDesc} placeholder="留空则清除描述" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showRename = false)}>取消</button>
<button class="btn btn-primary" onclick={saveRename}>保存</button>
</div>
</Modal>
{/if}
{#if showAdd && selected !== null}
<Modal title={`向「${selected.name}」添加成员`} onclose={() => (showAdd = false)}>
<div class="form-row">
<label class="form-label" for="ga-q">搜索用户(姓名 / userId / 飞书 openId)</label>
<div class="flex gap-2">
<input
id="ga-q"
class="input"
bind:value={addQuery}
placeholder="留空列出全部候选"
onkeydown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
/>
<button class="btn" onclick={searchUsers}><Icon name="search" size={13} /> 搜索</button>
</div>
<div class="section-note mt-1.5">已在本组的成员不会出现在结果里。</div>
</div>
<div class="max-h-[280px] overflow-y-auto">
{#if addSearching}
<div class="quiet px-3 py-6 text-center">搜索中…</div>
{:else if addResults.length === 0}
<div class="quiet px-3 py-6 text-center">无候选用户 · 先点「搜索」</div>
{:else}
{#each addResults as u (u.userId)}
<div class="flex items-center gap-2.5 border-b border-line-soft py-2 last:border-b-0">
<Avatar displayName={u.displayName} userId={u.userId} avatarUrl={u.avatarUrl} size={26} />
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">{u.displayName || "(未命名)"}</div>
<div class="file-meta truncate">{u.feishuOpenId || u.userId}</div>
</div>
<button class="btn btn-sm" onclick={() => void addMember(u.userId)}>
<Icon name="plus" size={12} /> 添加
</button>
</div>
{/each}
{/if}
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showAdd = false)}>关闭</button>
</div> </div>
</Modal> </Modal>
{/if} {/if}
+64
View File
@@ -36,6 +36,9 @@ export interface NodeDetail {
export interface MeResponse { export interface MeResponse {
readonly userId: string; readonly userId: string;
readonly isWebsiteAdmin: boolean; readonly isWebsiteAdmin: boolean;
/** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */
readonly displayName: string;
readonly avatarUrl: string | null;
} }
export interface FileEntry { export interface FileEntry {
@@ -92,9 +95,70 @@ export interface MemberGroupNode {
readonly description: string | null; readonly description: string | null;
readonly depth: number; readonly depth: number;
readonly memberCount: number; readonly memberCount: number;
/** 软删标记(ADR-0028 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。
* 仅在 ?includeArchived=1 时可能非 null。ISO 串(后端 JSON 序列化后不再是 Date)。 */
readonly archivedAt: string | null;
} }
export interface MemberGroupMember { export interface MemberGroupMember {
readonly userId: string; readonly userId: string;
readonly displayName: 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;
} }