Files
contentm_agent/执行会话/.trash/20261008-d85687/_probe/st-640x900.html
T
WorkBuddy df56c2c137 初始化提交:contentm_agent 工作区全量快照
内容分四块:
1、产品规划产出 —— MCN 短视频整合营销工作台的①段五份(1a 需求/1b 竞品/1c 画像/1d 策略/1e 场景)、②段两份(2a 功能/2b 布局)、③段界面(DESIGN.md 契约与令牌表 + mcn-workbench.html 原型 + 实测/会诊/审查三份 + 23 张闸门截图)。
2、开源竞品调研 —— 5 个内容工作台项目的取证原始件与 1b 系列分析文档。
3、参考资料 —— 竞品视频抽帧 1145 张 + 2 个源视频 + 功能点截图。
4、机制侧 —— 协作脚本与状态台账、工作区记忆日志、抽帧/OCR 脚本。

.gitignore 只排运行时日志、脚本备份副本与一次性探针输出,其余按原样入库。
2026-10-08 08:13:02 +08:00

2003 lines
140 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;
--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:16px;
--c-input-h:36px; --c-tag-h:22px;
--nav-w:240px;
--tfast: .15s cubic-bezier(0.32,0.72,0,1);
}
@media (max-width:1024px){ :root{ --nav-w:200px; } }
*,*::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; }
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{ display:grid; grid-template-columns:var(--nav-w) minmax(0,1fr); min-height:100vh; }
.sidebar{
background:var(--paper); border-right:1px solid var(--line-soft);
padding:var(--sp-24) var(--mg-12) var(--sp-24) var(--mg-12);
display:flex; flex-direction:column; gap:var(--sp-24); min-width:0;
}
.brand{ padding:0 var(--mg-8); }
.brand-mark{ font-size:var(--fs-16); font-weight:600; letter-spacing:.01em; }
.nav-group{ display:flex; flex-direction:column; gap:var(--mg-4); }
.nav-group-title{
display:flex; align-items:center; width:100%; height:32px; padding:0 var(--mg-8);
border:0; border-radius:var(--raw-radius-row); background:transparent; text-align:left;
font-family:inherit; font-size:var(--fs-12); color:var(--ink-mute); font-weight:600; cursor:pointer;
transition:background-color var(--tfast);
}
.nav-group-title:hover{ background:var(--surface-soft); }
.nav-group-items{ display:flex; flex-direction:column; gap:var(--mg-2); padding-left:var(--mg-8); }
.nav-item{
display:flex; align-items:center; justify-content:space-between; gap:var(--mg-8);
width:100%; height:36px; padding:0 var(--mg-12); border:0; border-left:3px solid transparent;
background:transparent; color:var(--ink); font-size:var(--fs-14); text-align:left;
border-radius:var(--raw-radius-row); cursor:pointer; min-width:0;
transition:background-color var(--tfast), border-color var(--tfast);
}
.nav-item:hover{ background:var(--surface-soft); }
.nav-item.is-active{ background:var(--surface-soft); border-left-color:var(--accent); font-weight:600; }
.nav-item .nav-count{ font-size:var(--fs-12); color:var(--ink-mute); font-variant-numeric:tabular-nums; }
.main{ display:flex; flex-direction:column; min-width:0; }
.topbar{
display:flex; align-items:center; gap:var(--sp-16); flex-wrap:wrap;
padding:var(--sp-16) 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; }
/* ⚠️ nowrap:不写这条,「演示 · 账号」会在中间折成两行,把顶栏顶到 70px ——
对外帧上肉眼可见(评审第一眼就点出来了),还白吃掉十来个像素的首屏高度。 */
.topbar-label{ font-size:var(--fs-12); color:var(--ink-mute); white-space:nowrap; }
.view{ 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;
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); }
.switch{ height:var(--c-input-h); padding:0 var(--mg-8); font-size:var(--fs-14); }
.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); }
/* ============ 结构件 ============ */
.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-8); border-bottom:1px solid var(--line); margin-bottom:var(--sp-16);
}
.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; }
.grid-main-side{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:var(--sp-24); align-items:start; }
.grid-cards{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--sp-16); }
/* 两栏带:把相邻两块并排。读序不变(左 → 右),只是不再一条道往下堆。
⛔ 不是增删板块:②段那份的顺序照旧,这里动的是「同一批块的摆法」。
起因见 3b-实测记录.md:单列往下堆时,P1 账号矩阵第 6 行掉到折叠线以下,密集档的
「一屏 ≥6 行」判据不达标。 */
.band{ display:grid; grid-template-columns:minmax(0,400px) minmax(0,1fr); gap:var(--sp-24); align-items:start; min-width:0; }
.band > .block{ margin-top:0; min-width:0; }
/* 统计条:一行放完几个数,⛔ 不用四张等高卡(那既占高又没主次) */
.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); }
/* 行式列表:行间 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{ box-shadow:inset 3px 0 0 var(--accent); }
.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:not(.is-dense) .primary-cell > *,
.list-row:not(.is-dense) .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)。
⛔ 同一个字段也不许在行里出现两次。 */
.list-row.is-dense{ grid-template-columns:auto minmax(0,1fr) auto; padding:var(--mg-6) var(--mg-8); }
.list-row.is-dense > span{ min-width:0; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.list-row.is-dense .dense-text strong{ font-weight:600; }
/* 宽屏的队列条目排两行:正文一行、账号与截止另起一行。
为什么:一行三段塞进 400px 的队列栏,正文必被截掉(实测「周五专题 · 3 个版本…」
「半夜解馋系列 · 5 条候选待…」),而队列存在的意义就是让人看清待办是什么。
窄屏(≤1024)⛔ 不这么排 —— 那里队列下面就是矩阵,多一行会把矩阵挤出首屏。 */
@media (min-width:1025px){
.list-row.is-dense{ grid-template-columns:auto minmax(0,1fr); row-gap:var(--mg-2); }
.list-row.is-dense > .meta{ grid-column:2; }
}
.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; }
/* 环节进度 */
.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{ display:flex; align-items:center; gap:var(--mg-8); }
.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)); }
/* ⚠️ 两栏带必须在 1024 就落回单列:1024px 扣掉侧栏与内边距只剩约 720px,
再横着劈两半,矩阵列表只剩不到 300px,每行会竖着折成三行 —— 实测踩过。 */
.band{ grid-template-columns:minmax(0,1fr); }
/* 顶栏在 1024 以下会折成两行:实测 768px 下控件合计 631px、可用只有 568px,
折行白占 41px 高,直接吃掉一行列表。两处收:间距 16→8、隐掉「演示 ·」文字标签
(下拉本身留着,它们是看原型用的演示控件、不是产品标签,完整含义在 title 里)。 */
.topbar{ gap:var(--mg-8); }
.topbar-label{ display:none; }
/* 🔴 列表行在窄屏只做一件事:**不许换行**。
为什么:三列形态下每列都被压窄,长一点的文字自己折成第二行,于是行高随文字长短在
66/82/100px 之间乱跳(实测 768px 宽:100/100/66/82/82),扫列表时没有节奏。
早先试过「让名称和小字各占一行」的堆叠修法 —— 那是错的:网格行距 16px 一加,
行高反而涨到 101px,比原来的 66px 还肥,密度倒退。已退回三列形态。
(「不许换行」这条规则本身定义在上面 .list-row 基础样式里,全宽度生效。) */
}
@media (max-width:640px){
.app{ grid-template-columns: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;
border-right:0; border-bottom:1px solid var(--line-soft);
padding:var(--mg-8) var(--mg-8); position:sticky; top:0; z-index:6; background:var(--paper);
}
.brand{ display:none; }
.nav-group{ flex:0 0 auto; flex-flow:row nowrap; align-items:center; gap:var(--mg-4); }
.nav-group-title{ flex:0 0 auto; white-space:nowrap; width:auto; padding:0 var(--mg-8); border-bottom:3px solid transparent; border-radius:999px; }
.nav-group-items{ flex:0 0 auto; flex-flow:row nowrap; gap:var(--mg-4); padding-left:0; }
.nav-item{ flex:0 0 auto; width:auto; white-space:nowrap; border-left:0; border-bottom:3px solid transparent; border-radius:999px; }
.nav-item.is-active{ border-left:0; border-bottom-color:var(--accent); }
.view{ padding:var(--sp-16) var(--sp-16) var(--sp-64); }
.topbar{ padding:var(--mg-12) var(--sp-16); }
.grid-main-side{ grid-template-columns:minmax(0,1fr); }
.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">整合营销工作台</span></div>
<nav id="nav">
<div class="nav-group">
<button class="nav-group-title" data-act="G-01" data-page="p2">找选题</button>
<div class="nav-group-items">
<button class="nav-item " data-act="G-05" data-page="p2">
<span>选题雷达</span><span class="nav-count">5</span>
</button>
<button class="nav-item " data-act="G-05" data-page="p3">
<span>资产库</span><span class="nav-count">6</span>
</button>
</div>
</div>
<div class="nav-group">
<button class="nav-group-title" data-act="G-02" data-page="p4">走创作流水线</button>
<div class="nav-group-items">
<button class="nav-item " data-act="G-05" data-page="p4">
<span>创作台</span>
</button>
<button class="nav-item " data-act="G-05" data-page="p5">
<span>发布中心</span><span class="nav-count">3</span>
</button>
</div>
</div>
<div class="nav-group">
<button class="nav-group-title" data-act="G-03" data-page="p1">看矩阵复盘</button>
<div class="nav-group-items">
<button class="nav-item " data-act="G-05" data-page="p1">
<span>矩阵总览</span><span class="nav-count">5</span>
</button>
<button class="nav-item " data-act="G-05" data-page="p7">
<span>复盘台</span><span class="nav-count">2</span>
</button>
<button class="nav-item is-active" data-act="G-05" data-page="p8">
<span>记录与对账</span><span class="nav-count">5</span>
</button>
</div>
</div>
<div class="nav-group">
<button class="nav-group-title" data-act="G-04" data-page="p6">账号画像(跨组)</button>
<div class="nav-group-items">
<button class="nav-item " data-act="G-05" data-page="p6">
<span>账号画像</span>
</button>
</div>
</div></nav>
</aside>
<div class="main">
<header class="topbar">
<!-- 下面两个下拉是「看原型用的」,不是产品功能:一个切账号上下文,一个把当前页掰到六态上。
标签直接写「演示」,免得被当成产品里的控件。 -->
<div class="demo" title="原型演示控件,不是产品功能">
<span class="topbar-label">演示 · 账号</span>
<select class="select switch" id="account-pick" data-act="G-07" aria-label="切换演示账号"><option value="a1" selected="">深夜食堂笔记</option><option value="a2">阿元的下班饭</option><option value="a3">阳台种菜日记</option><option value="a4">校门口那家店</option><option value="a5">咖啡因实验室</option></select>
</div>
<div class="demo" title="原型演示控件,不是产品功能">
<span class="topbar-label">演示 · 状态</span>
<select class="select switch" id="state-pick" data-act="G-09" aria-label="切换演示状态"><option value="ok" selected="">正常</option><option value="loading">加载中</option><option value="empty">空</option><option value="error">出错</option><option value="disabled">条件未满足</option><option value="noperm">无权限</option></select>
</div>
<div class="spacer"></div>
<button class="btn btn-sm" data-act="G-10">全局检索</button>
<button class="btn btn-sm" data-act="G-06">随手记</button>
<button class="btn btn-sm" data-act="G-08">假设 2 项</button>
</header>
<main class="view" id="view"><section class="block"><div class="block-head"><h2 class="grow">自检(#/selftest)</h2><span class="meta">自检结果:PASS 80 / FAIL 1</span></div><div class="st-out" id="selftest-out">自检结果:PASS 80 / FAIL 1
<span class="st-pass">PASS</span> 路由 #/p/p1 渲染出 5 个顶层节点
<span class="st-pass">PASS</span> 路由 #/p/p2 渲染出 6 个顶层节点
<span class="st-pass">PASS</span> 路由 #/p/p3 渲染出 4 个顶层节点
<span class="st-pass">PASS</span> 路由 #/p/p4 渲染出 6 个顶层节点
<span class="st-pass">PASS</span> 路由 #/p/p5 渲染出 5 个顶层节点
<span class="st-pass">PASS</span> 路由 #/p/p6 渲染出 4 个顶层节点
<span class="st-pass">PASS</span> 路由 #/p/p7 渲染出 5 个顶层节点
<span class="st-pass">PASS</span> 路由 #/p/p8 渲染出 4 个顶层节点
<span class="st-pass">PASS</span> 六态 × 八页 = 48 个组合,全部渲染出内容
<span class="st-pass">PASS</span> 侧栏二级页项 8 个,与八页一致
<span class="st-pass">PASS</span> 清单里每个 ID 都在页面上出现过(75 个,无悬空声明)
<span class="st-pass">PASS</span> 页面上每个 data-act 都在清单里(共 703 次出现,无未登记的可点元素)
<span class="st-pass">PASS</span> 清单里 71 个 ID 都真驱动过并给出可见反馈,0 个死控件(22 个场景,每个实例各重置一次)
<span class="st-pass">PASS</span> 当刻被禁用的控件 4 个(有 disabled 属性:按契约在特定条件下不可点):P4-03, P5-06, P6-03, R-02
<span class="st-pass">PASS</span> 清单里每个 ID 都至少被驱动过一次(无「从未跑过」的条目)
<span class="st-pass">PASS</span> 侧栏 8 项逐个点过,每一项都跳到了自己标的那一页
<span class="st-pass">PASS</span> 五处「输入后才可用」的联动都通:其中 4 处在空输入时确实是禁用的 → 输入后转可用 → 点了有反应
<span class="st-pass">PASS</span> 工具型纪律:零 .eyebrow / 零 .lead / 零 .hero
<span class="st-pass">PASS</span> 零 @keyframes(工具型不做入场编排)
<span class="st-pass">PASS</span> 骨架冻结:八页 37 个板块的数量与顺序与 ②段 2b「板块清单」逐位一致,⛔ 未增删挪
<span class="st-pass">PASS</span> 结构 ①:八页各一个 h1,标题逐级不跳;每个板块都有文字标题
<span class="st-pass">PASS</span> 结构 ②:字号 12/14/16/18/24px 共 5 档,相邻差 2/2/2/6,正文档 14–16 在用
<span class="st-pass">PASS</span> 结构 ④:逐页查过,首屏同屏主档按钮 ≤1 个;本页实底按钮 1 种
<span class="st-pass">PASS</span> 交互态与可读性::hover / :focus-visible 都在且没被 outline:none 抹掉;tabular-nums、prefers-reduced-motion、防溢出三件套齐全
<span class="st-pass">PASS</span> 阴影:面板/卡片都用多层叠层,无单层死黑重阴影
<span class="st-pass">PASS</span> 间距:flex/grid 的 gap 取值全部落在登记刻度内(9 个刻度)
<span class="st-pass">PASS</span> 浮层「随手记」中性关闭出口 = 1(右上 ×)|另有决策型关闭 1 个(R-03);遮罩是「点层外」通道,不计作出口
<span class="st-pass">PASS</span> 浮层「随手记」打开后焦点在层内(当前=X-close)
<span class="st-pass">PASS</span> 浮层「随手记」连按 8 次 Tab,焦点始终锁在层内
<span class="st-pass">PASS</span> 浮层「随手记」Esc 能关
<span class="st-pass">PASS</span> 浮层「随手记」关掉后焦点回到触发件 G-06
<span class="st-pass">PASS</span> 浮层「随手记」点遮罩(层外空白)能关
<span class="st-pass">PASS</span> 浮层「全局检索」中性关闭出口 = 1(右上 ×)|另有决策型关闭 0 个;遮罩是「点层外」通道,不计作出口
<span class="st-pass">PASS</span> 浮层「全局检索」打开后焦点在层内(当前=X-close)
<span class="st-pass">PASS</span> 浮层「全局检索」连按 8 次 Tab,焦点始终锁在层内
<span class="st-pass">PASS</span> 浮层「全局检索」Esc 能关
<span class="st-pass">PASS</span> 浮层「全局检索」关掉后焦点回到触发件 G-10
<span class="st-pass">PASS</span> 浮层「全局检索」点遮罩(层外空白)能关
<span class="st-pass">PASS</span> 浮层「两处假设说明」中性关闭出口 = 1(右上 ×)|另有决策型关闭 0 个;遮罩是「点层外」通道,不计作出口
<span class="st-pass">PASS</span> 浮层「两处假设说明」打开后焦点在层内(当前=X-close)
<span class="st-pass">PASS</span> 浮层「两处假设说明」连按 8 次 Tab,焦点始终锁在层内
<span class="st-pass">PASS</span> 浮层「两处假设说明」Esc 能关
<span class="st-pass">PASS</span> 浮层「两处假设说明」关掉后焦点回到触发件 G-08
<span class="st-pass">PASS</span> 浮层「两处假设说明」点遮罩(层外空白)能关
<span class="st-pass">PASS</span> 浮层「资产选择器」中性关闭出口 = 1(右上 ×)|另有决策型关闭 0 个;遮罩是「点层外」通道,不计作出口
<span class="st-pass">PASS</span> 浮层「资产选择器」打开后焦点在层内(当前=X-close)
<span class="st-pass">PASS</span> 浮层「资产选择器」连按 8 次 Tab,焦点始终锁在层内
<span class="st-pass">PASS</span> 浮层「资产选择器」Esc 能关
<span class="st-pass">PASS</span> 浮层「资产选择器」关掉后焦点回到触发件 P4-04
<span class="st-pass">PASS</span> 浮层「资产选择器」点遮罩(层外空白)能关
<span class="st-pass">PASS</span> 浮层「版本列表」中性关闭出口 = 1(右上 ×)|另有决策型关闭 0 个;遮罩是「点层外」通道,不计作出口
<span class="st-pass">PASS</span> 浮层「版本列表」打开后焦点在层内(当前=X-close)
<span class="st-pass">PASS</span> 浮层「版本列表」连按 8 次 Tab,焦点始终锁在层内
<span class="st-pass">PASS</span> 浮层「版本列表」Esc 能关
<span class="st-pass">PASS</span> 浮层「版本列表」关掉后焦点回到触发件 P4-07
<span class="st-pass">PASS</span> 浮层「版本列表」点遮罩(层外空白)能关
<span class="st-pass">PASS</span> 浮层「发布二次确认」中性关闭出口 = 1(右上 ×)|另有决策型关闭 1 个(P5-10);遮罩是「点层外」通道,不计作出口
<span class="st-pass">PASS</span> 浮层「发布二次确认」打开后焦点在层内(当前=X-close)
<span class="st-pass">PASS</span> 浮层「发布二次确认」连按 8 次 Tab,焦点始终锁在层内
<span class="st-pass">PASS</span> 浮层「发布二次确认」Esc 能关
<span class="st-pass">PASS</span> 浮层「发布二次确认」关掉后焦点回到触发件 P5-08
<span class="st-pass">PASS</span> 浮层「发布二次确认」点遮罩(层外空白)能关
<span class="st-pass">PASS</span> 浮层「核销二次确认」中性关闭出口 = 1(右上 ×)|另有决策型关闭 1 个(CHECKOUT-CANCEL);遮罩是「点层外」通道,不计作出口
<span class="st-pass">PASS</span> 浮层「核销二次确认」打开后焦点在层内(当前=X-close)
<span class="st-pass">PASS</span> 浮层「核销二次确认」连按 8 次 Tab,焦点始终锁在层内
<span class="st-pass">PASS</span> 浮层「核销二次确认」Esc 能关
<span class="st-pass">PASS</span> 浮层「核销二次确认」关掉后焦点回到触发件 P8-03
<span class="st-pass">PASS</span> 浮层「核销二次确认」点遮罩(层外空白)能关
--- 数值取证(布局视口 640 × 900 ;window.innerWidth 640)---
<span class="st-pass">PASS</span> 对比度 --warn 对 --surface = 5.93:1(契约 ≥4.5:1)
<span class="st-pass">PASS</span> 对比度 --accent-text 对 --surface = 4.76:1(契约 ≥4.5:1)
<span class="st-pass">PASS</span> 对比度 --ink-faint 对 --surface = 3.55:1(契约 ≥3:1)
<span class="st-pass">PASS</span> 强调色填充面积占比:八页最高 创作台 = 1.18%(1 个填充块,契约 ≤5%;不含 3px 左侧标条 / 描边 / 文字色)
量尺寸时 scrollY = 0(P1)
<span class="st-pass">PASS</span> 量尺寸前页面停在滚动零点(P1):rect 取到的是相对视口的真值
<span class="st-fail">FAIL</span> P1 账号矩阵一屏只能装下 5 行(首行顶 532px、行距 67px、视口高 900px),低于契约 6 行/900px 高
<span class="st-pass">PASS</span> P1 账号矩阵行高齐:67–68px,差 ≤2px(没有哪一行被状态标或长文字撑高)
<span class="st-pass">PASS</span> P1 实际在用字号档 5 档:12px / 14px / 16px / 18px / 24px(契约 ≥3)
<span class="st-pass">PASS</span> 视觉层间距 32px / 块内相邻行分隔 1px(发丝线,行本身不套框)⇒ 比值 32 ≥2(4 个视觉层)
P1 各块落位(顶 / 高):今日待处理@186+257 · 账号矩阵列表@467+417 · 异常值提示@916+215 · 本月表现最好的一条@1163+122 · 全局检索与随手记@1317+113(视口高 900;并排的两块顶相同)
量尺寸时 scrollY = 0(P2)
<span class="st-pass">PASS</span> 量尺寸前页面停在滚动零点(P2):rect 取到的是相对视口的真值
<span class="st-pass">PASS</span> P2 对标内容列表:首屏从 439px 起,行距 67px,一屏装得下 6 行(契约 ≥6 行/900px 高,实测宽 640)|示意数据 5 条 ⇒ 实际可见 5 行(数据用尽,非被截断)
P2 各块落位(顶 / 高):对标监控概览@202+139 · 对标内容列表@373+417 · 评论区洞察@822+108 · 选题库@962+330 · 灵感速记@1324+113
<span class="st-pass">PASS</span> 横向溢出 = 0(scrollWidth 640 = clientWidth 640)
<span class="st-pass">PASS</span> 八页逐页查横向溢出:全为 0(最差 p1)
(附)P1 行高 67/67/67/68/67px · P1 按钮高度集合 32px · 量时 scrollY=0 · 顶栏高 61px · 导航占 53px(row)· 主区内衬上 16px · innerWidth 640 − clientWidth 640 = 0px(窗口滚动条宽度)
控制台报错:0 条</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></main>
</div>
</div>
<div id="layer-root"></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,
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:'记录与对账'
};
const NAV = [
{group:'找选题', act:'G-01', items:[['p2','选题雷达'],['p3','资产库']]},
{group:'走创作流水线', act:'G-02', items:[['p4','创作台'],['p5','发布中心']]},
{group:'看矩阵复盘', act:'G-03', items:[['p1','矩阵总览'],['p7','复盘台'],['p8','记录与对账']]},
{group:'账号画像(跨组)', act:'G-04', items:[['p6','账号画像']]}
];
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; }
/* ---------- 导航 ---------- */
function renderNav(){
$('#nav').innerHTML = NAV.map(g=>`
<div class="nav-group">
<button class="nav-group-title" data-act="${g.act}" data-page="${g.items[0][0]}">${esc(g.group)}</button>
<div class="nav-group-items">
${g.items.map(([id,label])=>`
<button class="nav-item ${S.route===id?'is-active':''}" data-act="G-05" data-page="${id}">
<span>${esc(label)}</span>${navCount(id)}
</button>`).join('')}
</div>
</div>`).join('');
}
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>`; }
const ASSUME_TAG='<span class="tag tag-assume">假设</span>';
/* ============================ 八页 ============================ */
function pageP1(){
const todos=TODOS.map(t=>`
<li><div class="list-row is-dense" data-act="P1-01" data-to="${t.to}" role="button" tabindex="0" title="${esc(t.text)}|${esc(t.acct)} · 截止 ${esc(t.due)}">
<span>${tag(t.kind,'')}</span>
<span class="dense-text"><strong>${esc(t.text)}</strong></span>
<span class="meta">${esc(t.acct)} · ${esc(t.due)}</span>
</div></li>`).join('');
/* ⚠️「数据异动」状态标放**名称旁**,⛔ 不放操作列。
放操作列会把那一行的 auto 列撑宽,于是只有这一行的文字折行 ⇒ 行高忽高忽低
(改前实测:第 1 行 82px、其余 66px,扫列表时节奏是乱的)。
状态标贴着名称也顺扫读顺序;操作列只剩按钮,列宽自然一致。 */
const rows=ACCOUNTS.map(a=>`
<li><div class="list-row" data-act="P1-02" data-acct="${a.id}" role="button" tabindex="0" title="${esc(a.name)}|${esc(a.platform)} · ${a.posts} 条内容|今日该发:${esc(a.today)}|${esc(a.posted)} · ${esc(a.perf)}">
<span class="primary-cell"><span class="name-row"><strong>${esc(a.name)}</strong>${a.anom?tag('数据异动','tag-warn'):''}</span><span class="meta">${esc(a.platform)} · ${a.posts} 条内容</span></span>
<span class="secondary-cell"><span>今日该发:${esc(a.today)}</span><span>${esc(a.posted)} · ${esc(a.perf)}</span></span>
<span class="action-cell"><span data-act="P1-03" data-acct="${a.id}" class="btn btn-ghost" role="button" tabindex="0">看复盘</span></span>
</div></li>`).join('');
const anom=ACCOUNTS.filter(a=>a.anom);
return `
${head('P1 矩阵总览', ASSUME_NOTE_P1, 1)}
<div class="band">
<section class="block">
${head('今日待处理', `<span class="meta">${TODOS.length} 项 · 按截止时间排</span>`)}
<div class="panel"><ul class="list">${todos}</ul></div>
</section>
<section class="block">
${head('账号矩阵列表', `<span class="meta">${ACCOUNTS.length} 个账号 · 按今日待办排</span>`)}
<div class="panel"><ul class="list">${rows}</ul></div>
</section>
</div>
<section class="block">
${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 class="block">
${head('本月表现最好的一条', '')}
<div class="panel"><div class="row">
<div style="flex:1 1 240px;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>
<section class="block">
${head('全局检索与随手记', '')}
<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></div>
</section>`;
}
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>`)}
<div class="panel"><div class="row" style="gap:var(--sp-32)">
<span class="stat"><span class="meta">在监控账号</span><span class="num">3</span></span>
<button class="stat" data-act="P2-01" ${low?'':'disabled'}><span class="meta stat-label">低粉爆款</span><span class="num">${low}</span></button>
<button class="stat" data-act="P2-02" ${anom?'':'disabled'}><span class="meta stat-label">异常值</span><span class="num">${anom}</span></button>
<span class="stat"><span class="meta">近三天更新</span><span class="num">${BENCH.length}</span></span>
</div></div>
${zone!=='all'?`<p class="meta" style="margin-top:var(--mg-8)">列表已按「${esc(zone)}」筛过 · 点概览里的数字可再筛</p>`:''}
</section>
<section class="block">
${head('对标内容列表', `<span class="meta">${list.length} 条 · 倍数=相对该号平时水平</span>`)}
<div class="panel"><ul class="list">${list.map(b=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(b.title)}</strong><span class="meta">${esc(b.acct)} · ${esc(b.when)}</span></span>
<span class="secondary-cell"><span class="num">${esc(b.mult)} ${esc(b.tag)}</span></span>
<span class="action-cell"><button class="btn btn-sm" data-act="P2-03" data-bench="${b.id}">发起拆解</button></span>
</div></li>`).join('')}</ul></div>
</section>
<section class="block">
${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 btn-sm" data-act="P2-06" data-c="${c.id}" ${c.state!=='idle'?'disabled':''}>采纳</button>
<button class="btn btn-sm" 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 class="block">
${head('选题库', `<span class="meta">${topicList.length} 条</span>`)}
<div class="panel">
<div class="row" style="margin-bottom:var(--sp-16)">
${['todo','writing','done'].map(g=>`<button class="btn btn-sm" data-act="P2-08" data-group="${g}" ${S.topicGroup===g?'style="border-color:var(--ink);font-weight:600"':''}>${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 btn-sm" data-act="P2-09" data-k="${t.id}" ${t.group==='done'?'disabled':''}>转入创作</button>
<button class="btn btn-sm" data-act="P2-10" data-k="${t.id}">拆解</button>
</span>
</div></li>`).join('')}</ul>
</div>
</section>
<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)}
<section class="block">
${head('分区切换', `<span class="meta">一次看一个分区</span>`)}
<div class="panel"><div class="row">
${zones.map(([z,l])=>`<button class="btn btn-sm" data-act="P3-01" data-zone="${z}" ${S.assetZone===z?'style="border-color:var(--ink);font-weight:600"':''}>${l} <span class="num">${S.assets.filter(a=>a.zone===z).length}</span></button>`).join('')}
</div></div>
</section>
<div class="block grid-main-side">
<section>
${head('条目列表', `<span class="meta">${list.length} 条</span>`)}
<div class="panel"><ul class="list">${list.map(a=>`
<li><div class="list-row ${sel&&a.id===sel.id?'is-active':''}" data-act="P3-02" data-a="${a.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(a.text)}</strong><span class="meta">${a.src?('出处:'+esc(a.src)):'没有出处'}</span></span>
<span class="secondary-cell"><span>${esc(a.state)} · ${esc(a.time)}</span></span>
<span class="action-cell">${a.draft?tag('草稿','tag-warn'):''}</span>
</div></li>`).join('')}</ul></div>
</section>
<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>
</div>
<section class="block">
${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>`;
}
function pageP4(){
const out=S.stepOut[S.step]||'';
return `
${head('P4 创作台','',1)}
<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>
<section class="block">
${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>
<section class="block">
${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 btn-sm" 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 class="block">
${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 btn-sm" data-act="P4-08" data-v="${v.id}">切为当前版</button>`}</span>
</div></li>`).join('')}</ul>`:`<p class="meta">共 ${S.versions.length} 个版本。展开可看每个版本改了什么、切回旧版。</p>`}
</div>
</section>`;
}
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)}
<section class="block">
${head('目标选择', `<button class="btn btn-ghost" data-act="P5-01">${S.targetOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.targetOpen?`
<p class="field-label">发给哪些账号</p>
<div class="row" style="margin-bottom:var(--sp-16)">${ACCOUNTS.map(a=>`<button class="btn btn-sm" data-act="P5-02" data-acct="${a.id}" ${S.targets.accounts.includes(a.id)?'style="border-color:var(--ink);font-weight:600"':''}>${esc(a.name)}</button>`).join('')}</div>
<p class="field-label">发给哪些平台</p>
<div class="row">${['抖音','小红书','视频号','B 站'].map(p=>`<button class="btn btn-sm" data-act="P5-03" data-p="${p}" ${S.targets.platforms.includes(p)?'style="border-color:var(--ink);font-weight:600"':''}>${esc(p)}</button>`).join('')}</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="row" style="margin-bottom:var(--sp-16)">${vers.map(v=>`<button class="btn btn-sm" data-act="P5-04" data-v="${v.key}" ${S.verSel===v.key?'style="border-color:var(--ink);font-weight:600"':''}>${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('发布记录', '')}
<div class="panel"><div class="row">
<button class="btn btn-sm" data-act="P5-11">去记录与对账</button>
<span class="meta">目前 ${RECORDS.length} 条记录。</span>
</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)}
<div class="block grid-main-side">
<section>
${head('账号列表', `<span class="meta">${ACCOUNTS.length} 个</span>`)}
<div class="panel"><ul class="list">${ACCOUNTS.map(a=>`
<li><div class="list-row ${a.id===acc.id?'is-active':''}" data-act="P6-01" data-acct="${a.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(a.name)}</strong><span class="meta">${esc(a.platform)}</span></span>
<span class="secondary-cell"><span>${a.posts} 条内容</span></span>
<span class="action-cell"></span>
</div></li>`).join('')}</ul></div>
</section>
<section>
${head('画像六维', `<span class="meta">${esc(acc.name)}</span>`)}
<div class="panel">
${fields.map(([k,label])=>`
<div style="margin-bottom:var(--sp-16)">
<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>
</div>
<section class="block">
${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 class="block">
${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>`;
}
function pageP7(){
const r=REVIEWS.find(x=>x.id===S.reviewSel)||REVIEWS[0];
const acc=ACCOUNTS.find(a=>a.name===r.acct)||ACCOUNTS[0];
return `
${head('P7 复盘台','',1)}
<div class="block grid-main-side">
<section>
${head('待复盘列表', `<span class="meta">${REVIEWS.length} 条</span>`)}
<div class="panel"><ul class="list">${REVIEWS.map(x=>`
<li><div class="list-row ${x.id===r.id?'is-active':''}" data-act="P7-01" data-r="${x.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(x.text)}</strong><span class="meta">${esc(x.acct)} · ${esc(x.day)} 发布</span></span>
<span class="secondary-cell"><span>${x.data==='到了'?'数据已回':'数据还没回来'}</span></span>
<span class="action-cell">${x.data==='到了'?tag('可复盘','tag-ok'):tag('等数据','tag-warn')}</span>
</div></li>`).join('')}</ul></div>
</section>
<section>
${head('这一条的表现数据', `<span class="meta">${esc(r.day)} 回读</span>`)}
<div class="panel">
${r.data==='到了'?`
<div class="grid-cards" style="grid-template-columns:repeat(3,minmax(0,1fr))">
<div><p class="meta">播放</p><p class="num" style="font-size:var(--fs-20)">${esc(r.play)}</p></div>
<div><p class="meta">完播</p><p class="num" style="font-size:var(--fs-20)">${esc(r.fin)}</p></div>
<div><p class="meta">三连率</p><p class="num" style="font-size:var(--fs-20)">${esc(r.tri)}</p></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>
<section class="block">
${head('归因结论', `<span class="meta">这一栏由人来定,机器只给数据</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 class="block">
${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>
<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)}
<div class="block grid-main-side">
<section>
${head('发布记录列表', `<span class="meta">${RECORDS.length} 条 · 只追加不改写</span>`)}
<div class="panel"><ul class="list">${RECORDS.map(x=>`
<li><div class="list-row ${x.id===r.id?'is-active':''}" data-act="P8-01" data-rec="${x.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(x.text)}</strong><span class="meta">${esc(x.by)} · ${esc(x.time)} · ${esc(x.ver)}</span></span>
<span class="secondary-cell"><span>${esc(x.state)}</span></span>
<span class="action-cell">${x.state==='已发布'?tag('可核销',''):tag(x.state,'tag-warn')}</span>
</div></li>`).join('')}</ul></div>
</section>
<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>
</div>
<section class="block">
${head('对账核对区', `<span class="meta">内容 · 发布记录 · 收支 三样对得上</span>`)}
<div class="panel">
<div class="table-wrap"><table class="ds-table">
<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>
<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 btn-sm" 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();
/* 🔴 手机形态(≤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 ap=$('#account-pick'); if(ap){ ap.innerHTML=ACCOUNTS.map(a=>`<option value="${a.id}" ${a.id===S.account?'selected':''}>${esc(a.name)}</option>`).join(''); }
const sp=$('#state-pick'); if(sp){ sp.innerHTML=STATES.map(([v,l])=>`<option value="${v}" ${v===S.pageState?'selected':''}>${esc(l)}</option>`).join(''); }
const v=$('#view');
if(S.route==='selftest'){ v.innerHTML=renderSelftest(); return; }
/* ⚠️ 这里⛔ 不再另写一句「这里是 X 的 empty 态」——那是给做原型的人看的黑话,
而上面 statePanel() 已经把这一页这个状态的用户向说明铺好了(STATE_COPY 逐页不同)。
评审看的就是这句黑话。演示控件(切状态、回正常)留下的按钮已经在下面这一行里,
它们属交互清单登记在册的 G-09 / 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':()=>go('p2'), 'G-02':()=>go('p4'), 'G-03':()=>go('p1'), 'G-04':()=>go('p6'),
'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-07':null, /* select,由 change 事件处理 */
'G-08':()=>{ S.layer='assume'; paint(); },
'G-09':null, /* select */
'G-10':()=>{ S.layer='search'; paint(); setTimeout(()=>{const q=$('#q'); if(q) q.focus();},0); },
'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,
'P4-03':()=>{ S.stepBusy=true; paint(); setTimeout(()=>{ S.stepBusy=false; S.stepOut[S.step]=(S.stepOut[S.step]||'')||'机器出的一版,写在上面,你可以改'; paint(); },420); },
'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(); },
/* 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; }
});
document.addEventListener('change', ev=>{
const t=ev.target.closest('[data-act]'); if(!t) return;
if(t.dataset.act==='G-07'){ S.account=t.value; paint(); toast('已切到 '+t.value); }
if(t.dataset.act==='G-09'){ S.pageState=t.value; paint(); }
});
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 化的纯数据)
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}]; }},
{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。量尺寸前一律回到滚动零点。 */
window.scrollTo(0,0);
}
/* 板块骨架(冻结):逐页的板块**数量与顺序**= `prd/2b-界面布局.md` §二 的「板块清单(从上到下)」,
⛔ ③段不许增、删、挪。这里的串是②段的名字(去掉「(可点入)」这类注解)。
闸门比的是「数量 + 顺序 + 名字前缀」,⛔ 不是逐字相同 —— ②段的名字带注解,不是给界面抄的标题。
②段原文与这张表的对应关系由 `_tools/check_skeleton.py` 复核(改一边不改另一边会报红)。 */
const FROZEN={
p1:['P1 矩阵总览',['今日待处理','账号矩阵列表','异常值提示','本月表现最好的一条','全局检索与随手记入口']],
p2:['P2 选题雷达',['对标监控概览','对标内容列表','评论区洞察区','选题库','灵感速记入口']],
p3:['P3 资产库',['分区切换','条目列表','单条详情','引用入口']],
p4:['P4 创作台',['当前内容标识','环节进度','当前环节工作区','资产引用区','版本列表']],
p5:['P5 发布中心',['目标选择','各版本预览与逐版编辑','检查结果','确认发布','发布记录入口']],
p6:['P6 账号画像',['账号列表','画像六维','记忆条目','关联内容']],
p7:['P7 复盘台',['待复盘列表','这一条的表现数据','归因结论区','回写目标提示','历史复盘']],
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;
const navPages=new Set();
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);
});
document.querySelectorAll('[data-act="G-05"]').forEach(n=>navPages.add(n.dataset.page));
});
navPages.delete(undefined);
if(navPages.size===Object.keys(PAGES).length) ok('侧栏二级页项 '+navPages.size+' 个,与八页一致'); else bad('侧栏二级页项 '+navPages.size+' 个,不等于 8');
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=[];
Object.keys(PAGES).forEach(p=>{
applyScene(SCENES[0]);
const n=document.querySelector('[data-act="G-05"][data-page="'+p+'"]');
if(!n){ navBad.push(p+'(没这个入口)'); return; }
n.dispatchEvent(new MouseEvent('click',{bubbles:true}));
if(S.route!==p) navBad.push(p+'(点到「'+PAGES[S.route]+'」去了)');
});
if(navBad.length===0) ok('侧栏 8 项逐个点过,每一项都跳到了自己标的那一页');
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===0) ok('零 @keyframes(工具型不做入场编排)'); 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(' / '));
/* ④ 交互态与可读性:焦点环在、没被抹掉;数字列 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 y=window.scrollY;
L.push('量尺寸时 scrollY = '+y+'('+tag+')');
if(y===0) ok('量尺寸前页面停在滚动零点('+tag+'):rect 取到的是相对视口的真值');
else bad('量尺寸时页面被滚到 '+y+'px('+tag+')⇒ 下面的落位与行数全是假数,先复位再量');
};
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;
const rects=[...b.querySelectorAll('.list-row')].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.closest('.band')||b;
const units=[...new Set(blocks.map(unitOf))];
if(units.length>=2&&mx){
const gap=Math.round(units[1].getBoundingClientRect().top-units[0].getBoundingClientRect().bottom);
const rows=[...mx.querySelectorAll('.list-row')];
/* ⚠️ 别取第一行:列表第一行的上边框按设计不画(`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');
/* 版式诊断:把「首行之前占了多高」拆开登记 —— 密度不达标时要能一眼看出是顶栏折行、
导航变高还是主区内衬,⛔ 不许只报一个「装不下」了事。 */
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><span style="position: absolute; left: -99999px; top: 0px; width: 0px; height: 0px; color: var(--accent);"></span>
</body></html>