diff --git a/hub/src/database/README.md b/hub/src/database/README.md index 8b927d4..9d18952 100644 --- a/hub/src/database/README.md +++ b/hub/src/database/README.md @@ -3,26 +3,35 @@ `/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它, 所以服务器启动时能正确识别这些路由。 -**前后端分离**:页面已迁到独立的 SvelteKit 静态 SPA `hub/database-admin/` -(与 `hub/admin-web/` 同一套框架)。本目录的后端只保留三件事:鉴权透传、一个 -免鉴权配置端点、以及把 SPA 构建产物托管出去。页面全部由 SPA 客户端渲染。 +**前后端分离**:页面全部在 SvelteKit 静态 SPA `hub/filelib-web/`(与 `hub/admin-web/` +同一套框架)。**老师端 `/app` 与管理后台 `/database` 共用这一份工程和这一份构建产物** —— +两个挂载前缀,一个 SPA。本目录的后端只保留三件事:鉴权透传、JSON 数据端点、 +以及把构建产物托管出去。服务端不渲染任何 HTML。 后端路由: -- `GET /database/config` —— 免鉴权。返回 `{ siloOrganizationSlug, devLoginEnabled }`, +- `GET /database/config` —— 免鉴权。返回 `{ orgSlug, devLoginEnabled }`, 给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据。 + (`/database/api/login-info` 是同形状的既有端点,由 `routes/teacherApp.ts` 注册。) +- `GET /database/api/stats` —— 概览页统计。需登录 **且** 是 silo org OWNER/ADMIN。 - `GET /database/dev-login` —— 仅开发。见下。 -- `GET /database`、`GET /database/*` —— SPA shell / 客户端路由 fallback - (`static.ts` 的 `registerDatabaseSpa`);资产在 `/database/_app/*`。 +- `GET /database`、`GET /database/*`、`GET /app`、`GET /app/*` —— SPA shell / + 客户端路由 fallback(`static.ts` 的 `registerDatabaseSpa`)。 +- `GET /_filelib/*` —— 构建产物资源。SvelteKit 的 `appDir` 改名为 `_filelib`, + 以避开 `admin-web` 在根上注册的 `/_app/*`(同名会让 Fastify 启动即抛重复路由)。 -SPA 页面(`database-admin`,`paths.base='/database'`): +SPA 页面(`filelib-web`,真 URL 路由、无 hash): -- `/database/admin` —— 飞书登录页。按钮指向 `/auth/feishu/`(slug 来自 - `/database/config`),回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。 -- `/database/dashboard` —— 后台壳。未登录跳登录页;**登录但非 OWNER/ADMIN 显示无权提示**。 +- `/app` —— 老师端文件库。未登录显示登录卡片。 +- `/database/admin` —— 管理员飞书登录页。按钮指向 `/auth/feishu/`, + 回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。 +- `/database/dashboard` —— 后台外壳(侧栏 + 权限门)。未登录跳登录页; + **登录但非 OWNER/ADMIN 显示无权提示**。六个 tab 都是子路由: + `/database/dashboard`(概览)、`/library`、`/users`、`/groups`、`/search`、`/settings`。 -> **注册顺序要点**:concrete 路由(`/database/config`、`/database/dev-login`)必须在 -> `registerDatabaseSpa` 的 `/database/*` fallback 之前注册(已在 `plugin.ts` 保证), +> **注册顺序要点**:concrete 路由(`/database/config`、`/database/api/*`、 +> `/database/dev-login`、`/app/dev-login-teacher`)必须在 `registerDatabaseSpa` 的 +> `/database/*`、`/app/*` fallback 之前注册(已在 `plugin.ts` 保证), > 否则通配会 shadow 它们。 ## 开发模式:用环境变量开启一键登录 @@ -73,17 +82,17 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真 | 文件 | 职责 | |------|------| | `plugin.ts` | 模块对外入口,`hub.ts` 调 `registerDatabasePlugin()` | -| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 | +| `routes/databaseRoutes.ts` | `/database/config`、`/database/api/stats`、dev 旁路 + 各子路由装配点 | | `routes/filelibRoutes.ts` | 文件库 树/授权 API | | `routes/fileRoutes.ts` | 文件库 文件内容/导出 API | | `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) | -| `routes/adminPanels.ts` | dashboard「用户管理」(org 成员)/「Group 管理」(MemberGroup 嵌套树)面板 | -| `routes/libraryPage.ts` | `/database/library` 文件库浏览页 | +| `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 | +| `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app`、`/database` 两个 SPA 回退 | | `filelib/` | 文件库领域层(见下) | -新增一类**数据**端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/...")`, +新增一类**数据**端点时:要么直接往 `databaseRoutes.ts` 加 `app.get("/database/api/...")`, 要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts` 里 `registerXxxRoutes(app, {...})` -注册一次。 +注册一次。**不要在后端拼 HTML** —— 页面一律加在 `hub/filelib-web/src/routes/` 下。 ## 文件库(filelib/) diff --git a/hub/src/database/routes/adminPanels.ts b/hub/src/database/routes/adminPanels.ts deleted file mode 100644 index 6196d96..0000000 --- a/hub/src/database/routes/adminPanels.ts +++ /dev/null @@ -1,747 +0,0 @@ -/** - * 管理员后台「用户管理」「Group 管理」面板。 - * - * 用户管理 = org 成员(/api/org/:orgSlug/members)。 - * Group 管理 = **MemberGroup**(全局可无限嵌套,ADR-0028),走 /database/api/groups/*。 - * 已从旧的扁平 hub Team 迁过来 —— Team 无父子字段,建不出子组。此面板与 - * filelib-web 的 GroupAdmin.svelte 消费同一套 API,语义一致。 - * 交互:嵌套树(展开/折叠)+ 右键菜单(建子组/建根组/重命名/删除)。 - */ - -function apiBase(orgSlug: string): string { - return `/api/org/${encodeURIComponent(orgSlug)}`; -} - -/* ---------------------------------------------------------------- 用户管理 */ - -export function renderUsersPanel(orgSlug: string): string { - return ` -
-
-
添加成员
-
- - - - -
-
-
-
成员列表
- - - -
成员userId角色
-
-
-`; -} - -/* ---------------------------------------------------------------- Group 管理 */ - -/** - * MemberGroup 嵌套树管理(ADR-0028)。无 orgSlug 参数 —— MemberGroup 是全局主体, - * 不归属任何 Organization,端点也不带 org 段。 - */ -/** 内联 SVG 图标表(24x24 stroke 风格,与 dashboard 侧栏一致)。 */ -const GROUP_ICONS: Record = { - // Group 节点 = 人的集合。**不用文件夹图标** —— Group 不是目录, - // 与文件库的 FOLDER/PROJECT 是两套体系,图标上也不应混淆。两人剪影。 - group: "M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 11.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.38M15.4 5.22a3.25 3.25 0 0 1 0 6.06", - users: "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.75", - user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z", - plus: "M12 5v14M5 12h14", - pencil: "M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z", - trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14", - search: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z", - chevron: "m9 18 6-6-6-6", - layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5", - clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2", - minus: "M5 12h14", - // 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。 - archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6", - restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5", -}; - -/** `icon("users", 16)` → 内联 svg 串。 */ -function icon(name: keyof typeof GROUP_ICONS | string, size = 16): string { - const d = GROUP_ICONS[name] ?? ""; - return ``; -} - -export function renderGroupsPanel(): string { - return ` -
- -
-
- ${icon("layers", 17)} -
Group 树
- -
-
- ${icon("search", 13)} - -
- -
-
-
- - -
-
-
- ${icon("users", 40)} - 从左侧选择一个 Group 查看成员 -
-
-
-
- -`; -} diff --git a/hub/src/database/routes/databaseRoutes.ts b/hub/src/database/routes/databaseRoutes.ts index 597fef7..9cfaf35 100644 --- a/hub/src/database/routes/databaseRoutes.ts +++ b/hub/src/database/routes/databaseRoutes.ts @@ -5,11 +5,12 @@ * Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the * literal string it serves. * - * The login page and dashboard are now served by the `database-admin` SvelteKit - * SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the - * concrete JSON/redirect routes the SPA depends on: + * 标准前后端分离:本文件**不渲染任何 HTML**。登录页与管理后台六个 tab 全部由 + * `hub/filelib-web` 这一个 SvelteKit SPA 提供(同一份产物挂 /app 与 /database, + * 见 ../static.ts / registerDatabaseSpa)。此处只留 SPA 依赖的 JSON/跳转端点: * * /database/config — unauthenticated bootstrap: silo org slug + dev toggle + * /database/api/stats — 概览页统计(需登录 + silo org OWNER/ADMIN) * /database/dev-login — DEV ONLY bypass, registered only when the flag is on * * The dev bypass (/database/dev-login) is self-contained here and gated by @@ -23,19 +24,17 @@ 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 { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js"; import { registerFileLibRoutes } from "./filelibRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js"; import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; import { registerTeacherApp } from "./teacherApp.js"; -import { renderLibraryBrowser } from "./libraryBrowser.js"; -import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.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 { actorOrNull, sendRouteError } from "../filelib/routeShared.js"; import type { FileLibRouteDeps } from "../filelib/routeShared.js"; export interface DatabaseRouteConfig { @@ -55,19 +54,30 @@ export async function registerDatabaseRoutes( // 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。 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) { - return reply.redirect("/database/dashboard"); - } - return reply.type("text/html").send(renderLoginPage(config)); - }); + // 登录页/前端 bootstrap(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。 + // 与老师端 /database/api/login-info 同形状 —— 后者由 teacherApp.ts 注册, + // 两处并存是为了兼容既有前端调用点。 + app.get("/database/config", async () => ({ + orgSlug: 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"); - const stats = await loadDashboardStats(config.prisma, filelibDepsForStats); - return reply.type("text/html").send(renderDashboard(user.displayName, stats, config.siloOrganizationSlug)); + // 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的 + // 计数与审计流,不是某个节点的授权视图,所以不走 per-node 的 role 判定。 + app.get("/database/api/stats", async (request, reply) => { + if (filelibDepsForStats === null) { + return reply.status(503).send({ error: { code: "unavailable", message: "filelib not ready" } }); + } + const actor = await actorOrNull(request, reply, filelibDepsForStats); + if (actor === null) return reply; + if (!actor.isWebsiteAdmin) { + return reply.status(403).send({ error: { code: "forbidden", message: "requires organization OWNER/ADMIN" } }); + } + try { + return await loadDashboardStats(config.prisma, filelibDepsForStats); + } catch (error) { + return sendRouteError(reply, error); + } }); // DEV ONLY bypass — self-contained here, registered only when the flag is on @@ -121,7 +131,7 @@ export async function registerDatabaseRoutes( }); } - // 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。 + // 文件库(独立模块,《文件库-接口契约.md》):API + 老师端 /app 静态托管。 // 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换); // GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028), // HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); @@ -158,9 +168,10 @@ export async function registerDatabaseRoutes( allowDevLoginBypass: config.allowDevLoginBypass, }); - // 独立文件库页已并入后台「文件库」tab(/database/dashboard#library),旧地址跳转保留兼容。 + // 独立文件库页已并入后台「文件库」tab;旧地址跳转保留兼容。 + // SPA 化后目标是真路由(不再是 #library 锚点)。 app.get("/database/library", async (_request, reply) => - reply.redirect("/database/dashboard#library"), + reply.redirect("/database/dashboard/library"), ); filelibDepsForStats = filelibDeps; @@ -184,9 +195,8 @@ interface DashboardStats { /** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */ async function loadDashboardStats( prisma: PrismaClient, - deps: FileLibRouteDeps | null, -): Promise { - if (deps === null) return null; + deps: FileLibRouteDeps, +): Promise { const organizationId = deps.organizationId; const [folders, projects, grants] = await Promise.all([ prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }), @@ -234,193 +244,3 @@ async function loadDashboardStats( }); return { folders, projects, files, grants, recent }; } - -/** 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; -} - -/** 管理后台共享 head(全局 UI 主题,与老师端 /app 同源)。 */ -function pageHead(title: string): string { - return ` - - - ${title} - ${UI_HEAD_FONTS} - -`; -} - -function renderLoginPage(config: DatabaseRouteConfig): string { - const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`; - const devButton = config.allowDevLoginBypass - ? `
- 开发模式 - -
- ⚡ 一键登录管理员 -

仅开发环境可见 · 跳过飞书 OAuth

` - : ""; - - return ` - -${pageHead("Database Admin · 登录")} - -
-
-
Database Admin
-

使用飞书登录以管理数据库

- 使用飞书登录 - ${devButton} -
-
- -`; -} - -/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */ -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, stats: DashboardStats | null, orgSlug: string): string { - const nav = NAV_TABS.map((t) => ` - `).join("\n"); - - const cards = [ - { label: "文件夹", value: stats?.folders ?? "—" }, - { label: "项目", value: stats?.projects ?? "—" }, - { label: "文件", value: stats?.files ?? "—" }, - { label: "活跃授权", value: stats?.grants ?? "—" }, - ].map((s) => ` -
-

${s.label}

-

${s.value}

-
`).join(""); - - const recentRows = stats === null || stats.recent.length === 0 - ? `
暂无文件库活动 · 到「文件库」里创建第一个文件夹吧
` - : stats.recent.map((r) => ` -
- ${escapeHtml(r.action)} - ${escapeHtml(r.label)} - ${escapeHtml(r.actor)} · ${escapeHtml(r.when.toLocaleString("zh-CN"))} -
`).join(""); - - const initial = escapeHtml(displayName.slice(0, 1) || "U"); - - return ` - -${pageHead("Database Admin")} - -
- - -
-
-

概览

-

文件库实时数据

-
- ${cards} -
-
-

最近活动

- ${recentRows} -
-
- - - - - - - - - - -
-
- - - -`; -} - -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """); -} diff --git a/hub/src/database/routes/libraryBrowser.ts b/hub/src/database/routes/libraryBrowser.ts deleted file mode 100644 index 0499f48..0000000 --- a/hub/src/database/routes/libraryBrowser.ts +++ /dev/null @@ -1,597 +0,0 @@ -/** - * 管理员后台「文件库」页内浏览器区块(树 | 内容 | 预览三栏,含授权管理)。 - * - * renderLibraryBrowser() 返回可嵌入任意后台布局的 HTML+JS 片段: - * 以 #lib-root 为根、内部用 q() 作用域选择器,不与宿主页其他元素冲突。 - * 与独立页版浏览器的区别:去掉了自己的 wordmark/用户栏(由后台外壳提供), - * 保留管理员工具(根目录创建、授权面板、独立权限开关)。 - */ - -export function renderLibraryBrowser(): string { - return ` -
- -
-
- 文件库 - -
-
-
- - -
-
从左侧选择一个文件夹或项目
-
- - - - -
-
-
- - - -`; -} diff --git a/hub/src/database/routes/libraryPage.ts b/hub/src/database/routes/libraryPage.ts deleted file mode 100644 index 396f2d8..0000000 --- a/hub/src/database/routes/libraryPage.ts +++ /dev/null @@ -1,801 +0,0 @@ -/** - * /database/library —— 文件库浏览页(管理员后台的文件工具)。 - * - * 服务端渲染外壳 + 浏览器端 JS 调 /database/api/*(同源 session cookie)。 - * 设计令牌与全局 UI 主题(uiTheme.ts)一致。能力面全部由 API 的 404/403 表达(D8)。 - */ - -import type { FastifyInstance } from "fastify"; -import type { PrismaClient } from "@prisma/client"; -import { SESSION_COOKIE_NAME, verifySession } from "../../admin/auth/session.js"; - -export interface LibraryPageConfig { - readonly prisma: PrismaClient; - readonly sessionSecret: string; -} - -export async function registerLibraryPage( - app: FastifyInstance, - config: LibraryPageConfig, -): Promise { - app.get("/database/library", async (request, reply) => { - const raw = request.cookies[SESSION_COOKIE_NAME]; - const session = raw === undefined || raw === "" ? null : verifySession(raw, config.sessionSecret); - if (session === null) return reply.redirect("/database/admin"); - const user = await config.prisma.user.findUnique({ - where: { id: session.userId }, - select: { displayName: true }, - }); - if (user === null) return reply.redirect("/database/admin"); - return reply.type("text/html").send(renderLibraryPage(user.displayName)); - }); -} - -function renderLibraryPage(displayName: string): string { - const initial = displayName.slice(0, 1).replace(/[&<>"']/, "U"); - return ` - - - - - 文件库 - - - - - -
- -
-
从左侧选择一个文件夹或项目
-
-
- - -
- - - -`; -} diff --git a/hub/src/database/routes/teacherApp.ts b/hub/src/database/routes/teacherApp.ts index 665bf16..871f896 100644 --- a/hub/src/database/routes/teacherApp.ts +++ b/hub/src/database/routes/teacherApp.ts @@ -1,15 +1,12 @@ /** - * 老师端用户前端托管(标准前后端分离): - * GET /app/* — filelib-web(Svelte SPA)构建产物静态托管 + SPA 回退 - * GET /database/api/login-info — 登录页配置(org slug / dev 开关) - * GET /app/dev-login{,-teacher} — DEV ONLY 一键登录(管理员 / 普通老师) + * 老师端后端支撑(标准前后端分离): + * GET /database/api/login-info — 登录页配置(org slug / dev 开关) + * GET /app/dev-login-teacher — DEV ONLY 一键登录(普通老师) * - * 服务端不再渲染老师端页面;页面由独立前端工程 hub/filelib-web 产出。 + * 服务端不渲染任何页面。`/app/*` 的静态托管与 SPA 回退在 ../static.ts —— 那里 + * 与管理后台 `/database/*` 共用同一份 filelib-web 构建产物,资源路由只注册一次。 */ -import fs from "node:fs"; -import path from "node:path"; -import fastifyStatic from "@fastify/static"; import type { FastifyInstance } from "fastify"; import type { PrismaClient } from "@prisma/client"; import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js"; @@ -33,26 +30,6 @@ export async function registerTeacherApp( devLoginEnabled: config.allowDevLoginBypass, })); - // 标准分离:静态托管 SPA 构建产物;非文件路径回退 index.html 交给前端。 - const distDir = path.resolve(import.meta.dirname, "../../../filelib-web/dist"); - if (fs.existsSync(path.join(distDir, "index.html"))) { - await app.register(fastifyStatic, { root: distDir, prefix: "/app/", decorateReply: true }); - app.setNotFoundHandler((request, reply) => { - if (request.url.startsWith("/app")) { - return reply.sendFile("index.html", distDir); - } - return reply.status(404).send({ error: { code: "not_found", message: "not found" } }); - }); - } else { - app.log.warn({ distDir }, "filelib-web dist not found; build it with `npm run build --prefix filelib-web`"); - app.get("/app", async (_request, reply) => - reply - .status(503) - .type("text/plain") - .send("filelib-web 未构建。请先运行 npm run build --prefix hub/filelib-web"), - ); - } - if (!config.allowDevLoginBypass) return; registerDevLogins(app, config); } diff --git a/hub/src/database/routes/uiTheme.ts b/hub/src/database/routes/uiTheme.ts deleted file mode 100644 index 03776b9..0000000 --- a/hub/src/database/routes/uiTheme.ts +++ /dev/null @@ -1,155 +0,0 @@ -/** - * 全局共享 UI 主题(老师端 /app、管理员后台 /database、文件页 /database/library)。 - * - * 方向:高级简约、零视觉疲劳 —— 暖白底、发丝边框、近黑主按钮(唯一强调色)、 - * 无渐变、无彩色、无阴影(弹层仅一丝)、Inter 全界面、克制的动效。 - * 所有页面的设计令牌收敛于此,调色只改这里。 - */ - -export const UI_HEAD_FONTS = ` - - -`; - -export const UI_THEME_CSS = ` - :root { - --bg: #FCFCFB; - --panel: #FFFFFF; - --sidebar: #F7F7F5; - --text: #1A1A18; - --text-2: #6B6A66; - --text-3: #9C9B96; - --border: #ECECE8; - --border-soft: #F1F1EE; - --hover: #F4F4F1; - --selected: #EBEBE7; - --accent: #1A1A18; - --accent-hover: #333330; - --danger: #A13A33; - --guide: #E9E9E5; - --diff-add-bg: #F3F6F2; - --diff-add-text: #4A6741; - --diff-del-bg: #F8F2F1; - --diff-del-text: #A13A33; - --sans: 'Inter', -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif; - --mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; - --shadow-pop: 0 4px 20px rgba(26,26,24,.07); - } - * { box-sizing: border-box; } - html, body { margin: 0; height: 100%; } - body { - font-family: var(--sans); - background: var(--bg); color: var(--text); - font-size: 14px; line-height: 1.65; - -webkit-font-smoothing: antialiased; - } - .hidden { display: none !important; } - h1, h2, h3 { margin: 0; } - a { color: var(--text); text-decoration: none; } - a:hover { text-decoration: underline; } - - /* 按钮:近黑实心(主)/ 发丝边幽灵(次)/ 文字型(危险) */ - .btn { - display: inline-flex; align-items: center; gap: 5px; - padding: 6px 14px; border-radius: 8px; - border: 1px solid var(--border); background: var(--panel); - color: var(--text); font-size: 12.5px; font-weight: 500; - cursor: pointer; transition: all 120ms ease; text-decoration: none; - } - .btn:hover { background: var(--hover); text-decoration: none; } - .btn-primary { - background: var(--accent); border-color: var(--accent); color: #fff; - } - .btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); } - .btn-danger { border-color: transparent; background: transparent; color: var(--danger); } - .btn-danger:hover { background: #A13A3312; } - - /* 面板与标签 */ - .panel { - background: var(--panel); border: 1px solid var(--border-soft); - border-radius: 10px; padding: 20px 22px; - } - .tag { - font-size: 10.5px; font-weight: 500; font-family: var(--mono); - padding: 2px 8px; border-radius: 999px; - border: 1px solid var(--border-soft); color: var(--text-3); background: var(--panel); - } - .tag-role { color: var(--text-2); border-color: var(--border); } - - /* 页签 */ - .tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border-soft); margin-bottom: 18px; } - .tab { - padding: 8px 14px; font-size: 13px; color: var(--text-3); - border: none; background: none; cursor: pointer; - border-bottom: 2px solid transparent; margin-bottom: -1px; - transition: color 120ms ease; - } - .tab:hover { color: var(--text); } - .tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); } - - /* 表单 */ - .input, .select, .textarea { - width: 100%; padding: 7px 11px; border-radius: 8px; - border: 1px solid var(--border); background: var(--panel); - font-size: 13px; color: var(--text); font-family: inherit; - outline: none; transition: border-color 120ms ease; - } - .input:focus, .select:focus, .textarea:focus { border-color: var(--accent); } - .textarea { font-family: var(--mono); font-size: 12.5px; line-height: 1.75; resize: vertical; } - .form-label { display: block; font-size: 11.5px; color: var(--text-3); margin-bottom: 4px; } - .form-row { margin-bottom: 12px; } - .inline-form { display: flex; gap: 8px; align-items: center; } - .inline-form .input { flex: 1; } - - /* 表格 */ - table.list { width: 100%; border-collapse: collapse; font-size: 13px; } - table.list th { text-align: left; font-size: 11.5px; font-weight: 500; color: var(--text-3); padding: 4px 0; } - table.list td { padding: 8px 0; border-top: 1px solid var(--border-soft); } - table.list tr:first-child td { border-top: none; } - - /* 弹层 */ - .modal-mask { - position: fixed; inset: 0; z-index: 40; - background: rgba(26,26,24,.3); - display: flex; align-items: center; justify-content: center; padding: 16px; - } - .modal-card { - width: 100%; max-width: 430px; background: var(--panel); - border: 1px solid var(--border-soft); border-radius: 14px; padding: 22px; - box-shadow: var(--shadow-pop); - } - .modal-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; } - .modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; } - .toast { - border-radius: 8px; padding: 8px 16px; font-size: 12.5px; color: #fff; - background: #333230; max-width: 330px; - } - .toast-err { background: #7E2C26; } - #toast-root { position: fixed; bottom: 18px; right: 18px; z-index: 50; display: flex; flex-direction: column; gap: 8px; } - - .quiet { color: var(--text-3); font-size: 12.5px; } - .divider { border-top: 1px solid var(--border-soft); margin: 14px 0; } - .section-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; } - .section-note { font-size: 11.5px; color: var(--text-3); margin-top: 6px; } - .file-path { font-family: var(--mono); font-size: 12.5px; color: var(--text); } - .file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); } - .link-danger { color: var(--danger); font-size: 12.5px; background: none; border: none; cursor: pointer; padding: 0; } - .link-danger:hover { text-decoration: underline; } - - /* 开关(toggle)。用法: - 真正的 checkbox 藏在下面 —— 保留键盘可达与 :checked 语义,不做 div 假开关。 */ - .switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; } - .switch > input { position: absolute; opacity: 0; width: 0; height: 0; } - .switch > span { - position: relative; flex-shrink: 0; width: 30px; height: 17px; border-radius: 999px; - background: var(--border); transition: background .16s; - } - .switch > span::after { - content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px; - border-radius: 50%; background: #fff; transition: transform .16s; - box-shadow: 0 1px 2px rgba(0,0,0,.25); - } - .switch > input:checked + span { background: var(--accent); } - .switch > input:checked + span::after { transform: translateX(13px); } - .switch > input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; } -`; diff --git a/hub/src/database/static.ts b/hub/src/database/static.ts index a5d01e0..9fcacb6 100644 --- a/hub/src/database/static.ts +++ b/hub/src/database/static.ts @@ -1,98 +1,71 @@ /** - * Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`) - * and the SPA index fallback for client-side routes under `/database/*`. + * 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web` + * 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。 * - * 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. + * 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html + * 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*。 * - * 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. + * 三类路由: + * /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为 + * `_filelib`,以避开 admin-web 在根上注册的 /_app/* + * (见 ../admin/static.ts)—— 同名会让 Fastify 启动即抛重复路由。 + * /app, /app/* — 老师端 SPA 回退 + * /database, /database/* — 管理后台 SPA 回退 * - * Mirrors src/admin/static.ts. + * 具体路由(/database/api/*、/database/dev-login、/app/dev-login-teacher 等)由 + * databaseRoutes.ts / teacherApp.ts 先注册;Fastify 按具体度匹配,通配不遮蔽它们。 + * + * Override the UI directory with `CPH_FILELIB_UI_DIR` if needed. */ 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 = { - ".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", -}; +import { dirname, join, resolve as resolvePath } from "node:path"; +import fastifyStatic from "@fastify/static"; +import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"; function resolveUiDir(): string { - const override = process.env["CPH_DATABASE_UI_DIR"]; + const override = process.env["CPH_FILELIB_UI_DIR"]; if (override && override.trim() !== "") return resolvePath(override); const here = dirname(fileURLToPath(import.meta.url)); - return resolvePath(join(here, "..", "..", "database-admin", "build")); + return resolvePath(join(here, "..", "..", "filelib-web", "build")); } +/** 构建产物根目录下的顶层静态文件(SvelteKit 把 static/ 原样拷到这里)。 */ +const TOP_LEVEL_FILES = ["favicon.svg", "favicon.ico", "robots.txt"] as const; + export async function registerDatabaseSpa(app: FastifyInstance): Promise { 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.", + "filelib-web/build not found; /app and /database SPA shells disabled. Run `npm run build --prefix filelib-web` to enable. JSON APIs remain fully 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" } }); - } + // 构建资源。@fastify/static 负责 MIME、ETag/Last-Modified 与目录穿越防护。 + // 只注册一次 —— /app 与 /database 下的页面引用的都是这同一组绝对路径 + // (filelib-web 的 paths.relative=false 保证了这点)。 + await app.register(fastifyStatic, { + root: join(uiDir, "_filelib"), + prefix: "/_filelib/", + decorateReply: true, }); - // 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(); - } - }); + for (const name of TOP_LEVEL_FILES) { + if (!existsSync(join(uiDir, name))) continue; + app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir)); + } - // 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); - }); + // SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以 + // 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。 + const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise => + reply.type("text/html; charset=utf-8").send(indexHtml); + + app.get("/app", sendIndex); + app.get("/app/*", sendIndex); + app.get("/database", sendIndex); + app.get("/database/*", sendIndex); }