Files
curriculum-project-hub/hub/admin-web/src/routes/admin/teams/+page.svelte
T
hongjr03 7f09fb1f13 feat(hub): drop redundant /admin/org/:slug path + release v0.0.35 (#11)
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>
2026-07-19 01:36:10 +08:00

237 lines
7.4 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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}