feat(database): 后台成员组(MemberGroup)管理与嵌套解析

This commit is contained in:
2026-07-26 18:06:18 +08:00
parent 2f2ece1a3a
commit 11a7ec8004
17 changed files with 2289 additions and 124 deletions
+280
View File
@@ -0,0 +1,280 @@
<script lang="ts">
import { onMount } from "svelte";
import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember } from "./types.js";
import Modal from "./Modal.svelte";
// 后端返回活跃组扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[] | null>(null);
let listError = $state<string | null>(null);
let selectedId = $state<string | null>(null);
let members = $state<MemberGroupMember[] | null>(null);
let membersError = $state<string | null>(null);
// 新建组弹窗:parentId=null 建根,否则建子。
let showCreate = $state(false);
let createParentId = $state<string | null>(null);
let createParentName = $state<string | null>(null);
let newName = $state("");
let newDesc = $state("");
let addValue = $state("");
const selected = $derived(groups?.find((g) => g.id === selectedId) ?? null);
/** 扁平列表按 parentId 排成先根遍历顺序(每项自带 depth,渲染时缩进)。 */
const ordered = $derived.by(() => {
if (groups === null) return [];
const byParent = new Map<string | null, MemberGroupNode[]>();
for (const g of groups) {
const arr = byParent.get(g.parentId) ?? [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name));
const out: MemberGroupNode[] = [];
const walk = (parentId: string | null): void => {
for (const g of byParent.get(parentId) ?? []) {
out.push(g);
walk(g.id);
}
};
walk(null);
// 兜底:孤儿(父已不在活跃列表,理论上级联软删不会出现)也列出。
const seen = new Set(out.map((g) => g.id));
for (const g of groups) if (!seen.has(g.id)) out.push(g);
return out;
});
async function loadGroups(): Promise<void> {
try {
const r = await api<{ groups: MemberGroupNode[] }>("/database/api/groups");
groups = r.groups;
listError = null;
if (selectedId !== null && !groups.some((g) => g.id === selectedId)) {
selectedId = null;
members = null;
}
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
}
}
async function loadMembers(): Promise<void> {
if (selectedId === null) return;
members = null;
membersError = null;
try {
const r = await api<{ members: MemberGroupMember[] }>(
`/database/api/groups/${encodeURIComponent(selectedId)}/members`,
);
members = r.members;
} catch (e) {
membersError = e instanceof Error ? e.message : String(e);
}
}
onMount(loadGroups);
function select(id: string): void {
selectedId = id;
void loadMembers();
}
function openCreate(parent: MemberGroupNode | null): void {
createParentId = parent?.id ?? null;
createParentName = parent?.name ?? null;
newName = "";
newDesc = "";
showCreate = true;
}
async function createGroup(): Promise<void> {
const name = newName.trim();
if (name === "") return;
try {
await api("/database/api/groups", {
method: "POST",
body: {
name,
parentId: createParentId,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
});
toastOk("已创建成员组");
showCreate = false;
await loadGroups();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function deleteGroup(g: MemberGroupNode): Promise<void> {
if (!confirm(`删除「${g.name}」?其整棵子树将一并归档,相关授权立即失效。`)) return;
try {
const r = await api<{ archivedCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}`,
{ method: "DELETE" },
);
toastOk(`已归档 ${r.archivedCount} 个组`);
if (selectedId === g.id) { selectedId = null; members = null; }
await loadGroups();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function addMember(): Promise<void> {
const v = addValue.trim();
if (v === "" || selectedId === null) return;
try {
// ou_ 开头当飞书 openId,其余当 userId(与 hub 现有习惯一致)。
const body = v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v };
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body,
});
addValue = "";
toastOk("已添加成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
async function removeMember(userId: string): Promise<void> {
if (selectedId === null) return;
try {
await api(
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(userId)}`,
{ method: "DELETE" },
);
toastOk("已移除成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<div class="flex h-full">
<!-- 左栏:嵌套组树 -->
<aside class="flex w-[340px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
<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)}
>
+ 根 Group
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
{#if groups === null}
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if listError}
<div class="px-3 py-6 text-center text-xs text-danger">{listError}</div>
{:else if ordered.length === 0}
<div class="px-3 py-6 text-center text-xs text-ink-3">暂无成员组 · 点上方「+ 根 Group」开始</div>
{:else}
{#each ordered as g (g.id)}
<div
class="group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 transition hover:bg-hover"
class:bg-selected={selectedId === g.id}
style="padding-left: {8 + g.depth * 16}px"
>
<button class="flex min-w-0 flex-1 items-center gap-2 text-left" onclick={() => select(g.id)}>
<span class="truncate font-medium text-ink">{g.name}</span>
<span class="shrink-0 text-[11px] text-ink-3">{g.memberCount}</span>
</button>
<button
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"
title="在此下新建子 Group"
onclick={() => openCreate(g)}
>+</button>
<button
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"
title="删除(级联归档子树)"
onclick={() => deleteGroup(g)}
>删除</button>
</div>
{/each}
{/if}
</div>
</aside>
<!-- 右栏:成员管理 -->
<main class="flex-1 overflow-y-auto p-6">
{#if selected === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个 Group 查看成员</div>
{:else}
<div class="mb-1 text-[17px] font-semibold text-ink">{selected.name}</div>
{#if selected.description}
<p class="mb-4 text-[12.5px] text-ink-2">{selected.description}</p>
{:else}
<div class="mb-4"></div>
{/if}
<div class="mb-4 flex items-end gap-2">
<div class="flex-1">
<label class="mb-1 block text-[11.5px] text-ink-3" for="add-member">添加成员(userId 或飞书 openId)</label>
<input
id="add-member"
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}
{/if}
</div>
{/if}
</main>
</div>
{#if showCreate}
<Modal title={createParentName === null ? "新建根 Group" : `在「${createParentName}」下新建子 Group`} onclose={() => (showCreate = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="g-name">名称</label>
<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="例如:物理教研组" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="g-desc">描述(可选)</label>
<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>
<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="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>
</div>
</Modal>
{/if}