feat(工作台): 视频列表加收藏列+数字列右对齐,视频详情页信息三行+标签排版
This commit is contained in:
1 parent
3f5c687e48
commit
e43e5bbb6c
12 files changed
+830
-27
No files matched your search
@@ -204,3 +204,51 @@
|
||||
- **发现并修复 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 '*'**
|
||||
|
||||
## 09-01 14:5x 清理误提交的 mcn-plugin.db(git 卫生)
|
||||
- **发现**:提交 b049cca 时 `git add -A` 误纳入两个数据库文件——仓库根目录 `mcn-plugin.db`(0 字节空文件,测试误生成)+ `project/短视频脚本创作/V1.0/mcn-work-shop/mcn-plugin.db`(4.2MB 真实数据,俊希账号等)
|
||||
- **处理(治本)**:`git rm --cached` 两个文件(工作区文件保留,真实数据库未动)+ 物理删除根目录 0 字节空文件 + .gitignore 追加 `mcn-plugin.db`/`**/mcn-plugin.db` 规则 → 提交 3d65774(3 files changed);验证 `git ls-files | grep mcn-plugin` = 0 跟踪
|
||||
- **教训**:`git add -A` 前必须检查是否有大文件/数据库/二进制文件混入;db 类文件应永不在版本库
|
||||
|
||||
## 09-01 14:36 批量操作条与分页组件同行(UI 微调)
|
||||
- **需求**:批量操作和分页组件放一行,不需要换行
|
||||
- **实现**:data-pages.js 三处(renderAccounts/renderAccountDetail/renderRewrites)将 `batchBarHtml + pagerHtml` 包进 `<div class="list-footer">`;style.css 新增 `.list-footer { display:flex; align-items:center; justify-content:space-between; gap:12px; }`(左批量右分页),`.list-batchbar` padding 对齐 pager(12px 4px)
|
||||
- 语法检查通过 + 静态资源 200,页面已重开
|
||||
|
||||
## 09-01 14:38 账号列表渲染失败修复(accReset is not defined,我的失误)
|
||||
- **用户报障**:账号列表页面渲染失败「accReset is not defined」
|
||||
- **根因(git diff 定位)**:上一轮包 list-footer 时 Edit 误把裸赋值 `accReset = () => {...}` 加进 renderAccounts 的 load()(HEAD 里无此行)——data-pages.js 第 3 行 `'use strict'`,**严格模式对未声明变量赋值直接 ReferenceError** → load() 中断、页面渲染失败;同时账号列表(324)的 list-footer 没包上(只包了 416/601 两处)
|
||||
- **修复**:①删除 renderAccounts 中裸 accReset 行(347 行已有 DOM 版重置 `#accReset` 绑定,纯死代码)②账号列表 batchBarHtml+pagerHtml 补包 `<div class="list-footer">`;新增 cdp-verify-footer.mjs 验证脚本
|
||||
- **验证**:19/19 PASS——三列表渲染无错误文案/无 console 异常、批量条+分页同行、批量模式切换/取消正常
|
||||
- **教训**:data-pages.js 是严格模式('use strict')——**禁止裸赋值未声明变量**;Edit 替换后必须 git diff 复查改动是否落在预期位置(上轮第三个 Edit 落点错误未被发现)
|
||||
|
||||
## 09-01 14:42 批量条去掉「全选」(表头已有全选框,避免重复)
|
||||
- **需求**:批量操作按钮点击后,右边批量条里不要全选操作(列表表头上已有全选框)
|
||||
- **实现**:data-pages.js batchBarHtml 批量模式删除 `<label class="batch-all-label">…全选…</label>` 行;style.css 删除 .batch-all-label 规则;表头 `thead .batch-cb-all` 为唯一全选入口
|
||||
- **验证**:cdp-verify-noall.mjs 10/10 PASS——批量条内无全选/无 label、表头有全选框、点表头全选计数=行数、再点取消计数 0(账号列表 7 行、AI写脚本 20 行)
|
||||
|
||||
## 09-01 14:44 账号详情视频列表列宽微调(发布时间-20px / 视频标题+20px)
|
||||
- **需求**:账号详情视频列表「发布时间」列宽度减少 20px,「视频标题」列增加 20px
|
||||
- **实现**:data-pages.js 表头加 `th-pub`/`th-title` 类、行 td 加 `td-pub`/`td-title`;style.css 新增 `th.th-pub{width:130px}`、`td.td-pub`(tabular-nums)、`th.th-title{width:240px}`、`td.td-title{max-width:240px}`(覆盖 cell-content 默认 220px)
|
||||
- **验证**:node --check 语法 OK + 静态资源 css/js 200
|
||||
- **★P0 教训(列宽设置陷阱)**:给 `<th>` 设固定 `width` 在 auto 表格布局下会**钳制列宽上限**——标题列从自然分配 331px 被压到 240px(用户反馈「反而变窄了」)。**正确做法:th 不设 width(保持 auto 自然分配),要加宽只放宽 td 的 `max-width`(内容显示上限 220→240)**。实测对照:修改前 155/331 → 错误固定 width 130/240 → 修复后 135/357(发布时间−20 ✅ 标题+26 ✅)。列宽调整类需求必须实测 getBoundingClientRect 验证,不能只看 CSS 声明
|
||||
|
||||
## 09-01 14:49 账号详情视频列表:去视频ID列 + 标题点击进详情页
|
||||
- **需求**:视频列表去掉「视频ID」列;点击视频标题进入视频详情页(#/video/:id)
|
||||
- **实现**:data-pages.js 删行 td-id + 表头 th-id(colspan 9/8→8/7);标题 td 改为 `<a class="link" href="#/video/${v.id}">`(原来 video_url 打开外链改为进详情);style.css 删 .td-id/th.th-id 死代码(td-title 样式保留)
|
||||
- **验证**:cdp-verify-title-link.mjs 7/7 PASS——表头无视频ID列、行数=7、标题 href=#/video/680、点击后 hash 跳转+详情页无渲染失败
|
||||
|
||||
## 09-01 14:52 账号详情视频列表:新增「收藏」列(分享右边)
|
||||
- **需求**:视频列表缺少收藏数据,放到分享列右边
|
||||
- **实现**:data-pages.js 表头分享后加 `<th>收藏</th>`(colspan 8/7→9/8),行在 share_count 后加 `<td>${fmtWan(v.collect_count)}</td>`(数据源 account_videos.collect_count 已存在,625 行有值)
|
||||
- **验证**:cdp-verify-collect.mjs 5/5 PASS——表头分享→收藏相邻、行单元格=8、首行收藏值 519 正确、无渲染失败
|
||||
|
||||
## 09-01 14:53 账号详情视频列表:数字列右对齐
|
||||
- **需求**:点赞/评论/分享/收藏/时长 五列右对齐
|
||||
- **实现**:data-pages.js 表头 5 列加 `class="num"`、行单元格加 `class="num"`(复用周榜已有 `.table td.num` 右对齐+tabular-nums 等宽数字样式);style.css 补 `.table th.num { text-align: right; }`
|
||||
- **验证**:cdp-verify-numalign.mjs 11/11 PASS——表头+行共 10 个单元格 computed textAlign 均 right、无渲染失败
|
||||
|
||||
## 09-01 14:54 视频详情页:信息卡三行排版
|
||||
- **需求**:视频信息排版改三行——①视频标题(独立行)②账号/视频ID/时长/发布时间 ③点赞/评论/分享/收藏,最下面保留标签
|
||||
- **实现**:data-pages.js info-grid 平铺改 `.info-row` 分组(标题用 `.video-detail-title` 大字号独立行 + 两组 info-row + 标签 wide 行;去掉播放字段——需求未列);style.css 加 `.video-info .video-detail-title`(18px/600/margin-bottom)
|
||||
- **验证**:cdp-verify-videoinfo.mjs 7/7 PASS——标题 18px 独立行、三组行 label 顺序正确(账号组/数据组/标签)、无渲染失败
|
||||
@@ -0,0 +1,58 @@
|
||||
/* cdp-measure-cols.mjs — 测量账号详情视频列表各列实际宽度(09-01) */
|
||||
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 main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=measure' + '#/account/3' });
|
||||
await sleep(2500);
|
||||
const r = await evalJs(ws, `(() => {
|
||||
const ths = [...document.querySelectorAll('.video-list-title ~ .table-wrap thead th, .table thead th')];
|
||||
const first = document.querySelector('.table tbody tr');
|
||||
const tds = first ? [...first.children] : [];
|
||||
const w = (el) => el ? Math.round(el.getBoundingClientRect().width) : -1;
|
||||
return {
|
||||
ths: ths.map((t) => ({ text: t.textContent.trim(), w: w(t), cls: t.className })),
|
||||
tds: tds.map((t) => ({ text: (t.textContent || '').trim().slice(0, 12), w: w(t), cls: t.className })),
|
||||
tableW: w(document.querySelector('.table-wrap .table')),
|
||||
wrapW: w(document.querySelector('.table-wrap')),
|
||||
err: document.querySelector('.page') ? document.querySelector('.page').textContent.slice(0, 100) : null
|
||||
};
|
||||
})()`);
|
||||
console.log(JSON.stringify(r, null, 2));
|
||||
process.exit(0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,75 @@
|
||||
/* cdp-measure-cols2.mjs — 对照实验:移除 th-title width 后标题列实际宽度(09-01)
|
||||
* 验证假设:auto 布局下 th 设 width 240 钳制了列宽;不设时剩余空间分配使标题列更宽
|
||||
*/
|
||||
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 main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=measure2' + '#/account/3' });
|
||||
await sleep(2500);
|
||||
const measure = `(() => {
|
||||
const ths = [...document.querySelectorAll('.table thead th')];
|
||||
const w = (el) => el ? Math.round(el.getBoundingClientRect().width) : -1;
|
||||
const obj = {};
|
||||
ths.forEach((t) => { obj[t.textContent.trim()] = { w: w(t), cls: t.className }; });
|
||||
return obj;
|
||||
})()`;
|
||||
const base = await evalJs(ws, measure);
|
||||
console.log('当前(th width 240):', JSON.stringify(base, null, 1));
|
||||
// 实验1:移除 th-title / th-pub 的 width(恢复 auto)
|
||||
await evalJs(ws, `(() => {
|
||||
const st = document.createElement('style');
|
||||
st.id = 'exp';
|
||||
st.textContent = 'th.th-title{width:auto!important;min-width:0!important} th.th-pub{width:auto!important}';
|
||||
document.head.appendChild(st);
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const exp1 = await evalJs(ws, measure);
|
||||
console.log('实验1(th width auto):', JSON.stringify(exp1, null, 1));
|
||||
// 实验2:th min-width 240(不设上限 width)
|
||||
await evalJs(ws, `(() => {
|
||||
const st = document.getElementById('exp');
|
||||
st.textContent = 'th.th-title{width:auto!important;min-width:240px!important} th.th-pub{width:130px!important}';
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const exp2 = await evalJs(ws, measure);
|
||||
console.log('实验2(th min-width 240):', JSON.stringify(exp2, null, 1));
|
||||
process.exit(0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,80 @@
|
||||
/* cdp-measure-cols3.mjs — 还原「修改前」样式基准,测各列+td 实际宽度(09-01)
|
||||
* 修改前:th 无 class(auto)、td 为 cell-content(max-width:220px)
|
||||
* 对比:①原始 ②当前(th width240) ③修复方案(仅去掉 th-title width,td 保留加宽)
|
||||
*/
|
||||
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 main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=measure3' + '#/account/3' });
|
||||
await sleep(2500);
|
||||
const measure = `(() => {
|
||||
const ths = [...document.querySelectorAll('.table thead th')];
|
||||
const tds = document.querySelector('.table tbody tr') ? [...document.querySelector('.table tbody tr').children] : [];
|
||||
const w = (el) => el ? Math.round(el.getBoundingClientRect().width) : -1;
|
||||
const rows = [...document.querySelectorAll('.table tbody tr')].slice(0, 3).map((tr) => [...tr.children].map((td) => w(td)));
|
||||
return {
|
||||
ths: ths.map((t) => t.textContent.trim() + '=' + w(t)),
|
||||
tdTitle: w(tds[2] || null),
|
||||
tdPub: w(tds[0] || null),
|
||||
rows
|
||||
};
|
||||
})()`;
|
||||
const base = await evalJs(ws, measure);
|
||||
console.log('①当前(th width 240):', JSON.stringify(base));
|
||||
// 还原修改前:清掉 th-title/th-pub 宽度、td-title max-width 覆盖 → 恢复 cell-content 220 基准
|
||||
await evalJs(ws, `(() => {
|
||||
let st = document.getElementById('exp');
|
||||
if (!st) { st = document.createElement('style'); st.id = 'exp'; document.head.appendChild(st); }
|
||||
st.textContent = 'th.th-title{width:auto!important} th.th-pub{width:auto!important} td.td-title{max-width:220px!important}';
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const orig = await evalJs(ws, measure);
|
||||
console.log('②修改前基准(cell-content 220):', JSON.stringify(orig));
|
||||
// 修复方案A:th-title 去掉 width(auto),td-title max-width 保持 240(标题列内容自然加宽)
|
||||
await evalJs(ws, `(() => {
|
||||
const st = document.getElementById('exp');
|
||||
st.textContent = 'th.th-title{width:auto!important} th.th-pub{width:130px!important} td.td-title{max-width:240px!important}';
|
||||
return true;
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const fixA = await evalJs(ws, measure);
|
||||
console.log('③修复A(th-title auto, td 240):', JSON.stringify(fixA));
|
||||
process.exit(0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,74 @@
|
||||
/* cdp-verify-collect.mjs — 验证:账号详情视频列表新增「收藏」列(分享右边)(09-01) */
|
||||
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));
|
||||
const waitFor = async (ws, expr, timeout = 15000, label = '') => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
if (await evalJs(ws, expr)) return true;
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error('waitFor timeout: ' + label);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, cond) => { cond ? pass++ : fail++; console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); };
|
||||
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=collect#/account/3' });
|
||||
await waitFor(ws, `!!document.querySelector('.table tbody tr')`, 20000, '视频列表渲染');
|
||||
const r = await evalJs(ws, `(() => {
|
||||
const ths = [...document.querySelectorAll('.table thead th')].map((t) => t.textContent.trim());
|
||||
const shareIdx = ths.indexOf('分享');
|
||||
const collectIdx = ths.indexOf('收藏');
|
||||
const first = document.querySelector('.table tbody tr');
|
||||
const tds = first ? [...first.children].map((td) => td.textContent.trim()) : [];
|
||||
const hasRenderErr = document.body.textContent.includes('渲染失败') || document.body.textContent.includes('accReset is not defined');
|
||||
return { ths, shareIdx, collectIdx, tds, hasRenderErr };
|
||||
})()`);
|
||||
check('表头含「收藏」列', r.ths.includes('收藏'));
|
||||
check('收藏列在分享右边(share=' + r.shareIdx + ' collect=' + r.collectIdx + ')', r.collectIdx === r.shareIdx + 1);
|
||||
check('行单元格数=8(发布时间/标题/点赞/评论/分享/收藏/时长/操作)', r.tds.length === 8);
|
||||
check('首行收藏列有数值(' + (r.tds[r.collectIdx] || '空') + ')', !!r.tds[r.collectIdx] && r.tds[r.collectIdx] !== '-');
|
||||
check('无渲染失败', !r.hasRenderErr);
|
||||
console.log('表头:', r.ths.join(' | '));
|
||||
console.log('首行:', r.tds.join(' | '));
|
||||
|
||||
console.log(`\n结果: ${pass} PASS / ${fail} FAIL`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,130 @@
|
||||
/* cdp-verify-footer.mjs — 验证账号列表渲染修复(09-01)
|
||||
* 背景:上一轮误加裸赋值 accReset(严格模式 ReferenceError)+ list-footer 未包账号列表
|
||||
* 验证:①账号列表页渲染成功(无 console 异常)②批量操作条与分页同一行(.list-footer 内)
|
||||
* ③三列表均含 .list-footer ④批量模式切换正常
|
||||
* 运行:node cdp-verify-footer.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 main() {
|
||||
// 找 8900 标签页
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
await cdp(ws, 'Log.enable');
|
||||
|
||||
const errors = [];
|
||||
const onErr = (ev) => { const m = JSON.parse(ev.data); if (m.method === 'Runtime.exceptionThrown') errors.push(JSON.stringify(m.params.exceptionDetails).slice(0, 200)); };
|
||||
const ws2 = new WebSocket(ws);
|
||||
await new Promise((r) => { ws2.onopen = r; });
|
||||
ws2.onmessage = onErr;
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, cond) => { cond ? pass++ : fail++; console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); };
|
||||
|
||||
// 打开账号列表
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=verify#/accounts' });
|
||||
await sleep(2500);
|
||||
|
||||
const acc = await evalJs(ws, `(() => {
|
||||
const root = document.querySelector('#view, main, .view, #app');
|
||||
const page = root ? root.innerHTML : document.body.innerHTML;
|
||||
return {
|
||||
hasError: /页面渲染失败|not defined|ReferenceError/.test(page),
|
||||
hasTable: !!document.querySelector('.table'),
|
||||
hasRows: document.querySelectorAll('.table tbody tr').length,
|
||||
footer: !!document.querySelector('.list-footer'),
|
||||
footerBoth: !!document.querySelector('.list-footer .list-batchbar') && !!document.querySelector('.list-footer .pager'),
|
||||
batchBtn: !!document.querySelector('[data-batch-open]'),
|
||||
title: document.querySelector('.page-title')?.textContent || ''
|
||||
};
|
||||
})()`);
|
||||
check('账号列表渲染无错误文案', !acc.hasError);
|
||||
check('账号列表表格存在', acc.hasTable);
|
||||
check('列表有数据行', acc.hasRows > 0);
|
||||
check('.list-footer 存在(批量+分页同行容器)', acc.footer);
|
||||
check('.list-footer 内含批量条和分页器', acc.footerBoth);
|
||||
check('批量操作图标按钮存在', acc.batchBtn);
|
||||
console.log(' 页面标题:', acc.title, '| 行数:', acc.hasRows);
|
||||
|
||||
// 点批量操作图标 → 验证批量模式 + 同行不破坏
|
||||
await evalJs(ws, `document.querySelector('[data-batch-open]').click()`);
|
||||
await sleep(600);
|
||||
const bm = await evalJs(ws, `(() => ({
|
||||
cbs: document.querySelectorAll('.batch-cb').length,
|
||||
delBtn: !!document.querySelector('[data-batch-del]'),
|
||||
cancelBtn: !!document.querySelector('[data-batch-cancel]'),
|
||||
footerStill: !!document.querySelector('.list-footer .list-batchbar') && !!document.querySelector('.list-footer .pager'),
|
||||
rowHasCb: !!document.querySelector('.table tbody .cb-cell input.batch-cb')
|
||||
}))()`);
|
||||
check('批量模式每行有勾选框', bm.cbs > 0 && bm.rowHasCb);
|
||||
check('删除(trash)图标按钮出现', bm.delBtn);
|
||||
check('取消(x)图标按钮出现', bm.cancelBtn);
|
||||
check('批量模式下批量条+分页仍同行', bm.footerStill);
|
||||
|
||||
// 取消恢复
|
||||
await evalJs(ws, `document.querySelector('[data-batch-cancel]').click()`);
|
||||
await sleep(500);
|
||||
const back = await evalJs(ws, `({ cbs: document.querySelectorAll('.batch-cb').length, footer: !!document.querySelector('.list-footer') })`);
|
||||
check('取消后恢复常态(无勾选框)', back.cbs === 0);
|
||||
check('取消后 footer 仍在', back.footer);
|
||||
|
||||
// 顺手验证另外两个列表的 footer
|
||||
for (const [name, hash, sel] of [
|
||||
['账号详情视频列表', '#/account/3', '.list-footer'],
|
||||
['AI写脚本列表', '#/rewrites', '.list-footer']
|
||||
]) {
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=verify' + hash });
|
||||
await sleep(2000);
|
||||
const r = await evalJs(ws, `(() => {
|
||||
const f = document.querySelector('.list-footer');
|
||||
return { f: !!f, both: f ? !!f.querySelector('.list-batchbar') && !!f.querySelector('.pager') : false, err: /页面渲染失败|not defined/.test(document.body.innerHTML) };
|
||||
})()`);
|
||||
check(name + ' .list-footer 存在', r.f);
|
||||
check(name + ' 批量条+分页同行', r.both);
|
||||
check(name + ' 无渲染错误', !r.err);
|
||||
}
|
||||
|
||||
console.log('--- console 异常:', errors.length ? errors.join('\n') : '无');
|
||||
if (errors.length) fail++;
|
||||
check('无 console 异常', errors.length === 0);
|
||||
|
||||
console.log(`\n结果: ${pass} PASS / ${fail} FAIL`);
|
||||
ws2.close();
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,83 @@
|
||||
/* cdp-verify-noall.mjs — 验证批量条去掉全选后,表头全选仍正常(09-01)
|
||||
* 验证:①批量模式下批量条内无全选(batch-cb-all 只在表头)②点表头全选→计数=行数 ③取消全选→计数 0
|
||||
* 运行:node cdp-verify-noall.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 main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, cond) => { cond ? pass++ : fail++; console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); };
|
||||
|
||||
for (const [name, hash, rowSel] of [
|
||||
['账号列表', '#/accounts', '.table tbody tr'],
|
||||
['AI写脚本列表', '#/rewrites', '.table tbody tr']
|
||||
]) {
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=noall' + hash });
|
||||
await sleep(2200);
|
||||
await evalJs(ws, `document.querySelector('[data-batch-open]')?.click()`);
|
||||
await sleep(600);
|
||||
const r = await evalJs(ws, `(() => {
|
||||
const inBar = document.querySelectorAll('.list-batchbar .batch-cb-all').length;
|
||||
const inHead = document.querySelectorAll('thead .batch-cb-all').length;
|
||||
const rows = document.querySelectorAll('${rowSel} .batch-cb').length;
|
||||
const hasLabel = document.querySelectorAll('.batch-all-label').length;
|
||||
// 表头全选 → 计数
|
||||
const head = document.querySelector('thead .batch-cb-all');
|
||||
if (head) { head.click(); }
|
||||
return { inBar, inHead, rows, hasLabel };
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const cnt = await evalJs(ws, `Number(document.querySelector('.batch-count-n')?.textContent || -1)`);
|
||||
check(name + ' 批量条内无全选', r.inBar === 0);
|
||||
check(name + ' 表头有全选框', r.inHead === 1);
|
||||
check(name + ' 批量条内无全选 label', r.hasLabel === 0);
|
||||
check(name + ' 点表头全选→计数=行数(' + r.rows + ')', cnt === r.rows && r.rows > 0);
|
||||
// 取消全选
|
||||
await evalJs(ws, `document.querySelector('thead .batch-cb-all').click()`);
|
||||
await sleep(300);
|
||||
const cnt2 = await evalJs(ws, `Number(document.querySelector('.batch-count-n')?.textContent || -1)`);
|
||||
check(name + ' 再点取消全选→计数 0', cnt2 === 0);
|
||||
await evalJs(ws, `document.querySelector('[data-batch-cancel]')?.click()`);
|
||||
await sleep(300);
|
||||
}
|
||||
|
||||
console.log(`\n结果: ${pass} PASS / ${fail} FAIL`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,72 @@
|
||||
/* cdp-verify-numalign.mjs — 验证:视频列表数字列(点赞/评论/分享/收藏/时长)右对齐(09-01) */
|
||||
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));
|
||||
const waitFor = async (ws, expr, timeout = 15000, label = '') => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
if (await evalJs(ws, expr)) return true;
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error('waitFor timeout: ' + label);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, cond) => { cond ? pass++ : fail++; console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); };
|
||||
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=numalign#/account/3' });
|
||||
await waitFor(ws, `!!document.querySelector('.table tbody tr')`, 20000, '视频列表渲染');
|
||||
const r = await evalJs(ws, `(() => {
|
||||
const ths = [...document.querySelectorAll('.table thead th')];
|
||||
const nums = ['点赞', '评论', '分享', '收藏', '时长'];
|
||||
const thRes = {};
|
||||
ths.forEach((t) => { const txt = t.textContent.trim(); if (nums.includes(txt)) thRes[txt] = getComputedStyle(t).textAlign; });
|
||||
const first = document.querySelector('.table tbody tr');
|
||||
const tds = first ? [...first.children] : [];
|
||||
const tdRes = tds.slice(2, 7).map((td) => ({ txt: td.textContent.trim().slice(0, 10), align: getComputedStyle(td).textAlign }));
|
||||
const hasRenderErr = document.body.textContent.includes('渲染失败');
|
||||
return { thRes, tdRes, hasRenderErr };
|
||||
})()`);
|
||||
for (const [name, align] of Object.entries(r.thRes)) check('表头「' + name + '」右对齐(' + align + ')', align === 'right');
|
||||
for (const [i, td] of r.tdRes.entries()) check('行第' + (i + 3) + '列(' + td.txt + ')右对齐(' + td.align + ')', td.align === 'right');
|
||||
check('无渲染失败', !r.hasRenderErr);
|
||||
|
||||
console.log(`\n结果: ${pass} PASS / ${fail} FAIL`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,90 @@
|
||||
/* cdp-verify-title-link.mjs — 验证:视频列表去掉视频ID列 + 标题点击进入详情页(09-01) */
|
||||
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));
|
||||
const waitFor = async (ws, expr, timeout = 15000, label = '') => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
if (await evalJs(ws, expr)) return true;
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error('waitFor timeout: ' + label);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, cond) => { cond ? pass++ : fail++; console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); };
|
||||
|
||||
// ① 账号详情视频列表:无视频ID列、标题为链接
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=tlink#/account/3' });
|
||||
await waitFor(ws, `!!document.querySelector('.table tbody tr')`, 20000, '视频列表渲染');
|
||||
const r1 = await evalJs(ws, `(() => {
|
||||
const ths = [...document.querySelectorAll('.table thead th')].map((t) => t.textContent.trim());
|
||||
const first = document.querySelector('.table tbody tr');
|
||||
const tds = first ? [...first.children] : [];
|
||||
const titleA = first ? first.querySelector('td.td-title a') : null;
|
||||
return {
|
||||
ths, tdCount: tds.length,
|
||||
hasVideoIdCol: ths.includes('视频ID'),
|
||||
titleHref: titleA ? titleA.getAttribute('href') : null,
|
||||
titleText: titleA ? titleA.textContent.trim().slice(0, 20) : null
|
||||
};
|
||||
})()`);
|
||||
check('表头无「视频ID」列', !r1.hasVideoIdCol);
|
||||
check('表头列为 ' + r1.ths.join('/'), r1.ths.includes('发布时间') && r1.ths.includes('视频标题'));
|
||||
check('行单元格数=7(发布时间/标题/点赞/评论/分享/时长/操作)', r1.tdCount === 7);
|
||||
check('标题为链接 → ' + r1.titleHref, r1.titleHref && r1.titleHref.startsWith('#/video/'));
|
||||
check('标题有文字', !!r1.titleText && r1.titleText !== '-');
|
||||
|
||||
// ② 点击标题 → 进入视频详情页
|
||||
const href = r1.titleHref;
|
||||
const vid = href ? href.replace('#/video/', '') : '';
|
||||
await evalJs(ws, `document.querySelector('td.td-title a').click()`);
|
||||
await waitFor(ws, `location.hash.startsWith('#/video/')`, 8000, 'hash 跳转');
|
||||
await waitFor(ws, `!!document.querySelector('.page-detail, .page .page-title, .page-title')`, 10000, '视频详情渲染');
|
||||
const r2 = await evalJs(ws, `(() => {
|
||||
const title = document.querySelector('.page-title') ? document.querySelector('.page-title').textContent.trim() : '';
|
||||
const err = document.querySelector('.page') && document.querySelector('.page').textContent.includes('渲染失败') ? '渲染失败' : '';
|
||||
return { hash: location.hash, title, err };
|
||||
})()`);
|
||||
check('hash 已跳转 #/video/' + vid + '(实际 ' + r2.hash + ')', r2.hash === '#/video/' + vid);
|
||||
check('视频详情页无渲染失败', !r2.err);
|
||||
|
||||
console.log(`\n结果: ${pass} PASS / ${fail} FAIL`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -0,0 +1,83 @@
|
||||
/* cdp-verify-videoinfo.mjs — 验证:视频详情页信息卡三行排版(标题/账号组/数据组)(09-01) */
|
||||
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));
|
||||
const waitFor = async (ws, expr, timeout = 15000, label = '') => {
|
||||
const t0 = Date.now();
|
||||
while (Date.now() - t0 < timeout) {
|
||||
if (await evalJs(ws, expr)) return true;
|
||||
await sleep(300);
|
||||
}
|
||||
throw new Error('waitFor timeout: ' + label);
|
||||
};
|
||||
|
||||
async function main() {
|
||||
const tabs = await (await fetch(CDP + '/json')).json();
|
||||
const tab = tabs.find((x) => x.url.includes('localhost:8900')) || tabs.find((x) => x.type === 'page');
|
||||
if (!tab) throw new Error('no 8900 tab found');
|
||||
const ws = await getWs(tab.id);
|
||||
await cdp(ws, 'Page.enable');
|
||||
await cdp(ws, 'Runtime.enable');
|
||||
|
||||
let pass = 0, fail = 0;
|
||||
const check = (name, cond) => { cond ? pass++ : fail++; console.log((cond ? 'PASS' : 'FAIL') + ' | ' + name); };
|
||||
|
||||
await cdp(ws, 'Page.navigate', { url: 'http://localhost:8900/?v=vinfo#/video/680' });
|
||||
await waitFor(ws, `!!document.querySelector('.video-detail-title')`, 15000, '视频详情渲染');
|
||||
|
||||
const r = await evalJs(ws, `(() => {
|
||||
const card = document.querySelector('.video-info');
|
||||
const title = card ? card.querySelector('.video-detail-title') : null;
|
||||
const rows = card ? [...card.querySelectorAll('.info-row')] : [];
|
||||
const rowLabels = rows.map((row) => [...row.querySelectorAll('.info-label')].map((l) => l.textContent.trim()));
|
||||
const hasRenderErr = document.body.textContent.includes('渲染失败');
|
||||
const titleStyle = title ? { fontSize: getComputedStyle(title).fontSize, weight: getComputedStyle(title).fontWeight } : null;
|
||||
return {
|
||||
hasTitle: !!title,
|
||||
titleText: title ? title.textContent.trim().slice(0, 25) : '',
|
||||
titleStyle,
|
||||
rowCount: rows.length,
|
||||
rowLabels,
|
||||
hasRenderErr
|
||||
};
|
||||
})()`);
|
||||
|
||||
check('标题独立行存在', r.hasTitle && !!r.titleText);
|
||||
check('标题大字号(' + (r.titleStyle ? r.titleStyle.fontSize + '/' + r.titleStyle.weight : '') + ')', r.titleStyle && parseFloat(r.titleStyle.fontSize) >= 18);
|
||||
check('共 3 组信息行', r.rowCount === 3);
|
||||
check('第1行=账号/视频ID/时长/发布时间(' + r.rowLabels[0] + ')', JSON.stringify(r.rowLabels[0]) === JSON.stringify(['账号', '视频ID', '时长', '发布时间']));
|
||||
check('第2行=点赞/评论/分享/收藏(' + r.rowLabels[1] + ')', JSON.stringify(r.rowLabels[1]) === JSON.stringify(['点赞', '评论', '分享', '收藏']));
|
||||
check('第3行=标签(' + r.rowLabels[2] + ')', JSON.stringify(r.rowLabels[2]) === JSON.stringify(['标签']));
|
||||
check('无渲染失败', !r.hasRenderErr);
|
||||
|
||||
console.log(`\n结果: ${pass} PASS / ${fail} FAIL`);
|
||||
process.exit(fail ? 1 : 0);
|
||||
}
|
||||
main().catch((e) => { console.error('ERROR:', e.message); process.exit(1); });
|
||||
@@ -103,7 +103,6 @@ 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>
|
||||
@@ -321,8 +320,10 @@ async function renderAccounts(view) {
|
||||
<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 class="list-footer">
|
||||
${batchBarHtml(state, 0)}
|
||||
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
|
||||
</div>
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
view.querySelectorAll('[data-fav]').forEach((b) => b.addEventListener('click', async () => {
|
||||
@@ -388,13 +389,13 @@ async function renderAccountDetail(view, id) {
|
||||
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>
|
||||
<td>${fmtWan(v.like_display || v.like_count)}</td>
|
||||
<td>${fmtWan(v.comment_count)}</td>
|
||||
<td>${fmtWan(v.share_count)}</td>
|
||||
<td>${fmtDuration(v.duration)}</td>
|
||||
<td class="td-pub">${fmt(v.publish_time)}</td>
|
||||
<td class="cell-content td-title"><a class="link" href="#/video/${v.id}" title="查看视频分析详情">${esc(cleanTitle(v.video_title) || '-')}</a></td>
|
||||
<td class="num">${fmtWan(v.like_display || v.like_count)}</td>
|
||||
<td class="num">${fmtWan(v.comment_count)}</td>
|
||||
<td class="num">${fmtWan(v.share_count)}</td>
|
||||
<td class="num">${fmtWan(v.collect_count)}</td>
|
||||
<td class="num">${fmtDuration(v.duration)}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm ${(v.has_source || v.has_analysis) ? 'btn-view' : ''}" data-rewrite='${esc(JSON.stringify({ id: v.id, name: acc.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id, url: v.video_url || '', parsed: !!(v.has_source || v.has_analysis) }))}'>AI创作</button></td>
|
||||
</tr>`).join('');
|
||||
const tabBtns = [['info', '账号信息'], ['persona', '账号设定'], ['analysis', '数据分析']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
|
||||
@@ -411,9 +412,11 @@ 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>${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(); })}`;
|
||||
<div class="table-wrap"><table class="table"><thead><tr>${bstate.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th class="th-pub">发布时间</th><th class="th-title">视频标题</th><th class="num">点赞</th><th class="num">评论</th><th class="num">分享</th><th class="num">收藏</th><th class="num">时长</th><th class="th-act">操作</th></tr></thead><tbody>${rows || `<tr><td colspan="${bstate.batch ? 9 : 8}" class="empty">暂无视频数据</td></tr>`}</tbody></table></div>
|
||||
<div class="list-footer">
|
||||
${batchBarHtml(bstate, 0)}
|
||||
${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}
|
||||
</div>`;
|
||||
} else if (tab === 'persona') {
|
||||
if (!persona) {
|
||||
panel = `<div class="empty-state">
|
||||
@@ -541,12 +544,12 @@ async function renderVideoDetail(view, id) {
|
||||
<div class="page page-detail">
|
||||
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(cleanTitle(v.video_title) || '视频详情')}</span></div>
|
||||
<div class="detail-body">
|
||||
<div class="info-card"><div class="info-grid">
|
||||
${infoGrid('视频标题', titleVal, true, true)}${infoGrid('账号', v.account_name)}${infoGrid('视频ID', v.aweme_id)}
|
||||
${infoGrid('点赞', fmtWan(v.like_display || v.like_count))}${infoGrid('评论', fmtWan(v.comment_count))}${infoGrid('分享', fmtWan(v.share_count))}
|
||||
${infoGrid('收藏', v.collect_count)}${infoGrid('播放', (v.play_count == null || Number(v.play_count) === 0) ? null : v.play_count)}${infoGrid('时长', fmtDuration(v.duration))}
|
||||
${infoGrid('发布时间', v.publish_time)}${infoGrid('标签', v.tags, true)}
|
||||
</div></div>
|
||||
<div class="info-card video-info">
|
||||
<div class="video-detail-title">${titleVal}</div>
|
||||
<div class="info-row">${infoGrid('账号', v.account_name)}${infoGrid('视频ID', v.aweme_id)}${infoGrid('时长', fmtDuration(v.duration))}${infoGrid('发布时间', v.publish_time)}</div>
|
||||
<div class="info-row">${infoGrid('点赞', fmtWan(v.like_display || v.like_count))}${infoGrid('评论', fmtWan(v.comment_count))}${infoGrid('分享', fmtWan(v.share_count))}${infoGrid('收藏', fmtWan(v.collect_count))}</div>
|
||||
<div class="info-row">${infoGrid('标签', v.tags, true)}</div>
|
||||
</div>
|
||||
<div class="tabs">${tabBtns}</div>
|
||||
${body}
|
||||
</div>
|
||||
@@ -595,8 +598,10 @@ async function renderRewrites(view) {
|
||||
<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 class="list-footer">
|
||||
${batchBarHtml(state, 0)}
|
||||
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
|
||||
</div>
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
view.querySelector('#rwSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } });
|
||||
|
||||
@@ -322,6 +322,7 @@ main#view { height: calc(100vh - 55px); overflow: auto; } /* 55px=topbar 实际
|
||||
.sortable:hover { color: var(--primary); }
|
||||
.table th.th-wide { min-width: 90px; text-align: right; } /* 周榜数值列表头(综合得分/粉丝/新增粉丝/点赞/评论/分享):窄 + 右对齐(提特异性压过 .table th 默认左对齐) */
|
||||
.table td.num { text-align: right; font-size: 13px; font-variant-numeric: tabular-nums; padding-left: 8px; padding-right: 8px; } /* 数值列单元格:右对齐 + 紧凑(等宽数字,内容窄于 90px 让 min-width 生效) */
|
||||
.table th.num { text-align: right; } /* 数值列表头(点赞/评论/分享/收藏/时长):与 td.num 右对齐配套 */
|
||||
/* 周榜表:fixed 布局固定列宽(auto 布局下 100% 表格富余空间会按比例摊薄拉宽列,min-width 压不住;fixed 下 width 为硬约束,账号列吃剩余) */
|
||||
.table.table-rank { table-layout: fixed; }
|
||||
.table.table-rank th, .table.table-rank td { overflow: hidden; text-overflow: ellipsis; }
|
||||
@@ -348,9 +349,11 @@ main#view { height: calc(100vh - 55px); overflow: auto; } /* 55px=topbar 实际
|
||||
.tab-active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
|
||||
/* 数据页列表标题(如账号视频列表):与上方内容间隔 20px */
|
||||
.video-list-title { margin: 20px 0 10px; font-size: 17px; font-weight: 600; }
|
||||
/* 视频ID 列紧凑:小号数字,宽度刚好容纳 ID */
|
||||
.td-id { font-size: 13px; font-variant-numeric: tabular-nums; padding-left: 8px; padding-right: 8px; }
|
||||
th.th-id { width: 120px; }
|
||||
/* 账号详情视频列表:发布时间列收窄 20px(155→135);标题列 th 不设固定宽度(auto 自然分配,避免钳制列宽),
|
||||
td 内容显示上限 220→240px(标题多显示 20px 内容) */
|
||||
th.th-pub { width: 135px; }
|
||||
td.td-pub { font-variant-numeric: tabular-nums; }
|
||||
td.td-title { max-width: 240px; }
|
||||
|
||||
/* ================= 账号设定 卡片版/文字版 切换(右上角图标按钮组) ================= */
|
||||
.persona-wrap { position: relative; padding: 0 20px; } /* 账号设定内容左右各 20px 间隔 */
|
||||
@@ -374,6 +377,8 @@ th.th-id { width: 120px; }
|
||||
.info-row .info-item.info-wide { flex: 1 1 auto; min-width: 0; max-width: 100%; } /* 09-01 修复横向溢出:info-wide 允许收缩,否则长内容把 main 撑出横向滚动条 */
|
||||
.info-label { font-size: 14px; color: var(--dim); }
|
||||
.info-value { font-size: 15px; color: var(--text); word-break: break-word; white-space: pre-wrap; line-height: 1.6; }
|
||||
/* 视频详情信息卡:标题独立一行(大字号),下方按行分组 */
|
||||
.video-info .video-detail-title { font-size: 18px; font-weight: 600; color: var(--text); margin-bottom: 14px; word-break: break-word; line-height: 1.5; }
|
||||
/* 长文本默认最多 10 行,可点击展开/收起(账号定位等) */
|
||||
/* 09-01 改用 block+max-height 裁剪:display:-webkit-box 下长句不折行,内容宽被撑到 1896px 导致 main 横向滚动条 */
|
||||
.clamp-text { display: block; max-height: 240px; overflow: hidden; word-break: break-word; overflow-wrap: anywhere; } /* 240px=10行×15px×1.6 */
|
||||
@@ -478,12 +483,12 @@ th.th-id { width: 120px; }
|
||||
.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; }
|
||||
/* ================= 列表批量操作(左下角,与分页同行)================= */
|
||||
.list-footer { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
||||
.list-batchbar { display: flex; align-items: center; gap: 10px; padding: 12px 4px; 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; }
|
||||
|
||||
Reference in new issue
Block a user