forked from bai/curriculum-project-hub
802 lines
34 KiB
TypeScript
802 lines
34 KiB
TypeScript
/**
|
|
* /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>`;
|
|
}
|