forked from EduCraft/curriculum-project-hub
79f72ecca8
Add full skill lifecycle to the org-admin web surface: create, read, edit, disable. Skills are directories (SKILL.md manifest + supporting files), content-addressed by SHA-256 in an immutable store. Backend: - skillStore: extract commitSkillContent (shared populate→inspect→ dedup→atomic rename); add importSkillFromFiles (in-memory file list ingestion) and readSkillFiles (read stored version back as UTF-8) - configuration: add installSkillFromFiles, readSkillFiles, disableSkill (soft-delete + archive bound role sessions), updateSkillDescription (label-only, no archival); refactor installSkill to share commitInstalledSkill - agentConfigRoutes: wire skillStoreRoot; add GET /agent-skills/:name/files, PUT /agent-skills/:name (create/replace), PATCH /agent-skills/:name (description/disable) - orgRoutes: pass readSkillStoreRoot() to agent config routes Frontend: - api.ts: agentSkillFiles, installAgentSkill, patchAgentSkill methods - SkillEditor.svelte: file tree + text editor + version/description form - skills/+page.svelte: skill list, create form (generates SKILL.md template), per-skill editor - layout: add 技能 nav item ADR-0018: update Decision to reflect web surface joining host-console CLI in the shared content-addressed ingestion pipeline. Spec (AgentRole.lean): unchanged — storage mechanism is OPEN, web installation is one implementation of it.
388 lines
13 KiB
Svelte
388 lines
13 KiB
Svelte
<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: '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 },
|
||
];
|
||
|
||
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 isOnProjectRoute(): boolean {
|
||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||
return parts[0] === 'admin' && parts[1] === 'org' && parts[3] === 'projects';
|
||
}
|
||
|
||
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 slug = orgSlugFromPath();
|
||
const matched = slug ? memberships().find((o) => o.slug === slug) : null;
|
||
|
||
// Org admins: route to their first admin org if none matched as admin.
|
||
const admins = adminOrgs();
|
||
if (matched && isAdmin(matched)) {
|
||
redirecting = false;
|
||
return;
|
||
}
|
||
if (!matched && admins.length > 0) {
|
||
const target = `/admin/org/${admins[0].slug}`;
|
||
if (page.url.pathname !== target && !page.url.pathname.startsWith(`${target}/`)) {
|
||
redirecting = true;
|
||
void goto(target, { replaceState: true });
|
||
}
|
||
return;
|
||
}
|
||
|
||
// Members (non-admin): project pages are open to project MANAGE holders;
|
||
// the org overview and other admin-only surfaces are not for them.
|
||
if (matched && !isAdmin(matched)) {
|
||
redirecting = false;
|
||
const parts = page.url.pathname.split('/').filter(Boolean);
|
||
const onOverview = parts.length === 3; // /admin/org/:slug
|
||
if (onOverview) {
|
||
const target = `/admin/org/${matched.slug}/projects`;
|
||
if (page.url.pathname !== target) {
|
||
redirecting = true;
|
||
void goto(target, { replaceState: true });
|
||
}
|
||
}
|
||
return;
|
||
}
|
||
|
||
// No matched org and no admin orgs: route a member to their first org's
|
||
// projects page so they can reach project MANAGE surfaces.
|
||
if (!matched && memberships().length > 0) {
|
||
const home = memberships()[0];
|
||
const target = `/admin/org/${home.slug}/projects`;
|
||
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()!) && isOnProjectRoute()}
|
||
{@const org = currentOrg()!}
|
||
{@const me = $session.me!}
|
||
<div class="saas-shell">
|
||
<div class="saas-main">
|
||
<header class="saas-topbar">
|
||
<a href={`/admin/org/${org.slug}/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() && !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>。普通成员仅可访问自己有授权的项目。
|
||
</p>
|
||
<a class="saas-btn-primary" href={`/admin/org/${denied.slug}/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 = pickHomeOrg()!}
|
||
<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={`/admin/org/${denied.slug}/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}
|