Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e70a3a877f |
@@ -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>` | 运行详情(结果+日志) |
|
||||
|
||||
@@ -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
@@ -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,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">
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user