// ============================================================================
// Nav definition — maps Pencil sidebar → page keys. `impl:true` pages call live
// APIs; others render a "后端待建" placeholder.
// ============================================================================
const NAV = [
  { key: "standardDownloads", label: "标准附件下载", icon: "\ue131", impl: true, roles: ["standard_downloader"] },
  { sec: "采集配置" },
  { key: "sites",     label: "站点管理",     icon: "\ue0eb", impl: true },  // globe
  { key: "targets",   label: "采集目标管理", icon: "\ue184", impl: true },  // target
  { sec: "组织与分类" },
  { key: "localdepts",  label: "OA 部门维护", icon: "\ue290", impl: true },  // building-2
  { key: "departments", label: "部门绑定对账", icon: "\ue109", impl: true },  // link-2
  { sec: "数据中心" },
  { key: "articles",    label: "文章列表", icon: "\ue0d0", impl: true },  // file-text
  { key: "attachments", label: "附件列表", icon: "\ue131", impl: true },  // paperclip
  { key: "ragpush",     label: "RAG推送日志", icon: "\ue465", impl: true },  // send
  { sec: "运行监控" },
  { key: "stats",       label: "统计分析", icon: "\ue3d4", impl: true },
  { key: "jobs",        label: "任务队列", icon: "\ue4c8", impl: true },  // list-todo
  { key: "logs",        label: "抓取日志", icon: "\ue464", impl: true },  // scroll-text
  { key: "alerts",      label: "告警中心", icon: "\ue193", impl: true },  // triangle-alert
  { sec: "扩展" },
  { key: "adapters",    label: "CMS 适配器", icon: "\ue383", impl: true },  // plug
  // { key: "registry",    label: "全局栏目目录", icon: "\ue106", impl: true },  // library
  // { key: "subscriptions", label: "栏目订阅页", icon: "\ue14e", impl: true },  // rss
  // { key: "onboarding",  label: "批量接入向导", icon: "\ue35b", impl: true },  // wand-sparkles
];

const PAGE_META = {
  dashboard:   ["工作台", "关键指标与 24 小时成功率"],
  standardDownloads: ["标准附件下载", "未下载附件的国家标准，输入验证码后下载到服务器"],
  targets:     ["采集目标管理", "按站点/部门查看 crawl_target，支持立即抓取与启停"],
  stats:       ["统计分析", "按站点鸟瞰 crawl_target，钻入查看 target 明细"],
  sites:       ["站点管理", "crawl_site 登记与启停（YAML 路径或 cms_adapter 绑定）"],
  articles:    ["文章列表", "按关键词/状态/target 搜索 article 表"],
  ragpush:     ["RAG推送日志", "按文章查看手动/自动推送、耗时、文件数与异常"],
  jobs:        ["任务队列", "按站点串行化的抓取任务队列，支持取消排队与请求终止"],
  logs:        ["抓取日志", "最近 crawl_log 条目（可过滤仅失败），失败可重试"],
  adapters:    ["CMS 适配器", "已注册的 adapter 模块 + 当前绑定站点数"],
  localdepts:  ["OA 部门维护", "清远市及县区政府组成部门/乡镇主数据，支持增删改查"],
  departments: ["部门绑定对账", "站点部门路径 ⇄ OA 本地部门 的绑定对账台账"],
  attachments: ["附件列表", "article.attachment 最近下载的文件"],
  alerts:      ["告警中心", "R1/R2/R3 规则检测结果（实时计算）"],
  registry:    ["全局栏目目录", "按 adapter / 站点 / 主题查看 master_column_registry"],
  subscriptions: ["栏目订阅页", "查看已订阅栏目与 target 的关联关系"],
  onboarding:  ["批量接入向导", "从全局栏目目录批量生成 crawl_target"],
};

// Render sidebar
function renderNav() {
  const nav = document.getElementById("nav");
  nav.innerHTML = NAV.filter(_navVisible).map(n => {
    if (n.sec) return `<div class="nav-sec">${n.sec}</div>`;
    const cls = "nav-item" + (n.impl ? "" : " disabled");
    const icon = n.icon ? `<span class="lu">${n.icon}</span>` : "";
    const pill = n.impl ? "" : `<span class="pill pill-slate ml-auto" style="font-size:10px">待建</span>`;
    return `<div class="${cls}" data-key="${n.key}" onclick="go('${n.key}')">${icon}<span>${n.label}</span>${pill}</div>`;
  }).join("");
}

function setActive(key) {
  document.querySelectorAll(".nav-item").forEach(el => el.classList.toggle("active", el.dataset.key === key));
}

// ============================================================================
// Router
// ============================================================================
let CURRENT = null;
let OPEN_TABS = [];
let REGISTRY_SELECTION = new Set();
let CURRENT_USER = { role: "admin", user: null, standard_downloader: false };

async function loadCurrentUser() {
  try {
    CURRENT_USER = await api("/api/me");
  } catch {
    CURRENT_USER = { role: "admin", user: null, standard_downloader: false };
  }
}

function _navVisible(n) {
  if (!n.roles || !n.roles.length) return !CURRENT_USER.standard_downloader;
  return n.roles.includes(CURRENT_USER.role);
}

function loadRegistrySelection() {
  try {
    const raw = localStorage.getItem("registrySelection");
    REGISTRY_SELECTION = new Set(JSON.parse(raw || "[]"));
  } catch {
    REGISTRY_SELECTION = new Set();
  }
}

function saveRegistrySelection() {
  localStorage.setItem("registrySelection", JSON.stringify(Array.from(REGISTRY_SELECTION)));
}

function renderTabs() {
  const host = document.getElementById("tabs-strip");
  host.innerHTML = OPEN_TABS.map(key => {
    const meta = PAGE_META[key] || [key, ""];
    const closable = key !== "dashboard";
    return `
      <div class="worktab ${key === CURRENT ? "active" : ""}" onclick="activateTab('${key}')">
        <span>${esc(meta[0])}</span>
        ${closable ? `<span class="worktab-close" onclick="closeTab(event, '${key}')">✕</span>` : ""}
      </div>`;
  }).join("");
}

function activateTab(key) {
  if (!OPEN_TABS.includes(key)) OPEN_TABS.push(key);
  CURRENT = key;
  renderTabs();
  setActive(key);
  history.replaceState(null, "", "#" + key);
  PAGES[key]();
}

function closeTab(evt, key) {
  evt.stopPropagation();
  OPEN_TABS = OPEN_TABS.filter(k => k !== key);
  if (CURRENT === key) {
    CURRENT = OPEN_TABS[OPEN_TABS.length - 1] || "dashboard";
  }
  if (!OPEN_TABS.includes("dashboard")) OPEN_TABS.unshift("dashboard");
  renderTabs();
  activateTab(CURRENT);
}

function go(key) {
  // "dashboard" is a pinned tab (not in sidebar), so let it through
  const defaultKey = CURRENT_USER.standard_downloader ? "standardDownloads" : "dashboard";
  if (key === "dashboard" && CURRENT_USER.standard_downloader) key = defaultKey;
  if (key !== "dashboard") {
    const page = NAV.find(n => n.key === key);
    if (!page || !_navVisible(page)) return;
    if (!OPEN_TABS.includes(key)) OPEN_TABS.push(key);
    if (!page.impl) {
      renderPlaceholder(key, page.label);
      setActive(key);
      CURRENT = key;
      renderTabs();
      history.replaceState(null, "", "#" + key);
      return;
    }
  }
  activateTab(key);
}

function refreshCurrent() { if (CURRENT) go(CURRENT); }

// ============================================================================
// API helper
// ============================================================================
async function api(path, opts) {
  const r = await fetch("/admin" + path, opts);
  if (!r.ok) {
    const body = await r.text().catch(() => "");
    throw new Error(`${r.status} ${r.statusText} — ${body.slice(0,120)}`);
  }
  return r.json();
}

function toast(msg, kind = "ok") {
  const el = document.getElementById("toast");
  el.className = "toast " + kind;
  el.textContent = msg;
  el.classList.remove("hidden");
  setTimeout(() => el.classList.add("hidden"), 2600);
}

// ============================================================================
// Modal primitive (bQg74 / isCUn style)
// ============================================================================
let MODAL_OPEN = false;
function openModal({ title, subtitle = "", body, footer = "", wide = false, onClose }) {
  const root = document.getElementById("modal-root");
  root.innerHTML = `
    <div class="modal-backdrop" id="modal-backdrop">
      <div class="modal-dialog ${wide ? "wide" : ""}" onclick="event.stopPropagation()">
        <div class="modal-header">
          <div>
            <div class="modal-title">${esc(title)}</div>
            ${subtitle ? `<div class="modal-subtitle">${esc(subtitle)}</div>` : ""}
          </div>
          <div class="modal-close" onclick="closeModal()">✕</div>
        </div>
        <div class="modal-body">${body}</div>
        ${footer ? `<div class="modal-footer">${footer}</div>` : ""}
      </div>
    </div>`;
  MODAL_OPEN = true;
  MODAL_ON_CLOSE = onClose || null;
  document.getElementById("modal-backdrop").addEventListener("click", closeModal);
}
let MODAL_ON_CLOSE = null;
function closeModal() {
  if (!MODAL_OPEN) return;
  document.getElementById("modal-root").innerHTML = "";
  MODAL_OPEN = false;
  const cb = MODAL_ON_CLOSE; MODAL_ON_CLOSE = null;
  if (cb) try { cb(); } catch {}
}
document.addEventListener("keydown", (e) => { if (e.key === "Escape" && MODAL_OPEN) closeModal(); });

// ============================================================================
// Utilities
// ============================================================================
// Admin APIs return timestamp ISO strings with explicit offsets. Render every
// timestamp in the same business timezone so browser/OS timezone differences do
// not make article lists and job logs disagree. Date-only strings are shown as
// dates because fields such as publish_date/effective_date are not instants.
const fmt = s => {
  if (!s) return "—";
  const raw = String(s);
  if (/^\d{4}-\d{2}-\d{2}$/.test(raw)) return raw;
  const d = new Date(raw);
  if (Number.isNaN(d.getTime())) return raw;
  return d.toLocaleString("zh-CN", {
    hour12: false,
    timeZone: "Asia/Shanghai",
  });
};
const pct = x => x == null ? "—" : (x * 100).toFixed(1) + "%";
const esc = s => (s == null ? "" : String(s).replace(/[&<>"']/g, c => ({ "&":"&amp;","<":"&lt;",">":"&gt;","\"":"&quot;","'":"&#39;" })[c]));
const pill = (text, kind) => `<span class="pill pill-${kind}">${esc(text)}</span>`;

function card({ title, actions = "", body }) {
  return `<section class="card p-0 overflow-hidden">
    ${title ? `<header class="px-5 py-3 border-b border-[#E5EBF1] flex items-center justify-between">
      <h2 class="text-sm font-semibold text-slate-700">${esc(title)}</h2>
      <div class="flex gap-2">${actions}</div>
    </header>` : ""}
    <div>${body}</div>
  </section>`;
}

// Stat card (label + big value + optional pill + small sub-text). Promoted
// from dashboard.js to core because the 统计分析 page reuses it. Signature
// kept compatible: statCard(label, value, tag, tagKind, sub).
function statCard(label, value, tag, tagKind, sub) {
  return `<div class="card p-[18px] flex-1 flex flex-col gap-2.5">
    <div class="text-[13px] text-slate-500">${esc(label)}</div>
    <div class="flex items-center justify-between">
      <div class="text-[32px] font-semibold text-slate-900 leading-none">${esc(value)}</div>
      ${tag ? `<span class="pill pill-${tagKind}">${esc(tag)}</span>` : ""}
    </div>
    <div class="text-xs text-slate-400">${esc(sub)}</div>
  </div>`;
}

// Page banner (pencil pageHead) — title + subtitle + right-side actions
function pageBanner({ title, subtitle = "", actions = "" }) {
  return `<div class="page-banner">
    <div>
      <div class="page-banner-title">${esc(title)}</div>
      ${subtitle ? `<div class="page-banner-sub">${esc(subtitle)}</div>` : ""}
    </div>
    ${actions ? `<div class="page-banner-actions">${actions}</div>` : ""}
  </div>`;
}

// Client-side pager (pencil tfoot / pager). Renders into container, calls onPage(p).
function renderPager({ container, total, page, pageSize, summary = "", onPage }) {
  const totalPages = Math.max(1, Math.ceil(total / pageSize));
  page = Math.min(Math.max(1, page), totalPages);
  const btn = (label, p, { active = false, disabled = false } = {}) =>
    `<button ${disabled ? "disabled" : ""} class="${active ? "active" : ""}" data-p="${p}">${label}</button>`;
  // Generate at most 7 slots with ellipsis
  const pages = [];
  const push = p => pages.push(btn(p, p, { active: p === page }));
  if (totalPages <= 7) {
    for (let i = 1; i <= totalPages; i++) push(i);
  } else {
    push(1);
    let s = Math.max(2, page - 2), e = Math.min(totalPages - 1, page + 2);
    if (s > 2) pages.push(`<span class="text-slate-400 text-xs px-1">…</span>`);
    for (let i = s; i <= e; i++) push(i);
    if (e < totalPages - 1) pages.push(`<span class="text-slate-400 text-xs px-1">…</span>`);
    push(totalPages);
  }
  // Jump-to-page UI: number input + 跳转 button. Only meaningful when
  // there are at least 2 pages. Enter or click triggers onPage(N) bounded
  // to [1, totalPages]. Out-of-range value falls back to nearest end.
  const jumpInput = totalPages > 1
    ? `<span class="ml-2 inline-flex items-center gap-1 text-xs text-slate-500">
         跳到第
         <input type="number" min="1" max="${totalPages}" value="${page}"
                class="w-14 px-1.5 py-0.5 border border-slate-200 rounded text-xs text-center"
                data-pager-jump />
         页
         <button class="px-2 py-0.5 border border-slate-200 rounded text-xs hover:bg-slate-50"
                 data-pager-jump-btn>跳转</button>
       </span>` : "";

  container.innerHTML = `
    <div class="tfoot">
      <div class="text-xs text-slate-500">${esc(summary || `共 ${total} 条 · 第 ${page} / ${totalPages} 页`)}</div>
      <div class="pager flex items-center">
        ${btn("‹", page - 1, { disabled: page <= 1 })}
        ${pages.join("")}
        ${btn("›", page + 1, { disabled: page >= totalPages })}
        ${jumpInput}
      </div>
    </div>`;
  container.querySelectorAll(".pager button[data-p]").forEach(b => {
    b.onclick = () => { const p = Number(b.dataset.p); if (p >= 1 && p <= totalPages) onPage(p); };
  });
  const jin = container.querySelector("[data-pager-jump]");
  const jbtn = container.querySelector("[data-pager-jump-btn]");
  if (jin && jbtn) {
    const fire = () => {
      let p = Number(jin.value);
      if (!Number.isFinite(p)) return;
      p = Math.min(Math.max(1, Math.floor(p)), totalPages);
      onPage(p);
    };
    jbtn.onclick = fire;
    jin.onkeydown = (e) => {
      if (e.key === "Enter") { e.preventDefault(); fire(); }
    };
  }
}

// ---------- Dept picker (grouped <optgroup> + live search filter) ----------
//
// The OA local_department table has hundreds of rows (清远市 + 八县区 + 虚拟参考
// 节点) which makes a flat <select> unusable. groupDepts() buckets rows by the
// "市/县/区" prefix of full_name; renderDeptSelect() wires a search input that
// live-filters the wrapped <select>'s options by keyword.

// Regions ordered 省 → 一市 → 八县区（省内部门 / 市内部门 / 县区在尾部）
const DEPT_REGIONS = ["中国", "广东省", "清远市", "清城区", "清新区", "英德市", "连州市", "佛冈县", "阳山县", "连南瑶族自治县", "连山壮族瑶族自治县"];

// Fallback: dept_id ranges → region (derived from OA data). Used when
// full_name/dept_name has no region prefix (typically 镇街 rows, e.g. 青莲镇).
// Keyed by `Math.floor(dept_id / 100000)`.
const DEPT_ID_REGION = {
  0: "清远市",
  1: "英德市",
  2: "清城区",
  3: "清新区",
  4: "阳山县",
  5: "佛冈县",
  6: "连州市",
  7: "连山壮族瑶族自治县",
  8: "连南瑶族自治县",
  9: "连山壮族瑶族自治县",
  10: "连州市",
};

// Region comes from the DB column local_department.region (seeded from the
// 清远市及县区政府组成部门和乡镇/*.xlsx files). If a row is missing region
// (e.g. a hand-added dept that wasn't backfilled yet), fall back to
// name/id-range heuristics so it still groups somewhere sensible.
async function loadDeptRegionMap() { /* no-op now; region is embedded in each dept payload */ }
function _deptRegion(d) {
  if (d.region) return d.region;
  const name = `${d.full_name || ""} ${d.dept_name || ""}`;
  for (const r of DEPT_REGIONS) {
    if (name.includes(r) || name.includes(r.replace("自治县", "").replace("壮族瑶族", "").replace("瑶族", ""))) return r;
  }
  const bucket = Math.floor((d.dept_id || 0) / 100000);
  return DEPT_ID_REGION[bucket] || null;
}

function groupDepts(depts) {
  const groups = new Map();
  DEPT_REGIONS.forEach(r => groups.set(r, []));
  groups.set("其他/虚拟参考", []);
  for (const d of depts || []) {
    const region = _deptRegion(d) || "其他/虚拟参考";
    groups.get(region).push(d);
  }
  // Drop empty groups to keep the menu compact
  for (const [k, arr] of groups) if (!arr.length) groups.delete(k);
  return groups;
}

// Compact popover dept picker: a hidden <select> holds the value; the visible
// UI is a single trigger row showing the current selection. Click the row (or
// type in the search) to open the panel; click outside or pick an item closes
// it. Saves a lot of vertical space — the previous always-open tree was 200+
// px tall by default.
function renderDeptSelect({ selectId, placeholder = "— 不绑定部门 —" }) {
  return `
    <select id="${selectId}" class="dept-hidden"><option value="">${esc(placeholder)}</option></select>
    <div class="dept-tree" id="${selectId}-wrap" data-select-id="${selectId}">
      <div class="dept-tree-current text-slate-400" id="${selectId}-current"
           onclick="_toggleDeptPanel('${selectId}', event)" role="button" tabindex="0">
        <span class="flex-1 truncate">${esc(placeholder)}</span>
        <span class="text-slate-400 text-[11px]" id="${selectId}-caret">▾</span>
        <span class="clear" onclick="event.stopPropagation();_clearDeptPick('${selectId}')" title="清除选择">清除</span>
      </div>
      <div class="dept-tree-panel" id="${selectId}-panel" hidden>
        <input class="dept-tree-search" placeholder="搜索部门（ID / 名称）…"
               onfocus="_openDeptPanel('${selectId}')"
               oninput="_filterDeptTree('${selectId}', this.value)"/>
        <div id="${selectId}-groups" class="dept-tree-groups"></div>
      </div>
    </div>`;
}

function _openDeptPanel(selectId) {
  const panel = document.getElementById(selectId + "-panel");
  const caret = document.getElementById(selectId + "-caret");
  const trigger = document.getElementById(selectId + "-current");
  if (panel && trigger) {
    // Anchor to the trigger using fixed coords so the panel escapes any
    // ancestor overflow:hidden / overflow-y:auto (e.g. inside a modal body).
    const r = trigger.getBoundingClientRect();
    const PANEL_HEIGHT = 320;       // ~max possible panel height (roughly)
    const margin = 6;
    const spaceBelow = window.innerHeight - r.bottom;
    // Drop below if there's room, else flip up.
    if (spaceBelow >= PANEL_HEIGHT + margin || spaceBelow >= r.top) {
      panel.style.top = (r.bottom + margin) + "px";
      panel.style.maxHeight = Math.max(180, spaceBelow - margin - 8) + "px";
    } else {
      panel.style.top = "";
      panel.style.bottom = (window.innerHeight - r.top + margin) + "px";
      panel.style.maxHeight = Math.max(180, r.top - margin - 8) + "px";
    }
    panel.style.left = r.left + "px";
    panel.style.width = r.width + "px";
    panel.hidden = false;
  }
  if (caret) caret.textContent = "▴";
}
function _closeDeptPanel(selectId) {
  const panel = document.getElementById(selectId + "-panel");
  const caret = document.getElementById(selectId + "-caret");
  if (panel) {
    panel.hidden = true;
    // clear inline coords so next open repositions cleanly
    panel.style.top = panel.style.bottom = panel.style.left = "";
    panel.style.width = panel.style.maxHeight = "";
  }
  if (caret) caret.textContent = "▾";
}

// Close any open dept panels when an ANCESTOR scrolls — fixed positioning
// otherwise leaves them stuck while the trigger row scrolls away. We must
// NOT close when the scroll happens INSIDE the panel itself (the dept list
// is scrollable), otherwise mouse-wheel inside the panel kills it.
if (!window.__deptPanelScrollListener) {
  window.__deptPanelScrollListener = true;
  window.addEventListener("scroll", (e) => {
    document.querySelectorAll(".dept-tree[data-select-id]").forEach(wrap => {
      const panel = document.getElementById(wrap.dataset.selectId + "-panel");
      // Ignore scrolls originating inside the panel — that's the user
      // scrolling the dept list, not an ancestor moving under us.
      if (panel && !panel.hidden && e.target instanceof Node && panel.contains(e.target)) return;
      _closeDeptPanel(wrap.dataset.selectId);
    });
  }, true);  // capture so nested scrollables (modal-body) are caught too
}
function _toggleDeptPanel(selectId, evt) {
  const panel = document.getElementById(selectId + "-panel");
  if (!panel) return;
  if (panel.hidden) _openDeptPanel(selectId); else _closeDeptPanel(selectId);
  if (evt) evt.stopPropagation();
}
// Single document listener auto-closes any open dept panels when clicking
// outside the wrapper. Idempotent — multiple renderDeptSelect() calls don't
// stack listeners.
if (!window.__deptPanelOutsideListener) {
  window.__deptPanelOutsideListener = true;
  document.addEventListener("click", e => {
    document.querySelectorAll(".dept-tree[data-select-id]").forEach(wrap => {
      if (!wrap.contains(e.target)) _closeDeptPanel(wrap.dataset.selectId);
    });
  });
}

function populateDeptSelect(selectId, depts, selected = "") {
  const hidden = document.getElementById(selectId);
  if (!hidden) return;
  const keep = hidden.querySelector('option[value=""]')?.outerHTML || "";
  hidden.innerHTML = keep + (depts || []).map(
    d => `<option value="${d.dept_id}">${esc(d.full_name || d.dept_name || "")}</option>`
  ).join("");
  hidden.value = selected ? String(selected) : "";

  const groups = groupDepts(depts);
  const html = [];
  for (const [region, arr] of groups) {
    html.push(`<details><summary>${esc(region)} (${arr.length})</summary>`);
    for (const d of arr) {
      const name = d.full_name || d.dept_name || "";
      const isSel = String(selected || "") === String(d.dept_id);
      const k = (name + " " + d.dept_id).toLowerCase();
      html.push(`<div class="dept-tree-item ${isSel ? "selected" : ""}" data-id="${d.dept_id}" data-k="${esc(k)}" data-name="${esc(name)}" onclick="_pickDeptTreeItem('${selectId}', ${d.dept_id}, this)">${esc(d.dept_id)} · ${esc(name)}</div>`);
    }
    html.push(`</details>`);
  }
  const groupsEl = document.getElementById(selectId + "-groups");
  if (groupsEl) groupsEl.innerHTML = html.join("");

  // Update the "current selection" label
  if (selected) {
    const d = (depts || []).find(x => String(x.dept_id) === String(selected));
    if (d) _setDeptCurrent(selectId, d.dept_id, d.full_name || d.dept_name || "");
  }
}

function _pickDeptTreeItem(selectId, deptId, el) {
  const hidden = document.getElementById(selectId);
  if (hidden) hidden.value = String(deptId);
  _setDeptCurrent(selectId, deptId, el.dataset.name || "");
  const groups = document.getElementById(selectId + "-groups");
  if (groups) {
    groups.querySelectorAll('.dept-tree-item.selected').forEach(e => e.classList.remove('selected'));
    el.classList.add('selected');
  }
  // Close the panel after a pick — feels like a normal <select>
  _closeDeptPanel(selectId);
}

function _setDeptCurrent(selectId, deptId, name) {
  const c = document.getElementById(selectId + "-current");
  if (!c) return;
  const label = c.querySelector("span:not(.clear)");
  if (label) label.textContent = `✓ ${deptId} · ${name}`;
  c.classList.remove("text-slate-400");
  c.classList.add("text-slate-800");
}

function _clearDeptPick(selectId) {
  const hidden = document.getElementById(selectId);
  if (hidden) hidden.value = "";
  const c = document.getElementById(selectId + "-current");
  if (c) {
    const label = c.querySelector("span:not(.clear)");
    if (label) label.textContent = "— 不绑定部门 —";
    c.classList.remove("text-slate-800");
    c.classList.add("text-slate-400");
  }
  const groups = document.getElementById(selectId + "-groups");
  if (groups) groups.querySelectorAll('.dept-tree-item.selected').forEach(e => e.classList.remove('selected'));
}

function _filterDeptTree(selectId, q) {
  const groups = document.getElementById(selectId + "-groups");
  if (!groups) return;
  const needle = (q || "").trim().toLowerCase();
  groups.querySelectorAll('details').forEach(det => {
    let shown = 0;
    det.querySelectorAll('.dept-tree-item').forEach(it => {
      const match = !needle || (it.dataset.k || "").includes(needle);
      it.hidden = !match;
      if (match) shown++;
    });
    det.hidden = shown === 0;
    // Auto-expand matches while searching; collapse when search is empty
    det.open = needle ? shown > 0 : false;
  });
}

// Back-compat alias for older call sites
const _filterDeptSelect = _filterDeptTree;

function renderPlaceholder(key, label) {
  document.getElementById("workspace").innerHTML = card({
    title: label,
    body: `<div class="p-10 text-center text-slate-500">
      <div class="text-4xl mb-3">🚧</div>
      <div class="font-medium">此页面后端接口尚未就绪</div>
      <div class="text-xs mt-2 text-slate-400 mono">/admin/api/${key} · 将在后续里程碑落地</div>
      <div class="text-xs mt-6 text-slate-400">设计见 design.pen — <code>${esc(key)}</code></div>
    </div>`
  });
}

// ============================================================================
// Pages
// ============================================================================
const PAGES = {};
