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