From b049cca766fa3175cde92591e5c1b3057e8cacc8 Mon Sep 17 00:00:00 2001 From: maogeigei Date: Tue, 1 Sep 2026 14:31:46 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=B7=A5=E4=BD=9C=E5=8F=B0):=20=E4=B8=89?= =?UTF-8?q?=E5=88=97=E8=A1=A8=EF=BC=88=E8=B4=A6=E5=8F=B7/=E8=A7=86?= =?UTF-8?q?=E9=A2=91/AI=E5=86=99=E8=84=9A=E6=9C=AC=EF=BC=89=E6=89=B9?= =?UTF-8?q?=E9=87=8F=E6=93=8D=E4=BD=9C=E2=80=94=E2=80=94=E5=B7=A6=E4=B8=8B?= =?UTF-8?q?=E8=A7=92=E5=9B=BE=E6=A0=87=E6=8C=89=E9=92=AE+=E5=8B=BE?= =?UTF-8?q?=E9=80=89=E6=89=B9=E9=87=8F=E5=88=A0=E9=99=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 后端: dsh-data.js batchDelete(kind,ids)——accounts 软删(del_flag=1)+连坐清 persona/analysis/videos/source/analysis/rewrite_log; videos|rewrites 删视频行+关联(video_id 与 aweme_id 双键,兼容 video_id=null 历史行); server.js POST /api/dsh/batch-delete(kind 校验+空 ids 幂等) - 前端: data-pages.js batchBarHtml/bindBatchBar + 三列表勾选列+左下角批量操作条; 图标版——app.js 新增 check-square/trash 图标, 批量模式图标右侧出 trash/x+全选+计数; style.css 批量样式+--danger - 修复 P0: 账号连坐删除漏删 account_videos 行(孤儿残留), 已补并复验 - 验证: API 实测(删测试账号/视频连坐全清+非法kind+空ids) + CDP UI 33项PASS + 端到端删除 PASS(cdp-verify-batch.mjs / cdp-verify-batch-e2e.mjs) --- .workbuddy/memory/2026-09-01.md | 8 ++ .workbuddy/memory/MEMORY.md | 1 + mcn-plugin.db | 0 .../mcn-work-shop/cdp-verify-batch-e2e.mjs | 91 ++++++++++++ .../V1.0/mcn-work-shop/cdp-verify-batch.mjs | 135 ++++++++++++++++++ .../V1.0/mcn-work-shop/dsh-data.js | 47 +++++- .../V1.0/mcn-work-shop/mcn-plugin.db | Bin 4247552 -> 4247552 bytes .../V1.0/mcn-work-shop/public/app.js | 2 + .../V1.0/mcn-work-shop/public/data-pages.js | 89 ++++++++++-- .../V1.0/mcn-work-shop/public/style.css | 12 ++ .../V1.0/mcn-work-shop/server.js | 14 ++ 11 files changed, 390 insertions(+), 9 deletions(-) create mode 100644 mcn-plugin.db create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-batch-e2e.mjs create mode 100644 project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-batch.mjs diff --git a/.workbuddy/memory/2026-09-01.md b/.workbuddy/memory/2026-09-01.md index f601cb1..fa0ed3f 100644 --- a/.workbuddy/memory/2026-09-01.md +++ b/.workbuddy/memory/2026-09-01.md @@ -196,3 +196,11 @@ - **实现(server.js 启动段)**:`cleanupExpiredSessions()` 软删除(置 deleted_at)workbuddy.db sessions 表——cwd LIKE '%mcn-workshop%'(与 /api/run cwd 常量一致)+ status='completed'(working 执行中绝不删)+ deleted_at IS NULL + COALESCE(last_activity_at, updated_at, created_at, 0) < now-6h;启动时执行一次 + setInterval 每小时自动检查;删除条数 log 输出 - **字段判定依据**:sessions 表 cwd 区分空间(mcn-workshop 10 条);时间用 last_activity_at(最后活动)最准——442280f0 的 updated_at 曾被系统触碰(3h 前)但 last_activity 实为 14.1h 前,故以 last_activity 优先 - **实测**:重启服务 → 清除 9 条(13.6h~114.8h)保留 1 条(俊希创作 1.5h)working 零误删 + +## 09-01 14:3x 三列表批量操作(用户:账号列表/视频列表/AI写脚本列表 左下角批量操作,图标按钮,勾选批量删除) +- **需求**:三列表左下角批量操作(图标),点击后每行出现勾选框,勾选后批量删除 +- **后端**:dsh-data.js 新增 `batchDelete(kind, ids)`——accounts=软删(del_flag=1)+连坐清 persona/analysis/videos/source/analysis/rewrite_log;videos|rewrites=删视频行+关联(video_id 与 aweme_id 双键,因 source/analysis 有 video_id=null 行);server.js 新增 `POST /api/dsh/batch-delete`(kind 校验+空 ids 幂等) +- **前端**:data-pages.js 新增 batchBarHtml/bindBatchBar helpers + 三列表插入勾选列(cb-cell)+ 左下角批量操作条;**图标版**(用户要求)——app.js ICON_PATHS 新增 check-square(批量操作入口)/trash(删除所选),取消用 x;批量模式下图标右侧出现 trash/x 图标 + 全选 + 已选计数;style.css 加 .list-batchbar/.btn-danger/.cb-cell 等 + --danger 变量 +- **发现并修复 P0 bug**:账号连坐删除漏删 account_videos 行(只删了关联表,孤儿视频残留)——补 `DELETE FROM account_videos WHERE account_id IN (...)`,用测试数据复验全 0 残留 +- **验证三层闭环**:①API 实测(删测试账号 1632/1634+视频 682:deleted=1、连坐全清、非法 kind 400、空 ids 幂等)②CDP UI 33 项 PASS(cdp-verify-batch.mjs:三列表批量图标/勾选列/trash/x/全选/计数/取消恢复,纯图标无文字)③端到端删除 PASS(cdp-verify-batch-e2e.mjs:UI 勾选→confirm→API→列表刷新→测试行消失+库零残留;测试视频置顶技巧:publish_time='2099-01-01' 排第一页) +- **沙箱代理发现(重要)**:Bash 会话环境有 HTTP_PROXY/HTTPS_PROXY=http://127.0.0.1:7505(sandbox-cli.exe 监听,WorkBuddy 沙箱运行时注入,非用户系统配置)——curl localhost 会走代理转发失败(502/连接拒绝)→ **curl 工作台 API 必须加 --noproxy '*'** diff --git a/.workbuddy/memory/MEMORY.md b/.workbuddy/memory/MEMORY.md index a1233a1..15549a5 100644 --- a/.workbuddy/memory/MEMORY.md +++ b/.workbuddy/memory/MEMORY.md @@ -42,6 +42,7 @@ - **技能版本标签(08-26)**:frontmatter 存 `updated_at`+`last_change`+`deployment`;源=`source`+`created_at`+`updated_at`,副本=`dsh`+`updated_at`+`deployed_at`;副本 deployed_at<源updated_at=滞后 - **.dsh 单向只读(08-22 强约束)**:`.dsh` 副本只能「读取→回流源技能」,禁止反向写回(除非用户明确授权);允许1处环境差异 - **git(09-01 改)**:**2 小时 push 一次**(不要改一点就推;2026-08-31 原定「半天一次」,09-01 用户改为 2 小时);本地可随时 commit,push 攒到 2 小时统一推;push 前先 fetch 确认无分叉;Windows 下 PowerShell Stop-Process 关后台 node;全局代理 socks5 会破坏 git 连接需清空 +- **沙箱代理(09-01)**:Bash 会话环境有 `HTTP_PROXY/HTTPS_PROXY=http://127.0.0.1:7505`(sandbox-cli.exe 监听,WorkBuddy 沙箱注入,非用户系统配置)——**curl 本地 localhost 服务必须加 `--noproxy '*'`**,否则走代理转发失败(502/连接拒绝);工作台服务用 `node server.js` + Bash run_in_background 启动(`(cmd &)` 子 shell 方式进程会被回收) - **生成网页必参考设计技能(08-27 强约束)**:先读 `.workbuddy/skills/` 下 taste-skill 与 impeccable 再动手 ## 历史事件(摘要) diff --git a/mcn-plugin.db b/mcn-plugin.db new file mode 100644 index 0000000..e69de29 diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-batch-e2e.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-batch-e2e.mjs new file mode 100644 index 0000000..f48d07f --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-batch-e2e.mjs @@ -0,0 +1,91 @@ +/* cdp-verify-batch-e2e.mjs — 批量删除端到端验证(UI 点击删除 → confirm → API → 列表刷新 → 库清理) + * 用测试视频(标题 __batch_e2e__)走完整链路,删除后验证库中无残留 + * 方法:CDP 9223 → 8900 标签页 → #/account/3 视频列表 → 批量模式 → 勾选测试视频 → hook confirm 返回 true → 点删除 + * 运行:node cdp-verify-batch-e2e.mjs + */ +const CDP = 'http://127.0.0.1:9223'; +async function getWs(targetId) { + const tabs = await (await fetch(CDP + '/json')).json(); + const t = tabs.find((x) => x.id === targetId); + if (!t) throw new Error('target not found: ' + targetId); + return t.webSocketDebuggerUrl; +} +function cdp(ws, method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++cdp._id; + const w = new WebSocket(ws); + const timer = setTimeout(() => { try { w.close(); } catch (e) {} reject(new Error('timeout: ' + method)); }, 30000); + w.onopen = () => w.send(JSON.stringify({ id, method, params })); + w.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.id !== id) return; + clearTimeout(timer); + try { w.close(); } catch (e) {} + m.error ? reject(new Error(method + ': ' + JSON.stringify(m.error))) : resolve(m.result); + }; + w.onerror = () => { clearTimeout(timer); reject(new Error('ws error: ' + method)); }; + }); +} +cdp._id = 0; +async function evalJs(ws, expr) { + const r = await cdp(ws, 'Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.exceptionDetails) throw new Error('eval err: ' + JSON.stringify(r.exceptionDetails).slice(0, 300)); + return r.result && r.result.value; +} +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); +async function waitFor(ws, expr, timeout = 40000, label = '') { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + try { if (await evalJs(ws, expr)) return true; } catch (e) {} + await sleep(800); + } + throw new Error('waitFor timeout: ' + label + ' -> ' + expr); +} + +async function main() { + const tabs = await (await fetch(CDP + '/json')).json(); + const wb = tabs.find((t) => t.url.includes('localhost:8900') || t.url.includes('127.0.0.1:8900')); + if (!wb) throw new Error('未找到 8900 工作台标签页'); + const ws = await getWs(wb.id); + console.log('▶ 复用标签页:', wb.url); + + // 进账号详情视频列表(测试视频挂在俊希 id=3 下) + await evalJs(ws, `location.hash = '#/account/3'`); + await cdp(ws, 'Page.reload', { ignoreCache: true }); + await waitFor(ws, `!!document.querySelector('tbody tr .td-id a')`, 40000, '视频列表渲染'); + await sleep(400); + + // 进批量模式 + await evalJs(ws, `document.querySelector('[data-batch-open]').click()`); + await waitFor(ws, `!!document.querySelector('.batch-cb')`, 20000, '批量模式'); + await sleep(300); + + // 定位测试视频行(标题 __batch_e2e__)并勾选 + const found = await evalJs(ws, `(() => { + const rows = [...document.querySelectorAll('tbody tr')]; + const tr = rows.find((r) => r.textContent.includes('__batch_e2e__')); + if (!tr) return false; + tr.querySelector('.batch-cb').click(); + return true; + })()`); + if (!found) throw new Error('未找到测试视频行 __batch_e2e__'); + await sleep(200); + const cnt = await evalJs(ws, `document.querySelector('.batch-count-n').textContent`); + console.log('勾选测试视频后计数:', cnt); + if (cnt !== '1') throw new Error('计数异常: ' + cnt); + + // hook confirm 返回 true,点击删除所选 + await evalJs(ws, `window.confirm = () => true; true`); + await evalJs(ws, `document.querySelector('[data-batch-del]').click()`); + // 等待 toast 出现(删除完成提示)或列表刷新后批量模式退出 + await sleep(1500); + const after = await evalJs(ws, `(() => ({ + hasCbs: document.querySelectorAll('.batch-cb').length > 0, // 批量模式应退出(false) + hasOpen: !!document.querySelector('[data-batch-open]'), // 恢复常态图标 + hasTestRow: [...document.querySelectorAll('tbody tr')].some((r) => r.textContent.includes('__batch_e2e__')), // 测试行应消失 + }))()`); + console.log('删除后状态:', JSON.stringify(after)); + console.log((!after.hasCbs && after.hasOpen && !after.hasTestRow) ? '✅ 端到端删除 PASS(批量模式退出+测试行消失)' : '❌ FAIL'); + process.exit(after.hasCbs || !after.hasOpen || after.hasTestRow ? 1 : 0); +} +main().catch((e) => { console.error('脚本异常:', e.message); process.exit(1); }); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-batch.mjs b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-batch.mjs new file mode 100644 index 0000000..e3b83d1 --- /dev/null +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/cdp-verify-batch.mjs @@ -0,0 +1,135 @@ +/* cdp-verify-batch.mjs — 验证三列表(账号列表/视频列表/AI写脚本列表)批量操作 UI(09-01) + * 覆盖点:①左下角「批量操作」图标(check-square)存在 + * ②点击后每行出现勾选框 + 删除(trash)/取消(x) 图标 + 全选 + 计数 + * ③勾选计数正确、全选正确、取消恢复常态 + * 方法:CDP 9223 → 复用 8900 标签页 → 模拟点击(不真实调用删除 API) + * 运行:node cdp-verify-batch.mjs + */ +const CDP = 'http://127.0.0.1:9223'; + +async function getWs(targetId) { + const tabs = await (await fetch(CDP + '/json')).json(); + const t = tabs.find((x) => x.id === targetId); + if (!t) throw new Error('target not found: ' + targetId); + return t.webSocketDebuggerUrl; +} +function cdp(ws, method, params = {}) { + return new Promise((resolve, reject) => { + const id = ++cdp._id; + const w = new WebSocket(ws); + const timer = setTimeout(() => { try { w.close(); } catch (e) {} reject(new Error('timeout: ' + method)); }, 30000); + w.onopen = () => w.send(JSON.stringify({ id, method, params })); + w.onmessage = (ev) => { + const m = JSON.parse(ev.data); + if (m.id !== id) return; + clearTimeout(timer); + try { w.close(); } catch (e) {} + m.error ? reject(new Error(method + ': ' + JSON.stringify(m.error))) : resolve(m.result); + }; + w.onerror = () => { clearTimeout(timer); reject(new Error('ws error: ' + method)); }; + }); +} +cdp._id = 0; + +async function evalJs(ws, expr) { + const r = await cdp(ws, 'Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); + if (r.exceptionDetails) throw new Error('eval err: ' + JSON.stringify(r.exceptionDetails).slice(0, 300)); + return r.result && r.result.value; +} + +const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); + +async function waitFor(ws, expr, timeout = 40000, label = '') { + const t0 = Date.now(); + while (Date.now() - t0 < timeout) { + try { if (await evalJs(ws, expr)) return true; } catch (e) {} + await sleep(800); + } + throw new Error('waitFor timeout: ' + label + ' -> ' + expr); +} + +let pass = 0, fail = 0; +function ok(cond, label) { + if (cond) { pass++; console.log(' ✅ ' + label); } + else { fail++; console.log(' ❌ FAIL: ' + label); } +} + +// 对单个列表页执行批量操作 UI 全链路验证 +async function verifyList(ws, hash, label, rowSelector) { + console.log(`\n【${label}】 ${hash}`); + await evalJs(ws, `location.hash = '${hash}'`); + await cdp(ws, 'Page.reload', { ignoreCache: true }); + await waitFor(ws, `!!document.querySelector('${rowSelector}')`, 40000, label + ' 列表渲染'); + await sleep(400); + + // ① 常态:左下角批量操作图标按钮存在(含 svg 图标) + const init = await evalJs(ws, `(() => { + const bar = document.querySelector('.list-batchbar'); + const btn = bar && bar.querySelector('[data-batch-open]'); + return { hasBar: !!bar, hasBtn: !!btn, hasIcon: !!(btn && btn.querySelector('.ic')), btnText: btn ? btn.textContent.trim() : '' }; + })()`); + ok(init.hasBar && init.hasBtn, `左下角批量操作条存在`); + ok(init.hasIcon, `批量操作按钮为图标(非文字,文本="${init.btnText}")`); + ok(init.btnText.trim() === '', `按钮无文字残留(纯图标)`); + + // ② 点击批量操作图标 → 进入批量模式 + await evalJs(ws, `document.querySelector('[data-batch-open]').click()`); + await waitFor(ws, `!!document.querySelector('.batch-cb-all')`, 20000, label + ' 批量模式进入'); + await sleep(300); + const mode = await evalJs(ws, `(() => { + const rows = document.querySelectorAll('tbody tr .batch-cb').length; + const delBtn = document.querySelector('[data-batch-del]'); + const cancelBtn = document.querySelector('[data-batch-cancel]'); + return { + rowCbs: rows, + hasDel: !!delBtn, delIcon: !!(delBtn && delBtn.querySelector('.ic')), + hasCancel: !!cancelBtn, cancelIcon: !!(cancelBtn && cancelBtn.querySelector('.ic')), + hasAll: !!document.querySelector('.batch-cb-all'), + delText: delBtn ? delBtn.textContent.trim() : '', + }; + })()`); + ok(mode.rowCbs > 0, `每行出现勾选框(${mode.rowCbs} 行)`); + ok(mode.hasDel && mode.delIcon, `删除按钮为图标(trash)`); + ok(mode.hasCancel && mode.cancelIcon, `取消按钮为图标(x)`); + ok(mode.hasAll, `表头全选框存在`); + ok(mode.delText === '', `删除按钮无文字残留(纯图标)`); + + // ③ 勾选 2 行 → 计数更新 + await evalJs(ws, `(() => { const cbs = document.querySelectorAll('.batch-cb'); cbs[0].click(); cbs[1].click(); return true; })()`); + await sleep(200); + const cnt = await evalJs(ws, `document.querySelector('.batch-count-n').textContent`); + ok(cnt === '2', `勾选 2 行计数显示 2(实际 ${cnt})`); + + // ④ 全选 → 计数=行数 + await evalJs(ws, `document.querySelector('.batch-cb-all').click()`); + await sleep(200); + const cntAll = await evalJs(ws, `document.querySelector('.batch-count-n').textContent`); + const totalRows = await evalJs(ws, `document.querySelectorAll('.batch-cb').length`); + ok(cntAll === String(totalRows), `全选计数=行数 ${totalRows}(实际 ${cntAll})`); + + // ⑤ 取消 → 恢复常态(无勾选框) + await evalJs(ws, `document.querySelector('[data-batch-cancel]').click()`); + await sleep(400); + const back = await evalJs(ws, `(() => { + const bar = document.querySelector('.list-batchbar'); + return { hasCbs: document.querySelectorAll('.batch-cb').length > 0, hasOpen: !!(bar && bar.querySelector('[data-batch-open]')) }; + })()`); + ok(!back.hasCbs && back.hasOpen, `取消后恢复常态(勾选框消失,批量操作图标回归)`); +} + +async function main() { + const tabs = await (await fetch(CDP + '/json')).json(); + const wb = tabs.find((t) => t.url.includes('localhost:8900') || t.url.includes('127.0.0.1:8900')); + if (!wb) throw new Error('未找到 8900 工作台标签页,请先在内置浏览器打开工作台'); + const ws = await getWs(wb.id); + console.log('▶ 复用标签页:', wb.url); + + await verifyList(ws, '#/accounts', '账号列表', 'tbody tr a.link'); + await verifyList(ws, '#/account/3', '账号详情-视频列表(俊希)', 'tbody tr .td-id a'); + await verifyList(ws, '#/rewrites', 'AI写脚本列表', 'tbody tr [data-rewrite]'); + + console.log(`\n==== ${fail === 0 ? '全部 PASS' : '存在 FAIL ' + fail + ' 项'}(PASS ${pass} / FAIL ${fail})====`); + process.exit(fail === 0 ? 0 : 1); +} + +main().catch((e) => { console.error('脚本异常:', e.message); process.exit(1); }); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js b/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js index 91f3eb5..2af3efc 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js @@ -42,6 +42,51 @@ function setFavorite(accountId, fav) { const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; }; const fmt = (v) => (v == null || v === '') ? null : v; +// 批量删除:kind=accounts(软删账号+连坐清理)/ videos | rewrites(删视频行+关联) +// ids: 主键 id 数组;返回 { deleted: 实际删除条数 } +// 连坐范围:账号 → persona/analysis/videos(含 source/analysis/rewrite_log);视频 → source/analysis/rewrite_log +// 注意 source/analysis 存在 video_id=null 的行(俊希等历史数据),须同时按 aweme_id 双键删 +function batchDelete(kind, ids) { + if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用'); + const clean = (Array.isArray(ids) ? ids : []).map(Number).filter((n) => Number.isFinite(n) && n > 0); + if (clean.length === 0) return { deleted: 0 }; + const q = clean.map(() => '?').join(','); + const w = new DatabaseSync(DSH_DB); + try { + if (kind === 'accounts') { + w.prepare(`DELETE FROM account_persona WHERE account_id IN (${q})`).run(...clean); + w.prepare(`DELETE FROM account_analysis WHERE account_id IN (${q})`).run(...clean); + deleteVideosDeep(w, w.prepare(`SELECT id, aweme_id FROM account_videos WHERE account_id IN (${q})`).all(...clean)); + w.prepare(`DELETE FROM account_videos WHERE account_id IN (${q})`).run(...clean); + w.prepare(`DELETE FROM rewrite_log WHERE account_id IN (${q})`).run(...clean); + const r = w.prepare(`UPDATE hot_accounts SET del_flag=1 WHERE id IN (${q}) AND del_flag=0`).run(...clean); + return { deleted: Number(r.changes) }; + } + // videos | rewrites:删视频行 + 关联解析/脚本(按 video_id 与 aweme_id 双键) + deleteVideosDeep(w, w.prepare(`SELECT id, aweme_id FROM account_videos WHERE id IN (${q})`).all(...clean)); + const r = w.prepare(`DELETE FROM account_videos WHERE id IN (${q})`).run(...clean); + return { deleted: Number(r.changes) }; + } finally { + w.close(); + } +} + +function deleteVideosDeep(w, vids) { + if (!vids.length) return; + const ids = vids.map((v) => v.id); + const awemes = vids.map((v) => v.aweme_id).filter(Boolean); + const qi = ids.map(() => '?').join(','); + w.prepare(`DELETE FROM account_video_source WHERE video_id IN (${qi})`).run(...ids); + w.prepare(`DELETE FROM account_video_analysis WHERE video_id IN (${qi})`).run(...ids); + w.prepare(`DELETE FROM rewrite_log WHERE video_id IN (${qi})`).run(...ids); + if (awemes.length) { + const qa = awemes.map(() => '?').join(','); + w.prepare(`DELETE FROM account_video_source WHERE aweme_id IN (${qa})`).run(...awemes); + w.prepare(`DELETE FROM account_video_analysis WHERE aweme_id IN (${qa})`).run(...awemes); + w.prepare(`DELETE FROM rewrite_log WHERE aweme_id IN (${qa})`).run(...awemes); + } +} + /* ================= 账号 ================= */ function listTracks() { @@ -327,7 +372,7 @@ function getRanking({ date = '', category = '', sortBy = 'rank', order = '', pag } module.exports = { - dbAvailable, setFavorite, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis, + dbAvailable, setFavorite, batchDelete, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis, listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts, getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR, }; diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/mcn-plugin.db b/project/短视频脚本创作/V1.0/mcn-work-shop/mcn-plugin.db index 522f7807dfd790e68c22a9f7bb7d868259da6372..41083316683bcd4b44172f31da9cbb9f1a28ea6b 100644 GIT binary patch delta 1504 zcma)+O>7%Q6vt<0Kke*?oiE$5op^(b3AFBdZ6|U08q=0aAc08_JpfsI)2M(@a%jqh zR*e*8%tw$?C21vSRaBIqL`Vp^bRsw)5aLv!_R<4~svHP8Au3Q%RUz^G5#kytVrPHW zc;3AKzIprJPJcXur-At}$Ie9#a_o!ZwLVF*tD_Q$y=OchG7}ox5NWEHFEdT|Hj)KKc5IiSgr-YiyxlV_t%}+L#wF ztiOmyFz#e(0l%YrexB_|h`TczWS2(qgee{}?i*Vbf6)+G-z@&g8|V-r10M+*{Bmjm zsRcD#+mcWH0MVSojyD{_62hiGAQe+X!^3taof{l_`;6mEIZnB75d%CcTok^V5wtDs zb|s%WDzH*qJmayubs2R1foNlU9Q{=0umqt3iBmBS6xB;R6Gw+ z|0wkP1RTXuX1o?=Zgh$8ZOlU-@M~(2>17U|=`q$8Mo?O|LBg~bfS2lSse}l=EdCyqv6(U~# zCX&LU!r#=t*QV8DZMx^SvMj&CZJ_H5`P66Z`tMPZXNN6wj%Tm_75zk^zFz9paQV~7M2K$`Pi{L=AH#9F1$Pz&~I;3S9lFeM2}9-(A_l1C|djFP>S y?4u+}$$m;KN>Y^Al%y%iP?Du&kdh%vhAGKWa)6S@DS3jDCnF(rEPm+(|VDB(3 zOZGiVh9A!jUu4smdQ-9C)ZoK!{kM|$pQPerY${DIiY}C78N^bhXiAZi#on>+7Jt;` z8;tsVTH>|O&AfW@%5bVH%%+u_hU})_ZVaTJjh{Jjs#yssn}|)R!>A~sgjVL1ZA{_@+4+T1XoAMD#xMXyFa`X%wvPXl)02AU?td_Xnf570*4)7;W@guORj zlI3kjm*#dkk2ak>-PGFBa6$=L>< zS(@D8PN|DKEpmgN7NV7+!|Sn6muxy;Nwur!OvkMhfN$db137mg~L`3', 'arrow-left': '', 'x': '', + 'check-square': '', + 'trash': '', 'zap': '', 'user-plus': '', 'download': '', 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 bb40552..9419e36 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js @@ -97,6 +97,54 @@ function bindBack(container) { })); } +/* ===== 列表批量操作(账号列表 / 视频列表 / AI写脚本列表 左下角)===== */ +// 常态=左下角「批量操作」图标(勾选框);点击后 图标右侧出现「删除所选」图标 与「取消」图标 +function batchBarHtml(state, selected) { + if (!state.batch) return `
`; + return `
+ + + 已选 ${selected} 项 + + +
`; +} +function batchCbCount(container) { + return container.querySelectorAll('.batch-cb:checked').length; +} +function bindBatchBar(container, opts) { + const { state, kind, getSelected, confirmMsg, reload } = opts; + container.querySelectorAll('[data-batch-open]').forEach((b) => b.addEventListener('click', () => { state.batch = !state.batch; reload(); })); + container.querySelectorAll('[data-batch-cancel]').forEach((b) => b.addEventListener('click', () => { state.batch = false; reload(); })); + const allCb = container.querySelector('.batch-cb-all'); + if (allCb) allCb.addEventListener('change', () => { + container.querySelectorAll('.batch-cb').forEach((c) => { c.checked = allCb.checked; }); + const n = batchCbCount(container); + const el = container.querySelector('.batch-count-n'); + if (el) el.textContent = n; + }); + container.querySelectorAll('.batch-cb').forEach((c) => c.addEventListener('change', () => { + const el = container.querySelector('.batch-count-n'); + if (el) el.textContent = batchCbCount(container); + })); + container.querySelectorAll('[data-batch-del]').forEach((b) => b.addEventListener('click', async () => { + const ids = getSelected(); + if (!ids.length) { showToast('请先勾选要删除的项'); return; } + if (!confirm(confirmMsg.replace('{n}', ids.length))) return; + b.disabled = true; + try { + const r = await api('/api/dsh/batch-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ kind, ids }) }); + if (r.error) throw new Error(r.error); + showToast(`✅ 已删除 ${r.deleted || ids.length} 项`); + state.batch = false; + reload(); + } catch (e) { + showToast('❌ 删除失败: ' + e.message); + b.disabled = false; + } + })); +} + /* AI写脚本 确认弹窗:按账号是否有设定动态文案;选题输入区(手输 / AI 结合人设生成)→ 确认后执行创作任务 */ async function confirmAiScript(accountName, btn) { // 判断账号是否有有效设定(persona 非空且非测试占位):有 →「基于当前账号设定」,无 →「先进行账号设定分析」 @@ -230,12 +278,13 @@ async function renderRanking(view) { /* ================= 账号列表 ================= */ async function renderAccounts(view) { - let state = { type: 'all', search: '', track: '', page: 1, pageSize: 20 }; + let state = { type: 'all', search: '', track: '', page: 1, pageSize: 20, batch: false }; const tracks = (await api('/api/dsh/tracks').catch(() => ({ tracks: [] }))).tracks || []; async function load() { const d = await api('/api/dsh/accounts?type=' + state.type + '&search=' + encodeURIComponent(state.search) + '&track=' + encodeURIComponent(state.track) + '&page=' + state.page + '&pageSize=' + state.pageSize); const badge = (t) => `${t === 'external' ? '外部' : '内部'}`; const rows = (d.items || []).map((a) => ` + ${state.batch ? `` : ''} ${badge(a.account_type)} ${esc(a.account_name)} @@ -268,10 +317,11 @@ async function renderAccounts(view) {
- - ${rows || ''} + ${state.batch ? '' : ''} + ${rows || ``}
关注类型账号名赛道AI制作粉丝获赞作品数地区简介视频数操作
暂无账号数据
关注类型账号名赛道AI制作粉丝获赞作品数地区简介视频数操作
暂无账号数据
+ ${batchBarHtml(state, 0)} ${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })} `; bindBack(view); @@ -293,9 +343,15 @@ 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 }; load(); }); + view.querySelector('#accReset').addEventListener('click', () => { state = { type: 'all', search: '', track: '', page: 1, pageSize: 20, batch: false }; load(); }); view.querySelector('#accImport').addEventListener('click', (e) => openImportAccounts(e.currentTarget)); bindPager(view, (p) => { state.page = p; load(); }); + bindBatchBar(view, { + state, kind: 'accounts', + getSelected: () => [...view.querySelectorAll('.batch-cb:checked')].map((c) => Number(c.dataset.id)), + confirmMsg: '将删除选中的 {n} 个账号及其视频数据、账号设定、数据分析、AI脚本(不可恢复)。确定删除?', + reload: load, + }); view.querySelectorAll('[data-create]').forEach((b) => b.addEventListener('click', () => { confirmAiScript(b.dataset.create, b); })); @@ -307,6 +363,7 @@ async function renderAccounts(view) { async function renderAccountDetail(view, id) { const accountId = Number(id); let page = 1, tab = 'info'; + const bstate = { batch: false }; const d = await api('/api/dsh/account-videos?accountId=' + accountId + '&page=1&pageSize=20').catch((e) => ({ ok: false, error: e.message })); if (!d.ok) { view.innerHTML = `
${toolbarBack()}
${esc(d.error || '账号不存在')}
`; bindBack(view); return; } const acc = d.account; @@ -330,6 +387,7 @@ async function renderAccountDetail(view, id) { const dv = await api('/api/dsh/account-videos?accountId=' + accountId + '&page=' + page + '&pageSize=20'); const infoGrid = (label, val, wide, raw) => `
${label}
${raw ? val : esc(fmt(val))}
`; const rows = (dv.items || []).map((v) => ` + ${bstate.batch ? `` : ''} ${fmt(v.publish_time)} ${esc(v.aweme_id || '-')} ${v.video_url ? `${esc(cleanTitle(v.video_title) || '-')}` : esc(cleanTitle(v.video_title) || '-')} @@ -353,7 +411,8 @@ async function renderAccountDetail(view, id) {
${infoGrid('账号定位', contentHtml, true, true)}

视频列表

-
${rows || ''}
发布时间视频ID视频标题点赞评论分享时长操作
暂无视频数据
+
${bstate.batch ? '' : ''}${rows || ``}
发布时间视频ID视频标题点赞评论分享时长操作
暂无视频数据
+ ${batchBarHtml(bstate, 0)} ${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}`; } else if (tab === 'persona') { if (!persona) { @@ -397,6 +456,12 @@ async function renderAccountDetail(view, id) { load(); })); bindPager(view, (p) => { page = p; load(); }); + bindBatchBar(view, { + state: bstate, kind: 'videos', + getSelected: () => [...view.querySelectorAll('.batch-cb:checked')].map((c) => Number(c.dataset.id)), + confirmMsg: '将删除选中的 {n} 条视频(含视频解析、AI脚本,不可恢复)。确定删除?', + reload: load, + }); view.querySelectorAll('[data-clamp-toggle]').forEach((b) => b.addEventListener('click', () => { const text = b.closest('.clamp-wrap').querySelector('[data-clamp]'); const open = text.classList.toggle('open'); @@ -495,10 +560,11 @@ async function renderVideoDetail(view, id) { /* ================= AI写脚本 ================= */ async function renderRewrites(view) { - let state = { search: '', parsed: '', page: 1, pageSize: 20 }; + let state = { search: '', parsed: '', page: 1, pageSize: 20, batch: false }; async function load() { const d = await api('/api/dsh/videos?search=' + encodeURIComponent(state.search) + '&parsed=' + state.parsed + '&page=' + state.page + '&pageSize=' + state.pageSize); const rows = (d.items || []).map((v) => ` + ${state.batch ? `` : ''} ${v.account_id ? `${esc(v.account_name || '-')}` : esc(v.account_name || '-')} ${v.video_url ? `${esc(cleanTitle(v.video_title) || '-')}` : esc(cleanTitle(v.video_title) || '-')} ${fmtWan(v.like_display || v.like_count)} @@ -526,15 +592,22 @@ async function renderRewrites(view) {
每账号近期 30W+ 且评论 1W+ 取 10 条
- - ${rows || ''} + ${state.batch ? '' : ''} + ${rows || ``}
账号视频标题点赞评论分享时长发布时间状态AI脚本操作
暂无视频数据
账号视频标题点赞评论分享时长发布时间状态AI脚本操作
暂无视频数据
+ ${batchBarHtml(state, 0)} ${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })} `; bindBack(view); view.querySelector('#rwSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } }); view.querySelector('#rwParsed').addEventListener('change', (e) => { state.parsed = e.target.value; state.page = 1; load(); }); bindPager(view, (p) => { state.page = p; load(); }); + bindBatchBar(view, { + state, kind: 'rewrites', + getSelected: () => [...view.querySelectorAll('.batch-cb:checked')].map((c) => Number(c.dataset.id)), + confirmMsg: '将删除选中的 {n} 条视频(含视频解析、AI脚本,不可恢复)。确定删除?', + reload: load, + }); view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail))); view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', async () => { const r = JSON.parse(b.dataset.rewrite); diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css index 4e9f480..2acbab7 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css @@ -8,6 +8,7 @@ --primary: #2f6fed; --primary-soft: #eaf1fe; --accent: #e8590c; + --danger: #d93026; --shadow: 0 1px 3px rgba(0,0,0,.08); } @@ -476,3 +477,14 @@ th.th-id { width: 120px; } .ic { display: inline-block; vertical-align: -0.18em; flex-shrink: 0; } .top-actions .ghost .ic { color: var(--dim); } .top-actions .ghost:hover .ic { color: var(--primary); } + +/* ================= 列表批量操作(左下角)================= */ +.list-batchbar { display: flex; align-items: center; gap: 10px; padding: 8px 4px 2px; min-height: 34px; } +.btn-icon { display: inline-flex; align-items: center; justify-content: center; padding: 5px 9px; } +.btn-danger { background: #fff; border-color: var(--danger); color: var(--danger); } +.btn-danger:hover { background: var(--danger); border-color: var(--danger); color: #fff; } +.batch-all-label { display: inline-flex; align-items: center; gap: 4px; cursor: pointer; user-select: none; font-size: 14px; color: var(--text); } +.batch-count { font-size: 14px; color: var(--dim); } +.batch-count-n { color: var(--danger); font-weight: 600; } +.cb-cell { width: 36px; text-align: center; } +.cb-cell input { width: 15px; height: 15px; cursor: pointer; vertical-align: middle; accent-color: var(--primary); } diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js index e8e48e4..10ff458 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js @@ -422,6 +422,20 @@ const server = http.createServer((req, res) => { }); return; } + if (p === '/api/dsh/batch-delete' && req.method === 'POST') { + if (dshGuard()) return; + let body = ''; + req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); }); + req.on('end', () => { + try { + const { kind, ids } = JSON.parse(body || '{}'); + if (!['accounts', 'videos', 'rewrites'].includes(kind)) return sendErr(400, 'kind 不合法'); + const r = dsh.batchDelete(kind, ids); + return dshOk(200, { ok: true, deleted: r.deleted }); + } catch (e) { return sendErr(500, e.message); } + }); + return; + } if (p === '/api/dsh/account-videos') { if (dshGuard()) return; try {