forked from EduCraft/curriculum-project-hub
12628c9233
- 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>
161 lines
6.5 KiB
Svelte
161 lines
6.5 KiB
Svelte
<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}
|