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:
maogeigei committed 2026-08-28 18:54:01 +08:00
1 parent 9b0c198e2c
commit ab97842030
155 files changed
+1201 -198

No files matched your search

+162
View File
@@ -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
View File
@@ -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 迭代
+3 -2
View File
@@ -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,
};
@@ -6,9 +6,148 @@
/* ================= 工具 ================= */
const $ = (sel) => document.querySelector(sel);
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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)
Loaded 100 of 155 files, more files were not shown because too many files have changed in this diff. Show more