// ---------- 2. Targets ----------
let TARGET_EDITOR = null;
let TARGET_SITES = [];
let TARGET_DEPTS = [];

function _targetEditorBody() {
  const current = TARGET_EDITOR || { enabled: true, expected_cadence_days: 30, interval_sec: 10, interval_jitter_sec: 0, track_checkpoint: false };
  const sites = TARGET_SITES || [];
  const siteOptions = sites.map(s => `<option value="${esc(s.site_code)}" ${current.site_code === s.site_code ? "selected" : ""}>${esc(s.site_code)} · ${esc(s.site_name || "")}</option>`).join("");
  const enabledBadge = current.enabled
    ? `<span class="pill pill-green">已启用</span>`
    : `<span class="pill pill-slate">未启用</span>`;
  return `
    <div class="flex items-center justify-between mb-4">
      <div class="text-xs text-slate-500">${current.target_code ? `target_code · <span class="mono">${esc(current.target_code)}</span>` : "将新建一条 crawl_target 记录"}</div>
      ${enabledBadge}
    </div>
    <div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">基础信息</div>
    <div class="grid grid-cols-2 gap-4 mb-5">
      <div><div class="field-label">所属站点</div><select id="t-site" class="field-input">${siteOptions}</select></div>
      <div><div class="field-label">绑定部门</div>${renderDeptSelect({ selectId: "t-dept", placeholder: "— 无本地部门 —" })}</div>
      <div><div class="field-label">target_code</div><input id="t-code" value="${esc(current.target_code || "")}" class="field-input mono"/></div>
      <div><div class="field-label">目标名称</div><input id="t-name" value="${esc(current.target_name || "")}" class="field-input"/></div>
      <div class="col-span-2"><div class="field-label">入口 URL</div><input id="t-entry" value="${esc(current.entry_url || "")}" class="field-input mono"/></div>
      <div class="col-span-2">
        <div class="flex items-center justify-between">
          <div class="field-label">样例文章 URL</div>
          ${current.target_code ? `<button onclick="dryRunTargetEditor()" class="text-xs text-[#1D4ED8] hover:underline">查看 Dry-run</button>` : ""}
        </div>
        <input id="t-sample" value="${esc(current.sample_article_url || "")}" class="field-input mono"/>
      </div>
    </div>
    <div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">栏目与分类</div>
    <div class="grid grid-cols-2 gap-4 mb-5">
      <div><div class="field-label">栏目名称</div><input id="t-channel-name" value="${esc(current.channel_name || "")}" class="field-input"/></div>
      <div><div class="field-label">栏目路径</div><input id="t-channel-path" value="${esc(current.channel_path || "")}" class="field-input" placeholder="信息公开｜政策文件"/></div>
      <div><div class="field-label">一级分类</div><input id="t-category" value="${esc(current.content_category || "")}" class="field-input"/></div>
      <div><div class="field-label">二级分类</div><input id="t-subcategory" value="${esc(current.content_subcategory || "")}" class="field-input"/></div>
    </div>
    <div class="text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">抓取节奏</div>
    <div class="grid grid-cols-3 gap-4 mb-3">
      <div><div class="field-label">预期节奏（天）</div><input id="t-cadence" type="number" min="1" value="${esc(current.expected_cadence_days || 30)}" class="field-input"/></div>
      <div><div class="field-label">请求间隔（秒）</div><input id="t-interval" type="number" min="0" value="${esc(current.interval_sec ?? 10)}" class="field-input"/></div>
      <div><div class="field-label">跳动时间（秒）</div><input id="t-jitter" type="number" min="0" value="${esc(current.interval_jitter_sec ?? 0)}" class="field-input"/></div>
    </div>
    <div class="flex items-center gap-6 mb-2">
      <label class="flex items-center gap-2 text-sm"><input id="t-enabled" type="checkbox" ${current.enabled ? "checked" : ""} /> <span>启用该目标</span></label>
      <label class="flex items-center gap-2 text-sm"><input id="t-track-checkpoint" type="checkbox" ${current.track_checkpoint ? "checked" : ""} /> <span>启用断点续跑 checkpoint</span></label>
    </div>
    <div id="t-dryrun" class="text-xs text-slate-500 mt-3"></div>`;
}

function _targetEditorFooter() {
  return `
    <button onclick="closeModal()" class="btn-ghost">取消</button>
    <button onclick="saveTargetEditor()" class="btn-success">${TARGET_EDITOR && TARGET_EDITOR.target_code ? "保存并启用" : "保存为模板"}</button>`;
}

function _openTargetModal() {
  openModal({
    title: TARGET_EDITOR && TARGET_EDITOR.target_code ? `编辑采集目标 · ${TARGET_EDITOR.target_code}` : "新增采集目标",
    subtitle: "采集目标定义 crawl_target 记录：入口 URL + 抓取节奏 + 本地部门绑定",
    body: _targetEditorBody(),
    footer: _targetEditorFooter(),
    wide: true,
    onClose: () => { TARGET_EDITOR = null; },
  });
  populateDeptSelect("t-dept", TARGET_DEPTS || [], (TARGET_EDITOR || {}).dept_id || "");
}

async function _ensureTargetContext() {
  if (TARGET_SITES.length && TARGET_DEPTS.length) return;
  const [{ sites }, { items: depts }] = await Promise.all([
    api("/api/sites"),
    api("/api/local-departments?limit=1000"),
  ]);
  TARGET_SITES = sites;
  TARGET_DEPTS = depts;
}

async function editTarget(code) {
  const row = window.__targetRows?.find(t => t.target_code === code);
  if (!row) return;
  TARGET_EDITOR = { ...row };
  await _ensureTargetContext();
  _openTargetModal();
}

async function newTarget() {
  TARGET_EDITOR = null;
  await _ensureTargetContext();
  _openSimpleTargetModal();
}

// ---------- Simplified "new target" modal ----------
// Users only fill: entry_url / dept (optional) / cadence.
// target_code + site_code + target_name are inferred from the URL.
function _openSimpleTargetModal() {
  openModal({
    title: "新增采集目标",
    subtitle: "粘贴栏目入口 URL，系统自动识别站点、生成 target_code 并补齐元数据",
    body: `
      <div class="space-y-4">
        <div>
          <div class="field-label">入口 URL <span class="text-red-500">*</span></div>
          <input id="st-url" class="field-input mono" placeholder="https://站点域名/部门/栏目/list.html"/>
          <div id="st-site-hint" class="text-[11px] text-slate-400 mt-1">粘贴后自动匹配站点</div>
        </div>
        <div class="grid grid-cols-2 gap-4">
          <div>
            <div class="field-label">绑定部门（可选）</div>
            ${renderDeptSelect({ selectId: "st-dept" })}
          </div>
          <div>
            <div class="field-label">抓取节奏（每隔多少天）</div>
            <input id="st-cadence" type="number" min="1" value="30" class="field-input"/>
          </div>
        </div>
        <div class="text-xs text-slate-500 bg-slate-50 border border-slate-200 rounded p-3 leading-relaxed">
          系统会自动补齐：<span class="mono text-slate-700">target_code</span>、
          <span class="mono text-slate-700">site_code</span>（按域名匹配）、
          <span class="mono text-slate-700">target_name</span>（抓取后从页面标题回填）。保存后可在列表中「编辑」调整高级配置。
        </div>
      </div>`,
    footer: `
      <button onclick="closeModal()" class="btn-ghost">取消</button>
      <button onclick="saveSimpleTarget()" class="btn-success">保存并启用</button>`,
    wide: false,
    onClose: () => { TARGET_EDITOR = null; },
  });
  populateDeptSelect("st-dept", TARGET_DEPTS || []);
  document.getElementById("st-url").addEventListener("input", _previewSimpleSite);
}

function _matchSiteByUrl(urlStr) {
  // Some govs run BOTH a gkmlpt API platform and a legacy HTML zwgk/xxgk
  // tree under the same hostname (e.g. www.qingxin.gov.cn → site_codes
  // 'qingxin' [cms_adapter=gkmlpt] AND 'qingxin_zwgk' [yaml_path]). Pick
  // the one whose parser path matches the URL we're being asked to add:
  //   • URL contains '/gkmlpt/' → gkmlpt-adapter site wins
  //   • otherwise              → yaml_path site wins
  // Falls back to the first hostname match if no path-based winner.
  try {
    const u = new URL(urlStr);
    const host = u.hostname.toLowerCase();
    const isGkmlptUrl = u.pathname.includes("/gkmlpt/");
    const candidates = (TARGET_SITES || []).filter(s => {
      if (!s.base_url) return false;
      try {
        const sh = new URL(s.base_url).hostname.toLowerCase();
        return sh === host || host.endsWith("." + sh) || sh.endsWith("." + host);
      } catch { return false; }
    });
    if (!candidates.length) return null;
    if (isGkmlptUrl) {
      const adapterHit = candidates.find(s => (s.cms_adapter || "").toLowerCase() === "gkmlpt");
      if (adapterHit) return adapterHit;
    } else {
      const yamlHit = candidates.find(s => !!s.yaml_path && !s.cms_adapter);
      if (yamlHit) return yamlHit;
    }
    return candidates[0];
  } catch {}
  return null;
}

function _previewSimpleSite() {
  const v = document.getElementById("st-url").value.trim();
  const hint = document.getElementById("st-site-hint");
  if (!v) { hint.textContent = "粘贴后自动匹配站点"; hint.className = "text-[11px] text-slate-400 mt-1"; return; }
  const site = _matchSiteByUrl(v);
  if (site) {
    const mode = site.cms_adapter
      ? `<span class="mono text-slate-500">[adapter: ${esc(site.cms_adapter)}]</span>`
      : (site.yaml_path
          ? `<span class="mono text-slate-500">[yaml: ${esc(site.yaml_path)}]</span>`
          : "");
    hint.innerHTML = `✓ 匹配站点 <span class="mono text-slate-700">${esc(site.site_code)}</span> · ${esc(site.site_name || "")} ${mode}`;
    hint.className = "text-[11px] text-emerald-600 mt-1";
  } else {
    hint.innerHTML = `⚠ 无匹配站点，请先到「站点管理」添加对应域名的站点`;
    hint.className = "text-[11px] text-amber-600 mt-1";
  }
}

// Parse a gkmlpt / api-path URL and pull out { deptPath, columnId }.
// Supports:
//   http://host/{dept}/gkmlpt/index#1234          (page, hash=column_id)
//   http://host/{dept}/gkmlpt/api/all/1234?sid=.. (api path, segment=column_id)
//   http://host/gkmlpt/index#1234                 (no dept path)
function _parseGkmlptUrl(urlStr) {
  try {
    const u = new URL(urlStr);
    const pathSegs = u.pathname.split("/").filter(Boolean);
    // API path: .../gkmlpt/api/all/<column_id>
    const apiIdx = pathSegs.indexOf("api");
    if (apiIdx > 0 && pathSegs[apiIdx - 1] === "gkmlpt" && pathSegs[apiIdx + 1] === "all") {
      const columnId = pathSegs[apiIdx + 2] || "";
      const deptPath = apiIdx >= 2 ? pathSegs[apiIdx - 2] : "";
      return { deptPath, columnId };
    }
    // Page hash: .../gkmlpt/index#<column_id>
    const gkIdx = pathSegs.indexOf("gkmlpt");
    if (gkIdx >= 0) {
      const columnId = (u.hash || "").replace(/^#/, "");
      const deptPath = gkIdx >= 1 ? pathSegs[gkIdx - 1] : "";
      return { deptPath, columnId };
    }
  } catch {}
  return { deptPath: "", columnId: "" };
}

// For non-gkmlpt sites we can't auto-discover columns from the page, so we
// derive a stable per-URL slug for target_code. Strips scheme/host, strips
// common trailing files (`index.html`), and falls back to a short hash if
// the path is empty (e.g. site root).
function _slugifyUrlForTargetCode(urlStr) {
  let path = "";
  try {
    const u = new URL(urlStr);
    path = (u.pathname || "/").replace(/\/+$/, "");
    path = path.replace(/\/(index|default)\.(html?|aspx?|jsp|php)$/i, "");
    if (u.hash) path += "_" + u.hash.replace(/^#/, "");
  } catch {
    path = urlStr;
  }
  // Keep alnum + ASCII only — DB column is varchar and our split-by `__` parser
  // will choke on `__` inside the slug, so collapse those to single `_`.
  let slug = path.replace(/[^A-Za-z0-9]+/g, "_").replace(/^_+|_+$/g, "");
  slug = slug.replace(/_{2,}/g, "_");
  if (!slug) {
    let h = 0;
    for (const ch of urlStr) h = ((h << 5) - h + ch.charCodeAt(0)) | 0;
    slug = "u" + Math.abs(h).toString(36);
  }
  return slug.slice(0, 60);
}

async function saveSimpleTarget() {
  const url = document.getElementById("st-url").value.trim();
  if (!url) { toast("请先粘贴入口 URL", "err"); return; }
  const site = _matchSiteByUrl(url);
  if (!site) { toast("未匹配到站点，请先添加对应站点", "err"); return; }
  const deptId = document.getElementById("st-dept").value;
  const cadence = Number(document.getElementById("st-cadence").value || 30);

  const { deptPath, columnId } = _parseGkmlptUrl(url);

  // ─── Non-gkmlpt sites (yaml_path / other adapters) ───
  // The /api/targets/discover endpoint only understands gkmlpt's TREE
  // structure, so don't even try to call it. Directly create a single
  // target keyed by a URL-derived slug. yaml_path sites still need a
  // matching list_selector entry in config/sites/<site>.yaml — surface
  // that as a follow-up hint after creation.
  const isGkmlpt = (site.cms_adapter || "").toLowerCase() === "gkmlpt";
  if (!isGkmlpt) {
    // yaml-path / non-adapter site: try recursive HTML discovery first.
    // If the URL is a portal page (multiple sub-columns nested 2-3 levels
    // deep), surface a tree picker so the operator can bulk-create. If
    // discovery returns 0 or 1 nodes, fall through to single-target create.
    let nodes = [];
    try {
      const r = await api("/api/targets/discover-html", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ entry_url: url, max_depth: 3 }),
      });
      nodes = r.nodes || [];
    } catch (e) {
      // fetch failed entirely — treat as single-target. The single POST
      // below will surface a clearer error if the URL is itself broken.
    }

    // Show picker when there are real sub-columns (>=2 leaf candidates)
    const leaves = nodes.filter(n => n.has_articles && n.depth > 0);
    if (leaves.length >= 2) {
      _showHtmlColumnPicker({ url, site, deptId, cadence, nodes });
      return;
    }

    // Single-target fast-path
    const slug = _slugifyUrlForTargetCode(url);
    const targetCode = `${site.site_code}__${slug}`;
    let deptLabel = "";
    if (deptId) {
      const d = (TARGET_DEPTS || []).find(x => String(x.dept_id) === String(deptId));
      if (d) deptLabel = d.short_name || d.dept_name || d.full_name || "";
    }
    const payload = {
      site_code: site.site_code,
      target_code: targetCode,
      target_name: deptLabel ? `${deptLabel}-${slug}` : null,
      entry_url: url,
      dept_id: deptId ? Number(deptId) : null,
      expected_cadence_days: cadence,
      enabled: true,
    };
    try {
      await api("/api/targets", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify(payload),
      });
      toast(`已新增目标 ${targetCode}`);
      closeModal();
      PAGES.targets();
    } catch (e) { toast(e.message, "err"); }
    return;
  }

  // ─── gkmlpt path ───
  // Dept-index URL (no column_id) → discover mode: fetch TREE, show picker.
  if (!columnId) {
    try {
      const r = await api("/api/targets/discover", {
        method: "POST", headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ entry_url: url }),
      });
      _showColumnPicker({
        url, site, deptId, cadence,
        discoveredDeptPath: r.dept_path || deptPath,
        columns: r.columns || [],
      });
    } catch (e) { toast(e.message || "栏目发现失败", "err"); }
    return;
  }

  // Explicit column URL → single target (old fast-path).
  const parts = [site.site_code];
  if (deptPath) parts.push(deptPath);
  parts.push(columnId);
  const targetCode = parts.join("__");

  // Pre-check duplicate (target_code OR entry_url match). Friendly warn
  // instead of letting the POST 409 with a cryptic message.
  try {
    const lookup = await api("/api/targets/lookup?" + new URLSearchParams({
      target_code: targetCode, entry_url: url,
    }));
    if (lookup.exists) {
      const m = lookup.matches[0];
      const ok = confirm(
        `已存在采集目标：\n  ${m.target_code}\n  ${m.target_name || "(未命名)"}\n` +
        `entry_url: ${m.entry_url}\n\n` +
        `继续创建会因 target_code 唯一约束失败。点「确定」改为打开编辑器编辑这条已有目标。`
      );
      if (ok) {
        closeModal();
        editTarget(m.target_code);
      }
      return;
    }
  } catch (e) { /* lookup is best-effort; proceed */ }

  // Best-effort: look up column name from discover + dept display name from
  // the already-loaded TARGET_DEPTS to assemble a Chinese target_name like
  // "佛冈县政数局-政务动态". Both lookups are optional — fall back gracefully.
  let columnName = "";
  try {
    const base = url.split("#")[0];
    const r = await api("/api/targets/discover", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ entry_url: base }),
    });
    const hit = (r.columns || []).find(c => String(c.id) === String(columnId));
    if (hit) columnName = hit.name || "";
  } catch {}
  let deptLabel = "";
  if (deptId) {
    const d = (TARGET_DEPTS || []).find(x => String(x.dept_id) === String(deptId));
    if (d) deptLabel = d.short_name || d.dept_name || d.full_name || "";
  }
  const targetName = (deptLabel && columnName)
    ? `${deptLabel}-${columnName}`
    : (columnName || null);

  const payload = {
    site_code: site.site_code,
    target_code: targetCode,
    target_name: targetName,
    entry_url: url,
    dept_id: deptId ? Number(deptId) : null,
    expected_cadence_days: cadence,
    enabled: true,
  };
  try {
    await api("/api/targets", {
      method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
    });
    toast(`已新增目标 ${targetName || targetCode}`);
    closeModal();
    PAGES.targets();
  } catch (e) { toast(e.message, "err"); }
}

// Step-2 modal: shows all columns discovered under a dept index URL, lets the
// user pick which ones to bulk-create as crawl_targets.
function _showColumnPicker({ url, site, deptId, cadence, discoveredDeptPath, columns }) {
  // Exclude external-jump columns (they aren't crawlable gkmlpt pages)
  const rows = columns.filter(c => !c.is_external);
  if (!rows.length) {
    toast("未从该页面解析到可抓取的栏目", "err");
    return;
  }
  const indent = d => "　".repeat(d);
  const body = `
    <div class="text-[12px] text-slate-600 mb-3">
      从 <span class="mono text-slate-800">${esc(url)}</span> 解析到
      <span class="font-semibold">${rows.length}</span> 个可抓取栏目 ·
      站点 <span class="mono">${esc(site.site_code)}</span> ·
      部门路径 <span class="mono">${esc(discoveredDeptPath || "-")}</span>
    </div>
    <div class="flex items-center gap-2 mb-2">
      <button class="btn-outline" onclick="document.querySelectorAll('.st-col').forEach(el=>el.checked=true)">全选</button>
      <button class="btn-outline" onclick="document.querySelectorAll('.st-col').forEach(el=>el.checked=false)">全不选</button>
      <div class="flex-1"></div>
      <label class="text-[12px] text-slate-600">请求间隔(秒) <input id="st-interval" type="number" min="0" value="30" class="w-16 ml-1 border border-slate-300 rounded px-2 py-0.5 text-xs"/></label>
      <label class="text-[12px] text-slate-600">跳动(秒) <input id="st-jitter" type="number" min="0" value="0" class="w-16 ml-1 border border-slate-300 rounded px-2 py-0.5 text-xs"/></label>
    </div>
    <div class="border border-[#E5EBF1] rounded max-h-[360px] overflow-y-auto">
      <table class="w-full text-sm">
        <thead class="bg-slate-50 text-slate-500 text-xs">
          <tr>
            <th class="w-8 px-3 py-2"></th>
            <th class="text-left px-3 py-2">栏目名称</th>
            <th class="text-left px-3 py-2 w-24">column_id</th>
            <th class="text-left px-3 py-2 w-24">生成 target_code</th>
          </tr>
        </thead>
        <tbody>
          ${rows.map(c => {
            const parts = [site.site_code];
            if (discoveredDeptPath) parts.push(discoveredDeptPath);
            parts.push(c.id);
            const tc = parts.join("__");
            return `
              <tr class="border-t border-slate-100">
                <td class="px-3 py-2 text-center"><input type="checkbox" class="st-col" data-id="${esc(c.id)}" data-name="${esc(c.name)}" data-path="${esc(c.path || c.name || "")}" checked /></td>
                <td class="px-3 py-2">${indent(c.depth)}${esc(c.name || "—")}</td>
                <td class="px-3 py-2 mono text-xs text-slate-500">${esc(c.id)}</td>
                <td class="px-3 py-2 mono text-[11px] text-slate-400 truncate" title="${esc(tc)}">${esc(tc)}</td>
              </tr>`;
          }).join("")}
        </tbody>
      </table>
    </div>
    <div class="text-[11px] text-slate-500 mt-2">
      每个勾选栏目会生成一条 crawl_target，共用相同的部门绑定和抓取节奏。实际等待 = 请求间隔 + 0 到跳动时间之间的随机秒数。
    </div>`;
  openModal({
    title: "批量新增目标（按栏目）",
    subtitle: `节奏 ${cadence} 天 · 部门 ${deptId || "未绑定"}`,
    body,
    footer: `
      <button onclick="closeModal()" class="btn-ghost">取消</button>
      <button onclick="_confirmBulkCreate('${esc(site.site_code)}', '${esc(discoveredDeptPath)}', '${esc(url)}', ${Number(deptId) || "null"}, ${cadence})" class="btn-success">确认创建</button>`,
    wide: true,
  });
}

async function _confirmBulkCreate(siteCode, deptPath, entryUrlBase, deptId, cadence) {
  const picked = Array.from(document.querySelectorAll(".st-col")).filter(el => el.checked);
  if (!picked.length) { toast("请至少勾选一个栏目", "err"); return; }
  const interval = Number(document.getElementById("st-interval")?.value || 30);
  const jitter = Number(document.getElementById("st-jitter")?.value || 0);
  const payload = {
    site_code: siteCode,
    dept_path: deptPath || "",
    entry_url_base: entryUrlBase.split("#")[0],
    columns: picked.map(el => ({ id: el.dataset.id, name: el.dataset.name, path: el.dataset.path })),
    dept_id: deptId || null,
    expected_cadence_days: cadence,
    interval_sec: interval,
    interval_jitter_sec: jitter,
    enabled: true,
  };
  try {
    const r = await api("/api/targets/bulk-create", {
      method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
    });
    const msg = `已创建 ${r.created_count} 条`
      + (r.skipped_count ? ` · 跳过 ${r.skipped_count} 条（已存在）` : "");
    toast(msg, "ok");
    closeModal();
    PAGES.targets();
  } catch (e) { toast(e.message || "批量创建失败", "err"); }
}

// HTML-discover picker — counterpart to _showColumnPicker for non-gkmlpt
// (yaml-path) sites. Renders the BFS tree as an indented checkbox list,
// auto-checks only nodes with has_articles=true (the leaf list pages —
// intermediate portals are unchecked by default but still visible so the
// operator can see the path structure).
function _showHtmlColumnPicker({ url, site, deptId, cadence, nodes }) {
  // Order nodes so each parent is followed by its descendants (preorder).
  // The discover endpoint returns BFS, which puts all depth-1 first then
  // all depth-2; reorder for tree rendering.
  const byParent = new Map();
  let root = null;
  for (const n of nodes) {
    if (n.depth === 0) root = n;
    if (!byParent.has(n.parent_url || "__root__")) byParent.set(n.parent_url || "__root__", []);
    byParent.get(n.parent_url || "__root__").push(n);
  }
  const ordered = [];
  function walk(parentUrl) {
    const kids = byParent.get(parentUrl) || [];
    for (const k of kids) {
      ordered.push(k);
      walk(k.list_url);
    }
  }
  if (root) {
    ordered.push(root);
    walk(root.list_url);
  } else {
    ordered.push(...nodes);
  }

  const indent = d => "&nbsp;&nbsp;".repeat(Math.max(0, d * 2));
  // Auto-check leaves (has_articles + depth>0); leave the entry root and
  // intermediate portals unchecked — the operator can override.
  const body = `
    <div class="text-xs text-slate-600 mb-3">
      从 <span class="mono text-slate-800">${esc(url)}</span> 递归发现
      <span class="font-semibold">${nodes.length}</span> 个节点 ·
      <span class="text-emerald-600">▶</span> = 含文章的栏目（默认勾选）·
      <span class="text-slate-400">+</span> = 还有更深子栏目
    </div>
    <div class="flex items-center gap-2 mb-2">
      <button class="btn-outline" onclick="document.querySelectorAll('.sh-col').forEach(el=>el.checked=true)">全选</button>
      <button class="btn-outline" onclick="document.querySelectorAll('.sh-col').forEach(el=>el.checked=false)">全不选</button>
      <button class="btn-outline" onclick="document.querySelectorAll('.sh-col[data-leaf=&quot;1&quot;]').forEach(el=>el.checked=true);document.querySelectorAll('.sh-col[data-leaf=&quot;0&quot;]').forEach(el=>el.checked=false)">仅勾选叶子</button>
      <div class="flex-1"></div>
      <label class="text-[12px] text-slate-600">请求间隔(秒) <input id="sh-interval" type="number" min="0" value="10" class="w-16 ml-1 border border-slate-300 rounded px-2 py-0.5 text-xs"/></label>
      <label class="text-[12px] text-slate-600">跳动(秒) <input id="sh-jitter" type="number" min="0" value="0" class="w-16 ml-1 border border-slate-300 rounded px-2 py-0.5 text-xs"/></label>
    </div>
    <div class="border border-[#E5EBF1] rounded max-h-[420px] overflow-y-auto">
      <table class="w-full text-sm">
        <thead class="bg-slate-50 text-slate-500 text-xs">
          <tr>
            <th class="w-8 px-3 py-2"></th>
            <th class="text-left px-3 py-2">栏目名称</th>
            <th class="text-left px-3 py-2 w-28">column_id</th>
            <th class="text-left px-3 py-2 w-12 text-center">叶子</th>
            <th class="text-left px-3 py-2">URL</th>
          </tr>
        </thead>
        <tbody>
          ${ordered.map(n => {
            const isLeaf = n.has_articles ? 1 : 0;
            const flag = n.has_articles
              ? '<span class="text-emerald-600">▶</span>'
              : '<span class="text-slate-300">·</span>';
            const more = n.has_children ? '<span class="text-slate-400 ml-1">+</span>' : "";
            const tc = `${site.site_code}__${n.column_id}`;
            const checked = (isLeaf && n.depth > 0) ? "checked" : "";
            return `
              <tr class="border-t border-slate-100">
                <td class="px-3 py-2 text-center">
                  <input type="checkbox" class="sh-col"
                         data-cid="${esc(n.column_id)}"
                         data-name="${esc(n.name || "")}"
                         data-url="${esc(n.list_url)}"
                         data-leaf="${isLeaf}" ${checked}/>
                </td>
                <td class="px-3 py-2">${indent(n.depth)}${flag} ${esc(n.name || "—")}${more}</td>
                <td class="px-3 py-2 mono text-xs text-slate-500" title="${esc(tc)}">${esc(n.column_id)}</td>
                <td class="px-3 py-2 text-center text-xs">${n.has_articles ? "是" : "—"}</td>
                <td class="px-3 py-2 mono text-[11px] text-slate-400 truncate max-w-[260px]" title="${esc(n.list_url)}">${esc(n.list_url.replace(/^https?:\/\/[^/]+/, ""))}</td>
              </tr>`;
          }).join("")}
        </tbody>
      </table>
    </div>
    <div class="text-[11px] text-slate-500 mt-2">
      每个勾选项创建一条 crawl_target，target_code = <span class="mono">${esc(site.site_code)}__&lt;column_id&gt;</span>。
      yaml 不会被改动 — 选择器走站点的 default_column 兜底。
    </div>`;
  openModal({
    title: "批量新增目标（HTML 子栏目）",
    subtitle: `节奏 ${cadence} 天 · 部门 ${deptId || "未绑定"}`,
    body,
    footer: `
      <button onclick="closeModal()" class="btn-ghost">取消</button>
      <button onclick="_confirmHtmlBulkCreate('${esc(site.site_code)}', ${Number(deptId) || "null"}, ${cadence})" class="btn-success">确认创建</button>`,
    wide: true,
  });
}

async function _confirmHtmlBulkCreate(siteCode, deptId, cadence) {
  const picked = Array.from(document.querySelectorAll(".sh-col")).filter(el => el.checked);
  if (!picked.length) { toast("请至少勾选一个栏目", "err"); return; }
  const interval = Number(document.getElementById("sh-interval")?.value || 10);
  const jitter = Number(document.getElementById("sh-jitter")?.value || 0);
  const payload = {
    site_code: siteCode,
    columns: picked.map(el => ({
      column_id: el.dataset.cid,
      name: el.dataset.name,
      list_url: el.dataset.url,
    })),
    dept_id: deptId || null,
    expected_cadence_days: cadence,
    interval_sec: interval,
    interval_jitter_sec: jitter,
    enabled: true,
  };
  try {
    const r = await api("/api/targets/bulk-create-yaml", {
      method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
    });
    const msg = `已创建 ${r.created_count} 条`
      + (r.skipped_count ? ` · 跳过 ${r.skipped_count} 条（已存在）` : "");
    toast(msg, "ok");
    closeModal();
    PAGES.targets();
  } catch (e) { toast(e.message || "批量创建失败", "err"); }
}

async function saveTargetEditor() {
  const payload = {
    site_code: document.getElementById("t-site").value,
    target_code: document.getElementById("t-code").value.trim(),
    target_name: document.getElementById("t-name").value.trim(),
    entry_url: document.getElementById("t-entry").value.trim(),
    sample_article_url: document.getElementById("t-sample").value.trim() || null,
    dept_id: document.getElementById("t-dept").value ? Number(document.getElementById("t-dept").value) : null,
    channel_name: document.getElementById("t-channel-name").value.trim() || null,
    channel_path: document.getElementById("t-channel-path").value.trim() || null,
    content_category: document.getElementById("t-category").value.trim() || null,
    content_subcategory: document.getElementById("t-subcategory").value.trim() || null,
    expected_cadence_days: Number(document.getElementById("t-cadence").value || 30),
    interval_sec: Number(document.getElementById("t-interval").value || 0),
    interval_jitter_sec: Number(document.getElementById("t-jitter").value || 0),
    track_checkpoint: document.getElementById("t-track-checkpoint").checked,
    enabled: document.getElementById("t-enabled").checked,
  };
  try {
    if (TARGET_EDITOR && TARGET_EDITOR.target_code) {
      await api(`/api/targets/${encodeURIComponent(TARGET_EDITOR.target_code)}`, {
        method: "PUT", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
      });
      toast(`已更新 ${payload.target_code}`);
    } else {
      await api("/api/targets", {
        method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(payload),
      });
      toast(`已新增 ${payload.target_code}`);
    }
    TARGET_EDITOR = null;
    closeModal();
    PAGES.targets();
  } catch (e) { toast(e.message, "err"); }
}

async function dryRunTargetEditor() {
  if (!(TARGET_EDITOR && TARGET_EDITOR.target_code)) {
    toast("请先保存目标后再 dry-run", "err");
    return;
  }
  const box = document.getElementById("t-dryrun");
  box.innerHTML = `dry-run 中…`;
  try {
    const r = await api(`/api/targets/${encodeURIComponent(TARGET_EDITOR.target_code)}/dry-run`, {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({
        entry_url: document.getElementById("t-entry").value.trim() || null,
        sample_article_url: document.getElementById("t-sample").value.trim() || null,
      }),
    });
    box.innerHTML = `
      <div class="p-3 rounded border ${r.ok ? "border-emerald-200 bg-emerald-50 text-emerald-800" : "border-red-200 bg-red-50 text-red-800"}">
        <div class="font-medium">${r.ok ? "dry-run 通过" : "dry-run 未通过"} · mode=${esc(r.mode)}</div>
        <ul class="mt-2 space-y-1">
          ${(r.checks || []).map(it => `<li>[${esc(it.level)}] ${esc(it.message)}</li>`).join("")}
        </ul>
      </div>`;
  } catch (e) {
    box.innerHTML = `<div class="p-3 rounded border border-red-200 bg-red-50 text-red-800">dry-run 失败：${esc(e.message)}</div>`;
  }
}

// Targets page — banner + filter bar + pencil-spec columns + pagination
const TARGETS_STATE = {
  page: 1, pageSize: 10, q: "", site: "", dept: "", status: "",
  // Set of target_code strings currently checked. Persists across page
  // changes within the same filter view; cleared on filter changes.
  selected: new Set(),
};

PAGES.targets = async () => {
  const ws = document.getElementById("workspace");
  ws.innerHTML = `<div class="text-slate-400 text-sm">加载中…</div>`;
  try {
    const [{ sites }, { items: depts }] = await Promise.all([
      api("/api/sites"),
      api("/api/local-departments?limit=1000"),
    ]);
    const rows = [];
    sites.forEach(site => (site.targets || []).forEach(t => rows.push({ ...t, site_code: site.site_code, site_name: site.site_name })));
    // Newest-first across all sites. The per-site backend already sorts by
    // created_at desc, but the flatten interleaves sites — re-sort globally
    // so a target created 2 minutes ago on site B beats one created last
    // week on site A. Falls back to target_code so rows without created_at
    // (legacy data) stay deterministic.
    rows.sort((a, b) => {
      const ta = a.created_at ? Date.parse(a.created_at) : 0;
      const tb = b.created_at ? Date.parse(b.created_at) : 0;
      if (tb !== ta) return tb - ta;
      return (b.target_code || "").localeCompare(a.target_code || "");
    });
    window.__targetRows = rows;
    TARGET_SITES = sites;
    TARGET_DEPTS = depts;

    const siteOpts = `<option value="">所有站点</option>` +
      sites.map(s => `<option value="${esc(s.site_code)}">${esc(s.site_code)} · ${esc(s.site_name || "")}</option>`).join("");
    const deptOpts = `<option value="">所有部门</option>` +
      depts.map(d => `<option value="${d.dept_id}">${esc(d.dept_id)} · ${esc(d.full_name || d.dept_name || "")}</option>`).join("");

    ws.innerHTML = `
      ${pageBanner({
        title: "采集目标管理",
        subtitle: "配置栏目入口、绑定本地部门、保存前 dry-run 校验选择器",
      })}
      ${card({
        body: `
          <div class="filter-bar">
            <input id="t-q" class="filter-grow" placeholder="搜索 target_code / 名称 / entry_url…" value="${esc(TARGETS_STATE.q)}" />
            <select id="t-site-f" style="width:160px">${siteOpts}</select>
            <select id="t-dept-f" style="width:200px">${deptOpts}</select>
            <select id="t-status-f" style="width:140px">
              <option value="">状态：全部</option>
              <option value="enabled">已启用</option>
              <option value="disabled">已停用</option>
              <option value="full_done">完成过全量</option>
              <option value="full_missing">未完成全量</option>
            </select>
            <button class="btn-outline" onclick="resetTargetFilters()">重置</button>
            <div style="flex:1"></div>
            <button class="btn-outline" onclick="toast('YAML 导入待接入','err')">导入 YAML</button>
            <button class="btn-primary" onclick="newTarget()">+ 新建目标</button>
          </div>
          <div id="t-table"></div>
          <div id="t-pager-container"></div>`
      })}`;

    // Restore prior filter values
    document.getElementById("t-site-f").value = TARGETS_STATE.site;
    document.getElementById("t-dept-f").value = TARGETS_STATE.dept;
    document.getElementById("t-status-f").value = TARGETS_STATE.status;

    // When filters change, drop any active selection — keeping it would
    // be confusing because rows that were checked may no longer be visible
    // and "已选 N 项" wouldn't match what the user sees.
    const rerender = () => {
      TARGETS_STATE.page = 1;
      TARGETS_STATE.selected.clear();
      renderTargetsTable();
    };
    document.getElementById("t-q").oninput = (e) => { TARGETS_STATE.q = e.target.value; rerender(); };
    document.getElementById("t-site-f").onchange = (e) => { TARGETS_STATE.site = e.target.value; rerender(); };
    document.getElementById("t-dept-f").onchange = (e) => { TARGETS_STATE.dept = e.target.value; rerender(); };
    document.getElementById("t-status-f").onchange = (e) => { TARGETS_STATE.status = e.target.value; rerender(); };

    renderTargetsTable();
  } catch (e) {
    ws.innerHTML = `<div class="card p-6 text-sm text-red-600">加载失败：${esc(e.message)}</div>`;
  }
};

function resetTargetFilters() {
  TARGETS_STATE.q = ""; TARGETS_STATE.site = ""; TARGETS_STATE.dept = ""; TARGETS_STATE.status = "";
  TARGETS_STATE.page = 1;
  PAGES.targets();
}

function renderTargetsTable() {
  const rows = window.__targetRows || [];
  const { q, site, dept, status, page, pageSize } = TARGETS_STATE;
  const ql = q.trim().toLowerCase();
  const filtered = rows.filter(t => {
    if (site && t.site_code !== site) return false;
    if (dept && String(t.dept_id || "") !== String(dept)) return false;
    if (status === "enabled" && !t.enabled) return false;
    if (status === "disabled" && t.enabled) return false;
    if (status === "full_done" && !t.has_completed_full_crawl) return false;
    if (status === "full_missing" && t.has_completed_full_crawl) return false;
    if (ql) {
      const hay = `${t.target_code || ""} ${t.target_name || ""} ${t.entry_url || ""} ${t.channel_path || ""}`.toLowerCase();
      if (!hay.includes(ql)) return false;
    }
    return true;
  });
  const total = filtered.length;
  const start = (page - 1) * pageSize;
  const pageRows = filtered.slice(start, start + pageSize);

  const healthPill = t => {
    if (!t.enabled) return pill("停用", "slate");
    if (t.stale_days != null && t.stale_days >= 3) return pill(`Stale ${t.stale_days}d`, "red");
    return pill("正常", "green");
  };
  const crawlSummary = t => {
    const j = t.last_full_crawl_job;
    if (!j) return `<div class="text-xs text-slate-400">未完成过全量</div>`;
    const finished = fmt(j.finished_at);
    const total = j.items_seen != null ? j.items_seen : "—";
    const failed = j.items_failed || 0;
    return `
      <div class="text-xs text-slate-700" title="${esc(j.finished_at || "")}">${esc(finished)}</div>
      <div class="text-[11px] text-slate-400">共 ${esc(total)} 条${failed ? ` · 失败 ${esc(failed)}` : ""}</div>`;
  };

  // Checkbox state: header checks track whether all visible (page) rows
  // are selected; inverse on click toggles them as a unit.
  const visibleCodes = pageRows.map(t => t.target_code);
  const allVisibleChecked = visibleCodes.length > 0
    && visibleCodes.every(c => TARGETS_STATE.selected.has(c));
  const selectedCount = TARGETS_STATE.selected.size;

  const actionBar = selectedCount > 0 ? `
    <div class="flex flex-wrap items-center gap-2 mb-2 px-3 py-2 bg-blue-50 border border-blue-200 rounded">
      <span class="text-sm text-slate-700">已选 <span class="font-semibold text-blue-700">${selectedCount}</span> 项</span>
      <button class="btn-outline" onclick="_selectAllFiltered()">勾选全部过滤结果（${total} 项）</button>
      <button class="btn-outline" onclick="_clearTargetSelection()">清除选择</button>
      <button onclick="_bulkDeleteTargets()" class="text-sm rounded px-3 py-1.5 border border-red-200 text-red-600 hover:bg-red-50" title="删除采集目标，历史文章保留">批量删除</button>
      <div style="flex:1"></div>
      <button class="btn-outline" onclick="_bulkToggleTargets(true)">批量启用</button>
      <button class="btn-outline" onclick="_bulkToggleTargets(false)">批量停用</button>
      <button class="btn-primary" onclick="_bulkRunTargets(false)" title="增量">▶ 批量抓取</button>
      <button onclick="_bulkRunTargets(true)" class="text-sm rounded px-3 py-1.5 bg-amber-600 text-white hover:bg-amber-700" title="全量（忽略已抓过）">▶▶ 全量</button>
    </div>` : "";

  const body = `
    ${actionBar}
    <table class="w-full text-sm">
      <thead class="bg-slate-50 text-slate-500 text-xs">
        <tr>
          <th class="text-center px-3 py-3" style="width:36px">
            <input type="checkbox" id="t-check-all" ${allVisibleChecked ? "checked" : ""}
                   title="勾选当前页全部" />
          </th>
          <th class="text-left px-4 py-3" style="width:26%">目标</th>
          <th class="text-left px-4 py-3" style="width:16%">站点 · 部门</th>
          <th class="text-left px-4 py-3">栏目路径</th>
          <th class="text-left px-4 py-3 whitespace-nowrap" style="width:140px">节奏</th>
          <th class="text-left px-4 py-3 whitespace-nowrap" style="width:150px">全量记录</th>
          <th class="text-left px-4 py-3 whitespace-nowrap" style="width:140px">健康度</th>
          <th class="text-left px-4 py-3" style="width:140px">创建时间</th>
          <th class="text-right px-4 py-3" style="width:132px">操作</th>
        </tr>
      </thead>
      <tbody>
        ${pageRows.map(t => `
          <tr class="border-t border-slate-100 hover:bg-slate-50/60">
            <td class="px-3 py-3 text-center">
              <input type="checkbox" class="t-check"
                     data-tc="${esc(t.target_code)}"
                     ${TARGETS_STATE.selected.has(t.target_code) ? "checked" : ""} />
            </td>
            <td class="px-4 py-3">
              <div class="text-slate-900 font-medium">${esc(t.target_name || t.target_code)}</div>
              <div class="text-[11px] text-slate-400 mono">${esc(t.target_code)}</div>
            </td>
            <td class="px-4 py-3 text-xs">
              <div class="text-slate-700">${esc(t.site_code)}</div>
              <div class="text-slate-500">${esc(t.site_name || "—")}${t.dept_id ? ` · <span class="mono">${t.dept_id}</span>` : ""}</div>
            </td>
            <td class="px-4 py-3 text-xs text-slate-600 max-w-[380px] truncate" title="${esc(t.channel_path || "")}">${esc(t.channel_path || "—")}</td>
            <td class="px-4 py-3 text-xs whitespace-nowrap">
              <div class="text-slate-700">每 ${esc(t.expected_cadence_days || "—")} 天</div>
              <div class="text-slate-400 mono">${esc(t.interval_sec != null ? t.interval_sec + "s" : "")}${t.interval_jitter_sec ? ` +0-${esc(t.interval_jitter_sec)}s` : ""}</div>
              ${t.track_checkpoint ? `<div class="text-emerald-600 text-[11px]">checkpoint</div>` : ""}
            </td>
            <td class="px-4 py-3 whitespace-nowrap">${crawlSummary(t)}</td>
            <td class="px-4 py-3 whitespace-nowrap">${healthPill(t)}</td>
            <td class="px-4 py-3 text-xs text-slate-600 whitespace-nowrap" title="${esc(t.created_at || "")}">${esc(fmt(t.created_at))}</td>
            <td class="px-4 py-3 text-right whitespace-nowrap">
              <div class="flex items-center justify-end gap-1.5">
                <button onclick="editTarget('${esc(t.target_code)}')" class="text-xs px-2 py-1 rounded border border-slate-200 text-slate-700 hover:bg-slate-50">编辑</button>
                <button onclick="runTarget('${esc(t.target_code)}')" class="text-xs px-2 py-1 rounded bg-[#1D4ED8] text-white hover:bg-blue-700" title="增量抓取（命中已抓过的就停）">抓取</button>
                <button onclick="openTargetActionMenu(event, '${esc(t.target_code)}', ${!!t.enabled})" class="text-xs px-2 py-1 rounded border border-slate-200 text-slate-700 hover:bg-slate-50" title="更多操作">更多</button>
              </div>
            </td>
          </tr>`).join("") || `<tr><td colspan="9" class="p-10 text-center text-slate-400 text-sm">无匹配目标</td></tr>`}
      </tbody>
    </table>`;
  document.getElementById("t-table").innerHTML = body;

  // Wire up checkbox interactions. Per-row toggles update the Set; the
  // header checkbox flips the whole visible page (additive — doesn't
  // touch selections from other pages).
  document.querySelectorAll(".t-check").forEach(el => {
    el.onchange = (e) => {
      const tc = e.target.dataset.tc;
      if (e.target.checked) TARGETS_STATE.selected.add(tc);
      else TARGETS_STATE.selected.delete(tc);
      renderTargetsTable();
    };
  });
  const allEl = document.getElementById("t-check-all");
  if (allEl) {
    allEl.onchange = (e) => {
      for (const code of visibleCodes) {
        if (e.target.checked) TARGETS_STATE.selected.add(code);
        else TARGETS_STATE.selected.delete(code);
      }
      renderTargetsTable();
    };
  }

  renderPager({
    container: document.getElementById("t-pager-container"),
    total, page, pageSize,
    summary: `共 ${total} 条目标 · 按创建时间倒序`,
    onPage: p => { TARGETS_STATE.page = p; renderTargetsTable(); },
  });
}

// Action: pick every row matching the current filter (across all pages),
// not just the visible page. Useful for "select 30 targets and run them all".
function _selectAllFiltered() {
  const rows = window.__targetRows || [];
  const { q, site, dept, status } = TARGETS_STATE;
  const ql = q.trim().toLowerCase();
  for (const t of rows) {
    if (site && t.site_code !== site) continue;
    if (dept && String(t.dept_id || "") !== String(dept)) continue;
    if (status === "enabled" && !t.enabled) continue;
    if (status === "disabled" && t.enabled) continue;
    if (status === "full_done" && !t.has_completed_full_crawl) continue;
    if (status === "full_missing" && t.has_completed_full_crawl) continue;
    if (ql) {
      const hay = `${t.target_code || ""} ${t.target_name || ""} ${t.entry_url || ""} ${t.channel_path || ""}`.toLowerCase();
      if (!hay.includes(ql)) continue;
    }
    TARGETS_STATE.selected.add(t.target_code);
  }
  renderTargetsTable();
}
function _clearTargetSelection() {
  TARGETS_STATE.selected.clear();
  renderTargetsTable();
}

async function _bulkRunTargets(force = false) {
  const codes = Array.from(TARGETS_STATE.selected);
  if (!codes.length) { toast("请先勾选目标", "err"); return; }
  const mode = force ? "全量抓取（忽略已抓过）" : "增量抓取";
  if (!confirm(`确认${mode} ${codes.length} 个目标？\n同站点会按 FIFO 串行执行（避免触发反爬）。`)) return;
  try {
    const r = await api("/api/targets/bulk-run", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ target_codes: codes, force }),
    });
    const skip = r.skipped_count
      ? ` · 跳过 ${r.skipped_count}（${(r.skipped || []).slice(0, 3).map(s => s.reason).join("/")}）`
      : "";
    toast(`已排队 ${r.queued_count} 个${force ? "全量" : ""}抓取任务${skip}`, "ok");
    TARGETS_STATE.selected.clear();
    renderTargetsTable();
  } catch (e) { toast(e.message || "批量抓取失败", "err"); }
}

async function _bulkToggleTargets(enabled) {
  const codes = Array.from(TARGETS_STATE.selected);
  if (!codes.length) { toast("请先勾选目标", "err"); return; }
  if (!confirm(`确认批量${enabled ? "启用" : "停用"} ${codes.length} 个目标？`)) return;
  let ok = 0, fail = 0;
  for (const code of codes) {
    try {
      await api(`/api/targets/${encodeURIComponent(code)}/toggle?enabled=${enabled}`, { method: "POST" });
      ok++;
    } catch { fail++; }
  }
  toast(`已${enabled ? "启用" : "停用"} ${ok} 个${fail ? ` · 失败 ${fail}` : ""}`, fail ? "err" : "ok");
  TARGETS_STATE.selected.clear();
  PAGES.targets();
}

async function _bulkDeleteTargets() {
  const codes = Array.from(TARGETS_STATE.selected);
  if (!codes.length) { toast("请先勾选目标", "err"); return; }
  if (!confirm(`确认永久删除 ${codes.length} 个采集目标？\n历史文章会保留，但这些文章的 target 关联会被置空。`)) return;
  try {
    const r = await api("/api/targets/bulk-delete", {
      method: "POST", headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ target_codes: codes }),
    });
    const skip = r.skipped_count
      ? ` · 跳过 ${r.skipped_count}（${(r.skipped || []).slice(0, 3).map(s => s.reason).join("/")}）`
      : "";
    toast(`已删除 ${r.deleted_count} 个采集目标${skip}`, r.skipped_count ? "err" : "ok");
    TARGETS_STATE.selected.clear();
    PAGES.targets();
  } catch (e) { toast(e.message || "批量删除失败", "err"); }
}

async function toggleTarget(code, enabled) {
  try {
    await api(`/api/targets/${encodeURIComponent(code)}/toggle?enabled=${enabled}`, { method: "POST" });
    toast(`已${enabled ? "启用" : "停用"} ${code}`);
    PAGES.targets();
  } catch (e) { toast(e.message, "err"); }
}
async function runTarget(code, force = false, resumeFromCheckpoint = false) {
  try {
    const params = new URLSearchParams();
    if (force) params.set("force", "true");
    if (resumeFromCheckpoint) params.set("resume_from_latest_checkpoint", "true");
    const qs = params.toString() ? `?${params.toString()}` : "";
    await api(`/api/targets/${encodeURIComponent(code)}/run${qs}`, { method: "POST" });
    const mode = resumeFromCheckpoint ? "（从最近 checkpoint 继续）" : (force ? "（全量）" : "");
    toast(`已排队：${code}${mode}（几秒后查看抓取日志）`);
  } catch (e) { toast(e.message, "err"); }
}

function closeTargetActionMenu() {
  const el = document.getElementById("target-action-menu");
  if (el) el.remove();
}

function _targetActionMenuButton(label, onClick, extra = "") {
  return `
    <button type="button" onclick="${onClick}; closeTargetActionMenu();"
            class="w-full text-left px-3 py-2 text-xs hover:bg-slate-50 ${extra}">
      ${label}
    </button>`;
}

function openTargetActionMenu(evt, code, enabled) {
  evt.preventDefault();
  evt.stopPropagation();
  closeTargetActionMenu();
  const r = evt.currentTarget.getBoundingClientRect();
  const menu = document.createElement("div");
  menu.id = "target-action-menu";
  menu.className = "bg-white border border-slate-200 rounded-md shadow-lg overflow-hidden";
  menu.style.position = "fixed";
  menu.style.zIndex = "60";
  menu.style.width = "148px";
  menu.style.right = Math.max(8, window.innerWidth - r.right) + "px";
  menu.style.top = (r.bottom + 6) + "px";
  menu.innerHTML = `
    ${_targetActionMenuButton("日志", `openTargetCrawlJobs('${esc(code)}')`)}
    ${_targetActionMenuButton("模板", `openTemplateModal('${esc(code)}')`)}
    ${_targetActionMenuButton(enabled ? "停用" : "启用", `toggleTarget('${esc(code)}', ${!enabled})`)}
    <div class="border-t border-slate-100"></div>
    ${_targetActionMenuButton("全量抓取", `runTarget('${esc(code)}', true)`)}
    ${_targetActionMenuButton("继续全量", `runTarget('${esc(code)}', true, true)`)}
    <div class="border-t border-slate-100"></div>
    ${_targetActionMenuButton("删除", `deleteTarget('${esc(code)}')`, "text-red-600 hover:bg-red-50")}
  `;
  document.body.appendChild(menu);

  const mr = menu.getBoundingClientRect();
  if (mr.bottom > window.innerHeight - 8) {
    menu.style.top = "";
    menu.style.bottom = Math.max(8, window.innerHeight - r.top + 6) + "px";
  }
}

if (!window.__targetActionMenuListener) {
  window.__targetActionMenuListener = true;
  document.addEventListener("click", closeTargetActionMenu);
  window.addEventListener("scroll", closeTargetActionMenu, true);
  window.addEventListener("resize", closeTargetActionMenu);
}

async function openTargetCrawlJobs(code) {
  openModal({
    title: `采集日志 · ${code}`,
    subtitle: "每次启动采集都会写入一条任务记录；全量完成记录可用于筛选目标",
    wide: true,
    body: `<div class="text-slate-400 text-sm">加载中…</div>`,
  });
  try {
    const body = await api(`/api/targets/${encodeURIComponent(code)}/crawl-jobs?limit=100`);
    const statusLabel = {
      queued: "排队中",
      running: "运行中",
      done: "完成",
      failed: "失败",
      cancelled: "已取消",
    };
    const modeLabel = j => j.force ? "全量" : "增量";
    const timeLabel = v => fmt(v);
    const rows = (body.jobs || []).map(j => {
      const total = j.items_seen != null ? j.items_seen : "—";
      const counts = j.status === "done"
        ? `共 ${esc(total)} 条 · 新增 ${esc(j.items_new ?? "—")} · 跳过 ${esc(j.items_skipped ?? "—")} · 失败 ${esc(j.items_failed ?? "—")}`
        : (j.error_msg || "—");
      const progress = j.current_page
        ? `当前第 ${esc(j.current_page)} 页${j.last_completed_page ? ` · 已完成第 ${esc(j.last_completed_page)} 页` : ""}`
        : (j.last_completed_page ? `已完成第 ${esc(j.last_completed_page)} 页` : "—");
      return `
        <tr class="border-t border-slate-100">
          <td class="px-3 py-2 mono text-xs">${esc(j.job_id)}</td>
          <td class="px-3 py-2 text-xs">${esc(modeLabel(j))}</td>
          <td class="px-3 py-2 text-xs">${esc(statusLabel[j.status] || j.status)}</td>
          <td class="px-3 py-2 text-xs whitespace-nowrap">${esc(timeLabel(j.started_at))}</td>
          <td class="px-3 py-2 text-xs whitespace-nowrap">${esc(timeLabel(j.finished_at))}</td>
          <td class="px-3 py-2 text-xs">${esc(counts)}</td>
          <td class="px-3 py-2 text-xs">${progress}</td>
        </tr>`;
    }).join("") || `<tr><td colspan="7" class="p-8 text-center text-slate-400">暂无采集日志</td></tr>`;
    openModal({
      title: `采集日志 · ${code}`,
      subtitle: `共 ${body.count || 0} 条任务记录`,
      wide: true,
      body: `
        <table class="w-full text-sm">
          <thead class="bg-slate-50 text-slate-500 text-xs">
            <tr>
              <th class="text-left px-3 py-2">job</th>
              <th class="text-left px-3 py-2">类型</th>
              <th class="text-left px-3 py-2">状态</th>
              <th class="text-left px-3 py-2">开始时间</th>
              <th class="text-left px-3 py-2">完成时间</th>
              <th class="text-left px-3 py-2">结果</th>
              <th class="text-left px-3 py-2">页码 / checkpoint</th>
            </tr>
          </thead>
          <tbody>${rows}</tbody>
        </table>`,
    });
  } catch (e) {
    openModal({
      title: `采集日志 · ${code}`,
      wide: true,
      body: `<div class="text-sm text-red-600">加载失败：${esc(e.message)}</div>`,
    });
  }
}

async function deleteTarget(code) {
  if (!confirm(`确认永久删除目标 ${code}？\n历史文章的 target_id 会置空但不删除。`)) return;
  try {
    await api(`/api/targets/${encodeURIComponent(code)}`, { method: "DELETE" });
    toast(`已删除 ${code}`);
    PAGES.targets();
  } catch (e) { toast(e.message, "err"); }
}

// ---------- Parser override template modal (merged into Targets page) ----------
let TEMPLATE_TARGET = null;

async function openTemplateModal(code) {
  TEMPLATE_TARGET = code;
  openModal({
    title: `解析模板覆盖 · ${code}`,
    subtitle: "低频兜底：只在该 target 解析失败时才需要覆盖默认选择器",
    body: `
      <div class="text-xs text-slate-500 mb-3">
        <code class="mono">parser_override_json</code> 为整体 JSON 对象；适配器读取 <code class="mono">detail</code> 键下的选择器表（CSS 选择器或 JS 表达式）。保存为 <code>{}</code> 可清除覆盖。
      </div>
      <textarea id="tpl-json" class="w-full h-[360px] mono text-xs border border-slate-300 rounded p-3" placeholder='{
  "detail": {
    "title": "h1.article-title",
    "time":  "span.time",
    "content": "div.article-content"
  }
}'>加载中…</textarea>`,
    footer: `
      <button onclick="closeModal()" class="btn-ghost">取消</button>
      <button onclick="validateTemplate()" class="btn-outline">校验 JSON</button>
      <button onclick="saveTemplate()" class="btn-success">保存</button>`,
    wide: true,
    onClose: () => { TEMPLATE_TARGET = null; },
  });
  // Async load override payload
  try {
    const r = await api(`/api/targets/${encodeURIComponent(code)}/parser`);
    document.getElementById("tpl-json").value = JSON.stringify(r.parser_override || {}, null, 2);
  } catch (e) {
    document.getElementById("tpl-json").value = "";
    toast(e.message, "err");
  }
}

function validateTemplate() {
  const ta = document.getElementById("tpl-json");
  try {
    const val = ta.value.trim() ? JSON.parse(ta.value) : {};
    ta.value = JSON.stringify(val, null, 2);
    toast("JSON 合法", "ok");
    return val;
  } catch (e) {
    toast("JSON 解析失败：" + e.message, "err");
    return null;
  }
}

async function saveTemplate() {
  if (!TEMPLATE_TARGET) return;
  const val = validateTemplate();
  if (val == null) return;
  try {
    await api(`/api/targets/${encodeURIComponent(TEMPLATE_TARGET)}/parser`, {
      method: "PUT",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify(val),
    });
    toast(`已保存 ${TEMPLATE_TARGET} 的模板覆盖`);
    closeModal();
  } catch (e) { toast(e.message, "err"); }
}

// ============================================================================
// Top-bar search: press Enter → jump to Articles with q preset
