|
|
@@ -566,6 +566,15 @@
|
|
|
flex-wrap: wrap;
|
|
|
gap: 8px;
|
|
|
}
|
|
|
+ /* 按钮内计数:未做数(灰) / 已采纳总数(绿) */
|
|
|
+ .ph-actions .cnt-rest {
|
|
|
+ color: #9aa0a6;
|
|
|
+ font-weight: 700;
|
|
|
+ }
|
|
|
+ .ph-actions .cnt-total {
|
|
|
+ color: #2e9e5b;
|
|
|
+ font-weight: 700;
|
|
|
+ }
|
|
|
|
|
|
.qlist {
|
|
|
flex: 1;
|
|
|
@@ -979,23 +988,38 @@
|
|
|
font-size: 11.5px;
|
|
|
word-break: break-all;
|
|
|
}
|
|
|
- /* 归类命中 tag(实质/形式单元格内,原值下方)──绿色胶囊,与原文本区分 */
|
|
|
- .steps .match-tags {
|
|
|
- margin-top: 5px;
|
|
|
+ /* 归类命中(实质/形式):原值 → 命中值 逐行配对 */
|
|
|
+ .steps .sf-map {
|
|
|
+ display: flex;
|
|
|
+ flex-direction: column;
|
|
|
+ gap: 4px;
|
|
|
+ }
|
|
|
+ .steps .sf-pair {
|
|
|
display: flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 4px;
|
|
|
flex-wrap: wrap;
|
|
|
- gap: 3px;
|
|
|
+ line-height: 1.5;
|
|
|
+ }
|
|
|
+ .steps .sf-old {
|
|
|
+ color: #9aa0a6;
|
|
|
+ text-decoration: line-through;
|
|
|
+ }
|
|
|
+ .steps .sf-plain {
|
|
|
+ color: var(--ink);
|
|
|
}
|
|
|
- .steps .mtag {
|
|
|
+ .steps .sf-arrow {
|
|
|
+ color: #2e9e5b;
|
|
|
+ flex: none;
|
|
|
+ }
|
|
|
+ .steps .sf-new {
|
|
|
display: inline-block;
|
|
|
padding: 1px 7px;
|
|
|
border-radius: 10px;
|
|
|
background: #e3f3e8;
|
|
|
color: #2e6b45;
|
|
|
border: 1px solid #bfe3cb;
|
|
|
- font-size: 10.5px;
|
|
|
font-weight: 600;
|
|
|
- line-height: 1.6;
|
|
|
white-space: nowrap;
|
|
|
}
|
|
|
.inf {
|
|
|
@@ -2992,16 +3016,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;
|
|
|
+ // 解构全部(未解构/已采纳总数):未解构=已采纳但本方向未解构;A 灰 B 绿
|
|
|
+ const adopted = (state.posts || []).filter((p) => p.adopted);
|
|
|
+ const adoptedN = adopted.length;
|
|
|
+ const doneKey = state.mode === "process" ? "has_process" : "has_tools";
|
|
|
+ const undoneN = adopted.filter((p) => !p[doneKey]).length;
|
|
|
const ea = $("#btn-extract-adopted");
|
|
|
ea.hidden = !adoptedN;
|
|
|
- ea.textContent = `解构全部已采纳(${adoptedN})`;
|
|
|
- // 归类全部已采纳:仅工序方向 + 有「已采纳且已解构」的帖才显示(只有已解构能归类)
|
|
|
- const catN = (state.posts || []).filter((p) => p.adopted && p.has_process).length;
|
|
|
+ ea.innerHTML = `解构全部(<span class="cnt-rest">${undoneN}</span>/<span class="cnt-total">${adoptedN}</span>)`;
|
|
|
+ // 归类全部(未归类/已采纳总数):仅工序方向 + 有已采纳已解构帖才显示;未归类=已采纳未归类
|
|
|
+ const hasCatTarget = adopted.some((p) => p.has_process);
|
|
|
+ const unCatN = adopted.filter((p) => !p.has_category).length;
|
|
|
const ca = $("#btn-cat-adopted");
|
|
|
- ca.hidden = state.mode !== "process" || !catN;
|
|
|
- ca.textContent = `归类全部已采纳(${catN})`;
|
|
|
+ ca.hidden = state.mode !== "process" || !hasCatTarget;
|
|
|
+ ca.innerHTML = `归类全部(<span class="cnt-rest">${unCatN}</span>/<span class="cnt-total">${adoptedN}</span>)`;
|
|
|
}
|
|
|
$("#btn-batch").onclick = () => state.selected.size && startExtract([...state.selected]);
|
|
|
$("#btn-extract-adopted").onclick = async () => {
|
|
|
@@ -3020,21 +3048,31 @@
|
|
|
startExtract(cids, allDone ? { force: true } : {});
|
|
|
};
|
|
|
$("#btn-cat-adopted").onclick = async () => {
|
|
|
- // 只归类「已采纳且已解构」的帖(只有已解构才有 steps 可归类)
|
|
|
- const cids = (state.posts || []).filter((p) => p.adopted && p.has_process).map((p) => p.case_id);
|
|
|
+ // 只归类「已采纳且已解构」的帖(只有已解构才有 steps 可归类);has_category 来自 fetch_posts
|
|
|
+ const adoptedProc = (state.posts || []).filter((p) => p.adopted && p.has_process);
|
|
|
+ const cids = adoptedProc.map((p) => p.case_id);
|
|
|
if (!cids.length) return toast("当前 query 下没有「已采纳且已解构」的帖子", "warn");
|
|
|
- // 查这些帖已归类的数量,决定提示文案(已归类口径:steps 含 substanceMatch)
|
|
|
- let catN = 0;
|
|
|
- try {
|
|
|
- const r = await api(`/api/categorize_status?mode=${state.mode}&case_ids=${encodeURIComponent(cids.join(","))}`);
|
|
|
- catN = (r.categorized || []).length;
|
|
|
- } catch (e) { /* 查不到归类状态不阻断,按未归类提示 */ }
|
|
|
- const allCat = catN >= cids.length; // 全部已归类
|
|
|
- const msg = allCat
|
|
|
- ? `这 ${cids.length} 个帖的工序都已归类。是否重新归类?\n重新归类会用最新分类结果覆盖原有实质/形式 tag。`
|
|
|
- : `对该 query 下 ${cids.length} 个已采纳且已解构的帖做工序归类${catN ? `(其中 ${catN} 个已归类,将覆盖)` : ""}?\n将把命中的分类回写进各工序的实质/形式。`;
|
|
|
- if (!(await uiConfirm(msg))) return;
|
|
|
- startCategorize(cids); // 归类即覆盖写,无需 force
|
|
|
+ const rest = adoptedProc.filter((p) => !p.has_category).map((p) => p.case_id); // 未归类
|
|
|
+ const catN = cids.length - rest.length;
|
|
|
+
|
|
|
+ let action; // "all"=归类全部(覆盖) / "rest"=只归类剩余 / null=取消
|
|
|
+ if (rest.length === 0) {
|
|
|
+ // 全部已归类:只能重新归类全部
|
|
|
+ action = (await uiConfirm(`这 ${cids.length} 个帖的工序都已归类。是否重新归类?\n重新归类会用最新分类结果覆盖原有实质/形式 tag。`)) ? "all" : null;
|
|
|
+ } else if (catN === 0) {
|
|
|
+ // 都没归类:归类全部 = 归类剩余,两按钮即可
|
|
|
+ action = (await uiConfirm(`对该 query 下 ${cids.length} 个已采纳且已解构的帖做工序归类?\n将把命中的分类回写进各工序的实质/形式。`)) ? "all" : null;
|
|
|
+ } else {
|
|
|
+ // 部分已归类:归类剩余(只处理未归类) / 归类全部(覆盖) / 取消
|
|
|
+ action = await uiChoose(
|
|
|
+ `该 query 下 ${cids.length} 个已采纳且已解构的帖,其中 ${catN} 个已归类、${rest.length} 个未归类。\n「归类剩余」只处理未归类的 ${rest.length} 个;「归类全部」会重跑并覆盖已归类的。`,
|
|
|
+ [
|
|
|
+ { key: "rest", text: `归类剩余(${rest.length})` },
|
|
|
+ { key: "all", text: "归类全部(覆盖)", primary: true },
|
|
|
+ ]);
|
|
|
+ }
|
|
|
+ if (!action) return;
|
|
|
+ startCategorize(action === "rest" ? rest : cids); // 归类即覆盖写,无需 force
|
|
|
};
|
|
|
|
|
|
/* ════ 帖子详情弹层 ════ */
|
|
|
@@ -3543,8 +3581,8 @@
|
|
|
rows += `<td rowspan="${n}" class="sid">${esc(s.id || "")}</td>
|
|
|
<td rowspan="${n}"><div class="intent-text">${renderIntent(s.intent || s.directive || "")}</div></td>
|
|
|
<td rowspan="${n}">${s.effect ? `<span class="pill navy">${esc(s.effect)}</span>` : ""}</td>
|
|
|
- <td rowspan="${n}">${esc(fmtSF(s.substance))}${matchTag(s.substanceMatch)}</td>
|
|
|
- <td rowspan="${n}">${esc(fmtSF(s.form))}${matchTag(s.formMatch)}</td>`;
|
|
|
+ <td rowspan="${n}">${renderSF(s.substance, s.substanceMatch)}</td>
|
|
|
+ <td rowspan="${n}">${renderSF(s.form, s.formMatch)}</td>`;
|
|
|
}
|
|
|
rows += ioCell(ins[i], "in");
|
|
|
if (i === 0) {
|
|
|
@@ -3558,7 +3596,7 @@
|
|
|
return `<div style="overflow-x:auto"><table class="steps">
|
|
|
<colgroup>
|
|
|
<col style="width:44px"><col style="width:200px"><col style="width:92px">
|
|
|
- <col style="width:112px"><col style="width:100px">
|
|
|
+ <col style="width:180px"><col style="width:168px">
|
|
|
<col style="width:112px"><col style="width:330px"><col style="width:92px">
|
|
|
<col style="width:118px"><col style="width:130px">
|
|
|
<col style="width:112px"><col style="width:360px"><col style="width:110px">
|
|
|
@@ -3576,16 +3614,80 @@
|
|
|
function fmtSF(v) {
|
|
|
return v == null ? "" : Array.isArray(v) ? v.join("、") : v;
|
|
|
}
|
|
|
- /* 归类命中(substanceMatch/formMatch):多个用「、」拆,逐个出绿色 tag 放原值下方 */
|
|
|
- function matchTag(v) {
|
|
|
- if (v == null || v === "") return "";
|
|
|
- const tags = String(v)
|
|
|
- .split("、")
|
|
|
- .map((x) => x.trim())
|
|
|
- .filter(Boolean)
|
|
|
- .map((x) => `<span class="mtag">${esc(x)}</span>`)
|
|
|
- .join("");
|
|
|
- return tags ? `<div class="match-tags">${tags}</div>` : "";
|
|
|
+ const SF_ARROW =
|
|
|
+ '<svg class="sf-arrow" viewBox="0 0 24 24" width="13" height="13" aria-hidden="true"><path d="M5 12h14M13 6l6 6-6 6" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"/></svg>';
|
|
|
+ const SF_NO_MATCH = "无"; // 后端未命中占位符(与 category_match.py NO_MATCH 一致)
|
|
|
+ /* 原值拆分:与后端 _split_values 完全一致 —— 括号内的「、」不拆、去重保序,
|
|
|
+ 保证原值子项与 *Match 子项「等长等序」可按下标配对 */
|
|
|
+ function _splitParts(raw) {
|
|
|
+ if (raw == null) return [];
|
|
|
+ if (Array.isArray(raw)) {
|
|
|
+ const out = [],
|
|
|
+ seen = new Set();
|
|
|
+ for (const x of raw) {
|
|
|
+ const p = String(x).trim();
|
|
|
+ if (p && !seen.has(p)) {
|
|
|
+ seen.add(p);
|
|
|
+ out.push(p);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ const parts = [];
|
|
|
+ let cur = "",
|
|
|
+ depth = 0;
|
|
|
+ for (const ch of String(raw)) {
|
|
|
+ if (ch === "(" || ch === "(") {
|
|
|
+ depth++;
|
|
|
+ cur += ch;
|
|
|
+ } else if (ch === ")" || ch === ")") {
|
|
|
+ depth--;
|
|
|
+ cur += ch;
|
|
|
+ } else if (ch === "、" && depth === 0) {
|
|
|
+ const p = cur.trim();
|
|
|
+ if (p) parts.push(p);
|
|
|
+ cur = "";
|
|
|
+ } else {
|
|
|
+ cur += ch;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const last = cur.trim();
|
|
|
+ if (last) parts.push(last);
|
|
|
+ const out = [],
|
|
|
+ seen = new Set();
|
|
|
+ for (const p of parts)
|
|
|
+ if (!seen.has(p)) {
|
|
|
+ seen.add(p);
|
|
|
+ out.push(p);
|
|
|
+ }
|
|
|
+ return out;
|
|
|
+ }
|
|
|
+ /* *Match 拆分:按下标对齐,**不去重不滤空**,保留「无」占位 */
|
|
|
+ function _matchParts(v) {
|
|
|
+ return v == null ? [] : String(v).split("、").map((x) => x.trim());
|
|
|
+ }
|
|
|
+ /* 实质/形式单元格:逐子项「原值 → 命中值」配对。
|
|
|
+ 命中 → 原值灰色划除 + 箭头 + 绿色命中值;
|
|
|
+ 未命中(占位「无」/缺失)→ 只显原值,黑色正常(无划除无箭头) */
|
|
|
+ function renderSF(value, match) {
|
|
|
+ const olds = _splitParts(value);
|
|
|
+ const news = _matchParts(match);
|
|
|
+ if (!news.length) return esc(fmtSF(value)); // 整格未归类 → 原值黑色
|
|
|
+ const n = Math.max(olds.length, news.length);
|
|
|
+ let rows = "";
|
|
|
+ for (let i = 0; i < n; i++) {
|
|
|
+ const a = olds[i],
|
|
|
+ b = news[i];
|
|
|
+ const matched = b != null && b !== "" && b !== SF_NO_MATCH;
|
|
|
+ if (matched) {
|
|
|
+ rows += `<div class="sf-pair">${
|
|
|
+ a != null ? `<span class="sf-old">${esc(a)}</span>${SF_ARROW}` : ""
|
|
|
+ }<span class="sf-new">${esc(b)}</span></div>`;
|
|
|
+ } else if (a != null) {
|
|
|
+ rows += `<div class="sf-pair"><span class="sf-plain">${esc(a)}</span></div>`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return `<div class="sf-map">${rows}</div>`;
|
|
|
}
|
|
|
function ioCell(x, kind) {
|
|
|
const cls = kind === "in" ? "c-in" : "c-out";
|
|
|
@@ -3797,6 +3899,10 @@
|
|
|
if (!r.task_id) return toast(r.note || "无可归类帖", "info");
|
|
|
showTask(`工序归类 · ${caseIds.length} 帖${opts.auto ? "(自动)" : ""}`, r.task_id, async () => {
|
|
|
caseIds.forEach(invalidateExtractCache); // 归类改了 steps,清缓存才能拿到新 match
|
|
|
+ // 乐观更新:归类成功的帖标记已归类,刷新「归类全部(未归类/已采纳)」计数
|
|
|
+ const done = new Set(caseIds);
|
|
|
+ (state.posts || []).forEach((p) => { if (done.has(p.case_id)) p.has_category = true; });
|
|
|
+ updateBatchBtn();
|
|
|
// 当前正看的帖在本批里 → 重载解构,实质/形式立即出 tag
|
|
|
if (caseIds.includes(state.caseId)) {
|
|
|
state.version = null;
|
|
|
@@ -3845,6 +3951,41 @@
|
|
|
bg.querySelector('[data-act="ok"]').focus();
|
|
|
});
|
|
|
}
|
|
|
+ /* 多按钮选择弹框(uiConfirm 的多选版):buttons=[{key,text,primary?}]。
|
|
|
+ 点业务按钮返回其 key;取消/Esc/点遮罩返回 null;Enter=primary(或第一个)按钮。 */
|
|
|
+ function uiChoose(message, buttons, opt = {}) {
|
|
|
+ const { cancelText = "取消" } = opt;
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ const bg = document.createElement("div");
|
|
|
+ bg.className = "ui-confirm-bg";
|
|
|
+ const btns = buttons
|
|
|
+ .map((b) => `<button class="btn ${b.primary ? "seal" : ""}" data-key="${esc(b.key)}">${esc(b.text)}</button>`)
|
|
|
+ .join("");
|
|
|
+ bg.innerHTML =
|
|
|
+ '<div class="ui-confirm"><div class="ui-confirm-msg"></div>'
|
|
|
+ + '<div class="ui-confirm-acts">'
|
|
|
+ + `<button class="btn" data-key="">${esc(cancelText)}</button>`
|
|
|
+ + btns + "</div></div>";
|
|
|
+ bg.querySelector(".ui-confirm-msg").textContent = message;
|
|
|
+ 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(null);
|
|
|
+ const a = e.target.closest("[data-key]");
|
|
|
+ if (a) done(a.dataset.key || null); // data-key="" → 取消 → null
|
|
|
+ });
|
|
|
+ const onKey = (e) => {
|
|
|
+ if (e.key === "Escape") { e.preventDefault(); done(null); }
|
|
|
+ else if (e.key === "Enter") {
|
|
|
+ e.preventDefault();
|
|
|
+ const p = buttons.find((b) => b.primary) || buttons[0];
|
|
|
+ done(p ? p.key : null);
|
|
|
+ }
|
|
|
+ };
|
|
|
+ document.addEventListener("keydown", onKey, true);
|
|
|
+ (bg.querySelector(".ui-confirm-acts .seal") || bg.querySelector(".ui-confirm-acts .btn:last-child")).focus();
|
|
|
+ });
|
|
|
+ }
|
|
|
function showTask(title, taskId, onDone, onSettled) {
|
|
|
hasTask = true;
|
|
|
$("#task-panel").hidden = false;
|