|
|
|
@@ -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,"&").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 = {}) {
|
|
|
|
|
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>`;
|
|
|
|
|
}
|
|
|
|
|