R40-R41: 弹窗 tab 切换闪烁根治(就地局部刷新)+ 开始诊断文案 + tab 间 | 分隔;右侧诊断/分镜面板可收起

R40 弹窗闪烁根因:所有状态切换 mask.remove()+renderModal() 整窗销毁重建,重建前 await review-full 网络 → 空窗闪一下

- 重构 openScriptModal:mask 生命周期内不再移除,tab/分镜翻版=patchSide 就地刷右侧列、版本翻版=patchAll 刷 AI+右侧列、源折叠=纯 CSS

- sideContent 抽离(stab=diag 才请求复盘接口);空态按钮开始复盘→开始诊断

- sideToken 防连点竞态 + mask.isConnected 防关闭后写入;bindAll/bindSide/bindScrollSync 分层绑定

- 脚本诊断 | 分镜提示词 两 tab 间加 .rw-side-sep 分隔

R41 右侧面板收起:data-side-toggle 收起钮放 AI 列 head 最右(右列收起头部不可见,入口常驻)

- showSide 状态 + rw-sec-hidden 复用;收起后 AI 列占满 stack 100%;chevrons-right/left 图标

- 版本 style.css aa→ac / data-pages.js ab→ad / app.js ICON_PATHS +2

- CDP 冒烟:R40 16/16 + R41 17/17 全绿(mask 同一节点=不闪验证)+ R40 回归复跑 16/16
This commit is contained in:
maogeigei committed 2026-09-03 18:41:35 +08:00
1 parent 291715bb27
commit f103fdc0e4
5 files changed
+167 -79

No files matched your search

+28
View File
@@ -392,3 +392,31 @@
- **SIGTERM 间歇**:单脚本含 Page.reload+Page.navigate+hash 多次切换时偶发被沙箱 SIGTERM 杀死 node 进程——规避=**拆段独立连接**,单脚本只做一件事;首页跳转 hash 用 location.hash=赋值(Runtime.evaluate)而非 Page.navigate
- **视觉目检** `tmp/shot-r39-brand.png`:顶部 brand `MCN创意雷达` ✓;首页榜单 tab 组(账号周榜/视频热榜/点赞榜)+ globe 按钮 + 筛选箭头完全水平对齐,globe 图标浅灰 ✓
- **R39 待推送**:本地 HEAD=28eba2a(R37-R38 已提交),working tree R39 6 文件改动 + 记忆日志 R39 节未提交;累计本地领先 origin/main **4 commit**(449d784 R35 / 1f147e8 R36 / 28eba2a R37-38 / R39 待提交)
## R40 弹窗 tab 切换闪烁根治 + 开始诊断文案 + tab 间 | 分隔(09-03 18:10)
- **用户反馈**:「为什么点击 脚本诊断 和 分镜提示词 整个弹窗页面都会闪一下。开始复盘 改为 开始诊断。脚本诊断 和 分镜提示词 切换按钮 之间加个 |」
- **闪烁根因**:R34 弹窗所有状态切换(tab/版本/源折叠/分镜上下版)都是 `mask.remove(); renderModal();` **整窗销毁重建**;而 renderModal async 在重建前必须 `await diagOf()`(网络请求 `/api/dsh/review-full`)→ 弹窗被删后要等接口返回才重新 append → 空窗几百 ms = 用户看到的「闪一下」
- **修复(架构级,openScriptModal 内重构)**:弹窗 DOM(mask)生命周期内**不再移除**,仅就地替换受影响列:
- `curOf()` / `verCtrl()` / `sideHeadHtml()` / `srcToggleHtml()` / `srcSecHtml()` / `aiSecHtml()` 抽成独立 HTML 工厂
- `sideContent(cur)`:右侧列内容计算(**stab=diag 才请求 review-full**——原实现无论什么 tab 都查,浪费);空态按钮文案「开始复盘」→「**开始诊断**」
- `patchSide(mask)`:就地刷新右侧列(tab 切换/分镜上下版)——active 即时切换 + body「加载中…」→ await sideContent → 填充;`sideToken` 递增防连点竞态、`mask.isConnected` 防已关闭后写入
- `patchAll(mask)`:就地刷新 AI 列+右侧列(版本上/下一版)
- `bindAll/bindSide/bindScrollSync`:动态节点绑定分层——mask 外壳 data-x/背景关闭只绑一次;stack 内节点每次重建后重绑(旧节点随 innerHTML 替换销毁,无重复监听)
- 源折叠 `[data-src-toggle]` 改**纯 CSS class toggle**(不再重建)
- **tab 间 `|` 分隔**:sideHeadHtml 两按钮间插 `<span class="rw-side-sep">|</span>`;CSS `.rw-side-sep { color: var(--border); font-size: 14px; user-select:none }`
- **版本**:data-pages.js ab→ac、style.css aa→ab(app.js 不动)
- **CDP 冒烟** `tmp/cdp-r40-modal.mjs` **16/16 PASS**:| 分隔在两 tab 之间 ✓ / 默认 tab=diag ✓ / 按钮「开始诊断」且零「开始复盘」残留(源码级)✓ / **切分镜、切回诊断、版本下一版全程 mask 同一节点未重建(不闪)** ✓ / 分镜内容已填充 ✓
- **弹窗行为定版**:tab 切换/分镜翻版/版本翻版/源折叠全部就地刷新 → 无空窗闪烁;仅初次打开 await 数据齐后一次 append
## R41 弹窗右侧面板可收起(只展示 AI 脚本)(09-03 18:25)
- **用户反馈**:「添加功能 可以收起 脚本复盘 | 分镜提示词 内容板块,只展示AI脚本」
- **设计**:右侧「脚本诊断|分镜提示词」列可整体收起 → display:none → AI 列 flex:1 自动占满内容区(源列默认收起时即「只展示 AI 脚本」)
- 状态 `showSide`(默认 true);右侧列 rw-sec 挂 `data-side-sec` + 按需 `rw-sec-hidden`(复用 R34 源列收起类)
- **收起钮位置关键决策**:放 **AI 列 head 最右**(`margin-left:auto`)而非右列自身 head——右列收起后自身头部不可见,展开入口必须常驻 AI 列;`.rw-side-toggle`(chevrons-right=收起/chevrons-left=展开,收起态 `side-off` 高亮提示可点开)
- app.js ICON_PATHS 新增 `chevrons-right`/`chevrons-left`(Lucide 双箭头);改 app.js 后版本也要 bump
- **代码**:data-pages.js(showSide 状态、sideToggleHtml、bindAll 内 data-side-toggle 绑定、renderModal 初渲染按 showSide 加 class);style.css `.rw-side-toggle`(margin-left:auto + hover 蓝 + side-off 高亮)
- **版本**:style.css ab→ac、data-pages.js ac→ad
- **CDP 冒烟** `tmp/cdp-r41-side.mjs` **17/17 PASS**(两轮):收起后右列 display:none、仅 AI 列可见、**AI 宽=stack 宽 100%**、收起钮切展开态高亮 ✓ / 展开恢复 ✓ / 收起过程与展开过程 mask 未重建 ✓ / **收起态下切分镜 tab 正常**、展开后停在分镜 ✓
- **冒烟断言口径坑(3 FAIL 全为此)**:**源列默认收起 → 打开弹窗初始可见列=2(AI+右侧)不是 3**;AI 宽度占比基准应取 `.rw-stack` 内容宽而非 `.rw-modal` 宽(modal 有 padding 26px×2,占 modal 宽 95% 即已满)
- **回归**:R40 冒烟版本断言更新(ab/ac→ac/ad)后复跑 16/16 全绿——R41 未破坏 R40 就地刷新架构
- **R40-R41 待提交**:data-pages.js/style.css/app.js/index.html + 记忆日志本节;本地 HEAD=03de8be(R39 已 push origin/main)
@@ -175,6 +175,8 @@ const ICON_PATHS = {
'user-plus': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
'globe': '<circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/>',
'chevrons-right': '<path d="m6 17 5-5-5-5"/><path d="m13 17 5-5-5-5"/>',
'chevrons-left': '<path d="m11 17-5-5 5-5"/><path d="m18 17-5-5 5-5"/>',
'copy': '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
};
@@ -1650,7 +1650,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
const title = metaTitle || 'AI 脚本';
const hasSource = vid > 0 && !!source;
let stab = 'diag'; /* 右侧 tab:diag=脚本诊断(默认)/ sb=分镜提示词 */
let showSrc = false, sbIdx = 0;
let showSrc = false, sbIdx = 0, showSide = true; /* R41:showSide=右侧「脚本诊断|分镜提示词」面板是否展开(收起后只展示 AI 脚本) */
const stb = vid > 0 ? await api('/api/dsh/storyboards?videoId=' + vid).catch(() => ({ items: [] })) : { items: [] };
const storyboards = ((stb && stb.items) || []);
const visibleSbOf = (curId) => storyboards.filter((s) => s.rewrite_id == null || String(s.rewrite_id) === String(curId));
@@ -1679,7 +1679,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
btn,
});
};
/* 当前版本诊断(review-full 仅返回已完成复盘;无结果 → null → 空态 + 开始复盘) */
/* 当前版本诊断(review-full 仅返回已完成复盘;无结果 → null → 空态 + 开始诊断) */
const diagOf = async (cur) => {
const rf = await api('/api/dsh/review-full?rewriteId=' + cur.id).catch(() => null);
const rv = (rf && (rf.ai || rf.compare)) || null;
@@ -1702,30 +1702,43 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
return html;
};
async function renderModal() {
const cur = rewrites[Math.min(verIdx, rewrites.length - 1)] || rewrites[0];
if (!cur) return;
const visibleSb = visibleSbOf(cur.id);
/* ===== R40:弹窗状态切换就地局部刷新(不再 mask.remove() 整窗销毁重建——原实现销毁后需 await 复盘接口才重建,空窗即用户看到的「闪一下」) =====
切换类型与刷新范围:tab 脚本诊断/分镜提示词 / 分镜上下版 → 仅右侧列(patchSide);版本上下 → AI列+右侧列(patchAll);源折叠 → 纯 CSS toggle
弹窗 DOM(mask)生命周期内不被移除,仅替换受影响列内容 → 无整窗闪断 */
const curOf = () => rewrites[Math.min(verIdx, rewrites.length - 1)] || rewrites[0];
const verCtrl = (p, idx, len, label, genLabel) => {
const inner = len > 1
? `<span class="rw-sec-count">${label} ${idx + 1}/${len}</span><button class="btn btn-sm" data-${p}prev ${idx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-${p}next ${idx >= len - 1 ? 'disabled' : ''}>下一版 →</button>`
: (len === 1 ? `<span class="rw-sec-count">${label} 1/1</span>` : '');
return `<div class="rw-sec-foot">${inner}${genLabel ? `<button class="btn btn-sm" data-${p}gen>${svgIcon('refresh', 14, 'currentColor')} ${genLabel}</button>` : ''}</div>`;
};
const sideHeadHtml = () => `<div class="rw-sec-head rw-side-head"><div class="rw-side-tabs">
<button class="rw-side-tab${stab === 'diag' ? ' active' : ''}" data-stab="diag" title="该版本 AI 脚本的复盘评分与问题清单">脚本诊断</button><span class="rw-side-sep">|</span>
<button class="rw-side-tab${stab === 'sb' ? ' active' : ''}" data-stab="sb" title="基于 AI 脚本生成的镜头级分镜提示词">分镜提示词</button>
</div></div>`;
const srcToggleHtml = () => hasSource ? `<button class="btn btn-sm btn-view rw-src-toggle${showSrc ? ' src-on' : ''}" data-src-toggle title="${showSrc ? '收起源脚本' : '展开源脚本'}">${svgIcon('file-text', 14, 'currentColor')}</button>` : '';
/* R41:右侧面板收起/展开钮(放 AI 列头部最右——右列收起后自身头部不可见,展开入口必须常驻 AI 列) */
const sideToggleHtml = () => `<button class="rw-side-toggle${showSide ? '' : ' side-off'}" data-side-toggle title="${showSide ? '收起脚本诊断/分镜提示词,只看 AI 脚本' : '展开脚本诊断/分镜提示词'}">${svgIcon(showSide ? 'chevrons-right' : 'chevrons-left', 16, 'currentColor')}</button>`;
const srcSecHtml = () => hasSource
? `<div class="rw-sec${showSrc ? '' : ' rw-sec-hidden'}" data-src-sec>
<div class="rw-sec-head">源脚本</div>
<div class="rw-sec-body md-body">${renderMarkdown(source)}</div>
<div class="rw-sec-foot"></div>
</div>` : '';
const aiSecHtml = (c) => `<div class="rw-sec-head">AI脚本${srcToggleHtml()}${sideToggleHtml()}</div><div class="rw-sec-body md-body">${renderMarkdown(c.script_text || '(暂无内容)')}</div>${rewrites.length > 1 ? verCtrl('v', verIdx, rewrites.length, '版本') : ''}`;
/* 右侧列内容计算(diag 空态按钮 R40 改「开始诊断」;stab=diag 才请求复盘接口——原实现无论什么 tab 都查,浪费) */
async function sideContent(c) {
const visibleSb = visibleSbOf(c.id);
const sb = visibleSb[Math.min(sbIdx, visibleSb.length - 1)];
const dg = await diagOf(cur);
const verCtrl = (p, idx, len, label, genLabel) => {
const inner = len > 1
? `<span class="rw-sec-count">${label} ${idx + 1}/${len}</span><button class="btn btn-sm" data-${p}prev ${idx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-${p}next ${idx >= len - 1 ? 'disabled' : ''}>下一版 →</button>`
: (len === 1 ? `<span class="rw-sec-count">${label} 1/1</span>` : '');
return `<div class="rw-sec-foot">${inner}${genLabel ? `<button class="btn btn-sm" data-${p}gen>${svgIcon('refresh', 14, 'currentColor')} ${genLabel}</button>` : ''}</div>`;
};
const sideHead = `<div class="rw-sec-head rw-side-head"><div class="rw-side-tabs">
<button class="rw-side-tab${stab === 'diag' ? ' active' : ''}" data-stab="diag" title="该版本 AI 脚本的复盘评分与问题清单">脚本诊断</button>
<button class="rw-side-tab${stab === 'sb' ? ' active' : ''}" data-stab="sb" title="基于 AI 脚本生成的镜头级分镜提示词">分镜提示词</button>
</div></div>`;
let sideBody = '', sideFoot = '';
if (stab === 'diag') {
const dg = await diagOf(c);
if (dg) {
sideBody = diagHtml(dg);
sideFoot = `<div class="rw-sec-foot"><button class="btn btn-sm" data-diag-review>${svgIcon('refresh', 14, 'currentColor')} 更新诊断</button></div>`;
} else {
sideBody = '<div class="diag-empty">该版本 AI 脚本尚未诊断</div>';
sideFoot = `<div class="rw-sec-foot"><button class="btn btn-sm btn-view" data-diag-review>${svgIcon('zap', 13, 'currentColor')} 开始复盘</button></div>`;
sideFoot = `<div class="rw-sec-foot"><button class="btn btn-sm btn-view" data-diag-review>${svgIcon('zap', 13, 'currentColor')} 开始诊断</button></div>`;
}
} else if (visibleSb.length) {
sideBody = sb ? renderSbBlocks(sb.script_text) : '<div class="dim">(暂无分镜提示词)</div>';
@@ -1737,73 +1750,113 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
sideBody = '<div class="diag-empty">该脚本为自定义选题(无关联视频),暂不支持分镜提示词</div>';
sideFoot = '';
}
const srcSec = hasSource
? `<div class="rw-sec${showSrc ? '' : ' rw-sec-hidden'}" data-src-sec>
<div class="rw-sec-head">源脚本</div>
<div class="rw-sec-body md-body">${renderMarkdown(source)}</div>
<div class="rw-sec-foot"></div>
</div>` : '';
const srcToggle = hasSource ? `<button class="btn btn-sm btn-view rw-src-toggle${showSrc ? ' src-on' : ''}" data-src-toggle title="${showSrc ? '收起源脚本' : '展开源脚本'}">${svgIcon('file-text', 14, 'currentColor')}</button>` : '';
return { sideBody, sideFoot };
}
const sideSecOf = (mask) => [...mask.querySelectorAll('.rw-stack > .rw-sec')].find((s) => s.querySelector('.rw-side-tabs'));
const aiSecOf = (mask) => [...mask.querySelectorAll('.rw-stack > .rw-sec')].find((s) => { const h = s.querySelector('.rw-sec-head'); return h && h.textContent.trim().startsWith('AI脚本'); });
/* AI脚本列滚动 ↔ 分镜列场次对齐(仅右侧为「分镜提示词」且有分镜时生效;分镜列滚动不反向联动) */
const bindScrollSync = (mask) => {
const sideSec = sideSecOf(mask);
const aiSec = aiSecOf(mask);
const aiBody = aiSec && aiSec.querySelector('.rw-sec-body');
const sbBody = sideSec && sideSec.querySelector('.rw-sec-body');
if (!(stab === 'sb' && aiBody && sbBody)) return;
const syncSb = () => {
const heads = [...aiBody.querySelectorAll('strong, h1, h2, h3, h4')].filter((el) => /场\s*\d/.test(el.textContent));
if (!heads.length) return;
const aiTop = aiBody.getBoundingClientRect().top;
const limit = aiBody.scrollTop + 60; /* 视口上缘往下 60px 内最后一个场标题 = 当前场 */
let curH = heads[0];
for (const h of heads) {
const t = h.getBoundingClientRect().top - aiTop + aiBody.scrollTop;
if (t <= limit) curH = h; else break;
}
const m = /场\s*(\d+)/.exec(curH.textContent);
if (!m) return;
const target = [...sbBody.querySelectorAll('.sb-block')].find((b) => new RegExp('场' + m[1] + '(?![0-9])').test((b.querySelector('.sb-head') || {}).textContent || ''));
if (target) {
const t = target.getBoundingClientRect().top - sbBody.getBoundingClientRect().top + sbBody.scrollTop;
if (Math.abs(sbBody.scrollTop - t) > 1) sbBody.scrollTop = t; /* 位置相同不重复设置,避免事件环 */
}
};
aiBody.addEventListener('scroll', syncSb, { passive: true });
syncSb(); /* 打开弹窗 / 切入分镜 tab 时先对齐一次 */
};
/* 右侧列按钮重绑(diag 更新/开始诊断/完整报告 + 分镜生成/重生成/上下版 + 场次同步)——body/foot 每次局部刷新都是新节点,直接绑定无重复 */
const bindSide = (mask) => {
const sideSec = sideSecOf(mask); if (!sideSec) return;
const b = (sel, fn) => { const el = sideSec.querySelector(sel); if (el) el.addEventListener('click', fn); };
b('[data-diag-review]', (e) => fireReview(e.currentTarget));
b('[data-review]', (e) => reportFullReview(e.currentTarget.dataset.review));
b('[data-gen-sb]', (e) => fireGenSb(e.currentTarget, false));
b('[data-sgen]', (e) => fireGenSb(e.currentTarget, true));
b('[data-sprev]', () => { if (sbIdx > 0) { sbIdx--; patchSide(mask); } });
b('[data-snext]', () => { const vs = visibleSbOf(curOf().id); if (sbIdx < vs.length - 1) { sbIdx++; patchSide(mask); } });
bindScrollSync(mask);
};
/* 就地刷新右侧列(tab 切换 / 分镜上下版):不销毁弹窗,active 即时切换 + body 短暂加载后填充 */
let sideToken = 0;
const patchSide = async (mask) => {
const sideSec = sideSecOf(mask); if (!sideSec) return;
const c = curOf();
mask.querySelectorAll('[data-stab]').forEach((x) => x.classList.toggle('active', x.dataset.stab === stab));
const bodyEl = sideSec.querySelector('.rw-sec-body');
[...sideSec.children].forEach((ch) => { if (ch.classList && ch.classList.contains('rw-sec-foot')) ch.remove(); });
if (bodyEl) bodyEl.innerHTML = '<div class="dim" style="padding:12px 2px">加载中…</div>';
const token = ++sideToken;
const { sideBody, sideFoot } = await sideContent(c);
if (!mask.isConnected || token !== sideToken) return; /* 弹窗已关闭 / 期间又切了 tab → 丢弃过期结果 */
if (bodyEl) bodyEl.innerHTML = sideBody;
if (sideFoot) sideSec.insertAdjacentHTML('beforeend', sideFoot);
bindSide(mask);
};
/* 就地刷新 AI列 + 右侧列(版本上/下一版):源列保持不动,其余两列整体替换后重绑 */
const patchAll = async (mask) => {
const c = curOf(); if (!c) return;
const { sideBody, sideFoot } = await sideContent(c);
if (!mask.isConnected) return;
const aiSec = aiSecOf(mask), sideSec = sideSecOf(mask);
if (aiSec) aiSec.innerHTML = aiSecHtml(c);
if (sideSec) sideSec.innerHTML = `${sideHeadHtml()}<div class="rw-sec-body">${sideBody}</div>${sideFoot}`;
bindAll(mask);
};
/* 全部动态节点绑定(每次列内容重建后调用一次;mask 外壳 data-x/背景关闭只在首次绑,不在此重复) */
const bindAll = (mask) => {
mask.querySelectorAll('[data-stab]').forEach((t) => t.addEventListener('click', async () => { if (stab !== t.dataset.stab) { stab = t.dataset.stab; await patchSide(mask); } }));
const srcT = mask.querySelector('[data-src-toggle]');
if (srcT) srcT.addEventListener('click', () => {
showSrc = !showSrc;
const sec = mask.querySelector('[data-src-sec]'); if (sec) sec.classList.toggle('rw-sec-hidden', !showSrc);
srcT.classList.toggle('src-on', showSrc); srcT.title = showSrc ? '收起源脚本' : '展开源脚本';
});
const sideT = mask.querySelector('[data-side-toggle]');
if (sideT) sideT.addEventListener('click', () => {
showSide = !showSide;
const sec = sideSecOf(mask); if (sec) sec.classList.toggle('rw-sec-hidden', !showSide); /* R41:右侧面板收起=只展示 AI 脚本(display:none,AI 列 flex:1 自动占满) */
sideT.classList.toggle('side-off', !showSide);
sideT.title = showSide ? '收起脚本诊断/分镜提示词,只看 AI 脚本' : '展开脚本诊断/分镜提示词';
sideT.innerHTML = svgIcon(showSide ? 'chevrons-right' : 'chevrons-left', 16, 'currentColor');
});
const vp = mask.querySelector('[data-vprev]'); if (vp) vp.addEventListener('click', async () => { if (verIdx > 0) { verIdx--; sbIdx = 0; await patchAll(mask); } });
const vn = mask.querySelector('[data-vnext]'); if (vn) vn.addEventListener('click', async () => { if (verIdx < rewrites.length - 1) { verIdx++; sbIdx = 0; await patchAll(mask); } });
bindSide(mask);
};
async function renderModal() {
const c = curOf(); if (!c) return;
const { sideBody, sideFoot } = await sideContent(c);
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>${svgIcon('pen', 15, 'currentColor')} ${esc(fmtTitleQ(title))}${metaAcc ? ` <span class="dim" style="font-weight:400">${esc(metaAcc)}</span>` : ''}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
<div class="rw-stack">
${srcSec}
<div class="rw-sec">
<div class="rw-sec-head">AI脚本${srcToggle}</div>
<div class="rw-sec-body md-body">${renderMarkdown(cur.script_text || '(暂无内容)')}</div>
${rewrites.length > 1 ? verCtrl('v', verIdx, rewrites.length, '版本') : ''}
</div>
<div class="rw-sec">${sideHead}
<div class="rw-sec-body">${sideBody}</div>
${sideFoot}
</div>
${srcSecHtml()}
<div class="rw-sec">${aiSecHtml(c)}</div>
<div class="rw-sec${showSide ? '' : ' rw-sec-hidden'}" data-side-sec>${sideHeadHtml()}<div class="rw-sec-body">${sideBody}</div>${sideFoot}</div>
</div>
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
const q = (s) => mask.querySelector(s);
const xb = q('[data-x]'); if (xb) xb.addEventListener('click', () => mask.remove());
const srcT = q('[data-src-toggle]'); if (srcT) srcT.addEventListener('click', () => { showSrc = !showSrc; mask.remove(); renderModal(); });
const vp = q('[data-vprev]'); if (vp) vp.addEventListener('click', () => { if (verIdx > 0) { verIdx--; sbIdx = 0; mask.remove(); renderModal(); } });
const vn = q('[data-vnext]'); if (vn) vn.addEventListener('click', () => { if (verIdx < rewrites.length - 1) { verIdx++; sbIdx = 0; mask.remove(); renderModal(); } });
mask.querySelectorAll('[data-stab]').forEach((t) => t.addEventListener('click', () => { if (stab !== t.dataset.stab) { stab = t.dataset.stab; mask.remove(); renderModal(); } }));
const sp = q('[data-sprev]'); if (sp) sp.addEventListener('click', () => { if (sbIdx > 0) { sbIdx--; mask.remove(); renderModal(); } });
const sn = q('[data-snext]'); if (sn) sn.addEventListener('click', () => { if (sbIdx < visibleSb.length - 1) { sbIdx++; mask.remove(); renderModal(); } });
const genSb = q('[data-gen-sb]'); if (genSb) genSb.addEventListener('click', () => fireGenSb(genSb, false));
const sgen = q('[data-sgen]'); if (sgen) sgen.addEventListener('click', () => fireGenSb(sgen, true));
const dgBtn = q('[data-diag-review]'); if (dgBtn) dgBtn.addEventListener('click', () => fireReview(dgBtn));
const full = q('[data-review]'); if (full) full.addEventListener('click', () => reportFullReview(full.dataset.review));
/* AI脚本列滚动 ↔ 分镜列场次对齐(仅右侧为「分镜提示词」且有分镜时生效;分镜列滚动不反向联动) */
if (stab === 'sb' && visibleSb.length) {
const secs = [...mask.querySelectorAll('.rw-stack > .rw-sec')];
const sideSec = secs.find((s2) => s2.querySelector('.rw-side-tabs'));
const aiSec = secs.find((s2) => s2 !== sideSec && (s2.querySelector('.rw-sec-head') || {}).textContent && (s2.querySelector('.rw-sec-head') || {}).textContent.trim().startsWith('AI脚本'));
const aiBody = aiSec && aiSec.querySelector('.rw-sec-body');
const sbBody = sideSec && sideSec.querySelector('.rw-sec-body');
if (aiBody && sbBody) {
const syncSb = () => {
const heads = [...aiBody.querySelectorAll('strong, h1, h2, h3, h4')].filter((el) => /场\s*\d/.test(el.textContent));
if (!heads.length) return;
const aiTop = aiBody.getBoundingClientRect().top;
const limit = aiBody.scrollTop + 60; /* 视口上缘往下 60px 内最后一个场标题 = 当前场 */
let curH = heads[0];
for (const h of heads) {
const t = h.getBoundingClientRect().top - aiTop + aiBody.scrollTop;
if (t <= limit) curH = h; else break;
}
const m = /场\s*(\d+)/.exec(curH.textContent);
if (!m) return;
const target = [...sbBody.querySelectorAll('.sb-block')].find((b) => new RegExp('场' + m[1] + '(?![0-9])').test((b.querySelector('.sb-head') || {}).textContent || ''));
if (target) {
const t = target.getBoundingClientRect().top - sbBody.getBoundingClientRect().top + sbBody.scrollTop;
if (Math.abs(sbBody.scrollTop - t) > 1) sbBody.scrollTop = t; /* 位置相同不重复设置,避免事件环 */
}
};
aiBody.addEventListener('scroll', syncSb, { passive: true });
syncSb(); /* 打开弹窗时先对齐一次 */
}
}
const xb = mask.querySelector('[data-x]'); if (xb) xb.addEventListener('click', () => mask.remove());
bindAll(mask);
}
renderModal();
}
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>MCN创意雷达</title>
<link rel="stylesheet" href="style.css?v=20260903aa">
<link rel="stylesheet" href="style.css?v=20260903ac">
<script src="vendor/echarts.min.js"></script>
</head>
<body>
@@ -42,6 +42,6 @@
</div>
<script src="app.js?v=20260903x"></script>
<script src="data-pages.js?v=20260903ab"></script>
<script src="data-pages.js?v=20260903ad"></script>
</body>
</html>
@@ -567,6 +567,11 @@ td.td-title { max-width: 240px; }
.rw-side-tab { border: none; background: transparent; color: var(--dim); font-size: 14px; font-weight: 500; padding: 5px 12px; border-radius: 6px; cursor: pointer; line-height: 1.4; transition: color .15s; }
.rw-side-tab:hover { color: var(--primary); }
.rw-side-tab.active { color: var(--primary); font-weight: 600; }
.rw-side-sep { color: var(--border); font-size: 14px; line-height: 1; user-select: none; } /* R40:脚本诊断 | 分镜提示词 两 tab 间分隔符 */
/* R41:右侧「脚本诊断|分镜提示词」面板收起/展开钮(AI 列头部最右;右列收起后头部不可见,入口必须常驻 AI 列) */
.rw-side-toggle { margin-left: auto; border: none; background: transparent; color: var(--dim); padding: 4px; border-radius: 6px; cursor: pointer; display: inline-flex; align-items: center; line-height: 1; transition: color .15s; }
.rw-side-toggle:hover { color: var(--primary); background: var(--primary-soft); }
.rw-side-toggle.side-off { color: var(--primary); background: var(--primary-soft); } /* 收起态高亮:提示当前右列已收起、可点此展开 */
.src-on { background: #e8f1fd; border-color: #4a90e2; color: #2f6fed; } /* 源脚本展开态高亮 */
.rw-scroll { padding: 4px 2px; max-height: 75vh; overflow: auto; }
.sb-block { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; background: var(--panel); }