codebee 0.1.72 → 0.1.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/app/ui/app.js CHANGED
@@ -10,7 +10,7 @@ function codebeeDocumentTitle(lang) {
10
10
  document.title = codebeeDocumentTitle((localStorage.getItem("orch.lang") || "zh").toLowerCase());
11
11
 
12
12
  const $ = (id) => document.getElementById(id);
13
- const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", mainPage: "", /* 主栏正显示的全局页("" = 新建任务表单);左栏是任务树还是设置导航看 body.settings-mode */ detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, rdHiveFirst: false, _rdCtx: {}, lastPrefs: null, prefsApplied: false, sideUsage: null, sideUsageAt: 0, ovUsage: null, ovDays: undefined };
13
+ const S = { state: null, catalog: null, catSig: "", providers: null, bindings: null, modelsSig: "", bindSig: "", tab: "tasks", mainPage: "", /* 主栏正显示的全局页("" = 新建任务表单);左栏是任务树还是设置导航看 body.settings-mode */ detailRunId: null, pollTimer: null, showArchived: false, /* 会话内开关:每次加载默认隐藏已归档、图标不选中(不持久化,见 btn-side-arch) */ selProvs: {}, selModels: {}, selRuns: {}, provStatus: "enabled", mlistStatus: "enabled", /* 供应商/模型列表状态过滤:默认只看启用,停用的点对应 chip 才现身 */ bindSel: {}, catalogChecking: false, updateCheckAt: 0, control: null, sseLive: false, es: null, flows: null, orch: null, skills: null, orchSig: "", settings: null, sessionAgents: new Set(), atts: [], gitInfo: null, gitWb: null, gitWbKey: "", gitWbAt: 0, gitWbBusy: false, creatingTask: false, inspKey: null, inspData: null, inspSig: "", inspAt: 0, inspTab: "git", inspAutoSig: "", rdTab: null, rdTabSig: "", rdTabPin: false, rdHiveFirst: false, _rdCtx: {}, lastPrefs: null, prefsApplied: false, sideUsage: null, sideUsageAt: 0, ovUsage: null, ovDays: undefined };
14
14
 
15
15
  /* ---------------------------------------------------------- 内置浏览器工作区 */
16
16
  const BROWSER_STORAGE_KEY = "orch.browser.workspace.v1";
@@ -432,7 +432,7 @@ function flowIconHtml(f) {
432
432
  }
433
433
 
434
434
  function flowDesc(f) {
435
- if (f.id === "rank_scan") return t("抓四平台榜 → AI 选题洞察");
435
+ if (f.id === "rank_scan") return t("抓四平台榜 → AI 选题洞察·附证据分级");
436
436
  if (f.engine === "direct") return t("单智能体直达(快)");
437
437
  if (f.engine === "code") return t("实现 → 验证 → 评审");
438
438
  // 连载与单稿件同引擎,描述必须区分:连载强调逐章与断点续跑
@@ -1269,18 +1269,51 @@ async function api(path, opts) {
1269
1269
  }
1270
1270
  }
1271
1271
 
1272
- async function pbApiWithQualityOverride(path, body, prompt) {
1272
+ /* 质量门禁三选一弹框(替代原生 confirm 的单一「强制放行」出口):
1273
+ * 取消 / ↻ 重新评审 / 强制放行。「重新评审」= 重写未达标章再评(语义同详情页
1274
+ * 「↻ 重写未达标章」按钮,走 retry 的未达标分支),仅 serial 任务且非运行中
1275
+ * 才给(运行中后端 retry 也会拒)。返回 "review" | "force" | null(取消)。 */
1276
+ async function pbGateChoice(err, promptText, taskId) {
1277
+ const tk = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
1278
+ const canReview = !!(tk && tk.serial &&
1279
+ tk.status !== "running" && tk.status !== "queued");
1280
+ const msg = (promptText || t("质量门禁未通过")) + "\n\n" + ((err && err.message) || "");
1281
+ const v = await _askOpen({
1282
+ title: t("质量门禁未通过"),
1283
+ bodyHtml: '<div class="ask-msg">' + esc(msg) + "</div>" +
1284
+ (canReview ? '<div class="ask-msg">' +
1285
+ esc(t("「重新评审」只重写未达标章节并重新评审(已过线章节沿用成稿与评分),跑完后再来发布。")) + "</div>" : ""),
1286
+ okText: t("强制放行…"),
1287
+ extra: canReview ? [{ text: "↻ " + t("重新评审"), value: "review" }] : [],
1288
+ onOpen: () => $("ask-yes").focus(),
1289
+ });
1290
+ return v === "review" ? "review" : (v === true ? "force" : null);
1291
+ }
1292
+
1293
+ async function pbApiWithQualityOverride(path, body, prompt, taskId) {
1273
1294
  try {
1274
1295
  return await api(path, { method: "POST", body: JSON.stringify(body) });
1275
1296
  } catch (e) {
1276
1297
  const gate = e && e.data && e.data.quality_gate;
1277
1298
  if (!gate) throw e;
1278
- if (!confirm((prompt || "质量门禁未通过") + "\n\n" + (e.message || "") +
1279
- "\n\n确认后将记录为人工强制放行。继续?")) throw e;
1280
- const reason = window.prompt("请输入人工复核原因(必填)", "已人工复核稿件与平台版本") || "";
1281
- if (!reason.trim()) throw new Error("未填写人工复核原因,已取消强制放行");
1299
+ const pick = await pbGateChoice(e, prompt, taskId);
1300
+ if (pick === "review") {
1301
+ // 重写未达标章 + 重新评审:不继续本次发布,调用方按 gate_reviewed 跳过
1302
+ // 成功提示;评审完成后用户再点一次发布按钮即可。
1303
+ try {
1304
+ const r = await api("/api/tasks/" + encodeURIComponent(taskId) + "/retry",
1305
+ { method: "POST" });
1306
+ jumpToRun(r.run_id);
1307
+ toast(t("已发起重新评审(重写未达标章)——评审完成后再来发布"));
1308
+ } catch (e2) { toast(t("发起重新评审失败:") + e2.message, true); }
1309
+ return { gate_reviewed: true };
1310
+ }
1311
+ if (pick !== "force") throw e;
1312
+ const reason = await uiPrompt(t("强制放行:请输入人工复核原因(必填)"),
1313
+ "已人工复核稿件与平台版本");
1314
+ if (!reason) throw new Error(t("未填写人工复核原因,已取消强制放行"));
1282
1315
  return await api(path, { method: "POST", body: JSON.stringify({ ...body,
1283
- force: true, force_confirmed: true, force_reason: reason.trim() }) });
1316
+ force: true, force_confirmed: true, force_reason: reason }) });
1284
1317
  }
1285
1318
  }
1286
1319
 
@@ -1321,7 +1354,18 @@ function _askOpen(opts) {
1321
1354
  _askResolve = resolve;
1322
1355
  $("ask-title").textContent = opts.title || t("确认");
1323
1356
  $("ask-body").innerHTML = opts.bodyHtml || "";
1357
+ // 三态弹框:extra 按钮插在「取消」左侧,点按以 opts 里的 value 结束
1358
+ // (非布尔,只有用到 extra 的调用方需要判读);开新框先清上一帧残留。
1324
1359
  const yes = $("ask-yes");
1360
+ const foot = yes.parentNode;
1361
+ foot.querySelectorAll(".ask-extra").forEach((b) => b.remove());
1362
+ (opts.extra || []).forEach((b) => {
1363
+ const el = document.createElement("button");
1364
+ el.className = (b.cls || "ghost") + " ask-extra";
1365
+ el.textContent = b.text;
1366
+ el.addEventListener("click", () => _askClose(b.value));
1367
+ foot.insertBefore(el, $("ask-no"));
1368
+ });
1325
1369
  yes.textContent = opts.okText || t("确定");
1326
1370
  yes.className = opts.danger ? "danger" : "primary";
1327
1371
  $("ask").classList.remove("hidden");
@@ -1937,11 +1981,16 @@ function renderProvList() {
1937
1981
  const box = $("prov-list");
1938
1982
  if (!box) return;
1939
1983
  const kw = (S.provFilter || "").trim().toLowerCase();
1940
- const provs = (S.providers || []).filter((p) => !kw ||
1984
+ const kwHit = (p) => !kw ||
1941
1985
  (p.name || "").toLowerCase().includes(kw) ||
1942
1986
  (p.protocol || "").toLowerCase().includes(kw) ||
1943
1987
  (p.source || "").toLowerCase().includes(kw) ||
1944
- (p.base_url || "").toLowerCase().includes(kw));
1988
+ (p.base_url || "").toLowerCase().includes(kw);
1989
+ const view = S.provStatus || "enabled";
1990
+ // 状态过滤默认只看启用(2026-09-30 用户拍板):停用的点「已停用/全部」chip 才现身
1991
+ const stHit = (p) => view === "all" || (view === "off" ? p.enabled === false : p.enabled !== false);
1992
+ const hit = (S.providers || []).filter(kwHit);
1993
+ const provs = hit.filter(stHit);
1945
1994
  if (!S.selProv && provs.length) S.selProv = provs[0].id;
1946
1995
  if (S.selProv && !provs.some((p) => p.id === S.selProv)) {
1947
1996
  S.selProv = provs.length ? provs[0].id : null;
@@ -1952,6 +2001,13 @@ function renderProvList() {
1952
2001
  }
1953
2002
  const selN = Object.keys(S.selProvs || {}).length;
1954
2003
  box.classList.toggle("has-sel", selN > 0);
2004
+ const chips = hit.length ? '<div class="cat-chips prov-status">' +
2005
+ '<button class="cat-chip' + (view === "enabled" ? " active" : "") + '" onclick="setProvStatus(\'enabled\')">' +
2006
+ t("已启用") + " <b>" + hit.filter((p) => p.enabled !== false).length + "</b></button>" +
2007
+ '<button class="cat-chip' + (view === "off" ? " active" : "") + '" onclick="setProvStatus(\'off\')">' +
2008
+ t("已停用") + " <b>" + hit.filter((p) => p.enabled === false).length + "</b></button>" +
2009
+ '<button class="cat-chip' + (view === "all" ? " active" : "") + '" onclick="setProvStatus(\'all\')">' +
2010
+ t("全部") + " <b>" + hit.length + "</b></button></div>" : "";
1955
2011
  const batch = selN ? '<div class="prov-batch"><span class="n">' + t("已选 ") + selN + t(" 个") + "</span>" +
1956
2012
  '<button class="ghost small" onclick="batchProvOp(\'enable\')">' + t("启用") + '</button>' +
1957
2013
  '<button class="ghost small" onclick="batchProvOp(\'disable\')">' + t("停用") + '</button>' +
@@ -1960,9 +2016,10 @@ function renderProvList() {
1960
2016
  const allBox = provs.length ? '<label class="prov-all"><input type="checkbox"' +
1961
2017
  (selN === provs.length && selN > 0 ? " checked" : "") +
1962
2018
  ' onchange="toggleAllProvSel(this.checked)">' + t(" 全选") +
1963
- (kw ? t("(筛选后 ") + provs.length + t(" 个)") : t("(") + provs.length + t(")")) + "</label>" : "";
1964
- box.innerHTML = batch + allBox + (provs.map((p) => {
1965
- const n = p.models == null ? null : p.models.filter((m) => !m.hidden).length;
2019
+ (kw || view !== "all" ? t("(筛选后 ") + provs.length + t(" 个)") : t("(") + provs.length + t(")")) + "</label>" : "";
2020
+ box.innerHTML = chips + batch + allBox + (provs.map((p) => {
2021
+ // 卡片计数只数启用中的模型(列表默认也只展示启用中的,两边口径一致)
2022
+ const n = p.models == null ? null : p.models.filter((m) => !m.hidden && m.enabled !== false).length;
1966
2023
  const st = n == null ? t("未获取") : n + t(" 模型");
1967
2024
  const off = p.enabled === false;
1968
2025
  return '<div class="prov-item' + (p.id === S.selProv ? " active" : "") + (off ? " off" : "") +
@@ -1980,7 +2037,16 @@ function renderProvList() {
1980
2037
  }).join("") ||
1981
2038
  '<div class="hint" style="padding:8px">' + (kw && (S.providers || []).length
1982
2039
  ? t("没有匹配「") + esc(S.provFilter) + t("」的供应商。")
1983
- : t("暂无供应商——点上方「导入」。")) + "</div>");
2040
+ : (view === "enabled" && hit.some((p) => p.enabled === false)
2041
+ ? t("%1 个供应商已停用——点上方「已停用」查看。").replace("%1", hit.filter((p) => p.enabled === false).length)
2042
+ : t("暂无供应商——点上方「导入」。"))) + "</div>");
2043
+ }
2044
+
2045
+ function setProvStatus(v) {
2046
+ S.provStatus = v;
2047
+ S.modelsSig = null;
2048
+ renderProvList(); // 可能收敛 S.selProv,详情跟着换人
2049
+ renderProvDetail();
1984
2050
  }
1985
2051
 
1986
2052
  function toggleProvSel(id, on) {
@@ -1994,11 +2060,13 @@ function toggleAllProvSel(on) {
1994
2060
  S.selProvs = {};
1995
2061
  if (on) {
1996
2062
  const kw = (S.provFilter || "").trim().toLowerCase();
1997
- for (const p of (S.providers || []).filter((p) => !kw ||
2063
+ const view = S.provStatus || "enabled";
2064
+ for (const p of (S.providers || []).filter((p) => (!kw ||
1998
2065
  (p.name || "").toLowerCase().includes(kw) ||
1999
2066
  (p.protocol || "").toLowerCase().includes(kw) ||
2000
2067
  (p.source || "").toLowerCase().includes(kw) ||
2001
- (p.base_url || "").toLowerCase().includes(kw))) S.selProvs[p.id] = true;
2068
+ (p.base_url || "").toLowerCase().includes(kw)) &&
2069
+ (view === "all" || (view === "off" ? p.enabled === false : p.enabled !== false)))) S.selProvs[p.id] = true;
2002
2070
  }
2003
2071
  S.modelsSig = null;
2004
2072
  renderProvList();
@@ -2105,6 +2173,18 @@ function renderProvDetail() {
2105
2173
  ' value="' + esc(kw) + '" oninput="filterModels(\'' + esc(p.id) + '\', this.value)">' +
2106
2174
  '<span class="pm-count" id="pm-count-' + esc(p.id) + '"></span>' +
2107
2175
  pmAddHtml(p) + '</div>';
2176
+ // 模型列表状态过滤:默认只看启用(与供应商列表同一套 chip 口径)
2177
+ const vis = (p.models || []).filter((m) => !m.hidden);
2178
+ if (vis.length) {
2179
+ const mview = S.mlistStatus || "enabled";
2180
+ html += '<div class="cat-chips pm-status">' +
2181
+ '<button class="cat-chip' + (mview === "enabled" ? " active" : "") + '" onclick="setModelStatus(\'enabled\')">' +
2182
+ t("已启用") + " <b>" + vis.filter((m) => m.enabled !== false).length + "</b></button>" +
2183
+ '<button class="cat-chip' + (mview === "off" ? " active" : "") + '" onclick="setModelStatus(\'off\')">' +
2184
+ t("已停用") + " <b>" + vis.filter((m) => m.enabled === false).length + "</b></button>" +
2185
+ '<button class="cat-chip' + (mview === "all" ? " active" : "") + '" onclick="setModelStatus(\'all\')">' +
2186
+ t("全部") + " <b>" + vis.length + "</b></button></div>";
2187
+ }
2108
2188
  html += '<div id="pm-groups" class="pm-groups' + (selN ? " has-sel" : "") + '">' +
2109
2189
  provModelGroupsHtml(p) + "</div>";
2110
2190
  if (hidden.length) {
@@ -2258,7 +2338,9 @@ function wireCapTags(p, excludeProto) {
2258
2338
  /* 模型分组区 HTML(全量重绘与过滤重绘共用;过滤时暂停拖拽排序) */
2259
2339
  function provModelGroupsHtml(p) {
2260
2340
  const kw = ((S.modelFilter || {})[p.id] || "").trim().toLowerCase();
2341
+ const mview = S.mlistStatus || "enabled";
2261
2342
  const models = (p.models || []).filter((m) => !m.hidden)
2343
+ .filter((m) => mview === "all" || (mview === "off" ? m.enabled === false : m.enabled !== false))
2262
2344
  .filter((m) => !kw || (m.name || "").toLowerCase().includes(kw))
2263
2345
  .sort((a, b) => (a.priority || 0) - (b.priority || 0));
2264
2346
  const sel = modelSel(p.id);
@@ -2269,9 +2351,15 @@ function provModelGroupsHtml(p) {
2269
2351
  }
2270
2352
  const gnames = Object.keys(groups).sort();
2271
2353
  if (!gnames.length) {
2272
- return '<div class="empty">' + (kw
2354
+ const offAll = (p.models || []).filter((m) => !m.hidden && m.enabled === false).length;
2355
+ const msg = kw
2273
2356
  ? t("没有匹配「") + esc(kw) + t("」的模型。")
2274
- : t("该供应商没有可用模型(或全部被停用)。")) + "</div>";
2357
+ : mview === "off"
2358
+ ? t("没有已停用的模型。")
2359
+ : (mview === "enabled" && offAll
2360
+ ? t("%1 个模型已停用——点上方「已停用」查看。").replace("%1", offAll)
2361
+ : t("该供应商没有可用模型(或全部被停用)。"));
2362
+ return '<div class="empty">' + msg + "</div>";
2275
2363
  }
2276
2364
  let html = "";
2277
2365
  for (const g of gnames) {
@@ -2307,9 +2395,12 @@ function updateModelCount(pid) {
2307
2395
  const p = (S.providers || []).find((x) => x.id === pid);
2308
2396
  if (!el || !p || !p.models) return;
2309
2397
  const kw = (S.modelFilter || {})[pid] || "";
2310
- const total = p.models.filter((m) => !m.hidden).length;
2398
+ const mview = S.mlistStatus || "enabled";
2399
+ const inView = (m) => !m.hidden &&
2400
+ (mview === "all" || (mview === "off" ? m.enabled === false : m.enabled !== false));
2401
+ const total = p.models.filter(inView).length;
2311
2402
  const shown = total && kw.trim()
2312
- ? p.models.filter((m) => !m.hidden &&
2403
+ ? p.models.filter((m) => inView(m) &&
2313
2404
  (m.name || "").toLowerCase().includes(kw.trim().toLowerCase())).length
2314
2405
  : total;
2315
2406
  el.textContent = kw.trim() ? (shown + " / " + total + t(" 个模型")) : (total + t(" 个模型"));
@@ -2508,9 +2599,11 @@ function toggleGroupSel(pid, group, on) {
2508
2599
  const p = (S.providers || []).find((x) => x.id === pid);
2509
2600
  if (!p) return;
2510
2601
  const kw = ((S.modelFilter || {})[pid] || "").trim().toLowerCase();
2602
+ const mview = S.mlistStatus || "enabled";
2511
2603
  const s = modelSelSet(pid);
2512
2604
  for (const m of p.models || []) {
2513
2605
  if (m.hidden) continue;
2606
+ if (mview !== "all" && (mview === "off" ? m.enabled !== false : m.enabled === false)) continue;
2514
2607
  if ((m.protocol || p.protocol || t("其他")) !== group) continue;
2515
2608
  if (kw && !(m.name || "").toLowerCase().includes(kw)) continue; // 过滤时只作用于可见行
2516
2609
  if (on) s[m.name] = true; else delete s[m.name];
@@ -2519,6 +2612,13 @@ function toggleGroupSel(pid, group, on) {
2519
2612
  renderProvDetail();
2520
2613
  }
2521
2614
 
2615
+ /* 模型列表状态过滤 chip:默认只看启用 */
2616
+ function setModelStatus(v) {
2617
+ S.mlistStatus = v;
2618
+ S.modelsSig = null;
2619
+ renderProvDetail();
2620
+ }
2621
+
2522
2622
  function clearModelSel(pid) {
2523
2623
  if (S.selModels) delete S.selModels[pid];
2524
2624
  S.modelsSig = null;
@@ -4417,6 +4517,7 @@ window.sideOpenTask = function (key) {
4417
4517
  S.detailRunId = null;
4418
4518
  S.focusStep = 0;
4419
4519
  S.taskSig = "";
4520
+ S.runDetailSig = ""; // 详情在任务态/运行态间共用同一套 DOM:切走必须作废运行态签名
4420
4521
  rdTabReset();
4421
4522
  if (!S.histJump && typeof histPush === "function") histPush({ m: "main", tab: "run-detail" });
4422
4523
  showDetailInMain();
@@ -4662,11 +4763,23 @@ function drawTaskDetail(key, runs, appendFrom) {
4662
4763
  const lr0 = runs[0] || {};
4663
4764
  const resumePending = (lr0.resume_enqueue_at &&
4664
4765
  Date.now() < Date.parse(String(lr0.resume_enqueue_at).replace(" ", "T"))) ? 1 : 0;
4766
+ // cost_usd/tokens 故意不入签名:运行中每次记账都在变,入签名会把整帧
4767
+ // 重绘变回「几秒一闪」;步骤状态翻转自然带上新数字,任务累计统计另有
4768
+ // detailSide 通道(2s 节流)保实时,两者都够新鲜。
4769
+ // 消息数/verdict 必须归一化再入签名:loadTaskRuns 每轮把 state 的摘要
4770
+ // 投影(message_count 等)assign 进缓存 run,下一轮干净 run 又洗掉这些
4771
+ // 键——裸取字段会在 null↔0 / 全量↔精简 verdict 间来回摆,每 8s 必重画
4772
+ // 一帧(2026-09-30 任务页闪烁实案)。归一后两种形状取值一致。
4773
+ const msgN = (r) => r.message_count != null ? Number(r.message_count)
4774
+ : (r.messages || []).length;
4775
+ const msgP = (r) => r.pending_message_count != null ? Number(r.pending_message_count)
4776
+ : (r.messages || []).filter((m) => !m.consumed).length;
4777
+ const vcmp = (r) => { const v = r.verdict || null;
4778
+ return v ? JSON.stringify([v.pass, v.publishable, v.verify_pass, v.review_pass]) : null; };
4665
4779
  const sig = JSON.stringify(runs.map((r) => [r.id, r.status, (r.steps || []).length,
4666
4780
  (r.steps || []).map((s) => s.status).join(""),
4667
- (r.messages || []).length, (r.messages || []).filter((m) => !m.consumed).length,
4668
- r.message_count, r.pending_message_count,
4669
- r.summary, r.error, r.cost_usd, r.tokens, JSON.stringify(r.verdict || {})])
4781
+ msgN(r), msgP(r),
4782
+ r.summary, r.error, vcmp(r)])
4670
4783
  .concat([JSON.stringify([(bmRoot || {}).book_meta || null,
4671
4784
  (bmTask || {}).cover_gen || null]), resumePending]));
4672
4785
  if (sig === S.taskSig) return;
@@ -4915,6 +5028,7 @@ async function openRun(id, pinTab) {
4915
5028
  _taskStepRenderToken++;
4916
5029
  S.detailRunId = id;
4917
5030
  S.detailTaskKey = null;
5031
+ S.runDetailSig = ""; // 新目标首帧必画;也兜住「任务态改过 DOM 后回到本运行」的串台
4918
5032
  renderPageCrumb(); // 标题「运行详情」;必须在赋值后画——showDetailInMain 先于本函数跑,那边画不到
4919
5033
  rdTabReset(pinTab || null); // sideOpenRun 带步骤号时钉住步骤分区
4920
5034
  document.querySelector("#sub-runs .panel:first-child").classList.add("hidden");
@@ -4929,6 +5043,7 @@ function closeRun() {
4929
5043
  S.detailRunId = null;
4930
5044
  S.detailTaskKey = null;
4931
5045
  S.taskSig = "";
5046
+ S.runDetailSig = "";
4932
5047
  S.focusStep = 0;
4933
5048
  rdTabReset();
4934
5049
  stopLogLive();
@@ -5276,6 +5391,15 @@ async function renderRunDetail() {
5276
5391
  $("btn-share").classList.toggle("hidden", active); // 分享页:结束后可生成自包含 HTML
5277
5392
  $("btn-talk").classList.toggle("hidden", !(run.task_id && !chatEngineIsDirect(run)));
5278
5393
  const rcTask = ((S.state || {}).tasks || []).find((x) => x.id === run.task_id);
5394
+ // 签名守卫(模型页/检查器同款):数据没变不重绘。详情区是全页最重的
5395
+ // 渲染面,此前 SSE 推送+轮询每 8s 无差别整块重写,是「页面一闪一闪、
5396
+ // 发布表单填一半被清空」的总根源(2026-09-30 用户实案)。运行中 ETA
5397
+ // 随墙钟走,签名带 1 分钟桶让「预计剩余」每分钟翻新,不必跟轮询同频。
5398
+ const etaBucket = (run.status === "running" || run.status === "queued")
5399
+ ? Math.floor(Date.now() / 60000) : 0;
5400
+ const rdSig = JSON.stringify([run, rcTask || null, etaBucket]);
5401
+ if (rdSig === S.runDetailSig) return;
5402
+ S.runDetailSig = rdSig;
5279
5403
  // 归档按钮:非运行中任务可归档/取消归档(此前只有侧栏右键菜单入口,
5280
5404
  // 用户反馈「归档按钮不见了」——补显式入口,文案随状态切换)
5281
5405
  syncArchBtn(rcTask, active);
@@ -5499,6 +5623,17 @@ function renderBookMetaPanel(task) {
5499
5623
  if (box) box.classList.add("hidden");
5500
5624
  return;
5501
5625
  }
5626
+ // 输入/展开态保护(检查器 insp-msg-input 同款纪律):面板里有输入焦点
5627
+ //(正在填登记表单)或章节清单正展开时跳过本帧整块重绘——innerHTML
5628
+ // 重建会把没提交的字清掉、把展开列表折回去。轮询链继续转,blur/收起
5629
+ // 后下一轮数据变化自然跟上;登记草稿另有 S.pbRegDraft 兜底回填。
5630
+ const ae = document.activeElement;
5631
+ if ((ae && box.contains(ae) &&
5632
+ (ae.tagName === "INPUT" || ae.tagName === "TEXTAREA" || ae.isContentEditable)) ||
5633
+ box.querySelector(".pb-chapters:not(.hidden)")) {
5634
+ pbSyncState(task);
5635
+ return;
5636
+ }
5502
5637
  const src = bmRootTask(task); // 都是用第一个:开书资料读链上根任务
5503
5638
  const inherited = src.id !== task.id;
5504
5639
  const bm = src.book_meta || {};
@@ -5644,8 +5779,8 @@ window.coverGen = async function (taskId) {
5644
5779
  };
5645
5780
 
5646
5781
  window.bmCopyBtn = function (btn) {
5647
- copyText(btn.dataset.text || "");
5648
- toast(t("已复制:") + (btn.dataset.label || ""));
5782
+ // 只在真实复制成功后报「已复制」——剪贴板不可用时 fallbackCopy 会弹失败提示
5783
+ copyText(btn.dataset.text || "", () => toast(t("已复制:") + (btn.dataset.label || "")));
5649
5784
  };
5650
5785
 
5651
5786
  /* ---------------- 一键发布(bookmeta 分区内每平台卡的发布行) ----------------
@@ -5751,7 +5886,7 @@ function pbBlock(task, platform) {
5751
5886
  }
5752
5887
  btns += '<button class="primary" ' + (busy ? "disabled" : "") +
5753
5888
  ' onclick="pbCreateBook(\'' + esc(task.id) + "', '" + platform + '\')">' + t("创建作品") + "</button>";
5754
- btns += '<button class="ghost" onclick="pbRegToggle(\'' + platform + '\')" title="' +
5889
+ btns += '<button class="ghost" onclick="pbRegToggle(\'' + esc(task.id) + "','" + platform + '\')" title="' +
5755
5890
  esc(t("作品已在平台建好(建书流程没走通或手工建的)?补登记后直接发章,不会在平台新建")) + '">' + t("登记已有作品") + "</button>";
5756
5891
  }
5757
5892
  let extra = "";
@@ -5759,9 +5894,16 @@ function pbBlock(task, platform) {
5759
5894
  extra += '<div class="pb-hint">' + esc(t("若作品其实已在平台建好,点「登记已有作品」补登记,勿重复创建")) + "</div>";
5760
5895
  }
5761
5896
  if (!bookReady) {
5762
- extra += '<div class="pb-reg hidden" id="pb-reg-' + platform + '">' +
5763
- '<input id="pb-reg-title-' + platform + '" placeholder="' + esc(t("平台上的作品名(必填)")) + '">' +
5764
- '<input id="pb-reg-id-' + platform + '" placeholder="' + esc(t("作品ID(选填,管理页URL里有)")) + '">' +
5897
+ // 展开态+草稿值外置(S.pbRegDraft,按任务+平台键):整块重绘不再清空
5898
+ // 用户填了一半的作品名/ID——输入即存,重绘按草稿回填,登记成功才清。
5899
+ const reg = (S.pbRegDraft || {})[task.id + ":" + platform] || {};
5900
+ extra += '<div class="pb-reg' + (reg.open ? "" : " hidden") + '" id="pb-reg-' + platform + '">' +
5901
+ '<input id="pb-reg-title-' + platform + '" value="' + esc(reg.title || "") +
5902
+ '" placeholder="' + esc(t("平台上的作品名(必填)")) +
5903
+ '" oninput="pbRegDraftSave(\'' + esc(task.id) + "','" + platform + '\',\'title\',this.value)">' +
5904
+ '<input id="pb-reg-id-' + platform + '" value="' + esc(reg.id || "") +
5905
+ '" placeholder="' + esc(t("作品ID(选填,管理页URL里有)")) +
5906
+ '" oninput="pbRegDraftSave(\'' + esc(task.id) + "','" + platform + '\',\'id\',this.value)">' +
5765
5907
  '<button class="ghost" onclick="pbRegister(\'' + esc(task.id) + "', '" + platform + '\')">' + t("确认登记") + "</button>" +
5766
5908
  "</div>";
5767
5909
  }
@@ -5777,18 +5919,27 @@ function pbBlock(task, platform) {
5777
5919
  let _pbTimer = 0;
5778
5920
  function pbSyncState(task) {
5779
5921
  clearTimeout(_pbTimer);
5922
+ // 5s 节流折进调度间隔:距上一拉未满 5s 就等满再拉(pbKick 置 0 可立即拉)
5923
+ const delay = Math.max(120, 5000 - (Date.now() - (S.pubFetchAt || 0)));
5780
5924
  _pbTimer = setTimeout(async () => {
5781
- const now = Date.now();
5782
- if (now - (S.pubFetchAt || 0) < 5000) return; // 节流:主轮询不每次都打 /api/publish
5783
- S.pubFetchAt = now;
5925
+ // 自续轮询链:详情区签名守卫上线后,主渲染泵不再每 8s 带跑本面板,
5926
+ // 发布状态自己转——面板可见且仍指向同一任务就 5s 一拉,收起/切走
5927
+ // 自然停,下次 renderBookMetaPanel 重新点火。task 对象可能已老,
5928
+ // 一律按 id 从 S.state 重解析,渲染吃的永远是新数据。
5929
+ const box = $("rd-bookmeta");
5930
+ if (!task || !task.id || !box || box.classList.contains("hidden") ||
5931
+ $("run-detail").classList.contains("hidden")) return;
5932
+ const tk = ((S.state || {}).tasks || []).find((x) => x.id === task.id);
5933
+ if (!tk || ((S.lastRunTask || {}).id || "") !== tk.id) return;
5934
+ S.pubFetchAt = Date.now();
5784
5935
  let sig = "";
5785
5936
  try {
5786
5937
  const v = await api("/api/publish");
5787
5938
  S.pubState = v;
5788
5939
  sig += JSON.stringify(v.platforms || {});
5789
- } catch (e) { return; }
5940
+ } catch (e) { pbSyncState(tk); return; }
5790
5941
  try {
5791
- const ti = await api("/api/publish/task/" + encodeURIComponent(task.id) + "/history");
5942
+ const ti = await api("/api/publish/task/" + encodeURIComponent(tk.id) + "/history");
5792
5943
  S.pubTaskInfo = ti;
5793
5944
  sig += "#" + JSON.stringify(ti.books || {}) + "#" + (ti.history || []).length;
5794
5945
  // 已发章数自动校准:平台在线且登记在案、超 10 分钟没对过账就静默对一次
@@ -5803,22 +5954,23 @@ function pbSyncState(task) {
5803
5954
  if (ps2.status === "connected" && nowMs - at > 600000 &&
5804
5955
  nowMs - (S["_pbSyncAt_" + p] || 0) > 600000) {
5805
5956
  S["_pbSyncAt_" + p] = nowMs;
5806
- api("/api/publish/task/" + encodeURIComponent(task.id) + "/sync-published",
5957
+ api("/api/publish/task/" + encodeURIComponent(tk.id) + "/sync-published",
5807
5958
  { method: "POST", body: JSON.stringify({ platform: p }) }).catch(() => {});
5808
5959
  }
5809
5960
  });
5810
5961
  } catch (e) { /* 任务级失败不阻塞平台状态 */ }
5811
5962
  try {
5812
5963
  // 批量发布视图(待发数/护栏/进度)——进行中时靠本节流轮询自然刷新
5813
- const au = await api("/api/publish/task/" + encodeURIComponent(task.id) + "/pending");
5964
+ const au = await api("/api/publish/task/" + encodeURIComponent(tk.id) + "/pending");
5814
5965
  S.pubAuto = au;
5815
5966
  sig += "@" + JSON.stringify(au.books || {}) + "@" + JSON.stringify(au.running || {});
5816
5967
  } catch (e) { /* 视图缺失(老服务)不阻塞 */ }
5817
- if (sig !== (S._pbSig || "") && !$("rd-bookmeta").classList.contains("hidden")) {
5968
+ if (sig !== (S._pbSig || "")) {
5818
5969
  S._pbSig = sig;
5819
- renderBookMetaPanel(task);
5970
+ renderBookMetaPanel(tk);
5820
5971
  } else S._pbSig = sig;
5821
- }, 120);
5972
+ pbSyncState(tk); // 续链:面板还开着就下一轮 5s 后再来
5973
+ }, delay);
5822
5974
  }
5823
5975
 
5824
5976
  window.pbSyncPublished = async function (taskId, platform) {
@@ -5856,19 +6008,34 @@ window.pbProbe = async function (platform) {
5856
6008
  };
5857
6009
 
5858
6010
  window.pbCreateBook = async function (taskId, platform) {
5859
- if (!confirm(t("将用生成的作品信息在平台自动填建书表单。填好后会停在最后一步,由你在浏览器里人工点提交。继续?"))) return;
6011
+ if (!(await uiConfirm(t("将用生成的作品信息在平台自动填建书表单。填好后会停在最后一步,由你在浏览器里人工点提交。继续?"),
6012
+ { ok: t("开始建书") }))) return;
5860
6013
  try {
5861
- await pbApiWithQualityOverride(
6014
+ const r = await pbApiWithQualityOverride(
5862
6015
  "/api/publish/task/" + encodeURIComponent(taskId) + "/create-book",
5863
- { platform }, "创建作品前的质量门禁未通过");
5864
- toast(t("正在自动填写建书表单…(完成后请在浏览器里确认提交)"));
6016
+ { platform }, "创建作品前的质量门禁未通过", taskId);
6017
+ if (!(r && r.gate_reviewed))
6018
+ toast(t("正在自动填写建书表单…(完成后请在浏览器里确认提交)"));
5865
6019
  } catch (e) { toast(t("建书失败:") + e.message, true); }
5866
6020
  S._pbSig = ""; pbKick();
5867
6021
  };
5868
6022
 
5869
- window.pbRegToggle = function (platform) {
6023
+ /* 登记草稿随输随存:重绘回填的写侧(读侧在 pbBlock 模板里 value=/open) */
6024
+ window.pbRegDraftSave = function (taskId, platform, key, val) {
6025
+ S.pbRegDraft = S.pbRegDraft || {};
6026
+ const k = taskId + ":" + platform;
6027
+ const d = S.pbRegDraft[k] = Object.assign({}, S.pbRegDraft[k]);
6028
+ d[key] = String(val == null ? "" : val);
6029
+ };
6030
+
6031
+ window.pbRegToggle = function (taskId, platform) {
5870
6032
  const box = $("pb-reg-" + platform);
5871
- if (box) box.classList.toggle("hidden");
6033
+ if (!box) return;
6034
+ box.classList.toggle("hidden");
6035
+ S.pbRegDraft = S.pbRegDraft || {};
6036
+ const k = taskId + ":" + platform;
6037
+ const d = S.pbRegDraft[k] = Object.assign({}, S.pbRegDraft[k]);
6038
+ d.open = !box.classList.contains("hidden"); // 展开态也外置:重绘不再把表单折回去
5872
6039
  };
5873
6040
 
5874
6041
  window.pbRegister = async function (taskId, platform) {
@@ -5881,6 +6048,7 @@ window.pbRegister = async function (taskId, platform) {
5881
6048
  toast(t("已登记,可直接发章"));
5882
6049
  const box = $("pb-reg-" + platform);
5883
6050
  if (box) box.classList.add("hidden");
6051
+ if (S.pbRegDraft) delete S.pbRegDraft[taskId + ":" + platform]; // 登记落定,草稿使命完成
5884
6052
  } catch (e) { toast(t("登记失败:") + e.message, true); }
5885
6053
  S._pbSig = ""; pbKick();
5886
6054
  };
@@ -5938,20 +6106,23 @@ window.pbUploadChapter = async function (taskId, platform) {
5938
6106
  };
5939
6107
 
5940
6108
  window.pbSendChapter = async function (taskId, platform, relName) {
5941
- if (!confirm(t("将把《{0}》填进平台章节编辑器,填好后由你人工提交。继续?", relName))) return;
6109
+ if (!(await uiConfirm(t("将把《{0}》填进平台章节编辑器,填好后由你人工提交。继续?", relName),
6110
+ { ok: t("开始填稿") }))) return;
5942
6111
  try {
5943
- await pbApiWithQualityOverride(
6112
+ const r = await pbApiWithQualityOverride(
5944
6113
  "/api/publish/task/" + encodeURIComponent(taskId) + "/chapter",
5945
- { platform, file: relName }, "发章前的质量门禁未通过");
5946
- toast(t("正在填写章节…(完成后请在浏览器里确认提交)"));
5947
- const box = $("pb-ch-" + platform);
5948
- if (box) {
5949
- const n = chapterNumOf(relName);
5950
- box.classList.remove("hidden");
5951
- box.innerHTML = '<div class="pb-hint">' + esc(t("填稿已完成,请先在浏览器里提交,再点击确认")) +
5952
- '</div><button class="ghost pb-tool" onclick="pbConfirmChapter(\'' +
5953
- esc(taskId) + "', '" + platform + "', " + Number(n || 0) +
5954
- ')">' + t("我已在平台提交") + "</button>";
6114
+ { platform, file: relName }, "发章前的质量门禁未通过", taskId);
6115
+ if (!(r && r.gate_reviewed)) {
6116
+ toast(t("正在填写章节…(完成后请在浏览器里确认提交)"));
6117
+ const box = $("pb-ch-" + platform);
6118
+ if (box) {
6119
+ const n = chapterNumOf(relName);
6120
+ box.classList.remove("hidden");
6121
+ box.innerHTML = '<div class="pb-hint">' + esc(t("填稿已完成,请先在浏览器里提交,再点击确认")) +
6122
+ '</div><button class="ghost pb-tool" onclick="pbConfirmChapter(\'' +
6123
+ esc(taskId) + "', '" + platform + "', " + Number(n || 0) +
6124
+ ')">' + t("我已在平台提交") + "</button>";
6125
+ }
5955
6126
  }
5956
6127
  } catch (e) { toast(t("发章失败:") + e.message, true); }
5957
6128
  S._pbSig = ""; pbKick();
@@ -5970,12 +6141,14 @@ window.pbPublishAll = async function (taskId, platform) {
5970
6141
  const au = (((S.pubAuto && S.pubAuto.books) || [])
5971
6142
  .find((b) => b.platform === platform)) || {};
5972
6143
  if (!au.pending) return;
5973
- if (!confirm(t("将从最靠前的待发章节开始填稿(共 {0} 章待发)。本轮只填一章并停在表单页,由你在浏览器里确认提交;提交后点击“我已在平台提交”,再选择下一章。护栏(每日上限/连续失败暂停)生效。继续?", au.pending))) return;
6144
+ if (!(await uiConfirm(t("将从最靠前的待发章节开始填稿(共 {0} 章待发)。本轮只填一章并停在表单页,由你在浏览器里确认提交;提交后点击“我已在平台提交”,再选择下一章。护栏(每日上限/连续失败暂停)生效。继续?", au.pending),
6145
+ { ok: t("开始发布") }))) return;
5974
6146
  try {
5975
- await pbApiWithQualityOverride(
6147
+ const r = await pbApiWithQualityOverride(
5976
6148
  "/api/publish/task/" + encodeURIComponent(taskId) + "/publish-all",
5977
- { platform }, "批量发布前的质量门禁未通过");
5978
- toast(t("正在填第一章稿——填好后请在浏览器窗口里确认提交,再重新校准"));
6149
+ { platform }, "批量发布前的质量门禁未通过", taskId);
6150
+ if (!(r && r.gate_reviewed))
6151
+ toast(t("正在填第一章稿——填好后请在浏览器窗口里确认提交,再重新校准"));
5979
6152
  } catch (e) { toast(t("自动发布失败:") + e.message, true); }
5980
6153
  S._pbSig = ""; pbKick();
5981
6154
  };
@@ -7471,13 +7644,20 @@ async function renderPreview(runId) {
7471
7644
  rdTabsSync(); // 预览可用性(rdTabAvail 认 #rd-preview 的显隐)随之更新
7472
7645
  }
7473
7646
 
7474
- window.copyText = function (t) {
7475
- // writeText 返回 promise,无头/非安全上下文会拒绝——必须挂 catch,否则
7476
- // 未处理的 promise 拒绝会以控制台错误冒出来(测试断言 0 错误会被它打爆)
7647
+ /* 复制文本到剪贴板:writeText 只在安全上下文(https / localhost)可用,
7648
+ * 用局域网 IP 或远程地址打开页面时 navigator.clipboard 是 undefined 或直接
7649
+ * 拒绝——必须回退 execCommand,否则按钮点了、剪贴板却是空的。done=真实
7650
+ * 复制成功后的回调;失败由 fallbackCopy 统一弹错误提示。 */
7651
+ window.copyText = function (t, done) {
7652
+ const ok = () => { if (done) done(); };
7477
7653
  try {
7478
- const p = navigator.clipboard.writeText(t);
7479
- if (p && p.catch) p.catch(() => { /* 剪贴板不可用则忽略 */ });
7480
- } catch (e) { /* 剪贴板不可用则忽略 */ }
7654
+ if (navigator.clipboard && navigator.clipboard.writeText) {
7655
+ // 未处理的 promise 拒绝会以控制台错误冒出来(测试断言 0 错误会被它打爆)
7656
+ navigator.clipboard.writeText(t).then(ok, () => fallbackCopy(t, ok));
7657
+ return;
7658
+ }
7659
+ } catch (e) { /* 落到 execCommand 兜底 */ }
7660
+ fallbackCopy(t, ok);
7481
7661
  };
7482
7662
 
7483
7663
  let currentLog = null; // { runId, rel }:同一路径在不同轮次也必须能切换
@@ -9187,8 +9367,7 @@ function bindChat() {
9187
9367
  if (!it) return;
9188
9368
  const txt = it.kind === "user" ? String(it.text || "") : chatCleanText(it.text);
9189
9369
  if (act.dataset.cact === "copy") {
9190
- copyText(txt);
9191
- toast(t("已复制"));
9370
+ copyText(txt, () => toast(t("已复制")));
9192
9371
  } else if (act.dataset.cact === "edit") {
9193
9372
  const ta = $("rd-chat-input");
9194
9373
  if (ta && txt) {
@@ -13328,7 +13507,8 @@ async function scanPorts() {
13328
13507
  }
13329
13508
 
13330
13509
  async function closePort(port) {
13331
- if (!confirm(t("结束占用端口 %1 的进程?先温和关闭,无效会自动强制结束(系统进程会被拒绝)").replace("%1", port))) return;
13510
+ if (!(await uiConfirm(t("结束占用端口 %1 的进程?先温和关闭,无效会自动强制结束(系统进程会被拒绝)").replace("%1", port),
13511
+ { ok: t("结束进程") }))) return;
13332
13512
  try {
13333
13513
  const r = await api("/api/ports/close", { method: "POST", body: JSON.stringify({ port }) });
13334
13514
  toast(r.message || (r.ok ? t("已关闭") : t("未能关闭")), !r.ok);
@@ -13643,7 +13823,7 @@ function setLangBtn(lang) {
13643
13823
  if (getLang() === lang) return;
13644
13824
  setLang(lang);
13645
13825
  // 失效全部渲染签名,让下一次 render() 把所有动态内容重画一遍
13646
- S.catSig = ""; S.modelsSig = ""; S.bindSig = ""; S.orchSig = ""; S.sideSig = ""; S.taskSig = ""; S.mgmt = {};
13826
+ S.catSig = ""; S.modelsSig = ""; S.bindSig = ""; S.orchSig = ""; S.sideSig = ""; S.taskSig = ""; S.runDetailSig = ""; S.mgmt = {};
13647
13827
  syncLangMode();
13648
13828
  // 浏览器标签标题
13649
13829
  document.title = codebeeDocumentTitle(lang);
@@ -14488,9 +14668,14 @@ function copyConnUrl() {
14488
14668
  function fallbackCopy(text, done) {
14489
14669
  const ta = document.createElement("textarea");
14490
14670
  ta.value = text;
14671
+ ta.style.position = "fixed";
14672
+ ta.style.opacity = "0"; // 藏起来但不 display:none(后者 select 不到)
14491
14673
  document.body.appendChild(ta);
14492
14674
  ta.select();
14493
- try { document.execCommand("copy"); done(); } catch (e) { toast(t("复制失败,请手动选择地址"), true); }
14675
+ try {
14676
+ if (!document.execCommand("copy")) throw new Error("copy rejected");
14677
+ if (done) done();
14678
+ } catch (e) { toast(t("复制失败,请手动长按/右键复制"), true); }
14494
14679
  ta.remove();
14495
14680
  }
14496
14681
 
@@ -14548,7 +14733,7 @@ window.exportBackup = async function () {
14548
14733
  : '<div><span class="bad">' + esc(t("☁ 远程推送失败:") + (remote.error || "")) + "</span></div>"))
14549
14734
  + (ext.length ? '<div><span class="bad">' + esc(t("以下外部目录不在备份里,需自行拷贝:"))
14550
14735
  + esc(ext.join("、")) + "</span></div>" : "")
14551
- + '<button class="ghost small" onclick="copyText(this)" data-copy="' + esc(r.path) + '">'
14736
+ + '<button class="ghost small" onclick="copyText(this.getAttribute(\'data-copy\'))" data-copy="' + esc(r.path) + '">'
14552
14737
  + esc(t("复制路径")) + "</button>";
14553
14738
  } catch (e) {
14554
14739
  $("bk-result").innerHTML = '<span class="bad">' + esc(e.message || String(e)) + "</span>";
@@ -14623,12 +14808,10 @@ async function pullRemoteBackup(name) {
14623
14808
  if (typeof inspectBackup === "function") inspectBackup(); // 直接进预览向导
14624
14809
  }
14625
14810
 
14626
- /* 通用「复制到剪贴板」:data-copy 属性携带文本(导出路径等) */
14627
- window.copyText = async function (btn) {
14628
- const text = btn.getAttribute("data-copy") || "";
14629
- try { await navigator.clipboard.writeText(text); toast(t("已复制")); }
14630
- catch (e) { fallbackCopy(text, () => toast(t("已复制"))); }
14631
- };
14811
+ /* ⚠️ 不要在这里再定义 window.copyText——文件前部已有一个字符串版实现
14812
+ * (含 execCommand 兜底 + 成功回调);曾在文件尾再定义一次元素版(读
14813
+ * data-copy 属性),后定义覆盖前定义,导致所有传字符串的复制入口
14814
+ * (作品简介/对话/工作目录路径)一概 TypeError 静默失灵。 */
14632
14815
 
14633
14816
  window.pickBackupFile = async function () {
14634
14817
  const cur = (($("bi-path") || {}).value || "").trim();
@@ -14764,6 +14947,13 @@ function collapseDrawerIfMobile() {
14764
14947
  function switchTab(name, shell) {
14765
14948
  if (name === "__phone") { openPhoneConnect(); return; } // 手机连接是弹框,不切页
14766
14949
  if (name === "__guide") { welcomeOpen(); return; } // 帮助中心是弹层,不切页(设置导航「软件」组)
14950
+ // 详情开着时切去别的页:详情一并关掉(它是 runs 子页的住户,name==="runs" 除外)。
14951
+ // 不关会留两个残留:S.detailRunId 让顶栏标题卡在「运行详情」;对话态详情给 main
14952
+ // 挂的 chat-fill(overflow:hidden 锁滚动,见 renderChatNav)留在原地——设置/概览
14953
+ // 等子页整页滚不动(2026-09-30 设置页滚不动实案)。closeRun 里的 renderChatNav
14954
+ // 会看到详情已隐而解锚。此处用旧的 S.mainPage 走 closeRun 归位分支,随后本函数
14955
+ // 再落到用户真正点的目标页。
14956
+ if ((S.detailRunId || S.detailTaskKey) && name !== "runs") closeRun();
14767
14957
  // 形态由目标页决定:快捷导航页(概览/运行记录/自动化)走 main——只换主栏内容、
14768
14958
  // 左栏任务树原地不动;其余页进设置导航。调用方可用 shell 显式覆盖(如已在设置里
14769
14959
  // 点运行详情,仍要留在设置侧栏)。用户主动进设置时左栏一定会换成设置导航。
@@ -14807,6 +14997,7 @@ function switchTab(name, shell) {
14807
14997
 
14808
14998
  /* 退出设置/回到新建任务表单:左栏恢复任务树,主栏回到任务页 */
14809
14999
  function exitSettings() {
15000
+ if (S.detailRunId || S.detailTaskKey) closeRun(); // 设置运行列表开着详情就回表单:一并关掉,防 chat-fill 锁滚动与标题残留
14810
15001
  S.tab = "tasks";
14811
15002
  S.mainPage = ""; // 主栏回到表单:不再是快捷导航页
14812
15003
  stopAutoPoll(); // 离开设置视图:自动化页轮询一并停掉