Compare commits

...
1 Commits
6 changed files with 236 additions and 8 deletions
+5
View File
@@ -23,6 +23,7 @@
### 1. 前端管理界面
- **总体统计区**:任务总数 / 运行中 / 累计运行次数 / 成功失败页面 / 图片数 / 磁盘占用
- **🗑️ 回收站**:删除的任务统一进回收站(可恢复);只有手动在回收站中才能彻底删除或清空,防止误删
- **日间/夜间双主题**:右上角按钮一键切换,自动记忆选择
- 任务卡片总览:状态、进度、统计、下次调度时间一目了然
- 一键操作:开始 / 暂停 / 恢复 / 终止 / 编辑 / 删除
@@ -76,6 +77,10 @@
| POST | `/api/tasks/<id>/pause` | 暂停 |
| POST | `/api/tasks/<id>/resume` | 恢复 |
| POST | `/api/tasks/<id>/stop` | 终止 |
| GET | `/api/trash` | 回收站列表 |
| POST | `/api/trash/<id>/restore` | 从回收站恢复任务 |
| DELETE | `/api/trash/<id>` | 彻底删除单个(默认输出目录一并清理) |
| DELETE | `/api/trash` | 清空回收站 |
| POST | `/api/probe` | 试爬取(表单规则预览链接清单) |
| POST | `/api/tasks/<id>/probe` | 对已保存的自动任务试爬取 |
| GET | `/api/runs/<rid>` | 运行详情(结果+日志) |
+68 -4
View File
@@ -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/<tid>", 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/<tid>/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/<tid>", 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,
})
+72 -4
View File
@@ -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 = `<div class="empty" style="padding:40px 0"><div class="empty-icon">🗑️</div><p>回收站是空的</p></div>`;
return;
}
body.innerHTML = items.map((t) => `
<div class="trash-item">
<div class="card-head" style="min-width:0">
<span class="card-name" title="${esc(t.name)}">${esc(t.name)}</span>
<span class="badge ${esc(t.mode)}">${MODE_LABEL[t.mode] || esc(t.mode)}</span>
</div>
<div class="card-line">删除时间: <b>${esc(t.deleted_at)}</b> · 运行次数: <b>${t.runs_count || 0}</b></div>
<div class="card-line">输出目录: <b>${esc(t.out_dir || "")}</b></div>
<div class="card-actions">
<button class="btn sm primary" onclick="restoreTrash('${t.id}')">↩️ 恢复</button>
<button class="btn sm danger" onclick="purgeTrashTask('${t.id}')">🗑️ 彻底删除</button>
</div>
</div>`).join("")
+ `<div class="trash-foot">
<span class="card-line">共 ${items.length} 项 · 彻底删除将移除任务配置与运行记录(默认输出目录一并清理)</span>
<button class="btn danger" onclick="clearTrash()">🧹 清空回收站</button>
</div>`;
}
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"));
}
});
+12
View File
@@ -12,6 +12,7 @@
<div class="header-right">
<span class="status-pill" id="statusPill">运行中任务: 0</span>
<button id="btnTheme" class="btn ghost" title="切换日间/夜间主题">☀️</button>
<button id="btnTrash" class="btn ghost" title="回收站">🗑️ 回收站 <span id="trashCount" class="trash-count">0</span></button>
<button id="btnRefresh" class="btn ghost">⟳ 刷新</button>
<button id="btnNew" class="btn primary"> 新建任务</button>
</div>
@@ -143,6 +144,17 @@
</div>
</div>
<!-- 回收站弹窗 -->
<div id="trashModal" class="modal-overlay hidden">
<div class="modal wide">
<div class="modal-head">
<span>🗑️ 回收站(删除的任务统一在这里,可恢复或彻底删除)</span>
<button class="btn ghost sm" data-close-trash></button>
</div>
<div id="trashBody" class="detail-wrap"></div>
</div>
</div>
<!-- 试爬取结果弹窗 -->
<div id="probeModal" class="modal-overlay hidden">
<div class="modal wide">
+15
View File
@@ -215,6 +215,21 @@ td.title-cell { max-width: 220px; overflow: hidden; text-overflow: ellipsis; }
/* ---------- preview ---------- */
#previewFrame { flex: 1; border: none; background: var(--preview-bg); border-radius: 0 0 14px 14px; }
/* ---------- 回收站 ---------- */
.trash-count {
display: inline-block; min-width: 18px; padding: 0 5px; border-radius: 9px;
background: var(--red); color: #fff; font-size: 11px; line-height: 18px;
text-align: center; margin-left: 2px;
}
.trash-item {
border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px;
display: flex; flex-direction: column; gap: 6px; background: var(--panel2);
}
.trash-foot {
display: flex; justify-content: space-between; align-items: center;
gap: 10px; padding-top: 8px; border-top: 1px solid var(--border); margin-top: 4px;
}
/* ---------- 试爬取结果 / 元数据 ---------- */
.probe-summary { display: flex; flex-wrap: wrap; gap: 16px; font-size: 13px; }
.probe-list {
+64
View File
@@ -67,6 +67,7 @@ def upsert_task(task):
def delete_task(task_id):
"""彻底删除任务 (任务 + 运行记录)"""
with _lock:
tasks = _load(TASKS_FILE, [])
tasks = [t for t in tasks if t["id"] != task_id]
@@ -76,6 +77,69 @@ def delete_task(task_id):
_save(RUNS_FILE, runs)
# ---------------- 回收站 ----------------
def soft_delete_task(task_id, ts):
"""删除任务 -> 移入回收站 (软删除, 记录保留可恢复)"""
with _lock:
tasks = _load(TASKS_FILE, [])
for t in tasks:
if t["id"] == task_id:
t["deleted_at"] = ts
break
_save(TASKS_FILE, tasks)
def restore_task(task_id):
"""从回收站恢复任务"""
with _lock:
tasks = _load(TASKS_FILE, [])
for t in tasks:
if t["id"] == task_id:
t.pop("deleted_at", None)
break
_save(TASKS_FILE, tasks)
def list_trash():
"""回收站任务列表 (按删除时间倒序)"""
with _lock:
tasks = _load(TASKS_FILE, [])
return sorted(
[t for t in tasks if t.get("deleted_at")],
key=lambda x: x.get("deleted_at", ""), reverse=True,
)
def purge_task(task_id):
"""从回收站彻底删除单个任务 (任务 + 运行记录)"""
with _lock:
tasks = _load(TASKS_FILE, [])
tasks = [t for t in tasks if t["id"] != task_id]
_save(TASKS_FILE, tasks)
runs = _load(RUNS_FILE, {})
runs.pop(task_id, None)
_save(RUNS_FILE, runs)
def purge_trash():
"""清空回收站, 返回被清空的任务 id 列表"""
with _lock:
tasks = _load(TASKS_FILE, [])
kept, purged = [], []
for t in tasks:
if t.get("deleted_at"):
purged.append(t["id"])
else:
kept.append(t)
_save(TASKS_FILE, kept)
runs = _load(RUNS_FILE, {})
for tid in purged:
runs.pop(tid, None)
_save(RUNS_FILE, runs)
return purged
# ---------------- 运行记录 ----------------
def load_runs_map():