forked from EduCraft/curriculum-project-hub
0dd2ae347e
- GrantsPanel 重写为表格:顶部左侧授权成员搜索框(名称/id/类型过滤), 右侧「添加授权」弹窗(类型 + 主体搜索选择 + 权限);行内权限下拉 直接改级(复用 PUT upsert),操作列删除;成员单元格跳转用户管理 (?q= 过滤)或 Group 管理(?select= 选中)。 - grantService: GrantDto 增加 principalName,list/put/force 三处统一 批量回填(用户 displayName / 组 name),前端不再只显示裸 id。 - 用户管理页加过滤框并从 ?q= 初始化;GroupAdmin 支持 ?select= 直达。 - 测试:resetDb 补 MemberGroup 三表清理(全局表不被 org/user 级联清到, 此前跨用例污染导致级联软删用例断言失败);cph_hub_test 补 migrate。 - 顺带合并 types.ts 里重复的 Grant 声明(interface 合并残留)。
201 lines
6.7 KiB
Svelte
201 lines
6.7 KiB
Svelte
<script lang="ts">
|
|
/**
|
|
* 用户管理(迁自后端 adminPanels.ts renderUsersPanel)。
|
|
*
|
|
* 用户 = silo org 的成员,走平台层 /api/org/:slug/members(见 src/admin/routes/membersRoutes.ts)。
|
|
* 与 Group 管理是两套体系:MemberGroup 是全局主体、不归属 org(ADR-0028),
|
|
* 这里管的是 org 成员与其角色。
|
|
*/
|
|
import { onMount } from "svelte";
|
|
import { page } from "$app/state";
|
|
import { api } from "$lib/api.js";
|
|
import { loadConfig } from "$lib/config.js";
|
|
import { toastOk, toastErr } from "$lib/stores.js";
|
|
import type { OrgMember, OrgRole } from "$lib/types.js";
|
|
import Icon from "$lib/Icon.svelte";
|
|
|
|
const ROLE_LABEL: Record<OrgRole, string> = {
|
|
OWNER: "所有者",
|
|
ADMIN: "管理员",
|
|
MEMBER: "普通老师",
|
|
};
|
|
const ROLES: readonly OrgRole[] = ["OWNER", "ADMIN", "MEMBER"];
|
|
|
|
let orgSlug = $state<string | null>(null);
|
|
let members = $state<OrgMember[] | null>(null);
|
|
let error = $state<string | null>(null);
|
|
|
|
// 列表过滤;授权面板跳转会带 ?q=<userId>,以此为初始过滤词。
|
|
let filterText = $state(page.url.searchParams.get("q") ?? "");
|
|
|
|
let newOpenId = $state("");
|
|
let newName = $state("");
|
|
let newRole = $state<OrgRole>("MEMBER");
|
|
let adding = $state(false);
|
|
|
|
const base = $derived(orgSlug === null ? null : `/api/org/${encodeURIComponent(orgSlug)}`);
|
|
|
|
/** 按显示名 / userId / openId 过滤(纯前端;成员全量在手)。 */
|
|
const shown = $derived.by((): OrgMember[] | null => {
|
|
if (members === null) return null;
|
|
const q = filterText.trim().toLowerCase();
|
|
if (q === "") return members;
|
|
return members.filter(
|
|
(m) =>
|
|
m.displayName.toLowerCase().includes(q) ||
|
|
m.userId.toLowerCase().includes(q) ||
|
|
m.feishuOpenId.toLowerCase().includes(q),
|
|
);
|
|
});
|
|
|
|
async function load(): Promise<void> {
|
|
if (base === null) return;
|
|
try {
|
|
const r = await api<{ members: OrgMember[] }>(`${base}/members`);
|
|
members = r.members;
|
|
error = null;
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
}
|
|
}
|
|
|
|
onMount(async () => {
|
|
try {
|
|
orgSlug = (await loadConfig()).orgSlug;
|
|
await load();
|
|
} catch (e) {
|
|
error = e instanceof Error ? e.message : String(e);
|
|
}
|
|
});
|
|
|
|
async function addMember(): Promise<void> {
|
|
const feishuOpenId = newOpenId.trim();
|
|
if (feishuOpenId === "") {
|
|
toastErr("请填写用户 openId");
|
|
return;
|
|
}
|
|
if (base === null) return;
|
|
adding = true;
|
|
try {
|
|
const displayName = newName.trim();
|
|
await api(`${base}/members`, {
|
|
method: "POST",
|
|
body: { feishuOpenId, role: newRole, ...(displayName !== "" ? { displayName } : {}) },
|
|
});
|
|
newOpenId = "";
|
|
newName = "";
|
|
toastOk("已添加");
|
|
await load();
|
|
} catch (e) {
|
|
toastErr(e instanceof Error ? e.message : String(e));
|
|
} finally {
|
|
adding = false;
|
|
}
|
|
}
|
|
|
|
async function setRole(userId: string, role: string): Promise<void> {
|
|
if (base === null) return;
|
|
try {
|
|
await api(`${base}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: { role } });
|
|
toastOk("角色已更新");
|
|
await load();
|
|
} catch (e) {
|
|
toastErr(e instanceof Error ? e.message : String(e));
|
|
await load();
|
|
}
|
|
}
|
|
|
|
async function revoke(userId: string, displayName: string): Promise<void> {
|
|
if (base === null) return;
|
|
if (!confirm(`移除成员「${displayName || userId}」?`)) return;
|
|
try {
|
|
await api(`${base}/members/${encodeURIComponent(userId)}/revoke`, { method: "POST" });
|
|
toastOk("已移除");
|
|
await load();
|
|
} catch (e) {
|
|
toastErr(e instanceof Error ? e.message : String(e));
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<section class="flex-1 overflow-y-auto p-7">
|
|
<h1 class="mb-4 text-lg font-semibold text-ink">用户管理</h1>
|
|
|
|
<div class="max-w-[880px]">
|
|
<div class="panel mb-3.5">
|
|
<div class="section-title mb-2.5">添加成员</div>
|
|
<div class="flex flex-wrap items-center gap-2">
|
|
<input class="input min-w-0 flex-[2]" placeholder="用户 openId(飞书 ou_ 开头)" bind:value={newOpenId} />
|
|
<input class="input min-w-0 flex-1" placeholder="显示名(可选)" bind:value={newName} />
|
|
<select class="select !w-[130px]" bind:value={newRole}>
|
|
{#each ROLES as role (role)}
|
|
<option value={role}>{ROLE_LABEL[role]}</option>
|
|
{/each}
|
|
</select>
|
|
<button class="btn btn-primary disabled:opacity-50" onclick={addMember} disabled={adding}>
|
|
{adding ? "添加中…" : "添加"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="panel">
|
|
<div class="mb-2.5 flex items-center justify-between gap-2">
|
|
<div class="section-title">成员列表</div>
|
|
<div class="relative w-[260px] shrink-0">
|
|
<span class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-ink-3">
|
|
<Icon name="search" size={13} />
|
|
</span>
|
|
<input
|
|
class="input w-full !py-[5px] !pl-8 !text-[12.5px]"
|
|
placeholder="过滤:名称 / userId / openId"
|
|
bind:value={filterText}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{#if error}
|
|
<div class="py-3 text-[12.5px] text-danger">{error}</div>
|
|
{:else if shown === null}
|
|
<div class="quiet py-[18px] text-center">加载中…</div>
|
|
{:else if shown.length === 0}
|
|
<div class="quiet py-[18px] text-center">
|
|
{filterText.trim() === "" ? "暂无成员" : `无匹配「${filterText.trim()}」的成员`}
|
|
</div>
|
|
{:else}
|
|
<table class="list">
|
|
<thead>
|
|
<tr>
|
|
<th>成员</th>
|
|
<th>userId</th>
|
|
<th>角色</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each shown as m (m.userId)}
|
|
<tr>
|
|
<td class="text-ink">{m.displayName || m.userId}</td>
|
|
<td class="file-meta">{m.userId}</td>
|
|
<td>
|
|
<select
|
|
class="select !w-[110px] !px-2 !py-[3px] !text-xs"
|
|
value={m.role}
|
|
onchange={(e) => setRole(m.userId, e.currentTarget.value)}
|
|
>
|
|
{#each ROLES as role (role)}
|
|
<option value={role}>{ROLE_LABEL[role]}</option>
|
|
{/each}
|
|
</select>
|
|
</td>
|
|
<td class="text-right">
|
|
<button class="link-danger" onclick={() => revoke(m.userId, m.displayName)}>移除</button>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
</section>
|