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

This commit is contained in:
2026-07-26 18:06:18 +08:00
parent 2f2ece1a3a
commit 11a7ec8004
17 changed files with 2289 additions and 124 deletions
+604 -84
View File
@@ -1,9 +1,11 @@
/**
* 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)
* 管理员后台「用户管理」「Group 管理」面板。
*
* 用户管理 = org 成员(/api/org/:orgSlug/members);
* Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams),
* 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。
* 用户管理 = org 成员(/api/org/:orgSlug/members)
* Group 管理 = **MemberGroup**(全局可无限嵌套,ADR-0028),走 /database/api/groups/*。
* 已从旧的扁平 hub Team 迁过来 —— Team 无父子字段,建不出子组。此面板与
* filelib-web 的 GroupAdmin.svelte 消费同一套 API,语义一致。
* 交互:嵌套树(展开/折叠)+ 右键菜单(建子组/建根组/重命名/删除)。
*/
function apiBase(orgSlug: string): string {
@@ -105,35 +107,110 @@ export function renderUsersPanel(orgSlug: string): string {
/* ---------------------------------------------------------------- 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 `
<div id="groups-root" style="display:flex;gap:14px;align-items:flex-start">
<div style="width:340px;flex-shrink:0">
<div class="panel" style="margin-bottom:14px">
<div class="section-title">新建 Group</div>
<div class="form-row"><label class="form-label">标识(slug)</label><input id="g-slug" class="input" placeholder="physics-dept"/></div>
<div class="form-row"><label class="form-label">名称</label><input id="g-name" class="input" placeholder="物理教研组"/></div>
<div class="form-row"><label class="form-label">描述(可选)</label><input id="g-desc" class="input" placeholder="一句话说明"/></div>
<div style="text-align:right"><button id="g-create" class="btn btn-primary">创建</button></div>
<div id="groups-root" style="display:flex;gap:14px;align-items:stretch;height:100%;min-height:0">
<!-- 左:组树 -->
<div class="panel" style="width:326px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;padding:14px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<span style="display:flex;color:var(--accent)">${icon("layers", 17)}</span>
<div class="section-title" style="margin:0;flex:1">Group 树</div>
<button id="g-new-root" class="btn" style="font-size:11.5px;padding:4px 9px;display:inline-flex;align-items:center;gap:4px">
${icon("plus", 13)} 根组
</button>
</div>
<div class="panel">
<div class="section-title">Group 列表</div>
<div id="g-list"></div>
<div style="position:relative;margin-bottom:8px">
<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">${icon("search", 13)}</span>
<input id="g-filter" class="input" placeholder="过滤组名…" style="padding-left:28px;font-size:12.5px"/>
</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 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 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>
(function () {
const BASE = "${apiBase(orgSlug)}";
const listEl = document.getElementById("g-list");
const treeEl = document.getElementById("g-tree");
const detailEl = document.getElementById("g-detail");
const menuEl = document.getElementById("g-menu");
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
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 = {}) {
const res = await fetch(BASE + path, {
const res = await fetch("/database/api/groups" + path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
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);
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) =>
'<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">' +
'<span style="flex:1"><b>' + esc(t.name) + '</b> <span class="file-meta">' + esc(t.slug) + "</span></span>" +
'<button class="link-danger" data-archive="' + esc(t.id) + '" style="font-size:11px">归档</button>' +
"</div>").join("");
listEl.querySelectorAll(".g-team").forEach((el) => {
/* ---------------- 树渲染 ---------------- */
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
function orderedVisible() {
const byParent = new Map();
const byId = new Map();
for (const g of groups) {
byId.set(g.id, g);
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) => {
if (e.target.closest("button")) return;
selected = { id: el.dataset.id, name: el.dataset.name };
listEl.querySelectorAll(".g-team").forEach((x) => x.style.background = "");
el.style.background = "var(--selected)";
if (e.target.closest("[data-caret]")) return; // 折叠三角不触发选中
selectGroup({ id: el.dataset.id, name: el.dataset.name });
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) => {
btn.onclick = async () => {
if (!confirm("归档该 Group?其成员授权将失效。")) return;
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(); }
catch (e) { alert(e.message); }
treeEl.querySelectorAll("[data-caret]").forEach((el) => {
el.onclick = (e) => {
e.stopPropagation();
const id = el.dataset.caret;
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");
detailEl.innerHTML =
'<div class="section-title">' + esc(selected.name) + " · 成员(" + members.length + ")</div>" +
'<table class="list"><tbody>' +
(members.length === 0
? '<tr><td class="quiet" style="text-align:center;padding:14px">暂无成员</td></tr>'
: members.map((m) =>
"<tr><td>" + esc(m.displayName || m.userId) + '</td><td class="file-meta">' + esc(m.userId) + "</td>" +
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td></tr>').join("")) +
"</tbody></table>" +
'<div class="divider"></div>' +
'<div class="section-title">添加成员</div>' +
'<div class="inline-form">' +
'<input id="g-add-openid" class="input" placeholder="用户 openId 或 userId"/>' +
'<button id="g-add" class="btn btn-primary">添加</button>' +
"</div>";
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
btn.onclick = async () => {
try { await req("/teams/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); loadMembers(); }
catch (e) { alert(e.message); }
};
/* ---------------- 右键菜单 ---------------- */
function closeMenu() { menuEl.classList.add("hidden"); }
document.addEventListener("click", closeMenu);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
// 树空白处右键 = 建根组
treeEl.oncontextmenu = (e) => {
if (e.target.closest(".g-node")) return;
e.preventDefault();
openMenu(e.clientX, e.clientY, null);
};
function openMenu(x, y, target) {
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items = target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => createDialog(null, null) }]
: target.archived
? [
{ label: "查看成员(只读)", ic: "users", fn: () => { selectGroup(target); loadMembers(); } },
{ label: "恢复此 Group", ic: "restore", fn: () => restoreGroup(target) },
{ sep: true },
{ 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 () => {
const v = detailEl.querySelector("#g-add-openid").value.trim();
if (!v) return alert("请填写用户 id");
menuEl.classList.remove("hidden");
// 贴边翻转,避免菜单溢出视口。
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 {
await req("/teams/" + encodeURIComponent(selected.id) + "/members", {
method: "POST",
body: v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v },
});
loadMembers();
await req("", { method: "POST", body: { name, parentId, ...(description ? { description } : {}) } });
m.remove();
if (parentId) collapsed.delete(parentId); // 建完自动展开父节点
await loadTree();
} 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();
const name = document.getElementById("g-name").value.trim();
const description = document.getElementById("g-desc").value.trim();
if (!slug || !name) return alert("slug 和名称必填");
function renameDialog(target) {
const cur = groups.find((g) => g.id === target.id);
const m = modal(
'<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 {
await req("/teams", { method: "POST", body: { slug, name, ...(description ? { description } : {}) } });
document.getElementById("g-slug").value = "";
document.getElementById("g-name").value = "";
document.getElementById("g-desc").value = "";
loadList();
const r = await req("/" + encodeURIComponent(target.id) + "/restore", { method: "POST" });
await loadTree();
if (selected && selected.id === target.id) loadMembers();
alert("已恢复 " + r.restoredCount + " 个组");
} 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>`;
}
+8 -7
View File
@@ -26,11 +26,12 @@ import path from "node:path";
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
import { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js";
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
import { registerTeacherApp } from "./teacherApp.js";
import { renderLibraryBrowser } from "./libraryBrowser.js";
import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.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 { createManifestStubAdapter } from "../filelib/exportService.js";
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
@@ -122,7 +123,8 @@ export async function registerDatabaseRoutes(
// 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。
// 依赖装配: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,真导出工具到位后替换)。
const siloOrg = await config.prisma.organization.findUnique({
where: { slug: config.siloOrganizationSlug },
@@ -141,13 +143,14 @@ export async function registerDatabaseRoutes(
organizationId: siloOrg.id,
storageRoot,
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
? createTeamGroupResolver(config.prisma, siloOrg.id)
? createMemberGroupResolver(config.prisma)
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
versionStore,
exportAdapters: [createManifestStubAdapter(versionStore)],
};
await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps);
await registerMemberGroupRoutes(app, filelibDeps);
await registerTeacherApp(app, {
prisma: config.prisma,
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="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">
<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>
</div>
<button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button>
@@ -368,9 +370,8 @@ ${pageHead("Database Admin")}
${renderUsersPanel(orgSlug)}
</section>
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:16px">Group 管理</h1>
${renderGroupsPanel(orgSlug)}
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow:hidden;padding:20px">
${renderGroupsPanel()}
</section>
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
+2 -23
View File
@@ -261,29 +261,8 @@ export async function registerFileLibRoutes(
}
});
/* ------------------------------------------------------------ Group 搜索(过渡) */
// 过渡实现:读 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);
}
});
// Group 搜索(C2 /groups/search)已迁至 memberGroupRoutes.ts,读 in-hub
// MemberGroup 闭包(ADR-0028)。此处不再注册,避免重复。
}
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);
}
});
}
+17
View File
@@ -135,4 +135,21 @@ export const UI_THEME_CSS = `
.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: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; }
`;