feat(admin-web): add folder and project creation functionality in FolderNode and FolderTree components

This commit is contained in:
2026-07-18 12:32:45 +08:00
parent b93acd8e8c
commit a12984d174
5 changed files with 95 additions and 22 deletions
+4
View File
@@ -34,6 +34,10 @@ HUB_FEISHU_EVENTS_PER_MINUTE="120"
# to this path and rejects any overlap before installing the unit. # to this path and rejects any overlap before installing the unit.
HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces" HUB_PROJECT_WORKSPACE_ROOT="/var/lib/cph-hub/workspaces"
# Persistent root for the content-addressed agent skill store. Required at hub
# startup unless XDG_STATE_HOME is set (then defaults to $XDG_STATE_HOME/skills).
HUB_SKILL_STORE_ROOT="/var/lib/cph-hub/state/skills"
# This process is pinned to exactly one Organization. Feishu credentials are # This process is pinned to exactly one Organization. Feishu credentials are
# resolved from that Organization's encrypted ACTIVE connection. # resolved from that Organization's encrypted ACTIVE connection.
HUB_SILO_ORGANIZATION_ID="" HUB_SILO_ORGANIZATION_ID=""
@@ -8,6 +8,8 @@
folders, folders,
projects, projects,
slug, slug,
onCreateFolder,
onCreateProject,
}: { }: {
folder: ExplorerFolder; folder: ExplorerFolder;
folders: ExplorerFolder[]; folders: ExplorerFolder[];
@@ -19,17 +21,16 @@
binding: { chatId: string } | null; binding: { chatId: string } | null;
}[]; }[];
slug: string; slug: string;
onCreateFolder?: (parentId: string) => void;
onCreateProject?: (folderId: string) => void;
} = $props(); } = $props();
let open = $state(true); let open = $state(true);
</script> </script>
<div> <div>
<button <div class="group flex w-full items-center gap-2.5 px-3 py-2.5 text-sm transition hover:bg-surface-100">
type="button" <button type="button" class="flex min-w-0 flex-1 items-center gap-2.5 text-left" onclick={() => (open = !open)}>
class="flex w-full items-center gap-2.5 px-3 py-2.5 text-left text-sm transition hover:bg-surface-100"
onclick={() => (open = !open)}
>
<span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span> <span class="w-3.5 text-center text-xs text-surface-600">{open ? '▾' : '▸'}</span>
<span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800"> <span class="flex h-7 w-7 items-center justify-center border border-warning-300 bg-warning-50 text-warning-800">
<Icon name="folder" class="h-4 w-4" /> <Icon name="folder" class="h-4 w-4" />
@@ -40,9 +41,38 @@
<span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span> <span class="saas-badge-neutral">{folder.childFolderCount} 子夹</span>
{/if} {/if}
</button> </button>
{#if onCreateFolder || onCreateProject}
<span
class="flex shrink-0 items-center gap-1 opacity-0 transition group-hover:opacity-100 focus-within:opacity-100"
>
{#if onCreateFolder}
<button
type="button"
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
title="在此新建子文件夹"
aria-label="在 {folder.name} 内新建子文件夹"
onclick={() => onCreateFolder(folder.id)}
>
<Icon name="folder-plus" class="h-4 w-4" />
</button>
{/if}
{#if onCreateProject}
<button
type="button"
class="flex h-6 w-6 items-center justify-center border border-surface-300 bg-surface-50 text-surface-600 transition hover:border-primary-400 hover:text-primary-700"
title="在此新建项目"
aria-label="在 {folder.name} 内新建项目"
onclick={() => onCreateProject(folder.id)}
>
<Icon name="file-plus" class="h-4 w-4" />
</button>
{/if}
</span>
{/if}
</div>
{#if open} {#if open}
<div class="ml-4 border-l border-surface-300 pl-2"> <div class="ml-4 border-l border-surface-300 pl-2">
<FolderTree {folders} {projects} parentId={folder.id} {slug} /> <FolderTree {folders} {projects} parentId={folder.id} {slug} {onCreateFolder} {onCreateProject} />
</div> </div>
{/if} {/if}
</div> </div>
@@ -9,6 +9,8 @@
projects, projects,
parentId, parentId,
slug, slug,
onCreateFolder,
onCreateProject,
}: { }: {
folders: ExplorerFolder[]; folders: ExplorerFolder[];
projects: { projects: {
@@ -20,6 +22,8 @@
}[]; }[];
parentId: string | null; parentId: string | null;
slug: string; slug: string;
onCreateFolder?: (parentId: string) => void;
onCreateProject?: (folderId: string) => void;
} = $props(); } = $props();
let childFolders = $derived(folders.filter((f) => f.parentId === parentId)); let childFolders = $derived(folders.filter((f) => f.parentId === parentId));
@@ -44,6 +48,6 @@
{/each} {/each}
{#each childFolders as f (f.id)} {#each childFolders as f (f.id)}
<FolderNode folder={f} {folders} {projects} {slug} /> <FolderNode folder={f} {folders} {projects} {slug} {onCreateFolder} {onCreateProject} />
{/each} {/each}
</div> </div>
@@ -18,6 +18,8 @@
| 'arrow-left' | 'arrow-left'
| 'folder' | 'folder'
| 'file' | 'file'
| 'folder-plus'
| 'file-plus'
| 'check' | 'check'
| 'roles'; | 'roles';
class?: string; class?: string;
@@ -122,6 +124,22 @@
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z" d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m0 12.75h7.5m-7.5 3H12M10.5 2.25H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/> />
</svg> </svg>
{:else if name === 'folder-plus'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M12 10.5v6m3-3H9m4.06-7.19l-2.12-2.12a1.5 1.5 0 00-1.061-.44H4.5A2.25 2.25 0 002.25 6v12a2.25 2.25 0 002.25 2.25h15A2.25 2.25 0 0021.75 18V9a2.25 2.25 0 00-2.25-2.25h-5.379a1.5 1.5 0 01-1.06-.44z"
/>
</svg>
{:else if name === 'file-plus'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M19.5 14.25v-2.625a3.375 3.375 0 00-3.375-3.375h-1.5A1.125 1.125 0 0113.5 7.125v-1.5a3.375 3.375 0 00-3.375-3.375H8.25m3.75 9v6m3-3H9m1.5-12H5.625c-.621 0-1.125.504-1.125 1.125v17.25c0 .621.504 1.125 1.125 1.125h12.75c.621 0 1.125-.504 1.125-1.125V11.25a9 9 0 00-9-9z"
/>
</svg>
{:else if name === 'check'} {:else if name === 'check'}
<svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75"> <svg class={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75">
<path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" /> <path stroke-linecap="round" stroke-linejoin="round" d="M4.5 12.75l6 6 9-13.5" />
@@ -14,9 +14,7 @@
import { toastError, toastSuccess } from '$lib/toast'; import { toastError, toastSuccess } from '$lib/toast';
const slug = $derived(page.params.slug ?? ''); const slug = $derived(page.params.slug ?? '');
const org = $derived( const org = $derived(($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null);
($session.me?.organizations.find((o) => o.slug === slug) as OrgMembership | undefined) ?? null,
);
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN')); const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null); let data = $state<ExplorerData | null>(null);
@@ -32,6 +30,18 @@
let projectName = $state(''); let projectName = $state('');
let projectFolder = $state(''); let projectFolder = $state('');
function openFolderModal(parentId: string) {
folderName = '';
folderParent = parentId;
showFolderModal = true;
}
function openProjectModal(folderId: string) {
projectName = '';
projectFolder = folderId;
showProjectModal = true;
}
async function load() { async function load() {
loading = true; loading = true;
error = null; error = null;
@@ -113,8 +123,8 @@
{:else if isAdmin && data} {:else if isAdmin && data}
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。"> <PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
{#snippet actions()} {#snippet actions()}
<button class="saas-btn-secondary" onclick={() => (showFolderModal = true)}>新建文件夹</button> <button class="saas-btn-secondary" onclick={() => openFolderModal('')}>新建文件夹</button>
<button class="saas-btn-primary" onclick={() => (showProjectModal = true)}>新建项目</button> <button class="saas-btn-primary" onclick={() => openProjectModal('')}>新建项目</button>
{/snippet} {/snippet}
</PageHeader> </PageHeader>
@@ -122,7 +132,14 @@
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0} {#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" /> <EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
{:else} {:else}
<FolderTree folders={data.folders} projects={data.projects} parentId={null} {slug} /> <FolderTree
folders={data.folders}
projects={data.projects}
parentId={null}
{slug}
onCreateFolder={openFolderModal}
onCreateProject={openProjectModal}
/>
{/if} {/if}
</div> </div>