diff --git a/.workbuddy/memory/2026-08-28.md b/.workbuddy/memory/2026-08-28.md new file mode 100644 index 0000000..7269706 --- /dev/null +++ b/.workbuddy/memory/2026-08-28.md @@ -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()` 顶部加 `` + 「功能帮助」标题;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** 账号列:纯文本 → ``(复用 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 显示 `
(暂无分镜提示词)
` + 2. foot 按状态分流:有分镜=`verCtrl('s',...)`(分镜 1/N + 上/下一版);无分镜=`` + 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 尾部追加 ``(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→`

`);分镜块=`段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` 内追加 ` + + + `; + 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 = ` +

`; + 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': '', @@ -32,6 +171,7 @@ const ICON_PATHS = { 'arrow-left': '', 'x': '', 'zap': '', + 'download': '', }; // 功能图标亮色映射(不同功能不同亮色;未列出的保持 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 ``; + // color 参数覆盖彩色映射:传 'currentColor'/'inherit' 等可让图标跟随字体颜色 + const col = color !== undefined ? color : ICON_COLORS[name]; + const style = col ? ` style="color:${col}"` : ''; + return ``; } // 把 [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 `
@@ -168,7 +311,7 @@ function renderHome() {
-

抖音周榜

+

账号周榜

@@ -189,6 +332,10 @@ function renderHome() { function renderHelp() { return ` `).join('') : '暂无周榜数据'; 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 = '' + esc(e.message) + ''; } } 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 = ``; 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() { diff --git a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js index b854b72..f85b5af 100644 --- a/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js +++ b/project/短视频脚本创作/V1.0/mcn-work-shop/public/data-pages.js @@ -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 ``; } 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 = ` + `; + 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 = `
${toolbarBack()}抖音周榜
${esc(data.error || '加载失败')}
`; bindBack(view); return; } + if (!data.ok) { view.innerHTML = `
${toolbarBack()}账号周榜
${esc(data.error || '加载失败')}
`; 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) { ${fmt(r.newLikes)} ${fmt(r.newComments)} ${fmt(r.newShares)} - ${r.inDb ? `查看` : ``} + ${r.inDb ? `查看` : ``} `).join(''); view.innerHTML = `
${toolbarBack()} - 抖音周榜 + 账号周榜
- - - - - - - + 账号列表 +
+ + + + + +
- - ${rows || ''} + + ${rows || ''}
收藏类型账号名赛道AI内容粉丝获赞作品数地区年龄简介定位/内容视频数操作
暂无账号数据
关注类型账号名赛道AI制作粉丝获赞作品数地区简介视频数操作
暂无账号数据
${pagerHtml(d.total, d.page, state.pageSize, (p) => { state.page = p; load(); })}
`; 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) => `
${label}
${esc(fmt(val))}
`; + const infoGrid = (label, val, wide, raw) => `
${label}
${raw ? val : esc(fmt(val))}
`; const rows = (dv.items || []).map((v) => ` - ${esc(v.aweme_id || '-')} - ${v.video_url ? `${esc(v.video_title || '-')}` : esc(v.video_title || '-')} - ${fmt(v.like_display || v.like_count)} - ${fmt(v.comment_count)} - ${fmt(v.share_count)} - ${fmtDuration(v.duration)} ${fmt(v.publish_time)} - ${(v.has_source || v.has_analysis) ? `` : ``} + ${esc(v.aweme_id || '-')} + ${v.video_url ? `${esc(cleanTitle(v.video_title) || '-')}` : esc(cleanTitle(v.video_title) || '-')} + ${fmt(v.like_display || v.like_count)} + ${fmtWan(v.comment_count)} + ${fmtWan(v.share_count)} + ${fmtDuration(v.duration)} + `).join(''); const tabBtns = [['info', '账号信息'], ['persona', '账号设定'], ['analysis', '数据分析']].map(([k, label]) => ``).join(''); let panel = ''; if (tab === 'info') { - panel = `
- ${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 = `${esc(contentText) || '-'}${contentText.length > 200 ? `` : ''}`; + panel = `
+
${infoGrid('抖音号', acc.douyin_id)}${infoGrid('年龄', acc.age)}
+
${infoGrid('赛道', acc.track)}${infoGrid('AI制作', acc.is_ai === '是' ? '是' : '否')}${infoGrid('地区', acc.location)}
+
${infoGrid('作品数', acc.works_count)}${infoGrid('粉丝', acc.followers)}${infoGrid('获赞', acc.total_likes)}${infoGrid('获取视频', dv.total)}
+
${infoGrid('简介', acc.bio, true)}
+
${infoGrid('账号定位', contentHtml, true, true)}
-
${rows || ''}
视频ID视频标题点赞评论分享时长发布时间操作
暂无视频数据
+

视频列表

+
${rows || ''}
发布时间视频ID视频标题点赞评论分享时长操作
暂无视频数据
${pagerHtml(dv.total, dv.page, 20, (p) => { page = p; load(); })}`; } else if (tab === 'persona') { - panel = persona ? `
${renderMarkdown(persona)}
` : `
该账号暂无账号设定信息
`; + panel = persona ? `
${renderMarkdown(persona)}
` : `
+
${svgIcon('users', 40)}
+
该账号暂无账号设定信息
+
为达人提炼账号设定后,可基于设定生成脚本
+ +
`; } else { panel = analysis ? `
${renderMarkdown(analysis)}
` : `
该账号暂无数据分析
`; } view.innerHTML = ` -
+
${toolbarBack()}${esc(acc.account_name || '账号详情')}
${tabBtns}
- ${panel} +
${panel}
`; 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 ? `${esc(v.video_title)}` : esc(v.video_title); + const titleVal = v.video_url ? `${esc(cleanTitle(v.video_title))}` : 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 ? `
${renderMarkdown(deconstructMd)}
` : `
该视频暂无视频拆解(MCP 结构化分析)
`; else body = analysisMd ? `
${renderMarkdown(analysisMd)}
` : `
该视频暂无视频分析
`; view.innerHTML = ` -
-
${toolbarBack()}${esc(v.video_title || '视频详情')}
-
- ${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)} -
-
${tabBtns}
- ${body} +
+
${toolbarBack()}${esc(cleanTitle(v.video_title) || '视频详情')}
+
+
+ ${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)} +
+
${tabBtns}
+ ${body} +
`; 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) => ` - ${esc(v.account_name || '-')} - ${esc(v.video_title || '-')} + ${v.account_id ? `${esc(v.account_name || '-')}` : esc(v.account_name || '-')} + ${v.video_url ? `${esc(cleanTitle(v.video_title) || '-')}` : esc(cleanTitle(v.video_title) || '-')} ${fmt(v.like_display || v.like_count)} - ${fmt(v.comment_count)} - ${fmt(v.share_count)} + ${fmtWan(v.comment_count)} + ${fmtWan(v.share_count)} ${fmtDuration(v.duration)} ${fmt(v.publish_time)} ${(v.has_source || v.has_analysis) ? '✅已解析' : '未解析'} - ${v.rewrite_count > 0 ? '✅' + v.rewrite_count : '—'} - + ${v.rewrite_count > 0 ? `` : '—'} + `).join(''); view.innerHTML = `
${toolbarBack()} AI写脚本 - - - 每账号近期 30W+ 且评论 1W+ 取 10 条 +
+ + +
+
每账号近期 30W+ 且评论 1W+ 取 10 条
- + ${rows || ''}
账号视频标题点赞评论分享时长发布时间状态已改写操作
账号视频标题点赞评论分享时长发布时间状态已改写操作
暂无视频数据
${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 + ? `${label} ${idx + 1}/${len}` + : (len === 1 ? `${label} 1/1` : ''); + return `
${inner}${genLabel ? `` : ''}
`; + }; const mask = document.createElement('div'); mask.className = 'modal-mask'; mask.innerHTML = `