forked from EduCraft/curriculum-project-hub
4e7b158ff9
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin.
168 lines
5.3 KiB
Svelte
168 lines
5.3 KiB
Svelte
<script lang="ts">
|
|
import { page } from '$app/state';
|
|
import { api, type OrgMember } from '$lib/api';
|
|
import { session } from '$lib/session';
|
|
import { resolveOrg } from '$lib/org';
|
|
import { fmtDate } from '$lib/format';
|
|
import { ORG_ROLES, ORG_ROLE_LABELS, PERMISSION_ROLE_LABELS } from '$lib/constants';
|
|
import PageHeader from '$lib/components/PageHeader.svelte';
|
|
import LoadingState from '$lib/components/LoadingState.svelte';
|
|
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
|
|
import EmptyState from '$lib/components/EmptyState.svelte';
|
|
import SelectField from '$lib/components/SelectField.svelte';
|
|
import { toastError, toastSuccess } from '$lib/toast';
|
|
|
|
const org = $derived(resolveOrg($session.me, page.url.search));
|
|
const slug = $derived(org?.slug ?? '');
|
|
const roleItems = ORG_ROLES.map((r) => ({ value: r, label: ORG_ROLE_LABELS[r] }));
|
|
const permHint = Object.values(PERMISSION_ROLE_LABELS).join(' / ');
|
|
|
|
let members = $state<OrgMember[]>([]);
|
|
let loading = $state(true);
|
|
let error = $state<string | null>(null);
|
|
|
|
let newOpenId = $state('');
|
|
let newName = $state('');
|
|
let newRole = $state<string>('MEMBER');
|
|
let adding = $state(false);
|
|
|
|
async function load() {
|
|
loading = true;
|
|
error = null;
|
|
try {
|
|
const res = await api.members(slug);
|
|
members = res.members;
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
async function addMember() {
|
|
if (!newOpenId.trim()) return;
|
|
adding = true;
|
|
try {
|
|
const m = await api.addMember(slug, {
|
|
feishuOpenId: newOpenId.trim(),
|
|
role: newRole,
|
|
...(newName.trim() ? { displayName: newName.trim() } : {}),
|
|
});
|
|
members = [...members, m].sort((a, b) => a.role.localeCompare(b.role) || a.createdAt.localeCompare(b.createdAt));
|
|
newOpenId = '';
|
|
newName = '';
|
|
toastSuccess('成员已添加');
|
|
} catch (err) {
|
|
toastError(err instanceof Error ? err.message : String(err));
|
|
} finally {
|
|
adding = false;
|
|
}
|
|
}
|
|
|
|
async function changeRole(m: OrgMember, role: string) {
|
|
try {
|
|
await api.setMemberRole(slug, m.userId, role);
|
|
await load();
|
|
toastSuccess('角色已更新');
|
|
} catch (err) {
|
|
toastError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
async function revoke(m: OrgMember) {
|
|
if (!confirm(`移除成员 ${m.displayName} 出本组织?`)) return;
|
|
try {
|
|
await api.revokeMember(slug, m.userId);
|
|
await load();
|
|
toastSuccess('成员已移除');
|
|
} catch (err) {
|
|
toastError(err instanceof Error ? err.message : String(err));
|
|
}
|
|
}
|
|
|
|
$effect(() => {
|
|
if (slug) load();
|
|
});
|
|
</script>
|
|
|
|
<PageHeader title="成员与权限" description="管理组织角色:所有者与管理员可访问本后台,成员不可。" />
|
|
|
|
{#if loading}
|
|
<LoadingState />
|
|
{:else if error}
|
|
<ErrorBanner message={error} onretry={load} />
|
|
{:else}
|
|
<div class="saas-card-pad mb-6">
|
|
<h2 class="saas-section-title mb-4">添加成员</h2>
|
|
<div class="grid gap-3 md:grid-cols-[1.2fr_1fr_12rem_auto]">
|
|
<input class="saas-input" placeholder="飞书 open_id" bind:value={newOpenId} />
|
|
<input class="saas-input" placeholder="显示名(可选)" bind:value={newName} />
|
|
<SelectField items={roleItems} bind:value={newRole} />
|
|
<button class="saas-btn-primary" onclick={addMember} disabled={adding}>
|
|
{adding ? '添加中…' : '添加成员'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="saas-card overflow-hidden">
|
|
<div class="flex items-center justify-between border-b border-surface-200 px-5 py-3">
|
|
<h2 class="text-sm font-semibold">成员列表</h2>
|
|
<span class="saas-badge-neutral">{members.length}</span>
|
|
</div>
|
|
{#if members.length === 0}
|
|
<EmptyState title="暂无成员" description="使用上方表单按飞书 open_id 添加成员。" />
|
|
{:else}
|
|
<div class="overflow-x-auto">
|
|
<table class="data-table">
|
|
<thead>
|
|
<tr>
|
|
<th>用户</th>
|
|
<th>open_id</th>
|
|
<th>组织角色</th>
|
|
<th>加入时间</th>
|
|
<th></th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{#each members as m}
|
|
<tr>
|
|
<td>
|
|
<div class="flex items-center gap-2.5">
|
|
{#if m.avatarUrl}
|
|
<img src={m.avatarUrl} alt="" class="h-7 w-7 border border-surface-300 object-cover" />
|
|
{:else}
|
|
<div
|
|
class="flex h-7 w-7 items-center justify-center border border-primary-300 bg-primary-100 text-xs font-semibold text-primary-800"
|
|
>
|
|
{m.displayName.slice(0, 1)}
|
|
</div>
|
|
{/if}
|
|
<span class="font-medium">{m.displayName}</span>
|
|
</div>
|
|
</td>
|
|
<td class="font-mono text-xs text-surface-700">{m.feishuOpenId}</td>
|
|
<td class="min-w-36">
|
|
<SelectField
|
|
items={roleItems}
|
|
value={m.role}
|
|
onchange={(role) => {
|
|
if (role !== m.role) changeRole(m, role);
|
|
}}
|
|
/>
|
|
</td>
|
|
<td class="text-surface-700">{fmtDate(m.createdAt)}</td>
|
|
<td class="text-right">
|
|
<button class="saas-btn-danger py-1! text-sm" onclick={() => revoke(m)}>移除</button>
|
|
</td>
|
|
</tr>
|
|
{/each}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
{/if}
|
|
<p class="border-t border-surface-300 px-5 py-3 text-xs text-surface-600">
|
|
组织角色控制后台访问;项目级权限由「项目」页团队授权({permHint})决定。
|
|
</p>
|
|
</div>
|
|
{/if}
|