forked from EduCraft/curriculum-project-hub
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:
@@ -0,0 +1,23 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Output
|
||||||
|
.output
|
||||||
|
.vercel
|
||||||
|
.netlify
|
||||||
|
.wrangler
|
||||||
|
/.svelte-kit
|
||||||
|
/build
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Env
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
!.env.example
|
||||||
|
!.env.test
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
vite.config.js.timestamp-*
|
||||||
|
vite.config.ts.timestamp-*
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
engine-strict=true
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
build
|
||||||
|
.svelte-kit
|
||||||
|
node_modules
|
||||||
|
package-lock.json
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"useTabs": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"semi": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 120,
|
||||||
|
"plugins": ["prettier-plugin-svelte"],
|
||||||
|
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||||
|
}
|
||||||
Generated
+2660
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,33 @@
|
|||||||
|
{
|
||||||
|
"name": "database-admin",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.1",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite dev",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"prepare": "svelte-kit sync || echo ''",
|
||||||
|
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||||
|
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||||
|
"format": "prettier --write .",
|
||||||
|
"format:check": "prettier --check ."
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@skeletonlabs/skeleton": "^4.15.2",
|
||||||
|
"@skeletonlabs/skeleton-svelte": "^4.15.2",
|
||||||
|
"@sveltejs/adapter-auto": "^7.0.1",
|
||||||
|
"@sveltejs/adapter-static": "^3.0.10",
|
||||||
|
"@sveltejs/kit": "^2.63.0",
|
||||||
|
"@sveltejs/vite-plugin-svelte": "^7.1.2",
|
||||||
|
"@tailwindcss/vite": "^4.3.2",
|
||||||
|
"bits-ui": "^2.18.1",
|
||||||
|
"prettier": "^3.9.5",
|
||||||
|
"prettier-plugin-svelte": "^4.1.1",
|
||||||
|
"svelte": "^5.56.1",
|
||||||
|
"svelte-check": "^4.6.0",
|
||||||
|
"tailwindcss": "^4.3.2",
|
||||||
|
"typescript": "^6.0.3",
|
||||||
|
"vite": "^8.0.16"
|
||||||
|
}
|
||||||
|
}
|
||||||
Vendored
+13
@@ -0,0 +1,13 @@
|
|||||||
|
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||||
|
// for information about these interfaces
|
||||||
|
declare global {
|
||||||
|
namespace App {
|
||||||
|
// interface Error {}
|
||||||
|
// interface Locals {}
|
||||||
|
// interface PageData {}
|
||||||
|
// interface PageState {}
|
||||||
|
// interface Platform {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export {};
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<meta name="description" content="Curriculum Project Hub — 数据库管理后台" />
|
||||||
|
<link rel="icon" href="%sveltekit.assets%/favicon.svg" type="image/svg+xml" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<style>
|
||||||
|
/* Fallback before CSS bundle */
|
||||||
|
html {
|
||||||
|
font-family: 'Inter', system-ui, 'Noto Sans SC', 'PingFang SC', sans-serif;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<title>Database Admin</title>
|
||||||
|
%sveltekit.head%
|
||||||
|
</head>
|
||||||
|
<body
|
||||||
|
data-sveltekit-preload-data="hover"
|
||||||
|
class="relative min-h-screen overflow-x-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-700"
|
||||||
|
>
|
||||||
|
<div style="display: contents">%sveltekit.body%</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,76 @@
|
|||||||
|
/**
|
||||||
|
* Thin API client for the database-admin backend. Same-origin cookie auth,
|
||||||
|
* reusing the platform session (`cph_session`) and the admin plane's /api/me.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export class ApiError extends Error {
|
||||||
|
code: string;
|
||||||
|
status: number;
|
||||||
|
constructor(code: string, message: string, status: number) {
|
||||||
|
super(message);
|
||||||
|
this.name = 'ApiError';
|
||||||
|
this.code = code;
|
||||||
|
this.status = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function request(method: string, url: string, body?: unknown): Promise<unknown> {
|
||||||
|
const init: RequestInit = {
|
||||||
|
method,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: body !== undefined ? { 'content-type': 'application/json' } : undefined,
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
};
|
||||||
|
const res = await fetch(url, init);
|
||||||
|
const text = await res.text();
|
||||||
|
let data: unknown = null;
|
||||||
|
if (text !== '') {
|
||||||
|
try {
|
||||||
|
data = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
data = text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = (data as { error?: { code?: string; message?: string } } | null)?.error;
|
||||||
|
throw new ApiError(err?.code ?? 'http_error', err?.message ?? `HTTP ${res.status}`, res.status);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const get = (u: string) => request('GET', u);
|
||||||
|
const post = (u: string, b?: unknown) => request('POST', u, b);
|
||||||
|
|
||||||
|
// --- Types ---
|
||||||
|
|
||||||
|
export interface OrgMembership {
|
||||||
|
id: string;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
role: 'OWNER' | 'ADMIN' | 'MEMBER';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MeResponse {
|
||||||
|
user: {
|
||||||
|
id: string;
|
||||||
|
feishuOpenId: string;
|
||||||
|
displayName: string;
|
||||||
|
avatarUrl: string | null;
|
||||||
|
};
|
||||||
|
organizations: OrgMembership[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Unauthenticated bootstrap the login page needs: which org to OAuth against + dev toggle. */
|
||||||
|
export interface DatabaseConfig {
|
||||||
|
siloOrganizationSlug: string;
|
||||||
|
devLoginEnabled: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- API ---
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
me: () => get('/api/me') as Promise<MeResponse>,
|
||||||
|
logout: () => post('/auth/logout'),
|
||||||
|
databaseConfig: () => get('/database/config') as Promise<DatabaseConfig>,
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<!-- Animated aurora background blobs, shared by both pages (soft pastels on light). -->
|
||||||
|
<div class="pointer-events-none fixed inset-0 overflow-hidden">
|
||||||
|
<div class="aurora absolute -left-32 -top-32 h-96 w-96 rounded-full bg-violet-300/50"></div>
|
||||||
|
<div class="aurora absolute right-0 top-1/4 h-96 w-96 rounded-full bg-cyan-300/40" style="animation-delay:-6s"></div>
|
||||||
|
<div class="aurora absolute bottom-0 left-1/3 h-96 w-96 rounded-full bg-indigo-300/40" style="animation-delay:-12s"></div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import type { OrgMembership } from './api';
|
||||||
|
|
||||||
|
export function isOrgAdmin(org: OrgMembership | null | undefined): boolean {
|
||||||
|
if (!org) return false;
|
||||||
|
const role = String(org.role ?? '').toUpperCase();
|
||||||
|
return role === 'OWNER' || role === 'ADMIN';
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { goto } from '$app/navigation';
|
||||||
|
import { base } from '$app/paths';
|
||||||
|
import { api, type MeResponse } from './api';
|
||||||
|
|
||||||
|
interface SessionState {
|
||||||
|
loading: boolean;
|
||||||
|
me: MeResponse | null;
|
||||||
|
error: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const session = writable<SessionState>({
|
||||||
|
loading: true,
|
||||||
|
me: null,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
|
||||||
|
export async function loadSession(): Promise<void> {
|
||||||
|
session.update((s) => ({ ...s, loading: true, error: null }));
|
||||||
|
try {
|
||||||
|
const me = await api.me();
|
||||||
|
session.set({ loading: false, me, error: null });
|
||||||
|
} catch (err) {
|
||||||
|
const status = (err as { status?: number }).status;
|
||||||
|
if (status === 401) {
|
||||||
|
session.set({ loading: false, me: null, error: null });
|
||||||
|
void redirectToLogin();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
session.set({
|
||||||
|
loading: false,
|
||||||
|
me: null,
|
||||||
|
error: err instanceof Error ? err.message : String(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send the browser to the login page (`/database/admin`). Unlike admin-web we
|
||||||
|
* don't jump straight to Feishu OAuth: the login page reads /database/config
|
||||||
|
* to build the org-scoped link and to show the dev bypass when enabled.
|
||||||
|
*/
|
||||||
|
export async function redirectToLogin(): Promise<void> {
|
||||||
|
const loginPath = `${base}/admin`;
|
||||||
|
if (window.location.pathname === loginPath) return;
|
||||||
|
await goto(loginPath, { replaceState: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build the org-scoped Feishu OAuth entry point (a backend route, not under base). */
|
||||||
|
export function feishuLoginHref(orgSlug: string, returnTo: string = `${base}/dashboard`): string {
|
||||||
|
return `/auth/feishu/${encodeURIComponent(orgSlug)}?returnTo=${encodeURIComponent(returnTo)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function logout(): Promise<void> {
|
||||||
|
await api.logout();
|
||||||
|
session.set({ loading: false, me: null, error: null });
|
||||||
|
await redirectToLogin();
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
@@ -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}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32" fill="none">
|
||||||
|
<rect width="32" height="32" rx="8" fill="#4F46E5"/>
|
||||||
|
<path d="M8 10.5h7.5a3.5 3.5 0 0 1 0 7H11v4H8v-11Zm3 4.5h4.5a1.5 1.5 0 0 0 0-3H11v3Z" fill="white"/>
|
||||||
|
<path d="M20.5 21.5c1.93 0 3.5-1.34 3.5-3s-1.57-3-3.5-3S17 16.84 17 18.5s1.57 3 3.5 3Z" fill="white" opacity=".9"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 353 B |
@@ -0,0 +1,3 @@
|
|||||||
|
# allow crawling everything by default
|
||||||
|
User-agent: *
|
||||||
|
Disallow:
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import adapter from '@sveltejs/adapter-static';
|
||||||
|
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
|
||||||
|
|
||||||
|
/** @type {import('@sveltejs/kit').Config} */
|
||||||
|
const config = {
|
||||||
|
preprocess: vitePreprocess(),
|
||||||
|
kit: {
|
||||||
|
adapter: adapter({
|
||||||
|
pages: 'build',
|
||||||
|
assets: 'build',
|
||||||
|
fallback: 'index.html',
|
||||||
|
precompress: false,
|
||||||
|
strict: false,
|
||||||
|
}),
|
||||||
|
// This SPA is served under /database by the Hub. admin-web owns the
|
||||||
|
// root asset paths (/_app, /favicon.svg); a base path moves this app's
|
||||||
|
// assets to /database/_app/* so the two builds never collide.
|
||||||
|
paths: { base: '/database' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export default config;
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"extends": "./.svelte-kit/tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"rewriteRelativeImportExtensions": true,
|
||||||
|
"allowJs": true,
|
||||||
|
"checkJs": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"forceConsistentCasingInFileNames": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"sourceMap": true,
|
||||||
|
"strict": true,
|
||||||
|
"moduleResolution": "bundler"
|
||||||
|
}
|
||||||
|
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||||
|
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||||
|
//
|
||||||
|
// To make changes to top-level options such as include and exclude, we recommend extending
|
||||||
|
// the generated config; see https://svelte.dev/docs/kit/configuration#typescript
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { sveltekit } from '@sveltejs/kit/vite';
|
||||||
|
import tailwindcss from '@tailwindcss/vite';
|
||||||
|
import { defineConfig } from 'vite';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [tailwindcss(), sveltekit()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://127.0.0.1:8788',
|
||||||
|
'/auth': 'http://127.0.0.1:8788',
|
||||||
|
// Backend owns the /database/* HTTP surface (login page, dashboard,
|
||||||
|
// data routes). Proxy it in dev so those paths hit the real server
|
||||||
|
// instead of the SPA fallback.
|
||||||
|
'/database': 'http://127.0.0.1:8788',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -82,10 +82,11 @@ REMOTE
|
|||||||
rsync -az --delete \
|
rsync -az --delete \
|
||||||
--exclude node_modules --exclude dist --exclude .env \
|
--exclude node_modules --exclude dist --exclude .env \
|
||||||
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
|
--exclude admin-web/node_modules --exclude admin-web/build --exclude admin-web/.svelte-kit \
|
||||||
|
--exclude database-admin/node_modules --exclude database-admin/build --exclude database-admin/.svelte-kit \
|
||||||
-e "ssh ${SSH_OPTS[*]}" \
|
-e "ssh ${SSH_OPTS[*]}" \
|
||||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||||
|
|
||||||
echo "[fleet] npm ci + build (tsc + admin-web SPA)"
|
echo "[fleet] npm ci + build (tsc + admin-web & database-admin SPAs)"
|
||||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
|
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
flock /var/lock/cph-hub-release-publish bash -c '
|
flock /var/lock/cph-hub-release-publish bash -c '
|
||||||
@@ -97,9 +98,11 @@ flock /var/lock/cph-hub-release-publish bash -c '
|
|||||||
cd "$HUB_DIR"
|
cd "$HUB_DIR"
|
||||||
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
|
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
|
||||||
npm ci --prefix admin-web
|
npm ci --prefix admin-web
|
||||||
|
npm ci --prefix database-admin
|
||||||
npm run audit:production
|
npm run audit:production
|
||||||
npm run build
|
npm run build
|
||||||
test -f admin-web/build/index.html
|
test -f admin-web/build/index.html
|
||||||
|
test -f database-admin/build/index.html
|
||||||
touch "$RELEASE_DIR/.complete"
|
touch "$RELEASE_DIR/.complete"
|
||||||
'
|
'
|
||||||
REMOTE
|
REMOTE
|
||||||
|
|||||||
@@ -60,10 +60,11 @@ if [ "$release_ready" = false ]; then
|
|||||||
-e "ssh ${SSH_OPTS[*]}" \
|
-e "ssh ${SSH_OPTS[*]}" \
|
||||||
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
|
||||||
|
|
||||||
# 2. Install deps (hub + admin-web), audit hub prod, build tsc + SPA, mark complete.
|
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
|
||||||
# `npm run build` → tsc then admin:build → admin-web/build for registerStaticSpa.
|
# `npm run build` → tsc then admin:build + database:build → admin-web/build and
|
||||||
|
# database-admin/build for registerStaticSpa / registerDatabaseSpa.
|
||||||
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
|
||||||
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix database-admin && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# 3. Ensure the service is installed (idempotent), then restart.
|
# 3. Ensure the service is installed (idempotent), then restart.
|
||||||
|
|||||||
+4
-2
@@ -33,7 +33,7 @@
|
|||||||
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).",
|
"description": "Curriculum Project Hub — org-scoped Feishu collaboration and confined Agent runtime. Semantics pinned by docs/adr/ (ADR-0001 through ADR-0027).",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
|
"dev": "npm run prisma:migrate && tsx watch src/server.ts",
|
||||||
"build": "tsc -p tsconfig.json && npm run admin:build",
|
"build": "tsc -p tsconfig.json && npm run admin:build && npm run database:build",
|
||||||
"start": "npm run prisma:migrate && node dist/server.js",
|
"start": "npm run prisma:migrate && node dist/server.js",
|
||||||
"check": "tsc -p tsconfig.json --noEmit",
|
"check": "tsc -p tsconfig.json --noEmit",
|
||||||
"audit:production": "npm audit --omit=dev --audit-level=high",
|
"audit:production": "npm audit --omit=dev --audit-level=high",
|
||||||
@@ -48,6 +48,8 @@
|
|||||||
"test": "vitest run",
|
"test": "vitest run",
|
||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"admin:dev": "npm run dev --prefix admin-web",
|
"admin:dev": "npm run dev --prefix admin-web",
|
||||||
"admin:build": "npm run build --prefix admin-web"
|
"admin:build": "npm run build --prefix admin-web",
|
||||||
|
"database:dev": "npm run dev --prefix database-admin",
|
||||||
|
"database:build": "npm run build --prefix database-admin"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-10
@@ -3,13 +3,27 @@
|
|||||||
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
|
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
|
||||||
所以服务器启动时能正确识别这些路由。
|
所以服务器启动时能正确识别这些路由。
|
||||||
|
|
||||||
页面:
|
**前后端分离**:页面已迁到独立的 SvelteKit 静态 SPA `hub/database-admin/`
|
||||||
|
(与 `hub/admin-web/` 同一套框架)。本目录的后端只保留三件事:鉴权透传、一个
|
||||||
|
免鉴权配置端点、以及把 SPA 构建产物托管出去。页面全部由 SPA 客户端渲染。
|
||||||
|
|
||||||
- `/database/admin` —— 飞书登录页(唯一登录方式)
|
后端路由:
|
||||||
- `/database/dashboard` —— 左菜单 + 右内容的后台,未登录会跳回 `/database/admin`
|
|
||||||
|
|
||||||
登录走平台既有的飞书 OAuth:登录页的按钮指向 `/auth/feishu/<orgSlug>`,
|
- `GET /database/config` —— 免鉴权。返回 `{ siloOrganizationSlug, devLoginEnabled }`,
|
||||||
回调由 `src/admin/routes/authRoutes.ts` 处理并种下 session cookie。
|
给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据。
|
||||||
|
- `GET /database/dev-login` —— 仅开发。见下。
|
||||||
|
- `GET /database`、`GET /database/*` —— SPA shell / 客户端路由 fallback
|
||||||
|
(`static.ts` 的 `registerDatabaseSpa`);资产在 `/database/_app/*`。
|
||||||
|
|
||||||
|
SPA 页面(`database-admin`,`paths.base='/database'`):
|
||||||
|
|
||||||
|
- `/database/admin` —— 飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`(slug 来自
|
||||||
|
`/database/config`),回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
|
||||||
|
- `/database/dashboard` —— 后台壳。未登录跳登录页;**登录但非 OWNER/ADMIN 显示无权提示**。
|
||||||
|
|
||||||
|
> **注册顺序要点**:concrete 路由(`/database/config`、`/database/dev-login`)必须在
|
||||||
|
> `registerDatabaseSpa` 的 `/database/*` fallback 之前注册(已在 `plugin.ts` 保证),
|
||||||
|
> 否则通配会 shadow 它们。
|
||||||
|
|
||||||
## 开发模式:用环境变量开启一键登录
|
## 开发模式:用环境变量开启一键登录
|
||||||
|
|
||||||
@@ -29,7 +43,8 @@ HUB_DEV_LOGIN_BYPASS="true"
|
|||||||
|
|
||||||
开启后:
|
开启后:
|
||||||
|
|
||||||
- `/database/admin` 登录页显示「⚡ 一键登录管理员」按钮
|
- `/database/config` 返回 `devLoginEnabled: true`,SPA 登录页据此显示
|
||||||
|
「⚡ 一键登录管理员」按钮
|
||||||
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
|
- 后端注册 `/database/dev-login` 端点:按钮就是打它,它签发一个和飞书 OAuth
|
||||||
回调完全一样的 session,然后跳到 `/database/dashboard`
|
回调完全一样的 session,然后跳到 `/database/dashboard`
|
||||||
|
|
||||||
@@ -57,12 +72,22 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
|
|||||||
|
|
||||||
| 文件 | 职责 |
|
| 文件 | 职责 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` |
|
| `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()`;先挂 concrete 路由再挂 SPA |
|
||||||
| `routes/databaseRoutes.ts` | 路由 + 页面渲染,**你主要在这里加内容** |
|
| `routes/databaseRoutes.ts` | 后端 JSON / redirect 路由(`/database/config`、`/database/dev-login`),**数据端点加在这里** |
|
||||||
|
| `static.ts` | `registerDatabaseSpa`:托管 `database-admin/build` 的 SPA + `/database/*` fallback |
|
||||||
|
|
||||||
新增一类端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/...")`,
|
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/...")`,
|
||||||
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts` 里 `registerXxxRoutes(app, {...})`
|
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts` 里 `registerXxxRoutes(app, {...})`
|
||||||
注册一次。
|
注册一次。**页面**则加在 `database-admin/src/routes/` 下(SvelteKit 路由)。
|
||||||
|
|
||||||
|
## SPA 构建与托管
|
||||||
|
|
||||||
|
- 前端在 `hub/database-admin/`,`npm run build`(或 hub 根的 `npm run database:build`)
|
||||||
|
产出到 `database-admin/build/`。hub 的 `npm run build` 会把两个 SPA 一起带出来。
|
||||||
|
- `static.ts` 默认从 `../../database-admin/build` 读产物;可用 `CPH_DATABASE_UI_DIR`
|
||||||
|
覆盖。产物缺失时降级:只 warn,不挂 SPA,`/database/config` 与 `/database/dev-login` 仍可用。
|
||||||
|
- 本地开发:hub 根 `npm run database:dev` 起 Vite,它把 `/api`、`/auth`、`/database`
|
||||||
|
代理到 `127.0.0.1:8788`。
|
||||||
|
|
||||||
## 约定(与 admin 面一致)
|
## 约定(与 admin 面一致)
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import { registerDatabaseRoutes } from "./routes/databaseRoutes.js";
|
import { registerDatabaseRoutes } from "./routes/databaseRoutes.js";
|
||||||
|
import { registerDatabaseSpa } from "./static.js";
|
||||||
|
|
||||||
export interface DatabasePluginConfig {
|
export interface DatabasePluginConfig {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
@@ -42,4 +43,8 @@ export async function registerDatabasePlugin(
|
|||||||
siloOrganizationSlug: config.siloOrganizationSlug,
|
siloOrganizationSlug: config.siloOrganizationSlug,
|
||||||
allowDevLoginBypass,
|
allowDevLoginBypass,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// SPA shell + fallback, registered after the concrete /database/* routes above
|
||||||
|
// so the /database/* wildcard does not shadow them.
|
||||||
|
await registerDatabaseSpa(app);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,23 +5,30 @@
|
|||||||
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
|
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
|
||||||
* literal string it serves.
|
* literal string it serves.
|
||||||
*
|
*
|
||||||
* /database/admin — Feishu-only login page (+ dev one-click button)
|
* The login page and dashboard are now served by the `database-admin` SvelteKit
|
||||||
* /database/dashboard — sidebar + content admin shell, session-gated
|
* SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the
|
||||||
|
* concrete JSON/redirect routes the SPA depends on:
|
||||||
|
*
|
||||||
|
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle
|
||||||
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
|
* /database/dev-login — DEV ONLY bypass, registered only when the flag is on
|
||||||
*
|
*
|
||||||
* The dev bypass (button + /database/dev-login) is self-contained here and
|
* The dev bypass (/database/dev-login) is self-contained here and gated by
|
||||||
* gated by allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS
|
* allowDevLoginBypass (computed in ./plugin.ts from HUB_DEV_LOGIN_BYPASS +
|
||||||
* + NODE_ENV). Production requires real Feishu OAuth.
|
* NODE_ENV). Production requires real Feishu OAuth.
|
||||||
|
*
|
||||||
|
* NOTE: concrete routes here MUST be registered before the SPA fallback
|
||||||
|
* (registerDatabaseSpa serves /database and /database/*), or the wildcard would
|
||||||
|
* shadow them.
|
||||||
*/
|
*/
|
||||||
import type { FastifyInstance } from "fastify";
|
import type { FastifyInstance } from "fastify";
|
||||||
import type { PrismaClient } from "@prisma/client";
|
import type { PrismaClient } from "@prisma/client";
|
||||||
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
|
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
|
||||||
|
|
||||||
export interface DatabaseRouteConfig {
|
export interface DatabaseRouteConfig {
|
||||||
readonly prisma: PrismaClient;
|
readonly prisma: PrismaClient;
|
||||||
/** HMAC secret for the signed session cookie — reused from the admin plane. */
|
/** HMAC secret for the signed session cookie — reused from the admin plane. */
|
||||||
readonly sessionSecret: string;
|
readonly sessionSecret: string;
|
||||||
/** Silo Organization slug — builds the org-scoped Feishu login link. */
|
/** Silo Organization slug — the SPA builds the org-scoped Feishu login link from it. */
|
||||||
readonly siloOrganizationSlug: string;
|
readonly siloOrganizationSlug: string;
|
||||||
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
|
/** DEV ONLY. Enables the one-click button and the /database/dev-login route. */
|
||||||
readonly allowDevLoginBypass: boolean;
|
readonly allowDevLoginBypass: boolean;
|
||||||
@@ -31,18 +38,14 @@ export async function registerDatabaseRoutes(
|
|||||||
app: FastifyInstance,
|
app: FastifyInstance,
|
||||||
config: DatabaseRouteConfig,
|
config: DatabaseRouteConfig,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
app.get("/database/admin", async (request, reply) => {
|
// Unauthenticated bootstrap for the static SPA login page. Exposes only what
|
||||||
// Already signed in → straight to the dashboard.
|
// the page needs to build the Feishu login link and toggle the dev button —
|
||||||
if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) {
|
// no secrets, no user data.
|
||||||
return reply.redirect("/database/dashboard");
|
app.get("/database/config", async () => {
|
||||||
}
|
return {
|
||||||
return reply.type("text/html").send(renderLoginPage(config));
|
siloOrganizationSlug: config.siloOrganizationSlug,
|
||||||
});
|
devLoginEnabled: config.allowDevLoginBypass,
|
||||||
|
};
|
||||||
app.get("/database/dashboard", async (request, reply) => {
|
|
||||||
const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config);
|
|
||||||
if (user === null) return reply.redirect("/database/admin");
|
|
||||||
return reply.type("text/html").send(renderDashboard(user.displayName));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// DEV ONLY bypass — self-contained here, registered only when the flag is on
|
// DEV ONLY bypass — self-contained here, registered only when the flag is on
|
||||||
@@ -96,212 +99,8 @@ export async function registerDatabaseRoutes(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add more /database/* routes here. Guard data routes with requireSession /
|
// Add more /database/* JSON routes here. Guard data routes with requireSession
|
||||||
// requireOrgRole (../../admin/auth/guards.js) and scope every query to the
|
// / requireOrgRole (../../admin/auth/guards.js) and scope every query to the
|
||||||
// caller's org (ADR-0020). Access the DB via config.prisma.
|
// caller's org (ADR-0020). Access the DB via config.prisma. Register concrete
|
||||||
}
|
// routes before registerDatabaseSpa's /database/* fallback (done in ./plugin.ts).
|
||||||
|
|
||||||
/** Verify the session cookie and load the user, or null if not signed in. */
|
|
||||||
async function resolveUser(
|
|
||||||
rawCookie: string | undefined,
|
|
||||||
config: DatabaseRouteConfig,
|
|
||||||
): Promise<{ displayName: string } | null> {
|
|
||||||
if (rawCookie === undefined || rawCookie === "") return null;
|
|
||||||
const session = verifySession(rawCookie, config.sessionSecret);
|
|
||||||
if (session === null) return null;
|
|
||||||
const user = await config.prisma.user.findUnique({
|
|
||||||
where: { id: session.userId },
|
|
||||||
select: { displayName: true },
|
|
||||||
});
|
|
||||||
return user;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared document head: Tailwind CDN + a small design system (fonts, keyframes
|
|
||||||
* for the aurora background, fade-in, shimmer). Both pages import it so the
|
|
||||||
* look stays consistent.
|
|
||||||
*/
|
|
||||||
function pageHead(title: string): string {
|
|
||||||
return `<head>
|
|
||||||
<meta charset="utf-8"/>
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
|
||||||
<title>${title}</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap" rel="stylesheet"/>
|
|
||||||
<style>
|
|
||||||
:root { font-family: 'Inter', system-ui, 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; }
|
|
||||||
}
|
|
||||||
.aurora { filter: blur(80px); animation: aurora 18s ease-in-out infinite; }
|
|
||||||
.rise { animation: rise .7s cubic-bezier(.16,1,.3,1) both; }
|
|
||||||
.rise-2 { animation: rise .7s cubic-bezier(.16,1,.3,1) .1s both; }
|
|
||||||
.rise-3 { animation: rise .7s cubic-bezier(.16,1,.3,1) .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,.7); backdrop-filter: blur(16px); -webkit-backdrop-filter: blur(16px); }
|
|
||||||
.glow-btn { box-shadow: 0 12px 32px -10px rgba(124,58,237,.45); }
|
|
||||||
</style>
|
|
||||||
</head>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The animated aurora background blobs, shared by both pages (soft pastels on light). */
|
|
||||||
const AURORA = `
|
|
||||||
<div class="pointer-events-none fixed inset-0 overflow-hidden">
|
|
||||||
<div class="aurora absolute -left-32 -top-32 h-96 w-96 rounded-full bg-violet-300/50"></div>
|
|
||||||
<div class="aurora absolute right-0 top-1/4 h-96 w-96 rounded-full bg-cyan-300/40" style="animation-delay:-6s"></div>
|
|
||||||
<div class="aurora absolute bottom-0 left-1/3 h-96 w-96 rounded-full bg-indigo-300/40" style="animation-delay:-12s"></div>
|
|
||||||
</div>`;
|
|
||||||
|
|
||||||
function renderLoginPage(config: DatabaseRouteConfig): string {
|
|
||||||
const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`;
|
|
||||||
const devButton = config.allowDevLoginBypass
|
|
||||||
? `
|
|
||||||
<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>`
|
|
||||||
: "";
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
${pageHead("Database Admin · 登录")}
|
|
||||||
<body class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-800 flex items-center justify-center p-4">
|
|
||||||
${AURORA}
|
|
||||||
<main 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>
|
|
||||||
|
|
||||||
<a href="${feishuHref}"
|
|
||||||
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">
|
|
||||||
<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>
|
|
||||||
${devButton}
|
|
||||||
</main>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */
|
|
||||||
const NAV_ITEMS: ReadonlyArray<{ label: string; icon: string; href: string; active: boolean }> = [
|
|
||||||
{ label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z", href: "/database/dashboard", active: true },
|
|
||||||
{ label: "数据表", icon: "M4 5h16v4H4V5Zm0 6h16v4H4v-4Zm0 6h16v2H4v-2Z", href: "#", active: false },
|
|
||||||
{ label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", href: "#", active: false },
|
|
||||||
{ 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 2 1l2-1 2 3-2 1a7 7 0 0 1 0 2Z", href: "#", active: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
function renderDashboard(displayName: string): string {
|
|
||||||
const nav = NAV_ITEMS.map((item) => {
|
|
||||||
const cls = 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";
|
|
||||||
return `<a href="${item.href}" class="${cls}">
|
|
||||||
<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>`;
|
|
||||||
}).join("\n ");
|
|
||||||
|
|
||||||
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" },
|
|
||||||
].map((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>`).join("");
|
|
||||||
|
|
||||||
const initial = escapeHtml(displayName.slice(0, 1) || "U");
|
|
||||||
|
|
||||||
return `<!doctype html>
|
|
||||||
<html lang="zh-CN">
|
|
||||||
${pageHead("Database Admin · 概览")}
|
|
||||||
<body class="relative min-h-screen overflow-hidden bg-gradient-to-br from-slate-50 via-white to-indigo-50 text-slate-700">
|
|
||||||
${AURORA}
|
|
||||||
<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">
|
|
||||||
${nav}
|
|
||||||
</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">${escapeHtml(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 id="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">
|
|
||||||
${stats}
|
|
||||||
</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">
|
|
||||||
内容区占位 · 在 src/database/routes/databaseRoutes.ts 里继续搭建
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
document.getElementById("logout").addEventListener("click", async () => {
|
|
||||||
await fetch("/auth/logout", { method: "POST" });
|
|
||||||
window.location.href = "/database/admin";
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function escapeHtml(value: string): string {
|
|
||||||
return value
|
|
||||||
.replaceAll("&", "&")
|
|
||||||
.replaceAll("<", "<")
|
|
||||||
.replaceAll(">", ">")
|
|
||||||
.replaceAll('"', """);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,98 @@
|
|||||||
|
/**
|
||||||
|
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`)
|
||||||
|
* and the SPA index fallback for client-side routes under `/database/*`.
|
||||||
|
*
|
||||||
|
* The SvelteKit project lives in `hub/database-admin/` and is built with
|
||||||
|
* `paths.base = '/database'`, so its assets are emitted under `/database/_app/*`
|
||||||
|
* (not the root `/_app/*` that admin-web owns — that keeps the two SPAs from
|
||||||
|
* colliding). On disk the files still live at `build/_app/*`; this handler maps
|
||||||
|
* the `/database`-prefixed URLs back to those files.
|
||||||
|
*
|
||||||
|
* Run `npm run build` in database-admin/ to produce the static output. In
|
||||||
|
* development, `npm run dev` there proxies `/api`, `/auth`, and `/database` to
|
||||||
|
* the Hub. Override the UI directory with `CPH_DATABASE_UI_DIR` if needed.
|
||||||
|
*
|
||||||
|
* Mirrors src/admin/static.ts.
|
||||||
|
*/
|
||||||
|
import { readFile } from "node:fs/promises";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { dirname, extname, join, resolve as resolvePath } from "node:path";
|
||||||
|
import type { FastifyInstance } from "fastify";
|
||||||
|
|
||||||
|
const MIME: Record<string, string> = {
|
||||||
|
".html": "text/html; charset=utf-8",
|
||||||
|
".js": "text/javascript; charset=utf-8",
|
||||||
|
".mjs": "text/javascript; charset=utf-8",
|
||||||
|
".css": "text/css; charset=utf-8",
|
||||||
|
".svg": "image/svg+xml",
|
||||||
|
".ico": "image/x-icon",
|
||||||
|
".png": "image/png",
|
||||||
|
".jpg": "image/jpeg",
|
||||||
|
".woff": "font/woff",
|
||||||
|
".woff2": "font/woff2",
|
||||||
|
".json": "application/json; charset=utf-8",
|
||||||
|
".txt": "text/plain; charset=utf-8",
|
||||||
|
};
|
||||||
|
|
||||||
|
function resolveUiDir(): string {
|
||||||
|
const override = process.env["CPH_DATABASE_UI_DIR"];
|
||||||
|
if (override && override.trim() !== "") return resolvePath(override);
|
||||||
|
const here = dirname(fileURLToPath(import.meta.url));
|
||||||
|
return resolvePath(join(here, "..", "..", "database-admin", "build"));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
|
||||||
|
const uiDir = resolveUiDir();
|
||||||
|
if (!existsSync(join(uiDir, "index.html"))) {
|
||||||
|
app.log.warn(
|
||||||
|
{ uiDir },
|
||||||
|
"database-admin/build not found; /database SPA shell disabled. Run `npm run build` in database-admin/ to enable. /database/config and /database/dev-login remain functional.",
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
|
||||||
|
|
||||||
|
// SvelteKit build assets. base='/database' emits them at /database/_app/*,
|
||||||
|
// but on disk they're under build/_app/*.
|
||||||
|
app.get("/database/_app/*", async (request, reply) => {
|
||||||
|
const rel = (request.params as { "*": string })["*"];
|
||||||
|
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/");
|
||||||
|
try {
|
||||||
|
const buf = await readFile(join(uiDir, "_app", safe));
|
||||||
|
const mime = MIME[extname(safe)] ?? "application/octet-stream";
|
||||||
|
return reply.type(mime).send(buf);
|
||||||
|
} catch {
|
||||||
|
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Top-level static files emitted under the base path (favicon.svg, robots.txt).
|
||||||
|
app.get("/database/favicon.svg", async (_request, reply) => {
|
||||||
|
try {
|
||||||
|
const buf = await readFile(join(uiDir, "favicon.svg"));
|
||||||
|
return reply.type("image/svg+xml").send(buf);
|
||||||
|
} catch {
|
||||||
|
return reply.status(404).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
app.get("/database/robots.txt", async (_request, reply) => {
|
||||||
|
try {
|
||||||
|
const buf = await readFile(join(uiDir, "robots.txt"));
|
||||||
|
return reply.type("text/plain; charset=utf-8").send(buf);
|
||||||
|
} catch {
|
||||||
|
return reply.status(404).send();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// SPA client-side route fallback. Concrete /database/* routes (/database/config,
|
||||||
|
// /database/dev-login, and the asset routes above) are more specific, so
|
||||||
|
// Fastify's router matches them before this wildcard. Everything else under
|
||||||
|
// /database serves index.html so SvelteKit's client router can resolve the view.
|
||||||
|
app.get("/database", async (_request, reply) => {
|
||||||
|
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||||
|
});
|
||||||
|
app.get("/database/*", async (_request, reply) => {
|
||||||
|
return reply.type("text/html; charset=utf-8").send(indexHtml);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -2,8 +2,8 @@
|
|||||||
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
|
* Silo-wide HTTP request rate limit (ADR-0022 `requestRate`).
|
||||||
*
|
*
|
||||||
* Counts dynamic traffic only: APIs, auth, and other application handlers.
|
* Counts dynamic traffic only: APIs, auth, and other application handlers.
|
||||||
* Static SPA assets and the org-admin HTML shell are exempt so a single page
|
* Static SPA assets and the admin HTML shells are exempt so a single page load
|
||||||
* load (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
|
* (dozens of `/_app/*` chunks + favicon) does not exhaust the minute budget.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Paths that must not consume the silo HTTP request-rate budget. */
|
/** Paths that must not consume the silo HTTP request-rate budget. */
|
||||||
@@ -12,12 +12,21 @@ export function isSiloHttpRateLimitExempt(url: string): boolean {
|
|||||||
|
|
||||||
if (path === "/api/healthz") return true;
|
if (path === "/api/healthz") return true;
|
||||||
|
|
||||||
// SvelteKit build output and top-level static files (see admin/static.ts).
|
// admin-web SvelteKit build output at the root, and top-level static files
|
||||||
|
// (see admin/static.ts).
|
||||||
if (path === "/_app" || path.startsWith("/_app/")) return true;
|
if (path === "/_app" || path.startsWith("/_app/")) return true;
|
||||||
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
|
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
|
||||||
|
|
||||||
// SPA index shell for client-side routes (not an API).
|
// database-admin SvelteKit build output, served under /database (base path;
|
||||||
|
// see database/static.ts).
|
||||||
|
if (path === "/database/_app" || path.startsWith("/database/_app/")) return true;
|
||||||
|
if (path === "/database/favicon.svg" || path === "/database/robots.txt") return true;
|
||||||
|
|
||||||
|
// SPA index shells for client-side routes (not APIs). The /database/* shell is
|
||||||
|
// blanket-exempt like /admin/* since client routes are unknowable up front;
|
||||||
|
// this also covers the once-per-load /database/config bootstrap.
|
||||||
if (path === "/admin" || path.startsWith("/admin/")) return true;
|
if (path === "/admin" || path.startsWith("/admin/")) return true;
|
||||||
|
if (path === "/database" || path.startsWith("/database/")) return true;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user