Files
admin c1b5e4d966 chore(工作区): 全量入库 + 补齐 .gitignore(以工作区为准)
- 变更规模:新增 514 / 修改 62 / 重命名 155 / 删除 4(归档重组与文档轮次)
- .gitignore 修:`归档/**/db-cwd归一-备份-*/` —— 原规则写绝对层级(归档/db-cwd归一-…),
  目录搬进 归档/配置与备份/ 后**静默失效**,43 MB 的 DB 备份又变成未跟踪
- .gitignore 补:嵌套 git 内部数据(归档/内嵌git-20261008/、归档/skills-git-旧线-20261007/dotgit-原样移出/)
- .gitignore 补:运行态与部署副本(.workbuddy/collab/、.workbuddy/tools/、.workbuddy/.load-pending、.workbuddy/tmp-*)
- .gitignore 补:备份件(*.bak-*)
- 未跟踪文件从 2190 降到 890(其余为 归档/ 归档件与 .workbuddy/memory/ 知识文件,按口径入库)
2026-10-10 23:13:22 +08:00

1182 lines
83 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>多会话协作 · 实时架构看板</title>
<script>/* 🔴 在样式生效前就套用上次选的风格 —— ⛔ 别等 load,否则会先闪一下当前风格 */
(function(){try{var s=localStorage.getItem('dsh-board-style');
if(s&&s!=='base'){document.documentElement.setAttribute('data-style',s)}}catch(e){}})();
</script>
<style>
/* ══════════════════════════════════════════════════════════════════════════════
🔴 风格系统(用户 2026-09-30:「画架构图参考 archify 的样式,右上角加风格切换(保留当前风格)」)
· **切换只换 `<html data-style="…">`** —— 颜色/字体/网格全部走令牌,SVG 里没有硬编码颜色
⇒ 换风格**不需要重画图**。
· 三套:`base`(**保留的当前风格**)/`archify-dark`/`archify-light`
(后两套令牌逐字取自 archify 的 `assets/template.html`,MIT)。
· ⛔ 新写样式一律走令牌,别在组件里写死颜色 —— 否则换风格会花。
══════════════════════════════════════════════════════════════════════════════ */
/* ── 通用(与风格无关的尺寸/字体/网格令牌) ─────────────────────────────── */
:root{
--radius-8:8px; --radius-12:12px; --radius-16:16px; --radius-pill:999px;
--font-ui:system-ui,-apple-system,"Segoe UI","Microsoft YaHei",sans-serif;
--node-stroke-w:1;
--shadow-pop:0 10px 28px rgba(0,0,0,.18);
}
/* ── 风格 1 · base =**当前风格**(跟随系统深浅色) ─────────────────────── */
:root{ /* base · light */
--color-bg-page:#f6f8f5; --color-bg-surface:#ffffff; --color-bg-subtle:#eef2ee;
--color-text-primary:#12261a; --color-text-secondary:#4a5c50; --color-text-tertiary:#7a8a80;
--color-border-subtle:#e2e9e2; --color-border-default:#cdd8ce; --color-border-strong:#a9b8ab;
--color-accent:#256d37; --color-accent-subtle:#e6f1e8; --color-on-accent:#ffffff;
--color-success:#1f7a3d; --color-success-subtle:#e6f4ea;
--color-warning:#8a5a00; --color-warning-subtle:#fdf3e0;
--color-danger:#b3261e; --color-danger-subtle:#fdecea;
--color-focus-ring:#256d37;
--color-canvas-dot:transparent; --dot-size:22px;
}
@media (prefers-color-scheme: dark){
:root:not([data-style]), :root[data-style="base"]{ /* base · dark */
--color-bg-page:#0f1412; --color-bg-surface:#161d1a; --color-bg-subtle:#1d2622;
--color-text-primary:#e8f1ea; --color-text-secondary:#adbcb2; --color-text-tertiary:#7f8f85;
--color-border-subtle:#243029; --color-border-default:#2f3d36; --color-border-strong:#455349;
--color-accent:#7fd3a0; --color-accent-subtle:#14301f; --color-on-accent:#08200f;
--color-success:#6ede8a; --color-success-subtle:#12301c;
--color-warning:#ffcf7a; --color-warning-subtle:#33260f;
--color-danger:#ff8f85; --color-danger-subtle:#3a1613;
--color-focus-ring:#7fd3a0;
}}
/* ── 风格 2 · Archify 暗(令牌取自 archify 的 dark 主题) ────────────────── */
:root[data-style="archify-dark"]{
--color-bg-page:#020617; --color-bg-surface:#0f172a; --color-bg-subtle:#1e293b;
--color-text-primary:#ffffff; --color-text-secondary:#94a3b8; --color-text-tertiary:#64748b;
--color-border-subtle:#1e293b; --color-border-default:#334155; --color-border-strong:#475569;
--color-accent:#34d399; /* archify --arrow-emphasis */
--color-accent-subtle:rgba(52,211,153,0.14); --color-on-accent:#052e16;
--color-success:#34d399; --color-success-subtle:rgba(52,211,153,0.14); /* backend-stroke */
--color-warning:#fbbf24; --color-warning-subtle:rgba(251,191,36,0.14); /* cloud-stroke */
--color-danger:#fb7185; --color-danger-subtle:rgba(251,113,133,0.16); /* security-stroke*/
--color-focus-ring:#34d399;
--color-canvas-dot:rgba(148,163,184,0.16); --dot-size:22px; /* --canvas-dot */
--font-ui:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,"Microsoft YaHei",monospace;
--node-stroke-w:1.4;
}
/* ── 风格 3 · Archify 亮(令牌取自 archify 的 light 主题) ───────────────── */
:root[data-style="archify-light"]{
--color-bg-page:#f4f5f7; --color-bg-surface:#ffffff; --color-bg-subtle:#f1f5f9;
--color-text-primary:#111827; --color-text-secondary:#5b6474; --color-text-tertiary:#9aa3b2;
--color-border-subtle:#e5e7eb; --color-border-default:#cbd5e1; --color-border-strong:#94a3b8;
--color-accent:#059669;
--color-accent-subtle:rgba(5,150,105,0.10); --color-on-accent:#ffffff;
--color-success:#059669; --color-success-subtle:rgba(52,211,153,0.20);
--color-warning:#d97706; --color-warning-subtle:rgba(251,191,36,0.20);
--color-danger:#e11d48; --color-danger-subtle:rgba(251,113,133,0.15);
--color-focus-ring:#059669;
--color-canvas-dot:#d9dee5; --dot-size:22px;
--font-ui:ui-monospace,"Cascadia Mono","JetBrains Mono",Consolas,"Microsoft YaHei",monospace;
--node-stroke-w:1.4;
}
*{box-sizing:border-box}
body{margin:0;background:var(--color-bg-page);color:var(--color-text-primary);
font-family:var(--font-ui);font-size:14px;line-height:1.6;font-weight:400}
a,button{font:inherit;color:inherit}
:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:2px}
header{padding:24px 32px 16px;border-bottom:1px solid var(--color-border-subtle);
display:flex;flex-wrap:wrap;gap:16px;align-items:baseline}
h1{margin:0;font-size:20px;font-weight:500;line-height:1.3}
.lede{color:var(--color-text-secondary);font-size:13px}
.spacer{flex:1}
.stamp{color:var(--color-text-tertiary);font-size:12px;font-variant-numeric:tabular-nums}
.hd-left{display:flex;flex-direction:column;gap:2px}
.hd-left .lede strong,.hd-left .lede b{font-weight:500;color:var(--color-text-secondary)}
.hd-right{display:flex;flex-direction:column;align-items:flex-end;gap:4px}
.lag{font-size:12px;font-variant-numeric:tabular-nums;color:var(--color-text-tertiary)}
.lag.is-old{color:var(--color-warning)}
.ro{font-size:11px;color:var(--color-text-tertiary)}
.warnbar{margin:0 0 24px;border:1px solid var(--color-warning);background:var(--color-warning-subtle);
color:var(--color-warning);border-radius:var(--radius-12);padding:12px 16px;font-size:13px}
/* 「本项目」身份卡 —— 🔴 看板顶部第一眼就要说清"这是哪个需求目标项目" */
.card.proj{border-color:var(--color-accent);background:var(--color-accent-subtle)}
.pill{display:inline-flex;align-items:center;padding:3px 12px;border-radius:var(--radius-pill);
font-size:12px;font-weight:500;color:var(--color-on-accent);background:var(--color-accent)}
.crit{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.critLead{color:var(--color-text-secondary);font-size:12px}
.crit .chip{background:var(--color-bg-surface)}
/* 🆕 2026-09-30 任务类别行:一个工作区里可能有多个类别 ⇒ 每类一枚 chip,chip 里带**该类的主会话**。
⚠️ 这里 ⛔ **不**覆盖 `.chip` 的底色 —— 要保留 `.is-warn`(无主会话那种)的警示色。 */
.topics{margin-top:12px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.accRow{margin-top:10px;display:flex;flex-wrap:wrap;gap:8px;align-items:center}
.accRow .critLead{color:var(--color-text-secondary)}
.projGoal{display:flex;align-items:baseline;gap:10px;flex-wrap:wrap;font-size:17px;line-height:1.4}
.projGoal b{font-weight:600}
.accBar{height:6px;border-radius:3px;background:var(--color-border-tertiary);overflow:hidden;
width:220px;flex:0 0 auto}
.accBar i{display:block;height:100%;background:var(--color-success);transition:width .3s}
.accBar.is-badn{background:var(--color-danger-subtle)}
.accBar.is-badn i{background:var(--color-danger)}
.accNum{font-variant-numeric:tabular-nums;font-size:14px}
.accNum.is-ok{color:var(--color-success)}
.accNum.is-bad{color:var(--color-danger)}
/* ⛔ 2026-10-01 删:`.projAttr`("目标 id / 主会话 / 工作区 / 归属判据 压成一列"那一行)——
用户当天后续定性:「**看板要精简的展示重点有效的信息,不是写说明书**」⇒ 那一列整体**不进版面**,
全部追加到卡片右上角 `?`(见 `renderProject()` 末尾)。页面从此只剩三行:目标 / 完成情况 / 任务类别。 */
/* 🔴 2026-10-01 多目标 tab(用户:「把协作实时看板改为 **tab 支持多个目标**执行协作状态展示」)
⚠️ 一个目标时也画(只是只有一格)—— 让"这是多目标看板"这件事**自己说得出来**,
⛔ 不要等有第二个目标了才让人发现原来支持。 */
.tabs{display:flex;flex-wrap:wrap;gap:8px;align-items:stretch;margin:0 0 20px;
border-bottom:1px solid var(--color-border-subtle);padding-bottom:12px}
.tab{display:flex;flex-direction:column;gap:3px;min-width:180px;max-width:420px;text-align:left;
padding:9px 14px;border:1px solid var(--color-border-default);border-radius:var(--radius-12);
background:var(--color-bg-surface);color:var(--color-text-secondary);font:inherit;font-size:13px;
cursor:pointer;transition:border-color .15s,background .15s}
.tab:hover{border-color:var(--color-accent)}
.tab.is-on{border-color:var(--color-accent);background:var(--color-accent-subtle);
color:var(--color-text-primary)}
.tab .tt{display:flex;align-items:center;gap:6px;font-weight:500;color:var(--color-text-primary);
white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.tab .tsub{font-size:11px;color:var(--color-text-tertiary);white-space:nowrap;overflow:hidden;
text-overflow:ellipsis}
.tab .badge{flex:0 0 auto;padding:0 7px;border-radius:var(--radius-pill);font-size:11px;
border:1px solid var(--color-border-default);color:var(--color-text-tertiary)}
.tab .badge.is-ok{color:var(--color-success);border-color:var(--color-success);
background:var(--color-success-subtle)}
.tab .badge.is-bad{color:var(--color-danger);border-color:var(--color-danger);
background:var(--color-danger-subtle)}
.tab .badge.is-warn{color:var(--color-warning);border-color:var(--color-warning);
background:var(--color-warning-subtle)}
/* ⛔ 2026-10-01 删:`.tabHint`("只有一格/怎么加第二格"那段说明)—— 见 `renderTabs()` 里的说明。
tab 条**只放数据**;使用说明一律进各板块右上角 `?`。 */
td .role{display:inline-block;padding:1px 8px;border-radius:var(--radius-pill);font-size:11px;
border:1px solid var(--color-border-default);color:var(--color-text-tertiary);white-space:nowrap}
td .role.is-main{color:var(--color-accent);border-color:var(--color-accent);background:var(--color-accent-subtle)}
main{padding:24px 32px 48px;max-width:1440px;margin:0 auto}
section{margin:0 0 24px}
.card{background:var(--color-bg-surface);border:1px solid var(--color-border-subtle);
border-radius:var(--radius-16);padding:20px}
.card > h2{margin:0 0 16px;font-size:14px;font-weight:500;color:var(--color-text-secondary);padding-right:30px}
.card > h2 .hint,.hero h2 .hint{font-weight:400;color:var(--color-text-tertiary);font-size:12px;margin-left:8px}
.hero{background:var(--color-bg-surface);border:1px solid var(--color-border-subtle);
border-radius:var(--radius-16);padding:24px}
.hero h2{margin:0 0 16px;font-size:16px;font-weight:500;padding-right:30px}
.hero p{margin:0 0 16px;color:var(--color-text-tertiary);font-size:12px}
svg.arch{width:100%;height:auto;display:block}
/* 画布点阵(archify 的 --canvas-dot):base 风格为 transparent ⇒ 无点阵 */
#archWrap{background-image:radial-gradient(var(--color-canvas-dot) 1px, transparent 1px);
background-size:var(--dot-size) var(--dot-size);background-position:0 0;
border-radius:var(--radius-12)}
/* ── 右上角「风格切换」(用户 2026-09-30 要求;默认=当前风格) ───────────── */
.stylesw{display:inline-flex;border:1px solid var(--color-border-default);border-radius:var(--radius-pill);
overflow:hidden;background:var(--color-bg-surface)}
.stylesw button{border:0;background:transparent;color:var(--color-text-secondary);cursor:pointer;
padding:4px 12px;font-size:12px;line-height:1.6;font-family:var(--font-ui);white-space:nowrap}
.stylesw button + button{border-left:1px solid var(--color-border-subtle)}
.stylesw button:hover{background:var(--color-bg-subtle);color:var(--color-text-primary)}
.stylesw button.on{background:var(--color-accent);color:var(--color-on-accent);font-weight:500}
.stylesw button:focus-visible{outline:2px solid var(--color-focus-ring);outline-offset:-2px}
/* ── 🔴 各板块右上角「?」:小字描述收进这里,鼠标移上/键盘聚焦才显示 ─────────────
(用户 2026-09-30:「把各板块的小字描述放到各板块对应右上角 ?号图标中,鼠标移上去显示
(**只保留标题,主体,类别标签**这类信息)」)
⛔ 别再往版面里摊大段说明文字 —— 一律进 .qtip。 */
.card,.hero{position:relative}
.qh{position:absolute;top:12px;right:14px;z-index:6;width:20px;height:20px;padding:0;
border-radius:50%;border:1px solid var(--color-border-default);background:var(--color-bg-subtle);
color:var(--color-text-secondary);font-size:12px;font-weight:500;line-height:18px;text-align:center;
cursor:help;font-family:var(--font-ui);user-select:none}
.qh:hover,.qh:focus-visible{background:var(--color-accent);color:var(--color-on-accent);
border-color:var(--color-accent);outline:none}
.qh .qtip{position:absolute;top:26px;right:0;z-index:30;width:min(440px,78vw);padding:12px 14px;
border-radius:var(--radius-12);border:1px solid var(--color-border-default);
background:var(--color-bg-surface);box-shadow:var(--shadow-pop);
color:var(--color-text-secondary);font-size:12px;font-weight:400;line-height:1.7;text-align:left;
white-space:normal;cursor:default;opacity:0;visibility:hidden;transition:opacity .12s ease}
.qh:hover .qtip,.qh:focus-visible .qtip{opacity:1;visibility:visible}
@media (prefers-reduced-motion: reduce){.qh .qtip{transition:none}}
.qtip b{color:var(--color-text-primary);font-weight:500}
.qtip .kt{display:inline-block;padding:0 6px;border-radius:var(--radius-pill);
border:1px solid var(--color-border-default);color:var(--color-text-tertiary);font-size:11px}
/* 「协作架构」板块右上角:延迟算式 + 保留的快照时刻(固定可见,⛔ 不收进 ?) */
.herometa{position:absolute;top:12px;right:52px;text-align:right;line-height:1.5;
font-size:12px;font-variant-numeric:tabular-nums;color:var(--color-text-tertiary)}
.herometa > span{display:block;white-space:nowrap} /* ⚠️ 只作用于直接子元素:写 `.herometa span` 会把内层 #lag 也变成块 ⇒ "当前 3 秒"被拆两行 */
.herometa b{color:var(--color-text-primary);font-weight:500}
.herometa .sec{color:var(--color-text-secondary)}
.herometa.is-old .sec{color:var(--color-warning)}
@media(max-width:820px){.herometa{position:static;text-align:left;margin:0 0 12px}}
.cols-3{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px}
.cols-2{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:24px}
@media(max-width:1100px){.cols-3,.cols-2{grid-template-columns:1fr}}
.chip{display:inline-flex;align-items:center;gap:4px;padding:4px 12px;border-radius:var(--radius-pill);
font-size:12px;font-weight:400;border:1px solid var(--color-border-default);
color:var(--color-text-secondary);background:var(--color-bg-subtle)}
.chip.is-ok{color:var(--color-success);background:var(--color-success-subtle);border-color:var(--color-success)}
.chip.is-bad{color:var(--color-danger);background:var(--color-danger-subtle);border-color:var(--color-danger)}
.chip.is-warn{color:var(--color-warning);background:var(--color-warning-subtle);border-color:var(--color-warning)}
.rowchips{display:flex;flex-wrap:wrap;gap:8px}
table{width:100%;border-collapse:collapse;font-size:13px}
th,td{padding:8px 8px;border-bottom:1px solid var(--color-border-subtle);text-align:left;vertical-align:top}
th{font-weight:500;color:var(--color-text-tertiary);font-size:12px}
tr:last-child td{border-bottom:0}
td.num{font-variant-numeric:tabular-nums;color:var(--color-text-tertiary);font-size:12px;white-space:nowrap}
td .ellip{display:block;max-width:34ch;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
/* 🔴 弱化说明(台账「任务类别」列里标旧值/项目卡里标类别来源):⛔ 只弱化,⛔ 不隐藏 */
.oldline{color:var(--color-text-tertiary);font-size:12px}
.warnTx{color:var(--color-warning);font-weight:600}
code{font-family:ui-monospace,Consolas,monospace;font-size:12px;color:var(--color-text-secondary)}
.empty{padding:16px 0;color:var(--color-text-tertiary);font-size:13px}
.empty strong{display:block;color:var(--color-text-secondary);font-weight:500;margin-bottom:4px}
.err{border-color:var(--color-danger);background:var(--color-danger-subtle);color:var(--color-danger);
border-radius:var(--radius-12);padding:16px;font-size:13px}
.skel{height:14px;border-radius:var(--radius-8);background:var(--color-bg-subtle);margin:8px 0}
.legend{display:flex;flex-wrap:wrap;gap:16px;color:var(--color-text-tertiary);font-size:12px;margin-top:8px}
.legend i{display:inline-block;width:12px;height:12px;border-radius:4px;margin-right:6px;
vertical-align:-1px;border:1px solid var(--color-border-default)}
/* 🔴 2026-10-01(用户:「**这段乱七八糟的 也精简 分段落描述**」)——
架构图下面的说明原先是**一整行** `… · … · …`(八九条串起来,读起来是一堵墙)。
⇒ 改成**分块 + 一行一条**:块 = 一个小标题(「当前」「<唤醒会话名>」),
块内每条独立成行、行首一个小点。⛔ 别再退回 `join(' · ')` 那种串行。 */
.archNotes{margin-top:12px;display:flex;flex-direction:column;gap:10px}
.archNotes .anBlock{display:flex;flex-direction:column;gap:3px}
.archNotes .anLead{color:var(--color-text-secondary);font-size:12px;font-weight:500}
.archNotes .anItem{color:var(--color-text-tertiary);font-size:12px;line-height:1.65;
padding-left:14px;position:relative}
.archNotes .anItem::before{content:'·';position:absolute;left:3px}
.n-title{fill:var(--color-text-primary);font-size:16px;font-weight:500}
.n-title-sm{fill:var(--color-text-primary);font-size:13px;font-weight:500}
.n-sub{fill:var(--color-text-tertiary);font-size:11px}
.n-stat{fill:var(--color-accent);font-size:13px;font-weight:500}
.n-ok{fill:var(--color-success);font-size:12px;font-weight:500}
.n-idle{fill:var(--color-text-tertiary);font-size:12px}
.n-warn{fill:var(--color-warning);font-size:12px;font-weight:500}
.n-bad{fill:var(--color-danger);font-size:12px;font-weight:500}
.e-label{fill:var(--color-text-tertiary);font-size:12px}
.node-bg{fill:var(--color-bg-surface);stroke:var(--color-border-default)}
.node-on{fill:var(--color-success-subtle);stroke:var(--color-success)}
.node-warn{fill:var(--color-warning-subtle);stroke:var(--color-warning)}
.node-off{fill:var(--color-danger-subtle);stroke:var(--color-danger);stroke-dasharray:4 3}
.node-base{fill:var(--color-bg-subtle);stroke:var(--color-border-strong);stroke-dasharray:5 4}
.node-bg,.node-on,.node-warn,.node-off,.node-base,.trig-node{stroke-width:var(--node-stroke-w)}
/* 🔴 唤醒脉冲会话(画在 ② 主会话**左边**)—— 用户 2026-10-01:先要「换个样式 和 协作会话区分开」,
随后**定性**「**唤醒脉冲会话,本质还是会话**」⇒ **形状改回会话同族的 `rect rx=14`**,
⛔ **不再用六边形** —— 形状已不是区分手段,**位置**才是(贴在主会话左边、⛔ 不进下方那一排)。
⇒ 只剩一条视觉标记:外圈一圈**虚线光晕** ⇒ 一眼看出「它靠**脉冲**被叫醒,不是一直在跑」。
⚠️ 状态色仍然照旧(`up` ⇒ 绿/否则黄)—— 位置管"它是谁",颜色管"它活着没"。 */
.trig-halo{fill:none;stroke-width:1;stroke-dasharray:5 4;opacity:.6}
.trig-halo.h-on{stroke:var(--color-success)}
.trig-halo.h-warn{stroke:var(--color-warning)}
/* 状态徽章的三组图形(⛔ 用图形不用字符 —— 字符要靠字体,Linux/无 CJK 字体时会变成方框) */
.trig-dot{fill:var(--color-success)}
.trig-pause{fill:var(--color-warning)}
.trig-idle-dot{fill:none;stroke:var(--color-text-tertiary);stroke-width:1.4}
/* 🔴 2026-09-30 用户:「有些线都看不到」——根因是灰线用了 `--color-border-strong`(**太浅**,
白底对比度仅约 1.9:1)+ 1.5px ⇒ 基本看不见;而唤醒→主会话/WorkBuddy→Hook/--once/--tick/
④收结果**全是灰线** ⇒ 整张图看着像"线没连全"。改用 `--color-text-tertiary`(约 3.5:1),
并与 `.e-label` 同色(标签本就在描述这条线)⇒ 灰色语义保留,可见性恢复。
⚠️ 注释里⛔ 不写具体色值 —— 有静态用例守着"组件里零硬编码色"。 */
.edge{stroke:var(--color-accent);stroke-width:1.5;fill:none}
.edge-grey{stroke:var(--color-text-tertiary);stroke-width:1.5;fill:none}
@media (prefers-reduced-motion: no-preference){
.live{stroke-dasharray:6 5;animation:flow 1.6s linear infinite}
@keyframes flow{to{stroke-dashoffset:-22}}
}
</style>
</head>
<body>
<header>
<div class="hd-left">
<h1>多会话协作 · 实时架构看板</h1>
</div>
<span class="spacer"></span>
<div class="hd-right">
<!-- 🔴 右上角风格切换(你现在的风格 = 第一项,⛔ 不删) -->
<div class="stylesw" id="stylesw" role="group" aria-label="看板风格">
<button type="button" data-style="base" title="保留的原风格(跟随系统深浅色)">当前风格</button>
<button type="button" data-style="archify-dark" title="参考 archify 的暗色令牌">Archify 暗</button>
<button type="button" data-style="archify-light" title="参考 archify 的亮色令牌">Archify 亮</button>
</div>
</div>
</header>
<main>
<div class="err" id="err" hidden></div>
<div class="warnbar" id="warnbar" hidden></div>
<!-- 🔴 多目标 tab(用户 2026-10-01:「把协作实时看板改为 tab 支持多个目标执行协作状态展示」)
⛔ 一格 = 一个目标;下面「本项目 / 协作架构 / 需求台账 / 会话明细」四块跟着**当前 tab** 变,
⛔ 「前置 / 队列通知 / 最近上报」是**工作区级**的,不随 tab 变(它们本来就不属于某一个目标)。 -->
<div class="tabs" id="tabs" hidden></div>
<section class="card proj" id="projCard">
<h2>本项目</h2>
<span class="qh" tabindex="0" role="button" aria-label="本项目板块说明">?<span class="qtip" id="projTip">
上面每一格 tab 是<b>一个需求目标</b>;这一块画的是<b>当前那格</b>的目标。<br>
跟着 tab 走的还有:<b>协作架构</b>、<b>需求台账</b>、<b>会话明细</b> —— 因为它们都按"任务类别"归属到某个目标。<br>
上面那一排「前置 / 队列通知 / 最近上报」是<b>整个工作区</b>的,⛔ 不随 tab 变。<br>
<b>怎么多一格</b>:在收件箱的 <code>goals/</code> 目录里放一份与 <code>goal.json</code> 同字段的
<code>&lt;目标id&gt;.json</code>,刷新即多一格。只有一格时,就是本工作区只登记了一个目标。<br>
看板只读:后台隔一会儿生成一份快照,页面直接读缓存(间隔见「协作架构」那块的问题说明)。
</span></span>
<div id="proj"><div class="skel"></div></div>
</section>
<section class="hero">
<h2>协作架构<span class="hint" id="archHint">用户 · 会话 · 协作会话 · 程序 · WorkBuddy</span></h2>
<!-- 🔴 延迟与"保留的快照时刻"固定在板块右上角(用户 2026-09-30)—— ⛔ 别收进 ? 里 -->
<div class="herometa" id="herometa">
<span><b id="ts2">—</b></span>
<span class="sec">延迟 <span id="lag">—</span></span>
</div>
<span class="qh" tabindex="0" role="button" aria-label="协作架构板块说明">?<span class="qtip">
主会话<b>左边</b>那格是<b>唤醒会话</b>(名字由使用方给)—— 画成<b>圆角矩形 + 外圈虚线</b>,
它本身<b>就是一条会话</b>,而且是<b>独立会话</b>(自己一条,⛔ <b>不在主会话上处理</b>),
<b>随需求确定时才创建</b>;位置贴在主会话左边,⛔ 不进下方那一排。<br>
它框色/徽章=实时状态:<b>绿 ●</b> 在跑 · <b>黄 ‖</b> 停着 · <b>灰 ○</b> 还没建。<br>
图上每条线旁边都写了它是什么(① 派活 · ②③ 上报 · ④ 收结果 · 队列),<b>看线就行</b>。<br>
🔴 <b>① 派活</b>的特别之处:它是主会话建一条<b>自动化排期</b>、宿主到点开新会话 ——
这是<b>唯一能开新会话的通道</b>(钩子开不了会话)⇒ 所以"叫醒"只能喊、⛔ 不能自己开棒。<br>
主会话框里写着它<b>负责哪几个任务类别</b>;主会话<b>下方那一排</b>是<b>协作会话</b> —— 一格一条,横着排、有几条画几条,一条都没有时画个空框写明「暂无协作会话」。<br>
任务类别是<b>同一个工作区</b>里用<b>会话名称前缀</b>区分的(每个类别各有自己的主会话)。<br>
状态都是实读的,会话读 WorkBuddy 的库,程序读它自己留下的时间记录。哪一格停着不动,看框的颜色就知道。<br>
看板只读。后台每 <span id="iv">—</span> 秒生成一份快照,页面直接读缓存。
</span></span>
<div id="archWrap"><svg class="arch" id="arch" viewBox="0 20 1280 902" role="img"
aria-label="多会话协作架构:用户、主会话(框内标出它负责的任务类别;左侧挂唤醒会话(名字由使用方给)—— 它是一条**独立会话**,随需求确定时创建、不在主会话上处理,画成圆角矩形+外圈虚线,贴在主会话左边)、协作会话一排(有几条画几条,一条都没有时画一个空框写明暂无协作会话)、协作程序与上报、WorkBuddy 在特定时机起来的 Hook进程、WorkBuddy"></svg></div>
<div class="legend">
<span><i style="background:var(--color-success-subtle);border-color:var(--color-success)"></i>有会话在跑</span>
<span><i style="background:var(--color-warning-subtle);border-color:var(--color-warning)"></i>有件没人在跑</span>
<span><i style="background:var(--color-bg-surface);border-color:var(--color-border-default)"></i>空闲 / 件已全完</span>
<span><i style="background:var(--color-danger-subtle);border-color:var(--color-danger)"></i>已停</span>
<span><i style="background:var(--color-bg-subtle);border-color:var(--color-border-strong)"></i>WorkBuddy</span>
</div>
<!-- 🔴 2026-10-01:原来这里另有一行「① 派活…④ 收结果…队列…」的静态小字 ——
它和下面 `#archNote` 一起读就是一堵墙。⇒ **并进 `#archNote` 的「连线」块**(一行一条),
⛔ 不再单独占一行版面。 -->
<div class="archNotes" id="archNote"></div>
</section>
<section class="cols-3">
<!-- 🔴 本板块的名称/标签/内容**全部由使用方提供**(board_ext)—— 技能侧不写死任何项目的东西 -->
<div class="card" id="frontCard"><h2>前置</h2><span class="qh" tabindex="0" role="button" aria-label="前置板块说明">?<span class="qtip" id="frontTip">
看什么、叫什么名字,由**使用方**的看板扩展(`board_ext.py`)决定。<br>
组件名一律写成「系统 · 模块 · 功能名」,不用自造简称。<br>
状态只做探测:读组件自己留下的时间记录和日志,不发请求、不改任何东西。
</span></span><div id="front"><div class="skel"></div></div></div>
<div class="card"><h2>队列通知 / 握手<span class="hint">上报</span></h2><div id="notify"><div class="skel"></div></div></div>
<div class="card"><h2>最近上报<span class="hint">wakeups.jsonl</span></h2><div id="wake"><div class="skel"></div></div></div>
</section>
<section class="cols-2">
<div class="card"><h2>需求台账<span class="hint">tasks.json</span></h2><div id="tasks"><div class="skel"></div></div></div>
<div class="card"><h2>会话明细<span class="hint">本项目 · 含历史</span></h2> <span class="qh" tabindex="0" role="button" aria-label="会话明细板块说明">?<span class="qtip">
分工看架构图第三层。本表列本项目全部会话,含已完成的。
</span></span><div id="sess"><div class="skel"></div></div></div>
</section>
</main>
<script>
var D=null, LIVE=(location.protocol==='http:'||location.protocol==='https:');
/* 🔴 board.html 的签名(服务端随快照下发)。布局与样式都在本文件里,而页面只轮询 board.json
⇒ 改了看板**不刷新页面就看不到**(用户 2026-09-30 为此连问两次:"怎么没有变化呢")。
⇒ 发现签名变了就**自动重载一次**,⛔ 不再要求用户手动 F5。 */
var HTML_SIG=null;
/* 🔴 风格切换:只改 <html data-style>。颜色/字体/网格全走 CSS 令牌 ⇒ **不用重画图**。
⛔ `base` 是"保留的当前风格" —— 选它时把属性**摘掉**,好让它继续跟随系统深浅色。 */
var STYLE_KEY='dsh-board-style';
function currentStyle(){
var s=document.documentElement.getAttribute('data-style');
return (s==='archify-dark'||s==='archify-light')?s:'base';
}
function applyStyle(s){
if(s!=='archify-dark'&&s!=='archify-light') s='base';
if(s==='base') document.documentElement.removeAttribute('data-style');
else document.documentElement.setAttribute('data-style',s);
try{ localStorage.setItem(STYLE_KEY,s) }catch(e){}
var btns=document.querySelectorAll('#stylesw button');
for(var i=0;i<btns.length;i++){
var on=(btns[i].getAttribute('data-style')===s);
btns[i].classList.toggle('on',on);
btns[i].setAttribute('aria-pressed', on?'true':'false');
}
}
function initStyleSwitcher(){
var box=document.getElementById('stylesw');
if(!box) return;
box.addEventListener('click',function(ev){
var b=ev.target.closest?ev.target.closest('button[data-style]'):null;
if(b) applyStyle(b.getAttribute('data-style'));
});
/* 键盘:方向键在三个按钮间走 */
box.addEventListener('keydown',function(ev){
if(ev.key!=='ArrowLeft'&&ev.key!=='ArrowRight') return;
var order=['base','archify-dark','archify-light'], cur=order.indexOf(currentStyle());
cur=(cur+(ev.key==='ArrowRight'?1:order.length-1))%order.length;
applyStyle(order[cur]);
var b=box.querySelector('button[data-style="'+order[cur]+'"]'); if(b) b.focus();
ev.preventDefault();
});
applyStyle(currentStyle()); /* 把按钮高亮同步到实际生效的风格 */
}
function esc(s){return String(s==null?'':s).replace(/[&<>"]/g,function(c){
return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]})}
function ago(iso){
if(!iso)return '—';
var d=new Date(String(iso).replace(' ','T')); if(isNaN(d))return String(iso);
var s=Math.max(0,(Date.now()-d.getTime())/1000);
return s<90?Math.round(s)+' 秒前':(s<5400?Math.round(s/60)+' 分钟前':Math.round(s/3600)+' 小时前');
}
function chip(t,cls,title){return '<span class="chip '+(cls||'')+'"'
+(title?(' title="'+esc(title)+'"'):'')+'>'+esc(t)+'</span>'}
function fmtB(n){
if(n==null||isNaN(n))return '—';
n=Number(n);
return n<1024?(n+' B'):(n<1048576?((n/1024).toFixed(1)+' KB'):((n/1048576).toFixed(1)+' MB'));
}
function emptyBox(title,action){
return '<div class="empty"><strong>'+esc(title)+'</strong>'+esc(action||'')+'</div>'}
function tailOf(p){return String(p==null?'':p).replace(/\\/g,'/').replace(/\/+$/,'').split('/').pop()||'—'}
/* ⛔ 界面上不出现裸的 done/running/completed(用户看的是中文) */
function zhState(st){return {done:'已完成',running:'进行中',todo:'待办',pending:'待办',
blocked:'受阻',claimed:'已认领'}[String(st||'')]||(st||'—')}
function zhSess(st){return {working:'执行中',completed:'已完成',error:'出错',archived:'已归档',
idle:'空闲'}[String(st||'')]||(st||'—')}
/* 🔴 顶部「本项目」卡 —— 用户 2026-10-01 定调:
「为什么看板里不显示**项目目标**了,项目的**属性标签 规则 用一列描述**就好,
重点是看**目标需求 完成情况**」
⇒ 版面主角=**目标**(第一行)+**完成情况**(第二行,进度条 + 逐条点名未完项);
属性(目标 id / 默认主会话 / 工作区)与规则(归属判据)压成**最后那一列描述**,⛔ 不再各占一行抢戏。 */
function renderProject(d){
var p=d.project||{}, acc=(d.goal||{}).acceptance||{}, keys=Object.keys(acc).sort();
var src=p.topics_source||{}, mbt=p.main_by_topic||{}, tops=p.topics||[];
/* ── ① 目标(主角)── */
var goalHtml='<div class="projGoal"><span class="critLead">目标</span>'
+'<span class="pill">'+esc(p.short||'(未声明主题)')+'</span>'
+'<b>'+esc(p.title||'(未声明目标)')+'</b></div>';
/* ── ② 完成情况(第二主角)──
⚠️ 2026-09-30:**没有有效验收判据时 ⛔ 不许显示成"无验收记录"那种中性字样** ——
判据是"**有没有有效项**",不是"字典空不空"(只写 `_说明` 行时旧代码会当成"全过")。
⚠️ 非 pass 的**逐条点名** —— 只写"4/5 通过"看不出**还差什么**(用户要的是"看完成情况")。 */
var pass=keys.filter(function(k){return String(acc[k]).toLowerCase()==='pass'}).length;
var bad =keys.filter(function(k){return String(acc[k]).toLowerCase()!=='pass'});
var accHtml;
if(keys.length){
var pct=Math.round(pass/keys.length*100);
accHtml='<div class="accRow"><span class="critLead">完成情况</span>'
+'<span class="accBar'+(bad.length?' is-badn':'')+'" role="img" aria-label="验收通过 '+pass+' / '+keys.length+'">'
+'<i style="width:'+pct+'%"></i></span>'
+'<b class="accNum '+(bad.length?'is-bad':'is-ok')+'">'+pass+' / '+keys.length+' 通过</b>'
+(bad.length
? '<span class="oldline">未完:'+bad.map(function(k){
return esc(k)+'('+esc(acc[k])+')'}).join('、')+'</span>'
: '<span class="oldline">全部通过</span>')
+'</div>';
}else{
accHtml='<div class="accRow"><span class="critLead">完成情况</span>'
+chip('⚠️ 未声明验收判据(⛔ 不因此判完成)','is-warn')+'</div>';
}
/* ── ③ 任务类别:**保留成 chips**(⛔ 不压进描述列)——
因为它带"**该类的主会话是谁**",是**操作性信息**;而"属性/规则"只是背景。
🆕 任务类别(用户:「同一个工作区 多会话协作…**通过协作会话名称前缀**区分具体任务会话」)
⇒ 每个类别一枚 chip,且 chip 里带**该类的主会话** —— ⛔ 否则看不出"哪条管哪一类";
没有主会话的类别用警示色(那类的件投出去会被降级报告)。
🔴 2026-10-01 用户:「**看板要精简的展示重点有效的信息,不是写说明书**」
⇒ 括注("同一个工作区 · 按名称前缀区分")**删掉** —— 标签就叫「任务类别」,够了;
**来源细节**(什么时候、谁说明的)挪进 chip 的悬停提示,⛔ 不占版面。 */
var tp=tops.length
? tops.map(function(t){ var s=mbt[t]||'';
var _tip='来源:'+(src.kind==='declared'
? ('调用时在对话里说明'+(src.at?(' · '+src.at):'')+(src.by?(' · '+src.by):''))
: (src.kind==='fallback' ? '⛔ 还没在对话里说明过(暂按目标简称走单类别)' : '⛔ 目标与类别都还没声明'));
return '<span class="chip'+(s?'':' is-warn')+'" title="'+esc(_tip)+'">'+esc(t)
+(s?(' 主会话 <code>'+esc(s)+'</code>'):' ⚠️ 无主会话')+'</span>'}).join('')
: '<span class="chip is-warn">(未声明任务类别)</span>';
/* ⚠️ 「**没声明过**」必须留在**版面上**(同族红线:有却不说 / 没有却说有)——
但只有一句,⛔ 不再带"怎么说明"那种指导语(那是说明书,进 `?`)。 */
var tpLead = (src.kind==='fallback')
? '<span class="critLead">任务类别 <b class="warnTx">⚠ 未声明</b></span>'
: '<span class="critLead">任务类别</span>';
document.getElementById('proj').innerHTML = goalHtml + accHtml
+'<div class="topics">'+tpLead+tp+'</div>';
/* 🔴🔴 2026-10-01(用户两次:「**看板要精简的展示重点有效的信息,不是写说明书**」)
⇒ 版面上**只留三行**:目标 / 完成情况 / 任务类别。
其余全是**背景与规则**(目标 id、默认主会话、工作区、归属判据、类别来源的时间与人),
⛔ 不再占版面 —— 它们**一条都没丢**,全部追加进这一块右上角 `?` 里
(`?` 本来就是"小字说明"的正式落点;本文件 `frontTip` 早就是这个写法,此处同款)。
⚠️ 动态部分每轮**先还原 static 文案**再追加 ⇒ ⛔ 不会一轮轮叠加。 */
var tip=document.getElementById('projTip');
if(tip){
if(!tip.dataset.base) tip.dataset.base=tip.innerHTML; /* 首轮把静态文案存下来 */
tip.innerHTML=tip.dataset.base; /* 每轮先还原,防重复追加 */
var _crit=(p.criteria||[]).map(esc).join(' / ')||'—';
tip.innerHTML+='<br><br><b>这一格目标的依据</b>'
+'(目标 id <code>'+esc(p.id||'—')+'</code>'
+' · 默认主会话 <code>'+esc(p.main_sid8||'—')+'</code>'
+' · 工作区 <code>'+esc(tailOf(p.workspace))+'</code>)<br>'
+'<b>归属判据</b>(任一命中即算作本项目):'+esc(_crit)+'<br>'
+'<b>任务类别的来源</b>:'+(src.kind==='declared'
? ('调用时在对话里说明'+(src.at?(' · '+esc(src.at)):'')+(src.by?(' · '+esc(src.by)):''))
: (src.kind==='fallback'
? '⛔ 还没在对话里说明过 ⇒ 现按目标简称走单类别;说明方式:调用本技能时讲清「任务类别」'
: '⛔ 目标与类别都还没声明(调用本技能时在对话里说明)'));
}
}
/* 🔴🔴 架构图布局(**用户 2026-09-30 指定**)—— 纵向四层,从上到下:
① 用户 ② 主会话 ③ 协作会话1 … 协作会话n(**个数是活的**,按本项目在册会话渲染)
④ 协作程序 + 监督程序(并排) + 底部一条「宿主地基」带
每条边都走**空白带**(⛔ 不横穿节点框):
① 派活带 y=264 ② 上报带 y=404 ③ 上报走右侧竖井 x=1254 ④ 收结果走地基带上方
🔴 每格都带真状态:会话取宿主库 `status`,程序取状态戳 mtime(`board.json.runtime`)。 */
function fmtAge(m){
if(m==null)return '—';
m=Number(m);
return m<1?'刚刚':(m<90?Math.round(m)+' 分钟前':(m<2880?Math.round(m/60*10)/10+' 小时前':Math.round(m/1440*10)/10+' 天前'));
}
/* ⚠️ 中日韩字符 ≈ 1em(13px 字号 ⇒ 约 13px/字),ASCII 约 6.8px。
⛔ 别再用"每字符 8.4px"那种估法 —— 会把标题算长一倍、**文字溢出框外**(第一版就是这个毛病)。
下面按**逐字符实际宽度**累加,比"数够 N 个字符就切"稳得多。 */
function textW(s, cjk, ascii){
s=String(s==null?'':s); var w=0;
for(var i=0;i<s.length;i++){ w += (s.charCodeAt(i) > 0x2E80 ? cjk : ascii); }
return w;
}
function fitText(s, maxPx, cjk, ascii){
s=String(s==null?'':s);
if(textW(s,cjk,ascii)<=maxPx) return s;
var out='';
for(var i=0;i<s.length;i++){
var c=s.charAt(i), w=(s.charCodeAt(i) > 0x2E80 ? cjk : ascii);
if(textW(out,cjk,ascii)+w+ascii>maxPx) return out+'…';
out+=c;
}
return out;
}
/* 两行版:先按整宽切一行,剩下的切第二行(用于"标题较长、框较窄"的格子) */
function fitTwo(t, maxPx, cjk, ascii){
t=String(t==null?'':t);
if(textW(t,cjk,ascii)<=maxPx) return [t];
var cut=0, w=0;
for(var i=0;i<t.length;i++){
var cw=(t.charCodeAt(i)>0x2E80?cjk:ascii);
if(w+cw>maxPx) break;
w+=cw; cut=i+1;
}
return [t.slice(0,cut), fitText(t.slice(cut), maxPx, cjk, ascii)];
}
function drawArch(d){
var S=d.sessions||[], rt=d.runtime||{}, LB=d.labor||[];
/* 🔴 2026-10-01(用户报「**创建了协作会话 但是 看板中没有展示**」):
同工作区、**但按命名判据没归入本项目**的会话。⛔ 它们**不是**本项目会话
(不算进 `S`、不进第三层、不进分工板);**但必须说出口** ——
否则一条真在跑的棒会「凭空消失」,读者只会以为机制没派活(同族红线:读到了却不说)。
典型成因:棒名写成 `[协作]-<昵称>-…`(漏方括号、或用 `goal.short` 而非 `topics`)⇒
`parse_session_name()` / `in_project()` 双双认不出。 */
var UNREC=d.sessions_unrecognized||[];
/* 🆕 2026-09-30:**每个任务类别各自的主会话**(`{类别: sid8}`)—— 第三层按类别渲染要用它 */
var MBT=(d.project||{}).main_by_topic||{};
var main=(S.filter(function(x){return x.role==='主会话'})[0])||null;
var runningS=S.filter(function(x){return x.status==='working'});
var works=S.filter(function(x){return x.role!=='主会话'});
/* 🔴🔴 2026-10-01 用户定 —— **这一排第三次换语义**(前两次的来历见 §0.5.6):
「**主会话 下面 那一排只放协作会话**,横着排 有几个放几个,当前没有对应协作会话 就空着。
**唤醒机制如果是主会话的 事就放到主会话框里去**」
⇒ 这一排 = **协作会话**(本项目里 `role !== '主会话'` 的会话),⛔ 不再按"任务类别"铺格:
· 🔴 **任务类别是主会话自己的事** ⇒ 搬进 R2 主会话框,一行「负责类别:…」;
· 🔴 台账里的**旧线(legacy)** 也⛔ 不占这一排 ⇒ 汇总改到**图外说明**里摊开说(⛔ 不静默丢)。
⚠️ 仍算 `_lg`:**图外说明**要报"几条线、多少件"(本线红线:读到了就得说出口)。 */
var _lg=LB.filter(function(x){return x.kind==='legacy'});
var _fold=null;
if(_lg.length){
var _ft=0,_fd=0,_fo=0,_fr=[];
_lg.forEach(function(x){_ft+=x.total||0;_fd+=x.done||0;_fo+=x.open||0;
_fr=_fr.concat(x.running||[])});
_fold={total:_ft,done:_fd,open:_fo,running:_fr};
}
/* 这一排画谁:**协作会话**。用户要"有几个放几个" ⇒ 放宽上限(MAXW 只防极端,超了在图外点名)。
⛔ 不再按宽度截断类别 —— 类别不在这排了。 */
var MAXW=8;
var cells=works.slice(0,MAXW), moreW=Math.max(0,works.length-MAXW);
var TOPS=(d.project||{}).topics||[];
var working=S.filter(function(x){return x.status==='working'}).length;
var o=[];
/* 🔴 两类"让主会话动起来"的外部来源,⛔ 别混为一谈:
· **Hook进程**(R5 那格)—— **事件驱动**:WorkBuddy 在钩子时机起一个短命子进程,跑完即退;
· **唤醒 等**(画在 ② 主会话**左边**)—— **时间驱动**:主会话建的那个定时任务。
用户 2026-09-30 三连:「把心跳的节点也放到看板协作架构图中」→「把心跳 图框做小一点 放到
主会话左边就行 本来就是主会话创建的」→「把心跳 改为 唤醒 里面是每5分钟核对目标状态」。
⚠️ 它们**不由技能侧写死**(⛔ 技能里不出现任何项目的组件名)——
由**使用方**经 `front.triggers` 给,形状:
`[{name, detail, detail2, label, up, edge, tip}]`。空 ⇒ 主会话左边不画格子。 */
var TG=(d.front&&d.front.triggers)||[];
/* 🔴 行距(用户 2026-09-30:「摆不下就**加高**些,线都没连全 有些线都看不到」)
⇒ **只加高、⛔ 不加宽**:`svg.arch{width:100%}` ⇒ 缩放比由**宽度**定 ⇒ 加高**不会**把字变小,
只是每层之间更宽敞(走线带、拐点、标签都放得下)。 */
/* 🔴 2026-10-01 再改:R2↔R3 的间隔 **28 → 68**(用户:「增加 主会话框图 和 下方协作会话框图
区域的距离」)—— 原来那条带只有 28px,而 R1↔R2 是 60、R3↔R4 是 66 ⇒ 唯独主会话到协作会话
那条**挤得连 ① 标签都快贴上框**:主会话往下一排放射线 + ① 标签都要在这条带里落位。
⇒ R3 起整体下移 40(`viewBox` 同步加高 40,⛔ 否则底部地基带会被裁)。 */
var R1={y:36,h:80}, R2={y:176,h:112}, R3={y:356,h:134}, R4={y:556,h:112},
R5={y:702,h:96}, RB={y:834,h:56}; /* R5 = Hook进程(时间驱动源另画在 主会话 左侧) */
/* ⚠️ R2 由 84 → **112**(2026-10-01):主会话框多一行「**负责类别:…**」——
用户:「**唤醒机制如果是主会话的 事就放到主会话框里去**」⇒ 类别从第三层搬进主会话框。 */
var CX=640, SX0=60, SX1=1220, CH=1254; // 会话行 60..1220;右侧竖井 1254
var B1=(R2.y+R2.h+R3.y)/2, B2=(R3.y+R3.h+R4.y)/2; // ① 派活带 / ② 上报带(自动取中,⛔ 别写死)
var B3=(R4.y+R4.h+R5.y)/2; // R4↔R5 之间的走线带(⛔ 别写死 554)
var PX=280, PW=300, UX=700; // 协作程序 280..580;监督程序 700..1000
function rect(x,y,w,h,tone){
var m={on:'node-on',off:'node-off',base:'node-base'};
o.push('<rect class="'+(m[tone]||'node-bg')+'" x="'+x+'" y="'+y+'" width="'+w+'" height="'+h+'" rx="14"/>');
}
function tx(x,y,t,cls,anchor){
o.push('<text class="'+(cls||'n-sub')+'" x="'+x+'" y="'+y+'"'+(anchor?' text-anchor="'+anchor+'"':'')+'>'+esc(t)+'</text>');
}
function edge(dstr,grey,noArrow){
o.push('<path class="edge'+(grey?'-grey':'')+(noArrow?'':' live')+'" d="'+dstr+'"'
+(noArrow?'':' marker-end="url(#ar)"')+'/>');
}
function lbl(x,y,t,anchor){tx(x,y,t,'e-label',anchor||'middle')}
function toneOf(st){return st==='working'?'n-ok':(st==='completed'?'n-idle':'n-warn')}
function wordOf(st){return st==='working'?'执行中':(st==='completed'?'已完成':(st||'—'))}
// ── ① 用户 —— 🔴 写**职责 + 可执行动作**(用户 2026-09-30 要求;⛔ 不写"你 · 一张看板"这种空话)
var w1=300, x1=CX-w1/2, in1=w1-40;
rect(x1,R1.y,w1,R1.h,'bg');
tx(x1+20,R1.y+28,'用户','n-title');
tx(x1+20,R1.y+50, fitText('职责:制定目标 · 调整方向 · 做决策', in1, 11, 6), 'n-sub');
tx(x1+20,R1.y+70, fitText('可做:看板 · 放行授权 · 改目标 · 喊停', in1, 11, 6), 'n-sub');
// ── ② 主会话 —— 🔴 显示**会话名**(用户 2026-09-30 要求),⛔ 不只给一个 id
var w2=380, x2=CX-w2/2, in2=w2-40;
var mOn=!!(main&&main.status==='working');
rect(x2,R2.y,w2,R2.h, main?(mOn?'on':'bg'):'bg');
tx(x2+20,R2.y+26,'主会话','n-title');
tx(x2+20,R2.y+50, main?fitText(main.title, in2, 13, 7):'(未声明主会话)', 'n-title-sm');
tx(x2+20,R2.y+70, main?fitText('判断 + 派活 · '+wordOf(main.status)+' · '+main.id8, in2, 11, 6)
:'——', main?(mOn?'n-ok':'n-idle'):'n-idle');
/* 🔴 2026-10-01:**它负责哪几个任务类别** —— 用户:「**唤醒机制如果是主会话的 事就放到主会话框里去**」
⇒ 类别不再占第三层,落在这里。⚠️ 按 `main_by_topic` **反查**(⛔ 别假设"只有一类"),
也⛔ 不因为查不到就闭嘴 —— 没说明过时明说「未在对话里说明」(同族红线)。 */
var _myT=TOPS.filter(function(t){return main && String(MBT[t]||'')===String(main.id8)});
tx(x2+20,R2.y+92, _myT.length? fitText('负责类别:'+_myT.join('、'), in2, 11, 6)
: '负责类别:—(未在对话里说明)', _myT.length?'n-sub':'n-warn');
// 用户 → 主会话
edge('M'+CX+' '+(R1.y+R1.h)+' L'+CX+' '+R2.y);
lbl(CX+56, (R1.y+R1.h+R2.y)/2, '发消息 · 看板');
// ── ②′ 主会话**创建**的时间驱动触发源(用户 2026-09-30:「把心跳 图框做小一点 放到**主会话左边**
// 就行,本来就是主会话创建的」)⇒ 小框贴在主会话左侧,一条「创建」箭头从主会话指过去。
// 数据仍由**使用方**给(`front.triggers`),⛔ 技能里不写死"唤醒"这个名字。
// ⚠️ 左边界 SX0 之外放不下时就只画**最靠前的一个**,其余在图外注释里点名(⛔ 不硬塞、不压连线)。
if(TG.length){
/* 🔴 形状=**会话同族的圆角矩形**(2026-10-01 二次改)—— 用户定性:
「**唤醒脉冲会话,本质还是会话**」⇒ 它 ⛔ 不是"调度配置/触发器",是**一条会话**
⇒ 形状回到 `rect rx=14`(与主会话/协作会话同族),**靠位置区分**(贴在主会话左边、
⛔ 不进下方协作会话排)。**外圈虚线保留** —— 表示"它靠脉冲被叫醒、不是一直在跑"。
(前一版画成**六边形**,理由是用户当时要求"和协作会话区分开";定性变了故改回。)
🔴 颜色与徽章管**运行状态** —— 用户:「要**实时展示运行状态**,包括**图框样式要能体现**」:
`t.state`:`running` ⇒ 绿 + ● 运行中 / `paused` ⇒ 黄 + ‖ 已暂停 / `none` ⇒ 灰 + ○ 未登记。
⚠️ 缺 `state` 时回落 `up`(向后兼容:老的使用方只给 `up` 也能画)。
⛔ 技能侧**不写死**这一格叫什么 —— 名字由使用方经 `front.triggers[].name` 给。 */
var TGAP=64, TBH=78, TBR=x2-TGAP; // 右边界 = 主会话左边界 − 连接带宽度
var TBW=236, shown=TG;
if(TG.length>1 || TBR-TBW<SX0){ TBW=Math.max(150, TBR-SX0); shown=TG.slice(0,1); }
var TBY=R2.y, yc0=TBY+TBH/2; // 🔴 2026-10-01 起**不再是六边形**(见上)
shown.forEach(function(t){
var x=TBR-TBW, up=!!t.up, txi=x+14, inner=TBW-28;
var st=(t.state||(up?'running':'paused'));
var stWord=(st==='running')?'运行中':((st==='none')?'未登记':'已暂停');
var stTone=(st==='running')?'n-ok':((st==='none')?'n-idle':'n-warn');
var bx=x+TBW/2;
// 外圈虚线光晕 —— 颜色跟状态走("脉冲式"的观感)
o.push('<rect class="trig-halo '+(up?'h-on':'h-warn')+'" x="'+(x-5)+'" y="'+(TBY-5)+'" width="'+(TBW+10)+'" height="'+(TBH+10)+'" rx="19"/>');
// 本体:**会话同族的圆角矩形**(⛔ 不再是六边形 —— 它本质是一条会话)
o.push('<rect class="'+(up?'node-on':'node-warn')+' trig-node" x="'+x+'" y="'+TBY+'" width="'+TBW+'" height="'+TBH+'" rx="14">'
+(t.tip?('<title>'+esc(t.tip)+'</title>'):'')+'</rect>');
// 状态徽章(本体上方)—— ⛔ 图形而非字符(字符要靠字体,缺 CJK 字体会变方框)
if(st==='running'){ o.push('<circle class="trig-dot" cx="'+(bx-21)+'" cy="'+(TBY-15)+'" r="4.2"/>'); }
else if(st==='none'){ o.push('<circle class="trig-idle-dot" cx="'+(bx-21)+'" cy="'+(TBY-15)+'" r="4.2"/>'); }
else{
o.push('<rect class="trig-pause" x="'+(bx-25)+'" y="'+(TBY-20)+'" width="3.6" height="10" rx="1.2"/>');
o.push('<rect class="trig-pause" x="'+(bx-19.4)+'" y="'+(TBY-20)+'" width="3.6" height="10" rx="1.2"/>');
}
tx(bx-12, TBY-11, stWord, stTone, 'start');
tx(txi,TBY+22, fitText(t.name||'(未命名触发源)', inner, 13, 7), 'n-title-sm');
tx(txi,TBY+42, fitText(t.detail||'', inner, 11, 6), 'n-sub');
tx(txi,TBY+58, fitText(t.detail2||'', inner, 11, 6), 'n-sub');
tx(txi,TBY+73, fitText(t.label||'状态未知', inner, 10, 5.6), up?'n-ok':'n-warn');
// 它 → 主会话 —— 🔴 用户 2026-09-30:「唤醒和主会话 **强调的是唤醒的动作**,不是创建的动作」
// ⇒ 箭头由它**指向主会话**("它把主会话叫起来"),标签也用「唤醒」;
// "主会话创建的"只作**来历**写在框里一行,⛔ 不再画成箭头。
edge('M'+(x+TBW)+' '+yc0+' L'+x2+' '+yc0);
if(t.edge) lbl((x2+x+TBW)/2, yc0-8, fitText(t.edge, TGAP-6, 11, 6), 'middle');
});
}
// ── ③ **协作会话**(🔴 2026-10-01 用户定:「**这一排只放协作会话**,横着排 有几个放几个,
// 当前没有对应协作会话 就空着」+「放一个空的框 说明 暂无协作会话」)——
// ⚠️ 这一排**换过两次语义**(按线 → 按任务类别 → 现在是**按协作会话**):
// · **任务类别是主会话自己的事** ⇒ 已搬进 R2(一行「负责类别:…」);
// · 台账旧线(legacy)的汇总 ⇒ 已搬到**图外说明**(⛔ 不静默丢)。
// 每格=**一条协作会话**;格内:会话名 / 状态·id / 所属类别 / 多久没动。
var cx=[], n=cells.length;
if(n===0){
/* 🔴 **空态也要画框**(用户 2026-10-01:「**放一个空的框 说明 暂无协作会话**」)——
空 ≠ 坏了。⛔ 不许因为"没内容"就把这一层省略掉(读者会以为图缺了一块)。 */
o.push('<rect class="node-bg" x="'+(CX-240)+'" y="'+R3.y+'" width="480" height="'+R3.h+'" rx="14"'
+' stroke-dasharray="6 5"/>');
tx(CX,R3.y+56,'暂无协作会话','n-idle','middle');
tx(CX,R3.y+82,'主会话派活时创建','n-sub','middle');
/* ⚠️ **两条通道的标签在空态也要画**(2026-10-01 修)—— 原来只画了 ① 的,
② 的标签在 `else` 分支里 ⇒ 没有协作会话时图上**只剩 ①**,看不出还有 ② 上报这条回程。
⛔ 空态 ≠ 这两条通道不存在;只是当前没有会话可以连。 */
lbl(CX,B1,'① 派活 · 自动化排期'); /* 🔴 2026-10-01:去掉 -8 偏移 ⇒ 标签**正中**落在间隔带里 */
lbl(CX,B2-8,'② 上报 · --report');
}else{
var gap=(n>5)?16:24, avail=SX1-SX0;
var w=Math.min(340, Math.max(150, (avail-(n-1)*gap)/n));
var total=n*w+(n-1)*gap, sx=CX-total/2;
cells.forEach(function(W){
var x=sx+cx.length*(w+gap); cx.push(x+w/2);
var inner=w-28;
var tone=(W.status==='working')?'on':((W.status==='completed')?'bg':'warn');
rect(x,R3.y,w,R3.h,tone);
tx(x+14,R3.y+24, fitText(W.title||'(未命名会话)', inner, 13, 7), 'n-title-sm');
tx(x+14,R3.y+50, fitText(wordOf(W.status)+' · '+W.id8, inner, 11, 6), toneOf(W.status));
/* ⚠️ 类别前缀是**会话标题里读出来的**(`session.topic`)—— 读不到就**说出来**
(⛔ 不许让格子看着正常、实际没有类别 ⇒ 那是假情报)。 */
tx(x+14,R3.y+72, W.topic? fitText('类别:'+W.topic, inner, 11, 6)
: '(标题里没读类别前缀)', W.topic?'n-sub':'n-warn');
/* ⚠️ `fmtAge()` 的返回值**自带"前"字**(`4 分钟前`)⇒ ⛔ 别再加一个"前"
(2026-10-01 实测拼成过「4 分钟前前有活动」)。 */
tx(x+14,R3.y+94, fmtAge(W.age_min)+'有活动', 'n-sub');
tx(x+14,R3.y+116, '它承接的件见下方「需求台账」', 'n-sub');
});
// ① 派活:主会话 → 各条协作会话(走中间带)
edge('M'+CX+' '+(R2.y+R2.h)+' L'+CX+' '+B1, null, true);
edge('M'+Math.min.apply(null,cx)+' '+B1+' L'+Math.max.apply(null,cx)+' '+B1, null, true);
cx.forEach(function(c){ edge('M'+c+' '+B1+' L'+c+' '+R3.y) });
lbl(Math.max.apply(null,cx), B1, '① 派活 · 自动化排期', 'end'); /* 同上:正中落在间隔带 */
// ② 上报:各条协作会话 → 协作程序(走下方带)
cx.forEach(function(c){ edge('M'+c+' '+(R3.y+R3.h)+' L'+c+' '+B2, null, true) });
edge('M'+Math.min.apply(null,cx.concat([PX+PW/2]))+' '+B2+' L'+Math.max.apply(null,cx.concat([PX+PW/2]))+' '+B2, null, true);
edge('M'+(PX+PW/2)+' '+B2+' L'+(PX+PW/2)+' '+R4.y);
lbl(Math.max.apply(null,cx), B2-8, '② 上报 · --report', 'end');
}
if(moreW>0){
tx(SX1-6, R3.y+R3.h+16, '另有 '+moreW+' 条协作会话未画(图上最多 '+MAXW+' 条)', 'n-warn', 'end');
}
// ── ④ 协作程序 + 监督程序
var pr=rt.prog||{}, gu=rt.deliver||{}, q=d.queue||{}, qb=q.by||{}; /* gu = 上报(旧名「监督程序」,再早叫「投递」) */
// 协作程序:🔴 带**队列计数**(用户 2026-09-30:「协作程序 也要显示当前待验收队列数量」)
o.push('<rect class="'+(pr.up?'node-on':'node-off')+'" x="'+PX+'" y="'+R4.y+'" width="'+PW+'" height="'+R4.h+'" rx="14">'
+'<title>队列 = 需求台账 tasks.json 四态:待执行 '+((qb.pending)||0)+' / 执行中 '+((qb.running)||0)
+' / 已完成 '+((qb.done)||0)+' / 有阻碍 '+((qb.blocked)||0)+'。机制里没有「待验收」这个态。'
+'<br>'+esc(pr.how||'')+'</title></rect>');
tx(PX+18,R4.y+26,'协作程序','n-title');
tx(PX+18,R4.y+50, '队列 '+((q.total)||0)+' 件 · 已完成 '+((qb.done)||0)+' · 未完 '+((q.open)||0)
+(((qb.blocked)||0)?(' · 受阻 '+qb.blocked):''), ((q.open)||0)?'n-warn':'n-idle');
/* 🔴 2026-10-01:补一句**它怎么起来的** —— 实测 `runtime.prog.by = "钩子 --tick"`
⇒ 协作程序和右边的上报一样是**钩子按需唤起**的,⛔ 不是常驻进程。
原句只写「在线」,会被读成"有个服务一直开着"(同族:让格子看着正常却与事实不符)。 */
tx(PX+18,R4.y+70, fitText('需求台账 / 队列 · 只维护不上报 · 按需唤起', PW-36, 11, 6), 'n-sub');
tx(PX+18,R4.y+92, pr.label||'状态未知', pr.up?'n-ok':'n-bad');
// 监督程序:🔴 停着就把**停因**写在格子里(⛔ 不让用户只能猜"为什么停了")
var greason=String(gu.reason||'');
o.push('<rect class="'+(gu.up?'node-on':'node-warn')+'" x="'+UX+'" y="'+R4.y
+'" width="'+PW+'" height="'+R4.h+'" rx="14">'+(greason?('<title>'+esc(greason)+'</title>'):'')+'</rect>');
/* 🔴 这一格以前叫「监督程序」、并且显示一个"已停"的红框 ⇒ 让人以为有东西坏了。
实际上**它不是常驻进程**,是宿主钩子唤起的一次性 `--tick` ⇒ 没有"启动/停止"这回事。 */
tx(UX+18,R4.y+26,'上报','n-title');
tx(UX+18,R4.y+50, fitText(gu.label||'状态未知', PW-36, 12, 6.5), gu.up?'n-ok':'n-warn');
tx(UX+18,R4.y+70,'WorkBuddy 用时才喊它 · 不是一直开着','n-sub');
tx(UX+18,R4.y+92, fitText('它平时不占进程 ⇒ 没有"启动/停止"这回事', PW-36, 11, 6), 'n-sub');
// 队列:协作程序 → 监督程序
edge('M'+(PX+PW)+' '+(R4.y+R4.h/2)+' L'+UX+' '+(R4.y+R4.h/2));
lbl((PX+PW+UX)/2, R4.y+R4.h/2-10, '队列');
// ③ 上报 → 主会话(右侧竖井,⛔ 不横穿会话行)
var y4=R4.y+R4.h/2;
edge('M'+(UX+PW)+' '+y4+' L'+CH+' '+y4+' L'+CH+' '+(R2.y+R2.h/2)+' L'+(x2+w2)+' '+(R2.y+R2.h/2));
lbl(1050, R2.y+R2.h/2-10, '③ 上报 · reply/唤醒', 'middle');
// ── ⑤ 🔴 **Hook进程** —— 用户 2026-09-30 指出「图里是不是还缺少关键节点,比如宿主起的子进程」。
// 它**才是上报的真正执行者**:WorkBuddy 在钩子时机起一个短命子进程,跑完即退 ⇒ **事件驱动**。
// ⚠️ 时间驱动的那些(如「唤醒」)**不在这一行**,画在 ② 主会话左边(见上)—— 两者不是一回事:
// 整机没活动时 Hook 不会被唤起,「唤醒」补的正是这个缺口。
var hasTG=TG.length>0;
/* 🔴 2026-10-01(用户:「**Hook进程 框图宽度往右边延长一些,让 tick 那条线能竖直摆放**」)
⇒ 框宽 480 → **490**(右边界 880 → 890)。
⚠️ 加宽**不是为了好看**,是为了让 `--tick` 的出口点能落在**框顶边之内**(见下方 TICKX)——
⛔ 改宽度时别把它缩回去,否则出口点会跑到框外、线头悬空。 */
var hkX=400, hkW=490;
rect(hkX,R5.y,hkW,R5.h,'on');
tx(hkX+20,R5.y+26,'Hook进程','n-title');
tx(hkX+20,R5.y+48, fitText('WorkBuddy 在特定时机喊它 · 干完就退', hkW-36, 11, 6), 'n-sub');
tx(hkX+20,R5.y+66, fitText('它是 WorkBuddy 的子进程 ⇒ 拿得到网关口令', hkW-36, 11, 6), 'n-sub');
tx(hkX+20,R5.y+84, fitText('两个模式:--once(队列)/ --tick(上报)', hkW-36, 11, 6), 'n-sub');
// WorkBuddy → 钩子(它在钩子时机起它)
edge('M'+(hkX+70)+' '+RB.y+' L'+(hkX+70)+' '+(R5.y+R5.h), true);
lbl(hkX+82, (RB.y+R5.y+R5.h)/2, '用时才喊它', 'start');
// 钩子 → 协作程序(--once):直接向上
edge('M'+(hkX+40)+' '+(R5.y)+' L'+(hkX+40)+' '+(R4.y+R4.h), true);
lbl(hkX+52, (R5.y+R4.y+R4.h)/2, '--once', 'start');
/* Hook进程 → 上报(--tick):🔴 **一条纯竖直的线** —— 用户 2026-10-01:
「Hook进程 框图宽度往右边延长一些,**让 tick 那条线能竖直摆放**」。
⚠️ 原版出口在 `hkX+hkW-40`=840、而入口在上报框中心 `UX+PW/2`=850 ⇒ 中间夹了一段
**10px 的横折**(`L840 B3 → L850 B3`),看着像线画歪了。
⇒ 现在出口 x **≡ 上报框中心**,整条线只剩竖直一段(R5 顶 → R4 底),⛔ 不再横折。
⚠️ 出口点必须仍落在 Hook 框**顶边之内**(hkX..hkX+hkW)—— 这就是上面加宽到 490 的原因;
改这两个数时**必须一起改**,否则线头会悬在框外(几何自检 ⑥ 段守着这条)。 */
var TICKX=UX+PW/2;
edge('M'+TICKX+' '+R5.y+' L'+TICKX+' '+(R4.y+R4.h), true);
lbl(TICKX+12, B3+4, '--tick', 'start');
// ── 最底一条:WorkBuddy
// (用户 2026-09-30:「**宿主地基 这个就叫 WorkBuddy** 不要用这些AI语言」⇒ ⛔ 不用自造词)
rect(SX0,RB.y,SX1-SX0,RB.h,'base');
tx(SX0+20,RB.y+33,'WorkBuddy · 只读它的数据库 / 排期 / 钩子 / 文件占用登记','n-sub');
// ④ 收结果:WorkBuddy → 协作程序(只读,灰线)。
// ⚠️ 走 x=340 —— **必须落在协作程序框底边(280..580)上**,否则线头悬空
// (2026-09-30 用户一眼看出:"WorkBuddy 左边那根线是要连到哪里?")。
// x=340 同时⛔ 不穿 R5 的 Hook进程框(400..890 —— 2026-10-01 框宽 480→490 后右边界右移)。
edge('M340 '+RB.y+' L340 '+(R4.y+R4.h), true);
// ⚠️ 标签放线的**左边**(anchor end)—— 放右边会和「在钩子时机起它」挤成一行连读。
lbl(326, RB.y-14, '④ 收结果 · 读 WorkBuddy 库', 'end');
o.push('<defs><marker id="ar" viewBox="0 0 10 10" refX="8" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">'
+'<path d="M2 1L8 5L2 9" fill="none" stroke="var(--color-accent)" stroke-width="1.5" stroke-linecap="round"/></marker></defs>');
document.getElementById('arch').innerHTML=o.join('');
/* ══════════════════════════════════════════════════════════════════════════
🔴 2026-10-01(用户:「**这段乱七八糟的 也精简 分段落描述**」)
原版把八九条说明用 ` · ` 串成**一整行**(还要加上面那行静态小字),读起来是一堵墙 ——
看不出哪句在说哪件事。⇒ 改成 **分块 + 一行一条**:
· 「连线」 —— ①→④ 与队列**分别是什么意思**(静态语义)
· 「当前」 —— **实时读数**(每次刷新会变的那几项)
· 「<唤醒会话名>」 —— 那一格**和别的是什么关系**(仅当画了它)
⛔ 只**拆句、删冗余**,⛔ **不新增信息**;与右上角 `?` 重复的那段("第三层横着排/
空框占位")就在这里去掉 —— 它本来就是 `?` 里的内容,露两遍反而像两回事。
══════════════════════════════════════════════════════════════════════════ */
function _blk(lead, items){
items=(items||[]).filter(function(x){return !!x});
if(!items.length) return '';
return '<div class="anBlock"><div class="anLead">'+lead+'</div>'
+items.map(function(x){return '<div class="anItem">'+x+'</div>'}).join('')+'</div>';
}
/* ── 块①:当前(**实时读数**)──
🔴 2026-10-01 用户:「**连线 · 图上那几条线分别是什么** 这段话不需要,**看框架图就行了**」
⇒ 删掉原来那块 ①派活/②上报/③上报/④收结果/队列 的图例 —— 那几条线**图上本来就标了字**
(`① 派活 · 自动化排期` 等 SVG 标签就在线旁边),图外再列一遍是**重复**。
⚠️ 但其中"**① 是唯一能开新会话的通道**"这一条**图上看不出来**(是硬约束,不是线名)
⇒ 它搬进「协作架构」右上角 `?`,⛔ 不丢(同族红线:读到了就要说)。 */
var _now=[
'主会话<b>负责的任务类别</b>:'+(TOPS.length
? TOPS.map(function(t){var s=MBT[t]||''
return '<code>'+esc(t)+'</code>'+(s?('('+esc(s)+')'):'(⚠ 无主会话)')}).join('、')
: '⚠ 未在对话里说明过'),
'协作会话:在跑 <b>'+runningS.length+'</b> 个 · 第三层画 <b>'+cells.length+'</b> 格'
+((moreW>0)?('(另有 <b>'+moreW+'</b> 条未画)'):'')
+(cells.length?'':'(一条都没有 ⇒ 画空框占位)'),
'本项目会话共 <b>'+S.length+'</b> 个(含历史)· 明细见下方「会话」表',
/* 🔴 2026-10-01:**同工作区但没归入本项目**的会话 —— 数据披露,⛔ 不许静默丢。
⚠️ 用 `is-warn` 的语气说,但**措辞不许判它是本项目**(归属判据没变,它确实不算)。
位置说明:这是**机制层**的问题(棒命名不合规 ⇒ 会被漏管),所以留在**版面上**、⛔ 不进 `?`。 */
/* ⚠️ **只把"在跑的 / 近 3 小时的"摊在版面上**(用户反复要求"精简")——
陈年历史(早已 completed 的旧棒)**只计一个数**,⛔ 不铺半屏。
🔴 但**计数必须是全量**(⛔ 不许因为"它很旧"就少说一句)—— 这是同族红线的底线。 */
UNREC.length ? (function(){
var _hot=UNREC.filter(function(u){return u.status==='working'||Number(u.age_min||1e9)<=180});
var _show=_hot.slice(0,3);
return '⚠️ 本工作区另有 <b>'+UNREC.length+'</b> 条会话**按命名判据未归入本项目**'
+'(⛔ 不算本项目会话 ⇒ **会被漏管**):'
+(_show.length?('在跑/近 3 小时 <b>'+_hot.length+'</b> 条 —— '
+_show.map(function(u){return '<code>'+esc(u.id8)+'</code> '+esc(u.title)}).join(' · ')
+(_hot.length>_show.length?(' … 等 <b>'+_hot.length+'</b> 条'):''))
:'其中**近 3 小时内没有**(都是历史)')
+' · 其余 <b>'+(UNREC.length-_hot.length)+'</b> 条是历史'
+'(全量见 `board.json` 的 `sessions_unrecognized`)'
+' —— 成因:棒名须为 `[协作]-[<类别>]-<具体>`(第 2 级**带方括号**、'
+'值取 `goal.json` 的 `topics`(任务类别),⛔ 不是 `short` 简称)';
})() : '',
// ⚠️ 「另有 N 个未画」是**数据披露**(⛔ 不许静默丢)⇒ 留在版面上;它随数据变,⛔ 不能进 `?`
(TG.length>1)?('另有 <b>'+(TG.length-1)+'</b> 个时间驱动源未画(图上只画最靠前的一个)'):'',
];
/* 🔴 「未归类」必须在**图外说清**(⛔ 不许静默丢):这些线⛔ 不属任何当前任务类别、
也⛔ 不画在第三层(那排只放协作会话)—— 但"几条线、多少件"必须说出口。 */
if(_fold) _now.push('<b>未归类</b>:<b>'+_lg.length+'</b> 条线('
+_lg.map(function(z){return '<code>'+esc(z.name)+'</code>'}).join('、')
+')不在当前任务类别清单里,共 <b>'+_fold.total+'</b> 件 · 已完成 <b>'+_fold.done+'</b>'
+'(⛔ 不是第三层的协作会话,明细见下方台账表)');
/* 🔴 2026-10-01 用户:「这个**不需要**吧,看框架图就行了」⇒ 原来那块「<唤醒会话名>」**整块删掉**。
它的三条定性(**独立会话**/⛔ **不在主会话上处理**/**随需求确定时才创建**)+
徽章色义(绿 ● 在跑 · 黄 ‖ 停着 · 灰 ○ 还没建)**一条都没丢** ——
全在「协作架构」右上角 `?` 里(那块本来就是小字说明的正式落点:
`agent-operating-rules`「⛔ 别往版面摊大段说明」)。
⛔ 也别"在图里再标一遍" —— 图上已经有格名、类别、状态色、位置这四样了。
⚠️ 形状改过两次(六边形 → 圆角矩形,靠**位置**区分)—— 那段历史留在 CSS 与本文注释里。 */
document.getElementById('archNote').innerHTML=_blk('当前', _now);
/* 标题提示:按"图上真画了哪几层"说,⛔ 不用自造词(用户 2026-09-30:
「宿主地基 这个就叫 WorkBuddy 不要用这些AI语言」) */
var _ah=document.getElementById('archHint');
if(_ah) _ah.textContent='用户 · 会话 · 协作会话 · 程序 · WorkBuddy';
}
function render(d){
D=d; document.getElementById('err').hidden=true;
var iv=(d.board&&d.board.refresh_interval)||3;
POLL=Math.max(1000, Math.round(iv*1000));
document.getElementById('iv').textContent=iv;
/* 🔴 本文件(布局/样式)改了就自动重载一次 —— 首次拿到当作基线,之后只在**变了**那一刻 reload。 */
var hs=(d.board&&d.board.html_sig)||'';
if(hs){
if(HTML_SIG===null) HTML_SIG=hs;
else if(HTML_SIG!==hs){ HTML_SIG=hs; location.reload(); return; }
}
/* ⏱「保留的快照时刻」只在这一处显示(协作架构右上角)⇒ ⛔ 不再往 header 放第二份 */
document.getElementById('ts2').textContent=(d.ts||'').slice(11)||'—';
paintAge();
var wb=document.getElementById('warnbar'), wn=(d.warn||[]);
if(wn.length){ wb.hidden=false; wb.textContent='⚠️ 有数据没读到(看板仍可用):'+wn.join(';'); }
else wb.hidden=true;
/* ⚠️ 2026-10-01:`renderProject(d)` **从这一行搬走了** —— 它属于"**随 tab 变**"的那一组,
统一由 `paintScope(d)` 在下面画(⛔ 这里再画一次会被立刻覆盖,白画)。 */
/* 🔴 「前置」整块**通用渲染** —— 技能侧⛔ 不认识任何具体组件。
内容全部来自**使用方**的 `board_ext.py`(经 board.json 的 `front`):
chips[{label,up,ok,bad,tip}] · paragraphs[{text,tone}] · tip
⛔ 技能里不许再出现某个项目的端口/路径/真名(用户 2026-09-30 定则:
「技能就是技能 程序就是程序,谁用产生的文件 放在他自己那里」)。 */
/* 🔴 `deliver` = 旧名「监督程序」的那条职责(**上报**)。2026-09-30 改名:
它不是"该常驻的进程",而是宿主钩子唤起的一次性 `--tick` ⇒ ⛔ 不再显示"已停"。 */
var f=d.front||{}, rt2=d.runtime||{}, pg=rt2.prog||{}, gd=rt2.deliver||{};
var chips='';
(f.chips||[]).forEach(function(c){
chips+=chip(String(c.label||'')+(c.up?' '+(c.ok||''):' '+(c.bad||'')),
c.up?'is-ok':'is-bad', c.tip||'');
});
var paras='';
(f.paragraphs||[]).forEach(function(p){
var t=String(p.text||'');
paras+='<p class="lede" style="margin:8px 0 0'+(p.tone==='warn'?';color:var(--color-warning)':'')+'">'+esc(t)+'</p>';
});
document.getElementById('front').innerHTML='<div class="rowchips">'
+chip('协作程序 '+(pg.label||'未知'), pg.up?'is-ok':'is-bad')
+chip('上报 '+(gd.label||'未知'), gd.up?'is-ok':'is-warn')
+'</div>'
+(chips?'<div class="rowchips" style="margin-top:8px">'+chips+'</div>':'')
+paras
+((f._missing||f._err)?('<p class="lede" style="margin:8px 0 0">看板扩展:'+esc(String(f._missing||f._err))+'</p>'):'');
/* 🔴 长解释一律进「?」—— ⛔ 不摊在版面上(用户 2026-09-30 要求) */
var tip=document.getElementById('frontTip');
if(tip){
if(!tip.dataset.base) tip.dataset.base=tip.innerHTML; /* 首轮把静态文案存下来 */
tip.innerHTML=tip.dataset.base; /* 每轮先还原,防重复追加 */
if(f.tip) tip.innerHTML+='<br><br>'+String(f.tip); /* 项目侧说明(允许含标签) */
if(gd.stopped && gd.reason){
tip.innerHTML+='<br><br><b>监督程序为什么是停的</b>(主会话主动止损,不是崩溃):'
+esc(String(gd.reason)).replace(/\*\*/g,'').replace(/`/g,'');
}
}
/* 板块标题与类别标签也由使用方给(⛔ 不在技能里写死"链路前置/组件状态") */
var fh=document.querySelector('#frontCard h2');
if(fh) fh.innerHTML=esc(f.title||'前置')+(f.tag?('<span class="hint">'+esc(f.tag)+'</span>'):'');
var n=d.notify||{};
document.getElementById('notify').innerHTML=
'<table><tr><th>本条待处理</th><td><code>'+esc(n.awaiting||'(无)')+'</code></td></tr>'
+'<tr><th>握手阶段</th><td>'+esc(n.phase||'—')+'</td></tr>'
+'<tr><th>最近发出</th><td><span class="ellip"><code>'+esc(n.sent||n.notice||'(无)')+'</code></span></td></tr></table>';
var w=(d.wakeups||[]).slice().reverse();
document.getElementById('wake').innerHTML = w.length
? '<table><tr><th>时间</th><th>类型</th><th>结果</th></tr>'+w.map(function(x){
return '<tr><td class="num">'+esc(ago(x.ts))+'</td><td>'+esc(x.kind)+'</td><td>'
+(x.ok?chip('HTTP '+x.http,'is-ok'):chip('HTTP '+x.http,'is-bad'))+'</td></tr>'}).join('')+'</table>'
: emptyBox('还没有上报记录','队列有变化,或者长时间没动静时,会出现第一条。');
/* 🔴 剩下的四块(本项目 / 架构图 / 需求台账 / 会话明细)**随 tab 变** ⇒ 交给 `paintScope(d)`。
⛔ 不在这里画 —— 这里画的是**工作区级**的(前置 / 队列通知 / 最近上报)。 */
paintScope(d);
}
/* ══════════════════════════════════════════════════════════════════════════════
🔴🔴 多目标 tab(用户 2026-10-01:「把协作实时看板改为 tab 支持多个目标执行协作状态展示」)
数据:快照的 `goals[]`(`board.py` 产)—— **一格 = 一个目标**,活跃的排第一。
⛔ 老快照(没有 `goals` 键)⇒ 回落成"一格 tab",**行为与旧版一致**(向后兼容)。
作用域:切 tab **只换四块**(本项目 / 协作架构 / 需求台账 / 会话明细);
「前置 / 队列通知 / 最近上报」是**工作区级**的 ⇒ ⛔ 不随 tab 变(它们本就不属于某一个目标)。
记住选择:当前 tab 写进 URL hash(`#g=<目标 key>`)⇒ **刷新、分享链接都不丢**当前选的是哪个目标。
⛔ 此处**不含任何项目知识**(目标名一律来自快照)—— 同 `board_ext` 那条职责边界。
══════════════════════════════════════════════════════════════════════════════ */
var GIDX=0;
function goalKey(g){
return String((g&&(g.key||(g.goal&&(g.goal.short||g.goal.title))))||'');
}
function pickGoalIdx(gs){
var m=String(location.hash||'').match(/[#&]g=([^&]*)/);
if(!m) return 0;
var k='';
try{ k=decodeURIComponent(m[1]); }catch(e){ k=m[1]; }
for(var i=0;i<gs.length;i++) if(goalKey(gs[i])===k) return i;
return 0; /* ⛔ hash 里的目标没了 ⇒ 回到第一个,不报错 */
}
/* 一格 tab = 目标名 + 状态徽章 + 完成情况摘要(都是实读,⛔ 不写死)。 */
function renderTabs(gs){
var box=document.getElementById('tabs');
if(!box) return;
if(!gs.length){ box.hidden=true; return; }
box.hidden=false;
box.innerHTML=gs.map(function(g,i){
var acc=(g.goal||{}).acceptance||{}, ks=Object.keys(acc);
var pass=ks.filter(function(k){return String(acc[k]).toLowerCase()==='pass'}).length;
var cls=!ks.length?'is-warn':(pass===ks.length?'is-ok':'is-bad');
var txt=!ks.length?'未声明判据':(pass+'/'+ks.length+' 通过');
/* 🔴 2026-10-01(用户:「再确认下**目标名称**显示在哪里的,没看到呢」)
⇒ 根因是我上一版**第一行只写了目标简称**(`short`),全名只藏在 `title=` 提示里
—— 简称认不出是"哪个目标"。**改:第一行就是完整目标名**(超出省略 + 悬停看全),
简称退到第二行当小字前缀。⛔ 名字一律来自快照,技能侧不写死任何目标名。 */
var full=String((g.goal&&(g.goal.title||g.goal.short))||'(未声明目标)');
var short=String((g.goal&&g.goal.short)||'');
return '<button type="button" class="tab'+(i===GIDX?' is-on':'')+'" data-gi="'+i+'" role="tab"'
+' aria-selected="'+(i===GIDX?'true':'false')+'" title="目标:'+esc(full)+'">'
+'<span class="tt">'+(g.active?'<span class="badge is-ok">当前</span>':'')
+(g.paused?'<span class="badge is-warn">已停</span>':'')+esc(full)+'</span>'
+'<span class="tsub">'+(short&&short!==full?esc(short)+' · ':'')+chip(txt,cls)+'</span>'
+'</button>';
}).join('');
/* 🔴 2026-10-01(用户:「**下面这段话是干啥的,为什么也在 tab 区域**」)——
**tab 条上⛔ 不放任何说明文字**。上一版在两格之间塞了一句「只登记了 1 个目标 ⇒ 只有一格 /
要并行看第二个目标:在 goals/ 里放一份 …json」—— 那是**使用说明**,不是**数据**,
摊在版面上就成了噪声(同族老毛病:把小字说明写到版面上)。
⇒ **删掉**;"怎么加第二个目标"搬进「本项目」右上角 `?` 里(见 projCard 的 qtip)。
⛔ 也别退回「共 N 个目标」那种话 —— 有几格**看格子就知道**,写出来信息量为零。 */
[].forEach.call(box.querySelectorAll('.tab'), function(b){
b.onclick=function(){
var i=Number(b.dataset.gi)||0;
var k=goalKey(gs[i]);
location.hash='g='+encodeURIComponent(k);
GIDX=i;
if(D) paintScope(D); /* ⛔ 不重新拉数据 ⇒ 切 tab 是**零延迟零请求**的 */
};
});
}
/* 把"当前目标"那一段投影成顶层字段的形状 ⇒ 下面四个渲染器**原样复用**(⛔ 不复制代码)。 */
function scopeView(d,g){
if(!g||g===d) return d;
var v={}, k;
for(k in d) if(Object.prototype.hasOwnProperty.call(d,k)) v[k]=d[k];
v.goal=g.goal||d.goal; v.project=g.project||d.project;
v.tasks=g.tasks||{}; v.sessions=g.sessions||[];
v.labor=g.labor||[]; v.orphan=g.orphan||{};
v.queue=g.queue||{}; v.progress=g.progress||{};
v.others_running=g.others_running;
return v;
}
function paintScope(d){
/* ⛔ 老快照没有 `goals` ⇒ 回落成"一格 tab = 整份快照",行为与旧版一致。 */
var gs=(d.goals&&d.goals.length)?d.goals:[d];
GIDX=pickGoalIdx(gs);
if(GIDX<0||GIDX>=gs.length) GIDX=0;
renderTabs(gs);
var v=scopeView(d,gs[GIDX]);
renderProject(v);
renderTasks(v);
renderSessions(v);
drawArch(v);
}
/* 需求台账表(⛔ 随 tab 变 —— 台账条目按**任务类别**归属到目标)。 */
function renderTasks(d){
var LN={}, LK={}; (d.labor||[]).forEach(function(L){ LN[L.line]=L.name; LK[L.line]=L.kind||'topic' });
/* 🔴 台账条目的 `line` 现在语义=**任务类别**。旧数据里存的是**跨工作区时代的工作区名**
⇒ 列名与值对不上。⛔ 不隐藏、⛔ 不改历史记录,只**如实标一句**(同族红线:读到了就要说)。 */
var TP={}; (((d.project||{}).topics)||[]).forEach(function(t){ TP[t]=1 });
var tk=Object.keys(d.tasks||{}).sort();
document.getElementById('tasks').innerHTML = tk.length
? '<table><tr><th>需求</th><th>状态</th><th>任务类别</th></tr>'+tk.map(function(k){var v=d.tasks[k];
var cls=v.state==='done'?'is-ok':(v.state==='blocked'?'is-bad':'is-warn');
var _ln=v.line||'', _stale=(_ln && LK[_ln]==='legacy') || (_ln && !TP[_ln] && LK[_ln]!=='topic');
return '<tr><td><code>'+esc(k)+'</code></td><td>'+chip(zhState(v.state),cls)+'</td><td>'
+esc(LN[_ln]||_ln||'—')
+(_stale?'<span class="oldline">(旧值 · 不在当前任务类别清单)</span>':'')
+'</td></tr>'}).join('')+'</table>'
: emptyBox('台账为空','协作会话用 --report 上报后,条目进这里。');
}
/* 会话明细表(⛔ 随 tab 变 —— 会话按"标题里的任务类别"归属到目标)。 */
function renderSessions(d){
var ss=d.sessions||[];
document.getElementById('sess').innerHTML = (ss.length
? '<table><tr><th>会话</th><th>角色</th><th>任务类别</th><th>状态</th><th>活动</th></tr>'+ss.map(function(x){
return '<tr><td><code>'+esc(x.id8)+'</code> <span class="ellip">'+esc(x.title)+'</span></td>'
+'<td><span class="role'+(x.role==='主会话'?' is-main':'')+'">'+esc(x.role||'—')+'</span></td>'
+'<td>'+(x.topic?esc(x.topic):'—')+'</td><td>'
+(x.status==='working'?chip('执行中','is-ok'):chip(zhSess(x.status)))+'</td><td class="num">'+esc(x.age_min)+' 分钟前</td></tr>'}).join('')+'</table>'
: emptyBox('本项目暂无相关会话','只含主会话,和标题带 [任务类别] 的协作会话。'));
}
/* 数据延迟 = 现在 − 快照生成时刻(同机时钟可比)。⛔ 延迟不是错误 —— 是"看板不打扰程序"的代价。 */
function paintAge(){
if(!D||!D.epoch) return;
var s=Math.max(0, Math.round(Date.now()/1000 - D.epoch));
var el=document.getElementById('lag');
if(el) el.textContent=s+' 秒';
var w=document.getElementById('herometa'); /* 超 15 秒 ⇒ 整块转警示色 */
if(w) w.classList.toggle('is-old', s>15);
}
function load(){
if(!LIVE){
document.getElementById('err').hidden=false;
document.getElementById('err').textContent='要经本地服务打开:先跑 python board.py --serve 8788,再开 http://127.0.0.1:8788/(直接 file:// 打不开,取不到数据)';
return;
}
fetch('board.json?t='+Date.now(),{cache:'no-store'}).then(function(r){
if(!r.ok) throw new Error('HTTP '+r.status);
return r.json();
}).then(render).catch(function(e){
var el=document.getElementById('err'); el.hidden=false;
el.textContent='快照读取失败:'+e.message+'(服务还开着吗?重跑 python board.py --serve 8788)';
});
}
/* 🔴 轮询节奏 = 服务的快照节奏(⛔ 页面不自行加频:加了只是空转,不会更快) */
var POLL=3000;
function loop(){ load(); setTimeout(loop, POLL); }
initStyleSwitcher();
if(LIVE){ loop(); setInterval(paintAge,1000); } else { load(); }
</script>
</body>
</html>