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
6cb29a9877
commit
c48902f063
58 files changed
+895
-263
No files matched your search
@@ -1,9 +1,11 @@
|
||||
/* build-create-prompts.mjs — 构造 6 个视频的 AI 创作任务 prompt(与前端 AI创作 按钮完全一致:人设卡+视频选题块+创作指令),并提交 /api/run */
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
const { loadConfig } = require('./load-config');
|
||||
|
||||
const DB = path.join(__dirname, 'mcn-plugin.db');
|
||||
const API = 'http://localhost:8900/api/run';
|
||||
const CFG = loadConfig();
|
||||
const DB = path.join(__dirname, CFG.dbName);
|
||||
const API = `http://localhost:${CFG.port}/api/run`;
|
||||
const ACCOUNT_NAME = '俊希';
|
||||
const ACCOUNT_ID = 3;
|
||||
const VIDEO_IDS = [309, 313, 315, 316, 318, 320];
|
||||
@@ -72,7 +74,7 @@ for (const vid of VIDEO_IDS) {
|
||||
请为达人「${ACCOUNT_NAME}」创作一条全新的短视频故事脚本。${personaBlock}${noPersonaNote}${block}${createTail}
|
||||
|
||||
【任务完成后必须执行】
|
||||
将完整脚本保存到工作台数据库 mcn-plugin.db 的 rewrite_log 表(数据库路径:D:\\AgentSkill\\mcn-video-script\\project\\短视频脚本创作\\V1.0\\mcn-work-shop\\mcn-plugin.db),字段:
|
||||
将完整脚本保存到工作台数据库 ${CFG.dbName} 的 rewrite_log 表(数据库路径:${DB},即本会话 cwd 下的 ${CFG.dbName}),字段:
|
||||
- video_id=${v.id}(整数)
|
||||
- aweme_id=${v.aweme_id}
|
||||
- account_id=${ACCOUNT_ID}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"port": 8900,
|
||||
"dbName": "mcn-plugin.db",
|
||||
"outputRoot": "MCNSkill项目",
|
||||
"businessDataDir": ["MCNSkill项目", "三方数据", "红狐数据", "抖音榜单"],
|
||||
"extraRoots": []
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
|
||||
## 触发链路(所有入口共用)
|
||||
|
||||
按钮点击 → 确认弹窗(confirmRun / confirmAiScript / confirmSaveAccount)→ `executeTask()` → `POST /api/run`(server.js 写 automations 表,scheduledAt=now+5s + next_run_at=now+5000,`skills` 数组→`skills_json` 参数级技能挂载)→ 客户端调度器拾取 → 左侧会话栏「mcn-workshop」空间后台会话执行 → 轮询 `/api/run/status`(done/error/review)→ 完成刷新页面。
|
||||
按钮点击 → 确认弹窗(confirmRun / confirmAiScript / confirmSaveAccount)→ `executeTask()` → `POST /api/run`(server.js 写 automations 表,scheduledAt=now+5s + next_run_at=now+5000,`skills` 数组→`skills_json` 参数级技能挂载)→ 客户端调度器拾取 → 左侧会话栏「mcn-work-shop」空间后台会话执行(cwd = 工作台目录,由 `server.js` 从 `ROOT` 动态推导)→ 轮询 `/api/run/status`(done/error/review)→ 完成刷新页面。
|
||||
|
||||
## 功能入口 × 技能调用契约总表
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
# 短视频工作台 UI 规范
|
||||
|
||||
> **定位**:本文件是前端 UI 的**设计规范基线**(配色 / 字体 / 圆角 / 布局 / 组件 / 交互),供新增页面、调整样式时对照,避免重复踩坑。
|
||||
|
||||
---
|
||||
|
||||
## 1. 设计语言
|
||||
|
||||
亮色主题、工具类后台风格。三条主线:
|
||||
|
||||
1. **信息密度优先**——表格/列表是主体,行高紧凑(表格 td `7px 10px`,字号 15px)。
|
||||
2. **克制配色**——蓝(`#2f6fed`)作主色承担交互反馈,橙(`#e8590c`)只用于强调数值/警示,其余靠灰度拉开层次。
|
||||
3. **轻量质感**——圆角 8/12px、阴影 `0 1px 3px rgba(0,0,0,.08)`,不用重投影和渐变。
|
||||
|
||||
---
|
||||
|
||||
## 2. 设计 Token
|
||||
|
||||
### 2.1 色彩
|
||||
|
||||
| 变量 | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| `--bg` | `#f5f6f8` | 页面底色 |
|
||||
| `--panel` | `#ffffff` | 卡片/表格/弹窗面板 |
|
||||
| `--border` | `#e3e6ea` | 统一描边 |
|
||||
| `--text` | `#24292f` | 主文本 |
|
||||
| `--dim` | `#6b7280` | 次要文本、说明、占位 |
|
||||
| `--primary` | `#2f6fed` | 主色(按钮/激活态/链接 hover) |
|
||||
| `--primary-soft` | `#eaf1fe` | 主色浅底(hover 行、激活 tab 底) |
|
||||
| `--accent` | `#e8590c` | 强调(榜单得分、警示) |
|
||||
| `--danger` | `#d93026` | 危险操作(删除) |
|
||||
| `--shadow` | `0 1px 3px rgba(0,0,0,.08)` | 卡片/顶栏阴影 |
|
||||
|
||||
**扩展色(未进变量,直接写在规则里)**
|
||||
|
||||
| 场景 | 值 |
|
||||
|---|---|
|
||||
| 表头底色 | `#f6f8fa` |
|
||||
| 链接蓝 | `#4a90e2` |
|
||||
| 主色 hover 深一档 | `#2a5fd0` |
|
||||
| 图标垂直微调 | `vertical-align: -0.18em` |
|
||||
| 文件类型色 | md `#8250df` / json `#cf222e` / xlsx `#1a7f37` / txt·csv `#57606a` |
|
||||
| 半透明主色底 | `rgba(47,111,237,.07)`(首页统计条)、`.16`(分隔线) |
|
||||
|
||||
**功能图标亮色映射**(未列出的跟随 currentColor):
|
||||
`trending-up` 橙 · `users` 蓝 · `pen` 粉 `#ec4899` · `flask` 青 `#0ea5e9` · `lightbulb` 黄 `#d99a2b` · `refresh` 青绿 `#0f7f8f` · `chart` 玫红 `#d6336c` · `wrench` 灰 `#5b6470`
|
||||
|
||||
### 2.2 字体
|
||||
|
||||
```css
|
||||
font-family: "Microsoft YaHei", "PingFang SC", "Segoe UI", sans-serif;
|
||||
font-size: 16px; /* body 基准 */
|
||||
```
|
||||
|
||||
代码/JSON 预览:`Consolas, monospace`。
|
||||
|
||||
**字号阶梯**(实测归纳,不是硬 token):
|
||||
|
||||
| 级别 | 字号 | 用例 |
|
||||
|---|---|---|
|
||||
| 特大 | 26px / 21px | 首页统计数字、面包屑主标题 |
|
||||
| 标题 | 18px / 17px | 弹窗标题、区块标题、卡片标题 |
|
||||
| 正文 | 16px / 15px | 正文、按钮、表格、输入框 |
|
||||
| 次要 | 14px / 13px | 说明文字、数值列、徽章、tab(小) |
|
||||
|
||||
### 2.3 圆角 / 阴影 / 层级
|
||||
|
||||
| 类型 | 值 | 用例 |
|
||||
|---|---|---|
|
||||
| 圆角 | 6px | 小控件(btn-sm、侧栏切换组、树节点) |
|
||||
| | **8px** | 默认(按钮、输入框、表格容器、信息卡、评分块) |
|
||||
| | 10px | 徽章、榜单容器、卡片 |
|
||||
| | **12px** | 大卡片(首页 data-card、func-card、统计条) |
|
||||
| | 14px | 弹窗 |
|
||||
| 阴影 | `var(--shadow)` | 卡片、顶栏 |
|
||||
| | `0 8px 30px rgba(0,0,0,.18)` | 弹窗 |
|
||||
| | `0 4px 12px rgba(47,111,237,.15)` | 卡片 hover(主色扩散) |
|
||||
| z-index | 10 顶栏 / 100 弹窗遮罩 / 200 Toast | 表头 sticky 为 1 |
|
||||
|
||||
### 2.4 间距
|
||||
|
||||
没有 token 化,实际使用序列:**2 / 4 / 6 / 8 / 10 / 12 / 14 / 16 / 18 / 20 / 24px**。常用组合:
|
||||
|
||||
- 卡片内边距 `16px 18px`;信息卡 `14px 16px`;表格单元 `7px 10px`;表头 `8px 10px`
|
||||
- 页面外边距 `20px 24px`;首页/详情内容 `24px`;区块间距 14~24px
|
||||
- 工具条内部 gap 8~10px;表单行 gap 10px
|
||||
|
||||
---
|
||||
|
||||
## 3. 布局框架
|
||||
|
||||
```
|
||||
┌─ header.topbar(55px,sticky,白底 + 底部 1px 边框 + shadow,z-index 10)
|
||||
│ brand(logo) · crumbs(面包屑) · top-actions(首页/帮助/设置,右侧)
|
||||
├─ main#view height: calc(100vh - 55px) overflow: auto ← 唯一滚动容器
|
||||
│ .home(max-width 960) / .page(max-width 1440)
|
||||
└─ 弹窗层 .modal-mask(fixed inset 0,z-index 100)
|
||||
```
|
||||
|
||||
**滚动(坑点,勿改)**
|
||||
|
||||
| 规则 | 原因 |
|
||||
|---|---|
|
||||
| `html, body { overflow: hidden }` | 消除顶栏 1px 边框引发的第二层滚动条 |
|
||||
| `main#view { height: calc(100vh - 55px) }` | **55px 是实测值**(padding 20 + 行高 34 + 边框 1)。写成 54px 底部会溢出 1px,触发第二层滚动条 |
|
||||
| 长内容滚动交给内部容器(`.rw-sec-body`、`.rw-content`、`.file-tree`) | 保持页面级不滚 |
|
||||
|
||||
**内容宽度**:首页 `.home` max-width **960px**;功能页 `.page` max-width **1440px**(padding 20 24);弹窗 `.rw-modal` 宽 `min(1600px, 100% - 100px)`、高 `calc(100vh - 60px)`。
|
||||
|
||||
---
|
||||
|
||||
## 4. 组件规范
|
||||
|
||||
### 4.1 按钮
|
||||
|
||||
| 类 | 外观 | 用例 |
|
||||
|---|---|---|
|
||||
| `.btn` | 白底 + border + `6px 14px` + r8 + 15px;hover 边框/文字变 primary;`:disabled` opacity .5 | 默认 |
|
||||
| `.btn-primary` | primary 底白字;hover `#2a5fd0` | 主行动 |
|
||||
| `.btn-ghost` | 透明底 + border | 次要/弹窗内 |
|
||||
| `.btn-danger` | danger 系 | 删除 |
|
||||
| `.btn-sm` | `3px 10px` / 14px / r6 | 表格功能列 |
|
||||
| `.btn-icon` | `5px 9px`,纯图标 | 图标按钮 |
|
||||
| `.btn-busy` | primary-soft 底 + primary 字 + opacity .8,内含图标旋转动画 | 异步进行中 |
|
||||
| `.btn-import` | 白底 + `#d0d4da` 边 + `#495057` 字 | **保存类**(浅灰底深字) |
|
||||
| `.btn-view` | 白底 + primary 边 + primary 字 | **查看类** |
|
||||
|
||||
> 功能列成对出现时:**保存=浅灰底深字(.btn-import),查看=白底蓝字(.btn-view)**,靠底色深浅区分而非都用蓝。
|
||||
|
||||
顶栏三按钮用 `.ghost`(白底 + border,`6px 14px`,r8)。
|
||||
|
||||
### 4.2 输入框
|
||||
|
||||
`.input`(`6px 10px`,min-width 200px)· `.input-select`(`6px 8px`,筛选下拉更窄)。均 1px border + r8 + 15px + 白底。
|
||||
|
||||
### 4.3 表格
|
||||
|
||||
```css
|
||||
.table-wrap overflow auto + 1px border + r8 + 白底
|
||||
.table width 100%, border-collapse collapse, 15px
|
||||
.table th sticky top 0, 背景 #f6f8fa, 8px 10px, 600, nowrap, z-index 1
|
||||
.table td 7px 10px, nowrap, 底部 1px border
|
||||
tbody tr:hover 背景 var(--primary-soft)
|
||||
```
|
||||
|
||||
| 修饰类 | 作用 |
|
||||
|---|---|
|
||||
| `.num`(td/th) | **右对齐** + `font-size: 13px` + `font-variant-numeric: tabular-nums`(等宽数字,必须加,否则数字跳动) |
|
||||
| `.th-wide` | 数值列表头 min-width 90px + 右对齐 |
|
||||
| `.sortable` | cursor pointer,hover 变 primary |
|
||||
| `.cell-content` | max-width 220px + 省略号 |
|
||||
| `.cell-loc` | 账号定位列缩窄 110px |
|
||||
| `.th-act` / `.cell-act` | 功能列居中 |
|
||||
| `.title-plain` | 视频标题:默认同正文色,**hover 才变蓝+下划线**(避免整列花掉) |
|
||||
| `.link` / `.link.strong` | `#4a90e2`;`.strong` 加粗 |
|
||||
|
||||
**列宽策略(关键)**
|
||||
|
||||
| 场景 | 做法 |
|
||||
|---|---|
|
||||
| 榜单表格 `.table-rank` | **必须 `table-layout: fixed`**——auto 布局下 100% 表格的富余空间会按比例摊薄,**min-width 压不住**;fixed 下 width 才是硬约束,账号列自动吃剩余宽度 |
|
||||
| 普通数据表格 | auto 布局,靠 `.cell-content` max-width + 省略号收口 |
|
||||
|
||||
**行高对齐(关键)**:榜单三个 tab 的行高必须统一,否则切换时整表跳动——
|
||||
- 表格 `.table-rank td { height: 42px }`(功能列 `.btn-sm` 撑到 42px,其他 tab 原本只有 34.7px)
|
||||
- 首页预览 `.rank-preview-list .rank-row:not(.rank-head) { min-height: 40px }`
|
||||
|
||||
### 4.4 Tab(两套并存,注意覆盖)
|
||||
|
||||
| 位置 | 样式 |
|
||||
|---|---|
|
||||
| 顶栏 | 胶囊式:r8,hover 浅蓝底蓝字,active **蓝底白字 + 600** |
|
||||
| 数据页(**重新定义同名 `.tabs/.tab`**) | 下划线式:无背景无圆角,`border-bottom: 2px transparent` + `margin-bottom: -1px`(压住容器分隔线),active **蓝字 + 蓝色下划线** |
|
||||
|
||||
> ⚠️ 同名类二次定义,**数据页规则在后会覆盖顶栏规则**。新增页面若在下划线式区域用 `.tab`,拿到的是下划线式;需要胶囊式请另起类名。
|
||||
|
||||
榜单 tab `.rank-tab`:r8 8px 0 0(上圆角),active 浅蓝底 + 蓝字 + 底部 2px 蓝线。
|
||||
首页预览里需 `align-self: center`(全局 `.rank-tabs` 用 stretch,会让 tab 组相对兄弟控件偏上约 5px)。
|
||||
|
||||
### 4.5 卡片
|
||||
|
||||
| 类 | 规格 |
|
||||
|---|---|
|
||||
| `.data-card`(首页入口) | r12,`16px 18px`,shadow,hover **上浮** `translateY(-2px)` + 蓝边 + `0 4px 12px rgba(47,111,237,.15)`,transition .15s |
|
||||
| `.info-card`(信息区) | r8,`14px 16px`,margin-bottom 14px;标题 15px/600 + 底部 1px 分隔线 |
|
||||
| `.recent-card`(脚本快捷卡) | r10,`14px 16px`,min-height **172px**(保证网格等高),hover 蓝边 + 淡影 |
|
||||
| `.func-card` | r12,`16px 18px` |
|
||||
|
||||
### 4.6 徽章 / 标签
|
||||
|
||||
`.badge`:`2px 8px`,r10,13px。
|
||||
- `.badge-signed` 浅蓝底蓝字(签约/内部)
|
||||
- `.badge-ext` `#fff4e5` 底 + accent 橙字
|
||||
- `.rw-ver-new` primary 底白字 r9 12px(最新版本标记,附在版本条最右)
|
||||
- `.recent-card-score` 12px/600 r10
|
||||
|
||||
### 4.7 弹窗
|
||||
|
||||
```css
|
||||
.modal-mask fixed inset 0, rgba(0,0,0,.35), flex 居中, z-index 100
|
||||
.modal 白底 r14, 22px 26px, width 520px, max-width 92vw, shadow 0 8px 30px
|
||||
.req-modal 760px × min(840px, 92vh) /* AI创作 */
|
||||
.rw-modal min(1600px, 100% - 100px) × calc(100vh - 60px) /* 脚本对比/查看 */
|
||||
```
|
||||
|
||||
> ⚠️ `.modal-mask[hidden] { display: none }` **必须显式写**——`.modal-mask` 是 `display:flex`,会覆盖 HTML `hidden` 属性导致弹窗关不掉。
|
||||
|
||||
**对比弹窗三段式(`.rw-*`)**:`.rw-stack`(flex row, gap 12)→ `.rw-sec`(flex 1, min-width 0, r8, overflow hidden)→ 内分 `.rw-sec-head`(8px 12px,白底,底部边框)/ `.rw-sec-body`(12px 14px,overflow auto)/ `.rw-sec-foot`(**min-height 46px**,右对齐,顶部边框)。
|
||||
> `.rw-sec-foot` 的 min-height 是为了**空 foot(源脚本列占位)与其他列等高**,不能删。
|
||||
|
||||
### 4.8 Toast
|
||||
|
||||
固定在底部居中(`bottom: 32px`,`translateX(-50%)`),深底 `#24292f` 白字,`10px 18px` r8,z-index 200,**显示 1.8 秒后自动消失**。用于复制成功、保存结果等轻量反馈;失败重试类提示走行内 `.toolbar-note` / `.empty`。
|
||||
|
||||
### 4.9 空状态
|
||||
|
||||
`.empty-state`:纵向居中,`padding: 56px 20px`,gap 10px——图标(dim + opacity .45)→ 标题(17px/600)→ 说明(14px/dim)→ 行动按钮(margin-top 10px)。
|
||||
轻量占位可用 `.empty`(`padding: 30px`,居中,dim)。
|
||||
|
||||
### 4.10 分页 / 批量
|
||||
|
||||
`.pager` 右对齐(gap 12,`padding: 12px 4px`);`.list-footer` 用 space-between 把批量操作条放左、分页放右;批量条 `.list-batchbar` 出现时与分页同行。
|
||||
|
||||
### 4.11 评分条
|
||||
|
||||
`.score-row`:名称 `.score-name` 固定 **88px** → 进度条 `.score-bar`(flex 1,height 6px,r3,底 `#eef1f5`)→ 分值 `.score-val` 固定 **36px** 右对齐。三档宽度固定,保证多行对齐。
|
||||
|
||||
### 4.12 图标
|
||||
|
||||
```js
|
||||
svgIcon(name, size = 16, color) ;生成 class="ic" 的 SVG
|
||||
```
|
||||
|
||||
- Lucide 风格:`viewBox 0 0 24 24`,`stroke-width: 2`,`fill: none`,`stroke: currentColor`
|
||||
- 静态 HTML 用 `<i data-icon="home">` 占位,`initIcons()` 统一替换
|
||||
- `.ic { vertical-align: -0.18em; flex-shrink: 0 }` ——**垂直微调 -0.18em,去掉会和文字基线错开**
|
||||
|
||||
---
|
||||
|
||||
## 5. 关键交互约定
|
||||
|
||||
| 约定 | 说明 |
|
||||
|---|---|
|
||||
| **数值一律右对齐 + 等宽数字** | `text-align: right` + `font-variant-numeric: tabular-nums`,列表头同步右对齐 |
|
||||
| **字段对齐用 grid 列轨道共享** | 账号信息/视频详情的多行字段,用同一个 grid 让列跨行对齐;每行独立 flex 会因内容长短把后续字段顶偏(R44/R45 教训) |
|
||||
| **超宽字段移出共享网格** | 如「账号定位」整行留在网格内会把 4 条轨道撑成等宽(各 232.5px),右侧大片留白 |
|
||||
| **滚动联动单向** | AI 脚本列滚动驱动分镜列对齐,**不做双向耦合**(双向会互相抖动) |
|
||||
| **长文本截断三件套** | `overflow: hidden` + `text-overflow: ellipsis` + `white-space: nowrap`,并挂 `title` 显示全文 |
|
||||
| **hover 才提示可点** | 列表中的标题类文本用 `.title-plain`(默认同正文色,hover 变蓝+下划线),避免整列都是蓝字 |
|
||||
|
||||
---
|
||||
|
||||
## 6. 已知坑与规避
|
||||
|
||||
| # | 坑 | 规避 |
|
||||
|---|---|---|
|
||||
| 1 | `main#view` 写成 `calc(100vh - 54px)` → 底部溢出 1px,出现第二层滚动条 | 用 **55px**(padding 20 + 行高 34 + 边框 1) |
|
||||
| 2 | 榜单表格用 auto 布局 → 列宽被富余空间摊薄,`min-width` 失效 | 加 `.table-rank { table-layout: fixed }` + 显式列宽 |
|
||||
| 3 | 榜单三 tab 行高不一致 → 切换时整表跳动 | 表格 td 固定 42px;首页预览行 min-height 40px |
|
||||
| 4 | `.modal-mask` 的 `display:flex` 覆盖 `hidden` → 弹窗关不掉 | 显式写 `.modal-mask[hidden] { display: none }` |
|
||||
| 5 | 数值列没加 `tabular-nums` → 滚动/翻页时数字左右跳 | 数值列统一加 |
|
||||
| 6 | 图标与文字基线错开 | `.ic` 的 `vertical-align: -0.18em` 不能删 |
|
||||
| 7 | 首页 `.rank-tabs` 相对兄弟控件偏上 ~5px | 预览场景加 `align-self: center; margin-bottom: 0` |
|
||||
@@ -1,7 +1,8 @@
|
||||
// 短视频工作台 - 数据访问层(只读复用业务数据目录)
|
||||
// 数据源:工作台目录 mcn-plugin.db(dsh 库副本,node:sqlite 只读)+ 三方数据目录 抖音榜单/*.json
|
||||
// 与 dsh 原库(~/.dsh/mcn-plugin.db)隔离:两边各访问各的,互不影响
|
||||
// 页面逻辑参照 dsh-plugin-mcn 重新开发,仅复用数据库 schema 与榜单文件格式
|
||||
// 数据源:工作台目录 mcn-plugin.db(node:sqlite 只读)+ 三方数据目录 抖音榜单/*.json
|
||||
// 09-10:dsh 侧已整套下线(~/.dsh/skills/、~/.dsh/mcn-plugin.db、D:\dshworkspace 均不存在),
|
||||
// 工作台 DB 为唯一读写副本,不再有「与 dsh 原库隔离」的双库关系
|
||||
// 页面逻辑参照 dsh-plugin-mcn 重新开发(历史沿革),仅复用数据库 schema 与榜单文件格式
|
||||
'use strict';
|
||||
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
@@ -9,18 +10,19 @@ const path = require('node:path');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
|
||||
// 工作台自有数据库副本(与 dsh 原库隔离)
|
||||
const DSH_DB = path.join(__dirname, 'mcn-plugin.db');
|
||||
// 读取工作台 config.json(load-config.js 唯一入口 + 默认值唯一来源,配置收敛)
|
||||
const { loadConfig } = require('./load-config');
|
||||
const CFG = loadConfig();
|
||||
|
||||
// 榜单目录解析(09-03 定稿:跟随 mcn-data-insight 榜单落盘规范的数据源分层)
|
||||
// 优先级:① 环境变量 MCN_RANKING_DIR 显式覆盖 → ② dsh 部署(技能在 ~/.dsh 下)→ D:\dshworkspace\抖音榜单
|
||||
// → ③ 开发机/用户环境(①③):桌面 MCNSkill项目\三方数据\红狐数据\抖音榜单(运行时解析真实桌面,兼容 OneDrive 重定向)
|
||||
// 工作台自有数据库副本(唯一读写副本)
|
||||
const DSH_DB = path.join(__dirname, CFG.dbName);
|
||||
|
||||
// 榜单目录解析(09-03 定稿)
|
||||
// 优先级:① 环境变量 MCN_RANKING_DIR 显式覆盖 → ② 桌面/<CFG.businessDataDir>(config.json,运行时解析真实桌面,兼容 OneDrive 重定向)
|
||||
function resolveRankingDir() {
|
||||
if (process.env.MCN_RANKING_DIR) return process.env.MCN_RANKING_DIR;
|
||||
const isDshDeploy = __dirname.includes('.dsh');
|
||||
if (isDshDeploy) return 'D:\\dshworkspace\\抖音榜单';
|
||||
const home = os.homedir();
|
||||
const rel = path.join('MCNSkill项目', '三方数据', '红狐数据', '抖音榜单');
|
||||
const rel = path.join(...CFG.businessDataDir);
|
||||
const candidates = [
|
||||
path.join(home, 'Desktop', rel),
|
||||
path.join(home, 'OneDrive', 'Desktop', rel),
|
||||
@@ -295,8 +297,14 @@ function listRewrites(videoId) {
|
||||
return { items: rows };
|
||||
}
|
||||
|
||||
function listStoryboards(videoId) {
|
||||
/* 09-10:原创选题(video_id 为空)原本查不到分镜 → 分镜 tab 无生成入口。
|
||||
现支持按 rewrite_id 查询,使无视频载体的脚本也能挂分镜(storyboard_log 本就有 rewrite_id 列)。 */
|
||||
function listStoryboards(videoId, rewriteId) {
|
||||
const d = getDb(); if (!d) return [];
|
||||
const rid = Number(rewriteId);
|
||||
if (Number.isFinite(rid) && rid > 0 && !(Number(videoId) > 0)) {
|
||||
return d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time, updated_time FROM storyboard_log WHERE rewrite_id=? ORDER BY id DESC`).all(rid);
|
||||
}
|
||||
return d.prepare(`SELECT id, video_id, rewrite_id, script_text, status, created_time, updated_time FROM storyboard_log WHERE video_id=? ORDER BY id DESC`).all(videoId);
|
||||
}
|
||||
|
||||
@@ -428,12 +436,30 @@ function collectCategories(board, files) {
|
||||
return RANK_KEEP.filter((c) => found.has(c));
|
||||
}
|
||||
|
||||
// 期次真实区间:{ date: {start, end} }(周榜=周一~周日;日榜 start=end=当日)
|
||||
// 09-10:UI 改显示「08-31 ~ 09-06」而非裸期号 2026-08-31——期号是周一,只看期号会误以为仅 8/31 单日。
|
||||
// 同一 date 可能有多个文件(week 每赛道一个),取首个即可(区间相同)。
|
||||
function collectRanges(board, files) {
|
||||
const out = {};
|
||||
const seen = new Set();
|
||||
for (const pf of files) {
|
||||
if (seen.has(pf.date)) continue;
|
||||
seen.add(pf.date);
|
||||
try {
|
||||
const d = JSON.parse(fs.readFileSync(rankFilePath(pf.dir, pf.name), 'utf8'));
|
||||
const s = String(d.dateStart || d.date || '').trim();
|
||||
if (s) out[pf.date] = { start: s, end: String(d.dateEnd || d.date || s).trim() };
|
||||
} catch (e) {}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function rankingMeta(board = 'week') {
|
||||
const empty = { dates: [], categories: [] };
|
||||
const empty = { dates: [], categories: [], ranges: {} };
|
||||
const files = listRankFiles(board);
|
||||
if (!files.length) return empty;
|
||||
const dates = [...new Set(files.map((f) => f.date))];
|
||||
return { dates, categories: collectCategories(board, files) };
|
||||
return { dates, categories: collectCategories(board, files), ranges: collectRanges(board, files) };
|
||||
}
|
||||
|
||||
// 榜单数据新鲜度(更新防重依据,09-03 新增):递归扫描 RANKING_DIR(三榜子目录 + 根目录旧平铺)全部 .json,
|
||||
@@ -472,7 +498,7 @@ function parseNum(v) {
|
||||
}
|
||||
|
||||
function getRanking({ board = 'week', date = '', category = '', sortBy = '', order = '', page = 1, pageSize = 20 } = {}) {
|
||||
const empty = { dates: [], categories: [], category: '', date: '', total: 0, items: [] };
|
||||
const empty = { dates: [], categories: [], ranges: {}, category: '', date: '', total: 0, items: [] };
|
||||
const files = listRankFiles(board);
|
||||
if (!files.length) return empty;
|
||||
const dates = [...new Set(files.map((f) => f.date))];
|
||||
@@ -524,7 +550,7 @@ function getRanking({ board = 'week', date = '', category = '', sortBy = '', ord
|
||||
}
|
||||
return { ...row, inDb: !!hit, dbId: hit ? hit.id : null };
|
||||
});
|
||||
return { dates, categories, category, date: selDate, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items: pageItems };
|
||||
return { dates, categories, ranges: collectRanges(board, files), category, date: selDate, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items: pageItems };
|
||||
}
|
||||
|
||||
/* ================= AI写脚本:原创选题(无视频载体)================= */
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
// 输出工作台端口(供 start.bat 读取)
|
||||
// 09-10:bat 内嵌复杂 JS(-e "console.log(require('./load-config')...)" )会被 cmd 解析截断报错,
|
||||
// 改为独立脚本,bat 只执行简单命令 "%NODE_EXE%" get-port.js,避免引号/括号嵌套问题。
|
||||
console.log(require('./load-config').loadConfig().port);
|
||||
@@ -0,0 +1,24 @@
|
||||
// 工作台 config.json 读取工具(唯一入口 + 默认值唯一来源)
|
||||
// 所有工作台脚本(server.js / dsh-data.js / *.cjs)统一通过它读配置,
|
||||
// 调整端口 / DB 名 / 目录只需改 config.json 一处;config 缺失或损坏时回退下方默认值。
|
||||
'use strict';
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const DEFAULTS = {
|
||||
port: 8900,
|
||||
dbName: 'mcn-plugin.db',
|
||||
outputRoot: 'MCNSkill项目',
|
||||
businessDataDir: ['MCNSkill项目', '三方数据', '红狐数据', '抖音榜单'],
|
||||
extraRoots: [],
|
||||
};
|
||||
|
||||
function loadConfig() {
|
||||
try {
|
||||
const p = path.join(__dirname, 'config.json');
|
||||
if (fs.existsSync(p)) return Object.assign({}, DEFAULTS, JSON.parse(fs.readFileSync(p, 'utf8')));
|
||||
} catch (e) { /* 配置损坏回退默认 */ }
|
||||
return Object.assign({}, DEFAULTS);
|
||||
}
|
||||
|
||||
module.exports = { DEFAULTS, loadConfig };
|
||||
@@ -7,7 +7,9 @@ const TASKS = [
|
||||
{ videoId: 318, id: 'automation-1788258530542' },
|
||||
{ videoId: 320, id: 'automation-1788258533562' },
|
||||
];
|
||||
const API = 'http://localhost:8900/api/run/status?id=';
|
||||
const { loadConfig } = require('./load-config');
|
||||
const CFG = loadConfig();
|
||||
const API = `http://localhost:${CFG.port}/api/run/status?id=`;
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
|
||||
const stateOf = new Map(TASKS.map((t) => [t.videoId, 'queued']));
|
||||
|
||||
@@ -7,11 +7,14 @@ const TASKS = [
|
||||
{ rw: 27, id: 'automation-1788251464600' },
|
||||
{ rw: 26, id: 'automation-1788251467630' },
|
||||
];
|
||||
const { loadConfig } = require('./load-config');
|
||||
const CFG = loadConfig();
|
||||
const API = `http://localhost:${CFG.port}/api/run/status?id=`;
|
||||
const state = {};
|
||||
TASKS.forEach((t) => (state[t.rw] = 'pending'));
|
||||
const status = async (id) => {
|
||||
try {
|
||||
const r = await fetch('http://localhost:8900/api/run/status?id=' + encodeURIComponent(id));
|
||||
const r = await fetch(API + encodeURIComponent(id));
|
||||
const d = await r.json();
|
||||
return d.state || 'unknown';
|
||||
} catch { return 'unknown'; }
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// 短视频工作台 - 本地 Web 服务(零依赖 Node 实现)
|
||||
// 启动: node server.js [端口] → http://localhost:8900(默认;可传参指定端口,如 node server.js 9000)
|
||||
// 功能: 技能导航 + 产出内容浏览(默认读桌面 MCNSkill项目)+ dsh 数据功能页(账号/视频/脚本/复盘/周榜,只读)
|
||||
// 功能: 技能导航 + 产出内容浏览(默认读桌面 <outputRoot>,根目录名由 config.json 决定)+ dsh 数据功能页(账号/视频/脚本/复盘/周榜,只读)
|
||||
'use strict';
|
||||
|
||||
const http = require('http');
|
||||
@@ -11,14 +11,24 @@ const { execFileSync } = require('child_process');
|
||||
const { URL } = require('url');
|
||||
const dsh = require('./dsh-data');
|
||||
const { parseAttachment } = require('./scripts/attachment-parser');
|
||||
const { loadConfig } = require('./load-config');
|
||||
|
||||
// 09-01 默认端口由 8899 改为 8900:本机 8899 被小米 PC 管家 MiPCAudio.exe 系统服务占用(0.0.0.0 监听且自动复活),
|
||||
// 此时 127.0.0.1:8899 绑定不生效 → 默认直接起在 8900;仍可 `node server.js <端口>` 显式换端口
|
||||
const PORT_BASE = parseInt(process.argv[2], 10) || 8900;
|
||||
const ROOT = __dirname;
|
||||
const PUBLIC_DIR = path.join(ROOT, 'public');
|
||||
const CONFIG_FILE = path.join(ROOT, 'config.json');
|
||||
|
||||
// 09-10 配置收敛:工作台设定(端口/DB名/产出根/榜单目录)统一收进 config.json,代码层一律从 CFG 读,
|
||||
// 避免调整时到处改(config 缺失/损坏时回退 load-config.cjs 默认值)。argv 端口优先级仍高于 config。
|
||||
const CFG = loadConfig();
|
||||
const PORT_BASE = parseInt(process.argv[2], 10) || CFG.port;
|
||||
|
||||
// 09-10 路径治本(配套「仓库特征判定」):工作台运行时路径一律从 ROOT 动态推导,禁止写死盘符。
|
||||
// 根因:原写死盘符指向仓库外的固定目录,仓库迁移后该目录不存在 → 浏览器锁失效、会话 cwd 悬空。
|
||||
const BROWSER_LOCK_FILE = path.join(ROOT, '.browser-lock'); // 浏览器互斥锁文件(与工作台同目录)
|
||||
const SESSION_CWD = ROOT; // 工作台触发的 AI 会话 cwd(分组名 = mcn-work-shop)
|
||||
|
||||
// 09-07:prompt 模板文件化(prompts/*.md)——规则迭代只改文件不碰代码;文件缺失/损坏回退 null,调用方自行兜底。
|
||||
// 模板正文取 ==PROMPT== 与 ==CHANGELOG== 之间(头部说明/文末变更记录不发给模型)
|
||||
const PROMPTS_DIR = path.join(ROOT, 'prompts');
|
||||
@@ -45,22 +55,14 @@ function loadPrompt(name) {
|
||||
} catch (e) { return null; }
|
||||
}
|
||||
|
||||
// ---- 默认根目录:桌面/MCNSkill项目 ----
|
||||
const DEFAULT_ROOT = path.join(os.homedir(), 'Desktop', 'MCNSkill项目');
|
||||
// ---- 默认根目录:桌面/<CFG.outputRoot> ----
|
||||
const DEFAULT_ROOT = path.join(os.homedir(), 'Desktop', CFG.outputRoot);
|
||||
|
||||
// ---- 配置读写(自定义根目录列表)----
|
||||
function loadConfig() {
|
||||
try {
|
||||
if (fs.existsSync(CONFIG_FILE)) {
|
||||
const c = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
||||
if (Array.isArray(c.extraRoots)) return { extraRoots: c.extraRoots };
|
||||
}
|
||||
} catch (e) { /* 忽略损坏配置 */ }
|
||||
return { extraRoots: [] };
|
||||
}
|
||||
|
||||
function saveConfig(cfg) {
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2), 'utf8');
|
||||
// ---- 配置读写(自定义根目录列表;loadConfig 来自 load-config.js,每次读盘取最新值)----
|
||||
function saveConfig(patch) {
|
||||
// 合并写盘:保留 config.json 里的其它字段(port/dbName/outputRoot/businessDataDir),只更新传入的键
|
||||
const merged = Object.assign({}, loadConfig(), patch);
|
||||
fs.writeFileSync(CONFIG_FILE, JSON.stringify(merged, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
// ---- 允许读取的根目录白名单 ----
|
||||
@@ -338,7 +340,7 @@ function createOnceAutomation(name, prompt, skillsArr, connectorIdsArr) {
|
||||
try {
|
||||
const now = Date.now();
|
||||
const id = 'automation-' + now;
|
||||
const cwd = 'D:\\AgentSkill\\mcn-workshop'; // 工作台触发的会话归入 mcn-workshop 空间分组
|
||||
const cwd = SESSION_CWD; // 工作台触发的会话归入 mcn-work-shop 空间分组(由 ROOT 动态推导)
|
||||
const d = new Date(now + 5 * 1000);
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
@@ -490,7 +492,7 @@ const server = http.createServer((req, res) => {
|
||||
|
||||
// ---------- 数据功能(只读工作台自有数据库副本) ----------
|
||||
const dshOk = (code, obj) => sendJSON(code, { ok: true, ...obj });
|
||||
const dshGuard = (res2) => { if (!dsh.dbAvailable()) { sendJSON(200, { ok: false, error: '未检测到工作台数据库(mcn-plugin.db),本功能不可用' }); return true; } return false; };
|
||||
const dshGuard = (res2) => { if (!dsh.dbAvailable()) { sendJSON(200, { ok: false, error: '未检测到工作台数据库(' + CFG.dbName + '),本功能不可用' }); return true; } return false; };
|
||||
const qInt = (k, def) => { const v = Number(q.get(k)); return Number.isFinite(v) ? v : def; };
|
||||
|
||||
if (p === '/api/dsh/meta') {
|
||||
@@ -627,7 +629,7 @@ const server = http.createServer((req, res) => {
|
||||
}
|
||||
if (p === '/api/dsh/storyboards') {
|
||||
if (dshGuard()) return;
|
||||
return dshOk(200, { items: dsh.listStoryboards(qInt('videoId', 0)) });
|
||||
return dshOk(200, { items: dsh.listStoryboards(qInt('videoId', 0), qInt('rewriteId', 0)) });
|
||||
}
|
||||
if (p === '/api/dsh/review-detail') {
|
||||
if (dshGuard()) return;
|
||||
@@ -717,7 +719,7 @@ const server = http.createServer((req, res) => {
|
||||
// 09-01 并发方案A:浏览器类任务(账号信息/视频列表/采集/导入)自动注入浏览器锁约束——
|
||||
// 多个 AI 会话任务并行共享同一 9223 Chrome 实例,必须互斥使用,否则标签页互相导航抢占
|
||||
const BROWSER_HINT = /账号信息|视频列表|保存并分析|采集|导入账号|网页采集|浏览器/;
|
||||
const BROWSER_LOCK = 'D:\\\\AgentSkill\\\\mcn-workshop\\\\.browser-lock';
|
||||
const BROWSER_LOCK = BROWSER_LOCK_FILE;
|
||||
let finalPrompt = prompt.trim();
|
||||
if (BROWSER_HINT.test(finalPrompt)) {
|
||||
finalPrompt += `
|
||||
@@ -733,7 +735,7 @@ const server = http.createServer((req, res) => {
|
||||
const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'));
|
||||
const now = Date.now();
|
||||
const id = 'automation-' + now;
|
||||
const cwd = 'D:\\AgentSkill\\mcn-workshop'; // 工作台触发的会话归入 mcn-workshop 空间分组
|
||||
const cwd = SESSION_CWD; // 工作台触发的会话归入 mcn-work-shop 空间分组(由 ROOT 动态推导)
|
||||
const d = new Date(Date.now() + 5 * 1000); // 未来 5 秒(08-31 根因:客户端只对未来 scheduledAt 补算 next_run_at;写 now=过去时间→不补算→调度器扫不到→卡死)
|
||||
const pad = (n) => String(n).padStart(2, '0');
|
||||
const scheduledAt = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
|
||||
@@ -798,7 +800,7 @@ const server = http.createServer((req, res) => {
|
||||
let contentText = '', personaText = '', videoRefText = '';
|
||||
try {
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true });
|
||||
const db = new DatabaseSync(path.join(ROOT, CFG.dbName), { readOnly: true });
|
||||
const acc = db.prepare('SELECT * FROM hot_accounts WHERE account_name=? LIMIT 1').get(accountName);
|
||||
if (acc) {
|
||||
contentText = String(acc.content || '').slice(0, 500);
|
||||
@@ -873,7 +875,7 @@ const server = http.createServer((req, res) => {
|
||||
let personaText = '', contentText = '';
|
||||
try {
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { readOnly: true });
|
||||
const db = new DatabaseSync(path.join(ROOT, CFG.dbName), { readOnly: true });
|
||||
const acc = db.prepare('SELECT * FROM hot_accounts WHERE account_name=? LIMIT 1').get(accountName);
|
||||
if (acc) {
|
||||
contentText = String(acc.content || '').slice(0, 300);
|
||||
@@ -1019,7 +1021,7 @@ const server = http.createServer((req, res) => {
|
||||
const accountDir = resolveAccountDir(account, root);
|
||||
if (!accountDir) return sendErr(404, '账号产出目录不存在: ' + account);
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'));
|
||||
const db = new DatabaseSync(path.join(ROOT, CFG.dbName));
|
||||
const res = importAccountIntoDb(db, account, accountDir);
|
||||
db.close();
|
||||
log('分析产物入库: ' + account + ' → ' + JSON.stringify(res));
|
||||
@@ -1042,12 +1044,14 @@ const server = http.createServer((req, res) => {
|
||||
});
|
||||
});
|
||||
|
||||
// ---- 会话自动清理:mcn-workshop 空间超 6 小时无活动的已结束会话自动清除 ----
|
||||
// 09-01 用户需求:工作台触发(cwd=D:\AgentSkill\mcn-workshop)的 AI 会话任务,超过 6 小时自动清除
|
||||
// 判定:cwd 含 mcn-workshop + status=completed(执行中一律不删)+ 未删过 + 最后活动时间超过 6 小时
|
||||
// ---- 会话自动清理:mcn-work-shop 空间超 6 小时无活动的已结束会话自动清除 ----
|
||||
// 09-01 用户需求:工作台触发(cwd = 工作台目录 SESSION_CWD)的 AI 会话任务,超过 6 小时自动清除
|
||||
// 09-10 路径治本:cwd 由写死盘符改为 ROOT 动态推导(<技能根>/mcn-work-shop);
|
||||
// 清理模式放宽为 '%mcn-work%',同时覆盖历史会话(旧 mcn-workshop)与新建会话(mcn-work-shop)
|
||||
// 判定:cwd 含 mcn-work + status=completed(执行中一律不删)+ 未删过 + 最后活动时间超过 6 小时
|
||||
// 方式:软删除(置 deleted_at),客户端左侧会话栏按 deleted_at 过滤即不再显示;可逆安全
|
||||
const SESSION_TTL_MS = 6 * 3600 * 1000; // 6 小时
|
||||
const SESSION_CWD_PATTERN = '%mcn-workshop%'; // 与 /api/run 写入的 cwd 一致
|
||||
const SESSION_CWD_PATTERN = '%mcn-work%'; // 兼容 SESSION_CWD(mcn-work-shop)与历史 cwd(mcn-workshop)
|
||||
const SESSION_CLEAN_INTERVAL_MS = 60 * 60 * 1000; // 每小时检查一次
|
||||
|
||||
function cleanupExpiredSessions() {
|
||||
@@ -1064,7 +1068,7 @@ function cleanupExpiredSessions() {
|
||||
AND COALESCE(last_activity_at, updated_at, created_at, 0) < ?
|
||||
`).run(now, SESSION_CWD_PATTERN, cutoff);
|
||||
db.close();
|
||||
if (Number(r.changes) > 0) log(`会话自动清理:已清除 mcn-workshop 超 6 小时无活动的已完成会话 ${r.changes} 条`);
|
||||
if (Number(r.changes) > 0) log(`会话自动清理:已清除 mcn-work-shop 超 6 小时无活动的已完成会话 ${r.changes} 条`);
|
||||
} catch (e) {
|
||||
console.error('会话自动清理失败:', e.message);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
@echo off
|
||||
rem MCN工作台 启动脚本(Windows 双击运行)
|
||||
rem 优先使用 WorkBuddy managed node,回退系统 node
|
||||
rem 优先使用 WorkBuddy managed node(自动探测版本目录,避免写死版本号),回退系统 node
|
||||
|
||||
setlocal
|
||||
cd /d "%~dp0"
|
||||
|
||||
set "NODE_EXE=C:\Users\maidou\.workbuddy\binaries\node\versions\22.22.2\node.exe"
|
||||
if exist "%NODE_EXE%" goto run
|
||||
rem 09-10 修复:原写死 ...\versions\22.22.2\node.exe 已不存在(实际为 22.22.2-3)→ 改为遍历 versions 自动探测,版本升级免改脚本
|
||||
set "NODE_EXE="
|
||||
for /d %%v in ("%USERPROFILE%\.workbuddy\binaries\node\versions\*") do (
|
||||
if exist "%%v\node.exe" set "NODE_EXE=%%v\node.exe"
|
||||
)
|
||||
if defined NODE_EXE goto run
|
||||
|
||||
where node >nul 2>nul
|
||||
if %errorlevel%==0 (
|
||||
@@ -20,7 +24,10 @@ exit /b 1
|
||||
|
||||
:run
|
||||
echo 正在启动 MCN工作台...
|
||||
start "" "%NODE_EXE%" server.js 8900
|
||||
rem 09-10 配置收敛:端口从 config.json 读取(不再写死 8900),改 config.json 即可,无需改本脚本
|
||||
for /f "usebackq delims=" %%p in (`"%NODE_EXE%" get-port.js`) do set "PORT=%%p"
|
||||
if not defined PORT set "PORT=8900"
|
||||
start "" "%NODE_EXE%" server.js
|
||||
timeout /t 1 /nobreak >nul
|
||||
start http://localhost:8900
|
||||
start http://localhost:%PORT%
|
||||
endlocal
|
||||
@@ -2,9 +2,11 @@
|
||||
用法: node submit-review-tasks.cjs [rewriteId1,rewriteId2,...] (不传则自动取 account_id=3 的最新 done 记录) */
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const path = require('path');
|
||||
const { loadConfig } = require('./load-config');
|
||||
|
||||
const DB = path.join(__dirname, 'mcn-plugin.db');
|
||||
const API = 'http://localhost:8900/api/run';
|
||||
const CFG = loadConfig();
|
||||
const DB = path.join(__dirname, CFG.dbName);
|
||||
const API = `http://localhost:${CFG.port}/api/run`;
|
||||
const ACCOUNT_ID = 3;
|
||||
|
||||
const db = new DatabaseSync(DB, { readOnly: true });
|
||||
@@ -27,14 +29,19 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
脚本标题:《${r.title || r.video_title || ''}》(rewrite_log.id=${r.id},video_id=${r.video_id})
|
||||
|
||||
【任务完成后必须执行】
|
||||
将复盘结果保存到工作台数据库 mcn-plugin.db 的 script_review 表(数据库路径:D:\\AgentSkill\\mcn-video-script\\project\\短视频脚本创作\\V1.0\\mcn-work-shop\\mcn-plugin.db),字段:
|
||||
将复盘结果保存到工作台数据库 ${CFG.dbName} 的 script_review 表(数据库路径:${DB},即本会话 cwd 下的 ${CFG.dbName}),字段:
|
||||
- type='ai'
|
||||
- rewrite_id=${r.id}
|
||||
- video_id=${r.video_id}
|
||||
- dims_json=各维度评分 JSON(设定承接/框架节奏/钩子设置/叙事逻辑/极致事件/共鸣和共情/格式规范性/台词口语化/内容合规/教程完整链/其他)
|
||||
- total=总分(数字)
|
||||
- verdict=结论
|
||||
- issues_json=问题清单 JSON
|
||||
- issues_json=问题清单 JSON 数组,**每条字段固定如下(字段名与取值不可自创)**:
|
||||
[{"severity":"高","dim":"叙事逻辑","loc":"场8 妈妈台词(510-520s)","issue":"问题描述","suggestion":"修改建议","step":"S7"},
|
||||
{"severity":"中","dim":"台词口语化","loc":"场3","issue":"…","suggestion":"…","step":"S9"}]
|
||||
· severity:**只能取「高」「中」「低」三个中文值**(禁止 high/medium/low、禁止 sev/level 等其它字段名)
|
||||
· dim=所属维度名;loc=定位(场次/时间码);issue=问题描述;suggestion=修改建议;step=所属环节(S5-S11)
|
||||
· ⚠️ 若 severity 缺失或写成英文,工作台无法按严重度着色,会全部显示成绿色
|
||||
- content=复盘完整内容
|
||||
- summary=摘要
|
||||
- status='done'
|
||||
|
||||
Reference in new issue
Block a user