Files
curriculum-project-hub/hub/src/database/routes/adminPanels.ts
T

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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
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,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
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>`;
}