Files
curriculum-project-hub/hub/admin-web/src/routes/+layout.svelte
T

324 lines
11 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 { 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: 'members', label: '成员', icon: 'members' as const },
{ key: 'teams', label: '团队', icon: 'teams' as const },
{ key: 'projects', label: '项目', icon: 'projects' as const },
{ key: 'provider', label: '供应方', icon: 'provider' as const }
];
function isAdmin(org: OrgMembership): boolean {
const role = String(org.role ?? '').toUpperCase();
return role === 'OWNER' || role === 'ADMIN';
}
function orgSlugFromPath(): string | null {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] === 'admin' && parts[1] === 'org' && parts[2]) {
return decodeURIComponent(parts[2]);
}
return null;
}
function memberships(): OrgMembership[] {
return $session.me?.organizations ?? [];
}
function adminOrgs(): OrgMembership[] {
return memberships().filter(isAdmin);
}
function currentOrg(): OrgMembership | null {
const slug = orgSlugFromPath();
if (!slug) return null;
return memberships().find((o) => o.slug === slug) ?? null;
}
function pickHomeOrg(): OrgMembership | null {
const admin = adminOrgs()[0];
if (admin) return admin;
return memberships()[0] ?? null;
}
function activeKey(): string {
const parts = page.url.pathname.split('/').filter(Boolean);
if (parts[0] !== 'admin' || parts[1] !== 'org' || !parts[2]) return '';
return parts[3] ?? 'overview';
}
function navHref(key: string): string {
const slug = currentOrg()?.slug ?? pickHomeOrg()?.slug;
if (!slug) return '/';
if (key === 'overview') return `/admin/org/${slug}`;
return `/admin/org/${slug}/${key}`;
}
function pageTitle(): string {
const key = activeKey();
if (key === 'overview' || key === '') return '概览';
return navItems.find((i) => i.key === key)?.label ?? '管理后台';
}
function switchOrg(nextSlug: string) {
if (!nextSlug || nextSlug === currentOrg()?.slug) return;
void goto(`/admin/org/${nextSlug}`);
}
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 admins = adminOrgs();
if (admins.length === 0) return;
const slug = orgSlugFromPath();
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
if (matched && isAdmin(matched)) {
redirecting = false;
return;
}
if (!matched) {
const target = `/admin/org/${admins[0].slug}`;
if (page.url.pathname !== target && !page.url.pathname.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">{redirecting ? '正在进入组织…' : '正在加载会话…'}</p>
</div>
</div>
{:else if $session.error}
<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-error-300 bg-error-100 text-error-700 font-bold">!</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-5 flex h-12 w-12 items-center justify-center border border-primary-700 bg-primary-600 text-white font-bold">
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() && isAdmin(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">Curriculum Hub</div>
</div>
</div>
<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>
<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() && !isAdmin(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>
需要<strong>所有者</strong><strong>管理员</strong>
</p>
{#if memberships().length > 1}
<p class="saas-label text-left mb-1.5">切换到其他组织</p>
<div class="mb-4">
<SelectField
items={orgSelectItems(memberships())}
value={denied.slug}
onchange={switchOrg}
/>
</div>
{/if}
<button class="saas-btn-ghost" onclick={handleLogout}>退出登录</button>
</div>
</div>
{:else if memberships().length > 0}
{@const denied = pickHomeOrg()!}
<div class="saas-status-panel">
<div class="saas-status-card">
{#if adminOrgs().length === 0}
<h2 class="mb-2 text-lg font-semibold">无权访问管理后台</h2>
<p class="mb-5 text-sm text-surface-700">
你加入了 <strong>{denied.name}</strong>,角色是
<span class="saas-badge-neutral mx-1">{orgRoleLabel(denied.role)}</span>。仅所有者与管理员可进入后台。
</p>
{:else}
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-700">
即将进入 <strong>{adminOrgs()[0].name}</strong>
/{adminOrgs()[0].slug})。
</p>
<a class="saas-btn-primary" href={`/admin/org/${adminOrgs()[0].slug}`}>立即进入</a>
{/if}
<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}