forked from bai/curriculum-project-hub
228 lines
11 KiB
TypeScript
228 lines
11 KiB
TypeScript
/**
|
|
* 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)。
|
|
*
|
|
* 用户管理 = org 成员(/api/org/:orgSlug/members);
|
|
* Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams),
|
|
* 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。
|
|
*/
|
|
|
|
function apiBase(orgSlug: string): string {
|
|
return `/api/org/${encodeURIComponent(orgSlug)}`;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- 用户管理 */
|
|
|
|
export function renderUsersPanel(orgSlug: string): string {
|
|
return `
|
|
<div id="users-root" style="max-width:880px">
|
|
<div class="panel" style="margin-bottom:14px">
|
|
<div class="section-title">添加成员</div>
|
|
<div class="inline-form">
|
|
<input id="u-openid" class="input" placeholder="用户 openId(飞书 ou_ 开头)" style="flex:2"/>
|
|
<input id="u-name" class="input" placeholder="显示名(可选)" style="flex:1"/>
|
|
<select id="u-role" class="select" style="width:130px">
|
|
<option value="MEMBER">普通老师</option>
|
|
<option value="ADMIN">管理员</option>
|
|
<option value="OWNER">所有者</option>
|
|
</select>
|
|
<button id="u-add" class="btn btn-primary">添加</button>
|
|
</div>
|
|
</div>
|
|
<div class="panel">
|
|
<div class="section-title">成员列表</div>
|
|
<table class="list">
|
|
<thead><tr><th>成员</th><th>userId</th><th>角色</th><th></th></tr></thead>
|
|
<tbody id="u-tbody"></tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
const BASE = "${apiBase(orgSlug)}";
|
|
const tbody = document.getElementById("u-tbody");
|
|
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
const ROLE_LABEL = { OWNER: "所有者", ADMIN: "管理员", MEMBER: "普通老师" };
|
|
async function req(path, opts = {}) {
|
|
const res = await fetch(BASE + path, {
|
|
credentials: "same-origin",
|
|
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
|
method: opts.method ?? "GET",
|
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
});
|
|
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
|
|
const text = await res.text();
|
|
const data = text ? JSON.parse(text) : null;
|
|
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
|
|
return data;
|
|
}
|
|
async function load() {
|
|
const { members } = await req("/members");
|
|
tbody.innerHTML = members.length === 0
|
|
? '<tr><td colspan="4" class="quiet" style="text-align:center;padding:18px">暂无成员</td></tr>'
|
|
: members.map((m) =>
|
|
"<tr>" +
|
|
"<td>" + esc(m.displayName || m.userId) + "</td>" +
|
|
'<td class="file-meta">' + esc(m.userId) + "</td>" +
|
|
'<td><select class="select" style="width:110px;padding:3px 8px;font-size:12px" data-user="' + esc(m.userId) + '">' +
|
|
["OWNER","ADMIN","MEMBER"].map((r) =>
|
|
'<option value="' + r + '"' + (m.role === r ? " selected" : "") + ">" + ROLE_LABEL[r] + "</option>").join("") +
|
|
"</select></td>" +
|
|
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td>' +
|
|
"</tr>").join("");
|
|
tbody.querySelectorAll("select[data-user]").forEach((sel) => {
|
|
sel.onchange = async () => {
|
|
try { await req("/members/" + encodeURIComponent(sel.dataset.user), { method: "PATCH", body: { role: sel.value } }); }
|
|
catch (e) { alert(e.message); load(); }
|
|
};
|
|
});
|
|
tbody.querySelectorAll("button[data-revoke]").forEach((btn) => {
|
|
btn.onclick = async () => {
|
|
if (!confirm("移除该成员?")) return;
|
|
try { await req("/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); load(); }
|
|
catch (e) { alert(e.message); }
|
|
};
|
|
});
|
|
}
|
|
document.getElementById("u-add").onclick = async () => {
|
|
const openId = document.getElementById("u-openid").value.trim();
|
|
if (!openId) return alert("请填写用户 openId");
|
|
const displayName = document.getElementById("u-name").value.trim();
|
|
try {
|
|
await req("/members", { method: "POST", body: {
|
|
feishuOpenId: openId,
|
|
role: document.getElementById("u-role").value,
|
|
...(displayName ? { displayName } : {}),
|
|
}});
|
|
document.getElementById("u-openid").value = "";
|
|
document.getElementById("u-name").value = "";
|
|
load();
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
load().catch((e) => { tbody.innerHTML = '<tr><td colspan="4" style="color:var(--danger);padding:12px">' + esc(e.message) + "</td></tr>"; });
|
|
})();
|
|
</script>`;
|
|
}
|
|
|
|
/* ---------------------------------------------------------------- Group 管理 */
|
|
|
|
export function renderGroupsPanel(orgSlug: string): 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>
|
|
<div class="panel">
|
|
<div class="section-title">Group 列表</div>
|
|
<div id="g-list"></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>
|
|
</div>
|
|
<script>
|
|
(function () {
|
|
const BASE = "${apiBase(orgSlug)}";
|
|
const listEl = document.getElementById("g-list");
|
|
const detailEl = document.getElementById("g-detail");
|
|
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
|
let selected = null;
|
|
async function req(path, opts = {}) {
|
|
const res = await fetch(BASE + path, {
|
|
credentials: "same-origin",
|
|
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
|
method: opts.method ?? "GET",
|
|
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
|
});
|
|
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
|
|
const text = await res.text();
|
|
const data = text ? JSON.parse(text) : null;
|
|
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) => {
|
|
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)";
|
|
loadMembers();
|
|
};
|
|
});
|
|
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); }
|
|
};
|
|
});
|
|
}
|
|
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); }
|
|
};
|
|
});
|
|
detailEl.querySelector("#g-add").onclick = async () => {
|
|
const v = detailEl.querySelector("#g-add-openid").value.trim();
|
|
if (!v) return alert("请填写用户 id");
|
|
try {
|
|
await req("/teams/" + encodeURIComponent(selected.id) + "/members", {
|
|
method: "POST",
|
|
body: v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v },
|
|
});
|
|
loadMembers();
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
}
|
|
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 和名称必填");
|
|
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();
|
|
} catch (e) { alert(e.message); }
|
|
};
|
|
loadList().catch((e) => { listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>"; });
|
|
})();
|
|
</script>`;
|
|
}
|