Sfoglia il codice sorgente

feat(mode_workflow): Query 规则弹层 UI 重做(边界/布局/分组表头)+ 动作/类型 下钻筛选 + 点格评分气泡

刘文武 1 settimana fa
parent
commit
59ea74c270
1 ha cambiato i file con 150 aggiunte e 78 eliminazioni
  1. 150 78
      examples/mode_workflow/index.html

+ 150 - 78
examples/mode_workflow/index.html

@@ -2068,24 +2068,48 @@
           transform: translateY(-8px);
         }
       }
-      .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; }
+      /* Query 规则弹层 */
+      .qr-box { background:#fff; border-radius:14px; box-shadow:0 10px 44px rgba(0,0,0,.18);
+                width:96vw; max-width:1200px; max-height:92vh; display:flex; flex-direction:column; overflow:hidden; }
+      .qr-head { display:flex; align-items:center; padding:15px 20px; border-bottom:1px solid #eee; }
+      .qr-title { font-size:15px; font-weight:600; color:#1a1a1a; }
+      .qr-x { margin-left:auto; border:none; background:none; font-size:16px; color:#999; cursor:pointer; padding:4px 9px; border-radius:7px; line-height:1; }
+      .qr-x:hover { background:#f2f2f2; color:#333; }
+      .qr-bar { display:flex; align-items:center; gap:10px; padding:11px 20px; border-bottom:1px solid #f0f0f0; background:#fcfcfc; }
+      .qr-hint { color:#888; font-size:12px; margin-left:4px; }
+      .qr-dims { padding:8px 20px 10px; max-height:32vh; overflow:auto; border-bottom:1px solid #f0f0f0; }
+      .qr-row { display:flex; align-items:flex-start; gap:10px; padding:4px 0; }
+      .qr-row + .qr-row { border-top:1px solid #f6f6f6; }
+      .qr-lab { min-width:64px; color:#999; font-size:12.5px; padding-top:5px; flex-shrink:0; }
+      .qr-chips { display:flex; flex-wrap:wrap; gap:6px; flex:1; }
+      .chip { padding:4px 13px; border-radius:16px; font-size:12.5px; cursor:pointer; border:1.5px solid #e2e2e2; background:#fff; color:#555; line-height:1.4; }
+      .chip:hover { border-color:#bbb; color:#222; }
+      .chip.on { background:#1a1a1a; border-color:#1a1a1a; color:#fff; font-weight:500; }
+      .chip.axis.on { background:#2563eb; border-color:#2563eb; }
+      .qr-table-wrap { overflow:auto; flex:1; }
       .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 th, .qr-tab td { border:1px solid #eee; padding:3px 7px; 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-corner { position:sticky; left:0; z-index:3; background:#eef0f3; font-weight:600; color:#555; }
+      .qr-th { position:sticky; left:0; background:#fafafa; z-index:1; text-align:left; color:#444; }
+      .qr-tl1 { background:#eef0f3; font-weight:600; color:#444; }
+      .qr-gh { background:#e8ebef; font-weight:600; color:#333; text-align:center; }
+      .qr-ah { background:#f3f4f6; color:#555; text-align:center; }
       .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; }
-      .qr-gh { background:#eef0f3; font-weight:600; color:#333; text-align:center; }
-      .qr-ah { background:#f7f7f7; color:#555; text-align:center; }
+      .qr-c.t2 { background:#d8ecd8; } .qr-c.t3 { background:#b9ddb9; }
+      .qr-c.dead { color:#ccc; }
+      .qr-c.keep { outline:2px solid #2563eb; outline-offset:-2px; cursor:pointer; font-weight:600; }
+      .qr-c.keep:hover { background:#dbe7ff; }
+      .qr-bd { display:inline-block; margin-left:4px; font-style:normal; font-size:9px; font-weight:700; color:#2563eb; }
+      .qr-pop { position:fixed; z-index:60; width:300px; background:#fff; border:1px solid #e2e2e2;
+                border-radius:11px; box-shadow:0 8px 30px rgba(0,0,0,.18); padding:13px 15px; font-size:12.5px; color:#333; }
+      .qr-pop .q { font-size:13.5px; font-weight:600; color:#1a1a1a; margin-bottom:2px; word-break:break-all; }
+      .qr-pop .orig { font-size:11px; color:#aaa; margin-bottom:8px; }
+      .qr-pop .sc { display:flex; align-items:baseline; gap:8px; margin:6px 0; }
+      .qr-pop .sc b { font-size:22px; color:#2563eb; }
+      .qr-pop .dim { color:#888; font-size:11.5px; }
+      .qr-pop .rsn { color:#555; margin:7px 0 10px; line-height:1.5; }
+      .qr-pop .acts { display:flex; gap:8px; }
     </style>
   </head>
   <body>
@@ -2436,18 +2460,20 @@
 
     <!-- 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">
+      <div class="qr-box">
+        <div class="qr-head">
+          <span class="qr-title">Query 词组织器</span>
+          <button class="qr-x" id="qr-close" title="关闭">✕</button>
+        </div>
+        <div class="qr-bar">
           <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>
+          <span id="qr-hint" class="qr-hint"></span>
         </div>
-        <div id="qr-table-wrap" style="overflow:auto;flex:1;border:1px solid #eee;border-radius:8px"></div>
+        <div id="qr-dims" class="qr-dims"></div>
+        <div id="qr-table-wrap" class="qr-table-wrap"></div>
       </div>
+      <div id="qr-pop" class="qr-pop" hidden></div>
     </div>
 
     <script>
@@ -3665,63 +3691,87 @@
       ];
       const qrState = {
         flat: { tool_type: null, modality: null, suffix: null },
-        treePath: { substance: [], form: [] },   // 选中节点的祖先+自身 name 路径
-        treeData: { substance: null, form: null }, // 接口返回的 {tree:[...]}
+        treePath: { substance: [], form: [] },   // 实质/形式 选中 name 路径(仅作 Sonnet 上下文)
+        treeData: { substance: null, form: null },
+        axisPath: { action: [], type: [] },        // 动作/类型 筛选路径(l1/l2/叶子,来自 matrix,筛表显示)
         matrix: null,                              // /api/query_matrix
         scores: null,                              // 最近一次评分结果 cells
       };
-      function qrChip(label, active, on) {
+      function qrChip(label, active, on, cls) {
         const b = document.createElement("button");
-        b.className = "chip" + (active ? " on" : "");
+        b.className = "chip" + (cls ? " " + cls : "") + (active ? " on" : "");
         b.textContent = label;
         b.onclick = on;
         return b;
       }
+      function qrRow(root, labtxt) {
+        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";
+        row.appendChild(wrap);
+        root.appendChild(row);
+        return wrap;
+      }
+      // 动作/类型 三级(l1/l2/叶子)取自 matrix items 的 l1/l2/name
+      function qrAxisLevelOptions(items, prefix) {
+        if (prefix.length === 0) return [...new Set(items.map((x) => x.l1))];
+        if (prefix.length === 1) return [...new Set(items.filter((x) => x.l1 === prefix[0]).map((x) => x.l2))];
+        return items.filter((x) => x.l1 === prefix[0] && x.l2 === prefix[1]).map((x) => x.name);
+      }
+      function qrAxisMatch(item, path) {
+        if (path[0] && item.l1 !== path[0]) return false;
+        if (path[1] && item.l2 !== path[1]) return false;
+        if (path[2] && item.name !== path[2]) return false;
+        return true;
+      }
       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(); }));
+          const wrap = qrRow(root, `· ${d.label}`);
+          wrap.appendChild(qrChip("无", qrState.flat[d.id] == null, () => { qrState.flat[d.id] = null; renderQrDims(); renderQrTable(); }));
           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);
+            wrap.appendChild(qrChip(it, qrState.flat[d.id] === it, () => { qrState.flat[d.id] = it; renderQrDims(); renderQrTable(); })));
         });
-        // 实质/形式 下钻:每一层一行,展示当前层可选项;选中后再展开下一层
+        // 实质/形式 接口下钻:仅作 Sonnet 上下文,不拼词、不筛表
         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 = [];
+          let level = qrState.treeData[d.id] ? qrState.treeData[d.id].tree : [];
           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";
+            const wrap = qrRow(root, depth === 0 ? `· ${d.label}` : "");
             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));
         });
+        // 动作/类型 下钻(来自 matrix l1/l2):筛选表格显示
+        if (qrState.matrix) {
+          [{ id: "action", label: "动作", items: qrState.matrix.actions },
+           { id: "type", label: "类型", items: qrState.matrix.types }].forEach((d) => {
+            const path = qrState.axisPath[d.id];
+            for (let depth = 0; depth <= 2; depth++) {
+              const opts = qrAxisLevelOptions(d.items, path.slice(0, depth));
+              if (!opts.length) break;
+              const wrap = qrRow(root, depth === 0 ? `· ${d.label}` : "");
+              wrap.appendChild(qrChip("无", path.length === depth,
+                () => { qrState.axisPath[d.id] = path.slice(0, depth); renderQrDims(); renderQrTable(); }, "axis"));
+              opts.forEach((name) =>
+                wrap.appendChild(qrChip(name, path[depth] === name,
+                  () => { qrState.axisPath[d.id] = [...path.slice(0, depth), name]; renderQrDims(); renderQrTable(); }, "axis")));
+              if (path[depth] == null) break;   // 未选中本层 → 不展开下一层
+            }
+          });
+        }
       }
       async function qrOpen() {
+        qrClosePop();
         renderQrDims();
         // 并行拉矩阵 + 两棵分类树(接口挂则该维度降级:提示不可选)
         try { qrState.matrix = qrState.matrix || await api("/api/query_matrix"); }
@@ -3745,39 +3795,38 @@
         if (!m) return;
         const { actions, types, matrix } = m;
         const sc = qrState.scores;   // {"ai_ti":{keep,score,rewrite,...}}
-        // 表头:动作按 l1 分组(参考 Image #2,列头两行:l1 跨列 + 叶子)
+        // 按 动作/类型 筛选路径过滤可见行列(保留原始索引 ai/ti 供评分键 ai_ti)
+        const aIdx = actions.map((_, i) => i).filter((i) => qrAxisMatch(actions[i], qrState.axisPath.action));
+        const tIdx = types.map((_, i) => i).filter((i) => qrAxisMatch(types[i], qrState.axisPath.type));
         let html = '<table class="qr-tab"><thead>';
-        // l1 行
         html += '<tr><th class="qr-corner" rowspan="2">类型 \\ 动作</th>';
         const groups = [];
-        actions.forEach((a, ai) => {
+        aIdx.forEach((ai) => {
+          const l1 = actions[ai].l1;
           const last = groups[groups.length - 1];
-          if (last && last.l1 === a.l1) last.span++;
-          else groups.push({ l1: a.l1, span: 1, start: ai });
+          if (last && last.l1 === l1) last.span++;
+          else groups.push({ l1, span: 1 });
         });
         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>`; });
+        aIdx.forEach((ai) => { html += `<th class="qr-ah">${actions[ai].name}</th>`; });
         html += "</tr></thead><tbody>";
-        // 行:类型按 l1 分组,组首插一行组标题
         let curL1 = null;
-        types.forEach((t, ti) => {
+        tIdx.forEach((ti) => {
+          const t = types[ti];
           if (t.l1 !== curL1) {
             curL1 = t.l1;
-            html += `<tr><td class="qr-tl1" colspan="${actions.length + 1}">${t.l1}</td></tr>`;
+            html += `<tr><td class="qr-tl1" colspan="${aIdx.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);
+          aIdx.forEach((ai) => {
+            const tier = (matrix[ai][ti] || {}).tier || 0;
+            const q = qrCellQuery(actions[ai].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 += `<td class="${cls}" data-ai="${ai}" data-ti="${ti}">${q}${badge}</td>`;
           });
           html += "</tr>";
         });
@@ -3817,19 +3866,42 @@
         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) => {
+      // 点高亮格 → 气泡显示评分/理由 + 发起搜索/复制(不再用浏览器原生 confirm)
+      function qrClosePop() { $("#qr-pop").hidden = true; }
+      function qrShowPop(td, v) {
+        const pop = $("#qr-pop");
+        const query = v.rewrite || v.query;
+        const orig = v.rewrite && v.rewrite !== v.query ? `<div class="orig">原: ${v.query}</div>` : "";
+        const tier = td.classList.contains("t3") ? 3 : td.classList.contains("t2") ? 2 : td.classList.contains("t1") ? 1 : 0;
+        pop.innerHTML =
+          `<div class="q">${query}</div>${orig}` +
+          `<div class="sc"><b>${v.score ?? "?"}</b><span class="dim">人话 ${v.natural ?? "?"} · 可搜 ${v.findable ?? "?"} · 价值 ${v.useful ?? "?"} · tier ${tier}</span></div>` +
+          `<div class="rsn">${v.reason || "(无理由)"}</div>` +
+          `<div class="acts"><button class="btn seal" id="qr-pop-go">发起搜索</button><button class="btn" id="qr-pop-copy">复制</button></div>`;
+        pop.hidden = false;
+        const r = td.getBoundingClientRect(), pw = 300, ph = pop.offsetHeight || 150;
+        let left = r.left, top = r.bottom + 6;
+        if (left + pw > window.innerWidth - 10) left = window.innerWidth - pw - 10;
+        if (top + ph > window.innerHeight - 10) top = r.top - ph - 6;
+        pop.style.left = Math.max(10, left) + "px";
+        pop.style.top = Math.max(10, top) + "px";
+        $("#qr-pop-go").onclick = async () => {
+          qrClosePop();
+          try { const rr = await qrRunSearch(query); showTask(`搜索 · ${rr.query_id} ${query}`, rr.task_id, null); }
+          catch (err) { toast("搜索启动失败:" + (err.body?.error || err.status), "error"); }
+        };
+        $("#qr-pop-copy").onclick = () => { navigator.clipboard && navigator.clipboard.writeText(query); toast("已复制", "info", 1200); };
+      }
+      $("#qr-table-wrap").onclick = (e) => {
         const td = e.target.closest("td.qr-c.keep");
         if (!td) return;
-        const v = qrState.scores[`${td.dataset.ai}_${td.dataset.ti}`];
-        if (!v) return;
-        const query = v.rewrite || v.query;
-        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"); }
+        const v = qrState.scores && qrState.scores[`${td.dataset.ai}_${td.dataset.ti}`];
+        if (v) qrShowPop(td, v);
       };
+      // 点气泡外/表格空白处 关闭气泡
+      $("#qr-modal").addEventListener("click", (e) => {
+        if (!e.target.closest("#qr-pop") && !e.target.closest("td.qr-c.keep")) qrClosePop();
+      });
       // 搜全部达标:逐格起任务(朴素循环,失败不阻断)
       $("#qr-search-all").onclick = async () => {
         const keeps = Object.entries(qrState.scores || {}).filter(([, v]) => v.keep);