feat(账号分析+工作台): 功能三自动联动生成设定卡+双向联动维护+玫瑰金配色定稿+首页数据预览/周榜优化+CDP验证脚本

- mcn-dou-analysis: 功能三产出设定后自动生成设定卡(Step7); 设定md与卡片SVG双向联动维护规则固化(06文档+03文档+SKILL.md); 浅色系配色定稿玫瑰金(#D6A3A9→#9A5563)+背景文字提亮规则; 新增06生成账号设定卡片文档+模板样例
- mcn-work-shop: 首页数据预览条/周榜预览表加宽/改名AI脚本诊断/tab切换/添加账号弹窗等优化
- 新增 cdp-*.mjs 验证脚本14个(可复用); .gitignore 忽略运行日志/截图/tmp
This commit is contained in:
maogeigei committed 2026-08-31 15:27:54 +08:00
1 parent 96fce56fba
commit 53621279a5
35 files changed
+2567 -117

No files matched your search

@@ -0,0 +1,24 @@
// 截图王微斯账号设定卡 SVG 渲染效果(自检配色)
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && (decodeURIComponent(t.url).includes('王微斯') || t.url.includes('8899')));
if (!page) { console.log('ERR: no wangweisi/8899 page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
// 导航到本地 SVG 文件并强制重载(同一 URL 导航不触发重载)
const url = 'file:///C:/Users/maidou/Desktop/MCNSkill项目/王微斯/王微斯账号设定卡.svg';
await send('Page.navigate', { url });
await sleep(1200);
await send('Page.reload', { ignoreCache: true });
await sleep(2500);
const r = await send('Page.captureScreenshot', { format: 'png' });
const fs = await import('node:fs');
fs.writeFileSync('shot-wangweisi-card.png', Buffer.from(r.data, 'base64'));
console.log('截图完成: shot-wangweisi-card.png');
ws.close();
@@ -0,0 +1,36 @@
// 验证:周榜功能列按钮统一「白底带边框」(btn-view / btn-import)
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
const goto = async (hash) => { await send('Page.navigate', { url: 'http://localhost:8899/' + hash }); await sleep(1500); await send('Page.reload', { ignoreCache: true }); await sleep(3000); };
// 1) 功能页 #/ranking:查看 + 分析按钮
await goto('#/ranking');
const rank = await ev(`(() => {
const btns = [...document.querySelectorAll('.table tbody .cell-act .btn')];
return btns.slice(0, 4).map((b) => ({ txt: b.textContent.trim(), bg: getComputedStyle(b).backgroundColor, border: getComputedStyle(b).borderTopColor, color: getComputedStyle(b).color }));
})()`);
console.log('【功能页功能列】');
for (const b of rank) console.log(' ', b.txt, '| bg:', b.bg, '| border:', b.border);
// 2) 首页预览:查看 + 关注按钮
await goto('#/');
const home = await ev(`(() => {
const btns = [...document.querySelectorAll('#rankPreviewList .rank-row:not(.rank-head) .rk-act .btn')];
return btns.slice(0, 4).map((b) => ({ txt: b.textContent.trim(), bg: getComputedStyle(b).backgroundColor, border: getComputedStyle(b).borderTopColor }));
})()`);
console.log('【首页预览功能列】');
for (const b of home) console.log(' ', b.txt, '| bg:', b.bg, '| border:', b.border);
const allBtns = [...rank, ...home];
const ok = allBtns.length > 0 && allBtns.every((b) => b.bg === 'rgb(255, 255, 255)' && b.border !== 'rgb(245, 246, 248)');
console.log(ok ? 'PASS: 全部白底带边框' : 'CHECK');
ws.close();
@@ -0,0 +1,52 @@
// 验证「首页顶部数据预览」条带(参考 dsh-plugin-mcn 首页 stats):只读测量
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
// 1) 导航到首页并强制刷新加载最新代码(纯 hash 变化不触发文档重载,必须 reload)
await send('Page.navigate', { url: 'http://localhost:8899/#/' });
await sleep(1500);
await send('Page.reload', { ignoreCache: true });
await sleep(3000);
// 2) 检查 stats 条带结构 + 渲染值 + 样式
const check = await ev(`(() => {
const el = document.getElementById('homeStats');
if (!el) return { err: 'no #homeStats' };
const items = [...el.querySelectorAll('.home-stat-item')];
return {
count: items.length,
values: items.map((i) => ({ v: i.querySelector('.home-stat-value').textContent, l: i.querySelector('.home-stat-label').textContent })),
position: el.previousElementSibling ? el.previousElementSibling.className : '(first in .home)',
next: el.nextElementSibling ? el.nextElementSibling.className : '',
};
})()`);
console.log('条带结构:', JSON.stringify(check, null, 1));
// 3) computed 样式核对(条带底色 / 数字色 / item 分隔线)
const styleCheck = await ev(`(() => {
const el = document.getElementById('homeStats');
if (!el) return { err: 'no el' };
const cs = getComputedStyle(el);
const v = getComputedStyle(el.querySelector('.home-stat-value'));
const item = el.querySelector('.home-stat-item');
const its = getComputedStyle(item);
return {
statsBg: cs.backgroundColor, statsRadius: cs.borderRadius, statsPadding: cs.padding,
valueColor: v.color, valueSize: v.fontSize, valueWeight: v.fontWeight,
itemBorderRight: its.borderRightWidth + ' ' + its.borderRightStyle,
itemLast: getComputedStyle(el.querySelector('.home-stat-item:last-child')).borderRightWidth,
};
})()`);
console.log('样式:', JSON.stringify(styleCheck));
ws.close();
console.log('DONE');
@@ -0,0 +1,147 @@
// 验证「账号设定」卡片版/文字版切换(账号详情页 persona tab)
// 用例1:旧梦留声机(1629) 有卡片 → 默认卡片版 + 可切换文字版
// 用例2:王微斯(1627) 无卡片 → 直接文字版,无切换控件
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
const shot = async (name) => {
try {
const r = await send('Page.captureScreenshot', { format: 'png' });
const fs = await import('node:fs');
fs.writeFileSync('shot-' + name + '.png', Buffer.from(r.data, 'base64'));
console.log('截图:', name);
} catch (e) { console.log('截图失败(跳过):', e.message); }
};
const goto = async (url) => {
await send('Page.navigate', { url });
await sleep(2500);
await send('Page.reload', { ignoreCache: true }); // hash 路由 navigate 不重载文档,强制刷新加载最新 JS
await sleep(3000);
};
// ---- 用例1:旧梦留声机(有卡片)----
console.log('=== 用例1 旧梦留声机 1629 ===');
await goto('http://localhost:8899/#/account/1629');
// 点击「账号设定」tab
const tabClick = await ev(`(() => {
const b = document.querySelector('[data-tab="persona"]');
if (!b) return { err: 'no persona tab' };
b.click();
return 'clicked persona tab';
})()`);
console.log('切tab:', JSON.stringify(tabClick));
await sleep(1800); // 等 loadPersona 两个 fetch 完成
const s1 = await ev(`(() => {
const sw = document.querySelector('.persona-vs-icons');
const card = document.querySelector('.card-view');
const md = document.querySelector('.persona-text');
const img = card && card.querySelector('img');
const btns = sw ? [...sw.querySelectorAll('.vi-btn')] : [];
const active = btns.find((x) => x.classList.contains('vi-active'));
const cardIcon = btns[0] && btns[0].querySelector('svg path');
return {
hasIcons: !!sw,
btnTitles: btns.map((x) => x.title),
activeTitle: active ? active.title : null,
cardIconIsImage: cardIcon ? cardIcon.getAttribute('d').includes('m21 15-3.086') : null,
cardHidden: card ? card.classList.contains('vs-hidden') : null,
cardImgSrc: img ? img.getAttribute('src').slice(0, 60) : null,
mdHidden: md ? md.classList.contains('vs-hidden') : null,
mdLen: md ? md.textContent.length : 0,
};
})()`);
console.log('默认卡片版状态:', JSON.stringify(s1));
// 切到文字版
await ev(`(() => { const b = document.querySelector('[data-vs="text"]'); if (b) b.click(); return 1; })()`);
await sleep(400);
const s2 = await ev(`(() => {
const sw = document.querySelector('.persona-vs-icons');
const card = document.querySelector('.card-view');
const md = document.querySelector('.persona-text');
const active = sw && [...sw.querySelectorAll('.vi-btn')].find((x) => x.classList.contains('vi-active'));
return {
activeTitle: active ? active.title : null,
activeCount: sw ? [...sw.querySelectorAll('.vi-btn.vi-active')].length : null,
cardHidden: card ? card.classList.contains('vs-hidden') : null,
mdHidden: md ? md.classList.contains('vs-hidden') : null,
};
})()`);
console.log('切文字版后:', JSON.stringify(s2));
// 切回卡片版
await ev(`(() => { const b = document.querySelector('[data-vs="card"]'); if (b) b.click(); return 1; })()`);
await sleep(400);
const s3 = await ev(`(() => {
const card = document.querySelector('.card-view');
const md = document.querySelector('.persona-text');
const active = document.querySelector('.vi-btn.vi-active');
return {
activeTitle: active ? active.title : null,
activeCount: document.querySelectorAll('.vi-btn.vi-active').length,
cardHidden: card ? card.classList.contains('vs-hidden') : null,
mdHidden: md ? md.classList.contains('vs-hidden') : null,
};
})()`);
console.log('切回卡片版后:', JSON.stringify(s3));
// ---- 用例2:王微斯(无卡片)----
console.log('=== 用例2 王微斯 1627(无卡片)===');
await goto('http://localhost:8899/#/account/1627');
await ev(`(() => { const b = document.querySelector('[data-tab="persona"]'); if (b) b.click(); return 1; })()`);
await sleep(1800);
const s4 = await ev(`(() => {
const sw = document.querySelector('.persona-vs-icons');
const md = document.querySelector('.persona-text');
return {
hasIcons: !!sw,
mdExists: !!md,
mdHidden: md ? md.classList.contains('vs-hidden') : null,
mdLen: md ? md.textContent.length : 0,
};
})()`);
console.log('无卡片账号状态:', JSON.stringify(s4));
// 回到用例1 页面截图卡片版效果(最后统一截图,失败不中断)
await goto('http://localhost:8899/#/account/1629');
await ev(`(() => { const b = document.querySelector('[data-tab="persona"]'); if (b) b.click(); return 1; })()`);
await sleep(1800);
// 多视口宽度自检:验证图片是否随内容区自适应(不应超出 viewport 引发横向滚动)
for (const vw of [1280, 1024, 900, 1920, 2560]) {
await send('Emulation.setDeviceMetricsOverride', { width: vw, height: 800, deviceScaleFactor: 1, mobile: false });
await sleep(500);
const info = await ev(`(() => {
const img = document.querySelector('.card-view img');
const body = document.querySelector('.detail-body');
const wrap = document.querySelector('.persona-wrap');
const page = document.querySelector('.page-detail');
return {
viewport: ${vw},
docScrollW: document.documentElement.scrollWidth,
bodyW: body ? Math.round(body.getBoundingClientRect().width) : null,
wrapW: wrap ? Math.round(wrap.getBoundingClientRect().width) : null,
imgW: img ? Math.round(img.getBoundingClientRect().width) : null,
pageW: page ? Math.round(page.getBoundingClientRect().width) : null,
bodyOverflow: body ? getComputedStyle(body).overflowX : null,
};
})()`);
console.log('视口', vw, ':', JSON.stringify(info));
}
await send('Emulation.clearDeviceMetricsOverride');
await sleep(300);
await shot('persona-card-final');
console.log('DONE');
ws.close();
process.exit(0);
@@ -0,0 +1,70 @@
// 验证「账号设定」内容区左右 20px 间隔(persona-wrap padding 0 20px)
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
const goto = async (url) => {
await send('Page.navigate', { url });
await sleep(2500);
await send('Page.reload', { ignoreCache: true });
await sleep(3000);
};
console.log('=== 旧梦留声机 1629(有卡片)===');
await goto('http://localhost:8899/#/account/1629');
const tabClick = await ev(`(() => {
const b = document.querySelector('[data-tab="persona"]');
if (!b) return { err: 'no persona tab' };
b.click();
return 'clicked persona tab';
})()`);
console.log('切tab:', JSON.stringify(tabClick));
await sleep(1800);
const s1 = await ev(`(() => {
const wrap = document.querySelector('.persona-wrap');
const card = document.querySelector('.card-view');
const img = card && card.querySelector('img');
if (!wrap) return { err: 'no .persona-wrap' };
const cs = getComputedStyle(wrap);
const dbody = document.querySelector('.detail-body');
const dw = dbody ? dbody.getBoundingClientRect().width : null;
const ww = wrap.getBoundingClientRect().width;
const iw = img ? img.getBoundingClientRect().width : null;
return {
paddingLeft: cs.paddingLeft,
paddingRight: cs.paddingRight,
detailBodyW: dw,
wrapW: ww,
imgW: iw,
imgInset: iw !== null && dw !== null ? (dw - iw).toFixed(1) : null, // 卡片比内容区窄多少(两侧总和)
};
})()`);
console.log('persona padding 检查:', JSON.stringify(s1, null, 2));
// 切文字版后再看文字容器
const s2 = await ev(`(() => {
const btn = document.querySelector('.persona-vs-icons [data-vs="text"]');
if (btn) btn.click();
return 'switched to text';
})()`);
await sleep(600);
const s3 = await ev(`(() => {
const md = document.querySelector('.persona-text');
if (!md) return { err: 'no .persona-text' };
const dbody = document.querySelector('.detail-body');
const dw = dbody ? dbody.getBoundingClientRect().width : null;
const mw = md.getBoundingClientRect().width;
return { mdW: mw, inset: dw !== null ? (dw - mw).toFixed(1) : null };
})()`);
console.log('文字版检查:', JSON.stringify(s3, null, 2));
ws.close();
console.log('DONE');
@@ -0,0 +1,58 @@
// 验证周榜数值列「窄 + 右对齐」:功能页 #/ranking + 首页预览表,只读测量
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
const goto = async (hash) => { await send('Page.navigate', { url: 'http://localhost:8899/' + hash }); await sleep(1500); await send('Page.reload', { ignoreCache: true }); await sleep(3000); };
// ===== 1) 功能页 #/ranking =====
await goto('#/ranking');
const rankPage = await ev(`(() => {
const ths = [...document.querySelectorAll('.table th')];
const wideKeys = ['comprehensiveScore','followers','newFans','newLikes','newComments','newShares'];
const colInfo = ths.map((th) => {
const rect = th.getBoundingClientRect();
return { key: th.dataset.sort || '', label: th.textContent.trim().slice(0,8), wide: th.classList.contains('th-wide'), w: Math.round(rect.width), thAlign: getComputedStyle(th).textAlign };
});
// td 对齐抽查:第一个数据行
const row = document.querySelector('.table tbody tr');
const tds = row ? [...row.children] : [];
const tdAligns = tds.map((td) => td.className + ':' + getComputedStyle(td).textAlign);
return { colInfo, tdAligns, rowCount: document.querySelectorAll('.table tbody tr').length };
})()`);
console.log('【功能页 #/ranking】');
for (const c of rankPage.colInfo) {
if (c.wide) console.log(` ${(c.key||c.label).padEnd(20)} w=${String(c.w).padStart(4)} thAlign=${c.thAlign}`);
}
console.log(' td 对齐:', rankPage.tdAligns.join(' | '));
const wideCols = rankPage.colInfo.filter((c) => c.wide);
const rankOk = wideCols.length === 6 && wideCols.every((c) => c.w <= 100 && c.thAlign === 'right') && rankPage.tdAligns.every((t) => !t.startsWith('num') || t.endsWith('right'));
// ===== 2) 首页预览表 =====
await goto('#/');
const home = await ev(`(() => {
const row = document.querySelector('#rankPreviewList .rank-row:not(.rank-head)');
if (!row) return { err: 'no row' };
const W = (sel) => Math.round(row.querySelector(sel).getBoundingClientRect().width);
const A = (sel) => getComputedStyle(row.querySelector(sel)).textAlign;
return {
score: { w: W('.rk-score'), align: A('.rk-score') },
fans: { w: W('.rk-num:nth-child(5)'), align: A('.rk-num:nth-child(5)') },
likes: { w: W('.rk-num:nth-child(6)'), align: A('.rk-num:nth-child(6)') },
comments: { w: W('.rk-num:nth-child(7)'), align: A('.rk-num:nth-child(7)') },
shares: { w: W('.rk-num:nth-child(8)'), align: A('.rk-num:nth-child(8)') },
};
})()`);
console.log('【首页预览表】');
console.log(' ', JSON.stringify(home));
const homeOk = ['score','fans','likes','comments','shares'].every((k) => home[k] && home[k].w <= 85 && home[k].align === 'right');
console.log(rankOk && homeOk ? 'PASS' : 'CHECK: 功能页=' + rankOk + ' 首页=' + homeOk);
ws.close();
@@ -0,0 +1,49 @@
// 验证「首页账号周榜预览」5 数值列加宽 + 功能列按钮「分析」→「关注」:只读测量
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
// 1) 导航首页 + 强制刷新(hash 变化不重载文档)
await send('Page.navigate', { url: 'http://localhost:8899/#/' });
await sleep(1500);
await send('Page.reload', { ignoreCache: true });
await sleep(3500);
// 2) 测量首页预览表各列宽 + 功能列按钮
const check = await ev(`(() => {
const head = document.querySelector('#rankPreviewList .rank-head');
if (!head) return { err: 'no rank preview', html: (document.getElementById('rankPreviewList') || {}).innerHTML ? 'has content' : 'empty' };
const row = document.querySelector('#rankPreviewList .rank-row:not(.rank-head)');
const W = (sel) => { const el = row && row.querySelector(sel); return el ? Math.round(el.getBoundingClientRect().width) : null; };
const cols = [...head.children];
const actBtn = row ? row.querySelector('.rk-act .btn') : null;
const headTxt = [...head.children].map((c) => c.textContent.trim());
return {
headCols: headTxt,
widths: { score: W('.rk-score'), fans: W('.rk-num:nth-child(5)'), likes: W('.rk-num:nth-child(6)'), comments: W('.rk-num:nth-child(7)'), shares: W('.rk-num:nth-child(8)') },
actBtnText: actBtn ? actBtn.textContent.trim() : null,
actBtnTitle: actBtn ? actBtn.title : null,
actBtnHref: actBtn ? (actBtn.getAttribute('href') || null) : null,
rowCount: document.querySelectorAll('#rankPreviewList .rank-row').length - 1,
dataCardsStill: !!document.querySelector('.data-cards'),
statsStill: !!document.getElementById('homeStats'),
};
})()`);
console.log(JSON.stringify(check, null, 1));
// 3) 校验:5 列 >= 80px 且按钮为「关注」
const widths = check.widths || {};
const colsOk = ['score', 'fans', 'likes', 'comments', 'shares'].every((k) => widths[k] >= 80);
const btnOk = check.actBtnText === '关注' && (check.actBtnTitle || '').includes('关注');
console.log('列宽达标(>=80):', colsOk, '| 按钮为关注:', btnOk);
console.log(colsOk && btnOk ? 'PASS' : 'CHECK');
ws.close();
@@ -0,0 +1,51 @@
// 验证:首页周榜 tab 切换(账号周榜/作品热榜/点赞日榜/涨粉日榜,后 3 个占位)
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
await send('Page.navigate', { url: 'http://localhost:8899/#/' }); await sleep(1500);
await send('Page.reload', { ignoreCache: true }); await sleep(3000);
// 初始状态 + 逐个点击 tab
const states = [];
const initial = await ev(`(() => ({
tabs: [...document.querySelectorAll('.rank-tab')].map((t) => ({ txt: t.textContent.trim(), active: t.classList.contains('active') })),
toolsHidden: document.getElementById('rankTools').hidden,
listHidden: document.getElementById('rankPreviewList').hidden,
phHidden: document.getElementById('rankPlaceholder').hidden,
}))()`);
states.push({ clicked: '(初始)', ...initial });
for (const name of ['作品热榜', '点赞日榜', '涨粉日榜', '账号周榜']) {
const s = await ev(`(() => {
const t = [...document.querySelectorAll('.rank-tab')].find((x) => x.textContent.trim() === ${JSON.stringify(name)});
t.click();
return {
active: [...document.querySelectorAll('.rank-tab')].find((x) => x.classList.contains('active')).textContent.trim(),
toolsHidden: document.getElementById('rankTools').hidden,
listHidden: document.getElementById('rankPreviewList').hidden,
phHidden: document.getElementById('rankPlaceholder').hidden,
phText: document.getElementById('rankPlaceholder').textContent.trim(),
};
})()`);
await sleep(150);
states.push({ clicked: name, ...s });
}
console.log(JSON.stringify(states, null, 1));
const ok = states[0].tabs.length === 4
&& states[0].tabs[0].active && !states[0].toolsHidden && !states[0].listHidden && states[0].phHidden
&& states[1].clicked === '作品热榜' && states[1].toolsHidden && states[1].listHidden && !states[1].phHidden && states[1].phText.includes('建设中')
&& states[2].clicked === '点赞日榜' && states[2].toolsHidden && states[2].phText.includes('建设中')
&& states[3].clicked === '涨粉日榜' && states[3].phText.includes('建设中')
&& states[4].clicked === '账号周榜' && !states[4].toolsHidden && !states[4].listHidden && states[4].phHidden;
console.log(ok ? 'PASS' : 'CHECK');
ws.close();
@@ -0,0 +1,56 @@
// 验证「账号周榜」5 个数值列加宽(.th-wide min-width:110px)
// 只读测量,零副作用
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('#/ranking'));
if (!page) page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
// 1) 导航到周榜页(若不在)并刷新加载最新 CSS
if (!page.url.includes('#/ranking')) {
await send('Page.navigate', { url: 'http://localhost:8899/#/ranking' });
await sleep(2000);
}
await send('Page.reload', { ignoreCache: true });
await sleep(3000);
// 2) 测量表头各列宽度
const cols = await ev(`(() => {
const ths = [...document.querySelectorAll('.table th')];
const wideKeys = ['comprehensiveScore', 'newFans', 'newLikes', 'newComments', 'newShares'];
const rowCount = document.querySelectorAll('.table tbody tr').length;
const result = ths.map((th) => {
const rect = th.getBoundingClientRect();
return { key: th.dataset.sort || '', label: th.textContent.trim().slice(0, 8), wide: th.classList.contains('th-wide'), w: Math.round(rect.width) };
});
return { rowCount, result };
})()`);
console.log('行数:', cols.rowCount);
console.log('表头列宽:');
for (const c of cols.result) {
const mark = c.wide ? ' [th-wide]' : '';
const match = c.wide ? (c.w >= 110 ? ' >=110 ✓' : ' <110 ✗') : '';
console.log(' ' + (c.key || c.label).padEnd(22) + ' width=' + String(c.w).padStart(4) + mark + match);
}
const wideCols = cols.result.filter((c) => c.wide);
const allOk = wideCols.length === 5 && wideCols.every((c) => c.w >= 110);
// 3) 记录 CSS 规则是否生效
const cssCheck = await ev(`(() => {
const sheet = [...document.styleSheets].map((s) => s.href || 'inline').join(',');
const th = document.querySelector('.table th.th-wide');
const cs = th ? getComputedStyle(th) : null;
return { sheet, minWidth: cs ? cs.minWidth : 'n/a' };
})()`);
console.log('样式源:', cssCheck.sheet.slice(0, 160));
console.log('computed min-width:', cssCheck.minWidth);
ws.close();
console.log(allOk ? 'PASS: 5 列均已加宽(>=110px)' : 'CHECK: 存在未达标的列');
@@ -0,0 +1,41 @@
// 验证:「AI写作复盘」→「AI脚本诊断」全量改名 + 首页周榜列表底色
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
const goto = async (hash) => { await send('Page.navigate', { url: 'http://localhost:8899/' + hash }); await sleep(1500); await send('Page.reload', { ignoreCache: true }); await sleep(3000); };
// 1) 首页:数据预览 label + 快捷卡片 + 周榜列表底色 + 残留检查
await goto('#/');
const home = await ev(`(() => {
const stats = [...document.querySelectorAll('#homeStats .home-stat-item')].map((i) => i.querySelector('.home-stat-label').textContent);
const cards = [...document.querySelectorAll('.data-card')].map((c) => c.querySelector('.data-card-title').textContent);
const rk = document.getElementById('rankPreviewList');
const rkBg = rk ? getComputedStyle(rk).backgroundColor : null;
const bodyTxt = document.body.textContent;
return { stats, cards, rkBg, residualOld: bodyTxt.includes('AI写作复盘'), residualNew: bodyTxt.includes('AI脚本诊断') };
})()`);
console.log('【首页】');
console.log(' 数据预览 label:', home.stats.join(' / '));
console.log(' 快捷卡片:', home.cards.join(' / '));
console.log(' 周榜列表底色:', home.rkBg);
console.log(' 残留旧名:', home.residualOld, '| 出现新名:', home.residualNew);
// 2) 功能页 #/reviews:页面标题
await goto('#/reviews');
const rv = await ev(`(() => {
const t = document.querySelector('.page-title');
return { title: t ? t.textContent : null, hasOld: document.body.textContent.includes('AI写作复盘') };
})()`);
console.log('【功能页 #/reviews】页面标题:', rv.title, '| 残留旧名:', rv.hasOld);
ws.close();
const ok = !home.residualOld && home.residualNew && home.rkBg && home.rkBg !== 'rgba(0, 0, 0, 0)' && rv.title === 'AI脚本诊断' && !rv.hasOld;
console.log(ok ? 'PASS' : 'CHECK');
@@ -0,0 +1,45 @@
// 验证王微斯卡片玫瑰金背景渲染(断言渐变 stop + 背景上文字提亮 + 截图)
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && (decodeURIComponent(t.url).includes('王微斯') || t.url.includes('8899')));
if (!page) { console.log('ERR: no wangweisi/8899 page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
const url = 'file:///C:/Users/maidou/Desktop/MCNSkill项目/王微斯/王微斯账号设定卡.svg';
await send('Page.navigate', { url });
await sleep(1200);
await send('Page.reload', { ignoreCache: true });
await sleep(2500);
const check = await ev(`(() => {
const g = document.getElementById('bgGrad');
const stops = g ? g.querySelectorAll('stop') : [];
const titles = [...document.querySelectorAll('text')].filter((t) => t.textContent.includes('▍')).map((t) => t.getAttribute('fill'));
const deco = [...document.querySelectorAll('text')].find((t) => t.textContent.includes('channels requirement'))?.getAttribute('fill');
const copy = [...document.querySelectorAll('text')].filter((t) => t.textContent.includes('数据来源')).map((t) => t.getAttribute('fill'));
const arrows = [...document.querySelectorAll('line')].map((l) => [Number(l.getAttribute('x1')), Number(l.getAttribute('x2'))]);
return {
bg0: stops[0]?.getAttribute('stop-color'),
bg1: stops[1]?.getAttribute('stop-color'),
titles,
deco,
copy,
arrows,
arrowsInGap: arrows.every(([a, b]) => b - a >= 11), // 线长≥11px(原 2px 贴卡)
whiteCards: document.querySelectorAll('rect[fill="#FFFFFF"]').length,
pinkChips: document.querySelectorAll('rect[fill="#F7E8F2"]').length
};
})()`);
console.log(JSON.stringify(check, null, 2));
const r = await send('Page.captureScreenshot', { format: 'png' });
const fs = await import('node:fs');
fs.writeFileSync('shot-wangweisi-rosegold.png', Buffer.from(r.data, 'base64'));
console.log('截图完成: shot-wangweisi-rosegold.png');
ws.close();
@@ -0,0 +1,42 @@
// 验证:首页周榜 tab 选中样式 = 浅底色(primary-soft) + 2px 主色下划线,非选中透明
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
await send('Page.navigate', { url: 'http://localhost:8899/#/' }); await sleep(1500);
await send('Page.reload', { ignoreCache: true }); await sleep(3000);
const stylesOf = `(sel) => {
const el = document.querySelector(sel);
if (!el) return null;
const cs = getComputedStyle(el);
return { bg: cs.backgroundColor, color: cs.color, border: cs.borderBottomWidth + ' ' + cs.borderBottomStyle + ' ' + cs.borderBottomColor, weight: cs.fontWeight };
}`;
const states = [];
// 初始 active = 账号周榜
states.push({ clicked: '(初始)', active: '账号周榜', style: await ev(`(${stylesOf})('.rank-tab.active')`) });
// 记录非 active 的样式(作品热榜)
states.push({ clicked: '(初始)', note: '非active:作品热榜', style: await ev(`(${stylesOf})([...document.querySelectorAll('.rank-tab')].find(x => x.textContent.trim() === '作品热榜'))`) });
for (const name of ['作品热榜', '点赞日榜', '涨粉日榜', '账号周榜']) {
await ev(`(() => { [...document.querySelectorAll('.rank-tab')].find((x) => x.textContent.trim() === ${JSON.stringify(name)}).click(); })()`);
await sleep(150);
states.push({ clicked: name, active: await ev(`([...document.querySelectorAll('.rank-tab')].find(x => x.classList.contains('active'))?.textContent.trim() || null)`), style: await ev(`(${stylesOf})('.rank-tab.active')`) });
}
console.log(JSON.stringify(states, null, 1));
// 预期:active bg=rgb(234, 241, 254) color=rgb(47, 111, 237) border=2px solid rgb(47, 111, 237) weight=600
const isActiveStyle = (s) => s && s.bg === 'rgb(234, 241, 254)' && s.color === 'rgb(47, 111, 237)' && s.border === '2px solid rgb(47, 111, 237)' && s.weight === '600';
const isIdleStyle = (s) => s && s.bg === 'rgba(0, 0, 0, 0)' && s.border === '2px solid rgba(0, 0, 0, 0)';
const ok = states.every((s) => s.active === s.clicked) && isActiveStyle(states[0].style) && isIdleStyle(states[1].style) && states.slice(2).every((s) => isActiveStyle(s.style));
console.log(ok ? 'PASS' : 'CHECK');
ws.close();
@@ -0,0 +1,36 @@
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
const page = list.find((t) => t.type === 'page' && t.url.includes('#/rewrites'));
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
await send('Page.reload', { ignoreCache: true });
await sleep(2000);
await ev(`document.querySelector('[data-detail]').click()`);
await sleep(1200);
// 拿 AI脚本列 body 中心坐标
const rect = await ev(`(() => {
const ai = [...document.querySelectorAll('.rw-stack > .rw-sec')].find((s) => s.querySelector('.rw-sec-head')?.textContent === 'AI脚本');
const r = ai.querySelector('.rw-sec-body').getBoundingClientRect();
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + 200), top: Math.round(r.top), h: Math.round(r.height) };
})()`);
console.log('aiBody rect:', JSON.stringify(rect));
// 真实滚轮:向下滚 6 次(每次 -300 逻辑上内容上移?deltaY 正=向下滚动)
for (let i = 0; i < 8; i++) {
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: rect.x, y: rect.y, deltaX: 0, deltaY: 300 });
await sleep(120);
}
await sleep(300);
const st = await ev(`(() => {
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
const aiBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent === 'AI脚本').querySelector('.rw-sec-body');
const sbBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent === 'AI分镜提示词').querySelector('.rw-sec-body');
const vis = [...sbBody.querySelectorAll('.sb-block')].find((b) => b.getBoundingClientRect().top >= sbBody.getBoundingClientRect().top - 2);
return { aiScrollTop: Math.round(aiBody.scrollTop), sbScrollTop: Math.round(sbBody.scrollTop), sbVis: vis ? (vis.querySelector('.sb-head')?.textContent.trim() || '').slice(0, 28) : null };
})()`);
console.log('滚轮后:', JSON.stringify(st));
process.exit(0);
@@ -0,0 +1,34 @@
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
const page = list.find((t) => t.type === 'page' && t.url.includes('#/rewrites'));
const ws = new WebSocket(page.webSocketDebuggerUrl);
let id = 0; const pending = new Map();
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
await new Promise((r) => (ws.onopen = r));
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 200) }; return r.result.value; };
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
await send('Page.enable'); await send('Runtime.enable');
// 弹窗可能还在(上轮遗留),先清理再开
await ev(`document.querySelectorAll('.modal-mask').forEach((m) => m.remove()); document.querySelector('[data-detail]') && document.querySelector('[data-detail]').click()`);
await sleep(1500);
const rect = await ev(`(() => {
const ai = [...document.querySelectorAll('.rw-stack > .rw-sec')].find((s) => s.querySelector('.rw-sec-head')?.textContent === 'AI脚本');
if (!ai) return null;
const r = ai.querySelector('.rw-sec-body').getBoundingClientRect();
return { x: Math.round(r.left + r.width / 2), y: Math.round(r.top + 300) };
})()`);
console.log('rect:', JSON.stringify(rect));
for (let i = 0; i < 5; i++) {
await send('Input.dispatchMouseEvent', { type: 'mouseWheel', x: rect.x, y: rect.y, deltaX: 0, deltaY: 400 });
await sleep(80);
}
await sleep(400);
const st = await ev(`(() => {
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
const aiBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent === 'AI脚本').querySelector('.rw-sec-body');
const sbBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent === 'AI分镜提示词').querySelector('.rw-sec-body');
const vis = [...sbBody.querySelectorAll('.sb-block')].find((b) => b.getBoundingClientRect().top >= sbBody.getBoundingClientRect().top - 2);
return { aiScrollTop: Math.round(aiBody.scrollTop), sbScrollTop: Math.round(sbBody.scrollTop), sbVis: vis ? (vis.querySelector('.sb-head')?.textContent.trim() || '').slice(0, 28) : null };
})()`);
console.log('滚轮后:', JSON.stringify(st));
process.exit(0);
@@ -225,12 +225,22 @@ function getReviewFull(rewriteId) {
function getStats() {
const d = getDb(); if (!d) return null;
const c = (sql) => d.prepare(sql).get().c;
// AI写作复盘 = 已完成的 AI 脚本复盘数(script_review type='ai')
let reviews = 0;
try { reviews = c(`SELECT COUNT(*) c FROM script_review WHERE type='ai' AND status='done'`); } catch (e) { /* 统计失败不影响其他 */ }
// AI写作评分 = 最新 20 条 AI 复盘总分的平均(保留 1 位小数;无评分返回 null)
let writingScore = null;
try {
const rows = d.prepare(`SELECT total FROM script_review WHERE type='ai' AND status='done' AND total IS NOT NULL AND total <> '' ORDER BY updated_time DESC, id DESC LIMIT 20`).all();
const nums = rows.map((r) => Number(r.total)).filter((v) => Number.isFinite(v));
if (nums.length > 0) writingScore = Math.round((nums.reduce((s, v) => s + v, 0) / nums.length) * 10) / 10;
} catch (e) { /* 评分统计失败不影响其他统计 */ }
return {
accounts: c(`SELECT COUNT(*) c FROM hot_accounts WHERE del_flag=0`),
analyses: c(`SELECT COUNT(*) c FROM account_video_analysis`),
rewrites: c(`SELECT COUNT(*) c FROM rewrite_log WHERE status='done' AND script_text IS NOT NULL AND script_text <> ''`),
reviews: c(`SELECT COUNT(*) c FROM script_review WHERE status='done'`),
writingScore: c(`SELECT COUNT(*) c FROM script_review WHERE type='ai' AND status='done'`),
reviews,
writingScore,
};
}
@@ -100,30 +100,24 @@ function confirmSaveAccount(name, url, btn) {
});
}
// 导入外部账号数据(移植自 dsh 插件「热门账号 → 导入」):
// 方式一:抖音账号信息(名称/抖音号/地址)→ AI 网页采集账号信息与视频列表入库
// 方式二:本地文件夹绝对路径 → AI 读取目录分析文件结构后入库
// 添加外部账号数据(移植自 dsh 插件「热门账号 → 导入」):
// 填写抖音账号信息(名称/地址)→ AI 网页采集账号信息与视频列表入库
// 提交复用 executeTask 任务链路(/api/run → 左侧会话栏可见 → 完成自动刷新)
function openImportAccounts(btn) {
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `
<div class="modal" style="max-width:520px">
<h3>${svgIcon('download', 18)} 导入外部账号数据</h3>
<h3>${svgIcon('user-plus', 18)} 添加账号数据</h3>
<div class="import-form">
<p class="dim" style="margin-bottom:10px">方式一:填写抖音账号信息,AI 通过抖音网页获取账号信息与视频列表。</p>
<p class="dim" style="margin-bottom:10px">填写抖音账号信息,AI 通过抖音网页获取账号信息与视频列表。</p>
<div class="f-row"><label>账号名称</label>
<input class="input" placeholder="如:旧梦留声机" data-imp-name></div>
<div class="f-row"><label>抖音号</label>
<input class="input" placeholder="如:jiu_meng_ai" data-imp-did></div>
<div class="f-row"><label>账号地址(可选)</label>
<input class="input" placeholder="https://www.douyin.com/user/..." data-imp-url></div>
<div class="divider"></div>
<p class="dim" style="margin-bottom:10px">方式二:输入本地数据文件夹绝对路径,AI 读取目录分析文件结构后导入。</p>
<div class="f-row"><input class="input" placeholder="本地文件夹路径,如 D:\\账号对标" data-imp-dir></div>
</div>
<div class="modal-actions">
<button class="ghost" data-cancel>取消</button>
<button class="primary" data-ok>提交导入</button>
<button class="primary" data-ok>提交添加</button>
</div>
</div>`;
document.body.appendChild(mask);
@@ -131,20 +125,13 @@ function openImportAccounts(btn) {
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
mask.querySelector('[data-cancel]').addEventListener('click', close);
mask.querySelector('[data-ok]').addEventListener('click', () => {
const dir = (mask.querySelector('[data-imp-dir]').value || '').trim();
const name = (mask.querySelector('[data-imp-name]').value || '').trim();
const did = (mask.querySelector('[data-imp-did]').value || '').trim();
const url = (mask.querySelector('[data-imp-url]').value || '').trim();
if (!dir && !name && !did && !url) { showToast('请填写抖音账号信息或文件夹路径'); return; }
let prompt;
if (dir) {
prompt = `请帮我把本地文件夹「${dir}」中的账号数据导入:读取目录、分析文件结构,确认后写入账号列表`;
} else {
const parts = [name ? `账号名称「${name}」` : '', did ? `抖音号「${did}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
prompt = `请帮我把抖音账号 ${parts} 导入:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
}
if (!name && !url) { showToast('请填写账号名称或账号地址'); return; }
const parts = [name ? `账号名称「${name}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
const prompt = `请帮我把抖音账号 ${parts} 添加:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
close();
executeTask({ prompt, label: '导入指令', btn });
executeTask({ prompt, label: '添加指令', btn });
});
}
@@ -168,9 +155,11 @@ const ICON_PATHS = {
'file-code': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="m10 13-2 2 2 2"/><path d="m14 17 2-2-2-2"/>',
'table': '<path d="M12 3v18"/><rect width="18" height="18" x="3" y="3" rx="2"/><path d="M3 9h18"/><path d="M3 15h18"/>',
'file': '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/>',
'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"/>',
'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"/>',
};
@@ -298,10 +287,17 @@ function renderHome() {
{ icon: 'trending-up', title: '账号周榜', desc: '三方数据 · 赛道/日期筛选', hash: '#/ranking' },
{ icon: 'users', title: '账号列表', desc: '关注的账号 + 视频列表', hash: '#/accounts' },
{ icon: 'pen', title: 'AI写脚本', desc: 'AI生成脚本+分镜提示词', hash: '#/rewrites' },
{ icon: 'flask', title: 'AI写作复盘', desc: '检查清单+复盘报告', hash: '#/reviews' },
{ icon: 'flask', title: 'AI脚本诊断', desc: '检查清单+复盘报告', hash: '#/reviews' },
];
return `
<div class="home">
<div class="home-stats" id="homeStats" aria-label="数据预览">
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">热门账号</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">视频解析</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI写脚本</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI脚本诊断</span></div>
<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">AI写作评分</span></div>
</div>
<div class="data-cards">
${cards.map((c) => `<button class="data-card" data-hash="${c.hash}">
<span class="data-card-icon">${svgIcon(c.icon, 22)}</span>
@@ -311,8 +307,13 @@ function renderHome() {
</div>
<div class="rank-preview">
<div class="recent-head">
<h3>账号周榜</h3>
<div class="rank-tools">
<div class="rank-tabs" role="tablist">
<button class="rank-tab active" data-rank-tab="week" role="tab">账号周榜</button>
<button class="rank-tab" data-rank-tab="hot" role="tab">视频热榜</button>
<button class="rank-tab" data-rank-tab="likes" role="tab">点赞榜</button>
<button class="rank-tab" data-rank-tab="fans" role="tab">涨粉榜</button>
</div>
<div class="rank-tools" id="rankTools">
<select id="rankCat" class="input-select rank-filter" title="赛道筛选"></select>
<button class="btn btn-sm" id="rankPrevDate" title="上一期">◀</button>
<span id="rankDateLabel" class="rank-date-label"></span>
@@ -320,6 +321,7 @@ function renderHome() {
</div>
</div>
<div id="rankPreviewList" class="rank-preview-list"><span class="dim" style="padding:12px">加载中…</span></div>
<div id="rankPlaceholder" class="rank-placeholder" hidden></div>
</div>
<div class="recent-scripts">
<div class="recent-head"><h3>AI生成脚本</h3><a class="link" href="#/rewrites">查看全部</a></div>
@@ -328,6 +330,29 @@ function renderHome() {
</div>`;
}
/* ================= 首页 数据预览(顶部统计条,参考 dsh-plugin-mcn 首页 stats) ================= */
// 挂载时拉取一次(后端 SQLite 直查,无缓存),失败时保留占位「–」
async function loadHomeStats() {
const el = $('#homeStats');
if (!el) return;
try {
const d = await api('/api/dsh/stats');
const s = d.stats || {};
const items = [
['热门账号', s.accounts],
['视频解析', s.analyses],
['AI写脚本', s.rewrites],
['AI脚本诊断', s.reviews],
['AI写作评分', s.writingScore],
];
el.innerHTML = items.map(([label, value]) =>
`<div class="home-stat-item"><span class="home-stat-value">${value ?? '–'}</span><span class="home-stat-label">${label}</span></div>`
).join('');
} catch (e) {
el.innerHTML = `<div class="home-stat-item"><span class="home-stat-value">–</span><span class="home-stat-label">数据不可用</span></div>`;
}
}
/* ================= 帮助页(功能帮助 + 快速提问) ================= */
function renderHelp() {
return `
@@ -442,7 +467,7 @@ async function loadRankingPreview() {
<span class="rk-num">${esc(r.newLikes ?? '-')}</span>
<span class="rk-num">${esc(r.newComments ?? '-')}</span>
<span class="rk-num">${esc(r.newShares ?? '-')}</span>
<span class="rk-act">${r.inDb && r.dbId ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-rk-import data-rk-name="${esc(r.accountName)}" data-rk-url="${esc(r.profileUrl || '')}" title="分析该账号并加入账号列表">分析</button>`}</span>
<span class="rk-act">${r.inDb && r.dbId ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-rk-import data-rk-name="${esc(r.accountName)}" data-rk-url="${esc(r.profileUrl || '')}" title="关注该账号并加入账号列表">关注</button>`}</span>
</div>`).join('') : '<span class="dim" style="padding:12px">暂无周榜数据</span>';
listEl.querySelectorAll('[data-rk-import]').forEach((b) => b.addEventListener('click', () => {
confirmSaveAccount(b.dataset.rkName, b.dataset.rkUrl || '', b);
@@ -600,7 +625,7 @@ function openSettings() { renderRootList(); $('#settingsModal').hidden = false;
function closeSettings() { $('#settingsModal').hidden = true; }
/* ================= 路由 ================= */
const CRUMB_TITLES = { 'ranking': '账号周榜', 'accounts': '账号列表', 'rewrites': 'AI写脚本', 'reviews': 'AI写作复盘' };
const CRUMB_TITLES = { 'ranking': '账号周榜', 'accounts': '账号列表', 'rewrites': 'AI写脚本', 'reviews': 'AI脚本诊断' };
function parseHash() {
const h = (location.hash || '#/').replace(/^#/, '');
@@ -622,9 +647,20 @@ async function router() {
updateCrumbs('');
view.innerHTML = renderHome();
view.querySelectorAll('.data-card').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.hash; }));
// 周榜 tab 切换:账号周榜显示列表+工具条,其余 3 个新榜先占位
view.querySelectorAll('.rank-tab').forEach((t) => t.addEventListener('click', () => {
view.querySelectorAll('.rank-tab').forEach((x) => x.classList.toggle('active', x === t));
const week = t.dataset.rankTab === 'week';
view.querySelector('#rankTools').hidden = !week;
view.querySelector('#rankPreviewList').hidden = !week;
const ph = view.querySelector('#rankPlaceholder');
ph.hidden = week;
if (!week) ph.textContent = `「${t.textContent.trim()}」建设中,敬请期待`;
}));
loadHomeStats();
loadRankingPreview();
loadRecentScripts();
view._reload = () => { loadRankingPreview(); loadRecentScripts(); };
view._reload = () => { loadHomeStats(); loadRankingPreview(); loadRecentScripts(); };
return;
}
if (name === 'help') {
@@ -645,7 +681,7 @@ async function router() {
}
// 数据功能页
if (typeof window.DataPages !== 'undefined') {
const titles = { ranking: '账号周榜', accounts: '账号列表', account: '账号详情', video: '视频详情', rewrites: 'AI写脚本', reviews: 'AI写作复盘' };
const titles = { ranking: '账号周榜', accounts: '账号列表', account: '账号详情', video: '视频详情', rewrites: 'AI写脚本', reviews: 'AI脚本诊断' };
const pages = {
'ranking': () => window.DataPages.renderRanking(view),
'accounts': () => window.DataPages.renderAccounts(view),
@@ -132,17 +132,18 @@ async function renderRanking(view) {
async function load() {
const d = await api('/api/dsh/ranking?date=' + encodeURIComponent(state.date) + '&category=' + encodeURIComponent(state.category) + '&sortBy=' + state.sortBy + '&order=' + state.order + '&page=' + state.page + '&pageSize=' + state.pageSize);
const di = state.dates.indexOf(state.date);
const sortable = (key, label) => `<th class="sortable" data-sort="${key}">${label}${state.sortBy === key ? (state.order === 'asc' ? ' ↑' : ' ↓') : ''}</th>`;
const WIDE = new Set(['comprehensiveScore', 'followers', 'newFans', 'newLikes', 'newComments', 'newShares']); // 数值列(窄+右对齐)
const sortable = (key, label) => `<th class="sortable${WIDE.has(key) ? ' th-wide' : ''}" data-sort="${key}">${label}${state.sortBy === key ? (state.order === 'asc' ? ' ↑' : ' ↓') : ''}</th>`;
const rows = (d.items || []).map((r) => `<tr>
<td>${r.rank ?? '-'}</td>
<td>${esc(r.category || '-')}</td>
<td>${r.profileUrl ? `<a class="link" data-ext="${esc(r.profileUrl)}" title="在新标签打开账号主页">${esc(r.accountName)}</a>` : esc(r.accountName)}</td>
<td class="score-cell">${fmt(r.comprehensiveScore)}</td>
<td>${fmt(r.followers)}</td>
<td>${fmt(r.newFans)}</td>
<td>${fmt(r.newLikes)}</td>
<td>${fmt(r.newComments)}</td>
<td>${fmt(r.newShares)}</td>
<td class="num score-cell">${fmt(r.comprehensiveScore)}</td>
<td class="num">${fmt(r.followers)}</td>
<td class="num">${fmt(r.newFans)}</td>
<td class="num">${fmt(r.newLikes)}</td>
<td class="num">${fmt(r.newComments)}</td>
<td class="num">${fmt(r.newShares)}</td>
<td class="cell-act">${r.inDb ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-import='${esc(JSON.stringify(r))}'>分析</button>`}</td>
</tr>`).join('');
view.innerHTML = `
@@ -161,7 +162,7 @@ async function renderRanking(view) {
</div>
</div>
<div class="table-wrap">
<table class="table">
<table class="table table-rank">
<thead><tr>
${sortable('rank', '排名')}<th>赛道</th><th>账号</th>${sortable('comprehensiveScore', '综合得分')}${sortable('followers', '粉丝')}${sortable('newFans', '新增粉丝')}${sortable('newLikes', '新增点赞')}${sortable('newComments', '新增评论')}${sortable('newShares', '新增分享')}<th class="th-act">功能</th>
</tr></thead>
@@ -224,7 +225,7 @@ async function renderAccounts(view) {
</select>
<select id="accTrack" class="input-select"><option value="">全部赛道</option>${tracks.map((t) => `<option value="${esc(t)}" ${t === state.track ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
<input id="accSearch" class="input" placeholder="搜索账号名 / 内容 / 赛道" value="${esc(state.search)}">
<button class="btn" id="accImport" title="导入外部账号数据(抖音账号信息 / 本地文件夹)">${svgIcon('download', 15, 'currentColor')} 导入</button>
<button class="btn" id="accImport" title="添加账号数据(抖音账号信息 / 账号地址)">${svgIcon('user-plus', 15, 'currentColor')} 添加</button>
<button class="btn" id="accReset" title="重置筛选">${svgIcon('refresh', 15, 'currentColor')}</button>
</div>
</div>
@@ -272,11 +273,16 @@ async function renderAccountDetail(view, id) {
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;
let persona = null, analysis = null;
let persona = null, analysis = null, personaCard = null;
let viewMode = 'card'; // 账号设定展示模式:card(卡片版,默认)/ text(文字版)
async function loadPersona() {
const r = await api('/api/dsh/account-persona?accountId=' + accountId);
persona = r.content || null;
const [r, c] = await Promise.all([
api('/api/dsh/account-persona?accountId=' + accountId),
api('/api/dsh/account-card?name=' + encodeURIComponent(acc.account_name || '')),
]);
persona = (r && r.content) || null;
personaCard = (c && c.exists && c.path) ? { path: c.path } : null;
}
async function loadAnalysis() {
const r = await api('/api/dsh/account-analysis?accountId=' + accountId);
@@ -313,12 +319,30 @@ async function renderAccountDetail(view, id) {
<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>
${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}`;
} else if (tab === 'persona') {
panel = persona ? `<div class="md-body">${renderMarkdown(persona)}</div>` : `<div class="empty-state">
<div class="empty-icon">${svgIcon('users', 40)}</div>
<div class="empty-title">该账号暂无账号设定信息</div>
<div class="empty-desc">为达人提炼账号设定后,可基于设定生成脚本</div>
<button class="btn btn-view" data-extract>提炼账号设定</button>
</div>`;
if (!persona) {
panel = `<div class="empty-state">
<div class="empty-icon">${svgIcon('users', 40)}</div>
<div class="empty-title">该账号暂无账号设定信息</div>
<div class="empty-desc">为达人提炼账号设定后,可基于设定生成脚本</div>
<button class="btn btn-view" data-extract>提炼账号设定</button>
</div>`;
} else if (!personaCard) {
// 无卡片:直接文字版(无切换控件)
panel = `<div class="md-body persona-text">${renderMarkdown(persona)}</div>`;
} else {
// 有卡片:右上角图标切换(卡片版 / 文字版),默认卡片版
panel = `
<div class="persona-wrap">
<div class="persona-vs-icons">
<button class="vi-btn ${viewMode === 'card' ? 'vi-active' : ''}" data-vs="card" title="卡片版">${svgIcon('image', 16)}</button>
<button class="vi-btn ${viewMode === 'text' ? 'vi-active' : ''}" data-vs="text" title="文字版">${svgIcon('file-text', 16)}</button>
</div>
<div class="card-view ${viewMode === 'card' ? '' : 'vs-hidden'}">
<img src="/api/file?path=${encodeURIComponent(personaCard.path)}" alt="${esc(acc.account_name)}账号设定卡">
</div>
<div class="md-body persona-text ${viewMode === 'text' ? '' : 'vs-hidden'}">${renderMarkdown(persona)}</div>
</div>`;
}
} else {
panel = analysis ? `<div class="md-body">${renderMarkdown(analysis)}</div>` : `<div class="empty">该账号暂无数据分析</div>`;
}
@@ -341,6 +365,15 @@ async function renderAccountDetail(view, id) {
const open = text.classList.toggle('open');
b.textContent = open ? '收起' : '展开';
}));
// 账号设定:卡片版 / 文字版 切换(直接 DOM 切换,不重渲染,保留滚动位置)
view.querySelectorAll('[data-vs]').forEach((b) => b.addEventListener('click', () => {
viewMode = b.dataset.vs;
view.querySelectorAll('[data-vs]').forEach((x) => x.classList.toggle('vi-active', x.dataset.vs === viewMode));
const cardEl = view.querySelector('.card-view');
const mdEl = view.querySelector('.persona-text');
if (cardEl) cardEl.classList.toggle('vs-hidden', viewMode !== 'card');
if (mdEl) mdEl.classList.toggle('vs-hidden', viewMode !== 'text');
}));
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
const r = JSON.parse(b.dataset.rewrite);
const parsed = !!r.parsed;
@@ -427,7 +460,7 @@ async function renderRewrites(view) {
<td>${fmt(v.publish_time)}</td>
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
<td>${v.rewrite_count > 0 ? `<button class="btn btn-sm btn-view" data-detail="${v.id}" title="查看改写脚本">${v.rewrite_count} 查看</button>` : '—'}</td>
<td class="cell-act"><button class="btn btn-sm btn-view" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title) }))}'>AI改写</button></td>
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title) }))}'>AI写脚本</button></td>
</tr>`).join('');
view.innerHTML = `
<div class="page">
@@ -580,7 +613,7 @@ async function renderRewrites(view) {
await load();
}
/* ================= AI写作复盘 ================= */
/* ================= AI脚本诊断 ================= */
async function renderReviews(view) {
const d = await api('/api/dsh/rewrites').catch((e) => ({ ok: false, error: e.message }));
const DIMS = ['设定承接', '框架节奏', '钩子设置', '叙事逻辑', '极致事件', '共鸣和共情', '其他', '总分'];
@@ -624,7 +657,7 @@ async function renderReviews(view) {
view.innerHTML = `
<div class="page">
<div class="page-toolbar">${toolbarBack()}<span class="page-title">AI写作复盘</span><span class="pager-info">按生成时间倒序 · 共 ${items.length} 条</span></div>
<div class="page-toolbar">${toolbarBack()}<span class="page-title">AI脚本诊断</span><span class="pager-info">按生成时间倒序 · 共 ${items.length} 条</span></div>
<div class="table-wrap"><table class="table">
<thead><tr><th>ID</th><th>账号</th><th>生成方式</th><th>脚本标题</th><th>生成时间</th>${DIMS.map((h) => `<th style="text-align:center">${h}</th>`).join('')}<th>操作</th></tr></thead>
<tbody>${rows || '<tr><td colspan="13" class="empty">暂无 AI 写脚本(完成改写的脚本会出现在这里)</td></tr>'}</tbody>
@@ -235,7 +235,7 @@ main { height: calc(100vh - 54px); }
.root-add input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 15px; }
.root-msg { margin-top: 8px; font-size: 14px; color: var(--accent); min-height: 16px; }
.modal-actions { text-align: right; margin-top: 14px; }
/* 导入账号弹窗:方式一账号信息 / 方式二本地文件夹 */
/* 添加账号弹窗:账号信息(名称/地址) */
.import-form .f-row { margin-bottom: 10px; }
.import-form label { display: block; font-size: 13px; color: var(--dim); margin-bottom: 4px; }
.import-form .input { width: 100%; box-sizing: border-box; }
@@ -265,6 +265,12 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
/* ================= 首页 ================= */
.home { max-width: 960px; margin: 0 auto; padding: 24px; }
/* 数据预览条(顶部统计,参考 dsh-plugin-mcn 首页 stats:浅蓝底横条 + 均分竖排数字/标签) */
.home-stats { display: flex; width: 100%; box-sizing: border-box; background: rgba(47,111,237,.07); border-radius: 12px; padding: 20px 0; margin-bottom: 6px; }
.home-stat-item { flex: 1; min-width: 0; display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 2px 12px; border-right: 1px solid rgba(47,111,237,.16); }
.home-stat-item:last-child { border-right: none; }
.home-stat-value { font-size: 26px; font-weight: 700; color: var(--primary); line-height: 1.2; font-variant-numeric: tabular-nums; }
.home-stat-label { font-size: 13px; color: var(--dim); }
.data-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 14px; margin: 18px 0 24px; }
.data-card { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; cursor: pointer; text-align: left; box-shadow: var(--shadow); transition: all .15s; }
.data-card:hover { border-color: var(--primary); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(47,111,237,.15); }
@@ -290,8 +296,8 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
.btn-primary { background: var(--primary); color: #fff; border-color: transparent; }
.btn-primary:hover { background: #2a5fd0; color: #fff; }
/* 功能列按钮区分:保存=浅灰底深字,查看=白底蓝字 */
.btn-import { background: #f0f2f5; border-color: #e1e4e8; color: #495057; }
.btn-import:hover { background: #e4e7eb; border-color: #d0d4da; color: #343a40; }
.btn-import { background: #fff; border-color: #d0d4da; color: #495057; }
.btn-import:hover { background: var(--primary-soft); border-color: var(--primary); color: var(--primary); }
.btn-view { background: #fff; border-color: var(--primary); color: var(--primary); }
.btn-view:hover { background: var(--primary-soft); color: var(--primary); }
.empty { padding: 30px; text-align: center; color: var(--dim); font-size: 15px; }
@@ -306,6 +312,15 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
.cell-loc { max-width: 110px; } /* 账号定位列缩窄 */
.sortable { cursor: pointer; }
.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 生效) */
/* 周榜表: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; }
.table.table-rank th.th-wide { width: 90px; }
.table.table-rank th:nth-child(1) { width: 40px; } /* 排名 */
.table.table-rank th:nth-child(2) { width: 90px; } /* 赛道 */
.table.table-rank th:nth-child(10) { width: 85px; } /* 功能 */
.link { color: #4a90e2; cursor: pointer; text-decoration: none; }
.link:hover { text-decoration: underline; }
.link.strong { font-weight: 600; }
@@ -329,6 +344,16 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
.td-id { font-size: 13px; font-variant-numeric: tabular-nums; padding-left: 8px; padding-right: 8px; }
th.th-id { width: 120px; }
/* ================= 账号设定 卡片版/文字版 切换(右上角图标按钮组) ================= */
.persona-wrap { position: relative; padding: 0 20px; } /* 账号设定内容左右各 20px 间隔 */
.persona-vs-icons { position: absolute; top: 0; right: 0; z-index: 2; display: inline-flex; border: 1px solid var(--border); border-radius: 6px; overflow: hidden; background: var(--panel); }
.vi-btn { border: none; background: none; padding: 5px 8px; cursor: pointer; color: var(--dim); display: flex; align-items: center; }
.vi-btn + .vi-btn { border-left: 1px solid var(--border); }
.vi-btn:hover { color: var(--primary); }
.vi-btn.vi-active { background: var(--primary); color: #fff; }
.card-view img { width: 100%; height: auto; display: block; border-radius: 8px; }
.vs-hidden { display: none !important; }
/* ================= 信息卡 ================= */
.info-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; background: var(--panel); }
/* 账号信息按行分组:行内项目按内容宽度左对齐,保留行间距 */
@@ -396,11 +421,16 @@ th.th-id { width: 120px; }
/* ================= 首页 账号周榜 预览 ================= */
.rank-preview { margin: 4px 0 20px; }
.rank-tabs { display: flex; gap: 4px; border-bottom: 1px solid var(--border); padding-bottom: 0; margin-bottom: 10px; align-self: stretch; }
.rank-tab { border: none; background: transparent; color: var(--dim); padding: 6px 12px 4px; border-radius: 8px 8px 0 0; cursor: pointer; font-size: 14px; border-bottom: 2px solid transparent; }
.rank-tab:hover { color: var(--primary); background: var(--primary-soft); }
.rank-tab.active { color: var(--primary); font-weight: 600; background: var(--primary-soft); border-bottom: 2px solid var(--primary); }
.rank-placeholder { border: 1px dashed var(--border); border-radius: 10px; padding: 36px 12px; text-align: center; color: var(--dim); background: var(--panel); font-size: 14px; }
.rank-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
.rank-filter { padding: 3px 6px; font-size: 14px; max-width: 110px; }
.rank-date-label { font-size: 14px; color: var(--dim); min-width: 62px; text-align: center; white-space: nowrap; }
.rank-preview-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
.rank-row { display: grid; grid-template-columns: 34px 84px minmax(80px, 1fr) 52px repeat(4, 58px) 72px; align-items: center; gap: 6px; padding: 6px 10px; font-size: 14px; border-bottom: 1px solid var(--border); }
.rank-preview-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--panel); box-shadow: var(--shadow); }
.rank-row { display: grid; grid-template-columns: 34px 84px minmax(80px, 1fr) 76px repeat(4, 76px) 72px; align-items: center; gap: 6px; padding: 6px 10px; font-size: 14px; border-bottom: 1px solid var(--border); }
.rank-row:last-child { border-bottom: none; }
.rank-head { background: var(--panel); color: var(--dim); font-weight: 600; font-size: 13px; }
.rk-no { color: var(--dim); font-weight: 600; }
@@ -55,6 +55,31 @@ function resolveSafe(rawPath) {
return null;
}
// ---- 查找账号设定卡 SVG ----
// 产出规范:{产出根}/{达人昵称}/{达人昵称}账号设定卡.svg(功能六产出,与账号设定.md 同目录)
// 查找顺序:规范路径 → 根下直挂 → 账号目录内扫描(防目录名/账号名不一致)
function findAccountCard(name) {
const safeName = String(name || '').replace(/[\\/:*?"<>|]/g, '_').trim();
if (!safeName) return null;
for (const root of allowedRoots()) {
const p1 = path.join(root, safeName, safeName + '账号设定卡.svg');
const p2 = path.join(root, safeName + '账号设定卡.svg');
for (const c of [p1, p2]) {
try { if (fs.existsSync(c) && fs.statSync(c).isFile()) return c; } catch (e) { /* 忽略 */ }
}
}
for (const root of allowedRoots()) {
const dir = path.join(root, safeName);
try {
if (fs.statSync(dir).isDirectory()) {
const hit = fs.readdirSync(dir).find((f) => f.toLowerCase().endsWith('.svg') && f.includes('账号设定卡'));
if (hit) return path.join(dir, hit);
}
} catch (e) { /* 忽略 */ }
}
return null;
}
// ---- 目录树(递归,跳过隐藏项)----
function buildTree(absPath, depth) {
if (depth > 4) return null;
@@ -206,6 +231,25 @@ const server = http.createServer((req, res) => {
return;
}
if (p === '/api/file') {
// 内联输出(无 attachment 头):供 <img>/<object> 直接渲染(如账号设定卡 SVG)
const safe = resolveSafe(q.get('path'));
if (!safe) return sendErr(403, '路径不在允许范围');
try {
const st = fs.statSync(safe.abs);
if (!st.isFile()) return sendErr(404, '文件不存在');
res.writeHead(200, {
'Content-Type': mimeOf(safe.abs),
'Content-Length': st.size,
'Cache-Control': 'no-store',
});
fs.createReadStream(safe.abs).pipe(res);
} catch (e) {
return sendErr(500, '读取失败: ' + e.message);
}
return;
}
// ---------- 数据功能(只读工作台自有数据库副本) ----------
const dshOk = (code, obj) => sendJSON(code, { ok: true, ...obj });
const dshGuard = (res2) => { if (!dsh.dbAvailable()) { sendJSON(200, { ok: false, error: '未检测到工作台数据库(mcn-plugin.db),本功能不可用' }); return true; } return false; };
@@ -257,6 +301,12 @@ const server = http.createServer((req, res) => {
const r = dsh.getPersona(qInt('accountId', 0));
return dshOk(200, { content: r ? r.content : null, analysisTime: r ? r.analysis_time : null });
}
if (p === '/api/dsh/account-card') {
// 查询账号设定卡 SVG 文件(桌面产出,允许根目录下查找):{账号名}账号设定卡.svg
if (dshGuard()) return;
const hit = findAccountCard(q.get('name'));
return dshOk(200, { exists: !!hit, path: hit || null });
}
if (p === '/api/dsh/account-analysis') {
if (dshGuard()) return;
const r = dsh.getAccountAnalysis(qInt('accountId', 0));