v25: AI写脚本页三tab脚本中心(参考选题/自定义选题/ABC占位)+UI创作写库闭环

- dsh-data: listCustomScripts/getCustomScript/saveScript(可写连接)/getRewriteStats
- server: /api/dsh/custom-scripts|custom-script|script-save|rewrite-stats
- data-pages: renderRewrites tab化重构(保留openRewriteCompare),徽标计数,账号级进入默认落有数据tab,chip清账号筛选,openCustomScriptView全文弹窗
- saveHintOf 追加全部UI创作prompt尾(参考选题带videoId/awemeId,自定义仅accountId)
- 修复: note/accChip 顶层const不随tab更新→移入load()重算
- style: rw-tabs/rw-tab-badge/rw-chip/cw-modal; app.js +copy图标
This commit is contained in:
maogeigei committed 2026-09-02 19:32:19 +08:00
1 parent 9d95237f58
commit a11b68a03e
6 files changed
+333 -77

No files matched your search

+9
View File
@@ -462,3 +462,12 @@
- **改动 3 处**:data-pages.js REQ_FIELDS pain label `'痛点/爽点'`→`'痛点 / 爽点'`(parseReqSheet/reqMdOf 均 label 驱动,自动一致);data-pages.js renderRewrites `days:'30'`→`'90'`(下拉已有近三个月=90 选项,后端 listVideos 任意数值透传 `-${n} days`);server.js clarify prompt 模板行 + 707 叙述句同步 `痛点 / 爽点`
- **坑(Git Bash taskkill)**:`taskkill //PID 8528 //F` 在 Git Bash 报「无效参数/选项 - '//PID'」→ 旧进程没杀掉,新 node 发现 8900 被占退避 8901,8900 上仍是旧代码!改用 PowerShell `Stop-Process -Id X -Force` 双杀 8528+25320 后重启成功(新 PID 15148)。教训:**Windows 杀 node 进程一律用 PowerShell Stop-Process,勿用 Git Bash taskkill // 语法**
- 注释行 174/177(v17/v20 历史记录)保留旧写法不失真;commit 8443af1;重开 `?v=20260902-19#/rewrites`
## MCN工作台 v25:AI写脚本页三 tab 脚本中心 + UI 创作写库闭环(用户方案「2 先执行」)
- **背景根因**:UI 发起的 AI创作(账号列表弹窗/视频行)prompt 无写库指令 → 脚本只落会话/产出目录,从未进 rewrite_log(库内 26 条全为带写库提示的 batch 脚本);账号列表「AI脚本」N 条跳 rewrites 看不到自定义选题脚本(video_id 空行不被视频列表承载)
- **用户方案定稿**:AI写脚本 列表页分三 tab——【参考选题创作】(有原视频 video_id>0)/【自定义选题创作】(无原视频 video_id 空,账号弹窗创作)/【ABC融合创作(功能待实现,占位)】
- **后端(已全量)**:dsh-data.js 新增 listCustomScripts(video 空+done+非空 text,search=title/account/topic,account 精确过滤)/ getCustomScript(id 拉全文,拒绝 video_id 行)/ saveScript(可写 DatabaseSync 独立连接,videoId>0→video_id 否则 NULL,datetime localtime)/ getRewriteStats(徽标计数:videoScripts vs customScripts,按 account 可选);server.js 路由 custom-scripts / custom-script / script-save(POST) / rewrite-stats;module.exports 全导出
- **前端(tab 化重构 renderRewrites,data-pages.js ~943 行整体 Python 拼接替换,保留嵌套 openRewriteCompare)**:state={tab,scope,ref:{search/parsed/days=90/page/batch},custom:{search/days/page},stats};tab 徽标=state.stats 计数(口径与账号列表 AI脚本 列一致);账号级进入(#/rewrites/账号名)时若 customScripts>0 → 默认落自定义 tab,否则参考 tab;自定义 tab 行=账号/标题/字数/创建时间/查看(openCustomScriptView 全文弹窗:GET custom-script?id,md 渲染+复制全文);chip×清除账号筛选回全局(scope/ref.search 清空→reloadAll 重算徽标);ABC tab=empty-state 占位;**note/accChip 必须放 load() 内每次渲染重算**(顶层 const 只在函数首次执行取值,tab 切换不更新——已修复)
- **写库闭环**:saveHintOf(ctx)(data-pages.js 顶部常量)追加进所有 UI 创作 prompt 尾——参考选题=saveHintOf({videoId,awemeId,accountId})(rewrites ref 行 data-rewrite 补 aweme 字段);自定义=saveHintOf({accountId});指令=任务完成后 fetch POST /api/dsh/script-save JSON.stringify(明确不手拼 JSON、失败重试一次),agent 勿猜库路径
- **自检实录**:POST save(accountId 1629 旧梦留声机)→ ok:true id=38 → custom-scripts?account=旧梦留声机 total=1 → custom-script?id=38 全文 ok → rewrite-stats 26/1 → node DatabaseSync DELETE id=38 清理 → 26/0。既有库中 26 条全部 video_id>0=有源(custom=0 符合「UI 从未入库」根因)
- commit 待执行;重启(PowerShell Stop-Process 杀 8900 → 后台 node server.js 新进程);前端静态文件改动免重启(no-store),**重开页面必须带 ?v=20260902-2X 版本参数**
@@ -376,8 +376,73 @@ function getRanking({ date = '', category = '', sortBy = 'rank', order = '', pag
return { dates, categories, category, date: selDate, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items: pageItems };
}
/* ================= AI写脚本:自定义选题(无视频载体)================= */
/* 09-02 v25:账号列表「AI创作」(自定义选题,无原视频)入库的脚本 = video_id 空。此处逐条列出该类脚本 */
function listCustomScripts({ page = 1, pageSize = 20, search = '', days = '', account = '' } = {}) {
const d = getDb();
if (!d) return { total: 0, page: 1, pageSize, items: [] };
const where = [`(r.video_id IS NULL OR r.video_id = 0)`, `r.status='done'`, `r.script_text IS NOT NULL`, `r.script_text <> ''`];
const params = [];
if (search) { where.push('(r.title LIKE ? OR h.account_name LIKE ? OR r.topic LIKE ?)'); params.push(`%${search}%`, `%${search}%`, `%${search}%`); }
if (account) { where.push('h.account_name = ?'); params.push(account); }
const n = Number(days);
if (n > 0) { where.push(`r.created_time >= date('now', 'localtime', ?)`); params.push(`-${n} days`); }
const w = 'WHERE ' + where.join(' AND ');
const total = d.prepare(`SELECT COUNT(*) c FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id ${w}`).get(...params).c;
const limit = Math.max(1, Math.min(Number(pageSize) || 20, 100));
const offset = (Math.max(1, Number(page) || 1) - 1) * limit;
const items = d.prepare(`
SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.status, r.created_time, h.account_name,
length(r.script_text) AS script_len
FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id
${w} ORDER BY r.id DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
return { total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
}
/* 自定义选题脚本全文(查看弹窗用,避免列表全量携带大文本) */
function getCustomScript(id) {
const d = getDb(); if (!d) throw new Error('数据库不可用');
const row = d.prepare(`SELECT r.id, r.video_id, r.account_id, r.title, r.topic, r.script_text, r.status, r.created_time, r.updated_time, h.account_name
FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id WHERE r.id=?`).get(Number(id));
if (!row || row.video_id) throw new Error('脚本不存在或非自定义选题脚本');
return row;
}
/* 写库:AI创作任务完成后回调保存脚本(自定义选题 videoId 省略/0 → video_id=NULL;参考选题传 videoId)
可写连接单独打开(与只读 getDb 隔离),与 setFavorite 同模式 */
function saveScript({ videoId, awemeId, accountId, title, scriptText }) {
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
const text = String(scriptText || '').trim();
if (!text) throw new Error('脚本内容为空');
const w = new DatabaseSync(DSH_DB);
try {
const vid = Number(videoId) > 0 ? Number(videoId) : null;
const r = w.prepare(`INSERT INTO rewrite_log (video_id, aweme_id, account_id, script_text, title, status, created_time)
VALUES (?, ?, ?, ?, ?, 'done', datetime('now','localtime'))`)
.run(vid, awemeId ? String(awemeId) : null, Number(accountId) > 0 ? Number(accountId) : null, text, String(title || '').trim());
return { id: Number(r.lastInsertRowid) };
} finally {
w.close();
}
}
/* 09-02 v25:AI写脚本 三 tab 徽标计数——按 有原视频(参考选题)/无原视频(自定义选题) 拆分;
口径与账号列表「AI脚本」列一致(status=done 且脚本非空);account 传账号名时按账号统计 */
function getRewriteStats(account = '') {
const d = getDb();
if (!d) return { videoScripts: 0, customScripts: 0 };
const conds = [`r.status='done'`, `r.script_text IS NOT NULL`, `r.script_text <> ''`];
const params = [];
if (account) { conds.push('h.account_name = ?'); params.push(account); }
const base = `FROM rewrite_log r LEFT JOIN hot_accounts h ON h.id=r.account_id WHERE ${conds.join(' AND ')}`;
const videoScripts = d.prepare(`SELECT COUNT(*) c ${base} AND r.video_id > 0`).get(...params).c;
const customScripts = d.prepare(`SELECT COUNT(*) c ${base} AND (r.video_id IS NULL OR r.video_id = 0)`).get(...params).c;
return { videoScripts, customScripts };
}
module.exports = {
dbAvailable, setFavorite, batchDelete, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR,
listCustomScripts, getCustomScript, saveScript, getRewriteStats,
};
@@ -174,6 +174,7 @@ const ICON_PATHS = {
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
'user-plus': '<path d="M16 21v-2a4 4 0 0 0-4-4H6a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><line x1="19" x2="19" y1="8" y2="14"/><line x1="22" x2="16" y1="11" y2="11"/>',
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
'copy': '<rect width="14" height="14" x="8" y="8" rx="2" ry="2"/><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"/>',
};
// 功能图标亮色映射(不同功能不同亮色;未列出的保持 currentColor 中性)
@@ -14,6 +14,24 @@ const SKILL_HINT_STORYBOARD = '请调用 Skill 工具加载「短视频分镜脚
/* 脚本复盘技能声明(09-02 修复:原技能声明混写在业务 prompt 字符串内,拆出独立前缀统一管理;
复盘技能=只分析不改写,且工作台复盘必须独立会话一条脚本一上下文) */
const SKILL_HINT_REVIEW = '请调用 Skill 工具加载「脚本复盘」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频脚本创作/subskills/mcn-script-review/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为工作台复盘任务:严格按技能执行方式判定与复盘流程执行(评分式复盘/对比分析模式由任务说明指定;一条脚本=一个独立上下文,禁止混审多条脚本)。技能边界:只做分析与建议,不修改脚本、不触发创作流程、不改动任何技能文件。';
/* 09-02 v25:AI创作任务完成后的写库回调指令——否则脚本只落会话/产出目录,工作台「AI写脚本」列表看不到。
参考选题创作(有原视频,如视频行 AI创作):传 videoId/awemeId → rewrite_log.video_id 关联原视频;
自定义选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「自定义选题创作」tab。
保存走本地服务 /api/dsh/script-save:server 自行解析 mcn-plugin.db 路径(跨开发机/dsh 环境最可靠),勿让 agent 猜库路径 */
const saveHintOf = (ctx) => {
const ref = Number(ctx.videoId) > 0;
const lines = [];
if (ref) {
lines.push(` "videoId": ${Number(ctx.videoId)}${ctx.awemeId ? `,\n "awemeId": ${JSON.stringify(String(ctx.awemeId))}` : ''},`);
}
lines.push(` "accountId": ${Number(ctx.accountId) || 'null'},`);
lines.push(` "title": "(一句话脚本标题,供列表展示)",`);
lines.push(` "scriptText": "(完整脚本全文)"`);
return `\n\n【任务完成后必须执行】将完整脚本保存到工作台数据库(工作台与本任务同机运行,服务地址 http://localhost:8900):\n`
+ `1. 用 fetch 发起 POST http://localhost:8900/api/dsh/script-save;body 用 JSON.stringify 构造(scriptText 为完整脚本全文,注意转义,不要手拼 JSON):\n{\n${lines.join('\n')}\n}\n`
+ (ref ? '(参考选题创作:脚本归入「参考选题创作」列表,关联该原视频)\n' : '(自定义选题创作:不传 videoId,脚本归入「自定义选题创作」列表)\n')
+ `2. 响应含 ok:true 即已入库;失败重试一次,仍失败则在回复中说明原因。\n完成后回复:脚本标题 + 保存结果。`;
};
const fmt = (v) => (v == null || v === '') ? '-' : String(v);
/* 过万数字以"万"为单位,保留一位小数(如 57141 → 5.7万);已是"x.x万"文本(like_display 抖音原生格式)直接透传(09-01 修复:旧实现 Number('7.8万')=NaN→'-') */
@@ -471,7 +489,7 @@ async function confirmAiScript(accountName, btn) {
ck.querySelector('[data-cancel]').addEventListener('click', rm);
ck.querySelector('[data-ok]').addEventListener('click', async () => {
rm();
const prompt = `${SKILL_HINT_CREATE}\n${buildCreatePrompt(accountName, w, personaBlock(), pHas, attach.text)}`;
const prompt = `${SKILL_HINT_CREATE}\n${buildCreatePrompt(accountName, w, personaBlock(), pHas, attach.text)}${saveHintOf({ accountId: accId })}`;
close();
executeTask({ prompt, label: '创作指令', btn, skills: ['短视频脚本创作'] });
});
@@ -789,6 +807,9 @@ async function renderAccountDetail(view, id) {
const createTail = tb.mode === 'content'
? `请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
: `请结合账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`;
const basePrompt = parsed
? `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}`
: `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。`;
confirmRun({
title: 'AI创作',
desc: parsed
@@ -797,9 +818,7 @@ async function renderAccountDetail(view, id) {
: `该视频已解析,将基于达人账号设定与视频选题,为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频尚未解析,将先解析视频内容,再基于达人账号设定为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
target: `达人「${r.name}」|选题《${r.title}》`,
prompt: parsed
? `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}`
: `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。`,
prompt: basePrompt + saveHintOf({ videoId: r.id, awemeId: r.aweme, accountId: id }),
label: '创作指令',
skills: ['短视频脚本创作'],
btn: b,
@@ -921,13 +940,45 @@ async function renderVideoDetail(view, id) {
}
/* ================= AI写脚本 ================= */
async function renderRewrites(view, initAccount) { /* v22:initAccount=账号列表 AI脚本列跳转进入,预筛该账号 */
async function renderRewrites(view, initAccount) { /* v22:initAccount=账号列表 AI脚本列跳转进入,预筛该账号;09-02 v25:AI写脚本=三类脚本中心(参考选题创作/自定义选题创作/ABC融合创作) */
try { initAccount = decodeURIComponent(initAccount || ''); } catch (e) { initAccount = ''; } /* hash 路径段 param 未解码,中文账号名需还原(v22 fix) */
let state = { search: initAccount || '', parsed: '', days: '90', page: 1, pageSize: 20, batch: false };
/* tab 说明:
ref 参考选题创作——以视频行为选题来源(有原视频):基于「原视频」创作,脚本保存后挂在该视频下(rewrite_log.video_id>0)
custom 自定义选题创作——无原视频:账号列表/详情「AI创作」弹窗等触发,video_id 留空,脚本保存后自动入库归入本列表
abc ABC 融合创作——将同账号多条视频的爆款元素融合成一条新脚本(功能待实现,占位) */
const state = {
tab: 'ref',
scope: initAccount || '', // 账号级进入(账号列表「AI脚本」列):两个 tab 与徽标计数均按该账号过滤
ref: { search: initAccount || '', parsed: '', days: '90', page: 1, pageSize: 20, batch: false },
custom: { search: '', days: '90', page: 1, pageSize: 20 },
stats: { videoScripts: 0, customScripts: 0 },
};
const DAYS_OPTS = [['', '全部时间'], ['30', '近一个月'], ['90', '近三个月'], ['180', '近半年'], ['365', '近一年']];
const daysSel = (id, val) => `<select id="${id}" class="input-select">${DAYS_OPTS.map(([v, l]) => `<option value="${v}" ${val === v ? 'selected' : ''}>${l}</option>`).join('')}</select>`;
const tabCls = (k) => `tab ${state.tab === k ? 'tab-active' : ''}`;
async function load() {
const d = await api('/api/dsh/videos?search=' + encodeURIComponent(state.search) + '&parsed=' + state.parsed + '&days=' + state.days + '&page=' + state.page + '&pageSize=' + state.pageSize);
const rows = (d.items || []).map((v) => `<tr>
${state.batch ? `<td class="cb-cell"><input type="checkbox" class="batch-cb" data-id="${v.id}"></td>` : ''}
let d = null;
if (state.tab === 'ref') {
d = await api('/api/dsh/videos?search=' + encodeURIComponent(state.ref.search) + '&parsed=' + state.ref.parsed + '&days=' + state.ref.days + '&page=' + state.ref.page + '&pageSize=' + state.ref.pageSize);
} else if (state.tab === 'custom') {
d = await api('/api/dsh/custom-scripts?search=' + encodeURIComponent(state.custom.search) + '&days=' + state.custom.days + '&account=' + encodeURIComponent(state.scope) + '&page=' + state.custom.page + '&pageSize=' + state.custom.pageSize);
}
const items = (d && d.items) || [];
/* 每次渲染重算:账号筛选 chip 与说明文案(随 state.tab / state.scope 变化) */
const accChip = state.scope ? `<span class="rw-chip"><span title="当前账号筛选">账号:${esc(state.scope)}</span><button data-scope-clear title="清除账号筛选,查看全部脚本">×</button></span>` : '';
const note = state.tab === 'ref'
? (state.scope
? `账号「${esc(state.scope)}」· 参考选题创作:以该账号视频为选题来源,行内「AI脚本」列可打开脚本对比;行尾「AI创作」= 基于该视频创作新脚本(完成后自动保存并挂到该视频下)`
: '参考选题创作:以库中视频为选题来源(关注账号优先 · 按发布时间倒序),行尾「AI创作」= 基于该视频创作新脚本(完成后自动保存并挂到该视频下)')
: (state.tab === 'custom'
? (state.scope
? `账号「${esc(state.scope)}」· 自定义选题创作:无原视频的脚本(来自账号列表「AI创作」弹窗等),保存后自动入库显示在这里`
: '自定义选题创作:无原视频的脚本(来自账号列表 / 账号详情「AI创作」弹窗等),保存后自动入库显示在这里')
: 'ABC 融合创作:将同账号多条视频的爆款元素融合成一条全新脚本。功能开发中,敬请期待。');
/* —— ref tab:视频行(参考选题载体) —— */
const refRows = items.map((v) => `<tr>
${state.ref.batch ? `<td class="cb-cell"><input type="checkbox" class="batch-cb" data-id="${v.id}"></td>` : ''}
<td>${v.account_id ? `<a class="link" href="#/account/${v.account_id}" title="查看账号详情">${esc(v.account_name || '-')}</a>` : esc(v.account_name || '-')}</td>
<td class="cell-content td-title"><a class="link" href="#/video/${v.id}" title="查看视频详情">${esc(cleanTitle(v.video_title) || '-')}</a></td>
<td>${fmtWan(v.like_display || v.like_count)}</td>
@@ -936,87 +987,140 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
<td>${fmtDuration(v.duration)}</td>
<td>${fmt(v.publish_time)}</td>
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
<td>${v.rewrite_count > 0 ? `<button class="btn btn-sm btn-view" data-detail="${v.id}" title="查看 AI 脚本">${v.rewrite_count} 查看</button>` : '—'}</td>
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title), accountId: v.account_id, parsed: !!(v.has_source || v.has_analysis), url: v.video_url || '' }))}'>AI创作</button></td>
<td>${v.rewrite_count > 0 ? `<button class="btn btn-sm btn-view" data-detail="${v.id}" title="查看该视频下的 AI 脚本(源脚本对比/分镜)">${v.rewrite_count} 查看</button>` : '—'}</td>
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id || '', accountId: v.account_id, parsed: !!(v.has_source || v.has_analysis), url: v.video_url || '' }))}'>AI创作</button></td>
</tr>`).join('');
/* —— custom tab:无视频脚本行 —— */
const customRows = items.map((s) => `<tr>
<td>${s.account_id ? `<a class="link" href="#/account/${s.account_id}" title="查看账号详情">${esc(s.account_name || '-')}</a>` : esc(s.account_name || '-')}</td>
<td class="cell-content td-title" title="${esc(String(s.topic || ''))}">${esc(cleanTitle(s.title) || '(未命名脚本)')}</td>
<td>${s.script_len} 字</td>
<td>${fmt(s.created_time)}</td>
<td class="cell-act"><button class="btn btn-sm btn-view" data-cview="${s.id}" title="查看完整脚本">查看</button></td>
</tr>`).join('');
const toolbarCtrl = state.tab === 'ref' ? `
<input id="rwSearch" class="input" placeholder="搜索视频标题 / 账号" value="${esc(state.ref.search)}">
${daysSel('rwDays', state.ref.days)}
<select id="rwParsed" class="input-select">
<option value="" ${state.ref.parsed === '' ? 'selected' : ''}>全部状态</option>
<option value="1" ${state.ref.parsed === '1' ? 'selected' : ''}>已解析</option>
<option value="0" ${state.ref.parsed === '0' ? 'selected' : ''}>未解析</option>
</select>` : (state.tab === 'custom' ? `
${accChip}
<input id="rwCSearch" class="input" placeholder="搜索脚本标题 / 账号" value="${esc(state.custom.search)}">
${daysSel('rwCDays', state.custom.days)}` : '');
let bodyHtml = '';
if (state.tab === 'ref') {
bodyHtml = `<div class="table-wrap"><table class="table">
<thead><tr>${state.ref.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>AI脚本</th><th class="th-act">操作</th></tr></thead>
<tbody>${refRows || `<tr><td colspan="${state.ref.batch ? 11 : 10}" class="empty">暂无视频数据</td></tr>`}</tbody>
</table></div>`;
} else if (state.tab === 'custom') {
bodyHtml = `<div class="table-wrap"><table class="table">
<thead><tr><th>账号</th><th>脚本标题</th><th>字数</th><th>创建时间</th><th class="th-act">操作</th></tr></thead>
<tbody>${customRows || `<tr><td colspan="5" class="empty">暂无自定义选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里'}</td></tr>`}</tbody>
</table></div>`;
} else {
bodyHtml = `<div class="empty-state">
<div class="empty-icon">${svgIcon('zap', 40)}</div>
<div class="empty-title">ABC 融合创作</div>
<div class="empty-desc">将同账号多条视频的爆款元素融合成一条全新脚本,功能开发中。</div>
</div>`;
}
const foot = state.tab === 'ref'
? `${batchBarHtml(state.ref, 0)}${pagerHtml(d.total, d.page, state.ref.pageSize, (p) => { state.ref.page = p; load(); })}`
: (state.tab === 'custom' ? pagerHtml(d.total, d.page, state.custom.pageSize, (p) => { state.custom.page = p; load(); }) : '');
view.innerHTML = `
<div class="page">
<div class="page-toolbar">
${toolbarBack()}
<span class="page-title">AI写脚本</span>
<div class="toolbar-right" style="margin-right:10px">
<input id="rwSearch" class="input" placeholder="搜索视频标题 / 账号" value="${esc(state.search)}">
<select id="rwDays" class="input-select">
<option value="" ${state.days === '' ? 'selected' : ''}>全部时间</option>
<option value="30" ${state.days === '30' ? 'selected' : ''}>近一个月</option>
<option value="90" ${state.days === '90' ? 'selected' : ''}>近三个月</option>
<option value="180" ${state.days === '180' ? 'selected' : ''}>近半年</option>
<option value="365" ${state.days === '365' ? 'selected' : ''}>近一年</option>
</select>
<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 class="toolbar-right" style="margin-right:10px">${toolbarCtrl}</div>
</div>
<div class="toolbar-note">${initAccount ? `正在查看账号「${esc(initAccount)}」的视频与 AI 脚本 · 视频行「AI脚本」列可打开脚本对比 · 行尾「AI创作」可为该账号继续创作` : '全部视频分页展示 · 关注账号优先 · 按发布时间倒序'}</div>
<div class="table-wrap"><table class="table">
<thead><tr>${state.batch ? '<th class="cb-cell"><input type="checkbox" class="batch-cb-all"></th>' : ''}<th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>AI脚本</th><th class="th-act">操作</th></tr></thead>
<tbody>${rows || `<tr><td colspan="${state.batch ? 11 : 10}" class="empty">暂无视频数据</td></tr>`}</tbody>
</table></div>
<div class="list-footer">
${batchBarHtml(state, 0)}
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
<div class="rw-tabs">
<button class="${tabCls('ref')}" data-tab="ref" title="有原视频:以视频行为选题来源创作,脚本保存后挂在该视频下">参考选题创作<span class="rw-tab-badge">${state.stats.videoScripts}</span></button>
<button class="${tabCls('custom')}" data-tab="custom" title="无原视频:账号列表「AI创作」弹窗等创作,脚本保存后自动入库">自定义选题创作<span class="rw-tab-badge">${state.stats.customScripts}</span></button>
<button class="${tabCls('abc')}" data-tab="abc" title="多视频爆款元素融合创作(开发中)">ABC融合创作</button>
</div>
<div class="rw-note">${note}</div>
${bodyHtml}
<div class="list-footer">${foot}</div>
</div>`;
bindBack(view);
view.querySelector('#rwSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } });
view.querySelector('#rwDays').addEventListener('change', (e) => { state.days = e.target.value; state.page = 1; load(); });
view.querySelector('#rwParsed').addEventListener('change', (e) => { state.parsed = e.target.value; state.page = 1; load(); });
bindPager(view, (p) => { state.page = p; load(); });
bindBatchBar(view, {
state, kind: 'rewrites',
getSelected: () => [...view.querySelectorAll('.batch-cb:checked')].map((c) => Number(c.dataset.id)),
confirmMsg: '将删除选中的 {n} 条视频(含视频解析、AI脚本,不可恢复)。确定删除?',
reload: load,
});
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail)));
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', async () => {
const r = JSON.parse(b.dataset.rewrite);
const parsed = !!r.parsed;
let personaBlock = '';
if (r.accountId) {
try {
const pr = await api('/api/dsh/account-persona?accountId=' + encodeURIComponent(r.accountId));
const c = String(pr.content || '').trim();
if (c) personaBlock = `\n【账号设定(人设卡)】\n${personaForCreate(c)}\n`; // 剔除「十一、当前需解决的问题」及之后章节
} catch (e) { /* 人设缺失不阻塞 */ }
}
const noPersonaNote = personaBlock ? '' : `\n该账号暂无账号设定,请先为达人「${r.name}」提取账号设定(生成人设卡),再基于账号设定与选题创作。`;
// 已解析 → 视频选题块:有「# 选题」段直接用;无(老视频脚本=分镜头表等)→ 带解析内容,AI 先提炼选题再创作
const tb = await buildTopicBlock(r, parsed);
const createTail = tb.mode === 'content'
? `请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
: `请结合账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`;
confirmRun({
title: 'AI创作',
desc: parsed
? tb.mode === 'content'
? `该视频已解析(脚本为分镜头表格式,无选题段),将先提炼视频选题,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频已解析,将基于达人账号设定与视频选题,为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频尚未解析,将先解析视频内容,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
target: `达人「${r.name}」|选题《${r.title}》`,
prompt: parsed
? `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}`
: `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。`,
label: '创作指令',
skills: ['短视频脚本创作'],
btn: b,
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => { state.tab = b.dataset.tab; state.ref.page = 1; state.custom.page = 1; load(); }));
bindPager(view, (p) => { if (state.tab === 'ref') state.ref.page = p; else if (state.tab === 'custom') state.custom.page = p; load(); });
if (state.tab === 'ref') {
const ipt = view.querySelector('#rwSearch');
if (ipt) ipt.addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.ref.search = e.target.value; state.ref.page = 1; load(); } });
const days = view.querySelector('#rwDays');
if (days) days.addEventListener('change', (e) => { state.ref.days = e.target.value; state.ref.page = 1; load(); });
const parsed = view.querySelector('#rwParsed');
if (parsed) parsed.addEventListener('change', (e) => { state.ref.parsed = e.target.value; state.ref.page = 1; load(); });
bindBatchBar(view, {
state: state.ref, kind: 'rewrites',
getSelected: () => [...view.querySelectorAll('.batch-cb:checked')].map((c) => Number(c.dataset.id)),
confirmMsg: '将删除选中的 {n} 条视频(含视频解析、AI脚本,不可恢复)。确定删除?',
reload: load,
});
}));
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail)));
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => fireVideoCreate(b)));
} else if (state.tab === 'custom') {
const ipt = view.querySelector('#rwCSearch');
if (ipt) ipt.addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.custom.search = e.target.value; state.custom.page = 1; load(); } });
const days = view.querySelector('#rwCDays');
if (days) days.addEventListener('change', (e) => { state.custom.days = e.target.value; state.custom.page = 1; load(); });
const clear = view.querySelector('[data-scope-clear]');
if (clear) clear.addEventListener('click', () => { state.scope = ''; state.ref.search = ''; state.ref.page = 1; state.custom.page = 1; reloadAll(); });
view.querySelectorAll('[data-cview]').forEach((b) => b.addEventListener('click', () => openCustomScriptView(Number(b.dataset.cview))));
}
}
/* 视频行「AI创作」(参考选题创作):prompt 尾附写库指令(saveHintOf 带 videoId/awemeId),任务完成脚本自动保存并挂到该视频下 */
async function fireVideoCreate(b) {
const r = JSON.parse(b.dataset.rewrite);
const parsed = !!r.parsed;
let personaBlock = '';
if (r.accountId) {
try {
const pr = await api('/api/dsh/account-persona?accountId=' + encodeURIComponent(r.accountId));
const c = String(pr.content || '').trim();
if (c) personaBlock = `\n【账号设定(人设卡)】\n${personaForCreate(c)}\n`; // 剔除「十一、当前需解决的问题」及之后章节
} catch (e) { /* 人设缺失不阻塞 */ }
}
const noPersonaNote = personaBlock ? '' : `\n该账号暂无账号设定,请先为达人「${r.name}」提取账号设定(生成人设卡),再基于账号设定与选题创作。`;
// 已解析 → 视频选题块:有「# 选题」段直接用;无(老视频脚本=分镜头表等)→ 带解析内容,AI 先提炼选题再创作
const tb = await buildTopicBlock(r, parsed);
const createTail = tb.mode === 'content'
? `请先通读以上【视频解析内容】,提炼出该视频的选题(赛道/受众/视频主题/开场钩子/中间留人/结尾落点等),再结合账号设定与提炼出的选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
: `请结合账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`;
const saveHint = saveHintOf({ videoId: r.id, awemeId: r.aweme, accountId: r.accountId });
confirmRun({
title: 'AI创作',
desc: parsed
? tb.mode === 'content'
? `该视频已解析(脚本为分镜头表格式,无选题段),将先提炼视频选题,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频已解析,将基于达人账号设定与视频选题,为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`
: `该视频尚未解析,将先解析视频内容,再为达人「${r.name}」创作一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
target: `达人「${r.name}」|选题《${r.title}》`,
prompt: parsed
? `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${tb.block}${createTail}${saveHint}`
: `${SKILL_HINT_CREATE}\n请为达人「${r.name}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}\n该视频尚未解析,请先解析这个抖音视频并做拆解分析(${r.url ? '视频地址:' + r.url + '\n' : ''}视频标题:《${r.title}》),再基于账号设定与提炼出的选题,创作完整故事脚本。${saveHint}`,
label: '创作指令',
skills: ['短视频脚本创作'],
btn: b,
});
}
/* 拉取徽标计数 + 重载当前 tab(清除账号筛选后回全局口径) */
async function reloadAll() {
const st = await api('/api/dsh/rewrite-stats?account=' + encodeURIComponent(state.scope)).catch(() => ({ videoScripts: 0, customScripts: 0 }));
state.stats = st;
load();
}
async function openRewriteCompare(videoId) {
const d = await api('/api/dsh/video-script?videoId=' + videoId).catch(() => ({ ok: false }));
const detail = await api('/api/dsh/video-detail?videoId=' + videoId).catch(() => ({ ok: false }));
@@ -1127,7 +1231,38 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
renderModal();
}
/* 启动:拉取徽标计数;账号级进入(账号列表「AI脚本」列)时若该账号存在自定义选题脚本 → 默认落在「自定义选题创作」tab(否则「参考选题创作」) */
{
const st = await api('/api/dsh/rewrite-stats?account=' + encodeURIComponent(state.scope)).catch(() => ({ videoScripts: 0, customScripts: 0 }));
state.stats = st;
if (initAccount && Number(st.customScripts) > 0) state.tab = 'custom';
}
await load();
}
/* 自定义选题脚本 查看弹窗(09-02 v25):GET /api/dsh/custom-script?id 拉全文(脚本不进列表以控体积) */
async function openCustomScriptView(id) {
let s = null;
try { s = (await api('/api/dsh/custom-script?id=' + encodeURIComponent(id))).script; } catch (e) { s = null; }
if (!s) { showToast('❌ 脚本加载失败'); return; }
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `<div class="modal cw-modal">
<div class="modal-title-row"><span>${svgIcon('pen', 16)} 自定义选题脚本 · ${esc(s.account_name || '未知账号')}</span><button class="btn btn-icon" data-x title="关闭">${svgIcon('x', 14)}</button></div>
<div class="cw-meta">${s.title ? `标题:《${esc(s.title)}》 · ` : ''}创建于 ${esc(fmt(s.created_time))} · ${s.script_len} 字${s.topic ? ` · 主题:${esc(String(s.topic).slice(0, 80))}` : ''}</div>
<div class="cw-body md-body">${renderMarkdown(s.script_text || '(暂无内容)')}</div>
<div class="cw-foot">
<button class="btn btn-sm" data-copy title="复制脚本全文">${svgIcon('copy', 14, 'currentColor')} 复制全文</button>
<button class="btn btn-sm btn-primary" data-ok>关闭</button>
</div>
</div>`;
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
const x = mask.querySelector('[data-x]'); if (x) x.addEventListener('click', close);
const ok = mask.querySelector('[data-ok]'); if (ok) ok.addEventListener('click', close);
const cp = mask.querySelector('[data-copy]');
if (cp) cp.addEventListener('click', async () => { showToast((await copyText(s.script_text || '')) ? '✅ 已复制全文' : '❌ 复制失败'); });
}
/* ================= AI脚本诊断 ================= */
@@ -632,3 +632,22 @@ td.td-title { max-width: 240px; }
.batch-count-n { color: var(--danger); font-weight: 600; }
.cb-cell { width: 36px; text-align: center; }
.cb-cell input { width: 15px; height: 15px; cursor: pointer; vertical-align: middle; accent-color: var(--primary); }
/* ---- AI写脚本 三 tab(09-02 v25:参考选题创作 / 自定义选题创作 / ABC融合创作) ---- */
.rw-tabs { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: -12px 0 12px; padding: 0 4px; }
.rw-tabs .tab { font-size: 15px; padding: 9px 14px; }
.rw-tab-badge { display: inline-block; min-width: 18px; margin-left: 6px; padding: 0 6px; border-radius: 10px; background: var(--primary-soft); color: var(--primary); font-size: 12px; line-height: 18px; font-weight: 600; text-align: center; }
.tab-active .rw-tab-badge { background: var(--primary); color: #fff; }
.rw-note { font-size: 12px; color: var(--dim); margin: 0 0 12px; }
.rw-chip { display: inline-flex; align-items: center; gap: 2px; max-width: 260px; padding: 2px 8px 2px 12px; background: var(--primary-soft); color: var(--primary); border-radius: 999px; font-size: 13px; }
.rw-chip > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rw-chip button { border: none; background: none; color: inherit; cursor: pointer; font-size: 15px; line-height: 1; padding: 0 3px; border-radius: 50%; }
.rw-chip button:hover { opacity: .7; }
/* 自定义选题脚本 查看弹窗 */
.cw-modal { width: min(980px, calc(100% - 80px)); height: calc(100vh - 120px); display: flex; flex-direction: column; overflow: hidden; padding: 0; }
.cw-modal .modal-title-row { flex: none; padding: 14px 18px; border-bottom: 1px solid var(--border); margin: 0; }
.cw-meta { flex: none; padding: 8px 18px; border-bottom: 1px dashed var(--border); color: var(--dim); font-size: 13px; }
.cw-body { flex: 1; overflow: auto; padding: 14px 18px; }
.cw-body .md-body h1 { font-size: 20px; }
.cw-body .md-body h2 { font-size: 17px; }
.cw-foot { flex: none; padding: 10px 18px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
@@ -479,6 +479,33 @@ const server = http.createServer((req, res) => {
if (dshGuard()) return;
return dshOk(200, dsh.listRewrites(qInt('videoId', 0)));
}
if (p === '/api/dsh/custom-scripts') { /* 09-02 v25:自定义选题(无视频载体)脚本列表 */
if (dshGuard()) return;
return dshOk(200, dsh.listCustomScripts({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), days: String(q.get('days') || ''), account: String(q.get('account') || '') }));
}
if (p === '/api/dsh/rewrite-stats') { /* 09-02 v25:AI写脚本 tab 徽标计数(参考选题/自定义选题),可传 account 按账号统计 */
if (dshGuard()) return;
return dshOk(200, dsh.getRewriteStats(String(q.get('account') || '')));
}
if (p === '/api/dsh/custom-script') { /* 自定义选题脚本全文(查看弹窗) */
if (dshGuard()) return;
try { return dshOk(200, { script: dsh.getCustomScript(qInt('id', 0)) }); }
catch (e) { return sendErr(404, e.message); }
}
if (p === '/api/dsh/script-save' && req.method === 'POST') { /* AI创作任务完成回调写库(参考选题传 videoId;自定义选题省略→video_id 空) */
if (dshGuard()) return;
let body = '';
req.on('data', (c) => { body += c; if (body.length > 2e6) req.destroy(); });
req.on('end', () => {
try {
const { videoId, awemeId, accountId, title, scriptText } = JSON.parse(body || '{}');
if (!scriptText || !accountId) return sendErr(400, '缺少 scriptText/accountId');
const r = dsh.saveScript({ videoId, awemeId, accountId, title, scriptText });
return dshOk(200, { ok: true, id: r.id });
} catch (e) { return sendErr(500, e.message); }
});
return;
}
if (p === '/api/dsh/storyboards') {
if (dshGuard()) return;
return dshOk(200, { items: dsh.listStoryboards(qInt('videoId', 0)) });