Procházet zdrojové kódy

docs(mode_workflow): Part A2 query-builder 前端 实现计划

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
刘文武 před 1 týdnem
rodič
revize
8c74a90d8d

+ 426 - 0
docs/superpowers/plans/2026-06-18-partA2-query-builder-frontend.md

@@ -0,0 +1,426 @@
+# 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_matrix`、`GET /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_status`,`done` 时调 `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>` 之后紧挨着加:
+
+```html
+        <button class="btn" id="btn-query-rule" style="margin-left:8px">Query 规则</button>
+```
+
+- [ ] **Step 2: 加弹层 HTML(放在 `#search-modal` 那段 modal 之后)**
+
+```html
+    <!-- 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>` 内、初始化区之上)**
+
+```javascript
+      /* ════ 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 写法)**
+
+```javascript
+      $("#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: 提交**
+
+```bash
+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 区加:
+
+```javascript
+      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>` 内,紧凑即可)**
+
+```css
+      .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):
+```bash
+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: 提交**
+
+```bash
+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: 加拼词 + 建表函数**
+
+```javascript
+      // 单格 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: 加表格样式**
+
+```css
+      .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`。
+```bash
+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: 绑定「生成正交表 & 评估高亮」**
+
+```javascript
+      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`。
+```bash
+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: 点高亮格搜 + 批量搜(事件委托 + 按钮)**
+
+```javascript
+      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`。
+```bash
+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 需在跑)
+
+- [ ] **手测项(重启 server 后于 http://localhost:8772 操作)**
+
+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_score`;`qrRunSearch` body(query/platforms/max_count,无 mode_type)对齐 Part B 后的 run_search;复用 `showTask(title,taskId,onDone)` 签名一致。