feat(database): init database folder frontend and permission

This commit is contained in:
ymy
2026-07-23 23:41:11 +08:00
parent 5df1900ca8
commit 4021e58d5d
67 changed files with 9436 additions and 150 deletions
+248 -137
View File
@@ -15,7 +15,20 @@
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import path from "node:path";
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
import { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js";
import { registerTeacherApp } from "./teacherApp.js";
import { renderLibraryBrowser } from "./libraryBrowser.js";
import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js";
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
import { createTeamGroupResolver } from "../filelib/groupResolver.js";
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
import { createManifestStubAdapter } from "../filelib/exportService.js";
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
import { UI_HEAD_FONTS, UI_THEME_CSS } from "./uiTheme.js";
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
export interface DatabaseRouteConfig {
readonly prisma: PrismaClient;
@@ -31,6 +44,9 @@ export async function registerDatabaseRoutes(
app: FastifyInstance,
config: DatabaseRouteConfig,
): Promise<void> {
// 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。
let filelibDepsForStats: FileLibRouteDeps | null = null;
app.get("/database/admin", async (request, reply) => {
// Already signed in → straight to the dashboard.
if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) {
@@ -42,7 +58,8 @@ export async function registerDatabaseRoutes(
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));
const stats = await loadDashboardStats(config.prisma, filelibDepsForStats);
return reply.type("text/html").send(renderDashboard(user.displayName, stats, config.siloOrganizationSlug));
});
// DEV ONLY bypass — self-contained here, registered only when the flag is on
@@ -96,9 +113,116 @@ export async function registerDatabaseRoutes(
});
}
// Add more /database/* routes here. Guard data routes with requireSession /
// requireOrgRole (../../admin/auth/guards.js) and scope every query to the
// caller's org (ADR-0020). Access the DB via config.prisma.
// 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
// GroupResolver 默认读 hub Team,HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
const siloOrg = await config.prisma.organization.findUnique({
where: { slug: config.siloOrganizationSlug },
select: { id: true },
});
if (siloOrg === null) {
app.log.warn({ slug: config.siloOrganizationSlug }, "filelib: silo organization not found, routes not registered");
return;
}
const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos");
const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json"));
const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"];
const filelibDeps: FileLibRouteDeps = {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
organizationId: siloOrg.id,
storageRoot,
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
? createTeamGroupResolver(config.prisma, siloOrg.id)
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
versionStore,
exportAdapters: [createManifestStubAdapter(versionStore)],
};
await registerFileLibRoutes(app, filelibDeps);
await registerFileRoutes(app, filelibDeps);
await registerTeacherApp(app, {
prisma: config.prisma,
sessionSecret: config.sessionSecret,
siloOrganizationSlug: config.siloOrganizationSlug,
allowDevLoginBypass: config.allowDevLoginBypass,
});
// 独立文件库页已并入后台「文件库」tab(/database/dashboard#library),旧地址跳转保留兼容。
app.get("/database/library", async (_request, reply) =>
reply.redirect("/database/dashboard#library"),
);
filelibDepsForStats = filelibDeps;
}
/* ------------------------------------------------------------ 概览页统计 */
interface DashboardStats {
readonly folders: number;
readonly projects: number;
readonly files: number;
readonly grants: number;
readonly recent: ReadonlyArray<{
readonly action: string;
readonly actor: string;
readonly label: string;
readonly when: Date;
}>;
}
/** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */
async function loadDashboardStats(
prisma: PrismaClient,
deps: FileLibRouteDeps | null,
): Promise<DashboardStats | null> {
if (deps === null) return null;
const organizationId = deps.organizationId;
const [folders, projects, grants] = await Promise.all([
prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }),
prisma.fileLibNode.count({ where: { organizationId, kind: "PROJECT", deletedAt: null } }),
prisma.fileLibGrant.count({ where: { organizationId, revokedAt: null } }),
]);
// 文件计数:遍历 READY 项目问版本库(demo 规模;真版本包到位后应换成存储侧统计)
const readyProjects = await prisma.fileLibNode.findMany({
where: {
organizationId, kind: "PROJECT", deletedAt: null,
provisionStatus: "READY", storageDir: { not: null },
},
select: { storageDir: true },
});
let files = 0;
for (const project of readyProjects) {
if (project.storageDir === null) continue;
try {
files += (await deps.versionStore.list(project.storageDir)).length;
} catch { /* repo 缺失(如重启未恢复)不计 */ }
}
const entries = await prisma.auditEntry.findMany({
where: { organizationId, action: { in: Object.values(FILE_LIB_AUDIT_ACTIONS) } },
orderBy: { createdAt: "desc" },
take: 8,
});
const actorIds = [...new Set(entries.map((e) => e.actorUserId).filter((x): x is string => x !== null))];
const users = actorIds.length === 0
? []
: await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, displayName: true } });
const nameById = new Map(users.map((u) => [u.id, u.displayName]));
const recent = entries.map((entry) => {
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
const label =
(typeof meta["name"] === "string" ? meta["name"] : undefined) ??
(typeof meta["to"] === "string" ? meta["to"] : undefined) ??
(typeof meta["path"] === "string" ? meta["path"] : undefined) ??
(typeof meta["objectId"] === "string" ? meta["objectId"].slice(0, 8) : "");
return {
action: entry.action,
actor: nameById.get(entry.actorUserId ?? "") ?? entry.actorUserId ?? "unknown",
label,
when: entry.createdAt,
};
});
return { folders, projects, files, grants, recent };
}
/** Verify the session cookie and load the user, or null if not signed in. */
@@ -116,183 +240,170 @@ async function resolveUser(
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.
*/
/** 管理后台共享 head(全局 UI 主题,与老师端 /app 同源)。 */
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>
${UI_HEAD_FONTS}
<style>${UI_THEME_CSS}</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>`
? `<div style="display:flex;align-items:center;gap:10px;margin:22px 0;color:var(--text-3);font-size:11px">
<span style="flex:1;border-top:1px solid var(--border-soft)"></span>开发模式
<span style="flex:1;border-top:1px solid var(--border-soft)"></span>
</div>
<a href="/database/dev-login" class="btn" style="width:100%;justify-content:center">⚡ 一键登录管理员</a>
<p style="margin:10px 0 0;text-align:center;font-size:11px;color:var(--text-3)">仅开发环境可见 · 跳过飞书 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>
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px">
<div style="width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border-soft);border-radius:16px;padding:40px 36px;box-shadow:var(--shadow-pop)">
<div style="font-size:26px;font-weight:600;text-align:center">Database Admin</div>
<p style="margin:10px 0 30px;text-align:center;font-size:13px;color:var(--text-3)">使用飞书登录以管理数据库</p>
<a href="${feishuHref}" class="btn btn-primary" style="width:100%;justify-content:center;padding:11px 16px;font-size:14px">使用飞书登录</a>
${devButton}
</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>
</div>
</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 },
const NAV_TABS: ReadonlyArray<{ id: string; label: string; icon: string }> = [
{ id: "overview", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ id: "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" },
{ id: "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" },
{ id: "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" },
{ id: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ id: "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" },
];
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 ");
function renderDashboard(displayName: string, stats: DashboardStats | null, orgSlug: string): string {
const nav = NAV_TABS.map((t) => `
<button class="admin-tab" data-tab="${t.id}" style="display:flex;align-items:center;gap:10px;border-radius:10px;padding:9px 14px;font-size:13px;color:var(--text-3);background:none;border:none;cursor:pointer;text-align:left;width:100%">
<svg style="width:16px;height:16px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${t.icon}"/></svg>
${t.label}
</button>`).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>
const cards = [
{ label: "文件夹", value: stats?.folders ?? "—" },
{ label: "项目", value: stats?.projects ?? "—" },
{ label: "文件", value: stats?.files ?? "—" },
{ label: "活跃授权", value: stats?.grants ?? "—" },
].map((s) => `
<div class="panel" style="padding:18px 20px">
<p style="font-size:12.5px;color:var(--text-3)">${s.label}</p>
<p style="margin-top:6px;font-size:28px;font-weight:600;color:var(--text)">${s.value}</p>
</div>`).join("");
const recentRows = stats === null || stats.recent.length === 0
? `<div style="padding:26px 0;text-align:center;font-size:12.5px;color:var(--text-3)">暂无文件库活动 · 到「文件库」里创建第一个文件夹吧</div>`
: stats.recent.map((r) => `
<div style="display:flex;align-items:center;gap:12px;border-top:1px solid var(--border-soft);padding:9px 0;font-size:13px">
<span class="tag">${escapeHtml(r.action)}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">${escapeHtml(r.label)}</span>
<span style="margin-left:auto;flex-shrink:0;font-size:11.5px;color:var(--text-3)">${escapeHtml(r.actor)} · ${escapeHtml(r.when.toLocaleString("zh-CN"))}</span>
</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>
${pageHead("Database Admin")}
<body>
<div style="display:flex;height:100vh">
<aside style="width:240px;flex-shrink:0;background:var(--sidebar);border-right:1px solid var(--border-soft);display:flex;flex-direction:column">
<div style="padding:16px 16px 12px;border-bottom:1px solid var(--border-soft)">
<span style="font-size:15px;font-weight:600">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-1.5 px-3 py-2">
${nav}
<nav style="flex:1;display:flex;flex-direction:column;gap:2px;padding:10px">
${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 style="margin:10px;padding:10px 12px;border-top:1px solid var(--border-soft);display:flex;align-items:center;gap:9px">
<div style="width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">${initial}</div>
<div style="min-width:0;flex:1">
<p style="font-size:10.5px;color:var(--text-3)">已登录</p>
<p style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--text)">${escapeHtml(displayName)}</p>
</div>
<button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button>
</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 style="flex:1;display:flex;flex-direction:column;min-width:0">
<section id="tab-overview" class="admin-tab-section" style="flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">概览</h1>
<p style="font-size:11.5px;color:var(--text-3);margin-bottom:20px">文件库实时数据</p>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px">
${cards}
</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>
<div class="panel" style="margin-top:18px">
<h2 style="font-size:13.5px;font-weight:600;margin-bottom:8px">最近活动</h2>
${recentRows}
</div>
</section>
<main class="flex-1 p-8">
<div class="grid grid-cols-1 gap-5 sm:grid-cols-3">
${stats}
</div>
<section id="tab-library" class="admin-tab-section" style="display:none;flex:1;min-height:0">
${renderLibraryBrowser()}
</section>
<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>
<section id="tab-users" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:16px">用户管理</h1>
${renderUsersPanel(orgSlug)}
</section>
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:16px">Group 管理</h1>
${renderGroupsPanel(orgSlug)}
</section>
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">查询</h1>
<p style="font-size:12.5px;color:var(--text-3)">查询功能建设中</p>
</section>
<section id="tab-settings" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">设置</h1>
<p style="font-size:12.5px;color:var(--text-3)">设置功能建设中</p>
</section>
</div>
</div>
<script>
document.getElementById("logout").addEventListener("click", async () => {
await fetch("/auth/logout", { method: "POST" });
window.location.href = "/database/admin";
});
(function () {
const tabs = [...document.querySelectorAll(".admin-tab")];
const sections = Object.fromEntries(
[...document.querySelectorAll(".admin-tab-section")].map((s) => [s.id.replace("tab-", ""), s]),
);
function activate(id) {
tabs.forEach((t) => {
const on = t.dataset.tab === id;
t.style.background = on ? "var(--selected)" : "none";
t.style.color = on ? "var(--text)" : "var(--text-3)";
t.style.fontWeight = on ? "600" : "400";
});
Object.entries(sections).forEach(([key, s]) => {
s.style.display = key === id ? (key === "library" ? "block" : "block") : "none";
});
if (location.hash !== "#" + id) history.replaceState(null, "", "#" + id);
}
tabs.forEach((t) => t.addEventListener("click", () => activate(t.dataset.tab)));
document.getElementById("logout").addEventListener("click", async () => {
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch (e) {}
location.href = "/database/admin";
});
const fromHash = location.hash.replace(/^#/, "");
activate(tabs.some((t) => t.dataset.tab === fromHash) ? fromHash : "overview");
})();
</script>
</body>
</html>`;