feat(MCN工作台): 移植dsh导入账号功能 + 弹窗按钮/宽度优化 + 分镜技能归入subskill
- 账号列表页新增「导入账号」:两种方式(抖音账号信息/本地文件夹),复用 executeTask 任务链路提交 AI 任务 - 查看改写弹窗:展开源脚本按钮移入 AI脚本 标题右侧(间隔10px),弹窗最大宽度 1400→1600 - 短视频提示词生成 整体移入 短视频脚本创作/V1.0/subskill/mcn-video-prompt(与 mcn-dou-analysis 同构,138 文件 R100 保留历史) - 工作台 dsh-data/server 若干修复 + 记忆更新
This commit is contained in:
1 parent
9b0c198e2c
commit
ab97842030
155 files changed
+1201
-198
No files matched your search
@@ -0,0 +1,162 @@
|
||||
# 2026-08-28 工作日志
|
||||
|
||||
## MCN工作台 帮助页排版优化(09:43-09:51)
|
||||
- 用户反馈功能帮助页排版乱,先截图诊断(browser-harness + 独立 Chrome 9223)
|
||||
- **P0 根因**:`.qa-row` 用 `display:flex` 横排 + `.q { white-space:nowrap }`,长问法撑满宽度,`.a` 被挤成竖排一字一行(「写脚本」卡的回答竖排炸裂)
|
||||
- **修复**(`public/style.css` 2 块):
|
||||
- `.func-grid` → `grid-template-columns: 1fr` 单列,5 卡片各独占一行(原 2+2+1 孤卡)
|
||||
- `.qa-row` 改上下排:q 橙色一行(block + normal 换行),a 灰缩进 1.2em 一行
|
||||
- 数据与点击复制逻辑零改动
|
||||
|
||||
## 帮助页补返回按钮(09:50-09:53)
|
||||
- 帮助页缺 `page-toolbar` 返回按钮(数据页都有),用户指出
|
||||
- `renderHelp()` 顶部加 `<button data-back>←</button>` + 「功能帮助」标题;router help 分支绑定 `[data-back]` → `#/`
|
||||
- 复用现有 `.page-toolbar/.page-title` CSS
|
||||
|
||||
## 周榜改名 + 保存确认弹窗(09:53-10:00)
|
||||
- 「抖音周榜」→「账号周榜」:app.js(首页卡片/首页h3/CRUMB_TITLES/router titles/注释)+ data-pages.js(页title×2)+ style.css 注释;**dsh-data.js 的「抖音周榜_」文件名正则不能改**(匹配磁盘真实数据文件名)
|
||||
- 功能列「导入」→「保存」:app.js 首页预览 + data-pages.js 周榜页;title「保存并分析该账号」
|
||||
- **新增 `confirmSaveAccount(name, url)`**(app.js 工具区):点击保存 → modal 确认弹窗(账号名高亮 .confirm-target + URL + 确认保存/取消)→ 确认后 firePrompt 提交 `/api/run`(任务名「保存指令」,prompt 改「保存并分析」);两处周榜共用
|
||||
- 新增样式 `.confirm-target/.confirm-url`;弹窗图标用 refresh(无 save 图标)
|
||||
- **验证**(browser-harness 实测):首页/周榜页名称、保存按钮数(10/20)、弹窗出现 → 取消关闭(⚠️ 检测须用 `.modal-mask:not([hidden])`,设置弹窗常驻 DOM 会误报)
|
||||
|
||||
## 环境备忘
|
||||
- browser-harness 调用:`BU_CDP_URL=http://127.0.0.1:9223 PYTHONPATH='C:\Users\maidou\.dsh\skills\browser-harness\src' D:\miniconda3\envs\browser-harness\python.exe -m browser_harness.run`(截图用 `capture_screenshot(path=..., full=..., max_dim=...)`,无 shot 函数)
|
||||
- 独立 Chrome:`chrome.exe --remote-debugging-port=9223 --user-data-dir=C:\Users\maidou\.workbuddy\tmp\bh-chrome-9223`
|
||||
|
||||
## 全按钮确认弹窗 + 执行中状态(09:57-10:30)
|
||||
- 用户要求:所有按钮点击后需弹窗确认才执行;执行中按钮变「执行中」禁点,直到任务完成;弹窗提示词参考 dsh-plugin-mcn(client.js 的 confirm* state + busy/disabled + 轮询模式)
|
||||
- **盘点 8 处触发 AI 任务的按钮**:保存(首页周榜 data-rk-import + 周榜页 data-import,已有弹窗)+ 创作(账号列表 data-create)+ 改写(账号详情/写脚本页 data-rewrite ×2)+ 解析(data-analyze)+ 提炼(data-extract)+ 复盘(data-review-row)
|
||||
- **后端**(server.js):新增 `GET /api/run/status?id=`——读 workbuddy.db 三表判定状态:`runtime_state.running=1` 或 `automation_runs.status=IN_PROGRESS`→running;`PENDING_REVIEW`→pending;`DONE/COMPLETED/SUCCESS/FINISHED`→done;`ERROR/CANCELLED/FAILED/INTERRUPTED`→error;无记录→queued;自动化不存在→notfound。⚠️ workbuddy.db 打开必须 `{ readOnly: true }`
|
||||
- **前端**(app.js):`confirmRun(opts)` 通用确认弹窗(title/desc/target/url/prompt/label/btn)+ `executeTask(opts)` 提交+按钮「执行中…」禁用(.btn-busy)+ 5s 轮询状态(30min 上限)→ done/error 恢复按钮+toast;`confirmSaveAccount` 改为包装 confirmRun(传 btn);data-pages.js 6 处 firePrompt 全改 confirmRun(提示词沿用原 prompt,弹窗 desc 参考 dsh 文案)
|
||||
- **验证**(browser-harness 实测):7 类按钮弹窗全部出现(保存账号/创作脚本/改写脚本/解析视频/提炼账号设定/复盘评分);保存按钮完整链路:确认→「执行中…」disabled→轮询2次→恢复「保存」可点;⚠️ 测试技巧:覆盖 `window.api` 模拟 /api/run 假 id + /api/run/status 状态序列(前N次 running 后 done),避免产生真实任务;**钩子条件必须区分** status URL(无 opts GET)与 POST
|
||||
- firePrompt 保留定义无调用;服务已重启(旧进程 19732 无新接口,新进程绑定 127.0.0.1:8899,20604 MiPCAudio 占 0.0.0.0 不动)
|
||||
|
||||
## 热门账号页列表优化(10:22-10:30)
|
||||
- 筛选项整体右对齐:类型/赛道/搜索/重置 包进 `.toolbar-right`(margin-left:auto),赛道移到搜索左边
|
||||
- 列改动:AI内容→**AI制作**;定位/内容→**账号定位**(`.cell-loc` max-width:110px 缩窄 + th width:110px);操作列加 `th-act` 居中
|
||||
- **收藏按钮实装**(原来纯展示无交互):`fav-btn` 点击 → `POST /api/dsh/account-favorite` → 写库 → toast → load() 刷新(favorite DESC 排序变化);⚠️ 工作台 db 是只读连接(getDb readOnly:true),写操作用**独立可写连接**(`setFavorite` 单独 `new DatabaseSync(DSH_DB)` 用完即关,勿改只读连接)
|
||||
|
||||
## 热门账号改名关注账号 + is_ai 漏标修复 + 任务完成自动刷新(10:29-10:40)
|
||||
- **改名「关注账号」**:首页卡片/页标题/面包屑/弹窗文案(添加到关注账号)/复盘页生成方式(关注账号创作)全改;`hot_accounts` 表名不动(dsh schema 一致)
|
||||
- **旧梦留声机 is_ai 漏标修复(方案A)**:`is_ai="否"` 但 bio「账号由AI❤️生成」+ content「AI 生成+…」+ 视频 tags「#ai创作浪潮计划,#内容由ai生成」+ dsh 原库人设卡「群像型AI温情剧情账号」四方矛盾;全库仅此 1 例漏标。修工作台库 `UPDATE hot_accounts SET is_ai='是' WHERE id=1629`(用户选方案A,dsh 原库未动);**教训:is_ai 判定历史逻辑没覆盖 bio「AI❤️生成」类标记**
|
||||
- **任务完成自动刷新**:executeTask done 分支 → `refreshCurrentPage()`(当前 hash 是数据页/首页 → `router()` 重渲染),toast 改「任务执行完成,页面已自动刷新」;配合收藏的局部 load() 刷新,实现"改动后无需手动刷新"
|
||||
- ⚠️ browser-harness 验证轮询链路时:后台标签 setTimeout 会被 Chrome 节流导致轮询计数卡住(polls 不增长、按钮不恢复)——**前台用户不受影响**;验证 refreshCurrentPage 用 DOM marker(重渲染后元素消失)直测,避开时序
|
||||
|
||||
## 关注账号 AI脚本按钮 + AI 生成选题(10:41-10:55)
|
||||
- 用户指示:创作按钮改「AI脚本」;弹窗加选题输入区(手输 / 右下角 AI 按钮结合人设生成选题);**"不用看dsh,脚本创作技能中有生成选题功能"**——选题生成复用技能 S5 方法论 + Dify 接口
|
||||
- **技能 Dify 配置**:`scripts/MCN_CYLG_API.py` 调 `https://mydify.youmanvideo.com/v1/chat-messages`(blocking 即时返回,answer 字段);env:`DIFY_MCN_CYLG_KEY`(必)/`DIFY_MCN_MODEL`(默认gemini)/`DIFY_URL`;**用户提供 key `app-0vRaxPhvVOqzFQIa5eFzFdjD` 已内置脚本**(新增 `DEFAULT_CYLG_KEY` 常量,env 优先脚本兜底,第117行 api_key 读取改兜底);⚠️ 接口文档提醒:curl 中文 query 会被 CDN 拦截推荐 Python;search 模式(联网)60s 超时,recall 模式(is_online=0)47-55s 稳定返回
|
||||
- **后端**(server.js 新增 `POST /api/ai/topics`):查工作台库账号 content(500字)+ persona summary(500字)→ 构造 S5 选题方法 prompt(跳出行为框架/保留结构性符号/目标体感/3个选题每行一条)→ Dify recall 调用(100s abort)→ 返回 topics;key 读取链:env → 技能脚本 DEFAULT_CYLG_KEY 正则提取
|
||||
- **前端**(data-pages.js):`confirmAiScript(accountName, btn)` 弹窗——标题 AI脚本 + desc + textarea(.ai-topic-input)+ 右下角 AI 按钮(.ai-topic-btn,点击加载态…/disabled);AI 按钮 POST /api/ai/topics 填入输入框;确认后选题非空则 prompt 拼「选题方向:{topic}」;按钮「创作」→「AI脚本」
|
||||
- **实测**:接口返回旧梦留声机选题(困在1998年的爷爷/隔辈亲情,人设结合生效);弹窗 UI + AI 生成 55s 填入 1267 字 + 按钮恢复 + 取消关闭全通过
|
||||
|
||||
## 工作台 08-28 下午迭代(12:14-12:23)
|
||||
- 账号详情页:tab 样式多轮调整(胶囊→直角→**下划线式定稿**:无背景/蓝色文字/2px 直线下划线/border-radius:0——圆角根因是顶栏 `.tab` 残留 8px 未覆盖);视频列表加「账号视频列表」标题(上方间隔20px);视频ID 列缩窄(.td-id 13px 紧凑,187→165px);「定位/内容」→「账号定位」
|
||||
- **固化「改完必重开页面」到 SKILL.md**(用户强调 + 明确要求固化到 skill):工作台章节新增第 7 点硬性约定——改动完成后最后一步必须 present_files 重开对应页面(带 hash),因内置浏览器无 CDP 端口无法模拟 F5;同步写入 MEMORY.md 工作方式约定首条;SKILL.md frontmatter updated_at→08-28 + last_change 摘要(08-28 工作台迭代)
|
||||
- ⚠️ 教训:数据页 `.tabs/.tab` 会继承顶栏同名类样式(margin-left:20px、border-radius:8px),必须显式覆盖;用户对 tab 最终定稿=下划线式无背景无圆角
|
||||
|
||||
## 工作台 08-28 下午 UI 迭代 2(14:44-15:31)
|
||||
- 改名链路:周榜功能列「关注→分析」、功能名「关注账号→重点账号→**账号列表**」全链路联动(卡片/标题/面包屑/弹窗/复盘 GEN 枚举);重点账号列表「收藏」列→「关注」
|
||||
- 账号列表:重置按钮图标化(svgIcon refresh + title);去掉年龄/账号定位列(14→12列);账号名去加粗(.link.strong→.link);所有列表可点击链接深蓝 #2f6fed→浅蓝 #4a90e2(.link/.title-plain:hover/.md-body a)
|
||||
- AI写脚本弹窗:按钮「AI脚本→AI写脚本」+ 标题联动;选题输入框 84→168px;**按账号是否有设定动态文案**(按钮带 data-acc-id → 查 /api/dsh/account-persona → 有设定「基于当前账号设定生成脚本」/无「先进行账号设定分析」,prompt 同步联动;测试占位内容排除)
|
||||
- 设置弹窗:标题「项目地址设置→项目文件地址」、添加→确认、关闭按钮→右上角 ×(modal-title-row);desc 去掉「可添加其他项目地址」;placeholder→桌面具体路径 `C:\Users\maidou\Desktop\MCNSkill项目`
|
||||
- **路由记忆误伤修复(15:30)**:boot() 原在 URL 无 hash 时恢复 localStorage 上次路由(#/accounts)→ 用户访问 http://localhost:8899 根地址被强制跳 accounts;改为**无 hash 一律回首页**,带 hash 正常路由,删除自动恢复逻辑(saveRoute 保留)
|
||||
- ⚠️ 时间戳参数教训:为强制内置浏览器刷新加的 `?v=xxx` 会让地址栏带尾巴、用户反感——**改用干净 URL 重开 + 用户手动 F5**;内置浏览器无 CDP 无法模拟刷新
|
||||
|
||||
## 工作台 08-28 下午 UI 迭代 3(15:31-16:30,会话总结恢复)
|
||||
- **返回层级修复**:bindBack 固定 `#/` → `history.back()` + 250ms hash 未变回首页兜底(视频详情返回不再误跳首页)
|
||||
- **账号详情页**:`page-detail + detail-body` 内容区右移 10px(返回/tab 原位);账号信息 `info-card` 卡片化 + `info-row` 按行分组(行1:抖音号/年龄、行2:赛道/AI制作/地区、行3:作品数/粉丝/获赞/获取视频、行4:简介、行5:账号定位;行内左对齐 flex:0 0 auto、列间距 80px);账号定位 10 行 clamp + 展开/收起(.clamp-text/.clamp-btn);「账号视频列表」→「视频列表」;评论/分享 fmtWan(过万保留 1 位小数);无设定状态 `.empty-state` 居中排版(图标+标题+说明+btn-view 按钮,弃深蓝实心按钮)
|
||||
- **视频列表**:发布时间移至首列 + 后端 `ORDER BY v.publish_time IS NULL, v.publish_time DESC, v.id DESC`;操作列统一「AI改写」(已解析 `.btn-view` 蓝字/未解析黑色,`parsed` 标志),弹窗按解析状态提示处理方式
|
||||
- **AI写脚本页**:标题 `.title-plain data-ext` 新标签开原视频;已改写列「N 查看」按钮(0 时「—」不可点,去对号);操作列「AI改写」+ th-act 居中
|
||||
- **查看弹窗重构**:`rw-stack/rw-sec` 垂直三板块(源脚本/AI脚本/AI分镜提示词)等高(flex:1 + min-height:0);版本切换在对应板块右下 `.rw-sec-foot`;标题 fmtTitleQ(书名号只包标题主体 + `#` 后内容整体换行);弹窗 `height: calc(100vh - 60px)` 固定高度(max-height 在内容不足时不生效);关闭按钮右上(modal-title-row ×)
|
||||
- **首页卡片小字** 4 张卡片 desc 定稿:账号周榜「三方数据 · 赛道/日期筛选」、账号列表「关注的账号 + 视频列表」、AI写脚本「AI生成脚本+分镜提示词」、AI写作复盘「检查清单+复盘报告」
|
||||
- **boot() 删除 setInterval 20s 静默刷新**(仅保留任务触发 refreshCurrentPage)
|
||||
|
||||
## S9 格式修正:A 格式动作叙述去括号(17:02-17:15,用户确认后修改)
|
||||
- **背景**:用户贴《留守包裹》A 格式脚本,质疑对白表演标注 `(打开行李箱…)` 与独立动作叙述 `(娃儿们涌上来…)` 都用括号
|
||||
- **影响分析**:括号使用点——A 格式①对白行表演标注②独立动作叙述(本次去括号);B 格式角色名同行标注+纯动作/空镜括号(**不动**,07 指南 95/104 行);C 格式 `(口播·语气)`+B-roll 方括号(**不动**);D 格式画面描述/配乐/台词标注全括号(**不动**,07 指南 182 行)
|
||||
- **修改**(源 + dsh 副本同步,共 3 文件 9 处):`9_生成短视频脚本.md` 5 处(处理规则 A 格式模板/场次书写规范表「动作叙述」行/System Prompt A 格式规则/禁止行为新增「不得给独立动作叙述段落套括号(A 格式)」/输出模板 A 格式)+ `07_脚本格式选择指南.md` 2 处(A 格式内容格式+核心规则补「独立动作叙述段落不加括号」)+ `创作流程规范.md` 2 处(A 格式模板+动作叙述规则)
|
||||
- **规则表述**:括号仅用于对白行表演标注(A 格式另起一行 `(动作/表情/语气)`、画外 `(画外)`、B/C 格式角色名同行);A 格式无台词动作/场景连续叙述(多人行为/情绪反应/场景转换)裸写自然段落
|
||||
- **验证**:三文件源与 dsh diff 一致;B/D 括号规则保留;全 V1.0 无 `(动作叙述` 模板残留(10 文件两处为文字描述非模板)
|
||||
- SKILL.md frontmatter:源+dsh 更新 last_change,dsh 的 updated_at/deployed_at → 08-28
|
||||
|
||||
## 工作台查看弹窗改横排三列 + 源脚本响应式收起(17:30-17:40)
|
||||
- 用户澄清需求:查看改写弹窗三板块(源脚本/AI脚本/AI分镜提示词)应为**横着并排**(左右三列),不是竖排;弹窗宽度不够时源脚本可收起不展示
|
||||
- **改 style.css**(mcn-work-shop/public/):`.rw-stack` flex-direction column→**row**;`.rw-sec` 补 `min-width:0`(防内容撑破);新增 `@media (max-width:1100px)` 隐藏 `.rw-sec:first-child`(源脚本列收起,剩两列平分)
|
||||
- **验证**(CDP 9223 + Emulation.setDeviceMetricsOverride):宽视口 2560px → row 三列各 341px(left 748/1102/1455);窄视口 1000px → 源脚本 disp:none,AI脚本+分镜各 428px 平分
|
||||
- ⚠️ 教训:`goto_url` 同 URL(hash 路由)**不触发重新加载**,验证 CSS 改动必须 `location.reload()` 强制刷新
|
||||
|
||||
## AI写脚本列表账号可点击(17:35-17:40)
|
||||
- 用户需求:AI写脚本列表点击账号可查看账号详情
|
||||
- **改 data-pages.js renderRewrites** 账号列:纯文本 → `<a class="link" href="#/account/${v.account_id}">`(复用 renderAccounts 的账号详情链接写法;接口 `v.*` 已含 account_id)
|
||||
- **验证**(CDP):20 行账号全部变链接(首个 #/account/1631 白昼小熊),点击 → hash #/account/1631 → 页面标题「白昼小熊」渲染成功
|
||||
- 工具栏右对齐(toolbar-right + margin-right:10px,同账号列表页)+ 全部列表页功能栏与表格间距 14→24px(.page-toolbar margin-bottom 全局)
|
||||
- 写脚本页说明文字移出工具栏:`.toolbar-note` 小字 12px 右对齐放功能栏下(-14px 上贴工具栏,距表 16px)
|
||||
- 改写弹窗三列内容区等高:源脚本列补空 `.rw-sec-foot` 占位 + `.rw-sec-foot` min-height 46px + `verCtrl` 始终渲染 foot(len=1 显示「X 1/1」,len=0 空占位)→ 三列 body 均 1055px 等高
|
||||
- **源脚本默认收起**:`let showSrc=false` 控制源脚本列 `rw-sec-hidden`(display:none);标题行加「源脚本/收起源脚本」切换按钮(`data-src-toggle`,`.src-on` 高亮态),点击重渲染弹窗;删除原 `@media(max-width:1100px)` 媒体查询(JS 接管);默认两列 518px 平分,展开后三列 341px
|
||||
|
||||
## 查看弹窗 AI分镜列常显 + 生成分镜按钮 + 源脚本按钮图标化(12:25-12:45)
|
||||
- 用户需求:AI分镜提示词区域**默认展示**(没有内容也空着);下面功能区域提供**生成按钮**;原脚本按钮变成**图标**
|
||||
- **改 data-pages.js openRewriteCompare**:
|
||||
1. AI分镜列去掉 `${visibleSb.length ? ... : ''}` 条件包裹 → **始终渲染** `.rw-sec`;无分镜时 body 显示 `<div class="dim">(暂无分镜提示词)</div>`
|
||||
2. foot 按状态分流:有分镜=`verCtrl('s',...)`(分镜 1/N + 上/下一版);无分镜=`<button data-gen-sb>⚡ 生成分镜</button>`
|
||||
3. `data-gen-sb` 点击 → `confirmRun({title:'生成分镜', desc/prompt:'将基于该 AI 改写脚本展开成 AI 分镜提示词(按分镜技能生成镜头级描述)', label:'分镜指令', btn:genSb})`(复用改写按钮同款任务触发链路)
|
||||
4. 源脚本按钮文字 → `svgIcon('file-text', 14, 'currentColor')` 图标(title 提示「展开源脚本/收起源脚本」切换;`.src-on` 高亮态保留)
|
||||
- **验证**(CDP 9223 #/rewrites 标签页):v189 行(rewrite 21 有 2 分镜)→ 3 列常显、分镜 1/2+上/下一版、源脚本图标按钮(无文字)、展开后三列 body 1055px 等高 / foot 46px / 各 341px 等宽;v190 行(rewrite 25 无分镜)→ 空态 dim「(暂无分镜提示词)」+ foot「生成分镜」按钮(zap 图标)、AI脚本与分镜列 body 均 1031px 等高、点按钮弹出「生成分镜」确认弹窗(title/desc/target 正确)
|
||||
- 测试脚本用完即删(cdp-test*.mjs + 截图),工作区无残留
|
||||
|
||||
## 已有分镜的 AI 脚本提供「重新生成」按钮(17:56-18:00)
|
||||
- 用户需求:已经生成分镜的 AI 脚本,foot 也提供按钮可重新生成分镜
|
||||
- **改 data-pages.js**:`verCtrl(p, idx, len, label, genLabel)` 增加可选第5参——有值时 foot 尾部追加 `<button data-${p}gen>⟳ {genLabel}</button>`(refresh 图标 14px currentColor);分镜列调用 `verCtrl('s', sbIdx, visibleSb.length, '分镜', '重新生成')`(AI脚本列 `verCtrl('v',...)` 不传,不受影响)
|
||||
- **事件**:抽出通用 `fireGenSb(btn, regenerate)` → `confirmRun({title: regenerate?'重新生成分镜':'生成分镜', prompt 文案 regenerate 时加「重新」})`;`[data-gen-sb]`(无分镜,zap 图标)与 `[data-sgen]`(有分镜,refresh 图标)两个按钮绑定复用
|
||||
- **验证**(CDP 9223):v189 行(2 分镜)foot=「分镜 1/2 ← 上一版 下一版 → ⟳ 重新生成」(无溢出,count margin-right:auto 推右),点按钮弹「重新生成分镜」确认窗;v190 行(无分镜)仍只显「⚡ 生成分镜」不受影响
|
||||
|
||||
## 查看弹窗 AI脚本↔AI分镜 场次联动滚动(17:59-18:30)
|
||||
- 用户需求:滑动 AI脚本 滑到哪一场,右边 AI分镜提示词对应滚动到那一场
|
||||
- **数据结构**:AI脚本场标题=`**场N:…**`(renderMarkdown→`<p><strong>`);分镜块=`段N(x-y秒)|场M:…`(一场可拆多段:场2前半/后半),`renderSbBlocks`→`.sb-block/.sb-head`
|
||||
- **改 data-pages.js renderModal**(仅 `visibleSb.length>0` 时绑定):`sync()`——①AI脚本列 `strong,h1-h4` 过滤 `/场\s*\d/` 得场标题数组(DOM 顺序=文档顺序)②`limit=aiBody.scrollTop+60`,找最后一个 `rect.top-aiTop+scrollTop <= limit` 的标题=当前场 ③正则提取场号 N ④分镜列 `.sb-block` 中 `find` 第一个 head 匹配 `场N(?=前半|后半|[::]|\s|$)` 的块 ⑤`sbBody.scrollTop = target.rect.top - sbBody.rect.top + sbBody.scrollTop`(不用 offsetTop,防 offsetParent 歧义);`aiBody.addEventListener('scroll', sync, {passive:true})` + 打开时 `sync()` 一次
|
||||
- **⚠️ rAF 教训(重要)**:初版用 `requestAnimationFrame(sync)` 节流,CDP 9223 tab `visibilityState=hidden` **rAF 不触发**→联动失效;改为直接同步调用 sync(计算量小:30 strong + 8 块,性能无碍)。**CDP 验证场景禁依赖 rAF**
|
||||
- **⚠️ 测试脚本转义教训**:bash heredoc 写 `.mjs` 里 `/场\s*\d/` 单反斜杠 → Node 解析 JS 字符串时 `\s`→`s`(无效转义丢反斜杠)→ 正则变 `场s*\d` 不匹配,误判 2 小时;**JS 字符串里正则必须写 `\\s`**(Edit 工具直写文件的正则字面量不受影响,产品代码一直是对的)
|
||||
- **验证**(CDP reload 后):S0 打开弹窗初始对齐场1(sbScrollTop 12);滑到场3→分镜 4392 可见「场3:老屋堂屋·当天傍晚」;场5→8528「场5:老屋灶房·高三深夜」;回场1→12;四条链路全过
|
||||
|
||||
## 场次联动回退单向 + 修复段名格式正则 bug(18:30-19:05)
|
||||
- **用户明确:不要双向联动,只允许 AI脚本为主导的联动**——删 `syncAi` 反向逻辑与 `sbBody.addEventListener('scroll', syncAi)`,仅保留 `syncSb`(AI脚本列滚动 → 分镜列对齐)
|
||||
- **🐛 P0 真 bug(本次核心)**:`syncSb` 里分镜块匹配正则 `场N(?=前半|后半|[::]|\s|$)` 只兼容「场N:标题」冒号格式与「前半/后半」段名;**video 190 实际 head 是「场1前段/场3中段/场3中后段/场4末段」**(前段/后段/中段/中后段/末段 5 种段名)→ 正则完全不匹配 → target 永远 undefined → **联动失效**(之前会话验证的「场3:老屋堂屋」是冒号格式数据,掩盖了此 bug)
|
||||
- **修复**:`new RegExp('场' + m[1] + '(?![0-9])')`——负向前瞻防 `场1` 误匹配 `场10`,兼容全部段名格式与冒号格式
|
||||
- **验证**(CDP #/rewrites 页 video 190 rewrite 22,18 分镜块):
|
||||
- ① AI脚本主导:滚 400/800(limit 未跨场2标题 t=887)→ 仍对齐场1(正确);滚 1200(limit 1260 > 887)→ 分镜列 12→2473 对齐场2前段 ✅
|
||||
- ② 分镜列独立:分镜列 600→3073,AI脚本列 1200 纹丝不动 ✅(无反向联动)
|
||||
- ③ 跳底:AI列 5621 → 分镜列 17480 对齐场6前段(场6 t=5567 ≤ limit 5681)✅
|
||||
- **⚠️ CDP 新教训:`scrollTop` 赋值不触发 scroll 事件监听**(fired 0;Chrome 将 scroll 事件异步/合并派发),`dispatchEvent(new Event('scroll'))` 才触发(fired 1);**验证脚本必须设置 scrollTop 后手动补发 scroll 事件**;真实用户滚轮不受影响(真实滚动正常派发事件)
|
||||
- 测试脚本:保留 `cdp-verify-oneway2.mjs`(最终验证版),其余 cdp-probe/debug/regex/event 探针已删
|
||||
|
||||
## 视频标题去首尾引号(18:34-18:45)
|
||||
- 用户反馈:video 190「他守住的不止是那份快递…」标题带引号,要去掉
|
||||
- **根因**:`fmtTitleQ` 只清了尾部引号(`[\s"“”']+$`),开头引号没处理 → 弹窗显示 `《"他守住…》`;列表页完整标题(含 #标签)的引号在 `#` 前,也不在字符串首尾
|
||||
- **修复**(app.js + data-pages.js):
|
||||
- app.js 新增 2 个工具:`stripTitleQuotes(s)`(清整串首尾引号:英文/中文双引号/单引号/「『」』,仅首尾中间保留)+ `cleanTitle(t)`(# 前主体去引号、# 标签原样保留——列表页完整标题用)
|
||||
- `fmtTitleQ` 改调 stripTitleQuotes(弹窗);列表页/视频详情/改写按钮 payload/首页最近脚本 8 处显示改调 cleanTitle
|
||||
- **验证**(CDP):列表页 `他守住的不止是那份快递,还守住了留守孩子们的那份期待#AI创作浪潮计划…` 无引号;弹窗 `《他守住的不止是那份快递…》⏎#…` 无引号 ✅
|
||||
- 工具函数定义在 app.js 顶层(先加载),data-pages.js 直接复用(全局 lexical scope 共享)
|
||||
|
||||
## 查看改写弹窗宽度自适应(18:38-18:40)
|
||||
- 用户需求:「已改写 → N 查看」弹窗距网页左右各 50px,视口变宽弹窗跟着变宽,**最大 1400px**
|
||||
- **改 style.css 347 行**:`.rw-modal` width `min(1100px, 95vw)` → `min(1400px, calc(100% - 100px))`(全局 border-box,100% 相对 mask fixed inset:0 视口宽,不含滚动条)
|
||||
- **验证**(CDP 多视口):1200→1085(视口-100,左右 50/50);1400→1285;1600/1920/2200→1400 封顶居中 ✅
|
||||
|
||||
## 移植 dsh「热门账号→导入」功能到工作台(19:15-19:35)
|
||||
- **用户需求**:「账号列表页就是dsh插件 热门账号,里面还有个导入功能看看能否移植过来」
|
||||
- **dsh 原链路调研**(dsh-plugin-douyin-accounts/client.js + dsh-plugin-mcn/index.js):弹窗两种方式(方式一账号名/抖音号/地址 3 输入框、方式二本地文件夹路径)→ `POST /mcn/api/external/stage`(账号模式浏览器采集+AI会话确认,browserTaskRunning 409 互斥;目录模式直接 stage)→ preview(AI 确认前扫描比对不写库)→ import 写库
|
||||
- **移植设计**(工作台不直连 dsh /mcn 接口):前端加「导入账号」按钮 + 同款弹窗 → 复用 `executeTask` 提交 prompt 任务到 `/api/run`(左侧 mcn-workshop 会话栏可见,与「保存并分析」同构)→ WorkBuddy AI 执行导入 → 轮询完成自动刷新;确认环节由弹窗「提交导入」承担(dsh 的 AI 会话确认 → 工作台的 executeTask 任务模式)
|
||||
- **改动**:app.js 新增 `download` 图标 + `openImportAccounts(btn)`(弹窗两种方式、空校验 toast「请填写抖音账号信息或文件夹路径」、prompt 按目录/账号两种拼法、label「导入指令」);data-pages.js renderAccounts 工具栏加「导入账号」按钮(svgIcon download);style.css 补 `.import-form` 4 类样式;帮助页「账号分析」组加问法「把抖音账号「俊希」导入账号列表」
|
||||
- **验证**(CDP 9223 + fetch mock 零副作用):弹窗打开→4 输入框齐全→空提交 toast 校验→填账号提交→弹窗关闭+按钮「执行中…」+ /api/run prompt 正确→mock done 轮询→按钮恢复「导入」✅;帮助页问法 13 条含导入 ✅
|
||||
- **⚠️ 两个坑**:①页面有常驻隐藏 `#settingsModal`(index.html 静态弹窗),DOM 选择器查弹窗必须 `.modal-mask:not([hidden])`(否则匹配到设置弹窗误判);②fetch mock 判断顺序必须 `/api/run/status` 在前(`includes('/api/run')` 会先吞掉 status 请求)
|
||||
- 验证脚本保留 `cdp-verify-import.mjs`(含 fetch mock 模式,可复用)
|
||||
|
||||
## 查看弹窗:展开源脚本按钮移入 AI脚本 标题 + 弹窗最大宽度 1600(18:48-19:00)
|
||||
- 用户需求:①「展开原脚本」按钮图标放到 **AI脚本 标题右边,间隔 10px**(原在弹窗顶部 modal-title-row)②弹窗最大宽度 1400→**1600**
|
||||
- **改 data-pages.js**:modal-title-row 删 data-src-toggle(保留标题+×);AI脚本列 `.rw-sec-head` 内追加 `<button class="btn btn-sm btn-view rw-src-toggle" data-src-toggle>${svgIcon('file-text')}`(src-on 高亮态保留)
|
||||
- **改 style.css**:`.rw-sec-head` 加 `display:flex; align-items:center; gap:10px`(标题右侧放操作按钮,间隔 10px);`.rw-modal` width `min(1400px,…)` → `min(1600px, calc(100% - 100px))`,注释同步
|
||||
- **验证**(CDP #/rewrites):按钮在 AI脚本 head 内、与文字 gap=10px 精确、垂直对齐、顶栏无残留 ✅;点击展开源脚本列 + title 切「收起源脚本」✅;宽度 1200→1085 / 1500→1380 / 1700→1564 / **2000→1600 封顶居中** ✅
|
||||
- ⚠️ 左右边距探针:`right:65` 不是 bug——mask(fixed inset:0) 宽=clientWidth 1185(不含滚动条),弹窗 calc(100%-100)=1085 在 mask 内精确居中左右各 50;15px 是页面右侧 classic 滚动条区域,滚动条左缘到弹窗恰 50px;无滚动条页面左右严格 50/50
|
||||
- 验证脚本保留 `cdp-verify-srcbtn1600.mjs`
|
||||
+37
-55
@@ -1,72 +1,54 @@
|
||||
# MCNVideo AI 项目长期记忆
|
||||
|
||||
## 环境判定两步模型 + 三技能统一产出结构(2026-08-26 定稿)
|
||||
- **两步判定(用户 14:59 定版)**:第一步查本机 `D:\AgentSkill` 是否存在——**无=③用户环境**(技能无论装在哪、什么路径,不一定含 .dsh);有→第二步看技能路径——**在 `D:\AgentSkill\` 下=①开发机(源技能)**、**含 `.dsh`=②dsh 部署环境**(如 `C:\Users\maidou\.dsh\skills\`)。原单一 `.dsh` 锚点会把用户环境误判为开发机(P0 漏洞,已修复)
|
||||
- **产出落盘**:①开发机+③用户环境同构=**桌面 `MCNSkill项目/{账号名}/`**(**账号层直挂**,无技能文件夹层);②dsh=**`D:\dshworkspace\` 主工作区优先**(无主工作区兜底桌面 DSHSkill项目/)
|
||||
- **三技能统一结构(15:25 定稿)**:①③ 三技能**共用一个账号文件夹**——账号分析产出 `{账号名}账号设定.md`/`{账号名}账号数据分析.md`/`短视频表格.xlsx`/`视频分析\{视频标题}_标签1_标签2\` 直挂账号层;脚本=`脚本{编号}\`;分镜=`提示词{编号}\`(编号默认继承上游脚本编号)。② dsh **分目录**——`创作任务\{账号名}\`(脚本+分镜:脚本{编号}/提示词{编号} 平级)与 `解析任务\{达人昵称}\`(账号分析)分开
|
||||
- **废弃**:`DSHSkill项目\` 根目录(08-26 并入用户环境)、`MCNSkill项目/脚本创作/` 与 `/分镜提示词/` 技能文件夹层、旧 dsh 根 `MCNSkillCase/`
|
||||
- **现状(15:25 核对)**:dsh 解析任务已符合定稿(张天琪/白昼小熊);桌面 `MCNSkill项目/账号分析/{小咪橘座,旧梦留声机,王微斯}` 仍在技能集合层——**用户选"现有的先不动"(不迁移)**;账号分析技能配置、脚本创作技能配置尚未同步两步判定+账号层直挂(待办,用户未选)
|
||||
- 用户自定义路径 > 默认路径;产出路径唯一权威=`references-add/路径配置.md`(运行时优先于 SKILL.md 声明);Windows/macOS 双系统自适应
|
||||
## 环境判定两步模型 + 三技能统一产出结构(08-26 定稿)
|
||||
- **两步判定**:第一步查本机 `D:\AgentSkill` 是否存在——**无=③用户环境**;有→第二步看技能路径——**在 `D:\AgentSkill\` 下=①开发机(源技能)**、**含 `.dsh`=②dsh 部署环境**(如 `C:\Users\maidou\.dsh\skills\`)。单一 `.dsh` 锚点会把用户环境误判为开发机(P0 已修复)
|
||||
- **产出落盘**:①③同构=**桌面 `MCNSkill项目/{账号名}/`**(账号层直挂,无技能文件夹层);②dsh=**`D:\dshworkspace\` 主工作区优先**
|
||||
- **三技能结构**:①③ 三技能共用一个账号文件夹(账号分析产出 `{账号名}账号设定.md`/`账号数据分析.md`/`短视频表格.xlsx`/`视频分析\{标题}_标签1_标签2\` 直挂;脚本=`脚本{编号}\`;分镜=`提示词{编号}\` 继承上游编号)。② dsh 分目录:`创作任务\{账号名}\`(脚本/分镜平级)与 `解析任务\{达人昵称}\` 分开
|
||||
- **废弃**:`DSHSkill项目\` 根、`MCNSkill项目/脚本创作/`、`/分镜提示词/` 技能层、旧 dsh 根 `MCNSkillCase/`
|
||||
- 现状:dsh 解析任务已符合定稿;桌面 `MCNSkill项目/账号分析/{小咪橘座,旧梦留声机,王微斯}` 用户选"现有先不动";账号分析/脚本创作技能配置尚未同步两步判定(待办)
|
||||
- 产出路径唯一权威=`references-add/路径配置.md`(运行时优先于 SKILL.md);用户自定义路径 > 默认路径
|
||||
|
||||
## 核心技能清单
|
||||
- **短视频脚本创作** (`project/短视频脚本创作/V1.0/` + `Lite1.0/`):11步流程+知识库12分类(66文件);V1.0 完整版含 MCP/Dify 接口,Lite1.0 去全部接口、搜索用模型自带 WebSearch。扁平化定式=去「技能名」层、留「项目名」层(`project/项目名/{版本}/`);08-26 曾误去项目名层已回改
|
||||
- **同步策略(08-26 17:34 用户定)**:后续所有改动只改 V1.0 源技能 + dsh 副本两处;**Lite1.0 默认不动**(有需要再单独处理,其特化差异=去MCP/拆解贴文案/无工具接口块)
|
||||
- **MCN工作台(08-27 新增,仅 V1.0)**:`project/短视频脚本创作/V1.0/mcn-work-shop/`(server.js+public/,零依赖 Node 服务,端口8899自动避让);触发语「打开工作台」→启动服务+内置浏览器打开 http://localhost:8899;hash 路由:首页(功能入口卡+AI写剧本快捷卡)+数据功能页(📈周榜/👥热门账号/账号详情/视频详情/AI写脚本/AI写作复盘,读工作台自有 mcn-plugin.db 副本+dshworkspace 抖音榜单 JSON)+📁产出浏览(桌面 MCNSkill项目/ 账号层直挂)+❓帮助页(5功能块问法复制);**AI 任务触发(08-27 Automation 方案定版;19:25 归 mcn-workshop 空间)**:数据页「导入/创作/解析/改写/提炼/复盘」按钮 → 后端 `POST /api/run` → 写 `~/.workbuddy/workbuddy.db` automations 表(once+now,cwds=`D:\AgentSkill\mcn-workshop`)→ WorkBuddy 自动执行创建会话(cwd=mcn-workshop)→ **左侧会话栏 mcn-workshop 空间分组可见**(左侧按 cwd 分组,workspaces 表注册空间);曾错误降级为复制 prompt 已推翻;参考 dsh-plugin-mcn /mcn/api/creative 的 followup 模式;路径白名单防穿越;**用户明确不同步 dsh/Lite**(SKILL.md/帮助文档只改 V1.0,dsh 副本保持无此功能,属刻意 divergence)
|
||||
- **抖音账号分析 mcn-dou-analysis(主版本,08-27 用户宣布)**:源技能=`project/短视频脚本创作/V1.0/subskill/mcn-dou-analysis/`(SKILL.md 19KB);dsh 副本=`C:\Users\maidou\.dsh\skills\mcn-dou-analysis\`;**旧路径 `project/短视频账号分析/case_1/1_账号分析方法/` 已废弃**(08-26 迁移整合,旧三模式/12维度框架已被五大功能体系取代)。五大功能:F1获取账号信息/F2获取视频列表(13列Excel)/F3提炼账号设定(人设卡统一12段)/F4分析视频数据(逐条拆解→汇总两段式)/F5解析视频(单视频MCP);红线要点:达人账号表15列(仅用户要求才增改)、TOP6筛选(点赞+分享降序,唯一权威参数在SKILL.md)、≥60条停止滚动、29赛道枚举、源技能只读单向同步、技能边界(脚本必须切换脚本创作技能S1-S11)、浏览器操作规范(9223调试实例+标签页复用)。后续所有改动以本主版本为准
|
||||
- **短视频分镜脚本** (`project/短视频提示词生成/`,08-20 由"短视频分镜创作"改名,08-26 路径精简、08-27 去 V1.0 层:单版本无需版本层,SKILL.md 直挂技能根) — 提示词工程
|
||||
- 4功能:F1反推图片/F2反推视频/F3生成图片/F4生成分镜;参考 skills:seedance2.0/ShotDesign + seedance25 单文件
|
||||
- 三层引用架构:参考skills→references/→references-add/(运行时优先);**开发机不启用 references-add/**(直接编辑 references/),dsh/用户环境启用(references/ 只读)
|
||||
- 知识库15维度(08-20 重构):01-12用户维度(主体/动作/场景/镜头/运镜/焦距/光线/氛围/色彩/后期/特效/画质)+13音效音频+14构图布局+15视觉风格;词汇归入对应维度
|
||||
- F4输出格式(08-20 重写):以参考技能为准——主题+导演阐述+代码块(>5s时间戳分段/≤5s单段);史诗结构追加品质锚定+连贯声明+收束句;多段分镜(>15s)附过渡策略;MiniMax-H3 引入元素已全部清除
|
||||
- 剧本分析前置:总流程5步→6步(剧本分析→需求分析→路径分流→提示词构建→强制校验→交付);Step1 剧本分析5维度,产出仅上下文驱动不落盘;F2 保留5步
|
||||
- 提示词模板类型分档泛化:类型判定(A叙事剧情/B单人Vlog/C旁白知识/D产品展示/E纯氛围)+5维度①必做②-⑤按类型选做(D/E只做①)+4层×5类型适配表
|
||||
- 公式去向铁律:测算公式只留时间预算表等内部环节,提示词正文只写时长结论(如"1.2s"),禁止算式进正文
|
||||
- seedance25 时间测算体系:制作流程第五节(语速6类公式+动作14类+运镜7类+帧级换算+时间预算表+超调6级+不足处理);长视频分段≤180s;平台标注2.0/2.5
|
||||
- 标准库落库(08-20):`references/制作流程/`4文件+`references/知识库/`15维度;SKILL.md 引用指针切换;审计P0+P1修复完成(断链/校验项/工作流标题/六要素公式段)
|
||||
- dsh 副本回流核对(08-26):副本**无独有内容需回流**(唯一独有项 `name: storyboard-prompt` 环境特有保留);源→dsh 整体部署完成(SKILL.md/两 README 覆盖+name 保留,路径配置.md 不覆盖=工作区特化版有意差异);开发特征6处已修(`mcn-video-script\project\短视频提示词生成`);部署后全目录差异仅 2 处
|
||||
## 核心技能
|
||||
- **短视频脚本创作** `project/短视频脚本创作/{V1.0,Lite1.0}/`:11步流程+知识库12分类(66文件);V1.0 含 MCP/Dify 接口,Lite1.0 去全部接口、模型自带 WebSearch。**同步策略(08-26 用户定)**:只改 V1.0 源技能+dsh 副本两处;Lite1.0 默认不动
|
||||
- **MCN工作台(08-27,仅 V1.0)** `project/短视频脚本创作/V1.0/mcn-work-shop/`(server.js+public/,零依赖 Node,端口8899自动避让);触发「打开工作台」→ 启动服务+内置浏览器打开。hash 路由:首页+📈账号周榜/👥热门账号/账号详情/视频详情/AI写脚本/AI写作复盘(读自有 mcn-plugin.db 副本+dshworkspace 榜单 JSON)+📁产出浏览(桌面 MCNSkill项目/ 账号层直挂)+❓帮助页。**AI 任务触发(08-27 定版;归 mcn-workshop 空间)**:数据页按钮→`confirmRun()` 确认弹窗→`executeTask()` `POST /api/run` 写 `~/.workbuddy/workbuddy.db` automations(once+now,cwds=`D:\AgentSkill\mcn-workshop`)→WorkBuddy 自动执行创建会话→左侧会话栏 mcn-workshop 空间可见;轮询 `GET /api/run/status?id=`(读 automations/automation_runs/automation_runtime_state:running=IN_PROGRESS/running、done=DONE/COMPLETED/SUCCESS、error=ERROR/CANCELLED;5s 间隔 30min 上限)→done/error 恢复按钮(.btn-busy「执行中…」);**用户明确不同步 dsh/Lite**(刻意 divergence);AI脚本选题(08-28):关注账号「创作」→「AI脚本」`confirmAiScript` 选题 textarea→`POST /api/ai/topics`(查库带账号摘要→S5 方法论 prompt→Dify chat-messages recall 即时返回约50s);Dify key 内置 `scripts/MCN_CYLG_API.py` 的 `DEFAULT_CYLG_KEY`(env `DIFY_MCN_CYLG_KEY` 优先;key=app-0vRaxPhvVOqzFQIa5eFzFdjD),`DIFY_URL` 默认 mydify.youmanvideo.com/v1、model 默认 gemini、curl 中文被 CDN 拦推荐 Python、search 易超时用 recall
|
||||
- **抖音账号分析 mcn-dou-analysis(主版本 08-27)**:源=`project/短视频脚本创作/V1.0/subskill/mcn-dou-analysis/`;dsh 副本=`C:\Users\maidou\.dsh\skills\mcn-dou-analysis\`;旧路径 `project/短视频账号分析/case_1/` 已废弃。五大功能:F1账号信息/F2视频列表(13列Excel)/F3提炼账号设定(人设卡12段)/F4分析视频数据(逐条→汇总两段式)/F5解析视频(单视频MCP)。红线:达人账号表15列(仅用户要求增改)、TOP6筛选(点赞+分享降序,权威参数在SKILL.md)、≥60条停止滚动、29赛道枚举、源技能只读单向同步、技能边界(脚本必须切脚本创作技能)、浏览器9223调试实例+标签页复用
|
||||
- **短视频分镜脚本** `project/短视频提示词生成/`(08-27 去 V1.0 层):4功能 F1反推图片/F2反推视频/F3生成图片/F4生成分镜;三层引用架构 参考skills→references/→references-add/(运行时优先;开发机不启用 references-add/,dsh/用户环境启用);知识库15维度(01-12用户+13音效+14构图+15风格);F4输出=主题+导演阐述+代码块(>5s时间戳分段/≤5s单段);提示词模板类型分档(A剧情/B单人Vlog/C旁白知识/D产品展示/E纯氛围)+4层×5类型适配表;公式只留时间预算表等内部环节,提示词正文只写时长结论;seedance25 时间测算体系(语速6公式+动作14+运镜7+帧级换算+超调6级);dsh 副本无独有内容需回流(唯一独有项 `name: storyboard-prompt` 环境保留)
|
||||
|
||||
## 创作技能关键规则(权威源:SKILL.md 关键规则 + references/创作流程规范.md)
|
||||
1. 三态创作模式:⚡自动(默认)=静默执行+只落盘最终交付物;🤝共创=+每确认门询问;🛠️开发=+每步全落盘。7 确认门不可预判合并;创作方法与协作机制分离;角色边界(分析不做创作)
|
||||
1. 三态创作:⚡自动(默认)=静默执行+只落最终交付物;🤝共创=+每确认门询问;🛠️开发=+每步全落盘。7确认门不可合并;角色边界(分析不做创作)
|
||||
2. 内容总监兜底所有质量检查点;文件 `scripts/{视频标题}/XX_阶段名.md`
|
||||
3. 人设卡片 `{账号名}人设卡片.md`:账号层共用,存产出根 `{账号名}/`(dsh=`D:\dshworkspace\创作任务\{账号名}/`、开发机/用户环境=桌面 `MCNSkill项目/{账号名}/`);模板=账号设定(定位6字段+内容怎么做5章节)+达人设定6章节+账号类型三分类(单一达人/固定主角+轮换搭档/群像)+广告植入偏好(可选);**内容规则=账号级"如果…则…"事前约束,非事后验收清单**
|
||||
4. 产物链铁律(P0):`05_故事选题.md`→`06_短视频框架.md`→`07_短视频大纲.md` 顺序产出禁跳步;「产出与传递」永不省略;上游信息只从已确认产物读
|
||||
5. 硬编码禁止(P0):创作流程/知识库/素材库标准库禁账号名角色名;产出目录全允许
|
||||
6. references-add 增量优先(冲突以 references-add 为准,references/ 加⚠️);唯一例外:路径配置以 `references-add/路径配置.md` 为权威
|
||||
7. 三层分离架构:S6情绪规划→S7结构搭建→S9情绪演绎;S6情绪设计卡集中完成,S7只引用不重做;S9分行切镜;闪回≤2次/条;花字/字幕每场≤2条
|
||||
8. 叙事驱动4类:事件/对白/旁白/混合;剧情类禁旁白驱动;S6框架必须明确选择
|
||||
3. 人设卡片 `{账号名}人设卡片.md` 账号层共用(dsh=`D:\dshworkspace\创作任务\{账号名}/`,①③=桌面 `MCNSkill项目/{账号名}/`);模板=账号设定(定位6字段+内容怎么做5章节)+达人设定6章节+账号类型三分类+广告植入偏好(可选);**内容规则=账号级"如果…则…"事前约束,非事后验收清单**
|
||||
4. 产物链铁律(P0):`05_故事选题`→`06_短视频框架`→`07_短视频大纲` 顺序产出禁跳步;「产出与传递」永不省略
|
||||
5. 硬编码禁止(P0):标准库禁账号名角色名;产出目录全允许
|
||||
6. references-add 增量优先(冲突以 references-add 为准,references/ 加⚠️);路径配置以 `references-add/路径配置.md` 为权威
|
||||
7. 三层分离:S6情绪规划→S7结构搭建→S9情绪演绎;S6情绪设计卡集中完成,S7只引用;S9分行切镜;闪回≤2次/条;花字/字幕每场≤2条
|
||||
8. 叙事驱动4类:事件/对白/旁白/混合;剧情类禁旁白驱动;S6必须明确选择
|
||||
9. 情感铺垫分级:强=剧情类(四功能位+蓄力释放+七问自检);中=Vlog/探店;弱=知识/美妆/吃播/三农/户外
|
||||
10. 广告植入:`知识库/11_广告植入/广告植入方法论.md` L1-L4四级20小节;`L#-#`命名与S8字面匹配;L4评估归S11
|
||||
11. MCP:myai-mcp-production(正式)/myai-mcp-test(测试),10工具;首次提问检测提醒安装;飞书登录每天检查
|
||||
12. 分镜流程:分镜只能从S9生成,可选步骤🔶,默认流程结束于S9
|
||||
13. 知识库09/12重构(08-16):09_脚本诊断=三件套(AI检查+人工检查+自动清单八类32项);12_爆款拆解=反推复刻方法论(复刻迁移法则+13字段反推+母提示词模板);对话流程控制移入创作流程规范.md第七章;数据诊断3文件移入账号分析 12_数据诊断/;知识库总数65个
|
||||
14. 技能边界(08-26,已固化进 mcn-dou-analysis 三副本SKILL.md红线表):分析技能止步于五类账号分析侧交付物(达人账号表/短视频数据表格/人设卡/账号数据分析报告/视频解析产物);凡生成或改写脚本(含替换事件重写等轻量场景)必须切换脚本创作技能走S1-S11(含S11数字账取证/开场钩子铁律/编导终审验收),禁止会话内直接手写脚本交付。开场钩子=绝对时长3-7秒硬上限10秒,不看占比;数值校验必须取证列链、禁心算
|
||||
15. 交付物输出边界(08-26,已固化三副本):**验收口径=执行约束,不是输出内容**——铁律/时长占比/数字账取证禁在S9/S10交付物正文展开自证(禁秒级节拍分段/生成链路块/映射表/S11诊断记录);诊断归S11、开发过程归开发模式落盘;交付物只含输出模板定义的字段。固化位置=`创作流程规范.md`「交付物输出边界(通用铁律)」+`9_生成短视频脚本.md`输出模板「输出边界」块;教训=只写"必须写什么"的模板会被自证式加料,出口必须带禁止清单
|
||||
12. 分镜只能从S9生成,可选步骤🔶,默认流程结束于S9
|
||||
13. 知识库09_脚本诊断=三件套(AI检查+人工检查+自动清单八类32项);12_爆款拆解=反推复刻方法论;知识库总数65个
|
||||
14. 技能边界(08-26 固化三副本):分析技能止步于五类账号分析侧交付物;凡生成/改写脚本必须切换脚本创作技能走S1-S11(含S11数字账取证/开场钩子铁律/编导终审验收),禁止会话内直接手写脚本。开场钩子=绝对时长3-7秒硬上限10秒,不看占比;数值校验必须取证列链、禁心算
|
||||
15. 交付物输出边界(08-26 固化三副本):**验收口径=执行约束,不是输出内容**——铁律/时长占比/数字账取证禁在S9/S10交付物正文展开自证(禁秒级节拍分段/生成链路块/映射表/S11诊断记录);交付物只含输出模板定义字段;固化于 `创作流程规范.md`「交付物输出边界」+`9_生成短视频脚本.md`输出模板「输出边界」块
|
||||
|
||||
## 其他项目
|
||||
- **AI创作平台toc** (`project/AI创作平台toc/case_1/`):ToC 个人创作者,四层架构(输入→分析→脚本3版本→对话修改)
|
||||
- **内容创作产品规划** (`project/内容创作产品规划/`):产品功能设计/ + 内容知识库设计/(短剧+短视频);短剧知识库五层分类×三层标签×12创作维度(国内男频11类/女频10类,海外男频6类/女频9类);短视频知识库V2.0 三级分类(27赛道S01-S27×8叙事形态F01-F08×细分垂类)+13创作维度+极致事件双重类型+极致行为三分类
|
||||
- **ljg-skills** (`.workbuddy/skills/ljg-skills/`):通用思维工具 ljg-is/think/rank/structure + ljg-mcn(短视频编导思维,6种共鸣类型+具象场景+细微情绪=强共鸣,禁下钻神经科学)
|
||||
- **AI创作平台toc** `project/AI创作平台toc/case_1/`:ToC 个人创作者,输入→分析→脚本3版本→对话修改
|
||||
- **内容创作产品规划** `project/内容创作产品规划/`:短剧知识库五层分类×三层标签×12维度;短视频知识库V2.0 三级分类(27赛道S01-S27×8叙事形态F01-F08)+13维度+极致事件双重类型
|
||||
- **ljg-skills** `.workbuddy/skills/ljg-skills/`:ljg-is/think/rank/structure + ljg-mcn(6种共鸣类型+具象场景+细微情绪=强共鸣,禁下钻神经科学)
|
||||
|
||||
## 分析方法论规则
|
||||
- **跨技能借鉴/同步判断**:两步独立判断——①数据流验证(信息从哪来→落到哪→下游谁读,断点=缺口)②字段形态决策(直接搬/改造/已有字段承接);禁止用②覆盖①;先画数据流再定形态
|
||||
- **信息链路缺口识别**:Step 有方法论≠Step 能承接账号级信息;通用方法论管"怎么做",账号级信息管"这个账号怎么做才像它",不可互替
|
||||
## 分析方法论
|
||||
- **跨技能借鉴/同步判断**:两步独立判断——①数据流验证(信息从哪来→落到哪→下游谁读,断点=缺口)②字段形态决策(直接搬/改造/承接);禁止用②覆盖①;先画数据流再定形态
|
||||
- **信息链路缺口识别**:Step 有方法论≠Step 能承接账号级信息;通用方法论管"怎么做",账号级信息管"怎么做才像它",不可互替
|
||||
|
||||
## 工作方式约定
|
||||
- **逐条修复**:审计修复必须读一条→改一条→验证一条→再进下一条,禁止批量读取(用户 08-13 明确反馈)
|
||||
- **技能版本标签(08-19 确立;08-26 增补 deployment+时间字段对称)**:frontmatter 存 `updated_at`+`last_change`+`deployment`;源=`source`+`created_at`(架构定型日)+`updated_at`,副本=`dsh`+`updated_at`+`deployed_at`(部署时点);废弃 semver;每次改完顺手更新;副本 deployed_at<源updated_at=滞后;storyboard-prompt 两副本已打标,short-video-script 待打标
|
||||
- **.dsh 单向只读(08-22,用户强约束)**:`.dsh` 副本改动只能「读取→回流源技能」,禁止反向写回(除非用户明确授权);环境特有引用保留 .dsh 原样,回流时做适配;允许 1 处环境差异,不为求一致而覆盖 .dsh
|
||||
- **dsh 差异方向判定(08-26 定案)**:以「08-22 回流后 V1.0=.dsh 完全一致」为基准线+当日日志字面描述判定方向,不靠内容新旧直觉(Lite1.0 因持续同构同步不能当时间锚点)
|
||||
- **生成网页必参考设计技能(08-27 用户强约束)**:生成网页/HTML/前端 UI 时,必须先参考 `.workbuddy/skills/` 下的 **taste-skill** 与 **impeccable** 两个技能(`D:\AgentSkill\mcn-video-script\.workbuddy\skills\` 下)。
|
||||
- **taste-skill**(`taste-skill/SKILL.md`,1206行,design-taste-frontend):Anti-Slop 反模板化前端技能,用于落地页/作品集/重设计(**明确不适用 dashboard/数据表格/多步产品UI**);核心=读需求推断设计方向(Design Read 一句话)+ 三 dials(DESIGN_VARIANCE/MOTION_INTENSITY/VISUAL_DENSITY)+ 避免 LLM 默认审美(AI紫渐变/居中hero/三等宽卡/满屏glassmorphism等)
|
||||
- **impeccable**(`impeccable/SKILL.md`,85行 + agents/4个 + reference/20+命令 + scripts/大量工具):完整界面设计/评审工程技能,覆盖 website/landing/**dashboard**/product UI/components/forms/settings/onboarding/empty states;4 模式(Persuade 说服 / **Operate 操作** / Read 阅读 / Experience 体验)+ 20+ 命令(craft/shape/critique/audit/polish/bolder/quieter/distill/harden/onboard/animate/colorize/typeset/layout/delight/overdrive/clarify/adapt/optimize/live)
|
||||
- **分工**:工作台属 dashboard/product UI → 主参考 impeccable 的 Operate 模式;taste-skill 用于 landing/portfolio/marketing 类页面;生成网页前先读两技能 SKILL.md 再动手
|
||||
- **改完必重开页面(08-28 用户强调)**:MCN工作台等网页改动完成后,最后一步必须用 present_files 重新打开对应页面(带 hash 定位,如 `http://localhost:8899/#/rewrites`);内置浏览器无 CDP 调试端口无法模拟 F5,重开页面是唯一刷新方式
|
||||
- **逐条修复**:审计修复必须读一条→改一条→验证一条→再进下一条,禁止批量读取
|
||||
- **技能版本标签(08-26)**:frontmatter 存 `updated_at`+`last_change`+`deployment`;源=`source`+`created_at`+`updated_at`,副本=`dsh`+`updated_at`+`deployed_at`;副本 deployed_at<源updated_at=滞后
|
||||
- **.dsh 单向只读(08-22 强约束)**:`.dsh` 副本只能「读取→回流源技能」,禁止反向写回(除非用户明确授权);允许1处环境差异
|
||||
- **dsh 差异方向判定(08-26)**:以「08-22 回流后 V1.0=.dsh 完全一致」为基准线+当日日志字面描述判定,不靠内容新旧直觉
|
||||
- **生成网页必参考设计技能(08-27 强约束)**:先读 `.workbuddy/skills/` 下 taste-skill(Anti-Slop,适用于 landing/作品集,不适用 dashboard)与 impeccable(完整界面设计/评审工程,dashboard/product UI 主参考其 Operate 模式)再动手
|
||||
|
||||
## 历史事件(摘要)
|
||||
- 06-25 营销号风格清理;07-17 大炮创作方法136文件;07-20 知识库重组;07-29 短剧知识库V5.0;07-30 短视频知识库V2.0
|
||||
- 08-04 编导分析亮色调+证据标签(对话ID+技能名);08-05 维度磁盘对齐+术语重命名(需求完善→意图完善、选题灵感→故事选题);08-06 ljg-mcn新增+共鸣知识库改造
|
||||
- 08-11→12 素材库扁平化(8类型+四维标签)+去硬编码(30处)+引用链路重构+产物链铁律;08-12 人设卡片V2.1(内容规则认知纠偏);08-13 账号类型三分类判定落地
|
||||
- 08-16 知识库赛道8→27对齐三方数据平台(零残留);"预期操控"→"打破预期"回退;切换关系法补关系四维
|
||||
- 08-17 三态创作模式定稿(三态互斥);08-20 分镜标准库落库+审计P0/P1+seedance25提炼+剧本分析前置+模板泛化
|
||||
- 08-21 Lite1.0 新增(去接口);08-22 .dsh 单向只读确立;08-25 仓库更名 mcn-short-video→mcn-video-script
|
||||
- 08-26 路径重构(project/短视频脚本创作/{V1.0,Lite1.0}/、project/短视频提示词生成/V1.0/)+环境判定两步模型+三技能统一产出结构定稿;**08-27 短视频提示词生成去 V1.0 层**(单版本,git mv 上移至 `project/短视频提示词生成/`)
|
||||
- 08-27 用户宣布 mcn-dou-analysis 为抖音账号分析技能**主版本**:源=`project/短视频脚本创作/V1.0/subskill/mcn-dou-analysis/`(08-26 16:40 SKILL.md),dsh 副本=`C:\Users\maidou\.dsh\skills\mcn-dou-analysis\`(08-26 17:29 SKILL.md,**比源新 49 分钟,待核对是否需回流**)
|
||||
- 08-11→12 素材库扁平化(8类型+四维标签)+去硬编码(30处)+产物链铁律;08-12 人设卡片V2.1;08-16 知识库赛道8→27;08-17 三态创作模式定稿;08-20 分镜标准库落库+seedance25提炼;08-21 Lite1.0 新增;08-22 .dsh 单向只读确立;08-25 仓库更名 mcn-video-script
|
||||
- 08-26 路径重构+环境判定两步模型+三技能统一产出结构定稿;08-27 mcn-dou-analysis 定主版本、短视频提示词生成去 V1.0 层、工作台新增;08-28 S9 格式修正(A格式独立动作叙述去括号)+ 工作台改写弹窗 UI 迭代
|
||||
@@ -8,8 +8,8 @@ description: >
|
||||
触发场景:写脚本、创作短视频、生成脚本、拆解对标视频、分析爆款视频、
|
||||
诊断脚本质量、设计账号人设、选题策划、生成分镜、广告植入设计、生成创作回顾。
|
||||
version: "1.0"
|
||||
updated_at: "2026-08-26"
|
||||
last_change: "MCN工作台 AI 任务触发升级:按钮直接提交 Automation 一次性任务(POST /api/run → 写 ~/.workbuddy/workbuddy.db automations 表 schedule_type=once scheduled_at=now,cwds=D:\\AgentSkill\\mcn-workshop → WorkBuddy 自动执行创建会话,归入 mcn-workshop 空间分组,左侧会话栏可见),取代原『复制 prompt 降级』方案;参考 dsh-plugin-mcn /mcn/api/creative 模式;5 秒防连点;前置:数据库副本隔离+数据功能页"
|
||||
updated_at: "2026-08-28"
|
||||
last_change: "S9格式修正:A·场次叙事独立动作叙述段落去括号(裸写自然段落),括号仅限对白行表演标注(动作/表情/语气);B/C/D 格式括号规则不动;同步 9_生成短视频脚本.md 5处 + 07_脚本格式选择指南 2处 + 创作流程规范.md 2处"
|
||||
deployment: "source"
|
||||
agent_created: true
|
||||
---
|
||||
@@ -72,6 +72,7 @@ agent_created: true
|
||||
4. **数据边界**:数据库读工作台自有副本 `mcn-work-shop/mcn-plugin.db`(由 dsh 库复制而来,与 dsh 原库 `~/.dsh/mcn-plugin.db` 完全隔离,两边各访问各的互不影响);周榜读 `D:\dshworkspace\抖音榜单\*.json` 文件
|
||||
5. **AI 任务触发(Automation 方案)**:各数据页「导入/创作/解析/改写/提炼/复盘」按钮点击后 → 工作台后端 `POST /api/run` → 向 `~/.workbuddy/workbuddy.db` 的 `automations` 表写入一条**一次性任务**(`schedule_type='once'`、`scheduled_at=now`、`cwds=["D:\\AgentSkill\\mcn-workshop"]`)→ WorkBuddy 运行时自动消费执行(创建 AI 会话)→ **会话归入 `mcn-workshop` 空间分组**(左侧会话栏按 cwd 分组,可在该分组查看任务执行过程与结果)。参考 dsh 插件 `dsh-plugin-mcn` 的 `/mcn/api/creative` 模式(固定会话 followup 触发);前端 5 秒防连点
|
||||
6. 本功能仅 V1.0 源技能环境提供,**不同步 dsh/Lite 副本**
|
||||
7. **改完必重开页面(硬性约定)**:对工作台任何页面/代码改动完成后,**最后一步必须用 present_files 重新打开对应页面**(带 hash 直接定位,如 `http://localhost:8899/#/accounts`),让用户在 WorkBuddy 内置浏览器立即看到最新效果。原因:内置浏览器无 CDP 调试端口(9222/9224 等均无响应),无法模拟 F5;重开页面是唯一可靠刷新方式。此约定对工作台所有 UI 改动强制生效
|
||||
|
||||
---
|
||||
## MCP 依赖(摘要)
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
// 验证「导入外部账号数据」移植功能(账号列表页)
|
||||
// 零副作用:mock /api/run 与 /api/run/status,不创建真实任务
|
||||
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
|
||||
let page = list.find((t) => t.type === 'page' && t.url.includes('#/accounts'));
|
||||
if (!page) page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
|
||||
if (!page) { console.log('ERR: no workbuddy page open'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable');
|
||||
|
||||
// 1) 导航到账号列表页(若不在)并刷新加载最新代码
|
||||
if (!page.url.includes('#/accounts')) {
|
||||
await send('Page.navigate', { url: 'http://localhost:8899/#/accounts' });
|
||||
await sleep(2000);
|
||||
}
|
||||
await send('Page.reload', { ignoreCache: true });
|
||||
await sleep(3000);
|
||||
|
||||
// 2) mock fetch:/api/run 与 /api/run/status 返回假数据,其余走真实请求
|
||||
await ev(`(() => {
|
||||
window.__realFetch = window.fetch;
|
||||
window.__mockLog = [];
|
||||
window.fetch = (url, opts) => {
|
||||
const u = String(url);
|
||||
if (u.includes('/api/run/status')) {
|
||||
window.__mockLog.push('status');
|
||||
return Promise.resolve(new Response(JSON.stringify({ ok: true, id: 'mock-task-1', state: 'done' }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
if (u.includes('/api/run')) {
|
||||
window.__mockLog.push('run:' + (opts && opts.body || ''));
|
||||
return Promise.resolve(new Response(JSON.stringify({ ok: true, id: 'mock-task-1', message: 'mock' }), { status: 200, headers: { 'Content-Type': 'application/json' } }));
|
||||
}
|
||||
return window.__realFetch(url, opts);
|
||||
};
|
||||
return 'mocked';
|
||||
})()`);
|
||||
|
||||
// 3) 点工具栏「导入」按钮 → 弹窗(注意:页面有常驻隐藏的 #settingsModal,需用 :not([hidden]) 定位可见弹窗)
|
||||
const openRes = await ev(`(() => {
|
||||
const b = document.querySelector('#accImport');
|
||||
if (!b) return { err: 'no #accImport btn' };
|
||||
b.click();
|
||||
return { title: b.title, text: b.textContent.trim().slice(0, 20) };
|
||||
})()`);
|
||||
console.log('打开导入弹窗:', JSON.stringify(openRes));
|
||||
await sleep(400);
|
||||
const modal = await ev(`(() => {
|
||||
const m = document.querySelector('.modal-mask:not([hidden]) .modal');
|
||||
if (!m) return { err: 'no visible modal', masks: document.querySelectorAll('.modal-mask:not([hidden])').length };
|
||||
return {
|
||||
h3: m.querySelector('h3').textContent.trim(),
|
||||
inputs: [...m.querySelectorAll('input')].map((i) => ({ ph: i.placeholder, data: i.dataset.imp || '' })),
|
||||
desc: m.textContent.replace(/\\s+/g, ' ').slice(0, 120),
|
||||
};
|
||||
})()`);
|
||||
console.log('弹窗结构:', JSON.stringify(modal));
|
||||
|
||||
// 4) 空提交 → toast 校验
|
||||
const emptySubmit = await ev(`(() => {
|
||||
const m = document.querySelector('.modal-mask:not([hidden]) .modal');
|
||||
m.querySelector('[data-ok]').click();
|
||||
return 'clicked';
|
||||
})()`);
|
||||
await sleep(300);
|
||||
const toast1 = await ev(`(() => { const t = document.getElementById('toast'); return t ? t.textContent + '|show=' + t.classList.contains('show') : null; })()`);
|
||||
const modalStill = await ev(`(() => !!document.querySelector('.modal-mask:not([hidden]) .modal'))()`);
|
||||
console.log('空提交 → toast:', JSON.stringify(toast1), '| 弹窗仍在:', modalStill);
|
||||
|
||||
// 5) 填账号名 → 提交 → 弹窗关闭 + 按钮执行中
|
||||
const fillSubmit = await ev(`(() => {
|
||||
const m = document.querySelector('.modal-mask:not([hidden]) .modal');
|
||||
m.querySelector('[data-imp-name]').value = '旧梦留声机';
|
||||
m.querySelector('[data-imp-did]').value = 'jiu_meng_ai';
|
||||
m.querySelector('[data-ok]').click();
|
||||
return 'submitted';
|
||||
})()`);
|
||||
await sleep(600);
|
||||
const afterSubmit = await ev(`(() => {
|
||||
const b = document.querySelector('#accImport');
|
||||
return { modalGone: !document.querySelector('.modal-mask:not([hidden]) .modal'), btnText: b.textContent.trim().slice(0, 20), btnDisabled: b.disabled, mockLog: window.__mockLog };
|
||||
})()`);
|
||||
console.log('提交后:', JSON.stringify(afterSubmit));
|
||||
|
||||
// 6) 等轮询(mock 立即 done)→ 恢复 + 刷新
|
||||
await sleep(6000);
|
||||
const finalState = await ev(`(() => {
|
||||
const b = document.querySelector('#accImport');
|
||||
return { btnText: b.textContent.trim().slice(0, 20), btnDisabled: b.disabled, mockLog: window.__mockLog };
|
||||
})()`);
|
||||
console.log('轮询后:', JSON.stringify(finalState));
|
||||
|
||||
// 7) 恢复真实 fetch
|
||||
await ev(`window.fetch = window.__realFetch; 'restored'`);
|
||||
|
||||
// 8) 帮助页问法检查(导入问法已加入账号分析组)
|
||||
await send('Page.navigate', { url: 'http://localhost:8899/#/help' });
|
||||
await sleep(2500);
|
||||
const helpCheck = await ev(`(() => {
|
||||
const rows = [...document.querySelectorAll('.qa-row')];
|
||||
const hit = rows.find((r) => r.textContent.includes('导入账号列表'));
|
||||
return { hasImportQA: !!hit, qa: hit ? hit.querySelector('.q').textContent : null, total: rows.length };
|
||||
})()`);
|
||||
console.log('帮助页导入问法:', JSON.stringify(helpCheck));
|
||||
|
||||
ws.close();
|
||||
console.log('DONE');
|
||||
@@ -0,0 +1,123 @@
|
||||
// 验证「单向联动」v2:全部用 JS 设置 scrollTop 触发 scroll 事件(避免 mouseWheel SIGTERM)
|
||||
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
|
||||
const page = list.find((t) => t.type === 'page' && t.url.includes('#/rewrites'));
|
||||
if (!page) { console.log('ERR: no #/rewrites page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable');
|
||||
|
||||
await send('Page.reload', { ignoreCache: true });
|
||||
await sleep(3000);
|
||||
await ev(`document.querySelectorAll('.modal-mask').forEach((m) => m.remove()); 'cleaned'`);
|
||||
await ev(`(() => { const i = document.querySelector('#rwSearch'); if (i) { i.value = '快递'; i.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); } return !!i; })()`);
|
||||
await sleep(2000);
|
||||
const clickRes = await ev(`(() => {
|
||||
const btns = [...document.querySelectorAll('[data-detail]')];
|
||||
if (!btns.length) return { err: 'no data-detail' };
|
||||
btns[0].click();
|
||||
return { clicked: btns[0].dataset.detail };
|
||||
})()`);
|
||||
console.log('打开弹窗:', JSON.stringify(clickRes));
|
||||
await sleep(2500);
|
||||
|
||||
const st = async () => ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
if (!secs.length) return { err: 'no rw-stack' };
|
||||
const ai = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI脚本');
|
||||
const sb = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI分镜提示词');
|
||||
if (!ai || !sb) return { err: 'missing col', heads: secs.map((s) => s.querySelector('.rw-sec-head')?.textContent.trim()) };
|
||||
const aiBody = ai.querySelector('.rw-sec-body'); const sbBody = sb.querySelector('.rw-sec-body');
|
||||
const vis = [...sbBody.querySelectorAll('.sb-block')].find((b) => b.getBoundingClientRect().top >= sbBody.getBoundingClientRect().top - 4);
|
||||
return {
|
||||
aiScrollTop: Math.round(aiBody.scrollTop), sbScrollTop: Math.round(sbBody.scrollTop),
|
||||
aiHeads: aiBody.querySelectorAll('strong').length, sbBlocks: sbBody.querySelectorAll('.sb-block').length,
|
||||
sbVis: vis ? (vis.querySelector('.sb-head')?.textContent.trim() || '').slice(0, 30) : null,
|
||||
};
|
||||
})()`);
|
||||
|
||||
const init = await st();
|
||||
console.log('初始:', JSON.stringify(init));
|
||||
|
||||
// 翻到有分镜的 AI 脚本版本(rewrite 22)
|
||||
if ((init.sbBlocks || 0) === 0) {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const hasNext = await ev(`(() => { const b = document.querySelector('[data-vnext]'); if (!b || b.disabled) return false; b.click(); return true; })()`);
|
||||
if (!hasNext) break;
|
||||
await sleep(900);
|
||||
const s = await st();
|
||||
console.log('翻版本后:', JSON.stringify(s));
|
||||
if ((s.sbBlocks || 0) > 0) break;
|
||||
}
|
||||
}
|
||||
const ready = await st();
|
||||
console.log('就绪:', JSON.stringify(ready));
|
||||
if (!(ready.sbBlocks > 0)) { console.log('ERR: 无分镜版本可验证'); process.exit(0); }
|
||||
|
||||
// ===== 链路①:AI脚本列滚动 → 分镜列应对齐(设置scrollTop+补发scroll事件模拟滚动) =====
|
||||
const setAi = async (delta, tag) => {
|
||||
const r = await ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const aiBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI脚本').querySelector('.rw-sec-body');
|
||||
aiBody.scrollTop += ${delta};
|
||||
aiBody.dispatchEvent(new Event('scroll')); // CDP下scrollTop赋值不触发事件,补发
|
||||
return Math.round(aiBody.scrollTop);
|
||||
})()`);
|
||||
await sleep(350);
|
||||
const s = await st();
|
||||
console.log(`${tag}: aiScrollTop=${r} → sbScrollTop=${s.sbScrollTop} sbVis=${s.sbVis}`);
|
||||
return s;
|
||||
};
|
||||
let cur = await setAi(400, '链路① 第1步滚400');
|
||||
cur = await setAi(400, '链路① 第2步滚400');
|
||||
cur = await setAi(400, '链路① 第3步滚400');
|
||||
const link1 = {
|
||||
aiMoved: Math.abs(cur.aiScrollTop - ready.aiScrollTop) > 50,
|
||||
sbMoved: Math.abs(cur.sbScrollTop - ready.sbScrollTop) > 50,
|
||||
};
|
||||
console.log('链路①判定:', JSON.stringify(link1), link1.aiMoved && link1.sbMoved ? '✅ AI脚本主导联动生效' : '❌ 联动异常');
|
||||
|
||||
// ===== 链路②:分镜列滚动 → AI脚本列独立 =====
|
||||
const beforeSb = await st();
|
||||
const r2 = await ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const sbBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI分镜提示词').querySelector('.rw-sec-body');
|
||||
sbBody.scrollTop += 600;
|
||||
sbBody.dispatchEvent(new Event('scroll'));
|
||||
return Math.round(sbBody.scrollTop);
|
||||
})()`);
|
||||
await sleep(350);
|
||||
const afterSb = await st();
|
||||
console.log(`链路② 分镜列滚600: sbScrollTop=${r2} → aiScrollTop=${afterSb.aiScrollTop} sbVis=${afterSb.sbVis}`);
|
||||
const link2 = {
|
||||
sbMoved: Math.abs(afterSb.sbScrollTop - beforeSb.sbScrollTop) > 50,
|
||||
aiMovedBack: Math.abs(afterSb.aiScrollTop - beforeSb.aiScrollTop) > 5,
|
||||
};
|
||||
console.log('链路②判定:', JSON.stringify(link2), link2.sbMoved && !link2.aiMovedBack ? '✅ 分镜列独立滚动,AI脚本列未动' : (link2.sbMoved ? '❌ AI脚本列被反向联动' : '❌ 分镜列未滚动'));
|
||||
|
||||
// ===== 链路①b:AI脚本列跳底部 → 分镜列跟随最后场 =====
|
||||
const jump = await ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const aiBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI脚本').querySelector('.rw-sec-body');
|
||||
aiBody.scrollTop = aiBody.scrollHeight;
|
||||
aiBody.dispatchEvent(new Event('scroll'));
|
||||
return { set: Math.round(aiBody.scrollTop) };
|
||||
})()`);
|
||||
await sleep(400);
|
||||
const afterJump = await st();
|
||||
const detail = await ev(`(() => {
|
||||
const secs = [...document.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const aiBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI脚本').querySelector('.rw-sec-body');
|
||||
const heads = [...aiBody.querySelectorAll('strong, h1, h2, h3, h4')].filter((el) => /场\\s*\\d/.test(el.textContent));
|
||||
const sbBody = secs.find((s) => s.querySelector('.rw-sec-head')?.textContent.trim() === 'AI分镜提示词').querySelector('.rw-sec-body');
|
||||
const vis = [...sbBody.querySelectorAll('.sb-block')].find((b) => b.getBoundingClientRect().top >= sbBody.getBoundingClientRect().top - 4);
|
||||
return { lastAiHead: heads.length ? heads[heads.length - 1].textContent.trim().slice(0, 24) : null, sbVis: vis ? (vis.querySelector('.sb-head')?.textContent.trim() || '').slice(0, 30) : null };
|
||||
})()`);
|
||||
console.log('链路①b 跳底部:', JSON.stringify({ jump, sbScrollTop: afterJump.sbScrollTop, detail }));
|
||||
|
||||
try { ws.close(); } catch (e) {}
|
||||
process.exit(0);
|
||||
@@ -0,0 +1,76 @@
|
||||
// 验证:①展开源脚本按钮移到 AI脚本 标题右侧(间隔 10px)②弹窗最大宽度 1400→1600
|
||||
const list = await fetch('http://localhost:9223/json').then((r) => r.json());
|
||||
let page = list.find((t) => t.type === 'page' && t.url.includes('localhost:8899'));
|
||||
if (!page) { console.log('ERR: no workbuddy page'); process.exit(1); }
|
||||
const ws = new WebSocket(page.webSocketDebuggerUrl);
|
||||
let id = 0; const pending = new Map();
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej }); ws.send(JSON.stringify({ id: i, method, params })); });
|
||||
ws.onmessage = (e) => { const m = JSON.parse(e.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.rej(new Error(m.error.message)) : p.res(m.result); } };
|
||||
await new Promise((r) => (ws.onopen = r));
|
||||
const ev = async (expr) => { const r = await send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true }); if (r.exceptionDetails) return { __err: (r.exceptionDetails.exception?.description || r.exceptionDetails.text).slice(0, 300) }; return r.result.value; };
|
||||
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
||||
await send('Page.enable'); await send('Runtime.enable');
|
||||
|
||||
if (!page.url.includes('#/rewrites')) {
|
||||
await send('Page.navigate', { url: 'http://localhost:8899/#/rewrites' });
|
||||
await sleep(2000);
|
||||
}
|
||||
await send('Page.reload', { ignoreCache: true });
|
||||
await sleep(3000);
|
||||
|
||||
// 打开第一条改写的查看弹窗
|
||||
await ev(`(() => { const b = document.querySelector('[data-detail]'); if (!b) return 'no-detail-btn'; b.click(); return 'opened'; })()`);
|
||||
await sleep(2500);
|
||||
|
||||
// ① 按钮位置:应在 AI脚本 .rw-sec-head 内,与「AI脚本」文字间隔 10px
|
||||
const pos = await ev(`(() => {
|
||||
const head = [...document.querySelectorAll('.rw-sec-head')].find((h) => h.textContent.trim().startsWith('AI脚本'));
|
||||
if (!head) return { err: 'no AI脚本 head' };
|
||||
const btn = head.querySelector('[data-src-toggle]');
|
||||
if (!btn) return { err: 'no toggle in AI head' };
|
||||
const textNode = [...head.childNodes].find((n) => n.nodeType === 3 && n.textContent.includes('AI脚本'));
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(textNode);
|
||||
const textRect = range.getBoundingClientRect();
|
||||
const btnRect = btn.getBoundingClientRect();
|
||||
// 文字右缘 → 按钮左缘 的水平距离(同一行)
|
||||
const gap = btnRect.left - textRect.right;
|
||||
// 顶部标题行不应再有 toggle
|
||||
const titleRowHasToggle = !!document.querySelector('.modal-title-row [data-src-toggle]');
|
||||
return {
|
||||
inAiHead: true,
|
||||
gap: Math.round(gap * 100) / 100,
|
||||
btnTitle: btn.title,
|
||||
verticalAligned: Math.abs(btnRect.top - textRect.top) < 4,
|
||||
titleRowHasToggle,
|
||||
};
|
||||
})()`);
|
||||
console.log('① 按钮位置:', JSON.stringify(pos));
|
||||
|
||||
// ② 点击按钮 → 源脚本列展开
|
||||
await ev(`(() => { const b = document.querySelector('[data-src-toggle]'); b.click(); return 'toggled'; })()`);
|
||||
await sleep(600);
|
||||
const expanded = await ev(`(() => {
|
||||
const srcSec = document.querySelector('[data-src-sec]');
|
||||
const toggle = document.querySelector('[data-src-toggle]');
|
||||
return { srcVisible: srcSec && getComputedStyle(srcSec).display !== 'none', toggleTitle: toggle ? toggle.title : null };
|
||||
})()`);
|
||||
console.log('② 点击后展开源脚本:', JSON.stringify(expanded));
|
||||
await ev(`(() => { const b = document.querySelector('[data-src-toggle]'); b.click(); return 'toggled-back'; })()`);
|
||||
await sleep(500);
|
||||
|
||||
// ③ 弹窗宽度:多视口验证 1600 封顶
|
||||
const widthAt = async (vw) => {
|
||||
await send('Emulation.setDeviceMetricsOverride', { width: vw, height: 900, deviceScaleFactor: 1, mobile: false });
|
||||
await sleep(500);
|
||||
return ev(`(() => { const m = document.querySelector('.rw-modal'); if (!m) return null; const r = m.getBoundingClientRect(); return { vw: window.innerWidth, w: Math.round(r.width), left: Math.round(r.left), right: Math.round(window.innerWidth - r.right) }; })()`);
|
||||
};
|
||||
const w1200 = await widthAt(1200);
|
||||
const w1500 = await widthAt(1500);
|
||||
const w1700 = await widthAt(1700);
|
||||
const w2000 = await widthAt(2000);
|
||||
console.log('③ 弹窗宽度:', JSON.stringify({ w1200, w1500, w1700, w2000 }));
|
||||
await send('Emulation.clearDeviceMetricsOverride');
|
||||
|
||||
ws.close();
|
||||
console.log('DONE');
|
||||
@@ -27,6 +27,18 @@ function getDb() {
|
||||
}
|
||||
function dbAvailable() { return getDb() !== null; }
|
||||
|
||||
// 收藏切换:可写连接单独打开(与只读 getDb 隔离),操作完即关闭
|
||||
function setFavorite(accountId, fav) {
|
||||
if (!fs.existsSync(DSH_DB)) throw new Error('数据库不可用');
|
||||
const w = new DatabaseSync(DSH_DB);
|
||||
try {
|
||||
const r = w.prepare(`UPDATE hot_accounts SET favorite=? WHERE id=?`).run(fav ? 1 : 0, Number(accountId));
|
||||
if (r.changes === 0) throw new Error('账号不存在');
|
||||
} finally {
|
||||
w.close();
|
||||
}
|
||||
}
|
||||
|
||||
const num = (v) => { const n = Number(v); return Number.isFinite(n) ? n : 0; };
|
||||
const fmt = (v) => (v == null || v === '') ? null : v;
|
||||
|
||||
@@ -70,7 +82,7 @@ function listAccountVideos(accountId, page = 1, pageSize = 20) {
|
||||
SELECT v.*,
|
||||
(SELECT COUNT(*) FROM account_video_source s WHERE s.video_id=v.id OR s.aweme_id=v.aweme_id) AS has_source,
|
||||
(SELECT COUNT(*) FROM account_video_analysis a WHERE a.video_id=v.id OR a.aweme_id=v.aweme_id) AS has_analysis
|
||||
FROM account_videos v WHERE v.account_id=? ORDER BY v.id ASC LIMIT ? OFFSET ?
|
||||
FROM account_videos v WHERE v.account_id=? ORDER BY v.publish_time IS NULL, v.publish_time DESC, v.id DESC LIMIT ? OFFSET ?
|
||||
`).all(accountId, limit, offset);
|
||||
return { account: acc, total, page: Math.max(1, Number(page) || 1), pageSize: limit, items };
|
||||
}
|
||||
@@ -305,7 +317,7 @@ function getRanking({ date = '', category = '', sortBy = 'rank', order = '', pag
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
dbAvailable, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
|
||||
dbAvailable, setFavorite, listTracks, listAccounts, listAccountVideos, getPersona, getAccountAnalysis,
|
||||
listVideos, getVideoDetail, getVideoScript, listRewrites, listStoryboards, listRecentScripts,
|
||||
getReviewDetail, getReviewFull, getStats, rankingMeta, getRanking, DSH_DB, RANKING_DIR,
|
||||
};
|
||||
Binary file not shown.
@@ -6,9 +6,148 @@
|
||||
/* ================= 工具 ================= */
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
/* 清理标题首尾装饰性引号(英文/中文双引号、单引号、书名号式引号);仅首尾,中间引号保留 */
|
||||
const stripTitleQuotes = (s) => String(s || '').trim().replace(/^["“”'‘’「『]+|["“”'‘’」』]+$/g, '');
|
||||
/* 视频标题清理:标题主体(# 前)去首尾引号,# 标签原样保留(列表页完整标题显示用) */
|
||||
const cleanTitle = (t) => {
|
||||
const s = String(t || '').trim();
|
||||
const idx = s.indexOf('#');
|
||||
if (idx <= 0) return stripTitleQuotes(s);
|
||||
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'; };
|
||||
const EXT_ICON = { md: 'file-text', json: 'file-code', xlsx: 'table', csv: 'table', txt: 'file-text', log: 'file-text', bak: 'file' };
|
||||
|
||||
/* ================= AI 任务触发(确认弹窗 + 执行中状态) =================
|
||||
流程:点击按钮 → 确认弹窗 → 提交 /api/run → 按钮变「执行中…」禁用
|
||||
→ 轮询 /api/run/status 直到任务完成(done/error)→ 按钮恢复 */
|
||||
|
||||
// 任务完成后自动刷新当前数据页(无需手动刷新即可看到最新数据)
|
||||
function refreshCurrentPage() {
|
||||
const view = $('#view');
|
||||
if (view && typeof view._reload === 'function') { view._reload(); return; }
|
||||
const { name } = parseHash();
|
||||
const dataPages = ['ranking', 'accounts', 'account', 'video', 'rewrites', 'reviews'];
|
||||
if (name === '' || dataPages.includes(name)) router();
|
||||
}
|
||||
|
||||
// 提交任务 + 按钮执行中状态 + 轮询恢复(供 confirmSaveAccount/confirmRun 确认后调用)
|
||||
async function executeTask(opts) {
|
||||
const btn = opts.btn;
|
||||
const oldText = btn ? btn.innerHTML : '';
|
||||
const restore = () => { if (btn) { btn.disabled = false; btn.innerHTML = oldText; btn.classList.remove('btn-busy'); } };
|
||||
if (btn) { btn.disabled = true; btn.innerHTML = '执行中…'; btn.classList.add('btn-busy'); }
|
||||
let taskId = null;
|
||||
try {
|
||||
const d = await api('/api/run', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ prompt: opts.prompt, name: opts.label || '工作台任务' }),
|
||||
});
|
||||
if (d.error) throw new Error(d.error);
|
||||
taskId = d.id;
|
||||
showToast('✅ 任务已提交,请在左侧会话栏查看执行');
|
||||
} catch (e) {
|
||||
showToast('❌ 任务提交失败: ' + e.message);
|
||||
restore();
|
||||
return;
|
||||
}
|
||||
// 轮询任务状态直到完成(最长 30 分钟,账号提炼等任务耗时较长)
|
||||
const deadline = Date.now() + 30 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 5000));
|
||||
let s = null;
|
||||
try { s = await api('/api/run/status?id=' + encodeURIComponent(taskId)); } catch (e) { continue; }
|
||||
if (!s || s.state === 'notfound') break;
|
||||
if (s.state === 'done') { showToast('✅ 任务执行完成,页面已自动刷新'); refreshCurrentPage(); break; }
|
||||
if (s.state === 'error') { showToast('❌ 任务执行失败' + (s.error ? ':' + s.error : '')); break; }
|
||||
// queued / pending / running → 继续等待
|
||||
}
|
||||
restore();
|
||||
}
|
||||
|
||||
// 通用确认弹窗:确认后执行 AI 任务(opts: title/desc/target/url/prompt/label/btn/afterText)
|
||||
function confirmRun(opts) {
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `
|
||||
<div class="modal">
|
||||
<h3>${svgIcon('zap', 18)} ${esc(opts.title)}</h3>
|
||||
<p class="dim">${esc(opts.desc)}</p>
|
||||
${opts.target ? `<div class="confirm-target">${esc(opts.target)}</div>` : ''}
|
||||
${opts.url ? `<div class="confirm-url">${esc(opts.url)}</div>` : ''}
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" data-cancel>取消</button>
|
||||
<button class="primary" data-ok>确认执行</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
const close = () => mask.remove();
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
|
||||
mask.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
mask.querySelector('[data-ok]').addEventListener('click', () => { close(); executeTask(opts); });
|
||||
}
|
||||
|
||||
// 保存账号确认(首页周榜 + 完整周榜页共用)
|
||||
function confirmSaveAccount(name, url, btn) {
|
||||
confirmRun({
|
||||
title: '分析账号',
|
||||
desc: '确认分析该账号并添加到账号列表(账号信息 + 视频列表)?',
|
||||
target: name,
|
||||
url: url || '',
|
||||
prompt: `请帮我把抖音账号「${name}」保存并分析(账号信息 + 视频列表):${url || ''}`,
|
||||
label: '保存指令',
|
||||
btn,
|
||||
});
|
||||
}
|
||||
|
||||
// 导入外部账号数据(移植自 dsh 插件「热门账号 → 导入」):
|
||||
// 方式一:抖音账号信息(名称/抖音号/地址)→ AI 网页采集账号信息与视频列表入库
|
||||
// 方式二:本地文件夹绝对路径 → AI 读取目录分析文件结构后入库
|
||||
// 提交复用 executeTask 任务链路(/api/run → 左侧会话栏可见 → 完成自动刷新)
|
||||
function openImportAccounts(btn) {
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `
|
||||
<div class="modal" style="max-width:520px">
|
||||
<h3>${svgIcon('download', 18)} 导入外部账号数据</h3>
|
||||
<div class="import-form">
|
||||
<p class="dim" style="margin-bottom:10px">方式一:填写抖音账号信息,AI 通过抖音网页获取账号信息与视频列表。</p>
|
||||
<div class="f-row"><label>账号名称</label>
|
||||
<input class="input" placeholder="如:旧梦留声机" data-imp-name></div>
|
||||
<div class="f-row"><label>抖音号</label>
|
||||
<input class="input" placeholder="如:jiu_meng_ai" data-imp-did></div>
|
||||
<div class="f-row"><label>账号地址(可选)</label>
|
||||
<input class="input" placeholder="https://www.douyin.com/user/..." data-imp-url></div>
|
||||
<div class="divider"></div>
|
||||
<p class="dim" style="margin-bottom:10px">方式二:输入本地数据文件夹绝对路径,AI 读取目录分析文件结构后导入。</p>
|
||||
<div class="f-row"><input class="input" placeholder="本地文件夹路径,如 D:\\账号对标" data-imp-dir></div>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" data-cancel>取消</button>
|
||||
<button class="primary" data-ok>提交导入</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
const close = () => mask.remove();
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
|
||||
mask.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
mask.querySelector('[data-ok]').addEventListener('click', () => {
|
||||
const dir = (mask.querySelector('[data-imp-dir]').value || '').trim();
|
||||
const name = (mask.querySelector('[data-imp-name]').value || '').trim();
|
||||
const did = (mask.querySelector('[data-imp-did]').value || '').trim();
|
||||
const url = (mask.querySelector('[data-imp-url]').value || '').trim();
|
||||
if (!dir && !name && !did && !url) { showToast('请填写抖音账号信息或文件夹路径'); return; }
|
||||
let prompt;
|
||||
if (dir) {
|
||||
prompt = `请帮我把本地文件夹「${dir}」中的账号数据导入:读取目录、分析文件结构,确认后写入账号列表`;
|
||||
} else {
|
||||
const parts = [name ? `账号名称「${name}」` : '', did ? `抖音号「${did}」` : '', url ? `账号地址「${url}」` : ''].filter(Boolean).join(',');
|
||||
prompt = `请帮我把抖音账号 ${parts} 导入:通过抖音网页获取账号信息与视频列表,写入账号列表并保存到本地`;
|
||||
}
|
||||
close();
|
||||
executeTask({ prompt, label: '导入指令', btn });
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 扁平化线性图标(Lucide 风格:24 viewBox / stroke currentColor / 无底色) ================= */
|
||||
const ICON_PATHS = {
|
||||
'film': '<path d="M20.2 6 3 11l-.9-2.4c-.3-1.1.3-2.2 1.3-2.5l13.5-4c1.1-.3 2.2.3 2.5 1.3Z"/><path d="m6.2 5.3 3.1 3.9"/><path d="m12.4 3.4 3.1 4"/><path d="M3 11h18v8a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2Z"/>',
|
||||
@@ -32,6 +171,7 @@ const ICON_PATHS = {
|
||||
'arrow-left': '<path d="m12 19-7-7 7-7"/><path d="M19 12H5"/>',
|
||||
'x': '<path d="M18 6 6 18"/><path d="m6 6 12 12"/>',
|
||||
'zap': '<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/>',
|
||||
'download': '<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" x2="12" y1="15" y2="3"/>',
|
||||
};
|
||||
|
||||
// 功能图标亮色映射(不同功能不同亮色;未列出的保持 currentColor 中性)
|
||||
@@ -40,10 +180,12 @@ const ICON_COLORS = {
|
||||
'lightbulb': '#d99a2b', 'refresh': '#0f7f8f', 'chart': '#d6336c', 'wrench': '#5b6470',
|
||||
};
|
||||
|
||||
function svgIcon(name, size = 16) {
|
||||
function svgIcon(name, size = 16, color) {
|
||||
const p = ICON_PATHS[name] || ICON_PATHS['file'];
|
||||
const color = ICON_COLORS[name] ? ` style="color:${ICON_COLORS[name]}"` : '';
|
||||
return `<svg class="ic" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${color}>${p}</svg>`;
|
||||
// color 参数覆盖彩色映射:传 'currentColor'/'inherit' 等可让图标跟随字体颜色
|
||||
const col = color !== undefined ? color : ICON_COLORS[name];
|
||||
const style = col ? ` style="color:${col}"` : '';
|
||||
return `<svg class="ic" viewBox="0 0 24 24" width="${size}" height="${size}" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"${style}>${p}</svg>`;
|
||||
}
|
||||
|
||||
// 把 [data-icon] 占位元素替换为 SVG 图标(静态 HTML 里的 header 按钮等)
|
||||
@@ -134,7 +276,8 @@ const FUNC_GROUPS = [
|
||||
['"基于「旧梦留声机」账号设定,替换事件写一个脚本"', '读账号设定,按账号套路写全新事件脚本'],
|
||||
['"把刚写的脚本展开成分镜"', '按分镜技能生成镜头级描述'],
|
||||
]},
|
||||
{ icon: 'refresh', title: '账号分析', desc: '提炼账号设定 · 分析视频 · 分析账号', qa: [
|
||||
{ icon: 'refresh', title: '账号分析', desc: '导入账号 · 提炼账号设定 · 分析视频 · 分析账号', qa: [
|
||||
['"把抖音账号「俊希」导入账号列表"', '网页采集账号信息 + 视频列表入库'],
|
||||
['"帮我设计一个三农赛道的达人IP"', '提炼账号设定(人设卡)'],
|
||||
['"帮我拆解这个抖音视频"', '解析视频,拆解钩子/节奏/情绪/选题'],
|
||||
['"分析一下这个账号的内容套路"', '账号内容结构拆解 + 定位诊断'],
|
||||
@@ -152,10 +295,10 @@ const FUNC_GROUPS = [
|
||||
/* ================= 首页 ================= */
|
||||
function renderHome() {
|
||||
const cards = [
|
||||
{ icon: 'trending-up', title: '抖音周榜', desc: '本地周榜数据 · 赛道/日期筛选', hash: '#/ranking' },
|
||||
{ icon: 'users', title: '热门账号', desc: '签约/外部账号 + 视频列表', hash: '#/accounts' },
|
||||
{ icon: 'pen', title: 'AI写脚本', desc: '源脚本 vs AI脚本对比 + 分镜', hash: '#/rewrites' },
|
||||
{ icon: 'flask', title: 'AI写作复盘', desc: '评分维度 + 复盘报告', hash: '#/reviews' },
|
||||
{ icon: 'trending-up', title: '账号周榜', desc: '三方数据 · 赛道/日期筛选', hash: '#/ranking' },
|
||||
{ icon: 'users', title: '账号列表', desc: '关注的账号 + 视频列表', hash: '#/accounts' },
|
||||
{ icon: 'pen', title: 'AI写脚本', desc: 'AI生成脚本+分镜提示词', hash: '#/rewrites' },
|
||||
{ icon: 'flask', title: 'AI写作复盘', desc: '检查清单+复盘报告', hash: '#/reviews' },
|
||||
];
|
||||
return `
|
||||
<div class="home">
|
||||
@@ -168,7 +311,7 @@ function renderHome() {
|
||||
</div>
|
||||
<div class="rank-preview">
|
||||
<div class="recent-head">
|
||||
<h3>抖音周榜</h3>
|
||||
<h3>账号周榜</h3>
|
||||
<div class="rank-tools">
|
||||
<select id="rankCat" class="input-select rank-filter" title="赛道筛选"></select>
|
||||
<button class="btn btn-sm" id="rankPrevDate" title="上一期">◀</button>
|
||||
@@ -189,6 +332,10 @@ function renderHome() {
|
||||
function renderHelp() {
|
||||
return `
|
||||
<div class="home">
|
||||
<div class="page-toolbar">
|
||||
<button class="btn" data-back title="返回">←</button>
|
||||
<span class="page-title">功能帮助</span>
|
||||
</div>
|
||||
<div class="intro">
|
||||
<p>直接对 WorkBuddy 说需求即可,无需记指令。<strong>点击任意问法即可复制</strong>,粘贴到对话框回车执行:</p>
|
||||
</div>
|
||||
@@ -240,7 +387,7 @@ async function loadRecentScripts() {
|
||||
_recentScriptsCache = {};
|
||||
el.innerHTML = items.map((s) => {
|
||||
_recentScriptsCache[s.id] = s;
|
||||
const title = s.title || s.video_title || '未命名剧本';
|
||||
const title = cleanTitle(s.title || s.video_title) || '未命名剧本';
|
||||
const meta = [s.account_name, s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | ');
|
||||
const preview = previewLines(s.script_text, 5);
|
||||
return `<button class="recent-card" data-script="${s.id}" title="点击查看完整剧本">
|
||||
@@ -256,7 +403,7 @@ async function loadRecentScripts() {
|
||||
} catch (e) { el.innerHTML = '<span class="dim">' + esc(e.message) + '</span>'; }
|
||||
}
|
||||
|
||||
/* ================= 首页 抖音周榜 预览(前10 + 赛道/日期筛选,无分页) ================= */
|
||||
/* ================= 首页 账号周榜 预览(前10 + 赛道/日期筛选,无分页) ================= */
|
||||
let _rankPreview = { page: 1, pageSize: 10, dates: [], categories: [], date: '', category: '' };
|
||||
function renderRankTools() {
|
||||
const catSel = $('#rankCat');
|
||||
@@ -295,19 +442,19 @@ async function loadRankingPreview() {
|
||||
<span class="rk-num">${esc(r.newLikes ?? '-')}</span>
|
||||
<span class="rk-num">${esc(r.newComments ?? '-')}</span>
|
||||
<span class="rk-num">${esc(r.newShares ?? '-')}</span>
|
||||
<span class="rk-act">${r.inDb && r.dbId ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-rk-import data-rk-name="${esc(r.accountName)}" data-rk-url="${esc(r.profileUrl || '')}" title="导入并分析该账号">导入</button>`}</span>
|
||||
<span class="rk-act">${r.inDb && r.dbId ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-rk-import data-rk-name="${esc(r.accountName)}" data-rk-url="${esc(r.profileUrl || '')}" title="分析该账号并加入账号列表">分析</button>`}</span>
|
||||
</div>`).join('') : '<span class="dim" style="padding:12px">暂无周榜数据</span>';
|
||||
listEl.querySelectorAll('[data-rk-import]').forEach((b) => b.addEventListener('click', () => {
|
||||
firePrompt(`请帮我把抖音账号「${b.dataset.rkName}」导入并分析(账号信息 + 视频列表):${b.dataset.rkUrl || ''}`, '导入指令');
|
||||
confirmSaveAccount(b.dataset.rkName, b.dataset.rkUrl || '', b);
|
||||
}));
|
||||
} catch (e) { listEl.innerHTML = '<span class="dim" style="padding:12px">' + esc(e.message) + '</span>'; }
|
||||
}
|
||||
function openRecentScript(s) {
|
||||
const title = s.title || s.video_title || 'AI 写剧本';
|
||||
const title = cleanTitle(s.title || s.video_title) || 'AI 写剧本';
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `<div class="modal rw-modal">
|
||||
<div class="modal-title-row"><span>${svgIcon('pen')} ${esc(title)}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="recent-meta">${esc([s.account_name, s.video_title, s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | '))}</div>
|
||||
<div class="recent-meta">${esc([s.account_name, cleanTitle(s.video_title), s.created_time ? fmtDate(s.created_time) : ''].filter(Boolean).join(' | '))}</div>
|
||||
<div class="rw-content md-body" style="max-height:70vh">${renderMarkdown(s.script_text || '(暂无内容)')}</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
@@ -453,7 +600,7 @@ function openSettings() { renderRootList(); $('#settingsModal').hidden = false;
|
||||
function closeSettings() { $('#settingsModal').hidden = true; }
|
||||
|
||||
/* ================= 路由 ================= */
|
||||
const CRUMB_TITLES = { 'ranking': '抖音周榜', 'accounts': '热门账号', 'rewrites': 'AI写脚本', 'reviews': 'AI写作复盘' };
|
||||
const CRUMB_TITLES = { 'ranking': '账号周榜', 'accounts': '账号列表', 'rewrites': 'AI写脚本', 'reviews': 'AI写作复盘' };
|
||||
|
||||
function parseHash() {
|
||||
const h = (location.hash || '#/').replace(/^#/, '');
|
||||
@@ -477,11 +624,13 @@ async function router() {
|
||||
view.querySelectorAll('.data-card').forEach((b) => b.addEventListener('click', () => { location.hash = b.dataset.hash; }));
|
||||
loadRankingPreview();
|
||||
loadRecentScripts();
|
||||
view._reload = () => { loadRankingPreview(); loadRecentScripts(); };
|
||||
return;
|
||||
}
|
||||
if (name === 'help') {
|
||||
updateCrumbs('help', '功能帮助');
|
||||
view.innerHTML = renderHelp();
|
||||
bindBack(view); // 返回 = 上一页(无历史则回首页)
|
||||
view.querySelectorAll('.qa-row.copyable').forEach((row) => row.addEventListener('click', async () => {
|
||||
const text = (row.dataset.q || '').replace(/^"|"$/g, '');
|
||||
if (text) showToast((await copyText(text)) ? '✅ 已复制:' + text : '❌ 复制失败');
|
||||
@@ -496,7 +645,7 @@ async function router() {
|
||||
}
|
||||
// 数据功能页
|
||||
if (typeof window.DataPages !== 'undefined') {
|
||||
const titles = { ranking: '抖音周榜', accounts: '热门账号', account: '账号详情', video: '视频详情', rewrites: 'AI写脚本', reviews: 'AI写作复盘' };
|
||||
const titles = { ranking: '账号周榜', accounts: '账号列表', account: '账号详情', video: '视频详情', rewrites: 'AI写脚本', reviews: 'AI写作复盘' };
|
||||
const pages = {
|
||||
'ranking': () => window.DataPages.renderRanking(view),
|
||||
'accounts': () => window.DataPages.renderAccounts(view),
|
||||
@@ -575,12 +724,9 @@ function readRoute() {
|
||||
}
|
||||
window.addEventListener('hashchange', saveRoute);
|
||||
(function boot() {
|
||||
const saved = readRoute();
|
||||
if (saved && saved !== '#/' && saved !== '#' && (!location.hash || location.hash === '#/')) {
|
||||
location.hash = saved; // 刷新丢 hash → 恢复上次页面
|
||||
return;
|
||||
}
|
||||
saveRoute(); // 直接输入 URL/首次打开:立即记录当前路由,供下次刷新恢复
|
||||
// 不再自动恢复上次路由:URL 无 hash(访问 http://localhost:8899 根地址)一律回首页;
|
||||
// 带 hash(#/accounts 等)走正常路由。原恢复逻辑会误伤"主动访问根地址想回首页"的场景。
|
||||
saveRoute(); // 记录当前路由(保留机制,供后续按需使用)
|
||||
// 关键:data-pages.js 在 app.js 之后的 script 标签,下载/执行可能未完成。
|
||||
// 须等其就绪(DataPages 存在)再路由,否则数据页会被当成未知路由。
|
||||
function bootRouter() {
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
'use strict';
|
||||
|
||||
const fmt = (v) => (v == null || v === '') ? '-' : String(v);
|
||||
/* 过万数字以"万"为单位,保留一位小数(如 57141 → 5.7万) */
|
||||
const fmtWan = (v) => {
|
||||
const n = Number(v);
|
||||
if (!Number.isFinite(n) || n <= 0) return '-';
|
||||
return n >= 10000 ? (n / 10000).toFixed(1) + '万' : String(n);
|
||||
};
|
||||
/* 弹窗标题:书名号只包标题主体;#标签及共创/素材来源信息整体换一行显示 */
|
||||
const fmtTitleQ = (t) => {
|
||||
const s = String(t || '').trim();
|
||||
const idx = s.indexOf('#');
|
||||
if (idx <= 0) return s; // 无 # 或 # 开头(纯标签标题)→ 原样显示
|
||||
const title = stripTitleQuotes(s.slice(0, idx));
|
||||
const rest = s.slice(idx).trim();
|
||||
return '《' + title + '》' + (rest ? '\n' + rest : '');
|
||||
};
|
||||
const fmtDuration = (v) => { const s = Number(v); if (!Number.isFinite(s) || s <= 0) return '-'; const m = Math.floor(s / 60); const sec = Math.round(s % 60); return m > 0 ? `${m}分${sec}秒` : `${sec}秒`; };
|
||||
const fmtTime = (t) => { if (!t) return '-'; const d = new Date(t); if (Number.isNaN(d.getTime())) return String(t); const p = (n) => String(n).padStart(2, '0'); return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`; };
|
||||
|
||||
@@ -19,7 +34,14 @@ function toolbarBack() {
|
||||
return `<button class="btn" data-back title="返回">←</button>`;
|
||||
}
|
||||
function bindBack(container) {
|
||||
container.querySelectorAll('[data-back]').forEach((b) => b.addEventListener('click', () => { location.hash = '#/'; }));
|
||||
container.querySelectorAll('[data-back]').forEach((b) => b.addEventListener('click', () => {
|
||||
const before = location.hash;
|
||||
window.history.back();
|
||||
// 无历史可退(如直接打开/刷新后)→ 200ms 内 hash 未变化则回首页
|
||||
setTimeout(() => {
|
||||
if (location.hash === before) location.hash = '#/';
|
||||
}, 250);
|
||||
}));
|
||||
}
|
||||
|
||||
/* 触发 AI 任务:提交 WorkBuddy Automation 一次性任务,WorkBuddy 自动执行,左侧会话栏可见 */
|
||||
@@ -42,11 +64,69 @@ async function firePrompt(prompt, label) {
|
||||
}
|
||||
}
|
||||
|
||||
/* AI写脚本 确认弹窗:按账号是否有设定动态文案;选题输入区(手输 / AI 结合人设生成)→ 确认后执行创作任务 */
|
||||
async function confirmAiScript(accountName, btn) {
|
||||
// 判断账号是否有有效设定(persona 非空且非测试占位):有 →「基于当前账号设定」,无 →「先进行账号设定分析」
|
||||
const accId = btn && btn.dataset.accId ? btn.dataset.accId : '';
|
||||
let hasPersona = false;
|
||||
if (accId) {
|
||||
try {
|
||||
const r = await api('/api/dsh/account-persona?accountId=' + encodeURIComponent(accId));
|
||||
const content = String(r.content || '').trim();
|
||||
hasPersona = !!content && !/测试写入|连通性测试/.test(content);
|
||||
} catch (e) { hasPersona = false; }
|
||||
}
|
||||
const desc = hasPersona
|
||||
? `将为达人「${esc(accountName)}」基于当前账号设定生成脚本。可先填写选题方向,或点击右下角 AI 按钮结合人设生成选题:`
|
||||
: `将为达人「${esc(accountName)}」创作一条短视频脚本(先进行账号设定分析,再生成完整脚本)。可先填写选题方向,或点击右下角 AI 按钮结合人设生成选题:`;
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `
|
||||
<div class="modal">
|
||||
<h3>${svgIcon('pen', 18)} AI写脚本</h3>
|
||||
<p class="dim">${desc}</p>
|
||||
<div class="ai-topic">
|
||||
<textarea id="aiTopicInput" class="ai-topic-input" placeholder="输入选题方向(可选),如:亲子露营的搞笑瞬间"></textarea>
|
||||
<button class="ai-topic-btn" id="aiTopicGen" title="AI 结合人设生成选题">AI</button>
|
||||
</div>
|
||||
<div class="modal-actions">
|
||||
<button class="ghost" data-cancel>取消</button>
|
||||
<button class="primary" data-ok>确认执行</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
const close = () => mask.remove();
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
|
||||
mask.querySelector('[data-cancel]').addEventListener('click', close);
|
||||
const genBtn = mask.querySelector('#aiTopicGen');
|
||||
genBtn.addEventListener('click', async () => {
|
||||
if (genBtn.disabled) return;
|
||||
genBtn.disabled = true; genBtn.textContent = '…';
|
||||
try {
|
||||
const r = await api('/api/ai/topics', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ accountName }) });
|
||||
if (r.error) throw new Error(r.error);
|
||||
mask.querySelector('#aiTopicInput').value = r.topics || '(未生成内容)';
|
||||
showToast('✅ 已生成 3 个选题方向,可直接使用或修改');
|
||||
} catch (e) {
|
||||
showToast('❌ 选题生成失败: ' + e.message);
|
||||
}
|
||||
genBtn.disabled = false; genBtn.textContent = 'AI';
|
||||
});
|
||||
mask.querySelector('[data-ok]').addEventListener('click', () => {
|
||||
const topic = mask.querySelector('#aiTopicInput').value.trim();
|
||||
close();
|
||||
const act = hasPersona ? '基于当前账号设定创作。' : '请先进行账号设定分析,再生成完整脚本。';
|
||||
const prompt = topic
|
||||
? `请为达人「${accountName}」创作一条短视频脚本,选题方向:${topic}\n${act}`
|
||||
: `请为达人「${accountName}」创作一条短视频脚本。${act}`;
|
||||
executeTask({ prompt, label: '创作指令', btn });
|
||||
});
|
||||
}
|
||||
|
||||
/* ================= 周榜 ================= */
|
||||
async function renderRanking(view) {
|
||||
let state = { date: '', category: '', sortBy: 'comprehensiveScore', order: 'desc', page: 1, pageSize: 20, dates: [], categories: [] };
|
||||
const data = await api('/api/dsh/ranking?pageSize=20').catch((e) => ({ ok: false, error: e.message }));
|
||||
if (!data.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}<span class="page-title">抖音周榜</span></div><div class="empty">${esc(data.error || '加载失败')}</div></div>`; bindBack(view); return; }
|
||||
if (!data.ok) { view.innerHTML = `<div class="page"><div class="page-toolbar">${toolbarBack()}<span class="page-title">账号周榜</span></div><div class="empty">${esc(data.error || '加载失败')}</div></div>`; bindBack(view); return; }
|
||||
state.dates = data.dates || []; state.categories = data.categories || []; state.date = data.date; state.category = data.category;
|
||||
|
||||
async function load() {
|
||||
@@ -63,13 +143,13 @@ async function renderRanking(view) {
|
||||
<td>${fmt(r.newLikes)}</td>
|
||||
<td>${fmt(r.newComments)}</td>
|
||||
<td>${fmt(r.newShares)}</td>
|
||||
<td class="cell-act">${r.inDb ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-import='${esc(JSON.stringify(r))}'>导入</button>`}</td>
|
||||
<td class="cell-act">${r.inDb ? `<a class="btn btn-sm btn-view" href="#/account/${r.dbId}">查看</a>` : `<button class="btn btn-sm btn-import" data-import='${esc(JSON.stringify(r))}'>分析</button>`}</td>
|
||||
</tr>`).join('');
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page-toolbar">
|
||||
${toolbarBack()}
|
||||
<span class="page-title">抖音周榜</span>
|
||||
<span class="page-title">账号周榜</span>
|
||||
<div class="toolbar-right">
|
||||
<select id="rankCat" class="input-select" title="赛道筛选">
|
||||
<option value="">全部赛道</option>
|
||||
@@ -103,13 +183,14 @@ async function renderRanking(view) {
|
||||
bindPager(view, (p) => { state.page = p; load(); });
|
||||
view.querySelectorAll('[data-import]').forEach((b) => b.addEventListener('click', () => {
|
||||
const r = JSON.parse(b.dataset.import);
|
||||
firePrompt(`请帮我把抖音账号「${r.accountName}」导入并分析(账号信息 + 视频列表):${r.profileUrl || ''}`, '导入指令');
|
||||
confirmSaveAccount(r.accountName, r.profileUrl || '', b);
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留筛选/翻页状态)
|
||||
}
|
||||
await load();
|
||||
}
|
||||
|
||||
/* ================= 热门账号 ================= */
|
||||
/* ================= 账号列表 ================= */
|
||||
async function renderAccounts(view) {
|
||||
let state = { type: 'all', search: '', track: '', page: 1, pageSize: 20 };
|
||||
const tracks = (await api('/api/dsh/tracks').catch(() => ({ tracks: [] }))).tracks || [];
|
||||
@@ -117,51 +198,68 @@ async function renderAccounts(view) {
|
||||
const d = await api('/api/dsh/accounts?type=' + state.type + '&search=' + encodeURIComponent(state.search) + '&track=' + encodeURIComponent(state.track) + '&page=' + state.page + '&pageSize=' + state.pageSize);
|
||||
const badge = (t) => `<span class="badge ${t === 'external' ? 'badge-ext' : 'badge-signed'}">${t === 'external' ? '外部' : '签约'}</span>`;
|
||||
const rows = (d.items || []).map((a) => `<tr>
|
||||
<td>${a.favorite ? '⭐' : '☆'}</td>
|
||||
<td><button class="fav-btn" data-fav="${a.id}" data-fav-val="${a.favorite ? 1 : 0}" title="${a.favorite ? '取消关注' : '点击关注'}">${a.favorite ? '⭐' : '☆'}</button></td>
|
||||
<td>${badge(a.account_type)}</td>
|
||||
<td><a class="link strong" href="#/account/${a.id}">${esc(a.account_name)}</a></td>
|
||||
<td><a class="link" href="#/account/${a.id}">${esc(a.account_name)}</a></td>
|
||||
<td>${esc(a.track || '-')}</td>
|
||||
<td>${a.is_ai === '是' ? '是' : '否'}</td>
|
||||
<td>${fmt(a.followers)}</td>
|
||||
<td>${fmt(a.total_likes)}</td>
|
||||
<td>${fmt(a.works_count)}</td>
|
||||
<td>${esc(a.location || '-')}</td>
|
||||
<td>${fmt(a.age)}</td>
|
||||
<td class="cell-content" title="${esc(a.bio || '')}">${esc((a.bio || '').slice(0, 50))}</td>
|
||||
<td class="cell-content" title="${esc(a.content || '')}">${esc((a.content || '').slice(0, 50))}</td>
|
||||
<td>${a.video_count ?? 0}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-create='${esc(a.account_name)}'>创作</button></td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-create='${esc(a.account_name)}' data-acc-id="${a.id}">AI写脚本</button></td>
|
||||
</tr>`).join('');
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page-toolbar">
|
||||
${toolbarBack()}
|
||||
<span class="page-title">热门账号</span>
|
||||
<select id="accType" class="input-select">
|
||||
<option value="all" ${state.type === 'all' ? 'selected' : ''}>全部</option>
|
||||
<option value="signed" ${state.type === 'signed' ? 'selected' : ''}>签约</option>
|
||||
<option value="external" ${state.type === 'external' ? 'selected' : ''}>外部</option>
|
||||
</select>
|
||||
<input id="accSearch" class="input" placeholder="搜索账号名 / 内容 / 赛道" value="${esc(state.search)}">
|
||||
<select id="accTrack" class="input-select"><option value="">全部赛道</option>${tracks.map((t) => `<option value="${esc(t)}" ${t === state.track ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
|
||||
<button class="btn" id="accReset">重置</button>
|
||||
<span class="page-title">账号列表</span>
|
||||
<div class="toolbar-right" style="margin-right:10px">
|
||||
<select id="accType" class="input-select">
|
||||
<option value="all" ${state.type === 'all' ? 'selected' : ''}>全部</option>
|
||||
<option value="signed" ${state.type === 'signed' ? 'selected' : ''}>签约</option>
|
||||
<option value="external" ${state.type === 'external' ? 'selected' : ''}>外部</option>
|
||||
</select>
|
||||
<select id="accTrack" class="input-select"><option value="">全部赛道</option>${tracks.map((t) => `<option value="${esc(t)}" ${t === state.track ? 'selected' : ''}>${esc(t)}</option>`).join('')}</select>
|
||||
<input id="accSearch" class="input" placeholder="搜索账号名 / 内容 / 赛道" value="${esc(state.search)}">
|
||||
<button class="btn" id="accImport" title="导入外部账号数据(抖音账号信息 / 本地文件夹)">${svgIcon('download', 15, 'currentColor')} 导入</button>
|
||||
<button class="btn" id="accReset" title="重置筛选">${svgIcon('refresh', 15, 'currentColor')}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="table-wrap">
|
||||
<table class="table">
|
||||
<thead><tr><th>收藏</th><th>类型</th><th>账号名</th><th>赛道</th><th>AI内容</th><th>粉丝</th><th>获赞</th><th>作品数</th><th>地区</th><th>年龄</th><th>简介</th><th>定位/内容</th><th>视频数</th><th>操作</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="14" class="empty">暂无账号数据</td></tr>'}</tbody>
|
||||
<thead><tr><th>关注</th><th>类型</th><th>账号名</th><th>赛道</th><th>AI制作</th><th>粉丝</th><th>获赞</th><th>作品数</th><th>地区</th><th>简介</th><th>视频数</th><th class="th-act">操作</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="12" class="empty">暂无账号数据</td></tr>'}</tbody>
|
||||
</table>
|
||||
</div>
|
||||
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
view.querySelectorAll('[data-fav]').forEach((b) => b.addEventListener('click', async () => {
|
||||
const id = b.dataset.fav;
|
||||
const newVal = b.dataset.favVal !== '1';
|
||||
b.disabled = true;
|
||||
try {
|
||||
const r = await api('/api/dsh/account-favorite', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ id, fav: newVal }) });
|
||||
if (r.error) throw new Error(r.error);
|
||||
showToast(newVal ? '⭐ 已关注' : '☆ 已取消关注');
|
||||
load(); // 刷新列表(排序 favorite DESC 会变化)
|
||||
} catch (e) {
|
||||
showToast('❌ 收藏失败: ' + e.message);
|
||||
b.disabled = false;
|
||||
}
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留筛选/翻页状态)
|
||||
view.querySelector('#accType').addEventListener('change', (e) => { state.type = e.target.value; state.page = 1; load(); });
|
||||
view.querySelector('#accSearch').addEventListener('keydown', (e) => { if (e.key === 'Enter') { state.search = e.target.value; state.page = 1; load(); } });
|
||||
view.querySelector('#accTrack').addEventListener('change', (e) => { state.track = e.target.value; state.page = 1; load(); });
|
||||
view.querySelector('#accReset').addEventListener('click', () => { state = { type: 'all', search: '', track: '', page: 1, pageSize: 20 }; load(); });
|
||||
view.querySelector('#accImport').addEventListener('click', (e) => openImportAccounts(e.currentTarget));
|
||||
bindPager(view, (p) => { state.page = p; load(); });
|
||||
view.querySelectorAll('[data-create]').forEach((b) => b.addEventListener('click', () => {
|
||||
firePrompt(`请为达人「${b.dataset.create}」创作一条短视频脚本。请先进行账号设定分析,再生成完整脚本。`, '创作指令');
|
||||
confirmAiScript(b.dataset.create, b);
|
||||
}));
|
||||
}
|
||||
await load();
|
||||
@@ -187,38 +285,48 @@ async function renderAccountDetail(view, id) {
|
||||
|
||||
async function load() {
|
||||
const dv = await api('/api/dsh/account-videos?accountId=' + accountId + '&page=' + page + '&pageSize=20');
|
||||
const infoGrid = (label, val, wide) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${esc(fmt(val))}</div></div>`;
|
||||
const infoGrid = (label, val, wide, raw) => `<div class="info-item ${wide ? 'info-wide' : ''}"><div class="info-label">${label}</div><div class="info-value">${raw ? val : esc(fmt(val))}</div></div>`;
|
||||
const rows = (dv.items || []).map((v) => `<tr>
|
||||
<td><a class="link" href="#/video/${v.id}" title="查看视频分析详情">${esc(v.aweme_id || '-')}</a></td>
|
||||
<td class="cell-content">${v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(v.video_title || '-')}</a>` : esc(v.video_title || '-')}</td>
|
||||
<td>${fmt(v.like_display || v.like_count)}</td>
|
||||
<td>${fmt(v.comment_count)}</td>
|
||||
<td>${fmt(v.share_count)}</td>
|
||||
<td>${fmtDuration(v.duration)}</td>
|
||||
<td>${fmt(v.publish_time)}</td>
|
||||
<td class="cell-act">${(v.has_source || v.has_analysis) ? `<button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ name: acc.account_name, title: v.video_title, aweme: v.aweme_id }))}'>改写脚本</button>` : `<button class="btn btn-sm" data-analyze='${esc(JSON.stringify({ name: acc.account_name, title: v.video_title, aweme: v.aweme_id }))}'>解析</button>`}</td>
|
||||
<td class="td-id"><a class="link" href="#/video/${v.id}" title="查看视频分析详情">${esc(v.aweme_id || '-')}</a></td>
|
||||
<td class="cell-content">${v.video_url ? `<a class="title-plain" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(cleanTitle(v.video_title) || '-')}</a>` : esc(cleanTitle(v.video_title) || '-')}</td>
|
||||
<td>${fmt(v.like_display || v.like_count)}</td>
|
||||
<td>${fmtWan(v.comment_count)}</td>
|
||||
<td>${fmtWan(v.share_count)}</td>
|
||||
<td>${fmtDuration(v.duration)}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm ${(v.has_source || v.has_analysis) ? 'btn-view' : ''}" data-rewrite='${esc(JSON.stringify({ name: acc.account_name, title: cleanTitle(v.video_title), aweme: v.aweme_id, parsed: !!(v.has_source || v.has_analysis) }))}'>AI改写</button></td>
|
||||
</tr>`).join('');
|
||||
const tabBtns = [['info', '账号信息'], ['persona', '账号设定'], ['analysis', '数据分析']].map(([k, label]) => `<button class="tab ${tab === k ? 'tab-active' : ''}" data-tab="${k}">${label}</button>`).join('');
|
||||
let panel = '';
|
||||
if (tab === 'info') {
|
||||
panel = `<div class="info-grid">
|
||||
${infoGrid('抖音号', acc.douyin_id)}${infoGrid('赛道', acc.track)}${infoGrid('AI内容', acc.is_ai === '是' ? '是' : '否')}
|
||||
${infoGrid('粉丝', acc.followers)}${infoGrid('获赞', acc.total_likes)}${infoGrid('作品数', acc.works_count)}
|
||||
${infoGrid('地区', acc.location)}${infoGrid('年龄', acc.age)}${infoGrid('获取视频', dv.total)}
|
||||
${infoGrid('简介', acc.bio, true)}${infoGrid('定位/内容', acc.content, true)}
|
||||
const contentText = String(acc.content || '');
|
||||
// 账号定位:默认最多 10 行,可点击展开/收起
|
||||
const contentHtml = `<span class="clamp-wrap"><span class="clamp-text" data-clamp>${esc(contentText) || '-'}</span>${contentText.length > 200 ? `<button class="clamp-btn" data-clamp-toggle>展开</button>` : ''}</span>`;
|
||||
panel = `<div class="info-card">
|
||||
<div class="info-row">${infoGrid('抖音号', acc.douyin_id)}${infoGrid('年龄', acc.age)}</div>
|
||||
<div class="info-row">${infoGrid('赛道', acc.track)}${infoGrid('AI制作', acc.is_ai === '是' ? '是' : '否')}${infoGrid('地区', acc.location)}</div>
|
||||
<div class="info-row">${infoGrid('作品数', acc.works_count)}${infoGrid('粉丝', acc.followers)}${infoGrid('获赞', acc.total_likes)}${infoGrid('获取视频', dv.total)}</div>
|
||||
<div class="info-row">${infoGrid('简介', acc.bio, true)}</div>
|
||||
<div class="info-row">${infoGrid('账号定位', contentHtml, true, true)}</div>
|
||||
</div>
|
||||
<div class="table-wrap"><table class="table"><thead><tr><th>视频ID</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="8" class="empty">暂无视频数据</td></tr>'}</tbody></table></div>
|
||||
<h3 class="video-list-title">视频列表</h3>
|
||||
<div class="table-wrap"><table class="table"><thead><tr><th>发布时间</th><th class="th-id">视频ID</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th class="th-act">操作</th></tr></thead><tbody>${rows || '<tr><td colspan="8" class="empty">暂无视频数据</td></tr>'}</tbody></table></div>
|
||||
${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}`;
|
||||
} else if (tab === 'persona') {
|
||||
panel = persona ? `<div class="md-body">${renderMarkdown(persona)}</div>` : `<div class="empty">该账号暂无账号设定信息<button class="btn btn-primary" data-extract style="margin-top:12px">提炼账号设定</button></div>`;
|
||||
panel = persona ? `<div class="md-body">${renderMarkdown(persona)}</div>` : `<div class="empty-state">
|
||||
<div class="empty-icon">${svgIcon('users', 40)}</div>
|
||||
<div class="empty-title">该账号暂无账号设定信息</div>
|
||||
<div class="empty-desc">为达人提炼账号设定后,可基于设定生成脚本</div>
|
||||
<button class="btn btn-view" data-extract>提炼账号设定</button>
|
||||
</div>`;
|
||||
} else {
|
||||
panel = analysis ? `<div class="md-body">${renderMarkdown(analysis)}</div>` : `<div class="empty">该账号暂无数据分析</div>`;
|
||||
}
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page page-detail">
|
||||
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(acc.account_name || '账号详情')}</span></div>
|
||||
<div class="tabs">${tabBtns}</div>
|
||||
${panel}
|
||||
<div class="detail-body">${panel}</div>
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', async () => {
|
||||
@@ -228,16 +336,37 @@ async function renderAccountDetail(view, id) {
|
||||
load();
|
||||
}));
|
||||
bindPager(view, (p) => { page = p; load(); });
|
||||
view.querySelectorAll('[data-clamp-toggle]').forEach((b) => b.addEventListener('click', () => {
|
||||
const text = b.closest('.clamp-wrap').querySelector('[data-clamp]');
|
||||
const open = text.classList.toggle('open');
|
||||
b.textContent = open ? '收起' : '展开';
|
||||
}));
|
||||
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
|
||||
const r = JSON.parse(b.dataset.rewrite);
|
||||
firePrompt(`请为达人「${r.name}」改写一条短视频故事脚本。视频选题:《${r.title}》\n请基于账号设定与视频选题,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`, '改写指令');
|
||||
}));
|
||||
view.querySelectorAll('[data-analyze]').forEach((b) => b.addEventListener('click', () => {
|
||||
const r = JSON.parse(b.dataset.analyze);
|
||||
firePrompt(`请帮我解析这个抖音视频并做拆解分析:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》`, '解析指令');
|
||||
const parsed = !!r.parsed;
|
||||
confirmRun({
|
||||
title: 'AI改写',
|
||||
desc: parsed
|
||||
? `该视频已解析,将基于解析内容为达人「${r.name}」改写一条全新故事脚本(新故事、新情节,不复刻原视频)。`
|
||||
: `该视频尚未解析,将先解析视频内容,再为达人「${r.name}」改写一条全新故事脚本(新故事、新情节,不复刻原视频)。`,
|
||||
target: `达人「${r.name}」|选题《${r.title}》`,
|
||||
prompt: parsed
|
||||
? `请为达人「${r.name}」改写一条短视频故事脚本。视频选题:《${r.title}》\n请基于账号设定与已解析的视频内容,创作一个全新的故事脚本(新故事、新情节,不要复刻原视频),输出完整故事脚本。`
|
||||
: `请先解析这个抖音视频并做拆解分析,再为达人「${r.name}」改写一条全新故事脚本:https://www.douyin.com/video/${r.aweme || ''}\n视频标题:《${r.title}》`,
|
||||
label: '改写指令',
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
const exBtn = view.querySelector('[data-extract]');
|
||||
if (exBtn) exBtn.addEventListener('click', () => firePrompt(`请为达人「${acc.account_name}」提炼账号设定(生成人设卡)。`, '提炼指令'));
|
||||
if (exBtn) exBtn.addEventListener('click', () => confirmRun({
|
||||
title: '提炼账号设定',
|
||||
desc: '将为达人提炼账号设定(生成人设卡)。',
|
||||
target: acc.account_name,
|
||||
prompt: `请为达人「${acc.account_name}」提炼账号设定(生成人设卡)。`,
|
||||
label: '提炼指令',
|
||||
btn: exBtn,
|
||||
}));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab/翻页)
|
||||
}
|
||||
await load();
|
||||
}
|
||||
@@ -253,7 +382,7 @@ async function renderVideoDetail(view, id) {
|
||||
const analysisToMd = (obj) => Object.entries(obj || {}).map(([k, val]) => `## ${k}\n\n${val}`).join('\n\n---\n\n');
|
||||
|
||||
function load() {
|
||||
const titleVal = v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(v.video_title)}</a>` : esc(v.video_title);
|
||||
const titleVal = v.video_url ? `<a class="link" data-ext="${esc(v.video_url)}" title="在新标签打开视频">${esc(cleanTitle(v.video_title))}</a>` : esc(cleanTitle(v.video_title));
|
||||
const scriptMd = parse.content || null;
|
||||
const deconstructMd = parse.analysis ? analysisToMd(parse.analysis) : null;
|
||||
const analysisMd = analysis && analysis.content ? analysis.content : null;
|
||||
@@ -263,19 +392,22 @@ async function renderVideoDetail(view, id) {
|
||||
else if (tab === 'deconstruct') body = deconstructMd ? `<div class="md-body">${renderMarkdown(deconstructMd)}</div>` : `<div class="empty">该视频暂无视频拆解(MCP 结构化分析)</div>`;
|
||||
else body = analysisMd ? `<div class="md-body">${renderMarkdown(analysisMd)}</div>` : `<div class="empty">该视频暂无视频分析</div>`;
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(v.video_title || '视频详情')}</span></div>
|
||||
<div class="info-card"><div class="info-grid">
|
||||
${infoGrid('视频标题', titleVal, true, true)}${infoGrid('账号', v.account_name)}${infoGrid('视频ID', v.aweme_id)}
|
||||
${infoGrid('点赞', v.like_display || v.like_count)}${infoGrid('评论', v.comment_count)}${infoGrid('分享', v.share_count)}
|
||||
${infoGrid('收藏', v.collect_count)}${infoGrid('播放', (v.play_count == null || Number(v.play_count) === 0) ? null : v.play_count)}${infoGrid('时长', fmtDuration(v.duration))}
|
||||
${infoGrid('发布时间', v.publish_time)}${infoGrid('标签', v.tags, true)}
|
||||
</div></div>
|
||||
<div class="tabs">${tabBtns}</div>
|
||||
${body}
|
||||
<div class="page page-detail">
|
||||
<div class="page-toolbar">${toolbarBack()}<span class="page-title">${esc(cleanTitle(v.video_title) || '视频详情')}</span></div>
|
||||
<div class="detail-body">
|
||||
<div class="info-card"><div class="info-grid">
|
||||
${infoGrid('视频标题', titleVal, true, true)}${infoGrid('账号', v.account_name)}${infoGrid('视频ID', v.aweme_id)}
|
||||
${infoGrid('点赞', v.like_display || v.like_count)}${infoGrid('评论', v.comment_count)}${infoGrid('分享', v.share_count)}
|
||||
${infoGrid('收藏', v.collect_count)}${infoGrid('播放', (v.play_count == null || Number(v.play_count) === 0) ? null : v.play_count)}${infoGrid('时长', fmtDuration(v.duration))}
|
||||
${infoGrid('发布时间', v.publish_time)}${infoGrid('标签', v.tags, true)}
|
||||
</div></div>
|
||||
<div class="tabs">${tabBtns}</div>
|
||||
${body}
|
||||
</div>
|
||||
</div>`;
|
||||
bindBack(view);
|
||||
view.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => { tab = b.dataset.tab; load(); }));
|
||||
view._reload = load; // 静默自动刷新(保留当前 tab)
|
||||
}
|
||||
load();
|
||||
}
|
||||
@@ -286,32 +418,34 @@ async function renderRewrites(view) {
|
||||
async function load() {
|
||||
const d = await api('/api/dsh/videos?search=' + encodeURIComponent(state.search) + '&parsed=' + state.parsed + '&page=' + state.page + '&pageSize=' + state.pageSize);
|
||||
const rows = (d.items || []).map((v) => `<tr>
|
||||
<td>${esc(v.account_name || '-')}</td>
|
||||
<td class="cell-content"><a class="link strong" data-detail="${v.id}">${esc(v.video_title || '-')}</a></td>
|
||||
<td>${v.account_id ? `<a class="link" href="#/account/${v.account_id}" title="查看账号详情">${esc(v.account_name || '-')}</a>` : esc(v.account_name || '-')}</td>
|
||||
<td class="cell-content">${v.video_url ? `<a class="title-plain" data-ext="${esc(v.video_url)}" title="在新标签打开原视频">${esc(cleanTitle(v.video_title) || '-')}</a>` : esc(cleanTitle(v.video_title) || '-')}</td>
|
||||
<td>${fmt(v.like_display || v.like_count)}</td>
|
||||
<td>${fmt(v.comment_count)}</td>
|
||||
<td>${fmt(v.share_count)}</td>
|
||||
<td>${fmtWan(v.comment_count)}</td>
|
||||
<td>${fmtWan(v.share_count)}</td>
|
||||
<td>${fmtDuration(v.duration)}</td>
|
||||
<td>${fmt(v.publish_time)}</td>
|
||||
<td>${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'}</td>
|
||||
<td>${v.rewrite_count > 0 ? '✅' + v.rewrite_count : '—'}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: v.video_title }))}'>改写</button></td>
|
||||
<td>${v.rewrite_count > 0 ? `<button class="btn btn-sm btn-view" data-detail="${v.id}" title="查看改写脚本">${v.rewrite_count} 查看</button>` : '—'}</td>
|
||||
<td class="cell-act"><button class="btn btn-sm btn-view" data-rewrite='${esc(JSON.stringify({ id: v.id, name: v.account_name, title: cleanTitle(v.video_title) }))}'>AI改写</button></td>
|
||||
</tr>`).join('');
|
||||
view.innerHTML = `
|
||||
<div class="page">
|
||||
<div class="page-toolbar">
|
||||
${toolbarBack()}
|
||||
<span class="page-title">AI写脚本</span>
|
||||
<input id="rwSearch" class="input" placeholder="搜索视频标题 / 账号" value="${esc(state.search)}">
|
||||
<select id="rwParsed" class="input-select">
|
||||
<option value="" ${state.parsed === '' ? 'selected' : ''}>全部状态</option>
|
||||
<option value="1" ${state.parsed === '1' ? 'selected' : ''}>已解析</option>
|
||||
<option value="0" ${state.parsed === '0' ? 'selected' : ''}>未解析</option>
|
||||
</select>
|
||||
<span class="pager-info">每账号近期 30W+ 且评论 1W+ 取 10 条</span>
|
||||
<div class="toolbar-right" style="margin-right:10px">
|
||||
<input id="rwSearch" class="input" placeholder="搜索视频标题 / 账号" value="${esc(state.search)}">
|
||||
<select id="rwParsed" class="input-select">
|
||||
<option value="" ${state.parsed === '' ? 'selected' : ''}>全部状态</option>
|
||||
<option value="1" ${state.parsed === '1' ? 'selected' : ''}>已解析</option>
|
||||
<option value="0" ${state.parsed === '0' ? 'selected' : ''}>未解析</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="toolbar-note">每账号近期 30W+ 且评论 1W+ 取 10 条</div>
|
||||
<div class="table-wrap"><table class="table">
|
||||
<thead><tr><th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>已改写</th><th>操作</th></tr></thead>
|
||||
<thead><tr><th>账号</th><th>视频标题</th><th>点赞</th><th>评论</th><th>分享</th><th>时长</th><th>发布时间</th><th>状态</th><th>已改写</th><th class="th-act">操作</th></tr></thead>
|
||||
<tbody>${rows || '<tr><td colspan="10" class="empty">暂无视频数据</td></tr>'}</tbody>
|
||||
</table></div>
|
||||
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
|
||||
@@ -323,7 +457,14 @@ async function renderRewrites(view) {
|
||||
view.querySelectorAll('[data-detail]').forEach((a) => a.addEventListener('click', () => openRewriteCompare(a.dataset.detail)));
|
||||
view.querySelectorAll('[data-rewrite]').forEach((b) => b.addEventListener('click', () => {
|
||||
const r = JSON.parse(b.dataset.rewrite);
|
||||
firePrompt(`请为达人「${r.name}」基于视频《${r.title}》改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。`, '改写指令');
|
||||
confirmRun({
|
||||
title: '改写脚本',
|
||||
desc: '将基于该视频,为达人改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。',
|
||||
target: `达人「${r.name}」|《${r.title}》`,
|
||||
prompt: `请为达人「${r.name}」基于视频《${r.title}》改写一条全新的 S9 故事脚本(新故事、新情节,不复刻原视频)。`,
|
||||
label: '改写指令',
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -337,6 +478,7 @@ async function renderRewrites(view) {
|
||||
const stb = await api('/api/dsh/storyboards?videoId=' + videoId).catch(() => ({ items: [] }));
|
||||
const storyboards = stb.items || [];
|
||||
let sbIdx = 0;
|
||||
let showSrc = false; // 源脚本列默认收起
|
||||
|
||||
function renderSbBlocks(text) {
|
||||
const blocks = String(text || '').split(/\n\s*---+\s*\n/).map((b) => b.trim()).filter(Boolean);
|
||||
@@ -354,28 +496,83 @@ async function renderRewrites(view) {
|
||||
const cur = rewrites[verIdx];
|
||||
const visibleSb = storyboards.filter((s) => !cur || s.rewrite_id === cur.id);
|
||||
const sb = visibleSb[Math.min(sbIdx, visibleSb.length - 1)];
|
||||
const verCtrl = (p, idx, len, label, genLabel) => {
|
||||
const inner = len > 1
|
||||
? `<span class="rw-sec-count">${label} ${idx + 1}/${len}</span><button class="btn btn-sm" data-${p}prev ${idx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-${p}next ${idx >= len - 1 ? 'disabled' : ''}>下一版 →</button>`
|
||||
: (len === 1 ? `<span class="rw-sec-count">${label} 1/1</span>` : '');
|
||||
return `<div class="rw-sec-foot">${inner}${genLabel ? `<button class="btn btn-sm" data-${p}gen>${svgIcon('refresh', 14, 'currentColor')} ${genLabel}</button>` : ''}</div>`;
|
||||
};
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `<div class="modal rw-modal">
|
||||
<div class="modal-title-row"><span>源脚本 vs AI写脚本 — ${esc(title)}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="rw-split">
|
||||
<div class="rw-pane"><div class="rw-pane-title">源脚本</div><div class="rw-content md-body">${renderMarkdown(source)}</div></div>
|
||||
<div class="rw-pane"><div class="rw-pane-title">AI脚本 ${rewrites.length > 1 ? `(版本 ${verIdx + 1}/${rewrites.length})` : ''}</div>
|
||||
${rewrites.length > 1 ? `<div class="rw-ver"><button class="btn btn-sm" data-vprev ${verIdx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-vnext ${verIdx >= rewrites.length - 1 ? 'disabled' : ''}>下一版 →</button></div>` : ''}
|
||||
<div class="rw-content md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无改写脚本)'}</div>
|
||||
<div class="modal-title-row"><span>${esc(fmtTitleQ(title))}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="rw-stack">
|
||||
<div class="rw-sec${showSrc ? '' : ' rw-sec-hidden'}" data-src-sec>
|
||||
<div class="rw-sec-head">源脚本</div>
|
||||
<div class="rw-sec-body md-body">${renderMarkdown(source)}</div>
|
||||
<div class="rw-sec-foot"></div>
|
||||
</div>
|
||||
<div class="rw-sec">
|
||||
<div class="rw-sec-head">AI脚本<button class="btn btn-sm btn-view rw-src-toggle${showSrc ? ' src-on' : ''}" data-src-toggle title="${showSrc ? '收起源脚本' : '展开源脚本'}">${svgIcon('file-text', 14, 'currentColor')}</button></div>
|
||||
<div class="rw-sec-body md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无改写脚本)'}</div>
|
||||
${verCtrl('v', verIdx, rewrites.length, '版本')}
|
||||
</div>
|
||||
<div class="rw-sec">
|
||||
<div class="rw-sec-head">AI分镜提示词</div>
|
||||
<div class="rw-sec-body">${sb ? renderSbBlocks(sb.script_text) : '<div class="dim">(暂无分镜提示词)</div>'}</div>
|
||||
${visibleSb.length ? verCtrl('s', sbIdx, visibleSb.length, '分镜', '重新生成') : `<div class="rw-sec-foot"><button class="btn btn-sm" data-gen-sb>${svgIcon('zap', 14, 'currentColor')} 生成分镜</button></div>`}
|
||||
</div>
|
||||
</div>
|
||||
${visibleSb.length ? `<div class="rw-sb"><div class="rw-pane-title">AI分镜提示词 ${visibleSb.length > 1 ? `(${Math.min(sbIdx, visibleSb.length - 1) + 1}/${visibleSb.length})` : ''}</div>
|
||||
${visibleSb.length > 1 ? `<div class="rw-ver"><button class="btn btn-sm" data-sprev ${sbIdx <= 0 ? 'disabled' : ''}>← 上一版</button><button class="btn btn-sm" data-snext ${sbIdx >= visibleSb.length - 1 ? 'disabled' : ''}>下一版 →</button></div>` : ''}
|
||||
<div class="rw-content">${sb ? renderSbBlocks(sb.script_text) : '(暂无分镜提示词)'}</div></div>` : ''}
|
||||
</div>`;
|
||||
document.body.appendChild(mask);
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) mask.remove(); });
|
||||
mask.querySelector('[data-x]').addEventListener('click', () => mask.remove());
|
||||
const q = (s) => mask.querySelector(s);
|
||||
const srcToggle = q('[data-src-toggle]'); if (srcToggle) srcToggle.addEventListener('click', () => { showSrc = !showSrc; mask.remove(); renderModal(); });
|
||||
const vprev = q('[data-vprev]'); if (vprev) vprev.addEventListener('click', () => { if (verIdx > 0) { verIdx--; sbIdx = 0; mask.remove(); renderModal(); } });
|
||||
const vnext = q('[data-vnext]'); if (vnext) vnext.addEventListener('click', () => { if (verIdx < rewrites.length - 1) { verIdx++; sbIdx = 0; mask.remove(); renderModal(); } });
|
||||
const sprev = q('[data-sprev]'); if (sprev) sprev.addEventListener('click', () => { if (sbIdx > 0) { sbIdx--; mask.remove(); renderModal(); } });
|
||||
const snext = q('[data-snext]'); if (snext) snext.addEventListener('click', () => { if (sbIdx < visibleSb.length - 1) { sbIdx++; mask.remove(); renderModal(); } });
|
||||
const fireGenSb = (btn, regenerate) => {
|
||||
confirmRun({
|
||||
title: regenerate ? '重新生成分镜' : '生成分镜',
|
||||
desc: '将基于该 AI 改写脚本,展开成 AI 分镜提示词(按分镜技能生成镜头级描述)。',
|
||||
target: `《${title}》`,
|
||||
prompt: `请将视频《${title}》的 AI 改写脚本${regenerate ? '重新' : ''}展开成 AI 分镜提示词(按分镜技能生成镜头级描述)。`,
|
||||
label: '分镜指令',
|
||||
btn,
|
||||
});
|
||||
};
|
||||
const genSb = q('[data-gen-sb]'); if (genSb) genSb.addEventListener('click', () => fireGenSb(genSb, false));
|
||||
const sgen = q('[data-sgen]'); if (sgen) sgen.addEventListener('click', () => fireGenSb(sgen, true));
|
||||
// AI脚本列 ↔ AI分镜列 场次联动:滑动 AI脚本到第 N 场,分镜对应滚动到第 N 场块(仅该脚本有分镜时)
|
||||
if (visibleSb.length) {
|
||||
const secs = [...mask.querySelectorAll('.rw-stack > .rw-sec')];
|
||||
const aiBody = secs[1] && secs[1].querySelector('.rw-sec-body');
|
||||
const sbBody = secs[2] && secs[2].querySelector('.rw-sec-body');
|
||||
if (aiBody && sbBody) {
|
||||
// AI脚本列主导联动:AI脚本列滚动 → 分镜列对齐到对应场块(分镜列滚动不反向联动)
|
||||
const syncSb = () => {
|
||||
const heads = [...aiBody.querySelectorAll('strong, h1, h2, h3, h4')].filter((el) => /场\s*\d/.test(el.textContent));
|
||||
if (!heads.length) return;
|
||||
const aiTop = aiBody.getBoundingClientRect().top;
|
||||
const limit = aiBody.scrollTop + 60; // 视口上缘往下 60px 内最后一个场标题 = 当前场
|
||||
let cur = heads[0];
|
||||
for (const h of heads) {
|
||||
const t = h.getBoundingClientRect().top - aiTop + aiBody.scrollTop;
|
||||
if (t <= limit) cur = h; else break;
|
||||
}
|
||||
const m = /场\s*(\d+)/.exec(cur.textContent);
|
||||
if (!m) return;
|
||||
const target = [...sbBody.querySelectorAll('.sb-block')].find((b) => new RegExp('场' + m[1] + '(?![0-9])').test((b.querySelector('.sb-head') || {}).textContent || ''));
|
||||
if (target) {
|
||||
const t = target.getBoundingClientRect().top - sbBody.getBoundingClientRect().top + sbBody.scrollTop;
|
||||
if (Math.abs(sbBody.scrollTop - t) > 1) sbBody.scrollTop = t; // 位置相同不重复设置,避免事件环
|
||||
}
|
||||
};
|
||||
aiBody.addEventListener('scroll', syncSb, { passive: true });
|
||||
syncSb(); // 打开弹窗时先对齐一次
|
||||
}
|
||||
}
|
||||
}
|
||||
renderModal();
|
||||
}
|
||||
@@ -404,7 +601,7 @@ async function renderReviews(view) {
|
||||
};
|
||||
const reviewOf = (row) => (row.review_ai && row.review_ai.status === 'done' ? row.review_ai : (row.review_compare && row.review_compare.status === 'done' ? row.review_compare : null));
|
||||
const fmtScore = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : '-');
|
||||
const GEN = { rewrite: '爆款视频创作', new: '热门账号创作', abc: 'ABC融合创作' };
|
||||
const GEN = { rewrite: '爆款视频创作', new: '账号列表创作', abc: 'ABC融合创作' };
|
||||
|
||||
const rows = items.map((row) => {
|
||||
const sd = dimsOf(row);
|
||||
@@ -437,7 +634,13 @@ async function renderReviews(view) {
|
||||
view.querySelectorAll('[data-open]').forEach((a) => a.addEventListener('click', () => openScriptDetail(a.dataset.open)));
|
||||
view.querySelectorAll('[data-review]').forEach((a) => a.addEventListener('click', () => openReviewDetail(a.dataset.review)));
|
||||
view.querySelectorAll('[data-review-row]').forEach((b) => b.addEventListener('click', () => {
|
||||
firePrompt(`请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。`, '复盘指令');
|
||||
confirmRun({
|
||||
title: '复盘评分',
|
||||
desc: '将对该 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。',
|
||||
prompt: '请对这条 AI 脚本做复盘评分(AI脚本 / 原视频脚本 / 对比分析三种模式)。',
|
||||
label: '复盘指令',
|
||||
btn: b,
|
||||
});
|
||||
}));
|
||||
|
||||
async function openScriptDetail(rewriteId) {
|
||||
@@ -450,7 +653,7 @@ async function renderReviews(view) {
|
||||
const cur = idx >= 0 ? rewrites[idx] : null;
|
||||
const mask = document.createElement('div'); mask.className = 'modal-mask';
|
||||
mask.innerHTML = `<div class="modal rw-modal">
|
||||
<div class="modal-title-row"><span>源脚本 vs AI写脚本 — ${esc(row.title || '')}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="modal-title-row"><span>${esc(fmtTitleQ(row.title || ''))}</span><button class="btn" data-x>${svgIcon('x', 14)}</button></div>
|
||||
<div class="rw-split">
|
||||
<div class="rw-pane"><div class="rw-pane-title">源脚本</div><div class="rw-content md-body">${renderMarkdown(d2.source || '(暂无源脚本数据)')}</div></div>
|
||||
<div class="rw-pane"><div class="rw-pane-title">AI脚本</div><div class="rw-content md-body">${cur ? renderMarkdown(cur.script_text || '(暂无内容)') : '(暂无改写脚本)'}</div></div>
|
||||
|
||||
@@ -26,17 +26,17 @@
|
||||
<!-- ========== 设置弹窗 ========== -->
|
||||
<div id="settingsModal" class="modal-mask" hidden>
|
||||
<div class="modal">
|
||||
<h3><i data-icon="settings"></i> 项目地址设置</h3>
|
||||
<p class="dim">默认读取桌面 <code>MCNSkill项目</code>。可添加其他项目地址(如 dsh 环境的 <code>D:\dshworkspace</code>)。</p>
|
||||
<div class="modal-title-row">
|
||||
<span><i data-icon="settings"></i> 项目文件地址</span>
|
||||
<button class="btn" id="btnCloseSettings" title="关闭"><i data-icon="x"></i></button>
|
||||
</div>
|
||||
<p class="dim">默认读取桌面 <code>MCNSkill项目</code>。</p>
|
||||
<div id="rootList" class="root-list"></div>
|
||||
<div class="root-add">
|
||||
<input id="newRoot" type="text" placeholder="输入项目地址完整路径,如 D:\dshworkspace">
|
||||
<button id="btnAddRoot" class="primary">添加</button>
|
||||
<input id="newRoot" type="text" placeholder="C:\Users\maidou\Desktop\MCNSkill项目">
|
||||
<button id="btnAddRoot" class="primary">确认</button>
|
||||
</div>
|
||||
<p id="rootMsg" class="root-msg"></p>
|
||||
<div class="modal-actions">
|
||||
<button id="btnCloseSettings" class="ghost">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -65,8 +65,8 @@ main { height: calc(100vh - 54px); }
|
||||
.intro { max-width: 900px; margin: 0 auto 20px; color: var(--dim); }
|
||||
.func-grid {
|
||||
max-width: 900px; margin: 0 auto;
|
||||
display: grid; grid-template-columns: repeat(auto-fill, minmax(420px, 1fr));
|
||||
gap: 16px;
|
||||
display: grid; grid-template-columns: 1fr; /* 单列,卡片独占一行 */
|
||||
gap: 14px;
|
||||
}
|
||||
.func-card {
|
||||
background: var(--panel); border: 1px solid var(--border);
|
||||
@@ -75,10 +75,10 @@ main { height: calc(100vh - 54px); }
|
||||
.func-card h3 { font-size: 18px; margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
|
||||
.func-card .desc { color: var(--dim); font-size: 15px; margin-bottom: 10px; }
|
||||
.func-card .qa { border-top: 1px dashed var(--border); padding-top: 8px; }
|
||||
.qa-row { display: flex; gap: 8px; padding: 4px 0; font-size: 15px; }
|
||||
.qa-row .q { color: var(--accent); white-space: nowrap; }
|
||||
.qa-row .a { color: var(--dim); }
|
||||
.qa-row.copyable { cursor: pointer; border-radius: 6px; padding: 4px 6px; margin: 0 -6px; transition: background .15s; }
|
||||
.qa-row { padding: 6px 8px; font-size: 15px; line-height: 1.55; }
|
||||
.qa-row .q { color: var(--accent); font-weight: 500; display: block; white-space: normal; }
|
||||
.qa-row .a { color: var(--dim); display: block; padding-left: 1.2em; margin-top: 2px; }
|
||||
.qa-row.copyable { cursor: pointer; border-radius: 8px; margin: 0 -8px; transition: background .15s; }
|
||||
.qa-row.copyable:hover { background: var(--primary-soft); }
|
||||
.qa-row.copyable:hover .q::after { content: ' 📋'; font-size: 14px; opacity: .7; }
|
||||
|
||||
@@ -168,12 +168,13 @@ main { height: calc(100vh - 54px); }
|
||||
padding: 8px 14px; margin: 10px 0; border-radius: 0 8px 8px 0; color: #333;
|
||||
}
|
||||
.md-body table { border-collapse: collapse; margin: 10px 0; width: 100%; display: block; overflow-x: auto; }
|
||||
.md-body table th:first-child, .md-body table td:first-child { width: 100px; } /* markdown 表格第一列统一 100px(如人设卡维度列) */
|
||||
.md-body th, .md-body td { border: 1px solid var(--border); padding: 6px 10px; font-size: 15px; }
|
||||
.md-body th { background: #f6f8fa; font-weight: 600; }
|
||||
.md-body ul, .md-body ol { margin: 8px 0 8px 22px; }
|
||||
.md-body li { margin: 3px 0; }
|
||||
.md-body hr { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
|
||||
.md-body a { color: var(--primary); }
|
||||
.md-body a { color: #4a90e2; }
|
||||
|
||||
/* ---- JSON 预览 ---- */
|
||||
.json-view pre {
|
||||
@@ -195,6 +196,31 @@ main { height: calc(100vh - 54px); }
|
||||
.modal h3 { margin-bottom: 10px; display: flex; align-items: center; gap: 8px; }
|
||||
.modal .dim { color: var(--dim); font-size: 15px; margin-bottom: 12px; }
|
||||
.modal code { background: #f0f2f5; padding: 1px 5px; border-radius: 4px; font-size: 14px; }
|
||||
/* 保存确认弹窗:账号名/链接展示 */
|
||||
.confirm-target {
|
||||
background: var(--primary-soft); border-radius: 8px; padding: 10px 12px;
|
||||
font-size: 16px; font-weight: 600; color: var(--primary); margin-bottom: 6px;
|
||||
}
|
||||
.confirm-url {
|
||||
color: var(--dim); font-size: 13px; word-break: break-all;
|
||||
padding: 0 2px 4px; margin-bottom: 6px;
|
||||
}
|
||||
/* AI脚本弹窗:选题输入区 + 右下角 AI 生成按钮 */
|
||||
.ai-topic { position: relative; margin-bottom: 4px; }
|
||||
.ai-topic-input {
|
||||
width: 100%; min-height: 168px; padding: 8px 10px;
|
||||
border: 1px solid var(--border); border-radius: 8px;
|
||||
font-size: 15px; font-family: inherit; line-height: 1.5; resize: vertical;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.ai-topic-input:focus { outline: none; border-color: var(--primary); }
|
||||
.ai-topic-btn {
|
||||
position: absolute; right: 8px; bottom: 8px;
|
||||
border: 1px solid var(--primary); background: var(--primary-soft); color: var(--primary);
|
||||
border-radius: 6px; padding: 3px 12px; font-size: 14px; font-weight: 600; cursor: pointer;
|
||||
}
|
||||
.ai-topic-btn:hover { background: var(--primary); color: #fff; }
|
||||
.ai-topic-btn:disabled { opacity: .6; cursor: wait; }
|
||||
.root-list { display: flex; flex-direction: column; gap: 6px; margin-bottom: 12px; }
|
||||
.root-item {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
@@ -209,6 +235,11 @@ main { height: calc(100vh - 54px); }
|
||||
.root-add input { flex: 1; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: 15px; }
|
||||
.root-msg { margin-top: 8px; font-size: 14px; color: var(--accent); min-height: 16px; }
|
||||
.modal-actions { text-align: right; margin-top: 14px; }
|
||||
/* 导入账号弹窗:方式一账号信息 / 方式二本地文件夹 */
|
||||
.import-form .f-row { margin-bottom: 10px; }
|
||||
.import-form label { display: block; font-size: 13px; color: var(--dim); margin-bottom: 4px; }
|
||||
.import-form .input { width: 100%; box-sizing: border-box; }
|
||||
.import-form .divider { height: 1px; background: var(--border); margin: 14px 0; }
|
||||
|
||||
.dim { color: var(--dim); }
|
||||
|
||||
@@ -223,7 +254,7 @@ main { height: calc(100vh - 54px); }
|
||||
.toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
|
||||
/* ================= 面包屑 ================= */
|
||||
.crumbs { display: flex; align-items: baseline; gap: 8px; margin-left: 12px; min-width: 0; }
|
||||
.crumbs { display: flex; align-items: baseline; gap: 8px; margin-left: 0; min-width: 0; }
|
||||
.crumb-home { font-size: 21px; font-weight: 700; color: var(--text); cursor: pointer; text-decoration: none; }
|
||||
a.crumb-home:hover { color: var(--primary); }
|
||||
.crumb-sep { color: var(--dim); font-size: 16px; }
|
||||
@@ -243,18 +274,22 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
|
||||
/* ================= 功能页通用 ================= */
|
||||
.page { padding: 20px 24px; max-width: 1440px; margin: 0 auto; }
|
||||
.page-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 14px; }
|
||||
/* 账号详情页:仅内容区(信息卡/列表/面板)右移 10px,返回按钮与 tab 保持原位 */
|
||||
.page-detail .detail-body { padding-left: 10px; }
|
||||
.page-toolbar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.toolbar-right { display: flex; align-items: center; gap: 8px; margin-left: auto; }
|
||||
.toolbar-note { font-size: 12px; color: var(--dim); text-align: right; margin: -14px 10px 16px auto; } /* 功能栏下方小字说明,右对齐 */
|
||||
.page-title { font-size: 18px; font-weight: 600; display: inline-flex; align-items: center; gap: 8px; }
|
||||
.input { border: 1px solid var(--border); border-radius: 8px; padding: 6px 10px; font-size: 15px; min-width: 200px; background: #fff; color: var(--text); }
|
||||
.input-select { border: 1px solid var(--border); border-radius: 8px; padding: 6px 8px; font-size: 15px; background: #fff; color: var(--text); }
|
||||
.btn { border: 1px solid var(--border); background: var(--panel); color: var(--text); padding: 6px 14px; border-radius: 8px; cursor: pointer; font-size: 15px; text-decoration: none; }
|
||||
.btn:hover { border-color: var(--primary); color: var(--primary); }
|
||||
.btn:disabled { opacity: .5; cursor: not-allowed; }
|
||||
.btn-busy { background: var(--primary-soft) !important; color: var(--primary) !important; border-color: var(--primary) !important; opacity: .8; }
|
||||
.btn-sm { padding: 3px 10px; font-size: 14px; border-radius: 6px; }
|
||||
.btn-primary { background: var(--primary); color: #fff; border-color: transparent; }
|
||||
.btn-primary:hover { background: #2a5fd0; color: #fff; }
|
||||
/* 功能列按钮区分:导入=浅灰底深字,查看=白底蓝字 */
|
||||
/* 功能列按钮区分:保存=浅灰底深字,查看=白底蓝字 */
|
||||
.btn-import { background: #f0f2f5; border-color: #e1e4e8; color: #495057; }
|
||||
.btn-import:hover { background: #e4e7eb; border-color: #d0d4da; color: #343a40; }
|
||||
.btn-view { background: #fff; border-color: var(--primary); color: var(--primary); }
|
||||
@@ -268,40 +303,70 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
.table td { padding: 7px 10px; border-bottom: 1px solid var(--border); white-space: nowrap; }
|
||||
.table tbody tr:hover { background: var(--primary-soft); }
|
||||
.cell-content { max-width: 220px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.cell-loc { max-width: 110px; } /* 账号定位列缩窄 */
|
||||
.sortable { cursor: pointer; }
|
||||
.sortable:hover { color: var(--primary); }
|
||||
.link { color: var(--primary); cursor: pointer; text-decoration: none; }
|
||||
.link { color: #4a90e2; cursor: pointer; text-decoration: none; }
|
||||
.link:hover { text-decoration: underline; }
|
||||
.link.strong { font-weight: 600; }
|
||||
/* 无颜色区分的链接(视频标题等):默认同正文色,hover 才提示可点 */
|
||||
.title-plain { color: inherit; cursor: pointer; text-decoration: none; }
|
||||
.title-plain:hover { color: #4a90e2; text-decoration: underline; }
|
||||
.badge { display: inline-block; padding: 2px 8px; border-radius: 10px; font-size: 13px; }
|
||||
.badge-signed { background: var(--primary-soft); color: var(--primary); }
|
||||
.badge-ext { background: #fff4e5; color: var(--accent); }
|
||||
.pager { display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 12px 4px; }
|
||||
.pager-total, .pager-info { color: var(--dim); font-size: 15px; }
|
||||
|
||||
/* ================= 数据页 tab ================= */
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 14px; border-bottom: 1px solid var(--border); }
|
||||
.tab { border: none; background: none; padding: 8px 16px; font-size: 16px; color: var(--dim); cursor: pointer; border-bottom: 2px solid transparent; }
|
||||
/* ================= 数据页 tab(下划线式:无背景,激活蓝色文字+直线下划线,与内容左对齐) ================= */
|
||||
.tabs { display: flex; gap: 4px; margin-bottom: 20px; margin-left: 0; border-bottom: 1px solid var(--border); }
|
||||
.tab { border: none; background: none; padding: 8px 16px; font-size: 15px; color: var(--dim); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; border-radius: 0; }
|
||||
.tab:hover { color: var(--primary); }
|
||||
.tab-active { color: var(--primary); font-weight: 600; border-bottom-color: var(--primary); }
|
||||
/* 数据页列表标题(如账号视频列表):与上方内容间隔 20px */
|
||||
.video-list-title { margin: 20px 0 10px; font-size: 17px; font-weight: 600; }
|
||||
/* 视频ID 列紧凑:小号数字,宽度刚好容纳 ID */
|
||||
.td-id { font-size: 13px; font-variant-numeric: tabular-nums; padding-left: 8px; padding-right: 8px; }
|
||||
th.th-id { width: 120px; }
|
||||
|
||||
/* ================= 信息卡 ================= */
|
||||
.info-card { border: 1px solid var(--border); border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; background: var(--panel); }
|
||||
/* 账号信息按行分组:行内项目按内容宽度左对齐,保留行间距 */
|
||||
.info-row { display: flex; flex-wrap: wrap; gap: 10px 80px; }
|
||||
.info-row .info-item { flex: 0 0 auto; min-width: 100px; }
|
||||
.info-row + .info-row { margin-top: 10px; }
|
||||
.info-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 10px 18px; }
|
||||
.info-item { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
|
||||
.info-wide { grid-column: 1/-1; }
|
||||
.info-label { font-size: 14px; color: var(--dim); }
|
||||
.info-value { font-size: 15px; color: var(--text); word-break: break-word; white-space: pre-wrap; line-height: 1.6; }
|
||||
/* 长文本默认最多 10 行,可点击展开/收起(账号定位等) */
|
||||
.clamp-text { display: -webkit-box; -webkit-line-clamp: 10; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.clamp-text.open { -webkit-line-clamp: unset; }
|
||||
.clamp-btn { border: none; background: none; color: #4a90e2; cursor: pointer; font-size: 13px; padding: 2px 0; }
|
||||
.clamp-btn:hover { text-decoration: underline; }
|
||||
|
||||
/* ================= 对比弹窗 ================= */
|
||||
.modal-title-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 14px; font-size: 18px; font-weight: 600; }
|
||||
.rw-modal { width: min(1100px, 95vw); max-height: 90vh; overflow: auto; }
|
||||
.modal-title-row span { white-space: pre-wrap; word-break: break-word; } /* 标题内换行(标签/共创信息分行显示) */
|
||||
.rw-modal { width: min(1600px, calc(100% - 100px)); height: calc(100vh - 60px); overflow: auto; } /* 宽度:距左右各 50px,最大 1600px;固定高度距屏幕上下各 30px */
|
||||
.rw-split { display: flex; gap: 14px; }
|
||||
.rw-pane { flex: 1; min-width: 0; display: flex; flex-direction: column; }
|
||||
.rw-pane-title { font-weight: 600; margin-bottom: 8px; }
|
||||
.rw-content { flex: 1; overflow: auto; border: 1px solid var(--border); border-radius: 8px; padding: 12px 14px; max-height: 60vh; }
|
||||
.rw-ver { display: flex; gap: 6px; margin-bottom: 8px; }
|
||||
.rw-sb { margin-top: 14px; }
|
||||
/* 查看弹窗横向三板块:等高、与页面高度匹配,底部保留版本切换功能区;宽度不足时源脚本列收起 */
|
||||
.rw-modal { display: flex; flex-direction: column; overflow: hidden; }
|
||||
.rw-modal .modal-title-row { flex: none; }
|
||||
.rw-stack { flex: 1; min-height: 0; display: flex; flex-direction: row; gap: 12px; }
|
||||
.rw-sec { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
|
||||
.rw-sec-head { flex: none; display: flex; align-items: center; gap: 10px; padding: 8px 12px; font-weight: 600; background: var(--panel); border-bottom: 1px solid var(--border); } /* 标题右侧可放操作按钮(展开源脚本),间隔 10px */
|
||||
.rw-sec-body { flex: 1; min-height: 0; overflow: auto; padding: 12px 14px; }
|
||||
.rw-sec-foot { flex: none; min-height: 46px; display: flex; align-items: center; justify-content: flex-end; gap: 8px; padding: 8px 12px; border-top: 1px solid var(--border); } /* min-height 保证空 foot(源脚本列占位)与其他列等高,内容区一致 */
|
||||
.rw-sec-count { margin-right: auto; color: var(--dim); font-size: 14px; }
|
||||
.rw-sec-hidden { display: none; } /* 源脚本列默认收起 */
|
||||
.src-on { background: #e8f1fd; border-color: #4a90e2; color: #2f6fed; } /* 源脚本展开态高亮 */
|
||||
.rw-scroll { padding: 4px 2px; max-height: 75vh; overflow: auto; }
|
||||
.sb-block { border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; overflow: hidden; background: var(--panel); }
|
||||
.sb-head { display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: #f6f8fa; border-bottom: 1px solid var(--border); }
|
||||
@@ -329,7 +394,7 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
.recent-head .link { font-size: 13px; margin-left: auto; margin-right: 10px; }
|
||||
.recent-list { display: grid; grid-template-columns: repeat(auto-fill, minmax(380px, 1fr)); gap: 14px; }
|
||||
|
||||
/* ================= 首页 抖音周榜 预览 ================= */
|
||||
/* ================= 首页 账号周榜 预览 ================= */
|
||||
.rank-preview { margin: 4px 0 20px; }
|
||||
.rank-tools { display: flex; align-items: center; gap: 6px; margin-left: auto; }
|
||||
.rank-filter { padding: 3px 6px; font-size: 14px; max-width: 110px; }
|
||||
@@ -346,8 +411,19 @@ main#view { height: calc(100vh - 54px); overflow: auto; }
|
||||
.rk-act { display: flex; align-items: center; justify-content: center; white-space: nowrap; }
|
||||
.cell-act { text-align: center; white-space: nowrap; }
|
||||
.table th.th-act { text-align: center; }
|
||||
/* 收藏按钮(⭐/☆) */
|
||||
.fav-btn { border: none; background: none; cursor: pointer; font-size: 16px; padding: 2px 4px; line-height: 1; border-radius: 6px; }
|
||||
.fav-btn:hover { background: var(--primary-soft); }
|
||||
.fav-btn:disabled { opacity: .5; cursor: wait; }
|
||||
.table td.score-cell { color: var(--accent); font-weight: 600; }
|
||||
.rank-pager { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 8px; }
|
||||
|
||||
/* 空状态(如账号暂无设定):居中图标+标题+说明+按钮,清爽留白 */
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 56px 20px; gap: 10px; text-align: center; }
|
||||
.empty-state .empty-icon { color: var(--dim); opacity: .45; margin-bottom: 2px; }
|
||||
.empty-state .empty-title { font-size: 17px; font-weight: 600; color: var(--text); }
|
||||
.empty-state .empty-desc { font-size: 14px; color: var(--dim); }
|
||||
.empty-state .btn { margin-top: 10px; }
|
||||
.rank-page-info { font-size: 14px; color: var(--dim); }
|
||||
.recent-card { display: flex; flex-direction: column; align-items: flex-start; gap: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 10px; padding: 14px 16px; cursor: pointer; text-align: left; transition: all .15s; min-height: 172px; }
|
||||
.recent-card:hover { border-color: var(--primary); box-shadow: 0 2px 8px rgba(47,111,237,.12); }
|
||||
|
||||
@@ -232,6 +232,20 @@ const server = http.createServer((req, res) => {
|
||||
return dshOk(200, dsh.listAccounts({ page: qInt('page', 1), pageSize: qInt('pageSize', 20), search: String(q.get('search') || ''), track: String(q.get('track') || ''), type: String(q.get('type') || 'all') }));
|
||||
} catch (e) { return sendErr(500, e.message); }
|
||||
}
|
||||
if (p === '/api/dsh/account-favorite' && req.method === 'POST') {
|
||||
if (dshGuard()) return;
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||||
req.on('end', () => {
|
||||
try {
|
||||
const { id, fav } = JSON.parse(body || '{}');
|
||||
if (!id) return sendErr(400, '缺少 id');
|
||||
dsh.setFavorite(id, !!fav);
|
||||
return dshOk(200, { id, favorite: !!fav });
|
||||
} catch (e) { return sendErr(500, e.message); }
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (p === '/api/dsh/account-videos') {
|
||||
if (dshGuard()) return;
|
||||
try {
|
||||
@@ -320,6 +334,99 @@ const server = http.createServer((req, res) => {
|
||||
return;
|
||||
}
|
||||
|
||||
// 查询 AI 任务执行状态(供前端轮询恢复按钮):queued / pending / running / done / error / notfound
|
||||
if (p === '/api/run/status' && req.method === 'GET') {
|
||||
const id = q.get('id');
|
||||
if (!id) return sendErr(400, '缺少 id');
|
||||
try {
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const db = new DatabaseSync(process.env.WORKBUDDY_DB || path.join(os.homedir(), '.workbuddy', 'workbuddy.db'), { readOnly: true });
|
||||
const auto = db.prepare('SELECT status, deleted_at, last_run_at FROM automations WHERE id=?').get(id);
|
||||
if (!auto || auto.deleted_at) { db.close(); return sendJSON(200, { ok: true, id, state: 'notfound' }); }
|
||||
const st = db.prepare('SELECT running, last_error FROM automation_runtime_state WHERE automation_id=?').get(id);
|
||||
const runs = db.prepare("SELECT status FROM automation_runs WHERE automation_id=? ORDER BY created_at DESC LIMIT 1").get(id);
|
||||
db.close();
|
||||
let state = 'queued';
|
||||
if (st && Number(st.running) === 1) state = 'running';
|
||||
else if (runs) {
|
||||
const s = String(runs.status || '').toUpperCase();
|
||||
if (s === 'IN_PROGRESS') state = 'running';
|
||||
else if (s === 'PENDING_REVIEW') state = 'pending';
|
||||
else if (['DONE', 'COMPLETED', 'SUCCESS', 'FINISHED'].includes(s)) state = 'done';
|
||||
else if (['ERROR', 'CANCELLED', 'FAILED', 'INTERRUPTED'].includes(s)) state = 'error';
|
||||
else state = 'pending';
|
||||
}
|
||||
return sendJSON(200, { ok: true, id, state, error: (st && st.last_error) || null });
|
||||
} catch (e) {
|
||||
return sendErr(500, '状态查询失败: ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- AI 生成选题(Dify chat-messages 即时调用,参考技能 S5 选题方法论) ----------
|
||||
if (p === '/api/ai/topics' && req.method === 'POST') {
|
||||
let body = '';
|
||||
req.on('data', (c) => { body += c; if (body.length > 1e6) req.destroy(); });
|
||||
req.on('end', async () => {
|
||||
try {
|
||||
const { accountName } = JSON.parse(body || '{}');
|
||||
if (!accountName || typeof accountName !== 'string') return sendErr(400, '缺少账号名');
|
||||
// 1) 查账号定位 + 人设摘要(工作台库)
|
||||
let contentText = '', personaText = '';
|
||||
try {
|
||||
const { DatabaseSync } = require('node:sqlite');
|
||||
const db = new DatabaseSync(path.join(ROOT, 'mcn-plugin.db'), { 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);
|
||||
const p = db.prepare('SELECT content_json, summary FROM account_persona WHERE account_id=? ORDER BY id DESC LIMIT 1').get(acc.id);
|
||||
if (p) personaText = String(p.summary || p.content_json || '').slice(0, 500);
|
||||
}
|
||||
db.close();
|
||||
} catch (e) { /* 库不可用不阻塞,仍可生成 */ }
|
||||
// 2) 读 Dify key:环境变量优先,其次技能脚本内置默认
|
||||
let apiKey = process.env.DIFY_MCN_CYLG_KEY || '';
|
||||
if (!apiKey) {
|
||||
const scriptPath = path.join(ROOT, '..', 'scripts', 'MCN_CYLG_API.py');
|
||||
try {
|
||||
const src = fs.readFileSync(scriptPath, 'utf8');
|
||||
const m = src.match(/DEFAULT_CYLG_KEY\s*=\s*"([^"]+)"/);
|
||||
if (m) apiKey = m[1];
|
||||
} catch (e) { /* 脚本不存在则走 env */ }
|
||||
}
|
||||
if (!apiKey) return sendErr(500, '未配置 DIFY_MCN_CYLG_KEY(环境变量或技能脚本内置 KEY)');
|
||||
// 3) 构造选题 query(参考 references/创作流程/5_生成短视频选题.md 方法论)
|
||||
const query = '你是短视频选题策划师,请为达人「' + accountName + '」结合账号设定生成 3 个爆款选题方案。\n\n'
|
||||
+ '【达人信息】\n' + (contentText || '(无账号定位信息)') + '\n'
|
||||
+ (personaText ? '【人设摘要】\n' + personaText + '\n' : '')
|
||||
+ '\n要求:\n'
|
||||
+ '1. 选题要跳出账号核心行为框架,但保留账号结构性符号(固定机制/口头禅等)\n'
|
||||
+ '2. 每个选题一句话说清主题+切入角度,标注目标体感(如治愈感/爽感/获得感)\n'
|
||||
+ '3. 只输出 3 个选题,每行一个,格式:1. 主题(切入角度|目标体感)\n'
|
||||
+ '4. 不要输出其他解释';
|
||||
// 4) 调 Dify chat-messages(blocking 即时返回)
|
||||
const difyUrl = String(process.env.DIFY_URL || 'https://mydify.youmanvideo.com/v1').replace(/\/+$/, '');
|
||||
const ctrl = new AbortController();
|
||||
const timer = setTimeout(() => ctrl.abort(), 100000);
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(difyUrl + '/chat-messages', {
|
||||
method: 'POST',
|
||||
headers: { 'Authorization': 'Bearer ' + apiKey, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inputs: { is_think: '0', is_online: '0', model: process.env.DIFY_MCN_MODEL || 'gemini' }, query, response_mode: 'blocking', user: 'mcn-work-shop' }),
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
} finally { clearTimeout(timer); }
|
||||
const data = await resp.json().catch(() => ({}));
|
||||
if (!resp.ok) return sendErr(502, 'AI 接口错误: ' + (data.message || resp.status));
|
||||
log('AI 选题生成: ' + accountName);
|
||||
return dshOk(200, { topics: data.answer || '' });
|
||||
} catch (e) {
|
||||
return sendErr(500, '选题生成失败: ' + e.message);
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// ---------- 静态文件 ----------
|
||||
let filePath = path.join(PUBLIC_DIR, p === '/' ? 'index.html' : p);
|
||||
// 防穿越
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
**场1:地点·时间(Xs-Xs) 情绪:{从S6继承的情绪值}**
|
||||
【景别 | 环境 | 光线 | 场景 | 场景元素】
|
||||
|
||||
(动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号。情绪通过身体反应内嵌在动作中。)
|
||||
动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号,**不加括号**(括号仅用于对白行表演标注)。情绪通过身体反应内嵌在动作中。
|
||||
|
||||
**角色名**:
|
||||
(动作/表情/语气)
|
||||
@@ -92,7 +92,7 @@
|
||||
| 画外对白 | `**角色名**(画外):` 换行 `(动作/语气)` 换行后写台词 | 角色在画面外说话时使用。画外标注在角色名同行。动作标注另起一行。台词再另起一行。**画外对白≠旁白**——画外对白有明确的说话角色,旁白是无主体的画外音 |
|
||||
| 口播 | `**角色名**(口播·{语气}):台词` 同一行 | **角色口播/单人讲述**使用。角色面对镜头说话,人物标注与台词同一行,不换行 |
|
||||
| 旁白 | `旁白(语气):台词` 同一行,不加粗 | **旁白驱动**使用(仅限 Vlog口播/学习教育/观点输出/教程)。一句一行。**剧情演绎类严禁旁白** |
|
||||
| 动作叙述 | 自然段落 | 无台词的动作/场景描述,直接连续自然段落书写。**支持闪回叙事**:段落以 `闪回:` 开头切入回忆/前情。连续动作用 `→` 串联。场景信息通过动作叙述自然带出,不单独标注 |
|
||||
| 动作叙述 | 自然段落 | **不加括号**(括号仅用于对白行表演标注)。无台词的动作/场景描述,直接连续自然段落书写。**支持闪回叙事**:段落以 `闪回:` 开头切入回忆/前情。连续动作用 `→` 串联。场景信息通过动作叙述自然带出,不单独标注 |
|
||||
| 纯空镜 | 自然段落 | 仅画面叙事、无台词无动作的空镜/B-roll,自然段落书写 |
|
||||
|
||||
> **段落符号规则**:S9 脚本使用连续自然段落书写,不使用列表符号(`- `)。动作叙述、对白、闪回、空镜均以自然段落承载。表格(如账本/清单等纯数据展示)保留表格格式。
|
||||
@@ -207,7 +207,7 @@
|
||||
**_A·场次叙事(F01 剧情演绎):_**
|
||||
- **场次标题**:**加粗**,`第N场:地点·时间(Xs-Xs) 情绪:{值}`,用 `·` 分隔地点和时间。标题只交代场景(在哪里、什么时候),不写叙事内容。
|
||||
- **场次画面元数据**:场标题下加一行【景别 | 环境 | 光线 | 场景 | 场景元素】:景别=画面聚焦范围;环境=环境氛围;光线=光线氛围;场景=具体场景地点;场景元素=关键物件/人物/状态
|
||||
- **动作叙述**:连续自然段落书写,不使用列表符号。情绪通过身体反应内嵌在动作中。
|
||||
- **动作叙述**:连续自然段落书写,不使用列表符号,**不加括号**(括号仅用于对白行表演标注)。情绪通过身体反应内嵌在动作中。
|
||||
- **对白驱动/事件驱动** → 角色名**加粗**单独一行,动作标注另起一行括号包裹 `(动作/表情/语气)`,台词再另起一行。一行一句台词。
|
||||
- 角色在画面外说话 → 角色名加粗单独一行,标注 `(画外)`,动作标注另起一行,台词再另起一行
|
||||
- **禁止旁白驱动**:剧情类故事必须靠角色行为和对话演绎
|
||||
@@ -298,6 +298,7 @@
|
||||
- 不得漏掉蓄力释放出口(仅强情感铺垫强制):蓄力期必须有对应的释放期,释放量≥蓄力量
|
||||
- **不得省略语气标注**:每句台词必须标注表演方向和语气
|
||||
- **不得在分行格式中杂糅多条信息**:一行只放一句台词或一个动作段落
|
||||
- **不得给独立动作叙述段落套括号(A 格式)**:无台词的动作/场景连续叙述(多人行为、情绪反应、场景转换)一律裸写自然段落,不加括号;括号仅用于对白行表演标注(另起一行的 `(动作/表情/语气)`、画外 `(画外)`)。B 格式的纯动作/空镜与 D 格式的画面描述按各自格式规范保留括号包裹,不得混用
|
||||
- **不得用花字替代内容演绎**:好笑靠角色行为,不靠花字标注"好笑"。花字仅剧本确实需要提示后期方向时少量使用(每场≤2条)
|
||||
- **闪回不得替代主线叙事**:单条脚本闪回≤2次,单次时长参见知识库 `03_框架节奏/` 标准,闪回后必须紧跟回流动作
|
||||
- **不得使用列表符号 `- ` 书写脚本正文**(S9 使用连续自然段落格式)
|
||||
@@ -362,7 +363,7 @@
|
||||
**场1:{地点·时间}({X}-{X}秒) 情绪:{从S6继承的情绪值}**
|
||||
【景别 | 环境 | 光线 | 场景 | 场景元素】
|
||||
|
||||
({动作描述——场景/人物行为/情绪变化,连续自然段落})
|
||||
{动作描述——场景/人物行为/情绪变化,连续自然段落,不加括号}
|
||||
|
||||
**{角色名}**:
|
||||
({动作/表情/语气})
|
||||
|
||||
@@ -222,7 +222,7 @@
|
||||
**场1:地点·时间(Xs-Xs) 情绪:{从S6继承的情绪值}**
|
||||
【景别 | 环境 | 光线 | 场景 | 场景元素】
|
||||
|
||||
(动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号。情绪通过身体反应内嵌在动作中。)
|
||||
动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号,**不加括号**(括号仅用于对白行表演标注)。情绪通过身体反应内嵌在动作中。
|
||||
|
||||
**角色名**:
|
||||
(动作/表情/语气)
|
||||
@@ -245,7 +245,7 @@
|
||||
**场次书写规则:**
|
||||
- **场次标题**:**加粗**,`第N场:地点·时间(Xs-Xs) 情绪:{值}`,用 `·` 分隔地点和时间。转场时用 `→` 连接(如 `ATM前→家中·白天`)。标题只交代场景,不写叙事内容。
|
||||
- **场次画面元数据**:场标题下加一行 `【景别 | 环境 | 光线 | 场景 | 场景元素】`:景别=画面聚焦范围(近景/全景/特写);环境=环境氛围(时间/天气/气氛);光线=光线氛围(暖光/冷光/逆光);场景=具体场景地点;场景元素=关键物件/人物/状态。
|
||||
- **动作叙述**:连续自然段落,不使用列表符号。情绪通过身体反应内嵌在动作中(如"肩膀微微颤动了一下"),与标题的情绪标签形成双轨呼应。
|
||||
- **动作叙述**:连续自然段落,不使用列表符号,**不加括号**(括号仅用于对白行表演标注)。情绪通过身体反应内嵌在动作中(如"肩膀微微颤动了一下"),与标题的情绪标签形成双轨呼应。
|
||||
- **角色名**:**加粗**单独一行,后跟冒号。动作/表情/语气标注另起一行,用括号包裹(可选,纯台词无动作时省略)。动作标注与角色名**不在同一行**。
|
||||
- **台词**:另起一行,口语化、可直接朗读。一行一句台词。
|
||||
- **纯空镜/无台词**:自然段落描述画面。
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
|
||||
**内容格式:**
|
||||
```
|
||||
(动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号。)
|
||||
动作叙述——场景/人物行为/情绪变化。连续自然段落,不使用列表符号(**不加括号**,括号仅用于对白行表演标注)。
|
||||
|
||||
**角色名**:
|
||||
(动作/表情/语气)
|
||||
@@ -58,6 +58,7 @@
|
||||
- 场标题包含 地点·时间 + 情绪标签
|
||||
- **场次画面元数据**:场标题下加一行 `【景别 | 环境 | 光线 | 场景 | 场景元素】`:景别=画面聚焦范围(近景/全景/特写);环境=环境氛围;光线=光线氛围(暖光/冷光/逆光);场景=具体场景地点;场景元素=关键物件/人物/状态
|
||||
- 角色名加粗单独一行,动作标注另起一行括号包裹,台词再另起一行
|
||||
- **独立动作叙述段落(多人行为/情绪反应/场景变化)不加括号**,裸写自然段落;括号仅用于对白行表演标注(B/D 格式的纯动作/空镜/画面描述括号包裹规则不受影响)
|
||||
- 禁止旁白驱动——故事必须靠角色行为和对话演绎
|
||||
- 支持画外对白(角色在场但镜头未对其拍摄)
|
||||
- 三大段结构:开场钩子 → 中间主体 → 结尾收口
|
||||
|
||||
@@ -40,6 +40,8 @@ DIFY_URL = os.environ.get("DIFY_URL", "https://mydify.youmanvideo.com/v1")
|
||||
DIFY_ENDPOINT = f"{DIFY_URL.rstrip('/')}/chat-messages"
|
||||
DEFAULT_MODEL = os.environ.get("DIFY_MCN_MODEL", "gemini")
|
||||
DEFAULT_TIMEOUT = 1200 # 秒
|
||||
# 内置默认 KEY(环境变量 DIFY_MCN_CYLG_KEY 优先;未设置时用此兜底)
|
||||
DEFAULT_CYLG_KEY = "app-0vRaxPhvVOqzFQIa5eFzFdjD"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -112,9 +114,9 @@ def call_dify(
|
||||
Raises:
|
||||
SystemExit: 缺少密钥、网络错误、API 返回非 200 时退出
|
||||
"""
|
||||
api_key = os.environ.get("DIFY_MCN_CYLG_KEY", "").strip()
|
||||
api_key = os.environ.get("DIFY_MCN_CYLG_KEY", DEFAULT_CYLG_KEY).strip()
|
||||
if not api_key:
|
||||
print("ERROR: 环境变量 DIFY_MCN_CYLG_KEY 未设置。", file=sys.stderr)
|
||||
print("ERROR: DIFY_MCN_CYLG_KEY 未设置且脚本未内置默认 KEY。", file=sys.stderr)
|
||||
print("请先执行: $env:DIFY_MCN_CYLG_KEY=\"app-xxxx\"", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
File renamed without changes.
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user