前端整理: 合并文件管理页 + 新增导航页

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; 手动校验各页面路由与合并接口响应
This commit is contained in:
2026-07-28 14:04:12 +08:00
parent 9af28f41b4
commit 7c193ca46e
17 changed files with 275 additions and 289 deletions

View File

@@ -1,4 +1,6 @@
/* 文件浏览页:分页拉取 /api/admin/files、复选框多选 + 全选、批量下载/删除、复制 sha。
/* 文件管理页:分页拉取 /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";
@@ -53,7 +55,7 @@
countEl.textContent = "";
pagerEl.classList.add("hidden");
try {
const res = await api(`/api/admin/files?limit=${limit}&offset=${offset}`);
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 || [];
@@ -88,13 +90,15 @@
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 row = el("tr", { class: checked ? "sel" : "", dataset: { id: 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 } })
),
@@ -105,6 +109,7 @@
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) }, "删除")
@@ -190,6 +195,58 @@
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 {