forked from EduCraft/curriculum-project-hub
Compare commits
9 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6990082247 | |||
| cdeb29ccf2 | |||
| eeb8f56742 | |||
| 325b4fc137 | |||
| a7f90f387d | |||
| d159e372d2 | |||
| 3d0f4e5c2d | |||
| de9f846fd0 | |||
| 683e97ca53 |
@@ -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.
|
||||
@@ -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/
|
||||
|
||||
@@ -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 +0,0 @@
|
||||
engine-strict=true
|
||||
@@ -1,4 +0,0 @@
|
||||
build
|
||||
.svelte-kit
|
||||
node_modules
|
||||
package-lock.json
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"useTabs": true,
|
||||
"singleQuote": true,
|
||||
"semi": true,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 120,
|
||||
"plugins": ["prettier-plugin-svelte"],
|
||||
"overrides": [{ "files": "*.svelte", "options": { "parser": "svelte" } }]
|
||||
}
|
||||
Generated
-2660
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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 |
@@ -1,3 +0,0 @@
|
||||
# allow crawling everything by default
|
||||
User-agent: *
|
||||
Disallow:
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
Generated
+137
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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 {}
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
+28
-52
@@ -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}
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -1,5 +0,0 @@
|
||||
import { mount } from "svelte";
|
||||
import App from "./App.svelte";
|
||||
import "./app.css";
|
||||
|
||||
export default mount(App, { target: document.getElementById("app")! });
|
||||
@@ -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 />
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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/)
|
||||
|
||||
|
||||
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
|
||||
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>`;
|
||||
}
|
||||
@@ -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("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 树节点 */
|
||||
|
||||
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
|
||||
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>`;
|
||||
}
|
||||
@@ -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,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
|
||||
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>`;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user