feat(hub): drop redundant /admin/org/:slug path + release v0.0.35

Silo hostname already carries tenancy. Admin SPA routes become /admin/...,
legacy /admin/org/:slug/* bookmarks redirect, login lands on /admin.
This commit is contained in:
2026-07-18 17:36:08 +00:00
parent eb0be43eac
commit 4e7b158ff9
25 changed files with 221 additions and 129 deletions
@@ -0,0 +1,236 @@
<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}