forked from bai/curriculum-project-hub
feat(database): 后台成员组(MemberGroup)管理与嵌套解析
This commit is contained in:
@@ -99,3 +99,7 @@ _Avoid_: Cost budget, unlimited run
|
|||||||
**Emergency Workload Brake**:
|
**Emergency Workload Brake**:
|
||||||
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
|
An audited Platform Administrator control that prevents new agent work for one Organization or the whole platform and may explicitly stop active work during an incident.
|
||||||
_Avoid_: Organization deletion, service restart
|
_Avoid_: Organization deletion, service restart
|
||||||
|
|
||||||
|
**Member Group**:
|
||||||
|
A global, unlimited-depth, nestable authorization principal managed by the website administrator; a file-library grant on a group applies to that group and its whole descendant subtree, and a user's effective permission collects every group they belong to plus those groups' ancestors (ADR-0028). It stores no folder/project permission itself — only the user→group membership. Global: not owned by any Organization.
|
||||||
|
_Avoid_: Team (the org-scoped flat grouping), Feishu department
|
||||||
|
|||||||
@@ -0,0 +1,153 @@
|
|||||||
|
# ADR 0028: Member Group Management And Resolution
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ADR-0020 fixed `Organization` as the tenant root and ADR-0019 pinned the
|
||||||
|
principal-set permission model. The file library (《文件库-接口契约.md》) computes
|
||||||
|
effective permission over two principal kinds — `USER` and `GROUP` — and consumes
|
||||||
|
the group side through a single read-only port, `GroupResolver`
|
||||||
|
(`resolveMemberGroupIds(userId) → groupIds[]`, contract C2/G2).
|
||||||
|
|
||||||
|
The contract's v0.1 proposal framed the Group system as a *separate HTTP service*
|
||||||
|
owned by another team, consumed read-only. In practice the schema now carries the
|
||||||
|
group tables directly in the hub database (`MemberGroup`, `MemberGroupMembership`,
|
||||||
|
`MemberGroupClosure` — a global, unlimited-depth, closure-backed hierarchy), and
|
||||||
|
the product requirement is to build **group management in the backend admin**, not
|
||||||
|
to integrate a foreign service. Until this ADR, nothing read or wrote those tables:
|
||||||
|
the live `GroupResolver` was a transitional implementation reading flat hub `Team`
|
||||||
|
membership, and the admin "Group 管理" panel actually managed `Team`.
|
||||||
|
|
||||||
|
This ADR settles the semantics needed to make the `MemberGroup` tables the real,
|
||||||
|
in-hub group system.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
### Group system is in-hub, not a foreign service
|
||||||
|
|
||||||
|
`MemberGroup` is the platform's global member-group principal. It lives in the hub
|
||||||
|
database and is managed through the `/database` backend. The contract's "separate
|
||||||
|
service" framing was an unfrozen v0.1 proposal; the implementation aligns to the
|
||||||
|
tables that were actually built. The `GroupResolver` port stays — an external
|
||||||
|
`HUB_GROUP_SERVICE_URL` HTTP implementation remains a supported override — but the
|
||||||
|
default implementation reads the in-hub `MemberGroup` closure.
|
||||||
|
|
||||||
|
### Authority: website administrator only
|
||||||
|
|
||||||
|
Group create/delete and member add/remove are restricted to the **website
|
||||||
|
administrator**, defined (consistently with the rest of the file library, D19/C4
|
||||||
|
adaptation) as an `OWNER`/`ADMIN` of the silo Organization (`isWebsiteAdmin` in
|
||||||
|
`filelib/guards.ts`). ADR-0023's `PlatformIdentity` is the future "true" platform
|
||||||
|
control plane; the file library uniformly uses org OWNER/ADMIN today and this
|
||||||
|
feature stays consistent with that. Reading groups for the authorization selector
|
||||||
|
(`/groups/search`) is **not** admin-gated — picking a group to grant is a Manage
|
||||||
|
holder's ability, not an administrator's.
|
||||||
|
|
||||||
|
### Resolution semantics (the crux)
|
||||||
|
|
||||||
|
`resolveMemberGroupIds(user)` returns the user's **active direct groups ∪ the
|
||||||
|
active ancestors of those groups**, deduplicated (the closure's depth-0 self row
|
||||||
|
makes each direct group its own ancestor). This is the single query the permission
|
||||||
|
engine relies on; equivalently: a grant placed on group G applies to members of G
|
||||||
|
and of every descendant of G (requirement 3.2 — permission flows down the tree, so
|
||||||
|
resolution collects up the tree). It is computed **live, never cached** (contract
|
||||||
|
D4/G4): a membership change is visible on the very next protected request.
|
||||||
|
|
||||||
|
MemberGroup is global (no `organizationId`), so resolution is not org-scoped.
|
||||||
|
|
||||||
|
### Soft delete via `archivedAt`, cascading the subtree
|
||||||
|
|
||||||
|
Delete is soft: `MemberGroup.archivedAt` is a tag. Deleting a group
|
||||||
|
cascade-soft-deletes its **whole subtree** (walk `MemberGroupClosure` where
|
||||||
|
`ancestorId = G`, stamp `archivedAt` on each active descendant) — an application
|
||||||
|
operation, not a DB constraint. Closure and membership rows are **retained**;
|
||||||
|
resolution and listing filter by `archivedAt`, so an archived group and everything
|
||||||
|
under it stop contributing to permission at once.
|
||||||
|
|
||||||
|
### Closure maintenance
|
||||||
|
|
||||||
|
The closure is maintained on **create**: insert `(G, G, 0)`, then for a parent `P`
|
||||||
|
insert `(a.ancestorId, G, a.depth + 1)` for every `a` in
|
||||||
|
`closure where descendantId = P`. v1 does **not** support reparenting a group
|
||||||
|
(moving it under a new parent). The schema reserves reparent (closure rebuild plus
|
||||||
|
the cycle guard "reject a new parent inside the moved subtree"); it is a follow-on.
|
||||||
|
|
||||||
|
### Rename and description edits are in scope; reparent stays out
|
||||||
|
|
||||||
|
A group's `name` and `description` are mutable by the website administrator
|
||||||
|
(`PATCH /database/api/groups/:id`, audited as `group.update`). This is deliberately
|
||||||
|
separated from reparent: renaming touches **no** closure row and cannot create a
|
||||||
|
cycle, so it carries none of the invariant risk that keeps reparent out of v1. The
|
||||||
|
endpoint therefore **rejects** a `parentId` field outright rather than ignoring it,
|
||||||
|
so a future reparent cannot arrive silently through this route. Passing an empty
|
||||||
|
`description` clears it; omitting a field leaves it unchanged.
|
||||||
|
|
||||||
|
### Restore is deliberately asymmetric with delete
|
||||||
|
|
||||||
|
Archived groups stay visible to the administrator (`GET
|
||||||
|
/database/api/groups?includeArchived=1` returns them carrying `archivedAt`; the
|
||||||
|
console tags and greys them) and can be restored (`POST
|
||||||
|
/database/api/groups/:id/restore`, audited as `group.restore`).
|
||||||
|
|
||||||
|
Restore is **not** the mirror image of delete. Delete cascades down the whole
|
||||||
|
subtree; restore un-archives **the group plus every archived ancestor of it, and
|
||||||
|
nothing below it**:
|
||||||
|
|
||||||
|
- Restoring the ancestor chain is **mandatory**, not a convenience. An active group
|
||||||
|
whose parent is archived has no path in the tree, and the `depth` derivation
|
||||||
|
(closure row count) presumes "an active group's ancestors are active" — the
|
||||||
|
invariant that cascade-delete establishes. Restoring a node alone would break it.
|
||||||
|
- The subtree is deliberately **left archived**. A group's descendants may have been
|
||||||
|
archived for reasons of their own, and one click should not silently re-grant
|
||||||
|
permission across a whole historical branch. Descendants remain visible in their
|
||||||
|
archived state and are each restored explicitly.
|
||||||
|
|
||||||
|
Restore takes effect immediately, like every other membership change (D4/G4): the
|
||||||
|
group resumes contributing permission on the next resolution.
|
||||||
|
|
||||||
|
An archived group is **readable but not writable**. Its membership rows are never
|
||||||
|
revoked by archiving, so `listMembers` succeeds on an archived group — the console
|
||||||
|
must be able to show *who was in it* before deciding whether to restore it. Every
|
||||||
|
mutation, by contrast, still requires an active group (`requireActiveGroup` → 404):
|
||||||
|
rename, child creation, and member add/remove all reject. The group is inert for
|
||||||
|
permission purposes and frozen for editing, but not hidden and not forgotten.
|
||||||
|
|
||||||
|
### Member picker reads global users, admin-only
|
||||||
|
|
||||||
|
`GET /database/api/users/search` backs the "add member" picker: it matches `User`
|
||||||
|
by display name or Feishu open id and is gated to the website administrator, the
|
||||||
|
same authority that may add members. It widens no existing capability — adding a
|
||||||
|
member already accepts **any** global user (`resolveUser` does not require an org
|
||||||
|
membership), so the endpoint only replaces blind id entry with search. It is
|
||||||
|
deliberately **not** opened to the non-admin authorization-selector audience that
|
||||||
|
`/groups/search` serves: choosing a group to grant is a Manage-holder action,
|
||||||
|
whereas enumerating people is not. `excludeGroupId` filters out the target group's
|
||||||
|
active members so the picker cannot surface a candidate that must 409.
|
||||||
|
|
||||||
|
### Audit is written in-hub
|
||||||
|
|
||||||
|
The contract (C3 §6.3) originally deferred group actions to the foreign Group
|
||||||
|
service's own audit. With the group system in-hub, group mutations are audited
|
||||||
|
through the existing file-library sink (`filelib/audit.ts`, same-transaction
|
||||||
|
`AuditEntry`) under the silo Organization — `MemberGroup` has no `organizationId`,
|
||||||
|
so the audit row is attributed to the silo org. New actions: `group.create`,
|
||||||
|
`group.update`, `group.delete`, `group.restore`, `group.member_add`,
|
||||||
|
`group.member_remove`; new audit object type `group`.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The default `GroupResolver` becomes the in-hub `MemberGroup` closure reader.
|
||||||
|
`createTeamGroupResolver` is retained but deprecated (no longer wired); existing
|
||||||
|
flat-Team group grants no longer resolve for the file library.
|
||||||
|
- Group grants take effect in real time through the existing `effectiveRole`
|
||||||
|
reducer (P6) with no change to the permission algebra — only the set of group ids
|
||||||
|
fed to it changes.
|
||||||
|
- v1 omits reparent; the closure invariants above must hold whenever reparent is
|
||||||
|
added later (rebuild descendants' ancestor rows, reject cycles).
|
||||||
|
- Group management is an admin-only surface; the authorization selector is not.
|
||||||
|
- Numeric limits (max depth, max members) and a hard-delete/restore path remain
|
||||||
|
follow-on operational decisions; they must not weaken the archived-filter,
|
||||||
|
admin-authority, or live-resolution invariants fixed here.
|
||||||
@@ -8,6 +8,7 @@ dist/
|
|||||||
.dev-keyring.json
|
.dev-keyring.json
|
||||||
.dev-workspaces/
|
.dev-workspaces/
|
||||||
.dev-skills/
|
.dev-skills/
|
||||||
|
.filelib-repos/
|
||||||
admin-web/node_modules/
|
admin-web/node_modules/
|
||||||
admin-web/build/
|
admin-web/build/
|
||||||
admin-web/.svelte-kit/
|
admin-web/.svelte-kit/
|
||||||
|
|||||||
@@ -7,8 +7,12 @@
|
|||||||
import TreeNode from "./TreeNode.svelte";
|
import TreeNode from "./TreeNode.svelte";
|
||||||
import NodeDetailPanel from "./NodeDetailPanel.svelte";
|
import NodeDetailPanel from "./NodeDetailPanel.svelte";
|
||||||
import FileEditor from "./FileEditor.svelte";
|
import FileEditor from "./FileEditor.svelte";
|
||||||
|
import GroupAdmin from "./GroupAdmin.svelte";
|
||||||
import Modal from "./Modal.svelte";
|
import Modal from "./Modal.svelte";
|
||||||
|
|
||||||
|
// 视图切换(仅管理员可见 Group 管理入口;非管理员恒为 library)。
|
||||||
|
let view = $state<"library" | "groups">("library");
|
||||||
|
|
||||||
let roots = $state<NodeChild[] | null>(null);
|
let roots = $state<NodeChild[] | null>(null);
|
||||||
let treeError = $state<string | null>(null);
|
let treeError = $state<string | null>(null);
|
||||||
let showCreateRoot = $state(false);
|
let showCreateRoot = $state(false);
|
||||||
@@ -62,7 +66,31 @@
|
|||||||
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
|
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full">
|
<div class="flex h-full flex-col">
|
||||||
|
{#if $me?.isWebsiteAdmin}
|
||||||
|
<!-- 视图切换(仅管理员) -->
|
||||||
|
<div class="flex shrink-0 items-center gap-1 border-b border-line-soft bg-sidebar px-3 py-1.5">
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-1 text-[12.5px] font-medium transition"
|
||||||
|
class:bg-selected={view === "library"}
|
||||||
|
class:text-ink={view === "library"}
|
||||||
|
class:text-ink-3={view !== "library"}
|
||||||
|
onclick={() => (view = "library")}
|
||||||
|
>文件库</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg px-3 py-1 text-[12.5px] font-medium transition"
|
||||||
|
class:bg-selected={view === "groups"}
|
||||||
|
class:text-ink={view === "groups"}
|
||||||
|
class:text-ink-3={view !== "groups"}
|
||||||
|
onclick={() => (view = "groups")}
|
||||||
|
>Group 管理</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="flex min-h-0 flex-1">
|
||||||
|
{#if $me?.isWebsiteAdmin && view === "groups"}
|
||||||
|
<GroupAdmin />
|
||||||
|
{:else}
|
||||||
<!-- 侧栏 -->
|
<!-- 侧栏 -->
|
||||||
<aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
|
<aside class="flex w-[300px] 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">
|
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
|
||||||
@@ -114,6 +142,8 @@
|
|||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
{/if}
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if showCreateRoot}
|
{#if showCreateRoot}
|
||||||
|
|||||||
@@ -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}
|
||||||
@@ -83,3 +83,18 @@ export interface GroupSearchResult {
|
|||||||
readonly name: string;
|
readonly name: string;
|
||||||
readonly breadcrumb: string;
|
readonly breadcrumb: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 成员组(ADR-0028);后端返回扁平列表,前端按 parentId/depth 拼树。 */
|
||||||
|
export interface MemberGroupNode {
|
||||||
|
readonly id: string;
|
||||||
|
readonly parentId: string | null;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string | null;
|
||||||
|
readonly depth: number;
|
||||||
|
readonly memberCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberGroupMember {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -74,8 +74,10 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
|||||||
|------|------|
|
|------|------|
|
||||||
| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` |
|
| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` |
|
||||||
| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 |
|
| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 |
|
||||||
| `routes/filelibRoutes.ts` | 文件库 树/授权/搜索 API |
|
| `routes/filelibRoutes.ts` | 文件库 树/授权 API |
|
||||||
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
|
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
|
||||||
|
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) |
|
||||||
|
| `routes/adminPanels.ts` | dashboard「用户管理」(org 成员)/「Group 管理」(MemberGroup 嵌套树)面板 |
|
||||||
| `routes/libraryPage.ts` | `/database/library` 文件库浏览页 |
|
| `routes/libraryPage.ts` | `/database/library` 文件库浏览页 |
|
||||||
| `filelib/` | 文件库领域层(见下) |
|
| `filelib/` | 文件库领域层(见下) |
|
||||||
|
|
||||||
@@ -98,7 +100,9 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
|||||||
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
|
| `filelib/fileService.ts` | 文件路径安全 + 版本化读写(先 git 后审计的顺序铁律) |
|
||||||
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
|
| `filelib/exportService.ts` | 导出 job 状态机(D10 异步)+ ExportAdapter port |
|
||||||
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) |
|
| `filelib/versionStore.ts` | 契约 C1 port + 内存实现(版本团队 npm 包到位后替换) |
|
||||||
| `filelib/groupResolver.ts` | 契约 C2 port + Team 过渡实现 |
|
| `filelib/groupResolver.ts` | 契约 C2 port(+ 已弃用的 Team 过渡实现,ADR-0028) |
|
||||||
|
| `filelib/memberGroupResolver.ts` | **默认** C2 实现:读 in-hub MemberGroup 闭包(ADR-0028) |
|
||||||
|
| `filelib/memberGroupService.ts` | 成员组 CRUD(含改名)+ 成员增删 + 闭包维护 + 搜索(ADR-0028) |
|
||||||
| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
|
| `filelib/groupResolverHttp.ts` | C2 HTTP 实现(HUB_GROUP_SERVICE_URL 启用;失败 → 503) |
|
||||||
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
|
| `filelib/audit.ts` | 审计动作词表(C3 §6.3)+ 同事务写入 |
|
||||||
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) |
|
| `filelib/guards.ts` | session → FileLibActor;网站管理员 = org OWNER/ADMIN(D19) |
|
||||||
@@ -107,7 +111,8 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
|||||||
环境变量:
|
环境变量:
|
||||||
|
|
||||||
- `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`)
|
- `HUB_FILELIB_STORAGE_ROOT` — 项目 git 仓库根目录(默认 `./.filelib-repos`)
|
||||||
- `HUB_GROUP_SERVICE_URL` — Group 团队服务地址(C2);未配置时读 hub Team(扁平)
|
- `HUB_GROUP_SERVICE_URL` — 外部 Group 服务地址(C2);**未配置时读 in-hub
|
||||||
|
MemberGroup 闭包**(ADR-0028 起的默认;此前是扁平 hub Team)
|
||||||
|
|
||||||
> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**,
|
> ⚠️ 开发期注意:当前 VersionStore 是**进程内存**实现,**服务重启后仓库全失**,
|
||||||
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
|
> 此前创建的项目再访问文件会报 `repo_not_found`(需重建项目)。版本团队的
|
||||||
|
|||||||
@@ -32,9 +32,16 @@ export const FILE_LIB_AUDIT_ACTIONS = {
|
|||||||
fileConflictDetected: "file.conflict_detected",
|
fileConflictDetected: "file.conflict_detected",
|
||||||
exportRun: "export.run",
|
exportRun: "export.run",
|
||||||
adminForceAdjust: "admin.force_adjust",
|
adminForceAdjust: "admin.force_adjust",
|
||||||
|
// ADR-0028:成员组内置进 hub,组动作在本地审计(契约 C3 §6.3 原委托外部 Group 服务)。
|
||||||
|
groupCreate: "group.create",
|
||||||
|
groupUpdate: "group.update",
|
||||||
|
groupDelete: "group.delete",
|
||||||
|
groupRestore: "group.restore",
|
||||||
|
groupMemberAdd: "group.member_add",
|
||||||
|
groupMemberRemove: "group.member_remove",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job";
|
export type FileLibAuditObjectType = "folder" | "project" | "file" | "grant" | "export_job" | "group";
|
||||||
|
|
||||||
export interface FileLibAuditEntry {
|
export interface FileLibAuditEntry {
|
||||||
readonly action: string;
|
readonly action: string;
|
||||||
|
|||||||
@@ -2,8 +2,10 @@
|
|||||||
* GroupResolver port(契约 C2)。
|
* GroupResolver port(契约 C2)。
|
||||||
*
|
*
|
||||||
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
|
* 权限计算只依赖这一个查询:"用户 → 所属 Group(含全部祖先)"。
|
||||||
* Group 系统(需求系统二:全局、无限嵌套)由别的团队交付;调用方只依赖此
|
* ADR-0028 起,默认实现是 in-hub 的 MemberGroup 闭包读取器
|
||||||
* port,真身到位后替换实现,不换调用点。
|
* (`createMemberGroupResolver`,见 memberGroupResolver.ts);
|
||||||
|
* `HUB_GROUP_SERVICE_URL` 配置后切外部 HTTP 实现(groupResolverHttp.ts)。
|
||||||
|
* 调用方只依赖此 port,不换调用点。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
@@ -13,9 +15,11 @@ export interface GroupResolver {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
|
* @deprecated ADR-0028:成员组已内置为 in-hub MemberGroup,默认 resolver 改为
|
||||||
* 需求 3.2 的祖先递归语义在嵌套 Group 落地前无从谈起;此实现保证权限引擎
|
* `createMemberGroupResolver`。此扁平 Team 过渡实现不再接线,保留仅为历史参照
|
||||||
* 的 Group 通路今天就是真的,而不是 mock。
|
* (以及潜在的迁移对照),新代码不要使用。
|
||||||
|
*
|
||||||
|
* 旧过渡实现:读 hub 既有 Team(org 内、扁平无嵌套 → "祖先即自身")。
|
||||||
*/
|
*/
|
||||||
export function createTeamGroupResolver(
|
export function createTeamGroupResolver(
|
||||||
prisma: PrismaClient,
|
prisma: PrismaClient,
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* 默认 GroupResolver 实现:读 in-hub MemberGroup 闭包(ADR-0028)。
|
||||||
|
*
|
||||||
|
* resolveMemberGroupIds(user) = 用户**活跃直接组 ∪ 这些组的活跃祖先**,去重
|
||||||
|
* (闭包 depth0 自身行令每个直接组也是自己的祖先)。等价于:授权放在组 G 上,
|
||||||
|
* G 及其全部子孙的成员都命中(需求 3.2 权限沿树向下 → 解析沿树向上收集)。
|
||||||
|
*
|
||||||
|
* 实时、不缓存(契约 D4/G4):成员变更在下一次受保护请求即可见。
|
||||||
|
* MemberGroup 全局(无 organizationId),解析不做 org scope。
|
||||||
|
* 两条 Prisma 查询,不用裸 SQL(与 treeService 风格一致)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrismaClient } from "@prisma/client";
|
||||||
|
import type { GroupResolver } from "./groupResolver.js";
|
||||||
|
|
||||||
|
export function createMemberGroupResolver(prisma: PrismaClient): GroupResolver {
|
||||||
|
return {
|
||||||
|
async resolveMemberGroupIds(userId) {
|
||||||
|
// 1) 活跃直接组:成员未撤销 + 组未归档。
|
||||||
|
const direct = await prisma.memberGroupMembership.findMany({
|
||||||
|
where: { userId, revokedAt: null, group: { archivedAt: null } },
|
||||||
|
select: { groupId: true },
|
||||||
|
});
|
||||||
|
if (direct.length === 0) return [];
|
||||||
|
const directIds = direct.map((m) => m.groupId);
|
||||||
|
|
||||||
|
// 2) 经闭包取活跃祖先(含 depth0 自身);祖先组须未归档。
|
||||||
|
const ancestors = await prisma.memberGroupClosure.findMany({
|
||||||
|
where: { descendantId: { in: directIds }, ancestor: { archivedAt: null } },
|
||||||
|
select: { ancestorId: true },
|
||||||
|
});
|
||||||
|
return [...new Set(ancestors.map((a) => a.ancestorId))];
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,607 @@
|
|||||||
|
/**
|
||||||
|
* 成员组(MemberGroup)管理服务(ADR-0028)。
|
||||||
|
*
|
||||||
|
* 语义锚定:
|
||||||
|
* - 全局主体:MemberGroup 无 organizationId,不做租户 scope;审计行挂 silo org
|
||||||
|
* (deps.organizationId)—— MemberGroup 无 orgId,审计沿用文件库 sink(决策4)。
|
||||||
|
* - 权限门禁:创建/删除/成员增删仅网站管理员(silo org OWNER/ADMIN);
|
||||||
|
* 搜索(授权选择器)不限管理员 —— 选组授权是 Manage 持有者的能力(决策2)。
|
||||||
|
* - 软删除:archivedAt 打标;删组级联软删整棵子树(闭包 ancestorId=G);
|
||||||
|
* 闭包/成员行保留,list/解析按 archivedAt 过滤(决策4)。
|
||||||
|
* - 闭包维护:仅 create —— 插 (G,G,0),再对 parent P 插
|
||||||
|
* (a.ancestorId, G, a.depth+1) for a in closure where descendantId=P。
|
||||||
|
* v1 不支持 reparent(决策5)。
|
||||||
|
*
|
||||||
|
* 与 hub Team 不同:成员是全局用户,不要求 org membership;按 userId 或
|
||||||
|
* User.feishuOpenId(全局 @unique)解析。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { PrismaClient, Prisma } from "@prisma/client";
|
||||||
|
import { FileLibError } from "./model.js";
|
||||||
|
import type { FileLibActor } from "./treeService.js";
|
||||||
|
import { FILE_LIB_AUDIT_ACTIONS, writeFileLibAudit } from "./audit.js";
|
||||||
|
|
||||||
|
export interface MemberGroupServiceDeps {
|
||||||
|
readonly prisma: PrismaClient;
|
||||||
|
/** silo org id —— 仅用于审计归属(MemberGroup 全局无 orgId,决策4)。 */
|
||||||
|
readonly organizationId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberGroupDto {
|
||||||
|
readonly id: string;
|
||||||
|
readonly parentId: string | null;
|
||||||
|
readonly name: string;
|
||||||
|
readonly description: string | null;
|
||||||
|
/** 到根的边数(根 = 0);由闭包行数推导。 */
|
||||||
|
readonly depth: number;
|
||||||
|
readonly memberCount: number;
|
||||||
|
/** 软删标记(决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。 */
|
||||||
|
readonly archivedAt: Date | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberGroupMemberDto {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly feishuOpenId: string;
|
||||||
|
readonly avatarUrl: string | null;
|
||||||
|
/** 加入本组时间(membership.createdAt),用于成员表排序/展示。 */
|
||||||
|
readonly joinedAt: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 成员选择器候选(加成员弹窗搜索用)。 */
|
||||||
|
export interface UserSearchResult {
|
||||||
|
readonly userId: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly feishuOpenId: string;
|
||||||
|
readonly avatarUrl: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MemberGroupSearchResult {
|
||||||
|
readonly id: string;
|
||||||
|
readonly name: string;
|
||||||
|
/** 祖先链(根在前,自身在末),用 " / " 连接;无祖先时即自身名。 */
|
||||||
|
readonly breadcrumb: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMemberGroupInput {
|
||||||
|
readonly name: string;
|
||||||
|
readonly description?: string | undefined;
|
||||||
|
readonly parentId?: string | null | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AddMemberInput {
|
||||||
|
readonly userId?: string | undefined;
|
||||||
|
readonly feishuOpenId?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 改名/改描述(决策6)。字段缺省 = 不动;description 传空串 = 清空。 */
|
||||||
|
export interface UpdateMemberGroupInput {
|
||||||
|
readonly name?: string | undefined;
|
||||||
|
readonly description?: string | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Tx = Prisma.TransactionClient;
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 内部工具 */
|
||||||
|
|
||||||
|
/** 管理门禁:非网站管理员一律 403(决策2)。 */
|
||||||
|
function requireAdmin(actor: FileLibActor): void {
|
||||||
|
if (!actor.isWebsiteAdmin) {
|
||||||
|
throw new FileLibError(403, "forbidden", "group management requires website administrator");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组名校验(Group 域与节点域分开:轻量 trim/非空/长度,不套用节点命名规则)。 */
|
||||||
|
function normalizeGroupName(raw: string): string {
|
||||||
|
const name = raw.trim();
|
||||||
|
if (name === "") throw new FileLibError(400, "invalid_request", "group name must not be empty");
|
||||||
|
if (name.length > 100) throw new FileLibError(400, "invalid_request", "group name too long (max 100)");
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requireActiveGroup(
|
||||||
|
client: PrismaClient | Tx,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<{ readonly id: string; readonly name: string }> {
|
||||||
|
const group = await client.memberGroup.findFirst({
|
||||||
|
where: { id: groupId, archivedAt: null },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
|
||||||
|
return group;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 全局用户解析:按 userId,或 User.feishuOpenId(全局 @unique)。不要求 org 成员。 */
|
||||||
|
async function resolveUser(
|
||||||
|
tx: Tx,
|
||||||
|
input: AddMemberInput,
|
||||||
|
): Promise<{
|
||||||
|
readonly id: string;
|
||||||
|
readonly displayName: string;
|
||||||
|
readonly feishuOpenId: string;
|
||||||
|
readonly avatarUrl: string | null;
|
||||||
|
}> {
|
||||||
|
const select = { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } as const;
|
||||||
|
if (input.userId !== undefined && input.userId !== "") {
|
||||||
|
const user = await tx.user.findUnique({ where: { id: input.userId }, select });
|
||||||
|
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.userId}`);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
if (input.feishuOpenId !== undefined && input.feishuOpenId !== "") {
|
||||||
|
const user = await tx.user.findUnique({
|
||||||
|
where: { feishuOpenId: input.feishuOpenId },
|
||||||
|
select,
|
||||||
|
});
|
||||||
|
if (user === null) throw new FileLibError(404, "user_not_found", `user not found: ${input.feishuOpenId}`);
|
||||||
|
return user;
|
||||||
|
}
|
||||||
|
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------------------------------------------------------- 公共操作 */
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建成员组(建根 / 建子)。仅网站管理员。事务内维护闭包。
|
||||||
|
* parentId 给定时校验其活跃存在;闭包:插自身 depth0 + 继承 parent 的祖先。
|
||||||
|
*/
|
||||||
|
export async function createMemberGroup(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
input: CreateMemberGroupInput,
|
||||||
|
): Promise<MemberGroupDto> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
const name = normalizeGroupName(input.name);
|
||||||
|
const description = input.description?.trim() || null;
|
||||||
|
const parentId = input.parentId ?? null;
|
||||||
|
|
||||||
|
return deps.prisma.$transaction(async (tx) => {
|
||||||
|
let parentClosure: { ancestorId: string; depth: number }[] = [];
|
||||||
|
if (parentId !== null) {
|
||||||
|
const parent = await tx.memberGroup.findFirst({
|
||||||
|
where: { id: parentId, archivedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (parent === null) throw new FileLibError(404, "group_not_found", "parent group not found");
|
||||||
|
parentClosure = await tx.memberGroupClosure.findMany({
|
||||||
|
where: { descendantId: parentId },
|
||||||
|
select: { ancestorId: true, depth: true },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const group = await tx.memberGroup.create({
|
||||||
|
data: { name, parentId, ...(description !== null ? { description } : {}) },
|
||||||
|
select: { id: true, parentId: true, name: true, description: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 闭包维护:自身 depth0,再继承 parent 的每个祖先(depth+1)。
|
||||||
|
await tx.memberGroupClosure.create({
|
||||||
|
data: { ancestorId: group.id, descendantId: group.id, depth: 0 },
|
||||||
|
});
|
||||||
|
if (parentClosure.length > 0) {
|
||||||
|
await tx.memberGroupClosure.createMany({
|
||||||
|
data: parentClosure.map((a) => ({
|
||||||
|
ancestorId: a.ancestorId,
|
||||||
|
descendantId: group.id,
|
||||||
|
depth: a.depth + 1,
|
||||||
|
})),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// parent 的闭包行数 = parent.depth + 1 = 新组 depth(闭包不变量)。
|
||||||
|
const depth = parentClosure.length;
|
||||||
|
|
||||||
|
await writeFileLibAudit(tx, {
|
||||||
|
action: FILE_LIB_AUDIT_ACTIONS.groupCreate,
|
||||||
|
actorUserId: actor.userId,
|
||||||
|
organizationId: deps.organizationId,
|
||||||
|
objectType: "group",
|
||||||
|
objectId: group.id,
|
||||||
|
objectPath: group.id,
|
||||||
|
detail: { name, parentId },
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
parentId: group.parentId,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
depth,
|
||||||
|
memberCount: 0,
|
||||||
|
archivedAt: null,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 改名 / 改描述(决策6)。仅网站管理员。**不动 parentId** —— reparent 仍属 v1
|
||||||
|
* 范围外(决策5),闭包无需维护。字段缺省即不动;description 传 "" 清空。
|
||||||
|
*/
|
||||||
|
export async function updateMemberGroup(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
groupId: string,
|
||||||
|
input: UpdateMemberGroupInput,
|
||||||
|
): Promise<MemberGroupDto> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
if (input.name === undefined && input.description === undefined) {
|
||||||
|
throw new FileLibError(400, "invalid_request", "name or description is required");
|
||||||
|
}
|
||||||
|
const name = input.name === undefined ? undefined : normalizeGroupName(input.name);
|
||||||
|
|
||||||
|
return deps.prisma.$transaction(async (tx) => {
|
||||||
|
await requireActiveGroup(tx, groupId);
|
||||||
|
const group = await tx.memberGroup.update({
|
||||||
|
where: { id: groupId },
|
||||||
|
data: {
|
||||||
|
...(name !== undefined ? { name } : {}),
|
||||||
|
...(input.description !== undefined
|
||||||
|
? { description: input.description.trim() || null }
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
select: { id: true, parentId: true, name: true, description: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// depth 由闭包行数推导(与 listMemberGroups 同一不变量);update 不改闭包。
|
||||||
|
const closureCount = await tx.memberGroupClosure.count({ where: { descendantId: groupId } });
|
||||||
|
const memberCount = await tx.memberGroupMembership.count({
|
||||||
|
where: { groupId, revokedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeFileLibAudit(tx, {
|
||||||
|
action: FILE_LIB_AUDIT_ACTIONS.groupUpdate,
|
||||||
|
actorUserId: actor.userId,
|
||||||
|
organizationId: deps.organizationId,
|
||||||
|
objectType: "group",
|
||||||
|
objectId: group.id,
|
||||||
|
objectPath: group.id,
|
||||||
|
detail: {
|
||||||
|
...(name !== undefined ? { name } : {}),
|
||||||
|
...(input.description !== undefined ? { description: group.description } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: group.id,
|
||||||
|
parentId: group.parentId,
|
||||||
|
name: group.name,
|
||||||
|
description: group.description,
|
||||||
|
depth: closureCount - 1,
|
||||||
|
memberCount,
|
||||||
|
archivedAt: null, // requireActiveGroup 已保证是活跃组
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 软删除成员组:级联软删整棵子树(闭包 ancestorId=G 的全部活跃 descendant)。
|
||||||
|
* 闭包/成员行保留;list/解析按 archivedAt 过滤,整支立即停止贡献权限。
|
||||||
|
*/
|
||||||
|
export async function deleteMemberGroup(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<{ readonly archivedCount: number }> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
return deps.prisma.$transaction(async (tx) => {
|
||||||
|
const group = await requireActiveGroup(tx, groupId);
|
||||||
|
const subtree = await tx.memberGroupClosure.findMany({
|
||||||
|
where: { ancestorId: groupId },
|
||||||
|
select: { descendantId: true },
|
||||||
|
});
|
||||||
|
const ids = subtree.map((r) => r.descendantId);
|
||||||
|
const now = new Date();
|
||||||
|
const result = await tx.memberGroup.updateMany({
|
||||||
|
where: { id: { in: ids }, archivedAt: null },
|
||||||
|
data: { archivedAt: now },
|
||||||
|
});
|
||||||
|
await writeFileLibAudit(tx, {
|
||||||
|
action: FILE_LIB_AUDIT_ACTIONS.groupDelete,
|
||||||
|
actorUserId: actor.userId,
|
||||||
|
organizationId: deps.organizationId,
|
||||||
|
objectType: "group",
|
||||||
|
objectId: group.id,
|
||||||
|
objectPath: group.id,
|
||||||
|
detail: { name: group.name, archivedCount: result.count },
|
||||||
|
});
|
||||||
|
return { archivedCount: result.count };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 恢复(取消归档)。仅网站管理员。**与删除不对称**(决策7):
|
||||||
|
* - 删除级联整棵子树;恢复只恢复「该组 + 其全部已归档祖先」,**不动子树**。
|
||||||
|
* - 恢复祖先链是必须的:活跃组的祖先必须活跃,否则该组在树上无路径、
|
||||||
|
* depth 推导(闭包行数)与"祖先必活跃"的前提脱节。
|
||||||
|
* - 子树保持归档、仍可见(带标记),由管理员逐个决定是否恢复 —— 避免一次
|
||||||
|
* 恢复意外把整支历史组全部重新授权。
|
||||||
|
* 恢复即刻恢复该组贡献的权限(实时解析,不缓存)。
|
||||||
|
*/
|
||||||
|
export async function restoreMemberGroup(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<{ readonly restoredCount: number }> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
return deps.prisma.$transaction(async (tx) => {
|
||||||
|
const group = await tx.memberGroup.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
select: { id: true, name: true, archivedAt: true },
|
||||||
|
});
|
||||||
|
if (group === null) throw new FileLibError(404, "group_not_found", "group not found");
|
||||||
|
if (group.archivedAt === null) {
|
||||||
|
throw new FileLibError(409, "not_archived", "group is not archived");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 自身 + 祖先(闭包 descendantId=G 含 depth0 自身),只挑已归档的解标。
|
||||||
|
const chain = await tx.memberGroupClosure.findMany({
|
||||||
|
where: { descendantId: groupId },
|
||||||
|
select: { ancestorId: true },
|
||||||
|
});
|
||||||
|
const ids = chain.map((r) => r.ancestorId);
|
||||||
|
const result = await tx.memberGroup.updateMany({
|
||||||
|
where: { id: { in: ids }, archivedAt: { not: null } },
|
||||||
|
data: { archivedAt: null },
|
||||||
|
});
|
||||||
|
|
||||||
|
await writeFileLibAudit(tx, {
|
||||||
|
action: FILE_LIB_AUDIT_ACTIONS.groupRestore,
|
||||||
|
actorUserId: actor.userId,
|
||||||
|
organizationId: deps.organizationId,
|
||||||
|
objectType: "group",
|
||||||
|
objectId: group.id,
|
||||||
|
objectPath: group.id,
|
||||||
|
detail: { name: group.name, restoredCount: result.count },
|
||||||
|
});
|
||||||
|
return { restoredCount: result.count };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组扁平列表(前端自行按 parentId/depth 拼树);仅网站管理员。
|
||||||
|
* includeArchived=true 时连已归档组一并返回(带 archivedAt 标记),供后台展示/恢复;
|
||||||
|
* 默认只返回活跃组 —— 权限相关的调用方一律走默认。
|
||||||
|
*/
|
||||||
|
export async function listMemberGroups(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
includeArchived = false,
|
||||||
|
): Promise<readonly MemberGroupDto[]> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
const groups = await deps.prisma.memberGroup.findMany({
|
||||||
|
where: includeArchived ? {} : { archivedAt: null },
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, parentId: true, name: true, description: true, archivedAt: true },
|
||||||
|
});
|
||||||
|
if (groups.length === 0) return [];
|
||||||
|
const ids = groups.map((g) => g.id);
|
||||||
|
|
||||||
|
// depth:每个组的闭包行数(自身 + 祖先)- 1。级联软删保证活跃组的祖先必活跃。
|
||||||
|
const closure = await deps.prisma.memberGroupClosure.findMany({
|
||||||
|
where: { descendantId: { in: ids } },
|
||||||
|
select: { descendantId: true },
|
||||||
|
});
|
||||||
|
const closureCount = new Map<string, number>();
|
||||||
|
for (const row of closure) {
|
||||||
|
closureCount.set(row.descendantId, (closureCount.get(row.descendantId) ?? 0) + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const counts = await deps.prisma.memberGroupMembership.groupBy({
|
||||||
|
by: ["groupId"],
|
||||||
|
where: { groupId: { in: ids }, revokedAt: null },
|
||||||
|
_count: { _all: true },
|
||||||
|
});
|
||||||
|
const countByGroup = new Map(counts.map((c) => [c.groupId, c._count._all]));
|
||||||
|
|
||||||
|
return groups.map((g) => ({
|
||||||
|
id: g.id,
|
||||||
|
parentId: g.parentId,
|
||||||
|
name: g.name,
|
||||||
|
description: g.description,
|
||||||
|
depth: (closureCount.get(g.id) ?? 1) - 1,
|
||||||
|
memberCount: countByGroup.get(g.id) ?? 0,
|
||||||
|
archivedAt: g.archivedAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 组成员列表(仅网站管理员)。**已归档组也可读**(决策7):软删是打标,成员行仍在,
|
||||||
|
* 后台需要看得见「这个组曾经有谁」。写操作(add/remove)仍要求活跃组 —— 可读不可改。
|
||||||
|
*/
|
||||||
|
export async function listMembers(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
groupId: string,
|
||||||
|
): Promise<readonly MemberGroupMemberDto[]> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
const exists = await deps.prisma.memberGroup.findUnique({
|
||||||
|
where: { id: groupId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (exists === null) throw new FileLibError(404, "group_not_found", "group not found");
|
||||||
|
const rows = await deps.prisma.memberGroupMembership.findMany({
|
||||||
|
where: { groupId, revokedAt: null },
|
||||||
|
select: {
|
||||||
|
createdAt: true,
|
||||||
|
user: { select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true } },
|
||||||
|
},
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
});
|
||||||
|
return rows.map((r) => ({
|
||||||
|
userId: r.user.id,
|
||||||
|
displayName: r.user.displayName,
|
||||||
|
feishuOpenId: r.user.feishuOpenId,
|
||||||
|
avatarUrl: r.user.avatarUrl,
|
||||||
|
joinedAt: r.createdAt,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 加成员(userId 或 feishuOpenId 解析);已是活跃成员 → 409。仅网站管理员。 */
|
||||||
|
export async function addMember(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
groupId: string,
|
||||||
|
input: AddMemberInput,
|
||||||
|
): Promise<MemberGroupMemberDto> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
return deps.prisma.$transaction(async (tx) => {
|
||||||
|
const group = await requireActiveGroup(tx, groupId);
|
||||||
|
const user = await resolveUser(tx, input);
|
||||||
|
const existing = await tx.memberGroupMembership.findFirst({
|
||||||
|
where: { groupId: group.id, userId: user.id, revokedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (existing !== null) {
|
||||||
|
throw new FileLibError(409, "already_member", "user is already a member of this group");
|
||||||
|
}
|
||||||
|
const created = await tx.memberGroupMembership.create({
|
||||||
|
data: { groupId: group.id, userId: user.id },
|
||||||
|
select: { createdAt: true },
|
||||||
|
});
|
||||||
|
await writeFileLibAudit(tx, {
|
||||||
|
action: FILE_LIB_AUDIT_ACTIONS.groupMemberAdd,
|
||||||
|
actorUserId: actor.userId,
|
||||||
|
organizationId: deps.organizationId,
|
||||||
|
objectType: "group",
|
||||||
|
objectId: group.id,
|
||||||
|
objectPath: group.id,
|
||||||
|
detail: { userId: user.id },
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
displayName: user.displayName,
|
||||||
|
feishuOpenId: user.feishuOpenId,
|
||||||
|
avatarUrl: user.avatarUrl,
|
||||||
|
joinedAt: created.createdAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 移成员(软删 revokedAt);不在组 → 404。仅网站管理员。 */
|
||||||
|
export async function removeMember(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
groupId: string,
|
||||||
|
userId: string,
|
||||||
|
): Promise<void> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
await deps.prisma.$transaction(async (tx) => {
|
||||||
|
const group = await requireActiveGroup(tx, groupId);
|
||||||
|
const membership = await tx.memberGroupMembership.findFirst({
|
||||||
|
where: { groupId: group.id, userId, revokedAt: null },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (membership === null) throw new FileLibError(404, "member_not_found", "group member not found");
|
||||||
|
await tx.memberGroupMembership.update({
|
||||||
|
where: { id: membership.id },
|
||||||
|
data: { revokedAt: new Date() },
|
||||||
|
});
|
||||||
|
await writeFileLibAudit(tx, {
|
||||||
|
action: FILE_LIB_AUDIT_ACTIONS.groupMemberRemove,
|
||||||
|
actorUserId: actor.userId,
|
||||||
|
organizationId: deps.organizationId,
|
||||||
|
objectType: "group",
|
||||||
|
objectId: group.id,
|
||||||
|
objectPath: group.id,
|
||||||
|
detail: { userId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成员选择器:按显示名/openId 搜全局用户。**仅网站管理员**(与加成员同权,决策2)
|
||||||
|
* —— 加成员本就能指定任意全局用户(resolveUser 不要求 org 成员),故此端点不扩大
|
||||||
|
* 已有能力面,只是把"盲敲 id"变成"搜索选择"。
|
||||||
|
* excludeGroupId 给定时,过滤掉该组的活跃成员(避免选中必然 409 的人)。
|
||||||
|
*/
|
||||||
|
export async function searchUsers(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
actor: FileLibActor,
|
||||||
|
q: string,
|
||||||
|
excludeGroupId?: string,
|
||||||
|
limit = 20,
|
||||||
|
): Promise<readonly UserSearchResult[]> {
|
||||||
|
requireAdmin(actor);
|
||||||
|
const keyword = q.trim();
|
||||||
|
|
||||||
|
let excludeIds: string[] = [];
|
||||||
|
if (excludeGroupId !== undefined && excludeGroupId !== "") {
|
||||||
|
const rows = await deps.prisma.memberGroupMembership.findMany({
|
||||||
|
where: { groupId: excludeGroupId, revokedAt: null },
|
||||||
|
select: { userId: true },
|
||||||
|
});
|
||||||
|
excludeIds = rows.map((r) => r.userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = await deps.prisma.user.findMany({
|
||||||
|
where: {
|
||||||
|
...(excludeIds.length > 0 ? { id: { notIn: excludeIds } } : {}),
|
||||||
|
...(keyword === ""
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [
|
||||||
|
{ displayName: { contains: keyword, mode: "insensitive" as const } },
|
||||||
|
{ feishuOpenId: { contains: keyword, mode: "insensitive" as const } },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
orderBy: { displayName: "asc" },
|
||||||
|
select: { id: true, displayName: true, feishuOpenId: true, avatarUrl: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
return users.map((u) => ({
|
||||||
|
userId: u.id,
|
||||||
|
displayName: u.displayName,
|
||||||
|
feishuOpenId: u.feishuOpenId,
|
||||||
|
avatarUrl: u.avatarUrl,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 授权选择器搜索(契约 C2 /groups/search)。**不限管理员**(决策2)。
|
||||||
|
* 活跃组按名过滤,breadcrumb 由活跃祖先链按 depth 排序拼成。
|
||||||
|
*/
|
||||||
|
export async function searchMemberGroups(
|
||||||
|
deps: MemberGroupServiceDeps,
|
||||||
|
q: string,
|
||||||
|
limit = 20,
|
||||||
|
): Promise<readonly MemberGroupSearchResult[]> {
|
||||||
|
const keyword = q.trim();
|
||||||
|
const groups = await deps.prisma.memberGroup.findMany({
|
||||||
|
where: {
|
||||||
|
archivedAt: null,
|
||||||
|
...(keyword === "" ? {} : { name: { contains: keyword, mode: "insensitive" as const } }),
|
||||||
|
},
|
||||||
|
take: limit,
|
||||||
|
orderBy: { name: "asc" },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
if (groups.length === 0) return [];
|
||||||
|
const ids = groups.map((g) => g.id);
|
||||||
|
|
||||||
|
// 祖先链(仅活跃祖先);depth 越大越靠根。
|
||||||
|
const closure = await deps.prisma.memberGroupClosure.findMany({
|
||||||
|
where: { descendantId: { in: ids }, ancestor: { archivedAt: null } },
|
||||||
|
select: { descendantId: true, ancestorId: true, depth: true },
|
||||||
|
});
|
||||||
|
const ancestorIds = [...new Set(closure.map((c) => c.ancestorId))];
|
||||||
|
const names = await deps.prisma.memberGroup.findMany({
|
||||||
|
where: { id: { in: ancestorIds } },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
const nameById = new Map(names.map((n) => [n.id, n.name]));
|
||||||
|
const chainByGroup = new Map<string, { ancestorId: string; depth: number }[]>();
|
||||||
|
for (const row of closure) {
|
||||||
|
const arr = chainByGroup.get(row.descendantId) ?? [];
|
||||||
|
arr.push({ ancestorId: row.ancestorId, depth: row.depth });
|
||||||
|
chainByGroup.set(row.descendantId, arr);
|
||||||
|
}
|
||||||
|
|
||||||
|
return groups.map((g) => {
|
||||||
|
const chain = (chainByGroup.get(g.id) ?? []).slice().sort((a, b) => b.depth - a.depth);
|
||||||
|
const breadcrumb = chain
|
||||||
|
.map((c) => nameById.get(c.ancestorId) ?? "")
|
||||||
|
.filter((s) => s !== "")
|
||||||
|
.join(" / ");
|
||||||
|
return { id: g.id, name: g.name, breadcrumb: breadcrumb || g.name };
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)。
|
* 管理员后台「用户管理」「Group 管理」面板。
|
||||||
*
|
*
|
||||||
* 用户管理 = org 成员(/api/org/:orgSlug/members);
|
* 用户管理 = org 成员(/api/org/:orgSlug/members)。
|
||||||
* Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams),
|
* Group 管理 = **MemberGroup**(全局可无限嵌套,ADR-0028),走 /database/api/groups/*。
|
||||||
* 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。
|
* 已从旧的扁平 hub Team 迁过来 —— Team 无父子字段,建不出子组。此面板与
|
||||||
|
* filelib-web 的 GroupAdmin.svelte 消费同一套 API,语义一致。
|
||||||
|
* 交互:嵌套树(展开/折叠)+ 右键菜单(建子组/建根组/重命名/删除)。
|
||||||
*/
|
*/
|
||||||
|
|
||||||
function apiBase(orgSlug: string): string {
|
function apiBase(orgSlug: string): string {
|
||||||
@@ -105,35 +107,110 @@ export function renderUsersPanel(orgSlug: string): string {
|
|||||||
|
|
||||||
/* ---------------------------------------------------------------- Group 管理 */
|
/* ---------------------------------------------------------------- Group 管理 */
|
||||||
|
|
||||||
export function renderGroupsPanel(orgSlug: string): string {
|
/**
|
||||||
|
* MemberGroup 嵌套树管理(ADR-0028)。无 orgSlug 参数 —— MemberGroup 是全局主体,
|
||||||
|
* 不归属任何 Organization,端点也不带 org 段。
|
||||||
|
*/
|
||||||
|
/** 内联 SVG 图标表(24x24 stroke 风格,与 dashboard 侧栏一致)。 */
|
||||||
|
const GROUP_ICONS: Record<string, string> = {
|
||||||
|
// Group 节点 = 人的集合。**不用文件夹图标** —— Group 不是目录,
|
||||||
|
// 与文件库的 FOLDER/PROJECT 是两套体系,图标上也不应混淆。两人剪影。
|
||||||
|
group: "M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 11.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.38M15.4 5.22a3.25 3.25 0 0 1 0 6.06",
|
||||||
|
users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
|
||||||
|
user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",
|
||||||
|
plus: "M12 5v14M5 12h14",
|
||||||
|
pencil: "M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z",
|
||||||
|
trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14",
|
||||||
|
search: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z",
|
||||||
|
chevron: "m9 18 6-6-6-6",
|
||||||
|
layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5",
|
||||||
|
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2",
|
||||||
|
minus: "M5 12h14",
|
||||||
|
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
|
||||||
|
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
|
||||||
|
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
|
||||||
|
};
|
||||||
|
|
||||||
|
/** `icon("users", 16)` → 内联 svg 串。 */
|
||||||
|
function icon(name: keyof typeof GROUP_ICONS | string, size = 16): string {
|
||||||
|
const d = GROUP_ICONS[name] ?? "";
|
||||||
|
return `<svg style="width:${size}px;height:${size}px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function renderGroupsPanel(): string {
|
||||||
return `
|
return `
|
||||||
<div id="groups-root" style="display:flex;gap:14px;align-items:flex-start">
|
<div id="groups-root" style="display:flex;gap:14px;align-items:stretch;height:100%;min-height:0">
|
||||||
<div style="width:340px;flex-shrink:0">
|
<!-- 左:组树 -->
|
||||||
<div class="panel" style="margin-bottom:14px">
|
<div class="panel" style="width:326px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;padding:14px">
|
||||||
<div class="section-title">新建 Group</div>
|
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
|
||||||
<div class="form-row"><label class="form-label">标识(slug)</label><input id="g-slug" class="input" placeholder="physics-dept"/></div>
|
<span style="display:flex;color:var(--accent)">${icon("layers", 17)}</span>
|
||||||
<div class="form-row"><label class="form-label">名称</label><input id="g-name" class="input" placeholder="物理教研组"/></div>
|
<div class="section-title" style="margin:0;flex:1">Group 树</div>
|
||||||
<div class="form-row"><label class="form-label">描述(可选)</label><input id="g-desc" class="input" placeholder="一句话说明"/></div>
|
<button id="g-new-root" class="btn" style="font-size:11.5px;padding:4px 9px;display:inline-flex;align-items:center;gap:4px">
|
||||||
<div style="text-align:right"><button id="g-create" class="btn btn-primary">创建</button></div>
|
${icon("plus", 13)} 根组
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel">
|
<div style="position:relative;margin-bottom:8px">
|
||||||
<div class="section-title">Group 列表</div>
|
<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">${icon("search", 13)}</span>
|
||||||
<div id="g-list"></div>
|
<input id="g-filter" class="input" placeholder="过滤组名…" style="padding-left:28px;font-size:12.5px"/>
|
||||||
</div>
|
</div>
|
||||||
|
<label class="switch" style="margin-bottom:9px;font-size:11.5px;color:var(--text-3)">
|
||||||
|
<input type="checkbox" id="g-show-archived"/>
|
||||||
|
<span></span>
|
||||||
|
显示已删除的组
|
||||||
|
</label>
|
||||||
|
<div id="g-tree" style="flex:1;overflow-y:auto;margin:0 -6px;min-height:0"></div>
|
||||||
|
<div id="g-tree-foot" class="section-note" style="border-top:1px solid var(--border-soft);margin-top:8px;padding-top:8px"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="panel" style="flex:1;min-height:200px">
|
|
||||||
<div id="g-detail" class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>
|
<!-- 右:成员表 -->
|
||||||
|
<div class="panel" style="flex:1;min-width:0;display:flex;flex-direction:column;min-height:0;padding:0">
|
||||||
|
<div id="g-detail" style="display:flex;flex-direction:column;min-height:0;flex:1">
|
||||||
|
<div class="quiet" style="margin:auto;padding:28px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px">
|
||||||
|
<span style="color:var(--border);display:flex">${icon("users", 40)}</span>
|
||||||
|
从左侧选择一个 Group 查看成员
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div id="g-menu" class="hidden" style="position:fixed;z-index:60;min-width:184px;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-pop);padding:5px;font-size:13px"></div>
|
||||||
<script>
|
<script>
|
||||||
(function () {
|
(function () {
|
||||||
const BASE = "${apiBase(orgSlug)}";
|
const treeEl = document.getElementById("g-tree");
|
||||||
const listEl = document.getElementById("g-list");
|
|
||||||
const detailEl = document.getElementById("g-detail");
|
const detailEl = document.getElementById("g-detail");
|
||||||
|
const menuEl = document.getElementById("g-menu");
|
||||||
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||||
let selected = null;
|
|
||||||
|
const ICONS = ${JSON.stringify(GROUP_ICONS)};
|
||||||
|
function ico(name, size) {
|
||||||
|
return '<svg style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex-shrink:0" viewBox="0 0 24 24"' +
|
||||||
|
' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
|
||||||
|
'<path d="' + (ICONS[name] || "") + '"/></svg>';
|
||||||
|
}
|
||||||
|
/** 头像:有 avatarUrl 用图,否则首字母色块。 */
|
||||||
|
function avatar(m, size) {
|
||||||
|
const s = size || 28;
|
||||||
|
if (m.avatarUrl) {
|
||||||
|
return '<img src="' + esc(m.avatarUrl) + '" alt="" style="width:' + s + 'px;height:' + s +
|
||||||
|
'px;border-radius:50%;object-fit:cover;flex-shrink:0"/>';
|
||||||
|
}
|
||||||
|
const ch = esc((m.displayName || m.userId || "?").slice(0, 1).toUpperCase());
|
||||||
|
return '<span style="width:' + s + 'px;height:' + s + 'px;border-radius:50%;flex-shrink:0;background:var(--accent);' +
|
||||||
|
'color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:' + Math.round(s * 0.42) +
|
||||||
|
'px;font-weight:600">' + ch + "</span>";
|
||||||
|
}
|
||||||
|
function fmtDate(iso) {
|
||||||
|
try { return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" }); }
|
||||||
|
catch { return String(iso || ""); }
|
||||||
|
}
|
||||||
|
|
||||||
|
let groups = []; // 后端返回的活跃组扁平列表
|
||||||
|
let selected = null; // { id, name }
|
||||||
|
const collapsed = new Set(); // 折叠的组 id(默认全展开)
|
||||||
|
let filterText = ""; // 组名过滤(命中项的祖先链一并保留)
|
||||||
|
let showArchived = false; // 是否列出已归档(软删)的组
|
||||||
|
|
||||||
async function req(path, opts = {}) {
|
async function req(path, opts = {}) {
|
||||||
const res = await fetch(BASE + path, {
|
const res = await fetch("/database/api/groups" + path, {
|
||||||
credentials: "same-origin",
|
credentials: "same-origin",
|
||||||
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
||||||
method: opts.method ?? "GET",
|
method: opts.method ?? "GET",
|
||||||
@@ -145,83 +222,526 @@ export function renderGroupsPanel(orgSlug: string): string {
|
|||||||
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
|
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
async function loadList() {
|
|
||||||
const { teams } = await req("/teams");
|
/* ---------------- 树渲染 ---------------- */
|
||||||
listEl.innerHTML = teams.length === 0
|
|
||||||
? '<div class="quiet" style="text-align:center;padding:12px">暂无 Group</div>'
|
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
|
||||||
: teams.map((t) =>
|
function orderedVisible() {
|
||||||
'<div class="g-team" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '" style="display:flex;align-items:center;gap:8px;padding:8px 6px;border-radius:8px;cursor:pointer">' +
|
const byParent = new Map();
|
||||||
'<span style="flex:1"><b>' + esc(t.name) + '</b> <span class="file-meta">' + esc(t.slug) + "</span></span>" +
|
const byId = new Map();
|
||||||
'<button class="link-danger" data-archive="' + esc(t.id) + '" style="font-size:11px">归档</button>' +
|
for (const g of groups) {
|
||||||
"</div>").join("");
|
byId.set(g.id, g);
|
||||||
listEl.querySelectorAll(".g-team").forEach((el) => {
|
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, "zh-CN"));
|
||||||
|
|
||||||
|
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
|
||||||
|
let keep = null;
|
||||||
|
const q = filterText.trim().toLowerCase();
|
||||||
|
if (q !== "") {
|
||||||
|
keep = new Set();
|
||||||
|
for (const g of groups) {
|
||||||
|
if (!g.name.toLowerCase().includes(q)) continue;
|
||||||
|
let cur = g;
|
||||||
|
while (cur) { keep.add(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : null; }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const out = [];
|
||||||
|
(function walk(parentId) {
|
||||||
|
for (const g of byParent.get(parentId) || []) {
|
||||||
|
if (keep !== null && !keep.has(g.id)) continue;
|
||||||
|
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 || !collapsed.has(g.id)) walk(g.id);
|
||||||
|
}
|
||||||
|
})(null);
|
||||||
|
// 兜底:父不在活跃列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"。
|
||||||
|
const seen = new Set(out.map((r) => r.g.id));
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTree() {
|
||||||
|
const footEl = document.getElementById("g-tree-foot");
|
||||||
|
if (groups.length === 0) {
|
||||||
|
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px;display:flex;flex-direction:column;align-items:center;gap:8px">' +
|
||||||
|
'<span style="color:var(--border);display:flex">' + ico("layers", 30) + "</span>" +
|
||||||
|
"暂无成员组 · 点上方「根组」开始</div>";
|
||||||
|
footEl.textContent = "";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = orderedVisible();
|
||||||
|
if (rows.length === 0) {
|
||||||
|
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px">无匹配的组</div>';
|
||||||
|
} else {
|
||||||
|
treeEl.innerHTML = rows.map(({ g, hasKids, hit }) => {
|
||||||
|
const isSel = selected && selected.id === g.id;
|
||||||
|
const caret = hasKids
|
||||||
|
? '<span data-caret="' + esc(g.id) + '" style="display:inline-flex;width:15px;justify-content:center;cursor:pointer;' +
|
||||||
|
'color:var(--text-3);transition:transform .12s;' +
|
||||||
|
(collapsed.has(g.id) && filterText.trim() === "" ? "" : "transform:rotate(90deg)") + '">' +
|
||||||
|
ico("chevron", 13) + "</span>"
|
||||||
|
: '<span style="display:inline-block;width:15px"></span>';
|
||||||
|
const arch = !!g.archivedAt;
|
||||||
|
return '<div class="g-node" data-id="' + esc(g.id) + '" data-name="' + esc(g.name) + '"' +
|
||||||
|
' data-archived="' + (arch ? "1" : "") + '"' +
|
||||||
|
' style="display:flex;align-items:center;gap:6px;padding:6px 8px 6px ' + (8 + g.depth * 15) + 'px;' +
|
||||||
|
'border-radius:8px;cursor:pointer;user-select:none;' + (isSel ? "background:var(--selected)" : "") +
|
||||||
|
(hit ? "" : ";opacity:.5") + '">' +
|
||||||
|
caret +
|
||||||
|
'<span style="display:flex;color:' + (arch ? "var(--text-3)" : (isSel ? "var(--accent)" : "var(--text-3)")) + '">' +
|
||||||
|
ico(arch ? "archive" : "group", 15) + "</span>" +
|
||||||
|
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' +
|
||||||
|
(arch ? ";color:var(--text-3);text-decoration:line-through" : "") + '">' + esc(g.name) + "</span>" +
|
||||||
|
'<span class="tag" style="flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:10.5px' +
|
||||||
|
(arch ? ";opacity:.7" : "") + '">' + ico("user", 10) + g.memberCount + "</span>" +
|
||||||
|
(arch ? '<span class="tag" style="flex-shrink:0;font-size:10px;opacity:.85">已删除</span>' : "") +
|
||||||
|
"</div>";
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
const active = groups.filter((g) => !g.archivedAt);
|
||||||
|
const archivedN = groups.length - active.length;
|
||||||
|
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
|
||||||
|
footEl.textContent = active.length + " 个活跃组 · " + totalMembers + " 条成员关系" +
|
||||||
|
(archivedN > 0 ? " · " + archivedN + " 个已删除" : "");
|
||||||
|
|
||||||
|
treeEl.querySelectorAll(".g-node").forEach((el) => {
|
||||||
el.onclick = (e) => {
|
el.onclick = (e) => {
|
||||||
if (e.target.closest("button")) return;
|
if (e.target.closest("[data-caret]")) return; // 折叠三角不触发选中
|
||||||
selected = { id: el.dataset.id, name: el.dataset.name };
|
selectGroup({ id: el.dataset.id, name: el.dataset.name });
|
||||||
listEl.querySelectorAll(".g-team").forEach((x) => x.style.background = "");
|
|
||||||
el.style.background = "var(--selected)";
|
|
||||||
loadMembers();
|
loadMembers();
|
||||||
};
|
};
|
||||||
|
el.oncontextmenu = (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
openMenu(e.clientX, e.clientY, {
|
||||||
|
id: el.dataset.id,
|
||||||
|
name: el.dataset.name,
|
||||||
|
archived: el.dataset.archived === "1",
|
||||||
|
});
|
||||||
|
};
|
||||||
});
|
});
|
||||||
listEl.querySelectorAll("button[data-archive]").forEach((btn) => {
|
treeEl.querySelectorAll("[data-caret]").forEach((el) => {
|
||||||
btn.onclick = async () => {
|
el.onclick = (e) => {
|
||||||
if (!confirm("归档该 Group?其成员授权将失效。")) return;
|
e.stopPropagation();
|
||||||
try { await req("/teams/" + encodeURIComponent(btn.dataset.archive) + "/archive", { method: "POST" }); selected = null; detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>'; loadList(); }
|
const id = el.dataset.caret;
|
||||||
catch (e) { alert(e.message); }
|
if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
|
||||||
|
renderTree();
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async function loadMembers() {
|
|
||||||
if (!selected) return;
|
/* ---------------- 右键菜单 ---------------- */
|
||||||
detailEl.innerHTML = '<div class="quiet" style="padding:12px;text-align:center">加载中…</div>';
|
|
||||||
const { members } = await req("/teams/" + encodeURIComponent(selected.id) + "/members");
|
function closeMenu() { menuEl.classList.add("hidden"); }
|
||||||
detailEl.innerHTML =
|
document.addEventListener("click", closeMenu);
|
||||||
'<div class="section-title">' + esc(selected.name) + " · 成员(" + members.length + ")</div>" +
|
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
|
||||||
'<table class="list"><tbody>' +
|
// 树空白处右键 = 建根组
|
||||||
(members.length === 0
|
treeEl.oncontextmenu = (e) => {
|
||||||
? '<tr><td class="quiet" style="text-align:center;padding:14px">暂无成员</td></tr>'
|
if (e.target.closest(".g-node")) return;
|
||||||
: members.map((m) =>
|
e.preventDefault();
|
||||||
"<tr><td>" + esc(m.displayName || m.userId) + '</td><td class="file-meta">' + esc(m.userId) + "</td>" +
|
openMenu(e.clientX, e.clientY, null);
|
||||||
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td></tr>').join("")) +
|
};
|
||||||
"</tbody></table>" +
|
|
||||||
'<div class="divider"></div>' +
|
function openMenu(x, y, target) {
|
||||||
'<div class="section-title">添加成员</div>' +
|
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
|
||||||
'<div class="inline-form">' +
|
const items = target === null
|
||||||
'<input id="g-add-openid" class="input" placeholder="用户 openId 或 userId"/>' +
|
? [{ label: "新建根 Group", ic: "plus", fn: () => createDialog(null, null) }]
|
||||||
'<button id="g-add" class="btn btn-primary">添加</button>' +
|
: target.archived
|
||||||
"</div>";
|
? [
|
||||||
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
|
{ label: "查看成员(只读)", ic: "users", fn: () => { selectGroup(target); loadMembers(); } },
|
||||||
btn.onclick = async () => {
|
{ label: "恢复此 Group", ic: "restore", fn: () => restoreGroup(target) },
|
||||||
try { await req("/teams/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); loadMembers(); }
|
{ sep: true },
|
||||||
catch (e) { alert(e.message); }
|
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
|
||||||
};
|
]
|
||||||
|
: [
|
||||||
|
{ label: "新建子 Group", ic: "plus", fn: () => createDialog(target.id, target.name) },
|
||||||
|
{ label: "添加成员", ic: "user", fn: () => { selectGroup(target); addMemberDialog(); } },
|
||||||
|
{ label: "重命名 / 改描述", ic: "pencil", fn: () => renameDialog(target) },
|
||||||
|
{ sep: true },
|
||||||
|
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
|
||||||
|
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => removeGroup(target) },
|
||||||
|
];
|
||||||
|
menuEl.innerHTML = items.map((it, i) =>
|
||||||
|
it.sep
|
||||||
|
? '<div style="height:1px;background:var(--border-soft);margin:4px 6px"></div>'
|
||||||
|
: '<div data-mi="' + i + '" style="display:flex;align-items:center;gap:8px;padding:7px 11px;border-radius:6px;cursor:pointer' +
|
||||||
|
(it.danger ? ";color:var(--danger)" : "") + '">' +
|
||||||
|
'<span style="display:flex;opacity:.75">' + ico(it.ic, 14) + "</span>" + esc(it.label) + "</div>").join("");
|
||||||
|
menuEl.querySelectorAll("[data-mi]").forEach((el) => {
|
||||||
|
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
|
||||||
|
el.onmouseleave = () => { el.style.background = ""; };
|
||||||
|
el.onclick = (e) => { e.stopPropagation(); closeMenu(); items[Number(el.dataset.mi)].fn(); };
|
||||||
});
|
});
|
||||||
detailEl.querySelector("#g-add").onclick = async () => {
|
menuEl.classList.remove("hidden");
|
||||||
const v = detailEl.querySelector("#g-add-openid").value.trim();
|
// 贴边翻转,避免菜单溢出视口。
|
||||||
if (!v) return alert("请填写用户 id");
|
const r = menuEl.getBoundingClientRect();
|
||||||
|
menuEl.style.left = Math.min(x, window.innerWidth - r.width - 8) + "px";
|
||||||
|
menuEl.style.top = Math.min(y, window.innerHeight - r.height - 8) + "px";
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 弹窗:建组 / 改名 ---------------- */
|
||||||
|
|
||||||
|
function modal(html, width) {
|
||||||
|
const mask = document.createElement("div");
|
||||||
|
mask.className = "modal-mask";
|
||||||
|
mask.innerHTML = '<div class="modal-card"' +
|
||||||
|
(width ? ' style="max-width:' + width + 'px"' : "") + ">" + html + "</div>";
|
||||||
|
mask.onclick = (e) => { if (e.target === mask) mask.remove(); };
|
||||||
|
document.body.appendChild(mask);
|
||||||
|
const input = mask.querySelector("input");
|
||||||
|
if (input) input.focus();
|
||||||
|
return mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDialog(parentId, parentName) {
|
||||||
|
const m = modal(
|
||||||
|
'<div class="modal-title">' + (parentId ? "在「" + esc(parentName) + "」下新建子 Group" : "新建根 Group") + "</div>" +
|
||||||
|
'<div class="form-row"><label class="form-label">名称</label><input id="gc-name" class="input" placeholder="例如:物理教研组"/></div>' +
|
||||||
|
'<div class="form-row"><label class="form-label">描述(可选)</label><input id="gc-desc" class="input" placeholder="一句话说明"/></div>' +
|
||||||
|
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>创建</button></div>'
|
||||||
|
);
|
||||||
|
m.querySelector("[data-x]").onclick = () => m.remove();
|
||||||
|
const submit = async () => {
|
||||||
|
const name = m.querySelector("#gc-name").value.trim();
|
||||||
|
if (!name) return alert("名称必填");
|
||||||
|
const description = m.querySelector("#gc-desc").value.trim();
|
||||||
try {
|
try {
|
||||||
await req("/teams/" + encodeURIComponent(selected.id) + "/members", {
|
await req("", { method: "POST", body: { name, parentId, ...(description ? { description } : {}) } });
|
||||||
method: "POST",
|
m.remove();
|
||||||
body: v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v },
|
if (parentId) collapsed.delete(parentId); // 建完自动展开父节点
|
||||||
});
|
await loadTree();
|
||||||
loadMembers();
|
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
};
|
};
|
||||||
|
m.querySelector("[data-ok]").onclick = submit;
|
||||||
|
m.querySelector("#gc-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
|
||||||
}
|
}
|
||||||
document.getElementById("g-create").onclick = async () => {
|
|
||||||
const slug = document.getElementById("g-slug").value.trim();
|
function renameDialog(target) {
|
||||||
const name = document.getElementById("g-name").value.trim();
|
const cur = groups.find((g) => g.id === target.id);
|
||||||
const description = document.getElementById("g-desc").value.trim();
|
const m = modal(
|
||||||
if (!slug || !name) return alert("slug 和名称必填");
|
'<div class="modal-title">重命名 / 改描述</div>' +
|
||||||
|
'<div class="form-row"><label class="form-label">名称</label><input id="gr-name" class="input" value="' + esc(target.name) + '"/></div>' +
|
||||||
|
'<div class="form-row"><label class="form-label">描述</label><input id="gr-desc" class="input" value="' +
|
||||||
|
esc((cur && cur.description) || "") + '" placeholder="留空则清除描述"/></div>' +
|
||||||
|
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>保存</button></div>'
|
||||||
|
);
|
||||||
|
m.querySelector("[data-x]").onclick = () => m.remove();
|
||||||
|
const submit = async () => {
|
||||||
|
const name = m.querySelector("#gr-name").value.trim();
|
||||||
|
if (!name) return alert("名称必填");
|
||||||
|
try {
|
||||||
|
// description 总是回传(含空串)——空串即清除描述。
|
||||||
|
await req("/" + encodeURIComponent(target.id), {
|
||||||
|
method: "PATCH",
|
||||||
|
body: { name, description: m.querySelector("#gr-desc").value.trim() },
|
||||||
|
});
|
||||||
|
m.remove();
|
||||||
|
if (selected && selected.id === target.id) selected.name = name;
|
||||||
|
await loadTree();
|
||||||
|
if (selected && selected.id === target.id) loadMembers();
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
m.querySelector("[data-ok]").onclick = submit;
|
||||||
|
m.querySelector("#gr-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreGroup(target) {
|
||||||
|
// 恢复语义与删除不对称(决策7):只回该组 + 已归档祖先链,子树仍归档。
|
||||||
|
if (!confirm("恢复「" + target.name + "」?\\n\\n其已删除的上级会一并恢复(否则它在树上无路径);" +
|
||||||
|
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。")) return;
|
||||||
try {
|
try {
|
||||||
await req("/teams", { method: "POST", body: { slug, name, ...(description ? { description } : {}) } });
|
const r = await req("/" + encodeURIComponent(target.id) + "/restore", { method: "POST" });
|
||||||
document.getElementById("g-slug").value = "";
|
await loadTree();
|
||||||
document.getElementById("g-name").value = "";
|
if (selected && selected.id === target.id) loadMembers();
|
||||||
document.getElementById("g-desc").value = "";
|
alert("已恢复 " + r.restoredCount + " 个组");
|
||||||
loadList();
|
|
||||||
} catch (e) { alert(e.message); }
|
} catch (e) { alert(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeGroup(target) {
|
||||||
|
if (!confirm("删除「" + target.name + "」?\\n\\n软删除:整棵子树一并标记删除,相关授权立即失效," +
|
||||||
|
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。")) return;
|
||||||
|
try {
|
||||||
|
const r = await req("/" + encodeURIComponent(target.id), { method: "DELETE" });
|
||||||
|
if (selected && selected.id === target.id) {
|
||||||
|
selected = null;
|
||||||
|
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
|
||||||
|
}
|
||||||
|
await loadTree();
|
||||||
|
alert("已删除 " + r.archivedCount + " 个组(软删除,可恢复)");
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 成员面板 ---------------- */
|
||||||
|
|
||||||
|
let members = []; // 当前组的成员
|
||||||
|
let memberFilter = ""; // 成员表内过滤(显示名/userId/openId)
|
||||||
|
|
||||||
|
function selectGroup(target) {
|
||||||
|
selected = { id: target.id, name: target.name };
|
||||||
|
memberFilter = "";
|
||||||
|
renderTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 组头部 + 成员表(表格列:成员 / userId / 飞书 openId / 加入时间 / 操作)。 */
|
||||||
|
function renderMembers() {
|
||||||
|
if (!selected) return;
|
||||||
|
const g = groups.find((x) => x.id === selected.id);
|
||||||
|
const q = memberFilter.trim().toLowerCase();
|
||||||
|
const shown = q === ""
|
||||||
|
? members
|
||||||
|
: members.filter((m) =>
|
||||||
|
(m.displayName || "").toLowerCase().includes(q) ||
|
||||||
|
m.userId.toLowerCase().includes(q) ||
|
||||||
|
(m.feishuOpenId || "").toLowerCase().includes(q));
|
||||||
|
|
||||||
|
// 面包屑:祖先链(根在前)。
|
||||||
|
const byId = new Map(groups.map((x) => [x.id, x]));
|
||||||
|
const chain = [];
|
||||||
|
for (let cur = g; cur; cur = cur.parentId ? byId.get(cur.parentId) : null) chain.unshift(cur);
|
||||||
|
const crumb = chain.map((c, i) =>
|
||||||
|
(i > 0 ? '<span style="color:var(--text-3);margin:0 5px">/</span>' : "") +
|
||||||
|
(i === chain.length - 1
|
||||||
|
? '<span style="color:var(--text);font-weight:500">' + esc(c.name) + "</span>"
|
||||||
|
: '<span style="color:var(--text-3)">' + esc(c.name) + "</span>")).join("");
|
||||||
|
|
||||||
|
const isArchived = !!(g && g.archivedAt);
|
||||||
|
|
||||||
|
detailEl.innerHTML =
|
||||||
|
// 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。
|
||||||
|
(isArchived
|
||||||
|
? '<div style="display:flex;align-items:center;gap:9px;padding:10px 18px;flex-shrink:0;' +
|
||||||
|
'background:var(--hover);border-bottom:1px solid var(--border-soft);font-size:12.5px">' +
|
||||||
|
'<span style="display:flex;color:var(--text-3)">' + ico("archive", 15) + "</span>" +
|
||||||
|
'<span style="flex:1">此 Group 已删除于 ' +
|
||||||
|
esc(fmtDate(g.archivedAt)) + " · 成员只读,不再授予任何权限</span>" +
|
||||||
|
'<button id="gm-restore" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
|
||||||
|
ico("restore", 13) + "恢复</button>" +
|
||||||
|
"</div>"
|
||||||
|
: "") +
|
||||||
|
// 头部
|
||||||
|
'<div style="padding:16px 18px 12px;border-bottom:1px solid var(--border-soft);flex-shrink:0">' +
|
||||||
|
'<div style="font-size:12px;margin-bottom:6px">' + crumb + "</div>" +
|
||||||
|
'<div style="display:flex;align-items:center;gap:10px">' +
|
||||||
|
'<span style="display:flex;color:' + (isArchived ? "var(--text-3)" : "var(--accent)") + '">' +
|
||||||
|
ico(isArchived ? "archive" : "group", 20) + "</span>" +
|
||||||
|
'<div style="flex:1;min-width:0">' +
|
||||||
|
'<div style="font-size:16px;font-weight:600;color:' + (isArchived ? "var(--text-3)" : "var(--text)") + '">' +
|
||||||
|
esc(selected.name) + "</div>" +
|
||||||
|
(g && g.description
|
||||||
|
? '<div class="section-note" style="margin-top:2px">' + esc(g.description) + "</div>"
|
||||||
|
: '<div class="section-note" style="margin-top:2px;opacity:.6">无描述</div>') +
|
||||||
|
"</div>" +
|
||||||
|
// 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。
|
||||||
|
(isArchived
|
||||||
|
? ""
|
||||||
|
: '<button id="gm-rename" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
|
||||||
|
ico("pencil", 13) + "编辑</button>" +
|
||||||
|
'<button id="gm-add" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
|
||||||
|
ico("plus", 13) + "添加成员</button>") +
|
||||||
|
"</div>" +
|
||||||
|
// 统计条
|
||||||
|
'<div style="display:flex;gap:16px;margin-top:12px;font-size:12px;color:var(--text-3)">' +
|
||||||
|
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("user", 12) + members.length + " 名成员</span>" +
|
||||||
|
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("layers", 12) + "层级 " + ((g && g.depth) || 0) + "</span>" +
|
||||||
|
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("group", 12) +
|
||||||
|
groups.filter((x) => x.parentId === selected.id).length + " 个子组</span>" +
|
||||||
|
"</div>" +
|
||||||
|
"</div>" +
|
||||||
|
// 成员表工具条
|
||||||
|
'<div style="padding:10px 18px;flex-shrink:0;display:flex;align-items:center;gap:10px">' +
|
||||||
|
'<div style="position:relative;flex:1;max-width:280px">' +
|
||||||
|
'<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
|
||||||
|
ico("search", 13) + "</span>" +
|
||||||
|
'<input id="gm-filter" class="input" placeholder="搜索成员…" style="padding-left:28px;font-size:12.5px"' +
|
||||||
|
' value="' + esc(memberFilter) + '"/>' +
|
||||||
|
"</div>" +
|
||||||
|
'<span class="file-meta">' + (q === "" ? "" : shown.length + " / " + members.length) + "</span>" +
|
||||||
|
"</div>" +
|
||||||
|
// 成员表
|
||||||
|
'<div style="flex:1;overflow-y:auto;padding:0 18px 18px;min-height:0">' +
|
||||||
|
(members.length === 0
|
||||||
|
? '<div class="quiet" style="text-align:center;padding:36px 12px;display:flex;flex-direction:column;align-items:center;gap:10px">' +
|
||||||
|
'<span style="color:var(--border);display:flex">' + ico("users", 34) + "</span>" +
|
||||||
|
(isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」") + "</div>"
|
||||||
|
: shown.length === 0
|
||||||
|
? '<div class="quiet" style="text-align:center;padding:30px 12px">无匹配成员</div>'
|
||||||
|
: '<table class="list" style="width:100%">' +
|
||||||
|
"<thead><tr>" +
|
||||||
|
"<th>成员</th><th>userId</th><th>飞书 openId</th><th>加入时间</th>" +
|
||||||
|
(isArchived ? "" : "<th style=\\"text-align:right\\">操作</th>") +
|
||||||
|
"</tr></thead><tbody>" +
|
||||||
|
shown.map((m) =>
|
||||||
|
"<tr>" +
|
||||||
|
'<td><span style="display:inline-flex;align-items:center;gap:9px">' + avatar(m, 28) +
|
||||||
|
'<span style="font-weight:500">' + esc(m.displayName || "(未命名)") + "</span></span></td>" +
|
||||||
|
'<td class="file-meta">' + esc(m.userId) + "</td>" +
|
||||||
|
'<td class="file-meta">' + esc(m.feishuOpenId || "—") + "</td>" +
|
||||||
|
'<td class="file-meta">' + esc(fmtDate(m.joinedAt)) + "</td>" +
|
||||||
|
(isArchived
|
||||||
|
? ""
|
||||||
|
: '<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '"' +
|
||||||
|
' style="display:inline-flex;align-items:center;gap:4px">' + ico("minus", 12) + "移除</button></td>") +
|
||||||
|
"</tr>").join("") +
|
||||||
|
"</tbody></table>") +
|
||||||
|
"</div>";
|
||||||
|
|
||||||
|
// 归档态下这些按钮不渲染,故逐个判空。
|
||||||
|
const addBtn = detailEl.querySelector("#gm-add");
|
||||||
|
if (addBtn) addBtn.onclick = addMemberDialog;
|
||||||
|
const renameBtn = detailEl.querySelector("#gm-rename");
|
||||||
|
if (renameBtn) renameBtn.onclick = () => renameDialog(selected);
|
||||||
|
const restoreBtn = detailEl.querySelector("#gm-restore");
|
||||||
|
if (restoreBtn) {
|
||||||
|
restoreBtn.onclick = () => restoreGroup({ id: selected.id, name: selected.name, archived: true });
|
||||||
|
}
|
||||||
|
const f = detailEl.querySelector("#gm-filter");
|
||||||
|
if (f) {
|
||||||
|
f.oninput = () => { memberFilter = f.value; renderMembers(); detailEl.querySelector("#gm-filter").focus(); };
|
||||||
|
}
|
||||||
|
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
|
||||||
|
btn.onclick = async () => {
|
||||||
|
const uid = btn.dataset.revoke;
|
||||||
|
const who = (members.find((m) => m.userId === uid) || {}).displayName || uid;
|
||||||
|
if (!confirm("将「" + who + "」从「" + selected.name + "」移除?")) return;
|
||||||
|
try {
|
||||||
|
await req("/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(uid), { method: "DELETE" });
|
||||||
|
await Promise.all([loadTree(), loadMembers()]);
|
||||||
|
} catch (e) { alert(e.message); }
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadMembers() {
|
||||||
|
if (!selected) return;
|
||||||
|
// 归档组的成员照样拉取展示(只读)—— 软删是打标,成员行仍在库里。
|
||||||
|
detailEl.innerHTML = '<div class="quiet" style="margin:auto;padding:18px;text-align:center">加载中…</div>';
|
||||||
|
try {
|
||||||
|
const r = await req("/" + encodeURIComponent(selected.id) + "/members");
|
||||||
|
members = r.members;
|
||||||
|
renderMembers();
|
||||||
|
} catch (e) {
|
||||||
|
detailEl.innerHTML = '<div style="color:var(--danger);padding:16px">' + esc(e.message) + "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 添加成员弹窗(搜索选择) ---------------- */
|
||||||
|
|
||||||
|
function addMemberDialog() {
|
||||||
|
if (!selected) return;
|
||||||
|
const m = modal(
|
||||||
|
'<div class="modal-title" style="display:flex;align-items:center;gap:7px">' +
|
||||||
|
'<span style="display:flex;color:var(--accent)">' + ico("user", 16) + "</span>" +
|
||||||
|
"添加成员到「" + esc(selected.name) + "」</div>" +
|
||||||
|
'<div style="position:relative;margin-bottom:10px">' +
|
||||||
|
'<span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
|
||||||
|
ico("search", 14) + "</span>" +
|
||||||
|
'<input id="ga-q" class="input" placeholder="搜索用户显示名 / 飞书 openId…" style="padding-left:30px"/>' +
|
||||||
|
"</div>" +
|
||||||
|
'<div id="ga-list" style="max-height:290px;overflow-y:auto;margin:0 -4px"></div>' +
|
||||||
|
'<div class="section-note" id="ga-note" style="margin-top:10px">已在本组的用户不会出现在结果里</div>' +
|
||||||
|
'<div class="modal-actions"><button class="btn" data-x>关闭</button></div>',
|
||||||
|
520
|
||||||
|
);
|
||||||
|
m.querySelector("[data-x]").onclick = () => m.remove();
|
||||||
|
const qEl = m.querySelector("#ga-q");
|
||||||
|
const listEl = m.querySelector("#ga-list");
|
||||||
|
const noteEl = m.querySelector("#ga-note");
|
||||||
|
|
||||||
|
let seq = 0;
|
||||||
|
async function search() {
|
||||||
|
const mine = ++seq;
|
||||||
|
listEl.innerHTML = '<div class="quiet" style="padding:16px;text-align:center">搜索中…</div>';
|
||||||
|
try {
|
||||||
|
const url = "/database/api/users/search?q=" + encodeURIComponent(qEl.value.trim()) +
|
||||||
|
"&excludeGroupId=" + encodeURIComponent(selected.id);
|
||||||
|
const res = await fetch(url, { credentials: "same-origin" });
|
||||||
|
if (res.status === 401) { location.href = "/database/admin"; return; }
|
||||||
|
const data = await res.json();
|
||||||
|
if (mine !== seq) return; // 丢弃过期响应
|
||||||
|
if (!res.ok) throw new Error((data.error && data.error.message) || res.statusText);
|
||||||
|
render(data.users);
|
||||||
|
} catch (e) {
|
||||||
|
if (mine === seq) listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(users) {
|
||||||
|
if (users.length === 0) {
|
||||||
|
listEl.innerHTML = '<div class="quiet" style="padding:20px;text-align:center">无匹配用户</div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
listEl.innerHTML = users.map((u, i) =>
|
||||||
|
'<div data-ui="' + i + '" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer">' +
|
||||||
|
avatar(u, 30) +
|
||||||
|
'<span style="flex:1;min-width:0">' +
|
||||||
|
'<span style="display:block;font-size:13px;font-weight:500;color:var(--text)">' + esc(u.displayName || "(未命名)") + "</span>" +
|
||||||
|
'<span class="file-meta" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
|
||||||
|
esc(u.feishuOpenId) + "</span>" +
|
||||||
|
"</span>" +
|
||||||
|
'<span class="btn" style="flex-shrink:0;font-size:11.5px;padding:3px 9px;display:inline-flex;align-items:center;gap:4px">' +
|
||||||
|
ico("plus", 12) + "添加</span>" +
|
||||||
|
"</div>").join("");
|
||||||
|
listEl.querySelectorAll("[data-ui]").forEach((el) => {
|
||||||
|
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
|
||||||
|
el.onmouseleave = () => { el.style.background = ""; };
|
||||||
|
el.onclick = async () => {
|
||||||
|
const u = users[Number(el.dataset.ui)];
|
||||||
|
el.style.pointerEvents = "none";
|
||||||
|
el.style.opacity = ".5";
|
||||||
|
try {
|
||||||
|
await req("/" + encodeURIComponent(selected.id) + "/members", {
|
||||||
|
method: "POST", body: { userId: u.userId },
|
||||||
|
});
|
||||||
|
noteEl.textContent = "已添加 " + (u.displayName || u.userId);
|
||||||
|
noteEl.style.color = "var(--accent)";
|
||||||
|
await Promise.all([loadTree(), loadMembers()]);
|
||||||
|
await search(); // 刷新候选(已加的会被排除)
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
el.style.pointerEvents = "";
|
||||||
|
el.style.opacity = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
qEl.oninput = () => { clearTimeout(timer); timer = setTimeout(search, 200); };
|
||||||
|
search(); // 打开即列出候选
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 初始化 ---------------- */
|
||||||
|
|
||||||
|
async function loadTree() {
|
||||||
|
const r = await req(showArchived ? "?includeArchived=1" : "");
|
||||||
|
groups = r.groups;
|
||||||
|
// 选中项可能已被级联归档 → 清空右栏(showArchived 下归档组仍在列表里,不清)。
|
||||||
|
if (selected && !groups.some((g) => g.id === selected.id)) {
|
||||||
|
selected = null;
|
||||||
|
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
|
||||||
|
}
|
||||||
|
renderTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("g-new-root").onclick = () => createDialog(null, null);
|
||||||
|
const filterEl = document.getElementById("g-filter");
|
||||||
|
filterEl.oninput = () => { filterText = filterEl.value; renderTree(); };
|
||||||
|
const archEl = document.getElementById("g-show-archived");
|
||||||
|
archEl.onchange = () => {
|
||||||
|
showArchived = archEl.checked;
|
||||||
|
loadTree().catch((e) => alert(e.message));
|
||||||
};
|
};
|
||||||
loadList().catch((e) => { listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>"; });
|
loadTree().catch((e) => {
|
||||||
|
treeEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
|
||||||
|
});
|
||||||
})();
|
})();
|
||||||
</script>`;
|
</script>`;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,11 +26,12 @@ import path from "node:path";
|
|||||||
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
|
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
|
||||||
import { registerFileLibRoutes } from "./filelibRoutes.js";
|
import { registerFileLibRoutes } from "./filelibRoutes.js";
|
||||||
import { registerFileRoutes } from "./fileRoutes.js";
|
import { registerFileRoutes } from "./fileRoutes.js";
|
||||||
|
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
|
||||||
import { registerTeacherApp } from "./teacherApp.js";
|
import { registerTeacherApp } from "./teacherApp.js";
|
||||||
import { renderLibraryBrowser } from "./libraryBrowser.js";
|
import { renderLibraryBrowser } from "./libraryBrowser.js";
|
||||||
import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js";
|
import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js";
|
||||||
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
||||||
import { createTeamGroupResolver } from "../filelib/groupResolver.js";
|
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
|
||||||
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
||||||
import { createManifestStubAdapter } from "../filelib/exportService.js";
|
import { createManifestStubAdapter } from "../filelib/exportService.js";
|
||||||
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
||||||
@@ -122,7 +123,8 @@ export async function registerDatabaseRoutes(
|
|||||||
|
|
||||||
// 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。
|
// 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。
|
||||||
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
|
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
|
||||||
// GroupResolver 默认读 hub Team,HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
|
||||||
|
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
||||||
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
||||||
const siloOrg = await config.prisma.organization.findUnique({
|
const siloOrg = await config.prisma.organization.findUnique({
|
||||||
where: { slug: config.siloOrganizationSlug },
|
where: { slug: config.siloOrganizationSlug },
|
||||||
@@ -141,13 +143,14 @@ export async function registerDatabaseRoutes(
|
|||||||
organizationId: siloOrg.id,
|
organizationId: siloOrg.id,
|
||||||
storageRoot,
|
storageRoot,
|
||||||
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
|
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
|
||||||
? createTeamGroupResolver(config.prisma, siloOrg.id)
|
? createMemberGroupResolver(config.prisma)
|
||||||
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
||||||
versionStore,
|
versionStore,
|
||||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||||
};
|
};
|
||||||
await registerFileLibRoutes(app, filelibDeps);
|
await registerFileLibRoutes(app, filelibDeps);
|
||||||
await registerFileRoutes(app, filelibDeps);
|
await registerFileRoutes(app, filelibDeps);
|
||||||
|
await registerMemberGroupRoutes(app, filelibDeps);
|
||||||
await registerTeacherApp(app, {
|
await registerTeacherApp(app, {
|
||||||
prisma: config.prisma,
|
prisma: config.prisma,
|
||||||
sessionSecret: config.sessionSecret,
|
sessionSecret: config.sessionSecret,
|
||||||
@@ -339,7 +342,6 @@ ${pageHead("Database Admin")}
|
|||||||
<div style="margin:10px;padding:10px 12px;border-top:1px solid var(--border-soft);display:flex;align-items:center;gap:9px">
|
<div style="margin:10px;padding:10px 12px;border-top:1px solid var(--border-soft);display:flex;align-items:center;gap:9px">
|
||||||
<div style="width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">${initial}</div>
|
<div style="width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">${initial}</div>
|
||||||
<div style="min-width:0;flex:1">
|
<div style="min-width:0;flex:1">
|
||||||
<p style="font-size:10.5px;color:var(--text-3)">已登录</p>
|
|
||||||
<p style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--text)">${escapeHtml(displayName)}</p>
|
<p style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--text)">${escapeHtml(displayName)}</p>
|
||||||
</div>
|
</div>
|
||||||
<button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button>
|
<button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button>
|
||||||
@@ -368,9 +370,8 @@ ${pageHead("Database Admin")}
|
|||||||
${renderUsersPanel(orgSlug)}
|
${renderUsersPanel(orgSlug)}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
|
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow:hidden;padding:20px">
|
||||||
<h1 style="font-size:18px;font-weight:600;margin-bottom:16px">Group 管理</h1>
|
${renderGroupsPanel()}
|
||||||
${renderGroupsPanel(orgSlug)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
|
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
|
||||||
|
|||||||
@@ -261,29 +261,8 @@ export async function registerFileLibRoutes(
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
/* ------------------------------------------------------------ Group 搜索(过渡) */
|
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
|
||||||
|
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
|
||||||
// 过渡实现:读 hub Team(C2 /groups/search 由 Group 团队交付后切换)。
|
|
||||||
app.get("/database/api/groups/search", async (request, reply) => {
|
|
||||||
const actor = await actorOrNull(request, reply, deps);
|
|
||||||
if (actor === null) return reply;
|
|
||||||
try {
|
|
||||||
const q = ((request.query as { q?: string }).q ?? "").trim();
|
|
||||||
const teams = await deps.prisma.team.findMany({
|
|
||||||
where: {
|
|
||||||
organizationId: deps.organizationId,
|
|
||||||
archivedAt: null,
|
|
||||||
...(q === "" ? {} : { name: { contains: q, mode: "insensitive" as const } }),
|
|
||||||
},
|
|
||||||
take: 20,
|
|
||||||
orderBy: { name: "asc" },
|
|
||||||
select: { id: true, name: true },
|
|
||||||
});
|
|
||||||
return { groups: teams.map((t) => ({ id: t.id, name: t.name, breadcrumb: t.name })) };
|
|
||||||
} catch (error) {
|
|
||||||
return sendRouteError(reply, error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseGrants(raw: unknown): InitialGrant[] | undefined {
|
function parseGrants(raw: unknown): InitialGrant[] | undefined {
|
||||||
|
|||||||
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* /database/api/groups/* 成员组管理端点(ADR-0028)。
|
||||||
|
* 约定:绝对路径;actorOrNull 前置 fail closed;业务全走 memberGroupService;
|
||||||
|
* 错误统一 sendRouteError。
|
||||||
|
*
|
||||||
|
* 管理端点(CRUD + 成员)由 service 层门禁到网站管理员;搜索端点不限管理员
|
||||||
|
* (授权选择器是 Manage 持有者的能力,决策2)。
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
import {
|
||||||
|
addMember,
|
||||||
|
createMemberGroup,
|
||||||
|
deleteMemberGroup,
|
||||||
|
listMemberGroups,
|
||||||
|
listMembers,
|
||||||
|
removeMember,
|
||||||
|
restoreMemberGroup,
|
||||||
|
searchMemberGroups,
|
||||||
|
searchUsers,
|
||||||
|
updateMemberGroup,
|
||||||
|
} from "../filelib/memberGroupService.js";
|
||||||
|
import { FileLibError } from "../filelib/model.js";
|
||||||
|
import {
|
||||||
|
actorOrNull,
|
||||||
|
bodyObject,
|
||||||
|
optionalString,
|
||||||
|
requireString,
|
||||||
|
sendRouteError,
|
||||||
|
type FileLibRouteDeps,
|
||||||
|
} from "../filelib/routeShared.js";
|
||||||
|
|
||||||
|
export async function registerMemberGroupRoutes(
|
||||||
|
app: FastifyInstance,
|
||||||
|
deps: FileLibRouteDeps,
|
||||||
|
): Promise<void> {
|
||||||
|
const svc = { prisma: deps.prisma, organizationId: deps.organizationId };
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 搜索(授权选择器) */
|
||||||
|
|
||||||
|
// 契约 C2 /groups/search:活跃组 + breadcrumb。**非管理员可调**(决策2)。
|
||||||
|
// 注:必须先于 "/database/api/groups" 之类的段前缀之外单独成路径,Fastify
|
||||||
|
// 静态路由不会 shadow,顺序无关;此处与其它端点平级注册。
|
||||||
|
app.get("/database/api/groups/search", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const q = (request.query as { q?: string }).q ?? "";
|
||||||
|
return { groups: await searchMemberGroups(svc, q) };
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 成员选择器:搜全局用户。仅管理员(service 层门禁)。
|
||||||
|
// excludeGroupId 过滤掉该组已有活跃成员。
|
||||||
|
app.get("/database/api/users/search", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const query = request.query as { q?: string; excludeGroupId?: string };
|
||||||
|
return { users: await searchUsers(svc, actor, query.q ?? "", query.excludeGroupId) };
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 组 CRUD */
|
||||||
|
|
||||||
|
// includeArchived=1 时连已归档组一并返回(带 archivedAt),供后台展示/恢复。
|
||||||
|
app.get("/database/api/groups", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const raw = (request.query as { includeArchived?: string }).includeArchived;
|
||||||
|
const includeArchived = raw === "1" || raw === "true";
|
||||||
|
return { groups: await listMemberGroups(svc, actor, includeArchived) };
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/database/api/groups", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const body = bodyObject(request.body);
|
||||||
|
const parentIdRaw = body["parentId"];
|
||||||
|
if (parentIdRaw !== undefined && parentIdRaw !== null && typeof parentIdRaw !== "string") {
|
||||||
|
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
|
||||||
|
}
|
||||||
|
const group = await createMemberGroup(svc, actor, {
|
||||||
|
name: requireString(body, "name"),
|
||||||
|
description: optionalString(body, "description"),
|
||||||
|
parentId: parentIdRaw === undefined ? null : parentIdRaw,
|
||||||
|
});
|
||||||
|
return reply.status(201).send({ group });
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 改名 / 改描述(决策6)。不接受 parentId —— reparent 仍不在 v1(决策5)。
|
||||||
|
app.patch("/database/api/groups/:id", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = bodyObject(request.body);
|
||||||
|
if (body["parentId"] !== undefined) {
|
||||||
|
throw new FileLibError(400, "invalid_request", "reparent is not supported (ADR-0028)");
|
||||||
|
}
|
||||||
|
// description 需区分"未传"(不动)与 ""(清空),故不用 optionalString
|
||||||
|
// (它把 "" 也归为 undefined)。
|
||||||
|
const descRaw = body["description"];
|
||||||
|
if (descRaw !== undefined && descRaw !== null && typeof descRaw !== "string") {
|
||||||
|
throw new FileLibError(400, "invalid_request", "description must be a string");
|
||||||
|
}
|
||||||
|
const group = await updateMemberGroup(svc, actor, id, {
|
||||||
|
name: optionalString(body, "name"),
|
||||||
|
description: descRaw === undefined || descRaw === null ? undefined : descRaw,
|
||||||
|
});
|
||||||
|
return { group };
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/database/api/groups/:id", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const result = await deleteMemberGroup(svc, actor, id);
|
||||||
|
return { archivedCount: result.archivedCount };
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 恢复(取消归档)。与删除不对称:只恢复该组 + 已归档祖先链,不动子树(决策7)。
|
||||||
|
app.post("/database/api/groups/:id/restore", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const result = await restoreMemberGroup(svc, actor, id);
|
||||||
|
return { restoredCount: result.restoredCount };
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------ 成员 */
|
||||||
|
|
||||||
|
app.get("/database/api/groups/:id/members", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
return { members: await listMembers(svc, actor, id) };
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post("/database/api/groups/:id/members", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const { id } = request.params as { id: string };
|
||||||
|
const body = bodyObject(request.body);
|
||||||
|
const userId = optionalString(body, "userId");
|
||||||
|
const feishuOpenId = optionalString(body, "feishuOpenId");
|
||||||
|
if (userId === undefined && feishuOpenId === undefined) {
|
||||||
|
throw new FileLibError(400, "invalid_request", "userId or feishuOpenId is required");
|
||||||
|
}
|
||||||
|
const member = await addMember(svc, actor, id, { userId, feishuOpenId });
|
||||||
|
return reply.status(201).send({ member });
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.delete("/database/api/groups/:id/members/:userId", async (request, reply) => {
|
||||||
|
const actor = await actorOrNull(request, reply, deps);
|
||||||
|
if (actor === null) return reply;
|
||||||
|
try {
|
||||||
|
const { id, userId } = request.params as { id: string; userId: string };
|
||||||
|
await removeMember(svc, actor, id, userId);
|
||||||
|
return reply.status(204).send();
|
||||||
|
} catch (error) {
|
||||||
|
return sendRouteError(reply, error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -135,4 +135,21 @@ export const UI_THEME_CSS = `
|
|||||||
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
|
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
|
||||||
.link-danger { color: var(--danger); font-size: 12.5px; background: none; border: none; cursor: pointer; padding: 0; }
|
.link-danger { color: var(--danger); font-size: 12.5px; background: none; border: none; cursor: pointer; padding: 0; }
|
||||||
.link-danger:hover { text-decoration: underline; }
|
.link-danger:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
/* 开关(toggle)。用法:<label class="switch"><input type="checkbox"><span></span>文字</label>
|
||||||
|
真正的 checkbox 藏在下面 —— 保留键盘可达与 :checked 语义,不做 div 假开关。 */
|
||||||
|
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
|
||||||
|
.switch > input { position: absolute; opacity: 0; width: 0; height: 0; }
|
||||||
|
.switch > span {
|
||||||
|
position: relative; flex-shrink: 0; width: 30px; height: 17px; border-radius: 999px;
|
||||||
|
background: var(--border); transition: background .16s;
|
||||||
|
}
|
||||||
|
.switch > span::after {
|
||||||
|
content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px;
|
||||||
|
border-radius: 50%; background: #fff; transition: transform .16s;
|
||||||
|
box-shadow: 0 1px 2px rgba(0,0,0,.25);
|
||||||
|
}
|
||||||
|
.switch > input:checked + span { background: var(--accent); }
|
||||||
|
.switch > input:checked + span::after { transform: translateX(13px); }
|
||||||
|
.switch > input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||||
`;
|
`;
|
||||||
|
|||||||
@@ -0,0 +1,311 @@
|
|||||||
|
/**
|
||||||
|
* 成员组(MemberGroup)集成测试(真实 Postgres)。ADR-0028。
|
||||||
|
* 覆盖:嵌套创建 + 闭包维护、解析(直接组 ∪ 活跃祖先)、祖先授权递归传递
|
||||||
|
* (3.2)、级联软删 + 实时失效、非管理员 403、成员增删幂等/重加、搜索 breadcrumb。
|
||||||
|
* 运行前提:本地 PG(paradigm:paradigm@127.0.0.1:5432/cph_hub_test)且已 migrate。
|
||||||
|
*/
|
||||||
|
import { beforeEach, describe, expect, it } from "vitest";
|
||||||
|
import { prisma, resetDb, DEFAULT_ORG_ID } from "./helpers.js";
|
||||||
|
import {
|
||||||
|
addMember,
|
||||||
|
createMemberGroup,
|
||||||
|
deleteMemberGroup,
|
||||||
|
listMemberGroups,
|
||||||
|
listMembers,
|
||||||
|
removeMember,
|
||||||
|
searchMemberGroups,
|
||||||
|
searchUsers,
|
||||||
|
updateMemberGroup,
|
||||||
|
type MemberGroupServiceDeps,
|
||||||
|
} from "../../src/database/filelib/memberGroupService.js";
|
||||||
|
import { createMemberGroupResolver } from "../../src/database/filelib/memberGroupResolver.js";
|
||||||
|
import {
|
||||||
|
createNode,
|
||||||
|
getEffectiveRole,
|
||||||
|
type FileLibActor,
|
||||||
|
type TreeServiceDeps,
|
||||||
|
} from "../../src/database/filelib/treeService.js";
|
||||||
|
import { createInMemoryVersionStore } from "../../src/database/filelib/versionStore.js";
|
||||||
|
|
||||||
|
const ADMIN: FileLibActor = { userId: "u_admin", isWebsiteAdmin: true };
|
||||||
|
const ALICE: FileLibActor = { userId: "u_alice", isWebsiteAdmin: false };
|
||||||
|
|
||||||
|
function svc(): MemberGroupServiceDeps {
|
||||||
|
return { prisma, organizationId: DEFAULT_ORG_ID };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** treeService deps 用真实 MemberGroup 解析器,串起「组授权 → 成员生效」全链路。 */
|
||||||
|
function treeDeps(): TreeServiceDeps {
|
||||||
|
return {
|
||||||
|
prisma,
|
||||||
|
groupResolver: createMemberGroupResolver(prisma),
|
||||||
|
versionStore: createInMemoryVersionStore(),
|
||||||
|
organizationId: DEFAULT_ORG_ID,
|
||||||
|
storageRoot: "/tmp/member-groups-test",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seedUsers(): Promise<void> {
|
||||||
|
for (const [id, openId] of [["u_admin", "ou_admin"], ["u_alice", "ou_alice"], ["u_bob", "ou_bob"]] as const) {
|
||||||
|
await prisma.user.create({ data: { id, feishuOpenId: openId, displayName: id } });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 建嵌套链 A>B>C,返回三者 id。 */
|
||||||
|
async function seedChain(): Promise<{ a: string; b: string; c: string }> {
|
||||||
|
const a = await createMemberGroup(svc(), ADMIN, { name: "A" });
|
||||||
|
const b = await createMemberGroup(svc(), ADMIN, { name: "B", parentId: a.id });
|
||||||
|
const c = await createMemberGroup(svc(), ADMIN, { name: "C", parentId: b.id });
|
||||||
|
return { a: a.id, b: b.id, c: c.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await resetDb();
|
||||||
|
await seedUsers();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 创建与闭包", () => {
|
||||||
|
it("建根 depth0;建子继承祖先闭包,depth 递增", async () => {
|
||||||
|
const { a, b, c } = await seedChain();
|
||||||
|
|
||||||
|
// 闭包不变量:C 有 (A,C,2)/(B,C,1)/(C,C,0) 三行。
|
||||||
|
const closureC = await prisma.memberGroupClosure.findMany({
|
||||||
|
where: { descendantId: c },
|
||||||
|
orderBy: { depth: "asc" },
|
||||||
|
});
|
||||||
|
expect(closureC.map((r) => [r.ancestorId, r.depth])).toEqual([
|
||||||
|
[c, 0], [b, 1], [a, 2],
|
||||||
|
]);
|
||||||
|
|
||||||
|
const groups = await listMemberGroups(svc(), ADMIN);
|
||||||
|
const byId = new Map(groups.map((g) => [g.id, g]));
|
||||||
|
expect(byId.get(a)?.depth).toBe(0);
|
||||||
|
expect(byId.get(b)?.depth).toBe(1);
|
||||||
|
expect(byId.get(c)?.depth).toBe(2);
|
||||||
|
expect(byId.get(b)?.parentId).toBe(a);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("父组不存在/已归档 → 404", async () => {
|
||||||
|
await expect(createMemberGroup(svc(), ADMIN, { name: "X", parentId: "nope" }))
|
||||||
|
.rejects.toMatchObject({ statusCode: 404 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupResolver · 解析(直接组 ∪ 活跃祖先)", () => {
|
||||||
|
it("成员在 C → 解析得 {C,B,A};无所属 → 空", async () => {
|
||||||
|
const { a, b, c } = await seedChain();
|
||||||
|
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
|
||||||
|
const resolver = createMemberGroupResolver(prisma);
|
||||||
|
|
||||||
|
const ids = await resolver.resolveMemberGroupIds("u_alice");
|
||||||
|
expect([...ids].sort()).toEqual([a, b, c].sort());
|
||||||
|
expect(await resolver.resolveMemberGroupIds("u_bob")).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 3.2 祖先授权递归传递", () => {
|
||||||
|
it("给祖先组 A 授文件夹权限 → C 的成员经 effectiveRole 拿到该权限", async () => {
|
||||||
|
const { a, c } = await seedChain();
|
||||||
|
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
|
||||||
|
|
||||||
|
// ADMIN 建根文件夹,授权给"祖先组 A"。ALICE 只在 C,靠祖先方向解析命中 A。
|
||||||
|
const folder = await createNode(treeDeps(), ADMIN, {
|
||||||
|
parentId: null, kind: "FOLDER", name: "共享",
|
||||||
|
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
|
||||||
|
});
|
||||||
|
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 改名/改描述(决策6)", () => {
|
||||||
|
it("改名不动闭包:depth/parentId/子树关系全保持", async () => {
|
||||||
|
const { a, b, c } = await seedChain();
|
||||||
|
const before = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
|
||||||
|
|
||||||
|
const updated = await updateMemberGroup(svc(), ADMIN, b, { name: "B2", description: "改后" });
|
||||||
|
expect(updated.name).toBe("B2");
|
||||||
|
expect(updated.description).toBe("改后");
|
||||||
|
expect(updated.parentId).toBe(a);
|
||||||
|
expect(updated.depth).toBe(1);
|
||||||
|
|
||||||
|
// 闭包逐行未变 —— rename 不碰层级(ADR-0028 决策6 的核心不变量)。
|
||||||
|
const after = await prisma.memberGroupClosure.findMany({ orderBy: [{ ancestorId: "asc" }, { descendantId: "asc" }] });
|
||||||
|
expect(after).toEqual(before);
|
||||||
|
// C 仍在 B 之下,depth 不变。
|
||||||
|
const byId = new Map((await listMemberGroups(svc(), ADMIN)).map((g) => [g.id, g]));
|
||||||
|
expect(byId.get(c)?.depth).toBe(2);
|
||||||
|
expect(byId.get(c)?.parentId).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空描述清空;字段缺省则不动;两者皆缺 → 400", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G", description: "原描述" });
|
||||||
|
expect((await updateMemberGroup(svc(), ADMIN, g.id, { description: "" })).description).toBeNull();
|
||||||
|
|
||||||
|
// 只传 name → 描述保持(此时已是 null)。
|
||||||
|
const renamed = await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
|
||||||
|
expect(renamed.name).toBe("G2");
|
||||||
|
expect(renamed.description).toBeNull();
|
||||||
|
|
||||||
|
await expect(updateMemberGroup(svc(), ADMIN, g.id, {})).rejects.toMatchObject({ statusCode: 400 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("空名 → 400;已归档组 → 404;非管理员 → 403", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: " " })).rejects.toMatchObject({ statusCode: 400 });
|
||||||
|
await expect(updateMemberGroup(svc(), ALICE, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
|
||||||
|
await deleteMemberGroup(svc(), ADMIN, g.id);
|
||||||
|
await expect(updateMemberGroup(svc(), ADMIN, g.id, { name: "X" })).rejects.toMatchObject({ statusCode: 404 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("改名写 group.update 审计", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
await updateMemberGroup(svc(), ADMIN, g.id, { name: "G2" });
|
||||||
|
const actions = (await prisma.auditEntry.findMany({
|
||||||
|
where: { organizationId: DEFAULT_ORG_ID },
|
||||||
|
select: { action: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
})).map((e) => e.action);
|
||||||
|
expect(actions).toEqual(["group.create", "group.update"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 级联软删 + 实时失效", () => {
|
||||||
|
it("软删 B → B、C 从 list/解析消失,经此支的权限立即失效", async () => {
|
||||||
|
const { a, b, c } = await seedChain();
|
||||||
|
await addMember(svc(), ADMIN, c, { userId: "u_alice" });
|
||||||
|
const folder = await createNode(treeDeps(), ADMIN, {
|
||||||
|
parentId: null, kind: "FOLDER", name: "共享",
|
||||||
|
grants: [{ principalType: "GROUP", principalId: a, role: "EDIT" }],
|
||||||
|
});
|
||||||
|
expect(await getEffectiveRole(treeDeps(), ALICE, folder.id)).toBe("EDIT");
|
||||||
|
|
||||||
|
const { archivedCount } = await deleteMemberGroup(svc(), ADMIN, b);
|
||||||
|
expect(archivedCount).toBe(2); // B + C
|
||||||
|
|
||||||
|
const remaining = (await listMemberGroups(svc(), ADMIN)).map((g) => g.id);
|
||||||
|
expect(remaining).toEqual([a]);
|
||||||
|
|
||||||
|
// C 已归档 → ALICE 的直接组失效 → 解析空 → 对该文件夹不再可见(D8 → 404)。
|
||||||
|
expect(await createMemberGroupResolver(prisma).resolveMemberGroupIds("u_alice")).toEqual([]);
|
||||||
|
await expect(getEffectiveRole(treeDeps(), ALICE, folder.id)).rejects.toMatchObject({ statusCode: 404 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 成员增删", () => {
|
||||||
|
it("重复添加 → 409;移除后可重新添加", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||||
|
await expect(addMember(svc(), ADMIN, g.id, { userId: "u_alice" }))
|
||||||
|
.rejects.toMatchObject({ statusCode: 409 });
|
||||||
|
|
||||||
|
await removeMember(svc(), ADMIN, g.id, "u_alice");
|
||||||
|
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(0);
|
||||||
|
|
||||||
|
// 重加(revokedAt 软删允许 @@unique([groupId,userId,revokedAt]) 下的新活跃行)。
|
||||||
|
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||||
|
expect(await listMembers(svc(), ADMIN, g.id)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("按飞书 openId 解析成员", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
const m = await addMember(svc(), ADMIN, g.id, { feishuOpenId: "ou_bob" });
|
||||||
|
expect(m.userId).toBe("u_bob");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("移除不存在成员 → 404", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
await expect(removeMember(svc(), ADMIN, g.id, "u_alice"))
|
||||||
|
.rejects.toMatchObject({ statusCode: 404 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 管理门禁(决策2)", () => {
|
||||||
|
it("非管理员调 CRUD/成员 → 403;搜索不限管理员", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
await expect(createMemberGroup(svc(), ALICE, { name: "X" })).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
await expect(deleteMemberGroup(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
await expect(listMemberGroups(svc(), ALICE)).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
await expect(listMembers(svc(), ALICE, g.id)).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
await expect(addMember(svc(), ALICE, g.id, { userId: "u_bob" })).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
await expect(removeMember(svc(), ALICE, g.id, "u_bob")).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
|
||||||
|
// 搜索:非管理员可调(授权选择器)。
|
||||||
|
const results = await searchMemberGroups(svc(), "G");
|
||||||
|
expect(results.map((r) => r.id)).toContain(g.id);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 成员选择器 searchUsers", () => {
|
||||||
|
it("按显示名/openId 搜;excludeGroupId 排除已在组成员;仅管理员", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
|
||||||
|
// 空 q 列出全部(3 个 seed 用户)。
|
||||||
|
expect((await searchUsers(svc(), ADMIN, "")).length).toBe(3);
|
||||||
|
// 按 displayName 命中(seed 的 displayName 即 id)。
|
||||||
|
expect((await searchUsers(svc(), ADMIN, "alice")).map((u) => u.userId)).toEqual(["u_alice"]);
|
||||||
|
// 按 openId 命中。
|
||||||
|
expect((await searchUsers(svc(), ADMIN, "ou_bob")).map((u) => u.userId)).toEqual(["u_bob"]);
|
||||||
|
|
||||||
|
// 已在组的人被排除 —— 避免选中必然 409 的候选。
|
||||||
|
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||||
|
const ids = (await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId);
|
||||||
|
expect(ids).not.toContain("u_alice");
|
||||||
|
expect(ids).toContain("u_bob");
|
||||||
|
|
||||||
|
// 移除后重新成为候选(revokedAt 软删)。
|
||||||
|
await removeMember(svc(), ADMIN, g.id, "u_alice");
|
||||||
|
expect((await searchUsers(svc(), ADMIN, "", g.id)).map((u) => u.userId)).toContain("u_alice");
|
||||||
|
|
||||||
|
await expect(searchUsers(svc(), ALICE, "")).rejects.toMatchObject({ statusCode: 403 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 成员表字段", () => {
|
||||||
|
it("listMembers 返回 openId/avatar/joinedAt(供成员表列展示)", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||||
|
const [m] = await listMembers(svc(), ADMIN, g.id);
|
||||||
|
expect(m).toMatchObject({
|
||||||
|
userId: "u_alice",
|
||||||
|
displayName: "u_alice",
|
||||||
|
feishuOpenId: "ou_alice",
|
||||||
|
avatarUrl: null,
|
||||||
|
});
|
||||||
|
expect(m?.joinedAt).toBeInstanceOf(Date);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 搜索 breadcrumb", () => {
|
||||||
|
it("breadcrumb 由活跃祖先链按 depth 拼(根在前)", async () => {
|
||||||
|
const { c } = await seedChain();
|
||||||
|
const results = await searchMemberGroups(svc(), "C");
|
||||||
|
const hit = results.find((r) => r.id === c);
|
||||||
|
expect(hit?.breadcrumb).toBe("A / B / C");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("归档组不出现在搜索(G3)", async () => {
|
||||||
|
const { a, b } = await seedChain();
|
||||||
|
await deleteMemberGroup(svc(), ADMIN, b); // 归档 B、C
|
||||||
|
const ids = (await searchMemberGroups(svc(), "")).map((r) => r.id);
|
||||||
|
expect(ids).toContain(a);
|
||||||
|
expect(ids).not.toContain(b);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("memberGroupService · 审计(C3/决策4)", () => {
|
||||||
|
it("建组/加成员/删组写 AuditEntry(挂 silo org)", async () => {
|
||||||
|
const g = await createMemberGroup(svc(), ADMIN, { name: "G" });
|
||||||
|
await addMember(svc(), ADMIN, g.id, { userId: "u_alice" });
|
||||||
|
await removeMember(svc(), ADMIN, g.id, "u_alice");
|
||||||
|
await deleteMemberGroup(svc(), ADMIN, g.id);
|
||||||
|
const actions = (await prisma.auditEntry.findMany({
|
||||||
|
where: { organizationId: DEFAULT_ORG_ID },
|
||||||
|
select: { action: true },
|
||||||
|
orderBy: { createdAt: "asc" },
|
||||||
|
})).map((e) => e.action);
|
||||||
|
expect(actions).toEqual([
|
||||||
|
"group.create", "group.member_add", "group.member_remove", "group.delete",
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user