562 lines
22 KiB
JavaScript
562 lines
22 KiB
JavaScript
/* 通用爬虫系统 - 前端逻辑 */
|
|
"use strict";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const MODE_LABEL = { batch: "批量", scheduled: "定时", auto: "自动" };
|
|
|
|
const state = {
|
|
tasks: [],
|
|
editTask: null, // 正在编辑的任务
|
|
mode: "batch", // 当前表单模式
|
|
detail: { task: null, runId: null, logOffset: 0, timer: null },
|
|
logTimer: null,
|
|
};
|
|
let formDirty = false; // 新建/编辑表单是否有未保存修改
|
|
|
|
/* ---------------- 工具 ---------------- */
|
|
function toast(msg, isErr) {
|
|
const t = document.createElement("div");
|
|
t.className = "toast" + (isErr ? " error" : "");
|
|
t.textContent = msg;
|
|
document.body.appendChild(t);
|
|
setTimeout(() => t.remove(), 2600);
|
|
}
|
|
|
|
async function api(url, opts) {
|
|
const res = await fetch(url, opts);
|
|
let data = null;
|
|
try { data = await res.json(); } catch (e) { /* ignore */ }
|
|
if (!res.ok) throw new Error((data && data.error) || `HTTP ${res.status}`);
|
|
return data;
|
|
}
|
|
|
|
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"']/g,
|
|
(c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
|
|
|
function fmtTime(iso) { return iso ? String(iso).replace("T", " ").slice(0, 19) : "—"; }
|
|
|
|
/* ---------------- 任务列表 ---------------- */
|
|
async function loadTasks() {
|
|
try {
|
|
state.tasks = await api("/api/tasks");
|
|
renderTasks();
|
|
const running = state.tasks.filter((t) => t.running).length;
|
|
const pill = $("statusPill");
|
|
pill.textContent = `运行中任务: ${running}`;
|
|
pill.classList.toggle("active", running > 0);
|
|
const st = await api("/api/status");
|
|
$("version").textContent = `v${st.version}`;
|
|
} catch (e) {
|
|
toast("加载任务失败: " + e.message, true);
|
|
}
|
|
loadStats();
|
|
}
|
|
|
|
async function loadStats() {
|
|
try {
|
|
const s = await api("/api/stats");
|
|
$("stTasks").textContent = s.tasks;
|
|
$("stRunning").textContent = s.running;
|
|
$("stRuns").textContent = s.runs;
|
|
$("stOk").textContent = s.ok;
|
|
$("stFail").textContent = s.fail;
|
|
$("stImgs").textContent = s.images;
|
|
$("stDisk").textContent = s.disk_mb >= 1024
|
|
? (s.disk_mb / 1024).toFixed(1) + " GB" : s.disk_mb + " MB";
|
|
} catch (e) { /* 统计失败忽略 */ }
|
|
}
|
|
|
|
function taskStatusBadge(t) {
|
|
if (t.running) return '<span class="badge running">● 运行中</span>';
|
|
const r = t.latest_run;
|
|
if (r && r.status === "paused") return '<span class="badge paused">⏸ 已暂停</span>';
|
|
if (t.mode === "scheduled" && t.schedule && t.schedule.enabled) {
|
|
const next = t.schedule.next_run ? ` · 下次 ${t.schedule.next_run.slice(11)}` : "";
|
|
return `<span class="badge scheduled-wait">⏰ 等待调度${esc(next)}</span>`;
|
|
}
|
|
if (r) return `<span class="badge ${r.status}">${({ completed: "✅ 已完成", failed: "❌ 失败", stopped: "⏹ 已停止" })[r.status] || esc(r.status)}</span>`;
|
|
return '<span class="badge">未运行</span>';
|
|
}
|
|
|
|
function renderTasks() {
|
|
const box = $("taskList");
|
|
$("emptyState").classList.toggle("hidden", state.tasks.length > 0);
|
|
box.innerHTML = state.tasks.map(taskCard).join("");
|
|
}
|
|
|
|
function taskCard(t) {
|
|
const r = t.latest_run;
|
|
const cfg = t.config || {};
|
|
const prog = r && (r.status === "running" || r.status === "paused") ? `
|
|
<div class="progress"><div style="width:${r.progress.percent || 0}%"></div></div>
|
|
<div class="progress-label">
|
|
<span>${r.progress.done || 0} / ${r.progress.total || "?"}</span>
|
|
<span class="url-cell" style="max-width:220px">${esc(r.progress.current_url || "")}</span>
|
|
</div>` : "";
|
|
const stats = r ? `
|
|
<div class="stats">
|
|
<span class="ok">✅ ${r.stats.ok || 0}</span>
|
|
<span class="fail">❌ ${r.stats.fail || 0}</span>
|
|
<span class="img">🖼️ ${r.stats.images || 0}</span>
|
|
</div>` : "";
|
|
const urlCount = (t.urls || []).length;
|
|
const line2 = t.mode === "auto"
|
|
? `起始: <b>${esc((t.auto && t.auto.seed_url) || "")}</b>`
|
|
: `网址: <b>${urlCount}</b> 个`;
|
|
const line3 = t.mode === "scheduled"
|
|
? `调度: <b>${t.schedule.type === "cron" ? esc(t.schedule.cron) : "每 " + (t.schedule.interval_value || "-") + " " + ({ minutes: "分钟", hours: "小时", days: "天" }[t.schedule.interval_unit] || "")}</b>`
|
|
: "";
|
|
const outDir = cfg.out_dir || "out/" + t.id;
|
|
|
|
let acts = "";
|
|
if (t.running) {
|
|
acts = `
|
|
<button class="btn sm" onclick="actTask('${t.id}','pause')">⏸ 暂停</button>
|
|
<button class="btn sm" onclick="actTask('${t.id}','stop')">⏹ 终止</button>`;
|
|
} else {
|
|
const canStart = t.mode !== "scheduled" || (t.urls || []).length > 0;
|
|
acts = `<button class="btn sm primary" ${canStart ? "" : "disabled"} onclick="actTask('${t.id}','start')">▶ 开始</button>`;
|
|
}
|
|
acts += `
|
|
<button class="btn sm" onclick="openEdit('${t.id}')">✏️ 编辑</button>
|
|
<button class="btn sm" onclick="openDetail('${t.id}')">📋 详情</button>
|
|
<button class="btn sm danger" onclick="delTask('${t.id}')">🗑️</button>`;
|
|
|
|
return `
|
|
<div class="card">
|
|
<div class="card-head">
|
|
<span class="card-name" title="${esc(t.name)}">${esc(t.name)}</span>
|
|
${taskStatusBadge(t)}
|
|
</div>
|
|
<div class="card-meta">
|
|
<span class="badge ${t.mode}">${MODE_LABEL[t.mode]}</span>
|
|
<span class="card-line">${line2}</span>
|
|
</div>
|
|
${line3 ? `<div class="card-line">${line3}</div>` : ""}
|
|
<div class="card-line">输出: <b>${esc(outDir)}</b></div>
|
|
${prog}
|
|
${stats}
|
|
<div class="card-actions">${acts}</div>
|
|
</div>`;
|
|
}
|
|
|
|
/* ---------------- 任务操作 ---------------- */
|
|
async function actTask(tid, act) {
|
|
try {
|
|
const r = await api(`/api/tasks/${tid}/${act}`, { method: "POST" });
|
|
if (r.msg) toast(r.msg);
|
|
loadTasks();
|
|
if (state.detail.task && state.detail.task.id === tid) openDetail(tid);
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
async function delTask(tid) {
|
|
const t = state.tasks.find((x) => x.id === tid);
|
|
if (!confirm(`确定删除任务「${t ? t.name : tid}」?\n运行记录与任务配置将被删除(已爬取的文件保留在磁盘上)。`)) return;
|
|
try {
|
|
await api(`/api/tasks/${tid}`, { method: "DELETE" });
|
|
toast("已删除");
|
|
loadTasks();
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
/* ---------------- 新建 / 编辑表单 ---------------- */
|
|
function switchMode(mode, lock) {
|
|
state.mode = mode;
|
|
document.querySelectorAll("#modeTabs .tab").forEach((b) => {
|
|
b.classList.toggle("active", b.dataset.mode === mode);
|
|
b.disabled = !!lock;
|
|
});
|
|
$("urlsField").classList.toggle("hidden", mode !== "batch" && mode !== "scheduled");
|
|
$("scheduleBox").classList.toggle("hidden", mode !== "scheduled");
|
|
$("autoBox").classList.toggle("hidden", mode !== "auto");
|
|
}
|
|
|
|
function toLocalInputVal(iso) {
|
|
if (!iso) return "";
|
|
const d = new Date(String(iso).replace(" ", "T"));
|
|
if (isNaN(d.getTime())) return "";
|
|
const p = (n) => String(n).padStart(2, "0");
|
|
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
}
|
|
|
|
function openCreate() {
|
|
state.editTask = null;
|
|
formDirty = false;
|
|
$("modalTitle").textContent = "新建爬取任务";
|
|
$("taskForm").reset();
|
|
$("taskForm").elements["delay_min"].value = 2;
|
|
$("taskForm").elements["delay_max"].value = 5;
|
|
$("taskForm").elements["timeout"].value = 60;
|
|
$("taskForm").elements["retry_count"].value = 2;
|
|
$("taskForm").elements["retry_interval"].value = 3;
|
|
$("taskForm").elements["notify_email"].value = "wlq@tphai.com";
|
|
$("formHint").textContent = "";
|
|
switchMode("batch", false);
|
|
showModal("taskModal");
|
|
}
|
|
|
|
async function openEdit(tid) {
|
|
const t = state.tasks.find((x) => x.id === tid) || await api(`/api/tasks/${tid}`);
|
|
state.editTask = t;
|
|
$("modalTitle").textContent = `编辑任务「${t.name}」`;
|
|
const f = $("taskForm");
|
|
f.reset();
|
|
const cfg = t.config || {};
|
|
f.elements["name"].value = t.name || "";
|
|
f.elements["out_dir"].value = cfg.out_dir || "";
|
|
f.elements["delay_min"].value = cfg.delay_min ?? 2;
|
|
f.elements["delay_max"].value = cfg.delay_max ?? 5;
|
|
f.elements["timeout"].value = cfg.timeout ?? 60;
|
|
f.elements["retry_count"].value = cfg.retry_count ?? 2;
|
|
f.elements["retry_interval"].value = cfg.retry_interval ?? 3;
|
|
f.elements["crawl_images"].checked = !!cfg.crawl_images;
|
|
f.elements["notify"].checked = !!cfg.notify;
|
|
f.elements["notify_email"].value = cfg.notify_email || "wlq@tphai.com";
|
|
f.elements["urls"].value = (t.urls || []).join("\n");
|
|
if (t.mode === "scheduled" && t.schedule) {
|
|
f.elements["schedule_type"].value = t.schedule.type || "interval";
|
|
f.elements["schedule_enabled"].checked = t.schedule.enabled !== false;
|
|
f.elements["interval_value"].value = t.schedule.interval_value ?? 24;
|
|
f.elements["interval_unit"].value = t.schedule.interval_unit || "hours";
|
|
f.elements["cron"].value = t.schedule.cron || "";
|
|
f.elements["first_run"].value = toLocalInputVal(t.schedule.next_run || "");
|
|
}
|
|
if (t.mode === "auto" && t.auto) {
|
|
f.elements["seed_url"].value = t.auto.seed_url || "";
|
|
f.elements["include"].value = (t.auto.include || []).join("\n");
|
|
f.elements["exclude"].value = (t.auto.exclude || []).join("\n");
|
|
f.elements["same_domain"].checked = t.auto.same_domain !== false;
|
|
f.elements["use_regex"].checked = !!t.auto.use_regex;
|
|
f.elements["max_pages"].value = t.auto.max_pages ?? 50;
|
|
f.elements["max_depth"].value = t.auto.max_depth ?? 2;
|
|
}
|
|
syncScheduleUI();
|
|
$("formHint").textContent = t.running
|
|
? "⚠️ 任务运行中:参数修改将热更新(网址/规则改动下次运行生效)"
|
|
: "";
|
|
formDirty = false;
|
|
switchMode(t.mode, true);
|
|
showModal("taskModal");
|
|
}
|
|
|
|
function syncScheduleUI() {
|
|
const f = $("taskForm");
|
|
const t = f.elements["schedule_type"].value;
|
|
$("intervalBox").classList.toggle("hidden", t !== "interval");
|
|
$("cronBox").classList.toggle("hidden", t !== "cron");
|
|
}
|
|
|
|
function splitLines(v) { return String(v || "").split("\n").map((s) => s.trim()).filter(Boolean); }
|
|
|
|
async function submitForm(e) {
|
|
e.preventDefault();
|
|
const f = $("taskForm");
|
|
const name = f.elements["name"].value.trim();
|
|
if (!name) { toast("请填写项目名称", true); return; }
|
|
const mode = state.mode;
|
|
const config = {
|
|
out_dir: f.elements["out_dir"].value.trim(),
|
|
delay_min: parseFloat(f.elements["delay_min"].value) || 2,
|
|
delay_max: parseFloat(f.elements["delay_max"].value) || 5,
|
|
timeout: parseInt(f.elements["timeout"].value) || 60,
|
|
retry_count: parseInt(f.elements["retry_count"].value) || 0,
|
|
retry_interval: parseFloat(f.elements["retry_interval"].value) || 3,
|
|
crawl_images: f.elements["crawl_images"].checked,
|
|
notify: f.elements["notify"].checked,
|
|
notify_email: f.elements["notify_email"].value.trim() || "wlq@tphai.com",
|
|
};
|
|
const urls = splitLines(f.elements["urls"].value);
|
|
const payload = { name, mode, config, urls };
|
|
if (mode === "auto") {
|
|
payload.auto = {
|
|
seed_url: f.elements["seed_url"].value.trim(),
|
|
include: splitLines(f.elements["include"].value),
|
|
exclude: splitLines(f.elements["exclude"].value),
|
|
same_domain: f.elements["same_domain"].checked,
|
|
use_regex: f.elements["use_regex"].checked,
|
|
max_pages: parseInt(f.elements["max_pages"].value) || 50,
|
|
max_depth: parseInt(f.elements["max_depth"].value) || 2,
|
|
};
|
|
}
|
|
if (mode === "scheduled") {
|
|
payload.schedule = {
|
|
type: f.elements["schedule_type"].value,
|
|
enabled: f.elements["schedule_enabled"].checked,
|
|
interval_unit: f.elements["interval_unit"].value,
|
|
interval_value: parseInt(f.elements["interval_value"].value) || 1,
|
|
cron: f.elements["cron"].value.trim(),
|
|
first_run: f.elements["first_run"].value || "",
|
|
};
|
|
}
|
|
try {
|
|
if (state.editTask) {
|
|
await api(`/api/tasks/${state.editTask.id}`, {
|
|
method: "PUT", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
toast("任务已更新");
|
|
} else {
|
|
const t = await api("/api/tasks", {
|
|
method: "POST", headers: { "Content-Type": "application/json" },
|
|
body: JSON.stringify(payload),
|
|
});
|
|
toast(`任务「${t.name}」已创建`);
|
|
}
|
|
formDirty = false;
|
|
hideModal("taskModal");
|
|
loadTasks();
|
|
} catch (err) { toast(err.message, true); }
|
|
}
|
|
|
|
/* ---------------- 详情 ---------------- */
|
|
async function openDetail(tid) {
|
|
try {
|
|
const t = await api(`/api/tasks/${tid}`);
|
|
state.detail.task = t;
|
|
const runs = t.runs || [];
|
|
state.detail.runId = runs.find((r) => r.status === "running")
|
|
? runs[0].id : (runs[0] ? runs[0].id : null);
|
|
state.detail.logOffset = 0;
|
|
$("detailTitle").textContent = `任务详情 · ${t.name}`;
|
|
renderDetail();
|
|
showModal("detailModal");
|
|
startLogPoll();
|
|
} catch (e) { toast(e.message, true); }
|
|
}
|
|
|
|
function renderDetail() {
|
|
const t = state.detail.task;
|
|
const runs = t.runs || [];
|
|
const run = runs.find((r) => r.id === state.detail.runId) || runs[0] || null;
|
|
state.detail.runId = run ? run.id : null;
|
|
const cfg = t.config || {};
|
|
|
|
const meta = `
|
|
<div class="detail-meta">
|
|
<span>模式: <b>${MODE_LABEL[t.mode] || t.mode}</b></span>
|
|
<span>任务ID: <b>${t.id}</b></span>
|
|
<span>创建: <b>${fmtTime(t.created_at)}</b></span>
|
|
<span>输出: <b>${esc(cfg.out_dir || "out/" + t.id)}</b></span>
|
|
${t.mode === "auto" ? `<span>起始: <b>${esc((t.auto && t.auto.seed_url) || "")}</b></span>` : ""}
|
|
</div>`;
|
|
|
|
let sched = "";
|
|
if (t.mode === "scheduled" && t.schedule) {
|
|
const s = t.schedule;
|
|
sched = `<div class="detail-meta">
|
|
<span>调度: <b>${s.type === "cron" ? esc(s.cron) : "每 " + (s.interval_value || "-") + " " + ({ minutes: "分钟", hours: "小时", days: "天" }[s.interval_unit] || "")}</b></span>
|
|
<span>启用: <b>${s.enabled ? "是" : "否"}</b></span>
|
|
<span>下次: <b>${esc(s.next_run || "—")}</b></span>
|
|
<span>上次: <b>${esc(s.last_run || "—")}</b></span>
|
|
<span>已运行: <b>${s.runs_count || 0}</b> 次</span>
|
|
</div>`;
|
|
}
|
|
|
|
const acts = `
|
|
<div class="detail-actions">
|
|
${t.running ? `
|
|
<button class="btn" onclick="actTask('${t.id}','pause')">⏸ 暂停</button>
|
|
<button class="btn danger" onclick="actTask('${t.id}','stop')">⏹ 终止</button>`
|
|
: `<button class="btn primary" onclick="actTask('${t.id}','start')">▶ 立即执行</button>`}
|
|
<button class="btn" onclick="openEdit('${t.id}')">✏️ 编辑配置</button>
|
|
</div>`;
|
|
|
|
const chips = runs.map((r) => `
|
|
<span class="run-chip ${r.id === state.detail.runId ? "active" : ""}"
|
|
onclick="selectRun('${r.id}')">
|
|
${fmtTime(r.started_at).slice(5)} · ${r.status} · ${r.progress.done || 0}/${r.progress.total || "?"}
|
|
</span>`).join("");
|
|
|
|
const body = run ? renderRunPanel(t, run) : '<div class="card-line">暂无运行记录,点击「立即执行」开始第一次爬取。</div>';
|
|
|
|
$("detailBody").innerHTML = `
|
|
${meta}
|
|
${sched}
|
|
${acts}
|
|
<div class="runs-bar">${chips || '<span class="card-line">暂无运行记录</span>'}</div>
|
|
${body}`;
|
|
}
|
|
|
|
function renderRunPanel(t, run) {
|
|
const s = run.stats || { ok: 0, fail: 0, images: 0 };
|
|
const prog = run.status === "running" || run.status === "paused" ? `
|
|
<div class="progress"><div style="width:${run.progress.percent || 0}%"></div></div>` : "";
|
|
const results = run.results || [];
|
|
const rows = results.map((r, i) => {
|
|
const statusCls = r.status === "OK" ? "t-ok" : "t-fail";
|
|
const html = r.html_file ? `<span class="file-link" onclick="previewFile('${t.id}','${esc(r.html_file)}','HTML: ${esc(r.url)}')">HTML</span>` : "—";
|
|
const txt = r.txt_file ? `<span class="file-link" onclick="previewFile('${t.id}','${esc(r.txt_file)}','文本: ${esc(r.url)}')">TXT</span>` : "—";
|
|
const imgs = (r.images || []).length
|
|
? `<span class="file-link" onclick="scrollThumbs()">🖼️ ${(r.images || []).length}</span>` : "—";
|
|
return `<tr>
|
|
<td>${i + 1}</td>
|
|
<td class="${statusCls}">${r.status}</td>
|
|
<td class="title-cell" title="${esc(r.title)}">${esc(r.title)}</td>
|
|
<td class="url-cell" title="${esc(r.url)}">${esc(r.url)}</td>
|
|
<td>${html}</td><td>${txt}</td><td>${imgs}</td>
|
|
<td title="${esc(r.error || "")}">${esc((r.error || "").slice(0, 60))}</td>
|
|
</tr>`;
|
|
}).join("");
|
|
const thumbs = results.flatMap((r) => r.images || []).slice(0, 60);
|
|
const thumbHtml = thumbs.length ? `
|
|
<div id="thumbsBox" class="thumbs">
|
|
${thumbs.map((im) => `<img src="/api/file?task_id=${t.id}&path=${esc(im.file)}" loading="lazy"
|
|
onclick="previewFile('${t.id}','${esc(im.file)}','图片预览')">`).join("")}
|
|
</div>` : "";
|
|
|
|
return `
|
|
<div class="run-panel">
|
|
<div class="run-stats">
|
|
<span>运行ID: <b>${run.id}</b></span>
|
|
<span>状态: <b class="${run.status === "running" ? "t-ok" : ""}">${run.status}</b></span>
|
|
<span>开始: <b>${fmtTime(run.started_at)}</b></span>
|
|
<span>结束: <b>${fmtTime(run.finished_at)}</b></span>
|
|
<span class="ok">✅ ${s.ok}</span>
|
|
<span class="fail">❌ ${s.fail}</span>
|
|
<span class="img">🖼️ ${s.images}</span>
|
|
</div>
|
|
${prog}
|
|
<div class="card-line">当前: <b>${esc(run.progress.current_url || "")}</b></div>
|
|
${results.length ? `
|
|
<div class="table-wrap">
|
|
<table>
|
|
<thead><tr><th>#</th><th>状态</th><th>标题</th><th>网址</th><th>HTML</th><th>TXT</th><th>图片</th><th>错误</th></tr></thead>
|
|
<tbody>${rows}</tbody>
|
|
</table>
|
|
</div>` : '<div class="card-line">暂无结果</div>'}
|
|
${thumbHtml}
|
|
<div class="logs" id="logBox"></div>
|
|
</div>`;
|
|
}
|
|
|
|
function scrollThumbs() {
|
|
const box = $("thumbsBox");
|
|
if (box) box.scrollIntoView({ behavior: "smooth", block: "center" });
|
|
}
|
|
|
|
async function selectRun(rid) {
|
|
state.detail.runId = rid;
|
|
state.detail.logOffset = 0;
|
|
renderDetail();
|
|
startLogPoll();
|
|
}
|
|
|
|
async function startLogPoll() {
|
|
stopLogPoll();
|
|
const t = state.detail.task;
|
|
if (!t || !state.detail.runId) return;
|
|
const rid = state.detail.runId;
|
|
const tick = async () => {
|
|
try {
|
|
const d = await api(`/api/runs/${rid}/logs?offset=${state.detail.logOffset}`);
|
|
if (d.logs && d.logs.length) {
|
|
const box = $("logBox");
|
|
if (box) {
|
|
const atBottom = box.scrollHeight - box.scrollTop - box.clientHeight < 40;
|
|
d.logs.forEach((l) => {
|
|
const div = document.createElement("div");
|
|
div.className = l.level;
|
|
div.textContent = `[${l.ts}] ${l.msg}`;
|
|
box.appendChild(div);
|
|
});
|
|
if (atBottom) box.scrollTop = box.scrollHeight;
|
|
state.detail.logOffset += d.logs.length;
|
|
}
|
|
}
|
|
} catch (e) { /* ignore */ }
|
|
loadTasks(); // 后台保持列表刷新
|
|
};
|
|
tick();
|
|
state.logTimer = setInterval(tick, 2500);
|
|
}
|
|
|
|
function stopLogPoll() {
|
|
if (state.logTimer) { clearInterval(state.logTimer); state.logTimer = null; }
|
|
}
|
|
|
|
/* ---------------- 文件预览 ---------------- */
|
|
function previewFile(tid, path, title) {
|
|
$("previewTitle").textContent = title || "预览";
|
|
$("previewFrame").src = `/api/file?task_id=${tid}&path=${encodeURIComponent(path)}`;
|
|
showModal("previewModal");
|
|
}
|
|
|
|
/* ---------------- 弹窗控制 ---------------- */
|
|
function showModal(id) { $(id).classList.remove("hidden"); }
|
|
function hideModal(id) { $(id).classList.add("hidden"); }
|
|
|
|
/* 关闭任务弹窗: 有未保存修改时先确认 */
|
|
function safeCloseTaskModal() {
|
|
if (formDirty && !confirm("有未保存的修改,确定要放弃吗?")) return false;
|
|
formDirty = false;
|
|
hideModal("taskModal");
|
|
return true;
|
|
}
|
|
|
|
/* ---------------- 主题切换 ---------------- */
|
|
function applyTheme(theme) {
|
|
document.body.dataset.theme = theme;
|
|
$("btnTheme").textContent = theme === "light" ? "🌙" : "☀️";
|
|
try { localStorage.setItem("crawler_theme", theme); } catch (e) { /* ignore */ }
|
|
}
|
|
$("btnTheme").onclick = () => {
|
|
applyTheme(document.body.dataset.theme === "light" ? "dark" : "light");
|
|
};
|
|
try {
|
|
applyTheme(localStorage.getItem("crawler_theme") || "dark");
|
|
} catch (e) {
|
|
applyTheme("dark");
|
|
}
|
|
|
|
/* ---------------- 事件绑定 ---------------- */
|
|
$("btnNew").onclick = openCreate;
|
|
$("btnNew2").onclick = openCreate;
|
|
$("btnRefresh").onclick = loadTasks;
|
|
|
|
document.querySelectorAll("#modeTabs .tab").forEach((b) => {
|
|
b.onclick = () => { switchMode(b.dataset.mode, false); };
|
|
});
|
|
$("taskForm").onsubmit = submitForm;
|
|
$("taskForm").elements["schedule_type"].onchange = syncScheduleUI;
|
|
|
|
document.querySelectorAll("[data-close]").forEach((b) => b.onclick = safeCloseTaskModal);
|
|
document.querySelectorAll("[data-close-detail]").forEach((b) => b.onclick = () => { stopLogPoll(); hideModal("detailModal"); });
|
|
document.querySelectorAll("[data-close-preview]").forEach((b) => b.onclick = () => { $("previewFrame").src = "about:blank"; hideModal("previewModal"); });
|
|
|
|
/* 表单改动监听 -> 脏标记 */
|
|
$("taskForm").addEventListener("input", () => { formDirty = true; });
|
|
$("taskForm").addEventListener("change", () => { formDirty = true; });
|
|
|
|
document.querySelectorAll(".modal-overlay").forEach((ov) => {
|
|
ov.addEventListener("mousedown", (e) => {
|
|
if (e.target === ov) {
|
|
if (ov.id === "taskModal") {
|
|
safeCloseTaskModal();
|
|
} else {
|
|
if (ov.id === "detailModal") stopLogPoll();
|
|
if (ov.id === "previewModal") $("previewFrame").src = "about:blank";
|
|
ov.classList.add("hidden");
|
|
}
|
|
}
|
|
});
|
|
});
|
|
document.addEventListener("keydown", (e) => {
|
|
if (e.key === "Escape") {
|
|
if (!$("taskModal").classList.contains("hidden")) {
|
|
safeCloseTaskModal();
|
|
return;
|
|
}
|
|
stopLogPoll();
|
|
$("previewFrame").src = "about:blank";
|
|
["detailModal", "previewModal"].forEach((id) => $(id).classList.add("hidden"));
|
|
}
|
|
});
|
|
|
|
/* ---------------- 启动 ---------------- */
|
|
loadTasks();
|
|
setInterval(() => {
|
|
const detailOpen = !$("detailModal").classList.contains("hidden");
|
|
if (!detailOpen) loadTasks();
|
|
}, 4000);
|