Files
contentm_agent/docs/pm/mcn-shortvideo-agent/ui/mcn-workbench.html
T

1880 lines
150 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!doctype html>
<html lang="zh-CN" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>MCN 短视频整合营销工作台 · 可点通原型 V2</title>
<style>
/* ============================================================
三层令牌:primitive(--t- / --raw-)→ semantic(无前缀)→ component(--c-)
定调来源:design-system-tiaoyue 亮色基线(9 页 clipper 实测)
⛔ 本文件不设 .eyebrow / .lead / .hero(工具型页面)
V2(2026-10-09):按新版 2a/2b 重做 —— 导航回三组+跨组;新增 Page Shell 一层;
撤销「功能入口」那一层;业务板块按新版 39 块重排。绑定规范见同目录 DESIGN.md。
============================================================ */
:root{
--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;
--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;
--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);
--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;
/* 侧栏宽度=**设计供给的基准形态 64px**(2026-10-10 用户令「左侧导航改回去」)。
依据:`design-system-tiaoyue/SKILL.md` 行 95 逐字「左侧栏(app-sidebar,宽 w-16)」⇒ w-16 = 64px;
加第八轮实测值(栏 64/内衬 3/圆角 100/项 56×78/字 12px-500-行高 16)。
⇒ 64px 栏里放不下「图标在左、名字在右」,故项改成「图标在上、名字在下」的竖排。
几何不在样式库内(见 DESIGN.md §1.1 ⑤),故宽度是③段的判断;只改这一处 `--nav-w` 即可推翻。 */
--nav-w:64px;
--tfast: .15s cubic-bezier(0.32,0.72,0,1);
}
/* 高度链三件套(缺一处 ⇒ 整页「滚不动」而不是「不滚」):
.app 定高 → .main 可缩 → .view 是唯一的滚动容器。 */
html, body { height:100%; overflow:hidden; }
*,*::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; }
:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
/* ============ 外壳:主区 + 侧区 ============ */
.app{ display:grid; grid-template-columns:var(--nav-w) minmax(0,1fr); grid-template-rows:minmax(0,1fr); height:100vh; }
.sidebar{
align-self:center; max-height:100%; min-height:0; min-width:0;
width:var(--nav-w); 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;
overflow-y:auto; overscroll-behavior:contain;
}
.brand{ display:flex; align-items:center; gap:var(--mg-8); padding:var(--mg-4) var(--mg-8) var(--mg-8); }
/* 64px 栏里放不下完整的品牌名(顶栏已经写全称)⇒ 侧栏只留标记,居中。 */
.sidebar .brand{ justify-content:center; }
.sidebar .brand-name{ display:none; }
.brand-mark{
width:28px; height:28px; border-radius:var(--mg-8); flex:0 0 auto;
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-14); font-weight:600; line-height:1;
}
.brand-name{ font-size:var(--fs-14); font-weight:600; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* 导航=**一层七项**(2026-10-09 用户令改层级;2026-10-10 用户令改回 64px 基准形态)。
首页 / 选题 / 创作 / 投放 / 复盘 / 账号 / 资产 —— ⛔ 没有组标、⛔ 没有跨组线、⛔ 没有第二层。
项本身仍是药丸(`.nav-item`);64px 栏里放不下横排,故改成**竖排**:
图标在上(24px)、名字在下(两字常驻),说明走悬停提示(`title`)与读屏(`aria-label`)。 */
#nav{ display:flex; flex-direction:column; gap:var(--mg-2); min-width:0; }
.nav-item{
position:relative;
display:flex; flex-direction:column; align-items:center; justify-content:center; gap:var(--mg-6);
width:100%; height:78px; padding:var(--mg-8) 0; border:0; border-radius:999px; background:transparent;
color:var(--ink-mute); font-family:inherit; font-size:var(--fs-12); font-weight:500; line-height:16px;
cursor:pointer; min-width:0; text-align:center;
transition:background-color var(--tfast), color var(--tfast);
}
.nav-item:hover{ background:color-mix(in srgb, var(--ink) 5%, transparent); color:var(--ink); }
/* 选中态:全站只有一套画法 —— 整块浅紫底 + accent-text + 600(⛔ 不用单侧色条、⛔ 不用描边变黑) */
.nav-item.is-active{
background:color-mix(in srgb, var(--accent) 10%, var(--paper));
color:var(--accent-text); font-weight:600;
}
.nav-item svg{ width:24px; height:24px; display:block; flex:0 0 auto; }
.nav-item .nav-label{ white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
/* 计数徽章:竖排项里靠左上角不成,改悬在项右上角(不占竖排的两行) */
.nav-item .nav-count{
position:absolute; top:var(--mg-6); right:var(--mg-4); min-width:16px; height:16px; padding:0 var(--mg-4);
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;
}
.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;
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); }
/* 图标按钮:正方形、内衬 0(图标自己居中);用在页面标题行右侧的「刷新本页数据」。 */
.btn-icon{ width:var(--c-btn-h-sm); padding:0; }
.btn-icon svg{ width:18px; height:18px; display:block; }
/* 刷新按钮右对齐:`margin-left:auto` 把它推到标题行最右(`<p>` 副标题 `width:100%` 本就独占下一行)。 */
.shell-refresh{ margin-left:auto; }
.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; }
.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); }
/* ============ 结构件 ============ */
/* 页面公共框架(Page Shell):带上这三条硬规则 —— 不计业务板块数 / 不参与功能归属 / 不占冻结计数。
⛔ 它自己不带 .block、也不带 .block-head h2(机检就靠这一条把它排除在板块计数外)。 */
.page-shell{ display:flex; flex-direction:column; min-width:0; }
.page-shell-title{ display:flex; align-items:center; gap:var(--sp-16); flex-wrap:wrap; margin-bottom:var(--mg-12); }
.page-shell-sub{ width:100%; font-size:var(--fs-14); color:var(--ink-mute); }
/* 页面级动作不再独立占一行 —— 改挂页面标题行右侧的 icon 按钮(`.btn-icon` / `.shell-refresh`)。 */
.page-shell-aux{ display:flex; gap:var(--mg-8); flex-wrap:wrap; margin-bottom:var(--sp-16); }
.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; }
.grid-main-side{ display:grid; grid-template-columns:1.35fr .9fr; gap:var(--sp-24); align-items:start; margin-top:var(--sp-32); min-width:0; }
/* 并排的两块各自是独立业务板块(各自带 .block + data-obj,对象冻结靠它计数)。
它们进网格后不再需要块间外边距 —— 由网格 gap 负责,否则右列会被顶下去 32px。 */
.grid-main-side > .block{ margin-top:0; }
.grid-cards{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--sp-16); }
.stack{ display:flex; flex-direction:column; gap:var(--sp-16); min-width:0; }
.row{ display:flex; align-items:center; gap:var(--mg-8); flex-wrap:wrap; min-width:0; }
/* 统计条(数据摘要区 + P2 的对标监控概览共用同一套外观) */
.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); min-width:0; text-align:left; }
button.stat-item{ 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); }
/* 分段控件 —— 选中态全站只有一套画法(.is-on) */
.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 + .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);
}
li:first-child > .list-row{ border-top:0; }
.list-row:hover{ background:var(--surface-soft); }
.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; }
.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; }
.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); 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; }
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); }
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; }
table.ds-table.tbl-dense .tag{ height:20px; }
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; }
/* 状态面板 */
.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); }
.statebar{ display:flex; flex-wrap:wrap; gap:var(--mg-4); }
/* 浮层:可见关闭出口唯一(右上 ×) */
.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); min-width:0; flex-wrap:wrap;
}
.notice strong{ font-weight:600; }
.notice-warn strong{ color:var(--warn); }
.notice-err strong{ color:var(--err); }
.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:1000px){
.home-stats{ grid-template-columns:repeat(auto-fit,minmax(96px,1fr)); }
.grid-cards{ grid-template-columns:repeat(2,minmax(0,1fr)); }
.topbar{ gap:var(--mg-8); }
}
@media (max-width:900px){
.grid-main-side{ grid-template-columns:minmax(0,1fr); }
.view{ padding:var(--sp-16) var(--sp-16) var(--sp-64); }
}
@media (max-width:640px){
.app{ grid-template-columns:minmax(0,1fr); grid-template-rows:auto minmax(0,1fr); }
.view{ padding:var(--sp-16) var(--mg-12) var(--sp-64); }
/* 窄屏:侧栏整段还原成一条横滚标签条(圆角 / 描边 / 淡底 / 垂直居中全部取消) */
.sidebar{
align-self:stretch; max-height:none; width:auto; flex-flow:row nowrap; align-items:center;
gap:var(--mg-4); overflow-x:auto; scrollbar-width:none;
border:0; border-bottom:1px solid var(--line-soft); border-radius:0;
background:var(--paper); padding:var(--mg-8); position:sticky; top:0; z-index:6;
}
.sidebar::-webkit-scrollbar{ height:0; width:0; display:none; }
.brand{ display:none; }
#nav{ flex-flow:row nowrap; align-items:center; gap:var(--mg-4); flex:0 0 auto; }
/* 64px 栏的竖排项在这一档还原成横排小标签(一行装得下、条高才压得住) */
.nav-item{ flex-direction:row; width:auto; height:36px; padding:0 var(--mg-12); font-size:var(--fs-12); }
.nav-item svg{ width:16px; height:16px; }
.nav-item .nav-count{ position:static; margin-left:var(--mg-4); }
}
@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><span class="brand-name">MCN 工作台</span></div>
<nav id="nav"></nav>
</aside>
<div class="main">
<header class="topbar">
<span class="brand-name" style="font-family:var(--font-display);font-size:var(--fs-16)">MCN 短视频整合营销工作台</span>
<div class="spacer"></div>
<!-- 顶栏只留「随手记」:②段 2b §四 硬要求「随手记在任意页面一次点击内」。 -->
<button class="btn btn-sm" data-act="G-06">随手记</button>
</header>
<main class="view" id="view"></main>
</div>
</div>
<div id="layer-root"></div>
<script>
/* ============================================================
MCN 短视频整合营销工作台 · 可点通原型 V2
路由三段: #/p/<页>/<态>[/<浮层>] 例:#/p/p4/empty / #/p/p1/ok/quicknote
自检页: #/selftest
骨架=新版 2b-界面布局-新版.md §五(七页 39 块,冻结)。
============================================================ */
/* ---------- 数据 ---------- */
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:'p7',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'}
];
/* 选题库:装「对选题有帮助的任何信息」。两个正交维度 —— group=进度(待做/在写/已用),
type=类型(选题/对标线索/评论洞察/数据异动/灵感)。⛔ 不新增「待整理」状态、⛔ 不做多去向。
2026-10-10 用户口径:「选题库里面可以是对选题有帮助的任何信息 分类区分就好」。 */
const TOPIC_TYPES = ['选题','对标线索','评论洞察','数据异动','灵感'];
const TOPICS = [
{id:'k1',text:'一个人的晚饭怎么摆得不寒酸',group:'todo',type:'选题',src:'对标号 · 小食光',state:'待做'},
{id:'k2',text:'五千块月薪的一周菜谱',group:'todo',type:'评论洞察',src:'评论区洞察',state:'待做'},
{id:'k3',text:'冰箱清库存系列',group:'writing',type:'灵感',src:'灵感速记',state:'在写'},
{id:'k4',text:'夜宵摊的三个坑',group:'todo',type:'数据异动',src:'对标号 · 夜市老王',state:'待做'},
{id:'k5',text:'十分钟一餐的偷懒法',group:'done',type:'选题',src:'对标号 · 懒人厨房',state:'已用'},
{id:'k6',text:'换盆实录:土到底怎么配',group:'writing',type:'灵感',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:{定位:'把咖啡讲得不玄',风格:'实验对比 · 数据可视化',受众:'咖啡爱好者',偏好:'不吹器具、不做器材带货',记忆:['10-07 水温段太长,38 秒处开始掉']}
};
/* 🔴 2026-10-10 用户口径(账号是两个概念):**平台账号**=发布渠道,是**内容账号下的子项**(1 → N),
⛔ 不另立对象。每条带「平台 + 登录态 + 授权状态」。平台不写进画像 —— 画像五维只剩
定位/风格/受众/偏好与红线/长期记忆。 */
const PLATS = {
a1:[{id:'a1-p1',plat:'抖音',login:'已登录',auth:'已授权'},{id:'a1-p2',plat:'小红书',login:'已登录',auth:'待授权'}],
a2:[{id:'a2-p1',plat:'小红书',login:'已登录',auth:'已授权'}],
a3:[{id:'a3-p1',plat:'抖音',login:'未登录',auth:'未授权'}],
a4:[{id:'a4-p1',plat:'视频号',login:'已登录',auth:'已授权'}],
a5:[{id:'a5-p1',plat:'B 站',login:'已登录',auth:'已授权'}]
};
const CONTENT = {key:'k3',title:'冰箱清库存系列 · 第 3 条',step:2,stepOut:['选题:冰箱剩菜怎么配','思路:先给成品,再倒推三步','','','','','']};
/* 核心业务对象字典(11 个)—— 2a-产品功能-新版.md §二。板块归属的判据就是它。 */
const OBJECTS = ['Account','Benchmark','Topic','Asset','Content','ContentVersion','PublishTask','PublishRecord','Performance','Review','Campaign/Order'];
/* ---------- 状态 ---------- */
const S = {
route:'p1', pageState:'ok', account:'a1',
layer:null, noteDraft:'', searchQ:'',
topics:JSON.parse(JSON.stringify(TOPICS)), topicGroup:'todo', topicType:'all',
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,
aiVerdict:{}, aiOut:{},
/* F14:录制卡片与画板(成稿态之后) */
cardGen:false, cardSynced:false,
/* F7:母版展开成多个**平台账号**的版本(展开单位是平台账号,⛔ 不是「账号 × 平台」) */
expanded:false,
versions:JSON.parse(JSON.stringify(VERSIONS)), verSel:'v1', verDraft:null, verDirty:false,
/* F5:目标选择两级 —— accounts=勾中的内容账号,channels=勾中的平台账号(渠道,一版一路) */
targetOpen:false, targets:{accounts:['a1','a2'],channels:['a1-p1','a2-p1']},
checkState:'idle', confirmOpen:false, publishDone:false,
profileDirty:false, memoryOpen:false,
reviewSel:'r1', attrDraft:'', reviewVerdict:'', confirmed:false, wroteBack:false, historyOpen:false,
recordSel:'rec1', traceOpen:false, billingOpen:false, checkedOut:false,
toast:null
};
/* 🔴 2026-10-09 改(用户令「P8 放复盘」「当然是功能也要放过去」):
**P8 记录与对账不再是一页**,它的四个板块(发布记录 / 单条溯源 / 对账核对区 / 商单与收支)
整份搬进 P7 复盘台 ⇒ 页面表由八页收成**七页**,导航也跟着收成一层七项。 */
const PAGES = { p1:'矩阵总览', p2:'选题雷达', p3:'资产库', p4:'创作台', p5:'发布中心', p6:'账号画像', p7:'复盘台' };
const SHELL_SUB = {
p1:'一屏回答每个账号今天该发什么、发了什么、效果如何。',
p2:'找选题,并且把选中的那条推进下一步。',
p3:'统一管理资产条目;资产从哪来不限页,任何页产生的资产都能就地入库。',
p4:'一条内容从选题走到成稿,再从成稿展开成多个平台账号的版本。',
p5:'把成稿过来的版本过闸,由人确认发出;本页只管内容发布与排期。',
p6:'每个内容账号一份画像与记忆;它下面挂着 N 个平台账号,看和改都在这一页。',
p7:'把已发布内容的表现读回来写成这个账号的经验;发布留痕与商单收支也在这里核。'
};
/* 页面级动作「刷新本页数据」的文字标签(原 SP_LABEL 七条)已作废(2026-10-10 用户令):
按钮改成 icon ⇒ 语义交给 `title`(悬停)与 `aria-label`(读屏),见 `refreshBtn()`。 */
/* 导航:**一层七项**(2026-10-09 用户令改)。
用户原话逐字:「左侧导航应该是 首页 选题 创作 投放 复盘 账号」「P3 放导航 排最后」。
⇒ 三个组标(找选题 / 走创作流水线 / 看矩阵复盘)与跨组分隔**全去掉**,⛔ 不再有第二层。
项名与顺序照用户给的:首页 / 选题 / 创作 / 投放 / 复盘 / 账号 / **资产(排最后)**。
页与项的对应:首页→P1、选题→P2、创作→P4、投放→P5、复盘→P7(P8 已并入)、账号→P6、资产→P3。
⚠️ P5 的页面名仍是「发布中心」,导航里的名字按用户口径叫**投放** —— 这一页只管内容发布与排期,
⛔ 没有扩功能(页面副标题与目标选择块的说明里都写明了这一点)。 */
const NAV = [
{label:'首页',page:'p1'},
{label:'选题',page:'p2'},
{label:'创作',page:'p4'},
{label:'投放',page:'p5'},
{label:'复盘',page:'p7'},
{label:'账号',page:'p6'},
{label:'资产',page:'p3'}
];
const NAV_DESC = { p1:'五个账号的今日数据与待办', p2:'对标内容与选题库', p3:'资产条目的统一管理处', p4:'从选题到脚本到分镜', p5:'版本排期与发布留痕', p6:'内容账号口径、平台账号与长期记忆', p7:'复盘归因;发布留痕与收支对账也在这里' };
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:['还没有能复盘的内容','先有发布记录,才有复盘。','p5'],error:['数据读不到','可以手工录入这条的表现,归因照做;发布留痕与收支照常可查。'],disabled:['归因还没采纳','没有采纳的结论就不能回写,回写会改画像记忆。'],noperm:['利润列对你隐藏','你不是管理员,收支金额列不显示。其余列照常可看。']}
};
let toastTimer=null;
function toast(text){ S.toast=text; paint(); clearTimeout(toastTimer); toastTimer=setTimeout(()=>{ S.toast=null; paint(); },1500); }
/* ---------- 路由 ---------- */
const LAYER_IDS=['quicknote','assume','assetpick','confirm','checkout'];
function parseHash(){
const raw=(location.hash||'').replace(/^#\/?/,'');
const parts=raw.split('/').filter(Boolean);
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';
S.route=page; S.pageState=st;
S.layer=LAYER_IDS.includes(parts[3]||'') ? parts[3] : null;
}
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;'); }
/* ---------- 图标 ---------- */
const NAV_ICON = {
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"/>'
};
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 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}[id];
return n?'<span class="nav-count">'+n+'</span>':'';
}
/* 侧栏渲染:**一层七项**,⛔ 没有组标、⛔ 没有跨组线,也没有第二层。
块级动作(G-05)不变:点到哪一项,state 就切到它 data-page 那一页。 */
function navItem(it){
return '<button class="nav-item '+(S.route===it.page?'is-active':'')+'" data-act="G-05" data-page="'+it.page+'"'
+ ' aria-label="'+esc(PAGES[it.page])+'|'+esc(NAV_DESC[it.page])+'">'
+ navSvg(it.page,20)+'<span class="nav-label">'+esc(it.label)+'</span>'+navCount(it.page)+'</button>';
}
function renderNav(){
$('#nav').innerHTML = NAV.map(navItem).join('');
}
/* ---------- 通用结构件 ---------- */
/* 板块头:**业务板块**才用它(机检靠 .block .block-head h2 数板块)。 */
function blockHead(title,right){
return '<div class="block-head"><h2 class="grow">'+esc(title)+'</h2>'+(right||'')+'</div>';
}
/* 业务板块:带 data-obj(核心业务对象)—— 对象冻结的判据落点。
data-obj 取 11 对象字典里的名字;确实不产生对象的板块写 '—' 并给 data-obj-note 说明。 */
function block(title,obj,inner,right,objNote){
return '<section class="block" data-obj="'+esc(obj)+'"'+(objNote?' data-obj-note="'+esc(objNote)+'"':'')+'>'
+ blockHead(title,right) + inner + '</section>';
}
function tag(text,cls){ return '<span class="tag '+(cls||'')+'">'+esc(text)+'</span>'; }
const ASSUME_TAG='<button class="tag tag-assume" data-act="G-08" title="两处假设的复核人 / 时机 / 落点">假设</button>';
/* 页面公共框架(Page Shell):标题区(页面级动作=刷新 icon 挂标题行右侧)+ 数据摘要区(可选) + 辅助入口区(可选)。
⛔ 它自己不带 .block(所以不进板块计数),也不参与功能归属。 */
/* 页面级动作:刷新本页数据。icon 按钮,挂在页面标题行右侧、右对齐
(2026-10-10 用户令:「刷新按钮用 icon 按钮就行,一般和小标题一行、右对齐」)。
⛔ 不再用它当标签文字;语义交给 `title`(悬停)与 `aria-label`(读屏)。 */
function refreshBtn(page){
return '<button class="btn btn-sm btn-icon shell-refresh" data-act="SP-'+page.slice(1)+'"'
+ ' title="刷新本页数据" aria-label="刷新本页数据">'
+ '<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">'
+ '<path d="M20.5 12a8.5 8.5 0 1 1-2.5-6"/><path d="M20.5 4v5h-5"/></svg></button>';
}
function shell(page,opts){
const o=opts||{};
const stats=(o.stats&&o.stats.length)
? '<div class="home-stats">'+o.stats.map(x=>'<div class="stat-item"><span class="stat-value">'+x[0]+'</span><span class="stat-label">'+esc(x[1])+'</span></div>').join('')+'</div>'
: '';
const aux=o.aux?'<div class="page-shell-aux">'+o.aux+'</div>':'';
return '<div class="page-shell" data-shell="'+page+'">'
+ '<header class="page-shell-title"><h1 class="grow">'+esc(PAGES[page])+'</h1>'+(o.titleRight||'')+refreshBtn(page)
+ '<p class="page-shell-sub">'+esc(SHELL_SUB[page])+'</p></header>'
+ stats + aux + '</div>';
}
function statePanel(page){
const c=STATE_COPY[page][S.pageState]||['—',''];
if(!c[1]) return '';
const goTo=c[2]?'<button class="btn" data-act="STATE-GO" data-to="'+c[2]+'">去'+esc(PAGES[c[2]])+'</button>':'';
const body=S.pageState==='loading'
? '<div class="skel-stack"><div class="skel skel-w90"></div><div class="skel skel-w70"></div><div class="skel skel-w45"></div></div>'
: '<p>'+esc(c[1])+'</p>';
const retry=S.pageState==='error'?'<button class="btn btn-sm" data-act="STATE-RETRY">重试一次</button>':'';
return '<section class="block"><div class="state-panel"><h2>'+esc(c[0])+'</h2>'+body
+ '<div class="row" style="justify-content:center">'+goTo+retry+'</div>'
+ '<div class="statebar" style="justify-content:center;margin-top:var(--sp-16)">'
+ '<button class="btn btn-sm" data-act="STATE-NORMAL">回到正常内容</button></div>'
+ '</div></section>';
}
/* ============================ 七页 ============================ */
function pageP1(){
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('');
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);
const q=(S.searchQ||'').trim();
const IDX=[];
ACCOUNTS.forEach(a=>IDX.push({t:a.name+'(账号)',p:'p6'}));
S.topics.forEach(k=>IDX.push({t:k.text+'(选题)',p:'p2'}));
S.assets.forEach(x=>IDX.push({t:x.text.slice(0,18)+'(资产)',p:'p3'}));
RECORDS.forEach(r=>IDX.push({t:r.text+'(记录)',p:'p7'}));
const hits=q?IDX.filter(x=>x.t.includes(q)).slice(0,4):[];
const searchInner='<div class="panel"><label class="field-label" for="gq">跨页搜内容 / 选题 / 资产 / 记录</label>'
+ '<input class="input" id="gq" data-act="P1-06" placeholder="搜什么?" value="'+esc(S.searchQ||'')+'">'
+ '<ul class="list-plain" style="margin-top:var(--mg-8)">'
+ (q? (hits.length? hits.map(h=>'<li><button class="btn-micro" data-act="P1-07" data-to="'+h.p+'">'+esc(h.t)+'</button></li>').join('') : '<li class="meta">没有匹配的内容。</li>')
: '<li class="meta">输入关键字,跨页找内容、选题、资产与发布记录。</li>')
+ '</ul></div>';
return shell('p1',{titleRight:ASSUME_TAG,stats:[[ACCOUNTS.length,'在管账号'],[TODOS.length,'今日待处理'],[anom.length,'数据异动'],[ACCOUNTS.reduce((n,a)=>n+a.posts,0),'本月已发']]})
+ block('今日待处理','—','<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>'+todos+'</tbody></table></div></div>',
'<span class="meta">待审 / 待确认 / 待复盘 / 待核销 / 选题各几件</span>','跨对象待办汇总,本身不产生对象(2b §五 P1 板块 1)')
+ block('账号矩阵','Account','<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>'+rows+'</tbody></table></div></div>',
'<span class="meta">每个账号今日该发什么 / 已发什么 / 效果如何</span>')
+ block('异常值提示','Performance','<div class="panel"><ul class="list-plain">'
+ (anom.length? anom.map(a=>'<li><div class="row"><span class="cell-name" data-act="P1-04" data-b="b4" role="button" tabindex="0" style="">'+esc(a.name)+' 有 1 条数据异动</span>'+tag('异常','tag-warn')+'</div><p class="meta">'+esc(a.perf)+' · 高于该号平时水平</p></li>').join('') : '<li class="meta">最近没有数据异动。</li>')
+'</ul></div>','<span class="meta">最近哪些内容的数据异动(P2 的输入)</span>')
+ block('本月表现最好的一条','Performance','<div class="panel"><div class="row"><div class="stack" style="flex:1 1 260px">'
+'<strong>'+esc(ACCOUNTS[0].name)+' · 3 分钟快手菜</strong><span class="meta">播放 12.4 万 · 完播 38% · 10-08 发出</span></div>'
+'<button class="btn btn-sm" data-act="P1-05">看这条复盘</button></div></div>')
+ block('全局检索','—',searchInner,'<span class="meta">本页旁路,不推进主操作</span>','跨对象旁路检索,本身不产生对象(2b §五 P1 板块 5)');
}
function pageP2(){
const z=S.benchFilter;
const list=BENCH.filter(b=>z==='all'||b.zone===z);
const rows=list.map(b=>'<tr><td class="cell-lead">'+esc(b.title)+'</td><td>'+esc(b.acct)+'</td>'
+'<td class="num-col">'+esc(b.mult)+'</td><td>'+esc(b.when)+'</td><td>'+tag(b.tag,b.tag==='异常'?'tag-warn':(b.tag==='爆款'?'tag-ok':''))+'</td>'
+'<td class="cell-act"><button class="btn-micro" data-act="P2-03" data-b="'+b.id+'">发起拆解</button></td></tr>').join('');
const cand=S.candidates.map(c=>'<li><div class="row" style="justify-content:space-between">'
+'<span class="stack" style="flex:1 1 200px"><strong>'+esc(c.text)+'</strong><span class="meta">'+esc(c.hit)+'</span></span>'
+'<span class="row"><button class="btn-micro" data-act="P2-06" data-c="'+c.id+'" '+(c.state==='adopted'?'disabled':'')+'>采纳</button>'
+'<button class="btn-micro" data-act="P2-07" data-c="'+c.id+'" '+(c.state==='ignored'?'disabled':'')+'>忽略</button>'
+ (c.state==='adopted'?tag('已采纳','tag-ok'):(c.state==='ignored'?tag('已忽略','tag-warn'):''))+'</span></div></li>').join('');
const tl=S.topics.filter(t=>t.group===S.topicGroup&&(S.topicType==='all'||t.type===S.topicType)).map(t=>'<tr><td class="cell-lead"><span class="cell-name" role="button" tabindex="0">'+esc(t.text)+'</span></td>'
+'<td>'+tag(t.type,t.type==='灵感'?'tag-ok':'')+'</td><td>'+esc(t.src)+'</td><td>'+tag(t.state,'')+'</td>'
+'<td class="cell-act"><button class="btn-micro" data-act="P2-09" data-k="'+t.id+'">转入创作</button>'
+'<button class="btn-micro" data-act="P2-10" data-k="'+t.id+'">拆解</button></td></tr>').join('');
const groups=[['todo','待做'],['writing','在写'],['done','已用']];
const typeSeg=[['all','全部']].concat(TOPIC_TYPES.map(ty=>[ty,ty]));
return shell('p2',{aux:'<button class="btn btn-sm" data-act="P2-AUX" data-page="p4">开始创作</button>'})
+ block('对标监控概览','Benchmark','<div class="home-stats">'
+ '<button class="stat-item" data-act="P2-01" '+(BENCH.filter(b=>b.zone==='低粉爆款').length===0?'disabled':'')+'><span class="stat-value">'+BENCH.filter(b=>b.zone==='低粉爆款').length+'</span><span class="stat-label">低粉爆款条数</span></button>'
+ '<button class="stat-item" data-act="P2-02" '+(BENCH.filter(b=>b.zone==='异常值').length===0?'disabled':'')+'><span class="stat-value">'+BENCH.filter(b=>b.zone==='异常值').length+'</span><span class="stat-label">异常值条数</span></button>'
+ '<div class="stat-item"><span class="stat-value">3</span><span class="stat-label">在监控的账号数</span></div>'
+ '<div class="stat-item"><span class="stat-value">'+BENCH.filter(b=>b.when.indexOf('天')<0).length+'</span><span class="stat-label">近三天更新条数</span></div></div>',
'<span class="meta">F1 的产出</span>')
+ block('对标内容','Benchmark','<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>标签</th><th></th></tr></thead><tbody>'+rows+'</tbody></table></div></div>',
'<span class="meta">每条带它相对该账号平时水平的倍数</span>')
+ block('评论区洞察','Topic','<div class="panel">'
+ '<div class="row"><span class="meta">'+(S.commentState==='done'?'已出 3 条候选':'取一条内容的评论,出 AI 候选选题')+'</span>'
+ '<div class="spacer" style="flex:1 1 auto"></div>'
+ '<button class="btn btn-micro" data-act="P2-04">'+(S.commentOpen?'收起':'展开')+'</button>'
+ '<button class="btn btn-sm" data-act="P2-05" '+(S.commentState==='fetching'?'disabled':'')+'>抓取评论</button></div>'
+ (S.commentOpen? (S.commentState==='fetching'
? '<div class="skel-stack" style="margin-top:var(--sp-16)"><div class="skel skel-w90"></div><div class="skel skel-w45"></div></div>'
: (S.commentState==='done'? '<ul class="list-plain" style="margin-top:var(--mg-8)">'+cand+'</ul>' : '<p class="meta" style="margin-top:var(--mg-12)">点「抓取评论」,按点赞最多取前 N 条。</p>'))
: '')
+ '</div>','<span class="meta">可点入子区</span>')
+ block('选题库','Topic','<div class="panel">'
+ '<div class="seg" style="margin-bottom:var(--mg-8)">'
+ groups.map(g=>'<button class="seg-item '+(S.topicGroup===g[0]?'is-on':'')+'" data-act="P2-08" data-group="'+g[0]+'">'+g[1]+' <span class="num">'+S.topics.filter(t=>t.group===g[0]).length+'</span></button>').join('')+'</div>'
+ '<div class="seg" style="margin-bottom:var(--mg-8)">'
+ typeSeg.map(ty=>'<button class="seg-item '+(S.topicType===ty[0]?'is-on':'')+'" data-act="P2-12" data-type="'+ty[0]+'">'+ty[1]+' <span class="num">'+(ty[0]==='all'?S.topics.length:S.topics.filter(t=>t.type===ty[0]).length)+'</span></button>').join('')+'</div>'
+ '<div class="table-wrap"><table class="ds-table tbl-dense"><thead><tr><th>选题</th><th>类型</th><th>来源</th><th>状态</th><th></th></tr></thead><tbody>'+tl+'</tbody></table></div>'
+ '<div class="row" style="margin-top:var(--mg-8)"><span class="meta">进度 × 类型两个正交维度;随手记写进来的默认落「灵感 · 待做」(②段 2b §五 P2)</span></div>'
+ '</div>');
}
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);
const pending=S.assets.filter(a=>a.state==='待入库');
return shell('p3',{stats:[[S.assets.length,'条目总数'],[S.assets.filter(a=>a.state==='已入库').length,'已入库'],[pending.length,'待入库'],[4,'分区数']]})
+ block('分区切换','Asset','<div class="panel"><div class="seg">'
+ zones.map(z=>'<button class="seg-item '+(S.assetZone===z[0]?'is-on':'')+'" data-act="P3-01" data-zone="'+z[0]+'">'+z[1]+' <span class="num">'+S.assets.filter(a=>a.zone===z[0]).length+'</span></button>').join('')
+ '</div></div>','<span class="meta">一次看一个分区</span>')
+ block('资产条目','Asset','<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.length? 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('')
: '<tr><td colspan="4" class="meta">这个分区还没有条目。</td></tr>')
+ '</tbody></table></div></div>','<span class="meta">'+list.length+' 条</span>')
+ '<div class="grid-main-side">'
+ '<section class="block" data-obj="Asset">'+blockHead('单条详情',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 class="block" data-obj="Asset">'+blockHead('引用入口','<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]||'';
const cur=S.versions.find(v=>v.cur)||S.versions[0];
const done=S.step>=6;
const refs=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>';
return shell('p4',{
titleRight:'',
stats:[[(S.step+1),'当前环节(共 7 步)'],[S.step,'已完成环节'],[S.refs.length,'引用资产'],[S.versions.length,'版本']],
aux:'<button class="btn btn-sm" data-act="P4-AUX" data-page="p6">查账号口径</button>'
})
/* Page Shell 的「当前内容上下文」单独占一行:它服务整页,不是某个功能的对象(2b §九第 8 条)。 */
.replace('</div>','')
? ''
: '';
}
/* ⚠️ P4 需要把「当前内容上下文」塞进 Page Shell —— 用 shell() 的可选行实现,避免破上面的写法。 */
function pageP4Real(){
const out=S.stepOut[S.step]||'';
const aiV=(S.aiVerdict||{})[S.step]||'';
const aiOut=(S.aiOut||{})[S.step]||'';
const cur=S.versions.find(v=>v.cur)||S.versions[0];
const done=S.step>=6;
const refs=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>';
const ctx='<div class="notice" style="margin-bottom:0"><strong>当前内容:'+esc(CONTENT.title)+'</strong>'
+ '<span class="meta">账号 '+esc(ACCOUNTS[0].name)+' · 当前版 '+esc(cur.id)+' · 来自选题库</span></div>';
return '<div class="page-shell" data-shell="p4">'
+ '<header class="page-shell-title"><h1 class="grow">'+esc(PAGES.p4)+'</h1>'+refreshBtn('p4')+'<p class="page-shell-sub">'+esc(SHELL_SUB.p4)+'</p></header>'
+ ctx
+ '<div class="home-stats" style="margin-top:var(--mg-12)">'
+ '<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>'
+ '<div class="page-shell-aux"><button class="btn btn-sm" data-act="P4-AUX" data-page="p6">查账号口径</button></div>'
+ '</div>'
+ block('创作流程','Content','<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>','<span class="meta">第 '+(S.step+1)+'/7 步 · 可回退</span>')
+ '<div class="grid-main-side">'
+ '<section class="block" data-obj="Content">'+blockHead('当前环节工作区','<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 class="block" data-obj="Content">'+blockHead('AI 产出与采纳','<span class="meta">'+(S.stepBusy?'生成中':(aiV==='adopted'?'已采纳':(aiV==='rejected'?'已驳回':(aiV==='rework'?'改后再用':(aiOut?'待裁决':'无产出')))))+'</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-10">中断生成</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-07" '+(aiV==='adopted'?'disabled':'')+'>采纳</button>'
+'<button class="btn" data-act="P4-08" '+(aiV==='rejected'?'disabled':'')+'>驳回</button>'
+'<button class="btn" data-act="P4-09">改后再用</button>'
+'<span class="meta">产出与人的编辑分区:采纳才进正文</span></div>'
: '<p class="meta">这一步还没有机器产出。在上面写下方向,点「开始这一步」。</p>')
+ '</div></section>'
+ '<section class="block" data-obj="Asset">'+blockHead('资产引用','<span class="meta">'+S.refs.length+' 条已引用</span>')
+ '<div class="panel">'+refs+'<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-sm" data-act="P4-04">从资产库拿一条</button></div></div></section>'
+ '</div></div>'
+ block('版本管理','ContentVersion','<div class="panel">'
+ '<div class="row" style="justify-content:space-between;margin-bottom:var(--mg-8)">'
+ '<span class="meta">一稿多版;母版改一次,按各平台账号口径各出一版(F7,展开单位是平台账号)</span>'
+ '<button class="btn btn-sm" data-act="P4-11" '+(done?'':'disabled')+'>按平台账号展开</button></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-12" data-v="'+v.id+'">切为当前版</button>')+'</span></div></li>').join('')+'</ul>'
+ '<div class="row" style="margin-top:var(--mg-8)"><span class="meta">'+(S.expanded?'已按 2 个平台账号展开(一个平台账号一版),超限处已标出。':'未到成稿态时不可展开;展开与逐版确认都发生在这一页。')+'</span></div>'
+ '</div>','<span class="meta">F7 + F8 的落点(上一版我把 F7 拆到 P5,本版收回 P4)</span>')
+ block('录制卡片与画板','Content','<div class="panel"><div class="row" style="justify-content:space-between;flex-wrap:wrap">'
+ '<span class="meta">成稿态之后,把内容转成对着讲的卡片(F14)</span>'
+ '<span class="row"><button class="btn btn-sm" data-act="P4-13" '+(done?'':'disabled')+'>生成卡片</button>'
+ '<button class="btn btn-sm" data-act="P4-14" '+(S.cardGen?'':'disabled')+'>同步到画板</button>'
+ (S.cardGen?tag('已生成','tag-ok'):'')+(S.cardSynced?tag('已同步','tag-ok'):'')+'</span></div>'
+ '<p class="meta" style="margin-top:var(--mg-8)">'+(done?'成稿之后才能生成卡片 —— 它不是创作流程七步里的一环。':'当前还没到成稿态。')+'</p></div>',
'<span class="meta">成稿之后才出现的动作,不是七步之一</span>');
}
const PAGE_FN = { p1:pageP1, p2:pageP2, p3:pageP3, p4:pageP4Real, p5:pageP5, p6:pageP6, p7:pageP7 };
function pageP5(){
/* 目标选择两级:内容账号 → 它下面的平台账号(渠道)。一版一路 ⇒ 勾了几个平台账号就有几版。 */
const chans=S.targets.channels.map(cid=>{
const aid=String(cid).split('-')[0];
const a=ACCOUNTS.find(x=>x.id===aid);
const pa=(PLATS[aid]||[]).find(x=>x.id===cid);
return (a&&pa)?{cid:cid,a:a,pa:pa}:null;
}).filter(Boolean);
const platCount=new Set(chans.map(c=>c.pa.plat)).size;
const vers=chans.map((c,i)=>({id:'v'+(i+1),cid:c.cid,acct:c.a.name,plat:c.pa.plat,text:'('+c.a.name+' · '+c.pa.plat+')3 分钟快手菜:'+['省掉摆盘','先给成品','口语版'][i%3],words:180+i*15}));
const cur=vers.find(v=>v.id===S.verSel)||vers[0];
const hard=S.checkState==='hard';
const chk=S.checkState==='pass'?tag('无硬拦,可发','tag-ok'):(hard?tag('有硬拦 1 处','tag-err'):(S.checkState==='checking'?'检查中':tag('未检','')));
return shell('p5',{aux:'<span class="meta">导航名叫「投放」—— 本页只管内容发布与排期;版本在创作台生成,⛔ 本版没有扩功能</span>',
stats:[[chans.length,'目标渠道数'],[platCount,'覆盖平台数'],[vers.length,'本批版本数'],[hard?1:0,'硬拦项数']]})
+ block('目标选择','PublishTask','<div class="panel">'
+ '<div class="row" style="justify-content:space-between"><span class="meta">两级:先选内容账号,再勾它下面的平台账号(渠道)</span>'
+ '<button class="btn btn-sm" data-act="P5-01">'+(S.targetOpen?'收起':'展开')+'</button></div>'
+ (S.targetOpen?'<div class="field" style="margin-top:var(--sp-16)"><span class="field-label">内容账号</span><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>'
+ S.targets.accounts.map(aid=>'<div class="field"><span class="field-label">'+esc((ACCOUNTS.find(a=>a.id===aid)||{}).name||'')+' 的平台账号</span><div class="seg">'
+ (PLATS[aid]||[]).map(pa=>'<button class="seg-item '+(S.targets.channels.includes(pa.id)?'is-on':'')+'" data-act="P5-03" data-ch="'+pa.id+'">'+esc(pa.plat)+' · '+esc(pa.auth)+'</button>').join('')+'</div></div>').join('')
:'')
+ '</div>','<span class="meta">从 P4 带着版本进来时目标已定,从 P1 进来才需要现选</span>')
+ '<div class="grid-main-side">'
+ '<section class="block" data-obj="ContentVersion">'+blockHead('各版本与逐版编辑','<span class="meta">一个平台账号一版(一版一路)</span>')
+ '<div class="panel"><div class="seg" style="margin-bottom:var(--mg-8)">'
+ vers.map(v=>'<button class="seg-item '+(S.verSel===v.id?'is-on':'')+'" data-act="P5-04" data-v="'+v.id+'">'+esc(v.acct)+' · '+esc(v.plat)+'</button>').join('')+'</div>'
+ (cur?'<label class="field-label" for="p5-in">'+esc(cur.acct)+' · '+esc(cur.plat)+' · 约 '+cur.words+' 字</label>'
+'<textarea class="textarea" id="p5-in" data-act="P5-05" rows="4">'+esc(S.verDraft!=null?S.verDraft:cur.text)+'</textarea>'
+'<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-sm" data-act="P5-06" '+(S.verDirty?'':'disabled')+'>保存本版</button>'
+'<span class="meta">'+(S.verDirty?'已改,保存后检查结果重跑':'没有改动')+'</span></div>'
:'<p class="meta">还没有可发布的版本。</p>')+'</div></section>'
+ '<section class="block" data-obj="PublishTask">'+blockHead('检查结果',chk)
+ '<div class="panel">'+(S.checkState==='checking'
? '<div class="skel-stack"><div class="skel skel-w90"></div><div class="skel skel-w45"></div></div>'
: hard
? '<div class="notice notice-err" style="margin-bottom:0"><strong>硬拦:正文里有一处账号密钥</strong><span>敏感信息不过,去发布点不动。改完复检。</span></div>'
: S.checkState==='pass'
? '<div class="notice" style="margin-bottom:0"><strong>通过</strong><span>人设一致性 92 分,高于阈值;没有硬拦。</span></div>'
: '<p class="meta">还没跑检查。发布前必须过一遍:硬的拦住、软的只提醒。</p>')
+ '<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>'
+ block('确认发布','PublishRecord','<div class="panel"><div class="row" style="justify-content:space-between">'
+ '<span class="meta">'+(hard?'有硬拦项,确认发布不可用':'由人确认发出;发布这类不可逆动作不许自动放行')+'</span>'
+ '<button class="btn btn-primary" data-act="P5-08" '+(hard||!vers.length?'disabled':'')+'>确认发布</button></div></div>')
+ block('发布记录','PublishRecord','<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">'
+'<thead><tr><th>内容</th><th>确认人</th><th>时间</th><th>版本</th><th>状态</th><th></th></tr></thead><tbody>'
+ RECORDS.slice(0,4).map(r=>'<tr><td class="cell-lead">'+esc(r.text)+'</td><td>'+esc(r.by)+'</td><td>'+esc(r.time)+'</td><td>'+esc(r.ver)+'</td>'
+'<td>'+(r.state==='已发布'?tag('已发布','tag-ok'):(r.state==='待审'?tag('待审','tag-warn'):tag(r.state,'')))+'</td>'
+'<td class="cell-act"><button class="btn-micro" data-act="P5-11" data-rec="'+r.id+'">查留痕</button></td></tr>').join('')
+'</tbody></table></div></div>','<span class="meta">记录在 P5 产生,就在 P5 能看;复盘页是跨页查(P8 已并入复盘,⛔ 不再是独立页)</span>');
}
function pageP6(){
const p=PROFILES[S.account]||PROFILES.a1;
const pats=PLATS[S.account]||[];
/* 画像五维(2026-10-10 用户口径:平台不归内容账号 ⇒ 去掉「平台」这一维) */
const dims=[['定位','定位'],['风格','风格'],['受众','受众'],['偏好与红线','偏好']];
const mem=p.记忆;
const rel=RECORDS.filter(r=>r.acct===ACCOUNTS.find(a=>a.id===S.account).name);
return shell('p6',{stats:[[ACCOUNTS.length,'在管账号'],[Object.keys(PROFILES).length,'已建画像'],[mem.length,'长期记忆条数'],[rel.length,'关联选题数']]})
+ '<div class="grid-main-side">'
+ '<section class="block" data-obj="Account">'+blockHead('账号列表','<span class="meta">选一个内容账号</span>')
+ '<div class="panel"><ul class="list">'+ACCOUNTS.map(a=>'<li><div class="list-row" data-act="P6-01" data-acct="'+a.id+'" role="button" tabindex="0">'
+'<span class="primary-cell"><strong class="'+(S.account===a.id?'is-on':'')+'">'+esc(a.name)+'</strong><span class="meta">'+((PLATS[a.id]||[]).length)+' 个平台账号</span></span>'
+'<span class="secondary-cell"><span>'+a.posts+' 条内容</span></span><span class="action-cell">'+(S.account===a.id?tag('当前','tag-ok'):'')+'</span></div></li>').join('')+'</ul></div></section>'
+ '<section class="block" data-obj="Account">'+blockHead('画像五维','<span class="meta">'+esc(ACCOUNTS.find(a=>a.id===S.account).name)+'</span>')
+ '<div class="panel">'+dims.map(d=>'<div class="field"><label class="field-label">'+d[0]+'</label>'
+'<input class="input" data-act="P6-02" value="'+esc(p[d[1]])+'"></div>').join('')
+ '<div class="field"><label class="field-label">长期记忆</label><input class="input" data-act="P6-02" value="'+esc(mem.length?mem[mem.length-1]:'(还没有)')+'"></div>'
+ '<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-primary" data-act="P6-03" '+(S.profileDirty?'':'disabled')+'>保存画像</button>'
+ '<span class="meta">五维是一块,不是五块;平台不归它(②段 2b §五 P6 写死)</span></div></div></section>'
+ '</div>'
+ block('平台账号','Account','<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">'
+ '<thead><tr><th>平台</th><th>登录态</th><th>授权状态</th><th></th></tr></thead><tbody>'
+ (pats.length? pats.map(pa=>'<tr><td class="cell-lead">'+esc(pa.plat)+'</td>'
+ '<td>'+(pa.login==='已登录'?tag(pa.login,'tag-ok'):tag(pa.login,'tag-warn'))+'</td>'
+ '<td>'+(pa.auth==='已授权'?tag(pa.auth,'tag-ok'):tag(pa.auth,'tag-warn'))+'</td>'
+ '<td class="cell-act"><button class="btn-micro" data-act="P6-06" data-pa="'+pa.id+'">'+(pa.login==='已登录'?'重新授权':'去登录')+'</button></td></tr>').join('')
: '<tr><td colspan="4" class="meta">这个账号还没有平台账号,投放时无渠道可选。</td></tr>')
+ '</tbody></table></div>'
+ '<div class="row" style="margin-top:var(--mg-8)"><span class="meta">平台账号=发布渠道(平台 + 登录态 + 授权状态),是内容账号下的子项(1 → N);投放按它选目标</span>'
+ '<div class="spacer" style="flex:1 1 auto"></div><button class="btn btn-sm" data-act="P6-07">加一个平台账号</button></div></div>',
'<span class="meta">'+pats.length+' 个渠道</span>')
+ block('记忆条目','Account','<div class="panel"><div class="row" style="justify-content:space-between">'
+ '<span class="meta">只追加,不覆盖</span><button class="btn btn-sm" data-act="P6-04">'+(S.memoryOpen?'收起':'展开')+'</button></div>'
+ (S.memoryOpen?(mem.length?'<ul class="list-plain" style="margin-top:var(--mg-8)">'+mem.map(m=>'<li>'+esc(m)+'</li>').join('')+'</ul>':'<p class="meta" style="margin-top:var(--mg-8)">这个账号还没有记忆条目。</p>'):'')
+ '</div>','<span class="meta">'+mem.length+' 条</span>')
+ block('关联内容','Content','<div class="panel">'+(rel.length
? '<div class="table-wrap"><table class="ds-table tbl-side"><thead><tr><th>内容</th><th>状态</th><th></th></tr></thead><tbody>'
+ rel.map(r=>'<tr><td class="cell-lead">'+esc(r.text)+'</td><td>'+esc(r.state)+'</td><td class="cell-act"><button class="btn-micro" data-act="P6-05">去创作台</button></td></tr>').join('')+'</tbody></table></div>'
: '<p class="meta">这个账号还没有关联内容。</p>')+'</div>');
}
function pageP7(){
const r=REVIEWS.find(x=>x.id===S.reviewSel)||REVIEWS[0];
const pend=REVIEWS.filter(x=>x.data==='到了');
const canWrite=!!S.confirmed;
/* 以下三行服务本页后半段的四个板块 —— 它们是原 P8「记录与对账」整页搬过来的。 */
const rec=RECORDS.find(x=>x.id===S.recordSel)||RECORDS[0];
const recRows=RECORDS.map(x=>'<tr><td class="cell-lead"><span class="cell-name '+(x.id===S.recordSel?'is-on':'')+'" data-act="P7-09" data-rec="'+x.id+'" role="button" tabindex="0">'+esc(x.text)+'</span></td>'
+'<td>'+esc(x.by)+'</td><td>'+esc(x.time)+'</td><td>'+esc(x.ver)+'</td>'
+'<td>'+(x.state==='已发布'?tag('已发布','tag-ok'):(x.state==='待审'?tag('待审','tag-warn'):tag(x.state,'')))+'</td></tr>').join('');
const verdict=S.reviewVerdict==='adopted'?tag('已采纳','tag-ok'):(S.reviewVerdict==='rejected'?tag('已驳回','tag-warn'):(S.reviewVerdict==='rework'?tag('改后再用','tag-warn'):tag('待裁决','')));
return shell('p7',{stats:[[REVIEWS.length,'待复盘数'],[pend.length,'数据已回数'],[S.wroteBack?1:0,'本次已回写数'],[PROFILES[r?ACCOUNTS.find(a=>a.name===r.acct).id: 'a1'].记忆.length,'画像记忆数']]})
+ '<div class="grid-main-side">'
+ '<div class="stack">'
+ '<section class="block" data-obj="Review">'+blockHead('待复盘项','<span class="meta">'+pend.length+' 条数据已回</span>')
+ '<div class="panel"><ul class="list">'+REVIEWS.map(x=>'<li><div class="list-row '+(x.id===S.reviewSel?'is-active':'')+'" data-act="P7-01" data-r="'+x.id+'" role="button" tabindex="0">'
+'<span class="primary-cell"><strong>'+esc(x.text)+'</strong><span class="meta">'+esc(x.acct)+' · '+esc(x.day)+'</span></span>'
+'<span class="secondary-cell"><span>数据'+esc(x.data)+'</span></span>'
+'<span class="action-cell">'+(x.data==='到了'?tag('可复盘','tag-ok'):tag('等数据','tag-warn'))+'</span></div></li>').join('')+'</ul></div></section>'
+ '<section class="block" data-obj="Performance">'+blockHead('这一条的表现数据',r?(S.historyOpen?tag('数据已回','tag-ok'):tag('数据已回','tag-ok')):'')
+ '<div class="panel">'+(r?'<div class="grid-cards"><div class="stat"><span class="num">'+esc(r.play)+'</span><span class="stat-label">播放</span></div>'
+'<div class="stat"><span class="num">'+esc(r.fin)+'</span><span class="stat-label">完播率</span></div>'
+'<div class="stat"><span class="num">'+esc(r.tri)+'</span><span class="stat-label">三连率</span></div>'
+'<div class="stat"><span class="num">'+esc(r.day)+'</span><span class="stat-label">发布日</span></div></div>'
+(r.data!=='到了'?'<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-sm" data-act="P7-02">手工录入表现</button></div>':'')
:'<p class="meta">没有可复盘的内容。</p>')+'</div></section>'
+ '</div>'
+ '<div class="stack">'
+ '<section class="block" data-obj="Review">'+blockHead('归因结论','<span class="meta">由 AI 给出,可编辑</span>')
+ '<div class="panel"><label class="field-label" for="p7-in">结论</label>'
+'<textarea class="textarea" id="p7-in" data-act="P7-03" rows="3" placeholder="写一句这条内容为什么好 / 为什么差">'+esc(S.attrDraft||(r?r.note:''))+'</textarea></div></section>'
+ '<section class="block" data-obj="Review">'+blockHead('确认与改判',verdict)
+ '<div class="panel"><p class="meta" style="margin-bottom:var(--mg-8)">这一个块是**回写的前置开关**,不是只留痕:没有人工裁决,回写这一步不成立(②段 2b §九第 14 条、2a F10)。</p>'
+'<div class="row"><button class="btn '+(S.reviewVerdict==='adopted'?'btn-primary':'')+'" data-act="P7-04" '+(S.attrDraft?'':'disabled')+'>采纳这条归因</button>'
+'<button class="btn" data-act="P7-05" '+(S.attrDraft?'':'disabled')+'>驳回</button>'
+'<button class="btn" data-act="P7-06" '+(S.attrDraft?'':'disabled')+'>改后再用</button></div></div></section>'
+ '<section class="block" data-obj="Account">'+blockHead('回写目标提示','<span class="meta">这次回写到哪个账号</span>')
+ '<div class="panel"><div class="row" style="justify-content:space-between">'
+'<span class="meta">回写到:'+esc(r?r.acct:'—')+' 的画像记忆('+(S.wroteBack?'已回写':'待回写')+')</span>'
+'<button class="btn btn-primary" data-act="P7-07" '+(canWrite?'':'disabled')+'>确认归因并回写</button></div></div></section>'
+ '</div></div>'
+ block('历史复盘','Review','<div class="panel"><div class="row" style="justify-content:space-between">'
+ '<span class="meta">这个账号以前的结论</span><button class="btn btn-sm" data-act="P7-08">'+(S.historyOpen?'收起':'展开')+'</button></div>'
+ (S.historyOpen?'<ul class="list-plain" style="margin-top:var(--mg-8)"><li>10-05 收藏率靠步骤图带起来</li><li>10-03 长镜头留不住人,前 5 秒要动</li></ul>':'')
+ '</div>','<span class="meta">可点入</span>')
/* ---- 以下四块 = 原 P8「记录与对账」整页搬过来(2026-10-09 用户令「P8 放复盘」「当然是功能也要放过去」)。
块名与顺序照 `2b` §五 P7 清单:历史复盘之后接 发布记录 / 单条溯源 / 对账核对区 / 商单与收支。
动作 ID 由 P8-01…P8-04 改成 P7-09…P7-12 —— 原页已撤,ID 跟着页走(⛔ 不留 P8-xx 死引用)。 ---- */
+ '<div class="grid-main-side">'
+ '<section class="block" data-obj="PublishRecord">'+blockHead('发布记录','<span class="meta">谁在什么时候确认发的、发的是哪一版</span>')
+ '<div class="panel"><div class="table-wrap"><table class="ds-table tbl-dense">'
+'<thead><tr><th>内容</th><th>确认人</th><th>时间</th><th>版本</th><th>状态</th></tr></thead><tbody>'+recRows+'</tbody></table></div></div></section>'
+ '<div class="stack">'
+ '<section class="block" data-obj="PublishRecord">'+blockHead('单条溯源','<span class="meta">可点入</span>')
+ '<div class="panel"><div class="row" style="justify-content:space-between">'
+'<span class="meta">从记录回到内容与版本</span><button class="btn btn-sm" data-act="P7-10">'+(S.traceOpen?'收起':'展开')+'</button></div>'
+ (S.traceOpen?'<div class="soft" style="margin-top:var(--mg-8)"><p class="meta">记录 '+esc(rec?rec.id:'—')+' → 内容('+esc(rec?rec.text:'—')+')→ 版本 '+esc(rec?rec.ver:'—')+'</p></div>':'')
+ '</div></section>'
+ '<section class="block" data-obj="PublishRecord">'+blockHead('对账核对区','<span class="meta">内容 / 发布记录 / 收支三样对得上</span>')
+ '<div class="panel"><div class="table-wrap"><table class="ds-table tbl-side"><tbody>'
+'<tr><td class="cell-lead">内容</td><td>'+esc(rec?rec.text:'—')+'</td><td>'+(rec?tag('已发布','tag-ok'):'')+'</td></tr>'
+'<tr><td class="cell-lead">发布记录</td><td>'+esc(rec?rec.by+' · '+rec.time:'—')+'</td><td>'+tag(rec&&rec.state==='已发布'?'对上':'待核',rec&&rec.state==='已发布'?'tag-ok':'tag-warn')+'</td></tr>'
+'<tr><td class="cell-lead">收支</td><td>'+esc(rec?rec.settle:'—')+'</td><td>'+esc(rec?rec.amount:'—')+'</td></tr>'
+'</tbody></table></div>'
+'<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-primary" data-act="P7-11" '+(rec&&rec.state==='已发布'&&!S.checkedOut?'':'disabled')+'>'+(S.checkedOut?'已核销':'核销这条')+'</button></div>'
+'</div></section></div></div>'
+ block('商单与收支','Campaign/Order','<div class="panel"><div class="row" style="justify-content:space-between">'
+ '<span class="meta">一条内容带了商单,内容、发布记录和收支对得上</span>'
+ '<span class="row">'+ASSUME_TAG+'<button class="btn btn-sm" data-act="P7-12">'+(S.billingOpen?'收起':'展开')+'</button></span></div>'
+ (S.billingOpen?'<div class="soft" style="margin-top:var(--mg-8)"><p class="meta">本块依赖未复核项(1a 待复核第 6 条 + A3):商单对账进不进第一版、收支口径是不是内容级。所属功能 F12 标 P2/Deferred。</p></div>':'')
+ '</div>','<span class="meta">可点入 · 假设</span>');
}
/* ---------- 浮层 ---------- */
function layerHTML(){
const L=S.layer; if(!L) return '';
const head=(t)=>'<div class="layer-head"><h2 class="grow">'+esc(t)+'</h2>'
+'<button class="layer-close" data-act="X-close" aria-label="关闭">×</button></div>';
const box=(t,inner)=>'<div class="layer"><h2 class="grow" style="display:none"></h2>'+head(t)+inner+'</div>';
if(L==='quicknote'){
return '<div class="layer">'+head('随手记')+'<p class="meta">想到什么先记下来,进选题库「待做」。</p>'
+'<div class="field" style="margin-top:var(--sp-16)"><label class="field-label" for="note">灵感</label>'
+'<input class="input" id="note" data-act="R-01" value="'+esc(S.noteDraft||'')+'" placeholder="一句话就行"></div>'
+'<div class="layer-actions"><button class="btn btn-sm" data-act="R-03">取消</button>'
+'<button class="btn btn-sm" data-act="R-02" '+(S.noteDraft&&S.noteDraft.trim()?'':'disabled')+'>保存</button></div></div>';
}
if(L==='assume'){
return '<div class="layer">'+head('两处假设的复核人 / 时机 / 落点')+'<div class="list-plain">'
+'<li><strong>假设 1 · P1 的形态</strong><p class="meta">矩阵级视图是否真需要(1a 待复核第 5 条 + A1)。本轮按「需要矩阵级视图」做:P1=矩阵总览,5 个板块全在。</p><p class="meta">复核人:主会话转用户 | 时机:②段收口时一次性过 | 落点:ui/DESIGN.md §三 + 原型 P1 页头徽章</p></li>'
+'<li><strong>假设 2 · P7 的第 10 板块「商单与收支」</strong><p class="meta">商单对账进不进第一版、收支口径是否内容级(1a 待复核第 6 条 + A3)。本轮按「进第一版但属 P2、可整块缺」做。</p><p class="meta">复核人:主会话转用户 | 时机:同上 | 落点:ui/DESIGN.md §三 + 原型 P7 后半段第 4 板块徽章</p></li>'
+'</div></div>';
}
if(L==='assetpick'){
return '<div class="layer">'+head('从资产库拿一条')+'<ul class="list-plain">'
+ S.assets.filter(a=>a.state==='已入库'&&!S.refs.some(r=>r.id===a.id)).slice(0,5)
.map(a=>'<li><button class="btn-micro" data-act="PICK-ASSET" data-a="'+a.id+'">'+esc(a.text)+'</button></li>').join('')
+'</ul><div class="layer-actions"><button class="btn btn-sm" data-act="PICK-CANCEL">取消</button></div></div>';
}
if(L==='confirm'){
return '<div class="layer">'+head('确认发出这一批')+'<p>确认后登记一条发布记录,复盘页的记录区会多出这一条并停在它上面。</p>'
+'<div class="layer-actions"><button class="btn btn-sm" data-act="P5-10">取消</button>'
+'<button class="btn btn-primary" data-act="P5-09">确认发出</button></div></div>';
}
if(L==='checkout'){
return '<div class="layer">'+head('核销这条记录')+'<p>核销后这条记录标为已核销,不可逆,需要你确认。</p>'
+'<div class="layer-actions"><button class="btn btn-sm" data-act="CHECKOUT-CANCEL">取消</button>'
+'<button class="btn btn-primary" data-act="CHECKOUT-OK">确认核销</button></div></div>';
}
return '';
}
/* ---------- 绘制 ---------- */
/* 🔴 2026-10-09 修:`#/selftest` 跑完之后**不许再重绘**。
病根:自检过程里会点出 toast / 延时反馈(`toast()` 与几处 `setTimeout` 都会再调 `paint()`),
而自检末尾把 `S.route` 设成 'selftest' —— 于是那批定时器一到点就 `paint()`,
`paint()` 见 route==='selftest' 又整份重跑一遍自检,跑完再点出新的定时器 ⇒ **自己转个没完**。
实测后果(2026-10-09 早上):标签页渲染主线程被永久占住 —— CDP 的 `Runtime.evaluate`
一律 5 秒超时,端口还在、进程还活、页面却不应答(当天就是它把整机拖到卡死、用户手动关了全部会话)。
守卫放在**入口**处:ST.lock 由自检跑完时置真(第 1668 行),此后只放过一次重绘。
自检**跑的过程中** ST.lock 仍是假 ⇒ 内部那几百次 paint() 一个也不受影响。 */
function paint(){
if(S.route==='selftest' && ST.lock) return;
renderNav();
const v=$('#view');
if(S.route==='selftest'){ v.innerHTML=renderSelftest(); }
else if(S.pageState!=='ok'){ v.innerHTML=shell(S.route,{titleRight:S.route==='p1'?ASSUME_TAG:''})+statePanel(S.route); }
else { v.innerHTML=PAGE_FN[S.route](); }
$('#layer-root').innerHTML = (S.layer?'<div class="layer-mask" data-act="X-mask" aria-hidden="true"></div>':'') + layerHTML()
+ (S.toast?'<div class="toast">'+esc(S.toast)+'</div>':'');
focusIntoLayer();
}
/* ============================ 交互清单(唯一真相源) ============================ */
/* data-act 值即 DESIGN.md §2.4 的 ID。下面每个键都必须有实现,否则自检报红。 */
const IMPL = {
'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(); },
'X-close':()=>closeLayer(),
'X-mask':()=>closeLayer(),
'PICK-CANCEL':()=>closeLayer(),
'CHECKOUT-CANCEL':()=>closeLayer(),
'R-01':null,
'R-02':()=>{ if(!S.noteDraft) return; S.topics.unshift({id:'n'+Date.now(),text:S.noteDraft,group:'todo',type:'灵感',src:'灵感速记',state:'待做'});
S.layer=null; S.noteDraft=''; paint(); toast('已进选题库待做'); },
'R-03':()=>{ S.layer=null; S.noteDraft=''; paint(); },
'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=>{ S.account=e.target.closest('[data-acct]').dataset.acct; S.reviewSel='r1'; go('p7'); },
'P1-04':()=>{ S.benchFilter='异常值'; go('p2'); },
'P1-05':()=>{ S.reviewSel='r2'; go('p7'); },
'P1-06':null,
'P1-07':e=>{ const to=e.target.closest('[data-to]').dataset.to; S.searchQ=''; go(to); },
/* 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; if(S.commentOpen&&S.commentState==='idle'){ S.commentState='fetching'; paint(); setTimeout(()=>{S.commentState='done';paint();},420); return; } 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',type:'评论洞察',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'); },
'P2-12':e=>{ S.topicType=e.target.closest('[data-type]').dataset.type; paint(); },
'P2-AUX':()=>go('p4'),
/* P3 */
'P3-01':e=>{ S.assetZone=e.target.closest('[data-zone]').dataset.zone; const l=S.assets.filter(a=>a.zone===S.assetZone); if(l[0]) S.assetSel=l[0].id; paint(); },
'P3-02':e=>{ S.assetSel=e.target.closest('[data-a]').dataset.a; paint(); },
'P3-03':e=>{ const a=e.target.closest('[data-a]').dataset.a; const it=S.assets.find(x=>x.id===a); it.state='已入库'; it.draft=false; paint(); toast('已入库'); },
'P3-04':e=>{ const a=e.target.closest('[data-a]').dataset.a; const it=S.assets.find(x=>x.id===a); if(!S.refs.some(r=>r.id===a)) S.refs.push({id:a,text:it.text,zone:it.zone}); go('p4'); },
/* P4 */
'P4-01':e=>{ S.step=+e.target.closest('[data-i]').dataset.i; paint(); },
'P4-02':null,
'P4-03':()=>{ S.stepBusy=true; paint(); setTimeout(()=>{ S.stepBusy=false; S.aiOut[S.step]=(S.aiOut||{})[S.step]||('机器出的一版:'+STEPS[S.step]+'方向,等你裁决。'); S.aiVerdict[S.step]=''; paint(); },420); },
'P4-07':()=>{ S.aiVerdict[S.step]='adopted'; S.stepOut[S.step]=((S.aiOut||{})[S.step]||''); paint(); toast('已采纳,并入这一步的产出'); },
'P4-08':()=>{ S.aiVerdict[S.step]='rejected'; paint(); toast('已驳回,正文未改'); },
'P4-09':()=>{ S.aiVerdict[S.step]='rework'; paint(); toast('已标记改后再用'); },
'P4-10':()=>{ 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-11':()=>{ S.expanded=true; S.versions.push({id:'v4',name:'深夜食堂笔记 · 抖音版',time:'10-09 09:00',cur:false},{id:'v5',name:'阿元的下班饭 · 小红书版',time:'10-09 09:00',cur:false}); paint(); toast('已按平台账号展开'); },
'P4-12':e=>{ const v=e.target.closest('[data-v]').dataset.v; S.versions.forEach(x=>x.cur=(x.id===v)); paint(); toast('当前版切成 '+v); },
'P4-13':()=>{ S.cardGen=true; paint(); toast('已生成一张卡片'); },
'P4-14':()=>{ S.cardSynced=true; paint(); toast('卡片已同步到画板'); },
'P4-AUX':()=>go('p6'),
'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); S.targets.channels=S.targets.channels.filter(c=>String(c).split('-')[0]!==a); }
else { S.targets.accounts.push(a); const f=(PLATS[a]||[])[0]; if(f) S.targets.channels.push(f.id); }
if(!S.targets.accounts.length){ const o=ACCOUNTS.find(x=>x.id!==a); S.targets.accounts.push(o.id); const f=(PLATS[o.id]||[])[0]; if(f) S.targets.channels.push(f.id); }
paint(); },
'P5-03':e=>{ const c=e.target.closest('[data-ch]').dataset.ch; const i=S.targets.channels.indexOf(c);
if(i>=0) S.targets.channels.splice(i,1); else S.targets.channels.push(c);
if(!S.targets.channels.length){ const aid=S.targets.accounts[0]||ACCOUNTS[0].id; const alt=(PLATS[aid]||[]).find(x=>x.id!==c); if(alt) S.targets.channels.push(alt.id); }
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':e=>{ S.recordSel=e.target.closest('[data-rec]').dataset.rec; go('p7'); },
/* 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'),
/* 平台账号(发布渠道):内容账号下的子项,1 → N */
'P6-06':e=>{ const id=e.target.closest('[data-pa]').dataset.pa; const it=(PLATS[S.account]||[]).find(x=>x.id===id); if(it){ it.login='已登录'; it.auth='已授权'; } paint(); toast('已登录并授权'); },
'P6-07':()=>{ const arr=PLATS[S.account]||(PLATS[S.account]=[]); arr.push({id:S.account+'-p'+(arr.length+1),plat:'新平台',login:'未登录',auth:'未授权'}); paint(); toast('已加一个平台账号,去登录授权'); },
/* P7 */
'P7-01':e=>{ S.reviewSel=e.target.closest('[data-r]').dataset.r; S.wroteBack=false; S.confirmed=false; S.reviewVerdict=''; S.attrDraft=''; paint(); },
'P7-02':()=>{ toast('已切到手工录入'); },
'P7-03':null,
'P7-04':()=>{ S.reviewVerdict='adopted'; S.confirmed=true; paint(); toast('已采纳这条归因,可以回写了'); },
'P7-05':()=>{ S.reviewVerdict='rejected'; S.confirmed=false; paint(); toast('已驳回这条归因'); },
'P7-06':()=>{ S.reviewVerdict='rework'; S.confirmed=false; paint(); toast('已标记改后再用'); },
'P7-07':()=>{ 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-09 '+r.text+':'+(S.attrDraft||r.note)); paint(); toast('已回写到 '+r.acct); },
'P7-08':()=>{ S.historyOpen=!S.historyOpen; paint(); },
/* P7 后半段 —— 原 P8「记录与对账」搬过来的四个板块(动作 ID 也跟着从 P8-xx 挪成 P7-xx) */
'P7-09':e=>{ S.recordSel=e.target.closest('[data-rec]').dataset.rec; S.checkedOut=false; paint(); },
'P7-10':()=>{ S.traceOpen=!S.traceOpen; paint(); },
'P7-11':()=>{ S.layer='checkout'; paint(); },
'P7-12':()=>{ S.billingOpen=!S.billingOpen; paint(); },
'CHECKOUT-OK':()=>{ S.layer=null; S.checkedOut=true; paint(); toast('这条已核销'); }
};
/* 各页页面操作区(SP-1…SP-7)—— 页面级操作,一个动作一个 ID。 */
for(let i=1;i<=7;i++){ IMPL['SP-'+i]=(function(){ return ()=>toast('已刷新本页数据'); })(); }
/* ============================ 事件绑定 ============================ */
let layerTrigger=null;
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));
}
function closeLayer(){
const back=layerTrigger;
S.layer=null; paint();
if(back){ const el=document.querySelector('[data-act="'+back+'"]'); if(el) el.focus(); }
}
/* 浮层一开就把焦点送进层内 —— 对话框的基本义务(键盘用户不该在原地打转)。
⚠️ 之前只有「随手记」自己 setTimeout 去 focus #note,别的四个层开完焦点还留在触发件上
(自检第 5d 组因此判红)。现在统一在 paint() 里做,⛔ 各层不必各写一遍。
已在层内 ⇒ 不抢焦点(否则层内每次重绘都会把光标踢回第一个控件)。 */
function focusIntoLayer(){
const root=$('#layer-root');
if(!S.layer || !root) return;
if(root.contains(document.activeElement)) return;
const f=layerFocusable();
if(f.length) f[0].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;
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==='P1-06'){ S.searchQ=t.value; const q=t.value,pos=t.selectionStart; paint(); const n=$('#gq'); 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(); const c=document.querySelector('[data-act="P7-05"]'); if(c) c.disabled=!t.value.trim(); return; }
});
document.addEventListener('keydown', ev=>{
if(!S.layer||ST.lock) return;
if(ev.key==='Escape'){ closeLayer(); return; }
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();
}
});
/* 🔴 这条守卫是**载重**的,⛔ 不要放宽成「看 hash 里有没有 selftest」。
2026-10-09 实测:自检在跑的过程中,点侧栏那一条检查会调 `go()` 去改 `location.hash`
⇒ 排下一个 hashchange 事件;自检收尾虽然用 replaceState 把 hash 拽回 '#/selftest',
但**已经排队的那个事件取消不掉**。靠 `if(ST.lock) return;` 把它吃掉,自检那一屏才留得住。
我一度把它改成「只拦又往 #/selftest 走的那种」,结果那个迟到事件被放行、整屏自检输出
被随后导航到的那一页覆盖掉(变异 M6 当场从「报红」变成「连输出都没有」)。
⚠️ 副作用(已知、可接受):自检跑完以后,**在同一个文档里**再改 hash 不再生效。
取证工具走的是「先落脚 about:blank 再导航」(`cap.py` / `grab_selftest.py`),不受影响。 */
window.addEventListener('hashchange', ()=>{ if(ST.lock) return; parseHash(); paint(); });
/* ============================ 自检(#/selftest) ============================ */
const ST={lock:false};
let CONSOLE_ERRORS=[];
window.addEventListener('error', e=>{ CONSOLE_ERRORS.push('error: '+e.message); });
window.addEventListener('unhandledrejection', e=>{ CONSOLE_ERRORS.push('rejection: '+e.reason); });
const S0=JSON.parse(JSON.stringify(S));
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:'P1 有检索', set:()=>{ S.route='p1'; S.searchQ='选题'; }},
{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'; }},
{name:'P4 有引用', set:()=>{ S.route='p4'; S.refs=[{id:ASSETS[0].id,text:ASSETS[0].text,zone:ASSETS[0].zone}]; }},
{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:'P4 成稿态', set:()=>{ S.route='p4'; S.step=6; S.cardGen=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'; S.attrDraft='水温段讲太长,掉在 38 秒处'; }},
{name:'P7 已采纳', set:()=>{ S.route='p7'; S.attrDraft='水温段讲太长'; S.reviewVerdict='adopted'; S.confirmed=true; }},
{name:'P7 等数据', set:()=>{ S.route='p7'; S.reviewSel='r3'; }},
{name:'P7 记录对账', set:()=>{ S.route='p7'; S.recordSel='rec1'; }},
{name:'P7 对账展开', set:()=>{ S.route='p7'; S.traceOpen=true; S.billingOpen=true; }},
{name:'P1 空空态', set:()=>{ S.route='p1'; S.pageState='empty'; }},
{name:'P1 出错态', set:()=>{ S.route='p1'; S.pageState='error'; }},
{name:'外壳停在非 P1', set:()=>{ S.route='p7'; }},
{name:'随手记浮层', set:()=>{ S.route='p1'; S.layer='quicknote'; }},
{name:'假设浮层', set:()=>{ S.route='p1'; S.layer='assume'; }},
{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='p7'; S.layer='checkout'; }}
];
function scrollTops(){
return { win:window.scrollY||0, html:document.documentElement.scrollTop||0, body:document.body.scrollTop||0, view:($('.view')?$('.view').scrollTop:0) };
}
function resetScroll(){
window.scrollTo(0,0); document.documentElement.scrollTop=0; document.body.scrollTop=0;
const v=$('.view'); if(v) v.scrollTop=0;
}
function applyScene(sc){ resetScene(); sc.set(); paint(); resetScroll(); }
/* 骨架(冻结):逐页的板块**数量与顺序**=新版 2b §五 的「业务板块清单(从上到下)」。
⛔ ③段不许增、删、挪。闸门比「数量 + 顺序 + 名字剥注解后逐字相同」(②段名字带注解)。
②段原文与这张表的对应由 _tools/check_skeleton.py 复核(改一边不改另一边会报红)。
🔴 2026-10-09 改(用户令「P8 放复盘」「当然是功能也要放过去」):**八页收成七页** ——
P8 记录与对账整页撤掉,它的四个板块**原样接在 P7 后面**(本页由 6 块变 10 块;总数是搬位置,仍 39)。
🔴 2026-10-10 改(八条口径 V4):**P2 由 5 块收成 4 块**(「灵感速记」并进「选题库」,随手记本身是
全局可达的框架层入口、不占板块),**P6 由 4 块变 5 块**(「画像六维」收成「画像五维」,新增「平台账号」)
⇒ 一减一加,总数仍 39(5+4+4+6+5+5+10)。 */
const FROZEN={
p1:['P1 矩阵总览',['今日待处理','账号矩阵','异常值提示','本月表现最好的一条','全局检索']],
p2:['P2 选题雷达',['对标监控概览','对标内容','评论区洞察','选题库']],
p3:['P3 资产库',['分区切换','资产条目','单条详情','引用入口']],
p4:['P4 创作台',['创作流程','当前环节工作区','AI 产出与采纳','资产引用','版本管理','录制卡片与画板']],
p5:['P5 发布中心',['目标选择','各版本与逐版编辑','检查结果','确认发布','发布记录']],
p6:['P6 账号画像',['账号列表','画像五维','平台账号','记忆条目','关联内容']],
p7:['P7 复盘台',['待复盘项','这一条的表现数据','归因结论','确认与改判','回写目标提示','历史复盘','发布记录','单条溯源','对账核对区','商单与收支']]
};
/* 每个业务板块的核心业务对象(对象冻结判据):取值须 ∈ OBJECTS,或 '—'(并在 DOM 上带说明)。 */
const OBJ_MAP={
p1:{'今日待处理':'—','账号矩阵':'Account','异常值提示':'Performance','本月表现最好的一条':'Performance','全局检索':'—'},
p2:{'对标监控概览':'Benchmark','对标内容':'Benchmark','评论区洞察':'Topic','选题库':'Topic'},
p3:{'分区切换':'Asset','资产条目':'Asset','单条详情':'Asset','引用入口':'Asset'},
p4:{'创作流程':'Content','当前环节工作区':'Content','AI 产出与采纳':'Content','资产引用':'Asset','版本管理':'ContentVersion','录制卡片与画板':'Content'},
p5:{'目标选择':'PublishTask','各版本与逐版编辑':'ContentVersion','检查结果':'PublishTask','确认发布':'PublishRecord','发布记录':'PublishRecord'},
p6:{'账号列表':'Account','画像五维':'Account','平台账号':'Account','记忆条目':'Account','关联内容':'Content'},
p7:{'待复盘项':'Review','这一条的表现数据':'Performance','归因结论':'Review','确认与改判':'Review','回写目标提示':'Account','历史复盘':'Review','发布记录':'PublishRecord','单条溯源':'PublishRecord','对账核对区':'PublishRecord','商单与收支':'Campaign/Order'}
};
/* 功能 ↔ 板块 的一一对应(2026-10-09 用户令:「所有功能要和各自板块对应」)。
FN_HOME = 每条功能的**归属页**,逐字取 `2a-产品功能-新版.md` §四「功能归属总表」的归属页列。
· F13 是唯一有两个归属页的:产生在 P5 投放、查在 P7 复盘(记录在 P5 生成、跨页在复盘查)。
· F7/F8/F14 归 P4;F11 归 P1;F12 随 P8 一起归 P7(原 P8 页已撤)。
FN_MAP = 逐页逐块挂它服务哪几条功能。键必须与 FROZEN 的板块名**逐一相同**(多一个少一个都报红)。
判据三条:① 每条功能 F1–F14 至少落在一个板块上(无悬空功能);
② 每条功能的**每个**归属页上都要有它的板块(功能跟得上页);
③ 每个板块至少挂一条功能(无孤立板块)。 */
const FN_HOME = {
F1:['p2'], F2:['p2'], F3:['p2'], F4:['p3'], F5:['p5'], F6:['p4'], F7:['p4'],
F8:['p4'], F9:['p6'], F10:['p7'], F11:['p1'], F12:['p7'], F13:['p5','p7'], F14:['p4']
};
const FN_MAP = {
p1:{'今日待处理':['F11'],'账号矩阵':['F11'],'异常值提示':['F11'],'本月表现最好的一条':['F11'],'全局检索':['F11']},
p2:{'对标监控概览':['F1'],'对标内容':['F1'],'评论区洞察':['F3'],'选题库':['F2']},
p3:{'分区切换':['F4'],'资产条目':['F4'],'单条详情':['F4'],'引用入口':['F4']},
p4:{'创作流程':['F6'],'当前环节工作区':['F6'],'AI 产出与采纳':['F6'],'资产引用':['F6'],'版本管理':['F7','F8'],'录制卡片与画板':['F14']},
p5:{'目标选择':['F5'],'各版本与逐版编辑':['F5','F7'],'检查结果':['F5'],'确认发布':['F5','F13'],'发布记录':['F13']},
p6:{'账号列表':['F9'],'画像五维':['F9'],'平台账号':['F9'],'记忆条目':['F9'],'关联内容':['F9']},
p7:{'待复盘项':['F10'],'这一条的表现数据':['F10'],'归因结论':['F10'],'确认与改判':['F10'],'回写目标提示':['F10'],'历史复盘':['F10'],'发布记录':['F13'],'单条溯源':['F13'],'对账核对区':['F12','F13'],'商单与收支':['F12']}
};
const SELFTEST_HEAD_TEXT='自检结果:PASS ';
function renderSelftest(){
try{ return _renderSelftest(); }
catch(e){
/* 🔴 崩了也要**吐出可读输出**:原来这里只 render 一个没有 `id="selftest-out"` 的块 ⇒
取证工具什么都读不到,变异对照只会得到一句「注入后自检跑不出来(连输出都没有)」,
分不清是「注入点写坏」还是「自检自己炸了」。现在照正常格式吐一个 `#selftest-out`:
head 行写 PASS 0 / FAIL 1,正文写栈,读的人一眼能看见崩在哪一行。 */
const err=String((e&&e.stack)||e).replace(/</g,'&lt;');
const head=SELFTEST_HEAD_TEXT+'0 / FAIL 1';
return '<section class="block"><div class="block-head"><h2 class="grow">自检没有跑完</h2><span class="meta">'+esc(head)+'</span></div>'
+'<div class="st-out" id="selftest-out">'+esc(head)+'\n\n自检没有跑完(渲染期抛错):\n'+err+'</div></section>';
}
}
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;
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);
});
});
/* 3a-0) 页集合必须正好七页。
🔴 本条治的是本轮的主改动:P8 记录与对账**整页并入 P7**(用户令「P8 放复盘」)。
判据不是「P8 改了个名」,是**页集合里再也没有 P8** —— 所以直接比「页面表」与「骨架表」两张键集,
任一多出一页、少一页、或仍含 p8 都报红。 */
const pageKeys=Object.keys(PAGES), frozenKeys=Object.keys(FROZEN);
const pageSetBad=[];
if(pageKeys.length!==7) pageSetBad.push('页面表有 '+pageKeys.length+' 页(期望 7):'+pageKeys.join(' / '));
if(JSON.stringify(pageKeys.slice().sort())!==JSON.stringify(frozenKeys.slice().sort()))
pageSetBad.push('页面表与骨架表的页集合不同:'+pageKeys.join(' / ')+' ≠ '+frozenKeys.join(' / '));
if(frozenKeys.indexOf('p8')>=0) pageSetBad.push('骨架表里还留着 p8');
if(pageSetBad.length===0) ok('页集合正好七页,且与骨架表逐项相同(⛔ 页集合里没有 P8)');
else bad('页面集合不是七页:'+pageSetBad.join(' / '));
/* 3a) 导航:**一层七项**(2026-10-09 用户令改)—— 七个功能项、一项一页,
⛔ 没有组标(.nav-group / .nav-group-label)、⛔ 没有跨组线(.nav-cross)、⛔ 没有第二层。
判据两头都盯:项数=7、项与页一一对应、组标与跨组的残留=0、项名与顺序照用户口径。
⚠️ 形态改了(V2 是「3 组 + 1 跨组」三层容器),判据的选择器必须跟着改 —— 否则静默失效变恒绿。 */
const navItems=[...document.querySelectorAll('#nav .nav-item')];
const navHomes=navItems.map(n=>n.dataset.page);
const navLeftover=[...document.querySelectorAll('#nav .nav-group, #nav .nav-group-label, #nav .nav-cross')];
if(navItems.length===7 && new Set(navHomes).size===7 && navLeftover.length===0)
ok('导航一层七项:'+navItems.length+' 个功能项、一项一页,零组标 / 零跨组线(⛔ 没有第二层)');
else bad('导航层级不对:功能项 '+navItems.length+' 个 / 不同页面 '+new Set(navHomes).size+' 个 / 组标与跨组残留 '+navLeftover.length+' 个(期望 7 / 7 / 0)');
/* 3b) 七项的名字与顺序必须逐字照用户口径(首页 / 选题 / 创作 / 投放 / 复盘 / 账号 / 资产,资产排最后) */
const WANT_NAV=['首页','选题','创作','投放','复盘','账号','资产'];
const gotNav=navItems.map(n=>((n.querySelector('.nav-label')||{}).textContent||'').trim());
if(JSON.stringify(gotNav)===JSON.stringify(WANT_NAV))
ok('导航七项与用户口径逐字一致、顺序相同:'+WANT_NAV.join(' / ')+'(资产排最后)');
else bad('导航项与用户口径不一致:实为「'+gotNav.join(' / ')+'」,应为「'+WANT_NAV.join(' / ')+'」');
const unreachable=Object.keys(PAGES).filter(p=>navHomes.indexOf(p)<0);
if(unreachable.length===0) ok('七页一次点击可达:侧栏 '+navItems.length+' 个功能项直达(⛔ 已撤销「功能入口」那一层,跨页入口只留在侧栏与板块内行级动作)');
else bad('这些页面从侧栏到不了:'+unreachable.join(' / '));
/* 3b-2) 侧栏基准形态(2026-10-10 用户令「左侧导航改回去」)。
依据:`design-system-tiaoyue/SKILL.md` 行 95「左侧栏(app-sidebar,宽 w-16)」⇒ w-16 = 64px,
加第八轮实测值(栏 64/内衬 3/圆角 100/项 56×78/字 12px-500-行高 16)。
⚠️ 形态一改,判据的选择器与断言就必须跟着改,否则旧判据静默失效变恒绿(本项目已踩 4 次)。
判据分两档:桌面(>640)看 64px 竖排项;窄屏(≤640)看横滚标签条仍横排。 */
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const navShape=[];
const it0=document.querySelector('#nav .nav-item');
const narrowNow=window.innerWidth<=640;
const sbW=Math.round(document.querySelector('.sidebar').getBoundingClientRect().width);
if(!it0) navShape.push('找不到 .nav-item');
else if(narrowNow){
if(getComputedStyle(it0).flexDirection!=='row') navShape.push('≤640 的导航项没回到横排');
} else {
if(sbW!==64) navShape.push('侧栏宽 '+sbW+'px(期望 64)');
if(getComputedStyle(it0).flexDirection!=='column') navShape.push('.nav-item 是 '+getComputedStyle(it0).flexDirection+'(期望 column:图标在上、名字在下)');
const ic=it0.querySelector('svg');
const icw=ic?Math.round(ic.getBoundingClientRect().width):-1;
if(icw!==24) navShape.push('侧栏图标宽 '+icw+'px(期望 24)');
const ih=Math.round(it0.getBoundingClientRect().height);
if(Math.abs(ih-78)>1) navShape.push('导航项高 '+ih+'px(期望 78)');
const lb=((it0.querySelector('.nav-label')||{}).textContent||'').trim();
if(lb.length!==2) navShape.push('导航项名不是两字常驻:'+lb);
}
if(navShape.length===0) ok('侧栏基准形态:64px 药丸浮栏、导航项 56×78、图标在上(24px)名字在下(两字常驻);≤640 回横滚标签条');
else bad('侧栏没回到基准形态:'+navShape.join(' / '));
/* 3b-3) 页面级动作「刷新」=标题行右侧的 icon 按钮(2026-10-10 用户令)。
判据:七页各 1 个 SP-n 挂在 `.page-shell-title` 内;按钮无文字、有图标、有 aria-label;
⛔ 页面里不再有独立成行的 `.page-shell-ops`。 */
const rfBad=[];
Object.keys(PAGES).forEach(p=>{
applyScene({name:'rf-'+p, set:()=>{ S.route=p; }});
const bs=[...document.querySelectorAll('#view .page-shell-title [data-act="SP-'+p.slice(1)+'"]')];
if(bs.length!==1) rfBad.push(p+' 的标题行里刷新按钮 '+bs.length+' 个(期望 1)');
else{
const b=bs[0];
if(b.textContent.trim()) rfBad.push(p+' 的刷新按钮还带文字「'+b.textContent.trim()+'」');
if(!b.querySelector('svg')) rfBad.push(p+' 的刷新按钮没有图标');
if(!(b.getAttribute('aria-label')||'').trim()) rfBad.push(p+' 的刷新按钮缺 aria-label');
}
if(document.querySelectorAll('#view .page-shell-ops').length) rfBad.push(p+' 还留着独立成行的 .page-shell-ops');
});
if(rfBad.length===0) ok('页面级刷新:七页各 1 个 icon 按钮挂在页面标题行右侧(无文字、带 title/aria-label),⛔ 不再独立占一行');
else bad('刷新按钮没改对:'+rfBad.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) 逐个真驱动 */
const res={}; const why={};
const markOk=a=>{res[a]='ok'; delete why[a];};
const markShy=(a,w,wy)=>{ if(res[a]==='ok') return; res[a]=w; if(wy!==undefined) why[a]=wy; };
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(ps=>{
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(ps.only));
acts.forEach(act=>{
applyScene(sc);
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+' 个(按契约在特定条件下不可点)'+(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:'P7「采纳这条归因」→ 回写', sc:{set:()=>{ S.route='p7'; S.attrDraft=''; }}, 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(' / '));
/* 4d) 前置开关:P7 未采纳归因 ⇒ 回写必须禁用;采纳后 ⇒ 转可用(②段 2a F10) */
applyScene({name:'P7 未采纳', set:()=>{ S.route='p7'; S.attrDraft='写了一条结论'; S.confirmed=false; }});
const wBefore=[...document.querySelectorAll('[data-act="P7-07"]')].every(b=>b.hasAttribute('disabled'));
const ac=[...document.querySelectorAll('[data-act="P7-04"]')][0];
if(ac) ac.dispatchEvent(new MouseEvent('click',{bubbles:true}));
const wAfter=[...document.querySelectorAll('[data-act="P7-07"]')].some(b=>!b.hasAttribute('disabled'));
if(wBefore&&wAfter) ok('P7「确认与改判」是回写的**前置开关**:未采纳时回写禁用、采纳后转可用(不是只留痕)');
else bad('P7 前置开关不对:未采纳时禁用='+wBefore+'、采纳后可用='+wAfter+'(期望 true / true)');
/* 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) 骨架冻结闸 */
const norm=s=>String(s).split('(')[0].trim();
/* 页标题比较前先剥掉页码前缀:②段的小节标题形如「### P1 矩阵总览」,
其中 P1 是**页码**,界面上的 h1 只写标题本体(「矩阵总览」)。
⛔ 直接逐字比会把七页全判红 —— 那是判据太死,不是界面错。 */
const titleOf=s=>String(s||'').replace(/^P\d\s*/,'').trim();
const skelBad=[];
Object.keys(FROZEN).forEach(p=>{
applyScene({name:p, set:()=>{ S.route=p; }});
const [wantPg,want]=FROZEN[p];
const gotPg=($('#view h1')||{}).textContent;
const got=[...document.querySelectorAll('#view .block .block-head h2')].map(h=>h.textContent.trim());
if(!gotPg||titleOf(gotPg)!==titleOf(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(' / '));
/* 5b-2) 页面公共框架 ± 对象冻结(本项目追加的两条机检) */
const shellBad=[]; const objBad=[]; let shellOk=0;
Object.keys(FROZEN).forEach(p=>{
applyScene({name:'shell-'+p, set:()=>{ S.route=p; }});
const sh=[...document.querySelectorAll('#view .page-shell')];
if(sh.length!==1) shellBad.push(p+' 的 .page-shell 有 '+sh.length+' 个(期望 1)');
else {
shellOk++;
const s=sh[0];
if(s.classList.contains('block')) shellBad.push(p+' 的 .page-shell 自己也带了 .block(会混进板块计数)');
if(s.querySelector('.block-head h2')) shellBad.push(p+' 的 .page-shell 里出现了 .block-head h2(会被当成板块)');
}
/* 对象冻结:每个业务板块一个 data-obj,取值 ∈ 11 对象字典 或 '—'('—' 必须带说明) */
const blocks=[...document.querySelectorAll('#view .block[data-obj]')];
const want=FROZEN[p][1];
if(blocks.length!==want.length) objBad.push(p+' 带 data-obj 的板块 '+blocks.length+' 个 ≠ 骨架 '+want.length+' 个');
blocks.forEach(b=>{
const o=b.dataset.obj, note=b.dataset.objNote||'';
if(o!=='—' && OBJECTS.indexOf(o)<0) objBad.push(p+' 的板块「'+b.querySelector('h2').textContent+'」对象「'+o+'」不在 11 对象字典里');
if(o==='—' && !note) objBad.push(p+' 的板块「'+b.querySelector('h2').textContent+'」标了「—」但没写说明');
const tn=b.querySelector('h2').textContent.trim();
const exp=(OBJ_MAP[p]||{})[tn];
if(exp!==undefined && exp!==o) objBad.push(p+' 的板块「'+tn+'」对象应为 '+exp+',实为 '+o);
});
});
if(shellBad.length===0) ok('页面公共框架(Page Shell):七页各恰好 1 个,且都不带 .block / 不带 .block-head h2 ⇒ 不计业务板块数、不占冻结计数');
else bad('页面公共框架出错:'+shellBad.join(' / '));
if(objBad.length===0) ok('对象冻结:七页 '+totalFrozen+' 个业务板块各带一个核心业务对象,取值全在 11 对象字典内(或显式「—」+说明),且与 OBJ_MAP 逐块一致');
else bad('对象冻结出错:'+objBad.join(' / '));
/* 5b-3) 功能 ↔ 板块 一一对应(2026-10-09 本轮新增判据,用户令「所有功能要和各自板块对应」)。
三条判据:① 每条功能 F1–F14 至少落在一个板块上(无悬空功能);
② 每条功能的**每个**归属页上都要有它的板块(功能跟得上页);
③ 每个板块至少挂一条功能(无孤立板块)。
⚠️ 这条必须**双向**查:只查「功能都有块」会漏掉「块挂了字典外的功能」,
只查「块都有功能」会漏掉「某条功能整页没落地」。 */
const fnBad=[], fnSeen={};
const FN_IDS=Object.keys(FN_HOME);
Object.keys(FROZEN).forEach(p=>{
const want=FROZEN[p][1], m=FN_MAP[p]||{};
want.forEach(b=>{
const fns=m[b];
if(!fns){ fnBad.push(p+' 的板块「'+b+'」在 FN_MAP 里没有条目'); return; }
if(!fns.length){ fnBad.push(p+' 的板块「'+b+'」没挂任何功能'); return; }
fns.forEach(f=>{
if(FN_IDS.indexOf(f)<0) fnBad.push(p+' 的板块「'+b+'」挂了字典外的功能 '+f);
else (fnSeen[f]=fnSeen[f]||[]).push(p);
});
});
Object.keys(m).filter(b=>want.indexOf(b)<0).forEach(b=>fnBad.push(p+' 的 FN_MAP 里有一个骨架里不存在的板块「'+b+'」'));
});
FN_IDS.forEach(f=>{
if(!fnSeen[f]){ fnBad.push(f+' 没有任何板块承接(悬空功能)'); return; }
FN_HOME[f].forEach(h=>{ if(fnSeen[f].indexOf(h)<0) fnBad.push(f+' 的归属页 '+h+' 上没有它的板块'); });
});
if(fnBad.length===0) ok('功能 ↔ 板块一一对应:'+FN_IDS.length+' 条功能各自落到归属页的板块上,'+totalFrozen+' 个板块每个至少挂一条功能(无悬空、无孤立)');
else bad('功能与板块对不上:'+fnBad.join(' / '));
/* 辅助入口区不许把流转表再写一遍 */
const auxBad=[];
Object.keys(FROZEN).forEach(p=>{
applyScene({name:'aux-'+p, set:()=>{ S.route=p; }});
const aux=[...document.querySelectorAll('#view .page-shell-aux')];
const links=aux.reduce((n,a)=>n+a.querySelectorAll('[data-page]').length,0);
if(links>1) auxBad.push(p+' 的辅助入口区有 '+links+' 个跳转入口(上限 1)');
});
if(auxBad.length===0) ok('辅助入口区:每页最多 1 个跳转入口 ⇒ ⛔ 没有把流转表在框架层重写一遍');
else bad('辅助入口区超限:'+auxBad.join(' / '));
/* 「功能入口」那一层已撤销:页面上不许再出现 .sect-nav / .data-card / .data-cards。
🔴 2026-10-09 修:原写法只查 `document.querySelectorAll('.'+c)` —— 那看的是**当刻渲染中的那一页**,
而上面那条「辅助入口区」检查刚把 DOM 停在 FROZEN 的最后一页 ⇒ 只看了那一页,
别页藏一个 .sect-nav 根本不会报红(变异 M18 就是这样漏掉的:它注进 P1,却报 PASS 77/FAIL 0)。
现在**七页逐页渲染后再查**,与下面 5c 那几条同款。 */
const goneClasses=['sect-nav','data-card','data-cards'], goneHit=[];
Object.keys(FROZEN).forEach(p=>{
applyScene({name:'gone-'+p, set:()=>{ S.route=p; }});
goneClasses.forEach(c=>{ const n=document.querySelectorAll('.'+c).length; if(n) goneHit.push(c+'@'+p+'×'+n); });
});
if(goneHit.length===0) ok('「功能入口」那一层已撤销:.sect-nav / .data-card / .data-cards 在七页**逐页**出现 0 次(2b §九第 2 条)');
else bad('还有功能入口层的残留类名:'+goneHit.join(', '));
/* 5c) 结构四条 + 组件口径 */
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(' / '));
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(';'));
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(' / '));
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(' / '));
const cssAll=(()=>{ let t=''; try{ [...document.styleSheets].forEach(ss=>{ try{ [...ss.cssRules].forEach(r=>{ t+=r.cssText+'\n'; }); }catch(e){} }); }catch(e){} return t; })();
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) 浮层核 */
const LAYERS=[
{n:'随手记', sc:()=>{ S.route='p1'; }, trg:'G-06'},
{n:'两处假设说明', sc:()=>{ S.route='p1'; }, trg:'G-08'},
{n:'资产选择器', sc:()=>{ S.route='p4'; }, trg:'P4-04'},
{n:'发布二次确认', sc:()=>{ S.route='p5'; S.targetOpen=true; S.checkState='pass'; }, trg:'P5-08'},
{n:'核销二次确认', sc:()=>{ S.route='p7'; }, trg:'P7-11'}
];
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+' 层没打开'); return; }
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||''));
if(exits.length===1) ok('浮层「'+n+'」中性关闭出口 = 1(右上 ×)|另有决策型关闭 '+deciders.length+' 个'+(deciders.length?'('+deciders.map(actOf).join('、')+')':''));
else bad('浮层「'+n+'」中性关闭出口 = '+exits.length+',契约要求恰好 1');
if(inLayer()) ok('浮层「'+n+'」打开后焦点在层内(当前='+actOf(document.activeElement)+')'); else bad('浮层「'+n+'」打开后焦点不在层内');
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 后焦点漏到层外');
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) 数值取证 */
L.push('--- 数值取证(布局视口 '+document.documentElement.clientWidth+' × '+document.documentElement.clientHeight+' ;window.innerWidth '+window.innerWidth+')---');
const guardScroll=tag=>{
const t=scrollTops(); 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+')'); else bad('量尺寸时页面没回零点('+tag+'):'+bad0.join('、')+' ⇒ 下面的落位与行数全是假数');
};
const probe=document.createElement('span');
probe.style.cssText='position:absolute;left:-99999px;top:0;width:0;height:0';
document.body.appendChild(probe);
const rgbOf=t=>{ probe.style.color=''; probe.style.color='var('+t+')'; return getComputedStyle(probe).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');
});
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),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]; };
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){accPct=pc;accN=n;accPage=p;} });
if(accPct<=5) ok('强调色填充面积占比:七页最高 '+PAGES[accPage]+' = '+r1(accPct)+'%('+accN+' 个填充块,契约 ≤5%)'); 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));
const rowFit=b=>{
if(!b) return null;
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');
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+' 条');
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');
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+' 条');
else bad('P2 对标内容列表一屏只能装下 '+(p2Fit?p2Fit.cap+' 行':'?')+',低于契约 6 行/900px 高');
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');
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')
ok('纵向滚动只由主区承担:.app 高 '+appH+'px = 视口 '+window.innerHeight+'px;body 未承担滚动;主区 overflow-y '+vOvf+',clientH '+vwEl.clientHeight+' / scrollH '+vwEl.scrollHeight+'px'+(vCan?'(内容更高 ⇒ 主区可滚,折叠线以下的块拿得到)':'(本视口内容没超)'));
else bad('外壳高度链没约束住:.app '+appH+'px / 视口 '+window.innerHeight+'px;body.scrollTop '+document.body.scrollTop+';overflow-y '+vOvf);
const tbH=Math.round(document.querySelector('.topbar').getBoundingClientRect().height);
const navH=Math.round(document.querySelector('.sidebar').getBoundingClientRect().height);
const navW=Math.round(document.querySelector('.sidebar').getBoundingClientRect().width);
L.push('(附)P1 行高 '+(p1Fit?p1Fit.hs.join('/')+'px':'量不到')+' · 量时 scrollY='+window.scrollY+' · 顶栏高 '+tbH+'px · 侧栏 '+navW+'×'+navH+'px · 主区 clientH '+vwEl.clientHeight+' / scrollH '+vwEl.scrollHeight);
L.push('控制台报错:'+(CONSOLE_ERRORS.length?CONSOLE_ERRORS.join(' | '):'0 条'));
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"><div class="panel"><div class="row"><span class="meta">自检是原型自带的:它在真实场景里逐个点过每个可点元素,证明没有死按钮。</span>'
+'<button class="btn btn-sm" data-act="STATE-NORMAL">回到正常内容</button></div></div></div></section>';
}
if(!location.hash) location.hash='#/p/p1/ok';
parseHash(); paint();
</script>
</body>
</html>