forked from EduCraft/curriculum-project-hub
7f09fb1f13
Silo hostname already carries tenancy. Admin SPA routes become /admin/..., legacy bookmarks redirect, login lands on /admin. Co-authored-by: Hong Jiarong <me@jrhim.com> Co-committed-by: Hong Jiarong <me@jrhim.com>
237 lines
7.4 KiB
Svelte
237 lines
7.4 KiB
Svelte
<script lang="ts">
|
||
import { Collapsible } from 'bits-ui';
|
||
import { page } from '$app/state';
|
||
import { api, type TeamRow, type TeamMemberRow } from '$lib/api';
|
||
import { session } from '$lib/session';
|
||
import { resolveOrg } from '$lib/org';
|
||
import { fmtDate } from '$lib/format';
|
||
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 { toastError, toastSuccess } from '$lib/toast';
|
||
|
||
const org = $derived(resolveOrg($session.me, page.url.search));
|
||
const slug = $derived(org?.slug ?? '');
|
||
|
||
let teams = $state<TeamRow[]>([]);
|
||
let loading = $state(true);
|
||
let error = $state<string | null>(null);
|
||
|
||
let newSlug = $state('');
|
||
let newName = $state('');
|
||
let newDesc = $state('');
|
||
let adding = $state(false);
|
||
|
||
let expandedId = $state<string | null>(null);
|
||
let teamMembers = $state<TeamMemberRow[]>([]);
|
||
let memberInput = $state('');
|
||
let loadingMembers = $state(false);
|
||
|
||
async function load() {
|
||
loading = true;
|
||
error = null;
|
||
try {
|
||
const res = await api.teams(slug);
|
||
teams = res.teams;
|
||
} catch (err) {
|
||
error = err instanceof Error ? err.message : String(err);
|
||
} finally {
|
||
loading = false;
|
||
}
|
||
}
|
||
|
||
const SLUG_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/;
|
||
|
||
async function createTeam() {
|
||
if (!newSlug.trim() || !newName.trim()) return;
|
||
const teamSlug = newSlug.trim().toLowerCase();
|
||
if (!SLUG_RE.test(teamSlug)) {
|
||
toastError('标识须为小写字母数字,可用连字符连接');
|
||
return;
|
||
}
|
||
adding = true;
|
||
try {
|
||
await api.createTeam(slug, {
|
||
slug: teamSlug,
|
||
name: newName.trim(),
|
||
...(newDesc.trim() ? { description: newDesc.trim() } : {}),
|
||
});
|
||
newSlug = '';
|
||
newName = '';
|
||
newDesc = '';
|
||
await load();
|
||
toastSuccess('团队已创建');
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
adding = false;
|
||
}
|
||
}
|
||
|
||
async function archiveTeam(t: TeamRow) {
|
||
if (!confirm(`归档团队 ${t.name}? 归档后该团队不再解析为项目授权主体。`)) return;
|
||
try {
|
||
await api.archiveTeam(slug, t.id);
|
||
if (expandedId === t.id) expandedId = null;
|
||
await load();
|
||
toastSuccess('团队已归档');
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
async function openMembers(t: TeamRow) {
|
||
if (expandedId === t.id) return;
|
||
expandedId = t.id;
|
||
loadingMembers = true;
|
||
memberInput = '';
|
||
try {
|
||
const res = await api.teamMembers(slug, t.id);
|
||
teamMembers = res.members;
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
loadingMembers = false;
|
||
}
|
||
}
|
||
|
||
function onExpandChange(t: TeamRow, open: boolean) {
|
||
if (open) void openMembers(t);
|
||
else if (expandedId === t.id) expandedId = null;
|
||
}
|
||
|
||
async function addMember(t: TeamRow) {
|
||
if (!memberInput.trim()) return;
|
||
const v = memberInput.trim();
|
||
try {
|
||
await api.addTeamMember(slug, t.id, v.startsWith('ou') ? { feishuOpenId: v } : { userId: v });
|
||
memberInput = '';
|
||
const res = await api.teamMembers(slug, t.id);
|
||
teamMembers = res.members;
|
||
await load();
|
||
toastSuccess('已加入团队');
|
||
} catch (err) {
|
||
toastError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}
|
||
|
||
async function revokeMember(t: TeamRow, m: TeamMemberRow) {
|
||
if (!confirm(`将 ${m.displayName} 移出团队 ${t.name}?`)) return;
|
||
try {
|
||
await api.revokeTeamMember(slug, t.id, m.userId);
|
||
const res = await api.teamMembers(slug, t.id);
|
||
teamMembers = res.members;
|
||
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-[1fr_1fr_1.2fr_auto]">
|
||
<input class="saas-input" placeholder="标识(如 math-g7)" bind:value={newSlug} />
|
||
<input class="saas-input" placeholder="名称" bind:value={newName} />
|
||
<input class="saas-input" placeholder="描述(可选)" bind:value={newDesc} />
|
||
<button class="saas-btn-primary" onclick={createTeam} disabled={adding}>新建</button>
|
||
</div>
|
||
</div>
|
||
|
||
{#if teams.length === 0}
|
||
<div class="saas-card">
|
||
<EmptyState title="暂无团队" description="创建团队后,可在项目页授权项目访问。" />
|
||
</div>
|
||
{:else}
|
||
<div class="space-y-3">
|
||
{#each teams as t (t.id)}
|
||
<Collapsible.Root
|
||
class="saas-card p-5"
|
||
open={expandedId === t.id}
|
||
onOpenChange={(open) => onExpandChange(t, open)}
|
||
>
|
||
<div class="flex flex-wrap items-center gap-2.5">
|
||
<span class="text-base font-semibold text-surface-900">{t.name}</span>
|
||
<span class="font-mono text-xs text-surface-600">/{t.slug}</span>
|
||
<span class="saas-badge-neutral">{t.memberCount} 成员</span>
|
||
<div class="ml-auto flex flex-wrap gap-2">
|
||
<Collapsible.Trigger class="saas-btn-secondary py-1.5! text-sm">
|
||
{expandedId === t.id ? '收起' : '管理成员'}
|
||
</Collapsible.Trigger>
|
||
<button type="button" class="saas-btn-danger py-1.5! text-sm" onclick={() => archiveTeam(t)}>
|
||
归档
|
||
</button>
|
||
</div>
|
||
</div>
|
||
{#if t.description}
|
||
<p class="mt-1.5 text-sm text-surface-700">{t.description}</p>
|
||
{/if}
|
||
<p class="mt-1 text-xs text-surface-600">创建于 {fmtDate(t.createdAt)}</p>
|
||
|
||
<Collapsible.Content>
|
||
<div class="mt-4 border-t border-surface-200 pt-4">
|
||
{#if loadingMembers && expandedId === t.id}
|
||
<p class="text-sm text-surface-600">加载中…</p>
|
||
{:else if expandedId === t.id}
|
||
<div class="mb-4 flex gap-2">
|
||
<input
|
||
class="saas-input"
|
||
placeholder="飞书 open_id 或用户 id"
|
||
bind:value={memberInput}
|
||
onkeydown={(e) => {
|
||
if (e.key === 'Enter') addMember(t);
|
||
}}
|
||
/>
|
||
<button class="saas-btn-secondary shrink-0" onclick={() => addMember(t)}>加入</button>
|
||
</div>
|
||
{#if teamMembers.length === 0}
|
||
<p class="py-3 text-center text-sm text-surface-600">团队暂无成员</p>
|
||
{:else}
|
||
<table class="data-table">
|
||
<thead>
|
||
<tr>
|
||
<th>成员</th>
|
||
<th>open_id</th>
|
||
<th>加入时间</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{#each teamMembers as m}
|
||
<tr>
|
||
<td class="font-medium">{m.displayName}</td>
|
||
<td class="font-mono text-xs">{m.feishuOpenId}</td>
|
||
<td class="text-surface-700">{fmtDate(m.createdAt)}</td>
|
||
<td class="text-right">
|
||
<button class="saas-btn-danger py-1! text-xs" onclick={() => revokeMember(t, m)}>
|
||
移除
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
{/each}
|
||
</tbody>
|
||
</table>
|
||
{/if}
|
||
{/if}
|
||
</div>
|
||
</Collapsible.Content>
|
||
</Collapsible.Root>
|
||
{/each}
|
||
</div>
|
||
{/if}
|
||
<p class="mt-4 text-xs text-surface-600">归档团队会同步撤销其活跃的项目授权。</p>
|
||
{/if}
|