From e70a3a877fdc6eede530c14552354f8e3d3bcbfd Mon Sep 17 00:00:00 2001 From: hz4th_coder Date: Tue, 11 Aug 2026 13:25:28 +0800 Subject: [PATCH] =?UTF-8?q?v1.0.3:=20=E5=9B=9E=E6=94=B6=E7=AB=99=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=20-=20=E5=88=A0=E9=99=A4=E4=BB=BB=E5=8A=A1=E8=BF=9B?= =?UTF-8?q?=E5=9B=9E=E6=94=B6=E7=AB=99=E5=8F=AF=E6=81=A2=E5=A4=8D,=20?= =?UTF-8?q?=E4=BB=85=E5=8F=AF=E6=89=8B=E5=8A=A8=E5=BD=BB=E5=BA=95=E5=88=A0?= =?UTF-8?q?=E9=99=A4/=E6=B8=85=E7=A9=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 5 ++++ app.py | 72 +++++++++++++++++++++++++++++++++++++++++--- static/app.js | 76 ++++++++++++++++++++++++++++++++++++++++++++--- static/index.html | 12 ++++++++ static/style.css | 15 ++++++++++ store.py | 64 +++++++++++++++++++++++++++++++++++++++ 6 files changed, 236 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 0fe6c4e..f6da37f 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ ### 1. 前端管理界面 - **总体统计区**:任务总数 / 运行中 / 累计运行次数 / 成功失败页面 / 图片数 / 磁盘占用 +- **🗑️ 回收站**:删除的任务统一进回收站(可恢复);只有手动在回收站中才能彻底删除或清空,防止误删 - **日间/夜间双主题**:右上角按钮一键切换,自动记忆选择 - 任务卡片总览:状态、进度、统计、下次调度时间一目了然 - 一键操作:开始 / 暂停 / 恢复 / 终止 / 编辑 / 删除 @@ -76,6 +77,10 @@ | POST | `/api/tasks//pause` | 暂停 | | POST | `/api/tasks//resume` | 恢复 | | POST | `/api/tasks//stop` | 终止 | +| GET | `/api/trash` | 回收站列表 | +| POST | `/api/trash//restore` | 从回收站恢复任务 | +| DELETE | `/api/trash/` | 彻底删除单个(默认输出目录一并清理) | +| DELETE | `/api/trash` | 清空回收站 | | POST | `/api/probe` | 试爬取(表单规则预览链接清单) | | POST | `/api/tasks//probe` | 对已保存的自动任务试爬取 | | GET | `/api/runs/` | 运行详情(结果+日志) | diff --git a/app.py b/app.py index 4efad97..d1882f9 100644 --- a/app.py +++ b/app.py @@ -143,7 +143,7 @@ def api_status(): @app.route("/api/tasks", methods=["GET"]) def api_tasks(): - tasks = store.load_tasks() + tasks = [t for t in store.load_tasks() if not t.get("deleted_at")] for t in tasks: runs = store.get_runs(t["id"]) t["latest_run"] = runs[-1] if runs else None @@ -221,6 +221,8 @@ def api_update_task(tid): task = store.get_task(tid) if not task: return jsonify({"error": "任务不存在"}), 404 + if task.get("deleted_at"): + return jsonify({"error": "任务在回收站中,请先恢复"}), 400 body = request.get_json(force=True) or {} with JOBS_LOCK: job = JOBS.get(tid) @@ -250,11 +252,71 @@ def api_update_task(tid): @app.route("/api/tasks/", methods=["DELETE"]) def api_delete_task(tid): + """删除任务 -> 移入回收站 (可恢复)""" + task = store.get_task(tid) + if not task: + return jsonify({"error": "任务不存在"}), 404 + if task.get("deleted_at"): + return jsonify({"error": "任务已在回收站中"}), 400 _stop_job(tid) with JOBS_LOCK: JOBS.pop(tid, None) - store.delete_task(tid) - return jsonify({"ok": True}) + store.soft_delete_task(tid, now_str()) + return jsonify({"ok": True, "msg": "已移入回收站"}) + + +# ---------------- API: 回收站 ---------------- + +def _purge_out_dir(out_dir): + """删除任务输出目录; 仅当目录位于项目 out/ 下才删 (自定义目录保留), 返回是否删除""" + try: + d = os.path.realpath(out_dir) + base = os.path.realpath(os.path.join(HERE, "out")) + if d.startswith(base + os.sep) and os.path.isdir(d): + import shutil + shutil.rmtree(d, ignore_errors=True) + return True + except Exception: + pass + return False + + +@app.route("/api/trash", methods=["GET"]) +def api_trash_list(): + items = store.list_trash() + for t in items: + t["runs_count"] = len(store.get_runs(t["id"])) + t["out_dir"] = resolve_out_dir(t) + return jsonify(items) + + +@app.route("/api/trash//restore", methods=["POST"]) +def api_trash_restore(tid): + task = store.get_task(tid) + if not task or not task.get("deleted_at"): + return jsonify({"error": "任务不在回收站中"}), 404 + store.restore_task(tid) + return jsonify({"ok": True, "msg": "已恢复"}) + + +@app.route("/api/trash/", methods=["DELETE"]) +def api_trash_purge(tid): + task = store.get_task(tid) + if not task or not task.get("deleted_at"): + return jsonify({"error": "任务不在回收站中"}), 404 + out_dir = resolve_out_dir(task) + removed = _purge_out_dir(out_dir) + store.purge_task(tid) + return jsonify({"ok": True, "purged": True, "files_removed": removed, "out_dir": out_dir}) + + +@app.route("/api/trash", methods=["DELETE"]) +def api_trash_clear(): + items = store.list_trash() + dirs = [resolve_out_dir(t) for t in items] + store.purge_trash() + removed = sum(1 for d in dirs if _purge_out_dir(d)) + return jsonify({"ok": True, "purged": len(items), "dirs_removed": removed}) # ---------------- API: 运行控制 ---------------- @@ -301,7 +363,8 @@ def api_resume(tid): @app.route("/api/stats") def api_stats(): - tasks = store.load_tasks() + tasks = [t for t in store.load_tasks() if not t.get("deleted_at")] + trash_count = sum(1 for t in store.load_tasks() if t.get("deleted_at")) total_runs = ok = fail = imgs = 0 for t in tasks: for r in store.get_runs(t["id"]): @@ -334,6 +397,7 @@ def api_stats(): "fail": fail, "images": imgs, "disk_mb": round(size / 1048576, 1), + "trash": trash_count, }) diff --git a/static/app.js b/static/app.js index 7db8285..31a886e 100644 --- a/static/app.js +++ b/static/app.js @@ -63,6 +63,7 @@ async function loadStats() { $("stImgs").textContent = s.images; $("stDisk").textContent = s.disk_mb >= 1024 ? (s.disk_mb / 1024).toFixed(1) + " GB" : s.disk_mb + " MB"; + $("trashCount").textContent = s.trash || 0; } catch (e) { /* 统计失败忽略 */ } } @@ -152,10 +153,75 @@ async function actTask(tid, act) { async function delTask(tid) { const t = state.tasks.find((x) => x.id === tid); - if (!confirm(`确定删除任务「${t ? t.name : tid}」?\n运行记录与任务配置将被删除(已爬取的文件保留在磁盘上)。`)) return; + if (!confirm(`确定删除任务「${t ? t.name : tid}」?\n任务将移入回收站,可在回收站中恢复或彻底删除。`)) return; try { - await api(`/api/tasks/${tid}`, { method: "DELETE" }); - toast("已删除"); + const r = await api(`/api/tasks/${tid}`, { method: "DELETE" }); + toast(r.msg || "已删除"); + loadTasks(); + } catch (e) { toast(e.message, true); } +} + +/* ---------------- 回收站 ---------------- */ +async function openTrash() { + try { + const items = await api("/api/trash"); + state.trashItems = items; + renderTrash(items); + showModal("trashModal"); + } catch (e) { toast(e.message, true); } +} + +function renderTrash(items) { + const body = $("trashBody"); + if (!items.length) { + body.innerHTML = `
🗑️

回收站是空的

`; + return; + } + body.innerHTML = items.map((t) => ` +
+
+ ${esc(t.name)} + ${MODE_LABEL[t.mode] || esc(t.mode)} +
+
删除时间: ${esc(t.deleted_at)} · 运行次数: ${t.runs_count || 0}
+
输出目录: ${esc(t.out_dir || "")}
+
+ + +
+
`).join("") + + `
+ 共 ${items.length} 项 · 彻底删除将移除任务配置与运行记录(默认输出目录一并清理) + +
`; +} + +async function restoreTrash(tid) { + try { + const r = await api(`/api/trash/${tid}/restore`, { method: "POST" }); + toast(r.msg || "已恢复"); + openTrash(); + loadTasks(); + } catch (e) { toast(e.message, true); } +} + +async function purgeTrashTask(tid) { + const t = state.trashItems && state.trashItems.find((x) => x.id === tid); + if (!confirm(`彻底删除任务「${t ? t.name : tid}」?\n任务配置与所有运行记录将永久删除,不可恢复!`)) return; + try { + const r = await api(`/api/trash/${tid}`, { method: "DELETE" }); + toast(r.files_removed ? "已彻底删除(含输出文件)" : "已彻底删除(自定义输出目录已保留)"); + openTrash(); + loadTasks(); + } catch (e) { toast(e.message, true); } +} + +async function clearTrash() { + if (!confirm(`确定清空回收站?\n回收站中所有任务将永久删除,不可恢复!`)) return; + try { + const r = await api("/api/trash", { method: "DELETE" }); + toast(`已清空 ${r.purged} 项`); + openTrash(); loadTasks(); } catch (e) { toast(e.message, true); } } @@ -633,6 +699,7 @@ try { $("btnNew").onclick = openCreate; $("btnNew2").onclick = openCreate; $("btnRefresh").onclick = loadTasks; +$("btnTrash").onclick = openTrash; $("btnProbe").onclick = () => { const p = collectProbeFromForm(); if (!p.seed_url) { toast("请先填写起始网址", true); return; } @@ -650,6 +717,7 @@ document.querySelectorAll("[data-close-detail]").forEach((b) => b.onclick = () = document.querySelectorAll("[data-close-preview]").forEach((b) => b.onclick = () => { $("previewFrame").src = "about:blank"; hideModal("previewModal"); }); document.querySelectorAll("[data-close-probe]").forEach((b) => b.onclick = () => hideModal("probeModal")); document.querySelectorAll("[data-close-meta]").forEach((b) => b.onclick = () => hideModal("metaModal")); +document.querySelectorAll("[data-close-trash]").forEach((b) => b.onclick = () => hideModal("trashModal")); /* 表单改动监听 -> 脏标记 */ $("taskForm").addEventListener("input", () => { formDirty = true; }); @@ -676,7 +744,7 @@ document.addEventListener("keydown", (e) => { } stopLogPoll(); $("previewFrame").src = "about:blank"; - ["detailModal", "probeModal", "metaModal", "previewModal"].forEach((id) => $(id).classList.add("hidden")); + ["detailModal", "probeModal", "metaModal", "trashModal", "previewModal"].forEach((id) => $(id).classList.add("hidden")); } }); diff --git a/static/index.html b/static/index.html index bcbffa0..63a9d54 100644 --- a/static/index.html +++ b/static/index.html @@ -12,6 +12,7 @@
运行中任务: 0 +
@@ -143,6 +144,17 @@ + + +