feat(MCN工作台): 移植dsh导入账号功能 + 弹窗按钮/宽度优化 + 分镜技能归入subskill
- 账号列表页新增「导入账号」:两种方式(抖音账号信息/本地文件夹),复用 executeTask 任务链路提交 AI 任务 - 查看改写弹窗:展开源脚本按钮移入 AI脚本 标题右侧(间隔10px),弹窗最大宽度 1400→1600 - 短视频提示词生成 整体移入 短视频脚本创作/V1.0/subskill/mcn-video-prompt(与 mcn-dou-analysis 同构,138 文件 R100 保留历史) - 工作台 dsh-data/server 若干修复 + 记忆更新
This commit is contained in:
1 parent
9b0c198e2c
commit
ab97842030
155 files changed
+1201
-198
No files matched your search
@@ -8,8 +8,8 @@ description: >
|
||||
触发场景:写脚本、创作短视频、生成脚本、拆解对标视频、分析爆款视频、
|
||||
诊断脚本质量、设计账号人设、选题策划、生成分镜、广告植入设计、生成创作回顾。
|
||||
version: "1.0"
|
||||
updated_at: "2026-08-26"
|
||||
last_change: "MCN工作台 AI 任务触发升级:按钮直接提交 Automation 一次性任务(POST /api/run → 写 ~/.workbuddy/workbuddy.db automations 表 schedule_type=once scheduled_at=now,cwds=D:\\AgentSkill\\mcn-workshop → WorkBuddy 自动执行创建会话,归入 mcn-workshop 空间分组,左侧会话栏可见),取代原『复制 prompt 降级』方案;参考 dsh-plugin-mcn /mcn/api/creative 模式;5 秒防连点;前置:数据库副本隔离+数据功能页"
|
||||
updated_at: "2026-08-28"
|
||||
last_change: "S9格式修正:A·场次叙事独立动作叙述段落去括号(裸写自然段落),括号仅限对白行表演标注(动作/表情/语气);B/C/D 格式括号规则不动;同步 9_生成短视频脚本.md 5处 + 07_脚本格式选择指南 2处 + 创作流程规范.md 2处"
|
||||
deployment: "source"
|
||||
agent_created: true
|
||||
---
|
||||
@@ -72,6 +72,7 @@ agent_created: true
|
||||
4. **数据边界**:数据库读工作台自有副本 `mcn-work-shop/mcn-plugin.db`(由 dsh 库复制而来,与 dsh 原库 `~/.dsh/mcn-plugin.db` 完全隔离,两边各访问各的互不影响);周榜读 `D:\dshworkspace\抖音榜单\*.json` 文件
|
||||
5. **AI 任务触发(Automation 方案)**:各数据页「导入/创作/解析/改写/提炼/复盘」按钮点击后 → 工作台后端 `POST /api/run` → 向 `~/.workbuddy/workbuddy.db` 的 `automations` 表写入一条**一次性任务**(`schedule_type='once'`、`scheduled_at=now`、`cwds=["D:\\AgentSkill\\mcn-workshop"]`)→ WorkBuddy 运行时自动消费执行(创建 AI 会话)→ **会话归入 `mcn-workshop` 空间分组**(左侧会话栏按 cwd 分组,可在该分组查看任务执行过程与结果)。参考 dsh 插件 `dsh-plugin-mcn` 的 `/mcn/api/creative` 模式(固定会话 followup 触发);前端 5 秒防连点
|
||||
6. 本功能仅 V1.0 源技能环境提供,**不同步 dsh/Lite 副本**
|
||||
7. **改完必重开页面(硬性约定)**:对工作台任何页面/代码改动完成后,**最后一步必须用 present_files 重新打开对应页面**(带 hash 直接定位,如 `http://localhost:8899/#/accounts`),让用户在 WorkBuddy 内置浏览器立即看到最新效果。原因:内置浏览器无 CDP 调试端口(9222/9224 等均无响应),无法模拟 F5;重开页面是唯一可靠刷新方式。此约定对工作台所有 UI 改动强制生效
|
||||
|
||||
---
|
||||
## MCP 依赖(摘要)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 验证「导入外部账号数据」移植功能(账号列表页)
|
||||
// 零副作用:mock /api/run 与 /api/run/status,不创建真实任务
|
||||
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
|
||||
let page = list.find((t) => t.type === 'page' && t.url.includes('#/accounts'));
|
||||
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) 导航到账号列表页(若不在)并刷新加载最新代码
|
||||
if (!page.url.includes('#/accounts')) {
|
||||
await send('Page.navigate', { url: 'http://localhost:8899/#/accounts' });
|
||||
await sleep(2000);
|
||||
}
|
||||
await send('Page.reload', { ignoreCache: true });
|
||||
await sleep(3000);
|
||||
|
||||
// 2) mock fetch:/api/run 与 /api/run/status 返回假数据,其余走真实请求
|
||||
await ev(`(() => {
|
||||
window.__realFetch = window.fetch;
|
||||
window.__mockLog = [];
|
||||
window.fetch = (url, opts) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/api/run/status')) {
|
||||
window.__mockLog.push('status');
|
||||
return Promise.resolve(new Response(JSON.stringify({ ok: true, id: 'mock-task-1', state: 'done' }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
if (u.includes('/api/run')) {
|
||||
window.__mockLog.push('run:' + (opts && opts.body || ''));
|
||||
return Promise.resolve(new Response(JSON.stringify({ ok: true, id: 'mock-task-1', message: 'mock' }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
return window.__realFetch(url, opts);
|
||||
};
|
||||
return 'mocked';
|
||||
})()`);
|
||||
|
||||
// 3) 点工具栏「导入」按钮 → 弹窗(注意:页面有常驻隐藏的 #settingsModal,需用 :not([hidden]) 定位可见弹窗)
|
||||
const openRes = await ev(`(() => {
|
||||
const b = document.querySelector('#accImport');
|
||||
if (!b) return { err: 'no #accImport btn' };
|
||||
b.click();
|
||||
return { title: b.title, text: b.textContent.trim().slice(0, 20) };
|
||||
})()`);
|
||||
console.log('打开导入弹窗:', JSON.stringify(openRes));
|
||||
await sleep(400);
|
||||
const modal = await ev(`(() => {
|
||||
const m = document.querySelector('.modal-mask:not([hidden]) .modal');
|
||||
if (!m) return { err: 'no visible modal', masks: document.querySelectorAll('.modal-mask:not([hidden])').length };
|
||||
return {
|
||||
h3: m.querySelector('h3').textContent.trim(),
|
||||
inputs: [...m.querySelectorAll('input')].map((i) => ({ ph: i.placeholder, data: i.dataset.imp || '' })),
|
||||
desc: m.textContent.replace(/\\s+/g, ' ').slice(0, 120),
|
||||
};
|
||||
})()`);
|
||||
console.log('弹窗结构:', JSON.stringify(modal));
|
||||
|
||||
// 4) 空提交 → toast 校验
|
||||
const emptySubmit = await ev(`(() => {
|
||||
const m = document.querySelector('.modal-mask:not([hidden]) .modal');
|
||||
m.querySelector('[data-ok]').click();
|
||||
return 'clicked';
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const toast1 = await ev(`(() => { const t = document.getElementById('toast'); return t ? t.textContent + '|show=' + t.classList.contains('show') : null; })()`);
|
||||
const modalStill = await ev(`(() => !!document.querySelector('.modal-mask:not([hidden]) .modal'))()`);
|
||||
console.log('空提交 → toast:', JSON.stringify(toast1), '| 弹窗仍在:', modalStill);
|
||||
|
||||
// 5) 填账号名 → 提交 → 弹窗关闭 + 按钮执行中
|
||||
const fillSubmit = await ev(`(() => {
|
||||
const m = document.querySelector('.modal-mask:not([hidden]) .modal');
|
||||
m.querySelector('[data-imp-name]').value = '旧梦留声机';
|
||||
m.querySelector('[data-imp-did]').value = 'jiu_meng_ai';
|
||||
m.querySelector('[data-ok]').click();
|
||||
return 'submitted';
|
||||
})()`);
|
||||
await sleep(600);
|
||||
const afterSubmit = await ev(`(() => {
|
||||
const b = document.querySelector('#accImport');
|
||||
return { modalGone: !document.querySelector('.modal-mask:not([hidden]) .modal'), btnText: b.textContent.trim().slice(0, 20), btnDisabled: b.disabled, mockLog: window.__mockLog };
|
||||
})()`);
|
||||
console.log('提交后:', JSON.stringify(afterSubmit));
|
||||
|
||||
// 6) 等轮询(mock 立即 done)→ 恢复 + 刷新
|
||||
await sleep(6000);
|
||||
const finalState = await ev(`(() => {
|
||||
const b = document.querySelector('#accImport');
|
||||
return { btnText: b.textContent.trim().slice(0, 20), btnDisabled: b.disabled, mockLog: window.__mockLog };
|
||||
})()`);
|
||||
console.log('轮询后:', JSON.stringify(finalState));
|
||||
|
||||
// 7) 恢复真实 fetch
|
||||
await ev(`window.fetch = window.__realFetch; 'restored'`);
|
||||
|
||||
// 8) 帮助页问法检查(导入问法已加入账号分析组)
|
||||
await send('Page.navigate', { url: 'http://localhost:8899/#/help' });
|
||||
await sleep(2500);
|
||||
const helpCheck = await ev(`(() => {
|
||||
const rows = [...document.querySelectorAll('.qa-row')];
|
||||
const hit = rows.find((r) => r.textContent.includes('导入账号列表'));
|
||||
return { hasImportQA: !!hit, qa: hit ? hit.querySelector('.q').textContent : null, total: rows.length };
|
||||
})()`);
|
||||
console.log('帮助页导入问法:', JSON.stringify(helpCheck));
|
||||
|
||||
ws.close();
|
||||
console.log('DONE');
|
||||
@@ -0,0 +1,123 @@
|
||||
// 验证「单向联动」v2:全部用 JS 设置 scrollTop 触发 scroll 事件(避免 mouseWheel SIGTERM)
|
||||
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && t.url.includes('#/rewrites'));
|
||||
if (!page) { console.log('ERR: no #/rewrites page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 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(3000);
|
||||
await ev(`document.querySelectorAll('.modal-mask').forEach((m) => m.remove()); 'cleaned'`);
|
||||
await ev(`(() => { const i = document.querySelector('#rwSearch'); if (i) { i.value = '快递'; i.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); } return !!i; })()`);
|
||||
await sleep(2000);
|
||||
const clickRes = await ev(`(() => {
|
||||
const btns = [...document.querySelectorAll('[data-detail]')];
|
||||
if (!btns.length) return { err: 'no data-detail' };
|
||||
btns[0].click();
|
||||
return { clicked: btns[0].dataset.detail };
|
||||
})()`);
|
||||
console.log('打开弹窗:', JSON.stringify(clickRes));
|
||||
await sleep(2500);
|
||||
|
||||
const st = async () => ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
if (!secs.length) return { err: 'no rw-stack' };
|
||||
const ai = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI脚本');
|
||||
const sb = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI分镜提示词');
|
||||
if (!ai || !sb) return { err: 'missing col', heads: secs.map((s) => s.querySelector('.rw-sec-head')?.textContent.trim()) };
|
||||
const aiBody = ai.querySelector('.rw-sec-body'); const sbBody = sb.querySelector('.rw-sec-body');
|
||||
const vis = [...sbBody.querySelectorAll('.sb-block')].find((b) => b.getBoundingClientRect().top >= sbBody.getBoundingClientRect().top - 4);
|
||||
return {
|
||||
aiScrollTop: Math.round(aiBody.scrollTop), sbScrollTop: Math.round(sbBody.scrollTop),
|
||||
aiHeads: aiBody.querySelectorAll('strong').length, sbBlocks: sbBody.querySelectorAll('.sb-block').length,
|
||||
sbVis: vis ? (vis.querySelector('.sb-head')?.textContent.trim() || '').slice(0, 30) : null,
|
||||
};
|
||||
})()`);
|
||||
|
||||
const init = await st();
|
||||
console.log('初始:', JSON.stringify(init));
|
||||
|
||||
// 翻到有分镜的 AI 脚本版本(rewrite 22)
|
||||
if ((init.sbBlocks || 0) === 0) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const hasNext = await ev(`(() => { const b = document.querySelector('[data-vnext]'); if (!b || b.disabled) return false; b.click(); return true; })()`);
|
||||
if (!hasNext) break;
|
||||
await sleep(900);
|
||||
const s = await st();
|
||||
console.log('翻版本后:', JSON.stringify(s));
|
||||
if ((s.sbBlocks || 0) > 0) break;
|
||||
}
|
||||
}
|
||||
const ready = await st();
|
||||
console.log('就绪:', JSON.stringify(ready));
|
||||
if (!(ready.sbBlocks > 0)) { console.log('ERR: 无分镜版本可验证'); process.exit(0); }
|
||||
|
||||
// ===== 链路①:AI脚本列滚动 → 分镜列应对齐(设置scrollTop+补发scroll事件模拟滚动) =====
|
||||
const setAi = async (delta, tag) => {
|
||||
const r = await ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const aiBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI脚本').querySelector('.rw-sec-body');
|
||||
aiBody.scrollTop += ${delta};
|
||||
aiBody.dispatchEvent(new Event('scroll')); // CDP下scrollTop赋值不触发事件,补发
|
||||
return Math.round(aiBody.scrollTop);
|
||||
})()`);
|
||||
await sleep(350);
|
||||
const s = await st();
|
||||
console.log(`${tag}: aiScrollTop=${r} → sbScrollTop=${s.sbScrollTop} sbVis=${s.sbVis}`);
|
||||
return s;
|
||||
};
|
||||
let cur = await setAi(400, '链路① 第1步滚400');
|
||||
cur = await setAi(400, '链路① 第2步滚400');
|
||||
cur = await setAi(400, '链路① 第3步滚400');
|
||||
const link1 = {
|
||||
aiMoved: Math.abs(cur.aiScrollTop - ready.aiScrollTop) > 50,
|
||||
sbMoved: Math.abs(cur.sbScrollTop - ready.sbScrollTop) > 50,
|
||||
};
|
||||
console.log('链路①判定:', JSON.stringify(link1), link1.aiMoved && link1.sbMoved ? '✅ AI脚本主导联动生效' : '❌ 联动异常');
|
||||
|
||||
// ===== 链路②:分镜列滚动 → AI脚本列独立 =====
|
||||
const beforeSb = await st();
|
||||
const r2 = await ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const sbBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI分镜提示词').querySelector('.rw-sec-body');
|
||||
sbBody.scrollTop += 600;
|
||||
sbBody.dispatchEvent(new Event('scroll'));
|
||||
return Math.round(sbBody.scrollTop);
|
||||
})()`);
|
||||
await sleep(350);
|
||||
const afterSb = await st();
|
||||
console.log(`链路② 分镜列滚600: sbScrollTop=${r2} → aiScrollTop=${afterSb.aiScrollTop} sbVis=${afterSb.sbVis}`);
|
||||
const link2 = {
|
||||
sbMoved: Math.abs(afterSb.sbScrollTop - beforeSb.sbScrollTop) > 50,
|
||||
aiMovedBack: Math.abs(afterSb.aiScrollTop - beforeSb.aiScrollTop) > 5,
|
||||
};
|
||||
console.log('链路②判定:', JSON.stringify(link2), link2.sbMoved && !link2.aiMovedBack ? '✅ 分镜列独立滚动,AI脚本列未动' : (link2.sbMoved ? '❌ AI脚本列被反向联动' : '❌ 分镜列未滚动'));
|
||||
|
||||
// ===== 链路①b:AI脚本列跳底部 → 分镜列跟随最后场 =====
|
||||
const jump = await ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const aiBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI脚本').querySelector('.rw-sec-body');
|
||||
aiBody.scrollTop = aiBody.scrollHeight;
|
||||
aiBody.dispatchEvent(new Event('scroll'));
|
||||
return { set: Math.round(aiBody.scrollTop) };
|
||||
})()`);
|
||||
await sleep(400);
|
||||
const afterJump = await st();
|
||||
const detail = await ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const aiBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI脚本').querySelector('.rw-sec-body');
|
||||
const heads = [...aiBody.querySelectorAll('strong, h1, h2, h3, h4')].filter((el) => /场\\s*\\d/.test(el.textContent));
|
||||
const sbBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI分镜提示词').querySelector('.rw-sec-body');
|
||||
const vis = [...sbBody.querySelectorAll('.sb-block')].find((b) => b.getBoundingClientRect().top >= sbBody.getBoundingClientRect().top - 4);
|
||||
return { lastAiHead: heads.length ? heads[heads.length - 1].textContent.trim().slice(0, 24) : null, sbVis: vis ? (vis.querySelector('.sb-head')?.textContent.trim() || '').slice(0, 30) : null };
|
||||
})()`);
|
||||
console.log('链路①b 跳底部:', JSON.stringify({ jump, sbScrollTop: afterJump.sbScrollTop, detail }));
|
||||
|
||||
try { ws.close(); } catch (e) {}
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,76 @@
|
||||
// 验证:①展开源脚本按钮移到 AI脚本 标题右侧(间隔 10px)②弹窗最大宽度 1400→1600
|
||||
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'); 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');
|
||||
|
||||
if (!page.url.includes('#/rewrites')) {
|
||||
await send('Page.navigate', { url: 'http://localhost:8899/#/rewrites' });
|
||||
await sleep(2000);
|
||||
}
|
||||
await send('Page.reload', { ignoreCache: true });
|
||||
await sleep(3000);
|
||||
|
||||
// 打开第一条改写的查看弹窗
|
||||
await ev(`(() => { const b = document.querySelector('[data-detail]'); if (!b) return 'no-detail-btn'; b.click(); return 'opened'; })()`);
|
||||
await sleep(2500);
|
||||
|
||||
// ① 按钮位置:应在 AI脚本 .rw-sec-head 内,与「AI脚本」文字间隔 10px
|
||||
const pos = await ev(`(() => {
|
||||
const head = [...document.querySelectorAll('.rw-sec-head')].find((h) => h.textContent.trim().startsWith('AI脚本'));
|
||||
if (!head) return { err: 'no AI脚本 head' };
|
||||
const btn = head.querySelector('[data-src-toggle]');
|
||||
if (!btn) return { err: 'no toggle in AI head' };
|
||||
const textNode = [...head.childNodes].find((n) => n.nodeType === 3 && n.textContent.includes('AI脚本'));
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(textNode);
|
||||
const textRect = range.getBoundingClientRect();
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
// 文字右缘 → 按钮左缘 的水平距离(同一行)
|
||||
const gap = btnRect.left - textRect.right;
|
||||
// 顶部标题行不应再有 toggle
|
||||
const titleRowHasToggle = !!document.querySelector('.modal-title-row [data-src-toggle]');
|
||||
return {
|
||||
inAiHead: true,
|
||||
gap: Math.round(gap * 100) / 100,
|
||||
btnTitle: btn.title,
|
||||
verticalAligned: Math.abs(btnRect.top - textRect.top) < 4,
|
||||
titleRowHasToggle,
|
||||
};
|
||||
})()`);
|
||||
console.log('① 按钮位置:', JSON.stringify(pos));
|
||||
|
||||
// ② 点击按钮 → 源脚本列展开
|
||||
await ev(`(() => { const b = document.querySelector('[data-src-toggle]'); b.click(); return 'toggled'; })()`);
|
||||
await sleep(600);
|
||||
const expanded = await ev(`(() => {
|
||||
const srcSec = document.querySelector('[data-src-sec]');
|
||||
const toggle = document.querySelector('[data-src-toggle]');
|
||||
return { srcVisible: srcSec && getComputedStyle(srcSec).display !== 'none', toggleTitle: toggle ? toggle.title : null };
|
||||
})()`);
|
||||
console.log('② 点击后展开源脚本:', JSON.stringify(expanded));
|
||||
await ev(`(() => { const b = document.querySelector('[data-src-toggle]'); b.click(); return 'toggled-back'; })()`);
|
||||
await sleep(500);
|
||||
|
||||
// ③ 弹窗宽度:多视口验证 1600 封顶
|
||||
const widthAt = async (vw) => {
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: vw, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||
await sleep(500);
|
||||
return ev(`(() => { const m = document.querySelector('.rw-modal'); if (!m) return null; const r = m.getBoundingClientRect(); return { vw: window.innerWidth, w: Math.round(r.width), left: Math.round(r.left), right: Math.round(window.innerWidth - r.right) }; })()`);
|
||||
};
|
||||
const w1200 = await widthAt(1200);
|
||||
const w1500 = await widthAt(1500);
|
||||
const w1700 = await widthAt(1700);
|
||||
const w2000 = await widthAt(2000);
|
||||
console.log('③ 弹窗宽度:', JSON.stringify({ w1200, w1500, w1700, w2000 }));
|
||||
await send('Emulation.clearDeviceMetricsOverride');
|
||||
|
||||
ws.close();
|
||||
console.log('DONE');
|
||||
@@ -27,6 +27,18 @@ function getDb() {
|
||||
}
|
||||
function dbAvailable() { return getDb() !== null; }
|
||||
|
||||
// 收藏切换:可写连接单独打开(与只读 getDb 隔离),操作完即关闭
|
||||
function setFavorite(accountId, fav) {
|
||||
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
|
||||
const w = new DatabaseSync(DSH_DB);
|
||||
try {
|
||||
const r = w.prepare(`UPDATE hot_accounts SET favorite=? WHERE id=?`).run(fav ? 1 : 0, Number(accountId));
|
||||
if (r.changes === 0) throw new Error('账号不存在');
|
||||
} finally {
|
||||
w.close();
|
||||
}
|
||||
}
|
||||
|
||||
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
|
||||
const fmt = (v) => (v == null || v === '') ? null : v;
|
||||
|
||||
@@ -70,7 +82,7 @@ function listAccountVideos(accountId, page = 1, pageSize = 20) {
|
||||
SELECT v.*,
|
||||
(SELECT COUNT(*) FROM account_video_source s WHERE s.video_id=v.id OR s.aweme_id=v.aweme_id) AS has_source,
|
||||
(SELECT COUNT(*) FROM account_video_analysis a WHERE a.video_id=v.id OR a.aweme_id=v.aweme_id) AS has_analysis
|
||||
FROM account_videos v WHERE v.account_id=? ORDER BY v.id ASC LIMIT ? OFFSET ?
|
||||
FROM account_videos v WHERE v.account_id=? ORDER BY v.publish_time IS NULL, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
|
||||
`).all(accountId, limit, offset);
|
||||
return { account: acc, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
|
||||
}
|
||||
@@ -305,7 +317,7 @@ function getRanking({ date = '', category = '', sortBy = 'rank', order = '', pag
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dbAvailable, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
|
||||
dbAvailable, setFavorite, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
|
||||
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
|
||||
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR,
|
||||
};
|
||||
Binary file not shown.
@@ -6,9 +6,148 @@
|
||||
/* ================= 工具 ================= */
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
/* 清理标题首尾装饰性引号(英文/中文双引号、单引号、书名号式引号);仅首尾,中间引号保留 */
|
||||
const stripTitleQuotes = (s) => String(s || '').trim().replace(/^["“”'‘’「『]+|["“”'‘’」』]+$/g, '');
|
||||
/* 视频标题清理:标题主体(# 前)去首尾引号,# 标签原样保留(列表页完整标题显示用) */
|
||||
const cleanTitle = (t) => {
|
||||
const s = String(t || '').trim();
|
||||
const idx = s.indexOf('#');
|
||||
if (idx <= 0) return stripTitleQuotes(s);
|
||||
return stripTitleQuotes(s.slice(0, idx)) + s.slice(idx);
|
||||
};
|
||||
const fmtSize = (n) => { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB'; };
|
||||
const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'table', txt: 'file-text', log: 'file-text', bak: 'file' };
|
||||
|
||||
/* ================= AI 任务触发(确认弹窗 + 执行中状态) =================
|
||||
流程:点击按钮 → 确认弹窗 → 提交 /api/run → 按钮变「执行中…」禁用
|
||||
→ 轮询 /api/run/status 直到任务完成(done/error)→ 按钮恢复 */
|
||||
|
||||
// 任务完成后自动刷新当前数据页(无需手动刷新即可看到最新数据)
|
||||
function refreshCurrentPage() {
|
||||
const view = $('#view');
|
||||
if (view && typeof view._reload === 'function') { view._reload(); return; }
|
||||
const { name } = parseHash();
|
||||
const dataPages = ['ranking', 'accounts', 'account', 'video', 'rewrites', 'reviews'];
|
||||
if (name === '' || dataPages.includes(name)) router();
|
||||
}
|
||||
|
||||
// 提交任务 + 按钮执行中状态 + 轮询恢复(供 confirmSaveAccount/confirmRun 确认后调用)
|
||||
async function executeTask(opts) {
|
||||
const btn = opts.btn;
|
||||
const oldText = btn ? btn.innerHTML : '';
|
||||
const restore = () => { if (btn) { btn.disabled = false; btn.innerHTML = oldText; btn.classList.remove('btn-busy'); } };
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '执行中…'; btn.classList.add('btn-busy'); }
|
||||
let taskId = null;
|
||||
try {
|
||||
const d = await api('/api/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务' }),
|
||||
});
|
||||
if (d.error) throw new Error(d.error);
|
||||
taskId = d.id;
|
||||
showToast('✅ 任务已提交,请在左侧会话栏查看执行');
|
||||
} catch (e) {
|
||||
showToast('❌ 任务提交失败: ' + e.message);
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
// 轮询任务状态直到完成(最长 30 分钟,账号提炼等任务耗时较长)
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
let s = null;
|
||||
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
|
||||
if (!s || s.state === 'notfound') break;
|
||||
if (s.state === 'done') { showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
|
||||
if (s.state === 'error') { showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
|
||||
// queued / pending / running → 继续等待
|
||||
}
|
||||
restore();
|
||||
}
|
||||
|
||||
// 通用确认弹窗:确认后执行 AI 任务(opts: title/desc/target/url/prompt/label/btn/afterText)
|
||||
function confirmRun(opts) {
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `
|
||||
<div class="modal">
|
||||
<h3>${svgIcon('zap', 18)} ${esc(opts.title)}</h3>
|
||||
<p class="dim">${esc(opts.desc)}</p>
|
||||
${opts.target ? `<div class="confirm-target">${esc(opts.target)}</div>` : ''}
|
||||
${opts.url ? `<div class="confirm-url">${esc(opts.url)}</div>` : ''}
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" data-cancel>取消</button>
|
||||
<button class="primary" data-ok>确认执行</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
const close = () => mask.remove();
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
|
||||
mask.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
mask.querySelector('[data-ok]').addEventListener('click', () => { close(); executeTask(opts); });
|
||||
}
|
||||
|
||||
// 保存账号确认(首页周榜 + 完整周榜页共用)
|
||||
function confirmSaveAccount(name, url, btn) {
|
||||
confirmRun({
|
||||
title: '分析账号',
|
||||
desc: '确认分析该账号并添加到账号列表(账号信息 + 视频列表)?',
|
||||
target: name,
|
||||
url: url || '',
|
||||
prompt: `请帮我把抖音账号「${name}」保存并分析(账号信息 + 视频列表):${url || ''}`,
|
||||
label: '保存指令',
|
||||
btn,
|
||||
});
|
||||
}
|
||||
|
||||
// 导入外部账号数据(移植自 dsh 插件「热门账号 → 导入」):
|
||||
// 方式一:抖音账号信息(名称/抖音号/地址)→ AI 网页采集账号信息与视频列表入库
|
||||
// 方式二:本地文件夹绝对路径 → 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>
|
||||
<div class="import-form">
|
||||
<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>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
const close = () => mask.remove();
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
|
||||
mask.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
mask.querySelector('[data-ok]').addEventListener('click', () => {
|
||||
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} 导入:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
|
||||
}
|
||||
close();
|
||||
executeTask({ prompt, label: '导入指令', btn });
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 扁平化线性图标(Lucide 风格:24 viewBox / stroke currentColor / 无底色) ================= */
|
||||
const ICON_PATHS = {
|
||||
'film': '<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="m12.4 3.4 3.1 4"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
|
||||
@@ -32,6 +171,7 @@ const ICON_PATHS = {
|
||||
'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"/>',
|
||||
'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"/>',
|
||||
};
|
||||
|
||||
// 功能图标亮色映射(不同功能不同亮色;未列出的保持 currentColor 中性)
|
||||
@@ -40,10 +180,12 @@ const ICON_COLORS = {
|
||||
'lightbulb': '#d99a2b', 'refresh': '#0f7f8f', 'chart': '#d6336c', 'wrench': '#5b6470',
|
||||
};
|
||||
|
||||
function svgIcon(name, size = 16) {
|
||||
function svgIcon(name, size = 16, color) {
|
||||
const p = ICON_PATHS[name] || ICON_PATHS['file'];
|
||||
const color = ICON_COLORS[name] ? ` style="color:${ICON_COLORS[name]}"` : '';
|
||||
return `<svg class="ic" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${color}>${p}</svg>`;
|
||||
// color 参数覆盖彩色映射:传 'currentColor'/'inherit' 等可让图标跟随字体颜色
|
||||
const col = color !== undefined ? color : ICON_COLORS[name];
|
||||
const style = col ? ` style="color:${col}"` : '';
|
||||
return `<svg class="ic" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${style}>${p}</svg>`;
|
||||
}
|
||||
|
||||
// 把 [data-icon] 占位元素替换为 SVG 图标(静态 HTML 里的 header 按钮等)
|
||||
@@ -134,7 +276,8 @@ const FUNC_GROUPS = [
|
||||
['"基于「旧梦留声机」账号设定,替换事件写一个脚本"', '读账号设定,按账号套路写全新事件脚本'],
|
||||
['"把刚写的脚本展开成分镜"', '按分镜技能生成镜头级描述'],
|
||||
]},
|
||||
{ icon: 'refresh', title: '账号分析', desc: '提炼账号设定 · 分析视频 · 分析账号', qa: [
|
||||
{ icon: 'refresh', title: '账号分析', desc: '导入账号 · 提炼账号设定 · 分析视频 · 分析账号', qa: [
|
||||
['"把抖音账号「俊希」导入账号列表"', '网页采集账号信息 + 视频列表入库'],
|
||||
['"帮我设计一个三农赛道的达人IP"', '提炼账号设定(人设卡)'],
|
||||
['"帮我拆解这个抖音视频"', '解析视频,拆解钩子/节奏/情绪/选题'],
|
||||
['"分析一下这个账号的内容套路"', '账号内容结构拆解 + 定位诊断'],
|
||||
@@ -152,10 +295,10 @@ const FUNC_GROUPS = [
|
||||
/* ================= 首页 ================= */
|
||||
function renderHome() {
|
||||
const cards = [
|
||||
{ icon: 'trending-up', title: '抖音周榜', desc: '本地周榜数据 · 赛道/日期筛选', hash: '#/ranking' },
|
||||
{ icon: 'users', title: '热门账号', desc: '签约/外部账号 + 视频列表', hash: '#/accounts' },
|
||||
{ icon: 'pen', title: 'AI写脚本', desc: '源脚本 vs AI脚本对比 + 分镜', hash: '#/rewrites' },
|
||||
{ icon: 'flask', title: 'AI写作复盘', desc: '评分维度 + 复盘报告', hash: '#/reviews' },
|
||||
{ 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' },
|
||||
];
|
||||
return `
|
||||
<div class="home">
|
||||
@@ -168,7 +311,7 @@ function renderHome() {
|
||||
</div>
|
||||
<div class="rank-preview">
|
||||
<div class="recent-head">
|
||||
<h3>抖音周榜</h3>
|
||||
<h3>账号周榜</h3>
|
||||
<div class="rank-tools">
|
||||
<select id="rankCat" class="input-select rank-filter" title="赛道筛选"></select>
|
||||
<button class="btn btn-sm" id="rankPrevDate" title="上一期">◀</button>
|
||||
@@ -189,6 +332,10 @@ function renderHome() {
|
||||
function renderHelp() {
|
||||
return `
|
||||
<div class="home">
|
||||
<div class="page-toolbar">
|
||||
<button class="btn" data-back title="返回">←</button>
|
||||
<span class="page-title">功能帮助</span>
|
||||
</div>
|
||||
<div class="intro">
|
||||
<p>直接对 WorkBuddy 说需求即可,无需记指令。<strong>点击任意问法即可复制</strong>,粘贴到对话框回车执行:</p>
|
||||
</div>
|
||||
@@ -240,7 +387,7 @@ async function loadRecentScripts() {
|
||||
_recentScriptsCache = {};
|
||||
el.innerHTML = items.map((s) => {
|
||||
_recentScriptsCache[s.id] = s;
|
||||
const title = s.title || s.video_title || '未命名剧本';
|
||||
const title = cleanTitle(s.title || s.video_title) || '未命名剧本';
|
||||
const meta = [s.account_name, s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | ');
|
||||
const preview = previewLines(s.script_text, 5);
|
||||
return `<button class="recent-card" data-script="${s.id}" title="点击查看完整剧本">
|
||||
@@ -256,7 +403,7 @@ async function loadRecentScripts() {
|
||||
} catch (e) { el.innerHTML = '<span class="dim">' + esc(e.message) + '</span>'; }
|
||||
}
|
||||
|
||||
/* ================= 首页 抖音周榜 预览(前10 + 赛道/日期筛选,无分页) ================= */
|
||||
/* ================= 首页 账号周榜 预览(前10 + 赛道/日期筛选,无分页) ================= */
|
||||
let _rankPreview = { page: 1, pageSize: 10, dates: [], categories: [], date: '', category: '' };
|
||||
function renderRankTools() {
|
||||
const catSel = $('#rankCat');
|
||||
@@ -295,19 +442,19 @@ 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', () => {
|
||||
firePrompt(`请帮我把抖音账号「${b.dataset.rkName}」导入并分析(账号信息 + 视频列表):${b.dataset.rkUrl || ''}`, '导入指令');
|
||||
confirmSaveAccount(b.dataset.rkName, b.dataset.rkUrl || '', b);
|
||||
}));
|
||||
} catch (e) { listEl.innerHTML = '<span class="dim" style="padding:12px">' + esc(e.message) + '</span>'; }
|
||||
}
|
||||
function openRecentScript(s) {
|
||||
const title = s.title || s.video_title || 'AI 写剧本';
|
||||
const title = cleanTitle(s.title || s.video_title) || 'AI 写剧本';
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `<div class="modal rw-modal">
|
||||
<div class="modal-title-row"><span>${svgIcon('pen')} ${esc(title)}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="recent-meta">${esc([s.account_name, s.video_title, s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | '))}</div>
|
||||
<div class="recent-meta">${esc([s.account_name, cleanTitle(s.video_title), s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | '))}</div>
|
||||
<div class="rw-content md-body" style="max-height:70vh">${renderMarkdown(s.script_text || '(暂无内容)')}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
@@ -453,7 +600,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(/^#/, '');
|
||||
@@ -477,11 +624,13 @@ async function router() {
|
||||
view.querySelectorAll('.data-card').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.hash; }));
|
||||
loadRankingPreview();
|
||||
loadRecentScripts();
|
||||
view._reload = () => { loadRankingPreview(); loadRecentScripts(); };
|
||||
return;
|
||||
}
|
||||
if (name === 'help') {
|
||||
updateCrumbs('help', '功能帮助');
|
||||
view.innerHTML = renderHelp();
|
||||
bindBack(view); // 返回 = 上一页(无历史则回首页)
|
||||
view.querySelectorAll('.qa-row.copyable').forEach((row) => row.addEventListener('click', async () => {
|
||||
const text = (row.dataset.q || '').replace(/^"|"$/g, '');
|
||||
if (text) showToast((await copyText(text)) ? '✅ 已复制:' + text : '❌ 复制失败');
|
||||
@@ -496,7 +645,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),
|
||||
@@ -575,12 +724,9 @@ function readRoute() {
|
||||
}
|
||||
window.addEventListener('hashchange', saveRoute);
|
||||
(function boot() {
|
||||
const saved = readRoute();
|
||||
if (saved && saved !== '#/' && saved !== '#' && (!location.hash || location.hash === '#/')) {
|
||||
location.hash = saved; // 刷新丢 hash → 恢复上次页面
|
||||
return;
|
||||
}
|
||||
saveRoute(); // 直接输入 URL/首次打开:立即记录当前路由,供下次刷新恢复
|
||||
// 不再自动恢复上次路由:URL 无 hash(访问 http://localhost:8899 根地址)一律回首页;
|
||||
// 带 hash(#/accounts 等)走正常路由。原恢复逻辑会误伤"主动访问根地址想回首页"的场景。
|
||||
saveRoute(); // 记录当前路由(保留机制,供后续按需使用)
|
||||
// 关键:data-pages.js 在 app.js 之后的 script 标签,下载/执行可能未完成。
|
||||
// 须等其就绪(DataPages 存在)再路由,否则数据页会被当成未知路由。
|
||||
function bootRouter() {
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
'use strict';
|
||||
|
||||
const fmt = (v) => (v == null || v === '') ? '-' : String(v);
|
||||
/* 过万数字以"万"为单位,保留一位小数(如 57141 → 5.7万) */
|
||||
const fmtWan = (v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return '-';
|
||||
return n >= 10000 ? (n / 10000).toFixed(1) + '万' : String(n);
|
||||
};
|
||||
/* 弹窗标题:书名号只包标题主体;#标签及共创/素材来源信息整体换一行显示 */
|
||||
const fmtTitleQ = (t) => {
|
||||
const s = String(t || '').trim();
|
||||
const idx = s.indexOf('#');
|
||||
if (idx <= 0) return s; // 无 # 或 # 开头(纯标签标题)→ 原样显示
|
||||
const title = stripTitleQuotes(s.slice(0, idx));
|
||||
const rest = s.slice(idx).trim();
|
||||
return '《' + title + '》' + (rest ? '\n' + rest : '');
|
||||
};
|
||||
const fmtDuration = (v) => { const s = Number(v); if (!Number.isFinite(s) || s <= 0) return '-'; const m = Math.floor(s / 60); const sec = Math.round(s % 60); return m > 0 ? `${m}分${sec}秒` : `${sec}秒`; };
|
||||
const fmtTime = (t) => { if (!t) return '-'; const d = new Date(t); if (Number.isNaN(d.getTime())) return String(t); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; };
|
||||
|
||||
@@ -19,7 +34,14 @@ function toolbarBack() {
|
||||
return `<button class="btn" data-back title="返回">←</button>`;
|
||||
}
|
||||
function bindBack(container) {
|
||||
container.querySelectorAll('[data-back]').forEach((b) => b.addEventListener('click', () => { location.hash = '#/'; }));
|
||||
container.querySelectorAll('[data-back]').forEach((b) => b.addEventListener('click', () => {
|
||||
const before = location.hash;
|
||||
window.history.back();
|
||||
// 无历史可退(如直接打开/刷新后)→ 200ms 内 hash 未变化则回首页
|
||||
setTimeout(() => {
|
||||
if (location.hash === before) location.hash = '#/';
|
||||
}, 250);
|
||||
}));
|
||||
}
|
||||
|
||||
/* 触发 AI 任务:提交 WorkBuddy Automation 一次性任务,WorkBuddy 自动执行,左侧会话栏可见 */
|
||||
@@ -42,11 +64,69 @@ async function firePrompt(prompt, label) {
|
||||
}
|
||||
}
|
||||
|
||||
/* AI写脚本 确认弹窗:按账号是否有设定动态文案;选题输入区(手输 / AI 结合人设生成)→ 确认后执行创作任务 */
|
||||
async function confirmAiScript(accountName, btn) {
|
||||
// 判断账号是否有有效设定(persona 非空且非测试占位):有 →「基于当前账号设定」,无 →「先进行账号设定分析」
|
||||
const accId = btn && btn.dataset.accId ? btn.dataset.accId : '';
|
||||
let hasPersona = false;
|
||||
if (accId) {
|
||||
try {
|
||||
const r = await api('/api/dsh/account-persona?accountId=' + encodeURIComponent(accId));
|
||||
const content = String(r.content || '').trim();
|
||||
hasPersona = !!content && !/测试写入|连通性测试/.test(content);
|
||||
} catch (e) { hasPersona = false; }
|
||||
}
|
||||
const desc = hasPersona
|
||||
? `将为达人「${esc(accountName)}」基于当前账号设定生成脚本。可先填写选题方向,或点击右下角 AI 按钮结合人设生成选题:`
|
||||
: `将为达人「${esc(accountName)}」创作一条短视频脚本(先进行账号设定分析,再生成完整脚本)。可先填写选题方向,或点击右下角 AI 按钮结合人设生成选题:`;
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `
|
||||
<div class="modal">
|
||||
<h3>${svgIcon('pen', 18)} AI写脚本</h3>
|
||||
<p class="dim">${desc}</p>
|
||||
<div class="ai-topic">
|
||||
<textarea id="aiTopicInput" class="ai-topic-input" placeholder="输入选题方向(可选),如:亲子露营的搞笑瞬间"></textarea>
|
||||
<button class="ai-topic-btn" id="aiTopicGen" title="AI 结合人设生成选题">AI</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" data-cancel>取消</button>
|
||||
<button class="primary" data-ok>确认执行</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
const close = () => mask.remove();
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
|
||||
mask.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
const genBtn = mask.querySelector('#aiTopicGen');
|
||||
genBtn.addEventListener('click', async () => {
|
||||
if (genBtn.disabled) return;
|
||||
genBtn.disabled = true; genBtn.textContent = '…';
|
||||
try {
|
||||
const r = await api('/api/ai/topics', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountName }) });
|
||||
if (r.error) throw new Error(r.error);
|
||||
mask.querySelector('#aiTopicInput').value = r.topics || '(未生成内容)';
|
||||
showToast('✅ 已生成 3 个选题方向,可直接使用或修改');
|
||||
} catch (e) {
|
||||
showToast('❌ 选题生成失败: ' + e.message);
|
||||
}
|
||||
genBtn.disabled = false; genBtn.textContent = 'AI';
|
||||
});
|
||||
mask.querySelector('[data-ok]').addEventListener('click', () => {
|
||||
const topic = mask.querySelector('#aiTopicInput').value.trim();
|
||||
close();
|
||||
const act = hasPersona ? '基于当前账号设定创作。' : '请先进行账号设定分析,再生成完整脚本。';
|
||||
const prompt = topic
|
||||
? `请为达人「${accountName}」创作一条短视频脚本,选题方向:${topic}\n${act}`
|
||||
: `请为达人「${accountName}」创作一条短视频脚本。${act}`;
|
||||
executeTask({ prompt, label: '创作指令', btn });
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 周榜 ================= */
|
||||
async function renderRanking(view) {
|
||||
let state = { date: '', category: '', sortBy: 'comprehensiveScore', order: 'desc', page: 1, pageSize: 20, dates: [], categories: [] };
|
||||
const data = await api('/api/dsh/ranking?pageSize=20').catch((e) => ({ ok: false, error: e.message }));
|
||||
if (!data.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}<span class="page-title">抖音周榜</span></div><div class="empty">${esc(data.error || '加载失败')}</div></div>`; bindBack(view); return; }
|
||||
if (!data.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}<span class="page-title">账号周榜</span></div><div class="empty">${esc(data.error || '加载失败')}</div></div>`; bindBack(view); return; }
|
||||
state.dates = data.dates || []; state.categories = data.categories || []; state.date = data.date; state.category = data.category;
|
||||
|
||||
async function load() {
|
||||
@@ -63,13 +143,13 @@ async function renderRanking(view) {
|
||||
<td>${fmt(r.newLikes)}</td>
|
||||
<td>${fmt(r.newComments)}</td>
|
||||
<td>${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>
|
||||
<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 = `
|
||||
<div class="page">
|
||||
<div class="page-toolbar">
|
||||
${toolbarBack()}
|
||||
<span class="page-title">抖音周榜</span>
|
||||
<span class="page-title">账号周榜</span>
|
||||
<div class="toolbar-right">
|
||||
<select id="rankCat" class="input-select" title="赛道筛选">
|
||||
<option value="">全部赛道</option>
|
||||
@@ -103,13 +183,14 @@ async function renderRanking(view) {
|
||||
bindPager(view, (p) => { state.page = p; load(); });
|
||||
view.querySelectorAll('[data-import]').forEach((b) => b.addEventListener('click', () => {
|
||||
const r = JSON.parse(b.dataset.import);
|
||||
firePrompt(`请帮我把抖音账号「${r.accountName}」导入并分析(账号信息 + 视频列表):${r.profileUrl || ''}`, '导入指令');
|
||||
confirmSaveAccount(r.accountName, r.profileUrl || '', b);
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留筛选/翻页状态)
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
/* ================= 热门账号 ================= */
|
||||
/* ================= 账号列表 ================= */
|
||||
async function renderAccounts(view) {
|
||||
let state = { type: 'all', search: '', track: '', page: 1, pageSize: 20 };
|
||||
const tracks = (await api('/api/dsh/tracks').catch(() => ({ tracks: [] }))).tracks || [];
|
||||
@@ -117,51 +198,68 @@ async function renderAccounts(view) {
|
||||
const d = await api('/api/dsh/accounts?type=' + state.type + '&search=' + encodeURIComponent(state.search) + '&track=' + encodeURIComponent(state.track) + '&page=' + state.page + '&pageSize=' + state.pageSize);
|
||||
const badge = (t) => `<span class="badge ${t === 'external' ? 'badge-ext' : 'badge-signed'}">${t === 'external' ? '外部' : '签约'}</span>`;
|
||||
const rows = (d.items || []).map((a) => `<tr>
|
||||
<td>${a.favorite ? '⭐' : '☆'}</td>
|
||||
<td><button class="fav-btn" data-fav="${a.id}" data-fav-val="${a.favorite ? 1 : 0}" title="${a.favorite ? '取消关注' : '点击关注'}">${a.favorite ? '⭐' : '☆'}</button></td>
|
||||
<td>${badge(a.account_type)}</td>
|
||||
<td><a class="link strong" href="#/account/${a.id}">${esc(a.account_name)}</a></td>
|
||||
<td><a class="link" href="#/account/${a.id}">${esc(a.account_name)}</a></td>
|
||||
<td>${esc(a.track || '-')}</td>
|
||||
<td>${a.is_ai === '是' ? '是' : '否'}</td>
|
||||
<td>${fmt(a.followers)}</td>
|
||||
<td>${fmt(a.total_likes)}</td>
|
||||
<td>${fmt(a.works_count)}</td>
|
||||
<td>${esc(a.location || '-')}</td>
|
||||
<td>${fmt(a.age)}</td>
|
||||
<td class="cell-content" title="${esc(a.bio || '')}">${esc((a.bio || '').slice(0, 50))}</td>
|
||||
<td class="cell-content" title="${esc(a.content || '')}">${esc((a.content || '').slice(0, 50))}</td>
|
||||
<td>${a.video_count ?? 0}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-create='${esc(a.account_name)}'>创作</button></td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-create='${esc(a.account_name)}' data-acc-id="${a.id}">AI写脚本</button></td>
|
||||
</tr>`).join('');
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page-toolbar">
|
||||
${toolbarBack()}
|
||||
<span class="page-title">热门账号</span>
|
||||
<select id="accType" class="input-select">
|
||||
<option value="all" ${state.type === 'all' ? 'selected' : ''}>全部</option>
|
||||
<option value="signed" ${state.type === 'signed' ? 'selected' : ''}>签约</option>
|
||||
<option value="external" ${state.type === 'external' ? 'selected' : ''}>外部</option>
|
||||
</select>
|
||||
<input id="accSearch" class="input" placeholder="搜索账号名 / 内容 / 赛道" value="${esc(state.search)}">
|
||||
<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>
|
||||
<button class="btn" id="accReset">重置</button>
|
||||
<span class="page-title">账号列表</span>
|
||||
<div class="toolbar-right" style="margin-right:10px">
|
||||
<select id="accType" class="input-select">
|
||||
<option value="all" ${state.type === 'all' ? 'selected' : ''}>全部</option>
|
||||
<option value="signed" ${state.type === 'signed' ? 'selected' : ''}>签约</option>
|
||||
<option value="external" ${state.type === 'external' ? 'selected' : ''}>外部</option>
|
||||
</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="accReset" title="重置筛选">${svgIcon('refresh', 15, 'currentColor')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>收藏</th><th>类型</th><th>账号名</th><th>赛道</th><th>AI内容</th><th>粉丝</th><th>获赞</th><th>作品数</th><th>地区</th><th>年龄</th><th>简介</th><th>定位/内容</th><th>视频数</th><th>操作</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="14" class="empty">暂无账号数据</td></tr>'}</tbody>
|
||||
<thead><tr><th>关注</th><th>类型</th><th>账号名</th><th>赛道</th><th>AI制作</th><th>粉丝</th><th>获赞</th><th>作品数</th><th>地区</th><th>简介</th><th>视频数</th><th class="th-act">操作</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="12" class="empty">暂无账号数据</td></tr>'}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
view.querySelectorAll('[data-fav]').forEach((b) => b.addEventListener('click', async () => {
|
||||
const id = b.dataset.fav;
|
||||
const newVal = b.dataset.favVal !== '1';
|
||||
b.disabled = true;
|
||||
try {
|
||||
const r = await api('/api/dsh/account-favorite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, fav: newVal }) });
|
||||
if (r.error) throw new Error(r.error);
|
||||
showToast(newVal ? '⭐ 已关注' : '☆ 已取消关注');
|
||||
load(); // 刷新列表(排序 favorite DESC 会变化)
|
||||
} catch (e) {
|
||||
showToast('❌ 收藏失败: ' + e.message);
|
||||
b.disabled = false;
|
||||
}
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留筛选/翻页状态)
|
||||
view.querySelector('#accType').addEventListener('change', (e) => { state.type = e.target.value; state.page = 1; load(); });
|
||||
view.querySelector('#accSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } });
|
||||
view.querySelector('#accTrack').addEventListener('change', (e) => { state.track = e.target.value; state.page = 1; load(); });
|
||||
view.querySelector('#accReset').addEventListener('click', () => { state = { type: 'all', search: '', track: '', page: 1, pageSize: 20 }; load(); });
|
||||
view.querySelector('#accImport').addEventListener('click', (e) => openImportAccounts(e.currentTarget));
|
||||
bindPager(view, (p) => { state.page = p; load(); });
|
||||
view.querySelectorAll('[data-create]').forEach((b) => b.addEventListener('click', () => {
|
||||
firePrompt(`请为达人「${b.dataset.create}」创作一条短视频脚本。请先进行账号设定分析,再生成完整脚本。`, '创作指令');
|
||||
confirmAiScript(b.dataset.create, b);
|
||||
}));
|
||||
}
|
||||
await load();
|
||||
@@ -187,38 +285,48 @@ async function renderAccountDetail(view, id) {
|
||||
|
||||
async function load() {
|
||||
const dv = await api('/api/dsh/account-videos?accountId=' + accountId + '&page=' + page + '&pageSize=20');
|
||||
const infoGrid = (label, val, wide) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${esc(fmt(val))}</div></div>`;
|
||||
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>
|
||||
<td><a class="link" href="#/video/${v.id}" title="查看视频分析详情">${esc(v.aweme_id || '-')}</a></td>
|
||||
<td class="cell-content">${v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(v.video_title || '-')}</a>` : esc(v.video_title || '-')}</td>
|
||||
<td>${fmt(v.like_display || v.like_count)}</td>
|
||||
<td>${fmt(v.comment_count)}</td>
|
||||
<td>${fmt(v.share_count)}</td>
|
||||
<td>${fmtDuration(v.duration)}</td>
|
||||
<td>${fmt(v.publish_time)}</td>
|
||||
<td class="cell-act">${(v.has_source || v.has_analysis) ? `<button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ name: acc.account_name, title: v.video_title, aweme: v.aweme_id }))}'>改写脚本</button>` : `<button class="btn btn-sm" data-analyze='${esc(JSON.stringify({ name: acc.account_name, title: v.video_title, aweme: v.aweme_id }))}'>解析</button>`}</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>${fmt(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="cell-act"><button class="btn btn-sm ${(v.has_source || v.has_analysis) ? 'btn-view' : ''}" data-rewrite='${esc(JSON.stringify({ name: acc.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id, 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('');
|
||||
let panel = '';
|
||||
if (tab === 'info') {
|
||||
panel = `<div class="info-grid">
|
||||
${infoGrid('抖音号', acc.douyin_id)}${infoGrid('赛道', acc.track)}${infoGrid('AI内容', acc.is_ai === '是' ? '是' : '否')}
|
||||
${infoGrid('粉丝', acc.followers)}${infoGrid('获赞', acc.total_likes)}${infoGrid('作品数', acc.works_count)}
|
||||
${infoGrid('地区', acc.location)}${infoGrid('年龄', acc.age)}${infoGrid('获取视频', dv.total)}
|
||||
${infoGrid('简介', acc.bio, true)}${infoGrid('定位/内容', acc.content, true)}
|
||||
const contentText = String(acc.content || '');
|
||||
// 账号定位:默认最多 10 行,可点击展开/收起
|
||||
const contentHtml = `<span class="clamp-wrap"><span class="clamp-text" data-clamp>${esc(contentText) || '-'}</span>${contentText.length > 200 ? `<button class="clamp-btn" data-clamp-toggle>展开</button>` : ''}</span>`;
|
||||
panel = `<div class="info-card">
|
||||
<div class="info-row">${infoGrid('抖音号', acc.douyin_id)}${infoGrid('年龄', acc.age)}</div>
|
||||
<div class="info-row">${infoGrid('赛道', acc.track)}${infoGrid('AI制作', acc.is_ai === '是' ? '是' : '否')}${infoGrid('地区', acc.location)}</div>
|
||||
<div class="info-row">${infoGrid('作品数', acc.works_count)}${infoGrid('粉丝', acc.followers)}${infoGrid('获赞', acc.total_likes)}${infoGrid('获取视频', dv.total)}</div>
|
||||
<div class="info-row">${infoGrid('简介', acc.bio, true)}</div>
|
||||
<div class="info-row">${infoGrid('账号定位', contentHtml, true, true)}</div>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="table"><thead><tr><th>视频ID</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="8" class="empty">暂无视频数据</td></tr>'}</tbody></table></div>
|
||||
<h3 class="video-list-title">视频列表</h3>
|
||||
<div class="table-wrap"><table class="table"><thead><tr><th>发布时间</th><th class="th-id">视频ID</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th class="th-act">操作</th></tr></thead><tbody>${rows || '<tr><td colspan="8" class="empty">暂无视频数据</td></tr>'}</tbody></table></div>
|
||||
${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">该账号暂无账号设定信息<button class="btn btn-primary" data-extract style="margin-top:12px">提炼账号设定</button></div>`;
|
||||
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>`;
|
||||
} else {
|
||||
panel = analysis ? `<div class="md-body">${renderMarkdown(analysis)}</div>` : `<div class="empty">该账号暂无数据分析</div>`;
|
||||
}
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page page-detail">
|
||||
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(acc.account_name || '账号详情')}</span></div>
|
||||
<div class="tabs">${tabBtns}</div>
|
||||
${panel}
|
||||
<div class="detail-body">${panel}</div>
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', async () => {
|
||||
@@ -228,16 +336,37 @@ async function renderAccountDetail(view, id) {
|
||||
load();
|
||||
}));
|
||||
bindPager(view, (p) => { page = p; load(); });
|
||||
view.querySelectorAll('[data-clamp-toggle]').forEach((b) => b.addEventListener('click', () => {
|
||||
const text = b.closest('.clamp-wrap').querySelector('[data-clamp]');
|
||||
const open = text.classList.toggle('open');
|
||||
b.textContent = open ? '收起' : '展开';
|
||||
}));
|
||||
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
|
||||
const r = JSON.parse(b.dataset.rewrite);
|
||||
firePrompt(`请为达人「${r.name}」改写一条短视频故事脚本。视频选题:《${r.title}》\n请基于账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`, '改写指令');
|
||||
}));
|
||||
view.querySelectorAll('[data-analyze]').forEach((b) => b.addEventListener('click', () => {
|
||||
const r = JSON.parse(b.dataset.analyze);
|
||||
firePrompt(`请帮我解析这个抖音视频并做拆解分析:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》`, '解析指令');
|
||||
const parsed = !!r.parsed;
|
||||
confirmRun({
|
||||
title: 'AI改写',
|
||||
desc: parsed
|
||||
? `该视频已解析,将基于解析内容为达人「${r.name}」改写一条全新故事脚本(新故事、新情节,不复刻原视频)。`
|
||||
: `该视频尚未解析,将先解析视频内容,再为达人「${r.name}」改写一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
|
||||
target: `达人「${r.name}」|选题《${r.title}》`,
|
||||
prompt: parsed
|
||||
? `请为达人「${r.name}」改写一条短视频故事脚本。视频选题:《${r.title}》\n请基于账号设定与已解析的视频内容,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
|
||||
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」改写一条全新故事脚本:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》`,
|
||||
label: '改写指令',
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
const exBtn = view.querySelector('[data-extract]');
|
||||
if (exBtn) exBtn.addEventListener('click', () => firePrompt(`请为达人「${acc.account_name}」提炼账号设定(生成人设卡)。`, '提炼指令'));
|
||||
if (exBtn) exBtn.addEventListener('click', () => confirmRun({
|
||||
title: '提炼账号设定',
|
||||
desc: '将为达人提炼账号设定(生成人设卡)。',
|
||||
target: acc.account_name,
|
||||
prompt: `请为达人「${acc.account_name}」提炼账号设定(生成人设卡)。`,
|
||||
label: '提炼指令',
|
||||
btn: exBtn,
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab/翻页)
|
||||
}
|
||||
await load();
|
||||
}
|
||||
@@ -253,7 +382,7 @@ async function renderVideoDetail(view, id) {
|
||||
const analysisToMd = (obj) => Object.entries(obj || {}).map(([k, val]) => `## ${k}\n\n${val}`).join('\n\n---\n\n');
|
||||
|
||||
function load() {
|
||||
const titleVal = v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(v.video_title)}</a>` : esc(v.video_title);
|
||||
const titleVal = v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(cleanTitle(v.video_title))}</a>` : esc(cleanTitle(v.video_title));
|
||||
const scriptMd = parse.content || null;
|
||||
const deconstructMd = parse.analysis ? analysisToMd(parse.analysis) : null;
|
||||
const analysisMd = analysis && analysis.content ? analysis.content : null;
|
||||
@@ -263,19 +392,22 @@ async function renderVideoDetail(view, id) {
|
||||
else if (tab === 'deconstruct') body = deconstructMd ? `<div class="md-body">${renderMarkdown(deconstructMd)}</div>` : `<div class="empty">该视频暂无视频拆解(MCP 结构化分析)</div>`;
|
||||
else body = analysisMd ? `<div class="md-body">${renderMarkdown(analysisMd)}</div>` : `<div class="empty">该视频暂无视频分析</div>`;
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(v.video_title || '视频详情')}</span></div>
|
||||
<div class="info-card"><div class="info-grid">
|
||||
${infoGrid('视频标题', titleVal, true, true)}${infoGrid('账号', v.account_name)}${infoGrid('视频ID', v.aweme_id)}
|
||||
${infoGrid('点赞', v.like_display || v.like_count)}${infoGrid('评论', v.comment_count)}${infoGrid('分享', 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="tabs">${tabBtns}</div>
|
||||
${body}
|
||||
<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('点赞', v.like_display || v.like_count)}${infoGrid('评论', v.comment_count)}${infoGrid('分享', 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="tabs">${tabBtns}</div>
|
||||
${body}
|
||||
</div>
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => { tab = b.dataset.tab; load(); }));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab)
|
||||
}
|
||||
load();
|
||||
}
|
||||
@@ -286,32 +418,34 @@ async function renderRewrites(view) {
|
||||
async function load() {
|
||||
const d = await api('/api/dsh/videos?search=' + encodeURIComponent(state.search) + '&parsed=' + state.parsed + '&page=' + state.page + '&pageSize=' + state.pageSize);
|
||||
const rows = (d.items || []).map((v) => `<tr>
|
||||
<td>${esc(v.account_name || '-')}</td>
|
||||
<td class="cell-content"><a class="link strong" data-detail="${v.id}">${esc(v.video_title || '-')}</a></td>
|
||||
<td>${v.account_id ? `<a class="link" href="#/account/${v.account_id}" title="查看账号详情">${esc(v.account_name || '-')}</a>` : esc(v.account_name || '-')}</td>
|
||||
<td class="cell-content">${v.video_url ? `<a class="title-plain" data-ext="${esc(v.video_url)}" title="在新标签打开原视频">${esc(cleanTitle(v.video_title) || '-')}</a>` : esc(cleanTitle(v.video_title) || '-')}</td>
|
||||
<td>${fmt(v.like_display || v.like_count)}</td>
|
||||
<td>${fmt(v.comment_count)}</td>
|
||||
<td>${fmt(v.share_count)}</td>
|
||||
<td>${fmtWan(v.comment_count)}</td>
|
||||
<td>${fmtWan(v.share_count)}</td>
|
||||
<td>${fmtDuration(v.duration)}</td>
|
||||
<td>${fmt(v.publish_time)}</td>
|
||||
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
|
||||
<td>${v.rewrite_count > 0 ? '✅' + v.rewrite_count : '—'}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: v.video_title }))}'>改写</button></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>
|
||||
</tr>`).join('');
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page-toolbar">
|
||||
${toolbarBack()}
|
||||
<span class="page-title">AI写脚本</span>
|
||||
<input id="rwSearch" class="input" placeholder="搜索视频标题 / 账号" value="${esc(state.search)}">
|
||||
<select id="rwParsed" class="input-select">
|
||||
<option value="" ${state.parsed === '' ? 'selected' : ''}>全部状态</option>
|
||||
<option value="1" ${state.parsed === '1' ? 'selected' : ''}>已解析</option>
|
||||
<option value="0" ${state.parsed === '0' ? 'selected' : ''}>未解析</option>
|
||||
</select>
|
||||
<span class="pager-info">每账号近期 30W+ 且评论 1W+ 取 10 条</span>
|
||||
<div class="toolbar-right" style="margin-right:10px">
|
||||
<input id="rwSearch" class="input" placeholder="搜索视频标题 / 账号" value="${esc(state.search)}">
|
||||
<select id="rwParsed" class="input-select">
|
||||
<option value="" ${state.parsed === '' ? 'selected' : ''}>全部状态</option>
|
||||
<option value="1" ${state.parsed === '1' ? 'selected' : ''}>已解析</option>
|
||||
<option value="0" ${state.parsed === '0' ? 'selected' : ''}>未解析</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-note">每账号近期 30W+ 且评论 1W+ 取 10 条</div>
|
||||
<div class="table-wrap"><table class="table">
|
||||
<thead><tr><th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>已改写</th><th>操作</th></tr></thead>
|
||||
<thead><tr><th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>已改写</th><th class="th-act">操作</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="10" class="empty">暂无视频数据</td></tr>'}</tbody>
|
||||
</table></div>
|
||||
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
|
||||
@@ -323,7 +457,14 @@ async function renderRewrites(view) {
|
||||
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail)));
|
||||
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
|
||||
const r = JSON.parse(b.dataset.rewrite);
|
||||
firePrompt(`请为达人「${r.name}」基于视频《${r.title}》改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。`, '改写指令');
|
||||
confirmRun({
|
||||
title: '改写脚本',
|
||||
desc: '将基于该视频,为达人改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。',
|
||||
target: `达人「${r.name}」|《${r.title}》`,
|
||||
prompt: `请为达人「${r.name}」基于视频《${r.title}》改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。`,
|
||||
label: '改写指令',
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -337,6 +478,7 @@ async function renderRewrites(view) {
|
||||
const stb = await api('/api/dsh/storyboards?videoId=' + videoId).catch(() => ({ items: [] }));
|
||||
const storyboards = stb.items || [];
|
||||
let sbIdx = 0;
|
||||
let showSrc = false; // 源脚本列默认收起
|
||||
|
||||
function renderSbBlocks(text) {
|
||||
const blocks = String(text || '').split(/\n\s*---+\s*\n/).map((b) => b.trim()).filter(Boolean);
|
||||
@@ -354,28 +496,83 @@ async function renderRewrites(view) {
|
||||
const cur = rewrites[verIdx];
|
||||
const visibleSb = storyboards.filter((s) => !cur || s.rewrite_id === cur.id);
|
||||
const sb = visibleSb[Math.min(sbIdx, visibleSb.length - 1)];
|
||||
const verCtrl = (p, idx, len, label, genLabel) => {
|
||||
const inner = len > 1
|
||||
? `<span class="rw-sec-count">${label} ${idx + 1}/${len}</span><button class="btn btn-sm" data-${p}prev ${idx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-${p}next ${idx >= len - 1 ? 'disabled' : ''}>下一版 →</button>`
|
||||
: (len === 1 ? `<span class="rw-sec-count">${label} 1/1</span>` : '');
|
||||
return `<div class="rw-sec-foot">${inner}${genLabel ? `<button class="btn btn-sm" data-${p}gen>${svgIcon('refresh', 14, 'currentColor')} ${genLabel}</button>` : ''}</div>`;
|
||||
};
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `<div class="modal rw-modal">
|
||||
<div class="modal-title-row"><span>源脚本 vs AI写脚本 — ${esc(title)}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="rw-split">
|
||||
<div class="rw-pane"><div class="rw-pane-title">源脚本</div><div class="rw-content md-body">${renderMarkdown(source)}</div></div>
|
||||
<div class="rw-pane"><div class="rw-pane-title">AI脚本 ${rewrites.length > 1 ? `(版本 ${verIdx + 1}/${rewrites.length})` : ''}</div>
|
||||
${rewrites.length > 1 ? `<div class="rw-ver"><button class="btn btn-sm" data-vprev ${verIdx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-vnext ${verIdx >= rewrites.length - 1 ? 'disabled' : ''}>下一版 →</button></div>` : ''}
|
||||
<div class="rw-content md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无改写脚本)'}</div>
|
||||
<div class="modal-title-row"><span>${esc(fmtTitleQ(title))}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="rw-stack">
|
||||
<div class="rw-sec${showSrc ? '' : ' rw-sec-hidden'}" data-src-sec>
|
||||
<div class="rw-sec-head">源脚本</div>
|
||||
<div class="rw-sec-body md-body">${renderMarkdown(source)}</div>
|
||||
<div class="rw-sec-foot"></div>
|
||||
</div>
|
||||
<div class="rw-sec">
|
||||
<div class="rw-sec-head">AI脚本<button class="btn btn-sm btn-view rw-src-toggle${showSrc ? ' src-on' : ''}" data-src-toggle title="${showSrc ? '收起源脚本' : '展开源脚本'}">${svgIcon('file-text', 14, 'currentColor')}</button></div>
|
||||
<div class="rw-sec-body md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无改写脚本)'}</div>
|
||||
${verCtrl('v', verIdx, rewrites.length, '版本')}
|
||||
</div>
|
||||
<div class="rw-sec">
|
||||
<div class="rw-sec-head">AI分镜提示词</div>
|
||||
<div class="rw-sec-body">${sb ? renderSbBlocks(sb.script_text) : '<div class="dim">(暂无分镜提示词)</div>'}</div>
|
||||
${visibleSb.length ? verCtrl('s', sbIdx, visibleSb.length, '分镜', '重新生成') : `<div class="rw-sec-foot"><button class="btn btn-sm" data-gen-sb>${svgIcon('zap', 14, 'currentColor')} 生成分镜</button></div>`}
|
||||
</div>
|
||||
</div>
|
||||
${visibleSb.length ? `<div class="rw-sb"><div class="rw-pane-title">AI分镜提示词 ${visibleSb.length > 1 ? `(${Math.min(sbIdx, visibleSb.length - 1) + 1}/${visibleSb.length})` : ''}</div>
|
||||
${visibleSb.length > 1 ? `<div class="rw-ver"><button class="btn btn-sm" data-sprev ${sbIdx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-snext ${sbIdx >= visibleSb.length - 1 ? 'disabled' : ''}>下一版 →</button></div>` : ''}
|
||||
<div class="rw-content">${sb ? renderSbBlocks(sb.script_text) : '(暂无分镜提示词)'}</div></div>` : ''}
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
|
||||
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
|
||||
const q = (s) => mask.querySelector(s);
|
||||
const srcToggle = q('[data-src-toggle]'); if (srcToggle) srcToggle.addEventListener('click', () => { showSrc = !showSrc; mask.remove(); renderModal(); });
|
||||
const vprev = q('[data-vprev]'); if (vprev) vprev.addEventListener('click', () => { if (verIdx > 0) { verIdx--; sbIdx = 0; mask.remove(); renderModal(); } });
|
||||
const vnext = q('[data-vnext]'); if (vnext) vnext.addEventListener('click', () => { if (verIdx < rewrites.length - 1) { verIdx++; sbIdx = 0; mask.remove(); renderModal(); } });
|
||||
const sprev = q('[data-sprev]'); if (sprev) sprev.addEventListener('click', () => { if (sbIdx > 0) { sbIdx--; mask.remove(); renderModal(); } });
|
||||
const snext = q('[data-snext]'); if (snext) snext.addEventListener('click', () => { if (sbIdx < visibleSb.length - 1) { sbIdx++; mask.remove(); renderModal(); } });
|
||||
const fireGenSb = (btn, regenerate) => {
|
||||
confirmRun({
|
||||
title: regenerate ? '重新生成分镜' : '生成分镜',
|
||||
desc: '将基于该 AI 改写脚本,展开成 AI 分镜提示词(按分镜技能生成镜头级描述)。',
|
||||
target: `《${title}》`,
|
||||
prompt: `请将视频《${title}》的 AI 改写脚本${regenerate ? '重新' : ''}展开成 AI 分镜提示词(按分镜技能生成镜头级描述)。`,
|
||||
label: '分镜指令',
|
||||
btn,
|
||||
});
|
||||
};
|
||||
const genSb = q('[data-gen-sb]'); if (genSb) genSb.addEventListener('click', () => fireGenSb(genSb, false));
|
||||
const sgen = q('[data-sgen]'); if (sgen) sgen.addEventListener('click', () => fireGenSb(sgen, true));
|
||||
// AI脚本列 ↔ AI分镜列 场次联动:滑动 AI脚本到第 N 场,分镜对应滚动到第 N 场块(仅该脚本有分镜时)
|
||||
if (visibleSb.length) {
|
||||
const secs = [...mask.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const aiBody = secs[1] && secs[1].querySelector('.rw-sec-body');
|
||||
const sbBody = secs[2] && secs[2].querySelector('.rw-sec-body');
|
||||
if (aiBody && sbBody) {
|
||||
// AI脚本列主导联动:AI脚本列滚动 → 分镜列对齐到对应场块(分镜列滚动不反向联动)
|
||||
const syncSb = () => {
|
||||
const heads = [...aiBody.querySelectorAll('strong, h1, h2, h3, h4')].filter((el) => /场\s*\d/.test(el.textContent));
|
||||
if (!heads.length) return;
|
||||
const aiTop = aiBody.getBoundingClientRect().top;
|
||||
const limit = aiBody.scrollTop + 60; // 视口上缘往下 60px 内最后一个场标题 = 当前场
|
||||
let cur = heads[0];
|
||||
for (const h of heads) {
|
||||
const t = h.getBoundingClientRect().top - aiTop + aiBody.scrollTop;
|
||||
if (t <= limit) cur = h; else break;
|
||||
}
|
||||
const m = /场\s*(\d+)/.exec(cur.textContent);
|
||||
if (!m) return;
|
||||
const target = [...sbBody.querySelectorAll('.sb-block')].find((b) => new RegExp('场' + m[1] + '(?![0-9])').test((b.querySelector('.sb-head') || {}).textContent || ''));
|
||||
if (target) {
|
||||
const t = target.getBoundingClientRect().top - sbBody.getBoundingClientRect().top + sbBody.scrollTop;
|
||||
if (Math.abs(sbBody.scrollTop - t) > 1) sbBody.scrollTop = t; // 位置相同不重复设置,避免事件环
|
||||
}
|
||||
};
|
||||
aiBody.addEventListener('scroll', syncSb, { passive: true });
|
||||
syncSb(); // 打开弹窗时先对齐一次
|
||||
}
|
||||
}
|
||||
}
|
||||
renderModal();
|
||||
}
|
||||
@@ -404,7 +601,7 @@ async function renderReviews(view) {
|
||||
};
|
||||
const reviewOf = (row) => (row.review_ai && row.review_ai.status === 'done' ? row.review_ai : (row.review_compare && row.review_compare.status === 'done' ? row.review_compare : null));
|
||||
const fmtScore = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : '-');
|
||||
const GEN = { rewrite: '爆款视频创作', new: '热门账号创作', abc: 'ABC融合创作' };
|
||||
const GEN = { rewrite: '爆款视频创作', new: '账号列表创作', abc: 'ABC融合创作' };
|
||||
|
||||
const rows = items.map((row) => {
|
||||
const sd = dimsOf(row);
|
||||
@@ -437,7 +634,13 @@ async function renderReviews(view) {
|
||||
view.querySelectorAll('[data-open]').forEach((a) => a.addEventListener('click', () => openScriptDetail(a.dataset.open)));
|
||||
view.querySelectorAll('[data-review]').forEach((a) => a.addEventListener('click', () => openReviewDetail(a.dataset.review)));
|
||||
view.querySelectorAll('[data-review-row]').forEach((b) => b.addEventListener('click', () => {
|
||||
firePrompt(`请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。`, '复盘指令');
|
||||
confirmRun({
|
||||
title: '复盘评分',
|
||||
desc: '将对该 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。',
|
||||
prompt: '请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。',
|
||||
label: '复盘指令',
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
|
||||
async function openScriptDetail(rewriteId) {
|
||||
@@ -450,7 +653,7 @@ async function renderReviews(view) {
|
||||
const cur = idx >= 0 ? rewrites[idx] : null;
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `<div class="modal rw-modal">
|
||||
<div class="modal-title-row"><span>源脚本 vs AI写脚本 — ${esc(row.title || '')}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="modal-title-row"><span>${esc(fmtTitleQ(row.title || ''))}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="rw-split">
|
||||
<div class="rw-pane"><div class="rw-pane-title">源脚本</div><div class="rw-content md-body">${renderMarkdown(d2.source || '(暂无源脚本数据)')}</div></div>
|
||||
<div class="rw-pane"><div class="rw-pane-title">AI脚本</div><div class="rw-content md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无改写脚本)'}</div></div>
|
||||
|
||||
@@ -26,17 +26,17 @@
|
||||
<!-- ========== 设置弹窗 ========== -->
|
||||
<div id="settingsModal" class="modal-mask" hidden>
|
||||
<div class="modal">
|
||||
<h3><i data-icon="settings"></i> 项目地址设置</h3>
|
||||
<p class="dim">默认读取桌面 <code>MCNSkill项目</code>。可添加其他项目地址(如 dsh 环境的 <code>D:\dshworkspace</code>)。</p>
|
||||
<div class="modal-title-row">
|
||||
<span><i data-icon="settings"></i> 项目文件地址</span>
|
||||
<button class="btn" id="btnCloseSettings" title="关闭"><i data-icon="x"></i></button>
|
||||
</div>
|
||||
<p class="dim">默认读取桌面 <code>MCNSkill项目</code>。</p>
|
||||
<div id="rootList" class="root-list"></div>
|
||||
<div class="root-add">
|
||||
<input id="newRoot" type="text" placeholder="输入项目地址完整路径,如 D:\dshworkspace">
|
||||
<button id="btnAddRoot" class="primary">添加</button>
|
||||
<input id="newRoot" type="text" placeholder="C:\Users\maidou\Desktop\MCNSkill项目">
|
||||
<button id="btnAddRoot" class="primary">确认</button>
|
||||
</div>
|
||||
<p id="rootMsg" class="root-msg"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="btnCloseSettings" class="ghost">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ main { height: calc(100vh - 54px); }
|
||||
.intro { max-width: 900px; margin: 0 auto 20px; color: var(--dim); }
|
||||
.func-grid {
|
||||
max-width: 900px; margin: 0 auto;
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 16px;
|
||||
display: grid; grid-template-columns: 1fr; /* 单列,卡片独占一行 */
|
||||
gap: 14px;
|
||||
}
|
||||
.func-card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
@@ -75,10 +75,10 @@ main { height: calc(100vh - 54px); }
|
||||
.func-card h3 { font-size: 18px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
|
||||
.func-card .desc { color: var(--dim); font-size: 15px; margin-bottom: 10px; }
|
||||
.func-card .qa { border-top: 1px dashed var(--border); padding-top: 8px; }
|
||||
.qa-row { display: flex; gap: 8px; padding: 4px 0; font-size: 15px; }
|
||||
.qa-row .q { color: var(--accent); white-space: nowrap; }
|
||||
.qa-row .a { color: var(--dim); }
|
||||
.qa-row.copyable { cursor: pointer; border-radius: 6px; padding: 4px 6px; margin: 0 -6px; transition: background .15s; }
|
||||
.qa-row { padding: 6px 8px; font-size: 15px; line-height: 1.55; }
|
||||
.qa-row .q { color: var(--accent); font-weight: 500; display: block; white-space: normal; }
|
||||
.qa-row .a { color: var(--dim); display: block; padding-left: 1.2em; margin-top: 2px; }
|
||||
.qa-row.copyable { cursor: pointer; border-radius: 8px; margin: 0 -8px; transition: background .15s; }
|
||||
.qa-row.copyable:hover { background: var(--primary-soft); }
|
||||
.qa-row.copyable:hover .q::after { content: ' 📋'; font-size: 14px; opacity: .7; }
|
||||
|
||||
@@ -168,12 +168,13 @@ main { height: calc(100vh - 54px); }
|
||||
padding: 8px 14px; margin: 10px 0; border-radius: 0 8px 8px 0; color: #333;
|
||||
}
|
||||
.md-body table { border-collapse: collapse; margin: 10px 0; width: 100%; display: block; overflow-x: auto; }
|
||||
.md-body table th:first-child, .md-body table td:first-child { width: 100px; } /* markdown 表格第一列统一 100px(如人设卡维度列) */
|
||||
.md-body th, .md-body td { border: 1px solid var(--border); padding: 6px 10px; font-size: 15px; }
|
||||
.md-body th { background: #f6f8fa; font-weight: 600; }
|
||||
.md-body ul, .md-body ol { margin: 8px 0 8px 22px; }
|
||||
.md-body li { margin: 3px 0; }
|
||||
.md-body hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
||||
.md-body a { color: var(--primary); }
|
||||
.md-body a { color: #4a90e2; }
|
||||
|
||||
/* ---- JSON 预览 ---- */
|
||||
.json-view pre {
|
||||
@@ -195,6 +196,31 @@ main { height: calc(100vh - 54px); }
|
||||
.modal h3 { margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
|
||||
.modal .dim { color: var(--dim); font-size: 15px; margin-bottom: 12px; }
|
||||
.modal code { background: #f0f2f5; padding: 1px 5px; border-radius: 4px; font-size: 14px; }
|
||||
/* 保存确认弹窗:账号名/链接展示 */
|
||||
.confirm-target {
|
||||
background: var(--primary-soft); border-radius: 8px; padding: 10px 12px;
|
||||
font-size: 16px; font-weight: 600; color: var(--primary); margin-bottom: 6px;
|
||||
}
|
||||
.confirm-url {
|
||||
color: var(--dim); font-size: 13px; word-break: break-all;
|
||||
padding: 0 2px 4px; margin-bottom: 6px;
|
||||
}
|
||||
/* AI脚本弹窗:选题输入区 + 右下角 AI 生成按钮 */
|
||||
.ai-topic { position: relative; margin-bottom: 4px; }
|
||||
.ai-topic-input {
|
||||
width: 100%; min-height: 168px; padding: 8px 10px;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
font-size: 15px; font-family: inherit; line-height: 1.5; resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ai-topic-input:focus { outline: none; border-color: var(--primary); }
|
||||
.ai-topic-btn {
|
||||
position: absolute; right: 8px; bottom: 8px;
|
||||
border: 1px solid var(--primary); background: var(--primary-soft); color: var(--primary);
|
||||
border-radius: 6px; padding: 3px 12px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.ai-topic-btn:hover { background: var(--primary); color: #fff; }
|
||||
.ai-topic-btn:disabled { opacity: .6; cursor: wait; }
|
||||
.root-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.root-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
@@ -209,6 +235,11 @@ 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; }
|
||||
.import-form .divider { height: 1px; background: var(--border); margin: 14px 0; }
|
||||
|
||||
.dim { color: var(--dim); }
|
||||
|
||||
@@ -223,7 +254,7 @@ main { height: calc(100vh - 54px); }
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
/* ================= 面包屑 ================= */
|
||||
.crumbs { display: flex; align-items: baseline; gap: 8px; margin-left: 12px; min-width: 0; }
|
||||
.crumbs { display: flex; align-items: baseline; gap: 8px; margin-left: 0; min-width: 0; }
|
||||
.crumb-home { font-size: 21px; font-weight: 700; color: var(--text); cursor: pointer; text-decoration: none; }
|
||||
a.crumb-home:hover { color: var(--primary); }
|
||||
.crumb-sep { color: var(--dim); font-size: 16px; }
|
||||
@@ -243,18 +274,22 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
|
||||
/* ================= 功能页通用 ================= */
|
||||
.page { padding: 20px 24px; max-width: 1440px; margin: 0 auto; }
|
||||
.page-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
/* 账号详情页:仅内容区(信息卡/列表/面板)右移 10px,返回按钮与 tab 保持原位 */
|
||||
.page-detail .detail-body { padding-left: 10px; }
|
||||
.page-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.toolbar-note { font-size: 12px; color: var(--dim); text-align: right; margin: -14px 10px 16px auto; } /* 功能栏下方小字说明,右对齐 */
|
||||
.page-title { font-size: 18px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.input { border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 15px; min-width: 200px; background: #fff; color: var(--text); }
|
||||
.input-select { border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; font-size: 15px; background: #fff; color: var(--text); }
|
||||
.btn { border: 1px solid var(--border); background: var(--panel); color: var(--text); padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 15px; text-decoration: none; }
|
||||
.btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-busy { background: var(--primary-soft) !important; color: var(--primary) !important; border-color: var(--primary) !important; opacity: .8; }
|
||||
.btn-sm { padding: 3px 10px; font-size: 14px; border-radius: 6px; }
|
||||
.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-view { background: #fff; border-color: var(--primary); color: var(--primary); }
|
||||
@@ -268,40 +303,70 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
.table td { padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.table tbody tr:hover { background: var(--primary-soft); }
|
||||
.cell-content { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cell-loc { max-width: 110px; } /* 账号定位列缩窄 */
|
||||
.sortable { cursor: pointer; }
|
||||
.sortable:hover { color: var(--primary); }
|
||||
.link { color: var(--primary); cursor: pointer; text-decoration: none; }
|
||||
.link { color: #4a90e2; cursor: pointer; text-decoration: none; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
.link.strong { font-weight: 600; }
|
||||
/* 无颜色区分的链接(视频标题等):默认同正文色,hover 才提示可点 */
|
||||
.title-plain { color: inherit; cursor: pointer; text-decoration: none; }
|
||||
.title-plain:hover { color: #4a90e2; text-decoration: underline; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 13px; }
|
||||
.badge-signed { background: var(--primary-soft); color: var(--primary); }
|
||||
.badge-ext { background: #fff4e5; color: var(--accent); }
|
||||
.pager { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 12px 4px; }
|
||||
.pager-total, .pager-info { color: var(--dim); font-size: 15px; }
|
||||
|
||||
/* ================= 数据页 tab ================= */
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 14px; border-bottom: 1px solid var(--border); }
|
||||
.tab { border: none; background: none; padding: 8px 16px; font-size: 16px; color: var(--dim); cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
/* ================= 数据页 tab(下划线式:无背景,激活蓝色文字+直线下划线,与内容左对齐) ================= */
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 20px; margin-left: 0; border-bottom: 1px solid var(--border); }
|
||||
.tab { border: none; background: none; padding: 8px 16px; font-size: 15px; color: var(--dim); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; border-radius: 0; }
|
||||
.tab:hover { color: var(--primary); }
|
||||
.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; }
|
||||
|
||||
/* ================= 信息卡 ================= */
|
||||
.info-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; background: var(--panel); }
|
||||
/* 账号信息按行分组:行内项目按内容宽度左对齐,保留行间距 */
|
||||
.info-row { display: flex; flex-wrap: wrap; gap: 10px 80px; }
|
||||
.info-row .info-item { flex: 0 0 auto; min-width: 100px; }
|
||||
.info-row + .info-row { margin-top: 10px; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px 18px; }
|
||||
.info-item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.info-wide { grid-column: 1/-1; }
|
||||
.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; }
|
||||
/* 长文本默认最多 10 行,可点击展开/收起(账号定位等) */
|
||||
.clamp-text { display: -webkit-box; -webkit-line-clamp: 10; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.clamp-text.open { -webkit-line-clamp: unset; }
|
||||
.clamp-btn { border: none; background: none; color: #4a90e2; cursor: pointer; font-size: 13px; padding: 2px 0; }
|
||||
.clamp-btn:hover { text-decoration: underline; }
|
||||
|
||||
/* ================= 对比弹窗 ================= */
|
||||
.modal-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; font-size: 18px; font-weight: 600; }
|
||||
.rw-modal { width: min(1100px, 95vw); max-height: 90vh; overflow: auto; }
|
||||
.modal-title-row span { white-space: pre-wrap; word-break: break-word; } /* 标题内换行(标签/共创信息分行显示) */
|
||||
.rw-modal { width: min(1600px, calc(100% - 100px)); height: calc(100vh - 60px); overflow: auto; } /* 宽度:距左右各 50px,最大 1600px;固定高度距屏幕上下各 30px */
|
||||
.rw-split { display: flex; gap: 14px; }
|
||||
.rw-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.rw-pane-title { font-weight: 600; margin-bottom: 8px; }
|
||||
.rw-content { flex: 1; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; max-height: 60vh; }
|
||||
.rw-ver { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.rw-sb { margin-top: 14px; }
|
||||
/* 查看弹窗横向三板块:等高、与页面高度匹配,底部保留版本切换功能区;宽度不足时源脚本列收起 */
|
||||
.rw-modal { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.rw-modal .modal-title-row { flex: none; }
|
||||
.rw-stack { flex: 1; min-height: 0; display: flex; flex-direction: row; gap: 12px; }
|
||||
.rw-sec { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
.rw-sec-head { flex: none; display: flex; align-items: center; gap: 10px; padding: 8px 12px; font-weight: 600; background: var(--panel); border-bottom: 1px solid var(--border); } /* 标题右侧可放操作按钮(展开源脚本),间隔 10px */
|
||||
.rw-sec-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; }
|
||||
.rw-sec-foot { flex: none; min-height: 46px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); } /* min-height 保证空 foot(源脚本列占位)与其他列等高,内容区一致 */
|
||||
.rw-sec-count { margin-right: auto; color: var(--dim); font-size: 14px; }
|
||||
.rw-sec-hidden { display: none; } /* 源脚本列默认收起 */
|
||||
.src-on { background: #e8f1fd; border-color: #4a90e2; color: #2f6fed; } /* 源脚本展开态高亮 */
|
||||
.rw-scroll { padding: 4px 2px; max-height: 75vh; overflow: auto; }
|
||||
.sb-block { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; background: var(--panel); }
|
||||
.sb-head { display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: #f6f8fa; border-bottom: 1px solid var(--border); }
|
||||
@@ -329,7 +394,7 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
.recent-head .link { font-size: 13px; margin-left: auto; margin-right: 10px; }
|
||||
.recent-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px; }
|
||||
|
||||
/* ================= 首页 抖音周榜 预览 ================= */
|
||||
/* ================= 首页 账号周榜 预览 ================= */
|
||||
.rank-preview { margin: 4px 0 20px; }
|
||||
.rank-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||
.rank-filter { padding: 3px 6px; font-size: 14px; max-width: 110px; }
|
||||
@@ -346,8 +411,19 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
.rk-act { display: flex; align-items: center; justify-content: center; white-space: nowrap; }
|
||||
.cell-act { text-align: center; white-space: nowrap; }
|
||||
.table th.th-act { text-align: center; }
|
||||
/* 收藏按钮(⭐/☆) */
|
||||
.fav-btn { border: none; background: none; cursor: pointer; font-size: 16px; padding: 2px 4px; line-height: 1; border-radius: 6px; }
|
||||
.fav-btn:hover { background: var(--primary-soft); }
|
||||
.fav-btn:disabled { opacity: .5; cursor: wait; }
|
||||
.table td.score-cell { color: var(--accent); font-weight: 600; }
|
||||
.rank-pager { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||
|
||||
/* 空状态(如账号暂无设定):居中图标+标题+说明+按钮,清爽留白 */
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 20px; gap: 10px; text-align: center; }
|
||||
.empty-state .empty-icon { color: var(--dim); opacity: .45; margin-bottom: 2px; }
|
||||
.empty-state .empty-title { font-size: 17px; font-weight: 600; color: var(--text); }
|
||||
.empty-state .empty-desc { font-size: 14px; color: var(--dim); }
|
||||
.empty-state .btn { margin-top: 10px; }
|
||||
.rank-page-info { font-size: 14px; color: var(--dim); }
|
||||
.recent-card { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; cursor: pointer; text-align: left; transition: all .15s; min-height: 172px; }
|
||||
.recent-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(47,111,237,.12); }
|
||||
|
||||
@@ -232,6 +232,20 @@ const server = http.createServer((req, res) => {
|
||||
return dshOk(200, dsh.listAccounts({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), track: String(q.get('track') || ''), type: String(q.get('type') || 'all') }));
|
||||
} catch (e) { return sendErr(500, e.message); }
|
||||
}
|
||||
if (p === '/api/dsh/account-favorite' && req.method === 'POST') {
|
||||
if (dshGuard()) return;
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { id, fav } = JSON.parse(body || '{}');
|
||||
if (!id) return sendErr(400, '缺少 id');
|
||||
dsh.setFavorite(id, !!fav);
|
||||
return dshOk(200, { id, favorite: !!fav });
|
||||
} catch (e) { return sendErr(500, e.message); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (p === '/api/dsh/account-videos') {
|
||||
if (dshGuard()) return;
|
||||
try {
|
||||
@@ -320,6 +334,99 @@ const server = http.createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询 AI 任务执行状态(供前端轮询恢复按钮):queued / pending / running / done / error / notfound
|
||||
if (p === '/api/run/status' && req.method === 'GET') {
|
||||
const id = q.get('id');
|
||||
if (!id) return sendErr(400, '缺少 id');
|
||||
try {
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'), { readOnly: true });
|
||||
const auto = db.prepare('SELECT status, deleted_at, last_run_at FROM automations WHERE id=?').get(id);
|
||||
if (!auto || auto.deleted_at) { db.close(); return sendJSON(200, { ok: true, id, state: 'notfound' }); }
|
||||
const st = db.prepare('SELECT running, last_error FROM automation_runtime_state WHERE automation_id=?').get(id);
|
||||
const runs = db.prepare("SELECT status FROM automation_runs WHERE automation_id=? ORDER BY created_at DESC LIMIT 1").get(id);
|
||||
db.close();
|
||||
let state = 'queued';
|
||||
if (st && Number(st.running) === 1) state = 'running';
|
||||
else if (runs) {
|
||||
const s = String(runs.status || '').toUpperCase();
|
||||
if (s === 'IN_PROGRESS') state = 'running';
|
||||
else if (s === 'PENDING_REVIEW') state = 'pending';
|
||||
else if (['DONE', 'COMPLETED', 'SUCCESS', 'FINISHED'].includes(s)) state = 'done';
|
||||
else if (['ERROR', 'CANCELLED', 'FAILED', 'INTERRUPTED'].includes(s)) state = 'error';
|
||||
else state = 'pending';
|
||||
}
|
||||
return sendJSON(200, { ok: true, id, state, error: (st && st.last_error) || null });
|
||||
} catch (e) {
|
||||
return sendErr(500, '状态查询失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- AI 生成选题(Dify chat-messages 即时调用,参考技能 S5 选题方法论) ----------
|
||||
if (p === '/api/ai/topics' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { accountName } = JSON.parse(body || '{}');
|
||||
if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名');
|
||||
// 1) 查账号定位 + 人设摘要(工作台库)
|
||||
let contentText = '', personaText = '';
|
||||
try {
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true });
|
||||
const acc = db.prepare('SELECT * FROM hot_accounts WHERE account_name=? LIMIT 1').get(accountName);
|
||||
if (acc) {
|
||||
contentText = String(acc.content || '').slice(0, 500);
|
||||
const p = db.prepare('SELECT content_json, summary FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1').get(acc.id);
|
||||
if (p) personaText = String(p.summary || p.content_json || '').slice(0, 500);
|
||||
}
|
||||
db.close();
|
||||
} catch (e) { /* 库不可用不阻塞,仍可生成 */ }
|
||||
// 2) 读 Dify key:环境变量优先,其次技能脚本内置默认
|
||||
let apiKey = process.env.DIFY_MCN_CYLG_KEY || '';
|
||||
if (!apiKey) {
|
||||
const scriptPath = path.join(ROOT, '..', 'scripts', 'MCN_CYLG_API.py');
|
||||
try {
|
||||
const src = fs.readFileSync(scriptPath, 'utf8');
|
||||
const m = src.match(/DEFAULT_CYLG_KEY\s*=\s*"([^"]+)"/);
|
||||
if (m) apiKey = m[1];
|
||||
} catch (e) { /* 脚本不存在则走 env */ }
|
||||
}
|
||||
if (!apiKey) return sendErr(500, '未配置 DIFY_MCN_CYLG_KEY(环境变量或技能脚本内置 KEY)');
|
||||
// 3) 构造选题 query(参考 references/创作流程/5_生成短视频选题.md 方法论)
|
||||
const query = '你是短视频选题策划师,请为达人「' + accountName + '」结合账号设定生成 3 个爆款选题方案。\n\n'
|
||||
+ '【达人信息】\n' + (contentText || '(无账号定位信息)') + '\n'
|
||||
+ (personaText ? '【人设摘要】\n' + personaText + '\n' : '')
|
||||
+ '\n要求:\n'
|
||||
+ '1. 选题要跳出账号核心行为框架,但保留账号结构性符号(固定机制/口头禅等)\n'
|
||||
+ '2. 每个选题一句话说清主题+切入角度,标注目标体感(如治愈感/爽感/获得感)\n'
|
||||
+ '3. 只输出 3 个选题,每行一个,格式:1. 主题(切入角度|目标体感)\n'
|
||||
+ '4. 不要输出其他解释';
|
||||
// 4) 调 Dify chat-messages(blocking 即时返回)
|
||||
const difyUrl = String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1').replace(/\/+$/, '');
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 100000);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(difyUrl + '/chat-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-work-shop' }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally { clearTimeout(timer); }
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) return sendErr(502, 'AI 接口错误: ' + (data.message || resp.status));
|
||||
log('AI 选题生成: ' + accountName);
|
||||
return dshOk(200, { topics: data.answer || '' });
|
||||
} catch (e) {
|
||||
return sendErr(500, '选题生成失败: ' + e.message);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------- 静态文件 ----------
|
||||
let filePath = path.join(PUBLIC_DIR, p === '/' ? 'index.html' : p);
|
||||
// 防穿越
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
**场1:地点·时间(Xs-Xs) 情绪:{从S6继承的情绪值}**
|
||||
【景别 | 环境 | 光线 | 场景 | 场景元素】
|
||||
|
||||
(动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号。情绪通过身体反应内嵌在动作中。)
|
||||
动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号,**不加括号**(括号仅用于对白行表演标注)。情绪通过身体反应内嵌在动作中。
|
||||
|
||||
**角色名**:
|
||||
(动作/表情/语气)
|
||||
@@ -92,7 +92,7 @@
|
||||
| 画外对白 | `**角色名**(画外):` 换行 `(动作/语气)` 换行后写台词 | 角色在画面外说话时使用。画外标注在角色名同行。动作标注另起一行。台词再另起一行。**画外对白≠旁白**——画外对白有明确的说话角色,旁白是无主体的画外音 |
|
||||
| 口播 | `**角色名**(口播·{语气}):台词` 同一行 | **角色口播/单人讲述**使用。角色面对镜头说话,人物标注与台词同一行,不换行 |
|
||||
| 旁白 | `旁白(语气):台词` 同一行,不加粗 | **旁白驱动**使用(仅限 Vlog口播/学习教育/观点输出/教程)。一句一行。**剧情演绎类严禁旁白** |
|
||||
| 动作叙述 | 自然段落 | 无台词的动作/场景描述,直接连续自然段落书写。**支持闪回叙事**:段落以 `闪回:` 开头切入回忆/前情。连续动作用 `→` 串联。场景信息通过动作叙述自然带出,不单独标注 |
|
||||
| 动作叙述 | 自然段落 | **不加括号**(括号仅用于对白行表演标注)。无台词的动作/场景描述,直接连续自然段落书写。**支持闪回叙事**:段落以 `闪回:` 开头切入回忆/前情。连续动作用 `→` 串联。场景信息通过动作叙述自然带出,不单独标注 |
|
||||
| 纯空镜 | 自然段落 | 仅画面叙事、无台词无动作的空镜/B-roll,自然段落书写 |
|
||||
|
||||
> **段落符号规则**:S9 脚本使用连续自然段落书写,不使用列表符号(`- `)。动作叙述、对白、闪回、空镜均以自然段落承载。表格(如账本/清单等纯数据展示)保留表格格式。
|
||||
@@ -207,7 +207,7 @@
|
||||
**_A·场次叙事(F01 剧情演绎):_**
|
||||
- **场次标题**:**加粗**,`第N场:地点·时间(Xs-Xs) 情绪:{值}`,用 `·` 分隔地点和时间。标题只交代场景(在哪里、什么时候),不写叙事内容。
|
||||
- **场次画面元数据**:场标题下加一行【景别 | 环境 | 光线 | 场景 | 场景元素】:景别=画面聚焦范围;环境=环境氛围;光线=光线氛围;场景=具体场景地点;场景元素=关键物件/人物/状态
|
||||
- **动作叙述**:连续自然段落书写,不使用列表符号。情绪通过身体反应内嵌在动作中。
|
||||
- **动作叙述**:连续自然段落书写,不使用列表符号,**不加括号**(括号仅用于对白行表演标注)。情绪通过身体反应内嵌在动作中。
|
||||
- **对白驱动/事件驱动** → 角色名**加粗**单独一行,动作标注另起一行括号包裹 `(动作/表情/语气)`,台词再另起一行。一行一句台词。
|
||||
- 角色在画面外说话 → 角色名加粗单独一行,标注 `(画外)`,动作标注另起一行,台词再另起一行
|
||||
- **禁止旁白驱动**:剧情类故事必须靠角色行为和对话演绎
|
||||
@@ -298,6 +298,7 @@
|
||||
- 不得漏掉蓄力释放出口(仅强情感铺垫强制):蓄力期必须有对应的释放期,释放量≥蓄力量
|
||||
- **不得省略语气标注**:每句台词必须标注表演方向和语气
|
||||
- **不得在分行格式中杂糅多条信息**:一行只放一句台词或一个动作段落
|
||||
- **不得给独立动作叙述段落套括号(A 格式)**:无台词的动作/场景连续叙述(多人行为、情绪反应、场景转换)一律裸写自然段落,不加括号;括号仅用于对白行表演标注(另起一行的 `(动作/表情/语气)`、画外 `(画外)`)。B 格式的纯动作/空镜与 D 格式的画面描述按各自格式规范保留括号包裹,不得混用
|
||||
- **不得用花字替代内容演绎**:好笑靠角色行为,不靠花字标注"好笑"。花字仅剧本确实需要提示后期方向时少量使用(每场≤2条)
|
||||
- **闪回不得替代主线叙事**:单条脚本闪回≤2次,单次时长参见知识库 `03_框架节奏/` 标准,闪回后必须紧跟回流动作
|
||||
- **不得使用列表符号 `- ` 书写脚本正文**(S9 使用连续自然段落格式)
|
||||
@@ -362,7 +363,7 @@
|
||||
**场1:{地点·时间}({X}-{X}秒) 情绪:{从S6继承的情绪值}**
|
||||
【景别 | 环境 | 光线 | 场景 | 场景元素】
|
||||
|
||||
({动作描述——场景/人物行为/情绪变化,连续自然段落})
|
||||
{动作描述——场景/人物行为/情绪变化,连续自然段落,不加括号}
|
||||
|
||||
**{角色名}**:
|
||||
({动作/表情/语气})
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
**场1:地点·时间(Xs-Xs) 情绪:{从S6继承的情绪值}**
|
||||
【景别 | 环境 | 光线 | 场景 | 场景元素】
|
||||
|
||||
(动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号。情绪通过身体反应内嵌在动作中。)
|
||||
动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号,**不加括号**(括号仅用于对白行表演标注)。情绪通过身体反应内嵌在动作中。
|
||||
|
||||
**角色名**:
|
||||
(动作/表情/语气)
|
||||
@@ -245,7 +245,7 @@
|
||||
**场次书写规则:**
|
||||
- **场次标题**:**加粗**,`第N场:地点·时间(Xs-Xs) 情绪:{值}`,用 `·` 分隔地点和时间。转场时用 `→` 连接(如 `ATM前→家中·白天`)。标题只交代场景,不写叙事内容。
|
||||
- **场次画面元数据**:场标题下加一行 `【景别 | 环境 | 光线 | 场景 | 场景元素】`:景别=画面聚焦范围(近景/全景/特写);环境=环境氛围(时间/天气/气氛);光线=光线氛围(暖光/冷光/逆光);场景=具体场景地点;场景元素=关键物件/人物/状态。
|
||||
- **动作叙述**:连续自然段落,不使用列表符号。情绪通过身体反应内嵌在动作中(如"肩膀微微颤动了一下"),与标题的情绪标签形成双轨呼应。
|
||||
- **动作叙述**:连续自然段落,不使用列表符号,**不加括号**(括号仅用于对白行表演标注)。情绪通过身体反应内嵌在动作中(如"肩膀微微颤动了一下"),与标题的情绪标签形成双轨呼应。
|
||||
- **角色名**:**加粗**单独一行,后跟冒号。动作/表情/语气标注另起一行,用括号包裹(可选,纯台词无动作时省略)。动作标注与角色名**不在同一行**。
|
||||
- **台词**:另起一行,口语化、可直接朗读。一行一句台词。
|
||||
- **纯空镜/无台词**:自然段落描述画面。
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
**内容格式:**
|
||||
```
|
||||
(动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号。)
|
||||
动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号(**不加括号**,括号仅用于对白行表演标注)。
|
||||
|
||||
**角色名**:
|
||||
(动作/表情/语气)
|
||||
@@ -58,6 +58,7 @@
|
||||
- 场标题包含 地点·时间 + 情绪标签
|
||||
- **场次画面元数据**:场标题下加一行 `【景别 | 环境 | 光线 | 场景 | 场景元素】`:景别=画面聚焦范围(近景/全景/特写);环境=环境氛围;光线=光线氛围(暖光/冷光/逆光);场景=具体场景地点;场景元素=关键物件/人物/状态
|
||||
- 角色名加粗单独一行,动作标注另起一行括号包裹,台词再另起一行
|
||||
- **独立动作叙述段落(多人行为/情绪反应/场景变化)不加括号**,裸写自然段落;括号仅用于对白行表演标注(B/D 格式的纯动作/空镜/画面描述括号包裹规则不受影响)
|
||||
- 禁止旁白驱动——故事必须靠角色行为和对话演绎
|
||||
- 支持画外对白(角色在场但镜头未对其拍摄)
|
||||
- 三大段结构:开场钩子 → 中间主体 → 结尾收口
|
||||
|
||||
@@ -40,6 +40,8 @@ DIFY_URL = os.environ.get("DIFY_URL", "https://mydify.youmanvideo.com/v1")
|
||||
DIFY_ENDPOINT = f"{DIFY_URL.rstrip('/')}/chat-messages"
|
||||
DEFAULT_MODEL = os.environ.get("DIFY_MCN_MODEL", "gemini")
|
||||
DEFAULT_TIMEOUT = 1200 # 秒
|
||||
# 内置默认 KEY(环境变量 DIFY_MCN_CYLG_KEY 优先;未设置时用此兜底)
|
||||
DEFAULT_CYLG_KEY = "app-0vRaxPhvVOqzFQIa5eFzFdjD"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,9 +114,9 @@ def call_dify(
|
||||
Raises:
|
||||
SystemExit: 缺少密钥、网络错误、API 返回非 200 时退出
|
||||
"""
|
||||
api_key = os.environ.get("DIFY_MCN_CYLG_KEY", "").strip()
|
||||
api_key = os.environ.get("DIFY_MCN_CYLG_KEY", DEFAULT_CYLG_KEY).strip()
|
||||
if not api_key:
|
||||
print("ERROR: 环境变量 DIFY_MCN_CYLG_KEY 未设置。", file=sys.stderr)
|
||||
print("ERROR: DIFY_MCN_CYLG_KEY 未设置且脚本未内置默认 KEY。", file=sys.stderr)
|
||||
print("请先执行: $env:DIFY_MCN_CYLG_KEY=\"app-xxxx\"", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user