09-10 工作台:配置收敛、复盘字段契约修复、原创选题分镜支持、榜单期号区间显示
配置收敛:
- 端口/DB名/产出根/榜单目录单源化到 mcn-work-shop/config.json,代码层统一经 load-config.js 读取
- 新增 get-port.js 供 start.bat 读端口(bat 内嵌 node -e 会被 cmd 截断,读到的是兜底值)
- start.bat 修复失效的 managed node 路径(原写死 22.22.2 已不存在),改为遍历 versions 自动探测
复盘诊断页(真 bug 修复):
- 问题清单严重度全部显示成绿色 + 修改建议不显示:模型输出 {sev:'high',fix} 与前端读取 {severity:'高',suggestion} 字段名与取值均不匹配
- 前端新增 normIssue 归一化,兼容 sev/severity/level 与中英取值,并回补所属维度显示
- 在 submit-review-tasks 与诊断/复盘 prompt 中固化 issues_json 字段契约,从源头防 schema 漂移
分镜提示词:
- 原创选题(video_id 为空)原本无生成入口(分镜按 video_id 查询)
- listStoryboards 支持按 rewrite_id 查询,前端放开入口、切版本重查,并补落库指令(此前入库靠模型自由发挥)
榜单展示:
- 期号改为显示区间「08/31 ~ 09/06」,避免被误读为单日(期号=该周周一)
- 后端新增 collectRanges,从落盘文件读真实 dateStart/dateEnd 随 API 返回(日榜 start=end 不显区间)
其他:
- 清理 dsh 环境废弃后的历史叙述类废话(16 文件约 18 处),文末变更记录区保留
- 新增 docs/工作台UI规范.md(设计 token / 布局 / 8 页面路由 / 12 类组件 / 9 条已知坑)
This commit is contained in:
1 parent
3926d742fd
commit
0165144292
58 files changed
+895
-263
No files matched your search
@@ -16,6 +16,15 @@ const cleanTitle = (t) => {
|
||||
return stripTitleQuotes(s.slice(0, idx)) + s.slice(idx);
|
||||
};
|
||||
const fmtSize = (n) => { if (n < 1024) return n + ' B'; if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; return (n / 1024 / 1024).toFixed(1) + ' MB'; };
|
||||
/* 榜单期号显示为区间(09-10):周榜「08/31 ~ 09/06」、日榜「09/08」。
|
||||
期号 = 该周周一,只显示 2026-08-31 会被误读成"仅 8/31 单日",实际覆盖到 09-06。
|
||||
ranges 由 /api/dsh/ranking 返回(每期真实 dateStart/dateEnd,读自落盘文件)。 */
|
||||
const fmtRankPeriod = (date, ranges) => {
|
||||
const r = ranges && ranges[String(date || '')];
|
||||
if (!r) return String(date || '');
|
||||
const mm = (x) => String(x || '').slice(5).replace(/-/g, '/'); // 08/31(斜杠比连字符更易读)
|
||||
return r.start === r.end ? mm(r.start) : mm(r.start) + ' ~ ' + mm(r.end);
|
||||
};
|
||||
const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'table', txt: 'file-text', log: 'file-text', bak: 'file' };
|
||||
|
||||
/* ================= AI 任务触发(确认弹窗 + 执行中状态) =================
|
||||
@@ -460,7 +469,7 @@ async function loadRecentScripts() {
|
||||
}
|
||||
|
||||
/* ================= 首页 榜单预览(账号周榜/视频热榜/点赞榜 三榜;前10 + 赛道/日期筛选,无分页/排序) ================= */
|
||||
let _rankPreview = { board: 'week', pageSize: 10, dates: [], categories: [], date: '', category: '', _metaBoard: '' };
|
||||
let _rankPreview = { board: 'week', pageSize: 10, dates: [], categories: [], ranges: {}, date: '', category: '', _metaBoard: '' };
|
||||
function renderRankTools() {
|
||||
const has = _rankPreview.dates.length > 0;
|
||||
const tools = $('#rankTools');
|
||||
@@ -472,7 +481,7 @@ function renderRankTools() {
|
||||
}
|
||||
const dateLabel = $('#rankDateLabel');
|
||||
const di = _rankPreview.dates.indexOf(_rankPreview.date);
|
||||
if (dateLabel) dateLabel.textContent = _rankPreview.date ? _rankPreview.date.slice(5).replace('-', '-') : '';
|
||||
if (dateLabel) dateLabel.textContent = fmtRankPeriod(_rankPreview.date, _rankPreview.ranges);
|
||||
const prevBtn = $('#rankPrevDate'), nextBtn = $('#rankNextDate');
|
||||
if (prevBtn) { prevBtn.disabled = di <= 0; prevBtn.onclick = () => { if (di > 0) { _rankPreview.date = _rankPreview.dates[di - 1]; renderRankTools(); loadRankingPreview(); } }; }
|
||||
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(); } }; }
|
||||
@@ -532,6 +541,7 @@ async function loadRankingPreview() {
|
||||
const m = await api('/api/dsh/ranking?board=' + board + '&page=1&pageSize=1');
|
||||
_rankPreview.dates = m.dates || [];
|
||||
_rankPreview.categories = m.categories || [];
|
||||
_rankPreview.ranges = m.ranges || {};
|
||||
_rankPreview.date = m.date || (_rankPreview.dates[0] || '');
|
||||
_rankPreview.category = m.category || '';
|
||||
_rankPreview._metaBoard = board;
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
依赖 app.js 暴露的工具:esc/api/renderMarkdown/copyText/showToast/fmtSize */
|
||||
'use strict';
|
||||
|
||||
/* 工作台服务地址(09-10 配置收敛):前端用 location.origin 动态取当前访问地址,端口自动跟随,
|
||||
不再写死 8900 —— 换端口时 prompt 里的回调地址自动一致,无需改代码 */
|
||||
const BASE = location.origin;
|
||||
|
||||
/* AI创作技能指令(09-01 修复:任务必须按「短视频工作台」技能执行,禁止模型自由发挥导致分镜表格/TXT/MD 格式混杂。
|
||||
定位方式跨环境:优先用 Skill 工具按技能名加载;技能位于用户技能目录 ~/.workbuddy/skills/短视频工作台/(部署到用户环境时同此标准位),不写死机器绝对路径 */
|
||||
const SKILL_HINT_CREATE = '请调用 Skill 工具加载「短视频工作台」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务以技能 ⚡自动模式 执行:严格按 SKILL.md 的 S1-S11 步骤顺序逐步走完完整创作流程(选题→框架→大纲→情绪→结构→脚本)——中间步骤后台静默 = 各步产物在对话上下文中按序完整产出但不逐个展示,禁止跳过或合并中间步骤一步直出成品。最终输出 S9 标准脚本格式(按场次+对白组织,画面标注【景别|环境|光线|场景|场景元素】),禁止输出分镜头表格、禁止偏离脚本格式。创作时必须逐条读取并严格执行 SKILL.md 的「开场钩子铁律」与【账号设定】的「内容规则」(如果…则…)——二者为硬约束,不得自由发挥、不得跳过,具体标准一律以 SKILL.md 与内容规则原文为准。';
|
||||
@@ -16,19 +20,19 @@ const SKILL_HINT_STORYBOARD = '请调用 Skill 工具加载「短视频分镜脚
|
||||
const SKILL_HINT_REVIEW = '请调用 Skill 工具加载「脚本复盘」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-script-review/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为工作台复盘任务:严格按技能执行方式判定与复盘流程执行(评分式复盘/对比分析模式由任务说明指定;一条脚本=一个独立上下文,禁止混审多条脚本)。技能边界:只做分析与建议,不修改脚本、不触发创作流程、不改动任何技能文件。';
|
||||
/* 视频解析技能声明(09-03 R28:视频详情页「解析/刷新」按钮=单视频 MCP 解析,任务必须按 mcn-dou-analysis 功能五执行,
|
||||
一次性产出「视频脚本」(content 分镜头表)与「视频拆解」(analysis 结构化拆解)两个数据源 */
|
||||
const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为技能功能五「解析视频」:对单条抖音视频做 MCP 内容解析。按 references/feature/05_解析视频.md 流程执行:① 调用 MCP 工具 upload_douyin_video 提交视频链接(share_text=该视频抖音链接)获取 detailId;② 等待解析完成(新视频约 3-5 分钟,用 sleep 等待后查询,content 已出而 analysis 未出时稍后重试);③ 用 short_video_detail 查询解析结果 content(视频脚本/分镜头表)与 analysis(视频拆解/结构化JSON);④ 按 references/解析结果格式规范.md 用 scripts/json_tool.py 将 content.json 与 analysis.json 保存到产出目录对应视频文件夹;⑤ 解析结果入库:工作台环境(本机 http://localhost:8900)产物落盘后调用 POST /api/import/account(body {"account":"{达人昵称}"})同步入库,接口不可用则跳过不影响保存;**入库后必须自查关联是否成功(09-04 修复后强制)**:检查接口返回体 `unmatched`/`unmatched_folders` 字段——若 `unmatched>0` 且含本次视频文件夹名,说明 aweme_id 未能与库内视频关联(入库失败),必须明确汇报「入库关联失败 + unmatched 明细」,禁止报成功;返回正常后再查询库中该视频(按 aweme_id 或标题)确能查到 source 记录方为成功。完成后汇报 detailId、content/analysis 是否齐备、保存路径与入库关联结果(成功/失败含 unmatched)。技能边界:只做该单视频解析,不抓取账号信息、不提炼账号设定、不创作/改写脚本。';
|
||||
const SKILL_HINT_VIDEO_PARSE = '请调用 Skill 工具加载「mcn-dou-analysis」技能(技能根目录 = 用户技能目录 ~/.workbuddy/skills/短视频工作台/subskills/mcn-dou-analysis/,先读取其 SKILL.md 及 references 规范;若 Skill 工具无法加载,请直接读取该目录下的 SKILL.md 文件)。本任务为技能功能五「解析视频」:对单条抖音视频做 MCP 内容解析。按 references/feature/05_解析视频.md 流程执行:① 调用 MCP 工具 upload_douyin_video 提交视频链接(share_text=该视频抖音链接)获取 detailId;② 等待解析完成(新视频约 3-5 分钟,用 sleep 等待后查询,content 已出而 analysis 未出时稍后重试);③ 用 short_video_detail 查询解析结果 content(视频脚本/分镜头表)与 analysis(视频拆解/结构化JSON);④ 按 references/解析结果格式规范.md 用 scripts/json_tool.py 将 content.json 与 analysis.json 保存到产出目录对应视频文件夹;⑤ 解析结果入库:工作台环境(本机 ' + BASE + ')产物落盘后调用 POST /api/import/account(body {"account":"{达人昵称}"})同步入库,接口不可用则跳过不影响保存;**入库后必须自查关联是否成功(09-04 修复后强制)**:检查接口返回体 `unmatched`/`unmatched_folders` 字段——若 `unmatched>0` 且含本次视频文件夹名,说明 aweme_id 未能与库内视频关联(入库失败),必须明确汇报「入库关联失败 + unmatched 明细」,禁止报成功;返回正常后再查询库中该视频(按 aweme_id 或标题)确能查到 source 记录方为成功。完成后汇报 detailId、content/analysis 是否齐备、保存路径与入库关联结果(成功/失败含 unmatched)。技能边界:只做该单视频解析,不抓取账号信息、不提炼账号设定、不创作/改写脚本。';
|
||||
/* 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 猜库路径 */
|
||||
保存走本地服务 /api/dsh/script-save:server 自行解析 mcn-plugin.db 路径(server 端解析最可靠),勿让 agent 猜库路径 */
|
||||
/* 榜单数据更新技能声明(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 技能流程抓取最新一期账号日榜——用 fetch_rank.py --period day 直接获取全品类(不传赛道,单次调用),将结果写入 账号日榜/抖音账号日榜_{榜单日期}.json;③ 账号周榜:按 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项目\\三方数据\\红狐数据\\抖音榜单\\(按运行系统解析真实桌面,兼容 OneDrive 重定向;环境变量 MCN_RANKING_DIR 可覆盖),新产物入 账号日榜/账号周榜/视频热榜/点赞榜 四个子目录,文件名保持 抖音账号日榜_/抖音周榜_ 前缀;⑤ 查询幂等铁律:每次调用前先 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 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":"{达人昵称}"})同步入库(工作台环境 ' + BASE + ',接口不可用则跳过不影响保存)。技能边界:只做账号数据分析报告,不生成/改写脚本(属「短视频工作台」技能 S1-S11 流程),不提炼账号设定,不触发账号信息/视频列表抓取流程。';
|
||||
const saveHintOf = (ctx) => {
|
||||
const ref = Number(ctx.videoId) > 0;
|
||||
const lines = [];
|
||||
@@ -38,8 +42,8 @@ const saveHintOf = (ctx) => {
|
||||
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`
|
||||
return `\n\n【任务完成后必须执行】将完整脚本保存到工作台数据库(工作台与本任务同机运行,服务地址 ${BASE}):\n`
|
||||
+ `1. 用 fetch 发起 POST ${BASE}/api/dsh/script-save;body 用 JSON.stringify 构造(scriptText 为完整脚本全文,注意转义,不要手拼 JSON):\n{\n${lines.join('\n')}\n}\n`
|
||||
+ (ref ? '(参考选题创作:脚本归入「参考选题创作」列表,关联该原视频)\n' : '(原创选题创作:不传 videoId,脚本归入「原创选题创作」列表)\n')
|
||||
+ `2. 响应含 ok:true 即已入库;失败重试一次,仍失败则在回复中说明原因。\n完成后回复:脚本标题 + 保存结果。`;
|
||||
};
|
||||
@@ -698,7 +702,7 @@ async function renderRanking(view) {
|
||||
['hot', '视频热榜', '近7日数据'],
|
||||
['likes', '点赞榜', '近7日数据'],
|
||||
];
|
||||
const mkState = () => ({ date: '', category: '', sortBy: '', order: '', page: 1, pageSize: 20, dates: [], categories: [] });
|
||||
const mkState = () => ({ date: '', category: '', sortBy: '', order: '', page: 1, pageSize: 20, dates: [], categories: [], ranges: {} });
|
||||
// 每榜独立状态(切 tab 保留各自筛选/翻页/日期)
|
||||
const st = { week: mkState(), day: mkState(), hot: mkState(), likes: mkState() };
|
||||
let tab = 'week';
|
||||
@@ -713,7 +717,7 @@ async function renderRanking(view) {
|
||||
${s.categories.map((c) => `<option value="${esc(c)}" ${c === s.category ? 'selected' : ''}>${esc(c)}</option>`).join('')}
|
||||
</select>
|
||||
<button class="btn" id="rankPrev" title="上一期">◀</button>
|
||||
<span class="pager-info" title="选择日期">${esc(s.date)}</span>
|
||||
<span class="pager-info rank-period" title="该期覆盖区间(期号 = 该周周一)">${esc(fmtRankPeriod(s.date, s.ranges))}</span>
|
||||
<button class="btn" id="rankNext" title="下一期">▶</button>
|
||||
</div>` : '';
|
||||
return `<div class="page-toolbar">
|
||||
@@ -800,7 +804,7 @@ async function renderRanking(view) {
|
||||
if (!m.ok) { view.innerHTML = `<div class="page">${toolbar(st[tab], false)}<div class="empty">${esc(m.error || '加载失败')}</div></div>`; bindShell(); return null; }
|
||||
metas[tab] = m;
|
||||
const s = st[tab];
|
||||
s.dates = m.dates || []; s.categories = m.categories || [];
|
||||
s.dates = m.dates || []; s.categories = m.categories || []; s.ranges = m.ranges || {};
|
||||
if (!s.date) { s.date = m.date || (s.dates[0] || ''); s.category = m.category || ''; }
|
||||
return m;
|
||||
}
|
||||
@@ -2005,9 +2009,9 @@ async function renderReviews(view) {
|
||||
}
|
||||
const [desc, prompt] = hasSource
|
||||
? ['将对该 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。',
|
||||
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。`]
|
||||
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。\n${REVIEW_ISSUE_RULE}`]
|
||||
: ['将对该 AI 脚本做质量复盘评分(该脚本无原视频脚本,仅评 AI 脚本:独立分析模式·10 维评分制)。',
|
||||
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做质量复盘评分(独立分析模式·10 维评分制)。该脚本无原视频脚本,只评 AI 脚本本身,不做源脚本复盘、不做对比分析。`];
|
||||
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做质量复盘评分(独立分析模式·10 维评分制)。该脚本无原视频脚本,只评 AI 脚本本身,不做源脚本复盘、不做对比分析。\n${REVIEW_ISSUE_RULE}`];
|
||||
confirmRun({
|
||||
title: hasSource ? '复盘评分' : 'AI脚本复盘',
|
||||
desc,
|
||||
@@ -2039,7 +2043,7 @@ async function renderReviews(view) {
|
||||
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>`;
|
||||
if (issues.length) body += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map(issueRowHtml).join('')}</div>`;
|
||||
if (rv.summary) body += `<div class="review-summary">综合评语:${esc(rv.summary)}</div>`;
|
||||
if (rv.content) body += `<div class="md-body" style="border-top:1px solid var(--border);padding-top:10px">${renderMarkdown(rv.content)}</div>`;
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
@@ -2057,7 +2061,7 @@ async function renderReviews(view) {
|
||||
布局同 AI写脚本查看弹窗(rw-stack 横向列):源脚本(可折叠,有原视频时) | AI脚本(版本切换) | 右侧列
|
||||
右侧列头部 tab:脚本诊断(默认) | 分镜提示词
|
||||
—— 诊断内容 = 当前版本 AI 脚本最新复盘(script_review type=ai 优先;compare 取 AI 视角总分并带源对比分)
|
||||
—— 分镜内容 = 该视频下 storyboard_log 中属当前版本的记录 + 生成/重新生成入口(仅参考选题脚本;原创选题无视频载体不支持)
|
||||
—— 分镜内容 = storyboard_log 中属当前版本的记录 + 生成/重新生成入口(09-10 起:原创选题无视频载体时改按 rewrite_id 关联,同样支持)
|
||||
—— 数据源:/api/dsh/video-script + /api/dsh/video-detail(参考选题)/ /api/dsh/custom-script(原创选题)
|
||||
+ /api/dsh/review-full?rewriteId=(当前版本诊断)+ /api/dsh/storyboards?videoId=(分镜) */
|
||||
const DIAG_DIMS = ['创意看点', '设定承接', '框架节奏', '爆款开场', '叙事逻辑', '极致事件', '格式规范性', '台词口语化', '内容合规', '教程完整链', '其他'];
|
||||
@@ -2071,6 +2075,32 @@ const diagVisibleDims = (dims) => (diagDimHas(dims, '教程完整链') ? DIAG_DI
|
||||
const diagScoreColor = (v) => (v >= 75 ? '#3a9e6d' : v >= 60 ? '#d99a2b' : '#d9534f');
|
||||
const diagFmtScore = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : '-');
|
||||
|
||||
/* 问题清单字段归一化(09-10 修复):模型输出的 issues_json schema 不稳定——
|
||||
历史同时存在 {sev:'high', issue, fix} 与 {severity:'高', issue, suggestion} 两套写法,
|
||||
前端原按后者严格读取 → severity 取不到 → 严重度全部兜底成绿色(60 分却全绿),
|
||||
且 suggestion 取不到 → 修改建议整段不显示。此处统一归一化,兼容两种字段与中英取值。 */
|
||||
const normIssue = (it) => {
|
||||
const o = it && typeof it === 'object' ? it : {};
|
||||
const pick = (...ks) => { for (const k of ks) { if (o[k] != null && String(o[k]).trim() !== '') return String(o[k]).trim(); } return ''; };
|
||||
const raw = pick('severity', 'sev', 'level', '严重度').toLowerCase();
|
||||
return {
|
||||
level: /高|high|^h$|p0|严重/.test(raw) ? '高' : /中|mid|medium|^m$|p1/.test(raw) ? '中' : '低',
|
||||
text: pick('issue', 'desc', 'text', '问题'),
|
||||
fix: pick('suggestion', 'fix', 'suggest', '建议', '修改建议'),
|
||||
dim: pick('dim', 'dimension', '维度'),
|
||||
loc: pick('loc', 'position', '定位'),
|
||||
};
|
||||
};
|
||||
const issueLevelColor = (lv) => (lv === '高' ? '#d9534f' : lv === '中' ? '#d99a2b' : '#3a9e6d');
|
||||
/* 复盘落库 issues_json 的字段契约(09-10):作为 prompt 后缀发给模型,从源头避免 schema 漂移 */
|
||||
const REVIEW_ISSUE_RULE = '【落库字段要求】issues_json 必须是数组,每条固定为 {"severity":"高|中|低","dim":"维度名","loc":"定位(场次/时间码)","issue":"问题描述","suggestion":"修改建议","step":"环节(S5-S11)"};severity 只能取中文「高/中/低」,禁止写成英文 high/medium/low,也禁止改用 sev/level 等其它字段名——否则工作台无法按严重度着色(会全部显示成绿色),修改建议也不会显示。';
|
||||
/* 单条问题渲染:严重度(色) + [所属维度] + 问题描述 + →修改建议 */
|
||||
const issueRowHtml = (raw) => {
|
||||
const it = normIssue(raw);
|
||||
const dimTag = it.dim ? `<span class="dim" style="flex:none">[${esc(it.dim)}]</span>` : '';
|
||||
return `<div class="issue-row"><span style="color:${issueLevelColor(it.level)};font-weight:600;flex:none">${esc(it.level)}</span>${dimTag}<span style="flex:1;min-width:120px">${esc(it.text)}</span>${it.fix ? `<span class="dim" style="flex:1;min-width:120px">→ ${esc(it.fix)}</span>` : ''}</div>`;
|
||||
};
|
||||
|
||||
/* 分镜文本渲染:按「段 N(起-止 秒)|标题」分块(继承原 AI写脚本查看弹窗逻辑;分镜时间戳分段格式) */
|
||||
function renderSbBlocks(text) {
|
||||
const blocks = String(text || '').split(/\n\s*---+\s*\n/).map((b) => b.trim()).filter(Boolean);
|
||||
@@ -2101,7 +2131,7 @@ async function reportFullReview(reviewId) {
|
||||
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>`;
|
||||
if (issues.length) body += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map(issueRowHtml).join('')}</div>`;
|
||||
if (rv.summary) body += `<div class="review-summary">综合评语:${esc(rv.summary)}</div>`;
|
||||
if (rv.content) body += `<div class="md-body" style="border-top:1px solid var(--border);padding-top:10px">${renderMarkdown(rv.content)}</div>`;
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
@@ -2148,8 +2178,11 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
const hasSource = vid > 0 && !!source;
|
||||
let stab = 'diag'; /* 右侧 tab:diag=脚本诊断(默认)/ sb=分镜提示词 */
|
||||
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) || []).slice().reverse(); /* 同版本条:旧→新,末位=最新分镜 */
|
||||
/* 09-10:原创选题(vid=0)改按 rewriteId 查分镜——原逻辑只按 videoId,导致无视频载体的脚本永远查不到分镜、也没有生成入口 */
|
||||
const sbQueryOf = (curId) => (vid > 0 ? 'videoId=' + vid : 'rewriteId=' + encodeURIComponent(curId));
|
||||
const fetchSb = async (curId) => ((await api('/api/dsh/storyboards?' + sbQueryOf(curId)).catch(() => ({ items: [] }))) || {}).items || [];
|
||||
const stb = { items: await fetchSb((rewrites[verIdx] || {}).id) };
|
||||
let storyboards = ((stb && stb.items) || []).slice().reverse(); /* 同版本条:旧→新,末位=最新分镜 */
|
||||
const visibleSbOf = (curId) => storyboards.filter((s) => s.rewrite_id == null || String(s.rewrite_id) === String(curId));
|
||||
|
||||
/* 复盘任务触发:有源脚本 → 三模式(AI/源/对比);无源 → 独立分析仅评 AI 脚本(同脚本诊断页行按钮口径) */
|
||||
@@ -2158,9 +2191,9 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
try {
|
||||
const [desc, prompt] = hasSource
|
||||
? ['将对该 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。',
|
||||
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。`]
|
||||
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。\n${REVIEW_ISSUE_RULE}`]
|
||||
: ['将对该 AI 脚本做质量复盘评分(该脚本无原视频脚本,仅评 AI 脚本:独立分析模式·10 维评分制)。',
|
||||
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做质量复盘评分(独立分析模式·10 维评分制)。该脚本无原视频脚本,只评 AI 脚本本身,不做源脚本复盘、不做对比分析。`];
|
||||
`${SKILL_HINT_REVIEW}\n请对这条 AI 脚本做质量复盘评分(独立分析模式·10 维评分制)。该脚本无原视频脚本,只评 AI 脚本本身,不做源脚本复盘、不做对比分析。\n${REVIEW_ISSUE_RULE}`];
|
||||
confirmRun({ title: hasSource ? '复盘评分' : 'AI脚本复盘', desc, prompt, label: '复盘指令', skills: ['脚本复盘'], btn });
|
||||
} finally { delete btn.dataset.lock; }
|
||||
};
|
||||
@@ -2170,7 +2203,10 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
title: regenerate ? '重新生成分镜' : '生成分镜',
|
||||
desc: '将基于该 AI 脚本(AI 创作的故事脚本),展开成 AI 分镜提示词(按分镜技能生成镜头级描述)。',
|
||||
target: `《${title}》`,
|
||||
prompt: `${SKILL_HINT_STORYBOARD}\n请将视频《${title}》的 AI 脚本${regenerate ? '重新' : ''}展开成 AI 分镜提示词(F4 生成分镜,镜头级描述)。`,
|
||||
/* 09-10:补落库指令——此前 prompt 只让"产出分镜",未要求写 storyboard_log,
|
||||
入库全靠模型自由发挥(故仅 3 条记录、且原创选题因无 video_id 无从关联)。
|
||||
现明确 video_id / rewrite_id,原创选题(vid=0)靠 rewrite_id 关联即可挂分镜。 */
|
||||
prompt: `${SKILL_HINT_STORYBOARD}\n请将视频《${title}》的 AI 脚本${regenerate ? '重新' : ''}展开成 AI 分镜提示词(F4 生成分镜,镜头级描述)。\n\n【任务完成后必须执行】将分镜提示词全文写入工作台数据库 storyboard_log 表(数据库在本会话 cwd 下,文件名 mcn-plugin.db),字段:video_id=${vid > 0 ? vid : 'null'}、rewrite_id=${(curOf() || {}).id || ''}、script_text=分镜提示词全文、status='done'。(用 node:sqlite 或 sqlite3 写入;表不存在则创建)`,
|
||||
label: '分镜指令',
|
||||
skills: ['短视频分镜脚本'],
|
||||
btn,
|
||||
@@ -2194,7 +2230,7 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
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>`;
|
||||
if (issues.length) html += `<div class="review-issues"><div class="rw-pane-title">问题清单</div>${issues.map(issueRowHtml).join('')}</div>`;
|
||||
if (rv.summary) html += `<div class="review-summary">综合评语:${esc(rv.summary)}</div>`;
|
||||
html += `<div><a class="link" data-review="${rv.id}">查看完整复盘报告 →</a></div>`;
|
||||
return html;
|
||||
@@ -2283,12 +2319,10 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
} else if (visibleSb.length) {
|
||||
sideBody = sb ? renderSbBlocks(sb.script_text) : '' /* R51:空态提示清空——不再显示「(暂无分镜提示词)」 */;
|
||||
sideFoot = verCtrl('s', sbIdx, visibleSb.length, '分镜', '重新生成', sb ? sb.created_time : '');
|
||||
} else if (vid > 0) {
|
||||
} else {
|
||||
/* 09-10:移除 vid>0 限制——原创选题(无视频载体)同样可生成分镜,分镜按 rewrite_id 关联 */
|
||||
sideBody = '' /* R51:空态提示清空——不再显示「(暂无分镜提示词)」 */;
|
||||
sideFoot = `<div class="rw-sec-foot"><button class="btn btn-sm btn-view" data-gen-sb>${svgIcon('zap', 14, 'currentColor')} 生成分镜</button></div>`;
|
||||
} else {
|
||||
sideBody = '<div class="diag-empty">该脚本为原创选题(无关联视频),暂不支持分镜提示词</div>';
|
||||
sideFoot = '';
|
||||
}
|
||||
return { sideBody, sideFoot };
|
||||
}
|
||||
@@ -2344,6 +2378,8 @@ async function openScriptModal({ rewriteId, videoId } = {}) {
|
||||
[...sideSec.children].forEach((ch) => { if (ch.classList && ch.classList.contains('rw-sec-foot')) ch.remove(); });
|
||||
if (bodyEl) bodyEl.innerHTML = '<div class="dim" style="padding:12px 2px">加载中…</div>';
|
||||
const token = ++sideToken;
|
||||
/* 原创选题(vid=0)分镜按 rewrite_id 查,切版本必须重查,否则会显示上一版本的分镜 */
|
||||
if (!(vid > 0)) storyboards = (await fetchSb(c.id)).slice().reverse();
|
||||
const { sideBody, sideFoot } = await sideContent(c);
|
||||
if (!mask.isConnected || token !== sideToken) return; /* 弹窗已关闭 / 期间又切了 tab → 丢弃过期结果 */
|
||||
if (bodyEl) bodyEl.innerHTML = sideBody;
|
||||
|
||||
@@ -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=20260904i">
|
||||
<link rel="stylesheet" href="style.css?v=20260910c">
|
||||
<script src="vendor/echarts.min.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
@@ -41,8 +41,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=20260904i"></script>
|
||||
<script src="app.js?v=20260910b"></script>
|
||||
<script src="help-guide.js?v=20260904e"></script>
|
||||
<script src="data-pages.js?v=20260904v"></script>
|
||||
<script src="data-pages.js?v=20260910d"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -653,7 +653,7 @@ td.td-title { max-width: 240px; }
|
||||
.score-box { flex: 1; padding: 10px; border: 1px solid var(--border); border-radius: 8px; }
|
||||
.score-box-title { font-weight: 600; margin-bottom: 8px; }
|
||||
.review-issues { margin-bottom: 10px; }
|
||||
.issue-row { display: flex; gap: 6px; font-size: 14px; margin-bottom: 4px; }
|
||||
.issue-row { display: flex; gap: 6px; font-size: 14px; margin-bottom: 4px; flex-wrap: wrap; } /* 09-10:问题/建议较长时换行,避免挤压成一行读不清 */
|
||||
.review-summary { margin-bottom: 10px; font-size: 15px; }
|
||||
.diag-empty { color: var(--dim); font-size: 14px; padding: 14px 2px 10px; line-height: 1.6; } /* AI脚本诊断弹窗:该版本未诊断时的占位提示 */
|
||||
|
||||
@@ -678,7 +678,9 @@ td.td-title { max-width: 240px; }
|
||||
.rank-placeholder { border: 1px dashed var(--border); border-radius: 10px; padding: 36px 12px; text-align: center; color: var(--dim); background: var(--panel); font-size: 14px; }
|
||||
.rank-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||
.rank-filter { padding: 3px 6px; font-size: 14px; max-width: 110px; }
|
||||
.rank-date-label { font-size: 14px; color: var(--dim); min-width: 62px; text-align: center; white-space: nowrap; }
|
||||
/* 09-10:显示期区间「08/31 ~ 09/06」替代裸期号,宽度按最长文本留足避免切期时按钮抖动 */
|
||||
.rank-date-label { font-size: 14px; color: var(--dim); min-width: 104px; text-align: center; white-space: nowrap; }
|
||||
.rank-period { min-width: 104px; text-align: center; display: inline-block; white-space: nowrap; }
|
||||
.rank-preview-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--panel); box-shadow: var(--shadow); }
|
||||
.rank-row { display: grid; grid-template-columns: 34px 84px minmax(80px, 1fr) 76px repeat(4, 76px) 72px; align-items: center; gap: 6px; padding: 6px 10px; font-size: 14px; border-bottom: 1px solid var(--border); }
|
||||
/* R49 首页三 tab 行高对齐(09-03):账号周榜行高 40px(功能列按钮撑高),热榜/点赞榜 30.7px——统一数据行 min-height 40px */
|
||||
|
||||
Reference in new issue
Block a user