R64-R70: 工作台迭代—视频解析执行状态跨刷新/找不到MCP根因修复/全量排查需MCP功能挂connector/刷新功能强制重新获取/自定义选题创作改名原创选题创作+系列UI微调(cdp-verify回归脚本)
This commit is contained in:
1 parent
b805b77315
commit
1a5d319bfc
17 files changed
+745
-65
No files matched your search
@@ -26,6 +26,11 @@ const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'tabl
|
||||
定位方式跨环境:优先用 Skill 工具按技能名加载;技能位于用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/(部署到用户环境时同此标准位),不写死机器绝对路径 */
|
||||
const SKILL_HINT_ACCOUNT = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。严格按技能工作流执行(获取账号信息 → 视频列表抓取 → 登记达人账号表 15 列规范),遵守技能红线:TOP6 筛选、≥60 条停止、账号身份标识(sec_uid/抖音号/地址)必须由浏览器获取、禁止从数据库回填。';
|
||||
|
||||
/* 09-04 R67:业务任务统一挂载的连接器 configId(myai-mcp-production 自定义 MCP)。
|
||||
宿主 register(connectorIds) 只注入列出的连接器;凡任务可能调用 MCP(视频解析/对标拆解/功能一兜底数据源等)必须挂载,
|
||||
否则后台会话无 MCP 工具(「找不到 MCP」根因,见 R66)。复盘/分镜/榜单等纯脚本任务不挂。 */
|
||||
const MYAI_CONNECTORS = ['custom-mcp:myai-mcp-production'];
|
||||
|
||||
// 任务完成后自动刷新当前数据页(无需手动刷新即可看到最新数据)
|
||||
function refreshCurrentPage() {
|
||||
const view = $('#view');
|
||||
@@ -36,6 +41,8 @@ function refreshCurrentPage() {
|
||||
}
|
||||
|
||||
// 提交任务 + 按钮执行中状态 + 轮询恢复(供 confirmSaveAccount/confirmRun 确认后调用)
|
||||
// opts.onSubmit(taskId):提交成功拿到任务 id 后回调(调用方可持久化「执行中」状态,供页面刷新后恢复,见视频解析)
|
||||
// opts.onFinish(s):轮询结束(done/error/notfound/review/超时 s=null)后回调,restore 之前调用
|
||||
async function executeTask(opts) {
|
||||
const btn = opts.btn;
|
||||
const oldText = btn ? btn.innerHTML : '';
|
||||
@@ -43,15 +50,19 @@ async function executeTask(opts) {
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '执行中…'; btn.classList.add('btn-busy'); }
|
||||
let taskId = null;
|
||||
let finished = false;
|
||||
let s = null;
|
||||
let ended = false;
|
||||
const finish = (st) => { if (ended) return; ended = true; if (opts.onFinish) { try { opts.onFinish(st); } catch (e) {} } }; /* 终态清理须在 refresh 之前,避免刷新渲染读到陈旧执行中记录 */
|
||||
try {
|
||||
const d = await api('/api/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务', skills: opts.skills }),
|
||||
body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务', skills: opts.skills, connectorIds: opts.connectorIds }),
|
||||
});
|
||||
if (d.error) throw new Error(d.error);
|
||||
taskId = d.id;
|
||||
showToast('✅ 任务已提交,请在左侧会话栏查看执行');
|
||||
if (opts.onSubmit) { try { opts.onSubmit(taskId); } catch (e) {} }
|
||||
} catch (e) {
|
||||
showToast('❌ 任务提交失败: ' + e.message);
|
||||
restore();
|
||||
@@ -61,15 +72,15 @@ async function executeTask(opts) {
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
let s = null;
|
||||
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
|
||||
if (!s || s.state === 'notfound') { finished = true; break; }
|
||||
if (s.state === 'done') { finished = true; showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
|
||||
if (s.state === 'error') { finished = true; showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
|
||||
if (s.state === 'review') { finished = true; showToast('⏸ 任务待确认,请到左侧会话栏确认执行'); break; }
|
||||
if (!s || s.state === 'notfound') { finished = true; finish(s || null); break; }
|
||||
if (s.state === 'done') { finished = true; finish(s); showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
|
||||
if (s.state === 'error') { finished = true; finish(s); showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
|
||||
if (s.state === 'review') { finished = true; finish(s); showToast('⏸ 任务待确认,请到左侧会话栏确认执行'); break; }
|
||||
// queued / pending / running → 继续等待
|
||||
}
|
||||
restore();
|
||||
if (!ended) finish(null);
|
||||
if (!finished) showToast('⏳ 任务仍在执行,请到左侧会话栏查看进度');
|
||||
}
|
||||
|
||||
@@ -105,6 +116,7 @@ function confirmSaveAccount(name, url, btn) {
|
||||
label: '保存指令',
|
||||
btn,
|
||||
skills: ['mcn-dou-analysis'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67:功能一浏览器不可用时 MCP 账号库兜底 */
|
||||
});
|
||||
}
|
||||
|
||||
@@ -139,7 +151,7 @@ function openImportAccounts(btn) {
|
||||
const parts = [name ? `账号名称「${name}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
|
||||
const prompt = `${SKILL_HINT_ACCOUNT}\n请帮我把抖音账号 ${parts} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
|
||||
close();
|
||||
executeTask({ prompt, label: '添加指令', btn, skills: ['mcn-dou-analysis'] });
|
||||
executeTask({ prompt, label: '添加指令', btn, skills: ['mcn-dou-analysis'], connectorIds: MYAI_CONNECTORS });
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const SKILL_HINT_REVIEW = '请调用 Skill 工具加载「脚本复盘」技能
|
||||
const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为技能功能五「解析视频」:对单条抖音视频做 MCP 内容解析。按 references/feature/05_解析视频.md 流程执行:① 调用 MCP 工具 upload_douyin_video 提交视频链接(share_text=该视频抖音链接)获取 detailId;② 等待解析完成(新视频约 3-5 分钟,用 sleep 等待后查询,content 已出而 analysis 未出时稍后重试);③ 用 short_video_detail 查询解析结果 content(视频脚本/分镜头表)与 analysis(视频拆解/结构化JSON);④ 按 references/解析结果格式规范.md 用 scripts/json_tool.py 将 content.json 与 analysis.json 保存到产出目录对应视频文件夹;⑤ 解析结果入库:工作台环境(本机 http://localhost:8900)产物落盘后调用 POST /api/import/account(body {"account":"{达人昵称}"})同步入库,接口不可用则跳过不影响保存。完成后汇报 detailId、content/analysis 是否齐备与保存路径。技能边界:只做该单视频解析,不抓取账号信息、不提炼账号设定、不创作/改写脚本。';
|
||||
/* 09-02 v25:AI创作任务完成后的写库回调指令——否则脚本只落会话/产出目录,工作台「AI写脚本」列表看不到。
|
||||
参考选题创作(有原视频,如视频行 AI创作):传 videoId/awemeId → rewrite_log.video_id 关联原视频;
|
||||
自定义选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「自定义选题创作」tab。
|
||||
原创选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「原创选题创作」tab。
|
||||
保存走本地服务 /api/dsh/script-save:server 自行解析 mcn-plugin.db 路径(跨开发机/dsh 环境最可靠),勿让 agent 猜库路径 */
|
||||
/* 榜单数据更新技能声明(09-03 新增 09-04 四榜扩展:热点数据页「更新榜单」按钮 → 后台任务按 mcn-data-insight 落盘规范抓取四榜最新一期。
|
||||
范围与调用次数已在点击确认弹窗中向用户明示并获授权(账号日榜 全品类 1 次 + 账号周榜 5 赛道 + 视频热榜/点赞榜,约 8 次红狐 API);
|
||||
@@ -40,7 +40,7 @@ const saveHintOf = (ctx) => {
|
||||
lines.push(` "scriptText": "(完整脚本全文)"`);
|
||||
return `\n\n【任务完成后必须执行】将完整脚本保存到工作台数据库(工作台与本任务同机运行,服务地址 http://localhost:8900):\n`
|
||||
+ `1. 用 fetch 发起 POST http://localhost:8900/api/dsh/script-save;body 用 JSON.stringify 构造(scriptText 为完整脚本全文,注意转义,不要手拼 JSON):\n{\n${lines.join('\n')}\n}\n`
|
||||
+ (ref ? '(参考选题创作:脚本归入「参考选题创作」列表,关联该原视频)\n' : '(自定义选题创作:不传 videoId,脚本归入「自定义选题创作」列表)\n')
|
||||
+ (ref ? '(参考选题创作:脚本归入「参考选题创作」列表,关联该原视频)\n' : '(原创选题创作:不传 videoId,脚本归入「原创选题创作」列表)\n')
|
||||
+ `2. 响应含 ok:true 即已入库;失败重试一次,仍失败则在回复中说明原因。\n完成后回复:脚本标题 + 保存结果。`;
|
||||
};
|
||||
|
||||
@@ -502,7 +502,7 @@ async function confirmAiScript(accountName, btn) {
|
||||
rm();
|
||||
const prompt = `${SKILL_HINT_CREATE}\n${buildCreatePrompt(accountName, w, personaBlock(), pHas, attach.text)}${saveHintOf({ accountId: accId })}`;
|
||||
close();
|
||||
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频工作台'] });
|
||||
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频工作台'], connectorIds: MYAI_CONNECTORS });
|
||||
});
|
||||
document.body.appendChild(ck);
|
||||
});
|
||||
@@ -709,7 +709,7 @@ async function updateRankingData(view, btn) {
|
||||
mask.innerHTML = `
|
||||
<div class="modal" style="max-width:560px">
|
||||
<h3>${svgIcon('refresh', 18)} 更新榜单数据</h3>
|
||||
<p class="dim">将重新抓取热点数据四榜最新一期并落盘:账号日榜(全品类单次,不传赛道)+ 账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 8 次红狐 API 调用(幂等铁律:同榜名+赛道+数据日期已存在则跳过)。后台任务执行需数分钟,完成后自动刷新榜单。</p>
|
||||
<p class="dim">将重新抓取热点数据四榜最新一期并落盘:账号日榜(全品类单次,不传赛道)+ 账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 8 次红狐 API 调用${inCooldown ? '(强制刷新:同名文件已存在也重新抓取并覆盖写入)' : '(幂等铁律:同榜名+赛道+数据日期已存在则跳过)'}。后台任务执行需数分钟,完成后自动刷新榜单。</p>
|
||||
${st.lastUpdateAt ? `<div class="confirm-target">上次更新:${esc(st.lastUpdateDate || '—')} ${esc(timeTxt)}(${esc(ageTxt() || '')})</div>` : (st.exists === false ? '<div class="confirm-target">本地暂无榜单数据文件,本次为首次抓取</div>' : '')}
|
||||
${inCooldown ? `<p style="color:#b7791f;font-size:14px;margin:8px 0 0">⚠️ ${st.lastUpdateAgeMin} 分钟前刚更新过(防重复窗口 ${st.cooldownMin} 分钟),数据仍较新;确需强制刷新请点「仍要更新」。</p>` : ''}
|
||||
<div class="modal-actions">
|
||||
@@ -727,11 +727,15 @@ async function updateRankingData(view, btn) {
|
||||
const restore = () => { if (btn) { btn.disabled = false; btn.title = oldTitle; btn.classList.remove('btn-busy'); } };
|
||||
if (btn) { btn.disabled = true; btn.title = '榜单更新中…(完成后自动刷新)'; btn.classList.add('btn-busy'); } // 纯图标按钮:不动 innerHTML,图标经 .btn-busy .ic 旋转
|
||||
try {
|
||||
// 3) 提交(后端二次防重;冷却窗口内确认 = 传 force)
|
||||
// 3) 提交(后端二次防重;冷却窗口内确认 = 传 force)。R69:force 时在 prompt 内追加覆盖语义——
|
||||
// 否则 AI 仍按「幂等铁律」跳过同名同日期文件,强制刷新变空跑(重抓已存在文件=用户主动要求覆盖)
|
||||
const rankPrompt = SKILL_HINT_RANKING + (inCooldown
|
||||
? '\n【覆盖刷新模式(09-04 R69:用户已确认强制刷新)】本次为强制刷新:即使目标榜名+赛道+数据日期同名文件已存在,也要重新调用 API 抓取最新数据并覆盖写入同名文件,禁止因文件已存在而跳过 API 调用。'
|
||||
: '');
|
||||
const r = await api('/api/dsh/ranking-update', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: SKILL_HINT_RANKING, force: !!inCooldown }),
|
||||
body: JSON.stringify({ prompt: rankPrompt, force: !!inCooldown }),
|
||||
});
|
||||
if (!r || r.ok === false) throw new Error((r && r.error) || '任务提交失败');
|
||||
showToast('✅ 榜单更新任务已提交,请到左侧会话栏查看执行');
|
||||
@@ -800,7 +804,7 @@ async function renderAccounts(view) {
|
||||
<select id="accTrack" class="input-select"><option value="">全部赛道</option>${tracks.map((t) => `<option value="${esc(t)}" ${t === state.track ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
|
||||
<input id="accSearch" class="input" placeholder="搜索账号名 / 内容 / 赛道" value="${esc(state.search)}">
|
||||
<button class="btn" id="accImport" title="添加账号数据(抖音账号信息 / 账号地址)">${svgIcon('user-plus', 15, 'currentColor')} 添加</button>
|
||||
<button class="btn" id="accUpdate" title="重新抓取所有已关注账号(⭐)的最新账号数据与视频列表(后台任务逐个执行)">${svgIcon('globe', 15, 'currentColor')} 更新</button>
|
||||
<button class="btn" id="accUpdate" title="重新抓取所有已关注账号(⭐)的最新账号数据与视频列表(后台任务逐个执行)">${svgIcon('globe', 15, 'currentColor')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
@@ -885,7 +889,7 @@ function updateFollowedAccounts(btn) {
|
||||
for (let i = 0; i < names.length; i++) {
|
||||
const name = names[i];
|
||||
if (btn) btn.innerHTML = `更新中 ${i + 1}/${names.length}…`;
|
||||
const prompt = `${SKILL_HINT_ACCOUNT}\n请更新抖音账号「${name}」的数据:该账号已在本工作台账号库中登记,属数据更新场景(非新增)。通过抖音网页重新获取该账号最新的账号信息(粉丝/获赞/作品数/简介等)与最新视频列表,并覆盖更新该账号的登记数据与视频列表记录。账号身份标识(sec_uid/抖音号/抖音地址)一律以本次浏览器抓取到的为准,禁止沿用库中旧值回填。`;
|
||||
const prompt = `${SKILL_HINT_ACCOUNT}\n请更新抖音账号「${name}」的数据:该账号已在本工作台账号库中登记,属数据更新场景(非新增)。通过抖音网页重新获取该账号最新的账号信息(粉丝/获赞/作品数/简介等)与最新视频列表,并覆盖更新该账号的登记数据与视频列表记录。账号身份标识(sec_uid/抖音号/抖音地址)一律以本次浏览器抓取到的为准,禁止沿用库中旧值回填。\n【覆盖刷新模式(09-04 R69)】本次是用户主动「更新」操作 = 强制重新抓取并覆盖:不要因库中已有该账号记录而判断「数据已较新/无需更新」而跳过或缩短流程,禁止复用旧的抓取结果——必须重新走完整抓取流程,用本次结果整体覆盖更新该账号登记数据与视频列表记录。`;
|
||||
try {
|
||||
const r = await api('/api/run', {
|
||||
method: 'POST',
|
||||
@@ -1100,6 +1104,7 @@ async function renderAccountDetail(view, id) {
|
||||
prompt: basePrompt + saveHintOf({ videoId: r.id, awemeId: r.aweme, accountId: id }),
|
||||
label: '创作指令',
|
||||
skills: ['短视频工作台'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67: 未解析视频先 MCP 解析分支 */
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
@@ -1111,6 +1116,7 @@ async function renderAccountDetail(view, id) {
|
||||
prompt: `${SKILL_HINT_EXTRACT}\n请为达人「${acc.account_name}」提炼账号设定(生成人设卡)。`,
|
||||
label: '提炼指令',
|
||||
skills: ['mcn-dou-analysis'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67:功能三缺存量解析时会现场 MCP 解析 */
|
||||
btn: exBtn,
|
||||
}));
|
||||
// 09-04:数据分析 tab 空态「账号数据分析」按钮 → mcn-dou-analysis 功能四(近4个月数据 逐条拆解+汇总分析)
|
||||
@@ -1122,6 +1128,7 @@ async function renderAccountDetail(view, id) {
|
||||
prompt: `${SKILL_HINT_ANALYZE}\n请为达人「${acc.account_name}」进行账号数据分析(功能四)。`,
|
||||
label: '分析指令',
|
||||
skills: ['mcn-dou-analysis'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67:功能四需补齐无解析视频的 MCP 解析 */
|
||||
btn: anaBtn,
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab/翻页)
|
||||
@@ -1318,14 +1325,14 @@ function renderVideoKeySvg(analysis, vt, acc) {
|
||||
T(340, headY0 + 162, `${acc ? acc + ' · ' : ''}赛道:${D.track || '未知'}`, 9.5, P.headDim, null, 'middle');
|
||||
|
||||
/* —— 逐模块排版 —— */
|
||||
let y = headY0 + headH + 22; /* 头部框下起点 */
|
||||
let y = headY0 + headH + 34; /* 头部框下起点(09-04:首板块与头部大标题框间距加大 22→34,避免「内容定位」标题贴框底) */
|
||||
const vPadLR = 16, vPadT = 15, vPadB = 14; /* 卡内左右/上/下留白(F6:文字禁止贴靠卡边) */
|
||||
const insetTop = (s) => Math.round(s * 0.9); /* 字形顶 → 基线(中文 glyph 顶在基线之上约 0.9em) */
|
||||
const kvU = (size) => X((CW - vPadLR * 2 - 6) / (size + 1.1)); /* 卡内正文每行最多全角字数 */
|
||||
D.mods.forEach((mod, mi) => {
|
||||
if (mi > 0) y += 14;
|
||||
T(LX, y, '▍' + mod.title, 14, P.secTitle, 700);
|
||||
y += 30;
|
||||
y += 22; /* 板块标题 → 内容卡顶(09-04:30→22,标题与卡更靠近) */
|
||||
if (mod.kind === 'kv') {
|
||||
/* kv 卡:label 独占行(主题粗体)→ 值折行 ≤2 行;游标自卡顶 vPadT 起步 → 首行文字顶与卡顶留白 vPadT */
|
||||
const rows = mod.rows.filter(([, v]) => String(v).trim());
|
||||
@@ -1420,6 +1427,7 @@ async function renderVideoDetail(view, id) {
|
||||
const videoId = Number(id);
|
||||
let tab = 'script';
|
||||
let vkMode = 'card'; /* R59:拆解 tab 默认展示「视频关键信息卡」,可切换「拆解全文」文案版 */
|
||||
let watchStarted = false; /* R65:本页实例已挂恢复轮询(刷新后执行中状态接管),避免重复轮询 */
|
||||
const d = await api('/api/dsh/video-detail?videoId=' + videoId).catch((e) => ({ ok: false, error: e.message }));
|
||||
if (!d.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '视频不存在')}</div></div>`; bindBack(view); return; }
|
||||
const v = d.video, analysis = d.analysis, parse = d.parse || {};
|
||||
@@ -1431,8 +1439,12 @@ async function renderVideoDetail(view, id) {
|
||||
const scriptMd = parse.content || null;
|
||||
const deconstructMd = parse.analysis ? analysisToMd(parse.analysis) : null;
|
||||
const keyCardHtml = renderVideoKeySvg(parse.analysis, v.video_title, v.account_name); /* R59:关键信息卡(SVG 生成器,拆解维度自动抽取,无则空 → 纯全文) */
|
||||
/* R65:该视频存在未完成解析任务(页面刷新后恢复)→ 按钮渲染「执行中」禁用,轮询到终态才恢复 */
|
||||
const runningRec = readVideoTaskRec(videoId);
|
||||
/* 解析/刷新按钮(09-03 R28):未解析=空态「解析」;已解析=内容右上角「刷新」(同 F5 解析任务,幂等覆盖) */
|
||||
const parseBtn = (mode) => `<button class="btn btn-sm" data-parse="${mode}" title="${mode === 'refresh' ? '重新解析该视频(覆盖更新视频脚本与视频拆解)' : '对该视频做 MCP 解析(产出视频脚本与视频拆解)'}">${svgIcon(mode === 'refresh' ? 'refresh' : 'zap', 14, 'currentColor')} ${mode === 'refresh' ? '刷新' : '解析'}</button>`;
|
||||
const parseBtn = (mode) => runningRec
|
||||
? `<button class="btn btn-sm btn-busy" disabled title="解析任务执行中…(约需 3-5 分钟,完成后自动刷新本页;也可到左侧会话栏查看进度)">${svgIcon('refresh', 14, 'currentColor')} 执行中</button>`
|
||||
: `<button class="btn btn-sm" data-parse="${mode}" title="${mode === 'refresh' ? '重新解析该视频(覆盖更新视频脚本与视频拆解)' : '对该视频做 MCP 解析(产出视频脚本与视频拆解)'}">${svgIcon(mode === 'refresh' ? 'refresh' : 'zap', 14, 'currentColor')} ${mode === 'refresh' ? '刷新' : '解析'}</button>`;
|
||||
const emptyAct = (icon, title, desc) => `<div class="empty-state"><div class="empty-icon">${svgIcon(icon, 40)}</div><div class="empty-title">${title}</div><div class="empty-desc">${desc}</div><div style="margin-top:12px">${parseBtn('parse')}</div></div>`;
|
||||
const tabBtns = [['script', '视频脚本'], ['deconstruct', '视频拆解']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
|
||||
/* R59:拆解 tab 内容区 = 关键信息卡(默认)/ 拆解全文 切换视图(同账号设定卡片交互:卡/文切换钮,无 AI、纯前端抽取) */
|
||||
@@ -1482,12 +1494,49 @@ async function renderVideoDetail(view, id) {
|
||||
if (tv) tv.classList.toggle('vs-hidden', vkMode !== 'text');
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab)
|
||||
/* R65:页面刷新后接管未完成任务状态——按钮已渲染执行中,这里挂终态轮询(每页实例只挂一次),终态后清理记录并 load 恢复按钮 */
|
||||
if (runningRec && !watchStarted) { watchStarted = true; watchVideoParseTask(videoId, runningRec.taskId, load); }
|
||||
}
|
||||
load();
|
||||
}
|
||||
|
||||
/* 视频详情「解析/刷新」确认弹窗(09-03 R28):走 confirmRun + executeTask 单任务链路,
|
||||
prompt=技能功能五 F5(SKILL_HINT_VIDEO_PARSE),任务完成 executeTask 自动刷新本页显示新内容 */
|
||||
prompt=技能功能五 F5(SKILL_HINT_VIDEO_PARSE),任务完成 executeTask 自动刷新本页显示新内容。
|
||||
09-04 R65:任务 id 写 localStorage(wk_video_task_<videoId>)——页面刷新后按钮仍显示「执行中」并恢复轮询,任务真正结束才恢复可点 */
|
||||
const VIDEO_TASK_KEY = (videoId) => 'wk_video_task_' + videoId;
|
||||
function readVideoTaskRec(videoId) {
|
||||
try {
|
||||
const r = JSON.parse(localStorage.getItem(VIDEO_TASK_KEY(videoId)) || 'null');
|
||||
if (r && r.taskId && Date.now() - (r.ts || 0) < 35 * 60 * 1000) return r; /* 超 35 分钟视为陈旧,清理 */
|
||||
if (r) localStorage.removeItem(VIDEO_TASK_KEY(videoId));
|
||||
} catch (e) {}
|
||||
return null;
|
||||
}
|
||||
/* 刷新后恢复轮询:跟随该视频解析任务直到终态 → 清理记录 → 提示并刷新本页按钮态 */
|
||||
async function watchVideoParseTask(videoId, taskId, reload) {
|
||||
let s = null;
|
||||
const deadline = Date.now() + 35 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
|
||||
if (!s || s.state === 'notfound' || s.state === 'done' || s.state === 'error' || s.state === 'review') break;
|
||||
}
|
||||
try { localStorage.removeItem(VIDEO_TASK_KEY(videoId)); } catch (e) {}
|
||||
if (s && s.state === 'done') showToast('✅ 视频解析完成,已自动刷新本页');
|
||||
else if (s && s.state === 'error') showToast('❌ 视频解析失败' + (s.error ? ':' + s.error : '') + '(任务已结束,可重新点击解析)');
|
||||
else if (s && s.state === 'review') showToast('⏸ 任务待确认,请到左侧会话栏确认执行');
|
||||
else if (!s) showToast('⏳ 任务仍在执行,请到左侧会话栏查看进度');
|
||||
if (typeof reload === 'function') reload();
|
||||
}
|
||||
/* 09-04 R69:视频「刷新」= 强制重新获取覆盖(追加进刷新 prompt 的覆盖语义段)。
|
||||
旧实现刷新与解析共用同一条 prompt(SKILL_HINT_VIDEO_PARSE 无覆盖语义)——模型可能"先判断是否要更新"、
|
||||
复用旧记录、甚至用截断/不完整结果覆盖旧完整版本(已发生一次质量事故)。刷新模式必须追加本段:
|
||||
明确 重新抓取 + 整体覆盖 + 防截断兜底 */
|
||||
const VIDEO_REFRESH_OVERRIDE = '\n\n【覆盖刷新模式(用户点击「刷新」触发,非首次解析)】该视频已有历史解析记录,本次操作语义 = 强制重新获取数据并覆盖:\n'
|
||||
+ '1. 禁止判断「是否需更新」、禁止复用/保留旧解析结果——必须重新走完整解析流程(重新 upload_douyin_video 提交链接 → 等待 → short_video_detail 重新查询 content 与 analysis);\n'
|
||||
+ '2. 用本次全新抓取结果整体覆盖:content.json(视频脚本)与 analysis.json(视频拆解)均覆盖写入旧文件/旧记录,不保留、不合并任何旧版本内容;\n'
|
||||
+ '3. 完整性兜底:若查询结果疑似截断/字段缺失/解析未完成,先等待重试直到拿到完整结果再覆盖保存——严禁用不完整/截断结果覆盖旧的完整版本;\n'
|
||||
+ '4. 覆盖保存后照常调用 POST /api/import/account(body {"account":"{达人昵称}"})覆盖入库。';
|
||||
function confirmVideoParse(btn, v, mode) {
|
||||
const isRefresh = mode === 'refresh';
|
||||
confirmRun({
|
||||
@@ -1497,19 +1546,22 @@ function confirmVideoParse(btn, v, mode) {
|
||||
: '将对该视频做 MCP 内容解析,产出「视频脚本」(分镜头表)与「视频拆解」(结构化分析),约需 3-5 分钟,完成后自动刷新。',
|
||||
target: cleanTitle(v.video_title) || '视频',
|
||||
url: v.video_url || '',
|
||||
prompt: `${SKILL_HINT_VIDEO_PARSE}\n待解析视频链接:${v.video_url || ''}\n视频标题:${v.video_title || ''}\n所属达人:${v.account_name || ''}`,
|
||||
prompt: `${SKILL_HINT_VIDEO_PARSE}\n待解析视频链接:${v.video_url || ''}\n视频标题:${v.video_title || ''}\n所属达人:${v.account_name || ''}${isRefresh ? VIDEO_REFRESH_OVERRIDE : ''}`,
|
||||
label: '解析指令',
|
||||
btn,
|
||||
skills: ['mcn-dou-analysis'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R66/R67:任务会话按 configId 注入连接器,否则后台会话无 MCP 工具 */
|
||||
onSubmit: (taskId) => { try { localStorage.setItem(VIDEO_TASK_KEY(v.id), JSON.stringify({ taskId, ts: Date.now() })); } catch (e) {} },
|
||||
onFinish: () => { try { localStorage.removeItem(VIDEO_TASK_KEY(v.id)); } catch (e) {} },
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= AI写脚本 ================= */
|
||||
async function renderRewrites(view, initAccount) { /* v22:initAccount=账号列表 AI脚本列跳转进入,预筛该账号;09-02 v25:AI写脚本=三类脚本中心(参考选题创作/自定义选题创作/ABC融合创作) */
|
||||
async function renderRewrites(view, initAccount) { /* v22:initAccount=账号列表 AI脚本列跳转进入,预筛该账号;09-02 v25:AI写脚本=三类脚本中心(参考选题创作/原创选题创作/ABC融合创作) */
|
||||
try { initAccount = decodeURIComponent(initAccount || ''); } catch (e) { initAccount = ''; } /* hash 路径段 param 未解码,中文账号名需还原(v22 fix) */
|
||||
/* tab 说明:
|
||||
ref 参考选题创作——以视频行为选题来源(有原视频):基于「原视频」创作,脚本保存后挂在该视频下(rewrite_log.video_id>0)
|
||||
custom 自定义选题创作——无原视频:账号列表/详情「AI创作」弹窗等触发,video_id 留空,脚本保存后自动入库归入本列表
|
||||
custom 原创选题创作——无原视频:账号列表/详情「AI创作」弹窗等触发,video_id 留空,脚本保存后自动入库归入本列表
|
||||
abc ABC 融合创作——将同账号多条视频的爆款元素融合成一条新脚本(功能待实现,占位) */
|
||||
const state = {
|
||||
tab: 'ref',
|
||||
@@ -1538,8 +1590,8 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
: '参考选题创作:以库中视频为选题来源(关注账号优先 · 按发布时间倒序),行尾「AI创作」= 基于该视频创作新脚本(完成后自动保存并挂到该视频下)')
|
||||
: (state.tab === 'custom'
|
||||
? (state.scope
|
||||
? `账号「${esc(state.scope)}」· 自定义选题创作:无原视频的脚本(来自账号列表「AI创作」弹窗等),保存后自动入库显示在这里`
|
||||
: '自定义选题创作:无原视频的脚本(来自账号列表 / 账号详情「AI创作」弹窗等),保存后自动入库显示在这里')
|
||||
? `账号「${esc(state.scope)}」· 原创选题创作:无原视频的脚本(来自账号列表「AI创作」弹窗等),保存后自动入库显示在这里`
|
||||
: '原创选题创作:无原视频的脚本(来自账号列表 / 账号详情「AI创作」弹窗等),保存后自动入库显示在这里')
|
||||
: 'ABC 融合创作:将同账号多条视频的爆款元素融合成一条全新脚本。功能开发中,敬请期待。');
|
||||
/* —— ref tab:视频行(参考选题载体) —— */
|
||||
const refRows = items.map((v) => `<tr>
|
||||
@@ -1585,7 +1637,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
} else if (state.tab === 'custom') {
|
||||
bodyHtml = `<div class="table-wrap"><table class="table">
|
||||
<thead><tr><th>账号</th><th>脚本标题</th><th>字数</th><th>创建时间</th><th class="th-act">操作</th></tr></thead>
|
||||
<tbody>${customRows || `<tr><td colspan="5" class="empty">暂无自定义选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里'}</td></tr>`}</tbody>
|
||||
<tbody>${customRows || `<tr><td colspan="5" class="empty">暂无原创选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里 <a class="link" href="#/accounts" title="前往账号列表,用「AI创作」弹窗创作原创选题脚本">→ 去账号列表</a>'}</td></tr>`}</tbody>
|
||||
</table></div>`;
|
||||
} else {
|
||||
bodyHtml = `<div class="empty-state">
|
||||
@@ -1606,7 +1658,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
</div>
|
||||
<div class="rw-tabs">
|
||||
<button class="${tabCls('ref')}" data-tab="ref" title="有原视频:以视频行为选题来源创作,脚本保存后挂在该视频下">参考选题创作<span class="rw-tab-badge">${state.stats.videoScripts}</span></button>
|
||||
<button class="${tabCls('custom')}" data-tab="custom" title="无原视频:账号列表「AI创作」弹窗等创作,脚本保存后自动入库">自定义选题创作<span class="rw-tab-badge">${state.stats.customScripts}</span></button>
|
||||
<button class="${tabCls('custom')}" data-tab="custom" title="无原视频:账号列表「AI创作」弹窗等创作,脚本保存后自动入库">原创选题创作<span class="rw-tab-badge">${state.stats.customScripts}</span></button>
|
||||
<button class="${tabCls('abc')}" data-tab="abc" title="多视频爆款元素融合创作(开发中)">ABC融合创作</button>
|
||||
<div class="toolbar-right" style="margin-left:auto">${toolbarCtrl}</div>
|
||||
</div>
|
||||
@@ -1640,7 +1692,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
if (days) days.addEventListener('change', (e) => { state.custom.days = e.target.value; state.custom.page = 1; load(); });
|
||||
const clear = view.querySelector('[data-scope-clear]');
|
||||
if (clear) clear.addEventListener('click', () => { state.scope = ''; state.ref.search = ''; state.ref.page = 1; state.custom.page = 1; reloadAll(); });
|
||||
/* R58:自定义选题「查看」并入统一 AI 脚本弹窗(openScriptModal rewriteId 分支单条展示),获得删除按钮/诊断面板能力;原 openCustomScriptView 简单弹窗移除 */
|
||||
/* R58:原创选题「查看」并入统一 AI 脚本弹窗(openScriptModal rewriteId 分支单条展示),获得删除按钮/诊断面板能力;原 openCustomScriptView 简单弹窗移除 */
|
||||
view.querySelectorAll('[data-cview]').forEach((b) => b.addEventListener('click', () => openScriptModal({ rewriteId: Number(b.dataset.cview) })));
|
||||
}
|
||||
}
|
||||
@@ -1677,6 +1729,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
: `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。${saveHint}`,
|
||||
label: '创作指令',
|
||||
skills: ['短视频工作台'],
|
||||
connectorIds: MYAI_CONNECTORS, /* R67:未解析视频先 MCP 解析分支需要 myai */
|
||||
btn: b,
|
||||
});
|
||||
}
|
||||
@@ -1688,7 +1741,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
|
||||
load();
|
||||
}
|
||||
|
||||
/* 启动:拉取徽标计数;账号级进入(账号列表「AI脚本」列)时若该账号存在自定义选题脚本 → 默认落在「自定义选题创作」tab(否则「参考选题创作」) */
|
||||
/* 启动:拉取徽标计数;账号级进入(账号列表「AI脚本」列)时若该账号存在原创选题脚本 → 默认落在「原创选题创作」tab(否则「参考选题创作」) */
|
||||
{
|
||||
const st = await api('/api/dsh/rewrite-stats?account=' + encodeURIComponent(state.scope)).catch(() => ({ videoScripts: 0, customScripts: 0 }));
|
||||
state.stats = st;
|
||||
@@ -1829,8 +1882,8 @@ async function renderReviews(view) {
|
||||
布局同 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(自定义选题)
|
||||
—— 分镜内容 = 该视频下 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 = ['创意看点', '设定承接', '框架节奏', '爆款开场', '叙事逻辑', '极致事件', '格式规范性', '台词口语化', '内容合规', '教程完整链', '其他'];
|
||||
/* R46/R47 维度术语改名(09-03):钩子设置→爆款开场、共鸣和共情→创意和爆点(R46,排设定承接前)→创意看点(R47)。
|
||||
@@ -1899,7 +1952,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
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 拒绝则回退全量表定位(该脚本实际挂在视频下) */
|
||||
} 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 }];
|
||||
@@ -1936,7 +1989,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
confirmRun({ title: hasSource ? '复盘评分' : 'AI脚本复盘', desc, prompt, label: '复盘指令', skills: ['脚本复盘'], btn });
|
||||
} finally { delete btn.dataset.lock; }
|
||||
};
|
||||
/* 生成 / 重新生成分镜(仅参考选题脚本;自定义选题无视频载体不提供) */
|
||||
/* 生成 / 重新生成分镜(仅参考选题脚本;原创选题无视频载体不提供) */
|
||||
const fireGenSb = (btn, regenerate) => {
|
||||
confirmRun({
|
||||
title: regenerate ? '重新生成分镜' : '生成分镜',
|
||||
@@ -1997,7 +2050,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
const aiSecHtml = (c) => `<div class="rw-sec-head">AI脚本${srcToggleHtml()}${sideToggleHtml()}</div><div class="rw-sec-body md-body">${renderMarkdown(c.script_text || '(暂无内容)')}</div>
|
||||
<div class="rw-sec-foot">${delScriptBtnHtml(c)}${rewrites.length > 1 ? verCtrlInner('v', verIdx, rewrites.length, '版本', c.created_time) : ''}</div>`;
|
||||
/* R58:删除当前脚本(左下角浅红按钮)——同时删除其诊断复盘与分镜提示词;确认后执行 API,多版本就地移除当前版并刷新,单版本关弹窗并重建页面 */
|
||||
const delScriptBtnHtml = (c) => `<button class="btn btn-sm btn-del" data-del-script title="删除当前脚本(同时删除其诊断复盘与分镜提示词数据,不可恢复)">${svgIcon('trash', 13, 'currentColor')} 删除</button>`;
|
||||
const delScriptBtnHtml = (c) => `<button class="btn btn-sm btn-del" data-del-script title="删除当前脚本(同时删除其诊断复盘与分镜提示词数据,不可恢复)">${svgIcon('trash', 13, 'currentColor')}</button>`;
|
||||
const fireDel = async (btn) => {
|
||||
if (btn.dataset.lock) return; btn.dataset.lock = '1';
|
||||
try {
|
||||
@@ -2059,7 +2112,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
sideBody = '' /* R51:空态提示清空——不再显示「(暂无分镜提示词)」 */;
|
||||
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>';
|
||||
sideBody = '<div class="diag-empty">该脚本为原创选题(无关联视频),暂不支持分镜提示词</div>';
|
||||
sideFoot = '';
|
||||
}
|
||||
return { sideBody, sideFoot };
|
||||
|
||||
@@ -117,9 +117,9 @@
|
||||
},
|
||||
{
|
||||
key: 'rewrites', icon: 'pen', title: 'AI写脚本', route: '#/rewrites',
|
||||
desc: '脚本中心:参考选题创作(有原视频)/ 自定义选题创作(无原视频)/ ABC 融合创作',
|
||||
desc: '脚本中心:参考选题创作(有原视频)/ 原创选题创作(无原视频)/ ABC 融合创作',
|
||||
steps: [
|
||||
{ sel: '.rw-tabs [data-tab="custom"]', mode: 'click', title: '自定义选题创作', text: 'AI写脚本 分三种创作入口,先看「自定义选题创作」:无原视频的脚本(来自账号列表/详情 AI创作弹窗)都收在这里,带徽标计数。' + '\n' + '👉 点「自定义选题创作」tab。' },
|
||||
{ sel: '.rw-tabs [data-tab="custom"]', mode: 'click', title: '原创选题创作', text: 'AI写脚本 分三种创作入口,先看「原创选题创作」:无原视频的脚本(来自账号列表/详情 AI创作弹窗)都收在这里,带徽标计数。' + '\n' + '👉 点「原创选题创作」tab。' },
|
||||
{ sel: '.rw-tabs [data-tab="ref"]', mode: 'click', title: '参考选题创作', text: '「参考选题创作」:以库中视频为选题来源(关注账号优先、按发布时间倒序),每行视频可「AI创作」新脚本,完成后自动挂到该视频下。' + '\n' + '👉 点回「参考选题创作」。' },
|
||||
{ sel: '#rwSearch', mode: 'input', emptyOk: true, title: '搜索视频', text: '按视频标题 / 账号名搜索候选视频,配时间范围与「已解析/未解析」状态过滤。' + '\n' + '👉 输入关键词回车试试,或直接下一步。' },
|
||||
{ sel: '.rw-tabs [data-tab="abc"]', mode: 'info', title: 'ABC 融合创作', text: '「ABC融合创作」:将同账号多条视频的爆款元素融合成一条全新脚本(功能开发中,暂为占位说明页)。' },
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>短视频工作台</title>
|
||||
<link rel="stylesheet" href="style.css?v=20260904h">
|
||||
<link rel="stylesheet" href="style.css?v=20260904i">
|
||||
<script src="vendor/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -41,8 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260904f"></script>
|
||||
<script src="help-guide.js?v=20260904d"></script>
|
||||
<script src="data-pages.js?v=20260904l"></script>
|
||||
<script src="app.js?v=20260904i"></script>
|
||||
<script src="help-guide.js?v=20260904e"></script>
|
||||
<script src="data-pages.js?v=20260904u"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -520,8 +520,8 @@ td.td-title { max-width: 240px; }
|
||||
把 4 列轨道撑成等宽 232.5px、每格右侧大片留白——故将其移出共享网格(见 .account-info > .info-item),8→7 字段列轨贴内容收缩,
|
||||
值与值之间视觉距离恢复为「内容宽+80px」,与视频详情属性区观感一致 */
|
||||
.account-info .account-grid { display: grid; grid-template-columns: repeat(4, minmax(min-content, max-content)); grid-template-areas: 'a b c .' 'd e f g'; gap: 10px 0; justify-content: start; }
|
||||
.account-info .account-grid .info-item { width: 140px; }
|
||||
/* 账号信息属性内容统一宽度(09-03 R48 终版):所有属性值固定 width 140px,四列严格等宽;超宽单行省略号 + hover 看全量;
|
||||
.account-info .account-grid .info-item { width: 180px; }
|
||||
/* 账号信息属性内容统一宽度(09-03 R48 终版,09-04 宽度 140→180):所有属性值固定 width 180px,四列严格等宽;超宽单行省略号 + hover 看全量;
|
||||
横向间隔 0(用户定:内容统一宽度即可,不要其他间隔) */
|
||||
.account-info .account-grid .info-item .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
/* 账号定位整行(R45):作为网格容器的直接子项平级输出,占满卡片宽度,与网格保持纵向间距 */
|
||||
@@ -535,10 +535,10 @@ td.td-title { max-width: 240px; }
|
||||
/* 视频详情信息卡:标题独立一行(大字号),下方按行分组 */
|
||||
/* 视频详情字段对齐(09-02 终版):8 字段同处一个 grid 容器(2 行 × 4 列),列轨道跨行共享——第 i 列严格对齐(分享↔时长、收藏↔发布时间);列宽贴合内容(min-content~max-content,非均分);横向间隔 80px 与账号信息一致。之前用两个独立 .info-row 容器导致跨行不对齐 */
|
||||
.video-info .video-grid { display: grid; grid-template-columns: repeat(4, minmax(min-content, max-content)); gap: 10px 0; justify-content: start; }
|
||||
.video-info .video-grid .info-item { width: 140px; }
|
||||
.video-info .video-grid .info-item { width: 180px; }
|
||||
.video-info .video-grid .info-item.info-wide { grid-column: 1 / -1; width: auto; }
|
||||
/* 视频详情属性内容统一宽度(09-03 R48):与账号详情同款——8 字段固定 width 140px、横向间隔 0;超宽单行省略号 + hover 看全量;
|
||||
标签(info-wide)占整行不受限宽保持换行;原视频ID vid-limited 独立 max-width 220 已被统一 140 覆盖,移除冗余规则 */
|
||||
/* 视频详情属性内容统一宽度(09-03 R48,09-04 宽度 140→180):与账号详情同款——8 字段固定 width 180px、横向间隔 0;超宽单行省略号 + hover 看全量;
|
||||
标签(info-wide)占整行不受限宽保持换行;原视频ID vid-limited 独立 max-width 220 已被统一 180 覆盖,移除冗余规则 */
|
||||
.video-info .video-grid .info-item:not(.info-wide) .info-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
|
||||
/* ================= R59 视频关键信息卡(拆解 tab 默认视图,卡/文切换) ================= */
|
||||
@@ -694,7 +694,7 @@ td.td-title { max-width: 240px; }
|
||||
.cb-cell { width: 36px; text-align: center; }
|
||||
.cb-cell input { width: 15px; height: 15px; cursor: pointer; vertical-align: middle; accent-color: var(--primary); }
|
||||
|
||||
/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 自定义选题创作 / ABC融合创作) ---- */
|
||||
/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 原创选题创作 / ABC融合创作) ---- */
|
||||
/* 标题行(返回|标题)下方独立一行:左=tab 切换,右=搜索/时间/状态筛选(tab 与筛选同一行,均不与标题/返回同行) */
|
||||
.rw-tabs { display: flex; align-items: center; gap: 2px; border-bottom: 1px solid var(--border); margin: -12px 0 12px; padding: 0 4px; }
|
||||
.rw-tabs .tab { font-size: 15px; padding: 9px 14px; }
|
||||
@@ -711,7 +711,7 @@ td.td-title { max-width: 240px; }
|
||||
.rw-chip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.rw-chip button { border: none; background: none; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 3px; border-radius: 50%; }
|
||||
.rw-chip button:hover { opacity: .7; }
|
||||
/* 自定义选题脚本 查看弹窗 */
|
||||
/* 原创选题脚本 查看弹窗 */
|
||||
.cw-modal { width: min(980px, calc(100% - 80px)); height: calc(100vh - 120px); display: flex; flex-direction: column; overflow: hidden; padding: 0; }
|
||||
.cw-modal .modal-title-row { flex: none; padding: 14px 18px; border-bottom: 1px solid var(--border); margin: 0; }
|
||||
.cw-meta { flex: none; padding: 8px 18px; border-bottom: 1px dashed var(--border); color: var(--dim); font-size: 13px; }
|
||||
|
||||
Reference in new issue
Block a user