账号列表重置按钮改更新按钮(⭐已关注批量重抓,确认弹窗+逐账号串行任务);AI写脚本页tab与搜索筛选移至标题下同一行(还原R26误并入标题行);状态列已解析去图标改浅绿

This commit is contained in:
maogeigei committed 2026-09-03 15:48:16 +08:00
1 parent 06df702524
commit 4d5a432595
5 files changed
+187 -17

No files matched your search

@@ -0,0 +1,87 @@
// R27 冒烟 v3:AI写脚本页布局(标题行干净/tab+筛选下行)+ 状态列浅绿 + 账号列表「更新」按钮弹窗
// 注意:hash 不能带 ?query(会被路由当作账号名 scope 过滤)→ 切页用 location.hash
const list = await fetch('http://localhost:9225/json').then((r) => r.json());
const page = list.find((t) => t.type === 'page');
if (!page) { console.log('ERR: no page'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
const waitFor = async (expr, ms = 12000) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { if (await ev(expr)) return true; await sleep(300); } return false; };
const fail = (m) => { console.log('FAIL@' + m); process.exit(1); };
const goto = async (hash) => { await ev(`location.hash = ${JSON.stringify(hash)}`); await sleep(600); };
/* ============ 0) 先加载应用 ============ */
await send('Page.navigate', { url: 'http://localhost:8900/#/' });
if (!(await waitFor(`!!document.querySelector('#view')`))) fail('app boot');
/* ============ 1) AI写脚本页布局 + 状态列 ============ */
await goto('#/rewrites');
if (!(await waitFor(`!!document.querySelector('#view .rw-tabs')`))) fail('rw-tabs render');
if (!(await waitFor(`!!document.querySelector('#view table tbody tr')`))) fail('table rows: ' + JSON.stringify(await ev(`document.querySelector('#view .table-wrap') ? document.querySelector('#view .table-wrap').textContent.slice(0,80) : 'no wrap'`)));
const rw = await ev(`(() => {
const tb = document.querySelector('#view .page-toolbar');
const tabs = document.querySelector('#view .rw-tabs');
const tr = tabs.querySelector('.toolbar-right');
return {
toolbarChildren: [...tb.children].map((c) => (c.className && String(c.className)) || c.tagName),
toolbarHasFilter: !!tb.querySelector('.toolbar-right'),
toolbarHasTabs: !!tb.querySelector('.rw-tabs'),
tabsIsNextSibling: tb.nextElementSibling === tabs,
tabsCount: tabs.querySelectorAll('button[data-tab]').length,
tabsRowRight: !!tr,
rightInputs: tr ? [...tr.querySelectorAll('input,select')].map((x) => x.id) : [],
parsedCells: [...document.querySelectorAll('#view tbody td')].filter((td) => td.textContent.trim() === '已解析').slice(0, 3).map((td) => {
const sp = td.querySelector('span.st-parsed');
return { hasSpan: !!sp, color: sp ? getComputedStyle(sp).color : null };
}),
hasEmojiParsed: document.querySelector('#view tbody').textContent.includes('✅已解析'),
};
})()`);
/* ============ 2) 账号列表「更新」按钮 + 弹窗 ============ */
await goto('#/accounts');
// 全新测试 profile 无 localStorage:应用可能自动弹出「设置产出根目录」弹窗 → 先清掉杂项弹窗,避免误匹配
await ev(`document.querySelectorAll('.modal-mask').forEach((m) => m.remove())`);
if (!(await waitFor(`!!document.querySelector('#accUpdate')`))) fail('accUpdate render');
const acc = await ev(`(() => {
const b = document.querySelector('#accUpdate');
const r = { txt: b.textContent.trim(), hasIcon: !!b.querySelector('svg'), title: b.title };
b.click();
return r;
})()`);
await sleep(300);
if (!(await waitFor(`[...document.querySelectorAll('.modal-mask .modal')].some((m) => m.textContent.includes('更新关注账号'))`))) fail('update modal open');
const md = await ev(`(() => {
const m = [...document.querySelectorAll('.modal-mask .modal')].find((x) => x.textContent.includes('更新关注账号'));
const r = {
title: m.querySelector('h3').textContent.trim(),
target: m.querySelector('.confirm-target') ? m.querySelector('.confirm-target').textContent.trim() : null,
desc: m.querySelector('p.dim').textContent.trim(),
hasCancel: !!m.querySelector('[data-cancel]'),
hasOk: !!m.querySelector('[data-ok]'),
maskCount: document.querySelectorAll('.modal-mask').length,
};
m.querySelector('[data-cancel]').click();
return r;
})()`);
await sleep(400);
const closed = await ev(`![...document.querySelectorAll('.modal-mask')].some((m) => m.textContent.includes('更新关注账号'))`);
console.log(JSON.stringify({ rw, acc, md, closed }, null, 1));
const ok = rw && !rw.__err
&& rw.toolbarHasFilter === false && rw.toolbarHasTabs === false
&& rw.tabsIsNextSibling === true && rw.tabsCount === 3
&& rw.tabsRowRight === true && rw.rightInputs.length >= 2
&& rw.hasEmojiParsed === false
&& rw.parsedCells.length > 0 && rw.parsedCells.every((c) => c.hasSpan && c.color === 'rgb(82, 183, 136)')
&& acc && !acc.__err && acc.txt === '更新' && acc.hasIcon
&& md && !md.__err && md.title.includes('更新关注账号') && md.target.includes('做夢') && md.target.includes('胡德禄') && md.target.includes('旧梦')
&& md.hasCancel && md.hasOk && closed === true;
console.log(ok ? '\n=== PASS ===' : '\n=== CHECK ===');
ws.close();
process.exit(ok ? 0 : 1);
@@ -697,7 +697,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="accReset" title="重置筛选">${svgIcon('refresh', 15, 'currentColor')}</button>
<button class="btn" id="accUpdate" title="重新抓取所有已关注账号(⭐)的最新账号数据与视频列表(后台任务逐个执行)">${svgIcon('refresh', 15, 'currentColor')} 更新</button>
</div>
</div>
<div class="table-wrap">
@@ -730,7 +730,7 @@ async function renderAccounts(view) {
view.querySelector('#accType').addEventListener('change', (e) => { state.type = e.target.value; state.page = 1; load(); });
view.querySelector('#accSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } });
view.querySelector('#accTrack').addEventListener('change', (e) => { state.track = e.target.value; state.page = 1; load(); });
view.querySelector('#accReset').addEventListener('click', () => { state = { type: 'all', search: '', track: '', page: 1, pageSize: 20, batch: false }; load(); });
view.querySelector('#accUpdate').addEventListener('click', () => updateFollowedAccounts(view.querySelector('#accUpdate')));
view.querySelector('#accImport').addEventListener('click', (e) => openImportAccounts(e.currentTarget));
bindPager(view, (p) => { state.page = p; load(); });
bindBatchBar(view, {
@@ -746,6 +746,76 @@ async function renderAccounts(view) {
await load();
}
/* ===== 账号列表「更新」按钮(09-03 R27):重新抓取所有已关注账号的最新账号数据与视频列表 =====
范围 = ⭐ 已关注(favorite=1);后台任务逐账号串行执行(多会话并发会抢浏览器调试实例);
每个任务复用 mcn-dou-analysis 技能流程(SKILL_HINT_ACCOUNT 同款技能加载+红线),任务语义改为「更新已登记账号」:
身份标识(sec_uid/抖音号/地址)一律以本次浏览器抓取为准,禁止沿用库中旧值回填 */
function updateFollowedAccounts(btn) {
(async () => {
try {
const d = await api('/api/dsh/accounts?type=all&search=&track=&page=1&pageSize=100');
const favs = ((d && d.items) || []).filter((a) => a.favorite);
if (!favs.length) { showToast('⭐ 暂无已关注账号(先点击列表中的 ☆ 关注)'); return; }
const names = favs.map((a) => String(a.account_name || '').trim()).filter(Boolean);
if (!names.length) { showToast('已关注账号缺少账号名,无法更新'); return; }
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `
<div class="modal" style="max-width:540px">
<h3>${svgIcon('refresh', 18)} 更新关注账号</h3>
<p class="dim">将重新抓取 ${names.length} 个已关注账号的最新账号数据与视频列表(后台任务逐个执行,每个需数分钟,请耐心等待,可到左侧会话栏查看进度)。确认更新?</p>
<div class="confirm-target">${names.map((n) => esc(n)).join('、')}</div>
<div class="modal-actions">
<button class="ghost" data-cancel>取消</button>
<button class="primary" data-ok>确认更新</button>
</div>
</div>`;
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
mask.querySelector('[data-cancel]').addEventListener('click', close);
mask.querySelector('[data-ok]').addEventListener('click', async () => {
close();
const oldHtml = btn ? btn.innerHTML : '';
const restore = () => { if (btn) { btn.disabled = false; btn.innerHTML = oldHtml; btn.classList.remove('btn-busy'); } };
if (btn) { btn.disabled = true; btn.classList.add('btn-busy'); }
const done = [], failed = [];
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/抖音号/抖音地址)一律以本次浏览器抓取到的为准,禁止沿用库中旧值回填。`;
try {
const r = await api('/api/run', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ prompt, name: '更新账号·' + name, skills: ['mcn-dou-analysis'] }),
});
if (r.error) throw new Error(r.error);
const taskId = r.id;
const deadline = Date.now() + 20 * 60 * 1000;
let state = 'running';
while (Date.now() < deadline) {
await new Promise((res) => setTimeout(res, 5000));
let s = null;
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
if (!s || s.state === 'notfound') { state = 'done'; break; }
if (s.state === 'done') { state = 'done'; break; }
if (s.state === 'error' || s.state === 'review') { state = s.state; break; }
}
if (state === 'done') done.push(name);
else failed.push(name + (state === 'error' ? '(任务失败)' : (state === 'review' ? '(待确认)' : '(超时)')));
} catch (e) { failed.push(name + '(' + e.message + ')'); }
}
restore();
if (btn) btn.blur();
showToast(done.length ? `✅ 已更新 ${done.length} 个关注账号${failed.length ? `;${failed.length} 个未完成:${failed.join('、')}` : ''}` : '❌ 更新未完成:' + failed.join('、'));
refreshCurrentPage();
});
} catch (e) {
showToast('❌ 获取账号列表失败: ' + e.message);
}
})();
}
/* ================= 账号详情 ================= */
async function renderAccountDetail(view, id) {
const accountId = Number(id);
@@ -1077,7 +1147,7 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
<td>${fmtWan(v.share_count)}</td>
<td>${fmtDuration(v.duration)}</td>
<td>${fmt(v.publish_time)}</td>
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
<td>${(v.has_source || v.has_analysis) ? '<span class="st-parsed">已解析</span>' : '未解析'}</td>
<td>${v.rewrite_count > 0 ? `<button class="btn btn-sm btn-view" data-detail="${v.id}" title="查看该视频下的 AI 脚本(源脚本对比/分镜)">${v.rewrite_count} 查看</button>` : '—'}</td>
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id || '', accountId: v.account_id, parsed: !!(v.has_source || v.has_analysis), url: v.video_url || '' }))}'>AI创作</button></td>
</tr>`).join('');
@@ -1129,12 +1199,12 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
<div class="page-toolbar">
${toolbarBack()}
<span class="page-title">AI写脚本</span>
<div class="rw-tabs rw-toolbar-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('abc')}" data-tab="abc" title="多视频爆款元素融合创作(开发中)">ABC融合创作</button>
</div>
<div class="toolbar-right" style="margin-right:10px">${toolbarCtrl}</div>
</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('abc')}" data-tab="abc" title="多视频爆款元素融合创作(开发中)">ABC融合创作</button>
<div class="toolbar-right" style="margin-left:auto">${toolbarCtrl}</div>
</div>
<div class="rw-note">${note}</div>
${bodyHtml}
@@ -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=20260903h">
<link rel="stylesheet" href="style.css?v=20260903j">
<script src="vendor/echarts.min.js"></script>
</head>
<body>
@@ -41,7 +41,7 @@
</div>
</div>
<script src="app.js?v=20260903h"></script>
<script src="data-pages.js?v=20260903h"></script>
<script src="app.js?v=20260903j"></script>
<script src="data-pages.js?v=20260903j"></script>
</body>
</html>
@@ -650,15 +650,14 @@ td.td-title { max-width: 240px; }
.cb-cell input { width: 15px; height: 15px; cursor: pointer; vertical-align: middle; accent-color: var(--primary); }
/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 自定义选题创作 / ABC融合创作) ---- */
.rw-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: -12px 0 12px; padding: 0 4px; }
/* 标题行(返回|标题)下方独立一行:左=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; }
/* AI写脚本页:tab 并入 page-toolbar 同一行(与搜索/筛选同行),去整行分隔条 */
.page-toolbar .rw-tabs { border-bottom: none; margin: 0; padding: 0; align-self: center; }
.page-toolbar .rw-tabs .tab { padding: 4px 10px 3px; font-size: 13px; margin-bottom: 0; }
.page-toolbar .rw-tabs .tab-active { border-bottom: 2px solid var(--primary); }
.rw-tab-badge { display: inline-block; min-width: 18px; margin-left: 6px; padding: 0 6px; border-radius: 10px; background: var(--primary-soft); color: var(--primary); font-size: 12px; line-height: 18px; font-weight: 600; text-align: center; }
.tab-active .rw-tab-badge { background: var(--primary); color: #fff; }
.rw-note { font-size: 12px; color: var(--dim); margin: 0 0 12px; }
/* 视频状态「已解析」:浅绿色文字(去图标),区别于默认灰「未解析」 */
.st-parsed { color: #52b788; font-weight: 500; }
.rw-chip { display: inline-flex; align-items: center; gap: 2px; max-width: 260px; padding: 2px 8px 2px 12px; background: var(--primary-soft); color: var(--primary); border-radius: 999px; font-size: 13px; }
.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%; }