R34: 工作台脚本详情统一弹窗(三入口共用+右侧诊断/分镜tab默认诊断+tab文字变色+空态按钮统一btn-view)

- 三处入口(首页AI脚本卡/AI写脚本「N查看」/脚本诊断页标题)共用 openScriptModal 顶层组件
- 合并两套重复弹窗代码(openRewriteCompare/openScriptDetail)
- 右侧列头部两 tab「脚本诊断|分镜提示词」默认显示脚本诊断
- 后端数据装配:ref 走 video-script+video-detail;custom 走 custom-script(失败回退 rewrites 全表定位);诊断按 review-full(ai||compare);分镜按 rewrite_id 归属过滤
- 分镜边界:仅 ref 视频支持(custom 无视频载体);无源 → 独立分析复盘;三模式复盘(AI/源/对比)
- 报告完整复盘:reportFullReview 抽全局(跨页复用)
- 场次滚动联动:仅分镜态生效(AI列滚动 → 分镜列对齐场块)
- 样式打磨:tab 去掉深蓝底色(active 文字变色+加粗);空态开始复盘/生成分镜统一 btn btn-sm btn-view
- CDP 冒烟 tmp/cdp-r34-modal-smoke.mjs 16/16 PASS(JS=v/w 两次)
This commit is contained in:
maogeigei committed 2026-09-03 17:27:20 +08:00
1 parent fa55d3eecb
commit ad99afe4de
5 files changed
+293 -197

No files matched your search

+29
View File
@@ -290,3 +290,32 @@
- **验证**:node --check 3 JS ✓;curl --noproxy GET 状态正常(files=73/lastUpdateAgeMin=124/task=null);POST 空 prompt 400;CDP 冒烟 `tmp/cdp-rank-update-smoke.mjs`(9225)纯图标版 9 项全 PASS(text='' + svg + title + modal 标题/三榜范围/上次更新/按钮文案/取消)
- **服务/工具坑(重踩)**:nohup+& 后台 node 随 bash 退出被回收 → 须 Bash run_in_background 直接托管前台;taskkill //PID Git Bash 语法错 → PowerShell Stop-Process -Force(返回 exit 1 但端口已释放);curl 全局 socks 代理 502 → 必须 --noproxy '*'
- **待实测**:首次真实点击任务链路(调度拾取→技能会话→抓取执行→落盘→页面重建出最新期)
## R34 工作台「脚本详情统一弹窗」三入口共用(09-03 晚间)
- **需求**:首页 AI脚本卡 / AI写脚本「N 查看」/ 脚本诊断页标题 三处点击后展示同一弹窗;右侧内容由「AI分镜提示词」改为可切换「脚本诊断 | 分镜提示词」两个 tab,默认显示「脚本诊断」(用户指定)
- **范围**:用户选「三处入口统一升级 (Recommended)」——合并两套重复弹窗代码(`openRewriteCompare` AI写脚本页 / `openScriptDetail` 诊断页)+ 首页 `openRecentScript` 接入
- **顶层组件** `openScriptModal({ rewriteId, videoId })`(data-pages.js 取代两旧闭包):
- 数据装配:`vid>0` ref 走 `Promise.all([/api/dsh/video-script?videoId, /api/dsh/video-detail?videoId])`;`vid===0` custom 走 `/api/dsh/custom-script?id`(拒绝时回退 `/api/dsh/rewrites` 全表定位,若挂 video 则递归转 ref 形态);全空 → toast「该脚本不存在或已删除」
- 状态:`stab='diag'`(默认诊断)、`showSrc`、`sbIdx`、`verIdx`;`visibleSbOf(curId)` 按 rewrite_id 归属过滤
- 诊断口径:`review-full` 的 ai 或 compare 取一(compare 取 AI 视角 dims/total 与源对比分);11 维 DIAG_DIMS;无诊断 → 「该版本 AI 脚本尚未诊断」+ 开始复盘按钮
- 分镜边界:仅 ref 有(storyboard_log 需 video_id);custom 无视频载体 → 空态「该脚本为自定义选题(无关联视频),暂不支持分镜提示词」+ 无生成按钮(禁止 confirmRun 空视频)
- 复盘触发:有源 → 三模式复盘(AI/源/对比);无源 → 独立分析仅评 AI 脚本(与诊断页行按钮同文案)
- 完整报告:`reportFullReview(reviewId)` 顶层函数副本(原 openReviewDetail 弹窗逻辑抽全局,跨页复用)
- 场次滚动联动:仅 `stab==='sb'` 且有分镜时(AI 列滚动 → 分镜列对齐场块,分镜列不反向联动),sideSec 用 `.rw-side-tabs` 存在判定、aiSec 用 head 文本 startsWith('AI脚本') 判定
- **删除旧闭包**:`openRewriteCompare`(含内嵌 renderSbBlocks)+ `openScriptDetail`(node -e 锚点切割,各 -7139/-6799 字符);衔接点 grep 验证零残留 renderSbBlocks/openRewriteCompare/openScriptDetail 引用
- **app.js 首页接入**:`openRecentScript(s)` 优先 `window.DataPages.openScriptModal({ rewriteId: s.id, videoId: s.video_id })`;原单列全文弹窗降级兜底(data-pages.js 未就绪时)
- **style.css 新增**:`.rw-side-head`/`.rw-side-tabs`/`.rw-side-tab`(pill tab 容器,默认透明)
- **版本**:index.html ?v=20260903u→v(CSS 有 tab 底色修复须强制刷)
- **CDP 冒烟** `tmp/cdp-r34-modal-smoke.mjs`(9225):reload ignoreCache→首页卡点击 → 弹窗三列 / 两 tab / 默认诊断 / AI列存在 / 源收起 / 诊断渲染 / 切分镜 / 分镜空态 / 关闭 / 诊断页入口同弹窗 / AI写脚本查看同弹窗;JS 版本=20260903v 时 **16 PASS 全绿**
- **CSS tab 底色修复(09-03 末)**:用户反馈「切换内容的按钮不要用深蓝色底色,文字变色就行」→ `.rw-side-tab.active` 去 `background: var(--primary); color: #fff;`,改 `color: var(--primary); font-weight: 600`;hover 同步去 rgba 浅蓝底;CDP 计算样式验证:active `bg=rgba(0,0,0,0) color=rgb(47,111,237) weight=600`,符合「文字变色就行」
- **冒烟断言修复(关键)**:初版 C2 断言 `!![data-vnext] || !!.rw-sec-count` 对 videoId=185(仅 1 个 rewrite id=20,单版本)误判 FAIL——组件按 `rewrites.length>1` 才渲染版本切换控件,单版本无控件是正确行为;改为二分合法态:AI列正文渲染 + (无版本区 = 单版本合法) / (有 count = 验证「版本 x/y」格式);重跑全绿
- **代码删除纪律再踩(教训固化)**:同文件多 Edit 并行执行会互相覆盖(本次 3 处 index.html 版本 bump 并行,只存活 app.js 一处)——**同文件 Edit 必须串行**,并行 Edit 仅适用于不同文件
## R34 样式打磨(09-03 末,w 版)
- **tab 底色修复**(用户反馈):切换内容的按钮不要用深蓝色的底色,文字变色就行 → `.rw-side-tab.active` 去 `background: var(--primary); color: #fff;`,改 `color: var(--primary); font-weight: 600`;hover 同步去 rgba 浅蓝底;CDP 计算样式验证:active `bg=rgba(0,0,0,0) color=rgb(47,111,237) weight=600`
- **空态主操作按钮统一**(用户反馈):开始复盘 + 生成分镜 两按钮「一个样式」 → 统一 `btn btn-sm btn-view`(白底蓝字蓝边,复用弹窗主操作家族「展开源脚本」「开始复盘」原有风格);CDP 实测两按钮计算样式完全一致
- **版本**:data-pages.js ?v=20260903v→w(CSS 没改保持 v,app.js 保持 v)
- **冒烟回归**:w 资源下 `tmp/cdp-r34-modal-smoke.mjs` 16/16 PASS 无回归
## R34 同步 dsh 副本计划
- dsh 副本 `C:\Users\maidou\.dsh\skills\short-video-script\`(独立副本,非软链)—— R34 改动 data-pages.js/app.js/style.css/index.html 同步评估后人工复制
@@ -561,6 +561,12 @@ function setRankBoard(board, activeEl) {
loadRankingPreview();
}
function openRecentScript(s) {
// R34:首页「AI脚本」卡点击 → 打开与 AI写脚本页查看一致的三列统一弹窗(源脚本可折叠 | AI脚本版本 | 右侧 脚本诊断/分镜提示词 tab,默认脚本诊断)
if (window.DataPages && typeof window.DataPages.openScriptModal === 'function') {
window.DataPages.openScriptModal({ rewriteId: Number(s.id) || 0, videoId: Number(s.video_id) || 0 });
return;
}
// 兜底(data-pages.js 未就绪时):单列全文弹窗
const title = cleanTitle(s.title || s.video_title) || 'AI 写剧本';
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
@@ -1349,7 +1349,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
confirmMsg: '将删除选中的 {n} 条视频(含视频解析、AI脚本,不可恢复)。确定删除?',
reload: load,
});
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail)));
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openScriptModal({ videoId: Number(a.dataset.detail) }))); /* R34:统一脚本详情弹窗(右侧 tab 脚本诊断/分镜,默认诊断) */
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => fireVideoCreate(b)));
} else if (state.tab === 'custom') {
const ipt = view.querySelector('#rwCSearch');
@@ -1404,115 +1404,6 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
state.stats = st;
load();
}
async function openRewriteCompare(videoId) {
const d = await api('/api/dsh/video-script?videoId=' + videoId).catch(() => ({ ok: false }));
const detail = await api('/api/dsh/video-detail?videoId=' + videoId).catch(() => ({ ok: false }));
const title = (detail.video && detail.video.video_title) || '';
const source = d.source || '(暂无源脚本数据)';
const rewrites = d.rewrites || [];
let verIdx = 0;
const stb = await api('/api/dsh/storyboards?videoId=' + videoId).catch(() => ({ items: [] }));
const storyboards = stb.items || [];
let sbIdx = 0;
let showSrc = false; // 源脚本列默认收起
function renderSbBlocks(text) {
const blocks = String(text || '').split(/\n\s*---+\s*\n/).map((b) => b.trim()).filter(Boolean);
if (blocks.length <= 1) return renderMarkdown(text || '');
return blocks.map((b, bi) => {
const lines = b.split('\n'); const first = lines[0].trim();
const m = /^段\s*(\d+)\s*[((]\s*([\d.]+)\s*-\s*([\d.]+)\s*秒\s*[))]\s*[||]\s*(.+)$/.exec(first);
let head = m ? `<span class="sb-title">${esc(m[4].trim())}</span><span class="sb-meta">${esc(m[2] + ' - ' + m[3] + '秒')}</span>` : `<span class="sb-title">场${bi + 1}</span>`;
const content = m ? lines.slice(1).join('\n').trim() : b;
return `<div class="sb-block"><div class="sb-head">${head}</div><div class="sb-body md-body">${renderMarkdown(content)}</div></div>`;
}).join('');
}
function renderModal() {
const cur = rewrites[verIdx];
const visibleSb = storyboards.filter((s) => !cur || s.rewrite_id === cur.id);
const sb = visibleSb[Math.min(sbIdx, visibleSb.length - 1)];
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 mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>${esc(fmtTitleQ(title))}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
<div class="rw-stack">
<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>
<div class="rw-sec">
<div class="rw-sec-head">AI脚本<button class="btn btn-sm btn-view rw-src-toggle${showSrc ? ' src-on' : ''}" data-src-toggle title="${showSrc ? '收起源脚本' : '展开源脚本'}">${svgIcon('file-text', 14, 'currentColor')}</button></div>
<div class="rw-sec-body md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无 AI 脚本)'}</div>
${verCtrl('v', verIdx, rewrites.length, '版本')}
</div>
<div class="rw-sec">
<div class="rw-sec-head">AI分镜提示词</div>
<div class="rw-sec-body">${sb ? renderSbBlocks(sb.script_text) : '<div class="dim">(暂无分镜提示词)</div>'}</div>
${visibleSb.length ? verCtrl('s', sbIdx, visibleSb.length, '分镜', '重新生成') : `<div class="rw-sec-foot"><button class="btn btn-sm" data-gen-sb>${svgIcon('zap', 14, 'currentColor')} 生成分镜</button></div>`}
</div>
</div>
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
const q = (s) => mask.querySelector(s);
const srcToggle = q('[data-src-toggle]'); if (srcToggle) srcToggle.addEventListener('click', () => { showSrc = !showSrc; mask.remove(); renderModal(); });
const vprev = q('[data-vprev]'); if (vprev) vprev.addEventListener('click', () => { if (verIdx > 0) { verIdx--; sbIdx = 0; mask.remove(); renderModal(); } });
const vnext = q('[data-vnext]'); if (vnext) vnext.addEventListener('click', () => { if (verIdx < rewrites.length - 1) { verIdx++; sbIdx = 0; mask.remove(); renderModal(); } });
const sprev = q('[data-sprev]'); if (sprev) sprev.addEventListener('click', () => { if (sbIdx > 0) { sbIdx--; mask.remove(); renderModal(); } });
const snext = q('[data-snext]'); if (snext) snext.addEventListener('click', () => { if (sbIdx < visibleSb.length - 1) { sbIdx++; mask.remove(); renderModal(); } });
const fireGenSb = (btn, regenerate) => {
confirmRun({
title: regenerate ? '重新生成分镜' : '生成分镜',
desc: '将基于该 AI 脚本(AI 创作的故事脚本),展开成 AI 分镜提示词(按分镜技能生成镜头级描述)。',
target: `《${title}》`,
prompt: `${SKILL_HINT_STORYBOARD}\n请将视频《${title}》的 AI 脚本${regenerate ? '重新' : ''}展开成 AI 分镜提示词(F4 生成分镜,镜头级描述)。`,
label: '分镜指令',
skills: ['短视频分镜脚本'],
btn,
});
};
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));
// AI脚本列 ↔ AI分镜列 场次联动:滑动 AI脚本到第 N 场,分镜对应滚动到第 N 场块(仅该脚本有分镜时)
if (visibleSb.length) {
const secs = [...mask.querySelectorAll('.rw-stack > .rw-sec')];
const aiBody = secs[1] && secs[1].querySelector('.rw-sec-body');
const sbBody = secs[2] && secs[2].querySelector('.rw-sec-body');
if (aiBody && sbBody) {
// AI脚本列主导联动:AI脚本列滚动 → 分镜列对齐到对应场块(分镜列滚动不反向联动)
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 cur = heads[0];
for (const h of heads) {
const t = h.getBoundingClientRect().top - aiTop + aiBody.scrollTop;
if (t <= limit) cur = h; else break;
}
const m = /场\s*(\d+)/.exec(cur.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(); // 打开弹窗时先对齐一次
}
}
}
renderModal();
}
/* 启动:拉取徽标计数;账号级进入(账号列表「AI脚本」列)时若该账号存在自定义选题脚本 → 默认落在「自定义选题创作」tab(否则「参考选题创作」) */
{
@@ -1604,7 +1495,10 @@ async function renderReviews(view) {
</div>`;
bindBack(view);
bindPager(view, (p) => { page = p; render(); });
view.querySelectorAll('[data-open]').forEach((a) => a.addEventListener('click', () => openScriptDetail(a.dataset.open)));
view.querySelectorAll('[data-open]').forEach((a) => a.addEventListener('click', () => { /* R34:统一脚本详情弹窗(右侧 tab 脚本诊断/分镜,默认诊断) */
const row = items.find((x) => String(x.id) === String(a.dataset.open));
if (row) openScriptModal({ rewriteId: row.id, videoId: Number(row.video_id) || 0 });
}));
view.querySelectorAll('[data-review]').forEach((a) => a.addEventListener('click', () => openReviewDetail(a.dataset.review)));
view.querySelectorAll('[data-review-row]').forEach((b) => b.addEventListener('click', () => {
const row = items.find((x) => String(x.id) === String(b.dataset.reviewRow));
@@ -1640,88 +1534,6 @@ async function renderReviews(view) {
}
}
async function openScriptDetail(rewriteId) {
// 脚本详情弹窗:样式对齐「AI写脚本」查看弹窗(rw-stack 三列)——源脚本(可折叠) | AI脚本(版本切换) | AI诊断(复盘结果)
const row = items.find((x) => String(x.id) === String(rewriteId));
if (!row) return;
const d2 = await api('/api/dsh/video-script?videoId=' + row.video_id).catch(() => ({ ok: false }));
const rewrites = (d2.rewrites || []);
const hasSource = !!(d2.source && String(d2.source).trim());
let verIdx = Math.max(0, rewrites.findIndex((x) => x.id === row.id));
let showSrc = false; // 源脚本列默认收起(与查看弹窗一致)
const DIMS_ALL = ['设定承接', '框架节奏', '钩子设置', '叙事逻辑', '极致事件', '共鸣和共情', '格式规范性', '台词口语化', '内容合规', '教程完整链', '其他'];
const scoreColor = (v) => (v >= 75 ? '#3a9e6d' : v >= 60 ? '#d99a2b' : '#d9534f');
// 诊断内容 HTML(取 AI 视角评分:compare 类型取 ai 侧 dims/total 与源对比结论,列表总分口径一致)
const diagBodyHtml = (dRow) => {
const rv = reviewOf(dRow);
if (!rv) return '';
let dims = {}, total = NaN;
try { const j = JSON.parse(rv.dims_json || '{}'); dims = rv.type === 'compare' ? (j.ai || {}) : j; } catch (e) {}
try { const t = JSON.parse(rv.total || '{}'); total = rv.type === 'compare' ? Number(t.ai) : Number(rv.total); } catch (e) { total = Number(rv.total); }
const srcTotal = (() => { try { const t = JSON.parse(rv.total || '{}'); return rv.type === 'compare' ? Number(t.source) : NaN; } catch (e) { return NaN; } })();
const scoreBar = DIMS_ALL.map((k) => { const v = dims[k]; const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? scoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? scoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
let html = `<div class="score-box"><div class="score-box-title">AI 总分 ${fmtScore(Number.isFinite(total) ? total : rv.total)}${rv.type === 'compare' && Number.isFinite(srcTotal) ? `(对比源脚本 ${fmtScore(srcTotal)})` : ''}${rv.verdict ? ` · <span style="color:${Number.isFinite(total) ? scoreColor(total) : 'var(--dim)'}">${esc(rv.verdict)}</span>` : ''}</div>${scoreBar}</div>`;
let issues = []; try { issues = JSON.parse(rv.issues_json || '[]'); } catch (e) {}
if (issues.length) html += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map((it) => `<div class="issue-row"><span style="color:${it.severity === '高' ? '#d9534f' : it.severity === '中' ? '#d99a2b' : '#3a9e6d'};font-weight:600">${esc(it.severity || '低')}</span><span>${esc(it.issue || '')}</span>${it.suggestion ? `<span class="dim">→ ${esc(it.suggestion)}</span>` : ''}</div>`).join('')}</div>`;
if (rv.summary) html += `<div class="review-summary">综合评语:${esc(rv.summary)}</div>`;
html += `<div><a class="link" data-review="${rv.id}">查看完整复盘报告 →</a></div>`;
return html;
};
const diagEmptyHtml = (dRow) => (dRow && dRow.review_running)
? `<div class="diag-empty">复盘运行中…(完成后重新打开本弹窗即可查看评分)</div>`
: `<div class="diag-empty">该版本 AI 脚本尚未诊断</div>
<div><button class="btn btn-view btn-sm" data-diag-review>${svgIcon('zap', 13, 'currentColor')} 开始复盘</button></div>`;
function renderModal() {
const curRow = rewrites.length ? rewrites[Math.min(verIdx, rewrites.length - 1)] : null;
const dRow = curRow ? (items.find((x) => String(x.id) === String(curRow.id)) || curRow) : null; // items 行带 review_ai/compare;兜底 raw 行(无诊断)
const diag = dRow ? reviewOf(dRow) : null;
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(d2.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>` : '';
const verCtrl = (() => {
const len = rewrites.length;
if (len <= 1) return `<span class="rw-sec-count">${len === 1 ? '版本 1/1' : ''}</span>`;
return `<span class="rw-sec-count">版本 ${verIdx + 1}/${len}</span><button class="btn btn-sm" data-vprev ${verIdx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-vnext ${verIdx >= len - 1 ? 'disabled' : ''}>下一版 →</button>`;
})();
const running = !!(dRow && dRow.review_running);
const diagFoot = diag
? `<div class="rw-sec-foot">${running ? '<span class="rw-sec-count">复盘运行中…</span>' : ''}<button class="btn btn-sm" data-diag-review ${running ? 'disabled' : ''}>${svgIcon('refresh', 14, 'currentColor')} 更新诊断</button></div>`
: '<div class="rw-sec-foot"></div>';
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>${esc(fmtTitleQ(row.title || ''))}</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">${curRow ? renderMarkdown(curRow.script_text || '(暂无内容)') : '(暂无 AI 脚本)'}</div>
${verCtrl ? `<div class="rw-sec-foot">${verCtrl}</div>` : ''}
</div>
<div class="rw-sec">
<div class="rw-sec-head">AI诊断</div>
<div class="rw-sec-body">${diag ? diagBodyHtml(dRow) : diagEmptyHtml(dRow)}</div>
${diagFoot}
</div>
</div>
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
const q = (s) => mask.querySelector(s);
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--; mask.remove(); renderModal(); } });
const vn = q('[data-vnext]'); if (vn) vn.addEventListener('click', () => { if (verIdx < rewrites.length - 1) { verIdx++; mask.remove(); renderModal(); } });
const diagBtn = q('[data-diag-review]');
if (diagBtn) diagBtn.addEventListener('click', () => { const t = dRow; if (t) startReview(t, diagBtn); });
const full = q('[data-review]'); if (full) full.addEventListener('click', () => openReviewDetail(full.dataset.review));
}
renderModal();
}
async function openReviewDetail(reviewId) {
const d3 = await api('/api/dsh/review-detail?id=' + reviewId).catch(() => ({ ok: false }));
@@ -1755,4 +1567,247 @@ async function renderReviews(view) {
}
}
window.DataPages = { renderRanking, renderAccounts, renderAccountDetail, renderVideoDetail, renderRewrites, renderReviews };
/* ================= R34 统一「脚本详情」弹窗(首页 AI脚本卡 / AI写脚本「N 查看」/ 脚本诊断 标题 三入口共用) =================
布局同 AI写脚本查看弹窗(rw-stack 横向列):源脚本(可折叠,有原视频时) | AI脚本(版本切换) | 右侧列
右侧列头部 tab:脚本诊断(默认) | 分镜提示词
—— 诊断内容 = 当前版本 AI 脚本最新复盘(script_review type=ai 优先;compare 取 AI 视角总分并带源对比分)
—— 分镜内容 = 该视频下 storyboard_log 中属当前版本的记录 + 生成/重新生成入口(仅参考选题脚本;自定义选题无视频载体不支持)
数据源:/api/dsh/video-script + /api/dsh/video-detail(参考选题)/ /api/dsh/custom-script(自定义选题)
+ /api/dsh/review-full?rewriteId=(当前版本诊断)+ /api/dsh/storyboards?videoId=(分镜) */
const DIAG_DIMS = ['设定承接', '框架节奏', '钩子设置', '叙事逻辑', '极致事件', '共鸣和共情', '格式规范性', '台词口语化', '内容合规', '教程完整链', '其他'];
const diagScoreColor = (v) => (v >= 75 ? '#3a9e6d' : v >= 60 ? '#d99a2b' : '#d9534f');
const diagFmtScore = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : '-');
/* 分镜文本渲染:按「段 N(起-止 秒)|标题」分块(继承原 AI写脚本查看弹窗逻辑;分镜时间戳分段格式) */
function renderSbBlocks(text) {
const blocks = String(text || '').split(/\n\s*---+\s*\n/).map((b) => b.trim()).filter(Boolean);
if (blocks.length <= 1) return renderMarkdown(text || '');
return blocks.map((b, bi) => {
const lines = b.split('\n'); const first = lines[0].trim();
const m = /^段\s*(\d+)\s*[((]\s*([\d.]+)\s*-\s*([\d.]+)\s*秒\s*[))]\s*[||]\s*(.+)$/.exec(first);
let head = m ? `<span class="sb-title">${esc(m[4].trim())}</span><span class="sb-meta">${esc(m[2] + ' - ' + m[3] + '秒')}</span>` : `<span class="sb-title">场${bi + 1}</span>`;
const content = m ? lines.slice(1).join('\n').trim() : b;
return `<div class="sb-block"><div class="sb-head">${head}</div><div class="sb-body md-body">${renderMarkdown(content)}</div></div>`;
}).join('');
}
/* 完整复盘报告弹窗(组件内副本:完整报告入口跨页复用;样式与脚本诊断页 openReviewDetail 一致) */
async function reportFullReview(reviewId) {
const d3 = await api('/api/dsh/review-detail?id=' + reviewId).catch(() => ({ ok: false }));
if (!d3.ok) { showToast('复盘详情加载失败'); return; }
const rv = d3.review;
const typeName = rv.type === 'source' ? '原视频脚本复盘' : rv.type === 'compare' ? '对比分析' : 'AI 脚本复盘';
let dimsObj = {}; try { dimsObj = JSON.parse(rv.dims_json || '{}'); } catch (e) {}
const scoreBar = (dims, prefix) => DIAG_DIMS.map((k) => { const v = dims[k]; const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? diagScoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? diagScoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
let body = '';
if (rv.type === 'compare') {
const aiD = dimsObj.ai || {}, srcD = dimsObj.source || {};
let t = {}; try { t = JSON.parse(rv.total || '{}'); } catch (e) {}
body = `<div class="score-dual"><div class="score-box"><div class="score-box-title">AI 总分 ${diagFmtScore(Number(t.ai))}</div>${scoreBar(aiD, 'c-ai-')}</div><div class="score-box"><div class="score-box-title">源脚本 总分 ${diagFmtScore(Number(t.source))}</div>${scoreBar(srcD, 'c-src-')}</div></div>`;
} else {
body = `<div class="score-box"><div class="score-box-title">总分 ${diagFmtScore(Number(rv.total))}</div>${scoreBar(dimsObj, 'd-')}</div>`;
}
let issues = []; try { issues = JSON.parse(rv.issues_json || '[]'); } catch (e) {}
if (issues.length) body += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map((it) => `<div class="issue-row"><span style="color:${it.severity === '高' ? '#d9534f' : it.severity === '中' ? '#d99a2b' : '#3a9e6d'};font-weight:600">${esc(it.severity || '低')}</span><span>${esc(it.issue || '')}</span>${it.suggestion ? `<span class="dim">→ ${esc(it.suggestion)}</span>` : ''}</div>`).join('')}</div>`;
if (rv.summary) body += `<div class="review-summary">综合评语:${esc(rv.summary)}</div>`;
if (rv.content) body += `<div class="md-body" style="border-top:1px solid var(--border);padding-top:10px">${renderMarkdown(rv.content)}</div>`;
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal rw-modal">
<div class="modal-title-row"><span>${esc(typeName)}${rv.verdict ? ' · ' + esc(rv.verdict) : ''}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
<div class="rw-scroll">${body || '加载中…'}</div>
</div>`;
document.body.appendChild(mask);
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
}
async function openScriptModal({ rewriteId, videoId } = {}) {
rewriteId = Number(rewriteId) > 0 ? Number(rewriteId) : 0;
const vid = Number(videoId) > 0 ? Number(videoId) : 0;
/* ---- 数据装配:版本列表 + 源脚本 + 标题 meta ---- */
let rewrites = [], source = null, metaTitle = '', metaAcc = '';
if (vid > 0) { /* 参考选题:以视频为容器,展示该视频下全部 AI 脚本版本(含源脚本) */
const [vs, vd] = await Promise.all([
api('/api/dsh/video-script?videoId=' + vid).catch(() => ({ ok: false, rewrites: [], source: null })),
api('/api/dsh/video-detail?videoId=' + vid).catch(() => ({ ok: false, video: null })),
]);
rewrites = (vs && vs.rewrites) || [];
source = (vs && vs.source) || null;
if (vd && vd.video) { metaTitle = vd.video.video_title || ''; metaAcc = vd.video.account_name || ''; }
} else if (rewriteId) { /* 自定义选题(video_id 空):单条脚本;custom-script 拒绝则回退全量表定位(该脚本实际挂在视频下) */
const cs = await api('/api/dsh/custom-script?id=' + rewriteId).catch(() => ({ ok: false, error: 1 }));
if (cs && !cs.error && cs.script) {
rewrites = [{ id: cs.script.id, script_text: cs.script.script_text, created_time: cs.script.created_time }];
metaTitle = cs.script.title || ''; metaAcc = cs.script.account_name || '';
} else {
const all = await api('/api/dsh/rewrites').catch(() => ({ items: [] }));
const row = ((all && all.items) || []).find((x) => String(x.id) === String(rewriteId));
if (row && Number(row.video_id) > 0) { openScriptModal({ rewriteId, videoId: row.video_id }); return; }
if (row) { rewrites = [{ id: row.id, script_text: '', created_time: row.created_time }]; metaTitle = row.title || ''; }
}
}
if (!rewrites.length) { showToast('该脚本不存在或已删除'); return; }
let verIdx = rewriteId ? Math.max(0, rewrites.findIndex((x) => String(x.id) === String(rewriteId))) : 0;
if (verIdx < 0) verIdx = 0;
const title = metaTitle || 'AI 脚本';
const hasSource = vid > 0 && !!source;
let stab = 'diag'; /* 右侧 tab:diag=脚本诊断(默认)/ sb=分镜提示词 */
let showSrc = false, sbIdx = 0;
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));
/* 复盘任务触发:有源脚本 → 三模式(AI/源/对比);无源 → 独立分析仅评 AI 脚本(同脚本诊断页行按钮口径) */
const fireReview = (btn) => {
if (btn.dataset.lock) return; btn.dataset.lock = '1';
try {
const [desc, prompt] = hasSource
? ['将对该 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。',
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。`]
: ['将对该 AI 脚本做质量复盘评分(该脚本无原视频脚本,仅评 AI 脚本:独立分析模式·10 维评分制)。',
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做质量复盘评分(独立分析模式·10 维评分制)。该脚本无原视频脚本,只评 AI 脚本本身,不做源脚本复盘、不做对比分析。`];
confirmRun({ title: hasSource ? '复盘评分' : 'AI脚本复盘', desc, prompt, label: '复盘指令', skills: ['脚本复盘'], btn });
} finally { delete btn.dataset.lock; }
};
/* 生成 / 重新生成分镜(仅参考选题脚本;自定义选题无视频载体不提供) */
const fireGenSb = (btn, regenerate) => {
confirmRun({
title: regenerate ? '重新生成分镜' : '生成分镜',
desc: '将基于该 AI 脚本(AI 创作的故事脚本),展开成 AI 分镜提示词(按分镜技能生成镜头级描述)。',
target: `《${title}》`,
prompt: `${SKILL_HINT_STORYBOARD}\n请将视频《${title}》的 AI 脚本${regenerate ? '重新' : ''}展开成 AI 分镜提示词(F4 生成分镜,镜头级描述)。`,
label: '分镜指令',
skills: ['短视频分镜脚本'],
btn,
});
};
/* 当前版本诊断(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;
if (!rv) return null;
let dims = {}, total = NaN, srcTotal = NaN;
try { const j = JSON.parse(rv.dims_json || '{}'); dims = rv.type === 'compare' ? (j.ai || {}) : j; } catch (e) {}
if (rv.type === 'compare') { try { const t = JSON.parse(rv.total || '{}'); total = Number(t.ai); srcTotal = Number(t.source); } catch (e) { total = Number(rv.total); } }
else { total = Number(rv.total); }
return { rv, dims, total, srcTotal };
};
const diagHtml = (dg) => {
const { rv, dims, total, srcTotal } = dg;
const totalFin = Number.isFinite(total);
const scoreBar = DIAG_DIMS.map((k) => { const v = dims[k]; const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? diagScoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? diagScoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
let html = `<div class="score-box"><div class="score-box-title">AI 总分 ${diagFmtScore(totalFin ? total : Number(rv.total))}${rv.type === 'compare' && Number.isFinite(srcTotal) ? `(对比源脚本 ${diagFmtScore(srcTotal)})` : ''}${rv.verdict ? ` · <span style="color:${totalFin ? diagScoreColor(total) : 'var(--dim)'}">${esc(rv.verdict)}</span>` : ''}</div>${scoreBar}</div>`;
let issues = []; try { issues = JSON.parse(rv.issues_json || '[]'); } catch (e) {}
if (issues.length) html += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map((it) => `<div class="issue-row"><span style="color:${it.severity === '高' ? '#d9534f' : it.severity === '中' ? '#d99a2b' : '#3a9e6d'};font-weight:600">${esc(it.severity || '低')}</span><span>${esc(it.issue || '')}</span>${it.suggestion ? `<span class="dim">→ ${esc(it.suggestion)}</span>` : ''}</div>`).join('')}</div>`;
if (rv.summary) html += `<div class="review-summary">综合评语:${esc(rv.summary)}</div>`;
html += `<div><a class="link" data-review="${rv.id}">查看完整复盘报告 →</a></div>`;
return html;
};
async function renderModal() {
const cur = rewrites[Math.min(verIdx, rewrites.length - 1)] || rewrites[0];
if (!cur) return;
const visibleSb = visibleSbOf(cur.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') {
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>`;
}
} else if (visibleSb.length) {
sideBody = sb ? renderSbBlocks(sb.script_text) : '<div class="dim">(暂无分镜提示词)</div>';
sideFoot = verCtrl('s', sbIdx, visibleSb.length, '分镜', '重新生成');
} else if (vid > 0) {
sideBody = '<div class="dim">(暂无分镜提示词)</div>';
sideFoot = `<div class="rw-sec-foot"><button class="btn btn-sm btn-view" data-gen-sb>${svgIcon('zap', 14, 'currentColor')} 生成分镜</button></div>`;
} else {
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>` : '';
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>
</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(); /* 打开弹窗时先对齐一次 */
}
}
}
renderModal();
}
window.DataPages = { renderRanking, renderAccounts, renderAccountDetail, renderVideoDetail, renderRewrites, renderReviews, openScriptModal };
@@ -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=20260903t">
<link rel="stylesheet" href="style.css?v=20260903v">
<script src="vendor/echarts.min.js"></script>
</head>
<body>
@@ -41,7 +41,7 @@
</div>
</div>
<script src="app.js?v=20260903r"></script>
<script src="data-pages.js?v=20260903t"></script>
<script src="app.js?v=20260903v"></script>
<script src="data-pages.js?v=20260903w"></script>
</body>
</html>
@@ -557,6 +557,12 @@ td.td-title { max-width: 240px; }
.rw-sec-foot { flex: none; min-height: 46px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); } /* min-height 保证空 foot(源脚本列占位)与其他列等高,内容区一致 */
.rw-sec-count { margin-right: auto; color: var(--dim); font-size: 14px; }
.rw-sec-hidden { display: none; } /* 源脚本列默认收起 */
/* R34:脚本详情弹窗 右侧列头部 tab(脚本诊断 / 分镜提示词 切换) */
.rw-side-head { padding: 4px 6px; gap: 0; }
.rw-side-tabs { display: inline-flex; gap: 2px; align-items: center; }
.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; }
.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); }