Files
zTools2/static/file_browser.js
zikai 7c193ca46e 前端整理: 合并文件管理页 + 新增导航页
1. 合并 files-page 与 pdf-admin 为统一文件管理页 /api/files-page:
   - 新增 GET /api/admin/files/with-pdf 接口,以 uploaded_files 为基础,
     用 pdf_jobs.source_file_id / output_file_id 内存匹配,为关联文件标注
     转换状态、用户软删标记与角色(源epub/产物PDF)
   - PdfJobOut 补 source_file_id / output_file_id 字段
   - file_browser 新增 PDF 任务列(状态徽标/软删标记/硬删任务按钮)
   - 删除 /api/pdf-admin 路由及 static/pdf_admin.* 三个文件

2. 新增导航页 /api/index,卡片式收集所有页面入口;
   各子页(upload/whiteboard/system_status/files-page)脚注加返回导航链接

3. 更新 docs/routes.md、docs/configuration.md 同步说明

测试: pytest tests/test_pdf_service.py 7 passed; 手动校验各页面路由与合并接口响应
2026-07-28 14:04:12 +08:00

310 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 文件管理页:分页拉取 /api/admin/files-with-pdf、复选框多选 + 全选、批量下载/删除、复制 sha。
合并 PDF 转换管理:每个文件附带 pdf_jobsrole=source epub / role=output 产物 PDF
渲染转换状态徽标、用户软删标记并支持硬删任务DELETE /api/admin/pdf/jobs/{id})。
state.items 缓存当前页数据;切换页/页大小重新拉取;删除后若当前页空则回退一页。 */
(function () {
"use strict";
const { el, toast, copyText, fmtBytes, fmtTime, api } = window.ZK;
const listEl = document.getElementById("list");
const countEl = document.getElementById("count");
const refreshBtn = document.getElementById("refresh");
const pageSizeSel = document.getElementById("pageSize");
const batchbar = document.getElementById("batchbar");
const selCountEl = document.getElementById("selCount");
const batchDownloadBtn = document.getElementById("batchDownload");
const batchDeleteBtn = document.getElementById("batchDelete");
const clearSelBtn = document.getElementById("clearSel");
const pagerEl = document.getElementById("pager");
// 分页状态
const state = {
page: 1, // 从 1 开始
pageSize: 100, // 数字或 "all"
total: 0,
items: [],
selected: new Set(), // 选中的 file id
};
refreshBtn.addEventListener("click", () => load(state.page));
pageSizeSel.addEventListener("change", () => {
const v = pageSizeSel.value;
state.pageSize = v === "all" ? "all" : Number(v);
state.page = 1;
load(1);
});
batchDownloadBtn.addEventListener("click", batchDownload);
batchDeleteBtn.addEventListener("click", batchDelete);
clearSelBtn.addEventListener("click", () => {
state.selected.clear();
renderSelection();
});
function effectiveLimit() {
return state.pageSize === "all" ? 10000 : state.pageSize;
}
function totalPages() {
if (state.pageSize === "all") return 1;
return Math.max(1, Math.ceil(state.total / state.pageSize));
}
async function load(page) {
state.page = Math.max(1, page);
const limit = effectiveLimit();
const offset = (state.page - 1) * (state.pageSize === "all" ? 0 : state.pageSize);
listEl.innerHTML = '<div class="skel">加载中…</div>';
countEl.textContent = "";
pagerEl.classList.add("hidden");
try {
const res = await api(`/api/admin/files/with-pdf?limit=${limit}&offset=${offset}`);
if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json();
state.items = body.items || [];
state.total = body.total || 0;
// 删除后当前页可能空且不是首页:回退一页
if (state.items.length === 0 && state.page > 1) {
load(state.page - 1);
return;
}
countEl.textContent = `${state.total} 个文件`;
render();
renderPager();
} catch (e) {
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
}
}
function render() {
if (!state.items.length) {
listEl.innerHTML = '<div class="empty">还没有文件。去 <a class="link" href="/api/upload">上传</a> 一个吧。</div>';
renderSelection();
return;
}
const allChecked = state.items.length > 0 && state.items.every((f) => state.selected.has(f.id));
const table = el("table", { class: "files-table" });
const thead = el("thead", null,
el("tr", null,
el("th", { class: "col-sel" },
el("input", { type: "checkbox", id: "selectAll", checked: allChecked })
),
el("th", { class: "col-name" }, "文件名 / SHA-256"),
el("th", { class: "col-size" }, "大小"),
el("th", { class: "col-src" }, "来源"),
el("th", { class: "col-time" }, "上传时间"),
el("th", { class: "col-pdf" }, "PDF 任务"),
el("th", { class: "col-act" }, "操作")
)
);
const tbody = el("tbody", null);
for (const f of state.items) {
const checked = state.selected.has(f.id);
const pdfDeleted = (f.pdf_jobs || []).some((j) => j.user_deleted);
const row = el("tr", { class: [checked ? "sel" : "", pdfDeleted ? "row-pdf-deleted" : ""].filter(Boolean).join(" "), dataset: { id: f.id } },
el("td", { class: "col-sel" },
el("input", { type: "checkbox", class: "row-sel", checked, dataset: { id: f.id } })
),
el("td", { class: "col-name" },
el("div", { class: "fname" }, f.original_filename),
el("span", { class: "sha mono sha-short", title: "点击复制完整 SHA-256" }, shortSha(f.sha256))
),
el("td", { class: "col-size mono" }, fmtBytes(f.size_bytes)),
el("td", { class: "col-src" }, el("span", { class: "tag" }, f.source || "-")),
el("td", { class: "col-time muted" }, fmtTime(f.uploaded_at)),
el("td", { class: "col-pdf" }, renderPdfCell(f)),
el("td", { class: "col-act" },
el("a", { class: "btn primary", href: `/api/admin/files/${f.id}/download`, download: "" }, "下载"),
el("button", { class: "btn danger", onclick: () => removeOne(f) }, "删除")
)
);
const shaNode = row.querySelector(".sha-short");
shaNode.addEventListener("click", async () => {
const ok = await copyText(f.sha256);
toast(ok ? "已复制 SHA-256" : "复制失败");
});
const cb = row.querySelector(".row-sel");
cb.addEventListener("change", () => toggleSelect(f.id, cb.checked));
tbody.appendChild(row);
}
table.appendChild(thead);
table.appendChild(tbody);
listEl.innerHTML = "";
listEl.appendChild(table);
document.getElementById("selectAll").addEventListener("change", (e) => {
const on = e.target.checked;
for (const f of state.items) {
if (on) state.selected.add(f.id);
else state.selected.delete(f.id);
}
render();
renderSelection();
});
renderSelection();
}
function toggleSelect(id, on) {
if (on) state.selected.add(id);
else state.selected.delete(id);
render();
renderSelection();
}
function renderSelection() {
const n = state.selected.size;
selCountEl.textContent = n;
batchbar.classList.toggle("hidden", n === 0);
// 更新全选框状态(不重新渲染表格,只改 checkbox
const sa = document.getElementById("selectAll");
if (sa) {
sa.checked = state.items.length > 0 && state.items.every((f) => state.selected.has(f.id));
}
}
function renderPager() {
if (state.pageSize === "all" || totalPages() <= 1) {
pagerEl.classList.add("hidden");
return;
}
pagerEl.classList.remove("hidden");
pagerEl.innerHTML = "";
const tp = totalPages();
const cur = state.page;
const mk = (label, page, opts = {}) => {
const b = el("button", {
class: "btn" + (page === cur && !opts.ellipsis ? " is-current" : ""),
onclick: () => load(page),
}, String(label));
if (opts.disabled) b.disabled = true;
return b;
};
pagerEl.appendChild(mk("", cur - 1, { disabled: cur <= 1 }));
// 页码:始终显示首页、末页、当前页前后各 1 页,其余用省略号
const pages = new Set([1, tp, cur, cur - 1, cur + 1]);
const sorted = [...pages].filter((p) => p >= 1 && p <= tp).sort((a, b) => a - b);
let last = 0;
for (const p of sorted) {
if (p - last > 1) pagerEl.appendChild(el("span", { class: "pager-ellipsis" }, "…"));
pagerEl.appendChild(mk(p, p));
last = p;
}
pagerEl.appendChild(mk("", cur + 1, { disabled: cur >= tp }));
pagerEl.appendChild(el("span", { class: "pager-info" }, `${cur}/${tp}`));
}
function shortSha(sha) {
if (!sha) return "-";
return sha.length > 16 ? sha.slice(0, 12) + "…" + sha.slice(-4) : sha;
}
// 渲染 PDF 任务列:展示角色(源 epub / 产物 PDF徽标、转换状态、用户软删标记与硬删按钮。
// 一个文件可能同时被多个 job 引用(罕见),全列出;无关联则显示 -。
function renderPdfCell(f) {
const jobs = f.pdf_jobs || [];
if (!jobs.length) return el("span", { class: "muted" }, "-");
const cell = el("div", { class: "pdf-cell" });
const roles = el("div", { class: "pdf-roles" });
for (const j of jobs) {
roles.appendChild(el("span", { class: "role-tag " + j.role, title: j.role === "source" ? "PDF 转换的 epub 源文件" : "PDF 转换的产物 PDF" },
j.role === "source" ? "源" : "产物"));
roles.appendChild(statusTag(j));
if (j.user_deleted) {
roles.appendChild(el("span", { class: "del-mark", title: "用户已软删:" + fmtTime(j.deleted_at) }, "已删"));
}
roles.appendChild(el("span", { class: "muted" }, "#"+j.job_id));
}
cell.appendChild(roles);
// 硬删按钮:对每个关联 job 提供source 与 output 共属同一 job去重后只显示一个
const seenJobIds = new Set();
const actBar = el("div", { class: "pdf-act" });
for (const j of jobs) {
if (seenJobIds.has(j.job_id)) continue;
seenJobIds.add(j.job_id);
actBar.appendChild(el("button", {
class: "btn danger", onclick: () => removePdfJob(j, f)
}, "硬删任务"));
}
cell.appendChild(actBar);
return cell;
}
function statusTag(j) {
if (j.status === "done") return el("span", { class: "tag ok" }, "完成");
if (j.status === "failed") return el("span", { class: "tag err", title: j.error_message || "" }, "失败");
if (j.status === "converting") return el("span", { class: "tag warn" }, "转换中 " + j.progress + "%");
return el("span", { class: "tag" }, "排队中");
}
// 硬删 PDF 任务:删 job + 其源/产物文件。删除后重载当前页。
async function removePdfJob(j, f) {
if (!confirm(`确定硬删 PDF 任务 #${j.job_id}\n将同时删除关联的源文件与产物 PDF不可恢复。`)) return;
try {
const res = await api(`/api/admin/pdf/jobs/${j.job_id}`, { method: "DELETE" });
if (res.status === 404) { toast("任务已不存在"); }
else if (!res.ok) throw new Error("HTTP " + res.status);
toast("已硬删任务");
await load(state.page);
} catch (e) {
toast("硬删失败:" + (e.message || e), "err");
}
}
async function removeOne(f) {
if (!confirm(`确定删除「${f.original_filename}」?\n此操作不可恢复,将同时删除磁盘文件。`)) return;
try {
const res = await api(`/api/admin/files/${f.id}`, { method: "DELETE" });
if (!res.ok) throw new Error("HTTP " + res.status);
state.selected.delete(f.id);
// 删除后重新加载当前页(后续行会自动上移补位)
await load(state.page);
toast("已删除");
} catch (e) {
toast("删除失败:" + (e.message || e), "err");
}
}
async function batchDelete() {
const ids = [...state.selected];
if (!ids.length) return;
if (!confirm(`确定删除选中的 ${ids.length} 个文件?\n此操作不可恢复,将同时删除磁盘文件。`)) return;
batchDeleteBtn.disabled = true;
try {
const res = await api("/api/admin/files/batch-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
});
if (!res.ok) throw new Error("HTTP " + res.status);
const body = await res.json();
state.selected.clear();
await load(state.page);
const miss = body.not_found && body.not_found.length;
toast(`已删除 ${body.deleted}` + (miss ? `${miss} 个未找到` : ""));
} catch (e) {
toast("批量删除失败:" + (e.message || e), "err");
} finally {
batchDeleteBtn.disabled = false;
}
}
async function batchDownload() {
const ids = [...state.selected];
if (!ids.length) return;
if (ids.length > 20) {
if (!confirm(`将下载 ${ids.length} 个文件,浏览器可能弹出多个下载提示。继续?`)) return;
}
// 逐个触发下载:同源 Basic Auth 凭据由浏览器自动带上
for (const id of ids) {
const a = document.createElement("a");
a.href = `/api/admin/files/${id}/download`;
a.download = "";
document.body.appendChild(a);
a.click();
a.remove();
// 间隔 150ms避免浏览器拦截并发下载
await new Promise((r) => setTimeout(r, 150));
}
toast(`已触发 ${ids.length} 个下载`);
}
load(1);
})();