Compare commits

...

9 Commits

Author SHA1 Message Date
6990082247 build(deploy): 部署与限流配置切换到 filelib-web,并加共存回归测试
三处引用旧工程名/旧资源路径的地方一并更新,它们必须同时改 —— 少改一处
就是静默故障,而不是构建期报错:

1. 部署脚本(deploy_platform.sh / deploy_fleet_release.sh):npm ci 的
   prefix、rsync 排除项、构建产物存在性检查从 database-admin 换成
   filelib-web。最后一项是真门禁:static.ts 缺产物时只 warn 不注册路由,
   漏改会让 /app 与 /database 静默 404 —— 恰是 database-admin 长期处于
   禁用状态的原因。

2. silo 限流豁免:资源路径随 appDir 改名而变(/database/_app/* 已不存在,
   现为 /_filelib/*);/app/* 此前不在豁免列表,它现在也是 SPA 外壳,
   客户端路由无法预先枚举。
   注:/database/* 是整体豁免,filelib 的 JSON API 也绕过限流预算。这是
   迁移前就有的行为,原样保留,但覆盖面因多了 /app/* 而变宽。

3. 回归测试:把 registerStaticSpa 与 registerDatabaseSpa 挂到同一个
   Fastify 实例,断言 ready() 不因重复路由抛错 —— appDir 若用回默认的
   _app,这里会红(ADR-0029 的承重约束)。另断言 /app 与
   /database/dashboard/users 返回同一份字节(SPA 回退不读请求)、body 含
   /_filelib/。构建产物缺失时不 skip 而是直接失败:那说明该先跑
   filelib-web 的 build,不是测试不适用。
2026-07-26 20:23:15 +08:00
cdeb29ccf2 fix(database): 补回文件库的「授权」tab 与 /me 的显示名
迁移时整个授权 tab 连同四个端点一起漏掉了 —— 后端一直可用,前端零调用:
  GET/PUT/DELETE /nodes/:id/grants
  PUT /projects/:id/independent-permission
权限编辑是这个后台的核心用途,而它此前在界面上完全不可达。

tab 组装也修正为与旧 libraryBrowser 一致:概览恒有、文件仅 PROJECT、
授权仅 MANAGE。注意文件夹也有授权 tab —— 它虽是透明组织节点,授权仍
挂在节点上(ADR-0021);此前文件夹一个 tab 都没有。

GrantsPanel 的语义按契约 8.1:创建者授权不给收回入口;MANAGE 仅创建者
可授,前端不拦,后端 fail closed 的报错原样呈现;GROUP 主体走
/groups/search 下拉选,不手敲 id。

/database/api/me 加 displayName 与 avatarUrl:侧栏此前显示原始 userId。
旧页面是服务端渲染,handler 里查 Prisma 就有名字;页面不再服务端渲染后
(ADR-0029),模板闭包过的数据也是被迁移的契约的一部分,不是旧实现的
无关细节。

概览面板同时补回丢失的「类型」「更新时间」两行、导出 target 下拉、
节点标题旁的角色 tag,以及整块缺失的独立权限开关。
2026-07-26 20:19:47 +08:00
eeb8f56742 fix(filelib-web): 补齐 Group 管理面板,与旧后端面板逐条对齐
迁移时误把分支上一个早先存在的简易 GroupAdmin(281 行)当成迁移产物,
它与旧 renderGroupsPanel(747 行)从来不是同一个东西,于是后端 8 个
group 端点前端只调了 5 个。

补上的功能(端点一直可用,只是没有入口):
  PATCH /groups/:id          重命名 / 改描述
  GET  /groups?includeArchived=1  列出已归档组
  POST /groups/:id/restore   恢复(连带恢复已归档祖先链,子树仍归档)
  GET  /users/search         成员选择器,不再手敲 userId
影响最实际的是恢复:软删的组此前在界面上无法恢复。

补上的交互:折叠树、组名过滤(命中项保留整条祖先链,过滤态强制展开)、
右键菜单(归档组只给「恢复」)、面包屑、统计条、树底部计数、成员表的
头像/openId/加入时间三列。

types.ts 之前也是截断的:MemberGroupNode 少 archivedAt,
MemberGroupMember 少 feishuOpenId/avatarUrl/joinedAt —— 类型里没有,
UI 自然渲染不出来。

一处实现偏离:折叠状态用数组而非 Set。Svelte 5 的 $state 深层代理不
跟踪 Set 变更,用 Set 会点了没反应。

groups tab 外框补 padding:20px/overflow:hidden,对齐旧 #tab-groups,
否则面板贴着侧边栏。
2026-07-26 20:19:30 +08:00
325b4fc137 fix(filelib-web): 补回迁移丢失的共享组件样式层与图标集
第一版迁移只把 uiTheme.ts 的 @theme 颜色令牌搬了过来,155 行里约 90
行的组件类(.btn/.panel/.input/.select/table.list/.tag/.switch/
.link-danger/.quiet 等)被丢掉,于是每个组件各自内联重述按钮、输入框、
面板的样式 —— 正是旧代码的重复问题被原样复刻,后台观感明显退化。

现在 app.css 是设计系统的唯一去处:@theme 管令牌,@layer components
管组件类。组件只带布局工具类,不重述组件样式。

图标集同样是丢的:旧面板有 13 个内联 SVG,新版一个不剩,只有纯文字的
「+」「删除」—— 这是"简陋"最直接的来源。提成 Icon.svelte 共享。
Group 节点沿用两人剪影而非文件夹图标:MemberGroup 与文件库的
FOLDER/PROJECT 是两套无关层级,图标不应混淆(ADR-0028/0021)。

顺手修 FilesPanel 的 uploadInput:bind:this 的目标要用 $state,
否则 Svelte 5 下不保证更新。
2026-07-26 20:19:10 +08:00
a7f90f387d chore(database-admin): 删除该前端工程,已被 filelib-web 取代
12628c9 引入它意在替换后端渲染的 /database 页面,但从未接通:具体
路由 /database/dashboard 比 SPA 通配 /database/* 更具体,服务端
handler 永远胜出,SPA 的 dashboard 不可达。工程头注释声称 SPA 已
接管 dashboard、且 /database/config 存在,两者当时都不成立。

hub 的 build 脚本也从未构建它,于是 static.ts 里的 existsSync 守卫
每次部署都失败,这个外壳实际长期处于禁用状态 —— 它没服务过一个请求。

与 filelib-web 合并而非并存的理由:两者共用文件库浏览器、会话层、
toast 宿主与设计令牌,拆开就要把这些全复制一遍(ADR-0029)。

内容可从 git 历史取回。
2026-07-26 20:18:15 +08:00
d159e372d2 refactor(database)!: 后端不再渲染任何 HTML,只出 JSON
删掉约 1770 行服务端模板拼接:renderDashboard / renderLoginPage
(databaseRoutes)、adminPanels、libraryBrowser、uiTheme,以及
libraryPage —— 后者迁移前已是无人引用的死代码。

新增两个端点承接原先在 page handler 里 inline 算的东西:
  GET /database/config      免鉴权 bootstrap(org slug + dev 开关);
                            注册位置刻意早于 silo org 的提前返回,
                            org 未就绪时登录页仍要能渲染。
  GET /database/api/stats   概览统计,要求 silo org OWNER/ADMIN ——
                            它聚合的是 org 级计数与审计流,不是
                            单节点权限视图。

静态托管收敛到 static.ts:一份 filelib-web 构建产物挂 /app 与
/database 两个前缀,资源路由只注册一次。并发症是路由顺序成了硬约束
—— 具体页面路由必须先于 SPA 通配注册,否则重演 /database/dashboard
盖住 SPA 的老 bug(ADR-0029)。

/database/library 改为 302 到 /database/dashboard/library。

BREAKING: 部署需先构建 filelib-web,否则 static.ts 的 existsSync
守卫会让 /app 与 /database 全部 404。
2026-07-26 20:17:59 +08:00
3d0f4e5c2d feat(filelib-web): 把 /database 各页从后端 HTML 拼接迁到 SvelteKit 路由
登录页、后台外壳与六个 tab 全部成为客户端路由:
  /database/admin              登录(迁自 renderLoginPage)
  /database/dashboard          概览(迁自 renderDashboard)
  .../library .../users .../groups .../search .../settings

六个 tab 是真 URL,不再是 location.hash + display:none —— 刷新不丢
位置,链接可分享。

BrowserShell 拆成 LibraryView,加 showUserFooter:老师端 /app 显示
身份/登出页脚,后台的文件库 tab 不显示(外层已有身份区)。

bootstrap 走 /database/config 而非 /database/api/login-info:后者由
teacherApp 在 silo org 查找成功后才注册,前者无条件注册,登录页在
org 未就绪时也必须能拿到配置。

后端拥有的链接(OAuth、DEV 一键登录)标 data-sveltekit-reload,
否则被客户端路由拦下。
2026-07-26 20:17:43 +08:00
de9f846fd0 build(filelib-web): 从 Svelte+Vite 改为 SvelteKit(adapter-static)
纯 SPA:adapter-static + fallback index.html,不做 SSR/预渲染。
index.html / main.ts / App.svelte 由 app.html + src/routes/ 取代。

两项配置是承重的(ADR-0029),不是风格选择:
  appDir: '_filelib'   默认 _app 会与 admin-web 在根上注册的 /_app/*
                       撞成 Fastify 重复路由,启动即抛错。
  paths.relative: false 同一份 index.html 会在 /app 和
                       /database/dashboard/users 等不同深度送出,
                       相对资源路径会解析到错的 base。

dev 代理表列出后端拥有的全部路径:JSON API、免鉴权 bootstrap
(/database/config)、OAuth、以及 DEV 一键登录端点 —— 后者不代理会被
SPA 回退吃掉。
2026-07-26 20:17:27 +08:00
683e97ca53 docs(adr): 0029 web 界面一律静态 SPA,hub 只出 JSON
记录本次迁移的语义决策:没有 HTTP handler 渲染 HTML;/app 与
/database 是同一个前端工程 filelib-web,构建一次挂两个前缀;
客户端导航用真 URL 路由而非 hash 片段。

两条承重配置约束一并写明:appDir 必须改名(默认 _app 与 admin-web
在根上的 /_app/* 撞重复路由,Fastify 启动即失败),以及
paths.relative=false(同一份 index.html 在不同 URL 深度被送出)。
2026-07-26 20:17:09 +08:00
74 changed files with 2405 additions and 6349 deletions
@@ -0,0 +1,132 @@
# ADR 0029: Web Surfaces Are Static SPAs; the Hub Serves JSON Only
## Status
Accepted.
## Context
The Hub exposes three browser surfaces: the org-admin console (`/admin`), the
teacher-facing file library (`/app`), and the database admin back office
(`/database`). They arrived at different times and diverged in how HTML reached
the browser.
`/admin` and `/app` were already separated: the backend serves a prebuilt static
`index.html` and never inspects the request; all data flows through JSON
endpoints. `/database` was not. Roughly 1770 lines across four modules
(`renderDashboard`/`renderLoginPage` in `routes/databaseRoutes.ts`,
`routes/adminPanels.ts`, `routes/libraryBrowser.ts`, `routes/libraryPage.ts`)
assembled HTML template strings server-side, reading the session cookie and
querying Prisma inside the page handler, with layout expressed as inline
`style="…"` attributes and behavior as `<script>` text.
A prior migration (`12628c9`) introduced a fourth frontend project,
`hub/database-admin/`, intended to replace those pages. It was never wired up:
the concrete route `/database/dashboard` is more specific than the SPA wildcard
`/database/*`, so the server-rendered handler always won and the SPA's dashboard
was unreachable. That project's file header claimed the SPA served the dashboard
and that `/database/config` existed; neither was true. The `npm run build` script
also never built it, so the `existsSync` guard in `database/static.ts` failed on
every deploy and the shell was permanently disabled.
Duplicated visual rules were the practical cost: card padding and type sizes were
restated in each render module, and only the CSS variables in `routes/uiTheme.ts`
were genuinely shared.
## Decision
**No Hub HTTP handler renders HTML.** Every browser surface is a prebuilt static
SPA. Page handlers send a byte-identical `index.html` that does not depend on the
request; all per-user and per-request data is fetched by the client from JSON
endpoints under `/api/*` or `/database/api/*`.
**`/app` and `/database` are one frontend project, `hub/filelib-web`, built once
and mounted at two prefixes.** They share the file library browser, the session
layer, the toast host, and the design tokens; splitting them would duplicate all
of it. `hub/database-admin` is deleted — superseded before it ever served a
request.
Two configuration constraints follow from co-hosting two SvelteKit SPAs on one
Fastify instance, and are load-bearing:
- `filelib-web` sets `appDir: '_filelib'`. The SvelteKit default `_app` collides
with the root `/_app/*` asset route that `admin-web` owns
(`src/admin/static.ts`); Fastify rejects duplicate routes at startup, so the
collision is a boot failure, not a silent misroute.
- `filelib-web` sets `paths.relative: false`. The same `index.html` is served at
different URL depths (`/app`, `/database/dashboard/users`), so relative asset
paths would resolve against the wrong base.
**Client-side navigation uses real URL routes, not hash fragments or hidden
sections.** The six back-office tabs are `/database/dashboard`,
`/database/dashboard/library`, `/users`, `/groups`, `/search`, `/settings`.
Refresh preserves position and links are shareable — the previous
`location.hash` + `display:none` scheme lost both.
Concrete routes must be registered before the SPA wildcards. This is an ordering
obligation on `database/plugin.ts`, not an incidental detail: the earlier
`/database/dashboard` shadowing bug is exactly what happens when a concrete page
route outranks the fallback.
## Consequences
- Authorization is enforced only by the JSON endpoints. A client-side guard (the
`isWebsiteAdmin` check in the dashboard layout) is a navigation convenience and
carries no security weight; every endpoint keeps its own `fail closed` guard.
- `/database/api/stats` is a new endpoint carrying what `loadDashboardStats` used
to compute inline. It requires silo org `OWNER`/`ADMIN` because it aggregates
org-wide counts and the audit stream rather than a per-node permission view.
- `/database/api/me` grew `displayName` and `avatarUrl`. Anything the old page
handler read from Prisma to render chrome has to become part of a JSON payload
or it is simply unavailable: the sidebar identity strip showed a raw `userId`
until these were added. When migrating a server-rendered surface, the data the
template closed over is part of the contract being ported, not an incidental
detail of the old implementation.
- Editing a page no longer requires a Hub restart in development; `vite dev`
serves the frontend and proxies data requests to the Hub. In production the
`index.html` is cached in memory at startup, so a frontend rebuild does require
a restart.
- Deploy scripts and the silo rate-limit exemption list name `filelib-web` and
`/_filelib/*`. Adding a fourth surface means picking another `appDir` and
extending that list.
- The design system is one file, `filelib-web/src/app.css`: an `@theme` block for
tokens plus an `@layer components` block for the shared component classes
(`.btn`, `.panel`, `.input`, `.select`, `.list`, `.tag`, `.quiet`, …).
`routes/uiTheme.ts` is deleted; both halves live there now.
The first cut of this migration kept only the tokens and restated button,
input, and panel styling inline in every component. That reproduced the
duplication the old code had — the admin panels visibly regressed — so the
component layer was ported too. Components carry layout utilities; they do not
restate component styling. The one admitted exception is a data-derived value
(tree indent computed from `depth`), which cannot be a static class.
The icon set (`lib/Icon.svelte`, 13 paths) is likewise shared rather than
restated. It came from `adminPanels.ts`; Group nodes deliberately use a
two-person silhouette, not a folder glyph, because `MemberGroup` and the file
library's `FOLDER`/`PROJECT` are unrelated hierarchies (ADR-0028, ADR-0021).
- **A migrated surface is only done when its endpoint coverage matches.** Two
panels were rebuilt from a superficially similar component that predated the
migration rather than from the server module they replaced, and the mismatch
was invisible in the rendered page:
- Group management called 5 of 8 endpoints. Rename (`PATCH`),
`?includeArchived=1`, `/restore`, and `/users/search` had no entry point, so
a soft-deleted group could not be restored through the UI at all even though
the backend fully supported it.
- The library browser dropped the `授权` tab entirely — `GET/PUT/DELETE
.../grants` and `PUT .../independent-permission` had no caller. Permission
editing is the point of the back office, and it was unreachable.
Diffing the route table against the frontend's `api()` call sites catches this;
reading the new page does not.
## Deferred
- `/admin` (admin-web) stays a separate project. It has its own design language
(`saas-*` classes, `surface-*`/`primary-*` scales) and a different audience;
merging it is not motivated by shared code.
- The `search` and `settings` tabs remain placeholders, as they were server-side.
- Serving `/admin` and `/database` from a single SPA, which would remove the
`appDir` collision constraint entirely.
+3
View File
@@ -12,3 +12,6 @@ dist/
admin-web/node_modules/
admin-web/build/
admin-web/.svelte-kit/
filelib-web/node_modules/
filelib-web/build/
filelib-web/.svelte-kit/
-23
View File
@@ -1,23 +0,0 @@
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-*
-1
View File
@@ -1 +0,0 @@
engine-strict=true
-4
View File
@@ -1,4 +0,0 @@
build
.svelte-kit
node_modules
package-lock.json
-9
View File
@@ -1,9 +0,0 @@
{
"useTabs": true,
"singleQuote": true,
"semi": true,
"trailingComma": "all",
"printWidth": 120,
"plugins": ["prettier-plugin-svelte"],
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
}
File diff suppressed because it is too large Load Diff
-33
View File
@@ -1,33 +0,0 @@
{
"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"
}
}
-29
View File
@@ -1,29 +0,0 @@
<!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>
-76
View File
@@ -1,76 +0,0 @@
/**
* 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>,
};
@@ -1,6 +0,0 @@
<!-- 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>
-7
View File
@@ -1,7 +0,0 @@
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';
}
-58
View File
@@ -1,58 +0,0 @@
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();
}
@@ -1,7 +0,0 @@
<script lang="ts">
import './app.css';
let { children } = $props();
</script>
{@render children()}
@@ -1,30 +0,0 @@
<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>
@@ -1,78 +0,0 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { base } from '$app/paths';
import { api, type DatabaseConfig } from '$lib/api';
import { feishuLoginHref } from '$lib/session';
import Aurora from '$lib/components/Aurora.svelte';
let config = $state<DatabaseConfig | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
// Already signed in → straight to the dashboard.
try {
await api.me();
await goto(`${base}/dashboard`, { replaceState: true });
return;
} catch {
// Not signed in (401) or backend unreachable — show the login card.
}
try {
config = await api.databaseConfig();
} catch (err) {
error = err instanceof Error ? err.message : String(err);
}
});
const feishuHref = $derived(config ? feishuLoginHref(config.siloOrganizationSlug) : '#');
</script>
<Aurora />
<main class="flex min-h-screen items-center justify-center p-4">
<div class="rise glass relative w-full max-w-sm rounded-3xl border border-white/80 p-8 shadow-2xl shadow-indigo-200/50">
<div class="mb-7 text-center">
<div
class="mx-auto mb-5 flex h-14 w-14 items-center justify-center rounded-2xl bg-gradient-to-br from-violet-500 to-cyan-400 text-2xl font-bold text-white glow-btn"
>
D
</div>
<h1 class="text-2xl font-bold tracking-tight grad-text">Database Admin</h1>
<p class="mt-2 text-sm text-slate-500">使用飞书登录以管理数据库</p>
</div>
{#if error}
<div class="mb-4 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-600">
无法连接后端:{error}
</div>
{/if}
<a
href={feishuHref}
aria-disabled={config ? 'false' : 'true'}
class="rise-2 glow-btn group flex w-full items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-violet-500 to-indigo-500 px-4 py-3.5 text-sm font-semibold text-white transition hover:from-violet-400 hover:to-indigo-400 aria-disabled:pointer-events-none aria-disabled:opacity-50"
>
<svg class="h-4 w-4" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 2 3 7v10l9 5 9-5V7l-9-5Zm0 2.3 6.5 3.6L12 11.5 5.5 7.9 12 4.3Z" />
</svg>
使用飞书登录
</a>
{#if config?.devLoginEnabled}
<div class="relative my-6 rise-3">
<div class="absolute inset-0 flex items-center"><div class="w-full border-t border-slate-200"></div></div>
<div class="relative flex justify-center">
<span class="bg-white/70 px-3 text-[11px] font-medium uppercase tracking-[0.2em] text-slate-400">开发模式</span>
</div>
</div>
<a
href="/database/dev-login"
class="rise-3 group flex w-full items-center justify-center gap-2 rounded-xl border border-amber-300 bg-amber-50 px-4 py-3 text-sm font-semibold text-amber-700 transition hover:border-amber-400 hover:bg-amber-100"
>
<span></span> 一键登录管理员
</a>
<p class="rise-3 mt-2 text-center text-xs text-slate-400">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
</div>
</main>
-96
View File
@@ -1,96 +0,0 @@
@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);
}
}
@@ -1,160 +0,0 @@
<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}
-5
View File
@@ -1,5 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 353 B

-3
View File
@@ -1,3 +0,0 @@
# allow crawling everything by default
User-agent: *
Disallow:
-22
View File
@@ -1,22 +0,0 @@
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;
-20
View File
@@ -1,20 +0,0 @@
{
"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
}
-17
View File
@@ -1,17 +0,0 @@
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',
},
},
});
+4 -4
View File
@@ -82,11 +82,11 @@ REMOTE
rsync -az --delete \
--exclude node_modules --exclude dist --exclude .env \
--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 \
--exclude filelib-web/node_modules --exclude filelib-web/build --exclude filelib-web/.svelte-kit \
-e "ssh ${SSH_OPTS[*]}" \
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
echo "[fleet] npm ci + build (tsc + admin-web & database-admin SPAs)"
echo "[fleet] npm ci + build (tsc + admin-web & filelib-web SPAs)"
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" bash -s <<REMOTE
set -euo pipefail
flock /var/lock/cph-hub-release-publish bash -c '
@@ -98,11 +98,11 @@ flock /var/lock/cph-hub-release-publish bash -c '
cd "$HUB_DIR"
PUPPETEER_SKIP_DOWNLOAD=1 npm ci
npm ci --prefix admin-web
npm ci --prefix database-admin
npm ci --prefix filelib-web
npm run audit:production
npm run build
test -f admin-web/build/index.html
test -f database-admin/build/index.html
test -f filelib-web/build/index.html
touch "$RELEASE_DIR/.complete"
'
REMOTE
+3 -3
View File
@@ -61,10 +61,10 @@ if [ "$release_ready" = false ]; then
"$REPO_ROOT/hub/" "$DEPLOY_USER@$HOST:$HUB_DIR/"
# 2. Install deps (hub + both SPAs), audit hub prod, build tsc + SPAs, mark complete.
# `npm run build` → tsc then admin:build + database:build → admin-web/build and
# database-admin/build for registerStaticSpa / registerDatabaseSpa.
# `npm run build` → tsc then admin:build + filelib:build → admin-web/build and
# filelib-web/build for registerStaticSpa / registerDatabaseSpa.
ssh "${SSH_OPTS[@]}" "$DEPLOY_USER@$HOST" \
"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'"
"cd '$HUB_DIR' && PUPPETEER_SKIP_DOWNLOAD=1 npm ci && npm ci --prefix admin-web && npm ci --prefix filelib-web && npm run audit:production && npm run build && touch '$RELEASE_DIR/.complete'"
fi
# 3. Ensure the service is installed (idempotent), then restart.
-12
View File
@@ -1,12 +0,0 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>文件库</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
+137
View File
@@ -8,6 +8,8 @@
"name": "filelib-web",
"version": "0.1.0",
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"svelte": "^5.56.1",
@@ -130,6 +132,13 @@
"url": "https://github.com/sponsors/Boshen"
}
},
"node_modules/@polka/url": {
"version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
"integrity": "sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==",
"dev": true,
"license": "MIT"
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.5.tgz",
@@ -412,6 +421,13 @@
"dev": true,
"license": "MIT"
},
"node_modules/@standard-schema/spec": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz",
"integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==",
"dev": true,
"license": "MIT"
},
"node_modules/@sveltejs/acorn-typescript": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.11.tgz",
@@ -422,6 +438,58 @@
"acorn": "^8.9.0"
}
},
"node_modules/@sveltejs/adapter-static": {
"version": "3.0.10",
"resolved": "https://registry.npmjs.org/@sveltejs/adapter-static/-/adapter-static-3.0.10.tgz",
"integrity": "sha512-7D9lYFWJmB7zxZyTE/qxjksvMqzMuYrrsyh1f4AlZqeZeACPRySjbC3aFiY55wb1tWUaKOQG9PVbm74JcN2Iew==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@sveltejs/kit": "^2.0.0"
}
},
"node_modules/@sveltejs/kit": {
"version": "2.70.1",
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz",
"integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9",
"@types/cookie": "^0.6.0",
"acorn": "^8.16.0",
"cookie": "^0.6.0",
"devalue": "^5.8.1",
"esm-env": "^1.2.2",
"kleur": "^4.1.5",
"magic-string": "^0.30.5",
"mrmime": "^2.0.0",
"set-cookie-parser": "^3.0.0",
"sirv": "^3.0.0"
},
"bin": {
"svelte-kit": "svelte-kit.js"
},
"engines": {
"node": ">=18.13"
},
"peerDependencies": {
"@opentelemetry/api": "^1.0.0",
"@sveltejs/vite-plugin-svelte": "^3.0.0 || ^4.0.0-next.1 || ^5.0.0 || ^6.0.0-next.0 || ^7.0.0",
"svelte": "^4.0.0 || ^5.0.0-next.0",
"typescript": "^5.3.3 || ^6.0.0",
"vite": "^5.0.3 || ^6.0.0 || ^7.0.0-beta.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"typescript": {
"optional": true
}
}
},
"node_modules/@sveltejs/load-config": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@sveltejs/load-config/-/load-config-0.2.0.tgz",
@@ -747,6 +815,13 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
@@ -820,6 +895,16 @@
"node": ">=6"
}
},
"node_modules/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">= 0.6"
}
},
"node_modules/deepmerge": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz",
@@ -946,6 +1031,16 @@
"jiti": "lib/jiti-cli.mjs"
}
},
"node_modules/kleur": {
"version": "4.1.5",
"resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz",
"integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/lightningcss": {
"version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
@@ -1246,6 +1341,16 @@
"node": ">=4"
}
},
"node_modules/mrmime": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.1.tgz",
"integrity": "sha512-Y3wQdFg2Va6etvQ5I82yUhGdsKrcYox6p7FfL1LbK2J4V01F9TGlepTIhnK24t7koZibmg82KGglhA1XK5IsLQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=10"
}
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
@@ -1389,6 +1494,28 @@
"node": ">=6"
}
},
"node_modules/set-cookie-parser": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-3.1.2.tgz",
"integrity": "sha512-5/r/lTwbJ3zQ+qwdUFZYeRNqda7P5HD8zQKqlSjdGt1/S0cjLAphHusj4Y58ahDtWn/g32xrIS58/ikOvwl0Lw==",
"dev": true,
"license": "MIT"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
"integrity": "sha512-2wcC/oGxHis/BoHkkPwldgiPSYcpZK3JU28WoMVv55yHJgcZ8rlXvuG9iZggz+sU1d4bRgIGASwyWqjxu3FM0g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@polka/url": "^1.0.0-next.24",
"mrmime": "^2.0.0",
"totalist": "^3.0.0"
},
"engines": {
"node": ">=18"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1490,6 +1617,16 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/totalist": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/totalist/-/totalist-3.0.1.tgz",
"integrity": "sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
+5 -2
View File
@@ -4,12 +4,15 @@
"version": "0.1.0",
"type": "module",
"scripts": {
"dev": "vite",
"dev": "vite dev",
"build": "vite build",
"preview": "vite preview",
"check": "svelte-check --tsconfig ./tsconfig.json"
"prepare": "svelte-kit sync || echo ''",
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json"
},
"devDependencies": {
"@sveltejs/adapter-static": "^3.0.10",
"@sveltejs/kit": "^2.63.0",
"@sveltejs/vite-plugin-svelte": "^7.1.2",
"@tailwindcss/vite": "^4.3.2",
"svelte": "^5.56.1",
-30
View File
@@ -1,30 +0,0 @@
<script lang="ts">
import { onMount } from "svelte";
import { api, UnauthenticatedError } from "./lib/api.js";
import { me, authChecked } from "./lib/stores.js";
import type { MeResponse } from "./lib/types.js";
import LoginView from "./lib/LoginView.svelte";
import BrowserShell from "./lib/BrowserShell.svelte";
import Toasts from "./lib/Toasts.svelte";
onMount(async () => {
try {
me.set(await api<MeResponse>("/database/api/me"));
} catch (e) {
if (!(e instanceof UnauthenticatedError)) console.error(e);
me.set(null);
} finally {
authChecked.set(true);
}
});
</script>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me}
<BrowserShell />
{:else}
<LoginView />
{/if}
<Toasts />
+199 -2
View File
@@ -23,8 +23,7 @@
}
html,
body,
#app {
body {
height: 100%;
}
@@ -47,6 +46,204 @@ body {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
/* 共享组件层(ADR-0029)。
*
* 从已删除的 hub/src/database/routes/uiTheme.ts 原样搬来。组件只带布局工具类,
* 不重述这里的组件样式 —— 第一版迁移只搬了上面的 @theme 令牌,把按钮/输入框/
* 面板样式在每个组件里内联重写了一遍,后台随即明显退化。 */
@layer components {
.btn {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 6px 14px;
border-radius: 8px;
border: 1px solid var(--color-line);
background: var(--color-panel);
color: var(--color-ink);
font-size: 12.5px;
font-weight: 500;
cursor: pointer;
transition: all 120ms ease;
white-space: nowrap;
}
.btn:hover {
background: var(--color-hover);
}
.btn-sm {
padding: 4px 9px;
font-size: 11.5px;
}
.btn-primary {
background: var(--color-accent);
border-color: var(--color-accent);
color: #fff;
}
.btn-primary:hover {
background: var(--color-accent-hover);
border-color: var(--color-accent-hover);
}
.btn-danger {
border-color: transparent;
background: transparent;
color: var(--color-danger);
}
.btn-danger:hover {
background: color-mix(in srgb, var(--color-danger) 7%, transparent);
}
.panel {
background: var(--color-panel);
border: 1px solid var(--color-line-soft);
border-radius: 10px;
padding: 20px 22px;
}
.tag {
display: inline-flex;
align-items: center;
gap: 3px;
font-size: 10.5px;
font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
padding: 2px 8px;
border-radius: 999px;
border: 1px solid var(--color-line-soft);
color: var(--color-ink-3);
background: var(--color-panel);
}
.input,
.select,
.textarea {
width: 100%;
padding: 7px 11px;
border-radius: 8px;
border: 1px solid var(--color-line);
background: var(--color-panel);
font-size: 13px;
color: var(--color-ink);
font-family: inherit;
outline: none;
transition: border-color 120ms ease;
}
.input:focus,
.select:focus,
.textarea:focus {
border-color: var(--color-accent);
}
.textarea {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 12.5px;
line-height: 1.75;
resize: vertical;
}
.form-label {
display: block;
font-size: 11.5px;
color: var(--color-ink-3);
margin-bottom: 4px;
}
.form-row {
margin-bottom: 12px;
}
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(--color-ink-3);
padding: 4px 0;
}
table.list td {
padding: 8px 0;
border-top: 1px solid var(--color-line-soft);
}
table.list tr:first-child td {
border-top: none;
}
.quiet {
color: var(--color-ink-3);
font-size: 12.5px;
}
.section-title {
font-size: 13px;
font-weight: 600;
}
.section-note {
font-size: 11.5px;
color: var(--color-ink-3);
}
.file-meta {
font-size: 11px;
color: var(--color-ink-3);
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.link-danger {
color: var(--color-danger);
font-size: 12.5px;
background: none;
border: none;
cursor: pointer;
padding: 0;
}
.link-danger:hover {
text-decoration: underline;
}
/* 开关。真 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(--color-line);
transition: background 0.16s;
}
.switch > span::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 13px;
height: 13px;
border-radius: 50%;
background: #fff;
transition: transform 0.16s;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.25);
}
.switch > input:checked + span {
background: var(--color-accent);
}
.switch > input:checked + span::after {
transform: translateX(13px);
}
.switch > input:focus-visible + span {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
}
/* diff 渲染 */
pre.diff {
white-space: pre-wrap;
@@ -1,5 +1,4 @@
// See https://svelte.dev/docs/kit/types#app.d.ts
// for information about these interfaces
// See https://svelte.dev/docs/kit/types#app
declare global {
namespace App {
// interface Error {}
+16
View File
@@ -0,0 +1,16 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
%sveltekit.head%
</head>
<body data-sveltekit-preload-data="hover" style="height: 100%">
<div style="display: contents; height: 100%">%sveltekit.body%</div>
</body>
</html>
+31
View File
@@ -0,0 +1,31 @@
<script lang="ts">
/** 头像:有 avatarUrl 用图,否则显示首字母色块。 */
let {
displayName,
userId,
avatarUrl = null,
size = 28,
}: {
displayName?: string | null;
userId?: string | null;
avatarUrl?: string | null;
size?: number;
} = $props();
const initial = $derived((displayName || userId || "?").slice(0, 1).toUpperCase());
</script>
{#if avatarUrl}
<img
src={avatarUrl}
alt=""
class="shrink-0 rounded-full object-cover"
style="width:{size}px;height:{size}px"
/>
{:else}
<span
class="inline-flex shrink-0 items-center justify-center rounded-full bg-accent font-semibold text-white"
style="width:{size}px;height:{size}px;font-size:{Math.round(size * 0.42)}px"
aria-hidden="true">{initial}</span
>
{/if}
+15 -14
View File
@@ -3,6 +3,7 @@
import { toastOk, toastErr, toast } from "./stores.js";
import type { FileContent, VersionInfo, Role } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { projectId, path, role, onchanged, onclose }: { projectId: string; path: string; role: Role; onchanged: () => void; onclose?: () => void } = $props();
@@ -104,32 +105,32 @@
</script>
{#if loadError}
<div class="rounded-xl border border-line-soft bg-panel p-5 text-xs text-danger">{loadError}</div>
<div class="panel text-xs text-danger">{loadError}</div>
{:else if file}
<div class="rounded-xl border border-line-soft bg-panel p-5">
<div class="panel">
<div class="mb-2.5 flex items-center justify-between">
<span class="font-mono text-[11px] text-ink-3">{file.path} @ {file.version}</span>
<div class="flex items-center gap-2">
<a class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
<button class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={openHistory}>历史</button>
<span class="file-meta">{file.path} @ {file.version}</span>
<div class="flex items-center gap-1.5">
<a class="btn" href="/database/api/projects/{projectId}/file/raw?path={encodeURIComponent(file.path)}" download>下载</a>
<button class="btn" onclick={openHistory}><Icon name="clock" size={13} /> 历史</button>
{#if canEdit}
<button class="rounded-lg border border-transparent px-3 py-1 text-[12px] font-medium text-danger transition hover:bg-[#A13A3312]" onclick={remove}>删除文件</button>
<button class="btn btn-danger" onclick={remove}><Icon name="trash" size={13} /> 删除文件</button>
{/if}
{#if onclose}
<button class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[12px] font-medium text-ink-3 transition hover:bg-hover hover:text-ink" onclick={onclose} title="关闭预览"></button>
<button class="btn !px-2.5" onclick={onclose} title="关闭预览" aria-label="关闭预览"></button>
{/if}
</div>
</div>
{#if file.encoding === "base64"}
<div class="text-xs text-ink-3">二进制文件({file.size} B),不支持在线编辑</div>
<div class="quiet">二进制文件({file.size} B),不支持在线编辑</div>
{:else}
<textarea rows="14" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs leading-7 outline-none focus:border-accent" bind:value={draft} readonly={!canEdit}></textarea>
<textarea rows="14" class="textarea !leading-7" bind:value={draft} readonly={!canEdit}></textarea>
{/if}
{#if canEdit && file.encoding !== "base64"}
<div class="mt-3 flex justify-end">
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={save}>提交修改</button>
<button class="btn btn-primary" onclick={save}>提交修改</button>
</div>
{/if}
@@ -139,13 +140,13 @@
<pre class="diff rounded-lg border border-line-soft bg-panel p-3">{@html renderDiff(conflict.diff)}</pre>
<div class="mt-2 text-[11.5px] text-[#8A8059]">请人工合并后,以最新内容为全文重新提交(基版将更新为 {conflict.currentVersion})</div>
<div class="mt-2 flex justify-end">
<button class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={acceptLatest}>载入最新内容</button>
<button class="btn" onclick={acceptLatest}>载入最新内容</button>
</div>
</div>
{/if}
</div>
{:else}
<div class="rounded-xl border border-line-soft bg-panel p-5 text-xs text-ink-3">加载中…</div>
<div class="panel quiet">加载中…</div>
{/if}
{#if showHistory}
@@ -159,7 +160,7 @@
{/each}
</div>
<div class="mt-3 flex justify-end">
<button class="rounded-lg border border-line bg-panel px-3 py-1 text-[12px] font-medium text-ink transition hover:bg-hover" onclick={() => (showHistory = false)}>关闭</button>
<button class="btn" onclick={() => (showHistory = false)}>关闭</button>
</div>
</Modal>
{/if}
+23 -21
View File
@@ -4,6 +4,7 @@
import { selectedFilePath, filesVersion } from "./browser.js";
import type { FileEntry, NodeDetail } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
@@ -12,7 +13,8 @@
let showNewFile = $state(false);
let newPath = $state("");
let newContent = $state("");
let uploadInput: HTMLInputElement;
// bind:this 的目标要用 $state,否则 svelte 5 warn 不会正确更新。
let uploadInput = $state<HTMLInputElement | null>(null);
const canEdit = $derived(node.role !== "VIEW");
@@ -84,34 +86,34 @@
}
</script>
<div class="rounded-xl border border-line-soft bg-panel p-5">
<div class="mb-2 flex items-center justify-between">
<div class="text-[13px] font-semibold">项目文件({files?.length ?? 0})</div>
<div class="panel">
<div class="mb-1.5 flex items-center justify-between">
<div class="section-title">项目文件({files?.length ?? 0})</div>
{#if canEdit}
<div class="flex gap-2">
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showNewFile = true)}>+ 新建文件</button>
<button class="rounded-lg bg-accent px-3 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={() => uploadInput.click()}>上传文件</button>
<div class="flex gap-1.5">
<button class="btn" onclick={() => (showNewFile = true)}><Icon name="plus" size={13} /> 新建文件</button>
<button class="btn btn-primary" onclick={() => uploadInput?.click()}>上传文件</button>
<input bind:this={uploadInput} type="file" class="hidden" onchange={doUpload} />
</div>
{/if}
</div>
{#if files === null && loadError === null}
<div class="py-5 text-center text-xs text-ink-3">加载中…</div>
<div class="quiet py-5 text-center">加载中…</div>
{:else if loadError}
<div class="py-5 text-center text-xs text-danger">{loadError}</div>
{:else if files && files.length === 0}
<div class="py-5 text-center text-xs text-ink-3">空仓库 · 可新建或上传文件</div>
<div class="quiet py-5 text-center">空仓库 · 可新建或上传文件</div>
{:else if files}
<table class="w-full border-collapse text-[13px]">
<table class="list">
<tbody>
{#each files as f (f.path)}
<tr
class="cursor-pointer border-t border-line-soft first:border-t-0 {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
class="cursor-pointer {$selectedFilePath === f.path ? 'bg-selected' : 'hover:bg-hover'}"
onclick={() => selectedFilePath.set(f.path)}
>
<td class="py-2 font-mono text-[12.5px] text-ink">{f.path}</td>
<td class="py-2 text-right font-mono text-[11px] text-ink-3">{f.size} B</td>
<td class="font-mono text-[12.5px] text-ink">{f.path}</td>
<td class="file-meta text-right">{f.size} B</td>
</tr>
{/each}
</tbody>
@@ -121,17 +123,17 @@
{#if showNewFile}
<Modal title="新建文件" onclose={() => (showNewFile = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="nf-path">路径</label>
<input id="nf-path" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-[13px] outline-none focus:border-accent" bind:value={newPath} placeholder="docs/intro.md" />
<div class="form-row">
<label class="form-label" for="nf-path">路径</label>
<input id="nf-path" class="input font-mono" bind:value={newPath} placeholder="docs/intro.md" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="nf-content">内容</label>
<textarea id="nf-content" rows="8" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newContent} placeholder="内容…"></textarea>
<div class="form-row">
<label class="form-label" for="nf-content">内容</label>
<textarea id="nf-content" rows="8" class="textarea" bind:value={newContent} placeholder="内容…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showNewFile = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={submitNewFile}>创建</button>
<button class="btn" onclick={() => (showNewFile = false)}>取消</button>
<button class="btn btn-primary" onclick={submitNewFile}>创建</button>
</div>
</Modal>
{/if}
+191
View File
@@ -0,0 +1,191 @@
<script lang="ts">
/**
* 节点授权面板。迁自已删除的 routes/libraryBrowser.ts `renderGrantsTab`(ADR-0029)。
*
* 迁移时整个「授权」tab 连同这四个端点一起漏掉了 —— 后端一直可用,只是前端没入口。
*
* 语义(契约 8.1 / ADR-0021):
* - 创建者授权(isCreatorGrant)不可收回、不可改;
* - MANAGE 仅创建者可授,这里不做前端拦截 —— 后端 fail closed,报错原样呈现;
* - GROUP 主体走 in-hub MemberGroup(ADR-0028),用 /groups/search 选,不手敲 id。
*/
import { api } from "./api.js";
import { toastOk, toastErr } from "./stores.js";
import { currentNode } from "./browser.js";
import type { Grant, MemberGroupSearchResult, NodeDetail, Role } from "./types.js";
import Icon from "./Icon.svelte";
let { node }: { node: NodeDetail } = $props();
const ROLES: readonly Role[] = ["VIEW", "EDIT", "MANAGE"];
let grants = $state<Grant[] | null>(null);
let error = $state<string | null>(null);
let principalType = $state<"USER" | "GROUP">("USER");
let userIdInput = $state("");
let groupId = $state("");
let groupOptions = $state<MemberGroupSearchResult[] | null>(null);
let role = $state<Role>("VIEW");
let saving = $state(false);
const canManage = $derived(node.role === "MANAGE");
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
$effect(() => {
void node.id;
void load();
});
async function load(): Promise<void> {
grants = null;
error = null;
try {
const r = await api<{ grants: Grant[] }>(`/database/api/nodes/${node.id}/grants`);
grants = r.grants;
} catch (e) {
error = errText(e);
}
}
/** 切到 GROUP 时懒加载候选组(活跃组 + breadcrumb)。 */
async function onTypeChange(): Promise<void> {
if (principalType !== "GROUP" || groupOptions !== null) return;
try {
const r = await api<{ groups: MemberGroupSearchResult[] }>("/database/api/groups/search?q=");
groupOptions = r.groups;
if (r.groups.length > 0 && groupId === "") groupId = r.groups[0]!.id;
} catch (e) {
toastErr(errText(e));
}
}
async function addGrant(): Promise<void> {
const principalId = principalType === "GROUP" ? groupId : userIdInput.trim();
if (principalId === "") {
toastErr("请填写主体");
return;
}
saving = true;
try {
// PUT /grants 是增量语义(putGrants),不是整表替换。
await api(`/database/api/nodes/${node.id}/grants`, {
method: "PUT",
body: { grants: [{ principalType, principalId, role }] },
});
toastOk("已授予");
userIdInput = "";
await load();
} catch (e) {
toastErr(errText(e));
} finally {
saving = false;
}
}
async function revoke(g: Grant): Promise<void> {
if (!confirm(`收回「${g.principalId}」的 ${g.role} 授权?`)) return;
try {
await api(`/database/api/nodes/${node.id}/grants/${encodeURIComponent(g.id)}`, {
method: "DELETE",
});
toastOk("已收回");
await load();
} catch (e) {
toastErr(errText(e));
}
}
/** 独立权限开关(仅 PROJECT;关闭时只继承父级,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
await load();
} catch (e) {
toastErr(errText(e));
}
}
</script>
<div class="panel">
{#if error !== null}
<div class="py-2 text-[12.5px] text-danger">{error}</div>
{:else if grants === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else}
<table class="list">
<thead>
<tr><th>主体</th><th>级别</th><th></th></tr>
</thead>
<tbody>
{#if grants.length === 0}
<tr><td colspan="3" class="quiet !py-[18px] text-center">暂无显式授权</td></tr>
{:else}
{#each grants as g (g.id)}
<tr>
<td>
<span class="inline-flex items-center gap-2">
<span class="flex text-ink-3"><Icon name={g.principalType === "USER" ? "user" : "group"} size={14} /></span>
<span class="font-mono text-[12px]">{g.principalId}</span>
{#if g.isCreatorGrant}<span class="quiet">(创建者)</span>{/if}
</span>
</td>
<td class="file-meta">{g.role}</td>
<td class="text-right">
<!-- 创建者授权不可动(契约 8.1);非 MANAGE 也不给收回入口。 -->
{#if !g.isCreatorGrant && canManage}
<button class="link-danger inline-flex items-center gap-1" onclick={() => void revoke(g)}>
<Icon name="minus" size={12} /> 收回
</button>
{/if}
</td>
</tr>
{/each}
{/if}
</tbody>
</table>
{/if}
{#if canManage}
<div class="my-3.5 border-t border-line-soft"></div>
<div class="section-title mb-2.5">新增授权</div>
<div class="flex flex-wrap items-center gap-2">
<select class="select !w-[110px]" bind:value={principalType} onchange={onTypeChange}>
<option value="USER">用户</option>
<option value="GROUP">Group</option>
</select>
{#if principalType === "USER"}
<input class="input min-w-0 flex-1" placeholder="用户 id" bind:value={userIdInput} />
{:else if groupOptions === null}
<span class="quiet flex-1">加载 Group 列表…</span>
{:else if groupOptions.length === 0}
<span class="quiet flex-1">暂无可选 Group · 先到「Group 管理」建一个</span>
{:else}
<select class="select min-w-0 flex-1" bind:value={groupId}>
{#each groupOptions as g (g.id)}
<option value={g.id}>{g.breadcrumb}</option>
{/each}
</select>
{/if}
<select class="select !w-[110px]" bind:value={role}>
{#each ROLES as r (r)}
<option value={r}>{r}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addGrant} disabled={saving}>
{saving ? "授予中…" : "授予"}
</button>
</div>
<div class="section-note mt-1.5">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>
{/if}
</div>
+624 -142
View File
@@ -1,78 +1,174 @@
<script lang="ts">
/**
* Group 管理面板。从已删除的 routes/adminPanels.ts `renderGroupsPanel`(747 行)
* 迁来(ADR-0029),功能与视觉逐条对齐:折叠树 / 组名过滤(命中项保留祖先链)/
* 归档组展示与恢复 / 右键菜单 / 面包屑 / 统计条 / 成员表(头像·openId·加入时间)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { toastErr, toastOk } from "./stores.js";
import type { MemberGroupNode, MemberGroupMember } from "./types.js";
import type { MemberGroupNode, MemberGroupMember, UserSearchResult } from "./types.js";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
import Avatar from "./Avatar.svelte";
// 后端返回活跃组扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[] | null>(null);
// 后端返回扁平列表(ADR-0028);前端按 parentId/depth 拼成有序树。
let groups = $state<MemberGroupNode[]>([]);
let loaded = $state(false);
let listError = $state<string | null>(null);
let selectedId = $state<string | null>(null);
let members = $state<MemberGroupMember[] | null>(null);
// 折叠的组 id(默认全展开)。用数组而非 Set:$state 的深层代理只跟踪普通对象/
// 数组,Set 的变更不会触发重渲染。
let collapsedIds = $state<string[]>([]);
const isCollapsed = (id: string): boolean => collapsedIds.includes(id);
const toggleCollapsed = (id: string): void => {
collapsedIds = isCollapsed(id) ? collapsedIds.filter((x) => x !== id) : [...collapsedIds, id];
};
let filterText = $state("");
let showArchived = $state(false);
let members = $state<MemberGroupMember[]>([]);
let membersLoaded = $state(false);
let membersError = $state<string | null>(null);
let memberFilter = $state("");
// 新建组弹窗:parentId=null 建根,否则建子。
let showCreate = $state(false);
let createParentId = $state<string | null>(null);
let createParentName = $state<string | null>(null);
let newName = $state("");
let newDesc = $state("");
const selected = $derived(groups.find((g) => g.id === selectedId) ?? null);
const isArchived = $derived(selected?.archivedAt != null);
let addValue = $state("");
interface Row {
readonly g: MemberGroupNode;
readonly hasKids: boolean;
/** 过滤态下:自身是否命中(祖先链上的非命中项半透明显示)。 */
readonly hit: boolean;
}
const selected = $derived(groups?.find((g) => g.id === selectedId) ?? null);
/** 扁平列表按 parentId 排成先根遍历顺序(每项自带 depth,渲染时缩进)。 */
const ordered = $derived.by(() => {
if (groups === null) return [];
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
const rows = $derived.by((): Row[] => {
const byParent = new Map<string | null, MemberGroupNode[]>();
const byId = new Map<string, MemberGroupNode>();
for (const g of groups) {
byId.set(g.id, g);
const arr = byParent.get(g.parentId) ?? [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name));
const out: MemberGroupNode[] = [];
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
const q = filterText.trim().toLowerCase();
let keep: Set<string> | null = null;
if (q !== "") {
keep = new Set<string>();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur: MemberGroupNode | undefined = g;
while (cur !== undefined) {
keep.add(cur.id);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
}
}
const out: Row[] = [];
const walk = (parentId: string | null): void => {
for (const g of byParent.get(parentId) ?? []) {
out.push(g);
walk(g.id);
if (keep !== null && !keep.has(g.id)) continue;
const kids = (byParent.get(g.id) ?? []).filter((k) => keep === null || keep.has(k.id));
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
// 过滤态下强制展开(否则命中项被折叠的祖先藏住)。
if (keep !== null || !isCollapsed(g.id)) walk(g.id);
}
};
walk(null);
// 兜底:孤儿(父已不在活跃列表,理论上级联软删不会出现)也列出
const seen = new Set(out.map((g) => g.id));
for (const g of groups) if (!seen.has(g.id)) out.push(g);
// 兜底:父不在列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"
const seen = new Set(out.map((r) => r.g.id));
for (const g of groups) {
if (seen.has(g.id)) continue;
if (keep !== null && !keep.has(g.id)) continue;
out.push({ g, hasKids: false, hit: true });
}
return out;
});
const treeFoot = $derived.by(() => {
const active = groups.filter((g) => g.archivedAt === null);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
return (
`${active.length} 个活跃组 · ${totalMembers} 条成员关系` +
(archivedN > 0 ? ` · ${archivedN} 个已删除` : "")
);
});
/** 面包屑:祖先链(根在前,自身在末)。 */
const chain = $derived.by((): MemberGroupNode[] => {
if (selected === null) return [];
const byId = new Map(groups.map((g) => [g.id, g]));
const out: MemberGroupNode[] = [];
for (let cur: MemberGroupNode | undefined = selected; cur !== undefined; ) {
out.unshift(cur);
cur = cur.parentId === null ? undefined : byId.get(cur.parentId);
}
return out;
});
const childCount = $derived(groups.filter((g) => g.parentId === selectedId).length);
const shownMembers = $derived.by(() => {
const q = memberFilter.trim().toLowerCase();
if (q === "") return members;
return members.filter(
(m) =>
m.displayName.toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
m.feishuOpenId.toLowerCase().includes(q),
);
});
function fmtDate(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" });
} catch {
return iso;
}
}
const errText = (e: unknown): string => (e instanceof Error ? e.message : String(e));
async function loadGroups(): Promise<void> {
try {
const r = await api<{ groups: MemberGroupNode[] }>("/database/api/groups");
const r = await api<{ groups: MemberGroupNode[] }>(
`/database/api/groups${showArchived ? "?includeArchived=1" : ""}`,
);
groups = r.groups;
listError = null;
loaded = true;
if (selectedId !== null && !groups.some((g) => g.id === selectedId)) {
selectedId = null;
members = null;
members = [];
membersLoaded = false;
}
} catch (e) {
listError = e instanceof Error ? e.message : String(e);
listError = errText(e);
loaded = true;
}
}
async function loadMembers(): Promise<void> {
if (selectedId === null) return;
members = null;
membersLoaded = false;
membersError = null;
try {
const r = await api<{ members: MemberGroupMember[] }>(
`/database/api/groups/${encodeURIComponent(selectedId)}/members`,
);
members = r.members;
membersLoaded = true;
} catch (e) {
membersError = e instanceof Error ? e.message : String(e);
membersError = errText(e);
membersLoaded = true;
}
}
@@ -80,12 +176,77 @@
function select(id: string): void {
selectedId = id;
memberFilter = "";
void loadMembers();
}
async function toggleArchived(): Promise<void> {
showArchived = !showArchived;
await loadGroups();
}
/* ---------------- 右键菜单 ---------------- */
interface MenuItem {
readonly label?: string;
readonly ic?: import("./Icon.svelte").IconName;
readonly danger?: boolean;
readonly sep?: boolean;
readonly fn?: () => void;
}
let menu = $state<{ x: number; y: number; items: MenuItem[] } | null>(null);
function openMenu(e: MouseEvent, target: MemberGroupNode | null): void {
e.preventDefault();
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items: MenuItem[] =
target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => openCreate(null) }]
: target.archivedAt !== null
? [
{ label: "查看成员(只读)", ic: "users", fn: () => select(target.id) },
{ label: "恢复此 Group", ic: "restore", fn: () => void restoreGroup(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
]
: [
{ label: "新建子 Group", ic: "plus", fn: () => openCreate(target) },
{ label: "添加成员", ic: "user", fn: () => { select(target.id); openAddMember(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => openRename(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => openCreate(null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => void deleteGroup(target) },
];
// 贴边翻转,避免菜单溢出视口(菜单宽 184、每项约 34)。
const w = 184;
const h = items.reduce((n, it) => n + (it.sep === true ? 9 : 34), 10);
menu = {
x: Math.min(e.clientX, window.innerWidth - w - 8),
y: Math.min(e.clientY, window.innerHeight - h - 8),
items,
};
}
/* ---------------- 弹窗 ---------------- */
let showCreate = $state(false);
let createParent = $state<MemberGroupNode | null>(null);
let newName = $state("");
let newDesc = $state("");
let showRename = $state(false);
let renameTarget = $state<MemberGroupNode | null>(null);
let editName = $state("");
let editDesc = $state("");
let showAdd = $state(false);
let addQuery = $state("");
let addResults = $state<UserSearchResult[]>([]);
let addSearching = $state(false);
function openCreate(parent: MemberGroupNode | null): void {
createParentId = parent?.id ?? null;
createParentName = parent?.name ?? null;
createParent = parent;
newName = "";
newDesc = "";
showCreate = true;
@@ -93,188 +254,509 @@
async function createGroup(): Promise<void> {
const name = newName.trim();
if (name === "") return;
if (name === "") {
toastErr("名称必填");
return;
}
const parentId = createParent?.id ?? null;
try {
await api("/database/api/groups", {
method: "POST",
body: {
name,
parentId: createParentId,
...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}),
},
body: { name, parentId, ...(newDesc.trim() !== "" ? { description: newDesc.trim() } : {}) },
});
toastOk("已创建成员组");
showCreate = false;
// 建完自动展开父节点,否则新子组藏在折叠的父下面看不见。
if (parentId !== null) collapsedIds = collapsedIds.filter((x) => x !== parentId);
toastOk("已创建成员组");
await loadGroups();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
toastErr(errText(e));
}
}
function openRename(g: MemberGroupNode): void {
renameTarget = g;
editName = g.name;
editDesc = g.description ?? "";
showRename = true;
}
async function saveRename(): Promise<void> {
if (renameTarget === null) return;
const name = editName.trim();
if (name === "") {
toastErr("名称必填");
return;
}
try {
// description 总是回传(含空串)—— 空串即清除描述(ADR-0028 决策6)。
await api(`/database/api/groups/${encodeURIComponent(renameTarget.id)}`, {
method: "PATCH",
body: { name, description: editDesc.trim() },
});
showRename = false;
toastOk("已保存");
await loadGroups();
} catch (e) {
toastErr(errText(e));
}
}
async function deleteGroup(g: MemberGroupNode): Promise<void> {
if (!confirm(`删除「${g.name}」?其整棵子树将一并归档,相关授权立即失效。`)) return;
if (
!confirm(
`删除「${g.name}」?\n\n软删除:整棵子树一并标记删除,相关授权立即失效,` +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。",
)
)
return;
try {
const r = await api<{ archivedCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}`,
{ method: "DELETE" },
);
toastOk(`已归档 ${r.archivedCount} 个组`);
if (selectedId === g.id) { selectedId = null; members = null; }
if (selectedId === g.id && !showArchived) {
selectedId = null;
members = [];
membersLoaded = false;
}
toastOk(`已删除 ${r.archivedCount} 个组(软删除,可恢复)`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
toastErr(errText(e));
}
}
async function addMember(): Promise<void> {
const v = addValue.trim();
if (v === "" || selectedId === null) return;
async function restoreGroup(g: MemberGroupNode): Promise<void> {
// 恢复语义与删除不对称(ADR-0028 决策7):只回该组 + 已归档祖先链,子树仍归档。
if (
!confirm(
`恢复「${g.name}」?\n\n其已删除的上级会一并恢复(否则它在树上无路径);` +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。",
)
)
return;
try {
// ou_ 开头当飞书 openId,其余当 userId(与 hub 现有习惯一致)。
const body = v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v };
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body,
});
addValue = "";
toastOk("已添加成员");
await Promise.all([loadMembers(), loadGroups()]);
const r = await api<{ restoredCount: number }>(
`/database/api/groups/${encodeURIComponent(g.id)}/restore`,
{ method: "POST" },
);
toastOk(`已恢复 ${r.restoredCount} 个组`);
await loadGroups();
if (selectedId === g.id) await loadMembers();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
toastErr(errText(e));
}
}
async function removeMember(userId: string): Promise<void> {
function openAddMember(): void {
addQuery = "";
addResults = [];
showAdd = true;
}
/** 成员选择器:搜全局用户,excludeGroupId 过滤掉本组已有成员。 */
async function searchUsers(): Promise<void> {
if (selectedId === null) return;
addSearching = true;
try {
const r = await api<{ users: UserSearchResult[] }>(
`/database/api/users/search?q=${encodeURIComponent(addQuery.trim())}` +
`&excludeGroupId=${encodeURIComponent(selectedId)}`,
);
addResults = r.users;
} catch (e) {
toastErr(errText(e));
} finally {
addSearching = false;
}
}
async function addMember(userId: string): Promise<void> {
if (selectedId === null) return;
try {
await api(`/database/api/groups/${encodeURIComponent(selectedId)}/members`, {
method: "POST",
body: { userId },
});
toastOk("已添加成员");
addResults = addResults.filter((u) => u.userId !== userId);
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(errText(e));
}
}
async function removeMember(m: MemberGroupMember): Promise<void> {
if (selectedId === null) return;
if (!confirm(`将「${m.displayName || m.userId}」移出本组?其经由本组获得的授权立即失效。`)) return;
try {
await api(
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(userId)}`,
`/database/api/groups/${encodeURIComponent(selectedId)}/members/${encodeURIComponent(m.userId)}`,
{ method: "DELETE" },
);
toastOk("已移除成员");
await Promise.all([loadMembers(), loadGroups()]);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
toastErr(errText(e));
}
}
</script>
<div class="flex h-full">
<!-- 左栏:嵌套组树 -->
<aside class="flex w-[340px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
<span class="text-[15px] font-semibold text-ink">Group 管理</span>
<button
class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[11.5px] font-medium text-ink transition hover:bg-hover"
onclick={() => openCreate(null)}
>
+ 根 Group
<svelte:window
onclick={() => (menu = null)}
onkeydown={(e) => {
if (e.key === "Escape") menu = null;
}}
/>
<div class="flex h-full min-h-0 items-stretch gap-3.5">
<!-- 左:组树 -->
<div class="panel flex w-[326px] shrink-0 flex-col !p-3.5" style="min-height:0">
<div class="mb-2.5 flex items-center gap-2">
<span class="flex text-accent"><Icon name="layers" size={17} /></span>
<div class="section-title flex-1">Group 树</div>
<button class="btn btn-sm" onclick={() => openCreate(null)}>
<Icon name="plus" size={13} /> 根组
</button>
</div>
<div class="flex-1 overflow-y-auto px-2 py-2 text-[13px]">
{#if groups === null}
<div class="px-3 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if listError}
<div class="relative mb-2">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="过滤组名…" bind:value={filterText} />
</div>
<label class="switch mb-2.5 text-[11.5px] text-ink-3">
<input type="checkbox" checked={showArchived} onchange={toggleArchived} />
<span></span>
显示已删除的组
</label>
<!-- 树空白处右键 = 建根组 -->
<div
class="-mx-1.5 min-h-0 flex-1 overflow-y-auto"
role="tree"
tabindex="-1"
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("[data-node]") !== null) return;
openMenu(e, null);
}}
>
{#if !loaded}
<div class="quiet px-3 py-6 text-center">加载中…</div>
{:else if listError !== null}
<div class="px-3 py-6 text-center text-xs text-danger">{listError}</div>
{:else if ordered.length === 0}
<div class="px-3 py-6 text-center text-xs text-ink-3">暂无成员组 · 点上方「+ 根 Group」开始</div>
{:else if groups.length === 0}
<div class="quiet flex flex-col items-center gap-2 px-3 py-[22px] text-center">
<span class="flex text-line"><Icon name="layers" size={30} /></span>
暂无成员组 · 点上方「根组」开始
</div>
{:else if rows.length === 0}
<div class="quiet px-3 py-[22px] text-center">无匹配的组</div>
{:else}
{#each ordered as g (g.id)}
{#each rows as { g, hasKids, hit } (g.id)}
{@const arch = g.archivedAt !== null}
<div
class="group flex items-center gap-1.5 rounded-lg py-1.5 pr-1.5 transition hover:bg-hover"
data-node
class="flex cursor-pointer select-none items-center gap-1.5 rounded-lg py-1.5 pr-2 text-[13px]"
class:bg-selected={selectedId === g.id}
style="padding-left: {8 + g.depth * 16}px"
class:opacity-50={!hit}
style="padding-left: {8 + g.depth * 15}px"
role="treeitem"
aria-selected={selectedId === g.id}
tabindex="-1"
onclick={() => select(g.id)}
onkeydown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
select(g.id);
}
}}
oncontextmenu={(e) => openMenu(e, g)}
>
<button class="flex min-w-0 flex-1 items-center gap-2 text-left" onclick={() => select(g.id)}>
<span class="truncate font-medium text-ink">{g.name}</span>
<span class="shrink-0 text-[11px] text-ink-3">{g.memberCount}</span>
</button>
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[15px] leading-none text-ink-3 opacity-0 transition hover:bg-panel hover:text-ink group-hover:opacity-100"
title="在此下新建子 Group"
onclick={() => openCreate(g)}
>+</button>
<button
class="shrink-0 rounded px-1.5 py-0.5 text-[11px] leading-none text-ink-3 opacity-0 transition hover:text-danger group-hover:opacity-100"
title="删除(级联归档子树)"
onclick={() => deleteGroup(g)}
>删除</button>
{#if hasKids}
<span
class="flex w-[15px] shrink-0 justify-center text-ink-3 transition-transform"
class:rotate-90={!(isCollapsed(g.id) && filterText.trim() === "")}
role="button"
tabindex="-1"
aria-label="折叠 / 展开"
onclick={(e) => {
e.stopPropagation();
toggleCollapsed(g.id);
}}
onkeydown={(e) => {
if (e.key === "Enter") toggleCollapsed(g.id);
}}
>
<Icon name="chevron" size={13} />
</span>
{:else}
<span class="inline-block w-[15px] shrink-0"></span>
{/if}
<span class="flex" class:text-accent={selectedId === g.id && !arch} class:text-ink-3={arch || selectedId !== g.id}>
<Icon name={arch ? "archive" : "group"} size={15} />
</span>
<span class="flex-1 truncate" class:text-ink-3={arch} class:line-through={arch}>{g.name}</span>
<span class="tag shrink-0" class:opacity-70={arch}>
<Icon name="user" size={10} />{g.memberCount}
</span>
{#if arch}
<span class="tag shrink-0 !text-[10px] opacity-85">已删除</span>
{/if}
</div>
{/each}
{/if}
</div>
</aside>
<!-- 右栏:成员管理 -->
<main class="flex-1 overflow-y-auto p-6">
<div class="section-note mt-2 border-t border-line-soft pt-2">{loaded ? treeFoot : ""}</div>
</div>
<!-- 右:成员表 -->
<div class="panel flex min-h-0 min-w-0 flex-1 flex-col !p-0">
{#if selected === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">从左侧选择一个 Group 查看成员</div>
<div class="quiet m-auto flex flex-col items-center gap-2.5 p-7 text-center">
<span class="flex text-line"><Icon name="users" size={40} /></span>
从左侧选择一个 Group 查看成员
</div>
{:else}
<div class="mb-1 text-[17px] font-semibold text-ink">{selected.name}</div>
{#if selected.description}
<p class="mb-4 text-[12.5px] text-ink-2">{selected.description}</p>
{:else}
<div class="mb-4"></div>
{#if isArchived}
<!-- 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。 -->
<div
class="flex shrink-0 items-center gap-2.5 border-b border-line-soft bg-hover px-[18px] py-2.5 text-[12.5px]"
>
<span class="flex text-ink-3"><Icon name="archive" size={15} /></span>
<span class="flex-1">
此 Group 已删除于 {fmtDate(selected.archivedAt ?? "")} · 成员只读,不再授予任何权限
</span>
<button class="btn !text-xs" onclick={() => void restoreGroup(selected)}>
<Icon name="restore" size={13} /> 恢复
</button>
</div>
{/if}
<div class="mb-4 flex items-end gap-2">
<div class="flex-1">
<label class="mb-1 block text-[11.5px] text-ink-3" for="add-member">添加成员(userId 或飞书 openId)</label>
<input
id="add-member"
class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent"
bind:value={addValue}
placeholder="userId ou_ 开头的 openId"
onkeydown={(e) => { if (e.key === "Enter") void addMember(); }}
/>
<div class="shrink-0 border-b border-line-soft px-[18px] pb-3 pt-4">
<div class="mb-1.5 text-xs">
{#each chain as c, i (c.id)}
{#if i > 0}<span class="mx-[5px] text-ink-3">/</span>{/if}
<span class={i === chain.length - 1 ? "font-medium text-ink" : "text-ink-3"}>{c.name}</span>
{/each}
</div>
<div class="flex items-center gap-2.5">
<span class="flex" class:text-ink-3={isArchived} class:text-accent={!isArchived}>
<Icon name={isArchived ? "archive" : "group"} size={20} />
</span>
<div class="min-w-0 flex-1">
<div class="text-base font-semibold" class:text-ink-3={isArchived}>{selected.name}</div>
{#if selected.description !== null && selected.description !== ""}
<div class="section-note mt-0.5">{selected.description}</div>
{:else}
<div class="section-note mt-0.5 opacity-60">无描述</div>
{/if}
</div>
<!-- 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。 -->
{#if !isArchived}
<button class="btn !text-xs" onclick={() => openRename(selected)}>
<Icon name="pencil" size={13} /> 编辑
</button>
<button class="btn btn-primary !text-xs" onclick={openAddMember}>
<Icon name="plus" size={13} /> 添加成员
</button>
{/if}
</div>
<div class="mt-3 flex gap-4 text-xs text-ink-3">
<span class="inline-flex items-center gap-1"><Icon name="user" size={12} />{members.length} 名成员</span>
<span class="inline-flex items-center gap-1"><Icon name="layers" size={12} />层级 {selected.depth}</span>
<span class="inline-flex items-center gap-1"><Icon name="group" size={12} />{childCount} 个子组</span>
</div>
<button
class="rounded-lg bg-accent px-3.5 py-2 text-[12.5px] font-medium text-white transition hover:bg-accent-hover"
onclick={addMember}
>添加</button>
</div>
<div class="rounded-xl border border-line-soft bg-panel">
<div class="border-b border-line-soft px-4 py-2.5 text-[12.5px] font-semibold text-ink">
成员 {members === null ? "" : `(${members.length})`}
<div class="flex shrink-0 items-center gap-2.5 px-[18px] py-2.5">
<div class="relative max-w-[280px] flex-1">
<span class="pointer-events-none absolute left-[9px] top-1/2 flex -translate-y-1/2 text-ink-3">
<Icon name="search" size={13} />
</span>
<input class="input !pl-7 !text-[12.5px]" placeholder="搜索成员…" bind:value={memberFilter} />
</div>
{#if members === null}
<div class="px-4 py-6 text-center text-xs text-ink-3">加载中…</div>
{:else if membersError}
<div class="px-4 py-6 text-center text-xs text-danger">{membersError}</div>
<span class="file-meta">
{memberFilter.trim() === "" ? "" : `${shownMembers.length} / ${members.length}`}
</span>
</div>
<div class="min-h-0 flex-1 overflow-y-auto px-[18px] pb-[18px]">
{#if !membersLoaded}
<div class="quiet px-3 py-9 text-center">加载中…</div>
{:else if membersError !== null}
<div class="px-3 py-9 text-center text-xs text-danger">{membersError}</div>
{:else if members.length === 0}
<div class="px-4 py-6 text-center text-xs text-ink-3">暂无成员</div>
<div class="quiet flex flex-col items-center gap-2.5 px-3 py-9 text-center">
<span class="flex text-line"><Icon name="users" size={34} /></span>
{isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」"}
</div>
{:else if shownMembers.length === 0}
<div class="quiet px-3 py-[30px] text-center">无匹配成员</div>
{:else}
{#each members as m (m.userId)}
<div class="flex items-center gap-3 border-b border-line-soft px-4 py-2.5 text-[13px] last:border-b-0">
<span class="text-ink">{m.displayName}</span>
<span class="truncate font-mono text-[11px] text-ink-3">{m.userId}</span>
<button
class="ml-auto shrink-0 rounded px-1.5 py-0.5 text-[11.5px] text-ink-3 transition hover:text-danger"
onclick={() => removeMember(m.userId)}
>移除</button>
</div>
{/each}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>飞书 openId</th>
<th>加入时间</th>
{#if !isArchived}<th class="!text-right">操作</th>{/if}
</tr>
</thead>
<tbody>
{#each shownMembers as m (m.userId)}
<tr>
<td>
<span class="inline-flex items-center gap-2.5">
<Avatar displayName={m.displayName} userId={m.userId} avatarUrl={m.avatarUrl} size={28} />
<span class="font-medium">{m.displayName || "(未命名)"}</span>
</span>
</td>
<td class="file-meta">{m.userId}</td>
<td class="file-meta">{m.feishuOpenId || "—"}</td>
<td class="file-meta">{fmtDate(m.joinedAt)}</td>
{#if !isArchived}
<td class="text-right">
<button class="link-danger inline-flex items-center gap-1" onclick={() => void removeMember(m)}>
<Icon name="minus" size={12} /> 移除
</button>
</td>
{/if}
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
{/if}
</main>
</div>
</div>
<!-- 右键菜单 -->
{#if menu !== null}
<div
class="fixed z-[60] min-w-[184px] rounded-[10px] border border-line bg-panel p-[5px] text-[13px] shadow-[0_4px_20px_rgba(26,26,24,.07)]"
style="left:{menu.x}px;top:{menu.y}px"
role="menu"
tabindex="-1"
>
{#each menu.items as it, i (i)}
{#if it.sep === true}
<div class="mx-1.5 my-1 h-px bg-line-soft"></div>
{:else}
<div
class="flex cursor-pointer items-center gap-2 rounded-md px-[11px] py-[7px] hover:bg-hover"
class:text-danger={it.danger === true}
role="menuitem"
tabindex="-1"
onclick={(e) => {
e.stopPropagation();
menu = null;
it.fn?.();
}}
onkeydown={(e) => {
if (e.key === "Enter") {
menu = null;
it.fn?.();
}
}}
>
{#if it.ic !== undefined}<span class="flex opacity-75"><Icon name={it.ic} size={14} /></span>{/if}
{it.label}
</div>
{/if}
{/each}
</div>
{/if}
{#if showCreate}
<Modal title={createParentName === null ? "新建根 Group" : `在「${createParentName}」下新建子 Group`} onclose={() => (showCreate = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="g-name">名称</label>
<input id="g-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理教研组" />
<Modal
title={createParent === null ? "新建根 Group" : `在「${createParent.name}」下新建子 Group`}
onclose={() => (showCreate = false)}
>
<div class="form-row">
<label class="form-label" for="gc-name">名称</label>
<input id="gc-name" class="input" bind:value={newName} placeholder="例如:物理教研组" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="g-desc">描述(可选)</label>
<textarea id="g-desc" rows="2" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newDesc} placeholder="一句话说明"></textarea>
<div class="form-row">
<label class="form-label" for="gc-desc">描述(可选)</label>
<input id="gc-desc" class="input" bind:value={newDesc} placeholder="一句话说明" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreate = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={createGroup}>创建</button>
<button class="btn" onclick={() => (showCreate = false)}>取消</button>
<button class="btn btn-primary" onclick={createGroup}>创建</button>
</div>
</Modal>
{/if}
{#if showRename && renameTarget !== null}
<Modal title="重命名 / 改描述" onclose={() => (showRename = false)}>
<div class="form-row">
<label class="form-label" for="gr-name">名称</label>
<input id="gr-name" class="input" bind:value={editName} />
</div>
<div class="form-row">
<label class="form-label" for="gr-desc">描述</label>
<input id="gr-desc" class="input" bind:value={editDesc} placeholder="留空则清除描述" />
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showRename = false)}>取消</button>
<button class="btn btn-primary" onclick={saveRename}>保存</button>
</div>
</Modal>
{/if}
{#if showAdd && selected !== null}
<Modal title={`向「${selected.name}」添加成员`} onclose={() => (showAdd = false)}>
<div class="form-row">
<label class="form-label" for="ga-q">搜索用户(姓名 / userId / 飞书 openId)</label>
<div class="flex gap-2">
<input
id="ga-q"
class="input"
bind:value={addQuery}
placeholder="留空列出全部候选"
onkeydown={(e) => {
if (e.key === "Enter") void searchUsers();
}}
/>
<button class="btn" onclick={searchUsers}><Icon name="search" size={13} /> 搜索</button>
</div>
<div class="section-note mt-1.5">已在本组的成员不会出现在结果里。</div>
</div>
<div class="max-h-[280px] overflow-y-auto">
{#if addSearching}
<div class="quiet px-3 py-6 text-center">搜索中…</div>
{:else if addResults.length === 0}
<div class="quiet px-3 py-6 text-center">无候选用户 · 先点「搜索」</div>
{:else}
{#each addResults as u (u.userId)}
<div class="flex items-center gap-2.5 border-b border-line-soft py-2 last:border-b-0">
<Avatar displayName={u.displayName} userId={u.userId} avatarUrl={u.avatarUrl} size={26} />
<div class="min-w-0 flex-1">
<div class="truncate text-[13px] font-medium">{u.displayName || "(未命名)"}</div>
<div class="file-meta truncate">{u.feishuOpenId || u.userId}</div>
</div>
<button class="btn btn-sm" onclick={() => void addMember(u.userId)}>
<Icon name="plus" size={12} /> 添加
</button>
</div>
{/each}
{/if}
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="btn" onclick={() => (showAdd = false)}>关闭</button>
</div>
</Modal>
{/if}
+41
View File
@@ -0,0 +1,41 @@
<script lang="ts" module>
// 从已删除的 routes/adminPanels.ts 的 GROUP_ICONS 原样搬来(ADR-0029)。
export const ICONS = {
// 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",
} as const;
export type IconName = keyof typeof ICONS;
</script>
<script lang="ts">
let { name, size = 16 }: { name: IconName; size?: number } = $props();
</script>
<svg
style="width:{size}px;height:{size}px"
class="shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
><path d={ICONS[name]} /></svg>
@@ -1,17 +1,22 @@
<script lang="ts">
/**
* 文件库浏览器(树 + 详情 + 文件编辑栏)。
*
* 两处复用:老师端 /app(showUserFooter=true,侧栏底部带身份与退出)
* 与管理后台 /database/dashboard/library(false —— 外层壳已有身份区)。
*/
import { onMount } from "svelte";
import { api } from "./api.js";
import { me, toastErr, toastOk } from "./stores.js";
import { logout } from "./session.js";
import { treeVersion, bumpTree, currentNode, selectedFilePath, clearSelectedFile, bumpFiles } from "./browser.js";
import type { NodeChild } from "./types.js";
import TreeNode from "./TreeNode.svelte";
import NodeDetailPanel from "./NodeDetailPanel.svelte";
import FileEditor from "./FileEditor.svelte";
import GroupAdmin from "./GroupAdmin.svelte";
import Modal from "./Modal.svelte";
// 视图切换(仅管理员可见 Group 管理入口;非管理员恒为 library)。
let view = $state<"library" | "groups">("library");
let { showUserFooter = false }: { showUserFooter?: boolean } = $props();
let roots = $state<NodeChild[] | null>(null);
let treeError = $state<string | null>(null);
@@ -58,45 +63,16 @@
}
}
async function logout(): Promise<void> {
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch { /* ignore */ }
me.set(null);
}
const initial = $derived(($me?.userId ?? "U").slice(0, 1).toUpperCase());
</script>
<div class="flex h-full flex-col">
{#if $me?.isWebsiteAdmin}
<!-- 视图切换(仅管理员) -->
<div class="flex shrink-0 items-center gap-1 border-b border-line-soft bg-sidebar px-3 py-1.5">
<button
class="rounded-lg px-3 py-1 text-[12.5px] font-medium transition"
class:bg-selected={view === "library"}
class:text-ink={view === "library"}
class:text-ink-3={view !== "library"}
onclick={() => (view = "library")}
>文件库</button>
<button
class="rounded-lg px-3 py-1 text-[12.5px] font-medium transition"
class:bg-selected={view === "groups"}
class:text-ink={view === "groups"}
class:text-ink-3={view !== "groups"}
onclick={() => (view = "groups")}
>Group 管理</button>
</div>
{/if}
<div class="flex min-h-0 flex-1">
{#if $me?.isWebsiteAdmin && view === "groups"}
<GroupAdmin />
{:else}
<div class="flex min-h-0 flex-1">
<!-- 侧栏 -->
<aside class="flex w-[300px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="flex items-center justify-between border-b border-line-soft px-4 py-3.5">
<span class="text-[15px] font-semibold text-ink">文件库</span>
{#if $me?.isWebsiteAdmin}
<button class="rounded-lg border border-line bg-panel px-2.5 py-1 text-[11.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateRoot = true)}>
<button class="btn btn-sm" onclick={() => (showCreateRoot = true)}>
+ 根目录
</button>
{/if}
@@ -118,11 +94,13 @@
{/if}
</div>
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span>
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
</div>
{#if showUserFooter}
<div class="flex items-center gap-2 border-t border-line-soft px-4 py-3 text-[12.5px]">
<div class="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-accent text-[11px] font-semibold text-white">{initial}</div>
<span class="flex-1 truncate text-ink">{$me?.userId ?? ""}</span>
<button class="rounded-lg border border-line-soft px-2.5 py-1 text-[11.5px] text-ink-3 transition hover:bg-hover hover:text-ink" onclick={logout} title="退出登录">退出</button>
</div>
{/if}
</aside>
<!-- 主区 -->
@@ -142,30 +120,28 @@
/>
</section>
{/if}
{/if}
</div>
</div>
{#if showCreateRoot}
<Modal title="新建根目录" onclose={() => (showCreateRoot = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-name">名称</label>
<input id="root-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理教研" />
<div class="form-row">
<label class="form-label" for="root-name">名称</label>
<input id="root-name" class="input" bind:value={newName} placeholder="例如:物理教研" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-kind">类型</label>
<select id="root-kind" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newKind}>
<div class="form-row">
<label class="form-label" for="root-kind">类型</label>
<select id="root-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="root-desc">简介(可选)</label>
<textarea id="root-desc" rows="3" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
<div class="form-row">
<label class="form-label" for="root-desc">简介(可选)</label>
<textarea id="root-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateRoot = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={createRoot}>创建</button>
<button class="btn" onclick={() => (showCreateRoot = false)}>取消</button>
<button class="btn btn-primary" onclick={createRoot}>创建</button>
</div>
</Modal>
{/if}
+5 -9
View File
@@ -1,18 +1,13 @@
<script lang="ts">
import { onMount } from "svelte";
import { api } from "./api.js";
import { loadConfig, type AppConfig } from "./config.js";
interface LoginInfo {
orgSlug: string;
devLoginEnabled: boolean;
}
let info = $state<LoginInfo | null>(null);
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
try {
info = await api<LoginInfo>("/database/api/login-info");
info = await loadConfig();
} catch {
loadFailed = true;
}
@@ -27,6 +22,7 @@
{#if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
@@ -35,7 +31,7 @@
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a href="/app/dev-login-teacher" class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover">⚡ 一键登录(老师)</a>
<a href="/app/dev-login-teacher" data-sveltekit-reload class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover">⚡ 一键登录(老师)</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
+51 -32
View File
@@ -4,9 +4,12 @@
import { toastOk, toastErr } from "./stores.js";
import OverviewPanel from "./OverviewPanel.svelte";
import FilesPanel from "./FilesPanel.svelte";
import GrantsPanel from "./GrantsPanel.svelte";
import Modal from "./Modal.svelte";
import Icon from "./Icon.svelte";
let tab = $state<"detail" | "files">("detail");
type Tab = "detail" | "files" | "grants";
let tab = $state<Tab>("detail");
let showCreateChild = $state(false);
let newName = $state("");
let newKind = $state<"FOLDER" | "PROJECT">("FOLDER");
@@ -17,6 +20,15 @@
const canManage = $derived(node?.role === "MANAGE");
const canEdit = $derived(canManage || node?.role === "EDIT");
// 与旧 libraryBrowser 的 tab 组装一致:概览恒有;文件仅 PROJECT;授权仅 MANAGE
// (FOLDER 也有授权 —— 它虽是透明组织节点,授权仍挂在节点上,ADR-0021)。
const tabs = $derived.by((): ReadonlyArray<readonly [Tab, string]> => {
const out: Array<readonly [Tab, string]> = [["detail", "概览"]];
if (node?.kind === "PROJECT") out.push(["files", "文件"]);
if (canManage) out.push(["grants", "授权"]);
return out;
});
$effect(() => {
void node?.id;
tab = "detail";
@@ -84,36 +96,43 @@
</div>
<div class="mb-5 flex items-center justify-between">
<div class="flex items-center gap-2.5 text-[19px] font-semibold text-ink">
<div class="flex items-center gap-2 text-[17px] font-semibold text-ink">
{node.name}
<span class="rounded-full border border-line-soft bg-panel px-2 py-0.5 font-mono text-[10.5px] text-ink-3">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag">{node.kind === "PROJECT" ? "项目" : "文件夹"}</span>
<span class="tag !border-line !text-ink-2">{node.role}</span>
</div>
<div class="flex gap-2">
<div class="flex gap-1.5">
{#if canEdit && node.kind === "FOLDER"}
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateChild = true)}>+ 新建</button>
<button class="btn" onclick={() => (showCreateChild = true)}>
<Icon name="plus" size={13} /> 新建子节点
</button>
{/if}
{#if canManage}
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={renameNode}>重命名</button>
<button class="rounded-lg border border-transparent px-3 py-1.5 text-[12.5px] font-medium text-danger transition hover:bg-[#A13A3312]" onclick={deleteNode}>删除</button>
<button class="btn" onclick={renameNode}><Icon name="pencil" size={13} /> 重命名</button>
<button class="btn btn-danger" onclick={deleteNode}><Icon name="trash" size={13} /> 删除</button>
{/if}
</div>
</div>
{#if node.kind === "FOLDER"}
<div class="py-7 text-[13px] text-ink-3">点击左侧树展开以浏览子内容</div>
<div class="mb-[18px] flex gap-0.5 border-b border-line-soft">
{#each tabs as [id, label] (id)}
<button
class="-mb-px border-b-2 px-3.5 py-2 text-[13px] transition {tab === id
? 'border-accent font-semibold text-ink'
: 'border-transparent text-ink-3 hover:text-ink'}"
onclick={() => (tab = id)}
>{label}</button>
{/each}
</div>
{#if tab === "grants"}
<GrantsPanel {node} />
{:else if tab === "files" && node.kind === "PROJECT"}
<FilesPanel {node} />
{:else}
<div class="mb-5 flex gap-1 border-b border-line-soft">
{#each [["detail", "概览"], ["files", "文件"]] as [id, label] (id)}
<button
class="border-b-2 px-3.5 py-2 text-[13px] transition {tab === id ? 'border-accent font-semibold text-ink' : 'border-transparent text-ink-3 hover:text-ink'}"
onclick={() => (tab = id as "detail" | "files")}
>{label}</button>
{/each}
</div>
{#if tab === "detail"}
<OverviewPanel {node} />
{:else}
<FilesPanel {node} />
<OverviewPanel {node} />
{#if node.kind === "FOLDER"}
<div class="quiet mt-3.5">文件夹是透明组织节点,点左侧树展开以浏览子内容。</div>
{/if}
{/if}
</div>
@@ -121,24 +140,24 @@
{#if showCreateChild && node}
<Modal title="新建子节点" onclose={() => (showCreateChild = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-name">名称</label>
<input id="child-name" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newName} placeholder="例如:物理必修一" />
<div class="form-row">
<label class="form-label" for="child-name">名称</label>
<input id="child-name" class="input" bind:value={newName} placeholder="例如:物理必修一" />
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-kind">类型</label>
<select id="child-kind" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] outline-none focus:border-accent" bind:value={newKind}>
<div class="form-row">
<label class="form-label" for="child-kind">类型</label>
<select id="child-kind" class="select" bind:value={newKind}>
<option value="FOLDER">文件夹</option>
<option value="PROJECT">项目(课程资源库)</option>
</select>
</div>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="child-desc">简介(可选)</label>
<textarea id="child-desc" rows="3" class="w-full rounded-lg border border-line bg-panel px-3 py-2 font-mono text-xs outline-none focus:border-accent" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
<div class="form-row">
<label class="form-label" for="child-desc">简介(可选)</label>
<textarea id="child-desc" rows="3" class="textarea" bind:value={newDesc} placeholder="简要说明用途…"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showCreateChild = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={createChild}>创建</button>
<button class="btn" onclick={() => (showCreateChild = false)}>取消</button>
<button class="btn btn-primary" onclick={createChild}>创建</button>
</div>
</Modal>
{/if}
+57 -25
View File
@@ -12,8 +12,25 @@
let exportJob = $state<ExportJob | null>(null);
const canEdit = $derived(node.role === "MANAGE" || node.role === "EDIT");
const canManage = $derived(node.role === "MANAGE");
const roleLabel = $derived(node.role === "MANAGE" ? "可管理" : node.role === "EDIT" ? "可编辑" : "只读");
/** 独立权限开关(仅 PROJECT;关闭时只继承父级权限,创建者除外)。 */
async function toggleIndependent(): Promise<void> {
try {
await api(`/database/api/projects/${node.id}/independent-permission`, {
method: "PUT",
body: { enabled: !node.independentPermission },
});
toastOk("已切换");
currentNode.update((n) =>
n !== null && n.id === node.id ? { ...n, independentPermission: !node.independentPermission } : n,
);
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
$effect(() => {
void node.id;
exportJob = null;
@@ -67,7 +84,7 @@
}
</script>
<div class="rounded-xl border border-line-soft bg-panel p-5">
<div class="panel">
<div class="mb-4">
<div class="mb-1.5 text-[11.5px] text-ink-3">简介</div>
<div class="text-[13.5px] leading-7 text-ink">
@@ -77,7 +94,7 @@
<span class="italic text-ink-3">暂无简介</span>
{/if}
{#if canEdit}
<button class="ml-2.5 rounded-lg border border-line bg-panel px-2.5 py-0.5 align-middle text-[11.5px] text-ink transition hover:bg-hover" onclick={openEditDesc}>编辑</button>
<button class="btn ml-2.5 !px-2.5 !py-0.5 align-middle !text-[11.5px]" onclick={openEditDesc}>编辑</button>
{/if}
</div>
</div>
@@ -85,39 +102,54 @@
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-col gap-1.5 text-[13px] text-ink-2">
<div>类型 <b class="font-semibold text-ink">{node.kind === "PROJECT" ? "项目" : "文件夹"}</b></div>
<div>我的角色 <b class="font-semibold text-ink">{roleLabel}</b></div>
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}</b></div>
<div>最近修改 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleDateString("zh-CN", { year: "numeric", month: "long", day: "numeric" })}</b></div>
<div>创建时间 <b class="font-semibold text-ink">{new Date(node.createdAt).toLocaleString("zh-CN")}</b></div>
<div>更新时间 <b class="font-semibold text-ink">{new Date(node.updatedAt).toLocaleString("zh-CN")}</b></div>
</div>
<div class="my-4 border-t border-line-soft"></div>
<!-- 独立权限与导出都只对 PROJECT 有意义(FOLDER 是透明组织节点,ADR-0021)。 -->
{#if node.kind === "PROJECT"}
<div class="my-4 border-t border-line-soft"></div>
<div class="flex flex-wrap items-center gap-2.5">
<span class="quiet">独立权限</span>
<b class="text-[13px]">{node.independentPermission ? "开启" : "关闭"}</b>
{#if canManage}
<button class="btn" onclick={toggleIndependent}>{node.independentPermission ? "关闭" : "开启"}</button>
{/if}
<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>
</div>
<div class="mb-2 text-[12.5px] font-semibold">导出</div>
<div class="flex items-center gap-2">
<button class="rounded-lg border border-line bg-panel px-3 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={submitExport}>开始导出</button>
{#if exportJob}
<span class="font-mono text-[11px] text-ink-3">
{#if exportJob.status === "DONE"}
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
{:else if exportJob.status === "FAILED"}
失败:{exportJob.error ?? ""}
{:else}
{exportJob.status}
{/if}
</span>
{/if}
</div>
<div class="my-4 border-t border-line-soft"></div>
<div class="section-title mb-2">导出</div>
<div class="flex items-center gap-2">
<select class="select !w-auto"><option value="manifest">manifest(stub)</option></select>
<button class="btn" onclick={submitExport}>开始导出</button>
{#if exportJob}
<span class="file-meta">
{#if exportJob.status === "DONE"}
完成 · <a class="text-accent underline" href="/database/api/exports/{exportJob.id}/download">下载</a>
{:else if exportJob.status === "FAILED"}
失败:{exportJob.error ?? ""}
{:else}
{exportJob.status}
{/if}
</span>
{/if}
</div>
{/if}
</div>
{#if showEditDesc}
<Modal title="编辑简介" onclose={() => (showEditDesc = false)}>
<div class="mb-3">
<label class="mb-1 block text-[11.5px] text-ink-3" for="desc-draft">简要说明这个项目的内容</label>
<textarea id="desc-draft" rows="5" class="w-full rounded-lg border border-line bg-panel px-3 py-2 text-[13px] leading-7 outline-none focus:border-accent" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
<div class="form-row">
<label class="form-label" for="desc-draft">简要说明这个项目的内容</label>
<textarea id="desc-draft" rows="5" class="input !leading-7" bind:value={descDraft} placeholder="例如:高中物理必修一第三章,表面张力相关内容……"></textarea>
</div>
<div class="mt-4 flex justify-end gap-2">
<button class="rounded-lg border border-line bg-panel px-3.5 py-1.5 text-[12.5px] font-medium text-ink transition hover:bg-hover" onclick={() => (showEditDesc = false)}>取消</button>
<button class="rounded-lg bg-accent px-3.5 py-1.5 text-[12.5px] font-medium text-white transition hover:bg-accent-hover" onclick={saveDesc}>保存</button>
<button class="btn" onclick={() => (showEditDesc = false)}>取消</button>
<button class="btn btn-primary" onclick={saveDesc}>保存</button>
</div>
</Modal>
{/if}
+23
View File
@@ -0,0 +1,23 @@
/**
* 前端 bootstrap:silo org slug(拼飞书 OAuth 链接用)+ dev 一键登录开关。
*
* 打 `/database/config` 而非 `/database/api/login-info`:后者由 teacherApp.ts 在
* silo org 查找成功之后才注册,org 缺失时整条链路不存在;前者在
* databaseRoutes.ts 顶部无条件注册。两者形状相同(见 src/database/README.md)。
*
* 免鉴权 —— org slug 本就出现在 OAuth URL 里,不构成敏感信息。
*/
import { api } from "./api.js";
export interface AppConfig {
readonly orgSlug: string;
readonly devLoginEnabled: boolean;
}
let cached: AppConfig | null = null;
export async function loadConfig(): Promise<AppConfig> {
if (cached !== null) return cached;
cached = await api<AppConfig>("/database/config");
return cached;
}
+34
View File
@@ -0,0 +1,34 @@
/**
* 会话装载:GET /database/api/me 一次,结果进 `me` store。
* 老师端与管理后台共用 —— 两处的区别只是拿到 me 之后怎么用
* (老师端未登录显示登录视图;管理后台未登录跳 /database/admin,
* 非 isWebsiteAdmin 显示无权提示)。
*/
import { get } from "svelte/store";
import { api, UnauthenticatedError } from "./api.js";
import { me, authChecked } from "./stores.js";
import type { MeResponse } from "./types.js";
/** 幂等:已检查过就不再打请求(路由间切换不重复拉取)。 */
export async function loadSession(force = false): Promise<void> {
if (get(authChecked) && !force) return;
try {
me.set(await api<MeResponse>("/database/api/me"));
} catch (e) {
if (!(e instanceof UnauthenticatedError)) console.error(e);
me.set(null);
} finally {
authChecked.set(true);
}
}
/** 退出登录:清后端 cookie 再清前端 store。 */
export async function logout(): Promise<void> {
try {
await fetch("/auth/logout", { method: "POST", credentials: "same-origin" });
} catch {
/* 网络失败也照样清前端状态 */
}
me.set(null);
}
+64
View File
@@ -36,6 +36,9 @@ export interface NodeDetail {
export interface MeResponse {
readonly userId: string;
readonly isWebsiteAdmin: boolean;
/** 侧栏身份区显示用;后端取不到 User 行时回落为 userId。 */
readonly displayName: string;
readonly avatarUrl: string | null;
}
export interface FileEntry {
@@ -92,9 +95,70 @@ export interface MemberGroupNode {
readonly description: string | null;
readonly depth: number;
readonly memberCount: number;
/** 软删标记(ADR-0028 决策4)。null = 活跃;非 null = 已归档,不贡献任何权限。
* 仅在 ?includeArchived=1 时可能非 null。ISO 串(后端 JSON 序列化后不再是 Date)。 */
readonly archivedAt: string | null;
}
export interface MemberGroupMember {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
/** 加入本组时间;ISO 串。 */
readonly joinedAt: string;
}
/** 节点授权(GET /database/api/nodes/:id/grants)。 */
export interface Grant {
readonly id: string;
readonly principalType: "USER" | "GROUP";
readonly principalId: string;
readonly role: Role;
/** 创建者授权不可收回、不可改(契约 8.1)。 */
readonly isCreatorGrant: boolean;
readonly createdAt: string;
}
/** Group 选择器候选(GET /database/api/groups/search)。 */
export interface MemberGroupSearchResult {
readonly id: string;
readonly name: string;
/** 祖先链(根在前,自身在末),用 " / " 连接。 */
readonly breadcrumb: string;
}
/** 成员选择器候选(GET /database/api/users/search)。 */
export interface UserSearchResult {
readonly userId: string;
readonly displayName: string;
readonly feishuOpenId: string;
readonly avatarUrl: string | null;
}
/** 管理后台概览统计(GET /database/api/stats)。 */
export 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;
/** ISO 串;后端 JSON 序列化后不再是 Date。 */
readonly when: string;
}>;
}
/** org 成员(GET /api/org/:slug/members);用户管理面板消费。 */
export type OrgRole = "OWNER" | "ADMIN" | "MEMBER";
export interface OrgMember {
readonly userId: string;
readonly feishuOpenId: string;
readonly displayName: string;
readonly avatarUrl: string | null;
readonly role: OrgRole;
readonly createdAt: string;
}
-5
View File
@@ -1,5 +0,0 @@
import { mount } from "svelte";
import App from "./App.svelte";
import "./app.css";
export default mount(App, { target: document.getElementById("app")! });
+12
View File
@@ -0,0 +1,12 @@
<script lang="ts">
import "../app.css";
import Toasts from "$lib/Toasts.svelte";
let { children } = $props();
</script>
<div class="h-full">
{@render children()}
</div>
<Toasts />
+7
View File
@@ -0,0 +1,7 @@
/**
* 纯 SPA:关掉 SSR 与预渲染,构建产物只有一个 fallback index.html
* (adapter-static + fallback,见 svelte.config.js),由 hub 后端在
* /app 与 /database/* 两个前缀下原样送出。
*/
export const ssr = false;
export const prerender = false;
+11
View File
@@ -0,0 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// 根路径不承载界面:老师端在 /app,管理后台在 /database。
onMount(() => {
void goto("/app", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -0,0 +1,22 @@
<script lang="ts">
/** 老师端。未登录显示登录卡片;登录后直接是文件库浏览器。 */
import { onMount } from "svelte";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import LoginView from "$lib/LoginView.svelte";
import LibraryView from "$lib/LibraryView.svelte";
onMount(loadSession);
</script>
<svelte:head><title>文件库</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-ink-3">加载中…</div>
{:else if $me}
<div class="flex h-full flex-col">
<LibraryView showUserFooter />
</div>
{:else}
<LoginView />
{/if}
@@ -0,0 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { goto } from "$app/navigation";
// /database 本身不承载界面(与旧后端 /database/admin → dashboard 的跳转一致)。
onMount(() => {
void goto("/database/dashboard", { replaceState: true });
});
</script>
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转中…</div>
@@ -0,0 +1,63 @@
<script lang="ts">
/**
* 管理后台登录页(迁自后端 renderLoginPage)。
* 已登录直接跳 dashboard —— 与旧后端路由 /database/admin 的行为一致。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { me, authChecked } from "$lib/stores.js";
import { loadSession } from "$lib/session.js";
import { loadConfig, type AppConfig } from "$lib/config.js";
let info = $state<AppConfig | null>(null);
let loadFailed = $state(false);
onMount(async () => {
await loadSession();
if ($me !== null) {
void goto("/database/dashboard", { replaceState: true });
return;
}
try {
info = await loadConfig();
} catch {
loadFailed = true;
}
});
</script>
<svelte:head><title>Database Admin · 登录</title></svelte:head>
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[380px] rounded-2xl border border-line-soft bg-panel p-9 shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<div class="text-center text-[26px] font-semibold text-ink">Database Admin</div>
<p class="mt-2.5 mb-8 text-center text-[13px] text-ink-3">使用飞书登录以管理数据库</p>
{#if !$authChecked}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{:else if info}
<a
href="/auth/feishu/{encodeURIComponent(info.orgSlug)}"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg bg-accent px-4 py-3 text-sm font-medium text-white transition hover:bg-accent-hover"
>使用飞书登录</a>
{#if info.devLoginEnabled}
<div class="my-5 flex items-center gap-2.5 text-[11px] text-ink-3">
<span class="flex-1 border-t border-line-soft"></span>开发模式
<span class="flex-1 border-t border-line-soft"></span>
</div>
<a
href="/database/dev-login"
data-sveltekit-reload
class="flex w-full items-center justify-center rounded-lg border border-line bg-panel px-4 py-2 text-[12.5px] font-medium text-ink transition hover:bg-hover"
>⚡ 一键登录管理员</a>
<p class="mt-2.5 text-center text-[11px] text-ink-3">仅开发环境可见 · 跳过飞书 OAuth</p>
{/if}
{:else if loadFailed}
<p class="text-center text-[12.5px] text-danger">无法加载登录配置,请稍后重试</p>
{:else}
<p class="text-center text-[12.5px] text-ink-3">加载中…</p>
{/if}
</div>
</div>
@@ -0,0 +1,99 @@
<script lang="ts">
/**
* 管理后台外壳(迁自后端 renderDashboard 的侧栏 + 身份区)。
*
* 与旧实现的区别:六个 tab 是真 URL 路由(/database/dashboard/library 等),
* 不再是 location.hash + display:none 切换 —— 刷新不丢位置,链接可分享。
*
* 权限门:未登录跳 /database/admin;登录但非 OWNER/ADMIN(isWebsiteAdmin)
* 显示无权提示。语义与 ADR-0028 一致 —— 管理面板要求 silo org 的 OWNER/ADMIN。
*/
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { page } from "$app/state";
import { me, authChecked } from "$lib/stores.js";
import { loadSession, logout } from "$lib/session.js";
import Avatar from "$lib/Avatar.svelte";
let { children } = $props();
const NAV = [
{ seg: "", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ seg: "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" },
{ seg: "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" },
{ seg: "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" },
{ seg: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ seg: "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" },
] as const;
const BASE = "/database/dashboard";
onMount(async () => {
await loadSession();
if ($me === null) void goto("/database/admin", { replaceState: true });
});
function href(seg: string): string {
return seg === "" ? BASE : `${BASE}/${seg}`;
}
function isActive(seg: string): boolean {
const path = page.url.pathname.replace(/\/$/, "");
return seg === "" ? path === BASE : path === `${BASE}/${seg}`;
}
</script>
<svelte:head><title>Database Admin</title></svelte:head>
{#if !$authChecked}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">加载中…</div>
{:else if $me === null}
<div class="flex h-full items-center justify-center text-[13px] text-ink-3">跳转到登录页…</div>
{:else if !$me.isWebsiteAdmin}
<div class="flex min-h-full items-center justify-center p-6">
<div class="w-full max-w-[420px] rounded-2xl border border-line-soft bg-panel p-9 text-center shadow-[0_4px_20px_rgba(26,26,24,.07)]">
<h2 class="mb-2 text-lg font-semibold text-ink">无权访问管理后台</h2>
<p class="mb-6 text-[13px] text-ink-3">
当前账号不是本组织的所有者或管理员。普通老师请到文件库使用。
</p>
<a href="/app" class="btn btn-primary justify-center">前往文件库</a>
<button class="btn mt-3 w-full justify-center" onclick={logout}>退出登录</button>
</div>
</div>
{:else}
<div class="flex h-full">
<aside class="flex w-[240px] shrink-0 flex-col border-r border-line-soft bg-sidebar">
<div class="border-b border-line-soft px-4 py-4">
<span class="text-[15px] font-semibold text-ink">Database Admin</span>
</div>
<nav class="flex flex-1 flex-col gap-0.5 p-2.5">
{#each NAV as item (item.seg)}
{@const active = isActive(item.seg)}
<a
href={href(item.seg)}
class="flex items-center gap-2.5 rounded-[10px] px-3.5 py-2 text-[13px] transition"
class:bg-selected={active}
class:text-ink={active}
class:font-semibold={active}
class:text-ink-3={!active}
class:hover:bg-hover={!active}
>
<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-2.5 flex items-center gap-2.5 border-t border-line-soft px-3 py-2.5">
<Avatar displayName={$me.displayName} userId={$me.userId} avatarUrl={$me.avatarUrl} size={26} />
<p class="min-w-0 flex-1 truncate text-[12.5px] text-ink" title={$me.userId}>{$me.displayName}</p>
<button class="btn !px-2.5 !py-[3px] !text-[11px]" onclick={logout}>退出</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col">
{@render children()}
</div>
</div>
{/if}
@@ -0,0 +1,67 @@
<script lang="ts">
/** 概览(迁自后端 renderDashboard 的统计卡片 + 最近活动)。数据走 GET /database/api/stats。 */
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import type { DashboardStats } from "$lib/types.js";
let stats = $state<DashboardStats | null>(null);
let error = $state<string | null>(null);
onMount(async () => {
try {
stats = await api<DashboardStats>("/database/api/stats");
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
const cards = $derived([
{ label: "文件夹", value: stats?.folders },
{ label: "项目", value: stats?.projects },
{ label: "文件", value: stats?.files },
{ label: "活跃授权", value: stats?.grants },
]);
function fmtWhen(iso: string): string {
try {
return new Date(iso).toLocaleString("zh-CN");
} catch {
return iso;
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">概览</h1>
<p class="mb-5 text-[11.5px] text-ink-3">文件库实时数据</p>
{#if error}
<div class="panel text-[12.5px] text-danger">{error}</div>
{:else}
<div class="grid grid-cols-4 gap-4">
{#each cards as card (card.label)}
<div class="panel !px-5 !py-[18px]">
<p class="text-[12.5px] text-ink-3">{card.label}</p>
<p class="mt-1.5 text-[28px] font-semibold text-ink">{card.value ?? "—"}</p>
</div>
{/each}
</div>
<div class="panel mt-[18px]">
<h2 class="mb-2 text-[13.5px] font-semibold text-ink">最近活动</h2>
{#if stats === null}
<div class="quiet py-6 text-center">加载中…</div>
{:else if stats.recent.length === 0}
<div class="quiet py-[26px] text-center">暂无文件库活动 · 到「文件库」里创建第一个文件夹吧</div>
{:else}
{#each stats.recent as row (row.action + row.when + row.label)}
<div class="flex items-center gap-3 border-t border-line-soft py-2.5 text-[13px]">
<span class="tag shrink-0">{row.action}</span>
<span class="truncate text-ink">{row.label}</span>
<span class="ml-auto shrink-0 text-[11.5px] text-ink-3">{row.actor} · {fmtWhen(row.when)}</span>
</div>
{/each}
{/if}
</div>
{/if}
</section>
@@ -0,0 +1,10 @@
<script lang="ts">
/** Group 管理 tab —— MemberGroup 嵌套树(ADR-0028)。
* 外框 padding/overflow 对齐旧 `#tab-groups`(padding:20px;overflow:hidden):
* 两栏各自内部滚动,外层不滚。 */
import GroupAdmin from "$lib/GroupAdmin.svelte";
</script>
<div class="min-h-0 flex-1 overflow-hidden p-5">
<GroupAdmin />
</div>
@@ -0,0 +1,8 @@
<script lang="ts">
/** 文件库 tab —— 与老师端 /app 同一个浏览器组件,区别只在侧栏身份区由外壳提供。 */
import LibraryView from "$lib/LibraryView.svelte";
</script>
<div class="flex min-h-0 flex-1 flex-col">
<LibraryView />
</div>
@@ -0,0 +1,4 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">查询</h1>
<p class="text-[12.5px] text-ink-3">查询功能建设中</p>
</section>
@@ -0,0 +1,4 @@
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-1 text-lg font-semibold text-ink">设置</h1>
<p class="text-[12.5px] text-ink-3">设置功能建设中</p>
</section>
@@ -0,0 +1,168 @@
<script lang="ts">
/**
* 用户管理(迁自后端 adminPanels.ts renderUsersPanel)。
*
* 用户 = silo org 的成员,走平台层 /api/org/:slug/members(见 src/admin/routes/membersRoutes.ts)。
* 与 Group 管理是两套体系:MemberGroup 是全局主体、不归属 org(ADR-0028),
* 这里管的是 org 成员与其角色。
*/
import { onMount } from "svelte";
import { api } from "$lib/api.js";
import { loadConfig } from "$lib/config.js";
import { toastOk, toastErr } from "$lib/stores.js";
import type { OrgMember, OrgRole } from "$lib/types.js";
const ROLE_LABEL: Record<OrgRole, string> = {
OWNER: "所有者",
ADMIN: "管理员",
MEMBER: "普通老师",
};
const ROLES: readonly OrgRole[] = ["OWNER", "ADMIN", "MEMBER"];
let orgSlug = $state<string | null>(null);
let members = $state<OrgMember[] | null>(null);
let error = $state<string | null>(null);
let newOpenId = $state("");
let newName = $state("");
let newRole = $state<OrgRole>("MEMBER");
let adding = $state(false);
const base = $derived(orgSlug === null ? null : `/api/org/${encodeURIComponent(orgSlug)}`);
async function load(): Promise<void> {
if (base === null) return;
try {
const r = await api<{ members: OrgMember[] }>(`${base}/members`);
members = r.members;
error = null;
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
}
onMount(async () => {
try {
orgSlug = (await loadConfig()).orgSlug;
await load();
} catch (e) {
error = e instanceof Error ? e.message : String(e);
}
});
async function addMember(): Promise<void> {
const feishuOpenId = newOpenId.trim();
if (feishuOpenId === "") {
toastErr("请填写用户 openId");
return;
}
if (base === null) return;
adding = true;
try {
const displayName = newName.trim();
await api(`${base}/members`, {
method: "POST",
body: { feishuOpenId, role: newRole, ...(displayName !== "" ? { displayName } : {}) },
});
newOpenId = "";
newName = "";
toastOk("已添加");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
} finally {
adding = false;
}
}
async function setRole(userId: string, role: string): Promise<void> {
if (base === null) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}`, { method: "PATCH", body: { role } });
toastOk("角色已更新");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
await load();
}
}
async function revoke(userId: string, displayName: string): Promise<void> {
if (base === null) return;
if (!confirm(`移除成员「${displayName || userId}」?`)) return;
try {
await api(`${base}/members/${encodeURIComponent(userId)}/revoke`, { method: "POST" });
toastOk("已移除");
await load();
} catch (e) {
toastErr(e instanceof Error ? e.message : String(e));
}
}
</script>
<section class="flex-1 overflow-y-auto p-7">
<h1 class="mb-4 text-lg font-semibold text-ink">用户管理</h1>
<div class="max-w-[880px]">
<div class="panel mb-3.5">
<div class="section-title mb-2.5">添加成员</div>
<div class="flex flex-wrap items-center gap-2">
<input class="input min-w-0 flex-[2]" placeholder="用户 openId(飞书 ou_ 开头)" bind:value={newOpenId} />
<input class="input min-w-0 flex-1" placeholder="显示名(可选)" bind:value={newName} />
<select class="select !w-[130px]" bind:value={newRole}>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
<button class="btn btn-primary disabled:opacity-50" onclick={addMember} disabled={adding}>
{adding ? "添加中…" : "添加"}
</button>
</div>
</div>
<div class="panel">
<div class="section-title mb-2.5">成员列表</div>
{#if error}
<div class="py-3 text-[12.5px] text-danger">{error}</div>
{:else if members === null}
<div class="quiet py-[18px] text-center">加载中…</div>
{:else if members.length === 0}
<div class="quiet py-[18px] text-center">暂无成员</div>
{:else}
<table class="list">
<thead>
<tr>
<th>成员</th>
<th>userId</th>
<th>角色</th>
<th></th>
</tr>
</thead>
<tbody>
{#each members as m (m.userId)}
<tr>
<td class="text-ink">{m.displayName || m.userId}</td>
<td class="file-meta">{m.userId}</td>
<td>
<select
class="select !w-[110px] !px-2 !py-[3px] !text-xs"
value={m.role}
onchange={(e) => setRole(m.userId, e.currentTarget.value)}
>
{#each ROLES as role (role)}
<option value={role}>{ROLE_LABEL[role]}</option>
{/each}
</select>
</td>
<td class="text-right">
<button class="link-danger" onclick={() => revoke(m.userId, m.displayName)}>移除</button>
</td>
</tr>
{/each}
</tbody>
</table>
{/if}
</div>
</div>
</section>
+35
View File
@@ -0,0 +1,35 @@
import adapter from '@sveltejs/adapter-static';
import { vitePreprocess } from '@sveltejs/vite-plugin-svelte';
/**
* 老师端 /app 与管理后台 /database/* 共用这一份 SPA 构建产物,由 hub 后端静态托管
* (见 hub/src/database/static.ts)。服务端不渲染任何页面,只提供 /database/api/*。
*
* 两个关键配置:
*
* - `appDir: '_filelib'` —— 默认 `_app` 会与 admin-web 在同一个 Fastify 实例上注册的
* 根 `/_app/*` 资源路由撞车(见 hub/src/admin/static.ts),Fastify 重复路由会直接
* 在启动时抛错。改名后两套 SPA 的资源路径互不干扰。
*
* - `paths.relative: false` —— 同一份 index.html 会在不同深度的 URL 下被送出
* (`/app`、`/database/dashboard/users`),相对资源路径会解析错。必须用绝对路径。
*/
const config = {
preprocess: vitePreprocess(),
kit: {
adapter: adapter({
pages: 'build',
assets: 'build',
fallback: 'index.html',
precompress: false,
strict: false,
}),
appDir: '_filelib',
paths: {
base: '',
relative: false,
},
},
};
export default config;
+2 -2
View File
@@ -1,4 +1,5 @@
{
"extends": "./.svelte-kit/tsconfig.json",
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
@@ -12,6 +13,5 @@
"resolveJsonModule": true,
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"]
},
"include": ["src/**/*.ts", "src/**/*.svelte"]
}
}
+12 -10
View File
@@ -1,25 +1,27 @@
import { defineConfig } from "vite";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { sveltekit } from "@sveltejs/kit/vite";
import tailwindcss from "@tailwindcss/vite";
import { defineConfig } from "vite";
// 老师端独立 SPA:构建产物由 hub 后端静态托管于 /app;
// 开发时 vite dev(:5173)把 API/认证请求代理到后端(:8788)
// 老师端 /app + 管理后台 /database/* 的唯一前端工程;构建产物由 hub 后端静态托管
// 开发时 vite dev(:5173)把 API/认证/一键登录请求代理到后端(:8788);
// 页面路由全部由 SvelteKit 客户端路由处理,后端不参与。
const backend = "http://127.0.0.1:8788";
export default defineConfig({
plugins: [svelte(), tailwindcss()],
base: "/app/",
build: {
outDir: "dist",
emptyOutDir: true,
},
plugins: [tailwindcss(), sveltekit()],
server: {
port: 5173,
proxy: {
"/database/api": backend,
// 免鉴权 bootstrap(org slug + dev 开关);登录页和用户管理页都靠它。
"/database/config": backend,
"/auth": backend,
// 后端拥有的 DEV 一键登录端点(签 cookie 后 302);不代理会被 SPA 回退吃掉。
"/database/dev-login": backend,
"/app/dev-login": backend,
"/app/dev-login-teacher": backend,
// 平台层 org 成员 API(用户管理面板)。
"/api/org": backend,
},
},
});
+26 -17
View File
@@ -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/<orgSlug>`slug 来自
`/database/config`),回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。
- `/database/dashboard` —— 后台壳。未登录跳登录页;**登录但非 OWNER/ADMIN 显示无权提示**
- `/app` —— 老师端文件库。未登录显示登录卡片。
- `/database/admin` —— 管理员飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`
回调由 `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/)
-747
View File
@@ -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 `
<div id="users-root" style="max-width:880px">
<div class="panel" style="margin-bottom:14px">
<div class="section-title">添加成员</div>
<div class="inline-form">
<input id="u-openid" class="input" placeholder="用户 openId(飞书 ou_ 开头)" style="flex:2"/>
<input id="u-name" class="input" placeholder="显示名(可选)" style="flex:1"/>
<select id="u-role" class="select" style="width:130px">
<option value="MEMBER">普通老师</option>
<option value="ADMIN">管理员</option>
<option value="OWNER">所有者</option>
</select>
<button id="u-add" class="btn btn-primary">添加</button>
</div>
</div>
<div class="panel">
<div class="section-title">成员列表</div>
<table class="list">
<thead><tr><th>成员</th><th>userId</th><th>角色</th><th></th></tr></thead>
<tbody id="u-tbody"></tbody>
</table>
</div>
</div>
<script>
(function () {
const BASE = "${apiBase(orgSlug)}";
const tbody = document.getElementById("u-tbody");
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
const ROLE_LABEL = { OWNER: "所有者", ADMIN: "管理员", MEMBER: "普通老师" };
async function req(path, opts = {}) {
const res = await fetch(BASE + path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
method: opts.method ?? "GET",
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
return data;
}
async function load() {
const { members } = await req("/members");
tbody.innerHTML = members.length === 0
? '<tr><td colspan="4" class="quiet" style="text-align:center;padding:18px">暂无成员</td></tr>'
: members.map((m) =>
"<tr>" +
"<td>" + esc(m.displayName || m.userId) + "</td>" +
'<td class="file-meta">' + esc(m.userId) + "</td>" +
'<td><select class="select" style="width:110px;padding:3px 8px;font-size:12px" data-user="' + esc(m.userId) + '">' +
["OWNER","ADMIN","MEMBER"].map((r) =>
'<option value="' + r + '"' + (m.role === r ? " selected" : "") + ">" + ROLE_LABEL[r] + "</option>").join("") +
"</select></td>" +
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td>' +
"</tr>").join("");
tbody.querySelectorAll("select[data-user]").forEach((sel) => {
sel.onchange = async () => {
try { await req("/members/" + encodeURIComponent(sel.dataset.user), { method: "PATCH", body: { role: sel.value } }); }
catch (e) { alert(e.message); load(); }
};
});
tbody.querySelectorAll("button[data-revoke]").forEach((btn) => {
btn.onclick = async () => {
if (!confirm("移除该成员?")) return;
try { await req("/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); load(); }
catch (e) { alert(e.message); }
};
});
}
document.getElementById("u-add").onclick = async () => {
const openId = document.getElementById("u-openid").value.trim();
if (!openId) return alert("请填写用户 openId");
const displayName = document.getElementById("u-name").value.trim();
try {
await req("/members", { method: "POST", body: {
feishuOpenId: openId,
role: document.getElementById("u-role").value,
...(displayName ? { displayName } : {}),
}});
document.getElementById("u-openid").value = "";
document.getElementById("u-name").value = "";
load();
} catch (e) { alert(e.message); }
};
load().catch((e) => { tbody.innerHTML = '<tr><td colspan="4" style="color:var(--danger);padding:12px">' + esc(e.message) + "</td></tr>"; });
})();
</script>`;
}
/* ---------------------------------------------------------------- Group 管理 */
/**
* MemberGroup 嵌套树管理(ADR-0028)。无 orgSlug 参数 —— MemberGroup 是全局主体,
* 不归属任何 Organization,端点也不带 org 段。
*/
/** 内联 SVG 图标表(24x24 stroke 风格,与 dashboard 侧栏一致)。 */
const GROUP_ICONS: Record<string, string> = {
// 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 `<svg style="width:${size}px;height:${size}px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
}
export function renderGroupsPanel(): string {
return `
<div id="groups-root" style="display:flex;gap:14px;align-items:stretch;height:100%;min-height:0">
<!-- 左:组树 -->
<div class="panel" style="width:326px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;padding:14px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<span style="display:flex;color:var(--accent)">${icon("layers", 17)}</span>
<div class="section-title" style="margin:0;flex:1">Group 树</div>
<button id="g-new-root" class="btn" style="font-size:11.5px;padding:4px 9px;display:inline-flex;align-items:center;gap:4px">
${icon("plus", 13)} 根组
</button>
</div>
<div style="position:relative;margin-bottom:8px">
<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">${icon("search", 13)}</span>
<input id="g-filter" class="input" placeholder="过滤组名…" style="padding-left:28px;font-size:12.5px"/>
</div>
<label class="switch" style="margin-bottom:9px;font-size:11.5px;color:var(--text-3)">
<input type="checkbox" id="g-show-archived"/>
<span></span>
显示已删除的组
</label>
<div id="g-tree" style="flex:1;overflow-y:auto;margin:0 -6px;min-height:0"></div>
<div id="g-tree-foot" class="section-note" style="border-top:1px solid var(--border-soft);margin-top:8px;padding-top:8px"></div>
</div>
<!-- 右:成员表 -->
<div class="panel" style="flex:1;min-width:0;display:flex;flex-direction:column;min-height:0;padding:0">
<div id="g-detail" style="display:flex;flex-direction:column;min-height:0;flex:1">
<div class="quiet" style="margin:auto;padding:28px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px">
<span style="color:var(--border);display:flex">${icon("users", 40)}</span>
从左侧选择一个 Group 查看成员
</div>
</div>
</div>
</div>
<div id="g-menu" class="hidden" style="position:fixed;z-index:60;min-width:184px;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-pop);padding:5px;font-size:13px"></div>
<script>
(function () {
const treeEl = document.getElementById("g-tree");
const detailEl = document.getElementById("g-detail");
const menuEl = document.getElementById("g-menu");
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
const ICONS = ${JSON.stringify(GROUP_ICONS)};
function ico(name, size) {
return '<svg style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex-shrink:0" viewBox="0 0 24 24"' +
' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="' + (ICONS[name] || "") + '"/></svg>';
}
/** 头像:有 avatarUrl 用图,否则首字母色块。 */
function avatar(m, size) {
const s = size || 28;
if (m.avatarUrl) {
return '<img src="' + esc(m.avatarUrl) + '" alt="" style="width:' + s + 'px;height:' + s +
'px;border-radius:50%;object-fit:cover;flex-shrink:0"/>';
}
const ch = esc((m.displayName || m.userId || "?").slice(0, 1).toUpperCase());
return '<span style="width:' + s + 'px;height:' + s + 'px;border-radius:50%;flex-shrink:0;background:var(--accent);' +
'color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:' + Math.round(s * 0.42) +
'px;font-weight:600">' + ch + "</span>";
}
function fmtDate(iso) {
try { return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" }); }
catch { return String(iso || ""); }
}
let groups = []; // 后端返回的活跃组扁平列表
let selected = null; // { id, name }
const collapsed = new Set(); // 折叠的组 id(默认全展开)
let filterText = ""; // 组名过滤(命中项的祖先链一并保留)
let showArchived = false; // 是否列出已归档(软删)的组
async function req(path, opts = {}) {
const res = await fetch("/database/api/groups" + path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
method: opts.method ?? "GET",
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
return data;
}
/* ---------------- 树渲染 ---------------- */
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
function orderedVisible() {
const byParent = new Map();
const byId = new Map();
for (const g of groups) {
byId.set(g.id, g);
const arr = byParent.get(g.parentId) || [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
let keep = null;
const q = filterText.trim().toLowerCase();
if (q !== "") {
keep = new Set();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur = g;
while (cur) { keep.add(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : null; }
}
}
const out = [];
(function walk(parentId) {
for (const g of byParent.get(parentId) || []) {
if (keep !== null && !keep.has(g.id)) continue;
const kids = (byParent.get(g.id) || []).filter((k) => keep === null || keep.has(k.id));
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
// 过滤态下强制展开(否则命中项被折叠祖先藏住)。
if (keep !== null || !collapsed.has(g.id)) walk(g.id);
}
})(null);
// 兜底:父不在活跃列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"。
const seen = new Set(out.map((r) => r.g.id));
for (const g of groups) {
if (seen.has(g.id)) continue;
if (keep !== null && !keep.has(g.id)) continue;
out.push({ g, hasKids: false, hit: true });
}
return out;
}
function renderTree() {
const footEl = document.getElementById("g-tree-foot");
if (groups.length === 0) {
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px;display:flex;flex-direction:column;align-items:center;gap:8px">' +
'<span style="color:var(--border);display:flex">' + ico("layers", 30) + "</span>" +
"暂无成员组 · 点上方「根组」开始</div>";
footEl.textContent = "";
return;
}
const rows = orderedVisible();
if (rows.length === 0) {
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px">无匹配的组</div>';
} else {
treeEl.innerHTML = rows.map(({ g, hasKids, hit }) => {
const isSel = selected && selected.id === g.id;
const caret = hasKids
? '<span data-caret="' + esc(g.id) + '" style="display:inline-flex;width:15px;justify-content:center;cursor:pointer;' +
'color:var(--text-3);transition:transform .12s;' +
(collapsed.has(g.id) && filterText.trim() === "" ? "" : "transform:rotate(90deg)") + '">' +
ico("chevron", 13) + "</span>"
: '<span style="display:inline-block;width:15px"></span>';
const arch = !!g.archivedAt;
return '<div class="g-node" data-id="' + esc(g.id) + '" data-name="' + esc(g.name) + '"' +
' data-archived="' + (arch ? "1" : "") + '"' +
' style="display:flex;align-items:center;gap:6px;padding:6px 8px 6px ' + (8 + g.depth * 15) + 'px;' +
'border-radius:8px;cursor:pointer;user-select:none;' + (isSel ? "background:var(--selected)" : "") +
(hit ? "" : ";opacity:.5") + '">' +
caret +
'<span style="display:flex;color:' + (arch ? "var(--text-3)" : (isSel ? "var(--accent)" : "var(--text-3)")) + '">' +
ico(arch ? "archive" : "group", 15) + "</span>" +
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' +
(arch ? ";color:var(--text-3);text-decoration:line-through" : "") + '">' + esc(g.name) + "</span>" +
'<span class="tag" style="flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:10.5px' +
(arch ? ";opacity:.7" : "") + '">' + ico("user", 10) + g.memberCount + "</span>" +
(arch ? '<span class="tag" style="flex-shrink:0;font-size:10px;opacity:.85">已删除</span>' : "") +
"</div>";
}).join("");
}
const active = groups.filter((g) => !g.archivedAt);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
footEl.textContent = active.length + " 个活跃组 · " + totalMembers + " 条成员关系" +
(archivedN > 0 ? " · " + archivedN + " 个已删除" : "");
treeEl.querySelectorAll(".g-node").forEach((el) => {
el.onclick = (e) => {
if (e.target.closest("[data-caret]")) return; // 折叠三角不触发选中
selectGroup({ id: el.dataset.id, name: el.dataset.name });
loadMembers();
};
el.oncontextmenu = (e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY, {
id: el.dataset.id,
name: el.dataset.name,
archived: el.dataset.archived === "1",
});
};
});
treeEl.querySelectorAll("[data-caret]").forEach((el) => {
el.onclick = (e) => {
e.stopPropagation();
const id = el.dataset.caret;
if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
renderTree();
};
});
}
/* ---------------- 右键菜单 ---------------- */
function closeMenu() { menuEl.classList.add("hidden"); }
document.addEventListener("click", closeMenu);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
// 树空白处右键 = 建根组
treeEl.oncontextmenu = (e) => {
if (e.target.closest(".g-node")) return;
e.preventDefault();
openMenu(e.clientX, e.clientY, null);
};
function openMenu(x, y, target) {
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items = target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => createDialog(null, null) }]
: target.archived
? [
{ label: "查看成员(只读)", ic: "users", fn: () => { selectGroup(target); loadMembers(); } },
{ label: "恢复此 Group", ic: "restore", fn: () => restoreGroup(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
]
: [
{ label: "新建子 Group", ic: "plus", fn: () => createDialog(target.id, target.name) },
{ label: "添加成员", ic: "user", fn: () => { selectGroup(target); addMemberDialog(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => renameDialog(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => removeGroup(target) },
];
menuEl.innerHTML = items.map((it, i) =>
it.sep
? '<div style="height:1px;background:var(--border-soft);margin:4px 6px"></div>'
: '<div data-mi="' + i + '" style="display:flex;align-items:center;gap:8px;padding:7px 11px;border-radius:6px;cursor:pointer' +
(it.danger ? ";color:var(--danger)" : "") + '">' +
'<span style="display:flex;opacity:.75">' + ico(it.ic, 14) + "</span>" + esc(it.label) + "</div>").join("");
menuEl.querySelectorAll("[data-mi]").forEach((el) => {
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
el.onmouseleave = () => { el.style.background = ""; };
el.onclick = (e) => { e.stopPropagation(); closeMenu(); items[Number(el.dataset.mi)].fn(); };
});
menuEl.classList.remove("hidden");
// 贴边翻转,避免菜单溢出视口。
const r = menuEl.getBoundingClientRect();
menuEl.style.left = Math.min(x, window.innerWidth - r.width - 8) + "px";
menuEl.style.top = Math.min(y, window.innerHeight - r.height - 8) + "px";
}
/* ---------------- 弹窗:建组 / 改名 ---------------- */
function modal(html, width) {
const mask = document.createElement("div");
mask.className = "modal-mask";
mask.innerHTML = '<div class="modal-card"' +
(width ? ' style="max-width:' + width + 'px"' : "") + ">" + html + "</div>";
mask.onclick = (e) => { if (e.target === mask) mask.remove(); };
document.body.appendChild(mask);
const input = mask.querySelector("input");
if (input) input.focus();
return mask;
}
function createDialog(parentId, parentName) {
const m = modal(
'<div class="modal-title">' + (parentId ? "在「" + esc(parentName) + "」下新建子 Group" : "新建根 Group") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label><input id="gc-name" class="input" placeholder="例如:物理教研组"/></div>' +
'<div class="form-row"><label class="form-label">描述(可选)</label><input id="gc-desc" class="input" placeholder="一句话说明"/></div>' +
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>创建</button></div>'
);
m.querySelector("[data-x]").onclick = () => m.remove();
const submit = async () => {
const name = m.querySelector("#gc-name").value.trim();
if (!name) return alert("名称必填");
const description = m.querySelector("#gc-desc").value.trim();
try {
await req("", { method: "POST", body: { name, parentId, ...(description ? { description } : {}) } });
m.remove();
if (parentId) collapsed.delete(parentId); // 建完自动展开父节点
await loadTree();
} catch (e) { alert(e.message); }
};
m.querySelector("[data-ok]").onclick = submit;
m.querySelector("#gc-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
}
function renameDialog(target) {
const cur = groups.find((g) => g.id === target.id);
const m = modal(
'<div class="modal-title">重命名 / 改描述</div>' +
'<div class="form-row"><label class="form-label">名称</label><input id="gr-name" class="input" value="' + esc(target.name) + '"/></div>' +
'<div class="form-row"><label class="form-label">描述</label><input id="gr-desc" class="input" value="' +
esc((cur && cur.description) || "") + '" placeholder="留空则清除描述"/></div>' +
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>保存</button></div>'
);
m.querySelector("[data-x]").onclick = () => m.remove();
const submit = async () => {
const name = m.querySelector("#gr-name").value.trim();
if (!name) return alert("名称必填");
try {
// description 总是回传(含空串)——空串即清除描述。
await req("/" + encodeURIComponent(target.id), {
method: "PATCH",
body: { name, description: m.querySelector("#gr-desc").value.trim() },
});
m.remove();
if (selected && selected.id === target.id) selected.name = name;
await loadTree();
if (selected && selected.id === target.id) loadMembers();
} catch (e) { alert(e.message); }
};
m.querySelector("[data-ok]").onclick = submit;
m.querySelector("#gr-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
}
async function restoreGroup(target) {
// 恢复语义与删除不对称(决策7):只回该组 + 已归档祖先链,子树仍归档。
if (!confirm("恢复「" + target.name + "」?\\n\\n其已删除的上级会一并恢复(否则它在树上无路径);" +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。")) return;
try {
const r = await req("/" + encodeURIComponent(target.id) + "/restore", { method: "POST" });
await loadTree();
if (selected && selected.id === target.id) loadMembers();
alert("已恢复 " + r.restoredCount + " 个组");
} catch (e) { alert(e.message); }
}
async function removeGroup(target) {
if (!confirm("删除「" + target.name + "」?\\n\\n软删除:整棵子树一并标记删除,相关授权立即失效," +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。")) return;
try {
const r = await req("/" + encodeURIComponent(target.id), { method: "DELETE" });
if (selected && selected.id === target.id) {
selected = null;
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
}
await loadTree();
alert("已删除 " + r.archivedCount + " 个组(软删除,可恢复)");
} catch (e) { alert(e.message); }
}
/* ---------------- 成员面板 ---------------- */
let members = []; // 当前组的成员
let memberFilter = ""; // 成员表内过滤(显示名/userId/openId)
function selectGroup(target) {
selected = { id: target.id, name: target.name };
memberFilter = "";
renderTree();
}
/** 组头部 + 成员表(表格列:成员 / userId / 飞书 openId / 加入时间 / 操作)。 */
function renderMembers() {
if (!selected) return;
const g = groups.find((x) => x.id === selected.id);
const q = memberFilter.trim().toLowerCase();
const shown = q === ""
? members
: members.filter((m) =>
(m.displayName || "").toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
(m.feishuOpenId || "").toLowerCase().includes(q));
// 面包屑:祖先链(根在前)。
const byId = new Map(groups.map((x) => [x.id, x]));
const chain = [];
for (let cur = g; cur; cur = cur.parentId ? byId.get(cur.parentId) : null) chain.unshift(cur);
const crumb = chain.map((c, i) =>
(i > 0 ? '<span style="color:var(--text-3);margin:0 5px">/</span>' : "") +
(i === chain.length - 1
? '<span style="color:var(--text);font-weight:500">' + esc(c.name) + "</span>"
: '<span style="color:var(--text-3)">' + esc(c.name) + "</span>")).join("");
const isArchived = !!(g && g.archivedAt);
detailEl.innerHTML =
// 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。
(isArchived
? '<div style="display:flex;align-items:center;gap:9px;padding:10px 18px;flex-shrink:0;' +
'background:var(--hover);border-bottom:1px solid var(--border-soft);font-size:12.5px">' +
'<span style="display:flex;color:var(--text-3)">' + ico("archive", 15) + "</span>" +
'<span style="flex:1">此 Group 已删除于 ' +
esc(fmtDate(g.archivedAt)) + " · 成员只读,不再授予任何权限</span>" +
'<button id="gm-restore" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("restore", 13) + "恢复</button>" +
"</div>"
: "") +
// 头部
'<div style="padding:16px 18px 12px;border-bottom:1px solid var(--border-soft);flex-shrink:0">' +
'<div style="font-size:12px;margin-bottom:6px">' + crumb + "</div>" +
'<div style="display:flex;align-items:center;gap:10px">' +
'<span style="display:flex;color:' + (isArchived ? "var(--text-3)" : "var(--accent)") + '">' +
ico(isArchived ? "archive" : "group", 20) + "</span>" +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:16px;font-weight:600;color:' + (isArchived ? "var(--text-3)" : "var(--text)") + '">' +
esc(selected.name) + "</div>" +
(g && g.description
? '<div class="section-note" style="margin-top:2px">' + esc(g.description) + "</div>"
: '<div class="section-note" style="margin-top:2px;opacity:.6">无描述</div>') +
"</div>" +
// 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。
(isArchived
? ""
: '<button id="gm-rename" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("pencil", 13) + "编辑</button>" +
'<button id="gm-add" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("plus", 13) + "添加成员</button>") +
"</div>" +
// 统计条
'<div style="display:flex;gap:16px;margin-top:12px;font-size:12px;color:var(--text-3)">' +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("user", 12) + members.length + " 名成员</span>" +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("layers", 12) + "层级 " + ((g && g.depth) || 0) + "</span>" +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("group", 12) +
groups.filter((x) => x.parentId === selected.id).length + " 个子组</span>" +
"</div>" +
"</div>" +
// 成员表工具条
'<div style="padding:10px 18px;flex-shrink:0;display:flex;align-items:center;gap:10px">' +
'<div style="position:relative;flex:1;max-width:280px">' +
'<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
ico("search", 13) + "</span>" +
'<input id="gm-filter" class="input" placeholder="搜索成员…" style="padding-left:28px;font-size:12.5px"' +
' value="' + esc(memberFilter) + '"/>' +
"</div>" +
'<span class="file-meta">' + (q === "" ? "" : shown.length + " / " + members.length) + "</span>" +
"</div>" +
// 成员表
'<div style="flex:1;overflow-y:auto;padding:0 18px 18px;min-height:0">' +
(members.length === 0
? '<div class="quiet" style="text-align:center;padding:36px 12px;display:flex;flex-direction:column;align-items:center;gap:10px">' +
'<span style="color:var(--border);display:flex">' + ico("users", 34) + "</span>" +
(isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」") + "</div>"
: shown.length === 0
? '<div class="quiet" style="text-align:center;padding:30px 12px">无匹配成员</div>'
: '<table class="list" style="width:100%">' +
"<thead><tr>" +
"<th>成员</th><th>userId</th><th>飞书 openId</th><th>加入时间</th>" +
(isArchived ? "" : "<th style=\\"text-align:right\\">操作</th>") +
"</tr></thead><tbody>" +
shown.map((m) =>
"<tr>" +
'<td><span style="display:inline-flex;align-items:center;gap:9px">' + avatar(m, 28) +
'<span style="font-weight:500">' + esc(m.displayName || "(未命名)") + "</span></span></td>" +
'<td class="file-meta">' + esc(m.userId) + "</td>" +
'<td class="file-meta">' + esc(m.feishuOpenId || "—") + "</td>" +
'<td class="file-meta">' + esc(fmtDate(m.joinedAt)) + "</td>" +
(isArchived
? ""
: '<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '"' +
' style="display:inline-flex;align-items:center;gap:4px">' + ico("minus", 12) + "移除</button></td>") +
"</tr>").join("") +
"</tbody></table>") +
"</div>";
// 归档态下这些按钮不渲染,故逐个判空。
const addBtn = detailEl.querySelector("#gm-add");
if (addBtn) addBtn.onclick = addMemberDialog;
const renameBtn = detailEl.querySelector("#gm-rename");
if (renameBtn) renameBtn.onclick = () => renameDialog(selected);
const restoreBtn = detailEl.querySelector("#gm-restore");
if (restoreBtn) {
restoreBtn.onclick = () => restoreGroup({ id: selected.id, name: selected.name, archived: true });
}
const f = detailEl.querySelector("#gm-filter");
if (f) {
f.oninput = () => { memberFilter = f.value; renderMembers(); detailEl.querySelector("#gm-filter").focus(); };
}
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
btn.onclick = async () => {
const uid = btn.dataset.revoke;
const who = (members.find((m) => m.userId === uid) || {}).displayName || uid;
if (!confirm("将「" + who + "」从「" + selected.name + "」移除?")) return;
try {
await req("/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(uid), { method: "DELETE" });
await Promise.all([loadTree(), loadMembers()]);
} catch (e) { alert(e.message); }
};
});
}
async function loadMembers() {
if (!selected) return;
// 归档组的成员照样拉取展示(只读)—— 软删是打标,成员行仍在库里。
detailEl.innerHTML = '<div class="quiet" style="margin:auto;padding:18px;text-align:center">加载中…</div>';
try {
const r = await req("/" + encodeURIComponent(selected.id) + "/members");
members = r.members;
renderMembers();
} catch (e) {
detailEl.innerHTML = '<div style="color:var(--danger);padding:16px">' + esc(e.message) + "</div>";
}
}
/* ---------------- 添加成员弹窗(搜索选择) ---------------- */
function addMemberDialog() {
if (!selected) return;
const m = modal(
'<div class="modal-title" style="display:flex;align-items:center;gap:7px">' +
'<span style="display:flex;color:var(--accent)">' + ico("user", 16) + "</span>" +
"添加成员到「" + esc(selected.name) + "」</div>" +
'<div style="position:relative;margin-bottom:10px">' +
'<span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
ico("search", 14) + "</span>" +
'<input id="ga-q" class="input" placeholder="搜索用户显示名 / 飞书 openId…" style="padding-left:30px"/>' +
"</div>" +
'<div id="ga-list" style="max-height:290px;overflow-y:auto;margin:0 -4px"></div>' +
'<div class="section-note" id="ga-note" style="margin-top:10px">已在本组的用户不会出现在结果里</div>' +
'<div class="modal-actions"><button class="btn" data-x>关闭</button></div>',
520
);
m.querySelector("[data-x]").onclick = () => m.remove();
const qEl = m.querySelector("#ga-q");
const listEl = m.querySelector("#ga-list");
const noteEl = m.querySelector("#ga-note");
let seq = 0;
async function search() {
const mine = ++seq;
listEl.innerHTML = '<div class="quiet" style="padding:16px;text-align:center">搜索中…</div>';
try {
const url = "/database/api/users/search?q=" + encodeURIComponent(qEl.value.trim()) +
"&excludeGroupId=" + encodeURIComponent(selected.id);
const res = await fetch(url, { credentials: "same-origin" });
if (res.status === 401) { location.href = "/database/admin"; return; }
const data = await res.json();
if (mine !== seq) return; // 丢弃过期响应
if (!res.ok) throw new Error((data.error && data.error.message) || res.statusText);
render(data.users);
} catch (e) {
if (mine === seq) listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
}
}
function render(users) {
if (users.length === 0) {
listEl.innerHTML = '<div class="quiet" style="padding:20px;text-align:center">无匹配用户</div>';
return;
}
listEl.innerHTML = users.map((u, i) =>
'<div data-ui="' + i + '" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer">' +
avatar(u, 30) +
'<span style="flex:1;min-width:0">' +
'<span style="display:block;font-size:13px;font-weight:500;color:var(--text)">' + esc(u.displayName || "(未命名)") + "</span>" +
'<span class="file-meta" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
esc(u.feishuOpenId) + "</span>" +
"</span>" +
'<span class="btn" style="flex-shrink:0;font-size:11.5px;padding:3px 9px;display:inline-flex;align-items:center;gap:4px">' +
ico("plus", 12) + "添加</span>" +
"</div>").join("");
listEl.querySelectorAll("[data-ui]").forEach((el) => {
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
el.onmouseleave = () => { el.style.background = ""; };
el.onclick = async () => {
const u = users[Number(el.dataset.ui)];
el.style.pointerEvents = "none";
el.style.opacity = ".5";
try {
await req("/" + encodeURIComponent(selected.id) + "/members", {
method: "POST", body: { userId: u.userId },
});
noteEl.textContent = "已添加 " + (u.displayName || u.userId);
noteEl.style.color = "var(--accent)";
await Promise.all([loadTree(), loadMembers()]);
await search(); // 刷新候选(已加的会被排除)
} catch (e) {
alert(e.message);
el.style.pointerEvents = "";
el.style.opacity = "";
}
};
});
}
let timer = null;
qEl.oninput = () => { clearTimeout(timer); timer = setTimeout(search, 200); };
search(); // 打开即列出候选
}
/* ---------------- 初始化 ---------------- */
async function loadTree() {
const r = await req(showArchived ? "?includeArchived=1" : "");
groups = r.groups;
// 选中项可能已被级联归档 → 清空右栏(showArchived 下归档组仍在列表里,不清)。
if (selected && !groups.some((g) => g.id === selected.id)) {
selected = null;
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
}
renderTree();
}
document.getElementById("g-new-root").onclick = () => createDialog(null, null);
const filterEl = document.getElementById("g-filter");
filterEl.oninput = () => { filterText = filterEl.value; renderTree(); };
const archEl = document.getElementById("g-show-archived");
archEl.onchange = () => {
showArchived = archEl.checked;
loadTree().catch((e) => alert(e.message));
};
loadTree().catch((e) => {
treeEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
});
})();
</script>`;
}
+35 -215
View File
@@ -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<DashboardStats | null> {
if (deps === null) return null;
deps: FileLibRouteDeps,
): Promise<DashboardStats> {
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 `<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${title}</title>
${UI_HEAD_FONTS}
<style>${UI_THEME_CSS}</style>
</head>`;
}
function renderLoginPage(config: DatabaseRouteConfig): string {
const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`;
const devButton = config.allowDevLoginBypass
? `<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>
<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>
</div>
</body>
</html>`;
}
/** 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) => `
<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 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>
<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 style="flex:1;display:flex;flex-direction:column;gap:2px;padding:10px">
${nav}
</nav>
<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="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 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>
<div class="panel" style="margin-top:18px">
<h2 style="font-size:13.5px;font-weight:600;margin-bottom:8px">最近活动</h2>
${recentRows}
</div>
</section>
<section id="tab-library" class="admin-tab-section" style="display:none;flex:1;min-height:0">
${renderLibraryBrowser()}
</section>
<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:hidden;padding:20px">
${renderGroupsPanel()}
</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>
(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>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
+13 -1
View File
@@ -44,10 +44,22 @@ export async function registerFileLibRoutes(
/* ------------------------------------------------------------ 身份 */
// 前端判断能力面用:是否网站管理员(root 创建按钮显隐)。
// displayName/avatarUrl 供侧栏身份区显示 —— 页面不再服务端渲染(ADR-0029),
// 旧 renderDashboard 在 handler 里查 Prisma 拿到的名字,现在必须由这里带出去,
// 否则前端只有 userId 可显示。
app.get("/database/api/me", async (request, reply) => {
const actor = await actorOrNull(request, reply, deps);
if (actor === null) return reply;
return { userId: actor.userId, isWebsiteAdmin: actor.isWebsiteAdmin };
const user = await deps.prisma.user.findUnique({
where: { id: actor.userId },
select: { displayName: true, avatarUrl: true },
});
return {
userId: actor.userId,
isWebsiteAdmin: actor.isWebsiteAdmin,
displayName: user?.displayName ?? actor.userId,
avatarUrl: user?.avatarUrl ?? null,
};
});
/* ------------------------------------------------------------ 树节点 */
-597
View File
@@ -1,597 +0,0 @@
/**
* 管理员后台「文件库」页内浏览器区块(树 | 内容 | 预览三栏,含授权管理)。
*
* renderLibraryBrowser() 返回可嵌入任意后台布局的 HTML+JS 片段:
* 以 #lib-root 为根、内部用 q() 作用域选择器,不与宿主页其他元素冲突。
* 与独立页版浏览器的区别:去掉了自己的 wordmark/用户栏(由后台外壳提供),
* 保留管理员工具(根目录创建、授权面板、独立权限开关)。
*/
export function renderLibraryBrowser(): string {
return `
<div id="lib-root" style="display:flex;height:100%;min-height:0">
<!-- 左:目录树 -->
<div style="width:250px;flex-shrink:0;border-right:1px solid var(--border-soft);display:flex;flex-direction:column;background:var(--sidebar)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--border-soft)">
<span style="font-size:13px;font-weight:600;color:var(--text)">文件库</span>
<button id="lib-btn-new-root" class="btn hidden" style="padding:3px 10px;font-size:11px" title="新建根目录">+ 根目录</button>
</div>
<div id="lib-tree" style="flex:1;overflow-y:auto;padding:8px 8px 16px;font-size:13px"></div>
</div>
<!-- 中:节点内容 -->
<div id="lib-main" style="flex:1;overflow-y:auto;min-width:0">
<div class="lib-empty-hint">从左侧选择一个文件夹或项目</div>
</div>
<!-- 右:预览/编辑(选中文件时出现) -->
<div id="lib-preview" style="display:none;width:44%;min-width:380px;flex-shrink:0;overflow-y:auto;border-left:1px solid var(--border-soft);padding:16px"></div>
<div id="lib-modal-root"></div>
<div id="lib-toast-root" style="position:fixed;bottom:16px;right:16px;z-index:50;display:flex;flex-direction:column;gap:8px"></div>
</div>
<style>
#lib-root .lib-empty-hint { height:100%;display:flex;align-items:center;justify-content:center;color:var(--text-3);font-size:13px; }
#lib-root .tree-item { display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:6px;cursor:pointer;color:var(--text);transition:background 120ms ease;user-select:none; }
#lib-root .tree-item:hover { background:var(--hover); }
#lib-root .tree-item.selected { background:var(--selected); }
#lib-root .tree-children { border-left:1px solid var(--guide);margin-left:15px;padding-left:3px; }
#lib-root .tree-icon { width:16px;height:16px;flex-shrink:0;display:flex;align-items:center;justify-content:center; }
#lib-root .tree-caret { color:var(--text-3); }
#lib-root .tree-badge { margin-left:auto;padding-right:4px;font-size:10px;color:var(--text-3);font-family:var(--mono); }
#lib-root .tree-empty { padding:24px 12px;text-align:center;font-size:12px;color:var(--text-3); }
#lib-root .lib-toast { border-radius:8px;padding:8px 14px;font-size:12px;color:#fff;background:#333230;max-width:320px; }
#lib-root .lib-toast-err { background:#7E2C26; }
#lib-root .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; }
#lib-root .modal-card { width:100%;max-width:440px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:22px; }
#lib-root pre.diff { white-space:pre-wrap;font-family:var(--mono);font-size:12px;line-height:1.7;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin:0; }
#lib-root pre.diff .add { display:block;color:var(--diff-add-text);background:var(--diff-add-bg); }
#lib-root pre.diff .del { display:block;color:var(--diff-del-text);background:var(--diff-del-bg); }
#lib-root .meta-grid { display:grid;grid-template-columns:1fr 1fr;gap:10px 24px;font-size:13px;color:var(--text-2); }
#lib-root .meta-grid b { color:var(--text);font-weight:600; }
#lib-root .conflict-panel { margin-top:14px;border:1px solid #E8E2C8;background:#FCFBF4;border-radius:10px;padding:14px 16px; }
#lib-root .conflict-title { font-size:13px;font-weight:600;color:#6E6329;margin-bottom:8px; }
#lib-root .conflict-note { font-size:11px;color:#8A8059;margin-top:8px; }
#lib-root .export-status { font-size:11px;color:var(--text-3);font-family:var(--mono); }
</style>
<script>
(function () {
const libRoot = document.getElementById("lib-root");
if (!libRoot) return;
const $ = (sel) => libRoot.querySelector(sel);
const $$ = (sel) => [...libRoot.querySelectorAll(sel)];
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = (data && data.error) || { code: "unknown", message: res.statusText };
const e = new Error(err.message); e.code = err.code; e.status = res.status; Object.assign(e, err);
throw e;
}
return data;
}
function toast(msg, kind = "info") {
const el = document.createElement("div");
el.className = "lib-toast" + (kind === "err" ? " lib-toast-err" : "");
el.textContent = msg;
$("#lib-toast-root").appendChild(el);
setTimeout(() => el.remove(), 3600);
}
const ok = (m) => toast(m, "ok");
const fail = (e) => toast(e.message || String(e), "err");
function modal(html) {
const root = $("#lib-modal-root");
root.innerHTML = '<div class="modal-mask"><div class="modal-card">' + html + "</div></div>";
root.firstElementChild.addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
}
function closeModal() { $("#lib-modal-root").innerHTML = ""; }
let me = { userId: null, isWebsiteAdmin: false };
let current = null;
const expanded = new Set();
let currentFile = null;
const ICONS = {
caretRight: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M9 6l6 6-6 6z"/></svg>',
caretDown: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M6 9l6 6 6-6z"/></svg>',
folder: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="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"/></svg>',
project: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>',
};
async function loadChildren(parentId) {
const q = parentId === null ? "" : "?parentId=" + encodeURIComponent(parentId);
return (await api("/database/api/nodes" + q)).nodes;
}
async function renderTree() {
const tree = $("#lib-tree");
tree.innerHTML = "";
try {
const roots = await loadChildren(null);
if (roots.length === 0) {
tree.innerHTML = '<div class="tree-empty">空文件库' +
(me.isWebsiteAdmin ? " · 点上方「+ 根目录」开始" : "") + "</div>";
return;
}
for (const node of roots) tree.appendChild(await treeItem(node, 0));
} catch (e) { tree.innerHTML = '<div class="tree-empty">' + esc(e.message) + "</div>"; }
}
async function treeItem(node, depth) {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-item" + (current && current.id === node.id ? " selected" : "");
const caret = document.createElement("span");
caret.className = "tree-icon tree-caret";
if (node.kind === "FOLDER") {
caret.innerHTML = expanded.has(node.id) ? ICONS.caretDown : ICONS.caretRight;
caret.onclick = (e) => { e.stopPropagation(); toggleExpand(node.id); };
}
row.appendChild(caret);
const iconSpan = document.createElement("span");
iconSpan.className = "tree-icon";
iconSpan.style.color = node.kind === "PROJECT" ? "#1A1A18" : "#9C9B96";
iconSpan.innerHTML = node.kind === "PROJECT" ? ICONS.project : ICONS.folder;
row.appendChild(iconSpan);
const nameSpan = document.createElement("span");
nameSpan.className = "truncate";
nameSpan.textContent = node.name;
row.appendChild(nameSpan);
if (node.role !== "MANAGE") {
const badge = document.createElement("span");
badge.className = "tree-badge";
badge.textContent = node.role;
row.appendChild(badge);
}
row.onclick = () => {
if (node.kind === "FOLDER") {
if (expanded.has(node.id)) expanded.delete(node.id); else expanded.add(node.id);
}
selectNode(node.id);
};
wrap.appendChild(row);
if (node.kind === "FOLDER" && expanded.has(node.id)) {
wrap.appendChild(await childrenBlock(node.id, depth));
}
return wrap;
}
async function childrenBlock(parentId, depth) {
const block = document.createElement("div");
block.className = "tree-children";
const kids = await loadChildren(parentId);
for (const kid of kids) block.appendChild(await treeItem(kid, depth + 1));
return block;
}
async function toggleExpand(id) {
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
await renderTree();
}
async function selectNode(id) {
try {
const [{ node }, { breadcrumb }] = await Promise.all([
api("/database/api/nodes/" + id),
api("/database/api/nodes/" + id + "/breadcrumb"),
]);
current = node; current.breadcrumb = breadcrumb;
closePreview();
await renderTree();
renderMain();
} catch (e) { fail(e); }
}
function crumbsHtml() {
return current.breadcrumb.map((c) =>
c.name === null ? "<span>…</span>" : "<span>" + esc(c.name) + "</span>"
).join('<span style="margin:0 4px;color:var(--border)">/</span>');
}
function renderMain() {
const n = current;
const canManage = n.role === "MANAGE";
const canEdit = canManage || n.role === "EDIT";
const tabs = [];
tabs.push(["detail", "概览"]);
if (n.kind === "PROJECT") tabs.push(["files", "文件"]);
if (canManage) tabs.push(["grants", "授权"]);
current.tab = current.tab && tabs.some(t => t[0] === current.tab) ? current.tab : "detail";
$("#lib-main").innerHTML =
'<div style="max-width:860px;margin:0 auto;padding:28px 28px 56px">' +
'<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">' + crumbsHtml() + "</div>" +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">' +
'<div style="font-size:17px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px">' + esc(n.name) +
'<span class="tag">' + n.kind + "</span>" +
'<span class="tag tag-role">' + n.role + "</span>" +
"</div>" +
'<div style="display:flex;gap:6px">' +
(canEdit && n.kind === "FOLDER" ? '<button class="btn" onclick="window.__lib.createChild()">+ 新建子节点</button>' : "") +
(canManage ? '<button class="btn" onclick="window.__lib.renameCurrent()">重命名</button>' : "") +
(canManage ? '<button class="btn btn-danger" onclick="window.__lib.deleteCurrent()">删除</button>' : "") +
"</div>" +
"</div>" +
'<div class="tabs">' +
tabs.map(([id, label]) =>
'<button onclick="window.__lib.switchTab(\\'' + id + '\\')" class="tab' +
(current.tab === id ? " active" : "") + '">' + label + "</button>").join("") +
"</div>" +
'<div id="lib-tab-body"></div>' +
"</div>";
renderTab();
}
function renderTab() {
const body = $("#lib-tab-body");
if (current.tab === "files") return renderFilesTab(body);
if (current.tab === "grants") return renderGrantsTab(body);
let extra = "";
if (current.kind === "PROJECT") {
extra =
'<div class="divider"></div>' +
'<div class="inline-form" style="justify-content:flex-start">' +
'<span class="quiet">独立权限</span>' +
'<b style="font-size:13px">' + (current.independentPermission ? "开启" : "关闭") + "</b>" +
(current.role === "MANAGE"
? '<button class="btn" onclick="window.__lib.toggleIndependent()">' + (current.independentPermission ? "关闭" : "开启") + "</button>"
: "") +
'<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>' +
"</div>" +
'<div class="divider"></div>' +
'<div class="section-title">导出</div>' +
'<div class="inline-form">' +
'<select id="lib-x-target" class="select" style="width:auto"><option value="manifest">manifest(stub)</option></select>' +
'<button class="btn" onclick="window.__lib.submitExport()">开始导出</button>' +
'<span id="lib-x-status" class="export-status"></span>' +
"</div>";
}
body.innerHTML =
'<div class="panel">' +
'<div class="meta-grid">' +
"<div>类型 <b>" + current.kind + "</b></div>" +
"<div>我的权限 <b>" + current.role + "</b></div>" +
"<div>创建时间 " + new Date(current.createdAt).toLocaleString("zh-CN") + "</div>" +
"<div>更新时间 " + new Date(current.updatedAt).toLocaleString("zh-CN") + "</div>" +
"</div>" + extra +
"</div>";
pollExport();
}
/* ---------------- 节点操作 ---------------- */
function createDialog(parentId) {
modal(
'<div class="modal-title">' + (parentId ? "新建子节点" : "新建根目录") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label>' +
'<input id="lib-f-name" class="input" placeholder="例如:物理必修一"/></div>' +
'<div class="form-row"><label class="form-label">类型</label>' +
'<select id="lib-f-kind" class="select">' +
'<option value="FOLDER">文件夹</option><option value="PROJECT">项目(Git 仓库)</option>' +
"</select></div>" +
'<div class="modal-actions">' +
'<button class="btn" onclick="window.__lib.closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="window.__lib.submitCreate(\\'' + (parentId ?? "") + '\\')">创建</button>' +
"</div>"
);
}
async function submitCreate(parentId) {
try {
await api("/database/api/nodes", { method: "POST", body: {
parentId: parentId === "" ? null : parentId,
kind: $("#lib-f-kind").value, name: $("#lib-f-name").value,
}});
closeModal(); ok("已创建");
if (parentId) expanded.add(parentId);
await renderTree();
} catch (e) { fail(e); }
}
/* ---------------- 授权 ---------------- */
async function renderGrantsTab(body) {
try {
const { grants } = await api("/database/api/nodes/" + current.id + "/grants");
const rows = grants.map((g) =>
"<tr>" +
"<td>" + (g.principalType === "USER" ? "👤" : "👥") + " " + esc(g.principalId) +
(g.isCreatorGrant ? ' <span class="quiet">(创建者)</span>' : "") + "</td>" +
'<td class="file-meta">' + g.role + "</td>" +
"<td style='text-align:right'>" + (g.isCreatorGrant ? "" :
'<button class="link-danger" onclick="window.__lib.revokeGrant(\\'' + g.id + '\\')">收回</button>') + "</td>" +
"</tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<table class="list"><thead><tr><th>主体</th><th>级别</th><th></th></tr></thead>' +
"<tbody>" + rows + "</tbody></table>" +
'<div class="divider"></div>' +
'<div class="section-title">新增授权</div>' +
'<div class="inline-form">' +
'<select id="lib-g-type" class="select" style="width:110px" onchange="window.__lib.gTypeChanged()">' +
'<option value="USER">用户</option><option value="GROUP">Group</option></select>' +
'<input id="lib-g-principal" class="input" placeholder="用户 id"/>' +
'<select id="lib-g-group" class="select hidden"></select>' +
'<select id="lib-g-role" class="select" style="width:110px">' +
'<option value="VIEW">VIEW</option><option value="EDIT">EDIT</option><option value="MANAGE">MANAGE</option></select>' +
'<button class="btn btn-primary" onclick="window.__lib.addGrant()">授予</button>' +
"</div>" +
'<div class="section-note">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>' +
"</div>";
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
/* ---------------- 文件 ---------------- */
async function renderFilesTab(body) {
try {
const { files } = await api("/database/api/projects/" + current.id + "/files");
const rows = files.map((f) =>
'<tr class="file-row" onclick="window.__lib.openFile(\\'' + esc(f.path) + '\\')">' +
'<td class="file-path">' + esc(f.path) + "</td>" +
'<td class="file-meta" style="text-align:right">' + f.size + " B</td></tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:6px">' +
'<div class="section-title" style="margin:0">项目文件(' + files.length + ")</div>" +
(current.role !== "VIEW"
? '<div class="inline-form" style="gap:6px">' +
'<button class="btn" onclick="window.__lib.newFileDialog()">+ 新建文件</button>' +
'<button class="btn btn-primary" onclick="document.getElementById(\\'lib-upload-input\\').click()">上传文件</button>' +
'<input id="lib-upload-input" type="file" class="hidden"/>' +
"</div>"
: "") +
"</div>" +
(files.length === 0 ? '<div class="quiet" style="padding:20px 0;text-align:center">空仓库 · 可新建或上传文件</div>'
: '<table class="list">' + rows + "</table>") +
"</div>";
const up = $("#lib-upload-input");
if (up) up.onchange = (e) => doUpload(e.target);
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
function newFileDialog() {
modal(
'<div class="modal-title">新建文件</div>' +
'<div class="form-row"><label class="form-label">路径</label>' +
'<input id="lib-nf-path" class="input" style="font-family:var(--mono)" placeholder="docs/intro.md"/></div>' +
'<div class="form-row"><label class="form-label">内容</label>' +
'<textarea id="lib-nf-content" rows="8" class="textarea" placeholder="内容…"></textarea></div>' +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="window.__lib.submitNewFile()">创建</button></div>'
);
}
async function submitNewFile() {
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body: {
path: $("#lib-nf-path").value, content: $("#lib-nf-content").value,
}});
closeModal(); ok("已创建"); renderTab();
} catch (e) { fail(e); }
}
function u8ToBase64(bytes) {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
async function doUpload(input) {
const file = input.files && input.files[0];
input.value = "";
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("文件超过 10MB 上限", "err");
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
if (!targetPath) return;
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const body = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body });
ok("已上传 " + file.name);
renderTab();
} catch (e) { fail(e); }
}
async function openFile(path) {
try {
const f = await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(path));
currentFile = f;
const canEdit = current.role !== "VIEW";
const prev = $("#lib-preview");
prev.style.display = "block";
prev.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:10px">' +
'<span class="file-meta">' + esc(f.path) + " @ " + esc(f.version) + "</span>" +
'<div class="inline-form" style="gap:6px">' +
'<a class="btn" href="/database/api/projects/' + current.id + '/file/raw?path=' +
encodeURIComponent(f.path) + '" download>下载</a>' +
'<button class="btn" onclick="window.__lib.showHistory()">历史</button>' +
(canEdit ? '<button class="btn btn-danger" onclick="window.__lib.deleteFile()">删除文件</button>' : "") +
'<button class="btn" onclick="window.__lib.closePreview()" title="关闭预览">✕</button>' +
"</div>" +
"</div>" +
(f.encoding === "base64"
? '<div class="quiet">二进制文件(' + f.size + " B),不支持在线编辑</div>"
: '<textarea id="lib-ef-content" rows="16" class="textarea" ' + (canEdit ? "" : "readonly") +
">" + esc(f.content) + "</textarea>") +
(canEdit && f.encoding !== "base64"
? '<div class="modal-actions"><button class="btn btn-primary" onclick="window.__lib.saveFile()">提交修改</button></div>'
: "") +
'<div id="lib-conflict-zone"></div>' +
"</div>";
} catch (e) { fail(e); }
}
function closePreview() {
const prev = $("#lib-preview");
if (prev) { prev.style.display = "none"; prev.innerHTML = ""; }
currentFile = null;
}
async function saveFile() {
try {
const r = await api("/database/api/projects/" + current.id + "/file/commits", { method: "POST", body: {
path: currentFile.path, baseVersion: currentFile.version, content: $("#lib-ef-content").value,
}});
ok("已提交 " + r.version); await openFile(currentFile.path);
} catch (e) {
if (e.status === 409 && e.currentVersion) return showConflict(e.currentVersion);
fail(e);
}
}
async function showConflict(currentVersion) {
const { diff } = await api("/database/api/projects/" + current.id + "/file/diff?path=" +
encodeURIComponent(currentFile.path) + "&from=" + encodeURIComponent(currentFile.version) +
"&to=" + encodeURIComponent(currentVersion));
$("#lib-conflict-zone").innerHTML =
'<div class="conflict-panel">' +
'<div class="conflict-title">冲突:他人已提交 ' + esc(currentVersion) + ",差异如下(你的基版 → 最新版)</div>" +
'<pre class="diff">' + esc(diff)
.replace(/^\\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>') + "</pre>" +
'<div class="conflict-note">请人工合并后,以最新内容为全文重新提交(基版将更新为 ' + esc(currentVersion) + ")</div>" +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.acceptLatest()">载入最新内容</button></div>' +
"</div>";
currentFile.version = currentVersion;
}
async function deleteFile() {
if (!confirm("删除文件 " + currentFile.path + "?")) return;
try {
await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(currentFile.path),
{ method: "DELETE", body: { baseVersion: currentFile.version } });
ok("已删除"); closePreview(); renderTab();
} catch (e) { fail(e); }
}
async function showHistory() {
const { history } = await api("/database/api/projects/" + current.id + "/file/history?path=" +
encodeURIComponent(currentFile.path));
modal(
'<div class="modal-title">版本历史</div>' +
'<div style="max-height:320px;overflow-y:auto">' +
history.map((v) =>
'<div style="border-top:1px solid #F5F5F4;padding:8px 0;font-size:12px">' +
'<span class="file-meta" style="color:var(--accent)">' + esc(v.version) + "</span> " + esc(v.message) +
'<div class="quiet">' + new Date(v.committedAt).toLocaleString("zh-CN") +
(v.author ? " · " + esc(v.author) : "") + "</div></div>").join("") +
"</div>" +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.closeModal()">关闭</button></div>'
);
}
/* ---------------- 导出 ---------------- */
let exportJobId = null;
async function submitExport() {
try {
const r = await api("/database/api/projects/" + current.id + "/exports", { method: "POST", body: { target: $("#lib-x-target").value } });
exportJobId = r.jobId;
$("#lib-x-status").textContent = "任务 " + r.jobId.slice(0, 8) + "… 排队中";
pollExport();
} catch (e) { fail(e); }
}
async function pollExport() {
if (!exportJobId) return;
try {
const job = await api("/database/api/exports/" + exportJobId);
const el = $("#lib-x-status");
if (!el) return;
if (job.status === "DONE") {
el.innerHTML = '完成 · <a href="/database/api/exports/' + exportJobId + '/download">下载</a>';
} else if (job.status === "FAILED") {
el.textContent = "失败:" + (job.error || "");
} else {
el.textContent = "状态:" + job.status + "…";
setTimeout(pollExport, 1000);
}
} catch { /* ignore */ }
}
/* ---------------- 暴露给内联 onclick 的操作表 ---------------- */
window.__lib = {
createChild() { createDialog(current.id); },
async renameCurrent() {
const name = prompt("新名称", current.name);
if (name === null) return;
try {
await api("/database/api/nodes/" + current.id, { method: "PATCH", body: { name } });
ok("已重命名"); await selectNode(current.id);
} catch (e) { fail(e); }
},
async deleteCurrent() {
if (!confirm("确认删除「" + current.name + "」?软删除后不可见。")) return;
try {
await api("/database/api/nodes/" + current.id, { method: "DELETE" });
ok("已删除"); current = null; await renderTree();
$("#lib-main").innerHTML = '<div class="lib-empty-hint">从左侧选择一个文件夹或项目</div>';
closePreview();
} catch (e) { fail(e); }
},
switchTab(id) { current.tab = id; renderMain(); },
async toggleIndependent() {
try {
await api("/database/api/projects/" + current.id + "/independent-permission", {
method: "PUT", body: { enabled: !current.independentPermission },
});
ok("已切换"); await selectNode(current.id);
} catch (e) { fail(e); }
},
async gTypeChanged() {
const isGroup = $("#lib-g-type").value === "GROUP";
$("#lib-g-principal").classList.toggle("hidden", isGroup);
$("#lib-g-group").classList.toggle("hidden", !isGroup);
if (isGroup && $("#lib-g-group").options.length === 0) {
const { groups } = await api("/database/api/groups/search?q=");
$("#lib-g-group").innerHTML = groups.map((g) => '<option value="' + esc(g.id) + '">' + esc(g.name) + "</option>").join("");
}
},
async addGrant() {
const type = $("#lib-g-type").value;
const principalId = type === "GROUP" ? $("#lib-g-group").value : $("#lib-g-principal").value.trim();
if (!principalId) return toast("请填写主体", "err");
try {
await api("/database/api/nodes/" + current.id + "/grants", { method: "PUT", body: {
grants: [{ principalType: type, principalId, role: $("#lib-g-role").value }],
}});
ok("已授予"); renderTab();
} catch (e) { fail(e); }
},
async revokeGrant(grantId) {
try {
await api("/database/api/nodes/" + current.id + "/grants/" + grantId, { method: "DELETE" });
ok("已收回"); renderTab();
} catch (e) { fail(e); }
},
submitCreate,
newFileDialog, submitNewFile, openFile, closePreview, saveFile, deleteFile, showHistory,
acceptLatest() { openFile(currentFile.path); toast("已载入最新内容,请在此基础上合并", "info"); },
submitExport, closeModal,
};
/* ---------------- 初始化 ---------------- */
(async function init() {
try {
me = await api("/database/api/me");
if (me.isWebsiteAdmin) {
const btn = $("#lib-btn-new-root");
btn.classList.remove("hidden");
btn.onclick = () => createDialog(null);
}
} catch (e) { /* 401 已由 api 处理跳转 */ }
await renderTree();
})();
})();
</script>`;
}
-801
View File
@@ -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<void> {
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 `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>文件库</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
<style>
/* 设计令牌:与全局 UI 主题(uiTheme.ts)保持一致。 */
:root {
--bg: #FCFCFB;
--panel: #FFFFFF;
--sidebar: #F7F7F5;
--text: #1A1A18;
--text-2: #6B6A66;
--text-3: #9C9B96;
--border: #ECECE8;
--border-soft: #F1F1EE;
--accent: #1A1A18;
--accent-hover: #333330;
--selected: #EBEBE7;
--hover: #F4F4F1;
--danger: #A13A33;
--guide: #E9E9E5;
--diff-add-bg: #F3F6F2;
--diff-add-text: #4A6741;
--diff-del-bg: #F8F2F1;
--diff-del-text: #A13A33;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: 'Inter', -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg); color: var(--text);
font-size: 14px; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* 布局 */
.shell { display: flex; height: 100vh; }
.sidebar {
width: 292px; flex-shrink: 0; background: var(--sidebar);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
.sidebar-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.wordmark { font-size: 14px; font-weight: 600; color: var(--text); }
.sidebar-actions { display: flex; gap: 6px; }
.tree { flex: 1; overflow-y: auto; padding: 8px 8px 16px; font-size: 13px; }
.sidebar-user {
margin: 0; padding: 10px 16px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--text-2);
}
.avatar {
width: 24px; height: 24px; 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;
}
.main { flex: 1; overflow-y: auto; }
.main-inner { max-width: 860px; margin: 0 auto; padding: 32px 32px 64px; }
.empty-hint {
height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-size: 13px;
}
/* 树 */
.tree-item {
display: flex; align-items: center; gap: 4px;
padding: 4px 6px; border-radius: 6px; cursor: pointer;
color: var(--text); transition: background 120ms ease;
user-select: none;
}
.tree-item:hover { background: var(--hover); }
.tree-item.selected { background: var(--selected); color: #1C1917; }
.tree-children { border-left: 1px solid var(--guide); margin-left: 15px; padding-left: 3px; }
.tree-icon { width: 16px; height: 16px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.tree-caret { color: var(--text-3); }
.tree-badge { margin-left: auto; padding-right: 4px; font-size: 10px; color: var(--text-3); font-family: var(--mono); }
.tree-empty { padding: 24px 12px; text-align: center; font-size: 12px; color: var(--text-3); }
/* 按钮 */
.btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 5px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
color: var(--accent); font-size: 12px; font-weight: 500;
cursor: pointer; transition: all 120ms ease; text-decoration: none;
}
.btn:hover { background: var(--hover); border-color: #D6D3D1; }
.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: var(--diff-del-bg); border-color: transparent; }
/* 内容区 */
.crumbs { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.crumbs span + span::before { content: " / "; color: var(--border); }
.node-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.node-title { font-size: 17px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.tag {
font-size: 10px; font-weight: 500; font-family: var(--mono);
padding: 2px 7px; border-radius: 999px;
border: 1px solid var(--border); color: var(--text-2); background: var(--panel);
}
.tag-role { color: var(--accent); border-color: #D6D3D1; }
.node-actions { display: flex; gap: 6px; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.tab {
padding: 8px 14px; font-size: 13px; color: var(--text-2);
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); }
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 20px 22px;
}
.panel + .panel, .panel + #file-editor { margin-top: 14px; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; font-size: 13px; color: var(--text-2); }
.meta-grid b { color: var(--text); font-weight: 600; }
.section-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 10px; }
.section-note { font-size: 11px; color: var(--text-3); margin-top: 6px; }
/* 表格 */
table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
table.list th { text-align: left; font-size: 11px; font-weight: 500; color: var(--text-3); padding: 4px 0; }
table.list td { padding: 7px 0; border-top: 1px solid #F5F5F4; }
table.list tr:first-child td { border-top: none; }
.file-row { cursor: pointer; }
.file-row:hover td { background: var(--hover); }
.file-path { font-family: var(--mono); font-size: 12px; color: var(--text); }
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.link-danger { color: var(--danger); font-size: 12px; background: none; border: none; cursor: pointer; padding: 0; }
.link-danger:hover { text-decoration: underline; }
/* 表单 */
.input, .select, .textarea {
width: 100%; padding: 7px 10px; 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: 12px; line-height: 1.7; resize: vertical; }
.form-label { display: block; font-size: 11px; color: var(--text-2); margin-bottom: 4px; }
.form-row { margin-bottom: 12px; }
.inline-form { display: flex; gap: 8px; align-items: center; }
.inline-form .input { flex: 1; }
/* 弹层 */
.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: 440px; background: var(--panel);
border: 1px solid var(--border); border-radius: 12px; padding: 22px;
}
.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 14px; font-size: 12px; color: #fff;
background: #333230; max-width: 320px;
}
.toast-ok { background: #333230; }
.toast-err { background: #7E2C26; }
#toast-root { position: fixed; bottom: 16px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; }
/* 冲突与 diff */
.conflict-panel {
margin-top: 14px; border: 1px solid #E8E2C8; background: #FCFBF4;
border-radius: 10px; padding: 14px 16px;
}
.conflict-title { font-size: 13px; font-weight: 600; color: #6E6329; margin-bottom: 8px; }
.conflict-note { font-size: 11px; color: #8A8059; margin-top: 8px; }
pre.diff {
white-space: pre-wrap; font-family: var(--mono); font-size: 12px; line-height: 1.7;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px;
margin: 0;
}
pre.diff .add { display: block; color: var(--diff-add-text); background: var(--diff-add-bg); }
pre.diff .del { display: block; color: var(--diff-del-text); background: var(--diff-del-bg); }
.divider { border-top: 1px solid var(--border); margin: 14px 0; }
.quiet { color: var(--text-3); font-size: 12px; }
.export-status { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.export-status a { color: var(--accent); }
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<span class="wordmark">文件库</span>
<div class="sidebar-actions">
<button id="btn-new-root" class="btn hidden" title="新建根目录">+ 根目录</button>
<a class="btn" href="/database/dashboard">后台</a>
</div>
</div>
<div id="tree" class="tree"></div>
<div class="sidebar-user">
<div class="avatar">${initial}</div>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">${displayName}</span>
<button id="btn-logout" class="btn" style="padding:3px 10px;font-size:11px" title="退出登录">退出</button>
</div>
</aside>
<main class="main" id="main">
<div class="empty-hint">从左侧选择一个文件夹或项目</div>
</main>
</div>
<div id="modal-root"></div>
<div id="toast-root"></div>
<script>
/* ---------------- 基础设施 ---------------- */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = (data && data.error) || { code: "unknown", message: res.statusText };
const e = new Error(err.message); e.code = err.code; e.status = res.status; Object.assign(e, err);
throw e;
}
return data;
}
function toast(msg, kind = "info") {
const el = document.createElement("div");
el.className = "toast " + (kind === "err" ? "toast-err" : "toast-ok");
el.textContent = msg;
$("#toast-root").appendChild(el);
setTimeout(() => el.remove(), 3600);
}
const ok = (m) => toast(m, "ok");
const fail = (e) => toast(e.message || String(e), "err");
function modal(html) {
const root = $("#modal-root");
root.innerHTML = '<div class="modal-mask"><div class="modal-card">' + html + "</div></div>";
root.firstElementChild.addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
}
function closeModal() { $("#modal-root").innerHTML = ""; }
/* ---------------- 状态 ---------------- */
let me = { userId: null, isWebsiteAdmin: false };
let current = null;
const expanded = new Set();
/* ---------------- 目录树 ---------------- */
/* 图标统一用固定尺寸的 inline SVG。每行几何一致:[16px 箭头槽][16px 图标槽][名称]。 */
const ICONS = {
caretRight: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M9 6l6 6-6 6z"/></svg>',
caretDown: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M6 9l6 6 6-6z"/></svg>',
folder: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="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"/></svg>',
project: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>',
};
async function loadChildren(parentId) {
const q = parentId === null ? "" : "?parentId=" + encodeURIComponent(parentId);
return (await api("/database/api/nodes" + q)).nodes;
}
async function renderTree() {
const tree = $("#tree");
tree.innerHTML = "";
try {
const roots = await loadChildren(null);
if (roots.length === 0) {
tree.innerHTML = '<div class="tree-empty">空文件库' +
(me.isWebsiteAdmin ? " · 点上方「+ 根目录」开始" : "") + "</div>";
return;
}
for (const node of roots) tree.appendChild(await treeItem(node, 0));
} catch (e) { tree.innerHTML = '<div class="tree-empty">' + esc(e.message) + "</div>"; }
}
async function treeItem(node, depth) {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-item" + (current && current.id === node.id ? " selected" : "");
const caret = document.createElement("span");
caret.className = "tree-icon tree-caret";
if (node.kind === "FOLDER") {
caret.innerHTML = expanded.has(node.id) ? ICONS.caretDown : ICONS.caretRight;
caret.onclick = (e) => { e.stopPropagation(); toggleExpand(node.id, wrap, depth); };
}
row.appendChild(caret);
const iconSpan = document.createElement("span");
iconSpan.className = "tree-icon";
iconSpan.style.color = node.kind === "PROJECT" ? "#1A1A18" : "#9C9B96";
iconSpan.innerHTML = node.kind === "PROJECT" ? ICONS.project : ICONS.folder;
row.appendChild(iconSpan);
const nameSpan = document.createElement("span");
nameSpan.className = "truncate";
nameSpan.textContent = node.name;
row.appendChild(nameSpan);
if (node.role !== "MANAGE") {
const badge = document.createElement("span");
badge.className = "tree-badge";
badge.textContent = node.role;
row.appendChild(badge);
}
row.onclick = () => {
if (node.kind === "FOLDER") {
if (expanded.has(node.id)) expanded.delete(node.id); else expanded.add(node.id);
}
selectNode(node.id);
};
wrap.appendChild(row);
if (node.kind === "FOLDER" && expanded.has(node.id)) {
wrap.appendChild(await childrenBlock(node.id, depth));
}
return wrap;
}
async function childrenBlock(parentId, depth) {
const block = document.createElement("div");
block.className = "tree-children";
const kids = await loadChildren(parentId);
for (const kid of kids) block.appendChild(await treeItem(kid, depth + 1));
return block;
}
async function toggleExpand(id, wrap, depth) {
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
await renderTree();
}
/* ---------------- 节点详情 ---------------- */
async function selectNode(id) {
try {
const [{ node }, { breadcrumb }] = await Promise.all([
api("/database/api/nodes/" + id),
api("/database/api/nodes/" + id + "/breadcrumb"),
]);
current = node; current.breadcrumb = breadcrumb;
await renderTree();
renderMain();
} catch (e) { fail(e); }
}
function crumbsHtml() {
return current.breadcrumb.map((c) =>
c.name === null ? "<span>…</span>" : "<span>" + esc(c.name) + "</span>"
).join("");
}
function renderMain() {
const n = current;
const canManage = n.role === "MANAGE";
const canEdit = canManage || n.role === "EDIT";
const tabs = [];
tabs.push(["detail", "概览"]);
if (n.kind === "PROJECT") tabs.push(["files", "文件"]);
if (canManage) tabs.push(["grants", "授权"]);
current.tab = current.tab && tabs.some(t => t[0] === current.tab) ? current.tab : "detail";
$("#main").innerHTML =
'<div class="main-inner">' +
'<div class="crumbs">' + crumbsHtml() + "</div>" +
'<div class="node-head">' +
'<div class="node-title">' + esc(n.name) +
'<span class="tag">' + n.kind + "</span>" +
'<span class="tag tag-role">' + n.role + "</span>" +
"</div>" +
'<div class="node-actions">' +
(canEdit && n.kind === "FOLDER" ? '<button class="btn" onclick="createChild()">+ 新建子节点</button>' : "") +
(canManage ? '<button class="btn" onclick="renameCurrent()">重命名</button>' : "") +
(canManage ? '<button class="btn btn-danger" onclick="deleteCurrent()">删除</button>' : "") +
"</div>" +
"</div>" +
'<div class="tabs">' +
tabs.map(([id, label]) =>
'<button onclick="switchTab(\\'' + id + '\\')" class="tab' +
(current.tab === id ? " active" : "") + '">' + label + "</button>").join("") +
"</div>" +
'<div id="tab-body"></div>' +
"</div>";
renderTab();
}
function switchTab(id) { current.tab = id; renderMain(); }
async function renderTab() {
const body = $("#tab-body");
if (current.tab === "files") return renderFilesTab(body);
if (current.tab === "grants") return renderGrantsTab(body);
let extra = "";
if (current.kind === "PROJECT") {
extra =
'<div class="divider"></div>' +
'<div class="inline-form" style="justify-content:flex-start">' +
'<span class="quiet">独立权限</span>' +
'<b style="font-size:13px">' + (current.independentPermission ? "开启" : "关闭") + "</b>" +
(current.role === "MANAGE"
? '<button class="btn" onclick="toggleIndependent()">' + (current.independentPermission ? "关闭" : "开启") + "</button>"
: "") +
'<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>' +
"</div>" +
'<div class="divider"></div>' +
'<div class="section-title">导出</div>' +
'<div class="inline-form">' +
'<select id="x-target" class="select" style="width:auto"><option value="manifest">manifest(stub)</option></select>' +
'<button class="btn" onclick="submitExport()">开始导出</button>' +
'<span id="x-status" class="export-status"></span>' +
"</div>";
}
body.innerHTML =
'<div class="panel">' +
'<div class="meta-grid">' +
"<div>类型 <b>" + current.kind + "</b></div>" +
"<div>我的权限 <b>" + current.role + "</b></div>" +
"<div>创建时间 " + new Date(current.createdAt).toLocaleString("zh-CN") + "</div>" +
"<div>更新时间 " + new Date(current.updatedAt).toLocaleString("zh-CN") + "</div>" +
"</div>" + extra +
"</div>";
pollExport();
}
/* ---------------- 节点操作 ---------------- */
async function createChild() {
createDialog(current.id);
}
function createDialog(parentId) {
modal(
'<div class="modal-title">' + (parentId ? "新建子节点" : "新建根目录") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label>' +
'<input id="f-name" class="input" placeholder="例如:物理必修一"/></div>' +
'<div class="form-row"><label class="form-label">类型</label>' +
'<select id="f-kind" class="select">' +
'<option value="FOLDER">文件夹</option><option value="PROJECT">项目(Git 仓库)</option>' +
"</select></div>" +
'<div class="modal-actions">' +
'<button class="btn" onclick="closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="submitCreate(\\'' + (parentId ?? "") + '\\')">创建</button>' +
"</div>"
);
}
async function submitCreate(parentId) {
try {
await api("/database/api/nodes", { method: "POST", body: {
parentId: parentId === "" ? null : parentId,
kind: $("#f-kind").value, name: $("#f-name").value,
}});
closeModal(); ok("已创建");
if (parentId) expanded.add(parentId);
await renderTree();
} catch (e) { fail(e); }
}
async function renameCurrent() {
const name = prompt("新名称", current.name);
if (name === null) return;
try {
await api("/database/api/nodes/" + current.id, { method: "PATCH", body: { name } });
ok("已重命名"); await selectNode(current.id);
} catch (e) { fail(e); }
}
async function deleteCurrent() {
if (!confirm("确认删除「" + current.name + "」?软删除后不可见。")) return;
try {
await api("/database/api/nodes/" + current.id, { method: "DELETE" });
ok("已删除"); current = null; await renderTree();
$("#main").innerHTML = '<div class="empty-hint">从左侧选择一个文件夹或项目</div>';
} catch (e) { fail(e); }
}
async function toggleIndependent() {
try {
await api("/database/api/projects/" + current.id + "/independent-permission", {
method: "PUT", body: { enabled: !current.independentPermission },
});
ok("已切换"); await selectNode(current.id);
} catch (e) { fail(e); }
}
/* ---------------- 授权 ---------------- */
async function renderGrantsTab(body) {
try {
const { grants } = await api("/database/api/nodes/" + current.id + "/grants");
const rows = grants.map((g) =>
"<tr>" +
"<td>" + (g.principalType === "USER" ? "👤" : "👥") + " " + esc(g.principalId) +
(g.isCreatorGrant ? ' <span class="quiet">(创建者)</span>' : "") + "</td>" +
'<td class="file-meta">' + g.role + "</td>" +
"<td style='text-align:right'>" + (g.isCreatorGrant ? "" :
'<button class="link-danger" onclick="revokeGrant(\\'' + g.id + '\\')">收回</button>') + "</td>" +
"</tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<table class="list"><thead><tr><th>主体</th><th>级别</th><th></th></tr></thead>' +
"<tbody>" + rows + "</tbody></table>" +
'<div class="divider"></div>' +
'<div class="section-title">新增授权</div>' +
'<div class="inline-form">' +
'<select id="g-type" class="select" style="width:110px" onchange="gTypeChanged()">' +
'<option value="USER">用户</option><option value="GROUP">Group</option></select>' +
'<input id="g-principal" class="input" placeholder="用户 id"/>' +
'<select id="g-group" class="select hidden"></select>' +
'<select id="g-role" class="select" style="width:110px">' +
'<option value="VIEW">VIEW</option><option value="EDIT">EDIT</option><option value="MANAGE">MANAGE</option></select>' +
'<button class="btn btn-primary" onclick="addGrant()">授予</button>' +
"</div>" +
'<div class="section-note">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>' +
"</div>";
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
async function gTypeChanged() {
const isGroup = $("#g-type").value === "GROUP";
$("#g-principal").classList.toggle("hidden", isGroup);
$("#g-group").classList.toggle("hidden", !isGroup);
if (isGroup && $("#g-group").options.length === 0) {
const { groups } = await api("/database/api/groups/search?q=");
$("#g-group").innerHTML = groups.map((g) => '<option value="' + esc(g.id) + '">' + esc(g.name) + "</option>").join("");
}
}
async function addGrant() {
const type = $("#g-type").value;
const principalId = type === "GROUP" ? $("#g-group").value : $("#g-principal").value.trim();
if (!principalId) return toast("请填写主体", "err");
try {
await api("/database/api/nodes/" + current.id + "/grants", { method: "PUT", body: {
grants: [{ principalType: type, principalId, role: $("#g-role").value }],
}});
ok("已授予"); renderTab();
} catch (e) { fail(e); }
}
async function revokeGrant(grantId) {
try {
await api("/database/api/nodes/" + current.id + "/grants/" + grantId, { method: "DELETE" });
ok("已收回"); renderTab();
} catch (e) { fail(e); }
}
/* ---------------- 文件 ---------------- */
let currentFile = null;
async function renderFilesTab(body) {
try {
const { files } = await api("/database/api/projects/" + current.id + "/files");
const rows = files.map((f) =>
'<tr class="file-row" onclick=\\'openFile("' + esc(f.path) + '")\\'>' +
'<td class="file-path">' + esc(f.path) + "</td>" +
'<td class="file-meta" style="text-align:right">' + f.size + " B</td></tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:6px">' +
'<div class="section-title" style="margin:0">项目文件(' + files.length + ")</div>" +
(current.role !== "VIEW"
? '<div class="inline-form" style="gap:6px">' +
'<button class="btn" onclick="newFileDialog()">+ 新建文件</button>' +
'<button class="btn btn-primary" onclick="document.getElementById(\\'upload-input\\').click()">上传文件</button>' +
'<input id="upload-input" type="file" class="hidden" onchange="doUpload(this)"/>' +
"</div>"
: "") +
"</div>" +
(files.length === 0 ? '<div class="quiet" style="padding:20px 0;text-align:center">空仓库 · 可新建或上传文件</div>'
: '<table class="list">' + rows + "</table>") +
"</div>" +
'<div id="file-editor"></div>';
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
async function newFileDialog() {
modal(
'<div class="modal-title">新建文件</div>' +
'<div class="form-row"><label class="form-label">路径</label>' +
'<input id="nf-path" class="input" style="font-family:var(--mono)" placeholder="docs/intro.md"/></div>' +
'<div class="form-row"><label class="form-label">内容</label>' +
'<textarea id="nf-content" rows="8" class="textarea" placeholder="内容…"></textarea></div>' +
'<div class="modal-actions"><button class="btn" onclick="closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="submitNewFile()">创建</button></div>'
);
}
/* 磁盘文件上传:文本走 utf8,含 NUL 字节判为二进制走 base64(与服务端一致)。 */
function u8ToBase64(bytes) {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
async function doUpload(input) {
const file = input.files && input.files[0];
input.value = "";
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("文件超过 10MB 上限", "err");
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
if (!targetPath) return;
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const body = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body });
ok("已上传 " + file.name);
renderTab();
} catch (e) { fail(e); }
}
async function submitNewFile() {
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body: {
path: $("#nf-path").value, content: $("#nf-content").value,
}});
closeModal(); ok("已创建"); renderTab();
} catch (e) { fail(e); }
}
async function openFile(path) {
try {
const f = await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(path));
currentFile = f;
const canEdit = current.role !== "VIEW";
$("#file-editor").innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:10px">' +
'<span class="file-meta">' + esc(f.path) + " @ " + esc(f.version) + "</span>" +
'<div class="inline-form" style="gap:6px">' +
'<a class="btn" href="/database/api/projects/' + current.id + '/file/raw?path=' +
encodeURIComponent(f.path) + '" download>下载</a>' +
'<button class="btn" onclick="showHistory()">历史</button>' +
(canEdit ? '<button class="btn btn-danger" onclick="deleteFile()">删除文件</button>' : "") +
"</div>" +
"</div>" +
(f.encoding === "base64"
? '<div class="quiet">二进制文件(' + f.size + " B),不支持在线编辑</div>"
: '<textarea id="ef-content" rows="14" class="textarea" ' + (canEdit ? "" : "readonly") +
">" + esc(f.content) + "</textarea>") +
(canEdit && f.encoding !== "base64"
? '<div class="modal-actions"><button class="btn btn-primary" onclick="saveFile()">提交修改</button></div>'
: "") +
'<div id="conflict-zone"></div>' +
"</div>";
} catch (e) { fail(e); }
}
async function saveFile() {
try {
const r = await api("/database/api/projects/" + current.id + "/file/commits", { method: "POST", body: {
path: currentFile.path, baseVersion: currentFile.version, content: $("#ef-content").value,
}});
ok("已提交 " + r.version); await openFile(currentFile.path);
} catch (e) {
if (e.status === 409 && e.currentVersion) return showConflict(e.currentVersion);
fail(e);
}
}
async function showConflict(currentVersion) {
const { diff } = await api("/database/api/projects/" + current.id + "/file/diff?path=" +
encodeURIComponent(currentFile.path) + "&from=" + encodeURIComponent(currentFile.version) +
"&to=" + encodeURIComponent(currentVersion));
$("#conflict-zone").innerHTML =
'<div class="conflict-panel">' +
'<div class="conflict-title">冲突:他人已提交 ' + esc(currentVersion) + ",差异如下(你的基版 → 最新版)</div>" +
'<pre class="diff">' + esc(diff)
.replace(/^\\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>') + "</pre>" +
'<div class="conflict-note">请人工合并后,以最新内容为全文重新提交(基版将更新为 ' + esc(currentVersion) + ")</div>" +
'<div class="modal-actions"><button class="btn" onclick="acceptLatest(\\'' + esc(currentVersion) + '\\')">载入最新内容</button></div>' +
"</div>";
currentFile.version = currentVersion;
}
async function acceptLatest() { await openFile(currentFile.path); toast("已载入最新内容,请在此基础上合并", "info"); }
async function deleteFile() {
if (!confirm("删除文件 " + currentFile.path + "?")) return;
try {
await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(currentFile.path),
{ method: "DELETE", body: { baseVersion: currentFile.version } });
ok("已删除"); currentFile = null; renderTab();
} catch (e) { fail(e); }
}
async function showHistory() {
const { history } = await api("/database/api/projects/" + current.id + "/file/history?path=" +
encodeURIComponent(currentFile.path));
modal(
'<div class="modal-title">版本历史</div>' +
'<div style="max-height:320px;overflow-y:auto">' +
history.map((v) =>
'<div style="border-top:1px solid #F5F5F4;padding:8px 0;font-size:12px">' +
'<span class="file-meta" style="color:var(--accent)">' + esc(v.version) + "</span> " + esc(v.message) +
'<div class="quiet">' + new Date(v.committedAt).toLocaleString("zh-CN") +
(v.author ? " · " + esc(v.author) : "") + "</div></div>").join("") +
"</div>" +
'<div class="modal-actions"><button class="btn" onclick="closeModal()">关闭</button></div>'
);
}
/* ---------------- 导出 ---------------- */
let exportJobId = null;
async function submitExport() {
try {
const r = await api("/database/api/projects/" + current.id + "/exports", { method: "POST", body: { target: $("#x-target").value } });
exportJobId = r.jobId;
$("#x-status").textContent = "任务 " + r.jobId.slice(0, 8) + "… 排队中";
pollExport();
} catch (e) { fail(e); }
}
async function pollExport() {
if (!exportJobId) return;
try {
const job = await api("/database/api/exports/" + exportJobId);
const el = $("#x-status");
if (!el) return;
if (job.status === "DONE") {
el.innerHTML = '完成 · <a href="/database/api/exports/' + exportJobId + '/download">下载</a>';
} else if (job.status === "FAILED") {
el.textContent = "失败:" + (job.error || "");
} else {
el.textContent = "状态:" + job.status + "…";
setTimeout(pollExport, 1000);
}
} catch { /* job 可能属于别的项目 */ }
}
/* ---------------- 启动 ---------------- */
(async function init() {
const logoutBtn = $("#btn-logout");
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch {}
location.href = "/database/admin";
});
}
try {
me = await api("/database/api/me");
if (me.isWebsiteAdmin) {
const btn = $("#btn-new-root");
btn.classList.remove("hidden");
btn.onclick = () => createDialog(null);
}
} catch (e) { /* 401 已由 api 处理跳转 */ }
await renderTree();
})();
</script>
</body>
</html>`;
}
+5 -28
View File
@@ -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);
}
-155
View File
@@ -1,155 +0,0 @@
/**
* 全局共享 UI 主题(老师端 /app、管理员后台 /database、文件页 /database/library)。
*
* 方向:高级简约、零视觉疲劳 —— 暖白底、发丝边框、近黑主按钮(唯一强调色)、
* 无渐变、无彩色、无阴影(弹层仅一丝)、Inter 全界面、克制的动效。
* 所有页面的设计令牌收敛于此,调色只改这里。
*/
export const UI_HEAD_FONTS = `
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
`;
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)。用法:<label class="switch"><input type="checkbox"><span></span>文字</label>
真正的 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; }
`;
+43 -70
View File
@@ -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<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",
};
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<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.",
"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<FastifyReply> =>
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);
}
+7 -7
View File
@@ -17,16 +17,16 @@ export function isSiloHttpRateLimitExempt(url: string): boolean {
if (path === "/_app" || path.startsWith("/_app/")) return true;
if (path === "/favicon.ico" || path === "/favicon.svg" || path === "/robots.txt") return true;
// 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;
// filelib-web SvelteKit build output. appDir 改名为 `_filelib` 以避开根 /_app/*
// (见 database/static.ts);同一份产物服务 /app 与 /database 两个前缀。
if (path === "/_filelib" || path.startsWith("/_filelib/")) 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.
// SPA index shells for client-side routes (not APIs). /admin/*、/database/*
// /app/* 整体豁免 —— 客户端路由无法预先枚举;这也覆盖了每次加载一次的
// /database/config bootstrap
if (path === "/admin" || path.startsWith("/admin/")) return true;
if (path === "/database" || path.startsWith("/database/")) return true;
if (path === "/app" || path.startsWith("/app/")) return true;
return false;
}
+11
View File
@@ -27,6 +27,17 @@ describe("isSiloHttpRateLimitExempt", () => {
expect(isSiloHttpRateLimitExempt("/admin/org/para-26071100/members")).toBe(true);
});
it("exempts the filelib-web SPA assets and both of its mount prefixes", () => {
// appDir 改名为 `_filelib`(见 src/database/static.ts):同一份产物服务
// 老师端 /app 与管理后台 /database。
expect(isSiloHttpRateLimitExempt("/_filelib/version.json")).toBe(true);
expect(isSiloHttpRateLimitExempt("/_filelib/immutable/chunks/foo.js?v=1")).toBe(true);
expect(isSiloHttpRateLimitExempt("/app")).toBe(true);
expect(isSiloHttpRateLimitExempt("/app/dev-login-teacher")).toBe(true);
expect(isSiloHttpRateLimitExempt("/database")).toBe(true);
expect(isSiloHttpRateLimitExempt("/database/dashboard/users")).toBe(true);
});
it("still rate-limits APIs and auth", () => {
expect(isSiloHttpRateLimitExempt("/api/me")).toBe(false);
expect(isSiloHttpRateLimitExempt("/api/org/x/members")).toBe(false);
@@ -0,0 +1,43 @@
/**
* 两套 SPA 静态托管共存于同一个 Fastify 实例。
*
* 这是 ADR-0029 的承重约束的回归测试:filelib-web 的 appDir 若用 SvelteKit 默认的
* `_app`,就会与 admin-web 在根上注册的 `/_app/*` 撞成重复路由,Fastify 启动即抛错。
* 改名为 `_filelib` 后两者共存 —— 这里把两个注册函数挂到同一实例上验证。
*/
import Fastify from "fastify";
import { describe, expect, it } from "vitest";
import { registerStaticSpa } from "../../src/admin/static.js";
import { registerDatabaseSpa } from "../../src/database/static.js";
describe("SPA static route registration", () => {
it("admin-web and filelib-web shells coexist without duplicate routes", async () => {
const app = Fastify({ logger: false });
await registerStaticSpa(app);
await registerDatabaseSpa(app);
await expect(app.ready()).resolves.toBeDefined();
await app.close();
});
it("serves the same index.html at /app and /database, and assets under /_filelib", async () => {
const app = Fastify({ logger: false });
await registerDatabaseSpa(app);
await app.ready();
const appShell = await app.inject({ method: "GET", url: "/app" });
const dbShell = await app.inject({ method: "GET", url: "/database/dashboard/users" });
// 前置:构建产物必须存在。缺失时 registerDatabaseSpa 只 warn 不注册路由,
// 断言会全部落到 404 —— 那说明该先跑 `npm run build --prefix filelib-web`,
// 不是测试不适用,所以这里不跳过而是直接失败。
expect(appShell.statusCode).toBe(200);
expect(dbShell.statusCode).toBe(200);
// 同一份字节:SPA 回退不读请求(ADR-0029)。
expect(dbShell.body).toBe(appShell.body);
expect(appShell.headers["content-type"]).toContain("text/html");
// 资源用绝对路径引用(paths.relative=false),否则深层 URL 下会解析错。
expect(appShell.body).toContain("/_filelib/");
await app.close();
});
});