Files
contentm_agent/执行会话/目标-根据1a需求文档生成MCN短视频整合营销-d85687/ui/mcn-workbench.html
T

2559 lines
185 KiB
HTML
Raw Normal View History

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