Files
contentm_agent/执行会话/.trash/20261008-d85687/_probe/blk-p6.html
T

1672 lines
108 KiB
HTML
Raw Normal View History

<!DOCTYPE html>
<html lang="zh-CN" data-theme="light"><head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>账号画像 · MCN 短视频整合营销工作台</title>
<style>
/* ============================================================
三层令牌:primitive(--t- 与 --raw- 前缀)→ semantic(无语义前缀)→ component(--c- 前缀)
定调来源:design-system-tiaoyue 亮色基线(9 页 clipper 实测)
⛔ 本文件不设 .eyebrow / .lead / .hero(工具型页面)
============================================================ */
:root{
/* ---- primitive ---- */
--t-paper:#faf9f5; --t-surface:#ffffff; --t-surface-soft:#f7f6f1;
--t-ink:#1a1a18; --t-ink-mute:#57564f; --t-ink-faint:#8a887f;
--t-line:#e7e5dc; --t-line-soft:#efeee7;
--t-accent:#9E4CFF; --t-accent-text:#963dff; --t-on-accent:#FFFFFF;
--t-ok:#3d7a4f; --t-err:#b4453a; --t-warn:#8a5a00;
--raw-radius-card:24px; --raw-radius-panel:16px; --raw-radius-row:14px;
--raw-radius-input:9px; --raw-radius-btn:26px;
/* ---- semantic ---- */
--paper:var(--t-paper); --surface:var(--t-surface); --surface-soft:var(--t-surface-soft);
--ink:var(--t-ink); --ink-mute:var(--t-ink-mute); --ink-faint:var(--t-ink-faint);
--line:var(--t-line); --line-soft:var(--t-line-soft);
--accent:var(--t-accent); --accent-text:var(--t-accent-text); --on-accent:var(--t-on-accent);
--ok:var(--t-ok); --err:var(--t-err); --warn:var(--t-warn);
--fs-12:12px; --fs-14:14px; --fs-16:16px; --fs-18:18px; --fs-20:20px; --fs-24:24px; --fs-30:30px;
--sp-4:4px; --sp-8:8px; --sp-16:16px; --sp-24:24px; --sp-32:32px; --sp-64:64px;
--mg-2:2px; --mg-4:4px; --mg-6:6px; --mg-8:8px; --mg-12:12px;
--font: "PingFang SC", -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", "Noto Sans SC", sans-serif;
--sh-L1: 0 1px 1px rgba(26,26,24,.03), 0 2px 6px -2px rgba(26,26,24,.06);
--sh-L2: 0 1px 2px rgba(26,26,24,.04), 0 8px 20px -12px rgba(26,26,24,.14), 0 18px 40px -18px rgba(26,26,24,.10);
--sh-L3: 0 2px 4px rgba(26,26,24,.05), 0 14px 32px -12px rgba(26,26,24,.18), 0 32px 64px -24px rgba(26,26,24,.16);
/* ---- component ---- */
--c-btn-h:40px; --c-btn-h-sm:32px; --c-btn-px:20px; --c-btn-py:12px;
--c-card-pad:24px; --c-card-pad-dense:16px;
--c-input-h:36px; --c-tag-h:22px;
--nav-w:240px;
--tfast: .15s cubic-bezier(0.32,0.72,0,1);
}
@media (max-width:1024px){ :root{ --nav-w:200px; } }
*,*::before,*::after{ box-sizing:border-box; }
html,body{ margin:0; padding:0; }
body{
background:var(--paper); color:var(--ink);
font-family:var(--font); font-size:var(--fs-14); line-height:1.55;
-webkit-font-smoothing:antialiased;
}
h1,h2,h3{ margin:0; font-weight:600; }
h1{ font-size:var(--fs-24); line-height:1.3; }
h2{ font-size:var(--fs-18); line-height:1.35; }
h3{ font-size:var(--fs-16); line-height:1.4; }
p{ margin:0; }
ul,ol{ margin:0; padding:0; list-style:none; }
button{ font-family:inherit; }
a{ color:var(--accent-text); text-decoration:none; }
a:hover{ text-decoration:underline; }
.num{ font-variant-numeric:tabular-nums; }
.meta{ font-size:var(--fs-12); color:var(--ink-mute); font-variant-numeric:tabular-nums; }
.nowrap{ white-space:nowrap; }
/* 焦点环:⛔ 不许被 outline:none 抹掉 */
:focus-visible{ outline:2px solid var(--accent); outline-offset:2px; }
/* ============ 外壳:主区 + 侧区 ============ */
.app{ display:grid; grid-template-columns:var(--nav-w) minmax(0,1fr); min-height:100vh; }
.sidebar{
background:var(--paper); border-right:1px solid var(--line-soft);
padding:var(--sp-24) var(--mg-12) var(--sp-24) var(--mg-12);
display:flex; flex-direction:column; gap:var(--sp-24); min-width:0;
}
.brand{ padding:0 var(--mg-8); }
.brand-mark{ font-size:var(--fs-16); font-weight:600; letter-spacing:.01em; }
.nav-group{ display:flex; flex-direction:column; gap:var(--mg-4); }
.nav-group-title{
display:flex; align-items:center; width:100%; height:32px; padding:0 var(--mg-8);
border:0; border-radius:var(--raw-radius-row); background:transparent; text-align:left;
font-family:inherit; font-size:var(--fs-12); color:var(--ink-mute); font-weight:600; cursor:pointer;
transition:background-color var(--tfast);
}
.nav-group-title:hover{ background:var(--surface-soft); }
.nav-group-items{ display:flex; flex-direction:column; gap:var(--mg-2); padding-left:var(--mg-8); }
.nav-item{
display:flex; align-items:center; justify-content:space-between; gap:var(--mg-8);
width:100%; height:36px; padding:0 var(--mg-12); border:0; border-left:3px solid transparent;
background:transparent; color:var(--ink); font-size:var(--fs-14); text-align:left;
border-radius:var(--raw-radius-row); cursor:pointer; min-width:0;
transition:background-color var(--tfast), border-color var(--tfast);
}
.nav-item:hover{ background:var(--surface-soft); }
.nav-item.is-active{ background:var(--surface-soft); border-left-color:var(--accent); font-weight:600; }
.nav-item .nav-count{ font-size:var(--fs-12); color:var(--ink-mute); font-variant-numeric:tabular-nums; }
.main{ display:flex; flex-direction:column; min-width:0; }
.topbar{
display:flex; align-items:center; gap:var(--sp-16); flex-wrap:wrap;
padding:var(--sp-16) var(--sp-32); border-bottom:1px solid var(--line-soft);
background:var(--paper); position:sticky; top:0; z-index:5;
}
.topbar .spacer{ flex:1 1 auto; }
.topbar-label{ font-size:var(--fs-12); color:var(--ink-mute); }
.view{ padding:var(--sp-24) var(--sp-32) var(--sp-64) var(--sp-32); min-width:0; }
/* ============ 控件 ============ */
.btn{
display:inline-flex; align-items:center; justify-content:center; gap:var(--mg-6);
height:var(--c-btn-h); padding:0 var(--c-btn-px); border-radius:var(--raw-radius-btn);
font-size:var(--fs-14); font-weight:500; cursor:pointer; white-space:nowrap;
border:1px solid var(--line); background:var(--surface); color:var(--ink);
transition:background-color var(--tfast), border-color var(--tfast), box-shadow var(--tfast);
}
.btn:hover{ border-color:var(--ink-faint); }
.btn-primary{
background:var(--accent); border-color:var(--accent); color:var(--on-accent);
font-size:var(--fs-16); font-weight:600; /* 4.26:1 需走大字线 3:1,故 ≥16px + 600 */
}
.btn-primary:hover{ border-color:var(--accent); box-shadow:var(--sh-L1); }
.btn-ghost{
height:var(--c-btn-h-sm); padding:0 var(--mg-12); border:0; background:transparent;
color:var(--accent-text); font-size:var(--fs-14);
}
.btn-ghost:hover{ background:var(--surface-soft); }
.btn[disabled],.btn-primary[disabled]{ opacity:.45; cursor:not-allowed; }
.btn[disabled]:hover{ border-color:var(--line); box-shadow:none; }
.btn-sm{ height:var(--c-btn-h-sm); padding:0 var(--mg-12); font-size:var(--fs-14); }
.input,.select,.textarea{
width:100%; min-width:0; border:1px solid var(--line); border-radius:var(--raw-radius-input);
background:var(--surface); color:var(--ink); font-family:inherit;
}
.input,.select{ height:var(--c-input-h); padding:0 var(--mg-12); font-size:var(--fs-14); }
.textarea{ padding:var(--mg-8) var(--mg-12); font-size:var(--fs-14); line-height:1.6; resize:vertical; }
.input[disabled],.textarea[disabled],.select[disabled]{ background:var(--surface-soft); color:var(--ink-faint); cursor:not-allowed; }
.field-label{ display:block; font-size:var(--fs-12); color:var(--ink-mute); font-weight:600; margin-bottom:var(--mg-4); }
.switch{ height:var(--c-input-h); padding:0 var(--mg-8); font-size:var(--fs-14); }
.tag{
display:inline-flex; align-items:center; height:var(--c-tag-h); padding:0 var(--mg-8);
border-radius:999px; background:var(--surface-soft); font-size:var(--fs-12); font-weight:600;
color:var(--ink-mute); white-space:nowrap;
}
.tag-ok{ color:var(--ok); } .tag-err{ color:var(--err); } .tag-warn{ color:var(--warn); }
.tag-assume{ color:var(--warn); }
/* ============ 结构件 ============ */
.block{ margin-top:var(--sp-32); min-width:0; }
.block:first-child{ margin-top:0; }
.block-head{
display:flex; align-items:center; gap:var(--sp-16); flex-wrap:wrap;
padding-bottom:var(--mg-8); border-bottom:1px solid var(--line); margin-bottom:var(--sp-16);
}
.block-head .grow{ flex:1 1 auto; }
.panel{ background:var(--surface); border-radius:var(--raw-radius-panel); box-shadow:var(--sh-L1); padding:var(--c-card-pad-dense); min-width:0; }
.card{ background:var(--surface); border-radius:var(--raw-radius-card); box-shadow:var(--sh-L2); padding:var(--c-card-pad); min-width:0; }
.soft{ background:var(--surface-soft); border-radius:var(--raw-radius-panel); padding:var(--sp-16); min-width:0; }
.grid-main-side{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:var(--sp-24); align-items:start; }
.grid-cards{ display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--sp-16); }
/* 两栏带:把相邻两块并排。读序不变(左 → 右),只是不再一条道往下堆。
⛔ 不是增删板块:②段那份的顺序照旧,这里动的是「同一批块的摆法」。
起因见 3b-实测记录.md:单列往下堆时,P1 账号矩阵第 6 行掉到折叠线以下,密集档的
「一屏 ≥6 行」判据不达标。 */
.band{ display:grid; grid-template-columns:minmax(0,360px) minmax(0,1fr); gap:var(--sp-24); align-items:start; min-width:0; }
.band > .block{ margin-top:0; min-width:0; }
/* 统计条:一行放完几个数,⛔ 不用四张等高卡(那既占高又没主次) */
.stat{ display:flex; flex-direction:column; gap:var(--mg-2); text-align:left; background:transparent; border:0; padding:0; min-width:0; }
.stat .num{ font-size:var(--fs-24); }
button.stat{ cursor:pointer; }
button.stat:hover .stat-label{ color:var(--accent-text); }
.row{ display:flex; align-items:center; gap:var(--mg-8); flex-wrap:wrap; min-width:0; }
.stack{ display:flex; flex-direction:column; gap:var(--sp-16); min-width:0; }
.hairline{ border-top:1px solid var(--line-soft); }
/* 行式列表:行间 border-top,⛔ 不给行套框、⛔ 不给行整行底色 */
.list{ display:flex; flex-direction:column; min-width:0; }
.list-row{
display:grid; grid-template-columns:minmax(0,2.2fr) minmax(0,1.6fr) auto;
gap:var(--sp-16); align-items:center; width:100%; text-align:left;
padding:var(--mg-12) var(--mg-8); border:0; border-top:1px solid var(--line-soft);
background:transparent; color:var(--ink); cursor:pointer; font-size:var(--fs-14); min-width:0;
transition:background-color var(--tfast);
}
.list-row:first-child{ border-top:0; }
.list-row:hover{ background:var(--surface-soft); }
.list-row.is-active{ box-shadow:inset 3px 0 0 var(--accent); }
.list-row .primary-cell{ display:flex; flex-direction:column; gap:var(--mg-2); min-width:0; }
.list-row .primary-cell strong{ font-weight:600; }
.list-row .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-row.is-dense{ grid-template-columns:auto minmax(0,1fr) auto; padding:var(--mg-8); }
.list-row.is-dense .primary-cell{ flex-direction:row; align-items:baseline; }
.list-row.is-dense .action-cell{ color:var(--ink-mute); font-size:var(--fs-12); }
.list-plain{ display:flex; flex-direction:column; }
.list-plain > li{ padding:var(--mg-12) var(--mg-8); border-top:1px solid var(--line-soft); font-size:var(--fs-14); }
.list-plain > li:first-child{ border-top:0; }
table.ds-table{ width:100%; border-collapse:collapse; font-size:var(--fs-14); min-width:0; }
.table-wrap{ overflow-x:auto; min-width:0; }
table.ds-table th{
text-align:left; font-size:var(--fs-12); color:var(--ink-mute); font-weight:600;
padding:var(--mg-8) var(--mg-8); border-bottom:1px solid var(--line);
}
table.ds-table td{ padding:var(--mg-8); border-top:1px solid var(--line-soft); vertical-align:top; }
table.ds-table .num-col{ text-align:right; font-variant-numeric:tabular-nums; }
/* 环节进度 */
.steps{ display:flex; gap:var(--mg-4); flex-wrap:wrap; }
.step{
display:flex; align-items:center; gap:var(--mg-6); height:var(--c-btn-h-sm);
padding:0 var(--mg-12); border:1px solid var(--line); border-radius:999px;
background:var(--surface); font-size:var(--fs-12); cursor:pointer; color:var(--ink-mute);
transition:background-color var(--tfast), border-color var(--tfast), color var(--tfast);
}
.step.done{ color:var(--ink); }
.step.current{ background:var(--ink); border-color:var(--ink); color:var(--surface); font-weight:600; }
.step[disabled]{ opacity:.45; cursor:not-allowed; }
/* 演示开关条 */
.demo{ display:flex; align-items:center; gap:var(--mg-8); }
.statebar{ display:flex; flex-wrap:wrap; gap:var(--mg-4); }
/* 状态面板 */
.state-panel{ background:var(--surface); border-radius:var(--raw-radius-card); box-shadow:var(--sh-L2); padding:var(--sp-64) var(--sp-32); text-align:center; }
.state-panel h2{ margin-bottom:var(--mg-8); }
.state-panel p{ color:var(--ink-mute); max-width:56ch; margin:0 auto var(--sp-16); }
.skel{ height:14px; border-radius:var(--mg-8); background:var(--surface-soft); }
.skel-w70{ width:70%; } .skel-w45{ width:45%; } .skel-w90{ width:90%; }
.skel-stack{ display:flex; flex-direction:column; gap:var(--mg-12); }
/* 遮罩 / 浮层:可见关闭出口唯一(右上 ×) */
.overlay{
position:fixed; inset:0; background:rgba(26,26,24,.34);
display:flex; align-items:flex-start; justify-content:center; padding:var(--sp-64) var(--sp-16);
z-index:50;
}
.layer{
width:min(560px, calc(100vw - 32px)); background:var(--surface);
border-radius:var(--raw-radius-card); box-shadow:var(--sh-L3); padding:var(--sp-24);
max-height:calc(100vh - 128px); overflow:auto; text-align:left;
}
.layer-head{ display:flex; align-items:center; gap:var(--sp-16); margin-bottom:var(--sp-16); }
.layer-head h2{ flex:1 1 auto; }
.layer-close{
width:var(--c-btn-h-sm); height:var(--c-btn-h-sm); flex:0 0 auto; border:1px solid var(--line);
border-radius:999px; background:var(--surface); color:var(--ink); font-size:var(--fs-16);
line-height:1; cursor:pointer;
}
.layer-close:hover{ background:var(--surface-soft); }
.layer-actions{ display:flex; gap:var(--mg-8); justify-content:flex-end; margin-top:var(--sp-24); }
/* 提示条 */
.toast{
position:fixed; left:50%; bottom:var(--sp-32); transform:translateX(-50%);
background:var(--ink); color:var(--surface); font-size:var(--fs-14);
padding:var(--mg-8) var(--sp-16); border-radius:999px; box-shadow:var(--sh-L2); z-index:80;
}
.notice{
display:flex; gap:var(--mg-8); align-items:flex-start; padding:var(--mg-12) var(--sp-16);
border-radius:var(--raw-radius-panel); background:var(--surface-soft); font-size:var(--fs-14);
margin-bottom:var(--sp-16);
}
.notice strong{ font-weight:600; }
.notice-warn strong{ color:var(--warn); }
.notice-err strong{ color:var(--err); }
/* selftest 输出 */
.st-out{ background:#14140f; color:#f2f1ea; border-radius:var(--raw-radius-panel); padding:var(--sp-16); font-family:ui-monospace,Consolas,monospace; font-size:var(--fs-12); white-space:pre-wrap; overflow-x:auto; }
.st-pass{ color:#8fd6a5; } .st-fail{ color:#ff9a8f; }
@media (max-width:1024px){
.grid-cards{ grid-template-columns:repeat(2,minmax(0,1fr)); }
}
@media (max-width:640px){
.app{ grid-template-columns:minmax(0,1fr); }
.sidebar{
flex-direction:row; align-items:center; gap:var(--mg-4); overflow-x:auto;
border-right:0; border-bottom:1px solid var(--line-soft);
padding:var(--mg-8) var(--mg-8); position:sticky; top:0; z-index:6; background:var(--paper);
}
.brand{ display:none; }
.nav-group{ flex-direction:row; align-items:center; gap:var(--mg-4); }
.nav-group-title{ width:auto; padding:0 var(--mg-8); border-bottom:3px solid transparent; border-radius:999px; }
.nav-group-items{ flex-direction:row; gap:var(--mg-4); padding-left:0; }
.nav-item{ width:auto; border-left:0; border-bottom:3px solid transparent; border-radius:999px; }
.nav-item.is-active{ border-left:0; border-bottom-color:var(--accent); }
.view{ padding:var(--sp-16) var(--sp-16) var(--sp-64); }
.topbar{ padding:var(--mg-12) var(--sp-16); }
.grid-main-side{ grid-template-columns:minmax(0,1fr); }
.grid-cards{ grid-template-columns:minmax(0,1fr); }
.list-row{ grid-template-columns:minmax(0,1fr); }
.list-row .action-cell{ justify-content:flex-start; }
}
@media (prefers-reduced-motion: reduce){
*{ transition:none !important; }
}
</style>
</head>
<body>
<div class="app">
<aside class="sidebar">
<div class="brand"><span class="brand-mark">整合营销工作台</span></div>
<nav id="nav">
<div class="nav-group">
<button class="nav-group-title" data-act="G-01" data-page="p2">找选题</button>
<div class="nav-group-items">
<button class="nav-item " data-act="G-05" data-page="p2">
<span>选题雷达</span><span class="nav-count">5</span>
</button>
<button class="nav-item " data-act="G-05" data-page="p3">
<span>资产库</span><span class="nav-count">6</span>
</button>
</div>
</div>
<div class="nav-group">
<button class="nav-group-title" data-act="G-02" data-page="p4">走创作流水线</button>
<div class="nav-group-items">
<button class="nav-item " data-act="G-05" data-page="p4">
<span>创作台</span>
</button>
<button class="nav-item " data-act="G-05" data-page="p5">
<span>发布中心</span><span class="nav-count">3</span>
</button>
</div>
</div>
<div class="nav-group">
<button class="nav-group-title" data-act="G-03" data-page="p1">看矩阵复盘</button>
<div class="nav-group-items">
<button class="nav-item " data-act="G-05" data-page="p1">
<span>矩阵总览</span><span class="nav-count">5</span>
</button>
<button class="nav-item " data-act="G-05" data-page="p7">
<span>复盘台</span><span class="nav-count">2</span>
</button>
<button class="nav-item " data-act="G-05" data-page="p8">
<span>记录与对账</span><span class="nav-count">5</span>
</button>
</div>
</div>
<div class="nav-group">
<button class="nav-group-title" data-act="G-04" data-page="p6">账号画像(跨组)</button>
<div class="nav-group-items">
<button class="nav-item is-active" data-act="G-05" data-page="p6">
<span>账号画像</span>
</button>
</div>
</div></nav>
</aside>
<div class="main">
<header class="topbar">
<!-- 下面两个下拉是「看原型用的」,不是产品功能:一个切账号上下文,一个把当前页掰到六态上。
标签直接写「演示」,免得被当成产品里的控件。 -->
<div class="demo" title="原型演示控件,不是产品功能">
<span class="topbar-label">演示 · 账号</span>
<select class="select switch" id="account-pick" data-act="G-07" aria-label="切换演示账号"><option value="a1" selected="">深夜食堂笔记</option><option value="a2">阿元的下班饭</option><option value="a3">阳台种菜日记</option><option value="a4">校门口那家店</option><option value="a5">咖啡因实验室</option></select>
</div>
<div class="demo" title="原型演示控件,不是产品功能">
<span class="topbar-label">演示 · 状态</span>
<select class="select switch" id="state-pick" data-act="G-09" aria-label="切换演示状态"><option value="ok" selected="">正常</option><option value="loading">加载中</option><option value="empty">空</option><option value="error">出错</option><option value="disabled">条件未满足</option><option value="noperm">无权限</option></select>
</div>
<div class="spacer"></div>
<button class="btn btn-sm" data-act="G-10">全局检索</button>
<button class="btn btn-sm" data-act="G-06">随手记</button>
<button class="btn btn-sm" data-act="G-08">假设 2 项</button>
</header>
<main class="view" id="view">
<div class="block-head"><h2 class="grow">P6 账号画像</h2></div>
<div class="block grid-main-side">
<section>
<div class="block-head"><h2 class="grow">账号列表</h2><span class="meta">5 个</span></div>
<div class="panel"><ul class="list">
<li><div class="list-row is-active" data-act="P6-01" data-acct="a1" role="button" tabindex="0">
<span class="primary-cell"><strong>深夜食堂笔记</strong><span class="meta">抖音</span></span>
<span class="secondary-cell"><span>42 条内容</span></span>
<span class="action-cell"></span>
</div></li>
<li><div class="list-row " data-act="P6-01" data-acct="a2" role="button" tabindex="0">
<span class="primary-cell"><strong>阿元的下班饭</strong><span class="meta">小红书</span></span>
<span class="secondary-cell"><span>31 条内容</span></span>
<span class="action-cell"></span>
</div></li>
<li><div class="list-row " data-act="P6-01" data-acct="a3" role="button" tabindex="0">
<span class="primary-cell"><strong>阳台种菜日记</strong><span class="meta">抖音</span></span>
<span class="secondary-cell"><span>18 条内容</span></span>
<span class="action-cell"></span>
</div></li>
<li><div class="list-row " data-act="P6-01" data-acct="a4" role="button" tabindex="0">
<span class="primary-cell"><strong>校门口那家店</strong><span class="meta">视频号</span></span>
<span class="secondary-cell"><span>9 条内容</span></span>
<span class="action-cell"></span>
</div></li>
<li><div class="list-row " data-act="P6-01" data-acct="a5" role="button" tabindex="0">
<span class="primary-cell"><strong>咖啡因实验室</strong><span class="meta">B 站</span></span>
<span class="secondary-cell"><span>26 条内容</span></span>
<span class="action-cell"></span>
</div></li></ul></div>
</section>
<section>
<div class="block-head"><h2 class="grow">画像六维</h2><span class="meta">深夜食堂笔记</span></div>
<div class="panel">
<div style="margin-bottom:var(--sp-16)">
<label class="field-label" for="p6-定位">定位</label>
<input class="input" id="p6-定位" data-act="P6-02" data-k="定位" value="城市独居青年的快手饭">
</div>
<div style="margin-bottom:var(--sp-16)">
<label class="field-label" for="p6-风格">风格</label>
<input class="input" id="p6-风格" data-act="P6-02" data-k="风格" value="暖色实拍 · 无口播 · 字幕为主">
</div>
<div style="margin-bottom:var(--sp-16)">
<label class="field-label" for="p6-受众">受众</label>
<input class="input" id="p6-受众" data-act="P6-02" data-k="受众" value="22–30 岁,一二线独居">
</div>
<div style="margin-bottom:var(--sp-16)">
<label class="field-label" for="p6-平台">平台</label>
<input class="input" id="p6-平台" data-act="P6-02" data-k="平台" value="抖音为主,同步小红书">
</div>
<div style="margin-bottom:var(--sp-16)">
<label class="field-label" for="p6-偏好">偏好与红线</label>
<input class="input" id="p6-偏好" data-act="P6-02" data-k="偏好" value="真实厨房、不做摆拍;不接减肥类">
</div>
<div>
<p class="field-label">长期记忆</p>
<p class="meta">2 条 · 只追加,不覆盖历史结论</p>
</div>
<div class="row" style="margin-top:var(--sp-16);justify-content:space-between">
<span class="meta">没有改动</span>
<button class="btn btn-primary" data-act="P6-03" disabled="">保存画像</button>
</div>
</div>
</section>
</div>
<section class="block">
<div class="block-head"><h2 class="grow">记忆条目</h2><button class="btn btn-ghost" data-act="P6-04">展开</button></div>
<div class="panel">
<p class="meta">记忆是复盘回写的产物,不是编辑画像的输入。</p>
</div>
</section>
<section class="block">
<div class="block-head"><h2 class="grow">关联内容</h2></div>
<div class="panel">
<ul class="list">
<li><div class="list-row" data-act="P6-05" data-k="k1" role="button" tabindex="0">
<span class="primary-cell"><strong>一个人的晚饭怎么摆得不寒酸</strong><span class="meta">来源:对标号 · 小食光</span></span>
<span class="secondary-cell"><span>待做</span></span>
<span class="action-cell"><span class="tag ">去创作台</span></span>
</div></li>
<li><div class="list-row" data-act="P6-05" data-k="k2" role="button" tabindex="0">
<span class="primary-cell"><strong>五千块月薪的一周菜谱</strong><span class="meta">来源:评论区洞察</span></span>
<span class="secondary-cell"><span>待做</span></span>
<span class="action-cell"><span class="tag ">去创作台</span></span>
</div></li>
<li><div class="list-row" data-act="P6-05" data-k="k3" role="button" tabindex="0">
<span class="primary-cell"><strong>冰箱清库存系列</strong><span class="meta">来源:灵感速记</span></span>
<span class="secondary-cell"><span>在写</span></span>
<span class="action-cell"><span class="tag ">去创作台</span></span>
</div></li></ul>
</div>
</section></main>
</div>
</div>
<div id="layer-root"></div>
<script>
/* ============================================================
MCN 短视频整合营销工作台 · 可点通原型
状态由 hash 路由的第二段驱动: #/p/p4/empty
页面骨架照 prd/2b-界面布局.md 的八页(冻结),⛔ 未增删板块
============================================================ */
/* ---------- 数据(示意内容按真实产品的样子写,不写"示例/未接入"字样) ---------- */
const ACCOUNTS = [
{id:'a1',name:'深夜食堂笔记',platform:'抖音',posts:42,today:'待发:3 分钟快手菜',posted:'已发 1 条 10:20',perf:'播放 12.4 万 · 完播 38%',anom:true},
{id:'a2',name:'阿元的下班饭',platform:'小红书',posts:31,today:'待审 1 条:周五专题',posted:'已发 1 条 09:05',perf:'点赞 4 820 · 收藏率 9.1%',anom:false},
{id:'a3',name:'阳台种菜日记',platform:'抖音',posts:18,today:'在写:换盆实录',posted:'未发',perf:'上条播放 2.1 万',anom:false},
{id:'a4',name:'校门口那家店',platform:'视频号',posts:9,today:'待发:探店补拍',posted:'已发 1 条 11:40',perf:'转发 610 · 评论 88',anom:true},
{id:'a5',name:'咖啡因实验室',platform:'B 站',posts:26,today:'待复盘 1 条',posted:'已发 1 条 08:30',perf:'三连率 6.3%',anom:false}
];
const TODOS = [
{id:'t1',kind:'待审',text:'周五专题 · 3 个版本等确认',to:'p5',acct:'阿元的下班饭',due:'今天 12:00'},
{id:'t2',kind:'在写',text:'换盆实录 · 停在文案态',to:'p4',acct:'阳台种菜日记',due:'今天'},
{id:'t3',kind:'待复盘',text:'咖啡因实验室 · 昨天那期数据回了',to:'p7',acct:'咖啡因实验室',due:'今天'},
{id:'t4',kind:'待核销',text:'校门口那家店 · 一条商单记录待核',to:'p8',acct:'校门口那家店',due:'10-09'},
{id:'t5',kind:'选题',text:'半夜解馋系列 · 5 条候选待筛',to:'p2',acct:'深夜食堂笔记',due:'本周'}
];
const BENCH = [
{id:'b1',title:'一个人吃饭也要摆盘',acct:'对标号 · 小食光',mult:'4.8×',when:'3 小时前',tag:'爆款',zone:'低粉爆款'},
{id:'b2',title:'月薪五千怎么吃好一周',acct:'对标号 · 省钱食堂',mult:'3.1×',when:'昨天',tag:'爆款',zone:'低粉爆款'},
{id:'b3',title:'冰箱清库存的三种做法',acct:'对标号 · 冰箱博士',mult:'1.2×',when:'昨天',tag:'常态',zone:'常态'},
{id:'b4',title:'夜宵摊老板不会说的三件事',acct:'对标号 · 夜市老王',mult:'6.4×',when:'前天',tag:'异常',zone:'异常值'},
{id:'b5',title:'十分钟出一餐的偷懒法',acct:'对标号 · 懒人厨房',mult:'0.9×',when:'3 天前',tag:'常态',zone:'常态'}
];
const CANDIDATES = [
{id:'c1',text:'半夜饿了除了泡面还能怎么办',hit:'这条评论被顶了 2.1 千次,是这个号的老问题',state:'idle'},
{id:'c2',text:'一个人住怎么买食材不浪费',hit:'连续 4 条内容下面都在问',state:'idle'},
{id:'c3',text:'有没有不用洗锅的做法',hit:'出现 62 次',state:'idle'}
];
const TOPICS = [
{id:'k1',text:'一个人的晚饭怎么摆得不寒酸',group:'todo',src:'对标号 · 小食光',state:'待做'},
{id:'k2',text:'五千块月薪的一周菜谱',group:'todo',src:'评论区洞察',state:'待做'},
{id:'k3',text:'冰箱清库存系列',group:'writing',src:'灵感速记',state:'在写'},
{id:'k4',text:'夜宵摊的三个坑',group:'todo',src:'对标号 · 夜市老王',state:'待做'},
{id:'k5',text:'十分钟一餐的偷懒法',group:'done',src:'对标号 · 懒人厨房',state:'已用'},
{id:'k6',text:'换盆实录:土到底怎么配',group:'writing',src:'灵感速记',state:'在写'}
];
const ASSETS = [
{id:'as1',zone:'angle',text:'用"一个人"这个处境切,不用"简单"',src:'对标号 · 小食光 · 2026-10-06 那条',state:'已入库',time:'10-06',draft:false},
{id:'as2',zone:'angle',text:'把价格焦虑变成对照,不给结论',src:'对标号 · 省钱食堂 · 2026-10-05',state:'已入库',time:'10-05',draft:false},
{id:'as3',zone:'structure',text:'先给成品,再倒推步骤',src:'对标号 · 冰箱博士 · 2026-10-05',state:'待入库',time:'10-05',draft:false},
{id:'as4',zone:'structure',text:'开头三秒抛反常识,再解释',src:'',state:'待入库',time:'10-07',draft:true},
{id:'as5',zone:'opening',text:'"这句话我听了三年,直到自己试了一次"',src:'对标号 · 夜市老王 · 2026-10-04',state:'已入库',time:'10-04',draft:false},
{id:'as6',zone:'title',text:'数字 + 处境 + 反预期(3 种写法)',src:'对标号 · 懒人厨房 · 2026-10-03',state:'已入库',time:'10-03',draft:false}
];
const STEPS = ['选题态','思路态','文案态','标题态','封面态','检查态','成稿态'];
const VERSIONS = [
{id:'v1',name:'初稿',time:'10-07 21:10',cur:false},
{id:'v2',name:'加了价格对照',time:'10-08 09:02',cur:false},
{id:'v3',name:'缩到 45 秒',time:'10-08 10:15',cur:true}
];
const RECORDS = [
{id:'rec1',text:'深夜食堂笔记 · 3 分钟快手菜',by:'陈队长',time:'10-08 10:20',ver:'v3',state:'已发布',acct:'深夜食堂笔记',settle:'待结算',amount:'¥ 3 200'},
{id:'rec2',text:'阿元的下班饭 · 周五专题',by:'李编导',time:'10-08 09:05',ver:'v2',state:'已发布',acct:'阿元的下班饭',settle:'已结算',amount:'—'},
{id:'rec3',text:'校门口那家店 · 探店补拍',by:'陈队长',time:'10-08 11:40',ver:'v1',state:'待审',acct:'校门口那家店',settle:'未登记',amount:'—'},
{id:'rec4',text:'咖啡因实验室 · 手冲水温那期',by:'李编导',time:'10-07 08:30',ver:'v4',state:'已发布',acct:'咖啡因实验室',settle:'已结算',amount:'¥ 1 800'},
{id:'rec5',text:'阳台种菜日记 · 换盆实录',by:'李编导',time:'10-06 19:12',ver:'v1',state:'已归档',acct:'阳台种菜日记',settle:'未登记',amount:'—'}
];
const REVIEWS = [
{id:'r1',text:'咖啡因实验室 · 手冲水温那期',acct:'咖啡因实验室',day:'10-07',data:'到了',play:'8.6 万',fin:'41%',tri:'6.3%',note:'结论:水温段讲太长,掉在 38 秒处'},
{id:'r2',text:'深夜食堂笔记 · 3 分钟快手菜',acct:'深夜食堂笔记',day:'10-08',data:'到了',play:'12.4 万',fin:'38%',tri:'—',note:''},
{id:'r3',text:'校门口那家店 · 探店补拍',acct:'校门口那家店',day:'10-06',data:'未回',play:'—',fin:'—',tri:'—',note:''}
];
const PROFILES = {
a1:{定位:'城市独居青年的快手饭',风格:'暖色实拍 · 无口播 · 字幕为主',受众:'22–30 岁,一二线独居',平台:'抖音为主,同步小红书',偏好:'真实厨房、不做摆拍;不接减肥类',记忆:['10-06 观众对"一人食"这个词有共鸣,标题里保留','10-07 45 秒以下完播明显更好']},
a2:{定位:'下班后的一顿正经饭',风格:'慢剪 · 有人声 · 长镜头',受众:'25–35 岁,做饭新手',平台:'小红书',偏好:'不夸大功效、不用"必吃"',记忆:['10-05 收藏率靠步骤图带起来']},
a3:{定位:'阳台可复制的小规模种菜',风格:'记录向 · 手持 · 过程为主',受众:'30–45 岁',平台:'抖音',偏好:'不承诺产量',记忆:[]},
a4:{定位:'小城小店的故事',风格:'人物访谈 · 现场收音',受众:'本地 + 怀旧向',平台:'视频号',偏好:'不摆拍老板、不虚构故事',记忆:['10-04 转发集中在"开店多少年"这句']},
a5:{定位:'把咖啡讲得不玄',风格:'实验对比 · 数据可视化',受众:'咖啡爱好者',平台:'B 站',偏好:'不吹器具、不做器材带货',记忆:['10-07 水温段太长,38 秒处开始掉']}
};
const CONTENT = {key:'k3',title:'冰箱清库存系列 · 第 3 条',step:2,stepOut:['选题:冰箱剩菜怎么配','思路:先给成品,再倒推三步','','','','','']};
/* ---------- 状态 ---------- */
const S = {
route:'p1', pageState:'ok', account:'a1',
layer:null, noteDraft:'', searchQ:'',
topics:JSON.parse(JSON.stringify(TOPICS)), topicGroup:'todo',
benchFilter:'all', commentOpen:false, commentState:'idle',
candidates:JSON.parse(JSON.stringify(CANDIDATES)),
assets:JSON.parse(JSON.stringify(ASSETS)), assetZone:'angle', assetSel:'as1',
refs:[],
step:CONTENT.step, stepOut:CONTENT.stepOut.slice(), stepBusy:false,
versionOpen:false,
targetOpen:false, targets:{accounts:['a1','a2'],platforms:['抖音']},
versions:JSON.parse(JSON.stringify(VERSIONS)), verSel:'v1', verDraft:null, verDirty:false,
checkState:'idle', confirmOpen:false, publishDone:false,
profileDirty:false, memoryOpen:false,
reviewSel:'r1', attrDraft:'', historyOpen:false, wroteBack:false,
recordSel:'rec1', traceOpen:false, billingOpen:false, checkedOut:false,
toast:null
};
const PAGES = {
p1:'矩阵总览', p2:'选题雷达', p3:'资产库', p4:'创作台',
p5:'发布中心', p6:'账号画像', p7:'复盘台', p8:'记录与对账'
};
const NAV = [
{group:'找选题', act:'G-01', items:[['p2','选题雷达'],['p3','资产库']]},
{group:'走创作流水线', act:'G-02', items:[['p4','创作台'],['p5','发布中心']]},
{group:'看矩阵复盘', act:'G-03', items:[['p1','矩阵总览'],['p7','复盘台'],['p8','记录与对账']]},
{group:'账号画像(跨组)', act:'G-04', items:[['p6','账号画像']]}
];
const STATES = [['ok','正常'],['loading','加载中'],['empty','空'],['error','出错'],['disabled','条件未满足'],['noperm','无权限']];
/* ---------- 每页各状态的说明(逐页不同,⛔ 不用一句通用话糊过去) ---------- */
const STATE_COPY = {
p1:{loading:['正在取矩阵数据','5 个账号的今日数据正在汇总。'],empty:['还没接入账号','矩阵总览要先有账号。去账号画像建第一个账号。','p6'],error:['数据源不可用','最近一次成功取值:10-08 09:12。数据源没回话,这里不显示空白冒充正常。'],disabled:['今天没有要处理的事','待处理列表为空,主操作暂时没有对象。'],noperm:['利润相关字段需要管理员权限','你当前是编导角色,账号矩阵里的利润列对你不显示。']},
p2:{loading:['正在拉对标更新','3 个在监控的账号正在取近三天内容。'],empty:['监控名单是空的','先录一个对标号,才能看到它的更新和异常值。'],error:['拉取失败','平台授权可能已过期。可以先手工录入对标号,不必卡在这里。'],disabled:['还没选账号','选题要落到某个账号上才能转入创作。'],noperm:['—','']},
p3:{loading:['条目加载中','正在读这个分区的拆解结果。'],empty:['这个分区还没有条目','去选题雷达发一条拆解,结果会落到这里。','p2'],error:['读取失败','本地知识库目录没读出来。重试一次,或检查目录是否还在。'],disabled:['这条还不能入库','它没有出处,按门禁先留在草稿里。补上出处就能入库。'],noperm:['—','']},
p4:{loading:['正在跑这一步','文案生成中,已跑 24 秒。'],empty:['这一步还没有产出','先在上面写点方向,再点开始。'],error:['这一步失败了','已产出的内容保留在这里,可以直接重试这一步。'],disabled:['还差封面','文案有了、封面还没有。进检查态前封面必须有。'],noperm:['—','']},
p5:{loading:['检查中','硬闸与软闸正在过这一批版本。'],empty:['还没有可发布的版本','成稿之后才会有版本。回创作台把内容推到成稿。','p4'],error:['检查本身没跑成','按不过处理:宁拦不放。改完可以复检。'],disabled:['有硬拦项没处理','敏感信息这一处不改完,确认发布点不动。'],noperm:['这个账号你没有发布权限','校门口那家店归陈队长。你可以看版本,不能替它发。']},
p6:{loading:['画像加载中','正在读这个账号的口径与记忆。'],empty:['还没有画像','建一份画像,这个账号才有自己的口径。'],error:['保存失败','你改的内容还在,没有被清掉。重试一次。'],disabled:['这个账号你只能看','它不是归你负责的账号,字段锁住了。'],noperm:['没有改画像的权限','你可以看,改动需要内容负责人来做。']},
p7:{loading:['正在读这条的数据','已发布内容的回读最多 90 秒。'],empty:['还没有能复盘的内容','先有发布记录,才有复盘。','p8'],error:['数据读不到','可以手工录入这条的表现,归因照做。'],disabled:['归因还没写','没有结论就不能回写,回写会改画像记忆。'],noperm:['—','']},
p8:{loading:['记录加载中','正在读发布留痕。'],empty:['还没有发布记录','确认发布之后,这里会留一条。'],error:['读取失败','本地留痕库没读出来。重试一次。'],disabled:['还没选中记录','先点一条记录,才有东西可核销。'],noperm:['利润列对你隐藏','你不是管理员,收支金额列不显示。其余列照常可看。']}
};
/* ---------- 反馈提示 ---------- */
let toastTimer=null;
function toast(text){ S.toast=text; paint(); clearTimeout(toastTimer); toastTimer=setTimeout(()=>{ S.toast=null; paint(); },1500); }
/* ---------- 路由 ---------- */
function parseHash(){
const raw=(location.hash||'').replace(/^#\/?/,'');
const parts=raw.split('/').filter(Boolean); // ['p','p4','empty']
if(parts[0]==='selftest'){ S.route='selftest'; S.pageState='ok'; 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;
}
/* 跳页:先同步改内存态,再写 hash。
⚠️ 为什么不能只写 hash:hash 变化带来的 hashchange 是**异步**的 ⇒ 点完立刻读状态的一方
(自检、以及连点两下的用户)看到的是旧页。同步改态让跳页对调用者立即可见;
hash 照样写,是为刷新 / 前进后退 / 分享链接留的入口。
跳页一律关闭浮层 —— 不关的话浮层会盖在新页上,看着像没跳。 */
function go(page,st){
if(!PAGES[page]) return;
S.route=page; S.pageState=st||'ok'; S.layer=null;
const want='#/p/'+page+'/'+(st||'ok');
if(location.hash!==want){ try{ location.hash=want; }catch(e){} }
paint();
}
/* ---------- 元素 ---------- */
const $=s=>document.querySelector(s);
function esc(s){ return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); }
function el(html){ const d=document.createElement('div'); d.innerHTML=html.trim(); return d.firstElementChild; }
/* ---------- 导航 ---------- */
function renderNav(){
$('#nav').innerHTML = NAV.map(g=>`
<div class="nav-group">
<button class="nav-group-title" data-act="${g.act}" data-page="${g.items[0][0]}">${esc(g.group)}</button>
<div class="nav-group-items">
${g.items.map(([id,label])=>`
<button class="nav-item ${S.route===id?'is-active':''}" data-act="G-05" data-page="${id}">
<span>${esc(label)}</span>${navCount(id)}
</button>`).join('')}
</div>
</div>`).join('');
}
function navCount(id){
const n={p1:TODOS.length,p2:BENCH.length,p3:S.assets.length,p5:S.versions.length,p7:REVIEWS.filter(r=>r.data==='到了').length,p8:RECORDS.length}[id];
return n?'<span class="nav-count">'+n+'</span>':'';
}
/* ---------- 通用结构件 ---------- */
function head(title,right){
return `<div class="block-head"><h2 class="grow">${esc(title)}</h2>${right||''}</div>`;
}
function tag(text,cls){ return `<span class="tag ${cls||''}">${esc(text)}</span>`; }
const ASSUME_TAG='<span class="tag tag-assume">假设</span>';
/* ============================ 八页 ============================ */
function pageP1(){
const todos=TODOS.map(t=>`
<li><div class="list-row is-dense" data-act="P1-01" data-to="${t.to}" role="button" tabindex="0">
<span class="secondary-cell">${tag(t.kind,'')}</span>
<span class="primary-cell"><strong>${esc(t.text)}</strong></span>
<span class="action-cell"><span>${esc(t.acct)} · 截止 ${esc(t.due)}</span></span>
</div></li>`).join('');
const rows=ACCOUNTS.map(a=>`
<li><div class="list-row" data-act="P1-02" data-acct="${a.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(a.name)}</strong><span class="meta">${esc(a.platform)} · ${a.posts} 条内容</span></span>
<span class="secondary-cell"><span>今日该发:${esc(a.today)}</span><span>${esc(a.posted)} · ${esc(a.perf)}</span></span>
<span class="action-cell">${a.anom?tag('数据异动','tag-warn'):''}
<span data-act="P1-03" data-acct="${a.id}" class="btn btn-ghost" role="button" tabindex="0">看复盘</span></span>
</div></li>`).join('');
const anom=ACCOUNTS.filter(a=>a.anom);
return `
${head('P1 矩阵总览', ASMAY_NOTE_P1)}
<div class="band">
<section class="block">
${head('今日待处理', `<span class="meta">${TODOS.length} 项 · 按截止时间排</span>`)}
<div class="panel"><ul class="list">${todos}</ul></div>
</section>
<section class="block">
${head('账号矩阵列表', `<span class="meta">${ACCOUNTS.length} 个账号 · 按今日待办排</span>`)}
<div class="panel"><ul class="list">${rows}</ul></div>
</section>
</div>
<section class="block">
${head('异常值提示', `<span class="meta">${anom.length} 条异动</span>`)}
<div class="panel">
${anom.length?`<ul class="list">${anom.map(a=>`
<li><div class="list-row" data-act="P1-04" data-acct="${a.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(a.name)}</strong><span class="meta">${esc(a.perf)}</span></span>
<span class="secondary-cell"><span>比它平时高出一大截</span></span>
<span class="action-cell">${tag('去选题雷达','')}</span>
</div></li>`).join('')}</ul>`:`<p class="meta">目前没有异动。</p>`}
</div>
</section>
<section class="block">
${head('本月表现最好的一条', '')}
<div class="panel"><div class="row">
<div style="flex:1 1 240px;min-width:0">
<h3>阿元的下班饭 · 周五专题</h3>
<p class="meta">10-02 发布 · 点赞 9 800 · 收藏率 12.4%</p>
</div>
<button class="btn btn-sm" data-act="P1-05">看这条复盘</button>
</div></div>
</section>
<section class="block">
${head('全局检索与随手记', '')}
<div class="panel"><div class="row">
<button class="btn btn-sm" data-act="G-10">全局检索</button>
<button class="btn btn-sm" data-act="G-06">随手记一条</button>
<span class="meta">随手记在任意页面一次点击内都能碰到。</span>
</div></div>
</section>`;
}
const ASMAY_NOTE_P1 = ASSUME_TAG;
function pageP2(){
const zone=S.benchFilter;
const list=BENCH.filter(b=>zone==='all'||b.zone===zone);
const low=BENCH.filter(b=>b.zone==='低粉爆款').length, anom=BENCH.filter(b=>b.zone==='异常值').length;
const topicList=S.topics.filter(t=>t.group===S.topicGroup);
const groupLabel={todo:'待做',writing:'在写',done:'已用'};
return `
${head('P2 选题雷达','')}
<section class="block">
${head('对标监控概览', `<span class="meta">在监控 3 个账号 · 点数字可筛下面的列表</span>`)}
<div class="panel"><div class="row" style="gap:var(--sp-32)">
<span class="stat"><span class="meta">在监控账号</span><span class="num">3</span></span>
<button class="stat" data-act="P2-01" ${low?'':'disabled'}><span class="meta stat-label">低粉爆款</span><span class="num">${low}</span></button>
<button class="stat" data-act="P2-02" ${anom?'':'disabled'}><span class="meta stat-label">异常值</span><span class="num">${anom}</span></button>
<span class="stat"><span class="meta">近三天更新</span><span class="num">${BENCH.length}</span></span>
</div></div>
${zone!=='all'?`<p class="meta" style="margin-top:var(--mg-8)">列表已按「${esc(zone)}」筛过 · 点概览里的数字可再筛</p>`:''}
</section>
<section class="block">
${head('对标内容列表', `<span class="meta">${list.length} 条 · 倍数=相对该号平时水平</span>`)}
<div class="panel"><ul class="list">${list.map(b=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(b.title)}</strong><span class="meta">${esc(b.acct)} · ${esc(b.when)}</span></span>
<span class="secondary-cell"><span class="num">${esc(b.mult)} ${esc(b.tag)}</span></span>
<span class="action-cell"><button class="btn btn-sm" data-act="P2-03" data-bench="${b.id}">发起拆解</button></span>
</div></li>`).join('')}</ul></div>
</section>
<section class="block">
${head('评论区洞察', `<button class="btn btn-ghost" data-act="P2-04">${S.commentOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.commentOpen?`
<div class="row" style="margin-bottom:var(--sp-16)">
<button class="btn btn-sm" data-act="P2-05" ${S.commentState==='fetching'?'disabled':''}>抓取这条的评论</button>
<span class="meta">按点赞最多取前 20 条</span>
</div>
${S.commentState==='fetching'?`<div class="skel-stack"><div class="skel skel-w90"></div><div class="skel skel-w70"></div><div class="skel skel-w45"></div></div>`:''}
${S.commentState==='done'?`<ul class="list">${S.candidates.map(c=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(c.text)}</strong><span class="meta">${esc(c.hit)}</span></span>
<span class="secondary-cell"><span>${c.state==='idle'?'待处理':(c.state==='adopted'?'已采纳':'已忽略')}</span></span>
<span class="action-cell">
<button class="btn btn-sm" data-act="P2-06" data-c="${c.id}" ${c.state!=='idle'?'disabled':''}>采纳</button>
<button class="btn btn-sm" data-act="P2-07" data-c="${c.id}" ${c.state!=='idle'?'disabled':''}>忽略</button>
</span>
</div></li>`).join('')}</ul>`:''}
${S.commentState==='idle'?`<p class="meta">展开后点抓取,从这条内容的评论区里出候选选题。抓不到可以手工粘评论。</p>`:''}
`:`<p class="meta">这里不占主屏:从对标列表拿选题是主路,评论区是另一路输入。</p>`}
</div>
</section>
<section class="block">
${head('选题库', `<span class="meta">${topicList.length} 条</span>`)}
<div class="panel">
<div class="row" style="margin-bottom:var(--sp-16)">
${['todo','writing','done'].map(g=>`<button class="btn btn-sm" data-act="P2-08" data-group="${g}" ${S.topicGroup===g?'style="border-color:var(--ink);font-weight:600"':''}>${groupLabel[g]}</button>`).join('')}
</div>
<ul class="list">${topicList.map(t=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(t.text)}</strong><span class="meta">来源:${esc(t.src)}</span></span>
<span class="secondary-cell"><span>${esc(t.state)}</span></span>
<span class="action-cell">
<button class="btn btn-sm" data-act="P2-09" data-k="${t.id}" ${t.group==='done'?'disabled':''}>转入创作</button>
<button class="btn btn-sm" data-act="P2-10" data-k="${t.id}">拆解</button>
</span>
</div></li>`).join('')}</ul>
</div>
</section>
<section class="block">
${head('灵感速记', '')}
<div class="panel"><div class="row">
<button class="btn btn-sm" data-act="G-06">随手记一条</button>
<span class="meta">随手记在任意页面一次点击内都能碰到。</span>
</div></div>
</section>`;
}
function pageP3(){
const zones=[['angle','角度'],['structure','结构'],['opening','开场白'],['title','标题']];
const list=S.assets.filter(a=>a.zone===S.assetZone);
const sel=S.assets.find(a=>a.id===S.assetSel)||list[0];
const pending=S.assets.filter(a=>a.state==='待入库');
return `
${head('P3 资产库','')}
<section class="block">
${head('分区切换', `<span class="meta">一次看一个分区</span>`)}
<div class="panel"><div class="row">
${zones.map(([z,l])=>`<button class="btn btn-sm" data-act="P3-01" data-zone="${z}" ${S.assetZone===z?'style="border-color:var(--ink);font-weight:600"':''}>${l} <span class="num">${S.assets.filter(a=>a.zone===z).length}</span></button>`).join('')}
</div></div>
</section>
<div class="block grid-main-side">
<section>
${head('条目列表', `<span class="meta">${list.length} 条</span>`)}
<div class="panel"><ul class="list">${list.map(a=>`
<li><div class="list-row ${sel&&a.id===sel.id?'is-active':''}" data-act="P3-02" data-a="${a.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(a.text)}</strong><span class="meta">${a.src?('出处:'+esc(a.src)):'没有出处'}</span></span>
<span class="secondary-cell"><span>${esc(a.state)} · ${esc(a.time)}</span></span>
<span class="action-cell">${a.draft?tag('草稿','tag-warn'):''}</span>
</div></li>`).join('')}</ul></div>
</section>
<section>
${head('单条详情', sel?`<span class="meta">${esc(sel.time)} 采集</span>`:'')}
<div class="panel">
${sel?`
<p class="field-label">原文</p><p>${esc(sel.text)}</p>
<p class="field-label" style="margin-top:var(--sp-16)">出处</p><p>${sel.src?esc(sel.src):'没有出处 —— 按门禁只能留在草稿,补上出处才能入库。'}</p>
<div class="row" style="margin-top:var(--sp-16)">
${sel.draft?tag('草稿','tag-warn'):tag('已入库','tag-ok')}
${sel.state==='待入库'?`<button class="btn btn-sm" data-act="P3-03" data-a="${sel.id}" ${sel.src?'':'disabled'}>入库</button>`:''}
</div>
`:`<p class="meta">这个分区还没有条目。</p>`}
</div>
</section>
</div>
<section class="block">
${head('引用入口', `<span class="meta">把这条拿去创作台用</span>`)}
<div class="panel"><div class="row">
<button class="btn btn-sm" data-act="P3-04" data-a="${sel?sel.id:''}" ${sel?'':'disabled'}>引用到创作台</button>
<span class="meta">还有 ${pending.filter(p=>p.src).length} 条待入库,可入库的先入。</span>
</div></div>
</section>`;
}
function pageP4(){
const out=S.stepOut[S.step]||'';
return `
${head('P4 创作台','')}
<section class="block">
${head('当前内容', `<span class="meta">来自选题库</span>`)}
<div class="panel"><div class="row">
<div style="flex:1 1 260px;min-width:0">
<h3>${esc(CONTENT.title)}</h3>
<p class="meta">账号:${esc(ACCOUNTS[0].name)} · 当前版本 ${esc((S.versions.find(v=>v.cur)||S.versions[0]).id)}</p>
</div>
<button class="btn btn-sm" data-act="P4-07">版本列表 <span class="num">${S.versions.length}</span></button>
</div></div>
</section>
<section class="block">
${head('环节进度', `<span class="meta">第 ${S.step+1}/7 步 · 可回退</span>`)}
<div class="panel"><div class="steps">
${STEPS.map((s,i)=>`<button class="step ${i<S.step?'done':''} ${i===S.step?'current':''}" data-act="P4-01" data-i="${i}" ${i>S.step?'disabled':''}>${i+1} ${esc(s)}</button>`).join('')}
</div></div>
</section>
<section class="block">
${head('当前环节工作区', `<span class="meta">${esc(STEPS[S.step])}</span>`)}
<div class="panel">
<label class="field-label" for="p4-in">这一步要产出什么</label>
<textarea class="textarea" id="p4-in" data-act="P4-02" rows="5" placeholder="写清方向,点开始这一步">${esc(out)}</textarea>
<div class="row" style="margin-top:var(--sp-16);justify-content:space-between">
<button class="btn" data-act="P4-03" ${out?'':'disabled'}>${S.stepBusy?'生成中…':'开始这一步'}</button>
<button class="btn btn-primary" data-act="P4-06">推进到「${esc(STEPS[Math.min(S.step+1,6)])}」</button>
</div>
${S.stepBusy?'<div class="skel-stack" style="margin-top:var(--sp-16)"><div class="skel skel-w90"></div><div class="skel skel-w70"></div></div>':''}
</div>
</section>
<section class="block">
${head('资产引用区', `<span class="meta">${S.refs.length} 条已引用</span>`)}
<div class="panel">
${S.refs.length?`<ul class="list">${S.refs.map(r=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(r.text)}</strong><span class="meta">来自资产库 · ${esc(r.zone)}</span></span>
<span class="secondary-cell"></span>
<span class="action-cell"><button class="btn btn-sm" data-act="P4-05" data-a="${r.id}">移除引用</button></span>
</div></li>`).join('')}</ul>`:`<p class="meta">还没有引用资产。这里可以空着写,但引过一条的角度和结构,稿子会稳一些。</p>`}
<div class="row" style="margin-top:var(--sp-16)">
<button class="btn btn-sm" data-act="P4-04">从资产库拿一条</button>
</div>
</div>
</section>
<section class="block">
${head('版本', `<button class="btn btn-ghost" data-act="P4-07">${S.versionOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.versionOpen?`<ul class="list">${S.versions.map(v=>`
<li><div class="list-row">
<span class="primary-cell"><strong>${esc(v.id)}</strong><span class="meta">${esc(v.name)}</span></span>
<span class="secondary-cell"><span>${esc(v.time)}</span></span>
<span class="action-cell">${v.cur?tag('当前版','tag-ok'):`<button class="btn btn-sm" data-act="P4-08" data-v="${v.id}">切为当前版</button>`}</span>
</div></li>`).join('')}</ul>`:`<p class="meta">共 ${S.versions.length} 个版本。展开可看每个版本改了什么、切回旧版。</p>`}
</div>
</section>`;
}
function pageP5(){
const targetAccts=ACCOUNTS.filter(a=>S.targets.accounts.includes(a.id));
const vers=targetAccts.flatMap(a=>S.targets.platforms.map(p=>({key:a.id+'|'+p,acct:a.name,platform:p})));
const hard=S.checkState==='hard', soft=S.checkState==='soft';
return `
${head('P5 发布中心','')}
<section class="block">
${head('目标选择', `<button class="btn btn-ghost" data-act="P5-01">${S.targetOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.targetOpen?`
<p class="field-label">发给哪些账号</p>
<div class="row" style="margin-bottom:var(--sp-16)">${ACCOUNTS.map(a=>`<button class="btn btn-sm" data-act="P5-02" data-acct="${a.id}" ${S.targets.accounts.includes(a.id)?'style="border-color:var(--ink);font-weight:600"':''}>${esc(a.name)}</button>`).join('')}</div>
<p class="field-label">发给哪些平台</p>
<div class="row">${['抖音','小红书','视频号','B 站'].map(p=>`<button class="btn btn-sm" data-act="P5-03" data-p="${p}" ${S.targets.platforms.includes(p)?'style="border-color:var(--ink);font-weight:600"':''}>${esc(p)}</button>`).join('')}</div>
`:`<p class="meta">这次发 ${targetAccts.length} 个账号 × ${S.targets.platforms.length} 个平台 = ${vers.length} 个版本。从创作台带版本进来时目标已定,这里是补选。</p>`}
</div>
</section>
<div class="block grid-main-side">
<section>
${head('各版本预览与逐版编辑', `<span class="meta">${vers.length} 版</span>`)}
<div class="panel">
<div class="row" style="margin-bottom:var(--sp-16)">${vers.map(v=>`<button class="btn btn-sm" data-act="P5-04" data-v="${v.key}" ${S.verSel===v.key?'style="border-color:var(--ink);font-weight:600"':''}>${esc(v.acct)} / ${esc(v.platform)}</button>`).join('')}</div>
${vers.length?(()=>{ const cur=vers.find(v=>v.key===S.verSel)||vers[0]; return `
<p class="field-label" for="p5-edit">${esc(cur.acct)} · ${esc(cur.platform)}</p>
<textarea class="textarea" id="p5-edit" data-act="P5-05" rows="4">${esc(S.verDraft!==null?S.verDraft:('把这条内容按「'+cur.platform+'」的口径改一版:'+CONTENT.title))}</textarea>
<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-sm" data-act="P5-06" ${S.verDirty?'':'disabled'}>保存本版</button>${S.verDirty?tag('已改','tag-warn'):''}</div>
`})():'<p class="meta">还没有选目标账号。</p>'}
</div>
</section>
<section>
${head('检查结果', `<span class="meta">硬的拦住 · 软的只提醒</span>`)}
<div class="panel">
${S.checkState==='idle'?`<p class="meta">这一批还没过闸。</p>`:''}
${S.checkState==='checking'?`<div class="skel-stack"><div class="skel skel-w90"></div><div class="skel skel-w70"></div></div>`:''}
${hard?`<div class="notice notice-err"><strong>硬拦 1 处</strong><span>版本里出现了账号密钥(第 2 版第 3 行)。这一处不改完,不能发。</span></div>`:''}
${soft?`<div class="notice notice-warn"><strong>软劝 1 处</strong><span>这条的说话方式和这个账号平时的口径差得有点远。只是提醒,不拦你发。</span></div>`:''}
${S.checkState==='pass'?`<div class="notice"><strong>没有硬拦项</strong><span>两处软劝已看过。可以发。</span></div>`:''}
<div class="row" style="margin-top:var(--sp-16)"><button class="btn btn-sm" data-act="P5-07" ${S.checkState==='checking'?'disabled':''}>复检</button></div>
</div>
</section>
</div>
<section class="block">
${head('确认发布', `<span class="meta">这一步由人来点</span>`)}
<div class="panel"><div class="row" style="justify-content:space-between">
<span class="meta">${S.publishDone?'这一批已经登记进发布记录。':'发出后会在记录与对账里留一条,谁在什么时候确认的都查得到。'}</span>
<button class="btn btn-primary" data-act="P5-08" ${(hard||!vers.length||S.publishDone)?'disabled':''}>确认发布</button>
</div></div>
</section>
<section class="block">
${head('发布记录', '')}
<div class="panel"><div class="row">
<button class="btn btn-sm" data-act="P5-11">去记录与对账</button>
<span class="meta">目前 ${RECORDS.length} 条记录。</span>
</div></div>
</section>`;
}
function pageP6(){
const acc=ACCOUNTS.find(a=>a.id===S.account)||ACCOUNTS[0];
const p=PROFILES[acc.id]||{定位:'',风格:'',受众:'',平台:'',偏好:'',记忆:[]};
const fields=[['定位','定位'],['风格','风格'],['受众','受众'],['平台','平台'],['偏好','偏好与红线']];
return `
${head('P6 账号画像','')}
<div class="block grid-main-side">
<section>
${head('账号列表', `<span class="meta">${ACCOUNTS.length} 个</span>`)}
<div class="panel"><ul class="list">${ACCOUNTS.map(a=>`
<li><div class="list-row ${a.id===acc.id?'is-active':''}" data-act="P6-01" data-acct="${a.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(a.name)}</strong><span class="meta">${esc(a.platform)}</span></span>
<span class="secondary-cell"><span>${a.posts} 条内容</span></span>
<span class="action-cell"></span>
</div></li>`).join('')}</ul></div>
</section>
<section>
${head('画像六维', `<span class="meta">${esc(acc.name)}</span>`)}
<div class="panel">
${fields.map(([k,label])=>`
<div style="margin-bottom:var(--sp-16)">
<label class="field-label" for="p6-${k}">${esc(label)}</label>
<input class="input" id="p6-${k}" data-act="P6-02" data-k="${k}" value="${esc(p[k]||'')}">
</div>`).join('')}
<div>
<p class="field-label">长期记忆</p>
<p class="meta">${(p.记忆||[]).length} 条 · 只追加,不覆盖历史结论</p>
</div>
<div class="row" style="margin-top:var(--sp-16);justify-content:space-between">
<span class="meta">${S.profileDirty?'有改动没保存':'没有改动'}</span>
<button class="btn btn-primary" data-act="P6-03" ${S.profileDirty?'':'disabled'}>保存画像</button>
</div>
</div>
</section>
</div>
<section class="block">
${head('记忆条目', `<button class="btn btn-ghost" data-act="P6-04">${S.memoryOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.memoryOpen?((p.记忆&&p.记忆.length)?`<ul class="list-plain">${p.记忆.map(m=>`<li>${esc(m)}</li>`).join('')}</ul>`:`<p class="meta">这个账号还没有记忆条目。复盘回写会往这里加。</p>`):`<p class="meta">记忆是复盘回写的产物,不是编辑画像的输入。</p>`}
</div>
</section>
<section class="block">
${head('关联内容', '')}
<div class="panel">
${(S.topics.filter(t=>t.src.includes(acc.name)||true).slice(0,3).length)?`<ul class="list">${S.topics.slice(0,3).map(t=>`
<li><div class="list-row" data-act="P6-05" data-k="${t.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(t.text)}</strong><span class="meta">来源:${esc(t.src)}</span></span>
<span class="secondary-cell"><span>${esc(t.state)}</span></span>
<span class="action-cell">${tag('去创作台','')}</span>
</div></li>`).join('')}</ul>`:`<p class="meta">这个账号还没有做过内容。</p>`}
</div>
</section>`;
}
function pageP7(){
const r=REVIEWS.find(x=>x.id===S.reviewSel)||REVIEWS[0];
const acc=ACCOUNTS.find(a=>a.name===r.acct)||ACCOUNTS[0];
return `
${head('P7 复盘台','')}
<div class="block grid-main-side">
<section>
${head('待复盘列表', `<span class="meta">${REVIEWS.length} 条</span>`)}
<div class="panel"><ul class="list">${REVIEWS.map(x=>`
<li><div class="list-row ${x.id===r.id?'is-active':''}" data-act="P7-01" data-r="${x.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(x.text)}</strong><span class="meta">${esc(x.acct)} · ${esc(x.day)} 发布</span></span>
<span class="secondary-cell"><span>${x.data==='到了'?'数据已回':'数据还没回来'}</span></span>
<span class="action-cell">${x.data==='到了'?tag('可复盘','tag-ok'):tag('等数据','tag-warn')}</span>
</div></li>`).join('')}</ul></div>
</section>
<section>
${head('这一条的表现数据', `<span class="meta">${esc(r.day)} 回读</span>`)}
<div class="panel">
${r.data==='到了'?`
<div class="grid-cards" style="grid-template-columns:repeat(3,minmax(0,1fr))">
<div><p class="meta">播放</p><p class="num" style="font-size:var(--fs-20)">${esc(r.play)}</p></div>
<div><p class="meta">完播</p><p class="num" style="font-size:var(--fs-20)">${esc(r.fin)}</p></div>
<div><p class="meta">三连率</p><p class="num" style="font-size:var(--fs-20)">${esc(r.tri)}</p></div>
</div>
`:`<div class="notice notice-warn"><strong>数据还没回来</strong><span>可以先手工录这条的表现,归因照做,不用等。</span></div>
<button class="btn btn-sm" data-act="P7-02">手工录入表现</button>`}
</div>
</section>
</div>
<section class="block">
${head('归因结论', `<span class="meta">这一栏由人来定,机器只给数据</span>`)}
<div class="panel">
<label class="field-label" for="p7-attr">这条为什么是这个结果</label>
<textarea class="textarea" id="p7-attr" data-act="P7-03" rows="4" placeholder="写下你的判断。写了才能回写。">${esc(S.attrDraft||r.note)}</textarea>
</div>
</section>
<section class="block">
${head('回写目标', `<span class="meta">这次回写到哪个账号</span>`)}
<div class="panel"><div class="row" style="justify-content:space-between">
<div style="flex:1 1 240px;min-width:0">
<h3>${esc(acc.name)}</h3>
<p class="meta">${S.wroteBack?'已回写一条记忆到这份画像。':'回写会往这份画像的记忆里加一条,不改历史结论。'}</p>
</div>
<button class="btn btn-primary" data-act="P7-04" ${(S.attrDraft||r.note)?'':'disabled'}>确认归因并回写</button>
</div></div>
</section>
<section class="block">
${head('历史复盘', `<button class="btn btn-ghost" data-act="P7-05">${S.historyOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.historyOpen?`<ul class="list-plain">
<li>10-06 · 深夜食堂笔记:45 秒以下完播明显更好</li>
<li>10-05 · 阿元的下班饭:步骤图带起收藏率</li>
<li>10-04 · 校门口那家店:转发集中在"开店多少年"这句</li>
</ul>`:`<p class="meta">当前这次复盘不受历史影响,但写的时候可以参照。</p>`}
</div>
</section>`;
}
function pageP8(){
const r=RECORDS.find(x=>x.id===S.recordSel)||RECORDS[0];
return `
${head('P8 记录与对账','')}
<div class="block grid-main-side">
<section>
${head('发布记录列表', `<span class="meta">${RECORDS.length} 条 · 只追加不改写</span>`)}
<div class="panel"><ul class="list">${RECORDS.map(x=>`
<li><div class="list-row ${x.id===r.id?'is-active':''}" data-act="P8-01" data-rec="${x.id}" role="button" tabindex="0">
<span class="primary-cell"><strong>${esc(x.text)}</strong><span class="meta">${esc(x.by)} · ${esc(x.time)} · ${esc(x.ver)}</span></span>
<span class="secondary-cell"><span>${esc(x.state)}</span></span>
<span class="action-cell">${x.state==='已发布'?tag('可核销',''):tag(x.state,'tag-warn')}</span>
</div></li>`).join('')}</ul></div>
</section>
<section>
${head('单条溯源', `<button class="btn btn-ghost" data-act="P8-02">${S.traceOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.traceOpen?`
<ul class="list-plain">
<li>内容:${esc(r.text)}</li>
<li>版本:${esc(r.ver)}(当时发布用的就是这一版,之后改稿不影响它)</li>
<li>确认人:${esc(r.by)} · ${esc(r.time)}</li>
<li>检查留痕:硬拦 0 · 软劝 1(10-08 09:40)</li>
</ul>`:`<p class="meta">核销看列表就够;要深究这条发的是哪一版,点开这里。</p>`}
</div>
</section>
</div>
<section class="block">
${head('对账核对区', `<span class="meta">内容 · 发布记录 · 收支 三样对得上</span>`)}
<div class="panel">
<div class="table-wrap"><table class="ds-table">
<thead><tr><th>项</th><th>值</th><th>对得上</th></tr></thead>
<tbody>
<tr><td>内容</td><td>${esc(r.text)}</td><td>是</td></tr>
<tr><td>发布记录</td><td>${esc(r.by)} · ${esc(r.time)} · ${esc(r.ver)}</td><td>${r.state==='已发布'?'是':'还没有确认记录'}</td></tr>
<tr><td>收支</td><td class="num">${esc(r.amount)}</td><td>${r.amount==='—'?'没有商单':'与记录一致'}</td></tr>
</tbody>
</table></div>
<div class="row" style="margin-top:var(--sp-16);justify-content:space-between">
<span class="meta">${S.checkedOut?'这条已经核销。':'核销代表这三样你核过了。'}</span>
<button class="btn btn-primary" data-act="P8-03" ${(S.checkedOut||r.state!=='已发布')?'disabled':''}>核销这条记录</button>
</div>
</div>
</section>
<section class="block">
${head('商单与收支', `${ASSUME_TAG}<button class="btn btn-ghost" data-act="P8-04">${S.billingOpen?'收起':'展开'}</button>`)}
<div class="panel">
${S.billingOpen?`
<div class="notice notice-warn"><strong>本块是假设</strong><span>商单对账进不进第一版、收支口径是不是内容级,都还没有结论。这里按「进第一版、口径按内容级」画。</span></div>
<ul class="list-plain">
<li>${esc(r.text)} · 商单金额 <span class="num">${esc(r.amount)}</span> · ${esc(r.settle)}</li>
<li>深夜食堂笔记 · 10-08 · <span class="num">¥ 3 200</span> · 待结算</li>
<li>咖啡因实验室 · 10-07 · <span class="num">¥ 1 800</span> · 已结算</li>
</ul>`:`<p class="meta">一块 P2 的收支记录,可整块没有。</p>`}
</div>
</section>`;
}
/* ---------- 页面骨架渲染 ---------- */
const RENDER={p1:pageP1,p2:pageP2,p3:pageP3,p4:pageP4,p5:pageP5,p6:pageP6,p7:pageP7,p8:pageP8};
/* ---------- 页面级状态面板(逐页不同的话术) ---------- */
function statePanel(page){
const st=S.pageState; const c=(STATE_COPY[page]||{})[st];
if(!c||!c[1]) return '';
const [title,desc,to]=c;
const icons={loading:'',empty:'',error:'',disabled:'',noperm:''};
let body='';
if(st==='loading') body=`<div class="skel-stack" style="max-width:520px;margin:0 auto var(--sp-24)"><div class="skel skel-w90"></div><div class="skel skel-w70"></div><div class="skel skel-w45"></div></div>`;
if(st==='empty'&&to) body=`<button class="btn btn-primary" data-act="STATE-GO" data-to="${to}">去${esc(PAGES[to])}</button>`;
if(st==='error') body=`<button class="btn btn-sm" data-act="STATE-RETRY">重试一次</button>`;
if(st==='disabled') body=`<span class="tag tag-warn">条件未满足</span>`;
if(st==='noperm') body=`<span class="tag tag-warn">角色限制</span>`;
return `<section class="block"><div class="state-panel"><h2>${esc(title)}</h2><p>${esc(desc)}</p>${icons[st]}${body}</div></section>`;
}
/* ---------- 浮层 ---------- */
function layerHTML(){
const L=S.layer; if(!L) return '';
const close=`<button class="layer-close" data-act="X-close" aria-label="关闭">×</button>`;
if(L==='quicknote') return `<div class="layer">
<div class="layer-head"><h2>随手记一条</h2>${close}</div>
<label class="field-label" for="note">想到了什么</label>
<input class="input" id="note" data-act="R-01" value="${esc(S.noteDraft)}" placeholder="一句话就够,先进待做">
<div class="layer-actions">
<button class="btn" data-act="R-03">取消</button>
<button class="btn btn-primary" data-act="R-02" ${S.noteDraft?'':'disabled'}>保存进待做</button>
</div></div>`;
if(L==='search'){
const q=S.searchQ.trim();
const hits=q?[
{t:'选题 · '+ (S.topics.find(k=>k.text.includes(q))?.text||q),to:'p2'},
{t:'资产 · '+(S.assets.find(a=>a.text.includes(q))?.text||q),to:'p3'},
{t:'记录 · '+(RECORDS.find(r=>r.text.includes(q))?.text||q),to:'p8'}
]:[];
return `<div class="layer">
<div class="layer-head"><h2>全局检索</h2>${close}</div>
<label class="field-label" for="q">找选题、资产、记录</label>
<input class="input" id="q" data-act="S-01" value="${esc(S.searchQ)}" placeholder="打两个字就开始筛">
<div style="margin-top:var(--sp-16)">
${q?(hits.length?`<ul class="list">${hits.map(h=>`<li><div class="list-row" data-act="S-02" data-to="${h.to}" role="button" tabindex="0"><span class="primary-cell"><strong>${esc(h.t)}</strong></span><span class="secondary-cell"><span>去 ${esc(PAGES[h.to])}</span></span><span class="action-cell"></span></div></li>`).join('')}</ul>`:`<p class="meta">没有对上的条目。</p>`):`<p class="meta">输入关键字,结果按选题、资产、记录三类给。</p>`}
</div></div>`;
}
if(L==='assume') return `<div class="layer">
<div class="layer-head"><h2>两处还没结论的假设</h2>${close}</div>
<ul class="list-plain">
<li><strong>假设 1 · P1 的形态</strong><br>矩阵级视图是否真需要,还没有结论。这一版按「需要」画:P1 是矩阵总览,5 个板块都在。若结论是「不需要」,P1 降为单账号今日,只留今日待处理与本月最好,矩阵列表下移到账号切换。<br><span class="meta">复核人:主会话转用户 · 时机:②段收口时过 · 落点:2b §四 与 §二 P1</span></li>
<li><strong>假设 2 · P8 的第 4 板块</strong><br>商单对账进不进第一版、收支口径是不是内容级,还没有结论。这一版按「进第一版、口径按内容级」画,整块可缺。<br><span class="meta">复核人:主会话转用户 · 时机:同上 · 落点:2b §四 与 §二 P8</span></li>
</ul></div>`;
if(L==='versions') return `<div class="layer">
<div class="layer-head"><h2>版本列表</h2>${close}</div>
<ul class="list">${S.versions.map(v=>`<li><div class="list-row">
<span class="primary-cell"><strong>${esc(v.id)}</strong><span class="meta">${esc(v.name)}</span></span>
<span class="secondary-cell"><span>${esc(v.time)}</span></span>
<span class="action-cell">${v.cur?tag('当前版','tag-ok'):`<button class="btn btn-sm" data-act="P4-08" data-v="${v.id}">切为当前版</button>`}</span>
</div></li>`).join('')}</ul></div>`;
if(L==='assetpick') return `<div class="layer">
<div class="layer-head"><h2>从资产库拿一条</h2>${close}</div>
<ul class="list">${S.assets.filter(a=>a.state==='已入库').map(a=>`<li><div class="list-row">
<span class="primary-cell"><strong>${esc(a.text)}</strong><span class="meta">${esc(a.zone)} · 出处:${esc(a.src)}</span></span>
<span class="secondary-cell"></span>
<span class="action-cell"><button class="btn btn-sm" data-act="PICK-ASSET" data-a="${a.id}">引用</button></span>
</div></li>`).join('')}</ul></div>`;
if(L==='confirm') return `<div class="layer">
<div class="layer-head"><h2>确认发出这一批</h2>${close}</div>
<p>${S.targets.accounts.length} 个账号 × ${S.targets.platforms.length} 个平台。发出后会写进发布记录,谁在什么时候确认的都查得到。</p>
<div class="layer-actions">
<button class="btn" data-act="P5-10">取消</button>
<button class="btn btn-primary" data-act="P5-09">确认发出</button>
</div></div>`;
if(L==='checkout') return `<div class="layer">
<div class="layer-head"><h2>核销这条记录</h2>${close}</div>
<p>核销代表内容、发布记录、收支三样你都核过了。之后要改,得二次确认。</p>
<div class="layer-actions">
<button class="btn" data-act="X-close">取消</button>
<button class="btn btn-primary" data-act="CHECKOUT-OK">确认核销</button>
</div></div>`;
return '';
}
/* ---------- 绘制 ---------- */
const ST={ lock:false }; // 自检期间锁住异步重绘,防自检结果被覆盖
function paint(){
if(ST.lock) return;
document.title=(S.route==='selftest' ? '自检' : (PAGES[S.route]||''))+' · MCN 短视频整合营销工作台';
renderNav();
const ap=$('#account-pick'); if(ap){ ap.innerHTML=ACCOUNTS.map(a=>`<option value="${a.id}" ${a.id===S.account?'selected':''}>${esc(a.name)}</option>`).join(''); }
const sp=$('#state-pick'); if(sp){ sp.innerHTML=STATES.map(([v,l])=>`<option value="${v}" ${v===S.pageState?'selected':''}>${esc(l)}</option>`).join(''); }
const v=$('#view');
if(S.route==='selftest'){ v.innerHTML=renderSelftest(); return; }
const pageHTML = (S.pageState==='ok') ? RENDER[S.route]() : statePanel(S.route)+`<section class="block"><div class="panel"><div class="row"><span class="meta">这里是 ${esc(PAGES[S.route])} 的 ${esc(S.pageState)} 态。</span><button class="btn btn-sm" data-act="STATE-NORMAL">回到正常态</button></div></div></section>`;
v.innerHTML = pageHTML;
$('#layer-root').innerHTML = layerHTML();
if(S.toast){ $('#layer-root').insertAdjacentHTML('beforeend', `<div class="toast" role="status">${esc(S.toast)}</div>`); }
}
/* ============================ 交互清单(唯一真相源) ============================ */
/* data-act 值即 DESIGN.md §2.4 的 ID。下面每个键都必须有实现,否则自检报红。 */
const IMPL = {
/* 外壳 */
'G-01':()=>go('p2'), 'G-02':()=>go('p4'), 'G-03':()=>go('p1'), 'G-04':()=>go('p6'),
'G-05':e=>{ const p=e.target.closest('[data-page]'); if(p) go(p.dataset.page); }, /* 二级页项 */
'G-06':()=>{ S.layer='quicknote'; paint(); setTimeout(()=>{const n=$('#note'); if(n) n.focus();},0); },
'G-07':null, /* select,由 change 事件处理 */
'G-08':()=>{ S.layer='assume'; paint(); },
'G-09':null, /* select */
'G-10':()=>{ S.layer='search'; paint(); setTimeout(()=>{const q=$('#q'); if(q) q.focus();},0); },
'X-close':()=>{ S.layer=null; paint(); },
'R-01':null,'S-01':null, /* 输入框 */
'R-02':()=>{ if(!S.noteDraft) return; S.topics.unshift({id:'n'+Date.now(),text:S.noteDraft,group:'todo',src:'灵感速记',state:'待做'});
S.layer=null; S.noteDraft=''; paint(); toast('已进选题库待做'); },
'R-03':()=>{ S.layer=null; S.noteDraft=''; paint(); },
'S-02':e=>{ const to=e.target.closest('[data-to]').dataset.to; S.layer=null; S.searchQ=''; go(to); },
'STATE-NORMAL':()=>{ S.pageState='ok'; if(S.route==='selftest'){ location.hash='#/p/p1/ok'; return; } go(S.route,'ok'); },
'STATE-RETRY':()=>{ toast('已重试一次'); },
'STATE-GO':e=>{ go(e.target.closest('[data-to]').dataset.to); },
/* P1 */
'P1-01':e=>{ go(e.target.closest('[data-to]').dataset.to); },
'P1-02':e=>{ S.account=e.target.closest('[data-acct]').dataset.acct; S.layer=null; go('p6'); },
'P1-03':e=>{ const a=e.target.closest('[data-acct]').dataset.acct; S.account=a; go('p7'); },
'P1-04':()=>{ S.benchFilter='异常值'; go('p2'); },
'P1-05':()=>{ S.reviewSel='r2'; go('p7'); },
/* P2 */
'P2-01':()=>{ S.benchFilter='低粉爆款'; paint(); },
'P2-02':()=>{ S.benchFilter='异常值'; paint(); },
'P2-03':e=>{ S.assetZone='structure'; S.assetSel='as3'; go('p3'); },
'P2-04':()=>{ S.commentOpen=!S.commentOpen; paint(); },
'P2-05':()=>{ S.commentState='fetching'; paint(); setTimeout(()=>{ S.commentState='done'; paint(); },420); },
'P2-06':e=>{ const c=e.target.closest('[data-c]').dataset.c; const it=S.candidates.find(x=>x.id===c); it.state='adopted';
S.topics.unshift({id:'c'+c,text:it.text,group:'todo',src:'评论区洞察',state:'待做'}); paint(); toast('已进选题库待做'); },
'P2-07':e=>{ const c=e.target.closest('[data-c]').dataset.c; S.candidates.find(x=>x.id===c).state='ignored'; paint(); },
'P2-08':e=>{ S.topicGroup=e.target.closest('[data-group]').dataset.group; paint(); },
'P2-09':e=>{ const k=e.target.closest('[data-k]').dataset.k; const t=S.topics.find(x=>x.id===k); t.group='writing'; t.state='在写'; go('p4'); },
'P2-10':()=>{ S.assetZone='angle'; go('p3'); },
/* P3 */
'P3-01':e=>{ S.assetZone=e.target.closest('[data-zone]').dataset.zone; const l=S.assets.filter(a=>a.zone===S.assetZone); if(l[0]) S.assetSel=l[0].id; paint(); },
'P3-02':e=>{ S.assetSel=e.target.closest('[data-a]').dataset.a; paint(); },
'P3-03':e=>{ const a=e.target.closest('[data-a]').dataset.a; const it=S.assets.find(x=>x.id===a); it.state='已入库'; it.draft=false; paint(); toast('已入库'); },
'P3-04':e=>{ const a=e.target.closest('[data-a]').dataset.a; const it=S.assets.find(x=>x.id===a); if(!S.refs.some(r=>r.id===a)) S.refs.push({id:a,text:it.text,zone:it.zone}); go('p4'); },
/* P4 */
'P4-01':e=>{ S.step=+e.target.closest('[data-i]').dataset.i; paint(); },
'P4-02':null,
'P4-03':()=>{ S.stepBusy=true; paint(); setTimeout(()=>{ S.stepBusy=false; S.stepOut[S.step]=(S.stepOut[S.step]||'')||'机器出的一版,写在上面,你可以改'; paint(); },420); },
'P4-04':()=>{ S.layer='assetpick'; paint(); },
'P4-05':e=>{ const a=e.target.closest('[data-a]').dataset.a; S.refs=S.refs.filter(r=>r.id!==a); paint(); },
'P4-06':()=>{ if(S.step<6){ S.step++; if(!S.stepOut[S.step]) S.stepOut[S.step]=''; paint(); toast('已推进到「'+STEPS[S.step]+'」'); } },
'P4-07':()=>{ if(S.layer==='versions'){ S.layer=null; paint(); } else { S.layer='versions'; paint(); } },
'P4-08':e=>{ const v=e.target.closest('[data-v]').dataset.v; S.versions.forEach(x=>x.cur=(x.id===v)); paint(); toast('当前版切成 '+v); },
'PICK-ASSET':e=>{ const a=e.target.closest('[data-a]').dataset.a; const it=S.assets.find(x=>x.id===a); if(!S.refs.some(r=>r.id===a)) S.refs.push({id:a,text:it.text,zone:it.zone}); S.layer=null; paint(); toast('已引用 '+a); },
/* P5 */
'P5-01':()=>{ S.targetOpen=!S.targetOpen; paint(); },
'P5-02':e=>{ const a=e.target.closest('[data-acct]').dataset.acct; const i=S.targets.accounts.indexOf(a);
if(i>=0) S.targets.accounts.splice(i,1); else S.targets.accounts.push(a);
if(!S.targets.accounts.length){ const o=ACCOUNTS.find(x=>x.id!==a); S.targets.accounts.push(o.id); }
paint(); },
'P5-03':e=>{ const p=e.target.closest('[data-p]').dataset.p; const i=S.targets.platforms.indexOf(p);
if(i>=0) S.targets.platforms.splice(i,1); else S.targets.platforms.push(p);
if(!S.targets.platforms.length) S.targets.platforms.push(p==='抖音'?'小红书':'抖音');
paint(); },
'P5-04':e=>{ S.verSel=e.target.closest('[data-v]').dataset.v; S.verDraft=null; S.verDirty=false; paint(); },
'P5-05':null,
'P5-06':()=>{ S.verDirty=false; S.checkState='idle'; paint(); toast('本版已保存,检查结果要重跑'); },
'P5-07':()=>{ S.checkState='checking'; paint(); setTimeout(()=>{ S.checkState='pass'; paint(); },420); },
'P5-08':()=>{ S.layer='confirm'; paint(); },
'P5-09':()=>{ S.layer=null; S.publishDone=true; paint(); toast('已登记一条发布记录'); },
'P5-10':()=>{ S.layer=null; paint(); },
'P5-11':()=>go('p8'),
/* P6 */
'P6-01':e=>{ S.account=e.target.closest('[data-acct]').dataset.acct; S.profileDirty=false; paint(); },
'P6-02':null,
'P6-03':()=>{ S.profileDirty=false; paint(); toast('画像已保存'); },
'P6-04':()=>{ S.memoryOpen=!S.memoryOpen; paint(); },
'P6-05':()=>go('p4'),
/* P7 */
'P7-01':e=>{ S.reviewSel=e.target.closest('[data-r]').dataset.r; S.wroteBack=false; paint(); },
'P7-02':()=>{ toast('已切到手工录入'); },
'P7-03':null,
'P7-04':()=>{ S.wroteBack=true; const r=REVIEWS.find(x=>x.id===S.reviewSel); const acc=ACCOUNTS.find(a=>a.name===r.acct);
if(acc&&PROFILES[acc.id]) PROFILES[acc.id].记忆.push('10-08 '+r.text+':'+(S.attrDraft||r.note)); paint(); toast('已回写到 '+r.acct); },
'P7-05':()=>{ S.historyOpen=!S.historyOpen; paint(); },
/* P8 */
'P8-01':e=>{ S.recordSel=e.target.closest('[data-rec]').dataset.rec; S.checkedOut=false; paint(); },
'P8-02':()=>{ S.traceOpen=!S.traceOpen; paint(); },
'P8-03':()=>{ S.layer='checkout'; paint(); },
'P8-04':()=>{ S.billingOpen=!S.billingOpen; paint(); },
'CHECKOUT-OK':()=>{ S.layer=null; S.checkedOut=true; paint(); toast('这条已核销'); }
};
/* ============================ 事件绑定 ============================ */
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 fn=IMPL[act];
if(typeof fn==='function') fn({target:t});
});
document.addEventListener('input', ev=>{
const t=ev.target.closest('[data-act]'); if(!t) return;
const act=t.dataset.act;
if(act==='R-01'){ S.noteDraft=t.value; const b=document.querySelector('[data-act="R-02"]'); if(b) b.disabled=!t.value.trim(); return; }
if(act==='S-01'){ S.searchQ=t.value; const q=t.value,pos=t.selectionStart; paint(); const n=$('#q'); if(n){ n.value=q; n.focus(); n.setSelectionRange(pos,pos); } return; }
if(act==='P4-02'){ S.stepOut[S.step]=t.value; const b=document.querySelector('[data-act="P4-03"]'); if(b) b.disabled=!t.value.trim(); return; }
if(act==='P5-05'){ S.verDraft=t.value; S.verDirty=true; const b=document.querySelector('[data-act="P5-06"]'); if(b) b.disabled=false; return; }
if(act==='P6-02'){ S.profileDirty=true; const b=document.querySelector('[data-act="P6-03"]'); if(b) b.disabled=false; return; }
if(act==='P7-03'){ S.attrDraft=t.value; const b=document.querySelector('[data-act="P7-04"]'); if(b) b.disabled=!t.value.trim(); return; }
});
document.addEventListener('change', ev=>{
const t=ev.target.closest('[data-act]'); if(!t) return;
if(t.dataset.act==='G-07'){ S.account=t.value; paint(); toast('已切到 '+t.value); }
if(t.dataset.act==='G-09'){ S.pageState=t.value; paint(); }
});
document.addEventListener('keydown', ev=>{ if(ev.key==='Escape'&&S.layer&&!ST.lock){ S.layer=null; paint(); } });
window.addEventListener('hashchange', ()=>{ if(ST.lock) return; parseHash(); paint(); });
/* ============================ 自检(#/selftest) ============================ */
let CONSOLE_ERRORS=[];
window.addEventListener('error', e=>{ const m='error: '+e.message; CONSOLE_ERRORS.push(m); showErr(m); });
window.addEventListener('unhandledrejection', e=>{ const m='rejection: '+e.reason; CONSOLE_ERRORS.push(m); showErr(m); });
function showErr(m){
let b=document.getElementById('errbar');
if(!b){ b=document.createElement('div'); b.id='errbar'; b.className='notice notice-err';
b.style.cssText='position:fixed;left:16px;right:16px;bottom:16px;z-index:99;margin:0';
document.body.appendChild(b); }
b.innerHTML='<strong>页面报错</strong><span></span>';
b.lastElementChild.textContent=m;
}
/* 场景=「某个页面 / 某个浮层打开着 / 某页停在某个状态」。自检在真实场景里跑,不在空壳上跑。
⚠️ 每个场景都从**初始态快照**起跑:只声明「本场景要打开什么」,其余一律回到干净底片。
不这么做,上一场景 / 上一次点击留下的脏状态会漏进下一场景 —— 实测就漏过:
同一个场景里连点几个元素,后面的会因为前面点过的东西而少渲染出节点,被误判成死按钮。 */
const S0=JSON.parse(JSON.stringify(S)); // 初始态快照(S 里全是能 JSON 化的纯数据)
function resetScene(){
Object.keys(S0).forEach(k=>{ S[k]=JSON.parse(JSON.stringify(S0[k])); });
S.toast=null;
}
const SCENES=[
{name:'P1 矩阵总览', set:()=>{ S.route='p1'; }},
{name:'P2 选题雷达', set:()=>{ S.route='p2'; S.commentOpen=true; S.commentState='done'; }},
{name:'P3 资产库', set:()=>{ S.route='p3'; S.assetSel='as3'; }},
{name:'P4 创作台', set:()=>{ S.route='p4'; S.versionOpen=true; }},
{name:'P4 有引用', set:()=>{ S.route='p4'; S.refs=[{id:ASSETS[0].id,text:ASSETS[0].text,zone:ASSETS[0].zone}]; }},
{name:'P5 发布中心', set:()=>{ S.route='p5'; S.targetOpen=true; S.checkState='idle'; }},
{name:'P5 有硬拦', set:()=>{ S.route='p5'; S.targetOpen=true; S.checkState='hard'; }},
{name:'P6 账号画像', set:()=>{ S.route='p6'; S.memoryOpen=true; }},
{name:'P7 复盘台', set:()=>{ S.route='p7'; }},
{name:'P7 等数据', set:()=>{ S.route='p7'; S.reviewSel='r3'; }},
{name:'P8 记录对账', set:()=>{ S.route='p8'; }},
{name:'P1 空空态', set:()=>{ S.route='p1'; S.pageState='empty'; }},
{name:'P1 出错态', set:()=>{ S.route='p1'; S.pageState='error'; }},
{name:'外壳停在非 P1', set:()=>{ S.route='p8'; }},
{name:'随手记浮层', set:()=>{ S.route='p1'; S.layer='quicknote'; }},
{name:'检索浮层空', set:()=>{ S.route='p1'; S.layer='search'; }},
{name:'检索有结果', set:()=>{ S.route='p1'; S.layer='search'; S.searchQ='选题'; }},
{name:'假设浮层', set:()=>{ S.route='p1'; S.layer='assume'; }},
{name:'版本浮层', set:()=>{ S.route='p4'; S.layer='versions'; }},
{name:'资产选择浮层', set:()=>{ S.route='p4'; S.layer='assetpick'; }},
{name:'发布二次确认', set:()=>{ S.route='p5'; S.targetOpen=true; S.layer='confirm'; S.checkState='pass'; }},
{name:'核销二次确认', set:()=>{ S.route='p8'; S.layer='checkout'; }}
];
function applyScene(sc){
resetScene();
sc.set();
/* ⚠️ 这里**不写 hash**:file:// 下 replaceState 可能被拒,而且 hash 变化是异步的,
靠它反推状态会让自检自己骗自己。状态在内存里,hash 只是入口。 */
paint();
}
function _renderSelftest(){
const L=[]; const pass=[]; const fail=[];
const ok=(t)=>{pass.push(t); L.push('<span class="st-pass">PASS</span> '+t);};
const bad=(t)=>{fail.push(t); L.push('<span class="st-fail">FAIL</span> '+t);};
ST.lock=false;
/* 「有没有可见反馈」的判据 = 状态快照 或 DOM 变了。
⛔ 不看 location.hash —— 它是异步落实的,拿它判会把「已经跳页」读成「没反应」(实测踩过)。 */
const sig=()=>JSON.stringify(S);
const domSnap=()=>$('#view').innerHTML+'\u0002'+$('#layer-root').innerHTML+'\u0002'+$('#nav').innerHTML;
const snap=()=>sig()+'\u0001'+domSnap();
/* 1) 八页都能渲染出内容 */
Object.keys(PAGES).forEach(p=>{
S.route=p; S.pageState='ok'; S.layer=null; paint();
const n=$('#view').children.length;
if(n>0) ok('路由 #/p/'+p+' 渲染出 '+n+' 个顶层节点'); else bad('路由 #/p/'+p+' 是空的');
});
/* 2) 六态在每一页都能渲染 */
let stFail=0;
Object.keys(PAGES).forEach(p=>{
STATES.forEach(([st])=>{
S.route=p; S.pageState=st; S.layer=null; paint();
if(!($('#view').children.length>0)) { stFail++; bad('P'+p.slice(1)+' 的 '+st+' 态没渲染出内容'); }
});
});
if(stFail===0) ok('六态 × 八页 = '+ (Object.keys(PAGES).length*STATES.length) +' 个组合,全部渲染出内容');
/* 3) 交互清单 ↔ 页面:双向差集 */
const declared=new Set(Object.keys(IMPL));
const seen=new Set(); const extra=[]; let domCount=0;
const navPages=new Set();
SCENES.forEach(sc=>{
applyScene(sc);
document.querySelectorAll('[data-act]').forEach(n=>{
const a=n.dataset.act;
domCount++; seen.add(a);
if(!declared.has(a)) extra.push(a+'@'+sc.name);
});
document.querySelectorAll('[data-act="G-05"]').forEach(n=>navPages.add(n.dataset.page));
});
navPages.delete(undefined);
if(navPages.size===Object.keys(PAGES).length) ok('侧栏二级页项 '+navPages.size+' 个,与八页一致'); else bad('侧栏二级页项 '+navPages.size+' 个,不等于 8');
const dangling=[...declared].filter(k=>!seen.has(k));
if(dangling.length===0) ok('清单里每个 ID 都在页面上出现过('+declared.size+' 个,无悬空声明)'); else bad('声明了但页面里没有:'+dangling.join(', '));
if(extra.length===0) ok('页面上每个 data-act 都在清单里(共 '+domCount+' 次出现,无未登记的可点元素)'); else bad('未登记的 data-act:'+extra.join(', '));
/* 4) 逐个真驱动
· 三 类控件各用**自己真实的事件**:按钮用 click、输入框用 input、下拉用 change。
⛔ 别拿 click 去点输入框然后说它是死按钮 —— 那不是它的事件。
· **所有实例**逐个试,任一实例有可见反馈即算这个 ID 活着。
⛔ 只看第一个实例:tab / 分组这类「第一项本来就是当前项」的控件会被误杀(实测踩过)。
· 每个实例都从干净场景起跑。 */
const res={}; const why={};
const markOk=act=>{ res[act]='ok'; delete why[act]; };
const markShy=(act,what,w)=>{ if(res[act]==='ok') return; res[act]=what; if(w!==undefined) why[act]=w; };
const isFormName=n=>/^(INPUT|TEXTAREA|SELECT)$/.test(n.tagName);
const drive=node=>{
if(node.tagName==='SELECT'){
const other=[...node.options].map(o=>o.value).find(v=>v!==node.value);
if(other==null) return false;
node.value=other; node.dispatchEvent(new Event('change',{bubbles:true})); return true;
}
if(isFormName(node)){ node.value=(node.value||'')+'A'; node.dispatchEvent(new Event('input',{bubbles:true})); return true; }
node.dispatchEvent(new MouseEvent('click',{bubbles:true})); return true;
};
/* 表单控件先跑:跑完它们,依赖它们的按钮(保存 / 开始这一步 / 回写)才转为可用 */
[{label:'表单控件(输入 / 下拉)', only:isFormName},
{label:'可点元素(按钮 / 行 / 卡片)', only:n=>!isFormName(n)}].forEach(pass=>{
SCENES.forEach(sc=>{
applyScene(sc);
const acts=[...new Set([...document.querySelectorAll('[data-act]')].map(n=>n.dataset.act))]
.filter(a=>[...document.querySelectorAll('[data-act="'+a+'"]')].some(pass.only));
acts.forEach(act=>{
applyScene(sc); // ⚠️ 先定格到本场景:实例数必须按定格后的 DOM 数
const total=[...document.querySelectorAll('[data-act="'+act+'"]')].length;
let sawDisabled=false;
for(let i=0;i<total;i++){
applyScene(sc); // 每个实例都从干净态开驱动
const node=[...document.querySelectorAll('[data-act="'+act+'"]')][i];
if(!node) break;
if(node.hasAttribute('disabled')){ sawDisabled=true; continue; }
S.toast=null;
const before=snap();
let threw='';
try{ if(!drive(node)) continue; }catch(err){ threw=String(err); }
if(threw){ markShy(act,'dead','@'+sc.name+'|抛错:'+threw); break; }
if(snap()!==before){ markOk(act); break; }
if(i===total-1) markShy(act,'dead','@'+sc.name+'|'+node.tagName.toLowerCase()+'|'+total
+' 个实例逐个驱动都没变化|route='+S.route+'|layer='+S.layer);
}
if(!res[act]) markShy(act, sawDisabled?'disabled':'dead',
sawDisabled?undefined:'@'+sc.name+'|'+total+' 个实例都被禁用或驱动不了');
});
});
});
const okList=Object.keys(res).filter(k=>res[k]==='ok');
const disList=Object.keys(res).filter(k=>res[k]==='disabled');
const deadList=Object.keys(res).filter(k=>res[k]==='dead');
if(deadList.length===0) ok('清单里 '+okList.length+' 个 ID 都真驱动过并给出可见反馈,0 个死控件('+SCENES.length+' 个场景,每个实例各重置一次)'); else bad('驱动了没反应:'+deadList.join(' / '));
if(deadList.length){ L.push('--- 没反应的控件诊断 ---'); deadList.forEach(k=>L.push(' '+k+' ⇒ '+why[k])); }
ok('当刻被禁用的控件 '+disList.length+' 个(有 disabled 属性:按契约在特定条件下不可点)'+(disList.length?':'+disList.join(', '):''));
const never=[...declared].filter(k=>!res[k]);
if(never.length===0) ok('清单里每个 ID 都至少被驱动过一次(无「从未跑过」的条目)'); else bad('从未被驱动:'+never.join(', '));
/* 4b) 侧栏 8 项逐项核:每一项都真的跳到它标的那一页 */
const navBad=[];
Object.keys(PAGES).forEach(p=>{
applyScene(SCENES[0]);
const n=document.querySelector('[data-act="G-05"][data-page="'+p+'"]');
if(!n){ navBad.push(p+'(没这个入口)'); return; }
n.dispatchEvent(new MouseEvent('click',{bubbles:true}));
if(S.route!==p) navBad.push(p+'(点到「'+PAGES[S.route]+'」去了)');
});
if(navBad.length===0) ok('侧栏 8 项逐个点过,每一项都跳到了自己标的那一页');
else bad('侧栏有项跳错:'+navBad.join(' / '));
/* 4c) 五处「输入后才可用」的联动:空输入时禁用 → 输入后可用 → 点了有反应 */
const CHAINS=[
{label:'随手记「保存」', sc:{set:()=>{ S.route='p1'; S.layer='quicknote'; }}, fill:'R-01', btn:'R-02'},
{label:'创作台「开始这一步」', sc:{set:()=>{ S.route='p4'; S.stepOut[S.step]=''; }}, fill:'P4-02', btn:'P4-03'},
{label:'发布中心「保存本版」', sc:{set:()=>{ S.route='p5'; S.targetOpen=true; }}, fill:'P5-05', btn:'P5-06'},
{label:'账号画像「保存画像」', sc:{set:()=>{ S.route='p6'; }}, fill:'P6-02', btn:'P6-03'},
{label:'复盘台「确认归因并回写」', sc:{set:()=>{ S.route='p7'; }}, fill:'P7-03', btn:'P7-04'}
];
const chainBad=[]; let gated=0;
CHAINS.forEach(c=>{
const btns=()=>[...document.querySelectorAll('[data-act="'+c.btn+'"]')];
const usable=()=>btns().filter(b=>!b.hasAttribute('disabled'));
applyScene(c.sc);
if(btns().length && usable().length===0) gated++;
const f=[...document.querySelectorAll('[data-act="'+c.fill+'"]')];
if(!f.length){ chainBad.push(c.label+'(没找到输入框)'); return; }
f[0].value='试写一句'; f[0].dispatchEvent(new Event('input',{bubbles:true}));
if(!usable().length){ chainBad.push(c.label+'(输入后按钮还是禁用)'); return; }
const before=snap();
usable()[0].dispatchEvent(new MouseEvent('click',{bubbles:true}));
if(snap()===before) chainBad.push(c.label+'(点了没反应)');
});
if(chainBad.length===0) ok('五处「输入后才可用」的联动都通:其中 '+gated+' 处在空输入时确实是禁用的 → 输入后转可用 → 点了有反应');
else bad('联动没跑通:'+chainBad.join(' / '));
/* 5) 页型与动效纪律 */
const hits=['eyebrow','lead','hero'].filter(c=>document.querySelectorAll('.'+c).length);
if(hits.length===0) ok('工具型纪律:零 .eyebrow / 零 .lead / 零 .hero'); else bad('出现禁用类名:'+hits.join(','));
let kf=0;
try{ [...document.styleSheets].forEach(ss=>{ try{ [...ss.cssRules].forEach(r=>{ if(r.type===7) kf++; }); }catch(e){} }); }catch(e){}
if(kf===0) ok('零 @keyframes(工具型不做入场编排)'); else bad('出现 @keyframes '+kf+' 处');
/* 6) 数值取证:DESIGN.md §六 登记了 8 条「契约值」,这里逐条实测并当场判。
⛔ 不目测:全部 DOM 取值 / 真实像素算。 */
L.push('--- 数值取证(视口 '+document.documentElement.clientWidth+' × '+document.documentElement.clientHeight+')---');
const probeEl=document.createElement('span');
probeEl.style.cssText='position:absolute;left:-99999px;top:0;width:0;height:0';
document.body.appendChild(probeEl);
const rgbOf=t=>{ probeEl.style.color=''; probeEl.style.color='var('+t+')'; return getComputedStyle(probeEl).color; };
const nums=c=>String(c).match(/[\d.]+/g).slice(0,3).map(Number);
const lin=v=>{ v/=255; return v<=0.03928 ? v/12.92 : Math.pow((v+0.055)/1.055,2.4); };
const lum=c=>{ const [r,g,b]=nums(c).map(lin); return 0.2126*r+0.7152*g+0.0722*b; };
const cr=(a,b)=>{ const l1=lum(a), l2=lum(b), hi=Math.max(l1,l2), lo=Math.min(l1,l2); return (hi+0.05)/(lo+0.05); };
const r1=v=>Math.round(v*100)/100;
const surfaceRgb=rgbOf('--surface');
[['--warn 对 --surface',rgbOf('--warn'),4.5],
['--accent-text 对 --surface',rgbOf('--accent-text'),4.5],
['--ink-faint 对 --surface',rgbOf('--ink-faint'),3]].forEach(([nma,c,min])=>{
const v=cr(c,surfaceRgb);
if(v>=min) ok('对比度 '+nma+' = '+r1(v)+':1(契约 ≥'+min+':1)');
else bad('对比度 '+nma+' = '+r1(v)+':1,低于契约 '+min+':1');
});
/* 6a) P1:强调色填充面积占比 / 首屏可见行数 / 块间距比 / 字号档数 */
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const VW=document.documentElement.clientWidth, VH=document.documentElement.clientHeight;
const clip=r=>{ const x0=Math.max(0,r.left), y0=Math.max(0,r.top);
const x1=Math.min(VW,r.right), y1=Math.min(VH,r.bottom);
return (x1>x0&&y1>y0) ? (x1-x0)*(y1-y0) : 0; };
const accentRgb=rgbOf('--accent');
const accOnPage=()=>{ let a=0,n=0;
document.querySelectorAll('#view *, .sidebar *, .topbar *').forEach(e=>{
if(getComputedStyle(e).backgroundColor===accentRgb){ const q=clip(e.getBoundingClientRect()); if(q>0){ a+=q; n++; } }
});
return [a/(VW*VH)*100, n]; };
/* 强调色占比逐页取最大值 —— 契约是「任何一页都 ≤5%」,只看 P1 会漏掉按钮最多的页 */
let accPct=0, accN=0, accPage='p1';
Object.keys(PAGES).forEach(p=>{
applyScene({name:p, set:()=>{ S.route=p; }});
const [pc,n]=accOnPage(); if(pc>accPct||accPage==='p1'){ if(pc>=accPct){ accPct=pc; accN=n; accPage=p; } }
});
if(accPct<=5) ok('强调色填充面积占比:八页最高 '+PAGES[accPage]+' = '+r1(accPct)+'%('+accN+' 个填充块,契约 ≤5%;不含 3px 左侧标条 / 描边 / 文字色)');
else bad('强调色填充面积占比达 '+r1(accPct)+'%('+PAGES[accPage]+'),超过契约 5%');
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const findBlock=t=>[...document.querySelectorAll('#view .block')]
.find(b=>((b.querySelector('.block-head')||{}).textContent||'').includes(t));
/* ⚠️ 判据是「一屏**装得下**几行」,不是「实际渲染了几行」:
示意数据只有 5 条时,实际行数永远 ≤5,那是数据用尽,不是被折叠线截断。
所以按 首屏可用高度 ÷ 行距 算容量,另附实际条数。 */
const rowFit=b=>{
if(!b) return null;
const tops=[...b.querySelectorAll('.list-row')].map(r=>r.getBoundingClientRect().top);
if(tops.length<2) return null;
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:tops.length, top:Math.round(tops[0]), pitch, cap:Math.floor((VH-tops[0])/pitch)};
};
const mx=findBlock('账号矩阵');
const p1Fit=rowFit(mx);
if(p1Fit && p1Fit.cap>=6) ok('P1 账号矩阵:首屏从 '+p1Fit.top+'px 起,行距 '+p1Fit.pitch+'px,一屏装得下 '+p1Fit.cap+' 行(契约 ≥6);示意数据 '+p1Fit.n+' 条 ⇒ 实际可见 '+Math.min(p1Fit.cap,p1Fit.n)+' 行(数据用尽,非被截断)');
else bad('P1 账号矩阵一屏只能装下 '+(p1Fit?p1Fit.cap:'?')+' 行,低于契约 6');
const fsSet=new Set();
document.querySelectorAll('#view *').forEach(e=>{ if(e.children.length===0 && (e.textContent||'').trim()) fsSet.add(getComputedStyle(e).fontSize); });
if(fsSet.size>=3) ok('P1 实际在用字号档 '+fsSet.size+' 档:'+[...fsSet].sort((a,b)=>parseFloat(a)-parseFloat(b)).join(' / ')+'(契约 ≥3)');
else bad('P1 只用 '+fsSet.size+' 档字号,低于契约 3');
const blocks=[...document.querySelectorAll('#view .block')];
/* ⚠️ 量块间距要按**视觉层**量:并排的两块同属一层,直接拿相邻 .block 相减会出负值 */
const unitOf=b=>b.closest('.band')||b;
const units=[...new Set(blocks.map(unitOf))];
if(units.length>=2&&mx){
const gap=Math.round(units[1].getBoundingClientRect().top-units[0].getBoundingClientRect().bottom);
const rows=[...mx.querySelectorAll('.list-row')];
/* ⚠️ 别取第一行:行式列表的第一行没有上边框(.list-row:first-child),取到会是 0 */
const bw=rows.length?Math.max(...rows.map(r=>Math.abs(parseFloat(getComputedStyle(r).borderTopWidth)||0))):0;
const pitch=rows.length>=2?Math.round(rows[1].getBoundingClientRect().top-rows[0].getBoundingClientRect().top):0;
if(gap>=2*bw) ok('视觉层间距 '+gap+'px / 块内相邻行分隔 '+bw+'px(发丝线,行本身不套框)⇒ 比值 '+r1(gap/bw)+' ≥2('+units.length+' 个视觉层);矩阵行距 '+pitch+'px');
else bad('视觉层间距 '+gap+'px 不到块内分隔('+bw+'px)的两倍 ⇒ 分组读不出来');
}
const rowH=(()=>{ const rows=mx?[...mx.querySelectorAll('.list-row')]:[]; return rows.length?Math.round(rows[0].getBoundingClientRect().height):0; })();
const btnH=[...new Set([...document.querySelectorAll('#view button.btn')].map(b=>Math.round(b.getBoundingClientRect().height)).filter(h=>h>0))];
L.push('P1 各块落位(顶 / 高):'+blocks.map(b=>{
const hd=b.querySelector('.block-head h2'); const q=b.getBoundingClientRect();
return (hd?hd.textContent.trim().slice(0,9):'块')+'@'+Math.round(q.top)+'+'+Math.round(q.height);
}).join(' · ')+'(视口高 '+VH+';并排的两块顶相同)');
/* 6b) P2 对标列表:首屏可容纳行数 */
applyScene({name:'P2', set:()=>{ S.route='p2'; }});
const p2Fit=rowFit(findBlock('对标内容'));
if(p2Fit && p2Fit.cap>=6) ok('P2 对标内容列表:首屏从 '+p2Fit.top+'px 起,行距 '+p2Fit.pitch+'px,一屏装得下 '+p2Fit.cap+' 行(契约 ≥6);示意数据 '+p2Fit.n+' 条 ⇒ 实际可见 '+Math.min(p2Fit.cap,p2Fit.n)+' 行(数据用尽,非被截断)');
else bad('P2 对标内容列表一屏只能装下 '+(p2Fit?p2Fit.cap:'?')+' 行,低于契约 6');
L.push('P2 各块落位(顶 / 高):'+[...document.querySelectorAll('#view .block')].map(b=>{
const hd=b.querySelector('.block-head h2'); const q=b.getBoundingClientRect();
return (hd?hd.textContent.trim().slice(0,9):'块')+'@'+Math.round(q.top)+'+'+Math.round(q.height);
}).join(' · '));
/* 6c) 横向溢出:本视口下必须为 0 */
applyScene({name:'P1', set:()=>{ S.route='p1'; }});
const over=document.documentElement.scrollWidth-document.documentElement.clientWidth;
if(over<=0) ok('横向溢出 = 0(scrollWidth '+document.documentElement.scrollWidth+' = clientWidth '+document.documentElement.clientWidth+')');
else bad('横向溢出 '+over+'px');
let overWorst=null;
Object.keys(PAGES).forEach(p=>{
applyScene({name:p, set:()=>{ S.route=p; }});
const o=document.documentElement.scrollWidth-document.documentElement.clientWidth;
if(overWorst===null||o>overWorst[1]) overWorst=[p,o];
});
if(overWorst[1]<=0) ok('八页逐页查横向溢出:全为 0(最差 '+overWorst[0]+')');
else bad('有页面横向溢出:'+overWorst[0]+' 超出 '+overWorst[1]+'px');
L.push('(附)P1 账号矩阵一行高 '+rowH+'px · P1 按 900px 一屏约 '+Math.floor((900-260)/(rowH||56))+' 行 · P1 按钮高度集合 '+btnH.join('/')+'px');
L.push('控制台报错:'+(CONSOLE_ERRORS.length?CONSOLE_ERRORS.join(' | '):'0 条'));
/* 还原 + 自检期间锁住全部异步重绘(防自检结果被 420ms/1500ms 的定时重绘覆盖)。
离开方式:点侧栏导航或「回到正常态」—— 那两个入口会先把锁解开。 */
S.route='selftest';
ST.lock=true;
try{ history.replaceState(null,'','#/selftest'); }catch(e){}
const head='自检结果:PASS '+pass.length+' / FAIL '+fail.length;
return '<section class="block">'+
'<div class="block-head"><h2 class="grow">自检(#/selftest)</h2><span class="meta">'+esc(head)+'</span></div>'+
'<div class="st-out" id="selftest-out">'+esc(head)+'\n\n'+L.join('\n')+'</div>'+
'<div class="block" style="margin-top:var(--sp-24)"><div class="panel"><div class="row">'+
'<span class="meta">自检是原型自带的:它在真实场景里逐个点过每个可点元素,证明没有死按钮。</span>'+
'<button class="btn btn-sm" data-act="STATE-NORMAL">回到正常态</button>'+
'</div></div></div></section>';
}
/* ---------- 启动 ---------- */
function renderSelftest(){
try{ return _renderSelftest(); }
catch(e){ return '<section class="block"><div class="block-head"><h2>自检没有跑完</h2></div><div class="st-out">'+
String((e&&e.stack)||e).replace(/</g,'&lt;')+'</div></section>'; }
}
if(!location.hash) location.hash='#/p/p1/ok';
parseHash(); paint();
</script>
</body></html>