refactor(database)!: 后端不再渲染任何 HTML,只出 JSON

删掉约 1770 行服务端模板拼接:renderDashboard / renderLoginPage
(databaseRoutes)、adminPanels、libraryBrowser、uiTheme,以及
libraryPage —— 后者迁移前已是无人引用的死代码。

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

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

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

BREAKING: 部署需先构建 filelib-web,否则 static.ts 的 existsSync
守卫会让 /app 与 /database 全部 404。
This commit is contained in:
2026-07-26 20:17:59 +08:00
parent 3d0f4e5c2d
commit d159e372d2
8 changed files with 109 additions and 2630 deletions
+26 -17
View File
@@ -3,26 +3,35 @@
`/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它, `/database/*` HTTP 面。代码写在这个目录里,`hub.ts` 通过 `plugin.ts` 挂载它,
所以服务器启动时能正确识别这些路由。 所以服务器启动时能正确识别这些路由。
**前后端分离**:页面已迁到独立的 SvelteKit 静态 SPA `hub/database-admin/` **前后端分离**:页面全部在 SvelteKit 静态 SPA `hub/filelib-web/`(与 `hub/admin-web/`
(与 `hub/admin-web/` 同一套框架)。本目录的后端只保留三件事:鉴权透传、一个 同一套框架)。**老师端 `/app` 与管理后台 `/database` 共用这一份工程和这一份构建产物** ——
免鉴权配置端点、以及把 SPA 构建产物托管出去。页面全部由 SPA 客户端渲染。 两个挂载前缀,一个 SPA。本目录的后端只保留三件事:鉴权透传、JSON 数据端点、
以及把构建产物托管出去。服务端不渲染任何 HTML。
后端路由: 后端路由:
- `GET /database/config` —— 免鉴权。返回 `{ siloOrganizationSlug, devLoginEnabled }` - `GET /database/config` —— 免鉴权。返回 `{ orgSlug, devLoginEnabled }`
给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据。 给 SPA 登录页拼飞书链接、决定是否显示 dev 按钮用。不含任何敏感数据。
`/database/api/login-info` 是同形状的既有端点,由 `routes/teacherApp.ts` 注册。)
- `GET /database/api/stats` —— 概览页统计。需登录 **且** 是 silo org OWNER/ADMIN。
- `GET /database/dev-login` —— 仅开发。见下。 - `GET /database/dev-login` —— 仅开发。见下。
- `GET /database``GET /database/*` —— SPA shell / 客户端路由 fallback - `GET /database``GET /database/*``GET /app``GET /app/*` —— SPA shell /
`static.ts``registerDatabaseSpa`;资产在 `/database/_app/*` 客户端路由 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 来自 - `/app` —— 老师端文件库。未登录显示登录卡片。
`/database/config`),回调由 `src/admin/routes/authRoutes.ts` 处理并种 session cookie。 - `/database/admin` —— 管理员飞书登录页。按钮指向 `/auth/feishu/<orgSlug>`
- `/database/dashboard` —— 后台壳。未登录跳登录页;**登录但非 OWNER/ADMIN 显示无权提示** 回调由 `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`)必须在 > **注册顺序要点**concrete 路由(`/database/config`、`/database/api/*`、
> `registerDatabaseSpa` 的 `/database/*` fallback 之前注册(已在 `plugin.ts` 保证), > `/database/dev-login`、`/app/dev-login-teacher`)必须在 `registerDatabaseSpa` 的
> `/database/*`、`/app/*` fallback 之前注册(已在 `plugin.ts` 保证),
> 否则通配会 shadow 它们。 > 否则通配会 shadow 它们。
## 开发模式:用环境变量开启一键登录 ## 开发模式:用环境变量开启一键登录
@@ -73,17 +82,17 @@ allowDevLoginBypass = (NODE_ENV !== "production") && HUB_DEV_LOGIN_BYPASS 为真
| 文件 | 职责 | | 文件 | 职责 |
|------|------| |------|------|
| `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` | | `plugin.ts` | 模块对外入口,`hub.ts``registerDatabasePlugin()` |
| `routes/databaseRoutes.ts` | 登录页/dashboard + 各子路由装配点 | | `routes/databaseRoutes.ts` | `/database/config``/database/api/stats`、dev 旁路 + 各子路由装配点 |
| `routes/filelibRoutes.ts` | 文件库 树/授权 API | | `routes/filelibRoutes.ts` | 文件库 树/授权 API |
| `routes/fileRoutes.ts` | 文件库 文件内容/导出 API | | `routes/fileRoutes.ts` | 文件库 文件内容/导出 API |
| `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) | | `routes/memberGroupRoutes.ts` | 成员组管理 API + `/groups/search` + `/users/search`(ADR-0028) |
| `routes/adminPanels.ts` | dashboard「用户管理」(org 成员)/「Group 管理」(MemberGroup 嵌套树)面板 | | `routes/teacherApp.ts` | `/database/api/login-info` + 老师端 DEV 一键登录 |
| `routes/libraryPage.ts` | `/database/library` 文件库浏览页 | | `static.ts` | filelib-web 构建产物托管:`/_filelib/*` 资源 + `/app``/database` 两个 SPA 回退 |
| `filelib/` | 文件库领域层(见下) | | `filelib/` | 文件库领域层(见下) |
新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/...")` 新增一类**数据**端点时:要么直接往 `databaseRoutes.ts``app.get("/database/api/...")`
要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts``registerXxxRoutes(app, {...})` 要么新建 `routes/xxxRoutes.ts` 并在 `databaseRoutes.ts``registerXxxRoutes(app, {...})`
注册一次。 注册一次。**不要在后端拼 HTML** —— 页面一律加在 `hub/filelib-web/src/routes/` 下。
## 文件库(filelib/) ## 文件库(filelib/)
-747
View File
@@ -1,747 +0,0 @@
/**
* 管理员后台「用户管理」「Group 管理」面板。
*
* 用户管理 = org 成员(/api/org/:orgSlug/members)。
* Group 管理 = **MemberGroup**(全局可无限嵌套,ADR-0028),走 /database/api/groups/*。
* 已从旧的扁平 hub Team 迁过来 —— Team 无父子字段,建不出子组。此面板与
* filelib-web 的 GroupAdmin.svelte 消费同一套 API,语义一致。
* 交互:嵌套树(展开/折叠)+ 右键菜单(建子组/建根组/重命名/删除)。
*/
function apiBase(orgSlug: string): string {
return `/api/org/${encodeURIComponent(orgSlug)}`;
}
/* ---------------------------------------------------------------- 用户管理 */
export function renderUsersPanel(orgSlug: string): string {
return `
<div id="users-root" style="max-width:880px">
<div class="panel" style="margin-bottom:14px">
<div class="section-title">添加成员</div>
<div class="inline-form">
<input id="u-openid" class="input" placeholder="用户 openId(飞书 ou_ 开头)" style="flex:2"/>
<input id="u-name" class="input" placeholder="显示名(可选)" style="flex:1"/>
<select id="u-role" class="select" style="width:130px">
<option value="MEMBER">普通老师</option>
<option value="ADMIN">管理员</option>
<option value="OWNER">所有者</option>
</select>
<button id="u-add" class="btn btn-primary">添加</button>
</div>
</div>
<div class="panel">
<div class="section-title">成员列表</div>
<table class="list">
<thead><tr><th>成员</th><th>userId</th><th>角色</th><th></th></tr></thead>
<tbody id="u-tbody"></tbody>
</table>
</div>
</div>
<script>
(function () {
const BASE = "${apiBase(orgSlug)}";
const tbody = document.getElementById("u-tbody");
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
const ROLE_LABEL = { OWNER: "所有者", ADMIN: "管理员", MEMBER: "普通老师" };
async function req(path, opts = {}) {
const res = await fetch(BASE + path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
method: opts.method ?? "GET",
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
return data;
}
async function load() {
const { members } = await req("/members");
tbody.innerHTML = members.length === 0
? '<tr><td colspan="4" class="quiet" style="text-align:center;padding:18px">暂无成员</td></tr>'
: members.map((m) =>
"<tr>" +
"<td>" + esc(m.displayName || m.userId) + "</td>" +
'<td class="file-meta">' + esc(m.userId) + "</td>" +
'<td><select class="select" style="width:110px;padding:3px 8px;font-size:12px" data-user="' + esc(m.userId) + '">' +
["OWNER","ADMIN","MEMBER"].map((r) =>
'<option value="' + r + '"' + (m.role === r ? " selected" : "") + ">" + ROLE_LABEL[r] + "</option>").join("") +
"</select></td>" +
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td>' +
"</tr>").join("");
tbody.querySelectorAll("select[data-user]").forEach((sel) => {
sel.onchange = async () => {
try { await req("/members/" + encodeURIComponent(sel.dataset.user), { method: "PATCH", body: { role: sel.value } }); }
catch (e) { alert(e.message); load(); }
};
});
tbody.querySelectorAll("button[data-revoke]").forEach((btn) => {
btn.onclick = async () => {
if (!confirm("移除该成员?")) return;
try { await req("/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); load(); }
catch (e) { alert(e.message); }
};
});
}
document.getElementById("u-add").onclick = async () => {
const openId = document.getElementById("u-openid").value.trim();
if (!openId) return alert("请填写用户 openId");
const displayName = document.getElementById("u-name").value.trim();
try {
await req("/members", { method: "POST", body: {
feishuOpenId: openId,
role: document.getElementById("u-role").value,
...(displayName ? { displayName } : {}),
}});
document.getElementById("u-openid").value = "";
document.getElementById("u-name").value = "";
load();
} catch (e) { alert(e.message); }
};
load().catch((e) => { tbody.innerHTML = '<tr><td colspan="4" style="color:var(--danger);padding:12px">' + esc(e.message) + "</td></tr>"; });
})();
</script>`;
}
/* ---------------------------------------------------------------- Group 管理 */
/**
* MemberGroup 嵌套树管理(ADR-0028)。无 orgSlug 参数 —— MemberGroup 是全局主体,
* 不归属任何 Organization,端点也不带 org 段。
*/
/** 内联 SVG 图标表(24x24 stroke 风格,与 dashboard 侧栏一致)。 */
const GROUP_ICONS: Record<string, string> = {
// Group 节点 = 人的集合。**不用文件夹图标** —— Group 不是目录,
// 与文件库的 FOLDER/PROJECT 是两套体系,图标上也不应混淆。两人剪影。
group: "M16 19v-1.5a3.5 3.5 0 0 0-3.5-3.5h-5A3.5 3.5 0 0 0 4 17.5V19M10 11.5a3.25 3.25 0 1 0 0-6.5 3.25 3.25 0 0 0 0 6.5ZM20 19v-1.5a3.5 3.5 0 0 0-2.6-3.38M15.4 5.22a3.25 3.25 0 0 1 0 6.06",
users: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75",
user: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2M12 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Z",
plus: "M12 5v14M5 12h14",
pencil: "M17 3a2.8 2.8 0 0 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3Z",
trash: "M3 6h18M8 6V4h8v2m-9 0 1 14h8l1-14",
search: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z",
chevron: "m9 18 6-6-6-6",
layers: "m12 2 9 5-9 5-9-5 9-5Zm9 11-9 5-9-5m18 5-9 5-9-5",
clock: "M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Zm0-14v6l4 2",
minus: "M5 12h14",
// 已归档(软删)标记用;与"删除"区分 —— 数据仍在,只是打了 archivedAt。
archive: "M3 8h18v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8Zm1-5h16l1 5H3l1-5Zm5 9h6",
restore: "M3 12a9 9 0 1 0 3-6.7M3 4v4.5h4.5",
};
/** `icon("users", 16)` → 内联 svg 串。 */
function icon(name: keyof typeof GROUP_ICONS | string, size = 16): string {
const d = GROUP_ICONS[name] ?? "";
return `<svg style="width:${size}px;height:${size}px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${d}"/></svg>`;
}
export function renderGroupsPanel(): string {
return `
<div id="groups-root" style="display:flex;gap:14px;align-items:stretch;height:100%;min-height:0">
<!-- 左:组树 -->
<div class="panel" style="width:326px;flex-shrink:0;display:flex;flex-direction:column;min-height:0;padding:14px">
<div style="display:flex;align-items:center;gap:8px;margin-bottom:10px">
<span style="display:flex;color:var(--accent)">${icon("layers", 17)}</span>
<div class="section-title" style="margin:0;flex:1">Group 树</div>
<button id="g-new-root" class="btn" style="font-size:11.5px;padding:4px 9px;display:inline-flex;align-items:center;gap:4px">
${icon("plus", 13)} 根组
</button>
</div>
<div style="position:relative;margin-bottom:8px">
<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">${icon("search", 13)}</span>
<input id="g-filter" class="input" placeholder="过滤组名…" style="padding-left:28px;font-size:12.5px"/>
</div>
<label class="switch" style="margin-bottom:9px;font-size:11.5px;color:var(--text-3)">
<input type="checkbox" id="g-show-archived"/>
<span></span>
显示已删除的组
</label>
<div id="g-tree" style="flex:1;overflow-y:auto;margin:0 -6px;min-height:0"></div>
<div id="g-tree-foot" class="section-note" style="border-top:1px solid var(--border-soft);margin-top:8px;padding-top:8px"></div>
</div>
<!-- 右:成员表 -->
<div class="panel" style="flex:1;min-width:0;display:flex;flex-direction:column;min-height:0;padding:0">
<div id="g-detail" style="display:flex;flex-direction:column;min-height:0;flex:1">
<div class="quiet" style="margin:auto;padding:28px;text-align:center;display:flex;flex-direction:column;align-items:center;gap:10px">
<span style="color:var(--border);display:flex">${icon("users", 40)}</span>
从左侧选择一个 Group 查看成员
</div>
</div>
</div>
</div>
<div id="g-menu" class="hidden" style="position:fixed;z-index:60;min-width:184px;background:var(--panel);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-pop);padding:5px;font-size:13px"></div>
<script>
(function () {
const treeEl = document.getElementById("g-tree");
const detailEl = document.getElementById("g-detail");
const menuEl = document.getElementById("g-menu");
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
const ICONS = ${JSON.stringify(GROUP_ICONS)};
function ico(name, size) {
return '<svg style="width:' + (size || 16) + 'px;height:' + (size || 16) + 'px;flex-shrink:0" viewBox="0 0 24 24"' +
' fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">' +
'<path d="' + (ICONS[name] || "") + '"/></svg>';
}
/** 头像:有 avatarUrl 用图,否则首字母色块。 */
function avatar(m, size) {
const s = size || 28;
if (m.avatarUrl) {
return '<img src="' + esc(m.avatarUrl) + '" alt="" style="width:' + s + 'px;height:' + s +
'px;border-radius:50%;object-fit:cover;flex-shrink:0"/>';
}
const ch = esc((m.displayName || m.userId || "?").slice(0, 1).toUpperCase());
return '<span style="width:' + s + 'px;height:' + s + 'px;border-radius:50%;flex-shrink:0;background:var(--accent);' +
'color:#fff;display:inline-flex;align-items:center;justify-content:center;font-size:' + Math.round(s * 0.42) +
'px;font-weight:600">' + ch + "</span>";
}
function fmtDate(iso) {
try { return new Date(iso).toLocaleString("zh-CN", { dateStyle: "medium", timeStyle: "short" }); }
catch { return String(iso || ""); }
}
let groups = []; // 后端返回的活跃组扁平列表
let selected = null; // { id, name }
const collapsed = new Set(); // 折叠的组 id(默认全展开)
let filterText = ""; // 组名过滤(命中项的祖先链一并保留)
let showArchived = false; // 是否列出已归档(软删)的组
async function req(path, opts = {}) {
const res = await fetch("/database/api/groups" + path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
method: opts.method ?? "GET",
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
return data;
}
/* ---------------- 树渲染 ---------------- */
/** 扁平列表 → 先根遍历顺序;折叠的子树整段跳过。过滤时命中项的祖先链保留。 */
function orderedVisible() {
const byParent = new Map();
const byId = new Map();
for (const g of groups) {
byId.set(g.id, g);
const arr = byParent.get(g.parentId) || [];
arr.push(g);
byParent.set(g.parentId, arr);
}
for (const arr of byParent.values()) arr.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
// 过滤:命中集 = 名字命中的组 ∪ 其全部祖先(否则命中的深层组无路径可展示)。
let keep = null;
const q = filterText.trim().toLowerCase();
if (q !== "") {
keep = new Set();
for (const g of groups) {
if (!g.name.toLowerCase().includes(q)) continue;
let cur = g;
while (cur) { keep.add(cur.id); cur = cur.parentId ? byId.get(cur.parentId) : null; }
}
}
const out = [];
(function walk(parentId) {
for (const g of byParent.get(parentId) || []) {
if (keep !== null && !keep.has(g.id)) continue;
const kids = (byParent.get(g.id) || []).filter((k) => keep === null || keep.has(k.id));
out.push({ g, hasKids: kids.length > 0, hit: q === "" || g.name.toLowerCase().includes(q) });
// 过滤态下强制展开(否则命中项被折叠祖先藏住)。
if (keep !== null || !collapsed.has(g.id)) walk(g.id);
}
})(null);
// 兜底:父不在活跃列表的孤儿(级联软删理论上不产生)也列出,避免"看不见"。
const seen = new Set(out.map((r) => r.g.id));
for (const g of groups) {
if (seen.has(g.id)) continue;
if (keep !== null && !keep.has(g.id)) continue;
out.push({ g, hasKids: false, hit: true });
}
return out;
}
function renderTree() {
const footEl = document.getElementById("g-tree-foot");
if (groups.length === 0) {
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px;display:flex;flex-direction:column;align-items:center;gap:8px">' +
'<span style="color:var(--border);display:flex">' + ico("layers", 30) + "</span>" +
"暂无成员组 · 点上方「根组」开始</div>";
footEl.textContent = "";
return;
}
const rows = orderedVisible();
if (rows.length === 0) {
treeEl.innerHTML = '<div class="quiet" style="text-align:center;padding:22px 12px">无匹配的组</div>';
} else {
treeEl.innerHTML = rows.map(({ g, hasKids, hit }) => {
const isSel = selected && selected.id === g.id;
const caret = hasKids
? '<span data-caret="' + esc(g.id) + '" style="display:inline-flex;width:15px;justify-content:center;cursor:pointer;' +
'color:var(--text-3);transition:transform .12s;' +
(collapsed.has(g.id) && filterText.trim() === "" ? "" : "transform:rotate(90deg)") + '">' +
ico("chevron", 13) + "</span>"
: '<span style="display:inline-block;width:15px"></span>';
const arch = !!g.archivedAt;
return '<div class="g-node" data-id="' + esc(g.id) + '" data-name="' + esc(g.name) + '"' +
' data-archived="' + (arch ? "1" : "") + '"' +
' style="display:flex;align-items:center;gap:6px;padding:6px 8px 6px ' + (8 + g.depth * 15) + 'px;' +
'border-radius:8px;cursor:pointer;user-select:none;' + (isSel ? "background:var(--selected)" : "") +
(hit ? "" : ";opacity:.5") + '">' +
caret +
'<span style="display:flex;color:' + (arch ? "var(--text-3)" : (isSel ? "var(--accent)" : "var(--text-3)")) + '">' +
ico(arch ? "archive" : "group", 15) + "</span>" +
'<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap' +
(arch ? ";color:var(--text-3);text-decoration:line-through" : "") + '">' + esc(g.name) + "</span>" +
'<span class="tag" style="flex-shrink:0;display:inline-flex;align-items:center;gap:3px;font-size:10.5px' +
(arch ? ";opacity:.7" : "") + '">' + ico("user", 10) + g.memberCount + "</span>" +
(arch ? '<span class="tag" style="flex-shrink:0;font-size:10px;opacity:.85">已删除</span>' : "") +
"</div>";
}).join("");
}
const active = groups.filter((g) => !g.archivedAt);
const archivedN = groups.length - active.length;
const totalMembers = active.reduce((n, g) => n + g.memberCount, 0);
footEl.textContent = active.length + " 个活跃组 · " + totalMembers + " 条成员关系" +
(archivedN > 0 ? " · " + archivedN + " 个已删除" : "");
treeEl.querySelectorAll(".g-node").forEach((el) => {
el.onclick = (e) => {
if (e.target.closest("[data-caret]")) return; // 折叠三角不触发选中
selectGroup({ id: el.dataset.id, name: el.dataset.name });
loadMembers();
};
el.oncontextmenu = (e) => {
e.preventDefault();
openMenu(e.clientX, e.clientY, {
id: el.dataset.id,
name: el.dataset.name,
archived: el.dataset.archived === "1",
});
};
});
treeEl.querySelectorAll("[data-caret]").forEach((el) => {
el.onclick = (e) => {
e.stopPropagation();
const id = el.dataset.caret;
if (collapsed.has(id)) collapsed.delete(id); else collapsed.add(id);
renderTree();
};
});
}
/* ---------------- 右键菜单 ---------------- */
function closeMenu() { menuEl.classList.add("hidden"); }
document.addEventListener("click", closeMenu);
document.addEventListener("keydown", (e) => { if (e.key === "Escape") closeMenu(); });
// 树空白处右键 = 建根组
treeEl.oncontextmenu = (e) => {
if (e.target.closest(".g-node")) return;
e.preventDefault();
openMenu(e.clientX, e.clientY, null);
};
function openMenu(x, y, target) {
// 已归档组:只给「恢复」—— 归档态下不允许建子组/加成员/改名(后端亦 404 兜底)。
const items = target === null
? [{ label: "新建根 Group", ic: "plus", fn: () => createDialog(null, null) }]
: target.archived
? [
{ label: "查看成员(只读)", ic: "users", fn: () => { selectGroup(target); loadMembers(); } },
{ label: "恢复此 Group", ic: "restore", fn: () => restoreGroup(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
]
: [
{ label: "新建子 Group", ic: "plus", fn: () => createDialog(target.id, target.name) },
{ label: "添加成员", ic: "user", fn: () => { selectGroup(target); addMemberDialog(); } },
{ label: "重命名 / 改描述", ic: "pencil", fn: () => renameDialog(target) },
{ sep: true },
{ label: "新建根 Group", ic: "layers", fn: () => createDialog(null, null) },
{ label: "删除(级联子树)", ic: "trash", danger: true, fn: () => removeGroup(target) },
];
menuEl.innerHTML = items.map((it, i) =>
it.sep
? '<div style="height:1px;background:var(--border-soft);margin:4px 6px"></div>'
: '<div data-mi="' + i + '" style="display:flex;align-items:center;gap:8px;padding:7px 11px;border-radius:6px;cursor:pointer' +
(it.danger ? ";color:var(--danger)" : "") + '">' +
'<span style="display:flex;opacity:.75">' + ico(it.ic, 14) + "</span>" + esc(it.label) + "</div>").join("");
menuEl.querySelectorAll("[data-mi]").forEach((el) => {
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
el.onmouseleave = () => { el.style.background = ""; };
el.onclick = (e) => { e.stopPropagation(); closeMenu(); items[Number(el.dataset.mi)].fn(); };
});
menuEl.classList.remove("hidden");
// 贴边翻转,避免菜单溢出视口。
const r = menuEl.getBoundingClientRect();
menuEl.style.left = Math.min(x, window.innerWidth - r.width - 8) + "px";
menuEl.style.top = Math.min(y, window.innerHeight - r.height - 8) + "px";
}
/* ---------------- 弹窗:建组 / 改名 ---------------- */
function modal(html, width) {
const mask = document.createElement("div");
mask.className = "modal-mask";
mask.innerHTML = '<div class="modal-card"' +
(width ? ' style="max-width:' + width + 'px"' : "") + ">" + html + "</div>";
mask.onclick = (e) => { if (e.target === mask) mask.remove(); };
document.body.appendChild(mask);
const input = mask.querySelector("input");
if (input) input.focus();
return mask;
}
function createDialog(parentId, parentName) {
const m = modal(
'<div class="modal-title">' + (parentId ? "在「" + esc(parentName) + "」下新建子 Group" : "新建根 Group") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label><input id="gc-name" class="input" placeholder="例如:物理教研组"/></div>' +
'<div class="form-row"><label class="form-label">描述(可选)</label><input id="gc-desc" class="input" placeholder="一句话说明"/></div>' +
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>创建</button></div>'
);
m.querySelector("[data-x]").onclick = () => m.remove();
const submit = async () => {
const name = m.querySelector("#gc-name").value.trim();
if (!name) return alert("名称必填");
const description = m.querySelector("#gc-desc").value.trim();
try {
await req("", { method: "POST", body: { name, parentId, ...(description ? { description } : {}) } });
m.remove();
if (parentId) collapsed.delete(parentId); // 建完自动展开父节点
await loadTree();
} catch (e) { alert(e.message); }
};
m.querySelector("[data-ok]").onclick = submit;
m.querySelector("#gc-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
}
function renameDialog(target) {
const cur = groups.find((g) => g.id === target.id);
const m = modal(
'<div class="modal-title">重命名 / 改描述</div>' +
'<div class="form-row"><label class="form-label">名称</label><input id="gr-name" class="input" value="' + esc(target.name) + '"/></div>' +
'<div class="form-row"><label class="form-label">描述</label><input id="gr-desc" class="input" value="' +
esc((cur && cur.description) || "") + '" placeholder="留空则清除描述"/></div>' +
'<div class="modal-actions"><button class="btn" data-x>取消</button><button class="btn btn-primary" data-ok>保存</button></div>'
);
m.querySelector("[data-x]").onclick = () => m.remove();
const submit = async () => {
const name = m.querySelector("#gr-name").value.trim();
if (!name) return alert("名称必填");
try {
// description 总是回传(含空串)——空串即清除描述。
await req("/" + encodeURIComponent(target.id), {
method: "PATCH",
body: { name, description: m.querySelector("#gr-desc").value.trim() },
});
m.remove();
if (selected && selected.id === target.id) selected.name = name;
await loadTree();
if (selected && selected.id === target.id) loadMembers();
} catch (e) { alert(e.message); }
};
m.querySelector("[data-ok]").onclick = submit;
m.querySelector("#gr-name").onkeydown = (e) => { if (e.key === "Enter") submit(); };
}
async function restoreGroup(target) {
// 恢复语义与删除不对称(决策7):只回该组 + 已归档祖先链,子树仍归档。
if (!confirm("恢复「" + target.name + "」?\\n\\n其已删除的上级会一并恢复(否则它在树上无路径);" +
"子组保持删除状态,需各自恢复。恢复后该组的授权立即重新生效。")) return;
try {
const r = await req("/" + encodeURIComponent(target.id) + "/restore", { method: "POST" });
await loadTree();
if (selected && selected.id === target.id) loadMembers();
alert("已恢复 " + r.restoredCount + " 个组");
} catch (e) { alert(e.message); }
}
async function removeGroup(target) {
if (!confirm("删除「" + target.name + "」?\\n\\n软删除:整棵子树一并标记删除,相关授权立即失效," +
"但数据保留 —— 可在左侧打开「显示已删除的组」后恢复。")) return;
try {
const r = await req("/" + encodeURIComponent(target.id), { method: "DELETE" });
if (selected && selected.id === target.id) {
selected = null;
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
}
await loadTree();
alert("已删除 " + r.archivedCount + " 个组(软删除,可恢复)");
} catch (e) { alert(e.message); }
}
/* ---------------- 成员面板 ---------------- */
let members = []; // 当前组的成员
let memberFilter = ""; // 成员表内过滤(显示名/userId/openId)
function selectGroup(target) {
selected = { id: target.id, name: target.name };
memberFilter = "";
renderTree();
}
/** 组头部 + 成员表(表格列:成员 / userId / 飞书 openId / 加入时间 / 操作)。 */
function renderMembers() {
if (!selected) return;
const g = groups.find((x) => x.id === selected.id);
const q = memberFilter.trim().toLowerCase();
const shown = q === ""
? members
: members.filter((m) =>
(m.displayName || "").toLowerCase().includes(q) ||
m.userId.toLowerCase().includes(q) ||
(m.feishuOpenId || "").toLowerCase().includes(q));
// 面包屑:祖先链(根在前)。
const byId = new Map(groups.map((x) => [x.id, x]));
const chain = [];
for (let cur = g; cur; cur = cur.parentId ? byId.get(cur.parentId) : null) chain.unshift(cur);
const crumb = chain.map((c, i) =>
(i > 0 ? '<span style="color:var(--text-3);margin:0 5px">/</span>' : "") +
(i === chain.length - 1
? '<span style="color:var(--text);font-weight:500">' + esc(c.name) + "</span>"
: '<span style="color:var(--text-3)">' + esc(c.name) + "</span>")).join("");
const isArchived = !!(g && g.archivedAt);
detailEl.innerHTML =
// 归档横幅:软删除是"打标",数据仍在,只是不再贡献权限。
(isArchived
? '<div style="display:flex;align-items:center;gap:9px;padding:10px 18px;flex-shrink:0;' +
'background:var(--hover);border-bottom:1px solid var(--border-soft);font-size:12.5px">' +
'<span style="display:flex;color:var(--text-3)">' + ico("archive", 15) + "</span>" +
'<span style="flex:1">此 Group 已删除于 ' +
esc(fmtDate(g.archivedAt)) + " · 成员只读,不再授予任何权限</span>" +
'<button id="gm-restore" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("restore", 13) + "恢复</button>" +
"</div>"
: "") +
// 头部
'<div style="padding:16px 18px 12px;border-bottom:1px solid var(--border-soft);flex-shrink:0">' +
'<div style="font-size:12px;margin-bottom:6px">' + crumb + "</div>" +
'<div style="display:flex;align-items:center;gap:10px">' +
'<span style="display:flex;color:' + (isArchived ? "var(--text-3)" : "var(--accent)") + '">' +
ico(isArchived ? "archive" : "group", 20) + "</span>" +
'<div style="flex:1;min-width:0">' +
'<div style="font-size:16px;font-weight:600;color:' + (isArchived ? "var(--text-3)" : "var(--text)") + '">' +
esc(selected.name) + "</div>" +
(g && g.description
? '<div class="section-note" style="margin-top:2px">' + esc(g.description) + "</div>"
: '<div class="section-note" style="margin-top:2px;opacity:.6">无描述</div>') +
"</div>" +
// 归档态不给改名/加成员入口(后端 requireActiveGroup 亦 404 兜底)。
(isArchived
? ""
: '<button id="gm-rename" class="btn" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("pencil", 13) + "编辑</button>" +
'<button id="gm-add" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:5px;font-size:12px">' +
ico("plus", 13) + "添加成员</button>") +
"</div>" +
// 统计条
'<div style="display:flex;gap:16px;margin-top:12px;font-size:12px;color:var(--text-3)">' +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("user", 12) + members.length + " 名成员</span>" +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("layers", 12) + "层级 " + ((g && g.depth) || 0) + "</span>" +
'<span style="display:inline-flex;align-items:center;gap:4px">' + ico("group", 12) +
groups.filter((x) => x.parentId === selected.id).length + " 个子组</span>" +
"</div>" +
"</div>" +
// 成员表工具条
'<div style="padding:10px 18px;flex-shrink:0;display:flex;align-items:center;gap:10px">' +
'<div style="position:relative;flex:1;max-width:280px">' +
'<span style="position:absolute;left:9px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
ico("search", 13) + "</span>" +
'<input id="gm-filter" class="input" placeholder="搜索成员…" style="padding-left:28px;font-size:12.5px"' +
' value="' + esc(memberFilter) + '"/>' +
"</div>" +
'<span class="file-meta">' + (q === "" ? "" : shown.length + " / " + members.length) + "</span>" +
"</div>" +
// 成员表
'<div style="flex:1;overflow-y:auto;padding:0 18px 18px;min-height:0">' +
(members.length === 0
? '<div class="quiet" style="text-align:center;padding:36px 12px;display:flex;flex-direction:column;align-items:center;gap:10px">' +
'<span style="color:var(--border);display:flex">' + ico("users", 34) + "</span>" +
(isArchived ? "此组无成员记录" : "此组暂无成员 · 点右上「添加成员」") + "</div>"
: shown.length === 0
? '<div class="quiet" style="text-align:center;padding:30px 12px">无匹配成员</div>'
: '<table class="list" style="width:100%">' +
"<thead><tr>" +
"<th>成员</th><th>userId</th><th>飞书 openId</th><th>加入时间</th>" +
(isArchived ? "" : "<th style=\\"text-align:right\\">操作</th>") +
"</tr></thead><tbody>" +
shown.map((m) =>
"<tr>" +
'<td><span style="display:inline-flex;align-items:center;gap:9px">' + avatar(m, 28) +
'<span style="font-weight:500">' + esc(m.displayName || "(未命名)") + "</span></span></td>" +
'<td class="file-meta">' + esc(m.userId) + "</td>" +
'<td class="file-meta">' + esc(m.feishuOpenId || "—") + "</td>" +
'<td class="file-meta">' + esc(fmtDate(m.joinedAt)) + "</td>" +
(isArchived
? ""
: '<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '"' +
' style="display:inline-flex;align-items:center;gap:4px">' + ico("minus", 12) + "移除</button></td>") +
"</tr>").join("") +
"</tbody></table>") +
"</div>";
// 归档态下这些按钮不渲染,故逐个判空。
const addBtn = detailEl.querySelector("#gm-add");
if (addBtn) addBtn.onclick = addMemberDialog;
const renameBtn = detailEl.querySelector("#gm-rename");
if (renameBtn) renameBtn.onclick = () => renameDialog(selected);
const restoreBtn = detailEl.querySelector("#gm-restore");
if (restoreBtn) {
restoreBtn.onclick = () => restoreGroup({ id: selected.id, name: selected.name, archived: true });
}
const f = detailEl.querySelector("#gm-filter");
if (f) {
f.oninput = () => { memberFilter = f.value; renderMembers(); detailEl.querySelector("#gm-filter").focus(); };
}
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
btn.onclick = async () => {
const uid = btn.dataset.revoke;
const who = (members.find((m) => m.userId === uid) || {}).displayName || uid;
if (!confirm("将「" + who + "」从「" + selected.name + "」移除?")) return;
try {
await req("/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(uid), { method: "DELETE" });
await Promise.all([loadTree(), loadMembers()]);
} catch (e) { alert(e.message); }
};
});
}
async function loadMembers() {
if (!selected) return;
// 归档组的成员照样拉取展示(只读)—— 软删是打标,成员行仍在库里。
detailEl.innerHTML = '<div class="quiet" style="margin:auto;padding:18px;text-align:center">加载中…</div>';
try {
const r = await req("/" + encodeURIComponent(selected.id) + "/members");
members = r.members;
renderMembers();
} catch (e) {
detailEl.innerHTML = '<div style="color:var(--danger);padding:16px">' + esc(e.message) + "</div>";
}
}
/* ---------------- 添加成员弹窗(搜索选择) ---------------- */
function addMemberDialog() {
if (!selected) return;
const m = modal(
'<div class="modal-title" style="display:flex;align-items:center;gap:7px">' +
'<span style="display:flex;color:var(--accent)">' + ico("user", 16) + "</span>" +
"添加成员到「" + esc(selected.name) + "」</div>" +
'<div style="position:relative;margin-bottom:10px">' +
'<span style="position:absolute;left:10px;top:50%;transform:translateY(-50%);color:var(--text-3);display:flex">' +
ico("search", 14) + "</span>" +
'<input id="ga-q" class="input" placeholder="搜索用户显示名 / 飞书 openId…" style="padding-left:30px"/>' +
"</div>" +
'<div id="ga-list" style="max-height:290px;overflow-y:auto;margin:0 -4px"></div>' +
'<div class="section-note" id="ga-note" style="margin-top:10px">已在本组的用户不会出现在结果里</div>' +
'<div class="modal-actions"><button class="btn" data-x>关闭</button></div>',
520
);
m.querySelector("[data-x]").onclick = () => m.remove();
const qEl = m.querySelector("#ga-q");
const listEl = m.querySelector("#ga-list");
const noteEl = m.querySelector("#ga-note");
let seq = 0;
async function search() {
const mine = ++seq;
listEl.innerHTML = '<div class="quiet" style="padding:16px;text-align:center">搜索中…</div>';
try {
const url = "/database/api/users/search?q=" + encodeURIComponent(qEl.value.trim()) +
"&excludeGroupId=" + encodeURIComponent(selected.id);
const res = await fetch(url, { credentials: "same-origin" });
if (res.status === 401) { location.href = "/database/admin"; return; }
const data = await res.json();
if (mine !== seq) return; // 丢弃过期响应
if (!res.ok) throw new Error((data.error && data.error.message) || res.statusText);
render(data.users);
} catch (e) {
if (mine === seq) listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
}
}
function render(users) {
if (users.length === 0) {
listEl.innerHTML = '<div class="quiet" style="padding:20px;text-align:center">无匹配用户</div>';
return;
}
listEl.innerHTML = users.map((u, i) =>
'<div data-ui="' + i + '" style="display:flex;align-items:center;gap:10px;padding:8px 10px;border-radius:8px;cursor:pointer">' +
avatar(u, 30) +
'<span style="flex:1;min-width:0">' +
'<span style="display:block;font-size:13px;font-weight:500;color:var(--text)">' + esc(u.displayName || "(未命名)") + "</span>" +
'<span class="file-meta" style="display:block;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
esc(u.feishuOpenId) + "</span>" +
"</span>" +
'<span class="btn" style="flex-shrink:0;font-size:11.5px;padding:3px 9px;display:inline-flex;align-items:center;gap:4px">' +
ico("plus", 12) + "添加</span>" +
"</div>").join("");
listEl.querySelectorAll("[data-ui]").forEach((el) => {
el.onmouseenter = () => { el.style.background = "var(--hover)"; };
el.onmouseleave = () => { el.style.background = ""; };
el.onclick = async () => {
const u = users[Number(el.dataset.ui)];
el.style.pointerEvents = "none";
el.style.opacity = ".5";
try {
await req("/" + encodeURIComponent(selected.id) + "/members", {
method: "POST", body: { userId: u.userId },
});
noteEl.textContent = "已添加 " + (u.displayName || u.userId);
noteEl.style.color = "var(--accent)";
await Promise.all([loadTree(), loadMembers()]);
await search(); // 刷新候选(已加的会被排除)
} catch (e) {
alert(e.message);
el.style.pointerEvents = "";
el.style.opacity = "";
}
};
});
}
let timer = null;
qEl.oninput = () => { clearTimeout(timer); timer = setTimeout(search, 200); };
search(); // 打开即列出候选
}
/* ---------------- 初始化 ---------------- */
async function loadTree() {
const r = await req(showArchived ? "?includeArchived=1" : "");
groups = r.groups;
// 选中项可能已被级联归档 → 清空右栏(showArchived 下归档组仍在列表里,不清)。
if (selected && !groups.some((g) => g.id === selected.id)) {
selected = null;
detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>';
}
renderTree();
}
document.getElementById("g-new-root").onclick = () => createDialog(null, null);
const filterEl = document.getElementById("g-filter");
filterEl.oninput = () => { filterText = filterEl.value; renderTree(); };
const archEl = document.getElementById("g-show-archived");
archEl.onchange = () => {
showArchived = archEl.checked;
loadTree().catch((e) => alert(e.message));
};
loadTree().catch((e) => {
treeEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>";
});
})();
</script>`;
}
+35 -215
View File
@@ -5,11 +5,12 @@
* Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the * Handlers use ABSOLUTE paths (no Fastify prefix) so every route greps as the
* literal string it serves. * literal string it serves.
* *
* The login page and dashboard are now served by the `database-admin` SvelteKit * 标准前后端分离:本文件**不渲染任何 HTML**。登录页与管理后台六个 tab 全部由
* SPA (see ../static.ts / registerDatabaseSpa). This file keeps only the * `hub/filelib-web` 这一个 SvelteKit SPA 提供(同一份产物挂 /app 与 /database,
* concrete JSON/redirect routes the SPA depends on: * 见 ../static.ts / registerDatabaseSpa)。此处只留 SPA 依赖的 JSON/跳转端点:
* *
* /database/config — unauthenticated bootstrap: silo org slug + dev toggle * /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 * /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 * The dev bypass (/database/dev-login) is self-contained here and gated by
@@ -23,19 +24,17 @@
import type { FastifyInstance } from "fastify"; import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import path from "node:path"; 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 { registerFileLibRoutes } from "./filelibRoutes.js";
import { registerFileRoutes } from "./fileRoutes.js"; import { registerFileRoutes } from "./fileRoutes.js";
import { registerMemberGroupRoutes } from "./memberGroupRoutes.js"; import { registerMemberGroupRoutes } from "./memberGroupRoutes.js";
import { registerTeacherApp } from "./teacherApp.js"; import { registerTeacherApp } from "./teacherApp.js";
import { renderLibraryBrowser } from "./libraryBrowser.js";
import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js";
import { createInMemoryVersionStore } from "../filelib/versionStore.js"; import { createInMemoryVersionStore } from "../filelib/versionStore.js";
import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js"; import { createMemberGroupResolver } from "../filelib/memberGroupResolver.js";
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js"; import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
import { createManifestStubAdapter } from "../filelib/exportService.js"; import { createManifestStubAdapter } from "../filelib/exportService.js";
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.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"; import type { FileLibRouteDeps } from "../filelib/routeShared.js";
export interface DatabaseRouteConfig { export interface DatabaseRouteConfig {
@@ -55,19 +54,30 @@ export async function registerDatabaseRoutes(
// 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。 // 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。
let filelibDepsForStats: FileLibRouteDeps | null = null; let filelibDepsForStats: FileLibRouteDeps | null = null;
app.get("/database/admin", async (request, reply) => { // 登录页/前端 bootstrap(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
// Already signed in → straight to the dashboard. // 与老师端 /database/api/login-info 同形状 —— 后者由 teacherApp.ts 注册,
if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) { // 两处并存是为了兼容既有前端调用点。
return reply.redirect("/database/dashboard"); app.get("/database/config", async () => ({
} orgSlug: config.siloOrganizationSlug,
return reply.type("text/html").send(renderLoginPage(config)); devLoginEnabled: config.allowDevLoginBypass,
}); }));
app.get("/database/dashboard", async (request, reply) => { // 概览页统计。登录 + silo org OWNER/ADMIN 才给 —— 它聚合的是全 org 口径的
const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config); // 计数与审计流,不是某个节点的授权视图,所以不走 per-node 的 role 判定。
if (user === null) return reply.redirect("/database/admin"); app.get("/database/api/stats", async (request, reply) => {
const stats = await loadDashboardStats(config.prisma, filelibDepsForStats); if (filelibDepsForStats === null) {
return reply.type("text/html").send(renderDashboard(user.displayName, stats, config.siloOrganizationSlug)); 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 // 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 包到位后替换); // 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
// GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028), // GroupResolver 默认读 in-hub MemberGroup 闭包(ADR-0028),
// HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2); // HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
@@ -158,9 +168,10 @@ export async function registerDatabaseRoutes(
allowDevLoginBypass: config.allowDevLoginBypass, allowDevLoginBypass: config.allowDevLoginBypass,
}); });
// 独立文件库页已并入后台「文件库」tab(/database/dashboard#library),旧地址跳转保留兼容。 // 独立文件库页已并入后台「文件库」tab;旧地址跳转保留兼容。
// SPA 化后目标是真路由(不再是 #library 锚点)。
app.get("/database/library", async (_request, reply) => app.get("/database/library", async (_request, reply) =>
reply.redirect("/database/dashboard#library"), reply.redirect("/database/dashboard/library"),
); );
filelibDepsForStats = filelibDeps; filelibDepsForStats = filelibDeps;
@@ -184,9 +195,8 @@ interface DashboardStats {
/** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */ /** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */
async function loadDashboardStats( async function loadDashboardStats(
prisma: PrismaClient, prisma: PrismaClient,
deps: FileLibRouteDeps | null, deps: FileLibRouteDeps,
): Promise<DashboardStats | null> { ): Promise<DashboardStats> {
if (deps === null) return null;
const organizationId = deps.organizationId; const organizationId = deps.organizationId;
const [folders, projects, grants] = await Promise.all([ const [folders, projects, grants] = await Promise.all([
prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }), prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }),
@@ -234,193 +244,3 @@ async function loadDashboardStats(
}); });
return { folders, projects, files, grants, recent }; return { folders, projects, files, grants, recent };
} }
/** Verify the session cookie and load the user, or null if not signed in. */
async function resolveUser(
rawCookie: string | undefined,
config: DatabaseRouteConfig,
): Promise<{ displayName: string } | null> {
if (rawCookie === undefined || rawCookie === "") return null;
const session = verifySession(rawCookie, config.sessionSecret);
if (session === null) return null;
const user = await config.prisma.user.findUnique({
where: { id: session.userId },
select: { displayName: true },
});
return user;
}
/** 管理后台共享 head(全局 UI 主题,与老师端 /app 同源)。 */
function pageHead(title: string): string {
return `<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>${title}</title>
${UI_HEAD_FONTS}
<style>${UI_THEME_CSS}</style>
</head>`;
}
function renderLoginPage(config: DatabaseRouteConfig): string {
const feishuHref = `/auth/feishu/${encodeURIComponent(config.siloOrganizationSlug)}`;
const devButton = config.allowDevLoginBypass
? `<div style="display:flex;align-items:center;gap:10px;margin:22px 0;color:var(--text-3);font-size:11px">
<span style="flex:1;border-top:1px solid var(--border-soft)"></span>开发模式
<span style="flex:1;border-top:1px solid var(--border-soft)"></span>
</div>
<a href="/database/dev-login" class="btn" style="width:100%;justify-content:center">⚡ 一键登录管理员</a>
<p style="margin:10px 0 0;text-align:center;font-size:11px;color:var(--text-3)">仅开发环境可见 · 跳过飞书 OAuth</p>`
: "";
return `<!doctype html>
<html lang="zh-CN">
${pageHead("Database Admin · 登录")}
<body>
<div style="min-height:100vh;display:flex;align-items:center;justify-content:center;padding:24px">
<div style="width:100%;max-width:380px;background:var(--panel);border:1px solid var(--border-soft);border-radius:16px;padding:40px 36px;box-shadow:var(--shadow-pop)">
<div style="font-size:26px;font-weight:600;text-align:center">Database Admin</div>
<p style="margin:10px 0 30px;text-align:center;font-size:13px;color:var(--text-3)">使用飞书登录以管理数据库</p>
<a href="${feishuHref}" class="btn btn-primary" style="width:100%;justify-content:center;padding:11px 16px;font-size:14px">使用飞书登录</a>
${devButton}
</div>
</div>
</body>
</html>`;
}
/** Sidebar nav items. `active` marks the current page. `href` "#" = placeholder. */
const NAV_TABS: ReadonlyArray<{ id: string; label: string; icon: string }> = [
{ id: "overview", label: "概览", icon: "M4 13h6V4H4v9Zm0 7h6v-5H4v5Zm10 0h6V11h-6v9Zm0-16v5h6V4h-6Z" },
{ id: "library", label: "文件库", icon: "M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z" },
{ id: "users", label: "用户管理", icon: "M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm13 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75" },
{ id: "groups", label: "Group 管理", icon: "M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2M9 11a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm14 10v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75M23 21v-2a4 4 0 0 0-3-3.87" },
{ id: "search", label: "查询", icon: "m21 21-4.3-4.3M11 18a7 7 0 1 0 0-14 7 7 0 0 0 0 14Z" },
{ id: "settings", label: "设置", icon: "M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6Zm7-3 2 1-2 3-2-1a7 7 0 0 1-2 1l-1 2h-4l-1-2a7 7 0 0 1-2-1l-2 1-2-3 2-1a7 7 0 0 1 0-2l-2-1 2-3 2 1a7 7 0 0 1 2 1l1-2h4l1 2a7 7 0 0 1 0 2l2-1 2 3-2 1a7 7 0 0 1 0 2Z" },
];
function renderDashboard(displayName: string, stats: DashboardStats | null, orgSlug: string): string {
const nav = NAV_TABS.map((t) => `
<button class="admin-tab" data-tab="${t.id}" style="display:flex;align-items:center;gap:10px;border-radius:10px;padding:9px 14px;font-size:13px;color:var(--text-3);background:none;border:none;cursor:pointer;text-align:left;width:100%">
<svg style="width:16px;height:16px;flex-shrink:0" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="${t.icon}"/></svg>
${t.label}
</button>`).join("\n");
const cards = [
{ label: "文件夹", value: stats?.folders ?? "—" },
{ label: "项目", value: stats?.projects ?? "—" },
{ label: "文件", value: stats?.files ?? "—" },
{ label: "活跃授权", value: stats?.grants ?? "—" },
].map((s) => `
<div class="panel" style="padding:18px 20px">
<p style="font-size:12.5px;color:var(--text-3)">${s.label}</p>
<p style="margin-top:6px;font-size:28px;font-weight:600;color:var(--text)">${s.value}</p>
</div>`).join("");
const recentRows = stats === null || stats.recent.length === 0
? `<div style="padding:26px 0;text-align:center;font-size:12.5px;color:var(--text-3)">暂无文件库活动 · 到「文件库」里创建第一个文件夹吧</div>`
: stats.recent.map((r) => `
<div style="display:flex;align-items:center;gap:12px;border-top:1px solid var(--border-soft);padding:9px 0;font-size:13px">
<span class="tag">${escapeHtml(r.action)}</span>
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">${escapeHtml(r.label)}</span>
<span style="margin-left:auto;flex-shrink:0;font-size:11.5px;color:var(--text-3)">${escapeHtml(r.actor)} · ${escapeHtml(r.when.toLocaleString("zh-CN"))}</span>
</div>`).join("");
const initial = escapeHtml(displayName.slice(0, 1) || "U");
return `<!doctype html>
<html lang="zh-CN">
${pageHead("Database Admin")}
<body>
<div style="display:flex;height:100vh">
<aside style="width:240px;flex-shrink:0;background:var(--sidebar);border-right:1px solid var(--border-soft);display:flex;flex-direction:column">
<div style="padding:16px 16px 12px;border-bottom:1px solid var(--border-soft)">
<span style="font-size:15px;font-weight:600">Database Admin</span>
</div>
<nav style="flex:1;display:flex;flex-direction:column;gap:2px;padding:10px">
${nav}
</nav>
<div style="margin:10px;padding:10px 12px;border-top:1px solid var(--border-soft);display:flex;align-items:center;gap:9px">
<div style="width:26px;height:26px;border-radius:50%;background:var(--accent);color:#fff;display:flex;align-items:center;justify-content:center;font-size:11px;font-weight:600;flex-shrink:0">${initial}</div>
<div style="min-width:0;flex:1">
<p style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--text)">${escapeHtml(displayName)}</p>
</div>
<button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button>
</div>
</aside>
<div style="flex:1;display:flex;flex-direction:column;min-width:0">
<section id="tab-overview" class="admin-tab-section" style="flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">概览</h1>
<p style="font-size:11.5px;color:var(--text-3);margin-bottom:20px">文件库实时数据</p>
<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:16px">
${cards}
</div>
<div class="panel" style="margin-top:18px">
<h2 style="font-size:13.5px;font-weight:600;margin-bottom:8px">最近活动</h2>
${recentRows}
</div>
</section>
<section id="tab-library" class="admin-tab-section" style="display:none;flex:1;min-height:0">
${renderLibraryBrowser()}
</section>
<section id="tab-users" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:16px">用户管理</h1>
${renderUsersPanel(orgSlug)}
</section>
<section id="tab-groups" class="admin-tab-section" style="display:none;flex:1;overflow:hidden;padding:20px">
${renderGroupsPanel()}
</section>
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">查询</h1>
<p style="font-size:12.5px;color:var(--text-3)">查询功能建设中</p>
</section>
<section id="tab-settings" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">设置</h1>
<p style="font-size:12.5px;color:var(--text-3)">设置功能建设中</p>
</section>
</div>
</div>
<script>
(function () {
const tabs = [...document.querySelectorAll(".admin-tab")];
const sections = Object.fromEntries(
[...document.querySelectorAll(".admin-tab-section")].map((s) => [s.id.replace("tab-", ""), s]),
);
function activate(id) {
tabs.forEach((t) => {
const on = t.dataset.tab === id;
t.style.background = on ? "var(--selected)" : "none";
t.style.color = on ? "var(--text)" : "var(--text-3)";
t.style.fontWeight = on ? "600" : "400";
});
Object.entries(sections).forEach(([key, s]) => {
s.style.display = key === id ? (key === "library" ? "block" : "block") : "none";
});
if (location.hash !== "#" + id) history.replaceState(null, "", "#" + id);
}
tabs.forEach((t) => t.addEventListener("click", () => activate(t.dataset.tab)));
document.getElementById("logout").addEventListener("click", async () => {
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch (e) {}
location.href = "/database/admin";
});
const fromHash = location.hash.replace(/^#/, "");
activate(tabs.some((t) => t.dataset.tab === fromHash) ? fromHash : "overview");
})();
</script>
</body>
</html>`;
}
function escapeHtml(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;");
}
-597
View File
@@ -1,597 +0,0 @@
/**
* 管理员后台「文件库」页内浏览器区块(树 | 内容 | 预览三栏,含授权管理)。
*
* renderLibraryBrowser() 返回可嵌入任意后台布局的 HTML+JS 片段:
* 以 #lib-root 为根、内部用 q() 作用域选择器,不与宿主页其他元素冲突。
* 与独立页版浏览器的区别:去掉了自己的 wordmark/用户栏(由后台外壳提供),
* 保留管理员工具(根目录创建、授权面板、独立权限开关)。
*/
export function renderLibraryBrowser(): string {
return `
<div id="lib-root" style="display:flex;height:100%;min-height:0">
<!-- 左:目录树 -->
<div style="width:250px;flex-shrink:0;border-right:1px solid var(--border-soft);display:flex;flex-direction:column;background:var(--sidebar)">
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--border-soft)">
<span style="font-size:13px;font-weight:600;color:var(--text)">文件库</span>
<button id="lib-btn-new-root" class="btn hidden" style="padding:3px 10px;font-size:11px" title="新建根目录">+ 根目录</button>
</div>
<div id="lib-tree" style="flex:1;overflow-y:auto;padding:8px 8px 16px;font-size:13px"></div>
</div>
<!-- 中:节点内容 -->
<div id="lib-main" style="flex:1;overflow-y:auto;min-width:0">
<div class="lib-empty-hint">从左侧选择一个文件夹或项目</div>
</div>
<!-- 右:预览/编辑(选中文件时出现) -->
<div id="lib-preview" style="display:none;width:44%;min-width:380px;flex-shrink:0;overflow-y:auto;border-left:1px solid var(--border-soft);padding:16px"></div>
<div id="lib-modal-root"></div>
<div id="lib-toast-root" style="position:fixed;bottom:16px;right:16px;z-index:50;display:flex;flex-direction:column;gap:8px"></div>
</div>
<style>
#lib-root .lib-empty-hint { height:100%;display:flex;align-items:center;justify-content:center;color:var(--text-3);font-size:13px; }
#lib-root .tree-item { display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:6px;cursor:pointer;color:var(--text);transition:background 120ms ease;user-select:none; }
#lib-root .tree-item:hover { background:var(--hover); }
#lib-root .tree-item.selected { background:var(--selected); }
#lib-root .tree-children { border-left:1px solid var(--guide);margin-left:15px;padding-left:3px; }
#lib-root .tree-icon { width:16px;height:16px;flex-shrink:0;display:flex;align-items:center;justify-content:center; }
#lib-root .tree-caret { color:var(--text-3); }
#lib-root .tree-badge { margin-left:auto;padding-right:4px;font-size:10px;color:var(--text-3);font-family:var(--mono); }
#lib-root .tree-empty { padding:24px 12px;text-align:center;font-size:12px;color:var(--text-3); }
#lib-root .lib-toast { border-radius:8px;padding:8px 14px;font-size:12px;color:#fff;background:#333230;max-width:320px; }
#lib-root .lib-toast-err { background:#7E2C26; }
#lib-root .modal-mask { position:fixed;inset:0;z-index:40;background:rgba(26,26,24,.3);display:flex;align-items:center;justify-content:center;padding:16px; }
#lib-root .modal-card { width:100%;max-width:440px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:22px; }
#lib-root pre.diff { white-space:pre-wrap;font-family:var(--mono);font-size:12px;line-height:1.7;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin:0; }
#lib-root pre.diff .add { display:block;color:var(--diff-add-text);background:var(--diff-add-bg); }
#lib-root pre.diff .del { display:block;color:var(--diff-del-text);background:var(--diff-del-bg); }
#lib-root .meta-grid { display:grid;grid-template-columns:1fr 1fr;gap:10px 24px;font-size:13px;color:var(--text-2); }
#lib-root .meta-grid b { color:var(--text);font-weight:600; }
#lib-root .conflict-panel { margin-top:14px;border:1px solid #E8E2C8;background:#FCFBF4;border-radius:10px;padding:14px 16px; }
#lib-root .conflict-title { font-size:13px;font-weight:600;color:#6E6329;margin-bottom:8px; }
#lib-root .conflict-note { font-size:11px;color:#8A8059;margin-top:8px; }
#lib-root .export-status { font-size:11px;color:var(--text-3);font-family:var(--mono); }
</style>
<script>
(function () {
const libRoot = document.getElementById("lib-root");
if (!libRoot) return;
const $ = (sel) => libRoot.querySelector(sel);
const $$ = (sel) => [...libRoot.querySelectorAll(sel)];
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = (data && data.error) || { code: "unknown", message: res.statusText };
const e = new Error(err.message); e.code = err.code; e.status = res.status; Object.assign(e, err);
throw e;
}
return data;
}
function toast(msg, kind = "info") {
const el = document.createElement("div");
el.className = "lib-toast" + (kind === "err" ? " lib-toast-err" : "");
el.textContent = msg;
$("#lib-toast-root").appendChild(el);
setTimeout(() => el.remove(), 3600);
}
const ok = (m) => toast(m, "ok");
const fail = (e) => toast(e.message || String(e), "err");
function modal(html) {
const root = $("#lib-modal-root");
root.innerHTML = '<div class="modal-mask"><div class="modal-card">' + html + "</div></div>";
root.firstElementChild.addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
}
function closeModal() { $("#lib-modal-root").innerHTML = ""; }
let me = { userId: null, isWebsiteAdmin: false };
let current = null;
const expanded = new Set();
let currentFile = null;
const ICONS = {
caretRight: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M9 6l6 6-6 6z"/></svg>',
caretDown: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M6 9l6 6 6-6z"/></svg>',
folder: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></svg>',
project: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>',
};
async function loadChildren(parentId) {
const q = parentId === null ? "" : "?parentId=" + encodeURIComponent(parentId);
return (await api("/database/api/nodes" + q)).nodes;
}
async function renderTree() {
const tree = $("#lib-tree");
tree.innerHTML = "";
try {
const roots = await loadChildren(null);
if (roots.length === 0) {
tree.innerHTML = '<div class="tree-empty">空文件库' +
(me.isWebsiteAdmin ? " · 点上方「+ 根目录」开始" : "") + "</div>";
return;
}
for (const node of roots) tree.appendChild(await treeItem(node, 0));
} catch (e) { tree.innerHTML = '<div class="tree-empty">' + esc(e.message) + "</div>"; }
}
async function treeItem(node, depth) {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-item" + (current && current.id === node.id ? " selected" : "");
const caret = document.createElement("span");
caret.className = "tree-icon tree-caret";
if (node.kind === "FOLDER") {
caret.innerHTML = expanded.has(node.id) ? ICONS.caretDown : ICONS.caretRight;
caret.onclick = (e) => { e.stopPropagation(); toggleExpand(node.id); };
}
row.appendChild(caret);
const iconSpan = document.createElement("span");
iconSpan.className = "tree-icon";
iconSpan.style.color = node.kind === "PROJECT" ? "#1A1A18" : "#9C9B96";
iconSpan.innerHTML = node.kind === "PROJECT" ? ICONS.project : ICONS.folder;
row.appendChild(iconSpan);
const nameSpan = document.createElement("span");
nameSpan.className = "truncate";
nameSpan.textContent = node.name;
row.appendChild(nameSpan);
if (node.role !== "MANAGE") {
const badge = document.createElement("span");
badge.className = "tree-badge";
badge.textContent = node.role;
row.appendChild(badge);
}
row.onclick = () => {
if (node.kind === "FOLDER") {
if (expanded.has(node.id)) expanded.delete(node.id); else expanded.add(node.id);
}
selectNode(node.id);
};
wrap.appendChild(row);
if (node.kind === "FOLDER" && expanded.has(node.id)) {
wrap.appendChild(await childrenBlock(node.id, depth));
}
return wrap;
}
async function childrenBlock(parentId, depth) {
const block = document.createElement("div");
block.className = "tree-children";
const kids = await loadChildren(parentId);
for (const kid of kids) block.appendChild(await treeItem(kid, depth + 1));
return block;
}
async function toggleExpand(id) {
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
await renderTree();
}
async function selectNode(id) {
try {
const [{ node }, { breadcrumb }] = await Promise.all([
api("/database/api/nodes/" + id),
api("/database/api/nodes/" + id + "/breadcrumb"),
]);
current = node; current.breadcrumb = breadcrumb;
closePreview();
await renderTree();
renderMain();
} catch (e) { fail(e); }
}
function crumbsHtml() {
return current.breadcrumb.map((c) =>
c.name === null ? "<span>…</span>" : "<span>" + esc(c.name) + "</span>"
).join('<span style="margin:0 4px;color:var(--border)">/</span>');
}
function renderMain() {
const n = current;
const canManage = n.role === "MANAGE";
const canEdit = canManage || n.role === "EDIT";
const tabs = [];
tabs.push(["detail", "概览"]);
if (n.kind === "PROJECT") tabs.push(["files", "文件"]);
if (canManage) tabs.push(["grants", "授权"]);
current.tab = current.tab && tabs.some(t => t[0] === current.tab) ? current.tab : "detail";
$("#lib-main").innerHTML =
'<div style="max-width:860px;margin:0 auto;padding:28px 28px 56px">' +
'<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">' + crumbsHtml() + "</div>" +
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">' +
'<div style="font-size:17px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px">' + esc(n.name) +
'<span class="tag">' + n.kind + "</span>" +
'<span class="tag tag-role">' + n.role + "</span>" +
"</div>" +
'<div style="display:flex;gap:6px">' +
(canEdit && n.kind === "FOLDER" ? '<button class="btn" onclick="window.__lib.createChild()">+ 新建子节点</button>' : "") +
(canManage ? '<button class="btn" onclick="window.__lib.renameCurrent()">重命名</button>' : "") +
(canManage ? '<button class="btn btn-danger" onclick="window.__lib.deleteCurrent()">删除</button>' : "") +
"</div>" +
"</div>" +
'<div class="tabs">' +
tabs.map(([id, label]) =>
'<button onclick="window.__lib.switchTab(\\'' + id + '\\')" class="tab' +
(current.tab === id ? " active" : "") + '">' + label + "</button>").join("") +
"</div>" +
'<div id="lib-tab-body"></div>' +
"</div>";
renderTab();
}
function renderTab() {
const body = $("#lib-tab-body");
if (current.tab === "files") return renderFilesTab(body);
if (current.tab === "grants") return renderGrantsTab(body);
let extra = "";
if (current.kind === "PROJECT") {
extra =
'<div class="divider"></div>' +
'<div class="inline-form" style="justify-content:flex-start">' +
'<span class="quiet">独立权限</span>' +
'<b style="font-size:13px">' + (current.independentPermission ? "开启" : "关闭") + "</b>" +
(current.role === "MANAGE"
? '<button class="btn" onclick="window.__lib.toggleIndependent()">' + (current.independentPermission ? "关闭" : "开启") + "</button>"
: "") +
'<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>' +
"</div>" +
'<div class="divider"></div>' +
'<div class="section-title">导出</div>' +
'<div class="inline-form">' +
'<select id="lib-x-target" class="select" style="width:auto"><option value="manifest">manifest(stub)</option></select>' +
'<button class="btn" onclick="window.__lib.submitExport()">开始导出</button>' +
'<span id="lib-x-status" class="export-status"></span>' +
"</div>";
}
body.innerHTML =
'<div class="panel">' +
'<div class="meta-grid">' +
"<div>类型 <b>" + current.kind + "</b></div>" +
"<div>我的权限 <b>" + current.role + "</b></div>" +
"<div>创建时间 " + new Date(current.createdAt).toLocaleString("zh-CN") + "</div>" +
"<div>更新时间 " + new Date(current.updatedAt).toLocaleString("zh-CN") + "</div>" +
"</div>" + extra +
"</div>";
pollExport();
}
/* ---------------- 节点操作 ---------------- */
function createDialog(parentId) {
modal(
'<div class="modal-title">' + (parentId ? "新建子节点" : "新建根目录") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label>' +
'<input id="lib-f-name" class="input" placeholder="例如:物理必修一"/></div>' +
'<div class="form-row"><label class="form-label">类型</label>' +
'<select id="lib-f-kind" class="select">' +
'<option value="FOLDER">文件夹</option><option value="PROJECT">项目(Git 仓库)</option>' +
"</select></div>" +
'<div class="modal-actions">' +
'<button class="btn" onclick="window.__lib.closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="window.__lib.submitCreate(\\'' + (parentId ?? "") + '\\')">创建</button>' +
"</div>"
);
}
async function submitCreate(parentId) {
try {
await api("/database/api/nodes", { method: "POST", body: {
parentId: parentId === "" ? null : parentId,
kind: $("#lib-f-kind").value, name: $("#lib-f-name").value,
}});
closeModal(); ok("已创建");
if (parentId) expanded.add(parentId);
await renderTree();
} catch (e) { fail(e); }
}
/* ---------------- 授权 ---------------- */
async function renderGrantsTab(body) {
try {
const { grants } = await api("/database/api/nodes/" + current.id + "/grants");
const rows = grants.map((g) =>
"<tr>" +
"<td>" + (g.principalType === "USER" ? "👤" : "👥") + " " + esc(g.principalId) +
(g.isCreatorGrant ? ' <span class="quiet">(创建者)</span>' : "") + "</td>" +
'<td class="file-meta">' + g.role + "</td>" +
"<td style='text-align:right'>" + (g.isCreatorGrant ? "" :
'<button class="link-danger" onclick="window.__lib.revokeGrant(\\'' + g.id + '\\')">收回</button>') + "</td>" +
"</tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<table class="list"><thead><tr><th>主体</th><th>级别</th><th></th></tr></thead>' +
"<tbody>" + rows + "</tbody></table>" +
'<div class="divider"></div>' +
'<div class="section-title">新增授权</div>' +
'<div class="inline-form">' +
'<select id="lib-g-type" class="select" style="width:110px" onchange="window.__lib.gTypeChanged()">' +
'<option value="USER">用户</option><option value="GROUP">Group</option></select>' +
'<input id="lib-g-principal" class="input" placeholder="用户 id"/>' +
'<select id="lib-g-group" class="select hidden"></select>' +
'<select id="lib-g-role" class="select" style="width:110px">' +
'<option value="VIEW">VIEW</option><option value="EDIT">EDIT</option><option value="MANAGE">MANAGE</option></select>' +
'<button class="btn btn-primary" onclick="window.__lib.addGrant()">授予</button>' +
"</div>" +
'<div class="section-note">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>' +
"</div>";
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
/* ---------------- 文件 ---------------- */
async function renderFilesTab(body) {
try {
const { files } = await api("/database/api/projects/" + current.id + "/files");
const rows = files.map((f) =>
'<tr class="file-row" onclick="window.__lib.openFile(\\'' + esc(f.path) + '\\')">' +
'<td class="file-path">' + esc(f.path) + "</td>" +
'<td class="file-meta" style="text-align:right">' + f.size + " B</td></tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:6px">' +
'<div class="section-title" style="margin:0">项目文件(' + files.length + ")</div>" +
(current.role !== "VIEW"
? '<div class="inline-form" style="gap:6px">' +
'<button class="btn" onclick="window.__lib.newFileDialog()">+ 新建文件</button>' +
'<button class="btn btn-primary" onclick="document.getElementById(\\'lib-upload-input\\').click()">上传文件</button>' +
'<input id="lib-upload-input" type="file" class="hidden"/>' +
"</div>"
: "") +
"</div>" +
(files.length === 0 ? '<div class="quiet" style="padding:20px 0;text-align:center">空仓库 · 可新建或上传文件</div>'
: '<table class="list">' + rows + "</table>") +
"</div>";
const up = $("#lib-upload-input");
if (up) up.onchange = (e) => doUpload(e.target);
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
function newFileDialog() {
modal(
'<div class="modal-title">新建文件</div>' +
'<div class="form-row"><label class="form-label">路径</label>' +
'<input id="lib-nf-path" class="input" style="font-family:var(--mono)" placeholder="docs/intro.md"/></div>' +
'<div class="form-row"><label class="form-label">内容</label>' +
'<textarea id="lib-nf-content" rows="8" class="textarea" placeholder="内容…"></textarea></div>' +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="window.__lib.submitNewFile()">创建</button></div>'
);
}
async function submitNewFile() {
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body: {
path: $("#lib-nf-path").value, content: $("#lib-nf-content").value,
}});
closeModal(); ok("已创建"); renderTab();
} catch (e) { fail(e); }
}
function u8ToBase64(bytes) {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
async function doUpload(input) {
const file = input.files && input.files[0];
input.value = "";
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("文件超过 10MB 上限", "err");
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
if (!targetPath) return;
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const body = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body });
ok("已上传 " + file.name);
renderTab();
} catch (e) { fail(e); }
}
async function openFile(path) {
try {
const f = await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(path));
currentFile = f;
const canEdit = current.role !== "VIEW";
const prev = $("#lib-preview");
prev.style.display = "block";
prev.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:10px">' +
'<span class="file-meta">' + esc(f.path) + " @ " + esc(f.version) + "</span>" +
'<div class="inline-form" style="gap:6px">' +
'<a class="btn" href="/database/api/projects/' + current.id + '/file/raw?path=' +
encodeURIComponent(f.path) + '" download>下载</a>' +
'<button class="btn" onclick="window.__lib.showHistory()">历史</button>' +
(canEdit ? '<button class="btn btn-danger" onclick="window.__lib.deleteFile()">删除文件</button>' : "") +
'<button class="btn" onclick="window.__lib.closePreview()" title="关闭预览">✕</button>' +
"</div>" +
"</div>" +
(f.encoding === "base64"
? '<div class="quiet">二进制文件(' + f.size + " B),不支持在线编辑</div>"
: '<textarea id="lib-ef-content" rows="16" class="textarea" ' + (canEdit ? "" : "readonly") +
">" + esc(f.content) + "</textarea>") +
(canEdit && f.encoding !== "base64"
? '<div class="modal-actions"><button class="btn btn-primary" onclick="window.__lib.saveFile()">提交修改</button></div>'
: "") +
'<div id="lib-conflict-zone"></div>' +
"</div>";
} catch (e) { fail(e); }
}
function closePreview() {
const prev = $("#lib-preview");
if (prev) { prev.style.display = "none"; prev.innerHTML = ""; }
currentFile = null;
}
async function saveFile() {
try {
const r = await api("/database/api/projects/" + current.id + "/file/commits", { method: "POST", body: {
path: currentFile.path, baseVersion: currentFile.version, content: $("#lib-ef-content").value,
}});
ok("已提交 " + r.version); await openFile(currentFile.path);
} catch (e) {
if (e.status === 409 && e.currentVersion) return showConflict(e.currentVersion);
fail(e);
}
}
async function showConflict(currentVersion) {
const { diff } = await api("/database/api/projects/" + current.id + "/file/diff?path=" +
encodeURIComponent(currentFile.path) + "&from=" + encodeURIComponent(currentFile.version) +
"&to=" + encodeURIComponent(currentVersion));
$("#lib-conflict-zone").innerHTML =
'<div class="conflict-panel">' +
'<div class="conflict-title">冲突:他人已提交 ' + esc(currentVersion) + ",差异如下(你的基版 → 最新版)</div>" +
'<pre class="diff">' + esc(diff)
.replace(/^\\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>') + "</pre>" +
'<div class="conflict-note">请人工合并后,以最新内容为全文重新提交(基版将更新为 ' + esc(currentVersion) + ")</div>" +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.acceptLatest()">载入最新内容</button></div>' +
"</div>";
currentFile.version = currentVersion;
}
async function deleteFile() {
if (!confirm("删除文件 " + currentFile.path + "?")) return;
try {
await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(currentFile.path),
{ method: "DELETE", body: { baseVersion: currentFile.version } });
ok("已删除"); closePreview(); renderTab();
} catch (e) { fail(e); }
}
async function showHistory() {
const { history } = await api("/database/api/projects/" + current.id + "/file/history?path=" +
encodeURIComponent(currentFile.path));
modal(
'<div class="modal-title">版本历史</div>' +
'<div style="max-height:320px;overflow-y:auto">' +
history.map((v) =>
'<div style="border-top:1px solid #F5F5F4;padding:8px 0;font-size:12px">' +
'<span class="file-meta" style="color:var(--accent)">' + esc(v.version) + "</span> " + esc(v.message) +
'<div class="quiet">' + new Date(v.committedAt).toLocaleString("zh-CN") +
(v.author ? " · " + esc(v.author) : "") + "</div></div>").join("") +
"</div>" +
'<div class="modal-actions"><button class="btn" onclick="window.__lib.closeModal()">关闭</button></div>'
);
}
/* ---------------- 导出 ---------------- */
let exportJobId = null;
async function submitExport() {
try {
const r = await api("/database/api/projects/" + current.id + "/exports", { method: "POST", body: { target: $("#lib-x-target").value } });
exportJobId = r.jobId;
$("#lib-x-status").textContent = "任务 " + r.jobId.slice(0, 8) + "… 排队中";
pollExport();
} catch (e) { fail(e); }
}
async function pollExport() {
if (!exportJobId) return;
try {
const job = await api("/database/api/exports/" + exportJobId);
const el = $("#lib-x-status");
if (!el) return;
if (job.status === "DONE") {
el.innerHTML = '完成 · <a href="/database/api/exports/' + exportJobId + '/download">下载</a>';
} else if (job.status === "FAILED") {
el.textContent = "失败:" + (job.error || "");
} else {
el.textContent = "状态:" + job.status + "…";
setTimeout(pollExport, 1000);
}
} catch { /* ignore */ }
}
/* ---------------- 暴露给内联 onclick 的操作表 ---------------- */
window.__lib = {
createChild() { createDialog(current.id); },
async renameCurrent() {
const name = prompt("新名称", current.name);
if (name === null) return;
try {
await api("/database/api/nodes/" + current.id, { method: "PATCH", body: { name } });
ok("已重命名"); await selectNode(current.id);
} catch (e) { fail(e); }
},
async deleteCurrent() {
if (!confirm("确认删除「" + current.name + "」?软删除后不可见。")) return;
try {
await api("/database/api/nodes/" + current.id, { method: "DELETE" });
ok("已删除"); current = null; await renderTree();
$("#lib-main").innerHTML = '<div class="lib-empty-hint">从左侧选择一个文件夹或项目</div>';
closePreview();
} catch (e) { fail(e); }
},
switchTab(id) { current.tab = id; renderMain(); },
async toggleIndependent() {
try {
await api("/database/api/projects/" + current.id + "/independent-permission", {
method: "PUT", body: { enabled: !current.independentPermission },
});
ok("已切换"); await selectNode(current.id);
} catch (e) { fail(e); }
},
async gTypeChanged() {
const isGroup = $("#lib-g-type").value === "GROUP";
$("#lib-g-principal").classList.toggle("hidden", isGroup);
$("#lib-g-group").classList.toggle("hidden", !isGroup);
if (isGroup && $("#lib-g-group").options.length === 0) {
const { groups } = await api("/database/api/groups/search?q=");
$("#lib-g-group").innerHTML = groups.map((g) => '<option value="' + esc(g.id) + '">' + esc(g.name) + "</option>").join("");
}
},
async addGrant() {
const type = $("#lib-g-type").value;
const principalId = type === "GROUP" ? $("#lib-g-group").value : $("#lib-g-principal").value.trim();
if (!principalId) return toast("请填写主体", "err");
try {
await api("/database/api/nodes/" + current.id + "/grants", { method: "PUT", body: {
grants: [{ principalType: type, principalId, role: $("#lib-g-role").value }],
}});
ok("已授予"); renderTab();
} catch (e) { fail(e); }
},
async revokeGrant(grantId) {
try {
await api("/database/api/nodes/" + current.id + "/grants/" + grantId, { method: "DELETE" });
ok("已收回"); renderTab();
} catch (e) { fail(e); }
},
submitCreate,
newFileDialog, submitNewFile, openFile, closePreview, saveFile, deleteFile, showHistory,
acceptLatest() { openFile(currentFile.path); toast("已载入最新内容,请在此基础上合并", "info"); },
submitExport, closeModal,
};
/* ---------------- 初始化 ---------------- */
(async function init() {
try {
me = await api("/database/api/me");
if (me.isWebsiteAdmin) {
const btn = $("#lib-btn-new-root");
btn.classList.remove("hidden");
btn.onclick = () => createDialog(null);
}
} catch (e) { /* 401 已由 api 处理跳转 */ }
await renderTree();
})();
})();
</script>`;
}
-801
View File
@@ -1,801 +0,0 @@
/**
* /database/library —— 文件库浏览页(管理员后台的文件工具)。
*
* 服务端渲染外壳 + 浏览器端 JS 调 /database/api/*(同源 session cookie)。
* 设计令牌与全局 UI 主题(uiTheme.ts)一致。能力面全部由 API 的 404/403 表达(D8)。
*/
import type { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, verifySession } from "../../admin/auth/session.js";
export interface LibraryPageConfig {
readonly prisma: PrismaClient;
readonly sessionSecret: string;
}
export async function registerLibraryPage(
app: FastifyInstance,
config: LibraryPageConfig,
): Promise<void> {
app.get("/database/library", async (request, reply) => {
const raw = request.cookies[SESSION_COOKIE_NAME];
const session = raw === undefined || raw === "" ? null : verifySession(raw, config.sessionSecret);
if (session === null) return reply.redirect("/database/admin");
const user = await config.prisma.user.findUnique({
where: { id: session.userId },
select: { displayName: true },
});
if (user === null) return reply.redirect("/database/admin");
return reply.type("text/html").send(renderLibraryPage(user.displayName));
});
}
function renderLibraryPage(displayName: string): string {
const initial = displayName.slice(0, 1).replace(/[&<>"']/, "U");
return `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>文件库</title>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
<style>
/* 设计令牌:与全局 UI 主题(uiTheme.ts)保持一致。 */
:root {
--bg: #FCFCFB;
--panel: #FFFFFF;
--sidebar: #F7F7F5;
--text: #1A1A18;
--text-2: #6B6A66;
--text-3: #9C9B96;
--border: #ECECE8;
--border-soft: #F1F1EE;
--accent: #1A1A18;
--accent-hover: #333330;
--selected: #EBEBE7;
--hover: #F4F4F1;
--danger: #A13A33;
--guide: #E9E9E5;
--diff-add-bg: #F3F6F2;
--diff-add-text: #4A6741;
--diff-del-bg: #F8F2F1;
--diff-del-text: #A13A33;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: 'Inter', -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
background: var(--bg); color: var(--text);
font-size: 14px; line-height: 1.6;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
/* 布局 */
.shell { display: flex; height: 100vh; }
.sidebar {
width: 292px; flex-shrink: 0; background: var(--sidebar);
border-right: 1px solid var(--border);
display: flex; flex-direction: column;
}
.sidebar-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 16px; border-bottom: 1px solid var(--border);
}
.wordmark { font-size: 14px; font-weight: 600; color: var(--text); }
.sidebar-actions { display: flex; gap: 6px; }
.tree { flex: 1; overflow-y: auto; padding: 8px 8px 16px; font-size: 13px; }
.sidebar-user {
margin: 0; padding: 10px 16px;
border-top: 1px solid var(--border);
display: flex; align-items: center; gap: 8px;
font-size: 12px; color: var(--text-2);
}
.avatar {
width: 24px; height: 24px; border-radius: 50%;
background: var(--accent); color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 11px; font-weight: 600; flex-shrink: 0;
}
.main { flex: 1; overflow-y: auto; }
.main-inner { max-width: 860px; margin: 0 auto; padding: 32px 32px 64px; }
.empty-hint {
height: 100%; display: flex; align-items: center; justify-content: center;
color: var(--text-3); font-size: 13px;
}
/* 树 */
.tree-item {
display: flex; align-items: center; gap: 4px;
padding: 4px 6px; border-radius: 6px; cursor: pointer;
color: var(--text); transition: background 120ms ease;
user-select: none;
}
.tree-item:hover { background: var(--hover); }
.tree-item.selected { background: var(--selected); color: #1C1917; }
.tree-children { border-left: 1px solid var(--guide); margin-left: 15px; padding-left: 3px; }
.tree-icon { width: 16px; height: 16px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.tree-caret { color: var(--text-3); }
.tree-badge { margin-left: auto; padding-right: 4px; font-size: 10px; color: var(--text-3); font-family: var(--mono); }
.tree-empty { padding: 24px 12px; text-align: center; font-size: 12px; color: var(--text-3); }
/* 按钮 */
.btn {
display: inline-flex; align-items: center; gap: 4px;
padding: 5px 12px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
color: var(--accent); font-size: 12px; font-weight: 500;
cursor: pointer; transition: all 120ms ease; text-decoration: none;
}
.btn:hover { background: var(--hover); border-color: #D6D3D1; }
.btn-primary {
background: var(--accent); border-color: var(--accent); color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger {
border-color: transparent; background: transparent; color: var(--danger);
}
.btn-danger:hover { background: var(--diff-del-bg); border-color: transparent; }
/* 内容区 */
.crumbs { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
.crumbs span + span::before { content: " / "; color: var(--border); }
.node-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
.node-title { font-size: 17px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
.tag {
font-size: 10px; font-weight: 500; font-family: var(--mono);
padding: 2px 7px; border-radius: 999px;
border: 1px solid var(--border); color: var(--text-2); background: var(--panel);
}
.tag-role { color: var(--accent); border-color: #D6D3D1; }
.node-actions { display: flex; gap: 6px; }
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
.tab {
padding: 8px 14px; font-size: 13px; color: var(--text-2);
border: none; background: none; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color 120ms ease;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }
.panel {
background: var(--panel); border: 1px solid var(--border);
border-radius: 10px; padding: 20px 22px;
}
.panel + .panel, .panel + #file-editor { margin-top: 14px; }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; font-size: 13px; color: var(--text-2); }
.meta-grid b { color: var(--text); font-weight: 600; }
.section-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 10px; }
.section-note { font-size: 11px; color: var(--text-3); margin-top: 6px; }
/* 表格 */
table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
table.list th { text-align: left; font-size: 11px; font-weight: 500; color: var(--text-3); padding: 4px 0; }
table.list td { padding: 7px 0; border-top: 1px solid #F5F5F4; }
table.list tr:first-child td { border-top: none; }
.file-row { cursor: pointer; }
.file-row:hover td { background: var(--hover); }
.file-path { font-family: var(--mono); font-size: 12px; color: var(--text); }
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.link-danger { color: var(--danger); font-size: 12px; background: none; border: none; cursor: pointer; padding: 0; }
.link-danger:hover { text-decoration: underline; }
/* 表单 */
.input, .select, .textarea {
width: 100%; padding: 7px 10px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
font-size: 13px; color: var(--text); font-family: inherit;
outline: none; transition: border-color 120ms ease;
}
.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
.textarea { font-family: var(--mono); font-size: 12px; line-height: 1.7; resize: vertical; }
.form-label { display: block; font-size: 11px; color: var(--text-2); margin-bottom: 4px; }
.form-row { margin-bottom: 12px; }
.inline-form { display: flex; gap: 8px; align-items: center; }
.inline-form .input { flex: 1; }
/* 弹层 */
.modal-mask {
position: fixed; inset: 0; z-index: 40;
background: rgba(26, 26, 24, .3);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal-card {
width: 100%; max-width: 440px; background: var(--panel);
border: 1px solid var(--border); border-radius: 12px; padding: 22px;
}
.modal-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.toast {
border-radius: 8px; padding: 8px 14px; font-size: 12px; color: #fff;
background: #333230; max-width: 320px;
}
.toast-ok { background: #333230; }
.toast-err { background: #7E2C26; }
#toast-root { position: fixed; bottom: 16px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; }
/* 冲突与 diff */
.conflict-panel {
margin-top: 14px; border: 1px solid #E8E2C8; background: #FCFBF4;
border-radius: 10px; padding: 14px 16px;
}
.conflict-title { font-size: 13px; font-weight: 600; color: #6E6329; margin-bottom: 8px; }
.conflict-note { font-size: 11px; color: #8A8059; margin-top: 8px; }
pre.diff {
white-space: pre-wrap; font-family: var(--mono); font-size: 12px; line-height: 1.7;
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px;
margin: 0;
}
pre.diff .add { display: block; color: var(--diff-add-text); background: var(--diff-add-bg); }
pre.diff .del { display: block; color: var(--diff-del-text); background: var(--diff-del-bg); }
.divider { border-top: 1px solid var(--border); margin: 14px 0; }
.quiet { color: var(--text-3); font-size: 12px; }
.export-status { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.export-status a { color: var(--accent); }
</style>
</head>
<body>
<div class="shell">
<aside class="sidebar">
<div class="sidebar-head">
<span class="wordmark">文件库</span>
<div class="sidebar-actions">
<button id="btn-new-root" class="btn hidden" title="新建根目录">+ 根目录</button>
<a class="btn" href="/database/dashboard">后台</a>
</div>
</div>
<div id="tree" class="tree"></div>
<div class="sidebar-user">
<div class="avatar">${initial}</div>
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">${displayName}</span>
<button id="btn-logout" class="btn" style="padding:3px 10px;font-size:11px" title="退出登录">退出</button>
</div>
</aside>
<main class="main" id="main">
<div class="empty-hint">从左侧选择一个文件夹或项目</div>
</main>
</div>
<div id="modal-root"></div>
<div id="toast-root"></div>
<script>
/* ---------------- 基础设施 ---------------- */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
async function api(path, opts = {}) {
const res = await fetch(path, {
credentials: "same-origin",
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
...opts,
body: opts.body ? JSON.stringify(opts.body) : undefined,
});
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
if (res.status === 204) return null;
const text = await res.text();
const data = text ? JSON.parse(text) : null;
if (!res.ok) {
const err = (data && data.error) || { code: "unknown", message: res.statusText };
const e = new Error(err.message); e.code = err.code; e.status = res.status; Object.assign(e, err);
throw e;
}
return data;
}
function toast(msg, kind = "info") {
const el = document.createElement("div");
el.className = "toast " + (kind === "err" ? "toast-err" : "toast-ok");
el.textContent = msg;
$("#toast-root").appendChild(el);
setTimeout(() => el.remove(), 3600);
}
const ok = (m) => toast(m, "ok");
const fail = (e) => toast(e.message || String(e), "err");
function modal(html) {
const root = $("#modal-root");
root.innerHTML = '<div class="modal-mask"><div class="modal-card">' + html + "</div></div>";
root.firstElementChild.addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
}
function closeModal() { $("#modal-root").innerHTML = ""; }
/* ---------------- 状态 ---------------- */
let me = { userId: null, isWebsiteAdmin: false };
let current = null;
const expanded = new Set();
/* ---------------- 目录树 ---------------- */
/* 图标统一用固定尺寸的 inline SVG。每行几何一致:[16px 箭头槽][16px 图标槽][名称]。 */
const ICONS = {
caretRight: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M9 6l6 6-6 6z"/></svg>',
caretDown: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M6 9l6 6 6-6z"/></svg>',
folder: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></svg>',
project: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>',
};
async function loadChildren(parentId) {
const q = parentId === null ? "" : "?parentId=" + encodeURIComponent(parentId);
return (await api("/database/api/nodes" + q)).nodes;
}
async function renderTree() {
const tree = $("#tree");
tree.innerHTML = "";
try {
const roots = await loadChildren(null);
if (roots.length === 0) {
tree.innerHTML = '<div class="tree-empty">空文件库' +
(me.isWebsiteAdmin ? " · 点上方「+ 根目录」开始" : "") + "</div>";
return;
}
for (const node of roots) tree.appendChild(await treeItem(node, 0));
} catch (e) { tree.innerHTML = '<div class="tree-empty">' + esc(e.message) + "</div>"; }
}
async function treeItem(node, depth) {
const wrap = document.createElement("div");
const row = document.createElement("div");
row.className = "tree-item" + (current && current.id === node.id ? " selected" : "");
const caret = document.createElement("span");
caret.className = "tree-icon tree-caret";
if (node.kind === "FOLDER") {
caret.innerHTML = expanded.has(node.id) ? ICONS.caretDown : ICONS.caretRight;
caret.onclick = (e) => { e.stopPropagation(); toggleExpand(node.id, wrap, depth); };
}
row.appendChild(caret);
const iconSpan = document.createElement("span");
iconSpan.className = "tree-icon";
iconSpan.style.color = node.kind === "PROJECT" ? "#1A1A18" : "#9C9B96";
iconSpan.innerHTML = node.kind === "PROJECT" ? ICONS.project : ICONS.folder;
row.appendChild(iconSpan);
const nameSpan = document.createElement("span");
nameSpan.className = "truncate";
nameSpan.textContent = node.name;
row.appendChild(nameSpan);
if (node.role !== "MANAGE") {
const badge = document.createElement("span");
badge.className = "tree-badge";
badge.textContent = node.role;
row.appendChild(badge);
}
row.onclick = () => {
if (node.kind === "FOLDER") {
if (expanded.has(node.id)) expanded.delete(node.id); else expanded.add(node.id);
}
selectNode(node.id);
};
wrap.appendChild(row);
if (node.kind === "FOLDER" && expanded.has(node.id)) {
wrap.appendChild(await childrenBlock(node.id, depth));
}
return wrap;
}
async function childrenBlock(parentId, depth) {
const block = document.createElement("div");
block.className = "tree-children";
const kids = await loadChildren(parentId);
for (const kid of kids) block.appendChild(await treeItem(kid, depth + 1));
return block;
}
async function toggleExpand(id, wrap, depth) {
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
await renderTree();
}
/* ---------------- 节点详情 ---------------- */
async function selectNode(id) {
try {
const [{ node }, { breadcrumb }] = await Promise.all([
api("/database/api/nodes/" + id),
api("/database/api/nodes/" + id + "/breadcrumb"),
]);
current = node; current.breadcrumb = breadcrumb;
await renderTree();
renderMain();
} catch (e) { fail(e); }
}
function crumbsHtml() {
return current.breadcrumb.map((c) =>
c.name === null ? "<span>…</span>" : "<span>" + esc(c.name) + "</span>"
).join("");
}
function renderMain() {
const n = current;
const canManage = n.role === "MANAGE";
const canEdit = canManage || n.role === "EDIT";
const tabs = [];
tabs.push(["detail", "概览"]);
if (n.kind === "PROJECT") tabs.push(["files", "文件"]);
if (canManage) tabs.push(["grants", "授权"]);
current.tab = current.tab && tabs.some(t => t[0] === current.tab) ? current.tab : "detail";
$("#main").innerHTML =
'<div class="main-inner">' +
'<div class="crumbs">' + crumbsHtml() + "</div>" +
'<div class="node-head">' +
'<div class="node-title">' + esc(n.name) +
'<span class="tag">' + n.kind + "</span>" +
'<span class="tag tag-role">' + n.role + "</span>" +
"</div>" +
'<div class="node-actions">' +
(canEdit && n.kind === "FOLDER" ? '<button class="btn" onclick="createChild()">+ 新建子节点</button>' : "") +
(canManage ? '<button class="btn" onclick="renameCurrent()">重命名</button>' : "") +
(canManage ? '<button class="btn btn-danger" onclick="deleteCurrent()">删除</button>' : "") +
"</div>" +
"</div>" +
'<div class="tabs">' +
tabs.map(([id, label]) =>
'<button onclick="switchTab(\\'' + id + '\\')" class="tab' +
(current.tab === id ? " active" : "") + '">' + label + "</button>").join("") +
"</div>" +
'<div id="tab-body"></div>' +
"</div>";
renderTab();
}
function switchTab(id) { current.tab = id; renderMain(); }
async function renderTab() {
const body = $("#tab-body");
if (current.tab === "files") return renderFilesTab(body);
if (current.tab === "grants") return renderGrantsTab(body);
let extra = "";
if (current.kind === "PROJECT") {
extra =
'<div class="divider"></div>' +
'<div class="inline-form" style="justify-content:flex-start">' +
'<span class="quiet">独立权限</span>' +
'<b style="font-size:13px">' + (current.independentPermission ? "开启" : "关闭") + "</b>" +
(current.role === "MANAGE"
? '<button class="btn" onclick="toggleIndependent()">' + (current.independentPermission ? "关闭" : "开启") + "</button>"
: "") +
'<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>' +
"</div>" +
'<div class="divider"></div>' +
'<div class="section-title">导出</div>' +
'<div class="inline-form">' +
'<select id="x-target" class="select" style="width:auto"><option value="manifest">manifest(stub)</option></select>' +
'<button class="btn" onclick="submitExport()">开始导出</button>' +
'<span id="x-status" class="export-status"></span>' +
"</div>";
}
body.innerHTML =
'<div class="panel">' +
'<div class="meta-grid">' +
"<div>类型 <b>" + current.kind + "</b></div>" +
"<div>我的权限 <b>" + current.role + "</b></div>" +
"<div>创建时间 " + new Date(current.createdAt).toLocaleString("zh-CN") + "</div>" +
"<div>更新时间 " + new Date(current.updatedAt).toLocaleString("zh-CN") + "</div>" +
"</div>" + extra +
"</div>";
pollExport();
}
/* ---------------- 节点操作 ---------------- */
async function createChild() {
createDialog(current.id);
}
function createDialog(parentId) {
modal(
'<div class="modal-title">' + (parentId ? "新建子节点" : "新建根目录") + "</div>" +
'<div class="form-row"><label class="form-label">名称</label>' +
'<input id="f-name" class="input" placeholder="例如:物理必修一"/></div>' +
'<div class="form-row"><label class="form-label">类型</label>' +
'<select id="f-kind" class="select">' +
'<option value="FOLDER">文件夹</option><option value="PROJECT">项目(Git 仓库)</option>' +
"</select></div>" +
'<div class="modal-actions">' +
'<button class="btn" onclick="closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="submitCreate(\\'' + (parentId ?? "") + '\\')">创建</button>' +
"</div>"
);
}
async function submitCreate(parentId) {
try {
await api("/database/api/nodes", { method: "POST", body: {
parentId: parentId === "" ? null : parentId,
kind: $("#f-kind").value, name: $("#f-name").value,
}});
closeModal(); ok("已创建");
if (parentId) expanded.add(parentId);
await renderTree();
} catch (e) { fail(e); }
}
async function renameCurrent() {
const name = prompt("新名称", current.name);
if (name === null) return;
try {
await api("/database/api/nodes/" + current.id, { method: "PATCH", body: { name } });
ok("已重命名"); await selectNode(current.id);
} catch (e) { fail(e); }
}
async function deleteCurrent() {
if (!confirm("确认删除「" + current.name + "」?软删除后不可见。")) return;
try {
await api("/database/api/nodes/" + current.id, { method: "DELETE" });
ok("已删除"); current = null; await renderTree();
$("#main").innerHTML = '<div class="empty-hint">从左侧选择一个文件夹或项目</div>';
} catch (e) { fail(e); }
}
async function toggleIndependent() {
try {
await api("/database/api/projects/" + current.id + "/independent-permission", {
method: "PUT", body: { enabled: !current.independentPermission },
});
ok("已切换"); await selectNode(current.id);
} catch (e) { fail(e); }
}
/* ---------------- 授权 ---------------- */
async function renderGrantsTab(body) {
try {
const { grants } = await api("/database/api/nodes/" + current.id + "/grants");
const rows = grants.map((g) =>
"<tr>" +
"<td>" + (g.principalType === "USER" ? "👤" : "👥") + " " + esc(g.principalId) +
(g.isCreatorGrant ? ' <span class="quiet">(创建者)</span>' : "") + "</td>" +
'<td class="file-meta">' + g.role + "</td>" +
"<td style='text-align:right'>" + (g.isCreatorGrant ? "" :
'<button class="link-danger" onclick="revokeGrant(\\'' + g.id + '\\')">收回</button>') + "</td>" +
"</tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<table class="list"><thead><tr><th>主体</th><th>级别</th><th></th></tr></thead>' +
"<tbody>" + rows + "</tbody></table>" +
'<div class="divider"></div>' +
'<div class="section-title">新增授权</div>' +
'<div class="inline-form">' +
'<select id="g-type" class="select" style="width:110px" onchange="gTypeChanged()">' +
'<option value="USER">用户</option><option value="GROUP">Group</option></select>' +
'<input id="g-principal" class="input" placeholder="用户 id"/>' +
'<select id="g-group" class="select hidden"></select>' +
'<select id="g-role" class="select" style="width:110px">' +
'<option value="VIEW">VIEW</option><option value="EDIT">EDIT</option><option value="MANAGE">MANAGE</option></select>' +
'<button class="btn btn-primary" onclick="addGrant()">授予</button>' +
"</div>" +
'<div class="section-note">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>' +
"</div>";
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
async function gTypeChanged() {
const isGroup = $("#g-type").value === "GROUP";
$("#g-principal").classList.toggle("hidden", isGroup);
$("#g-group").classList.toggle("hidden", !isGroup);
if (isGroup && $("#g-group").options.length === 0) {
const { groups } = await api("/database/api/groups/search?q=");
$("#g-group").innerHTML = groups.map((g) => '<option value="' + esc(g.id) + '">' + esc(g.name) + "</option>").join("");
}
}
async function addGrant() {
const type = $("#g-type").value;
const principalId = type === "GROUP" ? $("#g-group").value : $("#g-principal").value.trim();
if (!principalId) return toast("请填写主体", "err");
try {
await api("/database/api/nodes/" + current.id + "/grants", { method: "PUT", body: {
grants: [{ principalType: type, principalId, role: $("#g-role").value }],
}});
ok("已授予"); renderTab();
} catch (e) { fail(e); }
}
async function revokeGrant(grantId) {
try {
await api("/database/api/nodes/" + current.id + "/grants/" + grantId, { method: "DELETE" });
ok("已收回"); renderTab();
} catch (e) { fail(e); }
}
/* ---------------- 文件 ---------------- */
let currentFile = null;
async function renderFilesTab(body) {
try {
const { files } = await api("/database/api/projects/" + current.id + "/files");
const rows = files.map((f) =>
'<tr class="file-row" onclick=\\'openFile("' + esc(f.path) + '")\\'>' +
'<td class="file-path">' + esc(f.path) + "</td>" +
'<td class="file-meta" style="text-align:right">' + f.size + " B</td></tr>").join("");
body.innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:6px">' +
'<div class="section-title" style="margin:0">项目文件(' + files.length + ")</div>" +
(current.role !== "VIEW"
? '<div class="inline-form" style="gap:6px">' +
'<button class="btn" onclick="newFileDialog()">+ 新建文件</button>' +
'<button class="btn btn-primary" onclick="document.getElementById(\\'upload-input\\').click()">上传文件</button>' +
'<input id="upload-input" type="file" class="hidden" onchange="doUpload(this)"/>' +
"</div>"
: "") +
"</div>" +
(files.length === 0 ? '<div class="quiet" style="padding:20px 0;text-align:center">空仓库 · 可新建或上传文件</div>'
: '<table class="list">' + rows + "</table>") +
"</div>" +
'<div id="file-editor"></div>';
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
}
async function newFileDialog() {
modal(
'<div class="modal-title">新建文件</div>' +
'<div class="form-row"><label class="form-label">路径</label>' +
'<input id="nf-path" class="input" style="font-family:var(--mono)" placeholder="docs/intro.md"/></div>' +
'<div class="form-row"><label class="form-label">内容</label>' +
'<textarea id="nf-content" rows="8" class="textarea" placeholder="内容…"></textarea></div>' +
'<div class="modal-actions"><button class="btn" onclick="closeModal()">取消</button>' +
'<button class="btn btn-primary" onclick="submitNewFile()">创建</button></div>'
);
}
/* 磁盘文件上传:文本走 utf8,含 NUL 字节判为二进制走 base64(与服务端一致)。 */
function u8ToBase64(bytes) {
let bin = "";
const CHUNK = 0x8000;
for (let i = 0; i < bytes.length; i += CHUNK) {
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
}
return btoa(bin);
}
async function doUpload(input) {
const file = input.files && input.files[0];
input.value = "";
if (!file) return;
if (file.size > 10 * 1024 * 1024) return toast("文件超过 10MB 上限", "err");
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
if (!targetPath) return;
const bytes = new Uint8Array(await file.arrayBuffer());
const isBinary = bytes.includes(0);
const body = isBinary
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body });
ok("已上传 " + file.name);
renderTab();
} catch (e) { fail(e); }
}
async function submitNewFile() {
try {
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body: {
path: $("#nf-path").value, content: $("#nf-content").value,
}});
closeModal(); ok("已创建"); renderTab();
} catch (e) { fail(e); }
}
async function openFile(path) {
try {
const f = await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(path));
currentFile = f;
const canEdit = current.role !== "VIEW";
$("#file-editor").innerHTML =
'<div class="panel">' +
'<div class="inline-form" style="justify-content:space-between;margin-bottom:10px">' +
'<span class="file-meta">' + esc(f.path) + " @ " + esc(f.version) + "</span>" +
'<div class="inline-form" style="gap:6px">' +
'<a class="btn" href="/database/api/projects/' + current.id + '/file/raw?path=' +
encodeURIComponent(f.path) + '" download>下载</a>' +
'<button class="btn" onclick="showHistory()">历史</button>' +
(canEdit ? '<button class="btn btn-danger" onclick="deleteFile()">删除文件</button>' : "") +
"</div>" +
"</div>" +
(f.encoding === "base64"
? '<div class="quiet">二进制文件(' + f.size + " B),不支持在线编辑</div>"
: '<textarea id="ef-content" rows="14" class="textarea" ' + (canEdit ? "" : "readonly") +
">" + esc(f.content) + "</textarea>") +
(canEdit && f.encoding !== "base64"
? '<div class="modal-actions"><button class="btn btn-primary" onclick="saveFile()">提交修改</button></div>'
: "") +
'<div id="conflict-zone"></div>' +
"</div>";
} catch (e) { fail(e); }
}
async function saveFile() {
try {
const r = await api("/database/api/projects/" + current.id + "/file/commits", { method: "POST", body: {
path: currentFile.path, baseVersion: currentFile.version, content: $("#ef-content").value,
}});
ok("已提交 " + r.version); await openFile(currentFile.path);
} catch (e) {
if (e.status === 409 && e.currentVersion) return showConflict(e.currentVersion);
fail(e);
}
}
async function showConflict(currentVersion) {
const { diff } = await api("/database/api/projects/" + current.id + "/file/diff?path=" +
encodeURIComponent(currentFile.path) + "&from=" + encodeURIComponent(currentFile.version) +
"&to=" + encodeURIComponent(currentVersion));
$("#conflict-zone").innerHTML =
'<div class="conflict-panel">' +
'<div class="conflict-title">冲突:他人已提交 ' + esc(currentVersion) + ",差异如下(你的基版 → 最新版)</div>" +
'<pre class="diff">' + esc(diff)
.replace(/^\\+(.*)$/gm, '<span class="add">+$1</span>')
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>') + "</pre>" +
'<div class="conflict-note">请人工合并后,以最新内容为全文重新提交(基版将更新为 ' + esc(currentVersion) + ")</div>" +
'<div class="modal-actions"><button class="btn" onclick="acceptLatest(\\'' + esc(currentVersion) + '\\')">载入最新内容</button></div>' +
"</div>";
currentFile.version = currentVersion;
}
async function acceptLatest() { await openFile(currentFile.path); toast("已载入最新内容,请在此基础上合并", "info"); }
async function deleteFile() {
if (!confirm("删除文件 " + currentFile.path + "?")) return;
try {
await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(currentFile.path),
{ method: "DELETE", body: { baseVersion: currentFile.version } });
ok("已删除"); currentFile = null; renderTab();
} catch (e) { fail(e); }
}
async function showHistory() {
const { history } = await api("/database/api/projects/" + current.id + "/file/history?path=" +
encodeURIComponent(currentFile.path));
modal(
'<div class="modal-title">版本历史</div>' +
'<div style="max-height:320px;overflow-y:auto">' +
history.map((v) =>
'<div style="border-top:1px solid #F5F5F4;padding:8px 0;font-size:12px">' +
'<span class="file-meta" style="color:var(--accent)">' + esc(v.version) + "</span> " + esc(v.message) +
'<div class="quiet">' + new Date(v.committedAt).toLocaleString("zh-CN") +
(v.author ? " · " + esc(v.author) : "") + "</div></div>").join("") +
"</div>" +
'<div class="modal-actions"><button class="btn" onclick="closeModal()">关闭</button></div>'
);
}
/* ---------------- 导出 ---------------- */
let exportJobId = null;
async function submitExport() {
try {
const r = await api("/database/api/projects/" + current.id + "/exports", { method: "POST", body: { target: $("#x-target").value } });
exportJobId = r.jobId;
$("#x-status").textContent = "任务 " + r.jobId.slice(0, 8) + "… 排队中";
pollExport();
} catch (e) { fail(e); }
}
async function pollExport() {
if (!exportJobId) return;
try {
const job = await api("/database/api/exports/" + exportJobId);
const el = $("#x-status");
if (!el) return;
if (job.status === "DONE") {
el.innerHTML = '完成 · <a href="/database/api/exports/' + exportJobId + '/download">下载</a>';
} else if (job.status === "FAILED") {
el.textContent = "失败:" + (job.error || "");
} else {
el.textContent = "状态:" + job.status + "…";
setTimeout(pollExport, 1000);
}
} catch { /* job 可能属于别的项目 */ }
}
/* ---------------- 启动 ---------------- */
(async function init() {
const logoutBtn = $("#btn-logout");
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch {}
location.href = "/database/admin";
});
}
try {
me = await api("/database/api/me");
if (me.isWebsiteAdmin) {
const btn = $("#btn-new-root");
btn.classList.remove("hidden");
btn.onclick = () => createDialog(null);
}
} catch (e) { /* 401 已由 api 处理跳转 */ }
await renderTree();
})();
</script>
</body>
</html>`;
}
+5 -28
View File
@@ -1,15 +1,12 @@
/** /**
* 老师端用户前端托管(标准前后端分离): * 老师端后端支撑(标准前后端分离):
* GET /app/* — filelib-web(Svelte SPA)构建产物静态托管 + SPA 回退 * GET /database/api/login-info — 登录页配置(org slug / dev 开关)
* GET /database/api/login-info — 登录页配置(org slug / dev 开关) * GET /app/dev-login-teacher — DEV ONLY 一键登录(普通老师)
* 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 { FastifyInstance } from "fastify";
import type { PrismaClient } from "@prisma/client"; import type { PrismaClient } from "@prisma/client";
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js"; import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
@@ -33,26 +30,6 @@ export async function registerTeacherApp(
devLoginEnabled: config.allowDevLoginBypass, 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; if (!config.allowDevLoginBypass) return;
registerDevLogins(app, config); registerDevLogins(app, config);
} }
-155
View File
@@ -1,155 +0,0 @@
/**
* 全局共享 UI 主题(老师端 /app、管理员后台 /database、文件页 /database/library)。
*
* 方向:高级简约、零视觉疲劳 —— 暖白底、发丝边框、近黑主按钮(唯一强调色)、
* 无渐变、无彩色、无阴影(弹层仅一丝)、Inter 全界面、克制的动效。
* 所有页面的设计令牌收敛于此,调色只改这里。
*/
export const UI_HEAD_FONTS = `
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
`;
export const UI_THEME_CSS = `
:root {
--bg: #FCFCFB;
--panel: #FFFFFF;
--sidebar: #F7F7F5;
--text: #1A1A18;
--text-2: #6B6A66;
--text-3: #9C9B96;
--border: #ECECE8;
--border-soft: #F1F1EE;
--hover: #F4F4F1;
--selected: #EBEBE7;
--accent: #1A1A18;
--accent-hover: #333330;
--danger: #A13A33;
--guide: #E9E9E5;
--diff-add-bg: #F3F6F2;
--diff-add-text: #4A6741;
--diff-del-bg: #F8F2F1;
--diff-del-text: #A13A33;
--sans: 'Inter', -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
--shadow-pop: 0 4px 20px rgba(26,26,24,.07);
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; }
body {
font-family: var(--sans);
background: var(--bg); color: var(--text);
font-size: 14px; line-height: 1.65;
-webkit-font-smoothing: antialiased;
}
.hidden { display: none !important; }
h1, h2, h3 { margin: 0; }
a { color: var(--text); text-decoration: none; }
a:hover { text-decoration: underline; }
/* 按钮:近黑实心(主)/ 发丝边幽灵(次)/ 文字型(危险) */
.btn {
display: inline-flex; align-items: center; gap: 5px;
padding: 6px 14px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
color: var(--text); font-size: 12.5px; font-weight: 500;
cursor: pointer; transition: all 120ms ease; text-decoration: none;
}
.btn:hover { background: var(--hover); text-decoration: none; }
.btn-primary {
background: var(--accent); border-color: var(--accent); color: #fff;
}
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
.btn-danger { border-color: transparent; background: transparent; color: var(--danger); }
.btn-danger:hover { background: #A13A3312; }
/* 面板与标签 */
.panel {
background: var(--panel); border: 1px solid var(--border-soft);
border-radius: 10px; padding: 20px 22px;
}
.tag {
font-size: 10.5px; font-weight: 500; font-family: var(--mono);
padding: 2px 8px; border-radius: 999px;
border: 1px solid var(--border-soft); color: var(--text-3); background: var(--panel);
}
.tag-role { color: var(--text-2); border-color: var(--border); }
/* 页签 */
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border-soft); margin-bottom: 18px; }
.tab {
padding: 8px 14px; font-size: 13px; color: var(--text-3);
border: none; background: none; cursor: pointer;
border-bottom: 2px solid transparent; margin-bottom: -1px;
transition: color 120ms ease;
}
.tab:hover { color: var(--text); }
.tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }
/* 表单 */
.input, .select, .textarea {
width: 100%; padding: 7px 11px; border-radius: 8px;
border: 1px solid var(--border); background: var(--panel);
font-size: 13px; color: var(--text); font-family: inherit;
outline: none; transition: border-color 120ms ease;
}
.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
.textarea { font-family: var(--mono); font-size: 12.5px; line-height: 1.75; resize: vertical; }
.form-label { display: block; font-size: 11.5px; color: var(--text-3); margin-bottom: 4px; }
.form-row { margin-bottom: 12px; }
.inline-form { display: flex; gap: 8px; align-items: center; }
.inline-form .input { flex: 1; }
/* 表格 */
table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
table.list th { text-align: left; font-size: 11.5px; font-weight: 500; color: var(--text-3); padding: 4px 0; }
table.list td { padding: 8px 0; border-top: 1px solid var(--border-soft); }
table.list tr:first-child td { border-top: none; }
/* 弹层 */
.modal-mask {
position: fixed; inset: 0; z-index: 40;
background: rgba(26,26,24,.3);
display: flex; align-items: center; justify-content: center; padding: 16px;
}
.modal-card {
width: 100%; max-width: 430px; background: var(--panel);
border: 1px solid var(--border-soft); border-radius: 14px; padding: 22px;
box-shadow: var(--shadow-pop);
}
.modal-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; }
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
.toast {
border-radius: 8px; padding: 8px 16px; font-size: 12.5px; color: #fff;
background: #333230; max-width: 330px;
}
.toast-err { background: #7E2C26; }
#toast-root { position: fixed; bottom: 18px; right: 18px; z-index: 50; display: flex; flex-direction: column; gap: 8px; }
.quiet { color: var(--text-3); font-size: 12.5px; }
.divider { border-top: 1px solid var(--border-soft); margin: 14px 0; }
.section-title { font-size: 13px; font-weight: 600; margin-bottom: 10px; }
.section-note { font-size: 11.5px; color: var(--text-3); margin-top: 6px; }
.file-path { font-family: var(--mono); font-size: 12.5px; color: var(--text); }
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
.link-danger { color: var(--danger); font-size: 12.5px; background: none; border: none; cursor: pointer; padding: 0; }
.link-danger:hover { text-decoration: underline; }
/* 开关(toggle)。用法:<label class="switch"><input type="checkbox"><span></span>文字</label>
真正的 checkbox 藏在下面 —— 保留键盘可达与 :checked 语义,不做 div 假开关。 */
.switch { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; user-select: none; }
.switch > input { position: absolute; opacity: 0; width: 0; height: 0; }
.switch > span {
position: relative; flex-shrink: 0; width: 30px; height: 17px; border-radius: 999px;
background: var(--border); transition: background .16s;
}
.switch > span::after {
content: ""; position: absolute; top: 2px; left: 2px; width: 13px; height: 13px;
border-radius: 50%; background: #fff; transition: transform .16s;
box-shadow: 0 1px 2px rgba(0,0,0,.25);
}
.switch > input:checked + span { background: var(--accent); }
.switch > input:checked + span::after { transform: translateX(13px); }
.switch > input:focus-visible + span { outline: 2px solid var(--accent); outline-offset: 2px; }
`;
+43 -70
View File
@@ -1,98 +1,71 @@
/** /**
* Serves the database-admin SPA (built by SvelteKit via `database-admin/build/`) * 前端静态托管:老师端 `/app/*` 与管理后台 `/database/*` 共用 `hub/filelib-web`
* and the SPA index fallback for client-side routes under `/database/*`. * 这**一份** SvelteKit 构建产物(adapter-static + fallback,见 filelib-web/svelte.config.js)。
* *
* The SvelteKit project lives in `hub/database-admin/` and is built with * 标准前后端分离:服务端不渲染任何页面 —— 两个前缀下都只把同一个 index.html
* `paths.base = '/database'`, so its assets are emitted under `/database/_app/*` * 原样送出,由 SvelteKit 客户端路由决定显示哪个视图;数据一律走 /database/api/*
* (not the root `/_app/*` that admin-web owns — that keeps the two SPAs from
* colliding). On disk the files still live at `build/_app/*`; this handler maps
* the `/database`-prefixed URLs back to those files.
* *
* Run `npm run build` in database-admin/ to produce the static output. In * 三类路由:
* development, `npm run dev` there proxies `/api`, `/auth`, and `/database` to * /_filelib/* — 构建产物资源(JS/CSS/字体)。SvelteKit 的 appDir 被改名为
* the Hub. Override the UI directory with `CPH_DATABASE_UI_DIR` if needed. * `_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 { readFile } from "node:fs/promises";
import { existsSync } from "node:fs"; import { existsSync } from "node:fs";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { dirname, extname, join, resolve as resolvePath } from "node:path"; import { dirname, join, resolve as resolvePath } from "node:path";
import type { FastifyInstance } from "fastify"; import fastifyStatic from "@fastify/static";
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify";
const MIME: Record<string, string> = {
".html": "text/html; charset=utf-8",
".js": "text/javascript; charset=utf-8",
".mjs": "text/javascript; charset=utf-8",
".css": "text/css; charset=utf-8",
".svg": "image/svg+xml",
".ico": "image/x-icon",
".png": "image/png",
".jpg": "image/jpeg",
".woff": "font/woff",
".woff2": "font/woff2",
".json": "application/json; charset=utf-8",
".txt": "text/plain; charset=utf-8",
};
function resolveUiDir(): string { 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); if (override && override.trim() !== "") return resolvePath(override);
const here = dirname(fileURLToPath(import.meta.url)); 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> { export async function registerDatabaseSpa(app: FastifyInstance): Promise<void> {
const uiDir = resolveUiDir(); const uiDir = resolveUiDir();
if (!existsSync(join(uiDir, "index.html"))) { if (!existsSync(join(uiDir, "index.html"))) {
app.log.warn( app.log.warn(
{ uiDir }, { 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; return;
} }
const indexHtml = await readFile(join(uiDir, "index.html"), "utf8"); const indexHtml = await readFile(join(uiDir, "index.html"), "utf8");
// SvelteKit build assets. base='/database' emits them at /database/_app/*, // 构建资源。@fastify/static 负责 MIME、ETag/Last-Modified 与目录穿越防护。
// but on disk they're under build/_app/*. // 只注册一次 —— /app 与 /database 下的页面引用的都是这同一组绝对路径
app.get("/database/_app/*", async (request, reply) => { // (filelib-web 的 paths.relative=false 保证了这点)。
const rel = (request.params as { "*": string })["*"]; await app.register(fastifyStatic, {
const safe = rel.split("/").filter((p) => p !== ".." && p !== "").join("/"); root: join(uiDir, "_filelib"),
try { prefix: "/_filelib/",
const buf = await readFile(join(uiDir, "_app", safe)); decorateReply: true,
const mime = MIME[extname(safe)] ?? "application/octet-stream";
return reply.type(mime).send(buf);
} catch {
return reply.status(404).send({ error: { code: "not_found", message: "asset not found" } });
}
}); });
// Top-level static files emitted under the base path (favicon.svg, robots.txt). for (const name of TOP_LEVEL_FILES) {
app.get("/database/favicon.svg", async (_request, reply) => { if (!existsSync(join(uiDir, name))) continue;
try { app.get(`/${name}`, async (_request, reply) => reply.sendFile(name, uiDir));
const buf = await readFile(join(uiDir, "favicon.svg")); }
return reply.type("image/svg+xml").send(buf);
} catch {
return reply.status(404).send();
}
});
app.get("/database/robots.txt", async (_request, reply) => {
try {
const buf = await readFile(join(uiDir, "robots.txt"));
return reply.type("text/plain; charset=utf-8").send(buf);
} catch {
return reply.status(404).send();
}
});
// SPA client-side route fallback. Concrete /database/* routes (/database/config, // SPA 回退:两个前缀,同一份 index.html。处理器不读请求、不查库 —— 所以
// /database/dev-login, and the asset routes above) are more specific, so // 启动时读一次缓存在闭包里是安全的(每个请求发出的字节完全相同)。
// Fastify's router matches them before this wildcard. Everything else under const sendIndex = async (_request: FastifyRequest, reply: FastifyReply): Promise<FastifyReply> =>
// /database serves index.html so SvelteKit's client router can resolve the view. 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); app.get("/app", sendIndex);
}); app.get("/app/*", sendIndex);
app.get("/database/*", async (_request, reply) => { app.get("/database", sendIndex);
return reply.type("text/html; charset=utf-8").send(indexHtml); app.get("/database/*", sendIndex);
});
} }