2026-06-18-partA2-query-builder-frontend.md 20 KB

Part A2:query-builder 前端弹层 Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 在工作台顶栏「新建搜索」旁加「Query 规则」按钮,打开弹层:上半用维度 chips 设上下文(工具类型/模态/后缀 扁平单选 + 实质/形式 任意深度下钻),下半渲染 动作×类型 整张 27×50 正交表(tier 底色),点「评估高亮」用 Sonnet 给 tier≥1 格打分并高亮 keep 格,点单格或「搜全部达标」直接发起搜索。

Architecture: 纯前端,全部加进单文件 index.html。复用现有 helper:$("#id")api(path,opt)toast()showTask(title,taskId,onDone)(轮询 /api/task_status,完成回调)。后端接口(A1 已就绪):GET /api/query_matrixGET /api/category_tree?source_type=POST /api/query_score(返回 {sel,task_id,cached})、GET /api/query_score?sel=(结果或 202)、POST /api/run_search(复用,默认 xhs,gzh×20、不传 mode_type)。

Tech Stack: 原生 JS/HTML/CSS(同 index.html 现状,无框架)。无自动化前端测试——验证 = node --check(抽出的 script 语法)/ 浏览器手测(用户)。

参考 spec:docs/superpowers/specs/2026-06-18-query-builder-design.md(Part A 前端、评分 Prompt、Image #1/#2 视觉)。

重要约定(已与用户确认): 动作/类型 只作正交表两轴,不做成 chips。chips 仅 5 个维度:工具类型/模态/后缀(扁平单选)、实质/形式(下钻)。

工作目录命令在 examples/mode_workflow/,提交在仓库根,分支 dev-lxn


实现前必读(现有模式,照抄风格)

实现者先在 index.html 里读这些锚点,照其风格写新代码:

  • const api = (p, opt) => (~2437):await api("/api/x") GET;await api("/api/x",{method:"POST",body:JSON.stringify(b)})。失败抛异常,e.body?.error/e.status 可取。
  • function toast(msg, type, ms) (~2422):toast("...", "warn"|"error"|"info")
  • function showTask(title, taskId, onDone) (~3581):弹任务面板 + 轮询 /api/task_statusdone 时调 onDone()
  • $("#btn-new-search").onclick / $("#s-go").onclick (~3659):新建搜索 modal 开关 + 组 body + POST /api/run_search + showTask镜像它
  • #search-modal 的 HTML(grep -n 'id="search-modal"' index.html,~2347):modal 结构/类(.modal-bg/.modal)参照它。
  • 事件绑定集中在底部 <script> 的初始化区($("#m-process").onclick = ... 一带,~3699)。新绑定加这里。

Task 1: 顶栏按钮 + 弹层骨架 + 维度常量

Files:

  • Modify: examples/mode_workflow/index.html

  • [ ] Step 1: 顶栏加按钮

<button class="btn seal" id="btn-new-search"> + 新建搜索 </button> 之后紧挨着加:

        <button class="btn" id="btn-query-rule" style="margin-left:8px">Query 规则</button>
  • Step 2: 加弹层 HTML(放在 #search-modal 那段 modal 之后)
    <!-- Query 规则组织器 弹层 -->
    <div class="modal-bg" id="qr-modal" hidden>
      <div class="modal" style="max-width:1180px;width:96vw;max-height:92vh;display:flex;flex-direction:column">
        <h2>Query 词组织器</h2>
        <div id="qr-dims" style="margin:6px 0 10px"></div>
        <div style="display:flex;gap:8px;align-items:center;margin:6px 0 10px">
          <button class="btn seal" id="qr-score">生成正交表 &amp; 评估高亮</button>
          <button class="btn" id="qr-search-all" hidden>搜全部达标</button>
          <span id="qr-hint" style="color:#888;font-size:12px"></span>
          <div style="flex:1"></div>
          <button class="btn" id="qr-close">关闭</button>
        </div>
        <div id="qr-table-wrap" style="overflow:auto;flex:1;border:1px solid #eee;border-radius:8px"></div>
      </div>
    </div>
  • Step 3: 加维度常量 + 状态(放在底部 <script> 内、初始化区之上)
      /* ════ Query 规则组织器 ════ */
      // 扁平维度词表(移植自 query-builder.html DIMS);实质/形式 改为接口下钻,不在此。
      const QR_FLAT = [
        { id: "tool_type", label: "工具类型", items: ["AI", "桌面 APP", "云端 Web", "API·CLI", "插件扩展"] },
        { id: "modality", label: "模态", items: ["图片", "视频"] },
        { id: "suffix", label: "后缀", items: ["怎么做"] },
      ];
      const QR_TREE = [
        { id: "substance", label: "实质", source: "实质" },
        { id: "form", label: "形式", source: "形式" },
      ];
      const qrState = {
        flat: { tool_type: null, modality: null, suffix: null },
        treePath: { substance: [], form: [] },   // 选中节点的祖先+自身 name 路径
        treeData: { substance: null, form: null }, // 接口返回的 {tree:[...]}
        matrix: null,                              // /api/query_matrix
        scores: null,                              // 最近一次评分结果 cells
      };
  • Step 4: 开关弹层绑定(初始化区,照 search-modal 写法)
      $("#btn-query-rule").onclick = () => { $("#qr-modal").hidden = false; qrOpen(); };
      $("#qr-close").onclick = () => { $("#qr-modal").hidden = true; };
      $("#qr-modal").onclick = (e) => { if (e.target === $("#qr-modal")) $("#qr-modal").hidden = true; };
  • Step 5: 提交
cd /Users/max_liu/max_liu/company/Agent
git add examples/mode_workflow/index.html
git commit -m "feat(mode_workflow): Query 规则 顶栏按钮 + 弹层骨架 + 维度常量"

Task 2: 维度 chips(扁平单选 + 实质/形式 下钻)

Files:

  • Modify: examples/mode_workflow/index.html

  • [ ] Step 1: 加 chips 渲染 + 打开时拉数据

在 Query 规则 script 区加:

      function qrChip(label, active, on) {
        const b = document.createElement("button");
        b.className = "chip" + (active ? " on" : "");
        b.textContent = label;
        b.onclick = on;
        return b;
      }
      function renderQrDims() {
        const root = $("#qr-dims");
        root.innerHTML = "";
        // 扁平维度
        QR_FLAT.forEach((d) => {
          const row = document.createElement("div");
          row.className = "qr-row";
          row.innerHTML = `<span class="qr-lab">· ${d.label}</span>`;
          const wrap = document.createElement("span");
          wrap.className = "qr-chips";
          wrap.appendChild(qrChip("无", qrState.flat[d.id] == null, () => { qrState.flat[d.id] = null; renderQrDims(); }));
          d.items.forEach((it) =>
            wrap.appendChild(qrChip(it, qrState.flat[d.id] === it, () => { qrState.flat[d.id] = it; renderQrDims(); })));
          row.appendChild(wrap);
          root.appendChild(row);
        });
        // 实质/形式 下钻:每一层一行,展示当前层可选项;选中后再展开下一层
        QR_TREE.forEach((d) => {
          const data = qrState.treeData[d.id];
          const path = qrState.treePath[d.id];
          // 沿 path 定位到当前层的 children 列表(逐层)
          let level = data ? data.tree : [];
          const rows = [];
          for (let depth = 0; ; depth++) {
            const labtxt = depth === 0 ? `· ${d.label}` : "";
            const row = document.createElement("div");
            row.className = "qr-row";
            row.innerHTML = `<span class="qr-lab">${labtxt}</span>`;
            const wrap = document.createElement("span");
            wrap.className = "qr-chips";
            wrap.appendChild(qrChip("无", path.length === depth, () => { qrState.treePath[d.id] = path.slice(0, depth); renderQrDims(); }));
            (level || []).forEach((node) =>
              wrap.appendChild(qrChip(node.name, path[depth] === node.name,
                () => { qrState.treePath[d.id] = [...path.slice(0, depth), node.name]; renderQrDims(); })));
            row.appendChild(wrap);
            rows.push(row);
            // 进入已选中节点的下一层
            const sel = (level || []).find((n) => n.name === path[depth]);
            if (!sel || !(sel.children && sel.children.length)) break;
            level = sel.children;
          }
          rows.forEach((r) => root.appendChild(r));
        });
      }
      async function qrOpen() {
        renderQrDims();
        // 并行拉矩阵 + 两棵分类树(接口挂则该维度降级:提示不可选)
        try { qrState.matrix = qrState.matrix || await api("/api/query_matrix"); }
        catch (e) { return toast("内容树矩阵加载失败", "error"); }
        for (const d of QR_TREE) {
          if (qrState.treeData[d.id]) continue;
          try { qrState.treeData[d.id] = await api("/api/category_tree?source_type=" + encodeURIComponent(d.source)); }
          catch (e) { toast(`${d.label} 接口不可达,该维度暂不可选`, "warn"); }
        }
        renderQrDims();
        renderQrTable();
      }
  • Step 2: 加 chips/行的样式(放在页面 <style> 内,紧凑即可)
      .qr-row { display:flex; align-items:flex-start; gap:10px; padding:5px 0; border-bottom:1px solid #f4f4f4; }
      .qr-lab { min-width:64px; color:#999; font-size:12.5px; padding-top:6px; flex-shrink:0; }
      .qr-chips { display:flex; flex-wrap:wrap; gap:7px; flex:1; }
      .chip { padding:4px 13px; border-radius:18px; font-size:12.5px; cursor:pointer; border:1.5px solid #e2e2e2; background:#fff; color:#666; }
      .chip:hover { border-color:#bbb; color:#333; }
      .chip.on { background:#1a1a1a; border-color:#1a1a1a; color:#fff; }

.chip 类已存在(被别处用),复用即可、勿重复定义;只补 .qr-row/.qr-lab/.qr-chips

  • Step 3: 浏览器手测占位(语法检查)

Run(抽出 <script> 做 JS 语法检查,需 node):

cd /Users/max_liu/max_liu/company/Agent/examples/mode_workflow
python3 - <<'PY'
import re, pathlib, subprocess, tempfile, os
html = pathlib.Path("index.html").read_text(encoding="utf-8")
scripts = re.findall(r"<script>(.*?)</script>", html, re.S)
src = "\n".join(scripts)
f = tempfile.NamedTemporaryFile("w", suffix=".js", delete=False, encoding="utf-8"); f.write(src); f.close()
r = subprocess.run(["node", "--check", f.name], capture_output=True, text=True)
os.unlink(f.name)
print("✔ JS 语法 OK" if r.returncode == 0 else "✗ 语法错:\n" + r.stderr)
PY

Expected: ✔ JS 语法 OK(若无 node 可跳过,记为手测项)。

  • Step 4: 提交
cd /Users/max_liu/max_liu/company/Agent
git add examples/mode_workflow/index.html
git commit -m "feat(mode_workflow): Query 规则 维度 chips(扁平单选 + 实质/形式 接口下钻)"

Task 3: 动作×类型 正交表渲染

Files:

  • Modify: examples/mode_workflow/index.html

  • [ ] Step 1: 加拼词 + 建表函数

      // 单格 query = [工具类型] 动作叶 类型叶 [模态] [后缀](与后端 query_score._build_cells 一致)
      function qrCellQuery(action, type) {
        const f = qrState.flat;
        return [f.tool_type, action, type, f.modality, f.suffix].filter((x) => x && x !== "无").join(" ");
      }
      function renderQrTable() {
        const m = qrState.matrix;
        if (!m) return;
        const { actions, types, matrix } = m;
        const sc = qrState.scores;   // {"ai_ti":{keep,score,rewrite,...}}
        // 表头:动作按 l1 分组(参考 Image #2,列头两行:l1 跨列 + 叶子)
        let html = '<table class="qr-tab"><thead>';
        // l1 行
        html += '<tr><th class="qr-corner" rowspan="2">类型 \\ 动作</th>';
        const groups = [];
        actions.forEach((a, ai) => {
          const last = groups[groups.length - 1];
          if (last && last.l1 === a.l1) last.span++;
          else groups.push({ l1: a.l1, span: 1, start: ai });
        });
        groups.forEach((g) => { html += `<th colspan="${g.span}" class="qr-gh">${g.l1}</th>`; });
        html += "</tr><tr>";
        actions.forEach((a) => { html += `<th class="qr-ah">${a.name}</th>`; });
        html += "</tr></thead><tbody>";
        // 行:类型按 l1 分组,组首插一行组标题
        let curL1 = null;
        types.forEach((t, ti) => {
          if (t.l1 !== curL1) {
            curL1 = t.l1;
            html += `<tr><td class="qr-tl1" colspan="${actions.length + 1}">${t.l1}</td></tr>`;
          }
          html += `<tr><th class="qr-th">${t.name}</th>`;
          actions.forEach((a, ai) => {
            const cell = matrix[ai][ti] || {};
            const tier = cell.tier || 0;
            const q = qrCellQuery(a.name, t.name);
            const v = sc ? sc[`${ai}_${ti}`] : null;
            const keep = v && v.keep;
            const cls = `qr-c t${tier}` + (tier === 0 ? " dead" : "") + (keep ? " keep" : "");
            const badge = keep && v.score != null ? `<i class="qr-bd">${v.score}</i>` : "";
            const title = v ? `${v.reason || ""} (n${v.natural}/f${v.findable}/u${v.useful})` : `tier ${tier}`;
            html += `<td class="${cls}" data-ai="${ai}" data-ti="${ti}" title="${title.replace(/"/g, "&quot;")}">`
                  + `${q}${badge}</td>`;
          });
          html += "</tr>";
        });
        html += "</tbody></table>";
        $("#qr-table-wrap").innerHTML = html;
      }
  • Step 2: 加表格样式
      .qr-tab { border-collapse:collapse; font-size:11px; }
      .qr-tab th, .qr-tab td { border:1px solid #eee; padding:3px 6px; white-space:nowrap; }
      .qr-tab thead th { position:sticky; top:0; background:#f7f7f7; z-index:2; }
      .qr-corner { position:sticky; left:0; z-index:3; background:#f0f0f0; }
      .qr-th { position:sticky; left:0; background:#fafafa; z-index:1; text-align:left; }
      .qr-tl1 { background:#f3f4f6; font-weight:600; color:#444; }
      .qr-c.t0 { background:#fafafa; color:#bbb; } .qr-c.t1 { background:#f1f8f1; }
      .qr-c.t2 { background:#dcecdc; } .qr-c.t3 { background:#bfe0bf; }
      .qr-c.dead { color:#ccc; } .qr-c.keep { outline:2px solid #2563eb; outline-offset:-2px; cursor:pointer; font-weight:600; }
      .qr-bd { display:inline-block; margin-left:4px; font-style:normal; font-size:9px; color:#2563eb; }
  • Step 3: 语法检查 + 提交

Run: 重跑 Task 2 Step 3 的 node --check 片段 → ✔ JS 语法 OK

cd /Users/max_liu/max_liu/company/Agent
git add examples/mode_workflow/index.html
git commit -m "feat(mode_workflow): Query 规则 动作×类型 27×50 正交表渲染(tier 底色)"

Task 4: 评估高亮(POST query_score + 轮询 + 渲染)

Files:

  • Modify: examples/mode_workflow/index.html

  • [ ] Step 1: 绑定「生成正交表 & 评估高亮」

      function qrSelBody() {
        return {
          tool_type: qrState.flat.tool_type || "",
          modality: qrState.flat.modality || "",
          suffix: qrState.flat.suffix || "",
          substance_path: qrState.treePath.substance,
          form_path: qrState.treePath.form,
        };
      }
      async function qrLoadScores(sel) {
        const r = await api("/api/query_score?sel=" + encodeURIComponent(sel));
        if (r && r.pending) return false;
        qrState.scores = r.cells || {};
        renderQrTable();
        const kept = Object.values(qrState.scores).filter((v) => v.keep).length;
        $("#qr-hint").textContent = `评估完成:keep ${kept} 格 · $${r.cost_usd ?? "?"}`;
        $("#qr-search-all").hidden = kept === 0;
        return true;
      }
      $("#qr-score").onclick = async () => {
        renderQrTable();   // 先按当前 chips 刷新拼词
        $("#qr-hint").textContent = "提交评分…";
        let r;
        try { r = await api("/api/query_score", { method: "POST", body: JSON.stringify(qrSelBody()) }); }
        catch (e) { return toast("评分启动失败:" + (e.body?.error || e.status), "error"); }
        if (r.cached) { await qrLoadScores(r.sel); return; }
        showTask("Query 评分 · 643 格", r.task_id, async () => { await qrLoadScores(r.sel); });
      };
  • Step 2: 语法检查 + 提交

Run: node --check 片段 → ✔ JS 语法 OK

cd /Users/max_liu/max_liu/company/Agent
git add examples/mode_workflow/index.html
git commit -m "feat(mode_workflow): Query 规则 评估高亮(POST query_score + 轮询 + keep 高亮)"

Task 5: 发起搜索(点单格 + 搜全部达标)

Files:

  • Modify: examples/mode_workflow/index.html

  • [ ] Step 1: 点高亮格搜 + 批量搜(事件委托 + 按钮)

      async function qrRunSearch(query) {
        const body = { query, platforms: "xhs,gzh", max_count: 20 };   // 方向无关,标签自动路由
        const r = await api("/api/run_search", { method: "POST", body: JSON.stringify(body) });
        return r;   // {task_id, query_id}
      }
      // 点高亮格 → 确认 → 搜
      $("#qr-table-wrap").onclick = async (e) => {
        const td = e.target.closest("td.qr-c.keep");
        if (!td) return;
        const v = qrState.scores[`${td.dataset.ai}_${td.dataset.ti}`];
        const query = (v && v.rewrite) || td.textContent.replace(/\d+(\.\d+)?$/, "").trim();
        if (!confirm(`发起搜索(小红书+公众号 各20):\n${query}`)) return;
        try {
          const r = await qrRunSearch(query);
          showTask(`搜索 · ${r.query_id} ${query}`, r.task_id, null);
        } catch (err) { toast("搜索启动失败:" + (err.body?.error || err.status), "error"); }
      };
      // 搜全部达标:逐格起任务(朴素循环,失败不阻断)
      $("#qr-search-all").onclick = async () => {
        const keeps = Object.entries(qrState.scores || {}).filter(([, v]) => v.keep);
        if (!keeps.length) return;
        if (!confirm(`将对 ${keeps.length} 个达标 query 各起一次搜索(小红书+公众号 各20),确认?`)) return;
        let ok = 0;
        for (const [, v] of keeps) {
          try { await qrRunSearch(v.rewrite || v.query); ok++; }
          catch (e) { /* 单格失败不阻断 */ }
        }
        toast(`已发起 ${ok}/${keeps.length} 个搜索任务`, "info");
      };
  • Step 2: 语法检查 + 提交

Run: node --check 片段 → ✔ JS 语法 OK

cd /Users/max_liu/max_liu/company/Agent
git add examples/mode_workflow/index.html
git commit -m "feat(mode_workflow): Query 规则 点格搜索 + 搜全部达标(run_search 方向无关)"

Task 6: 浏览器手测清单(交用户)

Files: 无(用户在浏览器验证;server 需在跑)

  1. 顶栏「Query 规则」打开弹层;5 个维度 chips 出现;实质/形式 选一层后自动展开下一层
  2. 下半出现 27×50 表,tier 底色梯度(0 灰 / 1~3 渐绿),tier0 格灰显。
  3. 选若干 chips(如 工具类型=AI、后缀=怎么做),点「生成正交表 & 评估高亮」→ 任务面板轮询 → 完成后 keep 格蓝框 + 角标分数;hint 显示 keep 数与成本。
  4. 再点一次同样选择 → 秒回(命中缓存,无新任务)。
  5. 点某个蓝框格 → 确认框显示 query → 确认后起搜索任务(任务面板可见)。
  6. 「搜全部达标」→ 确认 → toast 显示已发起 N 个任务。
  7. 切到 Dataset 工序/工具 tab,能看到新搜出的 query 与按标签入库的帖子。

Self-Review

  • Spec coverage(Part A 前端):顶栏按钮+弹层 → Task 1 ✓;chips(工具类型/模态/后缀 扁平 + 实质/形式 接口下钻任意深度)→ Task 2 ✓;27×50 正交表 l1 分组+tier 底色+tier0 灰 → Task 3 ✓;拼词与后端一致(qrCellQuery == _build_cells)→ Task 3 ✓;评估高亮(POST→轮询→GET→keep 描边+角标,缓存秒回)→ Task 4 ✓;点单格 + 搜全部达标(xhs,gzh×20、方向无关、rewrite 优先)→ Task 5 ✓;接口降级提示 → Task 2 qrOpen catch ✓。动作/类型 仅作表轴不进 chips(用户确认)✓。
  • Placeholder scan:无 TBD;每步给完整代码或精确 HTML/CSS/JS。CSS 若与现有 .chip 冲突,Task 2 Step 2 已注明复用不重复定义。
  • Type consistency:评分结果键 ${ai}_${ti}(Task 3/4/5 一致,对齐后端 <a_idx>_<t_idx>);qrSelBody() 字段(tool_type/modality/suffix/substance_path/form_path)对齐 POST /api/query_scoreqrRunSearch body(query/platforms/max_count,无 mode_type)对齐 Part B 后的 run_search;复用 showTask(title,taskId,onDone) 签名一致。