feat(filelib-web): 把 /database 各页从后端 HTML 拼接迁到 SvelteKit 路由

登录页、后台外壳与六个 tab 全部成为客户端路由:
  /database/admin              登录(迁自 renderLoginPage)
  /database/dashboard          概览(迁自 renderDashboard)
  .../library .../users .../groups .../search .../settings

六个 tab 是真 URL,不再是 location.hash + display:none —— 刷新不丢
位置,链接可分享。

BrowserShell 拆成 LibraryView,加 showUserFooter:老师端 /app 显示
身份/登出页脚,后台的文件库 tab 不显示(外层已有身份区)。

bootstrap 走 /database/config 而非 /database/api/login-info:后者由
teacherApp 在 silo org 查找成功后才注册,前者无条件注册,登录页在
org 未就绪时也必须能拿到配置。

后端拥有的链接(OAuth、DEV 一键登录)标 data-sveltekit-reload,
否则被客户端路由拦下。
This commit is contained in:
2026-07-26 20:17:43 +08:00
parent de9f846fd0
commit 3d0f4e5c2d
17 changed files with 576 additions and 61 deletions
@@ -0,0 +1,99 @@
<script lang="ts">
/**
* 管理后台外壳(迁自后端 renderDashboard 的侧栏 + 身份区)。
*
* 与旧实现的区别:六个 tab 是真 URL 路由(/database/dashboard/library 等),
* 不再是 location.hash + display:none 切换 —— 刷新不丢位置,链接可分享。
*
* 权限门:未登录跳 /database/admin;登录但非 OWNER/ADMIN(isWebsiteAdmin)
* 显示无权提示。语义与 ADR-0028 一致 —— 管理面板要求 silo org 的 OWNER/ADMIN。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { me, authChecked } from "$lib/stores.js";
import { loadSession, logout } from "$lib/session.js";
import Avatar from "$lib/Avatar.svelte";
let { children } = $props();
const NAV = [
{ seg: "", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ seg: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" },
{ seg: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" },
{ seg: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" },
{ seg: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ seg: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" },
] as const;
const BASE = "/database/dashboard";
onMount(async () => {
await loadSession();
if ($me === null) void goto("/database/admin", { replaceState: true });
});
function href(seg: string): string {
return seg === "" ? BASE : `${BASE}/${seg}`;
}
function isActive(seg: string): boolean {
const path = page.url.pathname.replace(/\/$/, "");
return seg === "" ? path === BASE : path === `${BASE}/${seg}`;
}
</script>
<svelte:head><title>Database Admin</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">加载中…</div>
{:else if $me === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转到登录页…</div>
{:else if !$me.isWebsiteAdmin}
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[420px] rounded-2xl border border-line-soft bg-panel p-9 text-center shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<h2 class="mb-2 text-lg font-semibold text-ink">无权访问管理后台</h2>
<p class="mb-6 text-[13px] text-ink-3">
当前账号不是本组织的所有者或管理员。普通老师请到文件库使用。
</p>
<a href="/app" class="btn btn-primary justify-center">前往文件库</a>
<button class="btn mt-3 w-full justify-center" onclick={logout}>退出登录</button>
</div>
</div>
{:else}
<div class="flex h-full">
<aside class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="border-b border-line-soft px-4 py-4">
<span class="text-[15px] font-semibold text-ink">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-0.5 p-2.5">
{#each NAV as item (item.seg)}
{@const active = isActive(item.seg)}
<a
href={href(item.seg)}
class="flex items-center gap-2.5 rounded-[10px] px-3.5 py-2 text-[13px] transition"
class:bg-selected={active}
class:text-ink={active}
class:font-semibold={active}
class:text-ink-3={!active}
class:hover:bg-hover={!active}
>
<svg class="h-4 w-4 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d={item.icon} /></svg>
{item.label}
</a>
{/each}
</nav>
<div class="m-2.5 flex items-center gap-2.5 border-t border-line-soft px-3 py-2.5">
<Avatar displayName={$me.displayName} userId={$me.userId} avatarUrl={$me.avatarUrl} size={26} />
<p class="min-w-0 flex-1 truncate text-[12.5px] text-ink" title={$me.userId}>{$me.displayName}</p>
<button class="btn !px-2.5 !py-[3px] !text-[11px]" onclick={logout}>退出</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
{@render children()}
</div>
</div>
{/if}