feat(工作台): 三列表(账号/视频/AI写脚本)批量操作——左下角图标按钮+勾选批量删除

- 后端: 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)
This commit is contained in:
maogeigei committed 2026-09-01 14:31:46 +08:00
1 parent 838aa4d443
commit b049cca766
11 files changed
+390 -9

No files matched your search

+8
View File
@@ -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 '*'**
+1
View File
@@ -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 再动手
## 历史事件(摘要)
View File
Whitespace-only changes.
@@ -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); });
@@ -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); });
@@ -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,
};
@@ -161,6 +161,8 @@ const ICON_PATHS = {
'image': '<rect width="18" height="18" x="3" y="3" rx="2" ry="2"/><circle cx="9" cy="9" r="2"/><path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21"/>',
'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
'check-square': '<polyline points="9 11 12 14 22 4"/><path d="M21 12v7a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11"/>',
'trash': '<path d="M3 6h18"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6"/><path d="M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/><line x1="10" x2="10" y1="11" y2="17"/><line x1="14" x2="14" y1="11" y2="17"/>',
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'user-plus': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
@@ -97,6 +97,54 @@ function bindBack(container) {
}));
}
/* ===== 列表批量操作(账号列表 / 视频列表 / AI写脚本列表 左下角)===== */
// 常态=左下角「批量操作」图标(勾选框);点击后 图标右侧出现「删除所选」图标 与「取消」图标
function batchBarHtml(state, selected) {
if (!state.batch) return `<div class="list-batchbar"><button class="btn btn-sm btn-icon" data-batch-open title="批量操作">${svgIcon('check-square', 16)}</button></div>`;
return `<div class="list-batchbar batch-on">
<button class="btn btn-sm btn-icon btn-batch-open" data-batch-open title="批量操作(再点收起)">${svgIcon('check-square', 16)}</button>
<label class="batch-all-label"><input type="checkbox" class="batch-cb-all"> 全选</label>
<span class="batch-count">已选 <b class="batch-count-n">${selected}</b> 项</span>
<button class="btn btn-sm btn-danger btn-icon" data-batch-del title="删除所选">${svgIcon('trash', 16)}</button>
<button class="btn btn-sm btn-icon" data-batch-cancel title="取消">${svgIcon('x', 16)}</button>
</div>`;
}
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) => `<span class="badge ${t === 'external' ? 'badge-ext' : 'badge-signed'}">${t === 'external' ? '外部' : '内部'}</span>`;
const rows = (d.items || []).map((a) => `<tr>
${state.batch ? `<td class="cb-cell"><input type="checkbox" class="batch-cb" data-id="${a.id}"></td>` : ''}
<td><button class="fav-btn" data-fav="${a.id}" data-fav-val="${a.favorite ? 1 : 0}" title="${a.favorite ? '取消关注' : '点击关注'}">${a.favorite ? '⭐' : '☆'}</button></td>
<td>${badge(a.account_type)}</td>
<td><a class="link" href="#/account/${a.id}">${esc(a.account_name)}</a></td>
@@ -268,10 +317,11 @@ async function renderAccounts(view) {
</div>
<div class="table-wrap">
<table class="table">
<thead><tr><th>关注</th><th>类型</th><th>账号名</th><th>赛道</th><th>AI制作</th><th>粉丝</th><th>获赞</th><th>作品数</th><th>地区</th><th>简介</th><th>视频数</th><th class="th-act">操作</th></tr></thead>
<tbody>${rows || '<tr><td colspan="12" class="empty">暂无账号数据</td></tr>'}</tbody>
<thead><tr>${state.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th>关注</th><th>类型</th><th>账号名</th><th>赛道</th><th>AI制作</th><th>粉丝</th><th>获赞</th><th>作品数</th><th>地区</th><th>简介</th><th>视频数</th><th class="th-act">操作</th></tr></thead>
<tbody>${rows || `<tr><td colspan="${state.batch ? 13 : 12}" class="empty">暂无账号数据</td></tr>`}</tbody>
</table>
</div>
${batchBarHtml(state, 0)}
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
</div>`;
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 = `<div class="page"><div class="page-toolbar">${toolbarBack()}</div><div class="empty">${esc(d.error || '账号不存在')}</div></div>`; 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) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${raw ? val : esc(fmt(val))}</div></div>`;
const rows = (dv.items || []).map((v) => `<tr>
${bstate.batch ? `<td class="cb-cell"><input type="checkbox" class="batch-cb" data-id="${v.id}"></td>` : ''}
<td>${fmt(v.publish_time)}</td>
<td class="td-id"><a class="link" href="#/video/${v.id}" title="查看视频分析详情">${esc(v.aweme_id || '-')}</a></td>
<td class="cell-content">${v.video_url ? `<a class="title-plain" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(cleanTitle(v.video_title) || '-')}</a>` : esc(cleanTitle(v.video_title) || '-')}</td>
@@ -353,7 +411,8 @@ async function renderAccountDetail(view, id) {
<div class="info-row">${infoGrid('账号定位', contentHtml, true, true)}</div>
</div>
<h3 class="video-list-title">视频列表</h3>
<div class="table-wrap"><table class="table"><thead><tr><th>发布时间</th><th class="th-id">视频ID</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th class="th-act">操作</th></tr></thead><tbody>${rows || '<tr><td colspan="8" class="empty">暂无视频数据</td></tr>'}</tbody></table></div>
<div class="table-wrap"><table class="table"><thead><tr>${bstate.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th>发布时间</th><th class="th-id">视频ID</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th class="th-act">操作</th></tr></thead><tbody>${rows || `<tr><td colspan="${bstate.batch ? 9 : 8}" class="empty">暂无视频数据</td></tr>`}</tbody></table></div>
${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) => `<tr>
${state.batch ? `<td class="cb-cell"><input type="checkbox" class="batch-cb" data-id="${v.id}"></td>` : ''}
<td>${v.account_id ? `<a class="link" href="#/account/${v.account_id}" title="查看账号详情">${esc(v.account_name || '-')}</a>` : esc(v.account_name || '-')}</td>
<td class="cell-content">${v.video_url ? `<a class="title-plain" data-ext="${esc(v.video_url)}" title="在新标签打开原视频">${esc(cleanTitle(v.video_title) || '-')}</a>` : esc(cleanTitle(v.video_title) || '-')}</td>
<td>${fmtWan(v.like_display || v.like_count)}</td>
@@ -526,15 +592,22 @@ async function renderRewrites(view) {
</div>
<div class="toolbar-note">每账号近期 30W+ 且评论 1W+ 取 10 条</div>
<div class="table-wrap"><table class="table">
<thead><tr><th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>AI脚本</th><th class="th-act">操作</th></tr></thead>
<tbody>${rows || '<tr><td colspan="10" class="empty">暂无视频数据</td></tr>'}</tbody>
<thead><tr>${state.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>AI脚本</th><th class="th-act">操作</th></tr></thead>
<tbody>${rows || `<tr><td colspan="${state.batch ? 11 : 10}" class="empty">暂无视频数据</td></tr>`}</tbody>
</table></div>
${batchBarHtml(state, 0)}
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
</div>`;
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);
@@ -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); }
@@ -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 {