feat: add org admin SPA for models, roles and provider

Introduce admin-web (Skeleton/SvelteKit), Prisma models for provider connection / OrgModel / OrgRole, DB-backed runtime settings, and admin API routes so org admins can manage agent configuration end-to-end.
This commit is contained in:
2026-07-11 12:33:56 +08:00
parent 461d2e89b0
commit 552c1c353e
50 changed files with 6986 additions and 151 deletions
+321
View File
@@ -0,0 +1,321 @@
<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 Icon from '$lib/components/Icon.svelte';
import ToastHost from '$lib/components/ToastHost.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: 'models', label: '模型', icon: 'models' as const },
{ key: 'roles', label: '角色', icon: 'roles' as const },
{ key: 'provider', label: 'Provider', 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);
}
/** Org matching the URL slug (if any). */
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 onOrgSwitch(e: Event) {
const sel = e.target as HTMLSelectElement;
void goto(`/admin/org/${sel.value}`);
}
function handleLogout(e: Event) {
e.preventDefault();
logout();
}
// Close mobile nav on route change.
$effect(() => {
page.url.pathname;
mobileNavOpen = false;
});
// If the user has admin rights but the URL is not under a valid org slug,
// send them to their home org. Fixes false "未加入组织" on / or wrong paths.
$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;
}
// Only auto-redirect when URL is not already a matched *member* org
// (member orgs keep their own denied screen). Missing / unknown slug → home.
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-400">
<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 rounded-2xl bg-error-100 text-error-600">!</div>
<h2 class="mb-1 text-lg font-semibold">无法连接到后端</h2>
<p class="mb-5 text-sm text-surface-500">{$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 rounded-2xl bg-primary-600 text-white font-bold">
CPH
</div>
<h1 class="text-xl font-semibold tracking-tight">Curriculum Project Hub</h1>
<p class="mt-2 mb-6 text-sm text-surface-500">登录以管理组织、项目、团队与模型供应。</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 rounded-xl 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">Org Admin</div>
<div class="truncate text-xs text-surface-400">Curriculum Hub</div>
</div>
</div>
<div class="px-3 pb-3">
<label for="org-sel" class="mb-1.5 flex items-center gap-1.5 text-xs font-medium text-surface-500">
<Icon name="org" class="h-3.5 w-3.5" />
组织
</label>
<select id="org-sel" class="saas-select text-sm" value={org.slug} onchange={onOrgSwitch}>
{#each me.organizations as o}
<option value={o.slug}>{o.name} · {o.role}</option>
{/each}
</select>
</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-400">工作台</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-200 p-3">
<div class="flex items-center gap-2.5 rounded-xl bg-surface-100/80 px-2.5 py-2">
{#if me.user.avatarUrl}
<img src={me.user.avatarUrl} alt="" class="h-8 w-8 rounded-full object-cover" />
{:else}
<div class="flex h-8 w-8 items-center justify-center rounded-full bg-primary-100 text-xs font-semibold text-primary-700">
{me.user.displayName.slice(0, 1)}
</div>
{/if}
<div class="min-w-0 flex-1">
<div class="truncate text-sm font-medium text-surface-800">{me.user.displayName}</div>
<div class="truncate text-[11px] text-surface-400">{org.role}</div>
</div>
<button
type="button"
class="rounded-lg p-1.5 text-surface-400 transition hover:bg-surface-200 hover:text-error-600"
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-400">
<span class="truncate">{org.name}</span>
<span>/</span>
<span class="truncate text-surface-600">{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-500">
组织 <strong>{denied.name}</strong>/{denied.slug})中你的角色是
<span class="saas-badge-neutral mx-1">{denied.role}</span>
需要 <strong>OWNER</strong><strong>ADMIN</strong>
</p>
{#if memberships().length > 1}
<label for="org-switch" class="saas-label text-left">切换到其他组织</label>
<select id="org-switch" class="saas-select mb-4" onchange={onOrgSwitch}>
{#each memberships() as o}
<option value={o.slug} selected={o.slug === denied.slug}>{o.name} · {o.role}</option>
{/each}
</select>
{/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-500">
你加入了 <strong>{denied.name}</strong>,角色是
<span class="saas-badge-neutral mx-1">{denied.role}</span>。仅 OWNER/ADMIN 可进入后台。
</p>
{:else}
<h2 class="mb-2 text-lg font-semibold">正在跳转…</h2>
<p class="mb-5 text-sm text-surface-500">
即将进入 <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-500">
飞书账号已登录,但 <code class="font-mono">/api/me</code>
<code class="font-mono">organizations</code> 为空。
</p>
<p class="mb-5 text-left text-xs text-surface-400">
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}