feat(hub): migrate database admin pages to SPA (database-admin)

- scaffold hub/database-admin as SvelteKit 2 + Svelte 5 static SPA
  with aurora/glass visual style (paths.base='/database')
- add lib/{api,session,org}.ts + Aurora.svelte component
- add routes: root redirect, /admin login page, /dashboard (OWNER/ADMIN only)
- backend: replace server-rendered HTML routes with /database/config JSON endpoint
- add hub/src/database/static.ts to serve SPA under /database/*
- wire registerDatabaseSpa into plugin.ts
- exempt /database/* from silo rate-limit (same treatment as /admin/*)
- add database:dev + database:build npm scripts; update deploy scripts
- update hub/src/database/README.md

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-23 21:51:53 +08:00
parent 5df1900ca8
commit 12628c9233
30 changed files with 3546 additions and 247 deletions
@@ -0,0 +1,7 @@
<script lang="ts">
import './app.css';
let { children } = $props();
</script>
{@render children()}
@@ -0,0 +1,30 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api } from '$lib/api';
onMount(async () => {
// /database entry — route to dashboard if signed in, else the login page.
try {
await api.me();
await goto(`${base}/dashboard`, { replaceState: true });
} catch {
await goto(`${base}/admin`, { replaceState: true });
}
});
</script>
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-3 text-slate-400">
<svg class="h-7 w-7 animate-spin text-violet-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">正在进入…</p>
</div>
</div>
@@ -0,0 +1,78 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api, type DatabaseConfig } from '$lib/api';
import { feishuLoginHref } from '$lib/session';
import Aurora from '$lib/components/Aurora.svelte';
let config = $state<DatabaseConfig | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
// Already signed in → straight to the dashboard.
try {
await api.me();
await goto(`${base}/dashboard`, { replaceState: true });
return;
} catch {
// Not signed in (401) or backend unreachable — show the login card.
}
try {
config = await api.databaseConfig();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
});
const feishuHref = $derived(config ? feishuLoginHref(config.siloOrganizationSlug) : '#');
</script>
<Aurora />
<main class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass relative w-full max-w-sm rounded-3xl border border-white/80 p-8 shadow-2xl shadow-indigo-200/50">
<div class="mb-7 text-center">
<div
class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-cyan-400 text-2xl font-bold text-white glow-btn"
>
D
</div>
<h1 class="text-2xl font-bold tracking-tight grad-text">Database Admin</h1>
<p class="mt-2 text-sm text-slate-500">使用飞书登录以管理数据库</p>
</div>
{#if error}
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
无法连接后端:{error}
</div>
{/if}
<a
href={feishuHref}
aria-disabled={config ? 'false' : 'true'}
class="rise-2 glow-btn group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-3.5 text-sm font-semibold text-white transition hover:from-violet-400 hover:to-indigo-400 aria-disabled:pointer-events-none aria-disabled:opacity-50"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3Z" />
</svg>
使用飞书登录
</a>
{#if config?.devLoginEnabled}
<div class="relative my-6 rise-3">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center">
<span class="bg-white/70 px-3 text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">开发模式</span>
</div>
</div>
<a
href="/database/dev-login"
class="rise-3 group flex w-full items-center justify-center gap-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:bg-amber-100"
>
<span></span> 一键登录管理员
</a>
<p class="rise-3 mt-2 text-center text-xs text-slate-400">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
</div>
</main>
+96
View File
@@ -0,0 +1,96 @@
@import 'tailwindcss';
@source './**/*.{html,js,svelte,ts}';
@source '../lib/**/*.{html,js,svelte,ts}';
/*
* Design system for the database-admin SPA. Migrated verbatim (in spirit) from
* the previous server-rendered pages in hub/src/database/routes/databaseRoutes.ts:
* a light aurora / glassmorphism look with violet→cyan gradients and soft rise-in
* entrances. Distinct from admin-web's flat industrial theme on purpose.
*/
@theme {
--font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', 'Noto Sans SC', 'PingFang SC', sans-serif;
}
@keyframes aurora {
0% {
transform: translate(0, 0) scale(1);
}
33% {
transform: translate(6%, -8%) scale(1.15);
}
66% {
transform: translate(-8%, 6%) scale(0.9);
}
100% {
transform: translate(0, 0) scale(1);
}
}
@keyframes rise {
from {
opacity: 0;
transform: translateY(16px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@keyframes shimmer {
0% {
background-position: -200% 0;
}
100% {
background-position: 200% 0;
}
}
@layer base {
:root {
font-family: var(--font-sans);
}
html {
height: 100%;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer components {
.aurora {
filter: blur(80px);
animation: aurora 18s ease-in-out infinite;
}
.rise {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.rise-1 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.rise-2 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.1s both;
}
.rise-3 {
animation: rise 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.2s both;
}
.grad-text {
background: linear-gradient(120deg, #7c3aed, #0891b2, #4f46e5);
background-size: 200% auto;
-webkit-background-clip: text;
background-clip: text;
color: transparent;
animation: shimmer 6s linear infinite;
}
.glass {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
}
.glow-btn {
box-shadow: 0 12px 32px -10px rgba(124, 58, 237, 0.45);
}
}
@@ -0,0 +1,160 @@
<script lang="ts">
import { onMount } from 'svelte';
import { base } from '$app/paths';
import { session, loadSession, logout } from '$lib/session';
import { isOrgAdmin } from '$lib/org';
import Aurora from '$lib/components/Aurora.svelte';
onMount(() => {
loadSession();
});
// The session is scoped to the silo org, so /api/me returns exactly that org's
// membership. Admin gate: only OWNER/ADMIN may use the database console.
const me = $derived($session.me);
const org = $derived(me?.organizations[0] ?? null);
const allowed = $derived(isOrgAdmin(org));
const displayName = $derived(me?.user.displayName ?? '');
const initial = $derived(displayName.slice(0, 1) || 'U');
const navItems = [
{ label: '概览', href: `${base}/dashboard`, active: true, icon: 'M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z' },
{ label: '数据表', href: '#', active: false, icon: 'M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z' },
{ label: '查询', href: '#', active: false, icon: 'm21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z' },
{
label: '设置',
href: '#',
active: false,
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 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z',
},
];
const stats = [
{ label: '数据表', value: '—', accent: 'from-violet-200/60 to-transparent' },
{ label: '记录数', value: '—', accent: 'from-cyan-200/60 to-transparent' },
{ label: '最近查询', value: '—', accent: 'from-indigo-200/60 to-transparent' },
];
</script>
<Aurora />
{#if $session.loading}
<div class="flex min-h-screen items-center justify-center">
<div class="flex flex-col items-center gap-3 text-slate-400">
<svg class="h-8 w-8 animate-spin text-violet-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">加载中…</p>
</div>
</div>
{:else if $session.error}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass w-full max-w-sm rounded-3xl border border-white/80 p-8 text-center shadow-2xl shadow-indigo-200/50">
<h2 class="mb-2 text-lg font-bold text-slate-900">无法连接后端</h2>
<p class="mb-5 text-sm text-slate-500">{$session.error}</p>
<button
class="rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-2 text-sm font-semibold text-white"
onclick={() => loadSession()}>重试</button
>
</div>
</div>
{:else if !allowed}
<div class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass w-full max-w-md rounded-3xl border border-white/80 p-8 text-center shadow-2xl shadow-indigo-200/50">
<h2 class="mb-2 text-lg font-bold text-slate-900">无权访问</h2>
<p class="mb-5 text-sm text-slate-500">数据库管理台仅向组织所有者与管理员开放。</p>
<button
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:bg-slate-50"
onclick={() => logout()}>退出登录</button
>
</div>
</div>
{:else}
<div class="relative flex min-h-screen">
<!-- 左侧菜单栏 -->
<aside class="flex w-64 shrink-0 flex-col border-r border-slate-200/80 glass">
<div class="flex items-center gap-3 px-5 py-6">
<div
class="flex h-10 w-10 items-center justify-center rounded-xl bg-gradient-to-br from-violet-500 to-cyan-400 text-lg font-bold text-white glow-btn"
>
D
</div>
<span class="text-base font-bold grad-text">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-1.5 px-3 py-2">
{#each navItems as item}
<a
href={item.href}
class={item.active
? 'group flex items-center gap-3 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-3 py-2.5 text-sm font-semibold text-white shadow-lg shadow-indigo-300/50'
: 'group flex items-center gap-3 rounded-xl px-3 py-2.5 text-sm font-medium text-slate-500 transition hover:bg-slate-100 hover:text-slate-900'}
>
<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-3 flex items-center gap-3 rounded-xl border border-slate-200 bg-white/60 px-3 py-3">
<div
class="flex h-9 w-9 items-center justify-center rounded-full bg-gradient-to-br from-violet-500 to-indigo-500 text-sm font-semibold text-white"
>
{initial}
</div>
<div class="min-w-0">
<p class="text-[11px] uppercase tracking-wider text-slate-400">已登录</p>
<p class="truncate text-sm font-medium text-slate-700">{displayName}</p>
</div>
</div>
</aside>
<!-- 右侧内容 -->
<div class="flex flex-1 flex-col">
<header class="flex items-center justify-between border-b border-slate-200/80 glass px-8 py-4">
<div>
<h1 class="text-lg font-bold text-slate-900">概览</h1>
<p class="text-xs text-slate-400">欢迎回来,这里是数据库管理台</p>
</div>
<button
onclick={() => logout()}
class="rounded-xl border border-slate-200 bg-white px-4 py-2 text-sm font-medium text-slate-600 transition hover:border-slate-300 hover:bg-slate-50 hover:text-slate-900"
>
退出登录
</button>
</header>
<main class="flex-1 p-8">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
{#each stats as s, i}
<div
class="rise-{i +
1} group relative overflow-hidden rounded-2xl border border-white/80 glass p-5 shadow-lg shadow-slate-200/50 transition hover:-translate-y-0.5 hover:shadow-xl hover:shadow-indigo-200/50"
>
<div class="absolute inset-0 bg-gradient-to-br {s.accent} opacity-0 transition group-hover:opacity-100"></div>
<p class="relative text-sm text-slate-500">{s.label}</p>
<p class="relative mt-2 text-3xl font-bold text-slate-900">{s.value}</p>
</div>
{/each}
</div>
<div
class="rise-3 mt-6 flex h-64 items-center justify-center rounded-2xl border border-dashed border-slate-300 glass text-sm text-slate-400"
>
内容区占位 · 后续数据端点挂在 /database/* 并加 guard
</div>
</main>
</div>
</div>
{/if}