|
|
@@ -1787,7 +1787,7 @@
|
|
|
bottom: 22px;
|
|
|
width: 440px;
|
|
|
max-height: 55vh;
|
|
|
- z-index: 60;
|
|
|
+ z-index: 130; /* 高于弹层(modal-bg 70/dialog 120),搜索进度日志始终可见 */
|
|
|
display: flex;
|
|
|
flex-direction: column;
|
|
|
border: 1px solid var(--line-dark);
|
|
|
@@ -2068,6 +2068,55 @@
|
|
|
transform: translateY(-8px);
|
|
|
}
|
|
|
}
|
|
|
+ /* 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-thr-lab { color:#666; font-size:12px; }
|
|
|
+ .qr-thr { width:46px; padding:3px 5px; border:1px solid #ddd; border-radius:6px; font-size:12px; }
|
|
|
+ #qr-score:disabled { opacity:.6; cursor:wait; }
|
|
|
+ .ui-confirm-bg { position:fixed; inset:0; background:rgba(0,0,0,.38); z-index:200; display:flex; align-items:center; justify-content:center; }
|
|
|
+ .ui-confirm { background:#fff; border-radius:14px; box-shadow:0 14px 50px rgba(0,0,0,.28); width:400px; max-width:92vw; padding:22px 24px; }
|
|
|
+ .ui-confirm-msg { font-size:14px; color:#1a1a1a; line-height:1.6; white-space:pre-line; margin-bottom:18px; }
|
|
|
+ .ui-confirm-acts { display:flex; justify-content:flex-end; gap:10px; }
|
|
|
+ .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 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:#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:#d8ecd8; } .qr-c.t3 { background:#b9ddb9; }
|
|
|
+ .qr-c.dead { color:#ccc; }
|
|
|
+ .qr-c.keep { background:#fff3bf !important; box-shadow:inset 0 0 0 2px #f59e0b; color:#111; font-weight:700; cursor:pointer; }
|
|
|
+ .qr-c.keep:hover { background:#ffe066 !important; }
|
|
|
+ .qr-bd { display:inline-block; margin-left:5px; font-style:normal; font-size:11px; font-weight:800; color:#fff; background:#f59e0b; padding:0 6px; border-radius:9px; }
|
|
|
+ .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>
|
|
|
@@ -2118,6 +2167,7 @@
|
|
|
>
|
|
|
+ 新建搜索
|
|
|
</button>
|
|
|
+ <button class="btn" id="btn-query-rule" style="margin-left:8px">Query 规则</button>
|
|
|
</div>
|
|
|
<div class="ds-grid">
|
|
|
<div class="card">
|
|
|
@@ -2151,6 +2201,7 @@
|
|
|
>
|
|
|
批量解构
|
|
|
</button>
|
|
|
+ <button class="btn sm" id="btn-extract-adopted" hidden>解构全部已采纳</button>
|
|
|
</span>
|
|
|
</div>
|
|
|
<div
|
|
|
@@ -2415,6 +2466,25 @@
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
+ <!-- Query 规则组织器 弹层 -->
|
|
|
+ <div class="modal-bg" id="qr-modal" hidden>
|
|
|
+ <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">生成正交表</button>
|
|
|
+ <button class="btn" id="qr-search-all" hidden>搜全部达标</button>
|
|
|
+ <label class="qr-thr-lab">达标分 ≥ <input id="qr-thr" class="qr-thr" type="number" min="0" max="10" step="0.5" value="7"></label>
|
|
|
+ <span id="qr-hint" class="qr-hint"></span>
|
|
|
+ </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>
|
|
|
/* ════ 基础 ════ */
|
|
|
const $ = (s) => document.querySelector(s);
|
|
|
@@ -2695,12 +2765,13 @@
|
|
|
async function loadQueries() {
|
|
|
/* 工序/工具各自一张搜索表,query 列表按当前子模式拉取 */
|
|
|
try {
|
|
|
- state.queries = await api("/api/queries?mode=" + state.mode);
|
|
|
+ /* 接口按 query_id 升序返回;列表倒序显示(最新在上) */
|
|
|
+ state.queries = (await api("/api/queries?mode=" + state.mode)).reverse();
|
|
|
} catch (e) {
|
|
|
state.queries = [];
|
|
|
}
|
|
|
renderQueries();
|
|
|
- /* 进入/切换子模式时默认选中第一个 query(进而联动第一帖与解构结果) */
|
|
|
+ /* 进入/切换子模式时默认选中第一个(即最新)query(进而联动第一帖与解构结果) */
|
|
|
if (!state.queryId && state.queries.length) await selectQuery(state.queries[0].query_id);
|
|
|
}
|
|
|
function renderQueries() {
|
|
|
@@ -2860,8 +2931,20 @@
|
|
|
const b = $("#btn-batch");
|
|
|
b.disabled = !state.selected.size;
|
|
|
b.textContent = state.selected.size ? `批量解构(${state.selected.size})` : "批量解构";
|
|
|
+ // 解构全部已采纳:有采纳帖才显示,带数量(口径同帖列表的 采纳 标记)
|
|
|
+ const adoptedN = (state.posts || []).filter((p) => p.adopted).length;
|
|
|
+ const ea = $("#btn-extract-adopted");
|
|
|
+ ea.hidden = !adoptedN;
|
|
|
+ ea.textContent = `解构全部已采纳(${adoptedN})`;
|
|
|
}
|
|
|
$("#btn-batch").onclick = () => state.selected.size && startExtract([...state.selected]);
|
|
|
+ $("#btn-extract-adopted").onclick = async () => {
|
|
|
+ const cids = (state.posts || []).filter((p) => p.adopted).map((p) => p.case_id);
|
|
|
+ if (!cids.length) return toast("当前 query 下没有已采纳的帖子", "warn");
|
|
|
+ const dir = state.mode === "process" ? "工序" : "工具";
|
|
|
+ if (!(await uiConfirm(`对该 query 下全部 ${cids.length} 个已采纳帖做${dir}解构?\n已解构过的会自动跳过/复用,不重复花钱。`))) return;
|
|
|
+ startExtract(cids); // 复用:认领锁 + 解构去重 + showTask 轮询
|
|
|
+ };
|
|
|
|
|
|
/* ════ 帖子详情弹层 ════ */
|
|
|
/* 得分可能是字符串("1")或数字(10),统一解析;非数值返回 null */
|
|
|
@@ -3048,9 +3131,10 @@
|
|
|
// Esc 由 <dialog> 原生处理(触发 close)
|
|
|
});
|
|
|
|
|
|
- function openPostDetail(cid) {
|
|
|
+ async function openPostDetail(cid) {
|
|
|
const p = state.posts.find((x) => x.case_id === cid);
|
|
|
if (!p) return;
|
|
|
+ await ensurePostDetail(p); // 列表瘦身,弹框前补全正文/评估
|
|
|
const e = p.llm_evaluation || {};
|
|
|
const meta = [];
|
|
|
meta.push(`${PLAT_LOGO(p.platform)}<span style="font-weight:600">${esc(PLAT_NAME(p.platform))}</span>`);
|
|
|
@@ -3131,8 +3215,19 @@
|
|
|
state.version = null;
|
|
|
state.post = state.posts.find((p) => p.case_id === cid) || null;
|
|
|
renderPosts();
|
|
|
+ await ensurePostDetail(state.post); // 列表瘦身,补全正文/评估再渲染右侧详情
|
|
|
await loadExtract();
|
|
|
}
|
|
|
+ // 列表是瘦身数据(无 body/llm_evaluation/videos);详情按需取单帖并合并到对象上(合并后再点 0 往返)
|
|
|
+ async function ensurePostDetail(p) {
|
|
|
+ if (!p || p.body !== undefined) return p;
|
|
|
+ try {
|
|
|
+ const full = await api(`/api/post?mode=${state.mode}&query_id=`
|
|
|
+ + encodeURIComponent(state.queryId) + "&case_id=" + encodeURIComponent(p.case_id));
|
|
|
+ if (full) Object.assign(p, full);
|
|
|
+ } catch (e) { /* 详情取不到不阻断,正文/评分留空 */ }
|
|
|
+ return p;
|
|
|
+ }
|
|
|
|
|
|
/* ── 解构结果:客户端缓存 + 请求竞态守卫(解决「多次点击卡顿 / 残留上一帖」)──
|
|
|
· _extractCache:解构数据只在「重新解构」时变(由本页 startExtract 触发,届时清缓存),
|
|
|
@@ -3578,7 +3673,36 @@
|
|
|
/* ════ 任务面板(✕ 只隐藏;「操作日志」按钮可随时唤回)════ */
|
|
|
let pollTimer = null,
|
|
|
hasTask = false;
|
|
|
- function showTask(title, taskId, onDone) {
|
|
|
+ // 自建确认弹框(替代浏览器原生 confirm):返回 Promise<bool>。Enter=确定 / Esc=取消 / 点遮罩=取消
|
|
|
+ function uiConfirm(message, opt = {}) {
|
|
|
+ const { okText = "确定", cancelText = "取消" } = opt;
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const bg = document.createElement("div");
|
|
|
+ bg.className = "ui-confirm-bg";
|
|
|
+ bg.innerHTML =
|
|
|
+ '<div class="ui-confirm"><div class="ui-confirm-msg"></div>'
|
|
|
+ + '<div class="ui-confirm-acts">'
|
|
|
+ + '<button class="btn" data-act="cancel"></button>'
|
|
|
+ + '<button class="btn seal" data-act="ok"></button></div></div>';
|
|
|
+ bg.querySelector(".ui-confirm-msg").textContent = message;
|
|
|
+ bg.querySelector('[data-act="cancel"]').textContent = cancelText;
|
|
|
+ bg.querySelector('[data-act="ok"]').textContent = okText;
|
|
|
+ document.body.appendChild(bg);
|
|
|
+ const done = (v) => { bg.remove(); document.removeEventListener("keydown", onKey, true); resolve(v); };
|
|
|
+ bg.addEventListener("click", (e) => {
|
|
|
+ if (e.target === bg) return done(false);
|
|
|
+ const a = e.target.closest("[data-act]");
|
|
|
+ if (a) done(a.dataset.act === "ok");
|
|
|
+ });
|
|
|
+ const onKey = (e) => {
|
|
|
+ if (e.key === "Escape") { e.preventDefault(); done(false); }
|
|
|
+ else if (e.key === "Enter") { e.preventDefault(); done(true); }
|
|
|
+ };
|
|
|
+ document.addEventListener("keydown", onKey, true);
|
|
|
+ bg.querySelector('[data-act="ok"]').focus();
|
|
|
+ });
|
|
|
+ }
|
|
|
+ function showTask(title, taskId, onDone, onSettled) {
|
|
|
hasTask = true;
|
|
|
$("#task-panel").hidden = false;
|
|
|
$("#task-title").textContent = title;
|
|
|
@@ -3591,6 +3715,7 @@
|
|
|
t = await api("/api/task_status?task_id=" + encodeURIComponent(taskId));
|
|
|
} catch (e) {
|
|
|
$("#task-log").textContent = "状态查询失败";
|
|
|
+ if (onSettled) onSettled("error"); // 轮询出错也算结束,让调用方恢复按钮等
|
|
|
return;
|
|
|
}
|
|
|
const log = $("#task-log");
|
|
|
@@ -3603,6 +3728,7 @@
|
|
|
$("#task-dot").className = "dot " + t.status;
|
|
|
$("#task-title").textContent = title + (t.status === "done" ? " · 完成" : " · 失败");
|
|
|
if (t.status === "done" && onDone) onDone();
|
|
|
+ if (onSettled) onSettled(t.status); // done / failed 都触发
|
|
|
};
|
|
|
poll();
|
|
|
}
|
|
|
@@ -3617,6 +3743,297 @@
|
|
|
log.scrollTop = log.scrollHeight;
|
|
|
}
|
|
|
|
|
|
+ /* ════ 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 路径(仅作 Sonnet 上下文)
|
|
|
+ treeData: { substance: null, form: null },
|
|
|
+ axisPath: { action: [], type: [] }, // 动作/类型 筛选路径(l1/l2/叶子,来自 matrix,筛表显示)
|
|
|
+ matrix: null, // /api/query_matrix
|
|
|
+ scores: null, // 最近一次评分结果 cells
|
|
|
+ threshold: 7, // 达标 = keep 且 综合分≥此值(可在弹层调)
|
|
|
+ lastCost: null,
|
|
|
+ };
|
|
|
+ function qrChip(label, active, on, cls) {
|
|
|
+ const b = document.createElement("button");
|
|
|
+ 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 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(); renderQrTable(); })));
|
|
|
+ });
|
|
|
+ // 实质/形式 接口下钻:仅作 Sonnet 上下文,不拼词、不筛表
|
|
|
+ QR_TREE.forEach((d) => {
|
|
|
+ const path = qrState.treePath[d.id];
|
|
|
+ let level = qrState.treeData[d.id] ? qrState.treeData[d.id].tree : [];
|
|
|
+ for (let depth = 0; ; depth++) {
|
|
|
+ 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(); })));
|
|
|
+ const sel = (level || []).find((n) => n.name === path[depth]);
|
|
|
+ if (!sel || !(sel.children && sel.children.length)) break;
|
|
|
+ level = sel.children;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ // 动作/类型 下钻(来自 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"); }
|
|
|
+ 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();
|
|
|
+ }
|
|
|
+
|
|
|
+ // 单格 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,...}}
|
|
|
+ // 按 动作/类型 筛选路径过滤可见行列(保留原始索引 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>';
|
|
|
+ html += '<tr><th class="qr-corner" rowspan="2">类型 \\ 动作</th>';
|
|
|
+ const groups = [];
|
|
|
+ aIdx.forEach((ai) => {
|
|
|
+ const l1 = actions[ai].l1;
|
|
|
+ const last = groups[groups.length - 1];
|
|
|
+ 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>";
|
|
|
+ aIdx.forEach((ai) => { html += `<th class="qr-ah">${actions[ai].name}</th>`; });
|
|
|
+ html += "</tr></thead><tbody>";
|
|
|
+ let curL1 = null;
|
|
|
+ tIdx.forEach((ti) => {
|
|
|
+ const t = types[ti];
|
|
|
+ if (t.l1 !== curL1) {
|
|
|
+ curL1 = t.l1;
|
|
|
+ html += `<tr><td class="qr-tl1" colspan="${aIdx.length + 1}">${t.l1}</td></tr>`;
|
|
|
+ }
|
|
|
+ html += `<tr><th class="qr-th">${t.name}</th>`;
|
|
|
+ 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 = qrKeep(v);
|
|
|
+ const cls = `qr-c t${tier}` + (tier === 0 ? " dead" : "") + (keep ? " keep" : "");
|
|
|
+ const badge = keep && v.score != null ? `<i class="qr-bd">${v.score}</i>` : "";
|
|
|
+ html += `<td class="${cls}" data-ai="${ai}" data-ti="${ti}">${q}${badge}</td>`;
|
|
|
+ });
|
|
|
+ html += "</tr>";
|
|
|
+ });
|
|
|
+ html += "</tbody></table>";
|
|
|
+ $("#qr-table-wrap").innerHTML = html;
|
|
|
+ }
|
|
|
+ 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 || {};
|
|
|
+ qrState.lastCost = r.cost_usd;
|
|
|
+ renderQrTable();
|
|
|
+ qrRefreshKeep();
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ // 达标 = Sonnet keep 且 综合分≥阈值;高亮/计数/搜全部达标 统一口径
|
|
|
+ function qrKeep(v) { return !!(v && v.keep && v.score != null && v.score >= qrState.threshold); }
|
|
|
+ function qrRefreshKeep() {
|
|
|
+ if (!qrState.scores) return;
|
|
|
+ const k = Object.values(qrState.scores).filter(qrKeep).length;
|
|
|
+ $("#qr-hint").textContent = `达标(keep 且 ≥${qrState.threshold}):${k} 格`
|
|
|
+ + (qrState.lastCost != null ? ` · 评分 $${qrState.lastCost}` : "");
|
|
|
+ $("#qr-search-all").hidden = k === 0;
|
|
|
+ }
|
|
|
+ $("#qr-thr").addEventListener("input", () => {
|
|
|
+ const x = parseFloat($("#qr-thr").value);
|
|
|
+ qrState.threshold = isNaN(x) ? 0 : x;
|
|
|
+ if (qrState.scores) { qrClosePop(); renderQrTable(); qrRefreshKeep(); }
|
|
|
+ });
|
|
|
+ $("#qr-score").onclick = async () => {
|
|
|
+ const btn = $("#qr-score");
|
|
|
+ if (btn.disabled) return; // 评分进行中:防重复点击(避免重复起任务/花钱)
|
|
|
+ const orig = btn.textContent;
|
|
|
+ btn.disabled = true; btn.textContent = "评估中…";
|
|
|
+ const restore = () => { btn.disabled = false; btn.textContent = orig; };
|
|
|
+ renderQrTable(); // 先按当前 chips 刷新拼词
|
|
|
+ $("#qr-hint").textContent = "提交评分…";
|
|
|
+ let r;
|
|
|
+ try { r = await api("/api/query_score", { method: "POST", body: JSON.stringify(qrSelBody()) }); }
|
|
|
+ catch (e) { toast("评分启动失败:" + (e.body?.error || e.status), "error"); return restore(); }
|
|
|
+ if (r.cached) { try { await qrLoadScores(r.sel); } finally { restore(); } return; }
|
|
|
+ // 后台任务:完成→载入结果;成功/失败/查询出错 都恢复按钮(onSettled)
|
|
|
+ showTask("Query 评分 · 643 格", r.task_id,
|
|
|
+ async () => { await qrLoadScores(r.sel); },
|
|
|
+ (status) => { if (status !== "done") toast("评分任务" + (status === "failed" ? "失败" : "状态异常") + ",可重试", "warn"); restore(); });
|
|
|
+ };
|
|
|
+ async function qrRunSearch(query, label) {
|
|
|
+ const body = { query, platforms: "xhs,gzh", max_count: 20 }; // 用改写词搜索,方向无关
|
|
|
+ if (label && label !== query) body.query_text = label; // query 列表存原始组合词
|
|
|
+ const r = await api("/api/run_search", { method: "POST", body: JSON.stringify(body) });
|
|
|
+ return r; // {task_id, query_id}
|
|
|
+ }
|
|
|
+ // 点高亮格 → 气泡显示评分/理由 + 发起搜索/复制(不再用浏览器原生 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 ?? "?"}×.4 + 可搜 ${v.findable ?? "?"}×.3 + 价值 ${v.useful ?? "?"}×.3</span></div>` +
|
|
|
+ `<div class="dim" style="margin:0 0 7px">内容树 tier ${tier}(人工预判可检索度,仅参考,不计入综合分)</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, v.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 && 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();
|
|
|
+ });
|
|
|
+ // 搜全部达标:逐格起任务(朴素循环,失败不阻断)
|
|
|
+ // 批量进度面板:任务面板里列出每个搜索的实时状态(轮询各 task_id)
|
|
|
+ function showQrBatch(items) {
|
|
|
+ hasTask = true;
|
|
|
+ $("#task-panel").hidden = false;
|
|
|
+ $("#task-title").textContent = `搜全部达标 · ${items.length} 个`;
|
|
|
+ $("#task-dot").className = "dot";
|
|
|
+ clearTimeout(pollTimer);
|
|
|
+ const st = {};
|
|
|
+ const poll = async () => {
|
|
|
+ await Promise.all(items.map(async (it) => {
|
|
|
+ if (st[it.task_id] && st[it.task_id] !== "running") return;
|
|
|
+ try { const t = await api("/api/task_status?task_id=" + encodeURIComponent(it.task_id)); st[it.task_id] = t.status; }
|
|
|
+ catch (e) { st[it.task_id] = st[it.task_id] || "running"; }
|
|
|
+ }));
|
|
|
+ const done = items.filter((it) => st[it.task_id] && st[it.task_id] !== "running").length;
|
|
|
+ $("#task-title").textContent = `搜全部达标 · ${done}/${items.length} 完成`;
|
|
|
+ $("#task-log").textContent = items.map((it) => {
|
|
|
+ const s = st[it.task_id] || "running";
|
|
|
+ return `${s === "done" ? "✅" : s === "failed" ? "❌" : "⏳"} ${it.query_id} ${it.query}`;
|
|
|
+ }).join("\n");
|
|
|
+ if (done < items.length) pollTimer = setTimeout(poll, 2000);
|
|
|
+ else $("#task-dot").className = "dot done";
|
|
|
+ };
|
|
|
+ poll();
|
|
|
+ }
|
|
|
+ $("#qr-search-all").onclick = async () => {
|
|
|
+ const keeps = Object.entries(qrState.scores || {}).filter(([, v]) => qrKeep(v));
|
|
|
+ if (!keeps.length) return;
|
|
|
+ if (!(await uiConfirm(`将对 ${keeps.length} 个达标 query 各起一次搜索(小红书+公众号 各20),确认?`))) return;
|
|
|
+ qrClosePop();
|
|
|
+ const items = [];
|
|
|
+ for (const [, v] of keeps) {
|
|
|
+ const q = v.rewrite || v.query;
|
|
|
+ try { const r = await qrRunSearch(q, v.query); if (r.task_id) items.push({ query: q, query_id: r.query_id, task_id: r.task_id }); }
|
|
|
+ catch (e) { /* 单格失败不阻断 */ }
|
|
|
+ }
|
|
|
+ if (!items.length) return toast("全部发起失败", "error");
|
|
|
+ toast(`已发起 ${items.length}/${keeps.length} 个搜索`, "info");
|
|
|
+ showQrBatch(items);
|
|
|
+ };
|
|
|
+
|
|
|
/* ════ 新建搜索 ════ */
|
|
|
/* 渠道下拉多选(选项同 search_eval:小红书/知乎/公众号/抖音/视频号/YouTube) */
|
|
|
const CHANNELS = [
|
|
|
@@ -3718,6 +4135,10 @@
|
|
|
loadQueries();
|
|
|
}
|
|
|
|
|
|
+ $("#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; };
|
|
|
+
|
|
|
window.addEventListener("resize", () => chartRefs.forEach((c) => c.resize()));
|
|
|
route();
|
|
|
</script>
|