diff --git a/.workbuddy/memory/2026-09-02.md b/.workbuddy/memory/2026-09-02.md index 71b72c4..85b75d4 100644 --- a/.workbuddy/memory/2026-09-02.md +++ b/.workbuddy/memory/2026-09-02.md @@ -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 版本参数** diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js b/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js index 93c1c4f..9f690dd 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/dsh-data.js @@ -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, }; diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js index 7af04db..6904297 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/app.js @@ -174,6 +174,7 @@ const ICON_PATHS = { 'zap': '', 'user-plus': '', 'download': '', + 'copy': '', }; // 功能图标亮色映射(不同功能不同亮色;未列出的保持 currentColor 中性) diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js index 1811a88..9625ccc 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js @@ -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) => ``; + 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) => ` - ${state.batch ? `` : ''} + 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 ? `账号:${esc(state.scope)}` : ''; + 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) => ` + ${state.ref.batch ? `` : ''} ${v.account_id ? `${esc(v.account_name || '-')}` : esc(v.account_name || '-')} ${esc(cleanTitle(v.video_title) || '-')} ${fmtWan(v.like_display || v.like_count)} @@ -936,87 +987,140 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号 ${fmtDuration(v.duration)} ${fmt(v.publish_time)} ${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'} - ${v.rewrite_count > 0 ? `` : '—'} - + ${v.rewrite_count > 0 ? `` : '—'} + `).join(''); + /* —— custom tab:无视频脚本行 —— */ + const customRows = items.map((s) => ` + ${s.account_id ? `${esc(s.account_name || '-')}` : esc(s.account_name || '-')} + ${esc(cleanTitle(s.title) || '(未命名脚本)')} + ${s.script_len} 字 + ${fmt(s.created_time)} + + `).join(''); + + const toolbarCtrl = state.tab === 'ref' ? ` + + ${daysSel('rwDays', state.ref.days)} + ` : (state.tab === 'custom' ? ` + ${accChip} + + ${daysSel('rwCDays', state.custom.days)}` : ''); + + let bodyHtml = ''; + if (state.tab === 'ref') { + bodyHtml = `
+ ${state.ref.batch ? '' : ''} + ${refRows || ``} +
账号视频标题点赞评论分享时长发布时间状态AI脚本操作
暂无视频数据
`; + } else if (state.tab === 'custom') { + bodyHtml = `
+ + ${customRows || ``} +
账号脚本标题字数创建时间操作
暂无自定义选题脚本${state.scope ? '(该账号暂无无视频脚本;有原视频的脚本请切到「参考选题创作」tab 查看)' : '——可从账号列表「AI创作」弹窗创作,任务完成自动入库后显示在这里'}
`; + } else { + bodyHtml = `
+
${svgIcon('zap', 40)}
+
ABC 融合创作
+
将同账号多条视频的爆款元素融合成一条全新脚本,功能开发中。
+
`; + } + 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 = `
${toolbarBack()} AI写脚本 -
- - - -
+
${toolbarCtrl}
-
${initAccount ? `正在查看账号「${esc(initAccount)}」的视频与 AI 脚本 · 视频行「AI脚本」列可打开脚本对比 · 行尾「AI创作」可为该账号继续创作` : '全部视频分页展示 · 关注账号优先 · 按发布时间倒序'}
-
- ${state.batch ? '' : ''} - ${rows || ``} -
账号视频标题点赞评论分享时长发布时间状态AI脚本操作
暂无视频数据
- `; + 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 = ``; + 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脚本诊断 ================= */ diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css index c4ecdef..451fd54 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/style.css @@ -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; } diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js index dfe5ef5..0c9fc3a 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/server.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/server.js @@ -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)) });