Files
contentm_agent/执行会话/目标-根据1a需求文档生成MCN短视频整合营销-d85687/ui/mcn-workbench.html
T
WorkBuddy eee042c855 ③段第八轮收口:功能入口卡按参照物对齐 + 导航按 tiaoyue 源站重建 + 密度 A 方案
原型(执行会话/目标-…-d85687/ui/mcn-workbench.html)
- 功能入口卡改三段纵向(图标/标题/描述各一行),68 → 111.19px,对齐 mcn-work-shop
  参考卡(同视口 1249×1277 实测 133px),补 --sh-L1 投影;主因是排布不是字号
- 左侧导航重建:由「纯图标 + 悬停提示」改为 64px 药丸浮栏,图标 24 在上、名字常驻在下
  (名字一律 2 字,说明走 data-tip);真值取自 tiaoyue 源站实测
  (栏 64/内衬 3/圆角 100/项 56×78/字 12px-500-行高 16)
- 密度 A 方案:≤1000 卡最小宽 200→160、≤820 统计条最小宽 132→96
  (两处 auto-fit 只在真装不下时才缩)⇒ 768/700/560 三处转绿,其余 10 个视口未动
- 移动档隐藏导航条横向滚动轨(16px 轨道曾吃掉 52px 条高的三成,还白吃 16px 主区)

取证通道
- _tools/cap.py 改为「只连不起」:只连 9223 常驻实例,视口走 Emulation.setDeviceMetricsOverride,
  不再自起 headless Chrome(顺带解除 500px 视口下限,390×844 可测)
- _tools/check_js.py 新增第 5 道门禁(node --check + 模板内注释反引号扫描)

文档
- 复盘-为什么第一版做得很差.md(新建):13 轮时间轴/四类反作用/三重归因/已修与仍欠
- 3d-审查报告.md:更正 §九 那条假 ✓(把「§5.5 五条逐条过完」记成机核,实际零覆盖,
  正是「五道闸门全绿却难看」的开关),补 §十二 功能入口卡高差、§十三 tiaoyue 导航逐条对照
- 3b-实测记录.md/DESIGN.md/3c-GPT 参考版与会诊/布局排版参考 ×2

机制与记忆
- .workbuddy/collab/collabctl.py:off --ws 不再全机扫描(原先一次杀掉他区+看板共 5 个进程)
- .workbuddy/memory/:本轮纪律与实测真值
- .gitignore:补排运行时噪音(ui/_scratch/__pycache__/浏览器取证临时目录/*.bak-*/一次性脚本)

闸门:语法全过/骨架八页 39 板块/变异对照 15-15/逐视口 14 绿(500×844 唯一红,缺口待拍板)/命名 2060-0-27
2026-10-08 22:21:42 +08:00

2560 lines
185 KiB
HTML
Raw 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" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCN 短视频整合营销工作台 · 可点通原型</title>
<style>
/* ============================================================
三层令牌:primitive(--t- 与 --raw- 前缀)→ semantic(无语义前缀)→ component(--c- 前缀)
定调来源:design-system-tiaoyue 亮色基线(9 页 clipper 实测)
⛔ 本文件不设 .eyebrow / .lead / .hero(工具型页面)
============================================================ */
:root{
/* ---- primitive ---- */
--t-paper:#faf9f5; --t-surface:#ffffff; --t-surface-soft:#f7f6f1;
--t-ink:#1a1a18; --t-ink-mute:#57564f; --t-ink-faint:#8a887f;
--t-line:#e7e5dc; --t-line-soft:#efeee7;
--t-accent:#9E4CFF; --t-accent-text:#963dff; --t-on-accent:#FFFFFF;
--t-ok:#3d7a4f; --t-err:#b4453a; --t-warn:#8a5a00;
--raw-radius-card:24px; --raw-radius-panel:16px; --raw-radius-row:14px;
--raw-radius-input:9px; --raw-radius-btn:26px;
/* ---- semantic ---- */
--paper:var(--t-paper); --surface:var(--t-surface); --surface-soft:var(--t-surface-soft);
--ink:var(--t-ink); --ink-mute:var(--t-ink-mute); --ink-faint:var(--t-ink-faint);
--line:var(--t-line); --line-soft:var(--t-line-soft);
--accent:var(--t-accent); --accent-text:var(--t-accent-text); --on-accent:var(--t-on-accent);
--ok:var(--t-ok); --err:var(--t-err); --warn:var(--t-warn);
--fs-12:12px; --fs-14:14px; --fs-16:16px; --fs-18:18px; --fs-20:20px; --fs-24:24px; --fs-30:30px;
--sp-4:4px; --sp-8:8px; --sp-16:16px; --sp-24:24px; --sp-32:32px; --sp-64:64px;
--mg-2:2px; --mg-4:4px; --mg-6:6px; --mg-8:8px; --mg-12:12px;
--font: "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "Noto Sans SC", sans-serif;
/* 展示体(标题专用 · 2026-10-08 方案 A):与正文黑体形成对照,给标题自己的性格 —— 出处见 DESIGN.md §2.3「标题字体」 */
--font-display: "Songti SC", "Noto Serif SC", "Source Han Serif SC", "SimSun", Georgia, serif;
--sh-L1: 0 1px 1px rgba(26,26,24,.03), 0 2px 6px -2px rgba(26,26,24,.06);
--sh-L2: 0 1px 2px rgba(26,26,24,.04), 0 8px 20px -12px rgba(26,26,24,.14), 0 18px 40px -18px rgba(26,26,24,.10);
--sh-L3: 0 2px 4px rgba(26,26,24,.05), 0 14px 32px -12px rgba(26,26,24,.18), 0 32px 64px -24px rgba(26,26,24,.16);
/* ---- component ---- */
--c-btn-h:40px; --c-btn-h-sm:32px; --c-btn-px:20px; --c-btn-py:12px;
--c-card-pad:24px; --c-card-pad-dense:12px;
--c-input-h:36px; --c-tag-h:22px;
/* 侧栏宽度=**照 design-system-tiaoyue 的 `app-sidebar` 实测值**:它的左栏宽 `w-16`(Tailwind 4rem = 64px)。
2026-10-08 用户口径「左侧导航参考 tiaoyue 的导航」⇒ 由 240px 的**带字列表**换成 64px 的**药丸浮栏**,
名字改由悬停/聚焦时向右浮出的提示给出(它的 `nav-tip`)。 */
--nav-w:64px;
--tfast: .15s cubic-bezier(0.32,0.72,0,1);
}
/* ===== 2026-10-08 布局改版:布局照 mcn-work-shop,样式(令牌/圆角/阴影)仍走 tiaoyue ===== */
/* ① 「整页不滚、滚动只由主区承担」——照抄 mcn-work-shop 的 html,body{height:100%;overflow:hidden}。
🔴 2026-10-08 修:只抄了那一条,**没把高度链约束住** ⇒ 整页其实**滚不动,不是不滚**。
取证(无头 Chrome,P1 正常态):`.app` 是 min-height:100vh ⇒ 它跟着内容长到 1543px,`.main` 1543、
`.view` clientHeight = 1474 = scrollHeight(**自己不滚**);html/body 又是 overflow:hidden ⇒
真正的滚动容器落到 `<body>` 上(实测 body.scrollTop = 435,而 window.scrollY 恒为 0),
而 body 是 hidden ⇒ 用户既滚不动、又看不见滚动条:1440×900 下 P1 有 1543px 内容,**只有前 900px 可达**;
500×844 下 2149px 内容只有前 844px 可达(异常值提示 / 本月表现最好 / 全局检索三块在真机里永远看不到)。
修法:给高度链一个**确定的高度**,把滚动交给 `.view`:
· `.app{ height:100vh }`(+显式一行 grid-template-rows,让主区自己吃掉剩余高度)
· `.main{ min-height:0; overflow:hidden }`(不写 min-height:0,flex 子项不会缩到内容以下)
· `.view{ flex:1 1 auto; min-height:0 }` ⇒ 它成为**唯一**的滚动容器。
副带好处:`#view` 自检在窄视口量到的「首行 top / 一屏几行」曾因 body 被滚而全是假数(假绿),修后归位。 */
html, body { height:100%; overflow:hidden; }
/* ② 顶栏收成「产品名 + 随手记」两项(2026-10-08 用户口径「页面顶上弄这么多乱七八糟的东西」)。
⛔ 删掉的六样,逐样记来源(详细排查见 `3d-审查报告.md` §十):
· 「收起侧栏」—— 来源是**本项目的参照物笔记** `布局排版参考-mcn-work-shop.md` §一/§五
(「侧栏按需出现」),⛔ 无任何技能规则要求;侧栏已是 64px 药丸浮栏,收起更没有意义。
· 面包屑 —— 同上(它的「导航靠面包屑,不靠固定侧栏」)。⚠️ 本来是反的:我们侧栏常驻,
而页面 h1 已经把页面全名写全了 ⇒ 纯重复。`layouts-tooling.md` §1 只把它列为
「**项目 < 4 项**时」的一种可选替代,我们有 8 项,不适用。
· 「演示 · 账号」下拉 —— 无来源,我造的原型件。
· 「演示 · 状态」下拉 —— 规则原文(`stage-delivery/SKILL.md` §3d、
`execution-runbook.md` §3.4)说的是「状态由**路由 + 演示开关**驱动」,是**并列可选项**;
路由那条已完整覆盖(`#/p/p4/empty` 直达六态,六态帧就是这么出的)⇒ 开关可去。
· 「全局检索」—— ②段 P1 的第 5 板块是「全局检索与随手记入口」,是**页内板块**;
②段 §286 只把**随手记**定为「全局可达」,没定全局检索。⇒ 顶栏那个是重复,删。
· 「假设 2 项」—— 来源是我自己写的 `DESIGN.md` §三,而且原来是**挂在顶栏**的第二处;
现改成**挂回那两处「假设」徽章**(假设在哪、详情就在哪),顶栏不再留。
✅ 留下的只有「随手记」——它由②段硬要求(`2b` §286:「随手记出现在任意页面一次点击内,
F2 是 P0,不能埋在两层下」)⇒ 必须挂在**全局外壳**上,顶栏正是它的落点。
③ 侧栏可收起:已删(见上),连带 `.app.side-collapsed` 一起清掉。 */
.app-name{ font-size:var(--fs-14); font-weight:600; color:var(--ink); white-space:nowrap; }
/* ④ 首页限宽单列卡片流(照它的 .func-grid:max-width 900 居中、单列、gap 14) */
/* gap 用登记刻度里的 16(原型自检有「间距只许用登记刻度」的判据;mcn-work-shop 原值是 14,
差 2px 不影响「限宽居中单列」这一形态 ⇒ 取 16 以守住既有判据,⛔ 不为了照抄去改判据) */
.narrow{ max-width:900px; margin:0 auto; display:grid; grid-template-columns:1fr; gap:var(--sp-16); }
/* ⑤ 问答行(照它的 .qa-row):AI 以「一问一答的行」当一等公民,问=强调色、答=次级色缩进。
参照物原文:`.qa-row{padding:6px 8px;font-size:15px;line-height:1.55}` +
`.qa-row.copyable:hover{background:var(--primary-soft)}` + hover 出现复制图标 ——
我方字号走自己的 14px 档;复制图标那条**未学**(本项目的复制交互还没做,记在差异表里)。 */
.qa-row{ padding:6px 8px; font-size:var(--fs-14); line-height:1.55; border-radius:8px; }
.qa-row .q{ color:var(--accent-text); font-weight:500; display:block; }
.qa-row .a{ color:var(--ink-mute); display:block; padding-left:1.2em; margin-top:2px; }
/* 问答区上方的**虚线分隔**(学参照物 `.func-card .qa{border-top:1px dashed var(--border);padding-top:8px}`):
一条虚线把「按钮区」与「问答区」分开,⛔ 不用实线 —— 实线会被读成「这是两个板块」。 */
.qa-block{ border-top:1px dashed var(--line); padding-top:var(--mg-8); margin-top:var(--sp-8); }
/* ⑥ 板块统计条(照它的 .home-stats):**板块内容的上方**放统计,⛔ 不放页脚 */
.home-stats{ display:grid; grid-template-columns:repeat(auto-fit,minmax(132px,1fr)); gap:var(--sp-16); margin-bottom:var(--mg-6); }
.stat-item{ background:var(--surface); border:1px solid var(--line); border-radius:var(--raw-radius-panel); padding:var(--mg-12) var(--sp-16); display:flex; flex-direction:column; gap:var(--mg-2); }
/* 统计条里的「可点的数字」(P2 的低粉爆款 / 异常值):外观与静态格完全一致,
⛔ 不给它单独的强调样式 —— 长成不一样的按钮就破坏了「统计条」这一形态的统一。 */
button.stat-item{ text-align:left; font-family:inherit; cursor:pointer; transition:border-color var(--tfast); }
button.stat-item:hover{ border-color:var(--accent); }
button.stat-item[disabled]{ opacity:.45; cursor:not-allowed; }
button.stat-item[disabled]:hover{ border-color:var(--line); }
.stat-value{ font-size:var(--fs-20); font-weight:600; font-variant-numeric:tabular-nums; }
.stat-label{ font-size:var(--fs-12); color:var(--ink-mute); }
/* ⑦ 功能入口卡(`.data-cards` / `.data-card`)。
🔴 2026-10-08 第二轮:用户看完第一版仍说「功能入口的高度还是没有 参考 MCN 的首页的功能入口
按钮的样式和布局」,于是**回到参照物逐值量**,而不是凭印象调。
同视口(1249 × 1277)实测对照——参照物 `mcn-work-shop/public/style.css:387-392` + `app.js:332-336`:
项 参照物 我方第一版 差值
卡高 133px(描述两行) 68px −65px
排布 图标独占一行 → 图标+标题 两段 vs 三段
标题 → 描述 挤在一行
内衬 16px 18px 12px 16px −4 / −2
段间距 6px 4px −2
标题 17px / 600 16px / 600 −1
描述 14px 12px −2
阴影 0 1px 3px .08 无 少了
高差 65px 的**主因是排布**(少了一段 + 少了两次 6px 段间距),次因是内衬与字号。
这轮三处全部对齐;两处按本项目刻度就近落,两处刻意不学:
· 内衬 —— 参照物 `16px 18px`,18px 不在本项目间距刻度(2/4/6/8/12/16/24/32)⇒ 走 `--sp-16` 四边
· 标题 —— 参照物 17px,不在本项目字号刻度(12/14/16/18/20/24/30)⇒ 取 `--fs-18`
(取 16 会跟描述 14 只差 2px,「标题明显大于描述」的层次就没了)
⛔ 刻意不学(理由见 3d §十一):
· 圆角不走它的 12px —— 圆角归我方样式库(`--raw-radius-panel` 16px)
· hover 不做它的 `translateY(-2px)` 抬起位移 —— 本项目动效纪律禁用位移
· 图标不学它的「一个功能一个色」(橙/蓝/粉/天蓝)—— 定调归我方样式库,统一 `--accent-text`
⚠️ 内衬由 12/16 放回 16/16、卡由两段变三段,卡高约 68 → 110px,这一行紧贴在「数据统计」下面,
抬高的每一像素都直接压给下面那张吃密度契约(一屏 ≥6 行)的表 —— 实测结果见 3b §五 第七轮表。 */
.data-cards{ display:grid; grid-template-columns:repeat(auto-fit,minmax(200px,1fr)); gap:var(--sp-16); }
.data-card{ display:flex; flex-direction:column; align-items:flex-start; gap:var(--mg-6);
padding:var(--sp-16); background:var(--surface); border:1px solid var(--line);
border-radius:var(--raw-radius-panel); box-shadow:var(--sh-L1);
text-align:left; min-width:0; transition:border-color var(--tfast), box-shadow var(--tfast); }
.data-card:hover{ border-color:var(--accent); }
/* 图标色=`--accent-text`。参照物给每个功能一个自己的主色(它自己的品牌调色);
我方统一走品牌紫的**文字档**(`--accent` 直接当图标描边不达标,见 §2.2)。 */
.data-card .dc-icon{ display:inline-flex; flex:0 0 auto; color:var(--accent-text); }
/* 标题行高写死 1.35(不靠 `normal`):`normal` 在不同中文字体下 1.31–1.47 都在飘,
P1 那张表就在下面,卡高飘 1px 就可能吃掉一行 —— 量高度的地方一律写死。 */
.data-card .dc-title{ font-size:var(--fs-18); font-weight:600; line-height:1.35; }
.data-card .dc-desc{ font-size:var(--fs-14); line-height:1.35; color:var(--ink-mute); }
/* ⛔ 侧栏宽度不再随断点收窄:64px 是 tiaoyue `app-sidebar` 的**定值**(`w-16`),
再收下去图标就没地方放了。窄屏的一切变化交给下面 ≤640 的「横滚标签条」。 */
*,*::before,*::after{ box-sizing:border-box; }
html,body{ margin:0; padding:0; }
body{
background:var(--paper); color:var(--ink);
font-family:var(--font); font-size:var(--fs-14); line-height:1.55;
-webkit-font-smoothing:antialiased;
}
h1,h2,h3{ margin:0; font-weight:600; }
h1{ font-size:var(--fs-24); line-height:1.3; }
h2{ font-size:var(--fs-18); line-height:1.35; }
h3{ font-size:var(--fs-16); line-height:1.4; }
/* 标题走展示体(与正文黑体对照);h3 是块内小标题,留在正文族 */
h1,h2,.brand{ font-family:var(--font-display); }
p{ margin:0; }
ul,ol{ margin:0; padding:0; list-style:none; }
button{ font-family:inherit; }
a{ color:var(--accent-text); text-decoration:none; }
a:hover{ text-decoration:underline; }
.num{ font-variant-numeric:tabular-nums; }
.meta{ font-size:var(--fs-12); color:var(--ink-mute); font-variant-numeric:tabular-nums; }
.nowrap{ white-space:nowrap; }
/* 焦点环:⛔ 不许被 outline:none 抹掉 */
:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
/* ============ 外壳:主区 + 侧区 ============ */
/* 高度链(见文件头 ① 的取证):app 定高 → main 可缩 → view 是唯一滚动容器 */
.app{ display:grid; grid-template-columns:var(--nav-w) minmax(0,1fr); grid-template-rows:minmax(0,1fr); height:100vh; }
/* 🔴 侧栏=**药丸浮栏**(2026-10-08 第七轮,照 tiaoyue 源站实测逐值对齐)。
实测(亮色形态):`w-16` ⇒ **64px** / `p-[3px]` ⇒ 内衬 **3px** / `rounded-[100px]` ⇒ 圆角 **100px**
(药丸)/ `bg rgba(100,112,160,.08)` ⇒ **淡色底** / `border 1px rgba(100,112,160,.14)` ⇒ **1px 淡描边**;
`flex-col items-center overflow-y-auto` ⇒ 竖排、项居中、超高自己滚。
⚠️ 它没有 `border-right` —— 栏与内容的分界靠**药丸自己的底与描边**,⛔ 不靠一条通栏发丝线。
⚠️ 我方**保留「占一列」的挂法**(`.app` 仍是 `64px 1fr` 两列),只把列里的这个盒子做成浮起的药丸:
`align-self:center` + `max-height:100%` ⇒ 高度随内容、垂直居中,上下留出纸底。
参照物是真的浮在页面上(`left:16px`,主区不缩进);那要动外壳骨架,本轮不换,差异记在 3d §十三。 */
.sidebar{
align-self:center; max-height:100%; min-height:0; min-width:0;
width:64px; background:color-mix(in srgb, var(--ink) 5%, transparent);
border:1px solid color-mix(in srgb, var(--ink) 12%, transparent); border-radius:100px;
padding:3px; display:flex; flex-direction:column; align-items:center;
overflow-y:auto; overscroll-behavior:contain;
}
/* 品牌位收成一枚方标(64px 药丸里放不下全名;全名在 `<title>` 与页头)。 */
.brand{ display:flex; justify-content:center; padding:var(--mg-8) 0 var(--mg-4); }
/* 64px 栏放不下中文全名 ⇒ 品牌位收成一枚方标(全名挂 title,页头/面包屑另有全名) */
.brand-mark{
width:32px; height:32px; border-radius:var(--mg-8);
display:inline-flex; align-items:center; justify-content:center;
background:color-mix(in srgb, var(--accent) 12%, var(--paper));
color:var(--accent-text); font-family:var(--font-display);
font-size:var(--fs-16); font-weight:600; line-height:1;
}
/* 侧栏=照 design-system-tiaoyue 的 `app-sidebar` 实测形态(2026-10-08 用户口径「左侧导航参考 tiaoyue 的导航」)。
它的组件清单摆得很明白:每页 `nav-item`×9 + `nav-icon`×9 + `nav-tip`×9
⇒ **一项一个图标、一项一个提示**;提示是 viewport 级浮层(`nav-tips-layer` / `nav-tips-viewport`)
并带 `is-expand-right`(向右展开)。⛔ 项里没有文字标签族(没有 nav-label 这类族)。
⚠️ 提示做成 viewport 级浮层、⛔ 不做成项内 absolute:`.sidebar` 有 `overflow:auto`
(定高外壳下导航超高时不许被裁),项内 absolute 的提示会被它剪掉。 */
/* 🔴 导航项=**56×78 的药丸**(照 tiaoyue 源站实测):
`h-[78px] w-14` ⇒ **56 × 78** / `p-4` ⇒ 内衬 **16px** / `rounded-[54px]` ⇒ 圆角 **54px** /
`gap-1.5` ⇒ 段间距 **6px** / `text-xs font-medium leading-4` ⇒ **12px / 500 / 行高 16px**;
56 − 16×2 = 24px 是内容宽 —— 所以**项内文字只能是 2 个字**(24px),这是参照物给的名字长度上限。
⚠️ 78 = 内衬 32 + 图标 24 + 段间距 6 + 文字行高 16,**正好**;⛔ 四个数动一个就破。
⚠️ 项与项之间**没有 gap**(参照物 78px 紧贴),所以 `#nav` 的 gap 归 0。
hover/当前态**只换颜色、不换位置、不加阴影**(参照物 `transition-colors` 0.15s)。 */
#nav{ display:flex; flex-direction:column; align-items:center; gap:0; min-width:0; }
.nav-item{
position:relative; display:flex; flex-direction:column; align-items:center; justify-content:center;
gap:var(--mg-6); width:56px; height:78px; padding:var(--sp-16);
border:0; border-radius:54px; background:transparent;
color:var(--ink-faint); font-family:inherit;
font-size:var(--fs-12); font-weight:500; line-height:16px;
cursor:pointer; min-width:0; transition:background-color var(--tfast), color var(--tfast);
}
.nav-item:hover{ background:color-mix(in srgb, var(--ink) 5%, transparent); color:var(--ink); }
/* 当前项=**中性填充 + 满墨字**(参照物 `is-active`:bg rgba(31,35,41,.06)、color 满墨)。
⚠️ 原来这里是品牌紫底 —— 按参照物换成中性填充;紫色留给「品牌标记」与主操作,⛔ 导航不抢。 */
.nav-item.is-active{ background:color-mix(in srgb, var(--ink) 6%, transparent); color:var(--ink); font-weight:500; }
.nav-item svg{ width:24px; height:24px; display:block; }
/* 短名标签:**全宽度常驻**(参照物就是图标在上、名字在下,名字一直在)。
⚠️ 2026-10-08 第七轮改:上一版宽屏把名字藏起来、只走悬停提示 —— 那是「图标栏」那一轮的取舍;
照参照物改成常驻之后,提示里的名字就冗余了 ⇒ 提示改说「这一页是干什么的」(见 `renderNav`)。 */
.nav-item .nav-label{ display:inline; font-size:var(--fs-12); white-space:nowrap; }
.nav-item .nav-count{
position:absolute; top:5px; right:5px; min-width:14px; height:14px; padding:0 3px;
display:inline-flex; align-items:center; justify-content:center; border-radius:999px;
background:var(--surface-soft); color:var(--ink-mute);
font-size:10px; font-weight:600; font-variant-numeric:tabular-nums;
}
/* 提示浮层(它的 `nav-tips-layer` + `nav-tips-viewport` + `is-expand-right`):
一个 viewport 级的层,跟着当前悬停/聚焦的那一项的右缘,向右展开。 */
.nav-tips-layer{ position:fixed; inset:0; pointer-events:none; z-index:70; display:none; }
.nav-tips-layer.is-visible{ display:block; }
.nav-tip{
position:absolute; transform:translateY(-50%); white-space:nowrap;
background:var(--ink); color:var(--surface); font-size:var(--fs-12); line-height:1;
padding:var(--mg-6) var(--mg-8); border-radius:var(--mg-8); box-shadow:var(--sh-L1);
}
/* 🔴 功能入口卡那一行**没有小标**(2026-10-08 收):原来这里有个 `<p>功能入口</p>`。
删掉的理由有两条,缺一条都不该删:
① **参照物就没有** —— `mcn-work-shop` 首页是 `.home-stats` 直接接 `.data-cards`,中间空无一物;
用户这一轮的口径是「参考 MCN 的首页」,那就照它办。
② **密度契约逼的** —— 功能入口卡按参照物从 68px 长到 111px(+43px),这 43px 得从别处还回去。
小标 + 它与卡之间的 6px 段间距合计 **24.6px(实测)**,是这一带最不值钱的高度:
「功能入口」四个字对扫读零增量 —— 卡片自己写着去哪儿。
⚠️ 它**不是** .block-head ⇒ 删掉不影响②段骨架计数(机检数 .block .block-head h2)。
⚠️ `.sect-nav` 的下边距与后面 `.block` 的 `margin-top:32px` **会折叠** ⇒ 这里写多少都不改变
实际间距(实测:写 12 与写 8,账号矩阵首行顶一模一样)。写 8 只是给基类一个值、替掉内联。 */
.sect-nav{ min-width:0; margin-bottom:var(--mg-8); }
.main{ display:flex; flex-direction:column; min-width:0; min-height:0; overflow:hidden; }
.topbar{
display:flex; align-items:center; gap:var(--sp-16); flex-wrap:wrap;
/* 内衬收到 8px(原 16px):顶栏现在只放「产品名 + 随手记」两项,32px 的按钮 + 上下 8px
⇒ 实测 **48px**。⛔ 别再回到 16px —— 那样顶栏 65px,而它本该是最不该占地方的一条。 */
padding:var(--mg-8) var(--sp-32); border-bottom:1px solid var(--line-soft);
background:var(--paper); position:sticky; top:0; z-index:5;
}
.topbar .spacer{ flex:1 1 auto; }
.view{ flex:1 1 auto; min-height:0; overflow:auto; padding:var(--sp-24) var(--sp-32) var(--sp-64) var(--sp-32); min-width:0; }
/* ============ 控件 ============ */
.btn{
display:inline-flex; align-items:center; justify-content:center; gap:var(--mg-6);
height:var(--c-btn-h); padding:0 var(--c-btn-px); border-radius:var(--raw-radius-btn);
font-size:var(--fs-14); font-weight:500; cursor:pointer; white-space:nowrap; flex-shrink:0;
border:1px solid var(--line); background:var(--surface); color:var(--ink);
transition:background-color var(--tfast), border-color var(--tfast), box-shadow var(--tfast);
}
.btn:hover{ border-color:var(--ink-faint); }
.btn-primary{
background:var(--accent); border-color:var(--accent); color:var(--on-accent);
font-size:var(--fs-16); font-weight:600; /* 4.26:1 需走大字线 3:1,故 ≥16px + 600 */
}
.btn-primary:hover{ border-color:var(--accent); box-shadow:var(--sh-L1); }
.btn-ghost{
height:var(--c-btn-h-sm); padding:0 var(--mg-12); border:0; background:transparent;
color:var(--accent-text); font-size:var(--fs-14);
}
.btn-ghost:hover{ background:var(--surface-soft); }
.btn[disabled],.btn-primary[disabled]{ opacity:.45; cursor:not-allowed; }
.btn[disabled]:hover{ border-color:var(--line); box-shadow:none; }
.btn-sm{ height:var(--c-btn-h-sm); padding:0 var(--mg-12); font-size:var(--fs-14); }
.input,.select,.textarea{
width:100%; min-width:0; border:1px solid var(--line); border-radius:var(--raw-radius-input);
background:var(--surface); color:var(--ink); font-family:inherit;
}
.input,.select{ height:var(--c-input-h); padding:0 var(--mg-12); font-size:var(--fs-14); }
.textarea{ padding:var(--mg-8) var(--mg-12); font-size:var(--fs-14); line-height:1.6; resize:vertical; }
.input[disabled],.textarea[disabled],.select[disabled]{ background:var(--surface-soft); color:var(--ink-faint); cursor:not-allowed; }
.field-label{ display:block; font-size:var(--fs-12); color:var(--ink-mute); font-weight:600; margin-bottom:var(--mg-4); }
.tag{
display:inline-flex; align-items:center; height:var(--c-tag-h); padding:0 var(--mg-8);
border-radius:999px; background:var(--surface-soft); font-size:var(--fs-12); font-weight:600;
color:var(--ink-mute); white-space:nowrap;
}
.tag-ok{ color:var(--ok); } .tag-err{ color:var(--err); } .tag-warn{ color:var(--warn); }
.tag-assume{ color:var(--warn); }
/* 「假设」徽章是**可点**的(点它打开假设浮层):外观与只读徽章完全一致,
⛔ 不给它单独的强调样式 —— 一上色就变成「另一类东西」,而它本来就是状态标的一种。 */
button.tag{ font-family:inherit; border:0; cursor:pointer; }
button.tag:hover{ background:var(--line-soft); }
/* ============ 结构件 ============ */
.block{ margin-top:var(--sp-32); min-width:0; }
.block:first-child{ margin-top:0; }
.block-head{
display:flex; align-items:center; gap:var(--sp-16); flex-wrap:wrap;
padding-bottom:var(--mg-6); border-bottom:1px solid var(--line); margin-bottom:var(--mg-8);
}
.block-head .grow{ flex:1 1 auto; }
.panel{ background:var(--surface); border-radius:var(--raw-radius-panel); box-shadow:var(--sh-L1); padding:var(--c-card-pad-dense); min-width:0; }
.card{ background:var(--surface); border-radius:var(--raw-radius-card); box-shadow:var(--sh-L2); padding:var(--c-card-pad); min-width:0; }
.soft{ background:var(--surface-soft); border-radius:var(--raw-radius-panel); padding:var(--sp-16); min-width:0; }
/* 两栏分栏(照 `3c-GPT参考版.md` 那版的 `.grid.cols2`):宽比 **3:2**,左宽右窄。
用途=把「上下文 / 列表」放左、「产出 / 动作 / 详情」放右;主数据块仍满宽。
⚠️ 宽比是照抄参考版的 1.35fr : .9fr(原先是 1:1)—— 用户 2026-10-08 口径「哪里左右布局的功能照着抄」。 */
.grid-main-side{ display:grid; grid-template-columns:1.35fr .9fr; gap:var(--sp-24); align-items:start; }
.grid-cards{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--sp-16); }
.stat{ display:flex; flex-direction:column; gap:var(--mg-2); text-align:left; background:transparent; border:0; padding:0; min-width:0; }
.stat .num{ font-size:var(--fs-24); }
button.stat{ cursor:pointer; }
button.stat:hover .stat-label{ color:var(--accent-text); }
.row{ display:flex; align-items:center; gap:var(--mg-8); flex-wrap:wrap; min-width:0; }
.stack{ display:flex; flex-direction:column; gap:var(--sp-16); min-width:0; }
.hairline{ border-top:1px solid var(--line-soft); }
/* 分段控件(`.seg` / `.seg-item`)——**选中态全站只有这一套画法**(2026-10-08 统一)。
起因:P2 分组 / P3 分区 / P5 账号多选 / P5 平台多选 / P5 版本 tab 这五处,
原来一律写成 `.btn btn-sm` + **内联** `style="border-color:var(--ink);font-weight:600"` ——
那是**第三套选中画法**(前两套是侧栏当前项的浅紫底、表格当前行的 accent 文字色),
而且靠内联样式铺,既没进令牌也没进组件表。
口径照 P1 的侧栏当前项:**整块浅紫底 + 字色 `--accent-text` + 字重 600**,
⛔ 不用「描边变黑」、⛔ 不用单侧色条。高度沿用 `--c-btn-h-sm`(32px),与全站弱档同一档。 */
.seg{ display:flex; flex-wrap:wrap; gap:var(--mg-4); min-width:0; }
.seg-item{
display:inline-flex; align-items:center; gap:var(--mg-4);
height:var(--c-btn-h-sm); padding:0 var(--mg-12); border:1px solid var(--line);
border-radius:999px; background:var(--surface); color:var(--ink);
font-family:inherit; font-size:var(--fs-14); cursor:pointer; white-space:nowrap;
transition:background-color var(--tfast), border-color var(--tfast), color var(--tfast);
}
.seg-item:hover{ border-color:var(--ink-faint); }
.seg-item.is-on{
background:color-mix(in srgb, var(--accent) 10%, var(--paper));
border-color:var(--accent); color:var(--accent-text); font-weight:600;
}
.seg-item[disabled]{ opacity:.45; cursor:not-allowed; }
.seg-item[disabled]:hover{ border-color:var(--line); }
/* 表单字段(`.field`)——统一字段之间的间距,⛔ 不再用内联 `margin-bottom` 一格一格铺。
用法:字段块(标签 + 控件)各加一个 `.field`,间距由 CSS 出。 */
.field + .field{ margin-top:var(--sp-16); }
.field > .seg{ margin-top:var(--mg-8); }
/* 行式列表:行间 border-top,⛔ 不给行套框、⛔ 不给行整行底色 */
.list{ display:flex; flex-direction:column; min-width:0; }
.list-row{
display:grid; grid-template-columns:minmax(0,2.2fr) minmax(0,1.6fr) auto;
gap:var(--sp-16); align-items:center; width:100%; text-align:left;
padding:var(--mg-12) var(--mg-8); border:0; border-top:1px solid var(--line-soft);
background:transparent; color:var(--ink); cursor:pointer; font-size:var(--fs-14); min-width:0;
transition:background-color var(--tfast);
}
/* 🔴 只有列表的第一行不画上边框。
原写法是 `.list-row:first-child` —— 错的:每一行都包在 <li> 里,于是**每一行都是自己
<li> 的 first-child**,整张表的分隔线被一起关掉(实测 borderTopStyle 全是 none)。
要判「是不是列表的第一行」,得从 <li> 那一层判。 */
li:first-child > .list-row{ border-top:0; }
.list-row:hover{ background:var(--surface-soft); }
.list-row.is-active{ background:color-mix(in srgb, var(--accent) 7%, var(--paper)); }
.list-row .primary-cell{ display:flex; flex-direction:column; gap:var(--mg-2); min-width:0; }
.list-row .primary-cell strong{ font-weight:600; }
/* 名称 + 状态标同一行:状态标不占操作列,行高才齐 */
.list-row .name-row{ display:flex; align-items:center; gap:var(--mg-6); min-width:0; }
/* 🔴 行内每一段都**不许换行** —— 这是行高能齐的唯一办法,全宽度都生效。
起因:文字一折行,行高就随文字长短乱跳(实测 1440px 下 66/82,768px 下 66/82/100)。
代价:中等宽度时小字那几段会出省略号,完整原文挂在行上的 title 里。
⛔ 别为了不出省略号去加宽栏位 —— 加宽队列栏会把矩阵的小字挤到 213px,反而折成三行,
行高立刻又不齐(实测踩过:col1 从 400 提到 460 就复发)。 */
.list-row .primary-cell > *,
.list-row .secondary-cell > *{
min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.list-row .secondary-cell{ display:flex; flex-direction:column; gap:var(--mg-2); min-width:0; font-size:var(--fs-12); color:var(--ink-mute); }
.list-row .action-cell{ display:flex; gap:var(--mg-8); align-items:center; justify-content:flex-end; }
/* 密集行:队列类列表用它。单行三段:[类标] [正文] [账号·截止]。
⚠️ 三段必须都 nowrap + 省略号,行高才恒为一行(实测:不加 nowrap 时窄栏里每行顶成
两三行,反而比不密还高;加了之后队列从 373px 压到约 260px)。
⛔ 同一个字段也不许在行里出现两次。 */
/* 宽屏的队列条目排两行:正文一行、账号与截止另起一行。
为什么:一行三段塞进 400px 的队列栏,正文必被截掉(实测「周五专题 · 3 个版本…」
「半夜解馋系列 · 5 条候选待…」),而队列存在的意义就是让人看清待办是什么。
窄屏(≤1024)⛔ 不这么排 —— 那里队列下面就是矩阵,多一行会把矩阵挤出首屏。 */
@media (min-width:1025px){
}
.list-plain{ display:flex; flex-direction:column; }
.list-plain > li{ padding:var(--mg-12) var(--mg-8); border-top:1px solid var(--line-soft); font-size:var(--fs-14); }
.list-plain > li:first-child{ border-top:0; }
table.ds-table{ width:100%; border-collapse:collapse; font-size:var(--fs-14); min-width:0; }
.table-wrap{ overflow-x:auto; min-width:0; }
table.ds-table th{
text-align:left; font-size:var(--fs-12); color:var(--ink-mute); font-weight:600;
padding:var(--mg-8) var(--mg-8); border-bottom:1px solid var(--line);
}
table.ds-table td{ padding:var(--mg-8); border-top:1px solid var(--line-soft); vertical-align:top; }
table.ds-table .num-col{ text-align:right; font-variant-numeric:tabular-nums; }
/* 紧凑档(P1 两块数据用):每格一个短值、单行不折。行高是**密度契约逼出来的** ——
参照物那张表行高约 50px(每行两段文字),照搬到 900px 高会只剩 5 行 ⇒ 这里取单行紧凑档。 */
table.ds-table.tbl-dense th{ padding:var(--mg-4) var(--mg-8); }
table.ds-table.tbl-dense td{ padding:var(--mg-4) var(--mg-8); vertical-align:middle; white-space:nowrap; }
table.ds-table.tbl-dense .cell-lead{ font-weight:600; }
table.ds-table.tbl-dense .cell-name{ font-weight:600; cursor:pointer; }
table.ds-table.tbl-dense .cell-name:hover{ color:var(--accent-text); }
/* 表格里的「当前行」= `.cell-name.is-on`。
⚠️ 这一条是补出来的:P3/P6/P7/P8 四张表原来各自用**内联** `style="color:var(--accent-text)"`
标当前行 —— 与侧栏当前项(浅紫底)、分段控件选中项(浅紫底 + accent 文字)
凑成了**三套选中画法**。现在统一:**选中态一律走 `.is-on` / `.is-active`,⛔ 不用内联样式**。 */
table.ds-table.tbl-dense .cell-name.is-on{ color:var(--accent-text); }
table.ds-table.tbl-dense .cell-act{ width:1%; text-align:right; }
/* 行级动作在紧密档里用 22px 的微号:32px 的 .btn-sm 会把整行撑到 45px(实测),
行高直接压给下面的密度契约。 */
.btn-micro{ display:inline-flex; align-items:center; height:22px; padding:0 var(--mg-8); border:0;
border-radius:999px; background:transparent; color:var(--accent-text); font-family:inherit; font-size:var(--fs-12); cursor:pointer; }
.btn-micro:hover{ background:var(--surface-soft); }
.btn-micro[disabled]{ opacity:.45; cursor:not-allowed; }
.btn-micro[disabled]:hover{ background:transparent; }
table.ds-table.tbl-dense .tag{ height:20px; }
/* 栏内表(两栏里的列表改成表格):⛔ 不套 tbl-dense 的 nowrap —— 窄栏里 nowrap 会把表推成横向滚动 */
table.ds-table.tbl-side th{ padding:var(--mg-4) var(--mg-8); }
table.ds-table.tbl-side td{ padding:var(--mg-4) var(--mg-8); vertical-align:middle; }
table.ds-table.tbl-side .cell-lead{ font-weight:600; }
table.ds-table.tbl-side .cell-act{ width:1%; text-align:right; white-space:nowrap; }
/* 环节进度 */
.steps{ display:flex; gap:var(--mg-4); flex-wrap:wrap; }
.step{
display:flex; align-items:center; gap:var(--mg-6); height:var(--c-btn-h-sm);
padding:0 var(--mg-12); border:1px solid var(--line); border-radius:999px;
background:var(--surface); font-size:var(--fs-12); cursor:pointer; color:var(--ink-mute);
transition:background-color var(--tfast), border-color var(--tfast), color var(--tfast);
}
.step.done{ color:var(--ink); }
.step.current{ background:var(--ink); border-color:var(--ink); color:var(--surface); font-weight:600; }
.step[disabled]{ opacity:.45; cursor:not-allowed; }
/* 演示开关条(.demo)已整条删除 —— 顶栏只留「产品名 + 随手记」,见文件头 ② 的排查记录 */
.statebar{ display:flex; flex-wrap:wrap; gap:var(--mg-4); }
/* 状态面板 */
.state-panel{ background:var(--surface); border-radius:var(--raw-radius-card); box-shadow:var(--sh-L2); padding:var(--sp-64) var(--sp-32); text-align:center; }
.state-panel h2{ margin-bottom:var(--mg-8); }
.state-panel p{ color:var(--ink-mute); max-width:56ch; margin:0 auto var(--sp-16); }
.skel{ height:14px; border-radius:var(--mg-8); background:var(--surface-soft); }
.skel-w70{ width:70%; } .skel-w45{ width:45%; } .skel-w90{ width:90%; }
.skel-stack{ display:flex; flex-direction:column; gap:var(--mg-12); }
/* 遮罩 / 浮层:可见关闭出口唯一(右上 ×) */
.overlay{
position:fixed; inset:0; background:rgba(26,26,24,.34);
display:flex; align-items:flex-start; justify-content:center; padding:var(--sp-64) var(--sp-16);
z-index:50;
}
/* 浮层:遮罩铺满、层本体自己 fixed 居中。
⚠️ 原先 `.layer` 只有宽高和阴影,没有定位 —— 它渲染在文档流里,会排在**页面最底下**
(`#layer-root` 没有任何定位样式),点「随手记」得滚到页尾才看得见,根本不算浮层。
⛔ 不要改成给 `#layer-root` 加 fixed 全屏:提示条 .toast 也渲染在那个容器里。 */
.layer-mask{ position:fixed; inset:0; background:rgba(26,26,24,.34); z-index:90; }
.layer{
position:fixed; left:50%; top:50%; transform:translate(-50%,-50%); z-index:91;
width:min(560px, calc(100vw - 32px)); background:var(--surface);
border-radius:var(--raw-radius-card); box-shadow:var(--sh-L3); padding:var(--sp-24);
max-height:calc(100vh - 128px); overflow:auto; text-align:left;
}
.layer-head{ display:flex; align-items:center; gap:var(--sp-16); margin-bottom:var(--sp-16); }
.layer-head h2{ flex:1 1 auto; }
.layer-close{
width:var(--c-btn-h-sm); height:var(--c-btn-h-sm); flex:0 0 auto; border:1px solid var(--line);
border-radius:999px; background:var(--surface); color:var(--ink); font-size:var(--fs-16);
line-height:1; cursor:pointer;
}
.layer-close:hover{ background:var(--surface-soft); }
.layer-actions{ display:flex; gap:var(--mg-8); justify-content:flex-end; margin-top:var(--sp-24); }
/* 提示条 */
.toast{
position:fixed; left:50%; bottom:var(--sp-32); transform:translateX(-50%);
background:var(--ink); color:var(--surface); font-size:var(--fs-14);
padding:var(--mg-8) var(--sp-16); border-radius:999px; box-shadow:var(--sh-L2); z-index:80;
}
.notice{
display:flex; gap:var(--mg-8); align-items:flex-start; padding:var(--mg-12) var(--sp-16);
border-radius:var(--raw-radius-panel); background:var(--surface-soft); font-size:var(--fs-14);
margin-bottom:var(--sp-16);
}
.notice strong{ font-weight:600; }
.notice-warn strong{ color:var(--warn); }
.notice-err strong{ color:var(--err); }
/* selftest 输出 */
.st-out{ background:#14140f; color:#f2f1ea; border-radius:var(--raw-radius-panel); padding:var(--sp-16); font-family:ui-monospace,Consolas,monospace; font-size:var(--fs-12); white-space:pre-wrap; overflow-x:auto; }
.st-pass{ color:#8fd6a5; } .st-fail{ color:#ff9a8f; }
@media (max-width:1024px){
.grid-cards{ grid-template-columns:repeat(2,minmax(0,1fr)); }
/* 顶栏现在只有「产品名 + 随手记」两项,任何宽度都不会折行 ⇒ 原来那套
「收间距 + 隐掉演示标签」的两处补丁(以及 ≤1000 的 `.demo{display:none}`)整段删除。
实测:顶栏由 69px(旧、单行)降到 **53px**,且不再随宽度变化。 */
.topbar{ gap:var(--mg-8); }
/* 🔴 列表行在窄屏只做一件事:**不许换行**。
为什么:三列形态下每列都被压窄,长一点的文字自己折成第二行,于是行高随文字长短在
66/82/100px 之间乱跳(实测 768px 宽:100/100/66/82/82),扫列表时没有节奏。
早先试过「让名称和小字各占一行」的堆叠修法 —— 那是错的:网格行距 16px 一加,
行高反而涨到 101px,比原来的 66px 还肥,密度倒退。已退回三列形态。
(「不许换行」这条规则本身定义在上面 .list-row 基础样式里,全宽度生效。) */
}
/* ≤1000:功能入口卡只留标题。
为什么单收这一处:这一行按用户口径紧贴在「数据统计」下面,它的高度**直接**压给下面那张
吃密度契约(一屏 ≥6 行)的列表。实测 960px 宽:带描述时 P2 首行顶 517px ⇒ 5 行(差 1 行);
隐掉描述后 494px ⇒ 6 行。窄屏上这类「去哪」的入口只要认得名字就够,描述不是必需信息。 */
@media (max-width:1000px){
/* 功能入口卡只留标题。为什么单收这一处:这一行按用户口径紧贴在「数据统计」下面,
它的高度**直接**压给下面那张吃密度契约(一屏 ≥6 行)的表。 */
.data-card .dc-desc{ display:none; }
/* 🔴 2026-10-08 第八轮追加(用户拍板「A 方案」):卡的最小宽由参照物原值 **200px** 降到 **160px**。
为什么:768px 宽时主区内容宽 625px ⇒ `floor((625+16)/216) = 2` 列 ⇒ 三张卡折成**两行**、
卡块高度翻倍(实测 P1 首行顶 779px ⇒ 一屏只剩 3 行)。
⚠️ 这不是"把参照物改小" —— `auto-fit` **只在真装不下时才缩**,≥1000px 时一个像素不动;
这一条只负责把「卡自己折行」这个与参照物无关的故障消掉。 */
.data-cards{ grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); }
}
@media (max-width:820px){
/* 🔴 2026-10-08 第八轮追加(A 方案):统计条的最小宽由 **132px** 降到 **96px**。
为什么:700px 宽时主区内容宽 557px ⇒ `floor((557+16)/148) = 3` 列 ⇒ 4 格折成**两行**、
统计条由 77.59px 翻到 171.19px(+93.6),P1 首行顶被顶到 872px ⇒ 一屏 0 行。
降到 96px 后是 5 列 ⇒ 回到一行。(96 = 内衬 32 + 5 个 12px 汉字 60 + 4px 余量) */
.home-stats{ grid-template-columns:repeat(auto-fit,minmax(96px,1fr)); }
}
@media (max-width:900px){
/* 两条横向分栏工具一起收:窄到 900 以下,3:2 的右栏只剩 200px 出头,行内文字会被截得很碎 */
.grid-main-side{ grid-template-columns:minmax(0,1fr); }
}
@media (max-width:640px){
/* 窄屏外壳换成「上标签栏 + 主区」两行:行也要写死,否则主区跟着内容长高、滚动又落回 body */
.app{ grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
/* 🔴 手机形态的导航:设计里写的是「**一条**可横向滚动的标签栏」。
曾经的写法只给了 `.sidebar` 一行 `flex-direction:row` + `overflow-x:auto`,但
导航分组其实挂在 `<nav id="nav">` 里,而 `#nav` **一条样式都没有** ⇒ 它仍是块级容器,
4 个分组照旧竖着堆,整条栏 161px 高(实测)—— 既不是「一条」,也没滚动。
修法:把 `#nav` 自己变成不折行、不许缩的横排,`overflow-x` 交给 `.sidebar` 去滚。 */
#nav{ display:flex; flex:0 0 auto; flex-flow:row nowrap; align-items:center; gap:var(--mg-4); }
/* 手机档把「药丸浮栏」整段还原成「通栏横条」:圆角、描边、淡底、垂直居中全部取消,
否则药丸会在窄屏变成一条上下留白的怪东西。 */
.sidebar{
flex-flow:row nowrap; align-items:center; gap:var(--mg-4); overflow-x:auto;
align-self:stretch; max-height:none; width:auto;
border:0; border-bottom:1px solid var(--line-soft); border-radius:0;
background:var(--paper); padding:var(--mg-8) var(--mg-8); position:sticky; top:0; z-index:6;
}
/* 🔴 2026-10-08 第八轮:**把横向滚动条藏掉**。
实测(560×900):这条标签条 `scrollWidth` 724 > 可见宽 560 ⇒ 自动冒出 **16px 高**的横向滚动条,
而条子本体只有 **52px** 高 —— **三成的高度是一根滚动轨**,看上去就是一道灰杠压在 8 个标签底下;
它还白吃 16px 主区高度(实测 P1 首行顶 714.66px,离 6 行线只差 0.66px)。
藏掉之后**仍然能横向滚**(触摸滑动、滚轮、键盘方向键都还在),只是不画那根轨。
⛔ 不是"为了省 16px 而删掉滚动能力" —— 滚动能力与画不画轨是两件事。 */
.sidebar{ scrollbar-width:none; }
.sidebar::-webkit-scrollbar{ height:0; width:0; display:none; }
.brand{ display:none; }
/* 🔴 窄屏的功能入口卡收成**紧凑档**。
2026-10-08 第二轮追加收紧:卡按参照物改成三段纵向后,640px 上空出 30px(实测首行顶
711 → 724,容量 6 行 → 5 行)。这三处各收一档 + 块间距收一档,把 640 这一档还回去:
内衬 `8/12` → `6/8`(−4)|图标 `18` → `16`(−2)|标题 `18` → `16`(−2.7)
`.block` 的上边距 `32` → `24`(P1 上方有两个 .block(今日待处理 / 账号矩阵),各 −8 ⇒ −16)
⛔ 不是删图标、也不是丢掉段落层次 —— 细节在窄屏打折,比丢掉密度契约划算。
⚠️ 「上边距 32→24」是 ≤1000 以下整片生效的,⛔ 只在 ≤640 这一档开,避免影响 768/820 那几档。 */
.data-card{ padding:var(--mg-6) var(--mg-8); }
.data-card .dc-icon svg{ width:16px; height:16px; }
.data-card .dc-title{ font-size:var(--fs-16); }
.block{ margin-top:var(--sp-24); }
/* 卡片的最小宽要跟着收:参照物原值 200px 在 640px 以下(主区约 608px)装不下三张,
会折成两行 —— 卡块高度直接翻倍。收到 160px 让三张回到一行。 */
.data-cards{ grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); }
/* 窄屏 ⇒ 药丸浮栏整段还原成**通栏横滚标签条**(圆角/描边/淡底/垂直居中全部取消)。
⛔ 提示浮层在窄屏关掉:横滚时它跟着项走反而更乱,名字已经直接看得见。 */
.nav-item{
flex:0 0 auto; width:auto; height:36px; flex-direction:row; gap:var(--mg-6);
padding:0 var(--mg-12); border-radius:999px; font-size:var(--fs-12);
}
.nav-item svg{ width:16px; height:16px; }
.nav-item .nav-label{ display:inline; }
.nav-item .nav-count{ position:static; margin-left:0; }
.nav-tips-layer{ display:none !important; }
.view{ padding:var(--sp-16) var(--sp-16) var(--sp-64); }
.topbar{ padding:var(--mg-12) var(--sp-16); }
.grid-cards{ grid-template-columns:minmax(0,1fr); }
}
@media (prefers-reduced-motion: reduce){
*{ transition:none !important; }
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><span class="brand-mark" title="MCN 短视频整合营销工作台">营</span></div>
<nav id="nav"></nav>
</aside>
<div class="main">
<header class="topbar">
<span class="app-name">MCN 短视频整合营销工作台</span>
<div class="spacer"></div>
<!-- 顶栏只留这一项:②段硬要求「随手记在任意页面一次点击内」(`2b` §286)。
⛔ 其余六样(收起侧栏 / 面包屑 / 演示·账号 / 演示·状态 / 全局检索 / 假设 2 项)
已删或改挂到别处,逐样来源见文件头 ② 的排查记录。 -->
<button class="btn btn-sm" data-act="G-06">随手记</button>
</header>
<main class="view" id="view"></main>
</div>
</div>
<div id="layer-root"></div>
<!-- 导航提示浮层(照 tiaoyue 的 `nav-tips-layer`):一个 viewport 级的层,跟着当前悬停/聚焦的那一项 -->
<div class="nav-tips-layer" id="nav-tips" aria-hidden="true"></div>
<script>
/* ============================================================
MCN 短视频整合营销工作台 · 可点通原型
地址栏里的路由三段: #/p/<页>/<态>[/<浮层>] 例:#/p/p4/empty / #/p/p1/ok/quicknote
自检页是 #/selftest
页面骨架照 prd/2b-界面布局.md 的八页(冻结),⛔ 未增删板块
============================================================ */
/* ---------- 数据(示意内容按真实产品的样子写,不写"示例/未接入"字样) ---------- */
/* ⚠️ today 这一段的值里⛔ 不要再带冒号:它会被拼进「今日该发:…」,
一冒号接一冒号连成「今日该发:待发:3 分钟快手菜」,评审当场读成了错字。
改成空格分隔,一行就是一个字段值。 */
const ACCOUNTS = [
{id:'a1',name:'深夜食堂笔记',platform:'抖音',posts:42,today:'待发 3 分钟快手菜',posted:'已发 1 条 10:20',perf:'播放 12.4 万 · 完播 38%',anom:true},
{id:'a2',name:'阿元的下班饭',platform:'小红书',posts:31,today:'待审 1 条 · 周五专题',posted:'已发 1 条 09:05',perf:'点赞 4 820 · 收藏率 9.1%',anom:false},
{id:'a3',name:'阳台种菜日记',platform:'抖音',posts:18,today:'在写 · 换盆实录',posted:'未发',perf:'上条播放 2.1 万',anom:false},
{id:'a4',name:'校门口那家店',platform:'视频号',posts:9,today:'待发 · 探店补拍',posted:'已发 1 条 11:40',perf:'转发 610 · 评论 88',anom:true},
{id:'a5',name:'咖啡因实验室',platform:'B 站',posts:26,today:'待复盘 1 条',posted:'已发 1 条 08:30',perf:'三连率 6.3%',anom:false}
];
const TODOS = [
{id:'t1',kind:'待审',text:'周五专题 · 3 个版本等确认',to:'p5',acct:'阿元的下班饭',due:'今天 12:00'},
{id:'t2',kind:'在写',text:'换盆实录 · 停在文案态',to:'p4',acct:'阳台种菜日记',due:'今天'},
{id:'t3',kind:'待复盘',text:'咖啡因实验室 · 昨天那期数据回了',to:'p7',acct:'咖啡因实验室',due:'今天'},
{id:'t4',kind:'待核销',text:'校门口那家店 · 一条商单记录待核',to:'p8',acct:'校门口那家店',due:'10-09'},
{id:'t5',kind:'选题',text:'半夜解馋系列 · 5 条候选待筛',to:'p2',acct:'深夜食堂笔记',due:'本周'}
];
const BENCH = [
{id:'b1',title:'一个人吃饭也要摆盘',acct:'对标号 · 小食光',mult:'4.8×',when:'3 小时前',tag:'爆款',zone:'低粉爆款'},
{id:'b2',title:'月薪五千怎么吃好一周',acct:'对标号 · 省钱食堂',mult:'3.1×',when:'昨天',tag:'爆款',zone:'低粉爆款'},
{id:'b3',title:'冰箱清库存的三种做法',acct:'对标号 · 冰箱博士',mult:'1.2×',when:'昨天',tag:'常态',zone:'常态'},
{id:'b4',title:'夜宵摊老板不会说的三件事',acct:'对标号 · 夜市老王',mult:'6.4×',when:'前天',tag:'异常',zone:'异常值'},
{id:'b5',title:'十分钟出一餐的偷懒法',acct:'对标号 · 懒人厨房',mult:'0.9×',when:'3 天前',tag:'常态',zone:'常态'}
];
const CANDIDATES = [
{id:'c1',text:'半夜饿了除了泡面还能怎么办',hit:'这条评论被顶了 2.1 千次,是这个号的老问题',state:'idle'},
{id:'c2',text:'一个人住怎么买食材不浪费',hit:'连续 4 条内容下面都在问',state:'idle'},
{id:'c3',text:'有没有不用洗锅的做法',hit:'出现 62 次',state:'idle'}
];
const TOPICS = [
{id:'k1',text:'一个人的晚饭怎么摆得不寒酸',group:'todo',src:'对标号 · 小食光',state:'待做'},
{id:'k2',text:'五千块月薪的一周菜谱',group:'todo',src:'评论区洞察',state:'待做'},
{id:'k3',text:'冰箱清库存系列',group:'writing',src:'灵感速记',state:'在写'},
{id:'k4',text:'夜宵摊的三个坑',group:'todo',src:'对标号 · 夜市老王',state:'待做'},
{id:'k5',text:'十分钟一餐的偷懒法',group:'done',src:'对标号 · 懒人厨房',state:'已用'},
{id:'k6',text:'换盆实录:土到底怎么配',group:'writing',src:'灵感速记',state:'在写'}
];
const ASSETS = [
{id:'as1',zone:'angle',text:'用"一个人"这个处境切,不用"简单"',src:'对标号 · 小食光 · 2026-10-06 那条',state:'已入库',time:'10-06',draft:false},
{id:'as2',zone:'angle',text:'把价格焦虑变成对照,不给结论',src:'对标号 · 省钱食堂 · 2026-10-05',state:'已入库',time:'10-05',draft:false},
{id:'as3',zone:'structure',text:'先给成品,再倒推步骤',src:'对标号 · 冰箱博士 · 2026-10-05',state:'待入库',time:'10-05',draft:false},
{id:'as4',zone:'structure',text:'开头三秒抛反常识,再解释',src:'',state:'待入库',time:'10-07',draft:true},
{id:'as5',zone:'opening',text:'"这句话我听了三年,直到自己试了一次"',src:'对标号 · 夜市老王 · 2026-10-04',state:'已入库',time:'10-04',draft:false},
{id:'as6',zone:'title',text:'数字 + 处境 + 反预期(3 种写法)',src:'对标号 · 懒人厨房 · 2026-10-03',state:'已入库',time:'10-03',draft:false}
];
const STEPS = ['选题态','思路态','文案态','标题态','封面态','检查态','成稿态'];
const VERSIONS = [
{id:'v1',name:'初稿',time:'10-07 21:10',cur:false},
{id:'v2',name:'加了价格对照',time:'10-08 09:02',cur:false},
{id:'v3',name:'缩到 45 秒',time:'10-08 10:15',cur:true}
];
const RECORDS = [
{id:'rec1',text:'深夜食堂笔记 · 3 分钟快手菜',by:'陈队长',time:'10-08 10:20',ver:'v3',state:'已发布',acct:'深夜食堂笔记',settle:'待结算',amount:'¥ 3 200'},
{id:'rec2',text:'阿元的下班饭 · 周五专题',by:'李编导',time:'10-08 09:05',ver:'v2',state:'已发布',acct:'阿元的下班饭',settle:'已结算',amount:'—'},
{id:'rec3',text:'校门口那家店 · 探店补拍',by:'陈队长',time:'10-08 11:40',ver:'v1',state:'待审',acct:'校门口那家店',settle:'未登记',amount:'—'},
{id:'rec4',text:'咖啡因实验室 · 手冲水温那期',by:'李编导',time:'10-07 08:30',ver:'v4',state:'已发布',acct:'咖啡因实验室',settle:'已结算',amount:'¥ 1 800'},
{id:'rec5',text:'阳台种菜日记 · 换盆实录',by:'李编导',time:'10-06 19:12',ver:'v1',state:'已归档',acct:'阳台种菜日记',settle:'未登记',amount:'—'}
];
const REVIEWS = [
{id:'r1',text:'咖啡因实验室 · 手冲水温那期',acct:'咖啡因实验室',day:'10-07',data:'到了',play:'8.6 万',fin:'41%',tri:'6.3%',note:'结论:水温段讲太长,掉在 38 秒处'},
{id:'r2',text:'深夜食堂笔记 · 3 分钟快手菜',acct:'深夜食堂笔记',day:'10-08',data:'到了',play:'12.4 万',fin:'38%',tri:'—',note:''},
{id:'r3',text:'校门口那家店 · 探店补拍',acct:'校门口那家店',day:'10-06',data:'未回',play:'—',fin:'—',tri:'—',note:''}
];
const PROFILES = {
a1:{定位:'城市独居青年的快手饭',风格:'暖色实拍 · 无口播 · 字幕为主',受众:'22–30 岁,一二线独居',平台:'抖音为主,同步小红书',偏好:'真实厨房、不做摆拍;不接减肥类',记忆:['10-06 观众对"一人食"这个词有共鸣,标题里保留','10-07 45 秒以下完播明显更好']},
a2:{定位:'下班后的一顿正经饭',风格:'慢剪 · 有人声 · 长镜头',受众:'25–35 岁,做饭新手',平台:'小红书',偏好:'不夸大功效、不用"必吃"',记忆:['10-05 收藏率靠步骤图带起来']},
a3:{定位:'阳台可复制的小规模种菜',风格:'记录向 · 手持 · 过程为主',受众:'30–45 岁',平台:'抖音',偏好:'不承诺产量',记忆:[]},
a4:{定位:'小城小店的故事',风格:'人物访谈 · 现场收音',受众:'本地 + 怀旧向',平台:'视频号',偏好:'不摆拍老板、不虚构故事',记忆:['10-04 转发集中在"开店多少年"这句']},
a5:{定位:'把咖啡讲得不玄',风格:'实验对比 · 数据可视化',受众:'咖啡爱好者',平台:'B 站',偏好:'不吹器具、不做器材带货',记忆:['10-07 水温段太长,38 秒处开始掉']}
};
const CONTENT = {key:'k3',title:'冰箱清库存系列 · 第 3 条',step:2,stepOut:['选题:冰箱剩菜怎么配','思路:先给成品,再倒推三步','','','','','']};
/* ---------- 状态 ---------- */
const S = {
route:'p1', pageState:'ok', account:'a1',
layer:null, noteDraft:'', searchQ:'',
topics:JSON.parse(JSON.stringify(TOPICS)), topicGroup:'todo',
benchFilter:'all', commentOpen:false, commentState:'idle',
candidates:JSON.parse(JSON.stringify(CANDIDATES)),
assets:JSON.parse(JSON.stringify(ASSETS)), assetZone:'angle', assetSel:'as1',
refs:[],
step:CONTENT.step, stepOut:CONTENT.stepOut.slice(), stepBusy:false,
/* AI 产出裁决:按环节记,''=未裁决 / adopted=采纳 / rejected=驳回 / rework=改后再用。
2026-10-08 按 layouts-tooling §4.7「AI 产出环节必须有独立产出区」新增。 */
aiVerdict:{}, aiOut:{}, reviewVerdict:'',
/* sideCollapsed 已删(2026-10-08):顶栏「收起侧栏」撤销,侧栏是 64px 药丸浮栏、不再可收起。 */
versionOpen:false,
targetOpen:false, targets:{accounts:['a1','a2'],platforms:['抖音']},
versions:JSON.parse(JSON.stringify(VERSIONS)), verSel:'v1', verDraft:null, verDirty:false,
checkState:'idle', confirmOpen:false, publishDone:false,
profileDirty:false, memoryOpen:false,
reviewSel:'r1', attrDraft:'', historyOpen:false, wroteBack:false,
recordSel:'rec1', traceOpen:false, billingOpen:false, checkedOut:false,
toast:null
};
const PAGES = {
p1:'矩阵总览', p2:'选题雷达', p3:'资产库', p4:'创作台',
p5:'发布中心', p6:'账号画像', p7:'复盘台', p8:'记录与对账'
};
/* 侧栏只有一层:**八个功能项平铺**,一项一页,全部用两三个字的短名。
2026-10-08 用户给了参照物的标准答案(那张顶部导航条:总览 / 找选题 / 资产库 / 创作 / 发布 /
账号画像 / 复盘 / 记录与对账),并指出上一版把导航收成「四个大板块」是理解反了 ——
「一层」=扁平一列、⛔ 不再分两级,不是「只剩板块、把页面藏起来」。
⚠️ 那 8 个名字与我方八页一一对应,只是短化:矩阵总览→总览、选题雷达→找选题、创作台→创作、
发布中心→发布、复盘台→复盘。页头仍写②段的页面全名(分工同参照物)。 */
/* 🔴 导航短名**一律 2 个字**(2026-10-08 第七轮):项宽 56 − 内衬 32 = **24px 内容宽**,
12px 字只能放得下 2 个字 —— 这是参照物定的上限(它的 9 项:首页 / 影视 / 短视频 / 转绘 /
画布 / 剧本 / 资产 / 空间 / 团队,最长 3 字已经是把字溢出到内衬里的极限)。
本轮把原来 4 字与 5 字的两个名字收到 2 字(账号画像 → 画像、记录与对账 → 对账),
同时「找选题 → 选题、资产库 → 资产」补齐一致性;页面标题(`PAGES`)不变,仍是全名。
⚠️ 每一页还带一句 `desc` —— 它进悬停提示。参照物的提示行是**说明文字**(「发现灵感与精选内容」),
⛔ 不是名字的重播;名字现在常驻了,提示再念一遍等于没说。 */
const NAV = [
{label:'总览', page:'p1', desc:'五个账号的今日数据与待办'},
{label:'选题', page:'p2', desc:'对标内容与选题库'},
{label:'资产', page:'p3', desc:'拆解结果与素材沉淀'},
{label:'创作', page:'p4', desc:'从选题到脚本到分镜'},
{label:'发布', page:'p5', desc:'版本排期与发布留痕'},
{label:'画像', page:'p6', desc:'账号口径与长期记忆'},
{label:'复盘', page:'p7', desc:'把表现读回来写成经验'},
{label:'对账', page:'p8', desc:'发布留痕与收支核对'}
];
const STATES = [['ok','正常'],['loading','加载中'],['empty','空'],['error','出错'],['disabled','条件未满足'],['noperm','无权限']];
/* ---------- 每页各状态的说明(逐页不同,⛔ 不用一句通用话糊过去) ---------- */
const STATE_COPY = {
p1:{loading:['正在取矩阵数据','5 个账号的今日数据正在汇总。'],empty:['还没接入账号','矩阵总览要先有账号。去账号画像建第一个账号。','p6'],error:['数据源不可用','最近一次成功取值:10-08 09:12。数据源没回话,这里不显示空白冒充正常。'],disabled:['今天没有要处理的事','待处理列表为空,主操作暂时没有对象。'],noperm:['利润相关字段需要管理员权限','你当前是编导角色,账号矩阵里的利润列对你不显示。']},
p2:{loading:['正在拉对标更新','3 个在监控的账号正在取近三天内容。'],empty:['监控名单是空的','先录一个对标号,才能看到它的更新和异常值。'],error:['拉取失败','平台授权可能已过期。可以先手工录入对标号,不必卡在这里。'],disabled:['还没选账号','选题要落到某个账号上才能转入创作。'],noperm:['—','']},
p3:{loading:['条目加载中','正在读这个分区的拆解结果。'],empty:['这个分区还没有条目','去选题雷达发一条拆解,结果会落到这里。','p2'],error:['读取失败','本地知识库目录没读出来。重试一次,或检查目录是否还在。'],disabled:['这条还不能入库','它没有出处,按门禁先留在草稿里。补上出处就能入库。'],noperm:['—','']},
p4:{loading:['正在跑这一步','文案生成中,已跑 24 秒。'],empty:['这一步还没有产出','先在上面写点方向,再点开始。'],error:['这一步失败了','已产出的内容保留在这里,可以直接重试这一步。'],disabled:['还差封面','文案有了、封面还没有。进检查态前封面必须有。'],noperm:['—','']},
p5:{loading:['检查中','硬闸与软闸正在过这一批版本。'],empty:['还没有可发布的版本','成稿之后才会有版本。回创作台把内容推到成稿。','p4'],error:['检查本身没跑成','按不过处理:宁拦不放。改完可以复检。'],disabled:['有硬拦项没处理','敏感信息这一处不改完,确认发布点不动。'],noperm:['这个账号你没有发布权限','校门口那家店归陈队长。你可以看版本,不能替它发。']},
p6:{loading:['画像加载中','正在读这个账号的口径与记忆。'],empty:['还没有画像','建一份画像,这个账号才有自己的口径。'],error:['保存失败','你改的内容还在,没有被清掉。重试一次。'],disabled:['这个账号你只能看','它不是归你负责的账号,字段锁住了。'],noperm:['没有改画像的权限','你可以看,改动需要内容负责人来做。']},
p7:{loading:['正在读这条的数据','已发布内容的回读最多 90 秒。'],empty:['还没有能复盘的内容','先有发布记录,才有复盘。','p8'],error:['数据读不到','可以手工录入这条的表现,归因照做。'],disabled:['归因还没写','没有结论就不能回写,回写会改画像记忆。'],noperm:['—','']},
p8:{loading:['记录加载中','正在读发布留痕。'],empty:['还没有发布记录','确认发布之后,这里会留一条。'],error:['读取失败','本地留痕库没读出来。重试一次。'],disabled:['还没选中记录','先点一条记录,才有东西可核销。'],noperm:['利润列对你隐藏','你不是管理员,收支金额列不显示。其余列照常可看。']}
};
/* ---------- 反馈提示 ---------- */
let toastTimer=null;
function toast(text){ S.toast=text; paint(); clearTimeout(toastTimer); toastTimer=setTimeout(()=>{ S.toast=null; paint(); },1500); }
/* ---------- 路由 ---------- */
/* 可用浮层名(路由第 4 段用):与 layerHTML() 的分支一一对应 */
const LAYER_IDS=['quicknote','search','assume','versions','assetpick','confirm','checkout'];
function parseHash(){
const raw=(location.hash||'').replace(/^#\/?/,'');
const parts=raw.split('/').filter(Boolean); // ['p','p4','empty','quicknote']
if(parts[0]==='selftest'){ S.route='selftest'; S.pageState='ok'; S.layer=null; return; }
let page=parts[1]||'p1'; let st=parts[2]||'ok';
if(!PAGES[page]) page='p1';
if(!STATES.some(s=>s[0]===st)) st='ok';
/* 第 4 段是浮层:`#/p/p1/ok/quicknote` 直接把层打开。
有它才能给浮层出截图、也才能把「某个状态」当链接发给别人。层名不认识就当没写。 */
S.route=page; S.pageState=st;
S.layer=LAYER_IDS.includes(parts[3]||'') ? parts[3] : null;
}
/* 跳页:先同步改内存态,再写 hash。
⚠️ 为什么不能只写 hash:hash 变化带来的 hashchange 是**异步**的 ⇒ 点完立刻读状态的一方
(自检、以及连点两下的用户)看到的是旧页。同步改态让跳页对调用者立即可见;
hash 照样写,是为刷新 / 前进后退 / 分享链接留的入口。
跳页一律关闭浮层 —— 不关的话浮层会盖在新页上,看着像没跳。 */
function go(page,st){
if(!PAGES[page]) return;
S.route=page; S.pageState=st||'ok'; S.layer=null;
const want='#/p/'+page+'/'+(st||'ok');
if(location.hash!==want){ try{ location.hash=want; }catch(e){} }
paint();
}
/* ---------- 元素 ---------- */
const $=s=>document.querySelector(s);
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function el(html){ const d=document.createElement('div'); d.innerHTML=html.trim(); return d.firstElementChild; }
/* ---------- 导航 ---------- */
/* 侧栏=**一层八个功能项**(2026-10-08 用户口径「只有大板块的导航 只有一层」),
八项与八页一一对应、一项一页。
形态照 `design-system-tiaoyue` 的 `app-sidebar`:**回源站量过**(`3d` §十三),它是一条
**64px 宽的药丸浮栏**(圆角 100px、内衬 3px、淡底+淡描边),项是 **56×78 的药丸**,
**图标 24px 在上、名字 12px/500 在下,两者都常驻**;`nav-tip` 那 81 个不是名字,是**说明**。
⇒ 所以这里一项=一个图标 + 一个常驻短名 + 一个悬停说明。
名字一律**两个字**(总览 / 选题 / 资产 / 创作 / 发布 / 画像 / 复盘 / 对账)——
项宽 56 − 内衬 16×2 = 24px 内容宽,只放得下 2 个 12px 汉字,这是参照物定的长度上限。 */
const NAV_ICON = {
/* 每页一个 24×24 的线性图标:viewBox 24、stroke 走 currentColor、fill none */
p1:'<path d="M4 4.5h6v5H4zM14 4.5h6v7h-6zM14 14.5h6v5h-6zM4 12.5h6v7H4z"/>', /* 总览=四格 */
p2:'<circle cx="10.5" cy="10.5" r="6"/><path d="M15 15l5 5"/><path d="M10.5 8v2.8l2 1.4"/>', /* 找选题=放大镜里一只罗盘 */
p3:'<path d="M4 7.5 12 4l8 3.5v9L12 20l-8-3.5z"/><path d="M4 7.5 12 11l8-3.5"/><path d="M12 11v9"/>', /* 资产库=箱子 */
p4:'<path d="M4 20h4L18.5 9.5l-4-4L4 16z"/><path d="M14.5 5.5l4 4"/>', /* 创作=笔 */
p5:'<path d="M12 19.5V6"/><path d="M7 11l5-5 5 5"/><path d="M5 20h14"/>', /* 发布=上传 */
p6:'<circle cx="12" cy="8" r="3.5"/><path d="M5.5 19.5c1.3-3.4 3.7-5.1 6.5-5.1s5.2 1.7 6.5 5.1"/>', /* 账号画像=人 */
p7:'<path d="M5 19.5V11M10 19.5V5M15 19.5v-5.5M20 19.5V8.5"/>', /* 复盘=柱状 */
p8:'<path d="M6 3.5h8.5L19 8v12.5H6z"/><path d="M9.2 13l2 2 4-4"/>' /* 记录与对账=单据+勾 */
};
/* 图标成品的唯一出口:**一处定型,处处复用**(侧栏 20px、功能入口卡 22px)。
规格**照参照物 mcn-work-shop 的 `svgIcon()` 学**(`public/app.js:201-207`):
`viewBox 24` / `fill:none` / `stroke:currentColor` / **`stroke-width:2`** / 圆头圆角。
⚠️ 我原先写 1.7,这一轮按参照物的实测值改成 **2**(lucide 标准线宽),侧栏与卡片一起改 ——
同一套图标只能有一套线宽。 */
function navSvg(page,size){
return `<svg viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor"`
+ ` stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${NAV_ICON[page]||''}</svg>`;
}
function renderNav(){
$('#nav').innerHTML = NAV.map(g=>`
<button class="nav-item ${S.route===g.page?'is-active':''}" data-act="G-05" data-page="${g.page}"
aria-label="${esc(PAGES[g.page])}|${esc(g.desc)}" data-tip="${esc(g.desc)}">
${navSvg(g.page,24)}
<span class="nav-label">${esc(g.label)}</span>${navCount(g.page)}
</button>`).join('');
hideNavTip(); /* 导航重建后旧的提示挂在一个已经不存在的项上,清掉 */
}
/* 导航提示(照 tiaoyue 的 `nav-tips-layer`):
悬停 / 聚焦到某一项时,在它的右缘向右浮出名字。
⚠️ 位置用 getBoundingClientRect 现算、层是 position:fixed —— 为什么不把提示放进 .nav-item:
`.sidebar` 是 overflow:auto,项内的 absolute 提示会被剪掉。 */
function showNavTip(item){
const layer=document.getElementById('nav-tips'); if(!layer||!item) return;
const tip=item.dataset.tip||''; if(!tip){ hideNavTip(); return; }
const r=item.getBoundingClientRect();
layer.innerHTML=`<span class="nav-tip">${esc(tip)}</span>`;
layer.classList.add('is-visible');
const t=layer.firstElementChild;
t.style.left=(r.right+8)+'px';
t.style.top=(r.top+r.height/2)+'px';
/* 贴近右边缘时改成向左展开(它的 is-expand-right 的反向兜底) */
const tr=t.getBoundingClientRect();
if(tr.right>window.innerWidth-8) t.style.left=Math.max(8,r.left-tr.width-8)+'px';
}
function hideNavTip(){
const layer=document.getElementById('nav-tips'); if(!layer) return;
layer.classList.remove('is-visible'); layer.innerHTML='';
}
/* 面包屑已删(2026-10-08):它和页头 h1 说的是同一件事(页面全名),而侧栏的药丸浮栏
本来就不显示名字 ⇒ 真正承担「我在哪一页」的是 h1。⛔ 不再保留 renderCrumbs()。 */
function navCount(id){
const n={p1:TODOS.length,p2:BENCH.length,p3:S.assets.length,p5:S.versions.length,p7:REVIEWS.filter(r=>r.data==='到了').length,p8:RECORDS.length}[id];
return n?'<span class="nav-count">'+n+'</span>':'';
}
/* ---------- 通用结构件 ---------- */
function head(title,right,lv){
/* lv 默认 2(板块标题);页面标题传 1 ⇒ 全篇有且只有一个 h1,标题不跳级。 */
const h='h'+(lv||2);
return `<div class="block-head"><${h} class="grow">${esc(title)}</${h}>${right||''}</div>`;
}
function tag(text,cls){ return `<span class="tag ${cls||''}">${esc(text)}</span>`; }
/* 「假设」徽章=**可点**(2026-10-08):原来假设浮层的入口挂在顶栏(G-08「假设 2 项」),
用户要求顶栏清干净 ⇒ 改成**挂回假设自己身上**:哪一块是假设,就点那一块的徽章,
弹出同一份「复核人 / 时机 / 落点」。入口与内容贴在一起,比顶栏那个更对。 */
/* 功能入口卡(`.data-card`)=**三段纵向**:图标独占一行 → 标题 → 描述。
这三段是逐字对齐参照物 `mcn-work-shop` 首页 `app.js:332-336` 的写法。
⚠️ 第一版把「图标 + 标题」并成一行(`.dc-line`)纯为省高度 —— 2026-10-08 用户点破
「功能入口的高度还是没有」,那个省法作废:**高度按参照物给足,省钱的活交给别处**。
图标用**目标页自己的那一个**(复用侧栏的 NAV_ICON)⇒ 同一个目标在侧栏与卡片上是同一个图标,
⛔ 不另造一套图标库。 */
function card(page,title,desc){
return `<button class="data-card" data-act="G-05" data-page="${page}">
<span class="dc-icon">${navSvg(page,22)}</span>
<span class="dc-title">${esc(title)}</span>
<span class="dc-desc">${esc(desc)}</span></button>`;
}
const ASSUME_TAG='<button class="tag tag-assume" data-act="G-08" title="两处假设的复核人 / 时机 / 落点">假设</button>';
/* ============================ 八页 ============================ */
function pageP1(){
/* 🔴 2026-10-08:P1 两块数据由「行式列表」改成**满宽表格**(照 3c 参照物 P1 的形态:
表头一排 + 每行一个短值)。行高取紧凑档,为的是守住下面那条密度契约(一屏 ≥6 行)。 */
const todos=TODOS.map(t=>`
<tr>
<td class="cell-lead">${esc(t.acct)}</td>
<td><strong>${esc(t.text)}</strong></td>
<td>${tag(t.kind,'')}</td>
<td class="num-col">${esc(t.due)}</td>
<td class="cell-act"><button class="btn-micro" data-act="P1-01" data-to="${t.to}">处理</button></td>
</tr>`).join('');
/* ⚠️「数据异动」状态标放**名称旁**,⛔ 不放操作列。
放操作列会把那一行的 auto 列撑宽,于是只有这一行的文字折行 ⇒ 行高忽高忽低
(改前实测:第 1 行 82px、其余 66px,扫列表时节奏是乱的)。
状态标贴着名称也顺扫读顺序;操作列只剩按钮,列宽自然一致。 */
const rows=ACCOUNTS.map(a=>`
<tr>
<td class="cell-lead"><span class="cell-name" data-act="P1-02" data-acct="${a.id}" role="button" tabindex="0" title="${esc(a.name)}|${esc(a.platform)} · ${a.posts} 条内容">${esc(a.name)}</span>${a.anom?tag('异动','tag-warn'):''}</td>
<td>${esc(a.today)}</td>
<td>${esc(a.posted)}</td>
<td class="num-col">${esc(a.perf)}</td>
<td class="cell-act"><button class="btn-micro" data-act="P1-03" data-acct="${a.id}">看复盘</button></td>
</tr>`).join('');
const anom=ACCOUNTS.filter(a=>a.anom);
return `
${head('P1 矩阵总览', ASSUME_NOTE_P1, 1)}
<!-- 板块形态(2026-10-08 用户口径:**功能入口并排放到「数据统计」下面**;照 mcn-work-shop 的 .home-stats/.data-cards):
① 页头之下先统计条(数据统计)② 紧跟本板块功能入口卡一行并排 ③ 再进板块内容。
⛔ 这两块都不是 .block ⇒ 不进②段骨架的板块计数(机检数的是 .block .block-head h2)。 -->
<div class="home-stats">
<div class="stat-item"><span class="stat-value">${ACCOUNTS.length}</span><span class="stat-label">在管账号</span></div>
<div class="stat-item"><span class="stat-value">${TODOS.length}</span><span class="stat-label">今日待处理</span></div>
<div class="stat-item"><span class="stat-value">${anom.length}</span><span class="stat-label">数据异动</span></div>
<div class="stat-item"><span class="stat-value">${ACCOUNTS.reduce((n,a)=>n+a.posts,0)}</span><span class="stat-label">本月已发</span></div>
</div>
<!-- 本板块功能入口(内容=②段 §三 里从 P1 出发的流转目标:P1→P7 待复盘 / P1→P8 待核销 / P1→P6 点账号进画像),
⛔ 不放「矩阵总览」自己(当前页不给自己当入口)。 -->
<div class="sect-nav">
<div class="data-cards">
${card('p7','复盘台','把表现读回来写成经验')}
${card('p8','记录与对账','发布留痕与收支核对')}
${card('p6','账号画像','每个账号的口径与长期记忆')}
</div>
</div>
<!-- ⛔ P1 **不套**「限宽单列」:mcn-work-shop 的首页是**功能入口页**(卡流+问答行,不需要行数),
而 P1 是**矩阵数据页**,契约要求一屏 ≥6 行(改版首跑实测:套了限宽单列 ⇒ 首行顶 622px、
一屏只装 4 行 ⇒ 自检红)⇒ 页型不同不能硬套,密度判据优先。问答行这一形态照搬保留。 -->
<!-- 两块数据=满宽表格、上下堆叠(2026-10-08 用户口径「gpt 给的布局……照着抄」:
参照物 P1 就是「今日待处理表 → 账号矩阵表」两张满宽表顺排)。 -->
<section class="block">
${head('今日待处理', `<span class="meta">${TODOS.length} 项 · 按截止时间排</span>`)}
<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">
<thead><tr><th>账号</th><th>今天要做</th><th>类型</th><th class="num-col">截止</th><th class="cell-act"></th></tr></thead>
<tbody>${todos}</tbody>
</table></div></div>
</section>
<section class="block">
${head('账号矩阵列表', `<span class="meta">${ACCOUNTS.length} 个账号 · 按今日待办排</span>`)}
<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">
<thead><tr><th>账号</th><th>今日该发</th><th>已发</th><th class="num-col">表现</th><th class="cell-act"></th></tr></thead>
<tbody>${rows}</tbody>
</table></div></div>
</section>
<!-- 异常值提示 | 本月表现最好的一条 并排(照 3c-GPT参考版.md P1 的 .grid.cols2:上下文在左、结果在右)。
⛔ 只动摆法:两块在 DOM 里的先后与②段 §二 P1 的板块清单一致。 -->
<div class="block grid-main-side">
<section>
${head('异常值提示', `<span class="meta">${anom.length} 条异动</span>`)}
<div class="panel">
${anom.length?`<ul class="list">${anom.map(a=>`
<li><div class="list-row" data-act="P1-04" data-acct="${a.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(a.name)}</strong><span class="meta">${esc(a.perf)}</span></span>
<span class="secondary-cell"><span>比它平时高出一大截</span></span>
<span class="action-cell">${tag('去选题雷达','')}</span>
</div></li>`).join('')}</ul>`:`<p class="meta">目前没有异动。</p>`}
</div>
</section>
<section>
${head('本月表现最好的一条', '')}
<div class="panel"><div class="row">
<div style="flex:1 1 200px;min-width:0">
<h3>阿元的下班饭 · 周五专题</h3>
<p class="meta">10-02 发布 · 点赞 9 800 · 收藏率 12.4%</p>
</div>
<button class="btn btn-sm" data-act="P1-05">看这条复盘</button>
</div></div>
</section>
</div>
<section class="block">
${head('全局检索与随手记', `<span class="meta">含 AI 问答</span>`)}
<div class="panel">
<div class="row">
<button class="btn btn-sm" data-act="G-10">全局检索</button>
<button class="btn btn-sm" data-act="G-06">随手记一条</button>
<span class="meta">随手记在任意页面一次点击内都能碰到。</span>
</div>
<!-- 问答行(照 mcn-work-shop 的 .qa-row):AI 以「一问一答的行」当一等公民,
⛔ 不是塞在角落的按钮。样式仍走 tiaoyue 令牌(问=强调色、答=次级色)。
外面那层 .qa-block 是**学参照物的虚线分隔**(它写的是 .func-card .qa{border-top:1px dashed})。 -->
<div class="qa-block">
<div class="qa-row">
<span class="q">今天哪个账号最该先处理?</span>
<span class="a">深夜食堂笔记 —— 有一条待审压了 9 小时,另有一条今晚 20:00 截止。</span>
</div>
<div class="qa-row">
<span class="q">上周表现最好的一条,好在哪?</span>
<span class="a">阿元的下班饭 · 周五专题 —— 收藏率 12.4%,高于该账号平时约 3 倍。</span>
</div>
</div>
</div>
</section>
</div>`;
}
const ASSUME_NOTE_P1 = ASSUME_TAG;
function pageP2(){
const zone=S.benchFilter;
const list=BENCH.filter(b=>zone==='all'||b.zone===zone);
const low=BENCH.filter(b=>b.zone==='低粉爆款').length, anom=BENCH.filter(b=>b.zone==='异常值').length;
const topicList=S.topics.filter(t=>t.group===S.topicGroup);
const groupLabel={todo:'待做',writing:'在写',done:'已用'};
return `
${head('P2 选题雷达','',1)}
<section class="block">
${head('对标监控概览', `<span class="meta">在监控 3 个账号 · 点数字可筛下面的列表</span>`)}
<!-- 统计条=**照 P1 首页的那一条**(.home-stats + .stat-item):数字大、标签小、一格一个数。
两个可点的格子用同一个 .stat-item 外观(⛔ 不给按钮单独的样式,一给就不是「统计条」了)。 -->
<div class="home-stats">
<div class="stat-item"><span class="stat-value">3</span><span class="stat-label">在监控账号</span></div>
<button class="stat-item" data-act="P2-01" ${low?'':'disabled'}><span class="stat-value">${low}</span><span class="stat-label">低粉爆款</span></button>
<button class="stat-item" data-act="P2-02" ${anom?'':'disabled'}><span class="stat-value">${anom}</span><span class="stat-label">异常值</span></button>
<div class="stat-item"><span class="stat-value">${BENCH.length}</span><span class="stat-label">近三天更新</span></div>
</div>
${zone!=='all'?`<p class="meta" style="margin-top:var(--mg-8)">列表已按「${esc(zone)}」筛过 · 点概览里的数字可再筛</p>`:''}
</section>
<!-- 功能入口(2026-10-08 用户口径:并排放到数据统计下面)。侧栏只有大板块一层 ⇒ 板块内的页面靠这里接住。
内容=②段 §三 里从 P2 出发的流转目标(P2→P3 拆解入库 / P2→P4 转入创作)。
⚠️ 这一行**不给下边距**:紧跟其后的「对标内容列表」吃密度契约(一屏 ≥6 行),
给 24px 时首行顶 503px ⇒ 只装得下 5 行(实测)。后面那个 .block 自带 32px 上边距,
所以这里留 0 也不挤。 -->
<div class="sect-nav">
<div class="data-cards">
${card('p3','资产库','把拆解结果存下来,下次直接拿')}
${card('p4','创作台','把一条选题推着走到成稿')}
</div>
</div>
<section class="block">
${head('对标内容列表', `<span class="meta">${list.length} 条 · 倍数=相对该号平时水平</span>`)}
<!-- 主数据块=**满宽表格**(照 P1 首页与 3c 参照物):表头一排 + 每行一个短值,行高走紧凑档。 -->
<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">
<thead><tr><th>标题</th><th>账号</th><th>发布</th><th class="num-col">倍数</th><th>类型</th><th class="cell-act"></th></tr></thead>
<tbody>${list.map(b=>`
<tr>
<td class="cell-lead">${esc(b.title)}</td>
<td>${esc(b.acct)}</td>
<td>${esc(b.when)}</td>
<td class="num-col">${esc(b.mult)}</td>
<td>${tag(b.tag, b.tag==='异常'?'tag-warn':'')}</td>
<td class="cell-act"><button class="btn-micro" data-act="P2-03" data-bench="${b.id}">发起拆解</button></td>
</tr>`).join('')}</tbody>
</table></div></div>
</section>
<!-- 评论区洞察 | 选题库 并排(照参考版 P2 的 .grid.cols2):输入在左、可推进的条目在右。
⛔ 只动摆法 —— 两块的先后仍与②段 §二 P2 的板块清单一致。 -->
<div class="block grid-main-side">
<section>
${head('评论区洞察', `<button class="btn btn-ghost" data-act="P2-04">${S.commentOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.commentOpen?`
<div class="row" style="margin-bottom:var(--sp-16)">
<button class="btn btn-sm" data-act="P2-05" ${S.commentState==='fetching'?'disabled':''}>抓取这条的评论</button>
<span class="meta">按点赞最多取前 20 条</span>
</div>
${S.commentState==='fetching'?`<div class="skel-stack"><div class="skel skel-w90"></div><div class="skel skel-w70"></div><div class="skel skel-w45"></div></div>`:''}
${S.commentState==='done'?`<ul class="list">${S.candidates.map(c=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(c.text)}</strong><span class="meta">${esc(c.hit)}</span></span>
<span class="secondary-cell"><span>${c.state==='idle'?'待处理':(c.state==='adopted'?'已采纳':'已忽略')}</span></span>
<span class="action-cell">
<button class="btn-micro" data-act="P2-06" data-c="${c.id}" ${c.state!=='idle'?'disabled':''}>采纳</button>
<button class="btn-micro" data-act="P2-07" data-c="${c.id}" ${c.state!=='idle'?'disabled':''}>忽略</button>
</span>
</div></li>`).join('')}</ul>`:''}
${S.commentState==='idle'?`<p class="meta">展开后点抓取,从这条内容的评论区里出候选选题。抓不到可以手工粘评论。</p>`:''}
`:`<p class="meta">这里不占主屏:从对标列表拿选题是主路,评论区是另一路输入。</p>`}
</div>
</section>
<section>
${head('选题库', `<span class="meta">${topicList.length} 条</span>`)}
<div class="panel">
<div class="seg" style="margin-bottom:var(--sp-16)">
${['todo','writing','done'].map(g=>`<button class="seg-item ${S.topicGroup===g?'is-on':''}" data-act="P2-08" data-group="${g}">${groupLabel[g]}</button>`).join('')}
</div>
<ul class="list">${topicList.map(t=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(t.text)}</strong><span class="meta">来源:${esc(t.src)}</span></span>
<span class="secondary-cell"><span>${esc(t.state)}</span></span>
<span class="action-cell">
<button class="btn-micro" data-act="P2-09" data-k="${t.id}" ${t.group==='done'?'disabled':''}>转入创作</button>
<button class="btn-micro" data-act="P2-10" data-k="${t.id}">拆解</button>
</span>
</div></li>`).join('')} </ul>
</div>
</section>
</div>
<section class="block">
${head('灵感速记', '')}
<div class="panel"><div class="row">
<button class="btn btn-sm" data-act="G-06">随手记一条</button>
<span class="meta">随手记在任意页面一次点击内都能碰到。</span>
</div></div>
</section>`;
}
function pageP3(){
const zones=[['angle','角度'],['structure','结构'],['opening','开场白'],['title','标题']];
const list=S.assets.filter(a=>a.zone===S.assetZone);
const sel=S.assets.find(a=>a.id===S.assetSel)||list[0];
const pending=S.assets.filter(a=>a.state==='待入库');
return `
${head('P3 资产库','',1)}
<!-- 页壳照 P1 首页:统计条 → 功能入口卡 → 再进板块内容。⛔ 这两块都不是 .block(不进②段骨架计数)。 -->
<div class="home-stats">
<div class="stat-item"><span class="stat-value">${S.assets.length}</span><span class="stat-label">条目总数</span></div>
<div class="stat-item"><span class="stat-value">${S.assets.filter(a=>a.state==='已入库').length}</span><span class="stat-label">已入库</span></div>
<div class="stat-item"><span class="stat-value">${pending.length}</span><span class="stat-label">待入库</span></div>
<div class="stat-item"><span class="stat-value">${zones.length}</span><span class="stat-label">分区</span></div>
</div>
<div class="sect-nav">
<div class="data-cards">
${card('p2','选题雷达','从对标内容里拆出可用的角度')}
${card('p4','创作台','把这条拿去推着走到成稿')}
</div>
</div>
<section class="block">
${head('分区切换', `<span class="meta">一次看一个分区</span>`)}
<div class="panel"><div class="seg">
${zones.map(([z,l])=>`<button class="seg-item ${S.assetZone===z?'is-on':''}" data-act="P3-01" data-zone="${z}">${l} <span class="num">${S.assets.filter(a=>a.zone===z).length}</span></button>`).join('')}
</div></div>
</section>
<!-- 主数据块=**满宽表格**(照 P1 首页):一行一条,点行名看详情。
⚠️ 表格**不放行级动作** —— 「入库」这一步按原设计留在「单条详情」里,
因为那里的门禁说明(补出处才能入库)是它的一部分;把动作搬进表格会把门禁与说明拆开。 -->
<section class="block">
${head('资产条目', `<span class="meta">${list.length} 条</span>`)}
<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">
<thead><tr><th>条目</th><th>出处</th><th>状态</th><th class="num-col">采集</th></tr></thead>
<tbody>${list.map(a=>`
<tr>
<td class="cell-lead"><span class="cell-name ${sel&&a.id===sel.id?'is-on':''}" data-act="P3-02" data-a="${a.id}" role="button" tabindex="0">${esc(a.text)}</span></td>
<td>${a.src?esc(a.src):'没有出处'}</td>
<td>${a.draft?tag('草稿','tag-warn'):esc(a.state)}</td>
<td class="num-col">${esc(a.time)}</td>
</tr>`).join('')}</tbody>
</table></div></div>
</section>
<!-- 单条详情 | 引用入口 并排(照参考版 P3 的左右两栏:左看这一条、右把它拿去用)。
⛔ 只动摆法 —— 两块的先后仍与②段 §二 P3 的板块清单一致(详情在前、引用在后)。 -->
<div class="block grid-main-side">
<section>
${head('单条详情', sel?`<span class="meta">${esc(sel.time)} 采集</span>`:'')}
<div class="panel">
${sel?`
<p class="field-label">原文</p><p>${esc(sel.text)}</p>
<p class="field-label" style="margin-top:var(--sp-16)">出处</p><p>${sel.src?esc(sel.src):'没有出处 —— 按门禁只能留在草稿,补上出处才能入库。'}</p>
<div class="row" style="margin-top:var(--sp-16)">
${sel.draft?tag('草稿','tag-warn'):tag('已入库','tag-ok')}
${sel.state==='待入库'?`<button class="btn btn-sm" data-act="P3-03" data-a="${sel.id}" ${sel.src?'':'disabled'}>入库</button>`:''}
</div>
`:`<p class="meta">这个分区还没有条目。</p>`}
</div>
</section>
<section>
${head('引用入口', `<span class="meta">把这条拿去创作台用</span>`)}
<div class="panel"><div class="row">
<button class="btn btn-sm" data-act="P3-04" data-a="${sel?sel.id:''}" ${sel?'':'disabled'}>引用到创作台</button>
<span class="meta">还有 ${pending.filter(p=>p.src).length} 条待入库,可入库的先入。</span>
</div></div>
</section>
</div>`;
}
function pageP4(){
const out=S.stepOut[S.step]||'';
const aiV=(S.aiVerdict||{})[S.step]||'';
const aiOut=(S.aiOut||{})[S.step]||'';
return `
${head('P4 创作台','',1)}
<!-- 页壳照 P1 首页:统计条 → 功能入口卡 → 再进板块内容。 -->
<div class="home-stats">
<div class="stat-item"><span class="stat-value">${S.step+1}</span><span class="stat-label">当前环节(共 7 步)</span></div>
<div class="stat-item"><span class="stat-value">${S.step}</span><span class="stat-label">已完成环节</span></div>
<div class="stat-item"><span class="stat-value">${S.refs.length}</span><span class="stat-label">引用资产</span></div>
<div class="stat-item"><span class="stat-value">${S.versions.length}</span><span class="stat-label">版本</span></div>
</div>
<!-- 功能入口。内容=②段 §三 里从 P4 出发的流转目标(P4→P5 成稿去发 / P4→P6 查账号口径)。 -->
<div class="sect-nav">
<div class="data-cards">
${card('p5','发布中心','展开成各平台版本,过闸后确认发出')}
${card('p6','账号画像','查这个账号的口径与长期记忆')}
</div>
</div>
<section class="block">
${head('当前内容', `<span class="meta">来自选题库</span>`)}
<div class="panel"><div class="row">
<div style="flex:1 1 260px;min-width:0">
<h3>${esc(CONTENT.title)}</h3>
<p class="meta">账号:${esc(ACCOUNTS[0].name)} · 当前版本 ${esc((S.versions.find(v=>v.cur)||S.versions[0]).id)}</p>
</div>
<button class="btn btn-sm" data-act="P4-07">版本列表 <span class="num">${S.versions.length}</span></button>
</div></div>
</section>
<section class="block">
${head('环节进度', `<span class="meta">第 ${S.step+1}/7 步 · 可回退</span>`)}
<div class="panel"><div class="steps">
${STEPS.map((s,i)=>`<button class="step ${i<S.step?'done':''} ${i===S.step?'current':''}" data-act="P4-01" data-i="${i}" ${i>S.step?'disabled':''}>${i+1} ${esc(s)}</button>`).join('')}
</div></div>
</section>
<!-- 当前环节工作区 | AI 产出与采纳 + 资产引用区 + 版本 并排
(照参考版 P4 的 .workgrid:干活的地方在左、机器产出与版本在右)。
⛔ 只动摆法 —— 四块在 DOM 里的先后与②段 §二 P4 的板块清单一致。 -->
<div class="block grid-main-side">
<section>
${head('当前环节工作区', `<span class="meta">${esc(STEPS[S.step])}</span>`)}
<div class="panel">
<label class="field-label" for="p4-in">这一步要产出什么</label>
<textarea class="textarea" id="p4-in" data-act="P4-02" rows="5" placeholder="写下这一步要往哪个方向走">${esc(out)}</textarea>
<div class="row" style="margin-top:var(--sp-16);justify-content:space-between">
<button class="btn" data-act="P4-03" ${out?'':'disabled'}>${S.stepBusy?'生成中…':'开始这一步'}</button>
<button class="btn btn-primary" data-act="P4-06">推进到「${esc(STEPS[Math.min(S.step+1,6)])}」</button>
</div>
${S.stepBusy?'<div class="skel-stack" style="margin-top:var(--sp-16)"><div class="skel skel-w90"></div><div class="skel skel-w70"></div></div>':''}
</div>
</section>
<div class="stack">
<section>
${head('AI 产出与采纳', `<span class="meta">${S.stepBusy?'生成中':(aiV==='adopted'?'已采纳':(aiV==='rejected'?'已驳回':(aiV==='rework'?'改后再用':'待裁决')))}</span>`)}
<div class="panel">
${S.stepBusy?`
<div class="row" style="justify-content:space-between">
<span class="meta">文案生成中,这一步已跑 ${(S.step+1)*6} 秒 · 可中断</span>
<button class="btn btn-sm" data-act="P4-12">中断生成</button>
</div>
<div class="skel-stack" style="margin-top:var(--sp-16)"><div class="skel skel-w90"></div><div class="skel skel-w70"></div></div>
`:aiOut?`
<label class="field-label">机器给的这一版(未采纳前不会进正文)</label>
<div class="notice ${aiV==='rejected'?'notice-warn':''}" style="margin-top:0">
<strong>${esc(aiOut)}</strong>
<span>${aiV==='adopted'?'已采纳,已并入这一步的产出。':(aiV==='rejected'?'已驳回,正文未改。':(aiV==='rework'?'已标记为改后再用,正文保留你改的版本。':'等你裁决:采纳 / 驳回 / 改后再用。'))}</span>
</div>
<div class="row" style="margin-top:var(--sp-16)">
<button class="btn ${aiV==='adopted'?'btn-primary':''}" data-act="P4-09" ${aiV==='adopted'?'disabled':''}>采纳</button>
<button class="btn" data-act="P4-10" ${aiV==='rejected'?'disabled':''}>驳回</button>
<button class="btn" data-act="P4-11">改后再用</button>
<span class="meta">⛔ 产出与人的编辑分区:采纳才进正文,驳回不改正文</span>
</div>
`:`<p class="meta">这一步还没有机器产出。在上面写下方向,点「开始这一步」。</p>`}
</div>
</section>
<section>
${head('资产引用区', `<span class="meta">${S.refs.length} 条已引用</span>`)}
<div class="panel">
${S.refs.length?`<ul class="list">${S.refs.map(r=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(r.text)}</strong><span class="meta">来自资产库 · ${esc(r.zone)}</span></span>
<span class="secondary-cell"></span>
<span class="action-cell"><button class="btn-micro" data-act="P4-05" data-a="${r.id}">移除引用</button></span>
</div></li>`).join('')}</ul>`:`<p class="meta">还没有引用资产。可以直接写,也可以从资产库引一条进来。</p>`}
<div class="row" style="margin-top:var(--sp-16)">
<button class="btn btn-sm" data-act="P4-04">从资产库拿一条</button>
</div>
</div>
</section>
<section>
${head('版本', `<button class="btn btn-ghost" data-act="P4-07">${S.versionOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.versionOpen?`<ul class="list">${S.versions.map(v=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(v.id)}</strong><span class="meta">${esc(v.name)}</span></span>
<span class="secondary-cell"><span>${esc(v.time)}</span></span>
<span class="action-cell">${v.cur?tag('当前版','tag-ok'):`<button class="btn-micro" data-act="P4-08" data-v="${v.id}">切为当前版</button>`}</span>
</div></li>`).join('')}</ul>`:`<p class="meta">共 ${S.versions.length} 个版本。展开可看每个版本改了什么、切回旧版。</p>`}
</div>
</section>
</div>
</div>`;
}
function pageP5(){
const targetAccts=ACCOUNTS.filter(a=>S.targets.accounts.includes(a.id));
const vers=targetAccts.flatMap(a=>S.targets.platforms.map(p=>({key:a.id+'|'+p,acct:a.name,platform:p})));
const hard=S.checkState==='hard', soft=S.checkState==='soft';
return `
${head('P5 发布中心','',1)}
<!-- 页壳照 P1 首页:统计条 → 功能入口卡 → 再进板块内容。 -->
<div class="home-stats">
<div class="stat-item"><span class="stat-value">${targetAccts.length}</span><span class="stat-label">目标账号</span></div>
<div class="stat-item"><span class="stat-value">${S.targets.platforms.length}</span><span class="stat-label">目标平台</span></div>
<div class="stat-item"><span class="stat-value">${vers.length}</span><span class="stat-label">本批版本</span></div>
<div class="stat-item"><span class="stat-value">${hard?1:0}</span><span class="stat-label">硬拦项</span></div>
</div>
<div class="sect-nav">
<div class="data-cards">
${card('p4','创作台','版本不够就回创作台接着推')}
${card('p8','记录与对账','发出去的每一条都留痕')}
</div>
</div>
<section class="block">
${head('目标选择', `<button class="btn btn-ghost" data-act="P5-01">${S.targetOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.targetOpen?`
<div class="field">
<p class="field-label">发给哪些账号</p>
<div class="seg">${ACCOUNTS.map(a=>`<button class="seg-item ${S.targets.accounts.includes(a.id)?'is-on':''}" data-act="P5-02" data-acct="${a.id}">${esc(a.name)}</button>`).join('')}</div>
</div>
<div class="field">
<p class="field-label">发给哪些平台</p>
<div class="seg">${['抖音','小红书','视频号','B 站'].map(p=>`<button class="seg-item ${S.targets.platforms.includes(p)?'is-on':''}" data-act="P5-03" data-p="${p}">${esc(p)}</button>`).join('')}</div>
</div>
`:`<p class="meta">这次发 ${targetAccts.length} 个账号 × ${S.targets.platforms.length} 个平台 = ${vers.length} 个版本。从创作台带版本进来时目标已定,这里是补选。</p>`}
</div>
</section>
<div class="block grid-main-side">
<section>
${head('各版本预览与逐版编辑', `<span class="meta">${vers.length} 版</span>`)}
<div class="panel">
<div class="seg" style="margin-bottom:var(--sp-16)">${vers.map(v=>`<button class="seg-item ${S.verSel===v.key?'is-on':''}" data-act="P5-04" data-v="${v.key}">${esc(v.acct)} / ${esc(v.platform)}</button>`).join('')}</div>
${vers.length?(()=>{ const cur=vers.find(v=>v.key===S.verSel)||vers[0]; return `
<p class="field-label" for="p5-edit">${esc(cur.acct)} · ${esc(cur.platform)}</p>
<textarea class="textarea" id="p5-edit" data-act="P5-05" rows="4">${esc(S.verDraft!==null?S.verDraft:('把这条内容按「'+cur.platform+'」的口径改一版:'+CONTENT.title))}</textarea>
<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-sm" data-act="P5-06" ${S.verDirty?'':'disabled'}>保存本版</button>${S.verDirty?tag('已改','tag-warn'):''}</div>
`})():'<p class="meta">还没有选目标账号。</p>'}
</div>
</section>
<section>
${head('检查结果', `<span class="meta">硬的拦住 · 软的只提醒</span>`)}
<div class="panel">
${S.checkState==='idle'?`<p class="meta">这一批还没过闸。</p>`:''}
${S.checkState==='checking'?`<div class="skel-stack"><div class="skel skel-w90"></div><div class="skel skel-w70"></div></div>`:''}
${hard?`<div class="notice notice-err"><strong>硬拦 1 处</strong><span>版本里出现了账号密钥(第 2 版第 3 行)。这一处不改完,不能发。</span></div>`:''}
${soft?`<div class="notice notice-warn"><strong>软劝 1 处</strong><span>这条的说话方式和这个账号平时的口径差得有点远。只是提醒,不拦你发。</span></div>`:''}
${S.checkState==='pass'?`<div class="notice"><strong>没有硬拦项</strong><span>两处软劝已看过。可以发。</span></div>`:''}
<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-sm" data-act="P5-07" ${S.checkState==='checking'?'disabled':''}>复检</button></div>
</div>
</section>
</div>
<section class="block">
${head('确认发布', `<span class="meta">这一步由人来点</span>`)}
<div class="panel"><div class="row" style="justify-content:space-between">
<span class="meta">${S.publishDone?'这一批已经登记进发布记录。':'发出后会在记录与对账里留一条,谁在什么时候确认的都查得到。'}</span>
<button class="btn btn-primary" data-act="P5-08" ${(hard||!vers.length||S.publishDone)?'disabled':''}>确认发布</button>
</div></div>
</section>
<section class="block">
${head('发布记录', `<button class="btn btn-ghost" data-act="P5-11">去记录与对账</button>`)}
<!-- 满宽表格(照 P1 首页):发过什么、谁确认的、什么状态,一眼扫完。 -->
<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">
<thead><tr><th>内容</th><th>账号</th><th>确认人</th><th class="num-col">时间</th><th>状态</th></tr></thead>
<tbody>${RECORDS.map(r=>`
<tr>
<td class="cell-lead">${esc(r.text)}</td>
<td>${esc(r.acct)}</td>
<td>${esc(r.by)}</td>
<td class="num-col">${esc(r.time)}</td>
<td>${r.state==='已发布'?tag('已发布','tag-ok'):esc(r.state)}</td>
</tr>`).join('')}</tbody>
</table></div></div>
</section>`;
}
function pageP6(){
const acc=ACCOUNTS.find(a=>a.id===S.account)||ACCOUNTS[0];
const p=PROFILES[acc.id]||{定位:'',风格:'',受众:'',平台:'',偏好:'',记忆:[]};
const fields=[['定位','定位'],['风格','风格'],['受众','受众'],['平台','平台'],['偏好','偏好与红线']];
return `
${head('P6 账号画像','',1)}
<!-- 页壳照 P1 首页:统计条 → 功能入口卡 → 再进板块内容。 -->
<div class="home-stats">
<div class="stat-item"><span class="stat-value">${ACCOUNTS.length}</span><span class="stat-label">在管账号</span></div>
<div class="stat-item"><span class="stat-value">${ACCOUNTS.filter(a=>PROFILES[a.id]).length}</span><span class="stat-label">已建画像</span></div>
<div class="stat-item"><span class="stat-value">${ACCOUNTS.reduce((n,a)=>n+(((PROFILES[a.id]||{}).记忆||[]).length),0)}</span><span class="stat-label">长期记忆</span></div>
<div class="stat-item"><span class="stat-value">${S.topics.length}</span><span class="stat-label">关联选题</span></div>
</div>
<!-- 功能入口。账号画像这一块只有一页 ⇒ 卡片给的是②段 §三 里从 P6 出发的流转目标(P6→P4 / P6→P5)。 -->
<div class="sect-nav">
<div class="data-cards">
${card('p4','创作台','画像保存后回到创作')}
${card('p5','发布中心','画像保存后回到发布')}
</div>
</div>
<!-- 主数据块=**满宽表格**(照 P1 首页):账号一行一个,点行名切画像。 -->
<section class="block">
${head('账号列表', `<span class="meta">${ACCOUNTS.length} 个</span>`)}
<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">
<thead><tr><th>账号</th><th>平台</th><th class="num-col">内容数</th><th>今日</th><th class="cell-act"></th></tr></thead>
<tbody>${ACCOUNTS.map(a=>`
<tr>
<td class="cell-lead"><span class="cell-name ${a.id===acc.id?'is-on':''}" data-act="P6-01" data-acct="${a.id}" role="button" tabindex="0">${esc(a.name)}</span></td>
<td>${esc(a.platform)}</td>
<td class="num-col">${a.posts}</td>
<td>${esc(a.today)}</td>
<td class="cell-act">${a.anom?tag('异动','tag-warn'):''}</td>
</tr>`).join('')}</tbody>
</table></div></div>
</section>
<section class="block">
${head('画像六维', `<span class="meta">${esc(acc.name)}</span>`)}
<div class="panel">
${fields.map(([k,label])=>`
<div class="field">
<label class="field-label" for="p6-${k}">${esc(label)}</label>
<input class="input" id="p6-${k}" data-act="P6-02" data-k="${k}" value="${esc(p[k]||'')}">
</div>`).join('')}
<div>
<p class="field-label">长期记忆</p>
<p class="meta">${(p.记忆||[]).length} 条 · 只追加,不覆盖历史结论</p>
</div>
<div class="row" style="margin-top:var(--sp-16);justify-content:space-between">
<span class="meta">${S.profileDirty?'有改动没保存':'没有改动'}</span>
<button class="btn btn-primary" data-act="P6-03" ${S.profileDirty?'':'disabled'}>保存画像</button>
</div>
</div>
</section>
<!-- 记忆条目 | 关联内容 并排(照参考版 P6 的第二处 .grid.cols2)。⛔ 只动摆法。 -->
<div class="block grid-main-side">
<section>
${head('记忆条目', `<button class="btn btn-ghost" data-act="P6-04">${S.memoryOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.memoryOpen?((p.记忆&&p.记忆.length)?`<ul class="list-plain">${p.记忆.map(m=>`<li>${esc(m)}</li>`).join('')}</ul>`:`<p class="meta">这个账号还没有记忆条目。复盘回写会往这里加。</p>`):`<p class="meta">记忆是复盘回写的产物,不是编辑画像的输入。</p>`}
</div>
</section>
<section>
${head('关联内容', '')}
<div class="panel">
${(S.topics.filter(t=>t.src.includes(acc.name)||true).slice(0,3).length)?`<ul class="list">${S.topics.slice(0,3).map(t=>`
<li><div class="list-row" data-act="P6-05" data-k="${t.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(t.text)}</strong><span class="meta">来源:${esc(t.src)}</span></span>
<span class="secondary-cell"><span>${esc(t.state)}</span></span>
<span class="action-cell">${tag('去创作台','')}</span>
</div></li>`).join('')}</ul>`:`<p class="meta">这个账号还没有做过内容。</p>`}
</div>
</section>
</div>`;
}
function pageP7(){
const r=REVIEWS.find(x=>x.id===S.reviewSel)||REVIEWS[0];
const acc=ACCOUNTS.find(a=>a.name===r.acct)||ACCOUNTS[0];
const rv=S.reviewVerdict||'';
return `
${head('P7 复盘台','',1)}
<!-- 页壳照 P1 首页:统计条 → 功能入口卡 → 主数据表 → 两栏 → 尾块。 -->
<div class="home-stats">
<div class="stat-item"><span class="stat-value">${REVIEWS.length}</span><span class="stat-label">待复盘</span></div>
<div class="stat-item"><span class="stat-value">${REVIEWS.filter(x=>x.data==='到了').length}</span><span class="stat-label">数据已回</span></div>
<div class="stat-item"><span class="stat-value">${S.wroteBack?1:0}</span><span class="stat-label">本次已回写</span></div>
<div class="stat-item"><span class="stat-value">${ACCOUNTS.reduce((n,a)=>n+(((PROFILES[a.id]||{}).记忆||[]).length),0)}</span><span class="stat-label">画像记忆</span></div>
</div>
<div class="sect-nav">
<div class="data-cards">
${card('p6','账号画像','回写落点,这条经验归到哪个号')}
${card('p1','矩阵总览','回到今天该先处理哪件事')}
</div>
</div>
<!-- 主数据块=**满宽表格**(照 P1 首页):一条一行,点行名换这一条的表现数据。 -->
<section class="block">
${head('待复盘项', `<span class="meta">${REVIEWS.length} 条</span>`)}
<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">
<thead><tr><th>内容</th><th>账号</th><th class="num-col">发布</th><th>数据</th><th class="num-col">播放</th></tr></thead>
<tbody>${REVIEWS.map(x=>`
<tr>
<td class="cell-lead"><span class="cell-name ${x.id===r.id?'is-on':''}" data-act="P7-01" data-r="${x.id}" role="button" tabindex="0">${esc(x.text)}</span></td>
<td>${esc(x.acct)}</td>
<td class="num-col">${esc(x.day)}</td>
<td>${x.data==='到了'?tag('可复盘','tag-ok'):tag('等数据','tag-warn')}</td>
<td class="num-col">${esc(x.play)}</td>
</tr>`).join('')}</tbody>
</table></div></div>
</section>
<!-- 表现数据 + 归因结论 + 确认与改判 | 回写目标 并排(照参考版 P7 的 .grid.cols2:左=看数据与写结论,右=回写到哪)。
⛔ 只动摆法 —— 六块在 DOM 里的先后与②段 §二 P7 的板块清单一致(待复盘项 → 表现数据 → 归因结论 → 确认与改判 → 回写目标 → 历史复盘)。 -->
<div class="block grid-main-side">
<div class="stack">
<section>
${head('这一条的表现数据', `<span class="meta">${esc(r.day)} 回读</span>`)}
<div class="panel">
${r.data==='到了'?`
<!-- 三个数**直接用 P1 首页那套统计格**(.home-stats / .stat-item):
原来这里是 .grid-cards + 内联 style="font-size:var(--fs-20)" 硬把字号顶上去,
既没进令牌也没进组件表 —— 同一个「值 20px/600 + 标签 12px」的画法全站应当只有一套。 -->
<div class="home-stats" style="grid-template-columns:repeat(3,minmax(0,1fr))">
<div class="stat-item"><span class="stat-value">${esc(r.play)}</span><span class="stat-label">播放</span></div>
<div class="stat-item"><span class="stat-value">${esc(r.fin)}</span><span class="stat-label">完播</span></div>
<div class="stat-item"><span class="stat-value">${esc(r.tri)}</span><span class="stat-label">三连率</span></div>
</div>
`:`<div class="notice notice-warn"><strong>数据还没回来</strong><span>可以先手工录这条的表现,归因照做,不用等。</span></div>
<button class="btn btn-sm" data-act="P7-02">手工录入表现</button>`}
</div>
</section>
</div>
<div class="stack">
<section>
${head('归因结论', `<span class="meta">由 AI 给出,可编辑 —— ${rv==='adopted'?'已采纳':(rv==='rejected'?'已驳回':(rv==='rework'?'改后再用':'待裁决'))}</span>`)}
<div class="panel">
<label class="field-label" for="p7-attr">这条为什么是这个结果</label>
<textarea class="textarea" id="p7-attr" data-act="P7-03" rows="4" placeholder="机器先给一版,你可以改。改完要在下面裁决。">${esc(S.attrDraft||r.note)}</textarea>
</div>
</section>
<section>
${head('确认与改判', `<span class="meta">采纳 / 驳回 / 改后再用</span>`)}
<div class="panel"><div class="row">
<button class="btn ${rv==='adopted'?'btn-primary':''}" data-act="P7-06" ${rv==='adopted'?'disabled':''}>采纳</button>
<button class="btn" data-act="P7-07" ${rv==='rejected'?'disabled':''}>驳回</button>
<button class="btn" data-act="P7-08">改后再用</button>
<span class="meta">⛔ 回写按上面编辑后的结论走 —— 裁决是留痕依据,不是回写的前置开关</span>
</div></div>
</section>
<section>
${head('回写目标', `<span class="meta">这次回写到哪个账号</span>`)}
<div class="panel"><div class="row" style="justify-content:space-between">
<div style="flex:1 1 240px;min-width:0">
<h3>${esc(acc.name)}</h3>
<p class="meta">${S.wroteBack?'已回写一条记忆到这份画像。':'回写会往这份画像的记忆里加一条,不改历史结论。'}</p>
</div>
<button class="btn btn-primary" data-act="P7-04" ${(S.attrDraft||r.note)?'':'disabled'}>确认归因并回写</button>
</div></div>
</section>
</div>
</div>
<section class="block">
${head('历史复盘', `<button class="btn btn-ghost" data-act="P7-05">${S.historyOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.historyOpen?`<ul class="list-plain">
<li>10-06 · 深夜食堂笔记:45 秒以下完播明显更好</li>
<li>10-05 · 阿元的下班饭:步骤图带起收藏率</li>
<li>10-04 · 校门口那家店:转发集中在"开店多少年"这句</li>
</ul>`:`<p class="meta">当前这次复盘不受历史影响,但写的时候可以参照。</p>`}
</div>
</section>`;
}
function pageP8(){
const r=RECORDS.find(x=>x.id===S.recordSel)||RECORDS[0];
return `
${head('P8 记录与对账','',1)}
<!-- 页壳照 P1 首页:统计条 → 功能入口卡 → 主数据表 → 两栏 → 尾块。 -->
<div class="home-stats">
<div class="stat-item"><span class="stat-value">${RECORDS.length}</span><span class="stat-label">记录总数</span></div>
<div class="stat-item"><span class="stat-value">${RECORDS.filter(x=>x.state==='已发布').length}</span><span class="stat-label">已发布</span></div>
<div class="stat-item"><span class="stat-value">${RECORDS.filter(x=>x.settle==='待结算').length}</span><span class="stat-label">待结算</span></div>
<div class="stat-item"><span class="stat-value">${S.checkedOut?1:0}</span><span class="stat-label">本次已核销</span></div>
</div>
<div class="sect-nav">
<div class="data-cards">
${card('p5','发布中心','记录是从那里发出来的')}
${card('p7','复盘台','这一条的表现读回来写成经验')}
</div>
</div>
<!-- 主数据块=**满宽表格**(照 P1 首页):一条记录一行,点行名看它的对账详情。 -->
<section class="block">
${head('发布记录列表', `<span class="meta">${RECORDS.length} 条 · 只追加不改写</span>`)}
<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">
<thead><tr><th>内容</th><th>账号</th><th>确认人</th><th class="num-col">时间</th><th>版本</th><th>状态</th></tr></thead>
<tbody>${RECORDS.map(x=>`
<tr>
<td class="cell-lead"><span class="cell-name ${x.id===r.id?'is-on':''}" data-act="P8-01" data-rec="${x.id}" role="button" tabindex="0">${esc(x.text)}</span></td>
<td>${esc(x.acct)}</td>
<td>${esc(x.by)}</td>
<td class="num-col">${esc(x.time)}</td>
<td>${esc(x.ver)}</td>
<td>${x.state==='已发布'?tag('可核销',''):tag(x.state,'tag-warn')}</td>
</tr>`).join('')}</tbody>
</table></div></div>
</section>
<!-- 单条溯源 | 对账核对区 并排(照参考版 P8 的 .grid.cols2:左边查来路、右边三样对账)。
⛔ 只动摆法 —— 四块在 DOM 里的先后与②段 §二 P8 的板块清单一致(发布记录 → 单条溯源 → 对账核对区 → 商单与收支)。 -->
<div class="block grid-main-side">
<section>
${head('单条溯源', `<button class="btn btn-ghost" data-act="P8-02">${S.traceOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.traceOpen?`
<ul class="list-plain">
<li>内容:${esc(r.text)}</li>
<li>版本:${esc(r.ver)}(当时发布用的就是这一版,之后改稿不影响它)</li>
<li>确认人:${esc(r.by)} · ${esc(r.time)}</li>
<li>检查留痕:硬拦 0 · 软劝 1(10-08 09:40)</li>
</ul>`:`<p class="meta">核销看列表就够;要深究这条发的是哪一版,点开这里。</p>`}
</div>
</section>
<section>
${head('对账核对区', `<span class="meta">内容 · 发布记录 · 收支 三样对得上</span>`)}
<div class="panel">
<!-- 核对表用的也是那一套表组件(.tbl-side):⛔ 不再用宽松档 .ds-table ——
全站表格只留「紧密档(满宽主数据)」与「栏内档(两栏里的表)」两种,行高与内衬口径一致。 -->
<div class="table-wrap"><table class="ds-table tbl-side">
<thead><tr><th>项</th><th>值</th><th>对得上</th></tr></thead>
<tbody>
<tr><td>内容</td><td>${esc(r.text)}</td><td>是</td></tr>
<tr><td>发布记录</td><td>${esc(r.by)} · ${esc(r.time)} · ${esc(r.ver)}</td><td>${r.state==='已发布'?'是':'还没有确认记录'}</td></tr>
<tr><td>收支</td><td class="num">${esc(r.amount)}</td><td>${r.amount==='—'?'没有商单':'与记录一致'}</td></tr>
</tbody>
</table></div>
<div class="row" style="margin-top:var(--sp-16);justify-content:space-between">
<span class="meta">${S.checkedOut?'这条已经核销。':'核销代表这三样你核过了。'}</span>
<button class="btn btn-primary" data-act="P8-03" ${(S.checkedOut||r.state!=='已发布')?'disabled':''}>核销这条记录</button>
</div>
</div>
</section>
</div>
<section class="block">
${head('商单与收支', `${ASSUME_TAG}<button class="btn btn-ghost" data-act="P8-04">${S.billingOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.billingOpen?`
<div class="notice notice-warn"><strong>本块是假设</strong><span>商单对账进不进第一版、收支口径是不是内容级,都还没有结论。这里按「进第一版、口径按内容级」画。</span></div>
<ul class="list-plain">
<li>${esc(r.text)} · 商单金额 <span class="num">${esc(r.amount)}</span> · ${esc(r.settle)}</li>
<li>深夜食堂笔记 · 10-08 · <span class="num">¥ 3 200</span> · 待结算</li>
<li>咖啡因实验室 · 10-07 · <span class="num">¥ 1 800</span> · 已结算</li>
</ul>`:`<p class="meta">一块 P2 的收支记录,可整块没有。</p>`}
</div>
</section>`;
}
/* ---------- 页面骨架渲染 ---------- */
const RENDER={p1:pageP1,p2:pageP2,p3:pageP3,p4:pageP4,p5:pageP5,p6:pageP6,p7:pageP7,p8:pageP8};
/* ---------- 页面级状态面板(逐页不同的话术) ---------- */
function statePanel(page){
const st=S.pageState; const c=(STATE_COPY[page]||{})[st];
if(!c||!c[1]) return '';
const [title,desc,to]=c;
const icons={loading:'',empty:'',error:'',disabled:'',noperm:''};
let body='';
if(st==='loading') body=`<div class="skel-stack" style="max-width:520px;margin:0 auto var(--sp-24)"><div class="skel skel-w90"></div><div class="skel skel-w70"></div><div class="skel skel-w45"></div></div>`;
if(st==='empty'&&to) body=`<button class="btn btn-primary" data-act="STATE-GO" data-to="${to}">去${esc(PAGES[to])}</button>`;
if(st==='error') body=`<button class="btn btn-sm" data-act="STATE-RETRY">重试一次</button>`;
if(st==='disabled') body=`<span class="tag tag-warn">条件未满足</span>`;
if(st==='noperm') body=`<span class="tag tag-warn">角色限制</span>`;
return `<section class="block"><div class="state-panel"><h2>${esc(title)}</h2><p>${esc(desc)}</p>${icons[st]}${body}</div></section>`;
}
/* ---------- 浮层 ---------- */
function layerHTML(){
const L=S.layer; if(!L) return '';
const close=`<button class="layer-close" data-act="X-close" aria-label="关闭">×</button>`;
if(L==='quicknote') return `<div class="layer">
<div class="layer-head"><h2>随手记一条</h2>${close}</div>
<label class="field-label" for="note">想到了什么</label>
<input class="input" id="note" data-act="R-01" value="${esc(S.noteDraft)}" placeholder="一句话就够,先进待做">
<div class="layer-actions">
<button class="btn" data-act="R-03">取消</button>
<button class="btn btn-primary" data-act="R-02" ${S.noteDraft?'':'disabled'}>保存进待做</button>
</div></div>`;
if(L==='search'){
const q=S.searchQ.trim();
const hits=q?[
{t:'选题 · '+ (S.topics.find(k=>k.text.includes(q))?.text||q),to:'p2'},
{t:'资产 · '+(S.assets.find(a=>a.text.includes(q))?.text||q),to:'p3'},
{t:'记录 · '+(RECORDS.find(r=>r.text.includes(q))?.text||q),to:'p8'}
]:[];
return `<div class="layer">
<div class="layer-head"><h2>全局检索</h2>${close}</div>
<label class="field-label" for="q">找选题、资产、记录</label>
<input class="input" id="q" data-act="S-01" value="${esc(S.searchQ)}" placeholder="打两个字就开始筛">
<div style="margin-top:var(--sp-16)">
${q?(hits.length?`<ul class="list">${hits.map(h=>`<li><div class="list-row" data-act="S-02" data-to="${h.to}" role="button" tabindex="0"><span class="primary-cell"><strong>${esc(h.t)}</strong></span><span class="secondary-cell"><span>去 ${esc(PAGES[h.to])}</span></span><span class="action-cell"></span></div></li>`).join('')}</ul>`:`<p class="meta">没有对上的条目。</p>`):`<p class="meta">输入关键字,结果按选题、资产、记录三类给。</p>`}
</div></div>`;
}
if(L==='assume') return `<div class="layer">
<div class="layer-head"><h2>两处还没结论的假设</h2>${close}</div>
<ul class="list-plain">
<li><strong>假设 1 · P1 的形态</strong><br>矩阵级视图是否真需要,还没有结论。这一版按「需要」画:P1 是矩阵总览,5 个板块都在。若结论是「不需要」,P1 降为单账号今日,只留今日待处理与本月最好,矩阵列表下移到账号切换。<br><span class="meta">复核人:主会话转用户 · 时机:②段收口时过 · 落点:2b §四 与 §二 P1</span></li>
<li><strong>假设 2 · P8 的第 4 板块</strong><br>商单对账进不进第一版、收支口径是不是内容级,还没有结论。这一版按「进第一版、口径按内容级」画,整块可缺。<br><span class="meta">复核人:主会话转用户 · 时机:同上 · 落点:2b §四 与 §二 P8</span></li>
</ul></div>`;
if(L==='versions') return `<div class="layer">
<div class="layer-head"><h2>版本列表</h2>${close}</div>
<ul class="list">${S.versions.map(v=>`<li><div class="list-row">
<span class="primary-cell"><strong>${esc(v.id)}</strong><span class="meta">${esc(v.name)}</span></span>
<span class="secondary-cell"><span>${esc(v.time)}</span></span>
<span class="action-cell">${v.cur?tag('当前版','tag-ok'):`<button class="btn-micro" data-act="P4-08" data-v="${v.id}">切为当前版</button>`}</span>
</div></li>`).join('')}</ul></div>`;
if(L==='assetpick') return `<div class="layer">
<div class="layer-head"><h2>从资产库拿一条</h2>${close}</div>
<ul class="list">${S.assets.filter(a=>a.state==='已入库').map(a=>`<li><div class="list-row">
<span class="primary-cell"><strong>${esc(a.text)}</strong><span class="meta">${esc(a.zone)} · 出处:${esc(a.src)}</span></span>
<span class="secondary-cell"></span>
<span class="action-cell"><button class="btn btn-sm" data-act="PICK-ASSET" data-a="${a.id}">引用</button></span>
</div></li>`).join('')}</ul></div>`;
if(L==='confirm') return `<div class="layer">
<div class="layer-head"><h2>确认发出这一批</h2>${close}</div>
<p>${S.targets.accounts.length} 个账号 × ${S.targets.platforms.length} 个平台。发出后会写进发布记录,谁在什么时候确认的都查得到。</p>
<div class="layer-actions">
<button class="btn" data-act="P5-10">取消</button>
<button class="btn btn-primary" data-act="P5-09">确认发出</button>
</div></div>`;
if(L==='checkout') return `<div class="layer">
<div class="layer-head"><h2>核销这条记录</h2>${close}</div>
<p>核销代表内容、发布记录、收支三样你都核过了。之后要改,得二次确认。</p>
<div class="layer-actions">
<button class="btn" data-act="CHECKOUT-CANCEL">取消</button>
<button class="btn btn-primary" data-act="CHECKOUT-OK">确认核销</button>
</div></div>`;
return '';
}
/* ---------- 绘制 ---------- */
const ST={ lock:false }; // 自检期间锁住异步重绘,防自检结果被覆盖
function paint(){
if(ST.lock) return;
document.title=(S.route==='selftest' ? '自检' : (PAGES[S.route]||''))+' · MCN 短视频整合营销工作台';
renderNav();
/* 面包屑与「收起侧栏」已删(顶栏收成「产品名 + 随手记」两项)⇒ 这里不再有 renderCrumbs()、
也不再有 side-collapsed 的切换。页面身份靠页头 h1(它本来就写着②段的页面全名)。 */
/* 🔴 手机形态(≤640px)里导航是一条横滚的标签栏,当前项很容易滚在视野之外 ——
于是「我现在在哪一页」在窄屏上就丢了(独立评审点出来的)。
这里只动 `.sidebar` 自己的 scrollLeft,⛔ 绝不碰 window:一碰,
自检里那条「量尺寸前页面停在滚动零点」的守卫就会误报红。 */
const sb=document.querySelector('.sidebar'), actNav=document.querySelector('#nav .nav-item.is-active');
if(sb && actNav && getComputedStyle(sb).flexDirection==='row'){
const sr=sb.getBoundingClientRect(), ar=actNav.getBoundingClientRect();
if(ar.left<sr.left || ar.right>sr.right) sb.scrollLeft += Math.round(ar.left-sr.left)-8;
}
const v=$('#view'); if(S.route==='selftest'){ v.innerHTML=renderSelftest(); return; }
/* ⚠️ 这里⛔ 不再另写一句「这里是 X 的 empty 态」——那是给做原型的人看的黑话,
而上面 statePanel() 已经把这一页这个状态的用户向说明铺好了(STATE_COPY 逐页不同)。
非正常态怎么进来:**路由**(`#/p/p4/empty` 直接打开),⛔ 不再有顶栏演示下拉;
退出走登记在册的 STATE-NORMAL。 */
const pageHTML = (S.pageState==='ok') ? RENDER[S.route]() : statePanel(S.route)+`<section class="block"><div class="panel"><div class="row"><span class="meta">切回正常内容继续看这一页。</span><button class="btn btn-sm" data-act="STATE-NORMAL">回到正常内容</button></div></div></section>`;
v.innerHTML = pageHTML;
/* 浮层:先铺遮罩、再放层本体。⛔ 遮罩不要做成 #layer-root 的样式 —— 提示条 .toast 也渲染在
这个容器里(它自己是 fixed),把容器整块变成全屏遮罩会把提示条一起卷进去。 */
const lh=layerHTML();
$('#layer-root').innerHTML = lh ? `<div class="layer-mask" data-act="X-mask" aria-hidden="true"></div>`+lh : '';
if(S.toast){ $('#layer-root').insertAdjacentHTML('beforeend', `<div class="toast" role="status">${esc(S.toast)}</div>`); }
/* 层一开就把焦点收进层里,否则键盘用户还停在底下的页面上。 */
if(S.layer && !$('#layer-root').contains(document.activeElement)){
const f=layerFocusable()[0]; if(f) f.focus();
}
}
/* ============================ 交互清单(唯一真相源) ============================ */
/* data-act 值即 DESIGN.md §2.4 的 ID。下面每个键都必须有实现,否则自检报红。 */
const IMPL = {
/* 外壳 */
/* G-01…G-04 撤销:侧栏扁平后没有「一级组」这一层,八个功能项统一走 G-05。 */
'G-05':e=>{ const p=e.target.closest('[data-page]'); if(p) go(p.dataset.page); }, /* 侧栏功能项(八项)+ 各页「功能入口」卡 */
'G-06':()=>{ S.layer='quicknote'; paint(); setTimeout(()=>{const n=$('#note'); if(n) n.focus();},0); },
'G-08':()=>{ S.layer='assume'; paint(); }, /* 两处「假设」徽章(P1 页头 / P8 商单与收支块头) */
'G-10':()=>{ S.layer='search'; paint(); setTimeout(()=>{const q=$('#q'); if(q) q.focus();},0); },
/* G-07 / G-09 / G-11 撤销(2026-10-08,用户口径「页面顶上弄这么多乱七八糟的东西」):
· G-07 顶栏账号切换器 —— 无来源,我造的原型件。
· G-09 顶栏演示状态开关 —— 规则只要求「状态由路由 + 演示开关驱动」,路由那条已完整覆盖。
· G-11 收起侧栏 —— 来源是本项目参照物笔记,⛔ 无技能依据;侧栏已是 64px 药丸浮栏。
⛔ 撤销这三个 ID 的同时必须把顶栏那三件从 DOM 里删掉,否则判据「页面上每个 data-act 都在清单里」会报红。 */
'X-close':()=>closeLayer(),
'CHECKOUT-CANCEL':()=>closeLayer(),
/* 点遮罩(层外空白)也关层。这是产品的第二条出口,不是装饰元素。 */
'X-mask':()=>closeLayer(),
'R-01':null,'S-01':null, /* 输入框 */
'R-02':()=>{ if(!S.noteDraft) return; S.topics.unshift({id:'n'+Date.now(),text:S.noteDraft,group:'todo',src:'灵感速记',state:'待做'});
S.layer=null; S.noteDraft=''; paint(); toast('已进选题库待做'); },
'R-03':()=>{ S.layer=null; S.noteDraft=''; paint(); },
'S-02':e=>{ const to=e.target.closest('[data-to]').dataset.to; S.layer=null; S.searchQ=''; go(to); },
'STATE-NORMAL':()=>{ S.pageState='ok'; if(S.route==='selftest'){ location.hash='#/p/p1/ok'; return; } go(S.route,'ok'); },
'STATE-RETRY':()=>{ toast('已重试一次'); },
'STATE-GO':e=>{ go(e.target.closest('[data-to]').dataset.to); },
/* P1 */
'P1-01':e=>{ go(e.target.closest('[data-to]').dataset.to); },
'P1-02':e=>{ S.account=e.target.closest('[data-acct]').dataset.acct; S.layer=null; go('p6'); },
'P1-03':e=>{ const a=e.target.closest('[data-acct]').dataset.acct; S.account=a; go('p7'); },
'P1-04':()=>{ S.benchFilter='异常值'; go('p2'); },
'P1-05':()=>{ S.reviewSel='r2'; go('p7'); },
/* P2 */
'P2-01':()=>{ S.benchFilter='低粉爆款'; paint(); },
'P2-02':()=>{ S.benchFilter='异常值'; paint(); },
'P2-03':e=>{ S.assetZone='structure'; S.assetSel='as3'; go('p3'); },
'P2-04':()=>{ S.commentOpen=!S.commentOpen; paint(); },
'P2-05':()=>{ S.commentState='fetching'; paint(); setTimeout(()=>{ S.commentState='done'; paint(); },420); },
'P2-06':e=>{ const c=e.target.closest('[data-c]').dataset.c; const it=S.candidates.find(x=>x.id===c); it.state='adopted';
S.topics.unshift({id:'c'+c,text:it.text,group:'todo',src:'评论区洞察',state:'待做'}); paint(); toast('已进选题库待做'); },
'P2-07':e=>{ const c=e.target.closest('[data-c]').dataset.c; S.candidates.find(x=>x.id===c).state='ignored'; paint(); },
'P2-08':e=>{ S.topicGroup=e.target.closest('[data-group]').dataset.group; paint(); },
'P2-09':e=>{ const k=e.target.closest('[data-k]').dataset.k; const t=S.topics.find(x=>x.id===k); t.group='writing'; t.state='在写'; go('p4'); },
'P2-10':()=>{ S.assetZone='angle'; go('p3'); },
/* P3 */
'P3-01':e=>{ S.assetZone=e.target.closest('[data-zone]').dataset.zone; const l=S.assets.filter(a=>a.zone===S.assetZone); if(l[0]) S.assetSel=l[0].id; paint(); },
'P3-02':e=>{ S.assetSel=e.target.closest('[data-a]').dataset.a; paint(); },
'P3-03':e=>{ const a=e.target.closest('[data-a]').dataset.a; const it=S.assets.find(x=>x.id===a); it.state='已入库'; it.draft=false; paint(); toast('已入库'); },
'P3-04':e=>{ const a=e.target.closest('[data-a]').dataset.a; const it=S.assets.find(x=>x.id===a); if(!S.refs.some(r=>r.id===a)) S.refs.push({id:a,text:it.text,zone:it.zone}); go('p4'); },
/* P4 */
'P4-01':e=>{ S.step=+e.target.closest('[data-i]').dataset.i; paint(); },
'P4-02':null,
/* ⛔ 生成**只写 aiOut、不写正文 stepOut**:产出与人的编辑必须分区,
采纳才并入正文(layouts-tooling §4.7)。旧写法直接写正文 ⇒ 说不清哪句是机器写的。 */
'P4-03':()=>{ S.stepBusy=true; paint(); setTimeout(()=>{ S.stepBusy=false; S.aiOut[S.step]=(S.aiOut||{})[S.step]||'机器出的一版:' + STEPS[S.step] + '方向,等你裁决。'; S.aiVerdict[S.step]=''; paint(); },420); },
'P4-09':()=>{ S.aiVerdict[S.step]='adopted'; S.stepOut[S.step]=((S.aiOut||{})[S.step]||''); paint(); toast('已采纳,并入这一步的产出'); },
'P4-10':()=>{ S.aiVerdict[S.step]='rejected'; paint(); toast('已驳回,正文未改'); },
'P4-11':()=>{ S.aiVerdict[S.step]='rework'; paint(); toast('已标记改后再用,正文保留你改的版本'); },
'P4-12':()=>{ S.stepBusy=false; paint(); toast('已中断这一步的生成'); },
'P4-04':()=>{ S.layer='assetpick'; paint(); },
'P4-05':e=>{ const a=e.target.closest('[data-a]').dataset.a; S.refs=S.refs.filter(r=>r.id!==a); paint(); },
'P4-06':()=>{ if(S.step<6){ S.step++; if(!S.stepOut[S.step]) S.stepOut[S.step]=''; paint(); toast('已推进到「'+STEPS[S.step]+'」'); } },
'P4-07':()=>{ if(S.layer==='versions'){ S.layer=null; paint(); } else { S.layer='versions'; paint(); } },
'P4-08':e=>{ const v=e.target.closest('[data-v]').dataset.v; S.versions.forEach(x=>x.cur=(x.id===v)); paint(); toast('当前版切成 '+v); },
'PICK-ASSET':e=>{ const a=e.target.closest('[data-a]').dataset.a; const it=S.assets.find(x=>x.id===a); if(!S.refs.some(r=>r.id===a)) S.refs.push({id:a,text:it.text,zone:it.zone}); S.layer=null; paint(); toast('已引用 '+a); },
/* P5 */
'P5-01':()=>{ S.targetOpen=!S.targetOpen; paint(); },
'P5-02':e=>{ const a=e.target.closest('[data-acct]').dataset.acct; const i=S.targets.accounts.indexOf(a);
if(i>=0) S.targets.accounts.splice(i,1); else S.targets.accounts.push(a);
if(!S.targets.accounts.length){ const o=ACCOUNTS.find(x=>x.id!==a); S.targets.accounts.push(o.id); }
paint(); },
'P5-03':e=>{ const p=e.target.closest('[data-p]').dataset.p; const i=S.targets.platforms.indexOf(p);
if(i>=0) S.targets.platforms.splice(i,1); else S.targets.platforms.push(p);
if(!S.targets.platforms.length) S.targets.platforms.push(p==='抖音'?'小红书':'抖音');
paint(); },
'P5-04':e=>{ S.verSel=e.target.closest('[data-v]').dataset.v; S.verDraft=null; S.verDirty=false; paint(); },
'P5-05':null,
'P5-06':()=>{ S.verDirty=false; S.checkState='idle'; paint(); toast('本版已保存,检查结果要重跑'); },
'P5-07':()=>{ S.checkState='checking'; paint(); setTimeout(()=>{ S.checkState='pass'; paint(); },420); },
'P5-08':()=>{ S.layer='confirm'; paint(); },
'P5-09':()=>{ S.layer=null; S.publishDone=true; paint(); toast('已登记一条发布记录'); },
'P5-10':()=>{ S.layer=null; paint(); },
'P5-11':()=>go('p8'),
/* P6 */
'P6-01':e=>{ S.account=e.target.closest('[data-acct]').dataset.acct; S.profileDirty=false; paint(); },
'P6-02':null,
'P6-03':()=>{ S.profileDirty=false; paint(); toast('画像已保存'); },
'P6-04':()=>{ S.memoryOpen=!S.memoryOpen; paint(); },
'P6-05':()=>go('p4'),
/* P7 */
'P7-01':e=>{ S.reviewSel=e.target.closest('[data-r]').dataset.r; S.wroteBack=false; paint(); },
'P7-02':()=>{ toast('已切到手工录入'); },
'P7-03':null,
'P7-04':()=>{ S.wroteBack=true; const r=REVIEWS.find(x=>x.id===S.reviewSel); const acc=ACCOUNTS.find(a=>a.name===r.acct);
if(acc&&PROFILES[acc.id]) PROFILES[acc.id].记忆.push('10-08 '+r.text+':'+(S.attrDraft||r.note)); paint(); toast('已回写到 '+r.acct); },
'P7-05':()=>{ S.historyOpen=!S.historyOpen; paint(); },
/* 2026-10-08 新增:归因结论的裁决(②段 P7 拆出「确认与改判」块)。⛔ 不改回写按钮的可用条件,避免动既有自检场景。 */
'P7-06':()=>{ S.reviewVerdict='adopted'; paint(); toast('已采纳这条归因'); },
'P7-07':()=>{ S.reviewVerdict='rejected'; paint(); toast('已驳回这条归因,回写会按你改后的结论走'); },
'P7-08':()=>{ S.reviewVerdict='rework'; paint(); toast('已标记改后再用'); },
/* P8 */
'P8-01':e=>{ S.recordSel=e.target.closest('[data-rec]').dataset.rec; S.checkedOut=false; paint(); },
'P8-02':()=>{ S.traceOpen=!S.traceOpen; paint(); },
'P8-03':()=>{ S.layer='checkout'; paint(); },
'P8-04':()=>{ S.billingOpen=!S.billingOpen; paint(); },
'CHECKOUT-OK':()=>{ S.layer=null; S.checkedOut=true; paint(); toast('这条已核销'); }
};
/* ============================ 事件绑定 ============================ */
/* ⚠️「上次是谁打开的浮层」必须放在 S **之外**。
放进去过一次,结果是:点任何元素都会改 S,状态快照跟着变,于是**没有一个控件会被判死**
—— 那条「死控件」判据整条失效(变异对照 M1 当场抓住)。它只是用于还焦点的旁路信息,
不属于界面状态,也不该进快照。 */
let layerTrigger=null;
/* 浮层:可聚焦元素(Tab 只在层内循环)、打开 / 关闭。
⚠️ 可见性判断⛔ 不能用 `offsetParent!==null` —— 浮层是 position:fixed,它的 offsetParent
恒为 null,那样写会一个可聚焦元素都查不到(焦点锁与初始聚焦一起失效)。
用 getClientRects().length 判「渲染出来了」。 */
const isShown=el=>el.getClientRects().length>0 && getComputedStyle(el).visibility!=='hidden';
function layerFocusable(){
return [...document.querySelectorAll('#layer-root .layer button, #layer-root .layer input, #layer-root .layer select, #layer-root .layer textarea, #layer-root .layer [tabindex]')]
.filter(el=>!el.hasAttribute('disabled') && isShown(el));
}
/* 关层:一律走这里,好把焦点**还回**刚才按下的那个触发件。
⛔ 直接写 `S.layer=null; paint()` 会把焦点丢回 <body>,键盘用户就断了。 */
function closeLayer(){
const back=layerTrigger;
S.layer=null; paint();
if(back){ const el=document.querySelector('[data-act="'+back+'"]'); if(el) el.focus(); }
}
document.addEventListener('click', ev=>{
const t=ev.target.closest('[data-act]'); if(!t) return;
const act=t.dataset.act;
/* 从自检页离开:任何导航动作都先把自检锁解开 */
if(act && (act.startsWith('G-0')||act.startsWith('STATE-'))) ST.lock=false;
if(t.hasAttribute('disabled')) return;
/* 记下「是谁把浮层打开的」,供关层后还焦点用。
⚠️ 只在「点之前没层、点之后有层」那一刻记 —— 按 act 记(paint() 会把 DOM 整个换掉),
且⛔ 不能见谁都记:层里再点一下「取消」就把触发件覆盖成取消键了,焦点会还到不存在的地方。 */
const hadLayer=S.layer;
const fn=IMPL[act];
if(typeof fn==='function') fn({target:t});
if(!hadLayer && S.layer) layerTrigger=act;
});
document.addEventListener('input', ev=>{
const t=ev.target.closest('[data-act]'); if(!t) return;
const act=t.dataset.act;
if(act==='R-01'){ S.noteDraft=t.value; const b=document.querySelector('[data-act="R-02"]'); if(b) b.disabled=!t.value.trim(); return; }
if(act==='S-01'){ S.searchQ=t.value; const q=t.value,pos=t.selectionStart; paint(); const n=$('#q'); if(n){ n.value=q; n.focus(); n.setSelectionRange(pos,pos); } return; }
if(act==='P4-02'){ S.stepOut[S.step]=t.value; const b=document.querySelector('[data-act="P4-03"]'); if(b) b.disabled=!t.value.trim(); return; }
if(act==='P5-05'){ S.verDraft=t.value; S.verDirty=true; const b=document.querySelector('[data-act="P5-06"]'); if(b) b.disabled=false; return; }
if(act==='P6-02'){ S.profileDirty=true; const b=document.querySelector('[data-act="P6-03"]'); if(b) b.disabled=false; return; }
if(act==='P7-03'){ S.attrDraft=t.value; const b=document.querySelector('[data-act="P7-04"]'); if(b) b.disabled=!t.value.trim(); return; }
});
/* G-07 / G-09 两个顶栏下拉已删(2026-10-08)⇒ change 监听整条撤掉。
⚠️ 将来若要再加下拉 / 开关类控件,记得同时把 ID 登记回 IMPL,
否则判据「页面上每个 data-act 都在清单里」会报红。 */
/* 导航提示的进出(照 tiaoyue 的 nav-tips):鼠标进出与键盘聚焦都要管 ——
只做 hover 的话,键盘用户 Tab 到某一项时看不到名字。 */
document.addEventListener('mouseover', ev=>{ const n=ev.target.closest?.('#nav .nav-item'); showNavTip(n); });
document.addEventListener('mouseout', ev=>{ if(ev.target.closest?.('#nav .nav-item')) hideNavTip(); });
document.addEventListener('focusin', ev=>{ const n=ev.target.closest?.('#nav .nav-item'); showNavTip(n); });
document.addEventListener('focusout', ev=>{ if(ev.target.closest?.('#nav .nav-item')) hideNavTip(); });
window.addEventListener('resize', hideNavTip);
window.addEventListener('scroll', hideNavTip, true);
document.addEventListener('keydown', ev=>{
if(!S.layer||ST.lock) return;
if(ev.key==='Escape'){ closeLayer(); return; }
/* Tab 只在层内循环:焦点不许漏到被遮住的页面上 */
if(ev.key==='Tab'){
const f=layerFocusable(); if(!f.length) return;
const i=f.indexOf(document.activeElement);
const n=ev.shiftKey ? (i<=0?f.length-1:i-1) : (i===-1||i===f.length-1?0:i+1);
ev.preventDefault(); f[n].focus();
}
});
window.addEventListener('hashchange', ()=>{ if(ST.lock) return; parseHash(); paint(); });
/* ============================ 自检(#/selftest) ============================ */
let CONSOLE_ERRORS=[];
window.addEventListener('error', e=>{ const m='error: '+e.message; CONSOLE_ERRORS.push(m); showErr(m); });
window.addEventListener('unhandledrejection', e=>{ const m='rejection: '+e.reason; CONSOLE_ERRORS.push(m); showErr(m); });
function showErr(m){
let b=document.getElementById('errbar');
if(!b){ b=document.createElement('div'); b.id='errbar'; b.className='notice notice-err';
b.style.cssText='position:fixed;left:16px;right:16px;bottom:16px;z-index:99;margin:0';
document.body.appendChild(b); }
b.innerHTML='<strong>页面报错</strong><span></span>';
b.lastElementChild.textContent=m;
}
/* 场景=「某个页面 / 某个浮层打开着 / 某页停在某个状态」。自检在真实场景里跑,不在空壳上跑。
⚠️ 每个场景都从**初始态快照**起跑:只声明「本场景要打开什么」,其余一律回到干净底片。
不这么做,上一场景 / 上一次点击留下的脏状态会漏进下一场景 —— 实测就漏过:
同一个场景里连点几个元素,后面的会因为前面点过的东西而少渲染出节点,被误判成死按钮。 */
const S0=JSON.parse(JSON.stringify(S)); // 初始态快照(S 里全是能 JSON 化的纯数据)
/* 把**所有**滚动容器拉回零点。三个都要:文档(html)、`<body>`、主区(`.view`)。
⛔ 只调 window.scrollTo(0,0):本机实测滚动真的落在 body 上(body.scrollTop = 435,
window.scrollY 恒为 0),于是窄视口量出来的「首行 top / 一屏几行」全是假数。 */
function resetScroll(){
try{ window.scrollTo(0,0); }catch(e){}
document.documentElement.scrollTop=0;
document.body.scrollTop=0;
const v=document.querySelector('.view'); if(v) v.scrollTop=0;
}
function scrollTops(){
const v=document.querySelector('.view');
return {win:window.scrollY, html:document.documentElement.scrollTop, body:document.body.scrollTop, view:v?v.scrollTop:0};
}
function resetScene(){
Object.keys(S0).forEach(k=>{ S[k]=JSON.parse(JSON.stringify(S0[k])); });
S.toast=null;
}
const SCENES=[
{name:'P1 矩阵总览', set:()=>{ S.route='p1'; }},
{name:'P2 选题雷达', set:()=>{ S.route='p2'; S.commentOpen=true; S.commentState='done'; }},
{name:'P3 资产库', set:()=>{ S.route='p3'; S.assetSel='as3'; }},
{name:'P4 创作台', set:()=>{ S.route='p4'; S.versionOpen=true; }},
{name:'P4 有引用', set:()=>{ S.route='p4'; S.refs=[{id:ASSETS[0].id,text:ASSETS[0].text,zone:ASSETS[0].zone}]; }},
/* 2026-10-08 新增两条场景:AI 产出裁决(待裁决态)与生成中(可中断)。
没有这两条 ⇒ P4-09/10/11/12 在那个场景集里根本不出现 ⇒ 自检判「从未被驱动」=死控件。 */
{name:'P4 AI 待裁决', set:()=>{ S.route='p4'; S.aiOut[S.step]='机器出的一版:'+STEPS[S.step]+'方向,等你裁决。'; S.aiVerdict[S.step]=''; }},
{name:'P4 生成中', set:()=>{ S.route='p4'; S.stepBusy=true; }},
{name:'P5 发布中心', set:()=>{ S.route='p5'; S.targetOpen=true; S.checkState='idle'; }},
{name:'P5 有硬拦', set:()=>{ S.route='p5'; S.targetOpen=true; S.checkState='hard'; }},
{name:'P6 账号画像', set:()=>{ S.route='p6'; S.memoryOpen=true; }},
{name:'P7 复盘台', set:()=>{ S.route='p7'; }},
{name:'P7 等数据', set:()=>{ S.route='p7'; S.reviewSel='r3'; }},
{name:'P8 记录对账', set:()=>{ S.route='p8'; }},
{name:'P1 空空态', set:()=>{ S.route='p1'; S.pageState='empty'; }},
{name:'P1 出错态', set:()=>{ S.route='p1'; S.pageState='error'; }},
{name:'外壳停在非 P1', set:()=>{ S.route='p8'; }},
{name:'随手记浮层', set:()=>{ S.route='p1'; S.layer='quicknote'; }},
{name:'检索浮层空', set:()=>{ S.route='p1'; S.layer='search'; }},
{name:'检索有结果', set:()=>{ S.route='p1'; S.layer='search'; S.searchQ='选题'; }},
{name:'假设浮层', set:()=>{ S.route='p1'; S.layer='assume'; }},
{name:'版本浮层', set:()=>{ S.route='p4'; S.layer='versions'; }},
{name:'资产选择浮层', set:()=>{ S.route='p4'; S.layer='assetpick'; }},
{name:'发布二次确认', set:()=>{ S.route='p5'; S.targetOpen=true; S.layer='confirm'; S.checkState='pass'; }},
{name:'核销二次确认', set:()=>{ S.route='p8'; S.layer='checkout'; }}
];
function applyScene(sc){
resetScene();
sc.set();
/* ⚠️ 这里**不写 hash**:file:// 下 replaceState 可能被拒,而且 hash 变化是异步的,
靠它反推状态会让自检自己骗自己。状态在内存里,hash 只是入口。 */
paint();
/* 🔴 必须滚回顶部。下面驱动几百次点击时,浏览器会为聚焦的元素自动滚动,
滚完再量 rect 就会拿到**负的 top**(实测 768 下整页被滚到 −618px)。
而「一屏装得下几行」= (视口高 − 首行 top) ÷ 行距,top 变负数就凭空变大
⇒ 窄视口上给出过「装得下 10 行」的假 PASS。量尺寸前一律回到滚动零点。
🔴 2026-10-08 修:只复位 window 是不够的 —— 本机实测滚动落在了 **`<body>`** 上
(body.scrollTop = 435,而 window.scrollY 恒为 0),`.view` 也可能自己滚。
三个容器**都要**复位,guardScroll 也要逐个核对;⛔ 只盯 window.scrollY 会放过假数。 */
resetScroll();
}
/* 板块骨架(冻结):逐页的板块**数量与顺序**= `prd/2b-界面布局.md` §二 的「板块清单(从上到下)」,
⛔ ③段不许增、删、挪。这里的串是②段的名字(去掉「(可点入)」这类注解)。
闸门比的是「数量 + 顺序 + 名字前缀」,⛔ 不是逐字相同 —— ②段的名字带注解,不是给界面抄的标题。
②段原文与这张表的对应关系由 `_tools/check_skeleton.py` 复核(改一边不改另一边会报红)。 */
const FROZEN={
p1:['P1 矩阵总览',['今日待处理','账号矩阵','异常值提示','本月表现最好的一条','全局检索与随手记入口']],
p2:['P2 选题雷达',['对标监控概览','对标内容','评论区洞察(可点入子区)','选题库','灵感速记入口']],
p3:['P3 资产库',['分区切换','资产条目','单条详情','引用入口']],
p4:['P4 创作台',['当前内容标识','环节进度','当前环节工作区','AI 产出与采纳','资产引用区','版本列表(可点入)']],
p5:['P5 发布中心',['目标选择','各版本预览与逐版编辑','检查结果','确认发布','发布记录入口']],
p6:['P6 账号画像',['账号列表','画像六维','记忆条目','关联内容']],
p7:['P7 复盘台',['待复盘项','这一条的表现数据','归因结论(由 AI 给出,可编辑)','确认与改判','回写目标提示','历史复盘(可点入)']],
p8:['P8 记录与对账',['发布记录','单条溯源','对账核对区','商单与收支']]
};
function _renderSelftest(){
const L=[]; const pass=[]; const fail=[];
const ok=(t)=>{pass.push(t); L.push('<span class="st-pass">PASS</span> '+t);};
const bad=(t)=>{fail.push(t); L.push('<span class="st-fail">FAIL</span> '+t);};
ST.lock=false;
/* 「有没有可见反馈」的判据 = 状态快照 或 DOM 变了。
⛔ 不看 location.hash —— 它是异步落实的,拿它判会把「已经跳页」读成「没反应」(实测踩过)。 */
const sig=()=>JSON.stringify(S);
const domSnap=()=>$('#view').innerHTML+'\u0002'+$('#layer-root').innerHTML+'\u0002'+$('#nav').innerHTML;
const snap=()=>sig()+'\u0001'+domSnap();
/* 1) 八页都能渲染出内容 */
Object.keys(PAGES).forEach(p=>{
S.route=p; S.pageState='ok'; S.layer=null; paint();
const n=$('#view').children.length;
if(n>0) ok('路由 #/p/'+p+' 渲染出 '+n+' 个顶层节点'); else bad('路由 #/p/'+p+' 是空的');
});
/* 2) 六态在每一页都能渲染 */
let stFail=0;
Object.keys(PAGES).forEach(p=>{
STATES.forEach(([st])=>{
S.route=p; S.pageState=st; S.layer=null; paint();
if(!($('#view').children.length>0)) { stFail++; bad('P'+p.slice(1)+' 的 '+st+' 态没渲染出内容'); }
});
});
if(stFail===0) ok('六态 × 八页 = '+ (Object.keys(PAGES).length*STATES.length) +' 个组合,全部渲染出内容');
/* 3) 交互清单 ↔ 页面:双向差集 */
const declared=new Set(Object.keys(IMPL));
const seen=new Set(); const extra=[]; let domCount=0;
SCENES.forEach(sc=>{
applyScene(sc);
document.querySelectorAll('[data-act]').forEach(n=>{
const a=n.dataset.act;
domCount++; seen.add(a);
if(!declared.has(a)) extra.push(a+'@'+sc.name);
});
});
/* 🔴 侧栏=**一层八个功能项**,一项一页(2026-10-08 用户口径「只有一层」)。
判据同时盯两头:既不许出现第二层(#nav 的直接子元素必须全是一条条 .nav-item),
也不许项数不等于八页。2026-10-08 用户口径「左侧导航参考 tiaoyue 的导航」之后,
项本身由「带字列表」换成「图标栏」⇒ 选择器跟着从 .nav-flat 换成 .nav-item
(⚠️ 形态改了,判据的选择器必须跟着改,否则这条会静默失效 → 恒绿)。 */
const navEntries=[...document.querySelectorAll('#nav .nav-item')];
const navHomes=new Set(navEntries.map(n=>n.dataset.page));
const twoLevel=[...$('#nav').children].filter(c=>!c.classList.contains('nav-item')).length;
if(navEntries.length===8 && navHomes.size===8 && twoLevel===0)
ok('侧栏只有一层:'+navEntries.length+' 个功能项('+NAV.map(g=>g.label).join(' / ')+')排成一列药丸浮栏(图标+2 字短名),一项一页,第二层 0 个');
else bad('侧栏层级不对:功能项 '+navEntries.length+' 个 / 不同页面 '+navHomes.size+' 个 / 第二层子元素 '+twoLevel+' 个(期望 8 项、8 页、0 个二级)');
/* 八页仍要一次点击可达:4 个板块入口直落主页面,其余页由各主页面上的「功能入口」卡接住 */
const reach=new Set(navHomes);
Object.keys(PAGES).forEach(p=>{
applyScene({name:'reach-'+p, set:()=>{ S.route=p; }});
document.querySelectorAll('#view [data-act="G-05"][data-page]').forEach(n=>reach.add(n.dataset.page));
});
const unreachable=Object.keys(PAGES).filter(p=>!reach.has(p));
if(unreachable.length===0) ok('八页一次点击可达:侧栏八个功能项直达 + 各页「功能入口」卡另给上下文入口(卡片共 '+reach.size+' 个落点、无缺口)');
else bad('这些页面从侧栏与主页面都到不了:'+unreachable.join(' / '));
const dangling=[...declared].filter(k=>!seen.has(k));
if(dangling.length===0) ok('清单里每个 ID 都在页面上出现过('+declared.size+' 个,无悬空声明)'); else bad('声明了但页面里没有:'+dangling.join(', '));
if(extra.length===0) ok('页面上每个 data-act 都在清单里(共 '+domCount+' 次出现,无未登记的可点元素)'); else bad('未登记的 data-act:'+extra.join(', '));
/* 4) 逐个真驱动
· 三 类控件各用**自己真实的事件**:按钮用 click、输入框用 input、下拉用 change。
⛔ 别拿 click 去点输入框然后说它是死按钮 —— 那不是它的事件。
· **所有实例**逐个试,任一实例有可见反馈即算这个 ID 活着。
⛔ 只看第一个实例:tab / 分组这类「第一项本来就是当前项」的控件会被误杀(实测踩过)。
· 每个实例都从干净场景起跑。 */
const res={}; const why={};
const markOk=act=>{ res[act]='ok'; delete why[act]; };
const markShy=(act,what,w)=>{ if(res[act]==='ok') return; res[act]=what; if(w!==undefined) why[act]=w; };
const isFormName=n=>/^(INPUT|TEXTAREA|SELECT)$/.test(n.tagName);
const drive=node=>{
if(node.tagName==='SELECT'){
const other=[...node.options].map(o=>o.value).find(v=>v!==node.value);
if(other==null) return false;
node.value=other; node.dispatchEvent(new Event('change',{bubbles:true})); return true;
}
if(isFormName(node)){ node.value=(node.value||'')+'A'; node.dispatchEvent(new Event('input',{bubbles:true})); return true; }
node.dispatchEvent(new MouseEvent('click',{bubbles:true})); return true;
};
/* 表单控件先跑:跑完它们,依赖它们的按钮(保存 / 开始这一步 / 回写)才转为可用 */
[{label:'表单控件(输入 / 下拉)', only:isFormName},
{label:'可点元素(按钮 / 行 / 卡片)', only:n=>!isFormName(n)}].forEach(pass=>{
SCENES.forEach(sc=>{
applyScene(sc);
const acts=[...new Set([...document.querySelectorAll('[data-act]')].map(n=>n.dataset.act))]
.filter(a=>[...document.querySelectorAll('[data-act="'+a+'"]')].some(pass.only));
acts.forEach(act=>{
applyScene(sc); // ⚠️ 先定格到本场景:实例数必须按定格后的 DOM 数
const total=[...document.querySelectorAll('[data-act="'+act+'"]')].length;
let sawDisabled=false;
for(let i=0;i<total;i++){
applyScene(sc); // 每个实例都从干净态开驱动
const node=[...document.querySelectorAll('[data-act="'+act+'"]')][i];
if(!node) break;
if(node.hasAttribute('disabled')){ sawDisabled=true; continue; }
S.toast=null;
const before=snap();
let threw='';
try{ if(!drive(node)) continue; }catch(err){ threw=String(err); }
if(threw){ markShy(act,'dead','@'+sc.name+'|抛错:'+threw); break; }
if(snap()!==before){ markOk(act); break; }
if(i===total-1) markShy(act,'dead','@'+sc.name+'|'+node.tagName.toLowerCase()+'|'+total
+' 个实例逐个驱动都没变化|route='+S.route+'|layer='+S.layer);
}
if(!res[act]) markShy(act, sawDisabled?'disabled':'dead',
sawDisabled?undefined:'@'+sc.name+'|'+total+' 个实例都被禁用或驱动不了');
});
});
});
const okList=Object.keys(res).filter(k=>res[k]==='ok');
const disList=Object.keys(res).filter(k=>res[k]==='disabled');
const deadList=Object.keys(res).filter(k=>res[k]==='dead');
if(deadList.length===0) ok('清单里 '+okList.length+' 个 ID 都真驱动过并给出可见反馈,0 个死控件('+SCENES.length+' 个场景,每个实例各重置一次)'); else bad('驱动了没反应:'+deadList.join(' / '));
if(deadList.length){ L.push('--- 没反应的控件诊断 ---'); deadList.forEach(k=>L.push(' '+k+' ⇒ '+why[k])); }
ok('当刻被禁用的控件 '+disList.length+' 个(有 disabled 属性:按契约在特定条件下不可点)'+(disList.length?':'+disList.join(', '):''));
const never=[...declared].filter(k=>!res[k]);
if(never.length===0) ok('清单里每个 ID 都至少被驱动过一次(无「从未跑过」的条目)'); else bad('从未被驱动:'+never.join(', '));
/* 4b) 侧栏 8 个功能项逐项核:每一项都真的落到它自己那一页 */
const navBad=[];
NAV.forEach(g=>{
applyScene(SCENES[0]);
const n=document.querySelector('#nav .nav-item[data-page="'+g.page+'"]');
if(!n){ navBad.push(g.label+'(没这个入口)'); return; }
n.dispatchEvent(new MouseEvent('click',{bubbles:true}));
if(S.route!==g.page) navBad.push(g.label+'(点到「'+PAGES[S.route]+'」去了)');
});
if(navBad.length===0) ok('侧栏 '+NAV.length+' 个功能项逐个点过,每一项都落到了自己那一页');
else bad('侧栏板块入口跳错:'+navBad.join(' / '));
/* 4c) 五处「输入后才可用」的联动:空输入时禁用 → 输入后可用 → 点了有反应 */
const CHAINS=[
{label:'随手记「保存」', sc:{set:()=>{ S.route='p1'; S.layer='quicknote'; }}, fill:'R-01', btn:'R-02'},
{label:'创作台「开始这一步」', sc:{set:()=>{ S.route='p4'; S.stepOut[S.step]=''; }}, fill:'P4-02', btn:'P4-03'},
{label:'发布中心「保存本版」', sc:{set:()=>{ S.route='p5'; S.targetOpen=true; }}, fill:'P5-05', btn:'P5-06'},
{label:'账号画像「保存画像」', sc:{set:()=>{ S.route='p6'; }}, fill:'P6-02', btn:'P6-03'},
{label:'复盘台「确认归因并回写」', sc:{set:()=>{ S.route='p7'; }}, fill:'P7-03', btn:'P7-04'}
];
const chainBad=[]; let gated=0;
CHAINS.forEach(c=>{
const btns=()=>[...document.querySelectorAll('[data-act="'+c.btn+'"]')];
const usable=()=>btns().filter(b=>!b.hasAttribute('disabled'));
applyScene(c.sc);
if(btns().length && usable().length===0) gated++;
const f=[...document.querySelectorAll('[data-act="'+c.fill+'"]')];
if(!f.length){ chainBad.push(c.label+'(没找到输入框)'); return; }
f[0].value='试写一句'; f[0].dispatchEvent(new Event('input',{bubbles:true}));
if(!usable().length){ chainBad.push(c.label+'(输入后按钮还是禁用)'); return; }
const before=snap();
usable()[0].dispatchEvent(new MouseEvent('click',{bubbles:true}));
if(snap()===before) chainBad.push(c.label+'(点了没反应)');
});
if(chainBad.length===0) ok('五处「输入后才可用」的联动都通:其中 '+gated+' 处在空输入时确实是禁用的 → 输入后转可用 → 点了有反应');
else bad('联动没跑通:'+chainBad.join(' / '));
/* 5) 页型与动效纪律 */
const hits=['eyebrow','lead','hero'].filter(c=>document.querySelectorAll('.'+c).length);
if(hits.length===0) ok('工具型纪律:零 .eyebrow / 零 .lead / 零 .hero'); else bad('出现禁用类名:'+hits.join(','));
let kf=0;
try{ [...document.styleSheets].forEach(ss=>{ try{ [...ss.cssRules].forEach(r=>{ if(r.type===7) kf++; }); }catch(e){} }); }catch(e){}
if(kf<=1) ok('入场编排不做逐区块:@keyframes 共 '+kf+' 处(工具型至多一处协调出场)'); else bad('逐区块入场编排:@keyframes 共 '+kf+' 处');
/* 5b) 骨架冻结闸:逐页板块数量与顺序必须与 ②段 2b-界面布局.md §二 一致 */
const norm=s=>String(s).split('(')[0].trim();
const skelBad=[];
Object.keys(FROZEN).forEach(p=>{
applyScene({name:p, set:()=>{ S.route=p; }});
const [wantPg,want]=FROZEN[p];
const gotPg=($('#view > .block-head h1')||{}).textContent;
const got=[...document.querySelectorAll('#view .block .block-head h2')].map(h=>h.textContent.trim());
if(!gotPg||gotPg.trim()!==wantPg) skelBad.push(p+' 页标题「'+(gotPg||'缺')+'」≠「'+wantPg+'」');
if(got.length!==want.length) skelBad.push(p+' 板块数 '+got.length+' ≠ ②段的 '+want.length+'('+got.join('/')+')');
else got.forEach((g,i)=>{
const a=norm(g), b=norm(want[i]);
if(!(a.startsWith(b)||b.startsWith(a))) skelBad.push(p+' 第 '+(i+1)+' 块「'+g+'」≠②段的「'+want[i]+'」');
});
});
const totalFrozen=Object.keys(FROZEN).reduce((n,p)=>n+FROZEN[p][1].length,0);
if(skelBad.length===0) ok('骨架冻结:八页 '+totalFrozen+' 个板块的数量与顺序与 ②段 2b「板块清单」逐位一致,⛔ 未增删挪');
else bad('骨架与②段不一致:'+skelBad.join(' / '));
/* 5c) 本项目追加判据(runbook §5.1 结构四条 + §5.3 那串「人工要量的」,这里能机检的都机检) */
const cssAll=(()=>{ let t=''; try{ [...document.styleSheets].forEach(ss=>{ try{ [...ss.cssRules].forEach(r=>{ t+=r.cssText+'\n'; }); }catch(e){} }); }catch(e){} return t; })();
/* ① 标题不跳级:每页有且只有一个 h1,层级逐级下降,且每个板块都有文字标题 */
const headBad=[];
Object.keys(PAGES).forEach(p=>{
applyScene({name:p, set:()=>{ S.route=p; }});
const hs=[...document.querySelectorAll('#view h1,#view h2,#view h3,#view h4')];
const h1n=hs.filter(h=>h.tagName==='H1').length;
if(h1n!==1) headBad.push(p+' 有 '+h1n+' 个 h1');
const lv=hs.map(h=>+h.tagName[1]);
for(let i=1;i<lv.length;i++) if(lv[i]-lv[i-1]>1){ headBad.push(p+' 标题从 h'+lv[i-1]+' 跳到 h'+lv[i]); break; }
const noTitle=[...document.querySelectorAll('#view .block')].filter(b=>!b.querySelector('.block-head h2, .block-head h3')).length;
if(noTitle) headBad.push(p+' 有 '+noTitle+' 个板块没有文字标题');
});
if(headBad.length===0) ok('结构 ①:八页各一个 h1,标题逐级不跳;每个板块都有文字标题');
else bad('标题大纲有问题:'+headBad.join(' / '));
/* ② 字号:档数 ≥3、相邻档差 ≥2px、正文档(14–16)确实在用 */
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const usedFs=new Set();
document.querySelectorAll('#view *').forEach(e=>{ if(e.children.length===0&&(e.textContent||'').trim()) usedFs.add(getComputedStyle(e).fontSize); });
const fsArr=[...usedFs].map(parseFloat).sort((a,b)=>a-b);
const fsDiff=fsArr.slice(1).map((v,i)=>v-fsArr[i]);
const fsBad=[];
if(fsArr.length<3) fsBad.push('只有 '+fsArr.length+' 档');
if(fsDiff.some(d=>d<2)) fsBad.push('相邻档差出现 '+Math.min(...fsDiff)+'px(要 ≥2)');
if(!fsArr.some(v=>v>=14&&v<=16)) fsBad.push('正文档 14–16 没在用');
if(fsBad.length===0) ok('结构 ②:字号 '+fsArr.join('/')+'px 共 '+fsArr.length+' 档,相邻差 '+fsDiff.join('/')+',正文档 14–16 在用');
else bad('字号不达标:'+fsBad.join(';'));
/* ③ 按钮分档:同屏主档 ≤1,且实底 ≥2 种 */
const btnBad=[];
Object.keys(PAGES).forEach(p=>{
applyScene({name:p, set:()=>{ S.route=p; }});
const vis=[...document.querySelectorAll('#view .btn-primary')].filter(b=>{ const r=b.getBoundingClientRect(); return r.top<window.innerHeight&&r.bottom>0; });
if(vis.length>1) btnBad.push(p+' 首屏有 '+vis.length+' 个主档按钮');
});
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const solids=new Set([...document.querySelectorAll('#view .btn')].map(b=>getComputedStyle(b).backgroundColor).filter(c=>c&&c!=='rgba(0, 0, 0, 0)'));
if(btnBad.length===0) ok('结构 ④:逐页查过,首屏同屏主档按钮 ≤1 个;本页实底按钮 '+solids.size+' 种');
else bad('主档按钮超量:'+btnBad.join(' / '));
/* ⑤ 组件口径:⛔ 不许用内联 style 表达「选中态」或覆盖字号(2026-10-08 新增)。
为什么单列这一条:改版前选中态有**三套画法** —— 侧栏当前项(浅紫底)、
分段控件(`.btn btn-sm` + 内联 `border-color:var(--ink)`,五处)、表格当前行(内联 `color`,四处);
字号也有内联覆盖(P7 三个数硬顶到 `--fs-20`、P4 的「/7」)。
⇒ 判据:八页里 `[style*=border-color]`、`[style*=color]`、`[style*=font-size]` 的出现次数都为 0。
选中态统一走 `.is-on` / `.is-active`,字号只许走 `--fs-*` 档。
⚠️ 只查这三样、不查全部内联样式:`margin-*` / `flex` / `justify-content` 这类一次性布局内联是允许的
(硬按「内联为 0」判会把正常的布局微调一起判红)。 */
const inlineBad=[]; let inlineSel=0, inlineFs=0;
Object.keys(PAGES).forEach(p=>{
applyScene({name:'inline-'+p, set:()=>{ S.route=p; }});
const a=[...document.querySelectorAll('#view [style*="border-color"]')].length;
const b=[...document.querySelectorAll('#view [style*="color"]')].filter(n=>!(n.getAttribute('style')||'').includes('background-color')).length;
const c=[...document.querySelectorAll('#view [style*="font-size"]')].length;
if(a||b||c){ inlineBad.push('P'+p.slice(1)+'(选中态 '+a+'/文字色 '+b+'/字号 '+c+')'); inlineSel+=a+b; inlineFs+=c; }
});
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
if(inlineBad.length===0) ok('组件口径:八页都没有用内联 style 表达选中态或覆盖字号(选中态走 .is-on / .is-active,字号走 --fs-* 档)');
else bad('内联 style 越权(选中态 '+inlineSel+' 处、字号 '+inlineFs+' 处):'+inlineBad.join(' / '));
/* ④ 交互态与可读性:焦点环在、没被抹掉;数字列 tabular-nums;阴影是多层叠层;reduced-motion 处理过 */
const a11yBad=[];
if(!/:focus-visible/.test(cssAll)) a11yBad.push('没有 :focus-visible 规则');
if(/outline\s*:\s*(none|0)/i.test(cssAll)) a11yBad.push('出现了 outline:none/0,焦点环被抹');
if(!/:hover/.test(cssAll)) a11yBad.push('没有 :hover');
if(!/tabular-nums/.test(cssAll)) a11yBad.push('数字列没有 tabular-nums');
if(!/prefers-reduced-motion/.test(cssAll)) a11yBad.push('没处理 prefers-reduced-motion');
if(!/minmax\(0,\s*1fr\)/.test(cssAll)) a11yBad.push('缺 minmax(0,1fr) 防溢出');
if(!/min-width:\s*0/.test(cssAll)) a11yBad.push('缺 min-width:0 防溢出');
if(!/overflow-x:\s*auto/.test(cssAll)) a11yBad.push('表格外层缺 overflow-x:auto');
if(a11yBad.length===0) ok('交互态与可读性::hover / :focus-visible 都在且没被 outline:none 抹掉;tabular-nums、prefers-reduced-motion、防溢出三件套齐全');
else bad('可读性缺项:'+a11yBad.join(' / '));
const shSingle=[...document.querySelectorAll('#view .panel, #view .card')]
.map(e=>getComputedStyle(e).boxShadow)
.filter(v=>v&&v!=='none'&&(v.match(/rgba?\(/g)||[]).length<2);
if(shSingle.length===0) ok('阴影:面板/卡片都用多层叠层,无单层死黑重阴影');
else bad('有 '+shSingle.length+' 处阴影只有一层叠层');
/* ⑤ 间距只用登记刻度 */
const toks=new Set();
[...document.styleSheets].forEach(ss=>{ try{ [...ss.cssRules].forEach(r=>{
if(r.style){ [...r.style].forEach(k=>{ if(/^--(sp|mg)-/.test(k)) toks.add(r.style.getPropertyValue(k).trim()); }); }
}); }catch(e){} });
const offScale=new Set();
document.querySelectorAll('#view *').forEach(e=>{
const cs=getComputedStyle(e); if(cs.display!=='flex'&&cs.display!=='grid') return;
const g=cs.gap; if(!g||g==='normal'||g==='0px') return;
g.split(/\s+/).forEach(v=>{ if(!toks.has(v)) offScale.add(v); });
});
if(offScale.size===0) ok('间距:flex/grid 的 gap 取值全部落在登记刻度内('+toks.size+' 个刻度)');
else bad('间距出现未登记刻度:'+[...offScale].join(', '));
/* 5d) 浮层核(runbook 要求逐个浮层手工过:可见关闭出口 = 1 / Esc 能关 / 点遮罩能关 /
焦点锁在层内 / 关掉后焦点回到触发件)。这里把它变成可复跑的机检。 */
const LAYERS=[
{n:'随手记', sc:()=>{ S.route='p1'; }, trg:'G-06'},
{n:'全局检索', sc:()=>{ S.route='p1'; }, trg:'G-10'},
{n:'两处假设说明', sc:()=>{ S.route='p1'; }, trg:'G-08'},
{n:'资产选择器', sc:()=>{ S.route='p4'; }, trg:'P4-04'},
{n:'版本列表', sc:()=>{ S.route='p4'; }, trg:'P4-07'},
{n:'发布二次确认', sc:()=>{ S.route='p5'; S.targetOpen=true; S.checkState='pass'; }, trg:'P5-08'},
{n:'核销二次确认', sc:()=>{ S.route='p8'; }, trg:'P8-03'}
];
const actOf=el=>(el&&el.dataset&&el.dataset.act)||(el?el.tagName:'无');
const inLayer=()=>$('#layer-root').contains(document.activeElement);
LAYERS.forEach(({n,sc,trg})=>{
/* 打开:从干净场景出发,用**真事件**点触发件 */
applyScene({name:n,set:()=>{ sc(); }});
const t=document.querySelector('[data-act="'+trg+'"]');
if(!t){ bad('浮层「'+n+'」:找不到触发件 '+trg); return; }
if(t.hasAttribute('disabled')){ bad('浮层「'+n+'」:触发件 '+trg+' 在本场景式样下是禁用的,层打不开'); return; }
t.dispatchEvent(new MouseEvent('click',{bubbles:true}));
if(!S.layer){ bad('浮层「'+n+'」:点了 '+trg+' 层没打开(S.layer 仍是 null)'); return; }
/* ① 可见关闭出口恰好 1 个。
🔴 口径必须写清:这里数的是**中性关闭控件**(右上 ×,`data-act="X-close"`)。
曾经只数 `.layer-close` 这个类名,于是「发布二次确认 / 核销二次确认」里那颗**可见的「取消」**
完全没进计数 —— 那两个层其实有两个看得见的关闭控件,而判据却报「出口 = 1」,
等于用选择器把第二个出口从眼皮底下藏掉了(独立评审一眼就看出这两个层不止一个出口)。
⇒ 两件事一起做:① 中性出口只认 X-close;② 决策型关闭(取消这类带语义的按钮)
单独报出个数,⛔ 不许当没看见。核销层的「取消」也已从 X-close 拆成 CHECKOUT-CANCEL。 */
const exits=[...document.querySelectorAll('#layer-root .layer [data-act="X-close"]')].filter(isShown);
const deciders=[...document.querySelectorAll('#layer-root .layer button')].filter(el=>isShown(el) && el.dataset.act!=='X-close' && /取消|不发|不核销/.test(el.textContent||''));
const decTxt=deciders.length?('('+deciders.map(actOf).join('、')+')'):'';
if(exits.length===1) ok('浮层「'+n+'」中性关闭出口 = 1(右上 ×)|另有决策型关闭 '+deciders.length+' 个'+decTxt+';遮罩是「点层外」通道,不计作出口');
else bad('浮层「'+n+'」中性关闭出口 = '+exits.length+',契约要求恰好 1(多出来的会让用户找不着北)');
/* ② 打开后焦点就在层内 */
if(inLayer()) ok('浮层「'+n+'」打开后焦点在层内(当前='+actOf(document.activeElement)+')');
else bad('浮层「'+n+'」打开后焦点不在层内,停在 '+document.activeElement.tagName);
/* ③ Tab 不会把焦点漏到被遮住的页面上 */
let escaped=false;
for(let i=0;i<8;i++){ document.dispatchEvent(new KeyboardEvent('keydown',{key:'Tab',bubbles:true})); if(!inLayer()){ escaped=true; break; } }
if(!escaped) ok('浮层「'+n+'」连按 8 次 Tab,焦点始终锁在层内');
else bad('浮层「'+n+'」按 Tab 后焦点漏到层外('+actOf(document.activeElement)+')');
/* ④ Esc 能关,且关掉后焦点回到触发件 */
document.dispatchEvent(new KeyboardEvent('keydown',{key:'Escape',bubbles:true}));
if(S.layer===null) ok('浮层「'+n+'」Esc 能关');
else bad('浮层「'+n+'」Esc 关不掉');
if(actOf(document.activeElement)===trg) ok('浮层「'+n+'」关掉后焦点回到触发件 '+trg);
else bad('浮层「'+n+'」关掉后焦点没回到 '+trg+',停在 '+actOf(document.activeElement));
/* ⑤ 点遮罩也能关 */
applyScene({name:n,set:()=>{ sc(); }});
const t2=document.querySelector('[data-act="'+trg+'"]');
if(t2 && !t2.hasAttribute('disabled')) t2.dispatchEvent(new MouseEvent('click',{bubbles:true}));
const mk=document.querySelector('[data-act="X-mask"]');
if(!mk){ bad('浮层「'+n+'」没有遮罩,点层外关不掉'); }
else{
mk.dispatchEvent(new MouseEvent('click',{bubbles:true}));
if(S.layer===null) ok('浮层「'+n+'」点遮罩(层外空白)能关');
else bad('浮层「'+n+'」点遮罩关不掉');
}
});
/* 6) 数值取证:DESIGN.md §六 登记了 8 条「契约值」,这里逐条实测并当场判。
⛔ 不目测:全部 DOM 取值 / 真实像素算。 */
L.push('--- 数值取证(布局视口 '+document.documentElement.clientWidth+' × '+document.documentElement.clientHeight
+' ;window.innerWidth '+window.innerWidth+')---');
/* 🔴 「页面在滚动零点」这条守卫必须贴到**真正量尺寸的那一次**(applyScene 之后),
不能放在本节开头。本节开头还带着前面驱动几百次点击留下的滚动位置 —— 窄视口页面更高,
实测被滚到 482px;在那里检查会**假报红**,而真正量尺寸时 applyScene() 已把页面复位到零点,
量出来的数是干净的(见行尾「量时 scrollY=0」)。贴着量点放,既不误报,又仍能抓住
「applyScene 忘了复位」这种真病(变异 M9 盯的就是它)。 */
const guardScroll=tag=>{
const t=scrollTops();
/* 🔴 三个容器逐个核,不再只看 window.scrollY。
2026-10-08 之前只核 window ⇒ 滚动其实落在 body 上(body.scrollTop 435 / window.scrollY 0),
窄视口因此报出过「500×844 一屏装得下 7 行」这种假绿(真值连 1 行都不到)。 */
const bad0=[];
if(t.win!==0) bad0.push('window.scrollY '+t.win);
if(t.html!==0) bad0.push('html.scrollTop '+t.html);
if(t.body!==0) bad0.push('body.scrollTop '+t.body);
if(t.view!==0) bad0.push('.view.scrollTop '+t.view);
L.push('量尺寸时滚动零点('+tag+'):window '+t.win+' / html '+t.html+' / body '+t.body+' / .view '+t.view);
if(bad0.length===0) ok('量尺寸前三个滚动容器都停在零点('+tag+'):rect 取到的是相对视口的真值');
else bad('量尺寸时页面没回零点('+tag+'):'+bad0.join('、')+' ⇒ 下面的落位与行数全是假数,先复位再量');
};
const probeEl=document.createElement('span');
probeEl.style.cssText='position:absolute;left:-99999px;top:0;width:0;height:0';
document.body.appendChild(probeEl);
const rgbOf=t=>{ probeEl.style.color=''; probeEl.style.color='var('+t+')'; return getComputedStyle(probeEl).color; };
const nums=c=>String(c).match(/[\d.]+/g).slice(0,3).map(Number);
const lin=v=>{ v/=255; return v<=0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055,2.4); };
const lum=c=>{ const [r,g,b]=nums(c).map(lin); return 0.2126*r+0.7152*g+0.0722*b; };
const cr=(a,b)=>{ const l1=lum(a), l2=lum(b), hi=Math.max(l1,l2), lo=Math.min(l1,l2); return (hi+0.05)/(lo+0.05); };
const r1=v=>Math.round(v*100)/100;
const surfaceRgb=rgbOf('--surface');
[['--warn 对 --surface',rgbOf('--warn'),4.5],
['--accent-text 对 --surface',rgbOf('--accent-text'),4.5],
['--ink-faint 对 --surface',rgbOf('--ink-faint'),3]].forEach(([nma,c,min])=>{
const v=cr(c,surfaceRgb);
if(v>=min) ok('对比度 '+nma+' = '+r1(v)+':1(契约 ≥'+min+':1)');
else bad('对比度 '+nma+' = '+r1(v)+':1,低于契约 '+min+':1');
});
/* 6a) P1:强调色填充面积占比 / 首屏可见行数 / 块间距比 / 字号档数 */
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const VW=document.documentElement.clientWidth, VH=document.documentElement.clientHeight;
const clip=r=>{ const x0=Math.max(0,r.left), y0=Math.max(0,r.top);
const x1=Math.min(VW,r.right), y1=Math.min(VH,r.bottom);
return (x1>x0&&y1>y0) ? (x1-x0)*(y1-y0) : 0; };
const accentRgb=rgbOf('--accent');
const accOnPage=()=>{ let a=0,n=0;
document.querySelectorAll('#view *, .sidebar *, .topbar *').forEach(e=>{
if(getComputedStyle(e).backgroundColor===accentRgb){ const q=clip(e.getBoundingClientRect()); if(q>0){ a+=q; n++; } }
});
return [a/(VW*VH)*100, n]; };
/* 强调色占比逐页取最大值 —— 契约是「任何一页都 ≤5%」,只看 P1 会漏掉按钮最多的页 */
let accPct=0, accN=0, accPage='p1';
Object.keys(PAGES).forEach(p=>{
applyScene({name:p, set:()=>{ S.route=p; }});
const [pc,n]=accOnPage(); if(pc>accPct||accPage==='p1'){ if(pc>=accPct){ accPct=pc; accN=n; accPage=p; } }
});
if(accPct<=5) ok('强调色填充面积占比:八页最高 '+PAGES[accPage]+' = '+r1(accPct)+'%('+accN+' 个填充块,契约 ≤5%;不含 3px 左侧标条 / 描边 / 文字色)');
else bad('强调色填充面积占比达 '+r1(accPct)+'%('+PAGES[accPage]+'),超过契约 5%');
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const findBlock=t=>[...document.querySelectorAll('#view .block')]
.find(b=>((b.querySelector('.block-head')||{}).textContent||'').includes(t));
/* ⚠️ 判据是「一屏**装得下**几行」,不是「实际渲染了几行」:
示意数据只有 5 条时,实际行数永远 ≤5,那是数据用尽,不是被折叠线截断。
所以按 首屏可用高度 ÷ 行距 算容量,另附实际条数。
⚠️ 行距只有**一个**口径:相邻行间距的**中位数**。⛔ 别在别处再算一遍「首行间距」或
「首行高度」冒充行距 —— 首行有时因文字折行比别行高一档,两处各报一个数就会互相打架。
是否折行则单列一条闸门(行高齐),不揉进行距里。 */
const rowFit=b=>{
if(!b) return null;
/* ⚠️ P1 两块已是表格(tbody tr),别的页仍是行式列表(.list-row)⇒ 两种都要认 */
const rects=[...b.querySelectorAll('.list-row, .ds-table tbody tr')].map(r=>r.getBoundingClientRect());
if(rects.length<2) return null;
const tops=rects.map(r=>r.top), hs=rects.map(r=>Math.round(r.height));
const ps=tops.slice(1).map((t,i)=>Math.round(t-tops[i])).sort((a,b2)=>a-b2);
const pitch=ps[Math.floor(ps.length/2)];
return {n:rects.length, top:Math.round(tops[0]), pitch, cap:Math.floor((VH-tops[0])/pitch),
hMin:Math.min(...hs), hMax:Math.max(...hs), hs};
};
const mx=findBlock('账号矩阵');
guardScroll('P1');
/* 密度契约:layouts-tooling.md §1 判定要点原文「一屏可见行数 ≥ 6(900px 高时)」。
⛔ 不分档、不按视口放宽 —— 我一度自己写了个「窄视口降为 ≥4」,那是编标准,已撤掉。
窄屏实测达不到 6 行的原因照实写进 DESIGN.md §五 缺口,⛔ 不靠改判据过关。 */
const needRows=6;
const p1Fit=rowFit(mx);
if(p1Fit && p1Fit.cap>=needRows) ok('P1 账号矩阵:首屏从 '+p1Fit.top+'px 起,行距 '+p1Fit.pitch+'px,一屏装得下 '+p1Fit.cap+' 行(契约 ≥6 行/900px 高,实测宽 '+VW+')|示意数据 '+p1Fit.n+' 条 ⇒ 实际可见 '+Math.min(p1Fit.cap,p1Fit.n)+' 行(数据用尽,非被截断)');
else bad('P1 账号矩阵一屏只能装下 '+(p1Fit?p1Fit.cap+' 行(首行顶 '+p1Fit.top+'px、行距 '+p1Fit.pitch+'px、视口高 '+VH+'px)':'?')+',低于契约 6 行/900px 高');
/* 行高齐:各行等高,列表才有扫读节奏。行里的状态标、长度不一的文字都可能把某一行撑高。 */
if(p1Fit && p1Fit.hMax-p1Fit.hMin<=2) ok('P1 账号矩阵行高齐:'+p1Fit.hMin+'–'+p1Fit.hMax+'px,差 ≤2px(没有哪一行被状态标或长文字撑高)');
else bad('P1 账号矩阵行高不齐:'+(p1Fit?p1Fit.hMin+'–'+p1Fit.hMax+'px 差 '+(p1Fit.hMax-p1Fit.hMin)+'px':'量不到')+' ⇒ 扫列表时节奏是乱的');
const fsSet=new Set();
document.querySelectorAll('#view *').forEach(e=>{ if(e.children.length===0 && (e.textContent||'').trim()) fsSet.add(getComputedStyle(e).fontSize); });
if(fsSet.size>=3) ok('P1 实际在用字号档 '+fsSet.size+' 档:'+[...fsSet].sort((a,b)=>parseFloat(a)-parseFloat(b)).join(' / ')+'(契约 ≥3)');
else bad('P1 只用 '+fsSet.size+' 档字号,低于契约 3');
const blocks=[...document.querySelectorAll('#view .block')];
/* ⚠️ 量块间距要按**视觉层**量:并排的两块同属一层,直接拿相邻 .block 相减会出负值 */
const unitOf=b=>b; /* P1 两块改成满宽顺排表格后没有并排块了,视觉层=块自己 */
const units=[...new Set(blocks.map(unitOf))];
if(units.length>=2&&mx){
const gap=Math.round(units[1].getBoundingClientRect().top-units[0].getBoundingClientRect().bottom);
/* ⚠️ 表格行的分隔线画在 `td` 的 border-top 上,列表行画在 `.list-row` 自己身上 ⇒ 两处都量 */
const rows=[...mx.querySelectorAll('.list-row, .ds-table td')];
/* ⚠️ 别只取第一行:列表第一行的上边框按设计不画(`li:first-child > .list-row`),取到会是 0 */
const bw=rows.length?Math.max(...rows.map(r=>Math.abs(parseFloat(getComputedStyle(r).borderTopWidth)||0))):0;
/* 🔴 除数必须是真发丝线(≥1px)。曾经写成无条件 `gap>=2*bw` —— 而 bw 恰好是 0 时
「0 的两倍」恒成立,比值算出来是 Infinity,判据永远 PASS。实测就是这么漏掉了
「全表行分隔线没画出来」这个真缺陷。⇒ 除数为 0 一律判红。 */
if(bw<1) bad('块内相邻行分隔量到 0px ⇒ 行式列表的行分隔线根本没画出来(这条判据在除数为 0 时形同虚设,先修行线再谈比值)');
else if(gap>=2*bw) ok('视觉层间距 '+gap+'px / 块内相邻行分隔 '+bw+'px(发丝线,行本身不套框)⇒ 比值 '+r1(gap/bw)+' ≥2('+units.length+' 个视觉层)');
else bad('视觉层间距 '+gap+'px 不到块内分隔('+bw+'px)的两倍 ⇒ 分组读不出来');
}
const btnH=[...new Set([...document.querySelectorAll('#view button.btn')].map(b=>Math.round(b.getBoundingClientRect().height)).filter(h=>h>0))];
L.push('P1 各块落位(顶 / 高):'+blocks.map(b=>{
const hd=b.querySelector('.block-head h2'); const q=b.getBoundingClientRect();
return (hd?hd.textContent.trim().slice(0,9):'块')+'@'+Math.round(q.top)+'+'+Math.round(q.height);
}).join(' · ')+'(视口高 '+VH+';并排的两块顶相同)');
/* 6b) P2 对标列表:首屏可容纳行数 */
applyScene({name:'P2', set:()=>{ S.route='p2'; }});
guardScroll('P2');
const p2Fit=rowFit(findBlock('对标内容'));
if(p2Fit && p2Fit.cap>=needRows) ok('P2 对标内容列表:首屏从 '+p2Fit.top+'px 起,行距 '+p2Fit.pitch+'px,一屏装得下 '+p2Fit.cap+' 行(契约 ≥6 行/900px 高,实测宽 '+VW+')|示意数据 '+p2Fit.n+' 条 ⇒ 实际可见 '+Math.min(p2Fit.cap,p2Fit.n)+' 行(数据用尽,非被截断)');
else bad('P2 对标内容列表一屏只能装下 '+(p2Fit?p2Fit.cap+' 行(首行顶 '+p2Fit.top+'px、行距 '+p2Fit.pitch+'px、视口高 '+VH+'px)':'?')+',低于契约 6 行/900px 高');
L.push('P2 各块落位(顶 / 高):'+[...document.querySelectorAll('#view .block')].map(b=>{
const hd=b.querySelector('.block-head h2'); const q=b.getBoundingClientRect();
return (hd?hd.textContent.trim().slice(0,9):'块')+'@'+Math.round(q.top)+'+'+Math.round(q.height);
}).join(' · '));
/* 6c) 横向溢出:本视口下必须为 0 */
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const over=document.documentElement.scrollWidth-document.documentElement.clientWidth;
if(over<=0) ok('横向溢出 = 0(scrollWidth '+document.documentElement.scrollWidth+' = clientWidth '+document.documentElement.clientWidth+')');
else bad('横向溢出 '+over+'px');
let overWorst=null;
Object.keys(PAGES).forEach(p=>{
applyScene({name:p, set:()=>{ S.route=p; }});
const o=document.documentElement.scrollWidth-document.documentElement.clientWidth;
if(overWorst===null||o>overWorst[1]) overWorst=[p,o];
});
if(overWorst[1]<=0) ok('八页逐页查横向溢出:全为 0(最差 '+overWorst[0]+')');
else bad('有页面横向溢出:'+overWorst[0]+' 超出 '+overWorst[1]+'px');
/* 6d) 纵向:滚动容器只许是主区
🔴 2026-10-08 修的缺陷:外壳只抄了 html,body{overflow:hidden},却没约束高度链 ⇒
`.app` 跟着内容长高、`.view` 自己不滚、真正的滚动落到 `<body>` 上,而 body 是 hidden
⇒ 内容掉到折叠线以下就**永远拿不到**(实测 1440×900 下 P1 有 1543px 内容只有前 900px 可达)。
判据:`.app` 高 = 视口高;body 不承担滚动;内容更高时主区自己可滚。 */
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const vwEl=$('.view'), appEl=$('.app');
const appH=Math.round(appEl.getBoundingClientRect().height);
const vCan=vwEl.scrollHeight>vwEl.clientHeight+1;
const vOvf=getComputedStyle(vwEl).overflowY;
if(appH<=window.innerHeight+1 && document.body.scrollTop===0 && vOvf==='auto' && (vCan||vwEl.scrollHeight<=vwEl.clientHeight+1))
ok('纵向滚动只由主区承担:.app 高 '+appH+'px = 视口 '+window.innerHeight+'px;body 未承担滚动(scrollTop 0);主区 overflow-y '+vOvf+',clientH '+vwEl.clientHeight+'px / scrollH '+vwEl.scrollHeight+'px'+(vCan?'(内容更高 ⇒ 主区可滚,折叠线以下的块拿得到)':'(本视口内容没超,无需滚)'));
else bad('外壳高度链没约束住:.app '+appH+'px / 视口 '+window.innerHeight+'px;body.scrollTop '+document.body.scrollTop
+';主区 overflow-y '+vOvf+'、clientH '+vwEl.clientHeight+' / scrollH '+vwEl.scrollHeight+' ⇒ 折叠线以下的内容可能拿不到');
/* 版式诊断:把「首行之前占了多高」拆开登记 —— 密度不达标时要能一眼看出是顶栏折行、
导航变高还是主区内衬,⛔ 不许只报一个「装不下」了事。 */
const tbH=Math.round(document.querySelector('.topbar').getBoundingClientRect().height);
const navH=Math.round(document.querySelector('.sidebar').getBoundingClientRect().height);
const vPadTop=getComputedStyle(document.querySelector('.view')).paddingTop;
const navRow=getComputedStyle(document.querySelector('.sidebar')).flexDirection;
L.push('(附)P1 行高 '+(p1Fit?p1Fit.hs.join('/')+'px':'量不到')+' · P1 按钮高度集合 '+btnH.join('/')+'px · 量时 scrollY='+window.scrollY
+' · 顶栏高 '+tbH+'px · 导航占 '+navH+'px('+navRow+')· 主区内衬上 '+vPadTop
+' · innerWidth '+window.innerWidth+' − clientWidth '+document.documentElement.clientWidth
+' = '+(window.innerWidth-document.documentElement.clientWidth)+'px(窗口滚动条宽度)');
L.push('控制台报错:'+(CONSOLE_ERRORS.length?CONSOLE_ERRORS.join(' | '):'0 条'));
/* 还原 + 自检期间锁住全部异步重绘(防自检结果被 420ms/1500ms 的定时重绘覆盖)。
离开方式:点侧栏导航或「回到正常内容」—— 那两个入口会先把锁解开。 */
S.route='selftest';
ST.lock=true;
try{ history.replaceState(null,'','#/selftest'); }catch(e){}
const head='自检结果:PASS '+pass.length+' / FAIL '+fail.length;
return '<section class="block">'+
'<div class="block-head"><h2 class="grow">自检(#/selftest)</h2><span class="meta">'+esc(head)+'</span></div>'+
'<div class="st-out" id="selftest-out">'+esc(head)+'\n\n'+L.join('\n')+'</div>'+
'<div class="block" style="margin-top:var(--sp-24)"><div class="panel"><div class="row">'+
'<span class="meta">自检是原型自带的:它在真实场景里逐个点过每个可点元素,证明没有死按钮。</span>'+
'<button class="btn btn-sm" data-act="STATE-NORMAL">回到正常内容</button>'+
'</div></div></div></section>';
}
/* ---------- 启动 ---------- */
function renderSelftest(){
try{ return _renderSelftest(); }
catch(e){ return '<section class="block"><div class="block-head"><h2>自检没有跑完</h2></div><div class="st-out">'+
String((e&&e.stack)||e).replace(/</g,'&lt;')+'</div></section>'; }
}
if(!location.hash) location.hash='#/p/p1/ok';
parseHash(); paint();
</script>
</body>
</html>