/* 通用爬虫系统 - 前端逻辑 */ "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"; $("trashCount").textContent = s.trash || 0; } catch (e) { /* 统计失败忽略 */ } } function taskStatusBadge(t) { if (t.running) return '● 运行中'; const r = t.latest_run; if (r && r.status === "paused") return '⏸ 已暂停'; if (t.mode === "scheduled" && t.schedule && t.schedule.enabled) { const next = t.schedule.next_run ? ` · 下次 ${t.schedule.next_run.slice(11)}` : ""; return `⏰ 等待调度${esc(next)}`; } if (r) return `${({ completed: "✅ 已完成", failed: "❌ 失败", stopped: "⏹ 已停止" })[r.status] || esc(r.status)}`; return '未运行'; } 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") ? `
${r.progress.done || 0} / ${r.progress.total || "?"} ${esc(r.progress.current_url || "")}
` : ""; const stats = r ? `
✅ ${r.stats.ok || 0} ❌ ${r.stats.fail || 0} 🖼️ ${r.stats.images || 0}
` : ""; const urlCount = (t.urls || []).length; const line2 = t.mode === "auto" ? `起始: ${esc((t.auto && t.auto.seed_url) || "")}` : `网址: ${urlCount} 个`; const line3 = t.mode === "scheduled" ? `调度: ${t.schedule.type === "cron" ? esc(t.schedule.cron) : "每 " + (t.schedule.interval_value || "-") + " " + ({ minutes: "分钟", hours: "小时", days: "天" }[t.schedule.interval_unit] || "")}` : ""; const outDir = cfg.out_dir || "out/" + t.id; let acts = ""; if (t.running) { acts = ` `; } else { const canStart = t.mode !== "scheduled" || (t.urls || []).length > 0; acts = ``; } acts += ` `; return `
${esc(t.name)} ${taskStatusBadge(t)}
${MODE_LABEL[t.mode]} ${line2}
${line3 ? `
${line3}
` : ""}
输出: ${esc(outDir)}
${prog} ${stats}
${acts}
`; } /* ---------------- 任务操作 ---------------- */ 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 { const r = await api(`/api/tasks/${tid}`, { method: "DELETE" }); toast(r.msg || "已删除"); loadTasks(); } catch (e) { toast(e.message, true); } } /* ---------------- 搜索 ---------------- */ async function doSearch() { const q = $("searchInput").value.trim(); if (!q) { toast("请输入搜索关键词", true); return; } try { const d = await api(`/api/search?q=${encodeURIComponent(q)}`); renderSearch(d); showModal("searchModal"); } catch (e) { toast(e.message, true); } } function renderSearch(d) { const body = $("searchBody"); if (!d.results.length) { body.innerHTML = `
🔍

没有找到与「${esc(d.q)}」相关的内容

`; return; } const items = d.results.map((r) => { const badge = `${MODE_LABEL[r.mode] || esc(r.mode)}`; if (r.type === "task") { return `
${badge} 📋 ${esc(r.task_name)} 任务
${r.seed_url ? "起始: " + esc(r.seed_url) : "网址 " + (r.urls_count || 0) + " 个"} · 创建于 ${esc(r.created_at || "")}
`; } const stCls = r.status === "OK" ? "t-ok" : "t-fail"; const html = r.html_file ? `HTML` : ""; const txt = r.txt_file ? `TXT` : ""; const meta = r.meta_file ? `📋 元数据` : ""; return `
${badge} ${esc(r.title) || "(无标题)"} ${r.status} ${esc(r.run_status)} ${esc(r.crawl_time || "")}
🌐 ${esc(r.url)}${r.images ? ` · 🖼️ ${r.images}` : ""}
${html}${txt}${meta}
`; }).join(""); body.innerHTML = `
关键词: ${esc(d.q)}${d.count} 条结果
${items}`; } /* 搜索结果的跳转动作: 先关搜索弹窗再打开目标 */ function searchOpenDetail(tid) { hideModal("searchModal"); openDetail(tid); } function searchPreview(tid, path, title) { hideModal("searchModal"); previewFile(tid, path, title); } function searchMeta(tid, metaFile) { hideModal("searchModal"); showMeta(tid, metaFile); } /* ---------------- 回收站 ---------------- */ 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); } } /* ---------------- 新建 / 编辑表单 ---------------- */ 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 = `
模式: ${MODE_LABEL[t.mode] || t.mode} 任务ID: ${t.id} 创建: ${fmtTime(t.created_at)} 输出: ${esc(cfg.out_dir || "out/" + t.id)} ${t.mode === "auto" ? `起始: ${esc((t.auto && t.auto.seed_url) || "")}` : ""}
`; let sched = ""; if (t.mode === "scheduled" && t.schedule) { const s = t.schedule; sched = `
调度: ${s.type === "cron" ? esc(s.cron) : "每 " + (s.interval_value || "-") + " " + ({ minutes: "分钟", hours: "小时", days: "天" }[s.interval_unit] || "")} 启用: ${s.enabled ? "是" : "否"} 下次: ${esc(s.next_run || "—")} 上次: ${esc(s.last_run || "—")} 已运行: ${s.runs_count || 0}
`; } const acts = `
${t.running ? ` ` : ``} ${t.mode === "auto" ? `` : ""}
`; const chips = runs.map((r) => ` ${fmtTime(r.started_at).slice(5)} · ${r.status} · ${r.progress.done || 0}/${r.progress.total || "?"} `).join(""); const body = run ? renderRunPanel(t, run) : '
暂无运行记录,点击「立即执行」开始第一次爬取。
'; $("detailBody").innerHTML = ` ${meta} ${sched} ${acts}
${chips || '暂无运行记录'}
${body}`; } function renderRunPanel(t, run) { const s = run.stats || { ok: 0, fail: 0, images: 0 }; const prog = run.status === "running" || run.status === "paused" ? `
` : ""; const results = run.results || []; const rows = results.map((r, i) => { const statusCls = r.status === "OK" ? "t-ok" : "t-fail"; const html = r.html_file ? `HTML` : "—"; const txt = r.txt_file ? `TXT` : "—"; const imgs = (r.images || []).length ? `🖼️ ${(r.images || []).length}` : "—"; const ctime = r.crawl_time ? `${esc(r.crawl_time.slice(5, 19))}` : "—"; const meta = r.meta_file ? `📋 元数据` : "—"; return ` ${i + 1} ${r.status} ${esc(r.title)} ${esc(r.url)} ${ctime} ${html}${txt}${imgs} ${meta} ${esc((r.error || "").slice(0, 50))} `; }).join(""); const thumbs = results.flatMap((r) => r.images || []).slice(0, 60); const thumbHtml = thumbs.length ? `
${thumbs.map((im) => ``).join("")}
` : ""; return `
运行ID: ${run.id} 状态: ${run.status} 开始: ${fmtTime(run.started_at)} 结束: ${fmtTime(run.finished_at)} ✅ ${s.ok} ❌ ${s.fail} 🖼️ ${s.images}
${prog}
当前: ${esc(run.progress.current_url || "")}
${results.length ? `
${rows}
#状态标题网址爬取时间HTMLTXT图片元数据错误
` : '
暂无结果
'} ${thumbHtml}
`; } 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 collectProbeFromForm() { const f = $("taskForm"); return { 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, timeout: parseInt(f.elements["timeout"].value) || 60, }; } async function runProbe(params, btn) { if (btn) { btn.disabled = true; btn.textContent = "⏳ 探测中..."; } try { const r = await api("/api/probe", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(params), }); renderProbe(r); } catch (e) { toast(e.message, true); } finally { if (btn) { btn.disabled = false; btn.textContent = "🧪 试爬取"; } } } async function probeTask(tid) { try { const r = await api(`/api/tasks/${tid}/probe`, { method: "POST" }); renderProbe(r); } catch (e) { toast(e.message, true); } } function renderProbe(r) { const body = $("probeBody"); if (!r.ok) { body.innerHTML = `
❌ ${esc(r.error || "试爬取失败")}
`; showModal("probeModal"); return; } const included = r.included || []; const excluded = r.excluded || []; const incHtml = included.length ? included.map((u) => `
${esc(u)}
`).join("") : '
没有符合条件的链接,请调整规则
'; const excHtml = excluded.length ? excluded.map((x) => `
${esc(x.url)}${esc(x.reason)}
`).join("") : '
'; const moreInc = r.total_included > included.length ? `
... 还有 ${r.total_included - included.length} 条未显示
` : ""; const moreExc = r.total_excluded > excluded.length ? `
... 还有 ${r.total_excluded - excluded.length} 条未显示
` : ""; body.innerHTML = `
页面标题: ${esc(r.title)} 抓取时间: ${esc(r.crawled_at)}
发现链接: ${r.total_links} ✅ 将爬取: ${r.total_included} 🚫 被排除: ${r.total_excluded}
✅ 符合规则、将爬取的链接(显示前 ${included.length} 条):
${incHtml}${moreInc}
🚫 被排除的链接及原因(显示前 ${excluded.length} 条):
${excHtml}${moreExc}
`; showModal("probeModal"); } /* ---------------- 元数据查看 ---------------- */ const META_FIELDS = [ ["type", "资源类型"], ["mode", "爬取模式"], ["task_name", "任务名称"], ["task_id", "任务ID"], ["run_id", "运行ID"], ["crawl_time", "爬取时间"], ["url", "爬取网址"], ["source_url", "来源链接"], ["depth", "爬取深度"], ["title", "页面标题"], ["status", "状态"], ["attempts", "尝试次数"], ["error", "错误信息"], ["html_file", "HTML文件"], ["txt_file", "文本文件"], ["page_url", "所属页面"], ]; async function showMeta(taskId, metaFile) { try { const data = await api(`/api/file?task_id=${taskId}&path=${encodeURIComponent(metaFile)}`); const rows = META_FIELDS.filter(([k]) => data[k] !== undefined && data[k] !== "" && data[k] !== null) .map(([k, label]) => { let v = data[k]; if (k === "status") v = `${esc(v)}`; if (k === "mode") v = `${MODE_LABEL[v] || esc(v)}`; if (k === "type") v = v === "images" ? "🖼️ 图片集" : "📄 网页"; if (k === "depth") v = v === 0 ? "0(起始页)" : esc(v); return `${label}${v}`; }).join(""); // 来源链接空值也显示(起始页无来源) const srcRow = `来源链接${data.source_url ? esc(data.source_url) : "—(起始页,无来源)"}`; const finalRows = rows.includes(srcRow) ? rows : rows + srcRow; let imgs = ""; if (data.images && data.images.length) { imgs = `
🖼️ 图片明细(${data.images.length} 张):
${data.images.map((im) => ``).join("")}
文件原始URL大小下载时间
${esc(im.file)} ${esc(im.url)} ${im.size ? Math.round(im.size / 1024) + " KB" : "—"} ${esc(im.download_time || "")}
`; } $("metaBody").innerHTML = `${finalRows}
${imgs}`; showModal("metaModal"); } catch (e) { toast(e.message, true); } } /* ---------------- 文件预览 ---------------- */ 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; $("btnTrash").onclick = openTrash; $("btnSearch").onclick = doSearch; $("searchInput").addEventListener("keydown", (e) => { if (e.key === "Enter") doSearch(); }); $("btnProbe").onclick = () => { const p = collectProbeFromForm(); if (!p.seed_url) { toast("请先填写起始网址", true); return; } runProbe(p, $("btnProbe")); }; 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"); }); 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")); document.querySelectorAll("[data-close-search]").forEach((b) => b.onclick = () => hideModal("searchModal")); /* 表单改动监听 -> 脏标记 */ $("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", "probeModal", "metaModal", "trashModal", "searchModal", "previewModal"].forEach((id) => $(id).classList.add("hidden")); } }); /* ---------------- 启动 ---------------- */ loadTasks(); setInterval(() => { const detailOpen = !$("detailModal").classList.contains("hidden"); if (!detailOpen) loadTasks(); }, 4000);