feat: 新增 PDF 转换服务(epub->pdf,cookie 用户隔离,软删/硬删两级删除)
纯 Python 转换(ebooklib 解析 epub + weasyprint 渲染 HTML/CSS 为 PDF), 无需 calibre/xvfb 系统依赖。复用 UploadedFile 存储(原始文件与产物 PDF)。 - model/dao/schema:PdfJob 记录转换任务(owner_cookie/source/output/进度/状态) - pdf_converter:epub->PDF 转换器(按 spine 顺序拼 HTML,OPF 目录解析相对资源) - pdf_service:上传落盘 + asyncio 后台转换(独立 Session)+ 进度追踪 + 两级删除 · 用户软删(user_deleted,管理页仍可见)· 管理员硬删(真正删磁盘+记录) - pdf_controller:/api/pdf/*(用户,cookie)+ /api/admin/pdf/*(Basic Auth) - /pdf、/pdf-admin 页面(原生 JS,复用 common.css/js) - 配置 pdf 段(max_size 250MB、转换超时、cookie) - pytest 7 项(SQLite→MySQL 隔离测试库):上传/转换/下载/软删/硬删/超限/越权
This commit is contained in:
40
static/pdf.css
Normal file
40
static/pdf.css
Normal file
@@ -0,0 +1,40 @@
|
||||
/* PDF 转换页专属样式:上传区、进度条、任务表格。复用 common.css 的 .btn/.tag/table 等。 */
|
||||
.uploader { margin: 0 0 1.6em; }
|
||||
.dropzone {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 0.6em; padding: 2.4em 1.2em; min-height: 160px;
|
||||
border: 2px dashed var(--border); border-radius: var(--radius);
|
||||
background: var(--surface); text-align: center; transition: border-color 0.15s, background 0.15s;
|
||||
}
|
||||
.dropzone.is-drag { border-color: var(--primary); background: var(--primary-soft); }
|
||||
.dropzone__hint { color: var(--text-dim); font-size: 0.95em; }
|
||||
.dropzone__limit { color: var(--text-dim); font-size: 0.8em; }
|
||||
.dropzone .btn.primary { padding: 0.55em 1.4em; }
|
||||
|
||||
.upload-progress { margin-top: 0.8em; }
|
||||
.upload-progress.hidden { display: none; }
|
||||
.upload-progress__name { font-size: 0.88em; color: var(--text); margin-bottom: 0.3em; word-break: break-all; }
|
||||
|
||||
.bar { height: 8px; background: var(--surface-2); border-radius: 6px; overflow: hidden; border: 1px solid var(--border); }
|
||||
.bar__fill { height: 100%; width: 0; background: var(--primary); border-radius: 6px; transition: width 0.2s; }
|
||||
.bar--sm { display: inline-block; width: 90px; vertical-align: middle; height: 6px; }
|
||||
|
||||
.jobs-table th.col-size { width: 8em; }
|
||||
.jobs-table th.col-status { width: 12em; }
|
||||
.jobs-table th.col-time { width: 11em; }
|
||||
.jobs-table th.col-act { width: 9em; text-align: right; }
|
||||
.jobs-table td.col-act { text-align: right; white-space: nowrap; }
|
||||
.jobs-table td.col-name { font-weight: 600; word-break: break-all; }
|
||||
.jobs-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; }
|
||||
|
||||
.prog { display: inline-flex; align-items: center; gap: 0.5em; font-size: 0.82em; }
|
||||
.prog__label { color: var(--text-dim); white-space: nowrap; }
|
||||
|
||||
.hidden { display: none; }
|
||||
.skel, .empty { padding: 1.6em; text-align: center; color: var(--text-dim); font-size: 0.9em; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.jobs-table th, .jobs-table td { padding: 0.5em 0.4em; }
|
||||
.jobs-table th.col-time, .jobs-table td.col-time { font-size: 0.78em; }
|
||||
.dropzone { padding: 1.8em 0.8em; min-height: 130px; }
|
||||
}
|
||||
44
static/pdf.html
Normal file
44
static/pdf.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PDF 转换 - zikai</title>
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/pdf.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>PDF 转换</h1>
|
||||
<p class="sub">上传 epub 文件转为 PDF,转换完成后可下载。单文件上限 250MB。凭浏览器 cookie 记住你的任务。</p>
|
||||
|
||||
<div id="uploader" class="uploader">
|
||||
<div class="dropzone" id="dropzone">
|
||||
<div class="dropzone__hint">拖入 .epub 文件,或</div>
|
||||
<label class="btn primary">
|
||||
点击选择文件
|
||||
<input type="file" id="fileInput" accept=".epub,application/epub+zip" hidden>
|
||||
</label>
|
||||
<div class="dropzone__limit">仅支持 epub,最大 250MB</div>
|
||||
</div>
|
||||
<div id="uploadProgress" class="upload-progress hidden">
|
||||
<div class="upload-progress__name" id="uploadName"></div>
|
||||
<div class="bar"><div class="bar__fill" id="uploadBar"></div></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" id="refresh">刷新</button>
|
||||
<span class="muted" id="count"></span>
|
||||
</div>
|
||||
|
||||
<div id="list">
|
||||
<div class="skel">加载中…</div>
|
||||
</div>
|
||||
|
||||
<p class="foot"><a class="link" href="/pdf-admin">管理页</a> · zikai file service</p>
|
||||
</div>
|
||||
<script src="/static/common.js"></script>
|
||||
<script src="/static/pdf.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
191
static/pdf.js
Normal file
191
static/pdf.js
Normal file
@@ -0,0 +1,191 @@
|
||||
/* PDF 转换页:上传 epub + 进度轮询 + 下载 + 用户软删。
|
||||
复用 common.js 的 el/toast/fmtBytes/fmtTime/api 工具。
|
||||
state.items 缓存当前用户任务列表;轮询未完成任务进度直至终态。 */
|
||||
(function () {
|
||||
"use strict";
|
||||
const { el, toast, fmtBytes, fmtTime, api } = window.ZK;
|
||||
const dropzone = document.getElementById("dropzone");
|
||||
const fileInput = document.getElementById("fileInput");
|
||||
const uploadProgress = document.getElementById("uploadProgress");
|
||||
const uploadName = document.getElementById("uploadName");
|
||||
const uploadBar = document.getElementById("uploadBar");
|
||||
const refreshBtn = document.getElementById("refresh");
|
||||
const countEl = document.getElementById("count");
|
||||
const listEl = document.getElementById("list");
|
||||
|
||||
const state = { items: [], polling: null };
|
||||
|
||||
refreshBtn.addEventListener("click", () => load());
|
||||
|
||||
// ---------- 上传 ----------
|
||||
dropzone.addEventListener("dragover", (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.add("is-drag");
|
||||
});
|
||||
dropzone.addEventListener("dragleave", () => dropzone.classList.remove("is-drag"));
|
||||
dropzone.addEventListener("drop", (e) => {
|
||||
e.preventDefault();
|
||||
dropzone.classList.remove("is-drag");
|
||||
if (e.dataTransfer.files.length) handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
fileInput.addEventListener("change", () => {
|
||||
if (fileInput.files.length) handleFile(fileInput.files[0]);
|
||||
fileInput.value = "";
|
||||
});
|
||||
|
||||
function handleFile(file) {
|
||||
const name = file.name || "";
|
||||
if (!name.toLowerCase().endsWith(".epub")) {
|
||||
toast("仅支持 epub 文件", "err");
|
||||
return;
|
||||
}
|
||||
if (file.size > 250 * 1024 * 1024) {
|
||||
toast("文件超过 250MB 上限", "err");
|
||||
return;
|
||||
}
|
||||
uploadFile(file);
|
||||
}
|
||||
|
||||
function uploadFile(file) {
|
||||
uploadProgress.classList.remove("hidden");
|
||||
uploadName.textContent = file.name;
|
||||
uploadBar.style.width = "0%";
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open("POST", "/api/pdf/jobs");
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable) {
|
||||
uploadBar.style.width = Math.round((e.loaded / e.total) * 100) + "%";
|
||||
}
|
||||
};
|
||||
xhr.onload = () => {
|
||||
uploadProgress.classList.add("hidden");
|
||||
if (xhr.status === 200) {
|
||||
toast("已提交,转换中…");
|
||||
load();
|
||||
} else {
|
||||
let msg = "上传失败";
|
||||
try {
|
||||
const b = JSON.parse(xhr.responseText);
|
||||
msg = b.detail || msg;
|
||||
} catch {}
|
||||
toast(msg, "err");
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => {
|
||||
uploadProgress.classList.add("hidden");
|
||||
toast("网络错误", "err");
|
||||
};
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
xhr.send(fd);
|
||||
}
|
||||
|
||||
// ---------- 列表 ----------
|
||||
async function load() {
|
||||
listEl.innerHTML = '<div class="skel">加载中…</div>';
|
||||
countEl.textContent = "";
|
||||
try {
|
||||
const res = await api("/api/pdf/jobs");
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const body = await res.json();
|
||||
state.items = body.items || [];
|
||||
countEl.textContent = `共 ${body.total} 个任务`;
|
||||
render();
|
||||
schedulePoll();
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
function render() {
|
||||
if (!state.items.length) {
|
||||
listEl.innerHTML = '<div class="empty">还没有任务。上传一个 epub 试试吧。</div>';
|
||||
return;
|
||||
}
|
||||
const tbl = el("table", { class: "jobs-table" });
|
||||
tbl.appendChild(el("thead", null,
|
||||
el("tr", null,
|
||||
el("th", { class: "col-name" }, "文件名"),
|
||||
el("th", { class: "col-size" }, "大小"),
|
||||
el("th", { class: "col-status" }, "状态"),
|
||||
el("th", { class: "col-time" }, "创建时间"),
|
||||
el("th", { class: "col-act" }, "操作")
|
||||
)
|
||||
));
|
||||
const tbody = el("tbody", null);
|
||||
for (const j of state.items) tbody.appendChild(renderRow(j));
|
||||
tbl.appendChild(tbody);
|
||||
listEl.innerHTML = "";
|
||||
listEl.appendChild(tbl);
|
||||
}
|
||||
|
||||
function renderRow(j) {
|
||||
return el("tr", { dataset: { id: j.id } },
|
||||
el("td", { class: "col-name" }, j.source_filename),
|
||||
el("td", { class: "col-size mono" }, fmtBytes(j.source_size)),
|
||||
el("td", { class: "col-status" }, statusCell(j)),
|
||||
el("td", { class: "col-time muted" }, fmtTime(j.created_at)),
|
||||
el("td", { class: "col-act" }, actionsCell(j))
|
||||
);
|
||||
}
|
||||
|
||||
function statusCell(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 || "" }, "失败");
|
||||
// pending / converting 显示进度条
|
||||
const wrap = el("span", { class: "prog" });
|
||||
wrap.appendChild(el("span", { class: "prog__label" }, j.status === "converting" ? "转换中" : "排队中"));
|
||||
wrap.appendChild(el("span", { class: "bar bar--sm" },
|
||||
el("span", { class: "bar__fill", style: "width:" + (j.progress || 0) + "%" })
|
||||
));
|
||||
return wrap;
|
||||
}
|
||||
|
||||
function actionsCell(j) {
|
||||
const cell = el("span", null);
|
||||
if (j.status === "done") {
|
||||
cell.appendChild(el("a", { class: "btn primary", href: `/api/pdf/jobs/${j.id}/download`, download: "" }, "下载"));
|
||||
}
|
||||
cell.appendChild(el("button", { class: "btn danger", onclick: () => removeOne(j) }, "删除"));
|
||||
return cell;
|
||||
}
|
||||
|
||||
// ---------- 轮询未完成任务 ----------
|
||||
function schedulePoll() {
|
||||
if (state.polling) clearInterval(state.polling);
|
||||
const pending = state.items.filter((j) => j.status === "pending" || j.status === "converting");
|
||||
if (!pending.length) return;
|
||||
state.polling = setInterval(async () => {
|
||||
let stillPending = false;
|
||||
for (const j of pending) {
|
||||
try {
|
||||
const res = await api(`/api/pdf/jobs/${j.id}`);
|
||||
if (res.ok) {
|
||||
const fresh = await res.json();
|
||||
Object.assign(j, fresh);
|
||||
}
|
||||
} catch {}
|
||||
if (j.status === "pending" || j.status === "converting") stillPending = true;
|
||||
}
|
||||
render();
|
||||
if (!stillPending) {
|
||||
clearInterval(state.polling);
|
||||
state.polling = null;
|
||||
}
|
||||
}, 1500);
|
||||
}
|
||||
|
||||
async function removeOne(j) {
|
||||
if (!confirm(`确定删除「${j.source_filename}」?\n删除后将不再显示(管理员仍可见,需管理员彻底删除才会清除)。`)) return;
|
||||
try {
|
||||
const res = await api(`/api/pdf/jobs/${j.id}`, { method: "DELETE" });
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
await load();
|
||||
toast("已删除");
|
||||
} catch (e) {
|
||||
toast("删除失败:" + (e.message || e), "err");
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
18
static/pdf_admin.css
Normal file
18
static/pdf_admin.css
Normal file
@@ -0,0 +1,18 @@
|
||||
/* PDF 转换管理页专属样式(叠加在 pdf.css 之上)。标注「已删除」行、列宽调整。 */
|
||||
.admin-table th.col-size { width: 7em; }
|
||||
.admin-table th.col-status { width: 9em; }
|
||||
.admin-table th.col-del { width: 8em; }
|
||||
.admin-table th.col-time { width: 11em; }
|
||||
.admin-table th.col-act { width: 11em; text-align: right; }
|
||||
.admin-table td.col-act { text-align: right; white-space: nowrap; }
|
||||
.admin-table td.col-act .btn { padding: 0.3em 0.8em; font-size: 0.85em; margin-left: 0.3em; }
|
||||
|
||||
/* 用户已软删的行:淡化背景提示 */
|
||||
.admin-table tbody tr.row-soft-deleted td { background: var(--danger-soft); }
|
||||
.admin-table tbody tr.row-soft-deleted:hover td { background: var(--danger-soft); }
|
||||
.row-removed { opacity: 0; transition: opacity 0.25s; }
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-table th, .admin-table td { padding: 0.5em 0.4em; }
|
||||
.admin-table th.col-time, .admin-table td.col-time { font-size: 0.78em; }
|
||||
}
|
||||
30
static/pdf_admin.html
Normal file
30
static/pdf_admin.html
Normal file
@@ -0,0 +1,30 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>PDF 转换管理 - zikai</title>
|
||||
<link rel="stylesheet" href="/static/common.css">
|
||||
<link rel="stylesheet" href="/static/pdf.css">
|
||||
<link rel="stylesheet" href="/static/pdf_admin.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<h1>PDF 转换管理</h1>
|
||||
<p class="sub">查看全部转换任务(含用户已软删的,标注是否已删除),可硬删(真正删除磁盘与记录)。</p>
|
||||
|
||||
<div class="toolbar">
|
||||
<button class="btn primary" id="refresh">刷新</button>
|
||||
<span class="muted" id="count"></span>
|
||||
</div>
|
||||
|
||||
<div id="list">
|
||||
<div class="skel">加载中…</div>
|
||||
</div>
|
||||
|
||||
<p class="foot"><a class="link" href="/pdf">PDF 转换页</a> · zikai file service</p>
|
||||
</div>
|
||||
<script src="/static/common.js"></script>
|
||||
<script src="/static/pdf_admin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
97
static/pdf_admin.js
Normal file
97
static/pdf_admin.js
Normal file
@@ -0,0 +1,97 @@
|
||||
/* PDF 转换管理页:拉取 /api/admin/pdf/jobs、渲染表格(含「是否已删除」列)、硬删。 */
|
||||
(function () {
|
||||
"use strict";
|
||||
const { el, toast, fmtBytes, fmtTime, api } = window.ZK;
|
||||
const listEl = document.getElementById("list");
|
||||
const countEl = document.getElementById("count");
|
||||
const refreshBtn = document.getElementById("refresh");
|
||||
|
||||
refreshBtn.addEventListener("click", load);
|
||||
|
||||
async function load() {
|
||||
listEl.innerHTML = '<div class="skel">加载中…</div>';
|
||||
countEl.textContent = "";
|
||||
try {
|
||||
const res = await api("/api/admin/pdf/jobs?limit=500&offset=0");
|
||||
if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
const body = await res.json();
|
||||
render(body.items || []);
|
||||
countEl.textContent = `共 ${body.total} 个任务`;
|
||||
} catch (e) {
|
||||
listEl.innerHTML = '<div class="empty">加载失败:' + (e.message || e) + "</div>";
|
||||
}
|
||||
}
|
||||
|
||||
function render(items) {
|
||||
if (!items.length) {
|
||||
listEl.innerHTML = '<div class="empty">还没有任务。</div>';
|
||||
return;
|
||||
}
|
||||
const table = el("table", { class: "jobs-table admin-table" });
|
||||
const thead = el("thead", null,
|
||||
el("tr", null,
|
||||
el("th", { class: "col-name" }, "文件名"),
|
||||
el("th", { class: "col-size" }, "大小"),
|
||||
el("th", { class: "col-status" }, "状态"),
|
||||
el("th", { class: "col-del" }, "是否已删除"),
|
||||
el("th", { class: "col-time" }, "创建时间"),
|
||||
el("th", { class: "col-act" }, "操作")
|
||||
)
|
||||
);
|
||||
const tbody = el("tbody", null);
|
||||
for (const j of items) tbody.appendChild(renderRow(j));
|
||||
table.appendChild(thead);
|
||||
table.appendChild(tbody);
|
||||
listEl.innerHTML = "";
|
||||
listEl.appendChild(table);
|
||||
}
|
||||
|
||||
function renderRow(j) {
|
||||
const row = el("tr", { class: j.user_deleted ? "row-soft-deleted" : "" },
|
||||
el("td", { class: "col-name" }, j.source_filename),
|
||||
el("td", { class: "col-size mono" }, fmtBytes(j.source_size)),
|
||||
el("td", { class: "col-status" }, statusCell(j)),
|
||||
el("td", { class: "col-del" }, deletedCell(j)),
|
||||
el("td", { class: "col-time muted" }, fmtTime(j.created_at)),
|
||||
el("td", { class: "col-act" },
|
||||
j.status === "done"
|
||||
? el("a", { class: "btn", href: `/api/admin/files/${j.output_file_id}/download`, download: "" }, "下载产物")
|
||||
: el("span", { class: "muted" }, "-"),
|
||||
el("button", { class: "btn danger", onclick: () => remove(j, row) }, "硬删")
|
||||
)
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
function statusCell(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 || "" }, "失败");
|
||||
return el("span", { class: "tag warn" }, j.status === "converting" ? `转换中 ${j.progress}%` : "排队中");
|
||||
}
|
||||
|
||||
function deletedCell(j) {
|
||||
if (j.user_deleted) {
|
||||
return el("span", { class: "tag err", title: fmtTime(j.deleted_at) }, "已删除");
|
||||
}
|
||||
return el("span", { class: "muted" }, "否");
|
||||
}
|
||||
|
||||
async function remove(j, row) {
|
||||
if (!confirm(`确定硬删「${j.source_filename}」?\n将真正删除原始文件与产物 PDF(磁盘 + 记录),不可恢复。`)) return;
|
||||
try {
|
||||
const res = await api(`/api/admin/pdf/jobs/${j.id}`, { method: "DELETE" });
|
||||
if (res.status === 404) { toast("任务已不存在"); }
|
||||
else if (!res.ok) throw new Error("HTTP " + res.status);
|
||||
row.classList.add("row-removed");
|
||||
setTimeout(() => row.remove(), 250);
|
||||
toast("已硬删");
|
||||
const m = (countEl.textContent || "").match(/(\d+)/);
|
||||
if (m) countEl.textContent = `共 ${Math.max(0, Number(m[1]) - 1)} 个任务`;
|
||||
} catch (e) {
|
||||
toast("删除失败:" + (e.message || e), "err");
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
})();
|
||||
Reference in New Issue
Block a user