forked from bai/curriculum-project-hub
7f09fb1f13
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>
224 lines
6.8 KiB
Svelte
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}
|