1880 lines
150 KiB
HTML
1880 lines
150 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"'); }
|
||
|
||
/* ---------- 图标 ---------- */
|
||
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,'<');
|
||
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>
|