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

224 lines
6.8 KiB
Svelte

<script lang="ts">
import { page } from '$app/state';
import { api, type ExplorerData, type ExplorerFolder, type ExplorerProject, type OrgMembership } from '$lib/api';
import { session } from '$lib/session';
import { resolveOrg } from '$lib/org';
import FolderTree from '$lib/components/FolderTree.svelte';
import PageHeader from '$lib/components/PageHeader.svelte';
import LoadingState from '$lib/components/LoadingState.svelte';
import ErrorBanner from '$lib/components/ErrorBanner.svelte';
import EmptyState from '$lib/components/EmptyState.svelte';
import { Label } from 'bits-ui';
import Modal from '$lib/components/Modal.svelte';
import SelectField from '$lib/components/SelectField.svelte';
import { fmtDate } from '$lib/format';
import { toastError, toastSuccess } from '$lib/toast';
const org = $derived(resolveOrg($session.me, page.url.search));
const slug = $derived(org?.slug ?? '');
const isAdmin = $derived(!!org && (org.role === 'OWNER' || org.role === 'ADMIN'));
let data = $state<ExplorerData | null>(null);
let myProjects = $state<ExplorerProject[] | null>(null);
let loading = $state(true);
let error = $state<string | null>(null);
let showFolderModal = $state(false);
let folderName = $state('');
let folderParent = $state('');
let showProjectModal = $state(false);
let projectName = $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() {
loading = true;
error = null;
try {
if (isAdmin) {
data = await api.explorer(slug);
myProjects = null;
} else {
myProjects = (await api.myProjects(slug)).projects;
data = null;
}
} catch (err) {
error = err instanceof Error ? err.message : String(err);
} finally {
loading = false;
}
}
async function createFolder() {
if (!folderName.trim()) return;
try {
await api.createFolder(slug, {
name: folderName.trim(),
...(folderParent ? { parentId: folderParent } : {}),
});
folderName = '';
folderParent = '';
showFolderModal = false;
await load();
toastSuccess('文件夹已创建');
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
async function createProject() {
if (!projectName.trim()) return;
try {
const res = await api.createProject(slug, {
name: projectName.trim(),
...(projectFolder ? { folderId: projectFolder } : {}),
});
projectName = '';
projectFolder = '';
showProjectModal = false;
window.location.href = `/admin/projects/${res.id}`;
} catch (err) {
toastError(err instanceof Error ? err.message : String(err));
}
}
function folderPath(f: ExplorerFolder): string {
if (!data) return f.name;
const parts: string[] = [f.name];
let cur: ExplorerFolder | undefined = f;
while (cur?.parentId) {
const parent = data.folders.find((x) => x.id === cur!.parentId);
if (!parent) break;
parts.unshift(parent.name);
cur = parent;
}
return parts.join(' / ');
}
function folderItems() {
if (!data) return [{ value: '', label: '(根)' }];
return [{ value: '', label: '(根)' }, ...data.folders.map((f) => ({ value: f.id, label: folderPath(f) }))];
}
$effect(() => {
if (slug && org) load();
});
</script>
{#if loading}
<LoadingState />
{:else if error}
<ErrorBanner message={error} onretry={load} />
{:else if isAdmin && data}
<PageHeader title="项目" description="文件夹是透明组织节点;项目是权限边界。">
{#snippet actions()}
<button class="saas-btn-secondary" onclick={() => openFolderModal('')}>新建文件夹</button>
<button class="saas-btn-primary" onclick={() => openProjectModal('')}>新建项目</button>
{/snippet}
</PageHeader>
<div class="saas-card p-2 sm:p-3">
{#if data.projects.filter((p) => !p.folderId).length === 0 && data.folders.filter((f) => !f.parentId).length === 0}
<EmptyState title="暂无项目" description="新建文件夹或项目,开始组织你的教研资产。" />
{:else}
<FolderTree
folders={data.folders}
projects={data.projects}
parentId={null}
{slug}
onCreateFolder={openFolderModal}
onCreateProject={openProjectModal}
/>
{/if}
</div>
<Modal bind:open={showFolderModal} title="新建文件夹">
<Label.Root class="saas-label" for="folder-name">名称</Label.Root>
<input
id="folder-name"
class="saas-input mb-4"
bind:value={folderName}
onkeydown={(e) => {
if (e.key === 'Enter') createFolder();
}}
/>
{#if data && data.folders.length > 0}
<p class="saas-label">父文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={folderParent} />
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showFolderModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={createFolder}>创建</button>
</div>
</Modal>
<Modal bind:open={showProjectModal} title="新建项目">
<Label.Root class="saas-label" for="project-name">项目名</Label.Root>
<input
id="project-name"
class="saas-input mb-4"
bind:value={projectName}
onkeydown={(e) => {
if (e.key === 'Enter') createProject();
}}
/>
{#if data && data.folders.length > 0}
<p class="saas-label">文件夹(可选)</p>
<div class="mb-4">
<SelectField items={folderItems()} bind:value={projectFolder} />
</div>
{/if}
<div class="flex justify-end gap-2">
<button class="saas-btn-ghost" onclick={() => (showProjectModal = false)}>取消</button>
<button class="saas-btn-primary" onclick={createProject}>创建</button>
</div>
</Modal>
{:else if myProjects !== null}
<PageHeader title="我的项目" description="你拥有访问授权的项目。">
{#snippet actions()}
<span class="saas-badge-neutral">仅显示已授权项目</span>
{/snippet}
</PageHeader>
<div class="saas-card overflow-hidden">
{#if myProjects.length === 0}
<EmptyState title="暂无可访问项目" description="当团队被授予项目访问权限时,项目会出现在这里。" />
{:else}
<table class="data-table">
<thead>
<tr>
<th>项目</th>
<th>飞书群</th>
<th>创建时间</th>
</tr>
</thead>
<tbody>
{#each myProjects as p}
<tr class="cursor-pointer" onclick={() => (window.location.href = `/admin/projects/${p.id}`)}>
<td class="font-medium">{p.name}</td>
<td class="font-mono text-xs">{p.binding ? ` ${p.binding.chatId}` : '—'}</td>
<td class="text-surface-700">{fmtDate(p.createdAt)}</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{:else}
<EmptyState title="项目数据不可用" description="无法加载项目列表。" />
{/if}