R55-R58: AI脚本弹窗版本条(新在右旧在左)+教程完整链维度不适用即隐藏+热点账号日榜+脚本删除按钮(级联诊断分镜, 含help-guide.js补纳入)

This commit is contained in:
maogeigei committed 2026-09-04 12:08:47 +08:00
1 parent 3343323226
commit 32565a7881
8 files changed
+719 -79

No files matched your search

+43
View File
@@ -25,3 +25,46 @@
- **改动 6 文件 8 处全量替换**(grep 零残留验证):`index.html` `<title>`、`app.js` 面包屑 crumb-home ×2 + 头注释、`data-pages.js`/`style.css`/`dsh-data.js` 头注释、`server.js` 头注释 + 启动日志;首页无独立大字标题,品牌名只在此两处 UI 呈现
- **缓存版本统一 bump** `?v=20260904a`(style/app/data-pages 三文件引用);brand 区曾误加 brand-name 文字会与 crumb-home 重复显示→已撤销
- **服务重启生效**:杀 8900 旧进程(34076)→ 重启(24836),curl 验证 `<title>短视频工作台</title>` + crumb-home 文本正确
## AI脚本弹窗版本条重构:新在右 旧在左(R55)
- 用户反馈:「AI脚本 弹窗内容下面的版本切换 不好理解,默认选中的是 最新版 还是 最老的版本」→ 答复=默认最新(rewrite_log ORDER BY id DESC,索引0=最新,verIdx=0);AskUserQuestion 确认「方向词+最新标记」;追加要求「新的放在右边 旧的放在左边」
- **实施(data-pages.js openScriptModal)**:①`rewrites = rewrites.slice().reverse()` 展示层 旧→新(副本翻转,最左=最初版、最右=最新版);②verIdx 默认=length-1(最新/最右),指定 rewriteId 时 findIndex 定位(找不到回最新);③storyboards 同法 reverse(末位=最新分镜);④sbIdx 语义改 -1=未选 → sideContent 自动钳制到该 AI 版本最新分镜(length-1),切版本按钮里 sbIdx=0 改 -1(vprev/vnext 绑定代码本身零改动——数组翻转后方向自动正确);⑤verCtrl 模板:`{label} {idx+1}/{len} · {fmtTime(created_time)} [最新徽标]` + 按钮方向词「← 较旧 / 较新 →」(idx+1=旧到新第几版,末位标最新)
- **样式**:style.css 新增 `.rw-ver-new`(primary 实底圆角徽标),count 区时间用 .dim
- **版本号**:style.css/data-pages.js bump `?v=20260904b→c`;服务重启(后台 task);node --check 语法通过
- **待实测**:浏览器授权后验证——默认打开=最新在最右带徽标、←较旧左移、较旧侧 disabled 边界、分镜列同样式同方向
## R55 版本条重构浏览器实测通过(11:40,9223 实例)
- **环境修复**(铺垫):用户 Chrome 默认 profile 9222 调试端点不响应(DevToolsActivePort 残留但 socket 无监听 + 启动独立 daemon 失败)→ 改用项目既有惯例:启动 9223 专用调试 Chrome `chrome.exe --remote-debugging-port=9223 --user-data-dir=C:/Users/maidou/.workbuddy/tmp/bh-chrome-9223`(固定 profile 已 09-02 授权过,直接返回 Browser JSON 零弹窗),`BU_CDP_URL=http://127.0.0.1:9223` 定向连接 → harness 不再反复弹授权(弹窗根因=连无授权实例,现连已授权 profile)
- **AI 脚本版(video 190 = 3 版)实测**:初始 3/3 · 08-24 17:45 最新 + 较新→disabled;← 较旧→2/3 (17:29) 双向可用;再← →1/3 (08-22 16:44) ← disabled;较新→ 回到 2/3 ✓
- **分镜版(video 189 = 1 AI + 2 分镜)实测**:初始 分镜 2/2 · 08-22 14:39 最新 + 较新→disabled;← 较旧→分镜 1/2 (08-21 23:06) ← disabled ✓ — 切回 2/2 正常
- **最新徽标 CSS**:bg=primary rgb(47,111,237) 蓝实底,color=#fff,fontWeight=600,fontSize=12px,borderRadius=9px,pad 0 7px,marginLeft 6px
- **方向词+时间+徽标+边界 disabled+切版本分镜自动定位最新** 全部通过
- **遗留**:9223 调试 Chrome 实例已启动常驻(按项目红线「不主动关闭」保留,后续 task 可复用)
## R56 教程完整链维度「不适用即隐藏」(11:50,实测通过)
- 用户诉求:脚本诊断报告中,当前脚本不需要「教程完整链」分析时就不展示该维度(剧情类脚本不评教程维度,现显示灰色 — 占位行)
- **数据事实**:script_review.dims_json 中模型对不适用脚本把「教程完整链」置 **null**(12 条复盘全部 null;其余维度有值)
- **落码(data-pages.js)**:新增 `diagDimHas(dims,k)`(值是否为有限数字)+ `diagVisibleDims(dims)` = 教程完整链有值→完整 11 维列表 / null 或缺失→过滤掉该维度;其余维度无值仍显示 — 灰条(不超范围改动)
- **3 处渲染统一替换**:①弹窗右侧诊断面板 diagHtml scoreBar;②完整报告弹窗 reportFullReview scoreBar(compare 双列 aiD/srcD 各自独立判断);③脚本诊断页 openReviewDetail scoreBar(删本地 DIMS_ALL 副本改引用顶层 helper)
- **实测(9223)**:video 190(剧情类复盘)弹窗诊断面板与「查看完整复盘报告」均为 10 行维度(创意看点…其他),无教程完整链;反例(教程类有值 88)逻辑显式返回 11 项
- **版本号**:data-pages.js bump ?v=20260904c→d
## R57 热点数据页新增「账号日榜」(12:10,实测通过)
- 用户诉求:热点数据榜单加「账号日榜」,放「账号周榜」右边;获取用**不传赛道**方式(全品类 1 次调用),只显示需要的赛道数据
- **事实**:douyin-top-account fetch_rank.py --period day 不传 category → API type=全部 全品类 TOP50 单次调用(to_normalized_json 条目带真实赛道 category + comprehensiveScore 等与周榜同构)
- **落码**:
- 后端 dsh-data.js:RANK_SUB/RANK_FILE_RE 加 `day: '账号日榜' / ^抖音账号日榜_(\d{4}-\d{2}-\d{2})\.json$`;DEF_KEY week||day → comprehensiveScore(账号榜维度,防 day 误落作品榜 key);白名单过滤分支 board!=='week' 自动覆盖 day(RANK_KEEP 5 赛道+生活记录并集)
- ranking 页 data-pages.js:RANK_TABS 插 ['day','账号日榜','每日更新,回溯 7 天'](周榜右);st.day;COLS 抽 acctCol(empty) 工厂 week/day 共用(day empty 提示含「更新榜单可抓取,不传赛道全品类」);表格 rank-video 分支加 day(账号榜非窄列);更新按钮/确认弹窗文案四榜 8 次 API;SKILL_HINT_RANKING 加日榜抓取指令(账号日榜/抖音账号日榜_{日期}.json)
- 首页预览 app.js:tab 行加 day(title 每日更新回溯7天)、_PREVIEW_COLS 抽 prevAcctCol week/day 共用、空态榜名映射加 day、热点数据卡 desc 改「账号日榜/周榜 · 视频热榜 · 点赞榜」
- **实测(9223 + fake 文件验证后删除)**:后端 board=day → dates/categories(5 赛道 RANK_KEEP 序)/10 条过滤非白名单剩 8;ranking 页 tab 顺序=周榜|日榜|热榜|点赞榜、10 列表头同 week、赛道下拉 5 项、筛小剧场 2 行;首页预览 day 正常。fake 文件已删(真实抓取等「更新榜单」任务,1 次全品类 API)
- **版本号**:app.js/data-pages.js bump ?v=20260904e;服务重启(dsh-data.js 模块变更)
## R58 AI脚本弹窗删除按钮(15:20,全路径实测通过)
- 用户诉求:AI脚本弹窗 AI脚本内容区域左下角增加浅红删除按钮,确认后删除当前脚本(含相关诊断和分镜提示词数据)
- **后端(dsh-data.js)**:`deleteScript(rewriteId)` 级联清理——rewrite_log 删除 → script_review(rewrite_id)+ storyboard_log(rewrite_id);该视频无剩余脚本 → 再清 source 复盘与游离分镜;独立 DatabaseSync 写连接(与只读 getDb 隔离),finally w.close();**必须加 module.exports 导出**(漏=server.js 报 dsh.deleteScript is not a function,R58 踩过一次)
- **server.js**:`/api/dsh/script-delete` POST({id})路由
- **前端(data-pages.js)**:`.btn-del` 浅红按钮(style.css:#fdf0ef/#f0c9c6/#d9534f + hover 深红)+ `fireDel` 确认弹窗(级联提示文案);多版本 → splice + renderModal() 就地重建(剩余版本、定位相邻版);单版本 isLast → router() 整页重建;verCtrlInner 拆分复用 aiSecHtml 单版本 foot
- **★openCustomScriptView 移除(关键改造)**:自定义选题「查看」(data-cview)原绑 openCustomScriptView(09-02 简单只读弹窗:无删除/无诊断/无版本条);openScriptModal rewriteId 分支本已支持自定义单条(custom-script API + 回退全量表定位),改绑 `openScriptModal({rewriteId})` 后统一弹窗(删除按钮+诊断面板齐备),死代码 openCustomScriptView 整体删除(grep 确认仅绑定处 1 引用)
- **实测(9223,fake 全清)**:①多版本(video 190 4 版)删第 4/4 → 弹窗重建 3/3 内容切 rw25 ✅;②单版本视频脚本 isLast → router 刷新 ✅;③自定义选题 id38(video_id 空)查看弹窗含删除按钮 → 确认删除 → 弹窗关+行消失,DB 复查 rewrite/review/storyboard 三级全 0 ✅
- **版本号**:data-pages.js bump ?v=20260904e→f(静态 js 实时读盘未重启)
- **遗留**:R55-R58 改动未 commit(待 git 提交);帮助文档一行两卡 + 模拟操作指引点击无效果仍待修
@@ -104,6 +104,32 @@ function deleteVideosDeep(w, vids) {
}
}
// 删除单条 AI 脚本(R58 弹窗「删除」):连坐该脚本的诊断复盘(script_review ai/compare)与分镜提示词(storyboard_log)。
// 若删除后其视频下已无剩余脚本 → 视频级 source 复盘与该视频游离分镜(rewrite_id 空)一并清理,避免孤儿数据。
function deleteScript(rewriteId) {
const id = Number(rewriteId);
if (!(id > 0)) throw new Error('脚本 ID 无效');
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
const w = new DatabaseSync(DSH_DB);
try {
const row = w.prepare(`SELECT id, video_id, aweme_id FROM rewrite_log WHERE id=?`).get(id);
if (!row) return { deleted: 0 };
w.prepare(`DELETE FROM script_review WHERE rewrite_id=?`).run(id); /* ai/compare 诊断 */
w.prepare(`DELETE FROM storyboard_log WHERE rewrite_id=?`).run(id); /* 该脚本分镜 */
if (row.video_id) {
const remain = w.prepare(`SELECT COUNT(*) c FROM rewrite_log WHERE video_id=? AND id<>?`).get(row.video_id, id).c;
if (remain === 0) {
w.prepare(`DELETE FROM script_review WHERE type='source' AND video_id=?`).run(row.video_id);
w.prepare(`DELETE FROM storyboard_log WHERE video_id=? AND (rewrite_id IS NULL OR rewrite_id=0)`).run(row.video_id);
}
}
const r = w.prepare(`DELETE FROM rewrite_log WHERE id=?`).run(id);
return { deleted: Number(r.changes) };
} finally {
w.close();
}
}
/* ================= 账号 ================= */
function listTracks() {
@@ -341,16 +367,17 @@ function listRecentScripts(limit = 4) {
// 根目录旧平铺文件仍兼容读取(任务旧产物)。
// 全品类单文件(hot/likes)无赛道段:category 顶层标记「全部」,实际赛道在 list[].category,
// 赛道下拉 = 各期文件条目 category 并集,赛道筛选按条目 category 匹配。
const RANK_SUB = { week: '账号周榜', hot: '视频热榜', likes: '点赞榜' };
const RANK_SUB = { week: '账号周榜', day: '账号日榜', hot: '视频热榜', likes: '点赞榜' };
const RANK_FILE_RE = {
week: /^抖音周榜_(.+)_(\d{4}-\d{2}-\d{2})\.json$/,
day: /^抖音账号日榜_(\d{4}-\d{2}-\d{2})\.json$/,
hot: /^抖音周榜_视频热榜_(\d{4}-\d{2}-\d{2})\.json$/,
likes: /^抖音周榜_点赞榜_(\d{4}-\d{2}-\d{2})\.json$/,
};
// 工作台所需赛道白名单(09-03 定:与账号周榜 douyin-top-account 保留 5 赛道同口径)
// 口径差异:账号榜 type=「生活vlog」;作品榜条目 category=「生活记录」——并集同时放行。
// 生效范围:hot/likes(全品类文件)赛道下拉与列表(含「全部」)只保留白名单条目;week 文件名赛道天然在白名单内。
// 生效范围:day/hot/likes(全品类文件)赛道下拉与列表(含「全部」)只保留白名单条目;week 文件名赛道天然在白名单内。
const RANK_KEEP = ['生活记录', '生活vlog', '小剧场', '亲子', '美食', '旅行'];
const RANK_KEEP_SET = new Set(RANK_KEEP);
@@ -464,8 +491,8 @@ function getRanking({ board = 'week', date = '', category = '', sortBy = '', ord
items = items.filter((it) => String(it.category || '').trim() === category);
}
}
// 默认排序键随榜:week=综合得分 / hot=点赞 / likes=新增点赞(同分按排名升序)
const DEF_KEY = board === 'week' ? 'comprehensiveScore' : board === 'hot' ? 'likeCount' : 'add_digg_count';
// 默认排序键随榜:week/day(账号榜)=综合得分 / hot=点赞 / likes=新增点赞(同分按排名升序)
const DEF_KEY = (board === 'week' || board === 'day') ? 'comprehensiveScore' : board === 'hot' ? 'likeCount' : 'add_digg_count';
if (!order && (!sortBy || sortBy === DEF_KEY)) {
items.sort((a, b) => {
const va = num(a[DEF_KEY]), vb = num(b[DEF_KEY]);
@@ -565,7 +592,7 @@ function getRewriteStats(account = '') {
}
module.exports = {
dbAvailable, setFavorite, batchDelete, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
dbAvailable, setFavorite, batchDelete, deleteScript, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, rankingFreshness, DSH_DB, RANKING_DIR,
listCustomScripts, getCustomScript, saveScript, getRewriteStats,
@@ -302,7 +302,7 @@ const FUNC_GROUPS = [
/* ================= 首页 ================= */
function renderHome() {
const cards = [
{ icon: 'trending-up', title: '热点数据', desc: '账号周榜 · 视频热榜 · 点赞榜', hash: '#/ranking' },
{ icon: 'trending-up', title: '热点数据', desc: '账号日榜/周榜 · 视频热榜 · 点赞榜', hash: '#/ranking' },
{ icon: 'users', title: '账号列表', desc: '关注的账号 + 视频列表', hash: '#/accounts' },
{ icon: 'pen', title: 'AI写脚本', desc: 'AI脚本+分镜提示词', hash: '#/rewrites' },
{ icon: 'flask', title: '脚本诊断', desc: '检查清单+复盘报告', hash: '#/reviews' },
@@ -327,10 +327,11 @@ function renderHome() {
<div class="recent-head">
<div class="rank-tabs" role="tablist">
<button class="rank-tab active" data-rank-tab="week" role="tab">账号周榜</button>
<button class="rank-tab" data-rank-tab="day" title="每日更新,回溯 7 天" role="tab">账号日榜</button>
<button class="rank-tab" data-rank-tab="hot" title="近7日数据" role="tab">视频热榜</button>
<button class="rank-tab" data-rank-tab="likes" title="近7日数据" role="tab">点赞榜</button>
</div>
<button class="btn btn-icon home-rank-update" id="homeRankUpdate" title="更新榜单数据:后台任务重新抓取三榜(账号周榜 5 赛道 / 视频热榜 / 点赞榜)最新一期并落盘;30 分钟内已更新会拦截防重复">${svgIcon('globe', 16, 'currentColor')}</button>
<button class="btn btn-icon home-rank-update" id="homeRankUpdate" title="更新榜单数据:后台任务重新抓取四榜(账号日榜 全品类 1 次 / 账号周榜 5 赛道 / 视频热榜 / 点赞榜)最新一期并落盘;30 分钟内已更新会拦截防重复">${svgIcon('globe', 16, 'currentColor')}</button>
<div class="rank-tools" id="rankTools">
<select id="rankCat" class="input-select rank-filter" title="赛道筛选"></select>
<button class="btn btn-sm" id="rankPrevDate" title="上一期">◀</button>
@@ -474,8 +475,7 @@ function renderRankTools() {
if (nextBtn) { nextBtn.disabled = di < 0 || di >= _rankPreview.dates.length - 1; nextBtn.onclick = () => { if (di >= 0 && di < _rankPreview.dates.length - 1) { _rankPreview.date = _rankPreview.dates[di + 1]; renderRankTools(); loadRankingPreview(); } }; }
}
// 首页各榜列模板:head → 表头行;row(r) → 数据行。wk-num 数值(万显示);标题/作者吃剩余宽
const _PREVIEW_COLS = {
week: {
const prevAcctCol = () => ({
gridCls: '',
head: `<span class="rk-no">排名</span><span class="rk-cat">赛道</span><span class="rk-name">账号</span><span class="rk-score">综合得分</span><span class="rk-num">新增粉丝</span><span class="rk-num">新增点赞</span><span class="rk-num">新增评论</span><span class="rk-num">新增分享</span><span class="rk-act">功能</span>`,
row: (r) => `<span class="rk-no">${r.rank ?? '-'}</span>
@@ -487,7 +487,10 @@ const _PREVIEW_COLS = {
<span class="rk-num">${esc(r.newComments ?? '-')}</span>
<span class="rk-num">${esc(r.newShares ?? '-')}</span>
<span class="rk-act">${r.inDb && r.dbId ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-rk-import data-rk-name="${esc(r.accountName)}" data-rk-url="${esc(r.profileUrl || '')}" title="关注该账号并加入账号列表">关注</button>`}</span>`,
},
});
const _PREVIEW_COLS = {
week: prevAcctCol(),
day: prevAcctCol(),
hot: {
// likesRank 周窗口作品榜
gridCls: ' rk-video',
@@ -533,7 +536,7 @@ async function loadRankingPreview() {
}
if (!_rankPreview.dates.length) {
listEl.hidden = true;
if (ph) { ph.hidden = false; ph.textContent = '「' + ({ week: '账号周榜', hot: '视频热榜', likes: '点赞榜' }[board] || '') + '」暂无本地数据文件,先运行 mcn-data-insight 抓取技能落盘'; }
if (ph) { ph.hidden = false; ph.textContent = '「' + ({ week: '账号周榜', day: '账号日榜', hot: '视频热榜', likes: '点赞榜' }[board] || '') + '」暂无本地数据文件,先运行 mcn-data-insight 抓取技能落盘'; }
return;
}
if (ph) ph.hidden = true;
@@ -758,12 +761,19 @@ async function router() {
}
if (name === 'help') {
updateCrumbs('help', '功能帮助');
// 09-04:板块化帮助 + 模拟操作引导(help-guide.js);文件缺失时回退旧版问法帮助
if (window.HelpGuide && typeof window.HelpGuide.renderHelp === 'function') {
view.innerHTML = window.HelpGuide.renderHelp();
bindBack(view); // 返回 = 上一页(无历史则回首页)
window.HelpGuide.bindHelp(view);
} else {
view.innerHTML = renderHelp();
bindBack(view); // 返回 = 上一页(无历史则回首页)
view.querySelectorAll('.qa-row.copyable').forEach((row) => row.addEventListener('click', async () => {
const text = (row.dataset.q || '').replace(/^"|"$/g, '');
if (text) showToast((await copyText(text)) ? '✅ 已复制:' + text : '❌ 复制失败');
}));
}
return;
}
if (name === 'files') {
@@ -21,10 +21,14 @@ const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis
参考选题创作(有原视频,如视频行 AI创作):传 videoId/awemeId → rewrite_log.video_id 关联原视频;
自定义选题创作(无原视频,如账号列表 AI创作 弹窗):不传 videoId → video_id 留空,归「自定义选题创作」tab。
保存走本地服务 /api/dsh/script-save:server 自行解析 mcn-plugin.db 路径(跨开发机/dsh 环境最可靠),勿让 agent 猜库路径 */
/* 榜单数据更新技能声明(09-03 新增:热点数据页「更新榜单」按钮 → 后台任务按 mcn-data-insight 落盘规范抓取三榜最新一期。
范围与调用次数已在点击确认弹窗中向用户明示并获授权(账号周榜 5 赛道 + 视频热榜/点赞榜,约 7 次红狐 API);
/* 榜单数据更新技能声明(09-03 新增 09-04 四榜扩展:热点数据页「更新榜单」按钮 → 后台任务按 mcn-data-insight 落盘规范抓取四榜最新一期。
范围与调用次数已在点击确认弹窗中向用户明示并获授权(账号日榜 全品类 1 次 + 账号周榜 5 赛道 + 视频热榜/点赞榜,约 8 次红狐 API);
prompt 内保留「查询幂等铁律」与「落盘目录按环境解析」,禁止模型逐赛道/逐日循环浪费 API 配额 */
const SKILL_HINT_RANKING = '请调用 Skill 工具加载「mcn-data-insight」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-data-insight/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为「榜单数据更新」(工作台热点数据页更新按钮触发,用户已确认本次调用范围与次数):按 SKILL.md「榜单数据落盘规范」将热点数据三榜最新一期数据落盘到本地 抖音榜单/ 目录:① 视频热榜 + 点赞榜:运行 scripts/fetch_week_ranks.py --board both(全品类单次调用,禁止逐赛道/逐日循环);② 账号周榜:按 subskills/douyin-top-account 技能流程抓取保留 5 赛道(生活vlog/小剧场/亲子/美食/旅行)最新一期账号周榜,逐赛道写入 账号周榜/抖音周榜_{赛道}_{周一}.json;③ 落盘目录按技能规范按环境解析(开发机/用户环境 = 桌面 MCNSkill项目\\三方数据\\红狐数据\\抖音榜单\\;dsh 环境 = D:\\dshworkspace\\抖音榜单\\),新产物入 账号周榜/视频热榜/点赞榜 三个子目录,文件名保持 抖音周榜_ 前缀;④ 查询幂等铁律:每次调用前先 ls 目标目录,同榜名+赛道+数据日期文件已存在则直接跳过对应 API 调用(红狐 API 按次计费);⑤ 数据日期取 API 返回的榜单对应日期(最新一期),非获取时刻。完成后汇报:三榜各写入的文件路径与数据期、缓存命中跳过项、实际 API 调用次数。技能边界:只做榜单数据抓取与落盘,不做账号分析/脚本创作等其他能力。';
const SKILL_HINT_RANKING = '请调用 Skill 工具加载「mcn-data-insight」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-data-insight/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为「榜单数据更新」(工作台热点数据页更新按钮触发,用户已确认本次调用范围与次数):按 SKILL.md「榜单数据落盘规范」将热点数据四榜最新一期数据落盘到本地 抖音榜单/ 目录:① 视频热榜 + 点赞榜:运行 scripts/fetch_week_ranks.py --board both(全品类单次调用,禁止逐赛道/逐日循环);② 账号日榜:按 subskills/douyin-top-account 技能流程抓取最新一期账号日榜——用 fetch_rank.py --period day 直接获取全品类(不传赛道,单次调用),将结果写入 账号日榜/抖音账号日榜_{榜单日期}.json;③ 账号周榜:按 subskills/douyin-top-account 技能流程抓取保留 5 赛道(生活vlog/小剧场/亲子/美食/旅行)最新一期账号周榜,逐赛道写入 账号周榜/抖音周榜_{赛道}_{周一}.json;④ 落盘目录按技能规范按环境解析(开发机/用户环境 = 桌面 MCNSkill项目\\三方数据\\红狐数据\\抖音榜单\\;dsh 环境 = D:\\dshworkspace\\抖音榜单\\),新产物入 账号日榜/账号周榜/视频热榜/点赞榜 四个子目录,文件名保持 抖音账号日榜_/抖音周榜_ 前缀;⑤ 查询幂等铁律:每次调用前先 ls 目标目录,同榜名+赛道+数据日期文件已存在则直接跳过对应 API 调用(红狐 API 按次计费);⑥ 数据日期取 API 返回的榜单对应日期(最新一期),非获取时刻。完成后汇报:四榜各写入的文件路径与数据期、缓存命中跳过项、实际 API 调用次数。技能边界:只做榜单数据抓取与落盘,不做账号分析/脚本创作等其他能力。';
/* 账号数据分析技能声明(09-04 新增:账号详情「数据分析」tab 空态按钮 → 后台任务按 mcn-dou-analysis 功能四执行。
功能四=基于近 4 个月视频数据(每月最好/最差各 1 条)先逐条拆解再汇总分析,产出 8 章结构报告;
数据源优先读取产出目录短视频表格 xlsx,缺失则以工作台库中视频数据为准(不重新抓取账号) */
const SKILL_HINT_ANALYZE = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为技能功能四「分析视频数据(账号数据分析)」:先读 SKILL.md「功能总览」定位功能四,再按 references/feature/04_分析视频数据.md 与 references/账号数据分析方法.md 执行——基于该账号近 4 个月视频数据(每月按 点赞数+分享数 降序取最好 1 条 + 最差 1 条,共最多 8 条;某月不足 2 条则全取),先逐条拆解(可复用该账号已有 MCP 解析 analysis,缺解析的按规范提交补齐)→ 拆解全部完成后再进入汇总分析(两段式强制,禁止跳过拆解直接写报告),按 3.1-3.6 维度产出 8 章结构报告。数据来源:优先读取该账号产出目录的 短视频表格*.xlsx(13 列标准格式);文件缺失时以工作台库中该账号视频数据为准(无需重新抓取账号信息/视频列表,属数据更新场景时按规范处理)。红线:所有分析结论必须具体到视频标题 + 数值(点赞/评论/分享/收藏等),私有数据(完播率/GMV 等)拿不到时标注「无数据」禁止推断;无 MCP 解析的视频只做数据层分析、禁止作为内容层结论论据(注明「无解析,植入方式待验证」)。产物落盘到该账号产出目录 {达人昵称}账号数据分析.md;落盘后调用 POST /api/import/account(body {"account":"{达人昵称}"})同步入库(工作台环境 http://localhost:8900,接口不可用则跳过不影响保存)。技能边界:只做账号数据分析报告,不生成/改写脚本(属「短视频工作台」技能 S1-S11 流程),不提炼账号设定,不触发账号信息/视频列表抓取流程。';
const saveHintOf = (ctx) => {
const ref = Number(ctx.videoId) > 0;
const lines = [];
@@ -508,24 +512,26 @@ async function confirmAiScript(accountName, btn) {
inputEl.focus();
}
/* ================= 热点数据(#/ranking:账号周榜 / 视频热榜 / 点赞榜 三榜,board 参数驱动,与首页 rank-preview 同构) ================= */
/* ================= 热点数据(#/ranking:账号周榜 / 账号日榜 / 视频热榜 / 点赞榜 四榜,board 参数驱动,与首页 rank-preview 同构) ================= */
async function renderRanking(view) {
// 数据源:RANKING_DIR 下周榜族 JSON(fetch_week_ranks.py 落盘)——账号周榜 抖音周榜_{赛道}_{周一}.json,
// 视频热榜 抖音周榜_视频热榜_{赛道}_{周一}.json,点赞榜 抖音周榜_点赞榜_{赛道}_{周一}.json;后端按 board 识别
// 数据源:RANKING_DIR 下周榜族 JSON(fetch_week_ranks.py / douyin-top-account 落盘)——账号周榜 抖音周榜_{赛道}_{周一}.json,
// 账号日榜 抖音账号日榜_{日期}.json(全品类单文件,抓取不传赛道一次取全),视频热榜 抖音周榜_视频热榜_{赛道}_{周一}.json,
// 点赞榜 抖音周榜_点赞榜_{赛道}_{周一}.json;后端按 board 识别
const RANK_TABS = [
['week', '账号周榜'],
['day', '账号日榜', '每日更新,回溯 7 天'],
['hot', '视频热榜', '近7日数据'],
['likes', '点赞榜', '近7日数据'],
];
const mkState = () => ({ date: '', category: '', sortBy: '', order: '', page: 1, pageSize: 20, dates: [], categories: [] });
// 每榜独立状态(切 tab 保留各自筛选/翻页/日期)
const st = { week: mkState(), hot: mkState(), likes: mkState() };
const st = { week: mkState(), day: mkState(), hot: mkState(), likes: mkState() };
let tab = 'week';
const tabLabel = () => { const t = RANK_TABS.find(([k]) => k === tab); return t ? t[1] : ''; };
// 09-03 page-toolbar 只放 返回+标题;rank-toolbar 恒渲染含 tab + 下载按钮(紧贴 tab 右侧)保证空态/错误态也能看到更新入口(首次抓取);筛选+翻页仅 withFilters 时出现在同行最右
const toolbar = (s, withFilters) => {
const tabsHtml = `${RANK_TABS.map(([k, label, tip]) => `<button class="rank-tab${k === tab ? ' active' : ''}" data-rank-tab="${k}"${tip ? ` title="${tip}"` : ''} role="tab">${label}</button>`).join('')}
<button class="btn btn-icon" id="rankUpdate" title="更新榜单数据:后台任务重新抓取三榜(账号周榜 5 赛道 / 视频热榜 / 点赞榜)最新一期并落盘;30 分钟内已更新会拦截防重复">${svgIcon('globe', 16, 'currentColor')}</button>`;
<button class="btn btn-icon" id="rankUpdate" title="更新榜单数据:后台任务重新抓取四榜(账号日榜 全品类 1 次 / 账号周榜 5 赛道 / 视频热榜 / 点赞榜)最新一期并落盘;30 分钟内已更新会拦截防重复">${svgIcon('globe', 16, 'currentColor')}</button>`;
const filtersHtml = withFilters ? `<div class="toolbar-right">
<select id="rankCat" class="input-select" title="赛道筛选">
<option value="">全部赛道</option>
@@ -552,10 +558,9 @@ async function renderRanking(view) {
// 可排序数值字段(表头窄列宽 + 点击排序)——非数值列不参与排序
const SORT_NUM = new Set(['rank', 'comprehensiveScore', 'followers', 'newFans', 'newLikes', 'newComments', 'newShares', 'likeCount', 'commentCount', 'shareCount', 'collectCount', 'add_digg_count', 'add_collect_count', 'add_comment_count', 'add_share_count']);
// 三榜列模板:head(s, sortable) → 表头;row(r) → 行单元格
const COLS = {
week: {
span: 10, empty: '暂无周榜数据', defKey: 'comprehensiveScore',
// 各榜列模板:head(s, sortable) → 表头;row(r) → 行单元格。账号榜(week/day)同结构——周榜逐赛道文件、日榜全品类单文件
const acctCol = (empty) => ({
span: 10, empty, defKey: 'comprehensiveScore',
head: (s, srt) => `${srt('rank', '排名', true)}<th>赛道</th><th>账号</th>${srt('comprehensiveScore', '综合得分')}${srt('followers', '粉丝')}${srt('newFans', '新增粉丝')}${srt('newLikes', '新增点赞')}${srt('newComments', '新增评论')}${srt('newShares', '新增分享')}<th class="th-act">功能</th>`,
row: (r) => `<tr>
<td>${r.rank ?? '-'}</td>
@@ -569,7 +574,10 @@ async function renderRanking(view) {
<td class="num">${fmt(r.newShares)}</td>
<td class="cell-act">${r.inDb ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-import='${esc(JSON.stringify(r))}'>关注</button>`}</td>
</tr>`,
},
});
const COLS = {
week: acctCol('暂无周榜数据'),
day: acctCol('暂无日榜数据(每日 17:30 更新;可用「更新榜单」抓取,不传赛道一次取全品类后仅展示工作台所需赛道)'),
hot: {
// likesRank 周窗口作品榜:标题/作者/赛道/点赞/评论/分享/收藏/发布时间
span: 9, empty: '暂无视频热榜数据', defKey: 'likeCount',
@@ -644,7 +652,7 @@ async function renderRanking(view) {
<div class="page">
${toolbar(s, true)}
<div class="table-wrap">
<table class="table table-rank${tab === 'week' ? '' : ' rank-video'}">
<table class="table table-rank${(tab === 'week' || tab === 'day') ? '' : ' rank-video'}">
<thead><tr>${col.head(s, srt)}</tr></thead>
<tbody>${rows || `<tr><td colspan="${col.span}" class="empty">${col.empty}</td></tr>`}</tbody>
</table>
@@ -701,7 +709,7 @@ async function updateRankingData(view, btn) {
mask.innerHTML = `
<div class="modal" style="max-width:560px">
<h3>${svgIcon('refresh', 18)} 更新榜单数据</h3>
<p class="dim">将重新抓取热点数据三榜最新一期并落盘:账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 7 次红狐 API 调用(幂等铁律:同榜名+赛道+数据日期已存在则跳过)。后台任务执行需数分钟,完成后自动刷新榜单。</p>
<p class="dim">将重新抓取热点数据四榜最新一期并落盘:账号日榜(全品类单次,不传赛道)+ 账号周榜(5 赛道)+ 视频热榜 + 点赞榜,约 8 次红狐 API 调用(幂等铁律:同榜名+赛道+数据日期已存在则跳过)。后台任务执行需数分钟,完成后自动刷新榜单。</p>
${st.lastUpdateAt ? `<div class="confirm-target">上次更新:${esc(st.lastUpdateDate || '—')} ${esc(timeTxt)}(${esc(ageTxt() || '')})</div>` : (st.exists === false ? '<div class="confirm-target">本地暂无榜单数据文件,本次为首次抓取</div>' : '')}
${inCooldown ? `<p style="color:#b7791f;font-size:14px;margin:8px 0 0">⚠️ ${st.lastUpdateAgeMin} 分钟前刚更新过(防重复窗口 ${st.cooldownMin} 分钟),数据仍较新;确需强制刷新请点「仍要更新」。</p>` : ''}
<div class="modal-actions">
@@ -956,6 +964,7 @@ async function renderAccountDetail(view, id) {
const dvP = api('/api/dsh/account-videos?accountId=' + accountId + '&page=' + page + '&pageSize=20');
if (tab === 'info' && persona === null) await loadPersona(); // 账号设定内联在信息页,首屏即加载(与视频数据并行)
if (tab === 'persona' && trendData === null) await loadTrend(); // 视频列表页首屏拉全量趋势数据(与分页数据并行)
if (tab === 'analysis') await loadAnalysis(); // 09-04:analysis tab 每次进入都拉取(任务完成自动刷新也能拿到新报告)
const dv = await dvP;
const infoGrid = (label, val, wide, raw, area) => `<div class="info-item ${wide ? 'info-wide' : ''}"${area ? ` style="grid-area:${area}"` : ''}><div class="info-label">${label}</div><div class="info-value">${raw ? val : esc(fmt(val))}</div></div>`;
const rows = (dv.items || []).map((v) => `<tr>
@@ -1023,7 +1032,13 @@ async function renderAccountDetail(view, id) {
${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}
</div>`;
} else {
panel = analysis ? `<div class="md-body">${renderMarkdown(analysis)}</div>` : `<div class="empty">该账号暂无数据分析</div>`;
panel = analysis ? `<div class="md-body">${renderMarkdown(analysis)}</div>`
: `<div class="empty-state">
<div class="empty-icon">${svgIcon('chart', 40)}</div>
<div class="empty-title">该账号暂无数据分析</div>
<div class="empty-desc">基于该账号近 4 个月视频数据(每月最好 / 最差视频)做内容表现与商业化空间分析,产出分析报告</div>
<button class="btn btn-view" data-analyze>账号数据分析</button>
</div>`;
}
view.innerHTML = `
<div class="page page-detail">
@@ -1035,9 +1050,8 @@ async function renderAccountDetail(view, id) {
if (tab === 'persona') renderTrendChart(view); // 视频列表页上方:点赞/评论趋势折线图(lieflat-charts G8 骨架,ECharts)
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', async () => {
tab = b.dataset.tab;
if (tab === 'analysis' && analysis === null) await loadAnalysis();
syncHash();
load(); // info tab 的账号设定加载已在 load() 内处理
load(); // info 的账号设定 / analysis 的分析报告均在 load() 内按需拉取
}));
bindPager(view, (p) => { page = p; syncHash(); load(); });
bindBatchBar(view, {
@@ -1099,6 +1113,17 @@ async function renderAccountDetail(view, id) {
skills: ['mcn-dou-analysis'],
btn: exBtn,
}));
// 09-04:数据分析 tab 空态「账号数据分析」按钮 → mcn-dou-analysis 功能四(近4个月数据 逐条拆解+汇总分析)
const anaBtn = view.querySelector('[data-analyze]');
if (anaBtn) anaBtn.addEventListener('click', () => confirmRun({
title: '账号数据分析',
desc: `将基于账号「${acc.account_name}」近 4 个月视频数据(每月最好/最差各 1 条,最多 8 条),先逐条拆解再汇总分析,产出 8 章结构分析报告(内容表现规律 / 月度趋势 / 差异化壁垒 / 商业化空间)。任务需数分钟,完成后自动刷新本页。`,
target: `账号「${acc.account_name}」|近 4 个月视频数据`,
prompt: `${SKILL_HINT_ANALYZE}\n请为达人「${acc.account_name}」进行账号数据分析(功能四)。`,
label: '分析指令',
skills: ['mcn-dou-analysis'],
btn: anaBtn,
}));
view._reload = load; // 静默自动刷新(保留当前 tab/翻页)
}
@@ -1364,7 +1389,8 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
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))));
/* R58:自定义选题「查看」并入统一 AI 脚本弹窗(openScriptModal rewriteId 分支单条展示),获得删除按钮/诊断面板能力;原 openCustomScriptView 简单弹窗移除 */
view.querySelectorAll('[data-cview]').forEach((b) => b.addEventListener('click', () => openScriptModal({ rewriteId: Number(b.dataset.cview) })));
}
}
@@ -1421,30 +1447,6 @@ async function renderRewrites(view, initAccount) { /* v22:initAccount=账号
}
/* 自定义选题脚本 查看弹窗(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脚本诊断 ================= */
async function renderReviews(view) {
const d = await api('/api/dsh/rewrites').catch((e) => ({ ok: false, error: e.message }));
@@ -1547,16 +1549,15 @@ async function renderReviews(view) {
const rv = d3.review;
const typeName = rv.type === 'source' ? '原视频脚本复盘' : rv.type === 'compare' ? '对比分析' : 'AI 脚本复盘';
let dimsObj = {}; try { dimsObj = JSON.parse(rv.dims_json || '{}'); } catch (e) {}
const DIMS_ALL = ['创意看点', '设定承接', '框架节奏', '爆款开场', '叙事逻辑', '极致事件', '格式规范性', '台词口语化', '内容合规', '教程完整链', '其他'];
const scoreColor = (v) => (v >= 75 ? '#3a9e6d' : v >= 60 ? '#d99a2b' : '#d9534f');
const scoreBar = (dims, prefix) => DIMS_ALL.map((k) => { const v = diagDimVal(dims, k); const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? scoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? scoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
const scoreBar = (dims) => diagVisibleDims(dims).map((k) => { const v = diagDimVal(dims, k); const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? scoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? scoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
let body = '';
if (rv.type === 'compare') {
const aiD = dimsObj.ai || {}, srcD = dimsObj.source || {};
let t = {}; try { t = JSON.parse(rv.total || '{}'); } catch (e) {}
body = `<div class="score-dual"><div class="score-box"><div class="score-box-title">AI 总分 ${fmtScore(Number(t.ai))}</div>${scoreBar(aiD, 'c-ai-')}</div><div class="score-box"><div class="score-box-title">源脚本 总分 ${fmtScore(Number(t.source))}</div>${scoreBar(srcD, 'c-src-')}</div></div>`;
body = `<div class="score-dual"><div class="score-box"><div class="score-box-title">AI 总分 ${fmtScore(Number(t.ai))}</div>${scoreBar(aiD)}</div><div class="score-box"><div class="score-box-title">源脚本 总分 ${fmtScore(Number(t.source))}</div>${scoreBar(srcD)}</div></div>`;
} else {
body = `<div class="score-box"><div class="score-box-title">总分 ${fmtScore(Number(rv.total))}</div>${scoreBar(dimsObj, 'd-')}</div>`;
body = `<div class="score-box"><div class="score-box-title">总分 ${fmtScore(Number(rv.total))}</div>${scoreBar(dimsObj)}</div>`;
}
let issues = []; try { issues = JSON.parse(rv.issues_json || '[]'); } catch (e) {}
if (issues.length) body += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map((it) => `<div class="issue-row"><span style="color:${it.severity === '高' ? '#d9534f' : it.severity === '中' ? '#d99a2b' : '#3a9e6d'};font-weight:600">${esc(it.severity || '低')}</span><span>${esc(it.issue || '')}</span>${it.suggestion ? `<span class="dim">→ ${esc(it.suggestion)}</span>` : ''}</div>`).join('')}</div>`;
@@ -1585,6 +1586,9 @@ const DIAG_DIMS = ['创意看点', '设定承接', '框架节奏', '爆款开场
维度名兼作 dims_json 数据 key——历史复盘仍为旧 key,取值回退别名兼容(新 key 缺失时读旧 key),保证历史分数继续展示 */
const DIAG_KEY_ALIAS = { '爆款开场': '钩子设置', '创意看点': '共鸣和共情' };
const diagDimVal = (dims, k) => { const v = dims[k]; return (typeof v === 'number' && Number.isFinite(v)) ? v : (DIAG_KEY_ALIAS[k] ? dims[DIAG_KEY_ALIAS[k]] : v); };
/* R56:教程完整链对当前脚本不适用(dims 中该维度为 null/缺失,如剧情类脚本)→ 该维度行整体不展示;其余维度无值时保持原「—」占位 */
const diagDimHas = (dims, k) => { const v = diagDimVal(dims, k); return typeof v === 'number' && Number.isFinite(v); };
const diagVisibleDims = (dims) => (diagDimHas(dims, '教程完整链') ? DIAG_DIMS : DIAG_DIMS.filter((k) => k !== '教程完整链'));
const diagScoreColor = (v) => (v >= 75 ? '#3a9e6d' : v >= 60 ? '#d99a2b' : '#d9534f');
const diagFmtScore = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : '-');
@@ -1608,14 +1612,14 @@ async function reportFullReview(reviewId) {
const rv = d3.review;
const typeName = rv.type === 'source' ? '原视频脚本复盘' : rv.type === 'compare' ? '对比分析' : 'AI 脚本复盘';
let dimsObj = {}; try { dimsObj = JSON.parse(rv.dims_json || '{}'); } catch (e) {}
const scoreBar = (dims, prefix) => DIAG_DIMS.map((k) => { const v = diagDimVal(dims, k); const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? diagScoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? diagScoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
const scoreBar = (dims) => diagVisibleDims(dims).map((k) => { const v = diagDimVal(dims, k); const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? diagScoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? diagScoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
let body = '';
if (rv.type === 'compare') {
const aiD = dimsObj.ai || {}, srcD = dimsObj.source || {};
let t = {}; try { t = JSON.parse(rv.total || '{}'); } catch (e) {}
body = `<div class="score-dual"><div class="score-box"><div class="score-box-title">AI 总分 ${diagFmtScore(Number(t.ai))}</div>${scoreBar(aiD, 'c-ai-')}</div><div class="score-box"><div class="score-box-title">源脚本 总分 ${diagFmtScore(Number(t.source))}</div>${scoreBar(srcD, 'c-src-')}</div></div>`;
body = `<div class="score-dual"><div class="score-box"><div class="score-box-title">AI 总分 ${diagFmtScore(Number(t.ai))}</div>${scoreBar(aiD)}</div><div class="score-box"><div class="score-box-title">源脚本 总分 ${diagFmtScore(Number(t.source))}</div>${scoreBar(srcD)}</div></div>`;
} else {
body = `<div class="score-box"><div class="score-box-title">总分 ${diagFmtScore(Number(rv.total))}</div>${scoreBar(dimsObj, 'd-')}</div>`;
body = `<div class="score-box"><div class="score-box-title">总分 ${diagFmtScore(Number(rv.total))}</div>${scoreBar(dimsObj)}</div>`;
}
let issues = []; try { issues = JSON.parse(rv.issues_json || '[]'); } catch (e) {}
if (issues.length) body += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map((it) => `<div class="issue-row"><span style="color:${it.severity === '高' ? '#d9534f' : it.severity === '中' ? '#d99a2b' : '#3a9e6d'};font-weight:600">${esc(it.severity || '低')}</span><span>${esc(it.issue || '')}</span>${it.suggestion ? `<span class="dim">→ ${esc(it.suggestion)}</span>` : ''}</div>`).join('')}</div>`;
@@ -1657,14 +1661,16 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
}
}
if (!rewrites.length) { showToast('该脚本不存在或已删除'); return; }
let verIdx = rewriteId ? Math.max(0, rewrites.findIndex((x) => String(x.id) === String(rewriteId))) : 0;
if (verIdx < 0) verIdx = 0;
/* R55:版本展示层 旧→新(最左=最初版、最右=最新版),版本条「新在右 旧在左」与方向词按钮一致 */
rewrites = rewrites.slice().reverse();
let verIdx = rewriteId ? rewrites.findIndex((x) => String(x.id) === String(rewriteId)) : rewrites.length - 1;
if (verIdx < 0) verIdx = rewrites.length - 1; /* 指定版本已不存在 → 回到最新版(最右) */
const title = metaTitle || 'AI 脚本';
const hasSource = vid > 0 && !!source;
let stab = 'diag'; /* 右侧 tab:diag=脚本诊断(默认)/ sb=分镜提示词 */
let showSrc = false, sbIdx = 0, showSide = false; /* R41:showSide=右侧「脚本诊断|分镜提示词」面板是否展开(收起后只展示 AI 脚本);R50:默认收起=不显示复盘和分镜 */
let showSrc = false, sbIdx = -1, showSide = false; /* R41:showSide=右侧「脚本诊断|分镜提示词」面板是否展开(收起后只展示 AI 脚本);R50:默认收起=不显示复盘和分镜;R55:sbIdx=-1=未选分镜版本,sideContent 自动指向该 AI 版本的最新分镜(末位) */
const stb = vid > 0 ? await api('/api/dsh/storyboards?videoId=' + vid).catch(() => ({ items: [] })) : { items: [] };
const storyboards = ((stb && stb.items) || []);
const storyboards = ((stb && stb.items) || []).slice().reverse(); /* 同版本条:旧→新,末位=最新分镜 */
const visibleSbOf = (curId) => storyboards.filter((s) => s.rewrite_id == null || String(s.rewrite_id) === String(curId));
/* 复盘任务触发:有源脚本 → 三模式(AI/源/对比);无源 → 独立分析仅评 AI 脚本(同脚本诊断页行按钮口径) */
@@ -1706,7 +1712,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
const diagHtml = (dg) => {
const { rv, dims, total, srcTotal } = dg;
const totalFin = Number.isFinite(total);
const scoreBar = DIAG_DIMS.map((k) => { const v = diagDimVal(dims, k); const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? diagScoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? diagScoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
const scoreBar = diagVisibleDims(dims).map((k) => { const v = diagDimVal(dims, k); const has = typeof v === 'number' && Number.isFinite(v); return `<div class="score-row"><span class="score-name">${esc(k)}</span><div class="score-bar"><div class="score-fill" style="width:${has ? v : 0}%;background:${has ? diagScoreColor(v) : '#eee'}"></div></div><span class="score-val" style="color:${has ? diagScoreColor(v) : 'var(--dim)'}">${has ? v : '—'}</span></div>`; }).join('');
let html = `<div class="score-box"><div class="score-box-title">AI 总分 ${diagFmtScore(totalFin ? total : Number(rv.total))}${rv.type === 'compare' && Number.isFinite(srcTotal) ? `(对比源脚本 ${diagFmtScore(srcTotal)})` : ''}${rv.verdict ? ` · <span style="color:${totalFin ? diagScoreColor(total) : 'var(--dim)'}">${esc(rv.verdict)}</span>` : ''}</div>${scoreBar}</div>`;
let issues = []; try { issues = JSON.parse(rv.issues_json || '[]'); } catch (e) {}
if (issues.length) html += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map((it) => `<div class="issue-row"><span style="color:${it.severity === '高' ? '#d9534f' : it.severity === '中' ? '#d99a2b' : '#3a9e6d'};font-weight:600">${esc(it.severity || '低')}</span><span>${esc(it.issue || '')}</span>${it.suggestion ? `<span class="dim">→ ${esc(it.suggestion)}</span>` : ''}</div>`).join('')}</div>`;
@@ -1719,12 +1725,11 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
切换类型与刷新范围:tab 脚本诊断/分镜提示词 / 分镜上下版 → 仅右侧列(patchSide);版本上下 → AI列+右侧列(patchAll);源折叠 → 纯 CSS toggle
弹窗 DOM(mask)生命周期内不被移除,仅替换受影响列内容 → 无整窗闪断 */
const curOf = () => rewrites[Math.min(verIdx, rewrites.length - 1)] || rewrites[0];
const verCtrl = (p, idx, len, label, genLabel) => {
const inner = len > 1
? `<span class="rw-sec-count">${label} ${idx + 1}/${len}</span><button class="btn btn-sm" data-${p}prev ${idx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-${p}next ${idx >= len - 1 ? 'disabled' : ''}>下一版 →</button>`
: (len === 1 ? `<span class="rw-sec-count">${label} 1/1</span>` : '');
return `<div class="rw-sec-foot">${inner}${genLabel ? `<button class="btn btn-sm" data-${p}gen>${svgIcon('refresh', 14, 'currentColor')} ${genLabel}</button>` : ''}</div>`;
};
/* R55:版本条 左旧右新——计数=旧→新第几版(idx+1/len,最右=len=最新);末位标「最新」徽标;按钮方向词 ← 较旧 / 较新 →;timeStr 显示该版创建时间辅助辨认 */
const verCtrlInner = (p, idx, len, label, timeStr) => (len > 1
? `<span class="rw-sec-count">${label} ${idx + 1}/${len}${timeStr ? ` · <span class="dim">${esc(fmtTime(timeStr))}</span>` : ''}${idx >= len - 1 ? ` <span class="rw-ver-new">最新</span>` : ''}</span><button class="btn btn-sm" data-${p}prev ${idx <= 0 ? 'disabled' : ''} title="查看较旧版本">← 较旧</button><button class="btn btn-sm" data-${p}next ${idx >= len - 1 ? 'disabled' : ''} title="查看较新版本">较新 →</button>`
: (len === 1 ? `<span class="rw-sec-count">${label} 1/1</span>` : ''));
const verCtrl = (p, idx, len, label, genLabel, timeStr) => `<div class="rw-sec-foot">${verCtrlInner(p, idx, len, label, timeStr)}${genLabel ? `<button class="btn btn-sm" data-${p}gen>${svgIcon('refresh', 14, 'currentColor')} ${genLabel}</button>` : ''}</div>`;
const sideHeadHtml = () => `<div class="rw-sec-head rw-side-head"><div class="rw-side-tabs">
<button class="rw-side-tab${stab === 'diag' ? ' active' : ''}" data-stab="diag" title="该版本 AI 脚本的复盘评分与问题清单">脚本诊断</button><span class="rw-side-sep">|</span>
<button class="rw-side-tab${stab === 'sb' ? ' active' : ''}" data-stab="sb" title="基于 AI 脚本生成的镜头级分镜提示词">分镜提示词</button>
@@ -1738,10 +1743,53 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
<div class="rw-sec-body md-body">${renderMarkdown(source)}</div>
<div class="rw-sec-foot"></div>
</div>` : '';
const aiSecHtml = (c) => `<div class="rw-sec-head">AI脚本${srcToggleHtml()}${sideToggleHtml()}</div><div class="rw-sec-body md-body">${renderMarkdown(c.script_text || '(暂无内容)')}</div>${rewrites.length > 1 ? verCtrl('v', verIdx, rewrites.length, '版本') : ''}`;
const aiSecHtml = (c) => `<div class="rw-sec-head">AI脚本${srcToggleHtml()}${sideToggleHtml()}</div><div class="rw-sec-body md-body">${renderMarkdown(c.script_text || '(暂无内容)')}</div>
<div class="rw-sec-foot">${delScriptBtnHtml(c)}${rewrites.length > 1 ? verCtrlInner('v', verIdx, rewrites.length, '版本', c.created_time) : ''}</div>`;
/* R58:删除当前脚本(左下角浅红按钮)——同时删除其诊断复盘与分镜提示词;确认后执行 API,多版本就地移除当前版并刷新,单版本关弹窗并重建页面 */
const delScriptBtnHtml = (c) => `<button class="btn btn-sm btn-del" data-del-script title="删除当前脚本(同时删除其诊断复盘与分镜提示词数据,不可恢复)">${svgIcon('trash', 13, 'currentColor')} 删除</button>`;
const fireDel = async (btn) => {
if (btn.dataset.lock) return; btn.dataset.lock = '1';
try {
const c = curOf(); if (!c) return;
const isLast = rewrites.length <= 1;
const mask = document.createElement('div'); mask.className = 'modal-mask';
mask.innerHTML = `
<div class="modal" style="max-width:520px">
<h3>${svgIcon('trash', 17, '#d9534f')} 删除当前脚本</h3>
<p class="dim">将删除 AI 脚本${rewrites.length > 1 ? `第 ${verIdx + 1}/${rewrites.length} 版` : ''}(创建于 ${esc(fmtTime(c.created_time))}):</p>
<div class="confirm-target" style="margin:8px 0">《${esc(fmtTitleQ(title))}》</div>
<p style="color:#b7791f;font-size:14px;margin:4px 0 0">⚠️ 该脚本的<strong>诊断复盘</strong>与<strong>分镜提示词</strong>将一并删除,不可恢复${isLast ? ';其视频若无其他 AI 脚本,视频级复盘也会清理' : ''}。</p>
<div class="modal-actions">
<button class="ghost" data-cancel>取消</button>
<button class="primary" data-ok style="background:#d9534f;border-color:#d9534f">确认删除</button>
</div>
</div>`;
document.body.appendChild(mask);
const close = () => mask.remove();
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
mask.querySelector('[data-cancel]').addEventListener('click', close);
mask.querySelector('[data-ok]').addEventListener('click', async () => {
close();
try {
const r = await api('/api/dsh/script-delete', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id: c.id }) });
if (!r || r.ok === false) { showToast('❌ 删除失败: ' + ((r && r.error) || '未知错误')); return; }
showToast('✅ 已删除该脚本及关联数据');
const host = document.querySelector('.rw-modal'); if (host) host.remove(); /* 先关当前弹窗 */
if (isLast) { router(); return; }
const i = rewrites.findIndex((x) => String(x.id) === String(c.id));
if (i >= 0) rewrites.splice(i, 1);
if (verIdx >= rewrites.length) verIdx = rewrites.length - 1;
sbIdx = -1;
renderModal(); /* 多版本:就地重建弹窗(剩余版本列表,定位相邻版本) */
} catch (e) { showToast('❌ 删除失败: ' + e.message); }
});
} finally { delete btn.dataset.lock; }
};
/* 右侧列内容计算(diag 空态按钮 R40 改「开始诊断」;stab=diag 才请求复盘接口——原实现无论什么 tab 都查,浪费) */
async function sideContent(c) {
const visibleSb = visibleSbOf(c.id);
/* R55:sbIdx=-1(未选/切过版本)或越界 → 自动指向该 AI 版本的最新分镜(末位);同版本内左右翻页保持当前位置 */
if (sbIdx < 0 || (visibleSb.length && sbIdx >= visibleSb.length)) sbIdx = Math.max(0, visibleSb.length - 1);
const sb = visibleSb[Math.min(sbIdx, visibleSb.length - 1)];
let sideBody = '', sideFoot = '';
if (stab === 'diag') {
@@ -1755,7 +1803,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
}
} else if (visibleSb.length) {
sideBody = sb ? renderSbBlocks(sb.script_text) : '' /* R51:空态提示清空——不再显示「(暂无分镜提示词)」 */;
sideFoot = verCtrl('s', sbIdx, visibleSb.length, '分镜', '重新生成');
sideFoot = verCtrl('s', sbIdx, visibleSb.length, '分镜', '重新生成', sb ? sb.created_time : '');
} else if (vid > 0) {
sideBody = '' /* R51:空态提示清空——不再显示「(暂无分镜提示词)」 */;
sideFoot = `<div class="rw-sec-foot"><button class="btn btn-sm btn-view" data-gen-sb>${svgIcon('zap', 14, 'currentColor')} 生成分镜</button></div>`;
@@ -1850,8 +1898,9 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
sideT.title = showSide ? '收起脚本诊断/分镜提示词,只看 AI 脚本' : '展开脚本诊断/分镜提示词';
sideT.innerHTML = svgIcon(showSide ? 'chevrons-right' : 'chevrons-left', 16, 'currentColor');
});
const vp = mask.querySelector('[data-vprev]'); if (vp) vp.addEventListener('click', async () => { if (verIdx > 0) { verIdx--; sbIdx = 0; await patchAll(mask); } });
const vn = mask.querySelector('[data-vnext]'); if (vn) vn.addEventListener('click', async () => { if (verIdx < rewrites.length - 1) { verIdx++; sbIdx = 0; await patchAll(mask); } });
const vp = mask.querySelector('[data-vprev]'); if (vp) vp.addEventListener('click', async () => { if (verIdx > 0) { verIdx--; sbIdx = -1; await patchAll(mask); } }); /* R55:vprev=向左=较旧;sbIdx=-1 使右侧分镜自动定位新版本最新分镜 */
const vn = mask.querySelector('[data-vnext]'); if (vn) vn.addEventListener('click', async () => { if (verIdx < rewrites.length - 1) { verIdx++; sbIdx = -1; await patchAll(mask); } }); /* vnext=向右=较新 */
const dl = mask.querySelector('[data-del-script]'); if (dl) dl.addEventListener('click', () => fireDel(dl)); /* R58:删除当前脚本 */
bindSide(mask);
};
async function renderModal() {
@@ -0,0 +1,438 @@
/* =========================================================================
短视频工作台 - 帮助文档 + 模拟操作引导(09-04 新增 help-guide)
-------------------------------------------------------------------------
板块化帮助:按工作台实际页面组织(首页/热点数据/账号列表/账号详情/视频详情/AI写脚本/脚本诊断)。
每个板块卡提供:用途说明 + 功能清单 + 「▶ 模拟操作指引」+ 「进入板块」+ 常见问法(可复制)。
模拟操作引导(startGuide):
- 跳转到该板块真实页面(详情类自动取库中第一条数据作示例);
- 全屏遮罩(拦截误点)+ 高亮目标控件 + 气泡讲解;
- 无副作用控件(tab/下拉/搜索/翻页)→ 引导你真实点击体验,点对即进下一步;
- 有副作用控件(更新数据/关注/AI创作/复盘等)→ 只讲解 + 「下一步」,防误触发;
- 步骤目标找不到(无示例数据)→ 自动跳过该步,不卡死。
========================================================================= */
(function () {
const $ = (s, r) => (r || document).querySelector(s);
/* ---------- 详情类示例路由:取库中第一条数据作引导样本 ---------- */
async function firstAccountRoute() {
try {
const d = await api('/api/dsh/accounts?type=all&search=&track=&page=1&pageSize=1');
const a = (d && d.items && d.items[0]);
if (a && a.id) return '#/account/' + a.id;
} catch (e) { /* 接口失败走兜底 */ }
return null;
}
async function firstVideoRoute() {
try {
const d = await api('/api/dsh/videos?search=&parsed=&days=&page=1&pageSize=1');
const v = (d && d.items && d.items[0]);
if (v && v.id) return '#/video/' + v.id;
} catch (e) { /* 接口失败走兜底 */ }
return null;
}
/* ---------- 板块定义 ----------
steps[].sel 目标选择器(页面渲染后查找;找不到自动跳过)
steps[].mode 'click' 真实点击推进(安全控件);'input' 输入回车推进;'info' 仅讲解 + 下一步
steps[].danger 危险/有副作用控件:加 pointer-events:none 防误触
steps[].emptyOk 目标缺失时仍展示文字(不跳过) */
const BOARDS = [
{
key: 'home', icon: 'home', title: '首页总览', route: '#/',
desc: '工作台主入口:数据预览 + 四大功能卡 + 榜单速览 + 最近 AI 脚本',
steps: [
{ sel: '.home-stats', mode: 'info', title: '数据总览条', text: '顶部统计条一屏看全库规模:关注账号数、视频解析数、AI写脚本数、AI脚本诊断数、AI脚本平均评分。数据会随库中变化自动刷新。' },
{ sel: '.data-cards', mode: 'info', title: '四大功能卡', text: '首页四张卡片 = 工作台四大板块:热点数据(榜单)、账号列表(达人库)、AI写脚本(脚本中心)、脚本诊断(复盘评分)。点击卡片即可进入对应板块。' },
{ sel: '.rank-preview .rank-tab[data-rank-tab="hot"]', mode: 'click', title: '榜单速览', text: '首页内置榜单预览,无需进完整页即可扫一眼:账号周榜 / 视频热榜 / 点赞榜。' + '\n' + '👉 现在点一下「视频热榜」tab 试试切换(可来回点三个 tab)。' },
{ sel: '#recentScriptsList .recent-card', mode: 'info', emptyOk: true, title: '最近 AI 脚本', text: '最近生成的 AI 脚本列表,点卡片可打开脚本详情(含源脚本对比/诊断/分镜)。若尚无脚本则为空。' },
],
qa: [
['"帮我写一个美妆种草脚本,30秒"', '全流程创作:人设→选题→框架→大纲→脚本 + 质检'],
['"帮我想几个美妆赛道的爆款选题"', '按赛道生成差异化选题'],
],
},
{
key: 'ranking', icon: 'trending-up', title: '热点数据', route: '#/ranking',
desc: '三榜浏览与更新:账号周榜 · 视频热榜 · 点赞榜(支持赛道筛选 / 历史期切换 / 点击表头排序)',
steps: [
{ sel: '.rank-tab[data-rank-tab="hot"]', mode: 'click', title: '切换榜单', text: '页面上方三个 tab 对应三张榜:账号周榜(达人综合实力)、视频热榜(近7日最热作品)、点赞榜(近7日点赞飙升)。' + '\n' + '👉 点「视频热榜」tab 切换到该榜。' },
{ sel: '.rank-tab[data-rank-tab="likes"]', mode: 'click', title: '再切一榜', text: '👉 再点「点赞榜」,感受榜单间切换。各榜列字段不同(周榜=粉丝/新增互动;热榜=点赞/评论/分享/收藏)。' },
{ sel: '#rankCat', mode: 'click', title: '赛道筛选', text: '右侧「赛道筛选」下拉只保留你想看的赛道(如 亲子/美食)。' + '\n' + '👉 点开下拉任选一个赛道,再切回「全部赛道」。' },
{ sel: '#rankPrev', mode: 'click', emptyOk: true, title: '历史期切换', text: '◀ ▶ 切换历史榜单期次,中间的日期即当前数据期(红狐日榜每日更新)。' + '\n' + '👉 点一下 ◀ 回看上一期。' },
{ sel: '#rankUpdate', mode: 'info', danger: true, title: '更新榜单数据', text: '🌐 按钮触发后台任务,重新抓取三榜最新一期并落盘(红狐数据按次计费,30 分钟内重复点会被拦截)。此处仅讲解,不实际点击。' },
{ sel: '.rank-tabs', mode: 'info', title: '榜单行操作', text: '账号周榜每行有「查看/关注」:已入库账号可点「查看」直达账号详情,未入库可「关注」纳入达人库(会触发抓取)。数值列表头可点击排序。' },
],
qa: [
['"更新一下热点数据三榜"', '触发后台任务抓取三榜最新一期并落盘'],
['"看看上周账号周榜的亲子赛道"', '切换期次 + 赛道筛选'],
],
},
{
key: 'accounts', icon: 'users', title: '账号列表', route: '#/accounts',
desc: '达人库:添加 / 关注 / 批量更新账号数据,行内直达账号详情与 AI 创作',
steps: [
{ sel: '#accType', mode: 'click', title: '类型筛选', text: '按账号类型过滤:全部 / 内部(公司签约)/ 外部(对标观察)。' + '\n' + '👉 点开下拉切换「外部」再切回「全部」。' },
{ sel: '#accSearch', mode: 'input', title: '搜索账号', text: '搜索框支持按账号名 / 简介内容 / 赛道检索。' + '\n' + '👉 输入关键词(如「亲子」)后按 回车 过滤,再看效果。' },
{ sel: '#accImport', mode: 'click', title: '添加账号', text: '「添加」弹窗填入抖音账号名称 / 地址,AI 将通过抖音网页抓取账号信息 + 视频列表入库(新增账号入口)。' + '\n' + '👉 点击打开弹窗看看表单,再点「取消」关闭(不实际添加)。' },
{ sel: '.modal-mask [data-cancel]', mode: 'click', title: '关闭弹窗', text: '👉 点「取消」关闭添加弹窗,回到账号列表。' },
{ sel: '#accUpdate', mode: 'info', danger: true, title: '更新关注账号', text: '🌐 重新抓取所有 ⭐ 已关注账号的最新数据与视频列表(后台逐账号串行,每个数分钟)。此处仅讲解,不实际点击。' },
{ sel: '.fav-btn', mode: 'info', danger: true, emptyOk: true, title: '关注 / 取消关注', text: '行首 ☆/⭐ 切换关注:关注的账号参与「更新」批量刷新,也是「关注账号优先」排榜的来源。此处仅讲解。' },
{ sel: '[data-create]', mode: 'info', danger: true, emptyOk: true, title: '行内 AI 创作', text: '行尾「AI创作」= 基于该账号设定创作全新脚本(弹窗可填选题维度,完成后脚本自动入库到「AI写脚本」)。此处仅讲解。' },
],
qa: [
['"把抖音账号「俊希」导入账号列表"', '网页采集账号信息 + 视频列表入库'],
['"更新一下所有关注账号的数据"', '后台逐账号重抓最新数据与视频'],
],
},
{
key: 'account', icon: 'image', title: '账号详情', route: firstAccountRoute,
desc: '单账号档案:账号信息 + 账号设定(人设卡)+ 视频列表(趋势图)+ 数据分析',
steps: [
{ sel: '.tabs [data-tab="persona"]', mode: 'click', emptyOk: true, title: '切到视频列表', text: '账号详情顶部三个 tab:账号信息 / 视频列表 / 数据分析。' + '\n' + '👉 点「视频列表」:上方是点赞/评论趋势折线图,下方是该账号全部视频表。' },
{ sel: '.tabs [data-tab="info"]', mode: 'click', emptyOk: true, title: '账号信息与设定', text: '👉 点回「账号信息」:上半是基础档案(抖音号/粉丝/赛道/AI制作等,抖音号可跳主页),下半是「账号设定」(人设卡,支持卡片版/文字版切换)。' },
{ sel: '.persona-vs-icons [data-vs="text"]', mode: 'click', emptyOk: true, title: '设定卡片切换', text: '账号设定右上角两个小图标:卡片版(设计感人设卡)/ 文字版(结构化全文)。' + '\n' + '👉 点文字版图标再点回卡片版。' },
{ sel: '.tabs [data-tab="analysis"]', mode: 'click', emptyOk: true, title: '数据分析', text: '👉 点「数据分析」:账号内容结构拆解与定位诊断(由账号解析产物生成,无数据时为空态)。' },
{ sel: '[data-rewrite]', mode: 'info', danger: true, emptyOk: true, title: '基于视频 AI 创作', text: '视频列表行尾「AI创作」= 以该视频为选题来源,结合账号设定创作全新脚本。此处仅讲解,不实际触发。' },
{ sel: '[data-extract]', mode: 'info', danger: true, emptyOk: true, title: '提炼账号设定', text: '账号无设定时可点「提炼账号设定」按钮,AI 基于库中解析数据生成人设卡。此处仅讲解。' },
],
qa: [
['"帮我设计一个三农赛道的达人IP"', '提炼账号设定(人设卡)'],
['"分析一下这个账号的内容套路"', '账号内容结构拆解 + 定位诊断'],
],
},
{
key: 'video', icon: 'file-text', title: '视频详情', route: firstVideoRoute,
desc: '单视频档案:视频脚本(分镜头表)+ 视频拆解(结构化分析)+ 解析/刷新',
steps: [
{ sel: '.tabs [data-tab="deconstruct"]', mode: 'click', emptyOk: true, title: '视频拆解 tab', text: '视频详情顶部两个 tab:视频脚本(原视频口播/分镜还原)与视频拆解(钩子/节奏/情绪/选题结构化分析)。' + '\n' + '👉 点「视频拆解」查看结构化拆解。' },
{ sel: '.tabs [data-tab="script"]', mode: 'click', emptyOk: true, title: '视频脚本 tab', text: '👉 点回「视频脚本」:展示该视频的脚本/分镜头表(由 MCP 解析产出)。' },
{ sel: '[data-parse]', mode: 'info', danger: true, emptyOk: true, title: '解析 / 刷新', text: '「解析」= 对该视频做 MCP 内容解析(约 3-5 分钟,产出脚本+拆解);已解析视频按钮变为「刷新」可覆盖更新。此处仅讲解。' },
{ sel: '.info-card.video-info', mode: 'info', title: '视频信息卡', text: '页面顶部信息卡:所属账号(可点进账号详情)、视频ID、时长、发布时间与互动数据,右上「查看视频」直接打开抖音原页。' },
],
qa: [
['"解析这个抖音链接"', 'MCP 自动解析视频内容'],
['"帮我拆解这个抖音视频"', '解析 + 钩子/节奏/情绪/选题拆解'],
],
},
{
key: 'rewrites', icon: 'pen', title: 'AI写脚本', route: '#/rewrites',
desc: '脚本中心:参考选题创作(有原视频)/ 自定义选题创作(无原视频)/ ABC 融合创作',
steps: [
{ sel: '.rw-tabs [data-tab="custom"]', mode: 'click', title: '自定义选题创作', text: 'AI写脚本 分三种创作入口,先看「自定义选题创作」:无原视频的脚本(来自账号列表/详情 AI创作弹窗)都收在这里,带徽标计数。' + '\n' + '👉 点「自定义选题创作」tab。' },
{ sel: '.rw-tabs [data-tab="ref"]', mode: 'click', title: '参考选题创作', text: '「参考选题创作」:以库中视频为选题来源(关注账号优先、按发布时间倒序),每行视频可「AI创作」新脚本,完成后自动挂到该视频下。' + '\n' + '👉 点回「参考选题创作」。' },
{ sel: '#rwSearch', mode: 'input', emptyOk: true, title: '搜索视频', text: '按视频标题 / 账号名搜索候选视频,配时间范围与「已解析/未解析」状态过滤。' + '\n' + '👉 输入关键词回车试试,或直接下一步。' },
{ sel: '.rw-tabs [data-tab="abc"]', mode: 'info', title: 'ABC 融合创作', text: '「ABC融合创作」:将同账号多条视频的爆款元素融合成一条全新脚本(功能开发中,暂为占位说明页)。' },
{ sel: '[data-rewrite]', mode: 'info', danger: true, emptyOk: true, title: '行内 AI 创作', text: '视频行尾「AI创作」:读取该账号人设 + 视频选题 → 创作全新故事脚本 → 自动入库挂到该视频下。此处仅讲解,不实际触发。' },
{ sel: '[data-detail]', mode: 'info', danger: true, emptyOk: true, title: '查看脚本详情', text: '行内「N 查看」打开脚本详情弹窗:左侧源脚本(可折叠)+ AI脚本(多版本切换),右侧诊断评分 / 分镜提示词。此处仅讲解。' },
],
qa: [
['"基于「旧梦留声机」账号设定,替换事件写一个脚本"', '读账号设定,按账号套路写全新事件脚本'],
['"把刚写的脚本展开成分镜"', '按分镜技能生成镜头级描述'],
],
},
{
key: 'reviews', icon: 'flask', title: '脚本诊断', route: '#/reviews',
desc: '复盘评分中心:AI 脚本质量评分表 + 复盘任务 + 完整诊断报告',
steps: [
{ sel: '.table thead', mode: 'info', title: '评分表结构', text: '列表展示全部 AI 脚本的复盘状态:每行含 生成方式(参考选题/输入选题)、脚本标题、以及各维度得分(创意看点/设定承接/框架节奏/爆款开场/叙事逻辑/极致事件/…/总分)。' },
{ sel: '[data-open]', mode: 'info', danger: true, emptyOk: true, title: '打开脚本详情', text: '点脚本标题打开脚本详情弹窗(右侧默认收起诊断,可展开看该版本的评分与问题清单)。此处仅讲解。' },
{ sel: '[data-review-row]', mode: 'info', danger: true, emptyOk: true, title: '发起复盘', text: '行尾「复盘 / 更新」按钮:对脚本发起评分式复盘(有原视频时含 AI/源/对比三模式,无则独立 10 维评分),后台任务数分钟完成。此处仅讲解。' },
{ sel: '[data-review]', mode: 'info', danger: true, emptyOk: true, title: '完整复盘报告', text: '点「总分」数字可打开完整复盘报告:各维得分条 + 问题清单(高/中/低)+ 修改建议。此处仅讲解。' },
],
qa: [
['"帮我诊断这个脚本"', '评分式复盘:各维得分 + 问题清单 + 修改建议'],
['"对比 AI脚本 和 原视频脚本"', 'AI 脚本 vs 源脚本对比分析(三段式)'],
],
},
];
const MODES = [
{ icon: 'zap', name: '自动(默认)', desc: '静默执行全流程,只交付最终脚本' },
{ icon: 'users', name: '共创', desc: '每个关键节点先确认再继续' },
{ icon: 'wrench', name: '开发', desc: '每个步骤都落盘保存中间产物' },
];
/* ============ 帮助页渲染 ============ */
function renderHelp() {
return `
<div class="home help-page">
<div class="page-toolbar">
<button class="btn" data-back title="返回">←</button>
<span class="page-title">功能帮助</span>
</div>
<div class="help-head">
<div class="help-head-icon">${svgIcon('help', 26)}</div>
<div>
<h2>短视频工作台 · 使用帮助</h2>
<p class="help-head-sub">跟着「模拟操作指引」在真实界面上一步步点一遍,比看文字快得多。也可以直接对 AI 说需求(点击问法即复制)。</p>
</div>
</div>
<div class="help-grid">
${BOARDS.map((b) => `
<div class="help-card" data-board="${b.key}">
<div class="help-card-head">
<span class="help-card-icon">${svgIcon(b.icon, 20)}</span>
<span class="help-card-title">${b.title}</span>
</div>
<div class="help-card-desc">${b.desc}</div>
<div class="help-card-acts">
<button class="btn btn-sm btn-primary" data-guide="${b.key}">${svgIcon('zap', 14, '#fff')} 模拟操作指引</button>
<button class="btn btn-sm" data-goto="${b.key}">进入板块 →</button>
</div>
<details class="help-card-qa">
<summary>常见问法(点击复制)</summary>
<div class="qa">
${(b.qa || []).map(([q, a]) => `<div class="qa-row copyable" data-q="${esc(q)}" title="点击复制此问法"><span class="q">${esc(q)}</span><span class="a">→ ${esc(a)}</span></div>`).join('')}
</div>
</details>
</div>`).join('')}
</div>
<div class="modes">
<h3>三种创作模式(对 AI 说一声即切换)</h3>
<table class="mode-table">
${MODES.map((m) => `<tr><td class="mode-name">${svgIcon(m.icon)} ${m.name}</td><td>${m.desc}</td></tr>`).join('')}
</table>
<p class="mode-hint">例:「用共创模式写一个脚本」「开发模式跑一遍」。</p>
</div>
</div>`;
}
/* ============ 帮助页事件绑定(进入按钮 / 指引按钮 / 问法复制) ============ */
function bindHelp(view) {
view.querySelectorAll('.qa-row.copyable').forEach((row) => row.addEventListener('click', async () => {
const text = (row.dataset.q || '').replace(/^"|"$/g, '');
if (text) showToast((await copyText(text)) ? '✅ 已复制:' + text : '❌ 复制失败');
}));
view.querySelectorAll('[data-guide]').forEach((b) => b.addEventListener('click', () => {
startGuide(b.dataset.guide, b);
}));
view.querySelectorAll('[data-goto]').forEach((b) => b.addEventListener('click', async () => {
const board = BOARDS.find((x) => x.key === b.dataset.goto);
if (!board) return;
let r = board.route;
if (typeof r === 'function') { try { r = await r(); } catch (e) { r = null; } }
if (!r) {
showToast('📌 暂无示例数据,请先在库中添加账号 / 解析视频');
return;
}
location.hash = (typeof r === 'string' && r.startsWith('#')) ? r : '#/';
}));
}
/* ============ 模拟操作引导引擎 ============ */
let guide = null; // { board, steps, idx, mask, pop, targetEl, clearFns[] }
const GUIDE_Z = 1100; // 遮罩/气泡层级(modal 为 100,须高于它)
const WAIT_MS = 9000; // 步骤目标最长等待
const POLL_MS = 180;
function waitFor(sel, timeout) {
return new Promise((resolve) => {
const t0 = Date.now();
(function poll() {
const el = sel ? $(sel) : null;
if (el || Date.now() - t0 > timeout) resolve(el || null);
else setTimeout(poll, POLL_MS);
})();
});
}
/* 路由是否已生效(当前 hash 与目标一致,且页面已渲染出内容) */
function routedTo(targetHash) {
const cur = (location.hash || '#/').split('?')[0];
if (cur !== targetHash) return false;
const view = $('#view');
return !!(view && view.innerHTML && view.innerHTML.length > 200 && !view.querySelector('.view-empty'));
}
async function gotoRoute(targetHash) {
if (!targetHash) return false;
if (!routedTo(targetHash)) {
location.hash = targetHash;
const t0 = Date.now();
while (Date.now() - t0 < WAIT_MS) {
await new Promise((r) => setTimeout(r, POLL_MS));
if (routedTo(targetHash)) break;
}
}
return routedTo(targetHash);
}
function cleanupGuide() {
if (!guide) return;
if (guide.mask && guide.mask.parentNode) guide.mask.parentNode.removeChild(guide.mask);
if (guide.pop && guide.pop.parentNode) guide.pop.parentNode.removeChild(guide.pop);
if (guide.targetEl) {
guide.targetEl.style.outline = '';
guide.targetEl.style.boxShadow = '';
guide.targetEl.style.zIndex = '';
guide.targetEl.style.position = '';
guide.targetEl.style.pointerEvents = '';
}
(guide.clearFns || []).forEach((fn) => { try { fn(); } catch (e) {} });
guide = null;
}
/* 定位高亮 + 气泡位置(fixed,相对视口;容器滚动不重算——气泡跟随目标下方固定,超界自动收敛到可视区) */
function placePop() {
if (!guide || !guide.targetEl || !guide.pop) return;
const r = guide.targetEl.getBoundingClientRect();
if (r.width === 0 && r.height === 0) return;
const pw = guide.pop.offsetWidth || 420;
const ph = guide.pop.offsetHeight || 120;
let x = Math.min(Math.max(12, r.left + r.width / 2 - pw / 2), window.innerWidth - pw - 12);
let y = r.bottom + 14;
if (y + ph > window.innerHeight - 12) y = Math.max(12, r.top - ph - 14);
guide.pop.style.left = x + 'px';
guide.pop.style.top = y + 'px';
}
function highlightTarget(el) {
if (!el) return;
el.style.outline = '3px solid var(--primary)';
el.style.outlineOffset = '2px';
el.style.boxShadow = '0 0 0 6px rgba(47,111,237,.25)';
// 提升层级使其可点/可见于遮罩之上
const pos = getComputedStyle(el).position;
if (pos === 'static') el.style.position = 'relative';
el.style.zIndex = String(GUIDE_Z + 2);
if (guide.step.danger) el.style.pointerEvents = 'none'; // 防误触危险控件
guide.targetEl = el;
el.scrollIntoView({ block: 'center', inline: 'center', behavior: 'smooth' });
}
/* 渲染气泡并挂一次性交互 */
function showStep() {
const g = guide;
g.step = g.steps[g.idx];
g.clearFns.forEach((fn) => { try { fn(); } catch (e) {} });
g.clearFns = [];
if (g.targetEl) {
g.targetEl.style.outline = ''; g.targetEl.style.boxShadow = ''; g.targetEl.style.zIndex = '';
g.targetEl.style.position = ''; g.targetEl.style.pointerEvents = '';
g.targetEl = null;
}
const st = g.step;
const total = g.steps.length;
const isLast = g.idx >= total - 1;
const needClick = st.mode === 'click';
const needInput = st.mode === 'input';
const actionHint = needClick ? '👉 点击上面高亮的控件体验(点对自动下一步)'
: needInput ? '👉 在上面高亮输入框输入后按 回车(或直接下一步)'
: (st.danger ? '⚠️ 该操作有副作用,此处仅讲解,请勿点击' : '');
g.pop.innerHTML = `
<div class="g-head"><span class="g-board">${esc(g.board.title)}</span><span class="g-count">${g.idx + 1}/${total}</span></div>
<div class="g-title">${esc(st.title || '')}</div>
<div class="g-text">${esc(st.text || '')}</div>
${actionHint ? `<div class="g-hint ${needClick || needInput ? 'g-hint-act' : 'g-hint-warn'}">${esc(actionHint)}</div>` : ''}
<div class="g-actions">
<button class="ghost btn-sm" data-g-prev ${g.idx === 0 ? 'disabled' : ''}>‹ 上一步</button>
<button class="primary btn-sm" data-g-next>${isLast ? '✓ 完成' : (needClick || needInput ? '跳过 →' : '下一步 →')}</button>
</div>`;
g.pop.style.display = 'block';
g.mask.style.display = 'block';
// 按钮
const prevBtn = g.pop.querySelector('[data-g-prev]');
if (prevBtn) prevBtn.addEventListener('click', () => { if (g.idx > 0) { g.idx--; showStep(); } });
g.pop.querySelector('[data-g-next]').addEventListener('click', () => {
if (isLast) return finishGuide();
g.idx++; showStep();
});
// 找目标控件
(async () => {
const el = await waitFor(st.sel, WAIT_MS);
if (!guide || guide !== g) return; // 已退出
if (!el) {
if (st.emptyOk) {
// 目标缺失但允许纯文字展示
g.pop.querySelector('.g-hint')?.remove();
placePop();
} else {
// 自动跳到下一步
if (g.idx < total - 1) { g.idx++; showStep(); }
else finishGuide();
}
return;
}
highlightTarget(el);
// 目标在弹窗(.modal-mask)内时隐藏全屏遮罩——modal z-index(100) < 遮罩(1100),留着会挡住弹窗操作
const inModal = !!el.closest('.modal-mask');
g.mask.style.display = inModal ? 'none' : 'block';
if (needClick && !st.danger) {
const fn = () => { if (guide === g) { if (g.idx < total - 1) { g.idx++; showStep(); } else finishGuide(); } };
el.addEventListener('click', fn, { once: true });
g.clearFns.push(() => el.removeEventListener('click', fn));
} else if (needInput) {
const fn = (e) => { if (e.key === 'Enter' && guide === g) { if (g.idx < total - 1) { g.idx++; showStep(); } else finishGuide(); } };
el.addEventListener('keydown', fn);
g.clearFns.push(() => el.removeEventListener('keydown', fn));
}
placePop();
// 页面内容可能异步替换(如切换 tab 重渲染),延迟后重定位一次
setTimeout(placePop, 500);
})();
}
function buildOverlay() {
cleanupGuide();
const mask = document.createElement('div');
mask.className = 'g-mask';
mask.style.zIndex = String(GUIDE_Z);
document.body.appendChild(mask);
const pop = document.createElement('div');
pop.className = 'g-pop';
pop.style.zIndex = String(GUIDE_Z + 1);
document.body.appendChild(pop);
guide.mask = mask;
guide.pop = pop;
// 点遮罩非高亮区 = 忽略(仅提示)
mask.addEventListener('click', () => showToast('请按气泡指引操作:点击高亮控件或气泡按钮'));
// Esc 随时退出引导
const escFn = (e) => { if (e.key === 'Escape') finishGuide(); };
document.addEventListener('keydown', escFn);
guide.clearFns.push(() => document.removeEventListener('keydown', escFn));
}
function finishGuide() {
const name = guide ? guide.board.title : '';
cleanupGuide();
showToast('🎉 已看完「' + name + '」指引,可自行继续操作');
}
/* 入口:boardKey 或 {route,title,steps} */
async function startGuide(boardKey, btn) {
const board = BOARDS.find((b) => b.key === boardKey);
if (!board) { showToast('❌ 未找到该板块指引'); return; }
if (guide) cleanupGuide();
// 详情类板块:先解析示例路由(取库中第一条)
let route = board.route;
if (typeof route === 'function') {
try { route = await route(); } catch (e) { route = null; }
}
if (typeof route === 'string' && !route.startsWith('#')) route = null;
if (!route) {
const needFirst = board.key === 'account' ? '还没有账号数据——请先到「账号列表」添加一个账号,再回来体验账号详情指引' : '还没有视频数据——请先在「热点数据/账号列表」解析或添加视频,再回来体验视频详情指引';
showToast('📌 ' + needFirst);
if (board.key === 'account') location.hash = '#/accounts';
else if (board.key === 'video') location.hash = '#/accounts';
return;
}
// 跳转 + 等渲染
const ok = await gotoRoute(route);
if (!ok) { showToast('❌ 页面加载超时,请重试'); return; }
guide = { board, steps: board.steps, idx: 0, clearFns: [] };
buildOverlay();
showStep();
}
/* 暴露到全局:renderHelp/bindHelp/startGuide/BOARDS(供 app.js 路由调用) */
window.HelpGuide = { renderHelp, bindHelp, startGuide, BOARDS };
})();
@@ -4,7 +4,7 @@
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>短视频工作台</title>
<link rel="stylesheet" href="style.css?v=20260904a">
<link rel="stylesheet" href="style.css?v=20260904c">
<script src="vendor/echarts.min.js"></script>
</head>
<body>
@@ -41,7 +41,8 @@
</div>
</div>
<script src="app.js?v=20260904a"></script>
<script src="data-pages.js?v=20260904a"></script>
<script src="app.js?v=20260904e"></script>
<script src="help-guide.js?v=20260904b"></script>
<script src="data-pages.js?v=20260904f"></script>
</body>
</html>
@@ -575,6 +575,7 @@ td.td-title { max-width: 240px; }
.rw-sec-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; }
.rw-sec-foot { flex: none; min-height: 46px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); } /* min-height 保证空 foot(源脚本列占位)与其他列等高,内容区一致 */
.rw-sec-count { margin-right: auto; color: var(--dim); font-size: 14px; }
.rw-ver-new { display: inline-block; margin-left: 6px; padding: 0 7px; border-radius: 9px; background: var(--primary); color: #fff; font-size: 12px; font-weight: 600; line-height: 17px; vertical-align: 1px; } /* R55:最新版徽标(版本条最右=最新) */
.rw-sec-hidden { display: none; } /* 源脚本列默认收起 */
/* R34:脚本详情弹窗 右侧列头部 tab(脚本诊断 / 分镜提示词 切换) */
.rw-side-head { padding: 4px 6px; gap: 0; }
@@ -685,6 +686,9 @@ td.td-title { max-width: 240px; }
.btn-icon { display: inline-flex; align-items: center; justify-content: center; padding: 5px 9px; }
.btn-danger { background: #fff; border-color: var(--danger); color: var(--danger); }
.btn-danger:hover { background: var(--danger); border-color: var(--danger); color: #fff; }
.btn-del { background: #fdf0ef; border-color: #f0c9c6; color: #d9534f; display: inline-flex; align-items: center; gap: 4px; } /* R58:删除按钮(浅红底红字) */
.btn-del:hover { background: #fbe0de; border-color: #d9534f; }
.btn-del svg { flex: none; }
.batch-count { font-size: 14px; color: var(--dim); }
.batch-count-n { color: var(--danger); font-weight: 600; }
.cb-cell { width: 36px; text-align: center; }
@@ -715,3 +719,57 @@ td.td-title { max-width: 240px; }
.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; }
/* ================= 帮助页(help-guide.js 09-04) ================= */
.help-page { max-width: 1080px; }
.help-head { display: flex; align-items: center; gap: 14px; margin-bottom: 20px; }
.help-head-icon { width: 52px; height: 52px; border-radius: 14px; background: var(--primary-soft); color: var(--primary); display: flex; align-items: center; justify-content: center; flex: none; }
.help-head h2 { font-size: 22px; margin-bottom: 4px; }
.help-head-sub { color: var(--dim); font-size: 15px; }
.help-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 14px; }
.help-card {
background: var(--panel); border: 1px solid var(--border); border-radius: 12px;
padding: 16px 18px; box-shadow: var(--shadow);
display: flex; flex-direction: column; gap: 10px;
}
.help-card-head { display: flex; align-items: center; gap: 10px; }
.help-card-icon { color: var(--primary); display: inline-flex; }
.help-card-title { font-size: 18px; font-weight: 700; }
.help-card-desc { color: var(--dim); font-size: 14px; line-height: 1.55; min-height: 42px; }
.help-card-acts { display: flex; gap: 8px; flex-wrap: wrap; }
.help-card-acts .btn { display: inline-flex; align-items: center; gap: 5px; }
.help-card-qa summary { cursor: pointer; color: var(--primary); font-size: 13px; user-select: none; }
.help-card-qa summary:hover { text-decoration: underline; }
.help-card-qa .qa { margin-top: 8px; border-top: 1px dashed var(--border); padding-top: 6px; }
/* ================= 模拟操作引导遮罩 / 气泡(help-guide.js 09-04) ================= */
.g-mask {
position: fixed; inset: 0;
background: rgba(18, 24, 40, .42);
display: none;
}
.g-pop {
position: fixed;
display: none;
width: min(440px, calc(100vw - 24px));
background: var(--panel); border-radius: 14px;
box-shadow: 0 12px 40px rgba(0, 0, 0, .28);
border: 1px solid var(--border);
padding: 16px 18px 14px;
font-size: 15px; line-height: 1.6;
}
.g-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px; }
.g-board {
font-size: 12px; font-weight: 600; color: var(--primary);
background: var(--primary-soft); border-radius: 20px; padding: 2px 10px;
}
.g-count { font-size: 12px; color: var(--dim); font-variant-numeric: tabular-nums; }
.g-title { font-size: 17px; font-weight: 700; margin-bottom: 6px; }
.g-text { color: var(--text); font-size: 14.5px; }
.g-text code { background: #f0f2f5; border-radius: 4px; padding: 0 4px; font-size: 13px; }
.g-hint { margin-top: 10px; padding: 8px 10px; border-radius: 8px; font-size: 14px; }
.g-hint-act { background: var(--primary-soft); color: var(--primary); font-weight: 600; }
.g-hint-warn { background: #fff3e6; color: #b3541e; font-weight: 600; }
.g-actions { display: flex; justify-content: flex-end; gap: 8px; margin-top: 12px; }
.g-actions .btn-sm { padding: 4px 12px; font-size: 14px; }
.g-actions .ghost:disabled { opacity: .5; cursor: default; }
@@ -497,6 +497,20 @@ const server = http.createServer((req, res) => {
});
return;
}
if (p === '/api/dsh/script-delete' && req.method === 'POST') {
if (dshGuard()) return;
let body = '';
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
req.on('end', () => {
try {
const { id } = JSON.parse(body || '{}');
if (!(Number(id) > 0)) return sendErr(400, '缺少脚本 id');
const r = dsh.deleteScript(Number(id)); /* R58:连坐删除该脚本的诊断复盘与分镜提示词 */
return dshOk(200, { ok: true, deleted: r.deleted });
} catch (e) { return sendErr(500, e.message); }
});
return;
}
if (p === '/api/dsh/account-videos') {
if (dshGuard()) return;
try {