Files
curriculum-project-hub/hub/admin-web/src/routes/+layout.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

365 lines
12 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 '../routes/app.css';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { page } from '$app/state';
import { session, loadSession, logout, redirectToLogin } from '$lib/session';
import type { OrgMembership } from '$lib/api';
import { adminPath, isOrgAdmin, resolveOrg } from '$lib/org';
import { orgRoleLabel } from '$lib/format';
import Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.svelte';
import SelectField from '$lib/components/SelectField.svelte';
let { children } = $props();
let mobileNavOpen = $state(false);
let redirecting = $state(false);
onMount(() => {
loadSession();
});
const navItems = [
{ key: 'overview', label: '概览', icon: 'overview' as const },
{ key: 'usage', label: '用量', icon: 'overview' as const },
{ key: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'capacity', label: '容量', icon: 'overview' as const },
{ key: 'provider', label: '供应方', icon: 'provider' as const },
{ key: 'skills', label: '技能', icon: 'roles' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'feishu', label: '飞书', icon: 'feishu' as const },
{ key: 'capabilities', label: '能力', icon: 'provider' as const },
];
function memberships(): OrgMembership[] {
return $session.me?.organizations ?? [];
}
function adminOrgs(): OrgMembership[] {
return memberships().filter((o) => isOrgAdmin(o));
}
function currentOrg(): OrgMembership | null {
return resolveOrg($session.me, page.url.search);
}
function isOnProjectRoute(): boolean {
const parts = page.url.pathname.split('/').filter(Boolean);
// /admin/projects or /admin/projects/:id
return parts[0] === 'admin' && parts[1] === 'projects';
}
function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin') return '';
// /admin → overview; /admin/usage → usage; /admin/projects/x → projects
return parts[1] ?? 'overview';
}
function navHref(key: string): string {
if (key === 'overview') return adminPath();
return adminPath(key);
}
function pageTitle(): string {
const key = activeKey();
if (key === 'overview' || key === '') return '概览';
if (key === 'sessions') return '会话详情';
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
// Path is tenancy-free; keep optional ?org= for local multi-membership debugging.
const url = new URL(page.url.href);
url.searchParams.set('org', nextSlug);
void goto(`${url.pathname}${url.search}`, { replaceState: true });
}
function handleLogout(e: Event) {
e.preventDefault();
logout();
}
function orgSelectItems(list: OrgMembership[]) {
return list.map((o) => ({
value: o.slug,
label: `${o.name} · ${orgRoleLabel(o.role)}`,
}));
}
$effect(() => {
page.url.pathname;
mobileNavOpen = false;
});
$effect(() => {
if ($session.loading || !$session.me) return;
const path = page.url.pathname;
// Legacy /admin/org/:slug… is handled by admin/org/[...path] page.
const org = currentOrg();
const admins = adminOrgs();
if (org && isOrgAdmin(org)) {
redirecting = false;
return;
}
// Member only: allow project routes; bounce admin-only surfaces to projects.
if (org && !isOrgAdmin(org)) {
redirecting = false;
if (!isOnProjectRoute()) {
const target = adminPath('projects');
if (path !== target) {
redirecting = true;
void goto(target, { replaceState: true });
}
}
return;
}
// No org resolved but user has memberships → land on first org's projects/overview via resolveOrg next tick
if (!org && memberships().length > 0) {
const home = admins[0] ?? memberships()[0]!;
const target = isOrgAdmin(home) ? adminPath() : adminPath('projects');
if (path !== target && !path.startsWith(`${target}/`)) {
redirecting = true;
void goto(target, { replaceState: true });
}
}
});
</script>
<ToastHost />
{#if $session.loading || redirecting}
<div class="saas-status-panel">
<div class="flex flex-col items-center gap-3 text-surface-600">
<svg class="h-8 w-8 animate-spin text-primary-500" viewBox="0 0 24 24" fill="none">
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path
class="opacity-90"
fill="currentColor"
d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
></path>
</svg>
<p class="text-sm">加载中…</p>
</div>
</div>
{:else if $session.error}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-3 flex h-12 w-12 items-center justify-center border border-error-200 bg-error-50 text-error-700"
>
!
</div>
<h2 class="mb-1 text-lg font-semibold">无法连接到后端</h2>
<p class="mb-5 text-sm text-surface-700">{$session.error}</p>
<button class="saas-btn-primary" onclick={() => loadSession()}>重试</button>
</div>
</div>
{:else if !$session.me}
<div class="saas-status-panel">
<div class="saas-status-card">
<div
class="mx-auto mb-4 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-sm font-bold text-white"
>
CPH
</div>
<h1 class="text-xl font-semibold">Curriculum Project Hub</h1>
<p class="mt-2 mb-6 text-sm text-surface-700">登录以管理组织、项目、团队与模型供应。</p>
<button class="saas-btn-primary w-full" onclick={() => redirectToLogin()}>使用飞书登录</button>
</div>
</div>
{:else if currentOrg() && isOrgAdmin(currentOrg()!)}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
{#if mobileNavOpen}
<button
type="button"
class="fixed inset-0 z-40 bg-surface-950/40 md:hidden"
aria-label="关闭导航"
onclick={() => (mobileNavOpen = false)}
></button>
{/if}
<aside
class="saas-sidebar fixed inset-y-0 left-0 z-50 transition-transform md:static md:translate-x-0
{mobileNavOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0'}"
>
<div class="flex items-center gap-2.5 px-4 py-4">
<div
class="flex h-9 w-9 items-center justify-center border border-primary-700 bg-primary-600 text-xs font-bold tracking-wide text-white"
>
CPH
</div>
<div class="min-w-0">
<div class="truncate text-sm font-semibold text-surface-900">组织后台</div>
<div class="truncate text-xs text-surface-600">{org.name}</div>
</div>
</div>
{#if me.organizations.length > 1}
<div class="px-3 pb-3">
<div class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-700">
<Icon name="org" class="h-3.5 w-3.5" />
组织
</div>
<SelectField items={orgSelectItems(me.organizations)} value={org.slug} onchange={switchOrg} />
</div>
{/if}
<nav class="flex-1 space-y-0.5 overflow-y-auto px-2 pb-3">
<p class="px-3 pb-1 pt-2 text-[11px] font-semibold uppercase tracking-wider text-surface-600">工作台</p>
{#each navItems as item}
{@const active =
activeKey() === item.key || (item.key === 'overview' && (activeKey() === '' || activeKey() === 'overview'))}
<a href={navHref(item.key)} class="saas-nav-item" data-active={active ? 'true' : 'false'}>
<Icon name={item.icon} class="h-4 w-4 shrink-0 opacity-80" />
<span>{item.label}</span>
</a>
{/each}
</nav>
<div class="border-t border-surface-300 p-3">
<div class="flex items-center gap-2.5 border border-surface-300 bg-surface-100 px-2.5 py-2">
{#if me.user.avatarUrl}
<img src={me.user.avatarUrl} alt="" class="h-8 w-8 object-cover" />
{:else}
<div
class="flex h-8 w-8 items-center justify-center border border-primary-300 bg-primary-100 text-xs font-semibold text-primary-800"
>
{me.user.displayName.slice(0, 1)}
</div>
{/if}
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-surface-900">{me.user.displayName}</div>
<div class="truncate text-[11px] text-surface-600">{orgRoleLabel(org.role)}</div>
</div>
<button
type="button"
class="p-1.5 text-surface-600 transition hover:bg-surface-200 hover:text-error-700"
title="退出登录"
onclick={handleLogout}
>
<Icon name="logout" class="h-4 w-4" />
</button>
</div>
</div>
</aside>
<div class="saas-main">
<header class="saas-topbar">
<button
type="button"
class="saas-btn-ghost px-2! md:hidden"
onclick={() => (mobileNavOpen = !mobileNavOpen)}
aria-label="打开导航"
>
<Icon name="menu" class="h-5 w-5" />
</button>
<div class="min-w-0">
<div class="flex items-center gap-1.5 text-xs text-surface-600">
<span class="truncate">{org.name}</span>
<span>/</span>
<span class="truncate font-medium text-surface-900">{pageTitle()}</span>
</div>
</div>
<div class="ml-auto hidden items-center gap-2 sm:flex">
<span class="saas-badge-primary">{org.status}</span>
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
</div>
</header>
<main class="saas-content">
<div class="saas-content-inner">
{@render children()}
</div>
</main>
</div>
</div>
{:else if currentOrg() && !isOrgAdmin(currentOrg()!) && isOnProjectRoute()}
{@const org = currentOrg()!}
{@const me = $session.me!}
<div class="saas-shell">
<div class="saas-main">
<header class="saas-topbar">
<a href={adminPath('projects')} class="saas-btn-ghost px-2!" aria-label="返回项目列表">
<Icon name="menu" class="h-5 w-5" />
</a>
<div class="min-w-0">
<div class="flex items-center gap-1.5 text-xs text-surface-600">
<span class="truncate">{org.name}</span>
<span>/</span>
<span class="truncate font-medium text-surface-900">项目</span>
</div>
</div>
<div class="ml-auto flex items-center gap-2">
<span class="saas-badge-neutral font-mono">/{org.slug}</span>
<button
type="button"
class="p-1.5 text-surface-600 transition hover:bg-surface-200 hover:text-error-700"
title="退出登录"
onclick={handleLogout}
>
<Icon name="logout" class="h-4 w-4" />
</button>
</div>
</header>
<main class="saas-content">
<div class="saas-content-inner">
{@render children()}
</div>
</main>
</div>
</div>
{:else if currentOrg() && !isOrgAdmin(currentOrg()!)}
{@const denied = currentOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
<p class="mb-3 text-sm text-surface-700">
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。普通成员仅可访问自己有授权的项目。
</p>
<a class="saas-btn-primary" href={adminPath('projects')}>查看我的项目</a>
{#if memberships().length > 1}
<p class="saas-label text-left mb-1.5 mt-3">切换到其他组织</p>
<div class="mb-4">
<SelectField items={orgSelectItems(memberships())} value={denied.slug} onchange={switchOrg} />
</div>
{/if}
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else if memberships().length > 0}
{@const denied = resolveOrg($session.me) ?? memberships()[0]!}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{denied.name}</strong>/{denied.slug})的项目。
</p>
<a class="saas-btn-primary" href={adminPath('projects')}>立即进入</a>
<button class="saas-btn-ghost mt-3" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else}
<div class="saas-status-panel">
<div class="saas-status-card">
<h2 class="mb-2 text-lg font-semibold">未加入组织</h2>
<p class="mb-3 text-sm text-surface-700">飞书账号已登录,但当前账号尚未加入任何组织。</p>
<p class="mb-5 text-left text-xs text-surface-600">
open_id
<code class="break-all font-mono text-surface-600">{$session.me!.user.feishuOpenId}</code>
</p>
<button class="saas-btn-primary" onclick={handleLogout}>退出登录</button>
</div>
</div>
{/if}