forked from bai/curriculum-project-hub
Merge remote-tracking branch 'origin/main' into feat/member-group-hierarchy
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
/**
|
||||
* 管理员后台「用户管理」「Group 管理」面板(复用 hub 已有 org 管理 API)。
|
||||
*
|
||||
* 用户管理 = org 成员(/api/org/:orgSlug/members);
|
||||
* Group 管理 = Team(当前 Group 的过渡实现,/api/org/:orgSlug/teams),
|
||||
* 真 Group 系统落地后此面板改接新 API 即可,文件库授权侧不动。
|
||||
*/
|
||||
|
||||
function apiBase(orgSlug: string): string {
|
||||
return `/api/org/${encodeURIComponent(orgSlug)}`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- 用户管理 */
|
||||
|
||||
export function renderUsersPanel(orgSlug: string): string {
|
||||
return `
|
||||
<div id="users-root" style="max-width:880px">
|
||||
<div class="panel" style="margin-bottom:14px">
|
||||
<div class="section-title">添加成员</div>
|
||||
<div class="inline-form">
|
||||
<input id="u-openid" class="input" placeholder="用户 openId(飞书 ou_ 开头)" style="flex:2"/>
|
||||
<input id="u-name" class="input" placeholder="显示名(可选)" style="flex:1"/>
|
||||
<select id="u-role" class="select" style="width:130px">
|
||||
<option value="MEMBER">普通老师</option>
|
||||
<option value="ADMIN">管理员</option>
|
||||
<option value="OWNER">所有者</option>
|
||||
</select>
|
||||
<button id="u-add" class="btn btn-primary">添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="section-title">成员列表</div>
|
||||
<table class="list">
|
||||
<thead><tr><th>成员</th><th>userId</th><th>角色</th><th></th></tr></thead>
|
||||
<tbody id="u-tbody"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const BASE = "${apiBase(orgSlug)}";
|
||||
const tbody = document.getElementById("u-tbody");
|
||||
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
const ROLE_LABEL = { OWNER: "所有者", ADMIN: "管理员", MEMBER: "普通老师" };
|
||||
async function req(path, opts = {}) {
|
||||
const res = await fetch(BASE + path, {
|
||||
credentials: "same-origin",
|
||||
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
||||
method: opts.method ?? "GET",
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) throw new Error((data && data.error && data.error.message) || res.statusText);
|
||||
return data;
|
||||
}
|
||||
async function load() {
|
||||
const { members } = await req("/members");
|
||||
tbody.innerHTML = members.length === 0
|
||||
? '<tr><td colspan="4" class="quiet" style="text-align:center;padding:18px">暂无成员</td></tr>'
|
||||
: members.map((m) =>
|
||||
"<tr>" +
|
||||
"<td>" + esc(m.displayName || m.userId) + "</td>" +
|
||||
'<td class="file-meta">' + esc(m.userId) + "</td>" +
|
||||
'<td><select class="select" style="width:110px;padding:3px 8px;font-size:12px" data-user="' + esc(m.userId) + '">' +
|
||||
["OWNER","ADMIN","MEMBER"].map((r) =>
|
||||
'<option value="' + r + '"' + (m.role === r ? " selected" : "") + ">" + ROLE_LABEL[r] + "</option>").join("") +
|
||||
"</select></td>" +
|
||||
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td>' +
|
||||
"</tr>").join("");
|
||||
tbody.querySelectorAll("select[data-user]").forEach((sel) => {
|
||||
sel.onchange = async () => {
|
||||
try { await req("/members/" + encodeURIComponent(sel.dataset.user), { method: "PATCH", body: { role: sel.value } }); }
|
||||
catch (e) { alert(e.message); load(); }
|
||||
};
|
||||
});
|
||||
tbody.querySelectorAll("button[data-revoke]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
if (!confirm("移除该成员?")) return;
|
||||
try { await req("/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); load(); }
|
||||
catch (e) { alert(e.message); }
|
||||
};
|
||||
});
|
||||
}
|
||||
document.getElementById("u-add").onclick = async () => {
|
||||
const openId = document.getElementById("u-openid").value.trim();
|
||||
if (!openId) return alert("请填写用户 openId");
|
||||
const displayName = document.getElementById("u-name").value.trim();
|
||||
try {
|
||||
await req("/members", { method: "POST", body: {
|
||||
feishuOpenId: openId,
|
||||
role: document.getElementById("u-role").value,
|
||||
...(displayName ? { displayName } : {}),
|
||||
}});
|
||||
document.getElementById("u-openid").value = "";
|
||||
document.getElementById("u-name").value = "";
|
||||
load();
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
load().catch((e) => { tbody.innerHTML = '<tr><td colspan="4" style="color:var(--danger);padding:12px">' + esc(e.message) + "</td></tr>"; });
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- Group 管理 */
|
||||
|
||||
export function renderGroupsPanel(orgSlug: string): string {
|
||||
return `
|
||||
<div id="groups-root" style="display:flex;gap:14px;align-items:flex-start">
|
||||
<div style="width:340px;flex-shrink:0">
|
||||
<div class="panel" style="margin-bottom:14px">
|
||||
<div class="section-title">新建 Group</div>
|
||||
<div class="form-row"><label class="form-label">标识(slug)</label><input id="g-slug" class="input" placeholder="physics-dept"/></div>
|
||||
<div class="form-row"><label class="form-label">名称</label><input id="g-name" class="input" placeholder="物理教研组"/></div>
|
||||
<div class="form-row"><label class="form-label">描述(可选)</label><input id="g-desc" class="input" placeholder="一句话说明"/></div>
|
||||
<div style="text-align:right"><button id="g-create" class="btn btn-primary">创建</button></div>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<div class="section-title">Group 列表</div>
|
||||
<div id="g-list"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="panel" style="flex:1;min-height:200px">
|
||||
<div id="g-detail" class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
const BASE = "${apiBase(orgSlug)}";
|
||||
const listEl = document.getElementById("g-list");
|
||||
const detailEl = document.getElementById("g-detail");
|
||||
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
let selected = null;
|
||||
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 loadList() {
|
||||
const { teams } = await req("/teams");
|
||||
listEl.innerHTML = teams.length === 0
|
||||
? '<div class="quiet" style="text-align:center;padding:12px">暂无 Group</div>'
|
||||
: teams.map((t) =>
|
||||
'<div class="g-team" data-id="' + esc(t.id) + '" data-name="' + esc(t.name) + '" style="display:flex;align-items:center;gap:8px;padding:8px 6px;border-radius:8px;cursor:pointer">' +
|
||||
'<span style="flex:1"><b>' + esc(t.name) + '</b> <span class="file-meta">' + esc(t.slug) + "</span></span>" +
|
||||
'<button class="link-danger" data-archive="' + esc(t.id) + '" style="font-size:11px">归档</button>' +
|
||||
"</div>").join("");
|
||||
listEl.querySelectorAll(".g-team").forEach((el) => {
|
||||
el.onclick = (e) => {
|
||||
if (e.target.closest("button")) return;
|
||||
selected = { id: el.dataset.id, name: el.dataset.name };
|
||||
listEl.querySelectorAll(".g-team").forEach((x) => x.style.background = "");
|
||||
el.style.background = "var(--selected)";
|
||||
loadMembers();
|
||||
};
|
||||
});
|
||||
listEl.querySelectorAll("button[data-archive]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
if (!confirm("归档该 Group?其成员授权将失效。")) return;
|
||||
try { await req("/teams/" + encodeURIComponent(btn.dataset.archive) + "/archive", { method: "POST" }); selected = null; detailEl.innerHTML = '<div class="quiet" style="padding:18px;text-align:center">从左侧选择一个 Group 查看成员</div>'; loadList(); }
|
||||
catch (e) { alert(e.message); }
|
||||
};
|
||||
});
|
||||
}
|
||||
async function loadMembers() {
|
||||
if (!selected) return;
|
||||
detailEl.innerHTML = '<div class="quiet" style="padding:12px;text-align:center">加载中…</div>';
|
||||
const { members } = await req("/teams/" + encodeURIComponent(selected.id) + "/members");
|
||||
detailEl.innerHTML =
|
||||
'<div class="section-title">' + esc(selected.name) + " · 成员(" + members.length + ")</div>" +
|
||||
'<table class="list"><tbody>' +
|
||||
(members.length === 0
|
||||
? '<tr><td class="quiet" style="text-align:center;padding:14px">暂无成员</td></tr>'
|
||||
: members.map((m) =>
|
||||
"<tr><td>" + esc(m.displayName || m.userId) + '</td><td class="file-meta">' + esc(m.userId) + "</td>" +
|
||||
'<td style="text-align:right"><button class="link-danger" data-revoke="' + esc(m.userId) + '">移除</button></td></tr>').join("")) +
|
||||
"</tbody></table>" +
|
||||
'<div class="divider"></div>' +
|
||||
'<div class="section-title">添加成员</div>' +
|
||||
'<div class="inline-form">' +
|
||||
'<input id="g-add-openid" class="input" placeholder="用户 openId 或 userId"/>' +
|
||||
'<button id="g-add" class="btn btn-primary">添加</button>' +
|
||||
"</div>";
|
||||
detailEl.querySelectorAll("button[data-revoke]").forEach((btn) => {
|
||||
btn.onclick = async () => {
|
||||
try { await req("/teams/" + encodeURIComponent(selected.id) + "/members/" + encodeURIComponent(btn.dataset.revoke) + "/revoke", { method: "POST" }); loadMembers(); }
|
||||
catch (e) { alert(e.message); }
|
||||
};
|
||||
});
|
||||
detailEl.querySelector("#g-add").onclick = async () => {
|
||||
const v = detailEl.querySelector("#g-add-openid").value.trim();
|
||||
if (!v) return alert("请填写用户 id");
|
||||
try {
|
||||
await req("/teams/" + encodeURIComponent(selected.id) + "/members", {
|
||||
method: "POST",
|
||||
body: v.startsWith("ou_") ? { feishuOpenId: v } : { userId: v },
|
||||
});
|
||||
loadMembers();
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
}
|
||||
document.getElementById("g-create").onclick = async () => {
|
||||
const slug = document.getElementById("g-slug").value.trim();
|
||||
const name = document.getElementById("g-name").value.trim();
|
||||
const description = document.getElementById("g-desc").value.trim();
|
||||
if (!slug || !name) return alert("slug 和名称必填");
|
||||
try {
|
||||
await req("/teams", { method: "POST", body: { slug, name, ...(description ? { description } : {}) } });
|
||||
document.getElementById("g-slug").value = "";
|
||||
document.getElementById("g-name").value = "";
|
||||
document.getElementById("g-desc").value = "";
|
||||
loadList();
|
||||
} catch (e) { alert(e.message); }
|
||||
};
|
||||
loadList().catch((e) => { listEl.innerHTML = '<div style="color:var(--danger);padding:12px">' + esc(e.message) + "</div>"; });
|
||||
})();
|
||||
</script>`;
|
||||
}
|
||||
@@ -22,7 +22,20 @@
|
||||
*/
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
|
||||
import path from "node:path";
|
||||
import { SESSION_COOKIE_NAME, signSession, verifySession } from "../../admin/auth/session.js";
|
||||
import { registerFileLibRoutes } from "./filelibRoutes.js";
|
||||
import { registerFileRoutes } from "./fileRoutes.js";
|
||||
import { registerTeacherApp } from "./teacherApp.js";
|
||||
import { renderLibraryBrowser } from "./libraryBrowser.js";
|
||||
import { renderGroupsPanel, renderUsersPanel } from "./adminPanels.js";
|
||||
import { createInMemoryVersionStore } from "../filelib/versionStore.js";
|
||||
import { createTeamGroupResolver } from "../filelib/groupResolver.js";
|
||||
import { createHttpGroupResolver } from "../filelib/groupResolverHttp.js";
|
||||
import { createManifestStubAdapter } from "../filelib/exportService.js";
|
||||
import { FILE_LIB_AUDIT_ACTIONS } from "../filelib/audit.js";
|
||||
import { UI_HEAD_FONTS, UI_THEME_CSS } from "./uiTheme.js";
|
||||
import type { FileLibRouteDeps } from "../filelib/routeShared.js";
|
||||
|
||||
export interface DatabaseRouteConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
@@ -38,14 +51,22 @@ export async function registerDatabaseRoutes(
|
||||
app: FastifyInstance,
|
||||
config: DatabaseRouteConfig,
|
||||
): Promise<void> {
|
||||
// Unauthenticated bootstrap for the static SPA login page. Exposes only what
|
||||
// the page needs to build the Feishu login link and toggle the dev button —
|
||||
// no secrets, no user data.
|
||||
app.get("/database/config", async () => {
|
||||
return {
|
||||
siloOrganizationSlug: config.siloOrganizationSlug,
|
||||
devLoginEnabled: config.allowDevLoginBypass,
|
||||
};
|
||||
// 文件库依赖在下方装配;概览页统计在请求时经此引用读取(请求一定晚于装配完成)。
|
||||
let filelibDepsForStats: FileLibRouteDeps | null = null;
|
||||
|
||||
app.get("/database/admin", async (request, reply) => {
|
||||
// Already signed in → straight to the dashboard.
|
||||
if ((await resolveUser(request.cookies[SESSION_COOKIE_NAME], config)) !== null) {
|
||||
return reply.redirect("/database/dashboard");
|
||||
}
|
||||
return reply.type("text/html").send(renderLoginPage(config));
|
||||
});
|
||||
|
||||
app.get("/database/dashboard", async (request, reply) => {
|
||||
const user = await resolveUser(request.cookies[SESSION_COOKIE_NAME], config);
|
||||
if (user === null) return reply.redirect("/database/admin");
|
||||
const stats = await loadDashboardStats(config.prisma, filelibDepsForStats);
|
||||
return reply.type("text/html").send(renderDashboard(user.displayName, stats, config.siloOrganizationSlug));
|
||||
});
|
||||
|
||||
// DEV ONLY bypass — self-contained here, registered only when the flag is on
|
||||
@@ -99,8 +120,306 @@ export async function registerDatabaseRoutes(
|
||||
});
|
||||
}
|
||||
|
||||
// Add more /database/* JSON routes here. Guard data routes with requireSession
|
||||
// / requireOrgRole (../../admin/auth/guards.js) and scope every query to the
|
||||
// caller's org (ADR-0020). Access the DB via config.prisma. Register concrete
|
||||
// routes before registerDatabaseSpa's /database/* fallback (done in ./plugin.ts).
|
||||
// 文件库(独立模块,《文件库-接口契约.md》):API + 浏览页 + 老师端 /app。
|
||||
// 依赖装配:VersionStore 当前为内存+快照实现(版本团队 npm 包到位后替换);
|
||||
// GroupResolver 默认读 hub Team,HUB_GROUP_SERVICE_URL 配置后切 HTTP(C2);
|
||||
// 导出适配器当前为 manifest stub(OPEN-6,真导出工具到位后替换)。
|
||||
const siloOrg = await config.prisma.organization.findUnique({
|
||||
where: { slug: config.siloOrganizationSlug },
|
||||
select: { id: true },
|
||||
});
|
||||
if (siloOrg === null) {
|
||||
app.log.warn({ slug: config.siloOrganizationSlug }, "filelib: silo organization not found, routes not registered");
|
||||
return;
|
||||
}
|
||||
const storageRoot = process.env["HUB_FILELIB_STORAGE_ROOT"] ?? path.resolve(".filelib-repos");
|
||||
const versionStore = createInMemoryVersionStore(path.join(storageRoot, ".version-store.json"));
|
||||
const groupServiceUrl = process.env["HUB_GROUP_SERVICE_URL"];
|
||||
const filelibDeps: FileLibRouteDeps = {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
organizationId: siloOrg.id,
|
||||
storageRoot,
|
||||
groupResolver: groupServiceUrl === undefined || groupServiceUrl.trim() === ""
|
||||
? createTeamGroupResolver(config.prisma, siloOrg.id)
|
||||
: createHttpGroupResolver({ baseUrl: groupServiceUrl }),
|
||||
versionStore,
|
||||
exportAdapters: [createManifestStubAdapter(versionStore)],
|
||||
};
|
||||
await registerFileLibRoutes(app, filelibDeps);
|
||||
await registerFileRoutes(app, filelibDeps);
|
||||
await registerTeacherApp(app, {
|
||||
prisma: config.prisma,
|
||||
sessionSecret: config.sessionSecret,
|
||||
siloOrganizationSlug: config.siloOrganizationSlug,
|
||||
allowDevLoginBypass: config.allowDevLoginBypass,
|
||||
});
|
||||
|
||||
// 独立文件库页已并入后台「文件库」tab(/database/dashboard#library),旧地址跳转保留兼容。
|
||||
app.get("/database/library", async (_request, reply) =>
|
||||
reply.redirect("/database/dashboard#library"),
|
||||
);
|
||||
|
||||
filelibDepsForStats = filelibDeps;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ 概览页统计 */
|
||||
|
||||
interface DashboardStats {
|
||||
readonly folders: number;
|
||||
readonly projects: number;
|
||||
readonly files: number;
|
||||
readonly grants: number;
|
||||
readonly recent: ReadonlyArray<{
|
||||
readonly action: string;
|
||||
readonly actor: string;
|
||||
readonly label: string;
|
||||
readonly when: Date;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** 概览页统计:org 范围内的文件夹/项目/授权(DB)+ 文件(版本库)+ 最近活动(AuditEntry)。 */
|
||||
async function loadDashboardStats(
|
||||
prisma: PrismaClient,
|
||||
deps: FileLibRouteDeps | null,
|
||||
): Promise<DashboardStats | null> {
|
||||
if (deps === null) return null;
|
||||
const organizationId = deps.organizationId;
|
||||
const [folders, projects, grants] = await Promise.all([
|
||||
prisma.fileLibNode.count({ where: { organizationId, kind: "FOLDER", deletedAt: null } }),
|
||||
prisma.fileLibNode.count({ where: { organizationId, kind: "PROJECT", deletedAt: null } }),
|
||||
prisma.fileLibGrant.count({ where: { organizationId, revokedAt: null } }),
|
||||
]);
|
||||
// 文件计数:遍历 READY 项目问版本库(demo 规模;真版本包到位后应换成存储侧统计)
|
||||
const readyProjects = await prisma.fileLibNode.findMany({
|
||||
where: {
|
||||
organizationId, kind: "PROJECT", deletedAt: null,
|
||||
provisionStatus: "READY", storageDir: { not: null },
|
||||
},
|
||||
select: { storageDir: true },
|
||||
});
|
||||
let files = 0;
|
||||
for (const project of readyProjects) {
|
||||
if (project.storageDir === null) continue;
|
||||
try {
|
||||
files += (await deps.versionStore.list(project.storageDir)).length;
|
||||
} catch { /* repo 缺失(如重启未恢复)不计 */ }
|
||||
}
|
||||
const entries = await prisma.auditEntry.findMany({
|
||||
where: { organizationId, action: { in: Object.values(FILE_LIB_AUDIT_ACTIONS) } },
|
||||
orderBy: { createdAt: "desc" },
|
||||
take: 8,
|
||||
});
|
||||
const actorIds = [...new Set(entries.map((e) => e.actorUserId).filter((x): x is string => x !== null))];
|
||||
const users = actorIds.length === 0
|
||||
? []
|
||||
: await prisma.user.findMany({ where: { id: { in: actorIds } }, select: { id: true, displayName: true } });
|
||||
const nameById = new Map(users.map((u) => [u.id, u.displayName]));
|
||||
const recent = entries.map((entry) => {
|
||||
const meta = (entry.metadata ?? {}) as Record<string, unknown>;
|
||||
const label =
|
||||
(typeof meta["name"] === "string" ? meta["name"] : undefined) ??
|
||||
(typeof meta["to"] === "string" ? meta["to"] : undefined) ??
|
||||
(typeof meta["path"] === "string" ? meta["path"] : undefined) ??
|
||||
(typeof meta["objectId"] === "string" ? meta["objectId"].slice(0, 8) : "");
|
||||
return {
|
||||
action: entry.action,
|
||||
actor: nameById.get(entry.actorUserId ?? "") ?? entry.actorUserId ?? "unknown",
|
||||
label,
|
||||
when: entry.createdAt,
|
||||
};
|
||||
});
|
||||
return { folders, projects, files, grants, recent };
|
||||
}
|
||||
|
||||
/** Verify the session cookie and load the user, or null if not signed in. */
|
||||
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="font-size:10.5px;color:var(--text-3)">已登录</p>
|
||||
<p style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:12.5px;color:var(--text)">${escapeHtml(displayName)}</p>
|
||||
</div>
|
||||
<button id="logout" class="btn" style="padding:3px 10px;font-size:11px">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<div 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-y:auto;padding:28px">
|
||||
<h1 style="font-size:18px;font-weight:600;margin-bottom:16px">Group 管理</h1>
|
||||
${renderGroupsPanel(orgSlug)}
|
||||
</section>
|
||||
|
||||
<section id="tab-search" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
|
||||
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">查询</h1>
|
||||
<p style="font-size:12.5px;color:var(--text-3)">查询功能建设中</p>
|
||||
</section>
|
||||
|
||||
<section id="tab-settings" class="admin-tab-section" style="display:none;flex:1;overflow-y:auto;padding:28px">
|
||||
<h1 style="font-size:18px;font-weight:600;margin-bottom:4px">设置</h1>
|
||||
<p style="font-size:12.5px;color:var(--text-3)">设置功能建设中</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const tabs = [...document.querySelectorAll(".admin-tab")];
|
||||
const sections = Object.fromEntries(
|
||||
[...document.querySelectorAll(".admin-tab-section")].map((s) => [s.id.replace("tab-", ""), s]),
|
||||
);
|
||||
function activate(id) {
|
||||
tabs.forEach((t) => {
|
||||
const on = t.dataset.tab === id;
|
||||
t.style.background = on ? "var(--selected)" : "none";
|
||||
t.style.color = on ? "var(--text)" : "var(--text-3)";
|
||||
t.style.fontWeight = on ? "600" : "400";
|
||||
});
|
||||
Object.entries(sections).forEach(([key, s]) => {
|
||||
s.style.display = key === id ? (key === "library" ? "block" : "block") : "none";
|
||||
});
|
||||
if (location.hash !== "#" + id) history.replaceState(null, "", "#" + id);
|
||||
}
|
||||
tabs.forEach((t) => t.addEventListener("click", () => activate(t.dataset.tab)));
|
||||
document.getElementById("logout").addEventListener("click", async () => {
|
||||
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch (e) {}
|
||||
location.href = "/database/admin";
|
||||
});
|
||||
const fromHash = location.hash.replace(/^#/, "");
|
||||
activate(tabs.some((t) => t.dataset.tab === fromHash) ? fromHash : "overview");
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
function escapeHtml(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,235 @@
|
||||
/**
|
||||
* /database/api/* 文件内容与导出端点(契约 9.3/9.4)。
|
||||
* 冲突流:POST commits 返回 200 {version} 或 409 {currentVersion}(编辑 UI 拉 diff 后重提)。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance, FastifyRequest } from "fastify";
|
||||
import {
|
||||
commitFile,
|
||||
deleteFile,
|
||||
diffFile,
|
||||
fileHistory,
|
||||
listFiles,
|
||||
readFile,
|
||||
readFileRaw,
|
||||
type FileContentEncoding,
|
||||
} from "../filelib/fileService.js";
|
||||
import {
|
||||
createManifestStubAdapter,
|
||||
downloadExport,
|
||||
getExportJob,
|
||||
submitExport,
|
||||
} from "../filelib/exportService.js";
|
||||
import { FileLibError } from "../filelib/model.js";
|
||||
import {
|
||||
actorOrNull,
|
||||
bodyObject,
|
||||
optionalString,
|
||||
requireString,
|
||||
sendRouteError,
|
||||
type FileLibRouteDeps,
|
||||
} from "../filelib/routeShared.js";
|
||||
|
||||
export async function registerFileRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: FileLibRouteDeps,
|
||||
): Promise<void> {
|
||||
const fileDeps = {
|
||||
prisma: deps.prisma,
|
||||
organizationId: deps.organizationId,
|
||||
groupResolver: deps.groupResolver,
|
||||
versionStore: deps.versionStore,
|
||||
};
|
||||
const exportDeps = { ...fileDeps, adapters: deps.exportAdapters };
|
||||
|
||||
function pathParam(request: FastifyRequest): string {
|
||||
const path = (request.query as { path?: string }).path;
|
||||
if (path === undefined) throw new FileLibError(400, "invalid_request", "missing query: path");
|
||||
return path;
|
||||
}
|
||||
|
||||
function encodingParam(raw: unknown): FileContentEncoding {
|
||||
if (raw === undefined || raw === "utf8") return "utf8";
|
||||
if (raw === "base64") return "base64";
|
||||
throw new FileLibError(400, "invalid_request", "encoding must be utf8 or base64");
|
||||
}
|
||||
|
||||
app.get("/database/api/projects/:id/files", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const prefix = (request.query as { prefix?: string }).prefix;
|
||||
return { files: await listFiles(fileDeps, actor, id, prefix) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/projects/:id/file", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return await readFile(fileDeps, actor, id, pathParam(request));
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 原始字节下载(Content-Disposition: attachment;浏览器直接 save-as)
|
||||
app.get("/database/api/projects/:id/file/raw", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const raw = await readFileRaw(fileDeps, actor, id, pathParam(request));
|
||||
return reply
|
||||
.header("Content-Disposition",
|
||||
`attachment; filename="${encodeURIComponent(raw.filename)}"; filename*=UTF-8''${encodeURIComponent(raw.filename)}`)
|
||||
.header("X-Content-Version", raw.version)
|
||||
.type("application/octet-stream")
|
||||
.send(raw.buffer);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 新建/上传(baseVersion 恒 null;已存在 → 409)
|
||||
app.put("/database/api/projects/:id/file", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const result = await commitFile(fileDeps, actor, id, {
|
||||
path: requireString(body, "path"),
|
||||
baseVersion: null,
|
||||
content: requireString(body, "content"),
|
||||
encoding: encodingParam(body["encoding"]),
|
||||
message: optionalString(body, "message"),
|
||||
});
|
||||
return reply.status(201).send(result);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// 提交编辑(乐观并发;409 → details.currentVersion + file.conflict_detected 审计)
|
||||
app.post("/database/api/projects/:id/file/commits", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const baseVersion = body["baseVersion"];
|
||||
if (typeof baseVersion !== "string" || baseVersion === "") {
|
||||
throw new FileLibError(400, "invalid_request", "baseVersion must be a non-empty string");
|
||||
}
|
||||
return await commitFile(fileDeps, actor, id, {
|
||||
path: requireString(body, "path"),
|
||||
baseVersion,
|
||||
content: requireString(body, "content"),
|
||||
encoding: encodingParam(body["encoding"]),
|
||||
message: optionalString(body, "message"),
|
||||
});
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/projects/:id/file", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
await deleteFile(fileDeps, actor, id, pathParam(request), requireString(body, "baseVersion"));
|
||||
return reply.status(204).send();
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/projects/:id/file/diff", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const query = request.query as { from?: string; to?: string };
|
||||
if (query.from === undefined || query.to === undefined) {
|
||||
throw new FileLibError(400, "invalid_request", "missing query: from / to");
|
||||
}
|
||||
return await diffFile(fileDeps, actor, id, pathParam(request), query.from, query.to);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/projects/:id/file/history", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const rawLimit = (request.query as { limit?: string }).limit;
|
||||
const limit = rawLimit === undefined ? undefined : Number.parseInt(rawLimit, 10);
|
||||
if (limit !== undefined && (!Number.isSafeInteger(limit) || limit <= 0)) {
|
||||
throw new FileLibError(400, "invalid_request", "limit must be a positive integer");
|
||||
}
|
||||
return { history: await fileHistory(fileDeps, actor, id, pathParam(request), limit) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 导出(D10 异步) */
|
||||
|
||||
app.post("/database/api/projects/:id/exports", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const params = body["params"];
|
||||
if (params !== undefined && (typeof params !== "object" || params === null || Array.isArray(params))) {
|
||||
throw new FileLibError(400, "invalid_request", "params must be an object");
|
||||
}
|
||||
const job = await submitExport(
|
||||
exportDeps,
|
||||
actor,
|
||||
id,
|
||||
requireString(body, "target"),
|
||||
(params as Record<string, unknown> | undefined) ?? {},
|
||||
);
|
||||
return reply.status(202).send({ jobId: job.id, status: job.status });
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/exports/:jobId", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { jobId } = request.params as { jobId: string };
|
||||
return await getExportJob(exportDeps, actor, jobId);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/exports/:jobId/download", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { jobId } = request.params as { jobId: string };
|
||||
const artifact = await downloadExport(exportDeps, actor, jobId);
|
||||
return reply
|
||||
.header("Content-Disposition", `attachment; filename="${artifact.filename}"`)
|
||||
.type("application/octet-stream")
|
||||
.send(artifact.content);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,310 @@
|
||||
/**
|
||||
* /database/api/* 树与授权端点(契约 9.1/9.2 + C2 过渡搜索)。
|
||||
* 约定:绝对路径;actorOrNull 前置;业务全走 filelib 服务层;错误统一 sendRouteError。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import {
|
||||
breadcrumb,
|
||||
createNode,
|
||||
getEffectiveRole,
|
||||
listChildren,
|
||||
moveNode,
|
||||
renameNode,
|
||||
softDeleteNode,
|
||||
updateNodeDescription,
|
||||
type FileLibActor,
|
||||
type InitialGrant,
|
||||
} from "../filelib/treeService.js";
|
||||
import {
|
||||
forceAdjustGrants,
|
||||
listGrants,
|
||||
putGrants,
|
||||
revokeGrant,
|
||||
setIndependentPermission,
|
||||
} from "../filelib/grantService.js";
|
||||
import { FileLibError } from "../filelib/model.js";
|
||||
import {
|
||||
actorOrNull,
|
||||
bodyObject,
|
||||
optionalString,
|
||||
requireString,
|
||||
sendRouteError,
|
||||
treeDeps,
|
||||
type FileLibRouteDeps,
|
||||
} from "../filelib/routeShared.js";
|
||||
|
||||
export async function registerFileLibRoutes(
|
||||
app: FastifyInstance,
|
||||
deps: FileLibRouteDeps,
|
||||
): Promise<void> {
|
||||
const tree = treeDeps(deps);
|
||||
const grantDeps = { prisma: deps.prisma, organizationId: deps.organizationId, groupResolver: deps.groupResolver };
|
||||
|
||||
/* ------------------------------------------------------------ 身份 */
|
||||
|
||||
// 前端判断能力面用:是否网站管理员(root 创建按钮显隐)。
|
||||
app.get("/database/api/me", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
return { userId: actor.userId, isWebsiteAdmin: actor.isWebsiteAdmin };
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 树节点 */
|
||||
|
||||
app.get("/database/api/nodes", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const query = request.query as { parentId?: string };
|
||||
const parentId = query.parentId === undefined || query.parentId === "" ? null : query.parentId;
|
||||
return { nodes: await listChildren(tree, actor, parentId) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post("/database/api/nodes", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const body = bodyObject(request.body);
|
||||
const parentIdRaw = body["parentId"];
|
||||
if (parentIdRaw !== null && typeof parentIdRaw !== "string") {
|
||||
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
|
||||
}
|
||||
const kindRaw = requireString(body, "kind");
|
||||
if (kindRaw !== "FOLDER" && kindRaw !== "PROJECT") {
|
||||
throw new FileLibError(400, "invalid_request", "kind must be FOLDER or PROJECT");
|
||||
}
|
||||
const grants = parseGrants(body["grants"]);
|
||||
const description = optionalString(body, "description");
|
||||
const node = await createNode(tree, actor, {
|
||||
parentId: parentIdRaw,
|
||||
kind: kindRaw,
|
||||
name: requireString(body, "name"),
|
||||
description: description || undefined,
|
||||
grants,
|
||||
});
|
||||
return reply.status(201).send({ node });
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/nodes/:id", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const role = await getEffectiveRole(tree, actor, id); // 无权限即 404(D8)
|
||||
const node = await deps.prisma.fileLibNode.findFirst({
|
||||
where: { id, organizationId: deps.organizationId },
|
||||
});
|
||||
if (node === null) throw new FileLibError(404, "node_not_found", "node not found");
|
||||
const settings = node.kind === "PROJECT"
|
||||
? await deps.prisma.fileLibProjectSettings.findUnique({ where: { nodeId: node.id } })
|
||||
: null;
|
||||
return {
|
||||
node: {
|
||||
id: node.id,
|
||||
parentId: node.parentId,
|
||||
kind: node.kind,
|
||||
name: node.name,
|
||||
description: node.description,
|
||||
role,
|
||||
provisionStatus: node.provisionStatus,
|
||||
independentPermission: settings?.independentPermissionsEnabled ?? false,
|
||||
createdAt: node.createdAt,
|
||||
updatedAt: node.updatedAt,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.patch("/database/api/nodes/:id", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const name = optionalString(body, "name");
|
||||
const hasParent = Object.prototype.hasOwnProperty.call(body, "parentId");
|
||||
const hasDesc = Object.prototype.hasOwnProperty.call(body, "description");
|
||||
if (name === undefined && !hasParent && !hasDesc) {
|
||||
throw new FileLibError(400, "invalid_request", "nothing to update (name? parentId? description?)");
|
||||
}
|
||||
let node;
|
||||
if (name !== undefined) node = await renameNode(tree, actor, id, name);
|
||||
if (hasParent) {
|
||||
const parentId = body["parentId"];
|
||||
if (parentId !== null && typeof parentId !== "string") {
|
||||
throw new FileLibError(400, "invalid_request", "parentId must be a string or null");
|
||||
}
|
||||
node = await moveNode(tree, actor, id, parentId);
|
||||
}
|
||||
if (hasDesc) {
|
||||
const d = body["description"];
|
||||
if (d !== null && typeof d !== "string") {
|
||||
throw new FileLibError(400, "invalid_request", "description must be a string or null");
|
||||
}
|
||||
node = await updateNodeDescription(tree, actor, id, typeof d === "string" ? d : null);
|
||||
}
|
||||
return { node };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/nodes/:id", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
await softDeleteNode(tree, actor, id);
|
||||
return reply.status(204).send();
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/nodes/:id/breadcrumb", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return { breadcrumb: await breadcrumb(tree, actor, id) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get("/database/api/nodes/:id/effective-permission", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return { role: await getEffectiveRole(tree, actor, id) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ 授权 */
|
||||
|
||||
app.get("/database/api/nodes/:id/grants", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
return { grants: await listGrants(grantDeps, actor, id) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/database/api/nodes/:id/grants", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const items = parseGrants(body["grants"]);
|
||||
if (items === undefined) throw new FileLibError(400, "invalid_request", "missing field: grants");
|
||||
return await putGrants(grantDeps, actor, id, items);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.delete("/database/api/nodes/:id/grants/:grantId", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id, grantId } = request.params as { id: string; grantId: string };
|
||||
await revokeGrant(grantDeps, actor, id, grantId);
|
||||
return reply.status(204).send();
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
// D19 高危通道:网站管理员凭 id 强制调整,全部留 admin.force_adjust 审计。
|
||||
app.put("/database/api/admin/nodes/:id/grants", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
const items = parseGrants(body["grants"]);
|
||||
if (items === undefined) throw new FileLibError(400, "invalid_request", "missing field: grants");
|
||||
return await forceAdjustGrants(grantDeps, actor, id, items);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
app.put("/database/api/projects/:id/independent-permission", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const { id } = request.params as { id: string };
|
||||
const body = bodyObject(request.body);
|
||||
if (typeof body["enabled"] !== "boolean") {
|
||||
throw new FileLibError(400, "invalid_request", "enabled must be a boolean");
|
||||
}
|
||||
return await setIndependentPermission(grantDeps, actor, id, body["enabled"]);
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
|
||||
/* ------------------------------------------------------------ Group 搜索(过渡) */
|
||||
|
||||
// 过渡实现:读 hub Team(C2 /groups/search 由 Group 团队交付后切换)。
|
||||
app.get("/database/api/groups/search", async (request, reply) => {
|
||||
const actor = await actorOrNull(request, reply, deps);
|
||||
if (actor === null) return reply;
|
||||
try {
|
||||
const q = ((request.query as { q?: string }).q ?? "").trim();
|
||||
const teams = await deps.prisma.team.findMany({
|
||||
where: {
|
||||
organizationId: deps.organizationId,
|
||||
archivedAt: null,
|
||||
...(q === "" ? {} : { name: { contains: q, mode: "insensitive" as const } }),
|
||||
},
|
||||
take: 20,
|
||||
orderBy: { name: "asc" },
|
||||
select: { id: true, name: true },
|
||||
});
|
||||
return { groups: teams.map((t) => ({ id: t.id, name: t.name, breadcrumb: t.name })) };
|
||||
} catch (error) {
|
||||
return sendRouteError(reply, error);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function parseGrants(raw: unknown): InitialGrant[] | undefined {
|
||||
if (raw === undefined) return undefined;
|
||||
if (!Array.isArray(raw)) throw new FileLibError(400, "invalid_request", "grants must be an array");
|
||||
return raw.map((item) => {
|
||||
if (typeof item !== "object" || item === null) {
|
||||
throw new FileLibError(400, "invalid_request", "grant entries must be objects");
|
||||
}
|
||||
const grant = item as Record<string, unknown>;
|
||||
const principalType = grant["principalType"];
|
||||
if (principalType !== "USER" && principalType !== "GROUP") {
|
||||
throw new FileLibError(400, "invalid_request", "grant.principalType must be USER or GROUP");
|
||||
}
|
||||
const role = grant["role"];
|
||||
if (role !== "VIEW" && role !== "EDIT" && role !== "MANAGE") {
|
||||
throw new FileLibError(400, "invalid_request", "grant.role must be VIEW, EDIT or MANAGE");
|
||||
}
|
||||
if (typeof grant["principalId"] !== "string" || grant["principalId"] === "") {
|
||||
throw new FileLibError(400, "invalid_request", "grant.principalId must be a non-empty string");
|
||||
}
|
||||
return { principalType, principalId: grant["principalId"], role };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* 管理员后台「文件库」页内浏览器区块(树 | 内容 | 预览三栏,含授权管理)。
|
||||
*
|
||||
* renderLibraryBrowser() 返回可嵌入任意后台布局的 HTML+JS 片段:
|
||||
* 以 #lib-root 为根、内部用 q() 作用域选择器,不与宿主页其他元素冲突。
|
||||
* 与独立页版浏览器的区别:去掉了自己的 wordmark/用户栏(由后台外壳提供),
|
||||
* 保留管理员工具(根目录创建、授权面板、独立权限开关)。
|
||||
*/
|
||||
|
||||
export function renderLibraryBrowser(): string {
|
||||
return `
|
||||
<div id="lib-root" style="display:flex;height:100%;min-height:0">
|
||||
<!-- 左:目录树 -->
|
||||
<div style="width:250px;flex-shrink:0;border-right:1px solid var(--border-soft);display:flex;flex-direction:column;background:var(--sidebar)">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;padding:12px 14px;border-bottom:1px solid var(--border-soft)">
|
||||
<span style="font-size:13px;font-weight:600;color:var(--text)">文件库</span>
|
||||
<button id="lib-btn-new-root" class="btn hidden" style="padding:3px 10px;font-size:11px" title="新建根目录">+ 根目录</button>
|
||||
</div>
|
||||
<div id="lib-tree" style="flex:1;overflow-y:auto;padding:8px 8px 16px;font-size:13px"></div>
|
||||
</div>
|
||||
|
||||
<!-- 中:节点内容 -->
|
||||
<div id="lib-main" style="flex:1;overflow-y:auto;min-width:0">
|
||||
<div class="lib-empty-hint">从左侧选择一个文件夹或项目</div>
|
||||
</div>
|
||||
|
||||
<!-- 右:预览/编辑(选中文件时出现) -->
|
||||
<div id="lib-preview" style="display:none;width:44%;min-width:380px;flex-shrink:0;overflow-y:auto;border-left:1px solid var(--border-soft);padding:16px"></div>
|
||||
|
||||
<div id="lib-modal-root"></div>
|
||||
<div id="lib-toast-root" style="position:fixed;bottom:16px;right:16px;z-index:50;display:flex;flex-direction:column;gap:8px"></div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
#lib-root .lib-empty-hint { height:100%;display:flex;align-items:center;justify-content:center;color:var(--text-3);font-size:13px; }
|
||||
#lib-root .tree-item { display:flex;align-items:center;gap:4px;padding:4px 6px;border-radius:6px;cursor:pointer;color:var(--text);transition:background 120ms ease;user-select:none; }
|
||||
#lib-root .tree-item:hover { background:var(--hover); }
|
||||
#lib-root .tree-item.selected { background:var(--selected); }
|
||||
#lib-root .tree-children { border-left:1px solid var(--guide);margin-left:15px;padding-left:3px; }
|
||||
#lib-root .tree-icon { width:16px;height:16px;flex-shrink:0;display:flex;align-items:center;justify-content:center; }
|
||||
#lib-root .tree-caret { color:var(--text-3); }
|
||||
#lib-root .tree-badge { margin-left:auto;padding-right:4px;font-size:10px;color:var(--text-3);font-family:var(--mono); }
|
||||
#lib-root .tree-empty { padding:24px 12px;text-align:center;font-size:12px;color:var(--text-3); }
|
||||
#lib-root .lib-toast { border-radius:8px;padding:8px 14px;font-size:12px;color:#fff;background:#333230;max-width:320px; }
|
||||
#lib-root .lib-toast-err { background:#7E2C26; }
|
||||
#lib-root .modal-mask { position:fixed;inset:0;z-index:40;background:rgba(26,26,24,.3);display:flex;align-items:center;justify-content:center;padding:16px; }
|
||||
#lib-root .modal-card { width:100%;max-width:440px;background:var(--panel);border:1px solid var(--border);border-radius:12px;padding:22px; }
|
||||
#lib-root pre.diff { white-space:pre-wrap;font-family:var(--mono);font-size:12px;line-height:1.7;background:var(--panel);border:1px solid var(--border);border-radius:8px;padding:10px;margin:0; }
|
||||
#lib-root pre.diff .add { display:block;color:var(--diff-add-text);background:var(--diff-add-bg); }
|
||||
#lib-root pre.diff .del { display:block;color:var(--diff-del-text);background:var(--diff-del-bg); }
|
||||
#lib-root .meta-grid { display:grid;grid-template-columns:1fr 1fr;gap:10px 24px;font-size:13px;color:var(--text-2); }
|
||||
#lib-root .meta-grid b { color:var(--text);font-weight:600; }
|
||||
#lib-root .conflict-panel { margin-top:14px;border:1px solid #E8E2C8;background:#FCFBF4;border-radius:10px;padding:14px 16px; }
|
||||
#lib-root .conflict-title { font-size:13px;font-weight:600;color:#6E6329;margin-bottom:8px; }
|
||||
#lib-root .conflict-note { font-size:11px;color:#8A8059;margin-top:8px; }
|
||||
#lib-root .export-status { font-size:11px;color:var(--text-3);font-family:var(--mono); }
|
||||
</style>
|
||||
|
||||
<script>
|
||||
(function () {
|
||||
const libRoot = document.getElementById("lib-root");
|
||||
if (!libRoot) return;
|
||||
const $ = (sel) => libRoot.querySelector(sel);
|
||||
const $$ = (sel) => [...libRoot.querySelectorAll(sel)];
|
||||
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
||||
...opts,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
|
||||
if (res.status === 204) return null;
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = (data && data.error) || { code: "unknown", message: res.statusText };
|
||||
const e = new Error(err.message); e.code = err.code; e.status = res.status; Object.assign(e, err);
|
||||
throw e;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function toast(msg, kind = "info") {
|
||||
const el = document.createElement("div");
|
||||
el.className = "lib-toast" + (kind === "err" ? " lib-toast-err" : "");
|
||||
el.textContent = msg;
|
||||
$("#lib-toast-root").appendChild(el);
|
||||
setTimeout(() => el.remove(), 3600);
|
||||
}
|
||||
const ok = (m) => toast(m, "ok");
|
||||
const fail = (e) => toast(e.message || String(e), "err");
|
||||
|
||||
function modal(html) {
|
||||
const root = $("#lib-modal-root");
|
||||
root.innerHTML = '<div class="modal-mask"><div class="modal-card">' + html + "</div></div>";
|
||||
root.firstElementChild.addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
|
||||
}
|
||||
function closeModal() { $("#lib-modal-root").innerHTML = ""; }
|
||||
|
||||
let me = { userId: null, isWebsiteAdmin: false };
|
||||
let current = null;
|
||||
const expanded = new Set();
|
||||
let currentFile = null;
|
||||
|
||||
const ICONS = {
|
||||
caretRight: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M9 6l6 6-6 6z"/></svg>',
|
||||
caretDown: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M6 9l6 6 6-6z"/></svg>',
|
||||
folder: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></svg>',
|
||||
project: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>',
|
||||
};
|
||||
|
||||
async function loadChildren(parentId) {
|
||||
const q = parentId === null ? "" : "?parentId=" + encodeURIComponent(parentId);
|
||||
return (await api("/database/api/nodes" + q)).nodes;
|
||||
}
|
||||
|
||||
async function renderTree() {
|
||||
const tree = $("#lib-tree");
|
||||
tree.innerHTML = "";
|
||||
try {
|
||||
const roots = await loadChildren(null);
|
||||
if (roots.length === 0) {
|
||||
tree.innerHTML = '<div class="tree-empty">空文件库' +
|
||||
(me.isWebsiteAdmin ? " · 点上方「+ 根目录」开始" : "") + "</div>";
|
||||
return;
|
||||
}
|
||||
for (const node of roots) tree.appendChild(await treeItem(node, 0));
|
||||
} catch (e) { tree.innerHTML = '<div class="tree-empty">' + esc(e.message) + "</div>"; }
|
||||
}
|
||||
|
||||
async function treeItem(node, depth) {
|
||||
const wrap = document.createElement("div");
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-item" + (current && current.id === node.id ? " selected" : "");
|
||||
const caret = document.createElement("span");
|
||||
caret.className = "tree-icon tree-caret";
|
||||
if (node.kind === "FOLDER") {
|
||||
caret.innerHTML = expanded.has(node.id) ? ICONS.caretDown : ICONS.caretRight;
|
||||
caret.onclick = (e) => { e.stopPropagation(); toggleExpand(node.id); };
|
||||
}
|
||||
row.appendChild(caret);
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "tree-icon";
|
||||
iconSpan.style.color = node.kind === "PROJECT" ? "#1A1A18" : "#9C9B96";
|
||||
iconSpan.innerHTML = node.kind === "PROJECT" ? ICONS.project : ICONS.folder;
|
||||
row.appendChild(iconSpan);
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "truncate";
|
||||
nameSpan.textContent = node.name;
|
||||
row.appendChild(nameSpan);
|
||||
if (node.role !== "MANAGE") {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "tree-badge";
|
||||
badge.textContent = node.role;
|
||||
row.appendChild(badge);
|
||||
}
|
||||
row.onclick = () => {
|
||||
if (node.kind === "FOLDER") {
|
||||
if (expanded.has(node.id)) expanded.delete(node.id); else expanded.add(node.id);
|
||||
}
|
||||
selectNode(node.id);
|
||||
};
|
||||
wrap.appendChild(row);
|
||||
if (node.kind === "FOLDER" && expanded.has(node.id)) {
|
||||
wrap.appendChild(await childrenBlock(node.id, depth));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function childrenBlock(parentId, depth) {
|
||||
const block = document.createElement("div");
|
||||
block.className = "tree-children";
|
||||
const kids = await loadChildren(parentId);
|
||||
for (const kid of kids) block.appendChild(await treeItem(kid, depth + 1));
|
||||
return block;
|
||||
}
|
||||
|
||||
async function toggleExpand(id) {
|
||||
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
|
||||
await renderTree();
|
||||
}
|
||||
|
||||
async function selectNode(id) {
|
||||
try {
|
||||
const [{ node }, { breadcrumb }] = await Promise.all([
|
||||
api("/database/api/nodes/" + id),
|
||||
api("/database/api/nodes/" + id + "/breadcrumb"),
|
||||
]);
|
||||
current = node; current.breadcrumb = breadcrumb;
|
||||
closePreview();
|
||||
await renderTree();
|
||||
renderMain();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
function crumbsHtml() {
|
||||
return current.breadcrumb.map((c) =>
|
||||
c.name === null ? "<span>…</span>" : "<span>" + esc(c.name) + "</span>"
|
||||
).join('<span style="margin:0 4px;color:var(--border)">/</span>');
|
||||
}
|
||||
|
||||
function renderMain() {
|
||||
const n = current;
|
||||
const canManage = n.role === "MANAGE";
|
||||
const canEdit = canManage || n.role === "EDIT";
|
||||
const tabs = [];
|
||||
tabs.push(["detail", "概览"]);
|
||||
if (n.kind === "PROJECT") tabs.push(["files", "文件"]);
|
||||
if (canManage) tabs.push(["grants", "授权"]);
|
||||
current.tab = current.tab && tabs.some(t => t[0] === current.tab) ? current.tab : "detail";
|
||||
|
||||
$("#lib-main").innerHTML =
|
||||
'<div style="max-width:860px;margin:0 auto;padding:28px 28px 56px">' +
|
||||
'<div style="font-size:12px;color:var(--text-3);margin-bottom:6px">' + crumbsHtml() + "</div>" +
|
||||
'<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:18px">' +
|
||||
'<div style="font-size:17px;font-weight:600;color:var(--text);display:flex;align-items:center;gap:8px">' + esc(n.name) +
|
||||
'<span class="tag">' + n.kind + "</span>" +
|
||||
'<span class="tag tag-role">' + n.role + "</span>" +
|
||||
"</div>" +
|
||||
'<div style="display:flex;gap:6px">' +
|
||||
(canEdit && n.kind === "FOLDER" ? '<button class="btn" onclick="window.__lib.createChild()">+ 新建子节点</button>' : "") +
|
||||
(canManage ? '<button class="btn" onclick="window.__lib.renameCurrent()">重命名</button>' : "") +
|
||||
(canManage ? '<button class="btn btn-danger" onclick="window.__lib.deleteCurrent()">删除</button>' : "") +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
'<div class="tabs">' +
|
||||
tabs.map(([id, label]) =>
|
||||
'<button onclick="window.__lib.switchTab(\\'' + id + '\\')" class="tab' +
|
||||
(current.tab === id ? " active" : "") + '">' + label + "</button>").join("") +
|
||||
"</div>" +
|
||||
'<div id="lib-tab-body"></div>' +
|
||||
"</div>";
|
||||
renderTab();
|
||||
}
|
||||
|
||||
function renderTab() {
|
||||
const body = $("#lib-tab-body");
|
||||
if (current.tab === "files") return renderFilesTab(body);
|
||||
if (current.tab === "grants") return renderGrantsTab(body);
|
||||
let extra = "";
|
||||
if (current.kind === "PROJECT") {
|
||||
extra =
|
||||
'<div class="divider"></div>' +
|
||||
'<div class="inline-form" style="justify-content:flex-start">' +
|
||||
'<span class="quiet">独立权限</span>' +
|
||||
'<b style="font-size:13px">' + (current.independentPermission ? "开启" : "关闭") + "</b>" +
|
||||
(current.role === "MANAGE"
|
||||
? '<button class="btn" onclick="window.__lib.toggleIndependent()">' + (current.independentPermission ? "关闭" : "开启") + "</button>"
|
||||
: "") +
|
||||
'<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>' +
|
||||
"</div>" +
|
||||
'<div class="divider"></div>' +
|
||||
'<div class="section-title">导出</div>' +
|
||||
'<div class="inline-form">' +
|
||||
'<select id="lib-x-target" class="select" style="width:auto"><option value="manifest">manifest(stub)</option></select>' +
|
||||
'<button class="btn" onclick="window.__lib.submitExport()">开始导出</button>' +
|
||||
'<span id="lib-x-status" class="export-status"></span>' +
|
||||
"</div>";
|
||||
}
|
||||
body.innerHTML =
|
||||
'<div class="panel">' +
|
||||
'<div class="meta-grid">' +
|
||||
"<div>类型 <b>" + current.kind + "</b></div>" +
|
||||
"<div>我的权限 <b>" + current.role + "</b></div>" +
|
||||
"<div>创建时间 " + new Date(current.createdAt).toLocaleString("zh-CN") + "</div>" +
|
||||
"<div>更新时间 " + new Date(current.updatedAt).toLocaleString("zh-CN") + "</div>" +
|
||||
"</div>" + extra +
|
||||
"</div>";
|
||||
pollExport();
|
||||
}
|
||||
|
||||
/* ---------------- 节点操作 ---------------- */
|
||||
function createDialog(parentId) {
|
||||
modal(
|
||||
'<div class="modal-title">' + (parentId ? "新建子节点" : "新建根目录") + "</div>" +
|
||||
'<div class="form-row"><label class="form-label">名称</label>' +
|
||||
'<input id="lib-f-name" class="input" placeholder="例如:物理必修一"/></div>' +
|
||||
'<div class="form-row"><label class="form-label">类型</label>' +
|
||||
'<select id="lib-f-kind" class="select">' +
|
||||
'<option value="FOLDER">文件夹</option><option value="PROJECT">项目(Git 仓库)</option>' +
|
||||
"</select></div>" +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn" onclick="window.__lib.closeModal()">取消</button>' +
|
||||
'<button class="btn btn-primary" onclick="window.__lib.submitCreate(\\'' + (parentId ?? "") + '\\')">创建</button>' +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
async function submitCreate(parentId) {
|
||||
try {
|
||||
await api("/database/api/nodes", { method: "POST", body: {
|
||||
parentId: parentId === "" ? null : parentId,
|
||||
kind: $("#lib-f-kind").value, name: $("#lib-f-name").value,
|
||||
}});
|
||||
closeModal(); ok("已创建");
|
||||
if (parentId) expanded.add(parentId);
|
||||
await renderTree();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
/* ---------------- 授权 ---------------- */
|
||||
async function renderGrantsTab(body) {
|
||||
try {
|
||||
const { grants } = await api("/database/api/nodes/" + current.id + "/grants");
|
||||
const rows = grants.map((g) =>
|
||||
"<tr>" +
|
||||
"<td>" + (g.principalType === "USER" ? "👤" : "👥") + " " + esc(g.principalId) +
|
||||
(g.isCreatorGrant ? ' <span class="quiet">(创建者)</span>' : "") + "</td>" +
|
||||
'<td class="file-meta">' + g.role + "</td>" +
|
||||
"<td style='text-align:right'>" + (g.isCreatorGrant ? "" :
|
||||
'<button class="link-danger" onclick="window.__lib.revokeGrant(\\'' + g.id + '\\')">收回</button>') + "</td>" +
|
||||
"</tr>").join("");
|
||||
body.innerHTML =
|
||||
'<div class="panel">' +
|
||||
'<table class="list"><thead><tr><th>主体</th><th>级别</th><th></th></tr></thead>' +
|
||||
"<tbody>" + rows + "</tbody></table>" +
|
||||
'<div class="divider"></div>' +
|
||||
'<div class="section-title">新增授权</div>' +
|
||||
'<div class="inline-form">' +
|
||||
'<select id="lib-g-type" class="select" style="width:110px" onchange="window.__lib.gTypeChanged()">' +
|
||||
'<option value="USER">用户</option><option value="GROUP">Group</option></select>' +
|
||||
'<input id="lib-g-principal" class="input" placeholder="用户 id"/>' +
|
||||
'<select id="lib-g-group" class="select hidden"></select>' +
|
||||
'<select id="lib-g-role" class="select" style="width:110px">' +
|
||||
'<option value="VIEW">VIEW</option><option value="EDIT">EDIT</option><option value="MANAGE">MANAGE</option></select>' +
|
||||
'<button class="btn btn-primary" onclick="window.__lib.addGrant()">授予</button>' +
|
||||
"</div>" +
|
||||
'<div class="section-note">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>' +
|
||||
"</div>";
|
||||
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
|
||||
}
|
||||
|
||||
/* ---------------- 文件 ---------------- */
|
||||
async function renderFilesTab(body) {
|
||||
try {
|
||||
const { files } = await api("/database/api/projects/" + current.id + "/files");
|
||||
const rows = files.map((f) =>
|
||||
'<tr class="file-row" onclick="window.__lib.openFile(\\'' + esc(f.path) + '\\')">' +
|
||||
'<td class="file-path">' + esc(f.path) + "</td>" +
|
||||
'<td class="file-meta" style="text-align:right">' + f.size + " B</td></tr>").join("");
|
||||
body.innerHTML =
|
||||
'<div class="panel">' +
|
||||
'<div class="inline-form" style="justify-content:space-between;margin-bottom:6px">' +
|
||||
'<div class="section-title" style="margin:0">项目文件(' + files.length + ")</div>" +
|
||||
(current.role !== "VIEW"
|
||||
? '<div class="inline-form" style="gap:6px">' +
|
||||
'<button class="btn" onclick="window.__lib.newFileDialog()">+ 新建文件</button>' +
|
||||
'<button class="btn btn-primary" onclick="document.getElementById(\\'lib-upload-input\\').click()">上传文件</button>' +
|
||||
'<input id="lib-upload-input" type="file" class="hidden"/>' +
|
||||
"</div>"
|
||||
: "") +
|
||||
"</div>" +
|
||||
(files.length === 0 ? '<div class="quiet" style="padding:20px 0;text-align:center">空仓库 · 可新建或上传文件</div>'
|
||||
: '<table class="list">' + rows + "</table>") +
|
||||
"</div>";
|
||||
const up = $("#lib-upload-input");
|
||||
if (up) up.onchange = (e) => doUpload(e.target);
|
||||
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
|
||||
}
|
||||
|
||||
function newFileDialog() {
|
||||
modal(
|
||||
'<div class="modal-title">新建文件</div>' +
|
||||
'<div class="form-row"><label class="form-label">路径</label>' +
|
||||
'<input id="lib-nf-path" class="input" style="font-family:var(--mono)" placeholder="docs/intro.md"/></div>' +
|
||||
'<div class="form-row"><label class="form-label">内容</label>' +
|
||||
'<textarea id="lib-nf-content" rows="8" class="textarea" placeholder="内容…"></textarea></div>' +
|
||||
'<div class="modal-actions"><button class="btn" onclick="window.__lib.closeModal()">取消</button>' +
|
||||
'<button class="btn btn-primary" onclick="window.__lib.submitNewFile()">创建</button></div>'
|
||||
);
|
||||
}
|
||||
async function submitNewFile() {
|
||||
try {
|
||||
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body: {
|
||||
path: $("#lib-nf-path").value, content: $("#lib-nf-content").value,
|
||||
}});
|
||||
closeModal(); ok("已创建"); renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
function u8ToBase64(bytes) {
|
||||
let bin = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
async function doUpload(input) {
|
||||
const file = input.files && input.files[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) return toast("文件超过 10MB 上限", "err");
|
||||
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
|
||||
if (!targetPath) return;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const isBinary = bytes.includes(0);
|
||||
const body = isBinary
|
||||
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
|
||||
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
|
||||
try {
|
||||
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body });
|
||||
ok("已上传 " + file.name);
|
||||
renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
async function openFile(path) {
|
||||
try {
|
||||
const f = await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(path));
|
||||
currentFile = f;
|
||||
const canEdit = current.role !== "VIEW";
|
||||
const prev = $("#lib-preview");
|
||||
prev.style.display = "block";
|
||||
prev.innerHTML =
|
||||
'<div class="panel">' +
|
||||
'<div class="inline-form" style="justify-content:space-between;margin-bottom:10px">' +
|
||||
'<span class="file-meta">' + esc(f.path) + " @ " + esc(f.version) + "</span>" +
|
||||
'<div class="inline-form" style="gap:6px">' +
|
||||
'<a class="btn" href="/database/api/projects/' + current.id + '/file/raw?path=' +
|
||||
encodeURIComponent(f.path) + '" download>下载</a>' +
|
||||
'<button class="btn" onclick="window.__lib.showHistory()">历史</button>' +
|
||||
(canEdit ? '<button class="btn btn-danger" onclick="window.__lib.deleteFile()">删除文件</button>' : "") +
|
||||
'<button class="btn" onclick="window.__lib.closePreview()" title="关闭预览">✕</button>' +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
(f.encoding === "base64"
|
||||
? '<div class="quiet">二进制文件(' + f.size + " B),不支持在线编辑</div>"
|
||||
: '<textarea id="lib-ef-content" rows="16" class="textarea" ' + (canEdit ? "" : "readonly") +
|
||||
">" + esc(f.content) + "</textarea>") +
|
||||
(canEdit && f.encoding !== "base64"
|
||||
? '<div class="modal-actions"><button class="btn btn-primary" onclick="window.__lib.saveFile()">提交修改</button></div>'
|
||||
: "") +
|
||||
'<div id="lib-conflict-zone"></div>' +
|
||||
"</div>";
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
function closePreview() {
|
||||
const prev = $("#lib-preview");
|
||||
if (prev) { prev.style.display = "none"; prev.innerHTML = ""; }
|
||||
currentFile = null;
|
||||
}
|
||||
async function saveFile() {
|
||||
try {
|
||||
const r = await api("/database/api/projects/" + current.id + "/file/commits", { method: "POST", body: {
|
||||
path: currentFile.path, baseVersion: currentFile.version, content: $("#lib-ef-content").value,
|
||||
}});
|
||||
ok("已提交 " + r.version); await openFile(currentFile.path);
|
||||
} catch (e) {
|
||||
if (e.status === 409 && e.currentVersion) return showConflict(e.currentVersion);
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
async function showConflict(currentVersion) {
|
||||
const { diff } = await api("/database/api/projects/" + current.id + "/file/diff?path=" +
|
||||
encodeURIComponent(currentFile.path) + "&from=" + encodeURIComponent(currentFile.version) +
|
||||
"&to=" + encodeURIComponent(currentVersion));
|
||||
$("#lib-conflict-zone").innerHTML =
|
||||
'<div class="conflict-panel">' +
|
||||
'<div class="conflict-title">冲突:他人已提交 ' + esc(currentVersion) + ",差异如下(你的基版 → 最新版)</div>" +
|
||||
'<pre class="diff">' + esc(diff)
|
||||
.replace(/^\\+(.*)$/gm, '<span class="add">+$1</span>')
|
||||
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>') + "</pre>" +
|
||||
'<div class="conflict-note">请人工合并后,以最新内容为全文重新提交(基版将更新为 ' + esc(currentVersion) + ")</div>" +
|
||||
'<div class="modal-actions"><button class="btn" onclick="window.__lib.acceptLatest()">载入最新内容</button></div>' +
|
||||
"</div>";
|
||||
currentFile.version = currentVersion;
|
||||
}
|
||||
async function deleteFile() {
|
||||
if (!confirm("删除文件 " + currentFile.path + "?")) return;
|
||||
try {
|
||||
await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(currentFile.path),
|
||||
{ method: "DELETE", body: { baseVersion: currentFile.version } });
|
||||
ok("已删除"); closePreview(); renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function showHistory() {
|
||||
const { history } = await api("/database/api/projects/" + current.id + "/file/history?path=" +
|
||||
encodeURIComponent(currentFile.path));
|
||||
modal(
|
||||
'<div class="modal-title">版本历史</div>' +
|
||||
'<div style="max-height:320px;overflow-y:auto">' +
|
||||
history.map((v) =>
|
||||
'<div style="border-top:1px solid #F5F5F4;padding:8px 0;font-size:12px">' +
|
||||
'<span class="file-meta" style="color:var(--accent)">' + esc(v.version) + "</span> " + esc(v.message) +
|
||||
'<div class="quiet">' + new Date(v.committedAt).toLocaleString("zh-CN") +
|
||||
(v.author ? " · " + esc(v.author) : "") + "</div></div>").join("") +
|
||||
"</div>" +
|
||||
'<div class="modal-actions"><button class="btn" onclick="window.__lib.closeModal()">关闭</button></div>'
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 导出 ---------------- */
|
||||
let exportJobId = null;
|
||||
async function submitExport() {
|
||||
try {
|
||||
const r = await api("/database/api/projects/" + current.id + "/exports", { method: "POST", body: { target: $("#lib-x-target").value } });
|
||||
exportJobId = r.jobId;
|
||||
$("#lib-x-status").textContent = "任务 " + r.jobId.slice(0, 8) + "… 排队中";
|
||||
pollExport();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function pollExport() {
|
||||
if (!exportJobId) return;
|
||||
try {
|
||||
const job = await api("/database/api/exports/" + exportJobId);
|
||||
const el = $("#lib-x-status");
|
||||
if (!el) return;
|
||||
if (job.status === "DONE") {
|
||||
el.innerHTML = '完成 · <a href="/database/api/exports/' + exportJobId + '/download">下载</a>';
|
||||
} else if (job.status === "FAILED") {
|
||||
el.textContent = "失败:" + (job.error || "");
|
||||
} else {
|
||||
el.textContent = "状态:" + job.status + "…";
|
||||
setTimeout(pollExport, 1000);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
/* ---------------- 暴露给内联 onclick 的操作表 ---------------- */
|
||||
window.__lib = {
|
||||
createChild() { createDialog(current.id); },
|
||||
async renameCurrent() {
|
||||
const name = prompt("新名称", current.name);
|
||||
if (name === null) return;
|
||||
try {
|
||||
await api("/database/api/nodes/" + current.id, { method: "PATCH", body: { name } });
|
||||
ok("已重命名"); await selectNode(current.id);
|
||||
} catch (e) { fail(e); }
|
||||
},
|
||||
async deleteCurrent() {
|
||||
if (!confirm("确认删除「" + current.name + "」?软删除后不可见。")) return;
|
||||
try {
|
||||
await api("/database/api/nodes/" + current.id, { method: "DELETE" });
|
||||
ok("已删除"); current = null; await renderTree();
|
||||
$("#lib-main").innerHTML = '<div class="lib-empty-hint">从左侧选择一个文件夹或项目</div>';
|
||||
closePreview();
|
||||
} catch (e) { fail(e); }
|
||||
},
|
||||
switchTab(id) { current.tab = id; renderMain(); },
|
||||
async toggleIndependent() {
|
||||
try {
|
||||
await api("/database/api/projects/" + current.id + "/independent-permission", {
|
||||
method: "PUT", body: { enabled: !current.independentPermission },
|
||||
});
|
||||
ok("已切换"); await selectNode(current.id);
|
||||
} catch (e) { fail(e); }
|
||||
},
|
||||
async gTypeChanged() {
|
||||
const isGroup = $("#lib-g-type").value === "GROUP";
|
||||
$("#lib-g-principal").classList.toggle("hidden", isGroup);
|
||||
$("#lib-g-group").classList.toggle("hidden", !isGroup);
|
||||
if (isGroup && $("#lib-g-group").options.length === 0) {
|
||||
const { groups } = await api("/database/api/groups/search?q=");
|
||||
$("#lib-g-group").innerHTML = groups.map((g) => '<option value="' + esc(g.id) + '">' + esc(g.name) + "</option>").join("");
|
||||
}
|
||||
},
|
||||
async addGrant() {
|
||||
const type = $("#lib-g-type").value;
|
||||
const principalId = type === "GROUP" ? $("#lib-g-group").value : $("#lib-g-principal").value.trim();
|
||||
if (!principalId) return toast("请填写主体", "err");
|
||||
try {
|
||||
await api("/database/api/nodes/" + current.id + "/grants", { method: "PUT", body: {
|
||||
grants: [{ principalType: type, principalId, role: $("#lib-g-role").value }],
|
||||
}});
|
||||
ok("已授予"); renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
},
|
||||
async revokeGrant(grantId) {
|
||||
try {
|
||||
await api("/database/api/nodes/" + current.id + "/grants/" + grantId, { method: "DELETE" });
|
||||
ok("已收回"); renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
},
|
||||
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>`;
|
||||
}
|
||||
@@ -0,0 +1,801 @@
|
||||
/**
|
||||
* /database/library —— 文件库浏览页(管理员后台的文件工具)。
|
||||
*
|
||||
* 服务端渲染外壳 + 浏览器端 JS 调 /database/api/*(同源 session cookie)。
|
||||
* 设计令牌与全局 UI 主题(uiTheme.ts)一致。能力面全部由 API 的 404/403 表达(D8)。
|
||||
*/
|
||||
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { SESSION_COOKIE_NAME, verifySession } from "../../admin/auth/session.js";
|
||||
|
||||
export interface LibraryPageConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
}
|
||||
|
||||
export async function registerLibraryPage(
|
||||
app: FastifyInstance,
|
||||
config: LibraryPageConfig,
|
||||
): Promise<void> {
|
||||
app.get("/database/library", async (request, reply) => {
|
||||
const raw = request.cookies[SESSION_COOKIE_NAME];
|
||||
const session = raw === undefined || raw === "" ? null : verifySession(raw, config.sessionSecret);
|
||||
if (session === null) return reply.redirect("/database/admin");
|
||||
const user = await config.prisma.user.findUnique({
|
||||
where: { id: session.userId },
|
||||
select: { displayName: true },
|
||||
});
|
||||
if (user === null) return reply.redirect("/database/admin");
|
||||
return reply.type("text/html").send(renderLibraryPage(user.displayName));
|
||||
});
|
||||
}
|
||||
|
||||
function renderLibraryPage(displayName: string): string {
|
||||
const initial = displayName.slice(0, 1).replace(/[&<>"']/, "U");
|
||||
return `<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>文件库</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"/>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet"/>
|
||||
<style>
|
||||
/* 设计令牌:与全局 UI 主题(uiTheme.ts)保持一致。 */
|
||||
:root {
|
||||
--bg: #FCFCFB;
|
||||
--panel: #FFFFFF;
|
||||
--sidebar: #F7F7F5;
|
||||
--text: #1A1A18;
|
||||
--text-2: #6B6A66;
|
||||
--text-3: #9C9B96;
|
||||
--border: #ECECE8;
|
||||
--border-soft: #F1F1EE;
|
||||
--accent: #1A1A18;
|
||||
--accent-hover: #333330;
|
||||
--selected: #EBEBE7;
|
||||
--hover: #F4F4F1;
|
||||
--danger: #A13A33;
|
||||
--guide: #E9E9E5;
|
||||
--diff-add-bg: #F3F6F2;
|
||||
--diff-add-text: #4A6741;
|
||||
--diff-del-bg: #F8F2F1;
|
||||
--diff-del-text: #A13A33;
|
||||
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; }
|
||||
body {
|
||||
font-family: 'Inter', -apple-system, 'Segoe UI', 'PingFang SC', 'Microsoft YaHei', sans-serif;
|
||||
background: var(--bg); color: var(--text);
|
||||
font-size: 14px; line-height: 1.6;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
.hidden { display: none !important; }
|
||||
|
||||
/* 布局 */
|
||||
.shell { display: flex; height: 100vh; }
|
||||
.sidebar {
|
||||
width: 292px; flex-shrink: 0; background: var(--sidebar);
|
||||
border-right: 1px solid var(--border);
|
||||
display: flex; flex-direction: column;
|
||||
}
|
||||
.sidebar-head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 14px 16px; border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.wordmark { font-size: 14px; font-weight: 600; color: var(--text); }
|
||||
.sidebar-actions { display: flex; gap: 6px; }
|
||||
.tree { flex: 1; overflow-y: auto; padding: 8px 8px 16px; font-size: 13px; }
|
||||
.sidebar-user {
|
||||
margin: 0; padding: 10px 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
font-size: 12px; color: var(--text-2);
|
||||
}
|
||||
.avatar {
|
||||
width: 24px; height: 24px; border-radius: 50%;
|
||||
background: var(--accent); color: #fff;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 11px; font-weight: 600; flex-shrink: 0;
|
||||
}
|
||||
.main { flex: 1; overflow-y: auto; }
|
||||
.main-inner { max-width: 860px; margin: 0 auto; padding: 32px 32px 64px; }
|
||||
.empty-hint {
|
||||
height: 100%; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text-3); font-size: 13px;
|
||||
}
|
||||
|
||||
/* 树 */
|
||||
.tree-item {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
padding: 4px 6px; border-radius: 6px; cursor: pointer;
|
||||
color: var(--text); transition: background 120ms ease;
|
||||
user-select: none;
|
||||
}
|
||||
.tree-item:hover { background: var(--hover); }
|
||||
.tree-item.selected { background: var(--selected); color: #1C1917; }
|
||||
.tree-children { border-left: 1px solid var(--guide); margin-left: 15px; padding-left: 3px; }
|
||||
.tree-icon { width: 16px; height: 16px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
|
||||
.tree-caret { color: var(--text-3); }
|
||||
.tree-badge { margin-left: auto; padding-right: 4px; font-size: 10px; color: var(--text-3); font-family: var(--mono); }
|
||||
.tree-empty { padding: 24px 12px; text-align: center; font-size: 12px; color: var(--text-3); }
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex; align-items: center; gap: 4px;
|
||||
padding: 5px 12px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--panel);
|
||||
color: var(--accent); font-size: 12px; font-weight: 500;
|
||||
cursor: pointer; transition: all 120ms ease; text-decoration: none;
|
||||
}
|
||||
.btn:hover { background: var(--hover); border-color: #D6D3D1; }
|
||||
.btn-primary {
|
||||
background: var(--accent); border-color: var(--accent); color: #fff;
|
||||
}
|
||||
.btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
|
||||
.btn-danger {
|
||||
border-color: transparent; background: transparent; color: var(--danger);
|
||||
}
|
||||
.btn-danger:hover { background: var(--diff-del-bg); border-color: transparent; }
|
||||
|
||||
/* 内容区 */
|
||||
.crumbs { font-size: 12px; color: var(--text-3); margin-bottom: 6px; }
|
||||
.crumbs span + span::before { content: " / "; color: var(--border); }
|
||||
.node-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 18px; }
|
||||
.node-title { font-size: 17px; font-weight: 600; color: var(--text); display: flex; align-items: center; gap: 8px; }
|
||||
.tag {
|
||||
font-size: 10px; font-weight: 500; font-family: var(--mono);
|
||||
padding: 2px 7px; border-radius: 999px;
|
||||
border: 1px solid var(--border); color: var(--text-2); background: var(--panel);
|
||||
}
|
||||
.tag-role { color: var(--accent); border-color: #D6D3D1; }
|
||||
.node-actions { display: flex; gap: 6px; }
|
||||
.tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin-bottom: 18px; }
|
||||
.tab {
|
||||
padding: 8px 14px; font-size: 13px; color: var(--text-2);
|
||||
border: none; background: none; cursor: pointer;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
transition: color 120ms ease;
|
||||
}
|
||||
.tab:hover { color: var(--text); }
|
||||
.tab.active { color: var(--text); font-weight: 600; border-bottom-color: var(--accent); }
|
||||
.panel {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 20px 22px;
|
||||
}
|
||||
.panel + .panel, .panel + #file-editor { margin-top: 14px; }
|
||||
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 24px; font-size: 13px; color: var(--text-2); }
|
||||
.meta-grid b { color: var(--text); font-weight: 600; }
|
||||
.section-title { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 10px; }
|
||||
.section-note { font-size: 11px; color: var(--text-3); margin-top: 6px; }
|
||||
|
||||
/* 表格 */
|
||||
table.list { width: 100%; border-collapse: collapse; font-size: 13px; }
|
||||
table.list th { text-align: left; font-size: 11px; font-weight: 500; color: var(--text-3); padding: 4px 0; }
|
||||
table.list td { padding: 7px 0; border-top: 1px solid #F5F5F4; }
|
||||
table.list tr:first-child td { border-top: none; }
|
||||
.file-row { cursor: pointer; }
|
||||
.file-row:hover td { background: var(--hover); }
|
||||
.file-path { font-family: var(--mono); font-size: 12px; color: var(--text); }
|
||||
.file-meta { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
|
||||
.link-danger { color: var(--danger); font-size: 12px; background: none; border: none; cursor: pointer; padding: 0; }
|
||||
.link-danger:hover { text-decoration: underline; }
|
||||
|
||||
/* 表单 */
|
||||
.input, .select, .textarea {
|
||||
width: 100%; padding: 7px 10px; border-radius: 8px;
|
||||
border: 1px solid var(--border); background: var(--panel);
|
||||
font-size: 13px; color: var(--text); font-family: inherit;
|
||||
outline: none; transition: border-color 120ms ease;
|
||||
}
|
||||
.input:focus, .select:focus, .textarea:focus { border-color: var(--accent); }
|
||||
.textarea { font-family: var(--mono); font-size: 12px; line-height: 1.7; resize: vertical; }
|
||||
.form-label { display: block; font-size: 11px; color: var(--text-2); margin-bottom: 4px; }
|
||||
.form-row { margin-bottom: 12px; }
|
||||
.inline-form { display: flex; gap: 8px; align-items: center; }
|
||||
.inline-form .input { flex: 1; }
|
||||
|
||||
/* 弹层 */
|
||||
.modal-mask {
|
||||
position: fixed; inset: 0; z-index: 40;
|
||||
background: rgba(26, 26, 24, .3);
|
||||
display: flex; align-items: center; justify-content: center; padding: 16px;
|
||||
}
|
||||
.modal-card {
|
||||
width: 100%; max-width: 440px; background: var(--panel);
|
||||
border: 1px solid var(--border); border-radius: 12px; padding: 22px;
|
||||
}
|
||||
.modal-title { font-size: 15px; font-weight: 600; margin-bottom: 14px; }
|
||||
.modal-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 16px; }
|
||||
.toast {
|
||||
border-radius: 8px; padding: 8px 14px; font-size: 12px; color: #fff;
|
||||
background: #333230; max-width: 320px;
|
||||
}
|
||||
.toast-ok { background: #333230; }
|
||||
.toast-err { background: #7E2C26; }
|
||||
#toast-root { position: fixed; bottom: 16px; right: 16px; z-index: 50; display: flex; flex-direction: column; gap: 8px; }
|
||||
|
||||
/* 冲突与 diff */
|
||||
.conflict-panel {
|
||||
margin-top: 14px; border: 1px solid #E8E2C8; background: #FCFBF4;
|
||||
border-radius: 10px; padding: 14px 16px;
|
||||
}
|
||||
.conflict-title { font-size: 13px; font-weight: 600; color: #6E6329; margin-bottom: 8px; }
|
||||
.conflict-note { font-size: 11px; color: #8A8059; margin-top: 8px; }
|
||||
pre.diff {
|
||||
white-space: pre-wrap; font-family: var(--mono); font-size: 12px; line-height: 1.7;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 8px; padding: 10px;
|
||||
margin: 0;
|
||||
}
|
||||
pre.diff .add { display: block; color: var(--diff-add-text); background: var(--diff-add-bg); }
|
||||
pre.diff .del { display: block; color: var(--diff-del-text); background: var(--diff-del-bg); }
|
||||
|
||||
.divider { border-top: 1px solid var(--border); margin: 14px 0; }
|
||||
.quiet { color: var(--text-3); font-size: 12px; }
|
||||
.export-status { font-size: 11px; color: var(--text-3); font-family: var(--mono); }
|
||||
.export-status a { color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="shell">
|
||||
<aside class="sidebar">
|
||||
<div class="sidebar-head">
|
||||
<span class="wordmark">文件库</span>
|
||||
<div class="sidebar-actions">
|
||||
<button id="btn-new-root" class="btn hidden" title="新建根目录">+ 根目录</button>
|
||||
<a class="btn" href="/database/dashboard">后台</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="tree" class="tree"></div>
|
||||
<div class="sidebar-user">
|
||||
<div class="avatar">${initial}</div>
|
||||
<span style="flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text)">${displayName}</span>
|
||||
<button id="btn-logout" class="btn" style="padding:3px 10px;font-size:11px" title="退出登录">退出</button>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="main" id="main">
|
||||
<div class="empty-hint">从左侧选择一个文件夹或项目</div>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<div id="modal-root"></div>
|
||||
<div id="toast-root"></div>
|
||||
|
||||
<script>
|
||||
/* ---------------- 基础设施 ---------------- */
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const esc = (s) => String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">").replace(/"/g,""");
|
||||
|
||||
async function api(path, opts = {}) {
|
||||
const res = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
headers: opts.body ? { "Content-Type": "application/json" } : undefined,
|
||||
...opts,
|
||||
body: opts.body ? JSON.stringify(opts.body) : undefined,
|
||||
});
|
||||
if (res.status === 401) { location.href = "/database/admin"; throw new Error("unauthenticated"); }
|
||||
if (res.status === 204) return null;
|
||||
const text = await res.text();
|
||||
const data = text ? JSON.parse(text) : null;
|
||||
if (!res.ok) {
|
||||
const err = (data && data.error) || { code: "unknown", message: res.statusText };
|
||||
const e = new Error(err.message); e.code = err.code; e.status = res.status; Object.assign(e, err);
|
||||
throw e;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
function toast(msg, kind = "info") {
|
||||
const el = document.createElement("div");
|
||||
el.className = "toast " + (kind === "err" ? "toast-err" : "toast-ok");
|
||||
el.textContent = msg;
|
||||
$("#toast-root").appendChild(el);
|
||||
setTimeout(() => el.remove(), 3600);
|
||||
}
|
||||
const ok = (m) => toast(m, "ok");
|
||||
const fail = (e) => toast(e.message || String(e), "err");
|
||||
|
||||
function modal(html) {
|
||||
const root = $("#modal-root");
|
||||
root.innerHTML = '<div class="modal-mask"><div class="modal-card">' + html + "</div></div>";
|
||||
root.firstElementChild.addEventListener("click", (e) => { if (e.target === e.currentTarget) closeModal(); });
|
||||
}
|
||||
function closeModal() { $("#modal-root").innerHTML = ""; }
|
||||
|
||||
/* ---------------- 状态 ---------------- */
|
||||
let me = { userId: null, isWebsiteAdmin: false };
|
||||
let current = null;
|
||||
const expanded = new Set();
|
||||
|
||||
/* ---------------- 目录树 ---------------- */
|
||||
/* 图标统一用固定尺寸的 inline SVG。每行几何一致:[16px 箭头槽][16px 图标槽][名称]。 */
|
||||
const ICONS = {
|
||||
caretRight: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M9 6l6 6-6 6z"/></svg>',
|
||||
caretDown: '<svg width="10" height="10" viewBox="0 0 24 24" fill="currentColor"><path d="M6 9l6 6 6-6z"/></svg>',
|
||||
folder: '<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor"><path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7Z"/></svg>',
|
||||
project: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4"/></svg>',
|
||||
};
|
||||
|
||||
async function loadChildren(parentId) {
|
||||
const q = parentId === null ? "" : "?parentId=" + encodeURIComponent(parentId);
|
||||
return (await api("/database/api/nodes" + q)).nodes;
|
||||
}
|
||||
|
||||
async function renderTree() {
|
||||
const tree = $("#tree");
|
||||
tree.innerHTML = "";
|
||||
try {
|
||||
const roots = await loadChildren(null);
|
||||
if (roots.length === 0) {
|
||||
tree.innerHTML = '<div class="tree-empty">空文件库' +
|
||||
(me.isWebsiteAdmin ? " · 点上方「+ 根目录」开始" : "") + "</div>";
|
||||
return;
|
||||
}
|
||||
for (const node of roots) tree.appendChild(await treeItem(node, 0));
|
||||
} catch (e) { tree.innerHTML = '<div class="tree-empty">' + esc(e.message) + "</div>"; }
|
||||
}
|
||||
|
||||
async function treeItem(node, depth) {
|
||||
const wrap = document.createElement("div");
|
||||
const row = document.createElement("div");
|
||||
row.className = "tree-item" + (current && current.id === node.id ? " selected" : "");
|
||||
|
||||
const caret = document.createElement("span");
|
||||
caret.className = "tree-icon tree-caret";
|
||||
if (node.kind === "FOLDER") {
|
||||
caret.innerHTML = expanded.has(node.id) ? ICONS.caretDown : ICONS.caretRight;
|
||||
caret.onclick = (e) => { e.stopPropagation(); toggleExpand(node.id, wrap, depth); };
|
||||
}
|
||||
row.appendChild(caret);
|
||||
|
||||
const iconSpan = document.createElement("span");
|
||||
iconSpan.className = "tree-icon";
|
||||
iconSpan.style.color = node.kind === "PROJECT" ? "#1A1A18" : "#9C9B96";
|
||||
iconSpan.innerHTML = node.kind === "PROJECT" ? ICONS.project : ICONS.folder;
|
||||
row.appendChild(iconSpan);
|
||||
|
||||
const nameSpan = document.createElement("span");
|
||||
nameSpan.className = "truncate";
|
||||
nameSpan.textContent = node.name;
|
||||
row.appendChild(nameSpan);
|
||||
if (node.role !== "MANAGE") {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "tree-badge";
|
||||
badge.textContent = node.role;
|
||||
row.appendChild(badge);
|
||||
}
|
||||
|
||||
row.onclick = () => {
|
||||
if (node.kind === "FOLDER") {
|
||||
if (expanded.has(node.id)) expanded.delete(node.id); else expanded.add(node.id);
|
||||
}
|
||||
selectNode(node.id);
|
||||
};
|
||||
wrap.appendChild(row);
|
||||
if (node.kind === "FOLDER" && expanded.has(node.id)) {
|
||||
wrap.appendChild(await childrenBlock(node.id, depth));
|
||||
}
|
||||
return wrap;
|
||||
}
|
||||
|
||||
async function childrenBlock(parentId, depth) {
|
||||
const block = document.createElement("div");
|
||||
block.className = "tree-children";
|
||||
const kids = await loadChildren(parentId);
|
||||
for (const kid of kids) block.appendChild(await treeItem(kid, depth + 1));
|
||||
return block;
|
||||
}
|
||||
|
||||
async function toggleExpand(id, wrap, depth) {
|
||||
if (expanded.has(id)) expanded.delete(id); else expanded.add(id);
|
||||
await renderTree();
|
||||
}
|
||||
|
||||
/* ---------------- 节点详情 ---------------- */
|
||||
async function selectNode(id) {
|
||||
try {
|
||||
const [{ node }, { breadcrumb }] = await Promise.all([
|
||||
api("/database/api/nodes/" + id),
|
||||
api("/database/api/nodes/" + id + "/breadcrumb"),
|
||||
]);
|
||||
current = node; current.breadcrumb = breadcrumb;
|
||||
await renderTree();
|
||||
renderMain();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
function crumbsHtml() {
|
||||
return current.breadcrumb.map((c) =>
|
||||
c.name === null ? "<span>…</span>" : "<span>" + esc(c.name) + "</span>"
|
||||
).join("");
|
||||
}
|
||||
|
||||
function renderMain() {
|
||||
const n = current;
|
||||
const canManage = n.role === "MANAGE";
|
||||
const canEdit = canManage || n.role === "EDIT";
|
||||
const tabs = [];
|
||||
tabs.push(["detail", "概览"]);
|
||||
if (n.kind === "PROJECT") tabs.push(["files", "文件"]);
|
||||
if (canManage) tabs.push(["grants", "授权"]);
|
||||
current.tab = current.tab && tabs.some(t => t[0] === current.tab) ? current.tab : "detail";
|
||||
|
||||
$("#main").innerHTML =
|
||||
'<div class="main-inner">' +
|
||||
'<div class="crumbs">' + crumbsHtml() + "</div>" +
|
||||
'<div class="node-head">' +
|
||||
'<div class="node-title">' + esc(n.name) +
|
||||
'<span class="tag">' + n.kind + "</span>" +
|
||||
'<span class="tag tag-role">' + n.role + "</span>" +
|
||||
"</div>" +
|
||||
'<div class="node-actions">' +
|
||||
(canEdit && n.kind === "FOLDER" ? '<button class="btn" onclick="createChild()">+ 新建子节点</button>' : "") +
|
||||
(canManage ? '<button class="btn" onclick="renameCurrent()">重命名</button>' : "") +
|
||||
(canManage ? '<button class="btn btn-danger" onclick="deleteCurrent()">删除</button>' : "") +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
'<div class="tabs">' +
|
||||
tabs.map(([id, label]) =>
|
||||
'<button onclick="switchTab(\\'' + id + '\\')" class="tab' +
|
||||
(current.tab === id ? " active" : "") + '">' + label + "</button>").join("") +
|
||||
"</div>" +
|
||||
'<div id="tab-body"></div>' +
|
||||
"</div>";
|
||||
renderTab();
|
||||
}
|
||||
|
||||
function switchTab(id) { current.tab = id; renderMain(); }
|
||||
|
||||
async function renderTab() {
|
||||
const body = $("#tab-body");
|
||||
if (current.tab === "files") return renderFilesTab(body);
|
||||
if (current.tab === "grants") return renderGrantsTab(body);
|
||||
let extra = "";
|
||||
if (current.kind === "PROJECT") {
|
||||
extra =
|
||||
'<div class="divider"></div>' +
|
||||
'<div class="inline-form" style="justify-content:flex-start">' +
|
||||
'<span class="quiet">独立权限</span>' +
|
||||
'<b style="font-size:13px">' + (current.independentPermission ? "开启" : "关闭") + "</b>" +
|
||||
(current.role === "MANAGE"
|
||||
? '<button class="btn" onclick="toggleIndependent()">' + (current.independentPermission ? "关闭" : "开启") + "</button>"
|
||||
: "") +
|
||||
'<span class="quiet">关闭时仅继承父级权限(创建者除外)</span>' +
|
||||
"</div>" +
|
||||
'<div class="divider"></div>' +
|
||||
'<div class="section-title">导出</div>' +
|
||||
'<div class="inline-form">' +
|
||||
'<select id="x-target" class="select" style="width:auto"><option value="manifest">manifest(stub)</option></select>' +
|
||||
'<button class="btn" onclick="submitExport()">开始导出</button>' +
|
||||
'<span id="x-status" class="export-status"></span>' +
|
||||
"</div>";
|
||||
}
|
||||
body.innerHTML =
|
||||
'<div class="panel">' +
|
||||
'<div class="meta-grid">' +
|
||||
"<div>类型 <b>" + current.kind + "</b></div>" +
|
||||
"<div>我的权限 <b>" + current.role + "</b></div>" +
|
||||
"<div>创建时间 " + new Date(current.createdAt).toLocaleString("zh-CN") + "</div>" +
|
||||
"<div>更新时间 " + new Date(current.updatedAt).toLocaleString("zh-CN") + "</div>" +
|
||||
"</div>" + extra +
|
||||
"</div>";
|
||||
pollExport();
|
||||
}
|
||||
|
||||
/* ---------------- 节点操作 ---------------- */
|
||||
async function createChild() {
|
||||
createDialog(current.id);
|
||||
}
|
||||
function createDialog(parentId) {
|
||||
modal(
|
||||
'<div class="modal-title">' + (parentId ? "新建子节点" : "新建根目录") + "</div>" +
|
||||
'<div class="form-row"><label class="form-label">名称</label>' +
|
||||
'<input id="f-name" class="input" placeholder="例如:物理必修一"/></div>' +
|
||||
'<div class="form-row"><label class="form-label">类型</label>' +
|
||||
'<select id="f-kind" class="select">' +
|
||||
'<option value="FOLDER">文件夹</option><option value="PROJECT">项目(Git 仓库)</option>' +
|
||||
"</select></div>" +
|
||||
'<div class="modal-actions">' +
|
||||
'<button class="btn" onclick="closeModal()">取消</button>' +
|
||||
'<button class="btn btn-primary" onclick="submitCreate(\\'' + (parentId ?? "") + '\\')">创建</button>' +
|
||||
"</div>"
|
||||
);
|
||||
}
|
||||
async function submitCreate(parentId) {
|
||||
try {
|
||||
await api("/database/api/nodes", { method: "POST", body: {
|
||||
parentId: parentId === "" ? null : parentId,
|
||||
kind: $("#f-kind").value, name: $("#f-name").value,
|
||||
}});
|
||||
closeModal(); ok("已创建");
|
||||
if (parentId) expanded.add(parentId);
|
||||
await renderTree();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function renameCurrent() {
|
||||
const name = prompt("新名称", current.name);
|
||||
if (name === null) return;
|
||||
try {
|
||||
await api("/database/api/nodes/" + current.id, { method: "PATCH", body: { name } });
|
||||
ok("已重命名"); await selectNode(current.id);
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function deleteCurrent() {
|
||||
if (!confirm("确认删除「" + current.name + "」?软删除后不可见。")) return;
|
||||
try {
|
||||
await api("/database/api/nodes/" + current.id, { method: "DELETE" });
|
||||
ok("已删除"); current = null; await renderTree();
|
||||
$("#main").innerHTML = '<div class="empty-hint">从左侧选择一个文件夹或项目</div>';
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function toggleIndependent() {
|
||||
try {
|
||||
await api("/database/api/projects/" + current.id + "/independent-permission", {
|
||||
method: "PUT", body: { enabled: !current.independentPermission },
|
||||
});
|
||||
ok("已切换"); await selectNode(current.id);
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
/* ---------------- 授权 ---------------- */
|
||||
async function renderGrantsTab(body) {
|
||||
try {
|
||||
const { grants } = await api("/database/api/nodes/" + current.id + "/grants");
|
||||
const rows = grants.map((g) =>
|
||||
"<tr>" +
|
||||
"<td>" + (g.principalType === "USER" ? "👤" : "👥") + " " + esc(g.principalId) +
|
||||
(g.isCreatorGrant ? ' <span class="quiet">(创建者)</span>' : "") + "</td>" +
|
||||
'<td class="file-meta">' + g.role + "</td>" +
|
||||
"<td style='text-align:right'>" + (g.isCreatorGrant ? "" :
|
||||
'<button class="link-danger" onclick="revokeGrant(\\'' + g.id + '\\')">收回</button>') + "</td>" +
|
||||
"</tr>").join("");
|
||||
body.innerHTML =
|
||||
'<div class="panel">' +
|
||||
'<table class="list"><thead><tr><th>主体</th><th>级别</th><th></th></tr></thead>' +
|
||||
"<tbody>" + rows + "</tbody></table>" +
|
||||
'<div class="divider"></div>' +
|
||||
'<div class="section-title">新增授权</div>' +
|
||||
'<div class="inline-form">' +
|
||||
'<select id="g-type" class="select" style="width:110px" onchange="gTypeChanged()">' +
|
||||
'<option value="USER">用户</option><option value="GROUP">Group</option></select>' +
|
||||
'<input id="g-principal" class="input" placeholder="用户 id"/>' +
|
||||
'<select id="g-group" class="select hidden"></select>' +
|
||||
'<select id="g-role" class="select" style="width:110px">' +
|
||||
'<option value="VIEW">VIEW</option><option value="EDIT">EDIT</option><option value="MANAGE">MANAGE</option></select>' +
|
||||
'<button class="btn btn-primary" onclick="addGrant()">授予</button>' +
|
||||
"</div>" +
|
||||
'<div class="section-note">MANAGE 仅创建者可授;创建者授权不可动(契约 8.1)</div>' +
|
||||
"</div>";
|
||||
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
|
||||
}
|
||||
async function gTypeChanged() {
|
||||
const isGroup = $("#g-type").value === "GROUP";
|
||||
$("#g-principal").classList.toggle("hidden", isGroup);
|
||||
$("#g-group").classList.toggle("hidden", !isGroup);
|
||||
if (isGroup && $("#g-group").options.length === 0) {
|
||||
const { groups } = await api("/database/api/groups/search?q=");
|
||||
$("#g-group").innerHTML = groups.map((g) => '<option value="' + esc(g.id) + '">' + esc(g.name) + "</option>").join("");
|
||||
}
|
||||
}
|
||||
async function addGrant() {
|
||||
const type = $("#g-type").value;
|
||||
const principalId = type === "GROUP" ? $("#g-group").value : $("#g-principal").value.trim();
|
||||
if (!principalId) return toast("请填写主体", "err");
|
||||
try {
|
||||
await api("/database/api/nodes/" + current.id + "/grants", { method: "PUT", body: {
|
||||
grants: [{ principalType: type, principalId, role: $("#g-role").value }],
|
||||
}});
|
||||
ok("已授予"); renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function revokeGrant(grantId) {
|
||||
try {
|
||||
await api("/database/api/nodes/" + current.id + "/grants/" + grantId, { method: "DELETE" });
|
||||
ok("已收回"); renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
|
||||
/* ---------------- 文件 ---------------- */
|
||||
let currentFile = null;
|
||||
async function renderFilesTab(body) {
|
||||
try {
|
||||
const { files } = await api("/database/api/projects/" + current.id + "/files");
|
||||
const rows = files.map((f) =>
|
||||
'<tr class="file-row" onclick=\\'openFile("' + esc(f.path) + '")\\'>' +
|
||||
'<td class="file-path">' + esc(f.path) + "</td>" +
|
||||
'<td class="file-meta" style="text-align:right">' + f.size + " B</td></tr>").join("");
|
||||
body.innerHTML =
|
||||
'<div class="panel">' +
|
||||
'<div class="inline-form" style="justify-content:space-between;margin-bottom:6px">' +
|
||||
'<div class="section-title" style="margin:0">项目文件(' + files.length + ")</div>" +
|
||||
(current.role !== "VIEW"
|
||||
? '<div class="inline-form" style="gap:6px">' +
|
||||
'<button class="btn" onclick="newFileDialog()">+ 新建文件</button>' +
|
||||
'<button class="btn btn-primary" onclick="document.getElementById(\\'upload-input\\').click()">上传文件</button>' +
|
||||
'<input id="upload-input" type="file" class="hidden" onchange="doUpload(this)"/>' +
|
||||
"</div>"
|
||||
: "") +
|
||||
"</div>" +
|
||||
(files.length === 0 ? '<div class="quiet" style="padding:20px 0;text-align:center">空仓库 · 可新建或上传文件</div>'
|
||||
: '<table class="list">' + rows + "</table>") +
|
||||
"</div>" +
|
||||
'<div id="file-editor"></div>';
|
||||
} catch (e) { body.innerHTML = '<div class="panel quiet">' + esc(e.message) + "</div>"; }
|
||||
}
|
||||
async function newFileDialog() {
|
||||
modal(
|
||||
'<div class="modal-title">新建文件</div>' +
|
||||
'<div class="form-row"><label class="form-label">路径</label>' +
|
||||
'<input id="nf-path" class="input" style="font-family:var(--mono)" placeholder="docs/intro.md"/></div>' +
|
||||
'<div class="form-row"><label class="form-label">内容</label>' +
|
||||
'<textarea id="nf-content" rows="8" class="textarea" placeholder="内容…"></textarea></div>' +
|
||||
'<div class="modal-actions"><button class="btn" onclick="closeModal()">取消</button>' +
|
||||
'<button class="btn btn-primary" onclick="submitNewFile()">创建</button></div>'
|
||||
);
|
||||
}
|
||||
|
||||
/* 磁盘文件上传:文本走 utf8,含 NUL 字节判为二进制走 base64(与服务端一致)。 */
|
||||
function u8ToBase64(bytes) {
|
||||
let bin = "";
|
||||
const CHUNK = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += CHUNK) {
|
||||
bin += String.fromCharCode.apply(null, bytes.subarray(i, i + CHUNK));
|
||||
}
|
||||
return btoa(bin);
|
||||
}
|
||||
async function doUpload(input) {
|
||||
const file = input.files && input.files[0];
|
||||
input.value = "";
|
||||
if (!file) return;
|
||||
if (file.size > 10 * 1024 * 1024) return toast("文件超过 10MB 上限", "err");
|
||||
const targetPath = prompt("保存到路径(可含目录):", "材料/" + file.name);
|
||||
if (!targetPath) return;
|
||||
const bytes = new Uint8Array(await file.arrayBuffer());
|
||||
const isBinary = bytes.includes(0);
|
||||
const body = isBinary
|
||||
? { path: targetPath, content: u8ToBase64(bytes), encoding: "base64" }
|
||||
: { path: targetPath, content: new TextDecoder("utf-8").decode(bytes), encoding: "utf8" };
|
||||
try {
|
||||
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body });
|
||||
ok("已上传 " + file.name);
|
||||
renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function submitNewFile() {
|
||||
try {
|
||||
await api("/database/api/projects/" + current.id + "/file", { method: "PUT", body: {
|
||||
path: $("#nf-path").value, content: $("#nf-content").value,
|
||||
}});
|
||||
closeModal(); ok("已创建"); renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function openFile(path) {
|
||||
try {
|
||||
const f = await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(path));
|
||||
currentFile = f;
|
||||
const canEdit = current.role !== "VIEW";
|
||||
$("#file-editor").innerHTML =
|
||||
'<div class="panel">' +
|
||||
'<div class="inline-form" style="justify-content:space-between;margin-bottom:10px">' +
|
||||
'<span class="file-meta">' + esc(f.path) + " @ " + esc(f.version) + "</span>" +
|
||||
'<div class="inline-form" style="gap:6px">' +
|
||||
'<a class="btn" href="/database/api/projects/' + current.id + '/file/raw?path=' +
|
||||
encodeURIComponent(f.path) + '" download>下载</a>' +
|
||||
'<button class="btn" onclick="showHistory()">历史</button>' +
|
||||
(canEdit ? '<button class="btn btn-danger" onclick="deleteFile()">删除文件</button>' : "") +
|
||||
"</div>" +
|
||||
"</div>" +
|
||||
(f.encoding === "base64"
|
||||
? '<div class="quiet">二进制文件(' + f.size + " B),不支持在线编辑</div>"
|
||||
: '<textarea id="ef-content" rows="14" class="textarea" ' + (canEdit ? "" : "readonly") +
|
||||
">" + esc(f.content) + "</textarea>") +
|
||||
(canEdit && f.encoding !== "base64"
|
||||
? '<div class="modal-actions"><button class="btn btn-primary" onclick="saveFile()">提交修改</button></div>'
|
||||
: "") +
|
||||
'<div id="conflict-zone"></div>' +
|
||||
"</div>";
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function saveFile() {
|
||||
try {
|
||||
const r = await api("/database/api/projects/" + current.id + "/file/commits", { method: "POST", body: {
|
||||
path: currentFile.path, baseVersion: currentFile.version, content: $("#ef-content").value,
|
||||
}});
|
||||
ok("已提交 " + r.version); await openFile(currentFile.path);
|
||||
} catch (e) {
|
||||
if (e.status === 409 && e.currentVersion) return showConflict(e.currentVersion);
|
||||
fail(e);
|
||||
}
|
||||
}
|
||||
async function showConflict(currentVersion) {
|
||||
const { diff } = await api("/database/api/projects/" + current.id + "/file/diff?path=" +
|
||||
encodeURIComponent(currentFile.path) + "&from=" + encodeURIComponent(currentFile.version) +
|
||||
"&to=" + encodeURIComponent(currentVersion));
|
||||
$("#conflict-zone").innerHTML =
|
||||
'<div class="conflict-panel">' +
|
||||
'<div class="conflict-title">冲突:他人已提交 ' + esc(currentVersion) + ",差异如下(你的基版 → 最新版)</div>" +
|
||||
'<pre class="diff">' + esc(diff)
|
||||
.replace(/^\\+(.*)$/gm, '<span class="add">+$1</span>')
|
||||
.replace(/^-(.*)$/gm, '<span class="del">-$1</span>') + "</pre>" +
|
||||
'<div class="conflict-note">请人工合并后,以最新内容为全文重新提交(基版将更新为 ' + esc(currentVersion) + ")</div>" +
|
||||
'<div class="modal-actions"><button class="btn" onclick="acceptLatest(\\'' + esc(currentVersion) + '\\')">载入最新内容</button></div>' +
|
||||
"</div>";
|
||||
currentFile.version = currentVersion;
|
||||
}
|
||||
async function acceptLatest() { await openFile(currentFile.path); toast("已载入最新内容,请在此基础上合并", "info"); }
|
||||
async function deleteFile() {
|
||||
if (!confirm("删除文件 " + currentFile.path + "?")) return;
|
||||
try {
|
||||
await api("/database/api/projects/" + current.id + "/file?path=" + encodeURIComponent(currentFile.path),
|
||||
{ method: "DELETE", body: { baseVersion: currentFile.version } });
|
||||
ok("已删除"); currentFile = null; renderTab();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function showHistory() {
|
||||
const { history } = await api("/database/api/projects/" + current.id + "/file/history?path=" +
|
||||
encodeURIComponent(currentFile.path));
|
||||
modal(
|
||||
'<div class="modal-title">版本历史</div>' +
|
||||
'<div style="max-height:320px;overflow-y:auto">' +
|
||||
history.map((v) =>
|
||||
'<div style="border-top:1px solid #F5F5F4;padding:8px 0;font-size:12px">' +
|
||||
'<span class="file-meta" style="color:var(--accent)">' + esc(v.version) + "</span> " + esc(v.message) +
|
||||
'<div class="quiet">' + new Date(v.committedAt).toLocaleString("zh-CN") +
|
||||
(v.author ? " · " + esc(v.author) : "") + "</div></div>").join("") +
|
||||
"</div>" +
|
||||
'<div class="modal-actions"><button class="btn" onclick="closeModal()">关闭</button></div>'
|
||||
);
|
||||
}
|
||||
|
||||
/* ---------------- 导出 ---------------- */
|
||||
let exportJobId = null;
|
||||
async function submitExport() {
|
||||
try {
|
||||
const r = await api("/database/api/projects/" + current.id + "/exports", { method: "POST", body: { target: $("#x-target").value } });
|
||||
exportJobId = r.jobId;
|
||||
$("#x-status").textContent = "任务 " + r.jobId.slice(0, 8) + "… 排队中";
|
||||
pollExport();
|
||||
} catch (e) { fail(e); }
|
||||
}
|
||||
async function pollExport() {
|
||||
if (!exportJobId) return;
|
||||
try {
|
||||
const job = await api("/database/api/exports/" + exportJobId);
|
||||
const el = $("#x-status");
|
||||
if (!el) return;
|
||||
if (job.status === "DONE") {
|
||||
el.innerHTML = '完成 · <a href="/database/api/exports/' + exportJobId + '/download">下载</a>';
|
||||
} else if (job.status === "FAILED") {
|
||||
el.textContent = "失败:" + (job.error || "");
|
||||
} else {
|
||||
el.textContent = "状态:" + job.status + "…";
|
||||
setTimeout(pollExport, 1000);
|
||||
}
|
||||
} catch { /* job 可能属于别的项目 */ }
|
||||
}
|
||||
|
||||
/* ---------------- 启动 ---------------- */
|
||||
(async function init() {
|
||||
const logoutBtn = $("#btn-logout");
|
||||
if (logoutBtn) {
|
||||
logoutBtn.addEventListener("click", async () => {
|
||||
try { await fetch("/auth/logout", { method: "POST", credentials: "same-origin" }); } catch {}
|
||||
location.href = "/database/admin";
|
||||
});
|
||||
}
|
||||
try {
|
||||
me = await api("/database/api/me");
|
||||
if (me.isWebsiteAdmin) {
|
||||
const btn = $("#btn-new-root");
|
||||
btn.classList.remove("hidden");
|
||||
btn.onclick = () => createDialog(null);
|
||||
}
|
||||
} catch (e) { /* 401 已由 api 处理跳转 */ }
|
||||
await renderTree();
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
/**
|
||||
* 老师端用户前端托管(标准前后端分离):
|
||||
* GET /app/* — filelib-web(Svelte SPA)构建产物静态托管 + SPA 回退
|
||||
* GET /database/api/login-info — 登录页配置(org slug / dev 开关)
|
||||
* GET /app/dev-login{,-teacher} — DEV ONLY 一键登录(管理员 / 普通老师)
|
||||
*
|
||||
* 服务端不再渲染老师端页面;页面由独立前端工程 hub/filelib-web 产出。
|
||||
*/
|
||||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import fastifyStatic from "@fastify/static";
|
||||
import type { FastifyInstance } from "fastify";
|
||||
import type { PrismaClient } from "@prisma/client";
|
||||
import { SESSION_COOKIE_NAME, signSession } from "../../admin/auth/session.js";
|
||||
|
||||
export interface TeacherAppConfig {
|
||||
readonly prisma: PrismaClient;
|
||||
readonly sessionSecret: string;
|
||||
/** 飞书 OAuth 链接按 silo org slug 构造。 */
|
||||
readonly siloOrganizationSlug: string;
|
||||
/** DEV ONLY(双重门禁,见 database/plugin.ts):一键登录端点与按钮同进同退。 */
|
||||
readonly allowDevLoginBypass: boolean;
|
||||
}
|
||||
|
||||
export async function registerTeacherApp(
|
||||
app: FastifyInstance,
|
||||
config: TeacherAppConfig,
|
||||
): Promise<void> {
|
||||
// 登录页配置(公开;org slug 本就在 OAuth URL 中,不构成敏感信息)。
|
||||
app.get("/database/api/login-info", async () => ({
|
||||
orgSlug: config.siloOrganizationSlug,
|
||||
devLoginEnabled: config.allowDevLoginBypass,
|
||||
}));
|
||||
|
||||
// 标准分离:静态托管 SPA 构建产物;非文件路径回退 index.html 交给前端。
|
||||
const distDir = path.resolve(import.meta.dirname, "../../../filelib-web/dist");
|
||||
if (fs.existsSync(path.join(distDir, "index.html"))) {
|
||||
await app.register(fastifyStatic, { root: distDir, prefix: "/app/", decorateReply: true });
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
if (request.url.startsWith("/app")) {
|
||||
return reply.sendFile("index.html", distDir);
|
||||
}
|
||||
return reply.status(404).send({ error: { code: "not_found", message: "not found" } });
|
||||
});
|
||||
} else {
|
||||
app.log.warn({ distDir }, "filelib-web dist not found; build it with `npm run build --prefix filelib-web`");
|
||||
app.get("/app", async (_request, reply) =>
|
||||
reply
|
||||
.status(503)
|
||||
.type("text/plain")
|
||||
.send("filelib-web 未构建。请先运行 npm run build --prefix hub/filelib-web"),
|
||||
);
|
||||
}
|
||||
|
||||
if (!config.allowDevLoginBypass) return;
|
||||
registerDevLogins(app, config);
|
||||
}
|
||||
|
||||
/** DEV ONLY:普通老师一键登录端点(双重门禁见 plugin.ts)。
|
||||
* 老师端不提供管理员登录 —— 管理员从 /database/admin 进。 */
|
||||
function registerDevLogins(app: FastifyInstance, config: TeacherAppConfig): void {
|
||||
app.get("/app/dev-login-teacher", async (_request, reply) => {
|
||||
const prisma = config.prisma;
|
||||
const organization = await prisma.organization.findFirst({
|
||||
where: { status: "ACTIVE" },
|
||||
select: { id: true },
|
||||
});
|
||||
if (organization === null) {
|
||||
return reply.status(404).send({ error: { code: "no_org", message: "no active organization" } });
|
||||
}
|
||||
|
||||
let membership = await prisma.organizationMembership.findFirst({
|
||||
where: { organizationId: organization.id, role: "MEMBER", revokedAt: null },
|
||||
select: { userId: true, organizationId: true },
|
||||
});
|
||||
if (membership === null) {
|
||||
const teacher = await prisma.user.upsert({
|
||||
where: { feishuOpenId: "ou_dev_teacher" },
|
||||
update: {},
|
||||
create: { feishuOpenId: "ou_dev_teacher", displayName: "测试老师" },
|
||||
});
|
||||
await prisma.organizationMembership.create({
|
||||
data: { organizationId: organization.id, userId: teacher.id, role: "MEMBER" },
|
||||
});
|
||||
membership = { userId: teacher.id, organizationId: organization.id };
|
||||
}
|
||||
|
||||
const connection = await prisma.organizationFeishuApplicationConnection.findFirst({
|
||||
where: { organizationId: membership.organizationId, status: "ACTIVE" },
|
||||
select: { id: true, organizationId: true },
|
||||
});
|
||||
if (connection === null) {
|
||||
return reply.status(404).send({ error: { code: "no_connection", message: "no active Feishu connection for org" } });
|
||||
}
|
||||
let identity = await prisma.feishuUserIdentity.findFirst({
|
||||
where: { userId: membership.userId, connectionId: connection.id },
|
||||
select: { id: true, connectionId: true },
|
||||
});
|
||||
if (identity === null) {
|
||||
identity = await prisma.feishuUserIdentity.create({
|
||||
data: { connectionId: connection.id, userId: membership.userId, openId: "ou_dev_teacher" },
|
||||
select: { id: true, connectionId: true },
|
||||
});
|
||||
}
|
||||
|
||||
setSessionCookie(reply, config.sessionSecret, membership.userId, identity.id, identity.connectionId, connection.organizationId);
|
||||
reply.log.warn({ userId: membership.userId }, "DEV teacher-app login bypass (regular teacher) used");
|
||||
return reply.redirect("/app");
|
||||
});
|
||||
}
|
||||
|
||||
function setSessionCookie(
|
||||
reply: { setCookie: (name: string, value: string, opts: Record<string, unknown>) => void },
|
||||
secret: string,
|
||||
userId: string,
|
||||
feishuIdentityId: string,
|
||||
feishuConnectionId: string,
|
||||
feishuOrganizationId: string,
|
||||
): void {
|
||||
const token = signSession({ userId, feishuIdentityId, feishuConnectionId, feishuOrganizationId }, secret);
|
||||
reply.setCookie(SESSION_COOKIE_NAME, token, {
|
||||
path: "/",
|
||||
httpOnly: true,
|
||||
sameSite: "lax",
|
||||
secure: false,
|
||||
maxAge: 7 * 24 * 60 * 60,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* 全局共享 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; }
|
||||
`;
|
||||
Reference in New Issue
Block a user