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:
1 parent
9d95237f58
commit
a11b68a03e
6 files changed
+333
-77
No files matched your search
@@ -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)) });
|
||||
|
||||
Reference in new issue
Block a user