diff --git a/.workbuddy/memory/2026-09-03.md b/.workbuddy/memory/2026-09-03.md index 5a8bfb3..79d6f68 100644 --- a/.workbuddy/memory/2026-09-03.md +++ b/.workbuddy/memory/2026-09-03.md @@ -207,3 +207,17 @@ - data-pages.js:.rw-tabs div 从 toolbar 下独立行移入 .page-toolbar(返回|标题|tabs|toolbar-right 筛选右),加 rw-toolbar-tabs 类;rw-note 留在 tab 行下方表上方 - style.css:.page-toolbar .rw-tabs 行内化(border-bottom none/margin 0/padding 0/center),tab 压缩 13px;tab-active 保留下划线条 - 纯静态改动无需重启;git 待查;版本 ?v=20260903h + +## R27a AI写脚本页布局修正(09-03,R26 返工) +- 用户澄清:R26「tab 与筛选同行」≠ 并入标题行;且追加要求筛选搜索也移下标题行 +- 终局结构:page-toolbar 只留 返回|标题「AI写脚本」;其下 .rw-tabs 单行 = 左三个 tab + 右 toolbar-right(搜索/时间/状态,margin-left:auto),rw-tabs 加 align-items:center +- 还原教训:R26 把 tabs 并入 toolbar 标题行属误读「放一行」,用户两次纠正(先 tab 放下、再筛选也放下) +- 状态列:ref tab「✅已解析」去图标改浅绿文字(.st-parsed #52b788,font-weight 500);「未解析」保持默认 + +## R27 账号列表 重置按钮 → 更新按钮(09-03,口径已确认) +- 口径(AskUserQuestion 确认):范围=⭐ 已关注(favorite=1 共 3 个:做夢/平凡的胡德禄/旧梦留声机);交互=确认弹窗后执行 +- data-pages.js:L700 accReset(纯 refresh 图标)→ accUpdate「refresh 图标 + 更新」;L733 重置绑定 → updateFollowedAccounts(btn) +- 新函数 updateFollowedAccounts(renderAccounts 后,文件级):GET /api/dsh/accounts pageSize=100 → filter favorite → 自建 modal 确认(confirm-target 列名单)→ 逐账号串行 POST /api/run(prompt=SKILL_HINT_ACCOUNT +「更新已登记账号」语义:浏览器重抓账号信息+视频列表覆盖更新、身份标识禁库中旧值回填;name='更新账号·X';skills mcn-dou-analysis)→ 轮询 /api/run/status(每 5s,单任务 20min 上限;done/error/review/notfound/超时 五终态)→ 按钮「更新中 i/N…」→ 汇总 toast + refreshCurrentPage +- 串行理由:多会话并发会抢浏览器 9223 调试实例;executeTask 单按钮封装不适配 N 任务,自写轮询 +- 未验证:首次实点后需核对任务侧能否覆盖更新 hot_accounts 主表(server.js importAccountIntoDb 不写 hot_accounts 粉丝/获赞/作品数——只 upsert 视频解析/人设/视频列表/拆解;技能侧登记链路待实测) +- 纯静态改动无需重启;版本 ?v=20260903j diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r27.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r27.mjs new file mode 100644 index 0000000..9b8ec31 --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-r27.mjs @@ -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); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js index f9e4aad..2fbeacb 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js @@ -697,7 +697,7 @@ async function renderAccounts(view) { - +
@@ -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 = ` + `; + 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=账号 ${fmtWan(v.share_count)} ${fmtDuration(v.duration)} ${fmt(v.publish_time)} - ${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'} + ${(v.has_source || v.has_analysis) ? '已解析' : '未解析'} ${v.rewrite_count > 0 ? `` : '—'} `).join(''); @@ -1129,12 +1199,12 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
${toolbarBack()} AI写脚本 -
- - - -
-
${toolbarCtrl}
+
+
+ + + +
${toolbarCtrl}
${note}
${bodyHtml} diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/index.html b/project/短视频脚本创作/V1.0/mcn-work-shop/public/index.html index 7ba08c0..4b176e8 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/index.html +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/index.html @@ -4,7 +4,7 @@ MCN工作台 - + @@ -41,7 +41,7 @@
- - + + diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css index 8ae8387..d1d9784 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css @@ -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%; }