codebee 0.1.105 → 0.1.106

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
@@ -17,6 +17,8 @@ const BROWSER_STORAGE_KEY = "orch.browser.workspace.v1";
17
17
  const BROWSER_HOME = "__codebee_home__";
18
18
  const BROWSER_SEARCH = "https://www.google.com/search?igu=1&q=";
19
19
  const BROWSER_FRAME_BLOCKED_HOSTS = new Set(["github.com", "www.github.com", "x.com", "twitter.com", "www.twitter.com", "linkedin.com", "www.linkedin.com"]);
20
+ const BROWSER_HISTORY_LIMIT = 500;
21
+ const BROWSER_PERMISSION_FEATURES = ["camera", "microphone", "geolocation", "clipboard-read"];
20
22
  const BROWSER_TASK_STORAGE_KEY = "orch.browser.task.v1";
21
23
  let browserState = browserLoadState();
22
24
  let browserTaskId = "";
@@ -30,12 +32,27 @@ function browserLoadState() {
30
32
  const raw = JSON.parse(localStorage.getItem(BROWSER_STORAGE_KEY) || "{}");
31
33
  const tabs = Array.isArray(raw.tabs) && raw.tabs.length ? raw.tabs : [{ id: "home", url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 }];
32
34
  const kept = tabs.slice(0, 20).map(browserNormalizeTab);
33
- return { tabs: kept, active: Math.max(0, Math.min(Number(raw.active) || 0, kept.length - 1)) };
35
+ const migratedHistory = kept.flatMap((tab) => tab.history.slice(0, tab.index + 1).map((url, index) => ({
36
+ url, title: browserTitle(url), visitedAt: Date.now() - (tab.history.length - index),
37
+ })));
38
+ const history = Array.isArray(raw.history) ? raw.history.filter(browserValidHistoryEntry) : migratedHistory;
39
+ return {
40
+ tabs: kept,
41
+ active: Math.max(0, Math.min(Number(raw.active) || 0, kept.length - 1)),
42
+ history: history.slice(-BROWSER_HISTORY_LIMIT),
43
+ permissions: raw.permissions && typeof raw.permissions === "object" && !Array.isArray(raw.permissions) ? raw.permissions : {},
44
+ updatedAt: Number(raw.updatedAt) || 0,
45
+ };
34
46
  } catch (e) {
35
- return { tabs: [{ id: "home", url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 }], active: 0 };
47
+ return { tabs: [{ id: "home", url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 }], active: 0, history: [], permissions: {}, updatedAt: 0 };
36
48
  }
37
49
  }
38
50
 
51
+ function browserValidHistoryEntry(entry) {
52
+ return !!entry && typeof entry.url === "string" && entry.url !== BROWSER_HOME &&
53
+ /^https?:\/\//i.test(entry.url) && Number.isFinite(Number(entry.visitedAt));
54
+ }
55
+
39
56
  function browserNormalizeTab(tab, i) {
40
57
  const history = Array.isArray(tab.history) && tab.history.length ? tab.history.slice(-20) : [tab.url || BROWSER_HOME];
41
58
  const index = Math.max(0, Math.min(Number(tab.index) || history.length - 1, history.length - 1));
@@ -44,14 +61,43 @@ function browserNormalizeTab(tab, i) {
44
61
 
45
62
  function browserSaveState() {
46
63
  try {
64
+ const stored = JSON.parse(localStorage.getItem(BROWSER_STORAGE_KEY) || "null");
65
+ if (stored && Number(stored.updatedAt) > Number(browserState.updatedAt || 0)) {
66
+ browserState = browserLoadState();
67
+ browserDraw(true);
68
+ return;
69
+ }
47
70
  browserState.tabs = browserState.tabs.slice(0, 20).map(browserNormalizeTab);
48
71
  browserState.active = Math.max(0, Math.min(browserState.active, browserState.tabs.length - 1));
72
+ browserState.history = (browserState.history || []).filter(browserValidHistoryEntry).slice(-BROWSER_HISTORY_LIMIT);
73
+ browserState.updatedAt = Math.max(Date.now(), Number(browserState.updatedAt || 0) + 1);
49
74
  localStorage.setItem(BROWSER_STORAGE_KEY, JSON.stringify(browserState));
50
75
  } catch (e) { /* private browsing/storage quota: browser remains usable in memory */ }
51
76
  const count = $("browser-history-count");
52
77
  if (count) count.textContent = t("{0} 个标签页", browserState.tabs.length);
53
78
  }
54
79
 
80
+ window.addEventListener("storage", (event) => {
81
+ if (event.key !== BROWSER_STORAGE_KEY) return;
82
+ if (!event.newValue) {
83
+ browserState = browserLoadState();
84
+ browserDraw(true);
85
+ return;
86
+ }
87
+ try {
88
+ const raw = JSON.parse(event.newValue);
89
+ const tabs = Array.isArray(raw.tabs) && raw.tabs.length ? raw.tabs.slice(0, 20).map(browserNormalizeTab) : [];
90
+ browserState = tabs.length ? {
91
+ tabs,
92
+ active: Math.max(0, Math.min(Number(raw.active) || 0, tabs.length - 1)),
93
+ history: Array.isArray(raw.history) ? raw.history.filter(browserValidHistoryEntry).slice(-BROWSER_HISTORY_LIMIT) : [],
94
+ permissions: raw.permissions && typeof raw.permissions === "object" && !Array.isArray(raw.permissions) ? raw.permissions : {},
95
+ updatedAt: Number(raw.updatedAt) || 0,
96
+ } : browserLoadState();
97
+ browserDraw(true);
98
+ } catch (e) { /* Ignore a partially written value; local state remains usable. */ }
99
+ });
100
+
55
101
  function browserTaskList() {
56
102
  return ((S.state || {}).tasks || []).filter((task) => task && task.id);
57
103
  }
@@ -93,7 +139,7 @@ function browserRenderHomeTask(task, run, status, verifyPass) {
93
139
  });
94
140
  if (!task) {
95
141
  title.textContent = t("还没有任务");
96
- meta.textContent = t("先新建任务,CodeBee 会在这里接管预览、验证与发布动作。");
142
+ meta.textContent = t("当前任务");
97
143
  runLine.textContent = t("等待任务进入工作台");
98
144
  Object.values(buttons).forEach((button) => { button.disabled = true; });
99
145
  return;
@@ -280,6 +326,72 @@ function browserSetFrameError(show, message) {
280
326
  if (text && message) text.textContent = message;
281
327
  }
282
328
 
329
+ function browserRecordVisit(url) {
330
+ if (!url || url === BROWSER_HOME) return;
331
+ const visitedAt = Date.now();
332
+ const entry = { url, title: browserTitle(url), visitedAt };
333
+ browserState.history = (browserState.history || []).filter((item) => item.url !== url);
334
+ browserState.history.push(entry);
335
+ browserState.history = browserState.history.slice(-BROWSER_HISTORY_LIMIT);
336
+ }
337
+
338
+ function browserCurrentOrigin() {
339
+ const tab = browserActiveTab();
340
+ try { return tab && tab.url !== BROWSER_HOME ? new URL(tab.url, location.origin).origin : ""; } catch (e) { return ""; }
341
+ }
342
+
343
+ function browserSitePermissions(origin) {
344
+ const saved = browserState.permissions && browserState.permissions[origin];
345
+ return new Set(Array.isArray(saved) ? saved.filter((feature) => BROWSER_PERMISSION_FEATURES.includes(feature)) : []);
346
+ }
347
+
348
+ function browserApplyFramePermissions() {
349
+ const frame = $("browser-frame");
350
+ if (!frame) return;
351
+ const origin = browserCurrentOrigin();
352
+ const allowed = browserSitePermissions(origin);
353
+ frame.setAttribute("allow", BROWSER_PERMISSION_FEATURES.filter((feature) => allowed.has(feature))
354
+ .map((feature) => feature + " 'src'").join("; "));
355
+ const summary = $("browser-site-origin");
356
+ if (summary) summary.textContent = origin || t("当前没有打开网站");
357
+ const controls = $("browser-permission-list");
358
+ if (controls) {
359
+ controls.querySelectorAll("input[data-browser-permission]").forEach((input) => {
360
+ input.checked = allowed.has(input.dataset.browserPermission);
361
+ input.disabled = !origin || !origin.startsWith("https://");
362
+ });
363
+ const note = $("browser-permission-note");
364
+ if (note) note.textContent = origin && !origin.startsWith("https://")
365
+ ? t("网站权限仅对 HTTPS 网站开放。")
366
+ : t("只允许此网站请求所选权限;浏览器仍会在网站实际使用时显示系统授权提示。");
367
+ }
368
+ const siteButton = $("browser-site-permissions");
369
+ if (siteButton) siteButton.disabled = !origin;
370
+ }
371
+
372
+ function browserRenderHistory() {
373
+ const list = $("browser-history-list");
374
+ if (!list) return;
375
+ const query = (($ ("browser-history-search") || {}).value || "").trim().toLocaleLowerCase();
376
+ const entries = (browserState.history || []).slice().reverse().filter((item) =>
377
+ !query || (item.title + " " + item.url).toLocaleLowerCase().includes(query));
378
+ if (!entries.length) {
379
+ list.innerHTML = '<div class="browser-dialog-empty">' + esc(t(query ? "没有匹配的浏览记录" : "暂无浏览记录")) + "</div>";
380
+ return;
381
+ }
382
+ list.innerHTML = entries.map((item) => '<button type="button" class="browser-history-entry" data-browser-history-url="' + esc(item.url) + '">' +
383
+ '<span class="browser-history-entry-mark"><svg class="ico" aria-hidden="true"><use href="#i-history"></use></svg></span>' +
384
+ '<span class="browser-history-entry-copy"><b>' + esc(item.title || browserTitle(item.url)) + '</b><small>' + esc(item.url) + '</small></span>' +
385
+ '<time>' + esc(new Date(item.visitedAt).toLocaleString()) + '</time></button>').join("");
386
+ }
387
+
388
+ function browserOpenDialog(id) {
389
+ const dialog = $(id);
390
+ if (!dialog) return;
391
+ if (typeof dialog.showModal === "function") dialog.showModal();
392
+ else dialog.setAttribute("open", "");
393
+ }
394
+
283
395
  function browserWatchFrame(url) {
284
396
  window.clearTimeout(browserFrameWatch);
285
397
  browserSetFrameError(false);
@@ -315,12 +427,13 @@ function browserDrawTabs() {
315
427
  });
316
428
  }
317
429
 
318
- function browserDraw() {
430
+ function browserDraw(skipSave) {
319
431
  const tab = browserActiveTab();
320
432
  if (!tab) return;
321
433
  browserRenderTaskContext();
322
434
  browserDrawTabs();
323
435
  const address = $("browser-address"), frame = $("browser-frame"), empty = $("browser-empty");
436
+ browserApplyFramePermissions();
324
437
  if (address) address.value = tab.url === BROWSER_HOME ? "" : tab.url;
325
438
  const canBack = tab.index > 0, canForward = tab.index < tab.history.length - 1;
326
439
  ["browser-back", "browser-forward"].forEach((id, i) => { const b = $(id); if (b) b.disabled = i ? !canForward : !canBack; });
@@ -338,7 +451,7 @@ function browserDraw() {
338
451
  }
339
452
  }
340
453
  browserRenderHint(tab.url);
341
- browserSaveState();
454
+ if (!skipSave) browserSaveState();
342
455
  }
343
456
 
344
457
  function browserNavigate(value, replace) {
@@ -347,6 +460,7 @@ function browserNavigate(value, replace) {
347
460
  const tab = browserActiveTab();
348
461
  if (replace) tab.history[tab.index] = url;
349
462
  else { tab.history = tab.history.slice(0, tab.index + 1).concat(url).slice(-20); tab.index = tab.history.length - 1; }
463
+ browserRecordVisit(url);
350
464
  tab.url = url; tab.title = browserTitle(url); browserDraw(); return true;
351
465
  }
352
466
 
@@ -356,14 +470,71 @@ function browserOpenPreview() {
356
470
 
357
471
  function browserBind() {
358
472
  if (!$("browser-frame")) return;
473
+ const contextToggle = $("browser-context-toggle");
474
+ document.body.classList.add("browser-context-collapsed");
475
+ if (contextToggle) contextToggle.setAttribute("aria-expanded", "false");
476
+ if (contextToggle) contextToggle.addEventListener("click", () => {
477
+ const collapsed = document.body.classList.toggle("browser-context-collapsed");
478
+ contextToggle.setAttribute("aria-expanded", collapsed ? "false" : "true");
479
+ });
480
+ const homeSearch = $("browser-home-search");
481
+ if (homeSearch) homeSearch.addEventListener("submit", (e) => {
482
+ e.preventDefault();
483
+ const input = homeSearch.querySelector("input");
484
+ browserNavigate(input && input.value);
485
+ if (input) input.value = "";
486
+ });
359
487
  $("browser-nav-form").addEventListener("submit", (e) => { e.preventDefault(); browserNavigate($("browser-address").value); });
360
488
  $("browser-back").addEventListener("click", () => { const t0 = browserActiveTab(); if (t0.index > 0) { t0.index--; t0.url = t0.history[t0.index]; browserDraw(); } });
361
489
  $("browser-forward").addEventListener("click", () => { const t0 = browserActiveTab(); if (t0.index < t0.history.length - 1) { t0.index++; t0.url = t0.history[t0.index]; browserDraw(); } });
362
490
  $("browser-reload").addEventListener("click", () => { const f = $("browser-frame"); if (f && !f.classList.contains("hidden")) f.src = f.src; });
363
491
  $("browser-home").addEventListener("click", () => browserNavigate(BROWSER_HOME));
364
- $("browser-new-tab").addEventListener("click", () => { browserState.tabs.push({ id: "tab-" + Date.now(), url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 }); browserState.active = browserState.tabs.length - 1; browserDraw(); });
492
+ $("browser-new-tab").addEventListener("click", () => {
493
+ browserState.tabs.push({ id: "tab-" + Date.now(), url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 });
494
+ browserState.active = browserState.tabs.length - 1;
495
+ browserDraw();
496
+ $("browser-address").focus();
497
+ });
365
498
  $("browser-open-external").addEventListener("click", () => { const t0 = browserActiveTab(); if (t0.url !== BROWSER_HOME) window.open(browserUrlAuth(t0.url), "_blank", "noopener"); });
366
499
  $("browser-frame-error-open").addEventListener("click", () => { const t0 = browserActiveTab(); if (t0.url !== BROWSER_HOME) window.open(browserUrlAuth(t0.url), "_blank", "noopener"); });
500
+ $("browser-history-open").addEventListener("click", () => { browserRenderHistory(); browserOpenDialog("browser-history-dialog"); });
501
+ $("browser-history-search").addEventListener("input", browserRenderHistory);
502
+ $("browser-history-list").addEventListener("click", (e) => {
503
+ const entry = e.target.closest("[data-browser-history-url]");
504
+ if (!entry) return;
505
+ $("browser-history-dialog").close();
506
+ browserNavigate(entry.dataset.browserHistoryUrl);
507
+ });
508
+ $("browser-clear-data").addEventListener("click", () => {
509
+ const confirm = $("browser-clear-confirm");
510
+ if (typeof confirm.showModal === "function") confirm.showModal(); else confirm.setAttribute("open", "");
511
+ });
512
+ $("browser-clear-confirm-yes").addEventListener("click", () => {
513
+ browserState.history = [];
514
+ browserState.permissions = {};
515
+ browserState.tabs = [{ id: "home", url: BROWSER_HOME, title: "CodeBee 浏览器", history: [BROWSER_HOME], index: 0 }];
516
+ browserState.active = 0;
517
+ browserSaveState();
518
+ $("browser-clear-confirm").close();
519
+ $("browser-history-dialog").close();
520
+ browserDraw(true);
521
+ });
522
+ $("browser-site-permissions").addEventListener("click", () => {
523
+ browserApplyFramePermissions();
524
+ browserOpenDialog("browser-permissions-dialog");
525
+ });
526
+ $("browser-permission-list").addEventListener("change", (e) => {
527
+ const input = e.target.closest("input[data-browser-permission]");
528
+ const origin = browserCurrentOrigin();
529
+ if (!input || !origin || !origin.startsWith("https://")) return;
530
+ const allowed = browserSitePermissions(origin);
531
+ if (input.checked) allowed.add(input.dataset.browserPermission); else allowed.delete(input.dataset.browserPermission);
532
+ if (allowed.size) browserState.permissions[origin] = Array.from(allowed);
533
+ else delete browserState.permissions[origin];
534
+ browserSaveState();
535
+ browserApplyFramePermissions();
536
+ });
537
+ document.querySelectorAll("[data-browser-dialog-close]").forEach((button) => button.addEventListener("click", () => button.closest("dialog").close()));
367
538
  $("browser-empty").addEventListener("click", (e) => {
368
539
  const button = e.target.closest("[data-browser-home-action]");
369
540
  if (!button || button.disabled) return;
@@ -1519,7 +1690,11 @@ function bindableProvs(provs) {
1519
1690
  }
1520
1691
 
1521
1692
  /* ---------------------------------------------------------- 通用弹框 */
1693
+ let _pbReturnFocus = null;
1522
1694
  function openModal(title, bodyHtml, footHtml) {
1695
+ _pbReturnFocus = null;
1696
+ delete $("modal").dataset.pbKey;
1697
+ $("modal").classList.remove("pb-selection-modal");
1523
1698
  $("modal-title").textContent = title;
1524
1699
  $("modal-body").innerHTML = bodyHtml || "";
1525
1700
  $("modal-foot").innerHTML = footHtml || "";
@@ -1531,9 +1706,165 @@ function closeModal() {
1531
1706
  const m = $("modal");
1532
1707
  if (!m) return;
1533
1708
  m.classList.add("hidden");
1709
+ delete m.dataset.pbKey;
1710
+ m.classList.remove("pb-selection-modal");
1534
1711
  $("modal-body").innerHTML = "";
1535
1712
  $("modal-foot").innerHTML = "";
1536
1713
  document.body.classList.remove("modal-open");
1714
+ if (_pbReturnFocus && _pbReturnFocus.isConnected) _pbReturnFocus.focus();
1715
+ _pbReturnFocus = null;
1716
+ }
1717
+
1718
+ /* ---------------------------------------------------------- 人工评审队列 */
1719
+ const REVIEW_QUEUE = { rows: [], loaded: false, loading: 0, saving: false };
1720
+ const REVIEW_LABELS = { requested: "待分配", assigned: "评审中", approved: "已批准",
1721
+ changes_requested: "要求修改", dismissed: "已关闭", expired: "已过期" };
1722
+
1723
+ function reviewActions(status) {
1724
+ if (status === "requested") return ["assign", "dismiss", "expire"];
1725
+ if (status === "assigned") return ["approve", "changes_requested", "dismiss", "expire"];
1726
+ if (status === "changes_requested") return ["dismiss", "expire"];
1727
+ return [];
1728
+ }
1729
+
1730
+ function reviewActionLabel(action) {
1731
+ return t(({ assign: "分配评审人", approve: "批准评审", changes_requested: "要求修改",
1732
+ dismiss: "关闭请求", expire: "标记过期" })[action] || action);
1733
+ }
1734
+
1735
+ function renderReviewQueue() {
1736
+ const list = $("review-queue-list");
1737
+ if (!list) return;
1738
+ const filter = $("review-queue-filter").value;
1739
+ const rows = REVIEW_QUEUE.rows.filter((row) => filter === "all" ||
1740
+ (filter === "active" ? reviewActions(row.status).length > 0 : row.status === filter))
1741
+ .sort((a, b) => Number(b.updated_at || 0) - Number(a.updated_at || 0));
1742
+ $("review-queue-count").textContent = t("显示 {0} / {1} 条", rows.length, REVIEW_QUEUE.rows.length);
1743
+ list.innerHTML = rows.map((row) => {
1744
+ const stamp = new Date(Number(row.updated_at || 0) * 1000);
1745
+ const time = Number.isFinite(stamp.getTime()) ? stamp.toLocaleString(getLang() === "en" ? "en-US" : "zh-CN") : "";
1746
+ return '<article class="review-request"><div class="review-request-head"><strong>' +
1747
+ esc(row.summary || t("未填写评审摘要")) + '</strong><span class="tag ' +
1748
+ (row.status === "approved" ? "ok" : row.status === "changes_requested" ? "warn" : "") + '">' +
1749
+ esc(t(REVIEW_LABELS[row.status] || row.status)) + '</span></div><div class="review-request-meta">' +
1750
+ '<span>' + esc(t("运行 ID")) + ': <code>' + esc(row.run_id) + '</code></span>' +
1751
+ (row.channel ? '<span>' + esc(t("评审渠道")) + ': ' + esc(row.channel) + '</span>' : "") +
1752
+ (row.reviewer ? '<span>' + esc(t("评审人")) + ': ' + esc(row.reviewer) + '</span>' : "") +
1753
+ '<time>' + esc(time) + '</time></div>' +
1754
+ (Array.isArray(row.paths) && row.paths.length ? '<div class="review-request-paths">' +
1755
+ row.paths.map((path) => '<code>' + esc(path) + '</code>').join("") + '</div>' : "") +
1756
+ (row.reason ? '<p class="review-request-reason">' + esc(row.reason) + '</p>' : "") +
1757
+ '<div class="actions">' + reviewActions(row.status).map((action) =>
1758
+ '<button type="button" class="ghost small" data-review-id="' + esc(row.id) +
1759
+ '" data-review-action="' + action + '">' + esc(reviewActionLabel(action)) + '</button>').join("") +
1760
+ '</div></article>';
1761
+ }).join("") || '<p class="hint">' + esc(t(REVIEW_QUEUE.loaded ? "暂无匹配的评审请求" : "正在加载评审请求…")) + '</p>';
1762
+ list.onclick = (event) => {
1763
+ const button = event.target.closest("[data-review-action]");
1764
+ if (button && list.contains(button)) openReviewTransition(button.dataset.reviewId, button.dataset.reviewAction);
1765
+ };
1766
+ }
1767
+
1768
+ async function loadReviewQueue() {
1769
+ const list = $("review-queue-list"), message = $("review-queue-message");
1770
+ if (!list || !message) return;
1771
+ const token = ++REVIEW_QUEUE.loading;
1772
+ list.setAttribute("aria-busy", "true");
1773
+ message.textContent = t("正在加载评审请求…");
1774
+ if (!REVIEW_QUEUE.loaded) renderReviewQueue();
1775
+ try {
1776
+ const data = await api("/api/reviews", { timeout: 15000 });
1777
+ if (token !== REVIEW_QUEUE.loading) return;
1778
+ if (!Array.isArray(data.reviews)) throw new Error(t("评审数据格式不正确"));
1779
+ REVIEW_QUEUE.rows = data.reviews;
1780
+ REVIEW_QUEUE.loaded = true;
1781
+ message.textContent = "";
1782
+ renderReviewQueue();
1783
+ } catch (e) {
1784
+ if (token === REVIEW_QUEUE.loading) message.textContent = t("评审队列加载失败:") + e.message;
1785
+ } finally {
1786
+ if (token === REVIEW_QUEUE.loading) list.setAttribute("aria-busy", "false");
1787
+ }
1788
+ }
1789
+
1790
+ function openReviewRequest() {
1791
+ if (REVIEW_QUEUE.saving) return;
1792
+ const runs = ((S.state || {}).runs || []).slice(0, 100);
1793
+ openModal(t("新建评审请求"), '<form id="review-request-form" class="form">' +
1794
+ '<div class="field"><label for="review-run-id">' + esc(t("运行 ID")) + ' *</label>' +
1795
+ '<input id="review-run-id" list="review-run-options" required maxlength="100" autocomplete="off">' +
1796
+ '<datalist id="review-run-options">' + runs.map((run) => '<option value="' + esc(run.id) + '">' +
1797
+ esc(run.goal || run.task_id || run.id) + '</option>').join("") + '</datalist></div>' +
1798
+ '<div class="field"><label for="review-summary">' + esc(t("评审摘要")) + '</label>' +
1799
+ '<textarea id="review-summary" rows="3" maxlength="1000"></textarea></div>' +
1800
+ '<div class="field"><label for="review-paths">' + esc(t("待评审文件(每行一个路径)")) + '</label>' +
1801
+ '<textarea id="review-paths" rows="3"></textarea></div>' +
1802
+ '<div class="field"><label for="review-channel">' + esc(t("评审渠道")) + '</label>' +
1803
+ '<input id="review-channel" maxlength="200"></div>' +
1804
+ '<p id="review-form-message" class="hint" role="status" aria-live="polite"></p></form>',
1805
+ '<button type="button" class="ghost" onclick="closeModal()">' + esc(t("取消")) + '</button>' +
1806
+ '<button id="review-save" type="submit" form="review-request-form" class="primary">' + esc(t("创建请求")) + '</button>');
1807
+ $("review-request-form").onsubmit = (event) => { event.preventDefault(); saveReviewRequest(); };
1808
+ $("review-run-id").value = S.detailRunId || "";
1809
+ $("review-run-id").focus();
1810
+ }
1811
+
1812
+ function openReviewTransition(id, action) {
1813
+ if (REVIEW_QUEUE.saving) return;
1814
+ const row = REVIEW_QUEUE.rows.find((item) => item.id === id);
1815
+ if (!row || !reviewActions(row.status).includes(action)) return;
1816
+ const assigning = action === "assign", needsReason = action === "changes_requested";
1817
+ openModal(reviewActionLabel(action), '<form id="review-transition-form" class="form">' +
1818
+ '<p class="hint">' + esc(row.summary || row.run_id) + '</p>' +
1819
+ '<div class="field"><label for="review-reviewer">' + esc(t("评审人")) + (assigning ? ' *' : '') + '</label>' +
1820
+ '<input id="review-reviewer" maxlength="200" value="' + esc(row.reviewer || "") + '"' +
1821
+ (assigning ? ' required' : '') + '></div>' +
1822
+ '<div class="field"><label for="review-reason">' + esc(t("处理意见")) + (needsReason ? ' *' : '') + '</label>' +
1823
+ '<textarea id="review-reason" rows="4" maxlength="1000"' + (needsReason ? ' required' : '') + '></textarea></div>' +
1824
+ '<p id="review-form-message" class="hint" role="status" aria-live="polite"></p></form>',
1825
+ '<button type="button" class="ghost" onclick="closeModal()">' + esc(t("取消")) + '</button>' +
1826
+ '<button id="review-save" type="submit" form="review-transition-form" class="primary">' + esc(reviewActionLabel(action)) + '</button>');
1827
+ $("review-transition-form").onsubmit = (event) => { event.preventDefault(); saveReviewTransition(id, action); };
1828
+ $(assigning ? "review-reviewer" : "review-reason").focus();
1829
+ }
1830
+
1831
+ async function reviewQueueSave(path, body) {
1832
+ if (REVIEW_QUEUE.saving) return;
1833
+ const button = $("review-save"), form = $("review-request-form") || $("review-transition-form");
1834
+ const message = $("review-form-message");
1835
+ REVIEW_QUEUE.saving = true;
1836
+ button.disabled = true;
1837
+ message.textContent = t("正在保存…");
1838
+ try {
1839
+ await api(path, { method: "POST", body: JSON.stringify(body), timeout: 15000 });
1840
+ if (form.isConnected) closeModal();
1841
+ toast(t("评审请求已保存"));
1842
+ await loadReviewQueue();
1843
+ } catch (e) {
1844
+ if (message.isConnected) message.textContent = t("评审请求保存失败:") + e.message;
1845
+ else toast(t("评审请求保存失败:") + e.message, true);
1846
+ } finally {
1847
+ REVIEW_QUEUE.saving = false;
1848
+ button.disabled = false;
1849
+ }
1850
+ }
1851
+
1852
+ async function saveReviewRequest() {
1853
+ const runId = $("review-run-id").value.trim();
1854
+ const paths = $("review-paths").value.split(/\r?\n/).map((path) => path.trim()).filter(Boolean);
1855
+ if (!runId) { $("review-form-message").textContent = t("请填写运行 ID"); return; }
1856
+ if (paths.length > 200 || paths.some((path) => path.length > 500)) {
1857
+ $("review-form-message").textContent = t("最多 200 个文件,每个路径最多 500 字符"); return;
1858
+ }
1859
+ await reviewQueueSave("/api/reviews", { run_id: runId, paths,
1860
+ summary: $("review-summary").value.trim(), channel: $("review-channel").value.trim() });
1861
+ }
1862
+
1863
+ async function saveReviewTransition(id, action) {
1864
+ const reviewer = $("review-reviewer").value.trim(), reason = $("review-reason").value.trim();
1865
+ if (action === "assign" && !reviewer) { $("review-form-message").textContent = t("请填写评审人"); return; }
1866
+ if (action === "changes_requested" && !reason) { $("review-form-message").textContent = t("请填写修改意见"); return; }
1867
+ await reviewQueueSave("/api/reviews/" + encodeURIComponent(id) + "/transition", { action, reviewer, reason });
1537
1868
  }
1538
1869
 
1539
1870
  /* catalog 里 model 可能是字符串,也可能是 {default:...} 之类的对象 */
@@ -1927,6 +2258,7 @@ function render() {
1927
2258
  renderImplSelects();
1928
2259
  renderRunList();
1929
2260
  renderSideTasks();
2261
+ CodeBeeActivity.render();
1930
2262
  renderRunDetail();
1931
2263
  renderCatalog();
1932
2264
  renderModels();
@@ -4358,11 +4690,14 @@ window.toggleNotifySound = function () {
4358
4690
  };
4359
4691
 
4360
4692
  function paintNotifyToggle() {
4361
- const b = $("btn-notify-toggle");
4693
+ CodeBeeActivity.paintToggle();
4694
+ const b = $("activity-sound");
4362
4695
  if (!b) return;
4363
4696
  const on = soundEnabled();
4364
4697
  b.classList.toggle("off", !on);
4365
4698
  b.title = on ? t("提示音已开(点击关闭)") : t("提示音已关(点击开启)");
4699
+ b.setAttribute("aria-label", b.title);
4700
+ b.setAttribute("aria-pressed", String(on));
4366
4701
  }
4367
4702
 
4368
4703
  // 「展开全部」钮:文件夹全开时箭头翻成收起朝向,提示文案跟着换(renderSideTasks 末尾与点击后各同步一次)
@@ -4386,6 +4721,43 @@ function paintArchToggle() {
4386
4721
  b.setAttribute("aria-label", tip);
4387
4722
  }
4388
4723
 
4724
+ /* 手机首页与侧栏共用目录/任务真源,保留独立的卡片展开态。 */
4725
+ function renderMobileWorkspaces(dirs) {
4726
+ const box = $("mobile-workspace-list");
4727
+ if (!box) return;
4728
+ const painted = box.querySelector("details");
4729
+ const open = new Set(Array.from(box.querySelectorAll("details[open]")).map((d) => d.dataset.dir));
4730
+ $("sub-tasks").classList.toggle("mobile-has-workspaces", dirs.length > 0);
4731
+ $("mobile-workspaces-count").textContent = t("{0} 个工作区 · {1} 个任务", dirs.length,
4732
+ dirs.reduce((n, d) => n + d.groups.length, 0));
4733
+ box.innerHTML = dirs.map((d, i) => {
4734
+ const label = d.dir === ORPHAN_DIR ? t("其他") : dirName(d.dir);
4735
+ const expanded = painted ? open.has(d.dir) : d.active || i === 0;
4736
+ return '<article class="mobile-workspace-card"><details class="mobile-workspace" data-dir="' + esc(d.dir) + '"' + (expanded ? ' open' : '') + '>' +
4737
+ '<summary><svg class="ico mobile-folder" aria-hidden="true"><use href="#i-folder"/></svg>' +
4738
+ '<span class="mobile-workspace-copy"><b>' + esc(label) + '</b><span title="' + esc(d.dir) + '">' + esc(d.dir === ORPHAN_DIR ? t("其他") : d.dir) + '</span>' +
4739
+ '<small>' + esc(relTime(d.time)) + '</small></span><span class="mobile-workspace-total">' + d.groups.length + '</span>' +
4740
+ '<svg class="ico mobile-workspace-chevron" aria-hidden="true"><use href="#i-chevron-r"/></svg></summary>' +
4741
+ '<div class="mobile-workspace-tasks">' + d.groups.map((g) =>
4742
+ '<button type="button" class="mobile-task" data-task="' + esc(g.taskId) + '" data-run="' + esc(g.runIds[0] || '') + '">' +
4743
+ '<span class="mobile-task-copy"><b>' + esc(g.title) + '</b><small>' + esc(relTime(g.time)) + '</small></span>' +
4744
+ (g.verdict ? '<span class="sbadge">' + t("待裁决") + '</span>' : '') +
4745
+ (g.archived ? '<span class="sbadge">' + t("已归档") + '</span>' : statusChip(g.active ? 'running' : g.status || t("未运行"))) + '</button>'
4746
+ ).join('') + '</div></details>' +
4747
+ (d.dir === ORPHAN_DIR ? '' : '<button type="button" class="ghost mobile-workspace-new" data-new-dir="' + esc(d.dir) + '" title="' + esc(t("在此工作区新建任务")) + '" aria-label="' + esc(t("在此工作区新建任务")) + '"><svg class="ico" aria-hidden="true"><use href="#i-plus"/></svg></button>') + '</article>';
4748
+ }).join('');
4749
+ }
4750
+
4751
+ function mobileNewTask(dir) {
4752
+ exitSettings();
4753
+ $("sub-tasks").classList.add("mobile-composing");
4754
+ if (dir) {
4755
+ $("f-workdir").value = dir;
4756
+ $("f-workdir").dispatchEvent(new Event("input", { bubbles: true }));
4757
+ }
4758
+ document.querySelector("main").scrollTop = 0;
4759
+ }
4760
+
4389
4761
  function renderSideTasks() {
4390
4762
  const box = $("side-tasks");
4391
4763
  if (!box) return;
@@ -4504,6 +4876,7 @@ function renderSideTasks() {
4504
4876
  '<span class="cnt">' + d.groups.length + "</span></summary>" +
4505
4877
  '<div class="dirbody">' + inner + "</div></details>";
4506
4878
  }).join("") || ('<div class="side-empty">' + (q ? t("无匹配任务") : t("暂无任务")) + "</div>");
4879
+ renderMobileWorkspaces(dirs);
4507
4880
  scanAttention(groups);
4508
4881
  paintNotifyToggle();
4509
4882
  syncSideExpandBtn();
@@ -4579,7 +4952,7 @@ function sideRowActivate(det) {
4579
4952
  /* 打开单条运行详情(右键菜单「打开详情」/检查器步骤条):不跳设置页——
4580
4953
  * 留在任务树主视图,主栏直接展示运行详情。
4581
4954
  * 带步骤号 n 时,详情渲染完自动定位到该步:滚动 + 高亮 + 展开它的日志。 */
4582
- window.sideOpenRun = function (id, n) {
4955
+ window.sideOpenRun = function (id, n, pinTab) {
4583
4956
  _taskStepRenderToken++;
4584
4957
  const earlierRunsButton = $("rd-load-earlier");
4585
4958
  if (earlierRunsButton) earlierRunsButton.hidden = true;
@@ -4588,17 +4961,18 @@ window.sideOpenRun = function (id, n) {
4588
4961
  S.detailTaskKey = null;
4589
4962
  if (!S.histJump && typeof histPush === "function") histPush({ m: "main", tab: "run-detail" });
4590
4963
  showDetailInMain();
4591
- openRun(id, n ? "steps" : null); // 带步骤号:钉住步骤分区,自动选卡不抢
4964
+ openRun(id, pinTab || (n ? "steps" : null)); // 活动视图可直达对应分区
4592
4965
  };
4593
4966
 
4594
4967
  /* 打开任务级详情:任务可能被续跑/重试过多次,步骤分散在多条 run 里。
4595
4968
  * 按时间顺序列出该任务全部 run 的全部步骤,run 之间加分隔条。 */
4596
- window.sideOpenTask = function (key) {
4969
+ window.sideOpenTask = function (key, pinTab) {
4597
4970
  _taskStepRenderToken++;
4598
4971
  chatJumpLatest(); // 首开任务详情:对话时间线落最新(默认落蜂巢时切到对话页签也带到底)
4599
4972
  $("rd-steps").innerHTML = '<div class="hint" role="status">' + esc(t("正在加载任务详情…")) + "</div>";
4600
4973
  S.lastRun = null;
4601
4974
  S.detailTaskKey = key;
4975
+ S.activityTarget = pinTab ? { key, tab: pinTab } : null;
4602
4976
  S.detailRunId = null;
4603
4977
  S.focusStep = 0;
4604
4978
  S.taskSig = "";
@@ -4612,8 +4986,9 @@ window.sideOpenTask = function (key) {
4612
4986
  detailSideReset(); // 换详情目标:任务级 side 缓存作废,等首拉
4613
4987
  const olderRunsButton = $("rd-load-earlier");
4614
4988
  if (olderRunsButton) olderRunsButton.hidden = true;
4615
- S.rdHiveFirst = true; // 侧栏点开任务详情:首拉后默认落「蜂巢」(用户 2026-09-29)
4616
- S.rdTab = null;
4989
+ S.rdHiveFirst = !pinTab; // 活动入口钉住目标页签;项目入口默认落蜂巢
4990
+ S.rdTab = pinTab || null;
4991
+ S.rdTabPin = !!pinTab;
4617
4992
  S.rdTabSig = "";
4618
4993
  applyRdTabs();
4619
4994
  syncInspectorVis(); // 详情已铺开:检查器让位(选中保留,返回列表自动滑回)
@@ -5487,6 +5862,13 @@ function rdTabBadges() {
5487
5862
  * renderPreview 等收尾调用——预览可用性是异步翻过来的,翻过来这次必须重选卡)。 */
5488
5863
  function rdTabsSync(ctx) {
5489
5864
  if (ctx) S._rdCtx = ctx;
5865
+ const target = S.activityTarget;
5866
+ if (target && target.key === S.detailTaskKey && rdTabAvail()[target.tab]) {
5867
+ S.rdTab = target.tab;
5868
+ S.rdTabPin = true;
5869
+ S.rdHiveFirst = false;
5870
+ S.activityTarget = null;
5871
+ }
5490
5872
  const c = ctx || S._rdCtx || {};
5491
5873
  if (!S.rdTabPin) {
5492
5874
  const chatAvail = $("rd-chat") && !$("rd-chat").classList.contains("hidden");
@@ -6064,6 +6446,7 @@ function renderBookMetaPanel(task) {
6064
6446
  box.classList.remove("hidden");
6065
6447
  box.innerHTML = html;
6066
6448
  box.dataset.taskId = task.id; // 跨任务守卫标记:展开态/输入保护只对同一任务生效
6449
+ pbRefreshSelectionDialog(task);
6067
6450
  // TAB 徽章:已生成平台数(生成中显示 ●,随下次轮询刷新)
6068
6451
  const badge = document.querySelector('#rd-tabs .rd-tab[data-tab="bookmeta"] .rd-badge');
6069
6452
  if (badge) {
@@ -6165,7 +6548,7 @@ const PB_ST = {
6165
6548
  error: ["出错", "pb-st-err"],
6166
6549
  };
6167
6550
 
6168
- function pbBlock(task, platform) {
6551
+ function pbBlock(task, platform, selectionOnly = false) {
6169
6552
  const ps = (S.pubState && S.pubState.platforms && S.pubState.platforms[platform]) || {};
6170
6553
  const st = ps.status || "none";
6171
6554
  const [label, cls] = PB_ST[st] || PB_ST.none;
@@ -6186,6 +6569,8 @@ function pbBlock(task, platform) {
6186
6569
  const remoteN = hasRemote ? ((typeof book.remote_total === "number")
6187
6570
  ? book.remote_total : book.remote_published) : null;
6188
6571
  const nCh = hasRemote ? remoteN : localN;
6572
+ const publishedLatest = Math.max(Number(book && book.confirmed_published_through) || 0,
6573
+ ((book && book.remote_published_nos) || []).reduce((max, n) => Math.max(max, Number(n) || 0), 0));
6189
6574
  let chTitle = "";
6190
6575
  if (hasRemote) {
6191
6576
  const parts = [];
@@ -6217,7 +6602,8 @@ function pbBlock(task, platform) {
6217
6602
  btns += ' <button class="primary" ' + (busy ? "disabled" : "") +
6218
6603
  ' onclick="pbUploadChapter(\'' + esc(task.id) + "', '" + platform + '\')" title="' +
6219
6604
  esc(chTitle) + '">' +
6220
- t("发一章") + (nCh ? t("(已发 ") + nCh + t(")") : "") + "</button>";
6605
+ t("发一章") + (publishedLatest ? t("(已发布至 {0} 章)", publishedLatest)
6606
+ : nCh ? t("(已发 ") + nCh + t(")") : "") + "</button>";
6221
6607
  btns += ' <button class="ghost pb-tool" ' + (busy ? "disabled" : "") +
6222
6608
  ' onclick="pbSyncPublished(\'' + esc(task.id) + "', '" + platform + '\')" title="' +
6223
6609
  esc(t("按平台实况纠正已发章数")) + '">' + t("校准") + "</button>";
@@ -6251,7 +6637,8 @@ function pbBlock(task, platform) {
6251
6637
  // 「发布所选」人工模式 / 「直发所选」逐章自动提交(护栏照常生效)。
6252
6638
  // 分卷由服务端按卷计划自动选/建(volumes.py),界面上不用管卷。
6253
6639
  const its = (au.items && au.items.length ? au.items : [])
6254
- .map((x) => ({ n: Number(x.chapter_no), v: x.volume || "" }))
6640
+ .map((x) => ({ n: Number(x.chapter_no), v: x.volume || "", review: x.review_status || "pending",
6641
+ reason: x.review_reason || "", ready: x.publish_ready === true }))
6255
6642
  .filter((x) => x.n > 0);
6256
6643
  if (its.length && !run) {
6257
6644
  const key = task.id + ":" + platform;
@@ -6266,49 +6653,79 @@ function pbBlock(task, platform) {
6266
6653
  }
6267
6654
  if (!S.pbSel[key] || S.pbSel[key].sig !== sig)
6268
6655
  S.pbSel[key] = { sig, sel: new Set(its.map((x) => x.n)), groups };
6656
+ S.pbSel[key].groups = groups;
6657
+ S.pbSel[key].ready = new Set(its.filter((x) => x.ready).map((x) => x.n));
6269
6658
  const sel = S.pbSel[key].sel;
6270
6659
  let chips = "";
6271
6660
  for (let gi = 0; gi < groups.length; gi++) {
6272
6661
  const g = groups[gi];
6662
+ chips += '<section class="pb-pend-volume" data-volume="' + gi + '">';
6273
6663
  if (groups.length > 1 || g.v) {
6274
6664
  // 卷头=整卷开关:全选打勾、部分选中显示 已选/总(2026-10-10 按卷选章)
6275
6665
  const cnt = g.ns.filter((n) => sel.has(n)).length;
6276
6666
  const allOn = cnt === g.ns.length;
6277
- chips += '<div class="pb-vol"><label class="pb-vol-sel' + (allOn ? " on" : "") +
6667
+ chips += '<div class="pb-vol"><label class="pb-vol-sel' + (allOn ? " on" : cnt ? " partial" : "") +
6278
6668
  '" title="' + esc(t("勾选卷头=整卷进所选,再点取消")) + '">' +
6279
6669
  '<input type="checkbox" ' + (allOn ? "checked" : "") +
6280
6670
  ' onchange="pbSelVolume(\'' + esc(task.id) + "','" + platform + "'," + gi + ',this.checked)">' +
6281
- esc(g.v || t("未分卷")) + t("(") + (allOn ? "" : cnt + "/") + g.ns.length + t(")") + "</label></div>";
6671
+ '<span class="pb-vol-name">' + esc(g.v || t("未分卷")) + '</span>' +
6672
+ '<span class="pb-vol-count">' + esc(t("已选 {0}/{1} 章", cnt, g.ns.length)) + "</span></label></div>";
6282
6673
  }
6283
- for (const n of g.ns)
6284
- chips += '<label class="pb-chip' + (sel.has(n) ? " on" : "") + '">' +
6674
+ chips += '<div class="pb-chapter-grid">';
6675
+ for (const n of g.ns) {
6676
+ const item = its.find((x) => x.n === n);
6677
+ const states = { ready: "已过审 · 可直发", passed_blocked: "已过审 · 发布受限",
6678
+ failed: "审稿未通过", stale: "已修改 · 需重审", pending: "待审或评审不足" };
6679
+ const review = Object.hasOwn(states, item.review) ? item.review : "pending";
6680
+ chips += '<label class="pb-chip review-' + review + (sel.has(n) ? " on" : "") + '" data-chapter="' + n + '" title="' +
6681
+ esc(t("第 {0} 章", n) + (item.reason ? " · " + item.reason : "")) + '">' +
6285
6682
  '<input type="checkbox" ' + (sel.has(n) ? "checked" : "") +
6286
6683
  ' onchange="pbSelToggle(\'' + esc(task.id) + "','" + platform + "'," + n + ',this.checked)">' +
6287
- n + "</label>";
6684
+ '<span class="pb-chip-number">' + n + '</span><small class="pb-chip-review">' + esc(t(states[review])) + "</small></label>";
6685
+ }
6686
+ chips += "</div></section>";
6288
6687
  }
6289
6688
  pendHtml += '<div class="pb-pend" data-key="' + esc(key) + '">' +
6290
6689
  '<div class="pb-pend-head">' +
6291
- '<span>' + esc(t("待发 ")) + its.length + esc(t(" 章(按卷计划自动分卷)")) + "</span>" +
6690
+ '<div class="pb-pend-heading"><strong>' + esc(t("待发 ")) + its.length + esc(t(" 章")) + '</strong>' +
6691
+ '<span>' + esc(t("按卷计划自动分卷")) + "</span></div>" +
6692
+ '<button class="ghost pb-ready-select" ' + (S.pbSel[key].ready.size ? "" : "disabled") +
6693
+ ' title="' + esc(t("仅选择单章审稿和发布门禁均已通过的章节")) + '" onclick="pbSelReady(\'' + esc(task.id) + "','" + platform + '\')">' + esc(t("只选可直发")) + '</button>' +
6292
6694
  '<a href="javascript:void(0)" onclick="pbSelAll(\'' + esc(task.id) + "','" + platform + "',true)" + '">' + esc(t("全选")) + "</a>" +
6293
6695
  '<a href="javascript:void(0)" onclick="pbSelAll(\'' + esc(task.id) + "','" + platform + "',false)" + '">' + esc(t("清空")) + "</a>" +
6294
6696
  "</div>" +
6697
+ '<div class="pb-review-legend" aria-label="' + esc(t("章节审核状态说明")) + '">' +
6698
+ '<span class="review-ready"><i aria-hidden="true"></i>' + esc(t("已过审 · 可直发")) + '</span>' +
6699
+ '<span class="review-passed_blocked"><i aria-hidden="true"></i>' + esc(t("已过审 · 发布受限")) + '</span>' +
6700
+ '<span class="review-failed"><i aria-hidden="true"></i>' + esc(t("审稿未通过")) + '</span>' +
6701
+ '<span class="review-pending"><i aria-hidden="true"></i>' + esc(t("待审或评审不足")) + '</span>' +
6702
+ "</div>" +
6703
+ '<div class="pb-pend-progress">' +
6704
+ (au.published_latest ? '<strong>' + esc(t("已发布至第 {0} 章", au.published_latest)) + '</strong>' : '') +
6705
+ (au.next_chapter ? '<span>' + esc(t("下一章:第 {0} 章", au.next_chapter)) + '</span>' : '') +
6706
+ '<span>' + esc(t("可直发 {0} 章 · 单章过审 {1} 章", au.ready_count || 0, au.review_passed_count || 0)) + '</span></div>' +
6295
6707
  '<div class="pb-pend-chips">' + chips + "</div>" +
6708
+ (au.drafted_nos && au.drafted_nos.length ? '<details class="pb-drafted-list"><summary>' +
6709
+ esc(t("已存草稿 {0} 章(未发布)", au.drafted_nos.length)) + '</summary><p>' +
6710
+ esc(au.drafted_nos.map((n) => t("第 {0} 章", n)).join(" · ")) + '</p></details>' : '') +
6711
+ '<div class="pb-pend-foot"><span class="pb-pend-count" aria-live="polite">' + esc(t("已选 {0}/{1} 章", sel.size, its.length)) + '</span>' +
6712
+ '<span class="pb-pend-mode-hint">' + esc(t("发布需逐章确认,直发自动提交,草稿不上线。")) + '</span>' +
6296
6713
  '<div class="pb-pend-btns">' +
6297
- '<button class="ghost" ' + (busy || !sel.size ? "disabled" : "") +
6714
+ '<button class="primary" data-selection-action data-locked="' + busy + '" ' + (busy || !sel.size ? "disabled" : "") +
6298
6715
  ' title="' + esc(au.guard_ok ? "" : t("护栏提示:") + (au.guard_reason || "")) + '"' +
6299
6716
  ' onclick="pbPublishSelected(\'' + esc(task.id) + "','" + platform + '\',false)">' +
6300
6717
  t("发布所选") + t("(") + sel.size + t(")") + "</button>" +
6301
- '<button class="ghost" ' + (busy || !sel.size ? "disabled" : "") +
6718
+ '<button class="ghost" data-selection-action data-locked="' + busy + '" ' + (busy || !sel.size ? "disabled" : "") +
6302
6719
  ' title="' + esc(t("所选章节逐章自动提交(无人值守),护栏每日上限/连败退避照常生效") +
6303
6720
  (au.guard_ok ? "" : ";" + t("护栏提示:") + (au.guard_reason || ""))) + '"' +
6304
6721
  ' onclick="pbPublishSelected(\'' + esc(task.id) + "','" + platform + '\',true)">' +
6305
6722
  t("直发所选") + t("(") + sel.size + t(")") + "</button>" +
6306
- '<button class="ghost" ' + (busy || !sel.size || au.draft_ok === false ? "disabled" : "") +
6723
+ '<button class="ghost" data-selection-action data-locked="' + (busy || au.draft_ok === false) + '" ' + (busy || !sel.size || au.draft_ok === false ? "disabled" : "") +
6307
6724
  ' title="' + esc(t("所选章节逐章自动存草稿(不上线、不受质量门禁拦截),完成后到平台检查并提交发布") +
6308
6725
  (au.draft_ok !== false ? "" : ";" + t("护栏提示:") + (au.draft_reason || ""))) + '"' +
6309
6726
  ' onclick="pbDraftSelected(\'' + esc(task.id) + "','" + platform + '\')">' +
6310
6727
  t("存草稿所选") + t("(") + sel.size + t(")") + "</button>" +
6311
- "</div></div>";
6728
+ "</div></div></div>";
6312
6729
  }
6313
6730
  if (au.pending > 0 && au.draft_ok === false) {
6314
6731
  // 硬护栏(建书未确认/每日上限/连败退避):存草稿也走不了,红色示警
@@ -6346,6 +6763,15 @@ function pbBlock(task, platform) {
6346
6763
  btns += '<button class="ghost" onclick="pbRegToggle(\'' + esc(task.id) + "','" + platform + '\')" title="' +
6347
6764
  esc(t("作品已在平台建好(建书流程没走通或手工建的)?补登记后直接发章,不会在平台新建")) + '">' + t("登记已有作品") + "</button>";
6348
6765
  }
6766
+ if (selectionOnly) return pendHtml;
6767
+ if (pendHtml) {
6768
+ const pending = (((S.pubAuto || {}).books || []).find((b) => b.platform === platform) || {}).pending || 0;
6769
+ const view = (((S.pubAuto || {}).books || []).find((b) => b.platform === platform) || {});
6770
+ pendHtml = '<div class="pb-pend-summary"><span>' + esc(t("待发 ")) + pending + esc(t(" 章")) +
6771
+ (view.next_chapter ? ' · ' + esc(t("下一章:第 {0} 章", view.next_chapter)) : '') + '</span>' +
6772
+ '<button class="primary" onclick="pbOpenSelection(\'' + esc(task.id) + "','" + platform + '\')">' +
6773
+ esc(t("选择章节并发布")) + '</button></div>';
6774
+ }
6349
6775
  let extra = "";
6350
6776
  extra += pendHtml;
6351
6777
  if (st === "error" && !bookReady && ps.last_action === "create_book") {
@@ -6375,6 +6801,53 @@ function pbBlock(task, platform) {
6375
6801
  }
6376
6802
 
6377
6803
  let _pbTimer = 0;
6804
+ window.pbOpenSelection = function (taskId, platform) {
6805
+ const task = ((S.state || {}).tasks || []).find((x) => x.id === taskId);
6806
+ if (!task) return;
6807
+ const html = pbBlock(task, platform, true);
6808
+ if (!html) { toast(t("当前没有可选择的待发章节")); return; }
6809
+ const title = t("选择章节并发布") + " · " + t(platform === "fanqie" ? "番茄" : "七猫");
6810
+ const returnFocus = document.activeElement;
6811
+ openModal(title, "", "");
6812
+ _pbReturnFocus = returnFocus;
6813
+ $("modal").dataset.pbKey = taskId + ":" + platform;
6814
+ $("modal").classList.add("pb-selection-modal");
6815
+ pbRefreshSelectionDialog(task);
6816
+ const first = $("modal-body").querySelector("input");
6817
+ if (first) first.focus({ preventScroll: true });
6818
+ };
6819
+
6820
+ function pbRefreshSelectionDialog(task) {
6821
+ const modal = $("modal");
6822
+ if (!modal || modal.classList.contains("hidden") || !modal.dataset.pbKey) return;
6823
+ const platform = ["fanqie", "qimao"].find((p) => modal.dataset.pbKey === task.id + ":" + p);
6824
+ if (!platform) { closeModal(); return; }
6825
+ const html = pbBlock(task, platform, true);
6826
+ if (!html) { closeModal(); return; }
6827
+ const shell = document.createElement("div");
6828
+ shell.innerHTML = html;
6829
+ const foot = shell.querySelector(".pb-pend-foot");
6830
+ foot.remove();
6831
+ const body = $("modal-body");
6832
+ const scrollTop = body.scrollTop;
6833
+ const active = document.activeElement;
6834
+ const hadFocus = modal.contains(active);
6835
+ const chip = active && active.closest(".pb-chip");
6836
+ const volume = active && active.closest(".pb-pend-volume");
6837
+ const action = active && active.getAttribute("onclick");
6838
+ const chapter = chip ? chip.dataset.chapter : "";
6839
+ const volumeIndex = volume ? volume.dataset.volume : "";
6840
+ body.innerHTML = shell.innerHTML;
6841
+ body.scrollTop = scrollTop;
6842
+ $("modal-foot").innerHTML = foot.outerHTML;
6843
+ pbSelCountSync(modal.dataset.pbKey);
6844
+ let target = null;
6845
+ if (chapter) target = [...body.querySelectorAll(".pb-chip")].find((c) => c.dataset.chapter === chapter)?.querySelector("input");
6846
+ else if (volumeIndex) target = body.querySelector('[data-volume="' + volumeIndex + '"] .pb-vol-sel input');
6847
+ else if (action && hadFocus) target = [...modal.querySelectorAll("[onclick]")].find((el) => el.getAttribute("onclick") === action);
6848
+ if (target) target.focus({ preventScroll: true });
6849
+ }
6850
+
6378
6851
  function pbSyncState(task) {
6379
6852
  clearTimeout(_pbTimer);
6380
6853
  // 5s 节流折进调度间隔:距上一拉未满 5s 就等满再拉(pbKick 置 0 可立即拉)
@@ -6525,15 +6998,15 @@ window.pbUploadChapter = async function (taskId, platform) {
6525
6998
  if (!wd) { box.innerHTML = '<div class="pb-err">' + esc(t("找不到任务工作目录")) + "</div>"; return; }
6526
6999
  let files = [];
6527
7000
  try {
6528
- const r = await api("/api/dir/scan?path=" + encodeURIComponent(wd));
6529
- files = (r.files || []).filter((f) => /\.md$/i.test(f.name || "") &&
6530
- !/^作品信息/.test((f.name || "").split("/").pop()));
7001
+ const r = await api("/api/publish/task/" + encodeURIComponent(taskId) + "/pending");
7002
+ const book = (r.books || []).find((b) => b.platform === platform);
7003
+ files = ((book && book.items) || []).map((f) => ({ name: f.file, chapter_no: f.chapter_no }));
6531
7004
  } catch (e) {
6532
7005
  box.innerHTML = '<div class="pb-err">' + esc(t("列文件失败:") + e.message) + "</div>";
6533
7006
  return;
6534
7007
  }
6535
- if (!files.length) { box.innerHTML = '<div class="pb-err">' + esc(t("工作目录里没有 .md 章稿")) + "</div>"; return; }
6536
- files.sort((a, b) => (a.name > b.name ? 1 : -1));
7008
+ if (!files.length) { box.innerHTML = '<div class="pb-hint">' + esc(t("暂无待发章节,已发布和已存草稿的章节已排除")) + "</div>"; return; }
7009
+ files.sort((a, b) => a.chapter_no - b.chapter_no);
6537
7010
  const item = (f) =>
6538
7011
  '<button class="ghost pb-ch-item" onclick="pbSendChapter(\'' + esc(taskId) + "', '" + platform +
6539
7012
  '\',\'' + esc(f.name) + '\')" title="' + esc(f.name) + '">' + esc(f.name.split("/").pop()) + "</button>";
@@ -6599,18 +7072,37 @@ function pbSelCountSync(key) {
6599
7072
  // 就地对账:按钮计数/禁用态与全部 chip 勾选态向状态层看齐——重绘链一旦
6600
7073
  // 断掉(poll 失败/详情上下文切换),勾选与计数也不许脱节(2026-10-08 实案
6601
7074
  // 「勾了 18 计数 48」即重绘未跟上)。
6602
- const box = document.querySelector('.pb-pend[data-key="' +
6603
- String(key).replace(/"/g, "&quot;") + '"]');
7075
+ const modal = $("modal");
7076
+ const box = modal && modal.dataset.pbKey === key ? modal
7077
+ : [...document.querySelectorAll(".pb-pend")].find((el) => el.dataset.key === key);
6604
7078
  const ent = (S.pbSel || {})[key];
6605
7079
  if (!box || !ent) return;
6606
- for (const b of box.querySelectorAll("button")) {
6607
- const t = b.textContent || "";
6608
- if (/(\d+)$/.test(t)) b.textContent = t.replace(/(\d+)$/, "(" + ent.sel.size + ")");
6609
- b.disabled = ent.sel.size === 0;
7080
+ const count = box.querySelector(".pb-pend-count");
7081
+ if (count) count.textContent = t("已选 {0}/{1} 章", ent.sel.size, ent.sig.split(",").length);
7082
+ for (const b of box.querySelectorAll("[data-selection-action]")) {
7083
+ const action = b.getAttribute("onclick") || "";
7084
+ const label = action.includes("pbDraftSelected") ? "存草稿所选"
7085
+ : action.includes(",true)") ? "直发所选" : "发布所选";
7086
+ b.textContent = t(label) + t("(") + ent.sel.size + t(")");
7087
+ b.disabled = ent.sel.size === 0 || b.dataset.locked === "true";
7088
+ if (!b.hasAttribute("data-ready-title")) b.dataset.readyTitle = b.title;
7089
+ b.title = ent.sel.size === 0 ? t("请先选择章节") : b.dataset.readyTitle;
7090
+ }
7091
+ for (const section of box.querySelectorAll(".pb-pend-volume")) {
7092
+ const g = ent.groups[Number(section.dataset.volume)];
7093
+ const label = section.querySelector(".pb-vol-sel");
7094
+ if (!g || !label) continue;
7095
+ const cnt = g.ns.filter((n) => ent.sel.has(n)).length;
7096
+ const input = label.querySelector("input");
7097
+ input.checked = cnt === g.ns.length;
7098
+ input.indeterminate = cnt > 0 && cnt < g.ns.length;
7099
+ label.classList.toggle("on", input.checked);
7100
+ label.classList.toggle("partial", input.indeterminate);
7101
+ label.querySelector(".pb-vol-count").textContent = t("已选 {0}/{1} 章", cnt, g.ns.length);
6610
7102
  }
6611
7103
  for (const c of box.querySelectorAll(".pb-chip")) {
6612
7104
  const inp = c.querySelector("input");
6613
- const num = Number((c.textContent || "").trim());
7105
+ const num = Number(c.dataset.chapter);
6614
7106
  if (!inp || !num) continue;
6615
7107
  const on = ent.sel.has(num);
6616
7108
  if (inp.checked !== on) inp.checked = on;
@@ -6623,8 +7115,7 @@ window.pbSelToggle = function (taskId, platform, no, on) {
6623
7115
  const ent = (S.pbSel || {})[key];
6624
7116
  if (!ent) return;
6625
7117
  if (on) ent.sel.add(Number(no)); else ent.sel.delete(Number(no));
6626
- pbSelCountSync(key); // 先就地同步,再触发整块重绘
6627
- S._pbSig = ""; pbKick();
7118
+ pbSelCountSync(key); // 选择只在本地同步,避免重绘打断滚动和键盘焦点
6628
7119
  };
6629
7120
 
6630
7121
  window.pbSelAll = function (taskId, platform, all) {
@@ -6634,7 +7125,14 @@ window.pbSelAll = function (taskId, platform, all) {
6634
7125
  if (all) for (const n of ent.sig.split(",")) ent.sel.add(Number(n));
6635
7126
  else ent.sel.clear();
6636
7127
  pbSelCountSync(key);
6637
- S._pbSig = ""; pbKick();
7128
+ };
7129
+
7130
+ window.pbSelReady = function (taskId, platform) {
7131
+ const key = taskId + ":" + platform;
7132
+ const ent = (S.pbSel || {})[key];
7133
+ if (!ent) return;
7134
+ ent.sel = new Set(ent.ready || []);
7135
+ pbSelCountSync(key);
6638
7136
  };
6639
7137
 
6640
7138
  window.pbSelVolume = function (taskId, platform, gi, on) {
@@ -6645,7 +7143,6 @@ window.pbSelVolume = function (taskId, platform, gi, on) {
6645
7143
  if (on) for (const n of g.ns) ent.sel.add(n);
6646
7144
  else for (const n of g.ns) ent.sel.delete(n);
6647
7145
  pbSelCountSync(key);
6648
- S._pbSig = ""; pbKick();
6649
7146
  };
6650
7147
 
6651
7148
  window.pbPublishSelected = async function (taskId, platform, autoSubmit) {
@@ -6663,6 +7160,7 @@ window.pbPublishSelected = async function (taskId, platform, autoSubmit) {
6663
7160
  "/api/publish/task/" + encodeURIComponent(taskId) + "/publish-all",
6664
7161
  { platform, chapters, auto_submit: !!autoSubmit },
6665
7162
  "批量发布前的质量门禁未通过", taskId);
7163
+ if (r && r.started && $("modal").dataset.pbKey === key) closeModal();
6666
7164
  if (!(r && r.gate_reviewed))
6667
7165
  toast(autoSubmit ? t("直发已启动,进度见发布台")
6668
7166
  : t("正在填第一章稿——填好后请在浏览器窗口里确认提交,再重新校准"));
@@ -6712,6 +7210,7 @@ window.pbDraftSelected = async function (taskId, platform) {
6712
7210
  try {
6713
7211
  await api("/api/publish/task/" + encodeURIComponent(taskId) + "/publish-all",
6714
7212
  { method: "POST", body: JSON.stringify({ platform, chapters, as_draft: true }) });
7213
+ if ($("modal").dataset.pbKey === key) closeModal();
6715
7214
  toast(t("存草稿已启动:逐章自动填稿并保存草稿,进度见发布台"));
6716
7215
  } catch (e) { toast(t("存草稿启动失败:") + e.message, true); }
6717
7216
  S._pbSig = ""; pbKick();
@@ -8646,7 +9145,7 @@ function hiveThinkLine(lines) {
8646
9145
  * localStorage 持久化/WebGL 不可用降级,以及把 renderHive 的泳道数据翻译给场景。 —— */
8647
9146
  let hiveScene = null; // Hive3D 场景实例(懒创建)
8648
9147
  let hiveSceneDead = false; // WebGL 不可用/上下文丢失——本次页面周期内不再尝试
8649
- let hiveMode = "live3d"; // live3d is the real scene; reference and 2d remain opt-in views
9148
+ let hiveMode = "reference"; // high-fidelity artwork is the public default; live3d stays internal for QA
8650
9149
  let hiveRunId = ""; // 最近一次 renderHive 的 run.id(尾巴/思考缓存键前缀)
8651
9150
  const hiveLiveMeta = {}; // rel -> { started_at }(3D 芯片秒表用)
8652
9151
  let hiveDialogPoll = null;
@@ -8654,6 +9153,11 @@ let hiveDialogKey = "";
8654
9153
 
8655
9154
  function setHiveSceneButtons(mode) {
8656
9155
  document.querySelectorAll("[data-hive-view]").forEach((button) => {
9156
+ // 自由 3D 尚未达到发布质量:保留引擎给内部 QA,但不把入口暴露给用户。
9157
+ // hidden/aria-hidden 双保险,避免旧缓存或浏览器恢复状态重新显示它。
9158
+ const internalOnly = button.dataset.hiveView === "live3d";
9159
+ button.hidden = internalOnly;
9160
+ button.setAttribute("aria-hidden", internalOnly ? "true" : "false");
8657
9161
  const active = button.dataset.hiveView === mode;
8658
9162
  button.classList.toggle("active", active);
8659
9163
  button.setAttribute("aria-pressed", active ? "true" : "false");
@@ -8725,6 +9229,8 @@ function closeHiveMonitorLog() {
8725
9229
  function setHiveSceneMode(mode, opts) {
8726
9230
  const viewport = $("rd-hive-viewport");
8727
9231
  if (!viewport) return;
9232
+ // Public mode selection is deliberately limited to the polished artwork and 2D list.
9233
+ // live3d remains callable by internal QA, but can never be restored from user state.
8728
9234
  let selected = ["live3d", "2d"].includes(mode) ? mode : "reference";
8729
9235
  if (selected !== "2d") {
8730
9236
  const sc = ensureHiveScene();
@@ -8816,18 +9322,18 @@ function setupHiveSceneControls() {
8816
9322
  else hiveScene.zoomAt(button.dataset.hiveSceneAction === "zoom-in" ? 1 / 1.18 : 1.18);
8817
9323
  });
8818
9324
  });
8819
- let saved = "live3d";
9325
+ let saved = "reference";
8820
9326
  try {
8821
9327
  saved = localStorage.getItem("orch.hiveView.v2") || "";
8822
9328
  if (!saved) {
8823
- // Migrate an explicitly selected 2D/live3D preference; the old default "reference"
8824
- // was auto-persisted for every new visitor, so it is not a reliable user preference.
8825
9329
  const legacy = localStorage.getItem("orch.hiveView");
8826
- saved = legacy === "2d" || legacy === "live3d" ? legacy : "live3d";
9330
+ saved = legacy === "2d" ? "2d" : "reference";
8827
9331
  localStorage.setItem("orch.hiveView.v2", saved);
8828
9332
  }
8829
- } catch (e) { /* ignore storage restrictions and keep the live 3D default */ }
8830
- setHiveSceneMode(["reference", "live3d", "2d"].includes(saved) ? saved : "live3d", { silent: true });
9333
+ } catch (e) { /* ignore storage restrictions and keep the high-fidelity default */ }
9334
+ // A previously saved free-3D preference must not reopen the unreleased mode.
9335
+ // The engine remains callable by internal QA through setHiveSceneMode("live3d").
9336
+ setHiveSceneMode(saved === "2d" ? "2d" : "reference", { silent: true });
8831
9337
  }
8832
9338
 
8833
9339
  /* renderHive → 场景数据翻译:泳道=蜂巢塔的一层,格子按状态映射高度/颜色。
@@ -9491,21 +9997,63 @@ function scheduleChatLive(runId) {
9491
9997
  }, 1200);
9492
9998
  }
9493
9999
 
9494
- /* 对话正文:``` 围栏渲染成真代码块(复用 codeBlockHTML:行号+高亮+代码主题),
9495
- * 其余文本保持 pre-wrap 原样(行内 `code` 与 **加粗** 轻量翻译)。模型输出先
9496
- * 全量 esc 再插标记;未闭合围栏按代码块收尾,流式输出中途也不撒裸反引号。 */
10000
+ /* 对话正文:保留 Markdown 的段落/标题/列表层级,``` 围栏复用代码块组件。
10001
+ * 模型文本先转义;链接只允许 http(s)/mailto,避免把回答内容当 HTML 或脚本执行。
10002
+ * 未闭合围栏按代码块收尾,流式输出中途也不撒裸反引号。 */
9497
10003
  function chatBodyHTML(text) {
9498
10004
  const lines = String(text || "").split(/\r?\n/);
9499
10005
  const out = [];
9500
10006
  let txt = [], code = null;
10007
+ const inline = (raw) => {
10008
+ let h = esc(raw);
10009
+ h = h.replace(/`([^`]+)`/g, "<code>$1</code>");
10010
+ h = h.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
10011
+ h = h.replace(/(^|[^*])\*([^*\n]+)\*(?!\*)/g, "$1<em>$2</em>");
10012
+ h = h.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+|mailto:[^\s)]+)\)/g,
10013
+ '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>');
10014
+ return h;
10015
+ };
9501
10016
  const flushTxt = () => {
9502
10017
  if (!txt.length) return;
9503
- out.push('<div class="chat-body">' + txt.map((l) => {
9504
- let h = esc(l);
9505
- h = h.replace(/`([^`]+)`/g, "<code>$1</code>");
9506
- h = h.replace(/\*\*([^*]+)\*\*/g, "<b>$1</b>");
9507
- return h;
9508
- }).join("\n") + "</div>");
10018
+ const blocks = [];
10019
+ let paragraph = [], list = null;
10020
+ const closeParagraph = () => {
10021
+ if (paragraph.length) blocks.push("<p>" + paragraph.map(inline).join("<br>") + "</p>");
10022
+ paragraph = [];
10023
+ };
10024
+ const closeList = () => {
10025
+ if (list) { blocks.push("</" + list + ">"); list = null; }
10026
+ };
10027
+ for (const rawLine of txt) {
10028
+ const line = rawLine.trimEnd();
10029
+ const heading = line.match(/^\s{0,3}(#{1,6})\s+(.+?)\s*#*\s*$/);
10030
+ const bullet = line.match(/^\s{0,3}[-+*]\s+(.+)$/);
10031
+ const numbered = line.match(/^\s{0,3}\d+[.)]\s+(.+)$/);
10032
+ if (!line.trim()) { closeParagraph(); closeList(); continue; }
10033
+ if (heading) {
10034
+ closeParagraph(); closeList();
10035
+ const level = heading[1].length;
10036
+ blocks.push("<h" + level + ">" + inline(heading[2]) + "</h" + level + ">");
10037
+ continue;
10038
+ }
10039
+ if (/^\s{0,3}(---+|___+|\*\*\*+)\s*$/.test(line)) {
10040
+ closeParagraph(); closeList(); blocks.push("<hr>"); continue;
10041
+ }
10042
+ if (bullet || numbered) {
10043
+ closeParagraph();
10044
+ const kind = bullet ? "ul" : "ol";
10045
+ if (list !== kind) { closeList(); list = kind; blocks.push("<" + list + ">"); }
10046
+ blocks.push("<li>" + inline((bullet || numbered)[1]) + "</li>");
10047
+ continue;
10048
+ }
10049
+ closeList();
10050
+ const quote = line.match(/^\s{0,3}>\s?(.*)$/);
10051
+ if (quote) {
10052
+ closeParagraph(); blocks.push("<blockquote><p>" + inline(quote[1]) + "</p></blockquote>");
10053
+ } else paragraph.push(line);
10054
+ }
10055
+ closeParagraph(); closeList();
10056
+ out.push('<div class="chat-body chat-markdown">' + blocks.join("") + "</div>");
9509
10057
  txt = [];
9510
10058
  };
9511
10059
  for (const raw of lines) {
@@ -9841,6 +10389,7 @@ function renderRunOutcome(run, runs, aggregate) {
9841
10389
  function renderDetailOverview(run, runs, task, aggregate) {
9842
10390
  const box = $("rd-overview");
9843
10391
  if (!box || !run) return;
10392
+ CodeBeeActivity.markViewed(run);
9844
10393
  const direct = chatEngineIsDirect(run);
9845
10394
  box.classList.toggle("direct-overview", direct);
9846
10395
  const all = runs && runs.length ? runs : [run];
@@ -10273,6 +10822,17 @@ function bindChat() {
10273
10822
  ta.style.height = "auto";
10274
10823
  ta.style.height = Math.min(ta.scrollHeight, 160) + "px";
10275
10824
  });
10825
+ // 移动端软键盘会缩短 visual viewport;把输入条主动带回可视区,
10826
+ // 避免窄屏详情里键盘弹出后发送按钮落在屏幕下方(尤其是 Android Chrome)。
10827
+ const keepChatInputVisible = () => {
10828
+ if (document.activeElement !== ta || window.innerWidth > 720) return;
10829
+ requestAnimationFrame(() => {
10830
+ try { ta.scrollIntoView({ block: "nearest", inline: "nearest" }); }
10831
+ catch (e) { ta.scrollIntoView(); }
10832
+ });
10833
+ };
10834
+ ta.addEventListener("focus", () => setTimeout(keepChatInputVisible, 60));
10835
+ if (window.visualViewport) window.visualViewport.addEventListener("resize", keepChatInputVisible);
10276
10836
  ta.addEventListener("keydown", (e) => {
10277
10837
  if (e.key === "Enter" && !e.shiftKey && !e.isComposing) { e.preventDefault(); window.chatSend(); }
10278
10838
  });
@@ -14928,6 +15488,7 @@ function setLangBtn(lang) {
14928
15488
  renderCodePreviews(); // 预览徽章是动态文案:语言切换时若停在皮肤页要跟着换
14929
15489
  // 数据与备份页的清理预估/状态是 JS 动态生成的:停在那一页时也要跟着换语言
14930
15490
  if (S.tab === "data") loadDataPage();
15491
+ if (S.tab === "agents") renderReviewQueue();
14931
15492
  // 帮助中心若开着:徽章/标题/目录/正文跟着换语言重画
14932
15493
  if (!$("welcome").classList.contains("hidden")) {
14933
15494
  paintHelpChrome();
@@ -15483,12 +16044,103 @@ function ovDateText() {
15483
16044
  function renderOverview() {
15484
16045
  ovRenderHead();
15485
16046
  ovRenderKpis();
16047
+ goalRender();
15486
16048
  syncOvRanges();
15487
16049
  renderOverviewTrend();
15488
16050
  ovRenderQuick();
15489
16051
  ovRenderRecent();
15490
16052
  }
15491
16053
 
16054
+ let _goalCache = null;
16055
+ let _goalBusy = false;
16056
+
16057
+ async function goalLoad() {
16058
+ if (_goalBusy) return _goalCache;
16059
+ _goalBusy = true;
16060
+ try {
16061
+ _goalCache = await api("/api/goals");
16062
+ goalRender();
16063
+ } catch (e) {
16064
+ const error = $("ov-goal-error");
16065
+ if (error) error.textContent = e.message || t("无法读取长目标");
16066
+ } finally { _goalBusy = false; }
16067
+ return _goalCache;
16068
+ }
16069
+
16070
+ function goalRender() {
16071
+ const card = $("ov-goal");
16072
+ if (!card) return;
16073
+ const current = _goalCache && _goalCache.current;
16074
+ const empty = $("ov-goal-empty"), active = $("ov-goal-active"), phase = $("ov-goal-phase");
16075
+ empty.classList.toggle("hidden", !!current);
16076
+ active.classList.toggle("hidden", !current);
16077
+ phase.classList.toggle("hidden", !current);
16078
+ if (!current) return;
16079
+ const paused = current.phase === "paused";
16080
+ const rounds = Math.max(0, Number(current.rounds_started) || 0);
16081
+ const maximum = Math.max(1, Number(current.rounds_max) || 1);
16082
+ const percent = Math.min(100, Math.round(rounds * 100 / maximum));
16083
+ $("ov-goal-name").textContent = current.title || "";
16084
+ $("ov-goal-description-text").textContent = current.description || "";
16085
+ $("ov-goal-progress").textContent = t(paused ? "已暂停" : "目标进度");
16086
+ $("ov-goal-rounds").textContent = rounds + " / " + maximum + t(" 轮");
16087
+ $("ov-goal-progress-fill").style.width = percent + "%";
16088
+ phase.textContent = t(paused ? "已暂停" : "进行中");
16089
+ phase.className = "chip " + (paused ? "warn" : "running");
16090
+ $("ov-goal-error").textContent = "";
16091
+ let actions;
16092
+ if (paused) actions = '<button type="button" class="primary small" data-goal-phase="active">' + esc(t("恢复目标")) + '</button>';
16093
+ else actions = '<button type="button" class="primary small" data-goal-round' + (rounds >= maximum ? ' disabled' : '') + '>' + esc(t("开始一轮")) + '</button>' +
16094
+ '<button type="button" class="ghost small" data-goal-phase="paused">' + esc(t("暂停")) + '</button>';
16095
+ $("ov-goal-actions").innerHTML = actions + '<button type="button" class="ghost small" data-goal-phase="complete">' + esc(t("标记完成")) + '</button>';
16096
+ }
16097
+
16098
+ async function goalCreate() {
16099
+ const title = $("ov-goal-title").value.trim();
16100
+ const description = $("ov-goal-description").value.trim();
16101
+ if (!title) { $("ov-goal-title").focus(); return; }
16102
+ const button = $("ov-goal-create"); button.disabled = true;
16103
+ try {
16104
+ const data = await api("/api/goals", { method: "POST", body: JSON.stringify({ title, description }) });
16105
+ _goalCache = { current: data.goal, goals: [data.goal] };
16106
+ $("ov-goal-title").value = ""; $("ov-goal-description").value = "";
16107
+ goalRender();
16108
+ } catch (e) { $("ov-goal-error").textContent = e.message || t("创建目标失败"); }
16109
+ finally { button.disabled = false; }
16110
+ }
16111
+
16112
+ async function goalTransition(phase) {
16113
+ const current = _goalCache && _goalCache.current;
16114
+ if (!current) return;
16115
+ const actions = $("ov-goal-actions");
16116
+ actions.querySelectorAll("button").forEach((button) => { button.disabled = true; });
16117
+ try {
16118
+ const data = await api("/api/goals/" + encodeURIComponent(current.goal_id), {
16119
+ method: "POST", body: JSON.stringify({ revision: current.revision, phase }) });
16120
+ _goalCache = { current: ["active", "paused"].includes(data.goal.phase) ? data.goal : null, goals: (_goalCache.goals || []).map((g) =>
16121
+ g.goal_id === data.goal.goal_id ? data.goal : g) };
16122
+ goalRender();
16123
+ } catch (e) {
16124
+ $("ov-goal-error").textContent = e.message || t("更新目标失败");
16125
+ goalLoad();
16126
+ }
16127
+ }
16128
+
16129
+ async function goalStartRound() {
16130
+ const current = _goalCache && _goalCache.current;
16131
+ if (!current) return;
16132
+ try {
16133
+ const data = await api("/api/goals/" + encodeURIComponent(current.goal_id) + "/round", {
16134
+ method: "POST", body: JSON.stringify({ revision: current.revision }) });
16135
+ _goalCache = { current: data.goal, goals: (_goalCache.goals || []).map((g) =>
16136
+ g.goal_id === data.goal.goal_id ? data.goal : g) };
16137
+ goalRender();
16138
+ } catch (e) {
16139
+ $("ov-goal-error").textContent = e.message || t("开始新一轮失败");
16140
+ goalLoad();
16141
+ }
16142
+ }
16143
+
15492
16144
  function ovRenderHead() {
15493
16145
  const dateEl = $("ov-date");
15494
16146
  if (dateEl) dateEl.textContent = ovDateText();
@@ -16031,7 +16683,7 @@ window.saveCleanupSettings = saveCleanupSettings;
16031
16683
 
16032
16684
  /* 手机端(<900px)抽屉收起:点导航/任何侧栏可点项后都应收回,别挡内容 */
16033
16685
  function collapseDrawerIfMobile() {
16034
- if (window.innerWidth < 900) document.body.classList.add("side-collapsed");
16686
+ if (window.innerWidth <= 900) document.body.classList.add("side-collapsed");
16035
16687
  }
16036
16688
 
16037
16689
  /* 切页:主栏换成目标子页。shell 决定左栏形态——"main" 保持任务树、"settings" 换成
@@ -16065,7 +16717,7 @@ function switchTab(name, shell) {
16065
16717
  document.querySelectorAll("#page-settings .subpage").forEach((d) => d.classList.toggle("hidden", d.id !== "sub-" + name));
16066
16718
  renderPageCrumb(); // 页名 + 面包屑组名一起刷新(组名从左栏导航现读)
16067
16719
  if (name === "runs" && !S.detailRunId) closeRun();
16068
- if (name === "agents") autoCheckUpdates(); // 进目录页自动查各 CLI 新版本
16720
+ if (name === "agents") { autoCheckUpdates(); loadReviewQueue(); }
16069
16721
  if (name === "orch") { loadOrchestrator(); loadSettings(); } // 进编排设置页拉取配置
16070
16722
  if (name === "evalbench") loadEvalBench(); // 进模型评测页:榜单 + 候选清单
16071
16723
  if (name === "skills") loadSkills(); // 进经验库页拉取沉淀
@@ -16079,6 +16731,7 @@ function switchTab(name, shell) {
16079
16731
  if (name === "data") loadDataPage(); // 进数据与备份页:清理配置/状态/可清理预估
16080
16732
  if (name === "usage") { syncUsageRange(); loadUsage(); } // 进用量页:对齐范围选中态并拉取
16081
16733
  if (name === "overview") { ovDays(); loadOverview(); } // 进概览页:对齐趋势范围并拉两期台账
16734
+ if (name === "overview") goalLoad();
16082
16735
  if (name === "browser") browserDraw();
16083
16736
  if (name === "appearance") renderAppearance(); // 进皮肤页:按当前皮肤/明暗重画卡片
16084
16737
  if (name === "appearance") renderCodeSettings(); // 代码设置行 + 双主题预览卡同步当前值
@@ -16091,6 +16744,7 @@ function switchTab(name, shell) {
16091
16744
  function exitSettings() {
16092
16745
  if (S.detailRunId || S.detailTaskKey) closeRun(); // 设置运行列表开着详情就回表单:一并关掉,防 chat-fill 锁滚动与标题残留
16093
16746
  S.tab = "tasks";
16747
+ $("sub-tasks").classList.remove("mobile-composing");
16094
16748
  S.mainPage = ""; // 主栏回到表单:不再是快捷导航页
16095
16749
  stopAutoPoll(); // 离开设置视图:自动化页轮询一并停掉
16096
16750
  document.querySelectorAll(".page").forEach((p) => p.classList.toggle("hidden", p.id !== "page-settings"));
@@ -16230,6 +16884,51 @@ document.addEventListener("DOMContentLoaded", () => {
16230
16884
  history.replaceState(null, "", location.pathname);
16231
16885
  }
16232
16886
  document.querySelectorAll(".set-item").forEach((b) => b.addEventListener("click", () => switchTab(b.dataset.sub)));
16887
+ const settingsSearch = $("settings-search");
16888
+ if (settingsSearch) {
16889
+ const filterSettings = () => {
16890
+ const query = settingsSearch.value.trim().toLocaleLowerCase();
16891
+ let matches = 0;
16892
+ document.querySelectorAll(".side-settings .set-item").forEach((item) => {
16893
+ const haystack = [item.innerText, item.dataset.search || ""].join(" ").toLocaleLowerCase();
16894
+ const visible = !query || haystack.includes(query);
16895
+ item.classList.toggle("settings-search-hidden", !visible);
16896
+ if (visible) matches++;
16897
+ });
16898
+ document.querySelectorAll(".side-settings .set-group").forEach((group) => {
16899
+ let next = group.nextElementSibling, hasVisibleItem = false;
16900
+ while (next && !next.classList.contains("set-group")) {
16901
+ if (next.classList.contains("set-item") && !next.classList.contains("settings-search-hidden")) hasVisibleItem = true;
16902
+ next = next.nextElementSibling;
16903
+ }
16904
+ group.classList.toggle("settings-search-hidden", !!query && !hasVisibleItem);
16905
+ });
16906
+ const empty = $("settings-search-empty");
16907
+ if (empty) empty.classList.toggle("hidden", !query || matches > 0);
16908
+ };
16909
+ settingsSearch.addEventListener("input", filterSettings);
16910
+ settingsSearch.addEventListener("keydown", (event) => {
16911
+ if (event.key === "Escape" && settingsSearch.value) {
16912
+ event.preventDefault();
16913
+ settingsSearch.value = "";
16914
+ filterSettings();
16915
+ }
16916
+ if (event.key === "Enter") {
16917
+ const first = document.querySelector(".side-settings .set-item:not(.settings-search-hidden)");
16918
+ if (first && settingsSearch.value.trim()) {
16919
+ event.preventDefault();
16920
+ first.click();
16921
+ }
16922
+ }
16923
+ });
16924
+ document.addEventListener("keydown", (event) => {
16925
+ if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === "f" && document.body.classList.contains("settings-mode")) {
16926
+ event.preventDefault();
16927
+ settingsSearch.focus();
16928
+ settingsSearch.select();
16929
+ }
16930
+ });
16931
+ }
16233
16932
  $("btn-set-back").addEventListener("click", exitSettings);
16234
16933
  $("btn-files-back").addEventListener("click", closeFolderFiles); // 文件浏览页返回任务树
16235
16934
  // 文件浏览页点文件行:中央弹窗预览(行是委托绑定,懒加载子目录里的行同样生效;
@@ -16444,7 +17143,6 @@ document.addEventListener("DOMContentLoaded", () => {
16444
17143
  "_blank", "noopener");
16445
17144
  });
16446
17145
  $("btn-theme").addEventListener("click", toggleTheme);
16447
- $("btn-notify-toggle").addEventListener("click", toggleNotifySound);
16448
17146
  // 命令面板:侧栏搜索行 / Ctrl+K 唤起(函数若尚未落地,跳过而不炸整个初始化)
16449
17147
  if (typeof bindCmdK === "function") bindCmdK();
16450
17148
  // 侧栏常驻用量条:首屏先取一次台账(之后由 applyState 的节流刷新接手)
@@ -16504,7 +17202,7 @@ document.addEventListener("DOMContentLoaded", () => {
16504
17202
  const root = document.documentElement;
16505
17203
  const apply = (w) => root.style.setProperty("--side-w", w + "px");
16506
17204
  const saved = parseInt(localStorage.getItem(KEY), 10);
16507
- if (saved >= MIN && saved <= MAX && window.innerWidth >= 900) apply(saved);
17205
+ if (saved >= MIN && saved <= MAX && window.innerWidth > 900) apply(saved);
16508
17206
  const el = $("side-resizer");
16509
17207
  let active = false, startX = 0, startW = 0;
16510
17208
  const onMove = (e) => {
@@ -16518,7 +17216,7 @@ document.addEventListener("DOMContentLoaded", () => {
16518
17216
  localStorage.setItem(KEY, String(Math.round($("sidebar").getBoundingClientRect().width)));
16519
17217
  };
16520
17218
  el.addEventListener("pointerdown", (e) => {
16521
- if (window.innerWidth < 900) return;
17219
+ if (window.innerWidth <= 900) return;
16522
17220
  active = true; startX = e.clientX;
16523
17221
  startW = $("sidebar").getBoundingClientRect().width;
16524
17222
  document.body.classList.add("side-resizing");
@@ -16539,17 +17237,47 @@ document.addEventListener("DOMContentLoaded", () => {
16539
17237
  $("sidebar").addEventListener("click", (e) => {
16540
17238
  if (e.target.closest("button, summary, .stask")) collapseDrawerIfMobile();
16541
17239
  });
16542
- $("btn-new-task").addEventListener("click", exitSettings);
17240
+ $("btn-new-task").addEventListener("click", () => mobileNewTask());
17241
+ $("btn-mobile-new").addEventListener("click", () => mobileNewTask());
17242
+ $("btn-mobile-workspaces").addEventListener("click", () => {
17243
+ $("sub-tasks").classList.remove("mobile-composing");
17244
+ document.querySelector("main").scrollTop = 0;
17245
+ });
17246
+ $("mobile-workspace-list").addEventListener("click", (e) => {
17247
+ const add = e.target.closest("[data-new-dir]");
17248
+ if (add) { mobileNewTask(add.dataset.newDir); return; }
17249
+ const task = e.target.closest(".mobile-task");
17250
+ if (task) sideRowActivate(task);
17251
+ });
17252
+ // 旋转/缩窄桌面窗口时也收起导航,断点与 CSS 保持一致。
17253
+ const mobileLayout = window.matchMedia("(max-width: 900px)");
17254
+ mobileLayout.addEventListener("change", (e) => {
17255
+ if (e.matches) document.body.classList.add("side-collapsed");
17256
+ });
16543
17257
  // 侧栏快捷导航:主栏换成目标页,左栏任务树不动(shell="main")。
16544
17258
  // 页面清单以 index.html .side-quick 的 data-page 为准,这里逐项绑 id。
16545
17259
  $("btn-q-overview").addEventListener("click", () => switchTab("overview", "main"));
17260
+ $("ov-goal-create").addEventListener("click", goalCreate);
17261
+ $("ov-goal-title").addEventListener("keydown", (e) => {
17262
+ if (e.key === "Enter" && !e.isComposing) { e.preventDefault(); goalCreate(); }
17263
+ });
17264
+ $("ov-goal-actions").addEventListener("click", (e) => {
17265
+ if (e.target.closest("[data-goal-round]")) { goalStartRound(); return; }
17266
+ const button = e.target.closest("[data-goal-phase]");
17267
+ if (!button) return;
17268
+ const phase = button.dataset.goalPhase;
17269
+ if (phase === "complete") {
17270
+ uiConfirm(t("确定将这个长目标标记为完成吗?"), { danger: false,
17271
+ ok: t("标记完成") }).then((ok) => { if (ok) goalTransition(phase); });
17272
+ } else goalTransition(phase);
17273
+ });
16546
17274
  $("btn-q-runs").addEventListener("click", () => switchTab("runs", "main"));
16547
17275
  $("btn-q-automation").addEventListener("click", () => switchTab("automation", "main"));
16548
17276
  $("btn-q-browser").addEventListener("click", () => switchTab("browser", "main"));
16549
17277
  browserBind();
16550
17278
  bindCtxMenus();
16551
17279
  bindInspector();
16552
- if (window.innerWidth < 900) document.body.classList.add("side-collapsed");
17280
+ if (window.innerWidth <= 900) document.body.classList.add("side-collapsed");
16553
17281
  applyAppearance();
16554
17282
  $("btn-create").addEventListener("click", createTask);
16555
17283
  /* 侧栏「显示已归档」开关(原「最近任务」面板移除后,归档找回的唯一入口)。