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:
1 parent
fa55d3eecb
commit
ad99afe4de
5 files changed
+293
-197
No files matched your search
@@ -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); }
|
||||
|
||||
Reference in new issue
Block a user