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; 手动校验各页面路由与合并接口响应
310 lines
12 KiB
JavaScript
310 lines
12 KiB
JavaScript
/* 文件管理页:分页拉取 /api/admin/files-with-pdf、复选框多选 + 全选、批量下载/删除、复制 sha。
|
||
合并 PDF 转换管理:每个文件附带 pdf_jobs(role=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);
|
||
})();
|