feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
移植自 mac-agent-os 的 mediacrawler_adapter:不起子进程、不开页面,用浏览器里那份
登录态直接调抖音 Web 接口。产物键名照抄 store/douyin,所以 ingest 那条链路一个字不用改。
**目前能用的(真环境实测,非推断)**:
profile/other : 200, 7075 字节 —— 博主主页指标(粉丝/获赞/作品数/昵称)
aweme/detail : 200, 45425 字节 —— 单条作品详情(含点赞/评论/收藏/分享)
**目前不能用的:作品列表 `aweme/post`。** 两个互相独立的原因:
1. 这个接口被抖音单独升级成了真校验:不带 x-tt-argus 回 403「Uifid Not Found」,
带上 dummy 值回 200 + **空 body**。也就是说「头在不在」骗得过,「真校验」过不了。
同一套头打 profile/other 和 aweme/detail 都是通的 —— 抖音是挑着接口加保护的,
挑中的恰好是「批量拉作品列表」这个最敏感的动作。
2. 改走页面截获也不行:CDP 浏览器打开博主主页会落到「验证码中间页」(当天大量探测的
代价,过几小时要重测)。
所以现在的边界是:**已知作品的指标刷新能做,自动发现新作品做不了**。
**排查中控住变量后得到的两条事实**(都写进注释了):
· `Accept` / `Accept-Language` / `Referer` 才是主页接口能返回真数据的原因 —— 只有
UA+client hints+Cookie 时是 200 但仅 121 字节的空壳,补上这三个头变 7074 字节。
(我先前猜的 sec-ch-ua 不是关键。)
· 因此 UA 与 client hints 必须**成套地取自同一个浏览器**,所以 BrowserIdentity 一次
从 CDP 取齐 cookie + UA + hints,而不是各自写死。
「200 + 空 body 必须当场报错」也是刻意写死的:放过去它会在下游变成「这个博主没作品」,
把一次失败伪装成一条正常结果 —— 爬虫那条路正是这么栽的,还被翻译成「账号被封」。
测试 +11:cookie 解析、请求头成套性(含 uifid 缺失/回退)、产物键名与 store 对齐、
以及 _get 的三条失败路径(空 body / 403 带网关原话 / 正常返回)。
This commit is contained in:
+88
@@ -0,0 +1,88 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📊 概览状态
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 联邦机器
|
||||
- generic [ref=e12] [cursor=pointer]: 告警中心
|
||||
- generic [ref=e13] [cursor=pointer]: 统计概览
|
||||
- generic [ref=e14] [cursor=pointer]: 系统核心
|
||||
- generic [ref=e15] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e16]: ▶
|
||||
- generic [ref=e17] [cursor=pointer]: 采集管理
|
||||
- generic [ref=e18] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e19]: ▶
|
||||
- generic:
|
||||
- generic [ref=e20] [cursor=pointer]: 角色管理
|
||||
- generic [ref=e21] [cursor=pointer]: 角色生成
|
||||
- generic [ref=e22] [cursor=pointer]: 原子能力
|
||||
- generic [ref=e23] [cursor=pointer]: 工作流
|
||||
- generic [ref=e24] [cursor=pointer]:
|
||||
- text: 📡 矩阵系统
|
||||
- generic [ref=e25]: ▶
|
||||
- generic:
|
||||
- generic [ref=e26] [cursor=pointer]: 身份与账号
|
||||
- generic [ref=e27] [cursor=pointer]: 命令与任务
|
||||
- generic [ref=e28] [cursor=pointer]: 蓝图编排
|
||||
- generic [ref=e29] [cursor=pointer]: 原子操作
|
||||
- generic [ref=e30] [cursor=pointer]: 录制管理
|
||||
- generic [ref=e31] [cursor=pointer]: ⚙️ 矩阵设置
|
||||
- generic [ref=e32] [cursor=pointer]:
|
||||
- text: ⚙️ 智能系统设置
|
||||
- generic [ref=e33]: ▶
|
||||
- generic:
|
||||
- generic [ref=e34] [cursor=pointer]: 知识库
|
||||
- generic [ref=e35] [cursor=pointer]: 记忆库
|
||||
- generic [ref=e36] [cursor=pointer]: 联邦系统说明
|
||||
- generic [ref=e37] [cursor=pointer]: 技能与工具
|
||||
- generic [ref=e38] [cursor=pointer]:
|
||||
- text: ⏰ 自动化任务
|
||||
- generic [ref=e39]: ▶
|
||||
- generic [ref=e40] [cursor=pointer]: 任务管理
|
||||
- generic [ref=e41] [cursor=pointer]:
|
||||
- text: 📡 社交矩阵
|
||||
- generic [ref=e42]: ▶
|
||||
- generic:
|
||||
- generic [ref=e43] [cursor=pointer]: 账号管理
|
||||
- generic [ref=e44] [cursor=pointer]: 运维执行
|
||||
- generic [ref=e45] [cursor=pointer]: 内容编排
|
||||
- generic [ref=e46] [cursor=pointer]: 任务调度
|
||||
- generic [ref=e47] [cursor=pointer]: 系统设置
|
||||
- generic [ref=e48] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦管理
|
||||
- generic [ref=e49]: ▶
|
||||
- generic:
|
||||
- generic [ref=e50] [cursor=pointer]: 集群概览
|
||||
- generic [ref=e51] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e52] [cursor=pointer]:
|
||||
- text: ⚙️ 系统设置
|
||||
- generic [ref=e53]: ▶
|
||||
- generic [ref=e54] [cursor=pointer]: 知识库
|
||||
- generic [ref=e56]:
|
||||
- generic "折叠全部" [ref=e57] [cursor=pointer]: 📂
|
||||
- generic [ref=e58] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e59] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e60]:
|
||||
- generic [ref=e62]:
|
||||
- combobox [ref=e63]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e64]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e65]
|
||||
- button "查询" [ref=e66] [cursor=pointer]
|
||||
+66
@@ -0,0 +1,66 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📊 概览
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e12] [cursor=pointer]: 告警中心
|
||||
- generic [ref=e13] [cursor=pointer]: 统计概览
|
||||
- generic [ref=e14] [cursor=pointer]:
|
||||
- text: 📱 矩阵养号
|
||||
- generic [ref=e15]: ▶
|
||||
- generic:
|
||||
- generic [ref=e16] [cursor=pointer]: 身份与账号
|
||||
- generic [ref=e17] [cursor=pointer]: 命令与任务
|
||||
- generic [ref=e18] [cursor=pointer]: 远程控制
|
||||
- generic [ref=e19] [cursor=pointer]: 蓝图编排
|
||||
- generic [ref=e20] [cursor=pointer]: 原子操作
|
||||
- generic [ref=e21] [cursor=pointer]: 录制管理
|
||||
- generic [ref=e22] [cursor=pointer]: 设置
|
||||
- generic [ref=e23] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e24]: ▶
|
||||
- generic:
|
||||
- generic [ref=e25] [cursor=pointer]: 角色管理
|
||||
- generic [ref=e26] [cursor=pointer]: 角色生成
|
||||
- generic [ref=e27] [cursor=pointer]: 原子能力
|
||||
- generic [ref=e28] [cursor=pointer]: 工作流
|
||||
- generic [ref=e29] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e30]: ▶
|
||||
- generic [ref=e31] [cursor=pointer]: 采集管理
|
||||
- generic [ref=e32] [cursor=pointer]:
|
||||
- text: ⚙️ 系统
|
||||
- generic [ref=e33]: ▶
|
||||
- generic:
|
||||
- generic [ref=e34] [cursor=pointer]: 知识库
|
||||
- generic [ref=e35] [cursor=pointer]: 记忆库
|
||||
- generic [ref=e36] [cursor=pointer]: 联邦说明
|
||||
- generic [ref=e37] [cursor=pointer]: 技能工具
|
||||
- generic [ref=e38] [cursor=pointer]: 自动任务
|
||||
- generic [ref=e40]:
|
||||
- generic "折叠全部" [ref=e41] [cursor=pointer]: 📂
|
||||
- generic [ref=e42] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e43] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e44]:
|
||||
- generic [ref=e46]:
|
||||
- combobox [ref=e47]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e48]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e49]
|
||||
- button "查询" [ref=e50] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e9]:
|
||||
- generic "折叠全部" [ref=e10] [cursor=pointer]: 📂
|
||||
- generic [ref=e11] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e12] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e15]: ⏳ 加载机器状态...
|
||||
- generic [ref=e17]:
|
||||
- combobox [ref=e18]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e19]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e20]
|
||||
- button "查询" [ref=e21] [cursor=pointer]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e9]:
|
||||
- generic "折叠全部" [ref=e10] [cursor=pointer]: 📂
|
||||
- generic [ref=e11] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e12] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e15]: ⏳ 加载机器状态...
|
||||
- generic [ref=e17]:
|
||||
- combobox [ref=e18]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e19]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e20]
|
||||
- button "查询" [ref=e21] [cursor=pointer]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e9]:
|
||||
- generic "折叠全部" [ref=e10] [cursor=pointer]: 📂
|
||||
- generic [ref=e11] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e12] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e15]: ⏳ 加载机器状态...
|
||||
- generic [ref=e17]:
|
||||
- combobox [ref=e18]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e19]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e20]
|
||||
- button "查询" [ref=e21] [cursor=pointer]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e9]:
|
||||
- generic "折叠全部" [ref=e10] [cursor=pointer]: 📂
|
||||
- generic [ref=e11] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e12] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e15]: ⏳ 加载机器状态...
|
||||
- generic [ref=e17]:
|
||||
- combobox [ref=e18]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e19]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e20]
|
||||
- button "查询" [ref=e21] [cursor=pointer]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e9]:
|
||||
- generic "折叠全部" [ref=e10] [cursor=pointer]: 📂
|
||||
- generic [ref=e11] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e12] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e15]: ⏳ 加载机器状态...
|
||||
- generic [ref=e17]:
|
||||
- combobox [ref=e18]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e19]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e20]
|
||||
- button "查询" [ref=e21] [cursor=pointer]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e9]:
|
||||
- generic "折叠全部" [ref=e10] [cursor=pointer]: 📂
|
||||
- generic [ref=e11] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e12] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e15]: ⏳ 加载机器状态...
|
||||
- generic [ref=e17]:
|
||||
- combobox [ref=e18]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e19]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e20]
|
||||
- button "查询" [ref=e21] [cursor=pointer]
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 蓝图管理
|
||||
- generic [ref=e16] [cursor=pointer]: 定向评论
|
||||
- generic [ref=e17] [cursor=pointer]: 定时任务
|
||||
- generic [ref=e18] [cursor=pointer]: 语料库
|
||||
- generic [ref=e19] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e20]: ▶
|
||||
- generic:
|
||||
- generic [ref=e21] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e22] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e23] [cursor=pointer]: 素材库
|
||||
- generic [ref=e24] [cursor=pointer]: 模板
|
||||
- generic [ref=e25] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e26]: ▶
|
||||
- generic:
|
||||
- generic [ref=e27] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e28] [cursor=pointer]: 源管理
|
||||
- generic [ref=e29] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e30] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e31]: ▶
|
||||
- generic:
|
||||
- generic [ref=e32] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e33] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e34] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e35] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e36] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e37]: ▶
|
||||
- generic:
|
||||
- generic [ref=e38] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e39] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e40] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e42]:
|
||||
- generic "折叠全部" [ref=e43] [cursor=pointer]: 📂
|
||||
- generic [ref=e44] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e45] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e46]:
|
||||
- generic [ref=e48]: ⏳ 加载机器状态...
|
||||
- generic [ref=e50]:
|
||||
- combobox [ref=e51]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e52]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e53]
|
||||
- button "查询" [ref=e54] [cursor=pointer]
|
||||
+70
@@ -0,0 +1,70 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 蓝图管理
|
||||
- generic [ref=e16] [cursor=pointer]: 定向评论
|
||||
- generic [ref=e17] [cursor=pointer]: 定时任务
|
||||
- generic [ref=e18] [cursor=pointer]: 语料库
|
||||
- generic [ref=e19] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e20]: ▶
|
||||
- generic:
|
||||
- generic [ref=e21] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e22] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e23] [cursor=pointer]: 素材库
|
||||
- generic [ref=e24] [cursor=pointer]: 模板
|
||||
- generic [ref=e25] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e26]: ▶
|
||||
- generic:
|
||||
- generic [ref=e27] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e28] [cursor=pointer]: 源管理
|
||||
- generic [ref=e29] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e30] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e31]: ▶
|
||||
- generic:
|
||||
- generic [ref=e32] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e33] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e34] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e35] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e36] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e37]: ▶
|
||||
- generic:
|
||||
- generic [ref=e38] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e39] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e40] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e42]:
|
||||
- generic "折叠全部" [ref=e43] [cursor=pointer]: 📂
|
||||
- generic [ref=e44] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e45] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e46]:
|
||||
- generic [ref=e48]: ⏳ 加载机器状态...
|
||||
- generic [ref=e50]:
|
||||
- combobox [ref=e51]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e52]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e53]
|
||||
- button "查询" [ref=e54] [cursor=pointer]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e9]:
|
||||
- generic "折叠全部" [ref=e10] [cursor=pointer]: 📂
|
||||
- generic [ref=e11] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e12] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e15]: ⏳ 加载机器状态...
|
||||
- generic [ref=e17]:
|
||||
- combobox [ref=e18]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e19]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e20]
|
||||
- button "查询" [ref=e21] [cursor=pointer]
|
||||
+73
@@ -0,0 +1,73 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e8]:
|
||||
- generic [ref=e9] [cursor=pointer]:
|
||||
- text: 📱 矩阵
|
||||
- generic [ref=e10]: ▶
|
||||
- generic:
|
||||
- generic [ref=e11] [cursor=pointer]: 🪪 账号管理
|
||||
- generic [ref=e12] [cursor=pointer]: 🏃 养号执行
|
||||
- generic [ref=e13] [cursor=pointer]: 📡 信息采集
|
||||
- generic [ref=e14] [cursor=pointer]: 📤 内容发布
|
||||
- generic [ref=e15] [cursor=pointer]: 💬 定向评论
|
||||
- generic [ref=e16] [cursor=pointer]: ❤️ 收藏点赞
|
||||
- generic [ref=e17] [cursor=pointer]: 📋 蓝图管理
|
||||
- generic [ref=e18] [cursor=pointer]: 🔑 登录管理
|
||||
- generic [ref=e19] [cursor=pointer]: ⏱ 定时任务
|
||||
- generic [ref=e20] [cursor=pointer]: 📚 语料库
|
||||
- generic [ref=e21] [cursor=pointer]: 🖥️ 联邦指挥台
|
||||
- generic [ref=e22] [cursor=pointer]:
|
||||
- text: 🎬 视频工厂
|
||||
- generic [ref=e23]: ▶
|
||||
- generic:
|
||||
- generic [ref=e24] [cursor=pointer]: 渲染任务
|
||||
- generic [ref=e25] [cursor=pointer]: 脚本生成
|
||||
- generic [ref=e26] [cursor=pointer]: 素材库
|
||||
- generic [ref=e27] [cursor=pointer]: 模板
|
||||
- generic [ref=e28] [cursor=pointer]:
|
||||
- text: 📡 内容采集
|
||||
- generic [ref=e29]: ▶
|
||||
- generic:
|
||||
- generic [ref=e30] [cursor=pointer]: 采集任务
|
||||
- generic [ref=e31] [cursor=pointer]: 源管理
|
||||
- generic [ref=e32] [cursor=pointer]: 采集历史
|
||||
- generic [ref=e33] [cursor=pointer]:
|
||||
- text: 🖥️ 联邦
|
||||
- generic [ref=e34]: ▶
|
||||
- generic:
|
||||
- generic [ref=e35] [cursor=pointer]: 机器状态
|
||||
- generic [ref=e36] [cursor=pointer]: 一键同步
|
||||
- generic [ref=e37] [cursor=pointer]: 对账检查
|
||||
- generic [ref=e38] [cursor=pointer]: 远程Shell
|
||||
- generic [ref=e39] [cursor=pointer]:
|
||||
- text: ⚙️ 服务
|
||||
- generic [ref=e40]: ▶
|
||||
- generic:
|
||||
- generic [ref=e41] [cursor=pointer]: MCP状态
|
||||
- generic [ref=e42] [cursor=pointer]: Dashboard日志
|
||||
- generic [ref=e43] [cursor=pointer]: 全局定时任务
|
||||
- generic [ref=e45]:
|
||||
- generic "折叠全部" [ref=e46] [cursor=pointer]: 📂
|
||||
- generic [ref=e47] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e48] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e49]:
|
||||
- generic [ref=e51]: ⏳ 加载机器状态...
|
||||
- generic [ref=e53]:
|
||||
- combobox [ref=e54]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e55]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e56]
|
||||
- button "查询" [ref=e57] [cursor=pointer]
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
- generic [active] [ref=e1]:
|
||||
- navigation [ref=e2]:
|
||||
- generic [ref=e3]:
|
||||
- heading "🖥 AgentOS v4" [level=1] [ref=e4] [cursor=pointer]:
|
||||
- text: 🖥 AgentOS
|
||||
- generic [ref=e5]: v4
|
||||
- generic "折叠侧边栏" [ref=e6] [cursor=pointer]: ◀
|
||||
- generic [ref=e9]:
|
||||
- generic "折叠全部" [ref=e10] [cursor=pointer]: 📂
|
||||
- generic [ref=e11] [cursor=pointer]: 🔄 同步
|
||||
- generic [ref=e12] [cursor=pointer]: ⟳ 刷新
|
||||
- main [ref=e13]:
|
||||
- generic [ref=e15]: ⏳ 加载机器状态...
|
||||
- generic [ref=e17]:
|
||||
- combobox [ref=e18]:
|
||||
- option "全部策略" [selected]
|
||||
- option "口播"
|
||||
- option "卡点"
|
||||
- option "数字人"
|
||||
- option "test"
|
||||
- combobox [ref=e19]:
|
||||
- option "全部状态" [selected]
|
||||
- option "已完成"
|
||||
- option "失败"
|
||||
- option "运行中"
|
||||
- textbox "搜索脚本名称..." [ref=e20]
|
||||
- button "查询" [ref=e21] [cursor=pointer]
|
||||
@@ -0,0 +1,225 @@
|
||||
# AgentOS 核心业务架构 v4.0 — 业务领域划分与指挥体系
|
||||
|
||||
> **版本**: 4.0 | **更新**: 2026-06-17
|
||||
> **解决的核心问题**: 账号管理 vs 操作执行边界模糊、指挥链路不清晰、结果回传缺失
|
||||
|
||||
---
|
||||
|
||||
## 一、核心原则(先理解这个)
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────────┐
|
||||
│ USER │
|
||||
│ (Dashboard / AI Agent / CLI) │
|
||||
└──────────┬────────────────────────────────┬───────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
┌──────────────────────┐ ┌──────────────────────────┐
|
||||
│ 🪪 账号管理层 │ │ ⚡ 操作执行层 │
|
||||
│ │ │ │
|
||||
│ 只看不操作 │ │ 只操作不看 │
|
||||
│ - 查看账号列表 │ │ - 养号(日常活跃) │
|
||||
│ - 查看状态 │ │ - 信息采集 │
|
||||
│ - 注册/删除 │ │ - 内容发布 │
|
||||
│ - 跨机分配 │ │ - 定向评论 │
|
||||
│ - 编辑配置 │ │ - 收藏点赞 │
|
||||
│ │ │ - 登录/登出 │
|
||||
└──────────────────────┘ └──────────┬───────────────┘
|
||||
│
|
||||
▼
|
||||
┌──────────────────┐
|
||||
│ 统一执行引擎 │
|
||||
│ mc run / mc ops │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
**关键约束(解决了用户的困惑):**
|
||||
|
||||
1. **账号管理 = 只读 + 配置**。它显示账号的登录状态、Cookie 有效性、昵称、上次采集时间,但不触发任何操作。
|
||||
2. **信息采集是操作,不是管理**。采集应该放在「操作执行层」作为一个独立的操作类型,不应该混在账号管理里。
|
||||
3. **所有操作走统一指挥体系**。无论是养号、采集、发布、评论,底层都走同一个执行引擎,只是参数不同。
|
||||
|
||||
---
|
||||
|
||||
## 二、业务领域划分
|
||||
|
||||
### 领域 1:账号管理 🪪 — "只看不操作"
|
||||
|
||||
| 功能 | 说明 | API |
|
||||
| :--- | :---------------------------------- | :---------------------------------------- |
|
||||
| 账号列表 | 显示所有账号(跨机聚合)+ 登录状态 + Cookie状态 + 昵称 | `GET /api/matrix/accounts` |
|
||||
| 注册账号 | 在本机或指定机器创建新账号 | `POST /api/matrix/accounts` |
|
||||
| 删除账号 | 删除账号 + 可选删除身份目录 | `DELETE /api/matrix/accounts/{id}` |
|
||||
| 编辑账号 | 修改 phone / platform / owner_machine | `PUT /api/matrix/accounts/{id}` |
|
||||
| 跨机分配 | 将账号从一个机器移到另一个 | `POST /api/matrix/accounts/{id}/relocate` |
|
||||
| 身份管理 | 查看/编辑身份目录 | `POST /api/matrix/identities` |
|
||||
|
||||
**当前状态**: 这部分已有完整前后端,需要的是**和采集解耦**。
|
||||
|
||||
---
|
||||
|
||||
### 领域 2:操作执行 ⚡ — "只操作不看"
|
||||
|
||||
所有操作共享相同的执行基础设施:
|
||||
|
||||
```
|
||||
All Ops → POST /api/ops/run → 自动路由(本机subprocess/远程SSH) → 执行 → 写结果文件
|
||||
↓
|
||||
GET /api/ops/status ← 轮询结果
|
||||
GET /api/ops/results ← 获取结果
|
||||
GET /api/ops/log/{id} ← 查看日志
|
||||
```
|
||||
|
||||
#### 操作类型清单
|
||||
|
||||
| 操作 | 命令 | 蓝图 | 说明 |
|
||||
|:-----|:------|:------|:------|
|
||||
| 🏃 养号执行 | `mc run --accounts=X --blueprints=Y` | douyin_daily / xhs_daily | 浏览、滑动、点赞、关注等日常活跃 |
|
||||
| 📡 信息采集 | `mc collect` | — | 采集账号主页信息(昵称/粉丝数/作品数) |
|
||||
| 📤 内容发布 | `mc publish` | — | 发布视频/图文到已登录账号 |
|
||||
| 💬 定向评论 | `mc task comment` | — | 在指定作品下发表评论 |
|
||||
| ❤️ 收藏点赞 | `mc task like/favorite` | — | 对指定内容点赞/收藏 |
|
||||
| 🔑 登录/登出 | `mc smart-login` / `mc account logout` | — | 管理账号登录态 |
|
||||
|
||||
**关键设计**:所有操作通过 `POST /api/ops/run` 这一个入口,用 `type` 参数区分操作类型。
|
||||
|
||||
---
|
||||
|
||||
## 三、统一指挥体系(解决了"一台指挥三台"的问题)
|
||||
|
||||
### 3.1 当前架构(已实现)
|
||||
|
||||
```
|
||||
Dashboard → /api/ops/run
|
||||
→ 解析 type + accounts + params
|
||||
→ 按 owner_machine 路由到各机器
|
||||
→ 本机: subprocess.Popen → nurture_runner.sh
|
||||
→ 远程: SSH → remote_exec → nurture_runner.sh
|
||||
→ 结果写入: ~/agent-local/runtime/ops/{type}_{timestamp}_{account}.json
|
||||
```
|
||||
|
||||
### 3.2 同一个 API 处理所有操作
|
||||
|
||||
```
|
||||
POST /api/ops/run
|
||||
{
|
||||
"type": "nurture"|"collect"|"publish"|"comment"|"like",
|
||||
"accounts": ["douyin_01", "xhs_01"],
|
||||
"params": {
|
||||
"blueprint": "douyin_daily", // 仅 nurture 用
|
||||
"rounds": 10, // 仅 nurture 用
|
||||
"url": "...", // 仅 comment/like 用
|
||||
"content": "..." // 仅 comment 用
|
||||
},
|
||||
"machine": "", // 可选,自动路由
|
||||
"dry_run": false
|
||||
}
|
||||
```
|
||||
|
||||
**返回值**:
|
||||
```json
|
||||
{
|
||||
"status": "started",
|
||||
"ops": [
|
||||
{"account": "douyin_01", "run_id": "nurture_123_douyin_01", "machine": "chengzigedeAir"},
|
||||
{"account": "xhs_01", "run_id": "nurture_124_xhs_01", "machine": "7kecheng"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 结果回传(已实现)
|
||||
|
||||
```
|
||||
每台机器执行完后写入:
|
||||
~/agent-local/runtime/ops/{type}_{ts}_{account}.json
|
||||
|
||||
Dashboard 统一查询:
|
||||
GET /api/ops/results?type=nurture&limit=10
|
||||
GET /api/ops/status
|
||||
GET /api/ops/log/{run_id}
|
||||
```
|
||||
|
||||
**跨机结果聚合**:
|
||||
- 本机结果:直接读本地文件
|
||||
- 远程结果:Dashboard 通过 HTTP 轮询远程机器的 `/api/ops/results` 端点
|
||||
|
||||
---
|
||||
|
||||
## 四、Dashboard 导航视图设计(新方案)
|
||||
|
||||
```
|
||||
📱 矩阵
|
||||
├── 🪪 账号管理 ← 只读 + 注册/删除/跨机 (原 sms-proxy)
|
||||
├── 🏃 养号执行 ← 日常活跃 (原 nurture)
|
||||
├── 📡 信息采集 ← 采集主页信息 (原 collect)
|
||||
├── 📤 内容发布 ← 发作品 (原 publish)
|
||||
├── 💬 定向评论 ← 评论 (原 comment)
|
||||
├── ❤️ 收藏点赞 ← 点赞收藏 (新建)
|
||||
├── 📋 蓝图管理 ← 编排原子操作 (原 blueprints)
|
||||
├── 🔑 登录管理 ← 查看/管理登录态 (新建)
|
||||
└── ⏱ 定时任务 ← 定时执行以上任何操作 (原 schedule)
|
||||
```
|
||||
|
||||
**「账号管理」只做这些**:
|
||||
✅ 查看所有账号(三机聚合)
|
||||
✅ 显示昵称 / 手机号 / 登录态 / Cookie状态 / 上次采集时间
|
||||
✅ 注册新账号
|
||||
✅ 删除账号(可选删身份目录)
|
||||
✅ 编辑账号(改平台/机器/备注)
|
||||
✅ 跨机迁移
|
||||
|
||||
❌ **不做**:信息采集、养号执行、评论、发布(这些归到对应的操作视图)
|
||||
|
||||
---
|
||||
|
||||
## 五、「联邦指挥台」概念
|
||||
|
||||
一个独立的指挥视图,统一操作入口:
|
||||
|
||||
```
|
||||
┌──────── 联邦指挥台 ────────┐
|
||||
│ │
|
||||
│ 目标机器: [▼全部/本机/7k] │
|
||||
│ 操作类型: [▼养号/采集/...] │
|
||||
│ 账号筛选: [按平台/机器...] │
|
||||
│ 参数设置: [轮数/蓝图...] │
|
||||
│ │
|
||||
│ [🚀 批量执行] │
|
||||
│ │
|
||||
│ 执行记录: │
|
||||
│ ┌──────────────────────┐ │
|
||||
│ │ 12:30 douyin_01 ✅ 完成│ │
|
||||
│ │ 12:30 xhs_01 ⏳ 执行│ │
|
||||
│ │ 12:28 douyin_02 ❌ 失败│ │
|
||||
│ └──────────────────────┘ │
|
||||
└────────────────────────────┘
|
||||
```
|
||||
|
||||
但这**不替代**各操作专用视图。专用视图提供更多操作特定参数(比如评论要有内容输入框、发布要有文件上传)。联邦指挥台用于快速批量操作。
|
||||
|
||||
---
|
||||
|
||||
## 六、执行计划(按此逐步落地)
|
||||
|
||||
### Phase 1(当前阶段):统一 ops API + 结果追踪
|
||||
| # | 事项 | 状态 |
|
||||
|:-:|:-----|:-----|
|
||||
| 1 | 账号管理解除采集耦合 | 🔴 待做 |
|
||||
| 2 | `POST /api/ops/run` 统一入口 | 🟡 部分(已做 nurture) |
|
||||
| 3 | `GET /api/ops/results` 结果查询 | ✅ 已实现 |
|
||||
| 4 | `GET /api/ops/status` 状态查询 | ✅ 已实现 |
|
||||
| 5 | `GET /api/ops/log/{id}` 日志查看 | ✅ 已实现 |
|
||||
|
||||
### Phase 2:各操作视图完善
|
||||
| # | 事项 | 优先级 |
|
||||
|:-:|:-----|:-------|
|
||||
| 1 | 信息采集视图(独立于账号管理) | P0 |
|
||||
| 2 | 收藏点赞视图(新建) | P1 |
|
||||
| 3 | 登录管理视图(查看+批量登录) | P1 |
|
||||
| 4 | 联邦指挥台(统一批量操作入口) | P2 |
|
||||
|
||||
### Phase 3:跨机结果聚合
|
||||
| # | 事项 | 优先级 |
|
||||
|:-:|:-----|:-------|
|
||||
| 1 | 远程机器结果通过 HTTP 聚合到本机 Dashboard | P1 |
|
||||
| 2 | 联邦指挥台显示所有机器的执行历史和状态 | P2 |
|
||||
@@ -0,0 +1,164 @@
|
||||
# 多机部署与同步更新指南
|
||||
|
||||
> 版本: 1.1.0 | 最后更新: 2026-05-16
|
||||
> 适用: AgentOS 联邦架构下所有主机
|
||||
|
||||
---
|
||||
|
||||
## 更新流程概览
|
||||
|
||||
```
|
||||
本机 (Redmi-12C) 其他主机 (Mac mini / …)
|
||||
┌──────────────────┐ ┌──────────────────┐
|
||||
│ git add + commit │ │ │
|
||||
│ git push │──Gitee──▶│ git pull │
|
||||
│ (本机已就绪) │ │ 停止旧服务 │
|
||||
│ │ │ 启动新服务 │
|
||||
└──────────────────┘ │ (完成更新) │
|
||||
└──────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 一、本机提交(已完成)
|
||||
|
||||
变更清单:
|
||||
|
||||
| 文件 | 类型 | 说明 |
|
||||
|:----|:----:|:-----|
|
||||
| `05_tools/09_ave/scripts/main.py` | 修改 | dashboard 命令重定向到 `10_dashboard/` |
|
||||
| `05_tools/09_ave/scripts/lib/dashboard.py` | 修改 | Python 3.9 兼容修复 (`from __future__ import annotations`) |
|
||||
| `05_tools/09_ave/scripts/dashboard/app.py` | 删除 | 迁出到 `10_dashboard/` |
|
||||
| `05_tools/09_ave/scripts/dashboard/static/index.html` | 删除 | 迁出到 `10_dashboard/` |
|
||||
| `05_tools/10_dashboard/` | 新增 | 系统级监控面板(完整模块) |
|
||||
| `03_knowledge/versions.json` | 新增 | guardd 版本跟踪清单 |
|
||||
| `10_dashboard/app.py` | 修改 | v1.1.0 新增 `/api/machines` 联邦机器状态接口 |
|
||||
| `10_dashboard/static/index.html` | 修改 | 新增「机器状态」面板(含在线/离线/磁盘/CPU) |
|
||||
| `requirements.txt` | 修改 | 新增 fastapi / uvicorn 依赖 |
|
||||
| `05_tools/09_ave/scripts/composer/hybrid.py` | 新增 | Sprint 2 口播+卡点融合 |
|
||||
| `05_tools/09_ave/scripts/composer/speed_ramp.py` | 新增 | Sprint 2 变速卡点引擎 |
|
||||
| `05_tools/09_ave/scripts/asset_manager/` | 新增 | 素材资产管理 |
|
||||
| `05_tools/09_ave/scripts/story_director/` | 新增 | 故事导演模块 |
|
||||
|
||||
---
|
||||
|
||||
## 二、其他主机同步步骤
|
||||
|
||||
在 **每台** 需要更新的主机上按顺序执行:
|
||||
|
||||
### 步骤 1:同步代码
|
||||
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync
|
||||
git pull
|
||||
```
|
||||
|
||||
预期结果:
|
||||
- `05_tools/10_dashboard/` 目录出现(含 app.py / run.py / plugins/ 等)
|
||||
- `05_tools/09_ave/scripts/dashboard/` 目录消失(已被删除)
|
||||
- `05_tools/09_ave/scripts/main.py` 更新(dashboard 命令)
|
||||
- `05_tools/09_ave/scripts/lib/dashboard.py` 更新(Python 3.9 修复)
|
||||
- `03_knowledge/versions.json` 出现(版本跟踪)
|
||||
- `05_tools/10_dashboard/static/index.html` 新增「机器状态」导航项
|
||||
- `requirements.txt` 新增 fastapi / uvicorn(需重新安装依赖)
|
||||
|
||||
### 步骤 2:检查旧 Dashboard 进程(如已启动过)
|
||||
|
||||
```bash
|
||||
ps aux | grep uvicorn | grep -v grep
|
||||
```
|
||||
|
||||
如果有输出,说明旧 Dashboard 仍在运行,需要停止并重启。
|
||||
|
||||
### 步骤 3:停止旧服务
|
||||
|
||||
```bash
|
||||
# 找到旧进程 PID(第二列数字)
|
||||
ps aux | grep uvicorn | grep -v grep | awk '{print $2}' | xargs kill
|
||||
```
|
||||
|
||||
> ⚠️ 旧 Dashboard 的路径 `09_ave/scripts/dashboard/` 已被删除,
|
||||
> 如果不停止,进程会报 FileNotFoundError。
|
||||
|
||||
### 步骤 4:启动新 Dashboard(使用 managed Python 3.13)
|
||||
|
||||
```bash
|
||||
# 推荐使用 managed Python venv(避免 Python 3.9 兼容问题)
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python \
|
||||
~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard/run.py
|
||||
```
|
||||
|
||||
或者简写为:
|
||||
|
||||
```bash
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python run.py
|
||||
```
|
||||
|
||||
验证启动:
|
||||
|
||||
```bash
|
||||
curl http://localhost:9988/api/health
|
||||
# 预期: {"status":"ok","version":"1.0.0","plugins":{"ave":"available"}}
|
||||
|
||||
curl http://localhost:9988/api/machines
|
||||
# 预期: {"machhes":[ ... ],"total":N} 显示联邦内所有主机状态
|
||||
```
|
||||
|
||||
### 步骤 5:验证 guardd 正常运行(无需重启)
|
||||
|
||||
```bash
|
||||
cat ~/workbuddy-agent-os/agent-local/runtime/guardd/last_run.json
|
||||
# 确认所有 7 模块状态为 "ok"
|
||||
```
|
||||
|
||||
guardd 本身未变化,无需重启。它会自动发现 `versions.json`,检测到 version 变更后写入事件。
|
||||
|
||||
---
|
||||
|
||||
## 三、启动方式参考(Dashboard)
|
||||
|
||||
新 Dashboard 支持三种启动方式:
|
||||
|
||||
```bash
|
||||
# 方式 1: 通过 AVE 入口(推荐,自动处理路径)
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/09_ave/scripts
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python main.py dashboard
|
||||
|
||||
# 方式 2: 独立启动
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/python run.py
|
||||
|
||||
# 方式 3: uvicorn 开发模式
|
||||
cd ~/workbuddy-agent-os/agent-sync/05_tools/10_dashboard
|
||||
~/.workbuddy/binaries/python/envs/agent-os/bin/uvicorn app:app --reload --port 9988
|
||||
```
|
||||
|
||||
> ⚠️ **注意 Python 版本**:系统默认 `python3` 为 3.9.6,不支持 `X | Y` 类型注解语法。
|
||||
> **必须**使用 managed Python 3.13.12 venv 路径,否则会报 TypeError。
|
||||
|
||||
---
|
||||
|
||||
## 四、故障排查
|
||||
|
||||
| 症状 | 原因 | 解决 |
|
||||
|:----|:----|:-----|
|
||||
| `TypeError: unsupported operand type(s) for |` | 使用系统 Python 3.9 运行 | 改用 `~/.workbuddy/binaries/python/envs/agent-os/bin/python` |
|
||||
| `ModuleNotFoundError: No module named 'app'` | uvicorn 找不到 `10_dashboard/app.py` | 确保 CWD 在 `10_dashboard/` 目录下 |
|
||||
| `curl: (7) Connection refused` | Dashboard 未启动 | 先执行启动命令 |
|
||||
| `FileNotFoundError: dashboard/app.py` | 旧进程仍在运行 | kill 旧 uvicorn 进程 |
|
||||
| guardd 未检测到版本更新 | `versions.json` 不存在或格式错误 | 检查 `03_knowledge/versions.json` 是否存在且 JSON 有效 |
|
||||
|
||||
---
|
||||
|
||||
## 五、后续模块接入
|
||||
|
||||
当前仪表盘仅含 AVE 插件。后续模块(Matrix / guardd)接入方法:
|
||||
|
||||
```python
|
||||
# 1. 创建 10_dashboard/plugins/matrix.py
|
||||
# 2. 实现 DashboardPlugin 基类
|
||||
# 3. 在 app.py 的 _register_plugins() 中添加一行
|
||||
```
|
||||
|
||||
详见 `10_dashboard/PLANS/ARCHITECTURE.md`。
|
||||
@@ -0,0 +1,296 @@
|
||||
# Dashboard 系统监控面板
|
||||
|
||||
> 版本: 2.0.0 | AgentOS 4.2.1
|
||||
> 最后更新: 2026-06-25
|
||||
> 端口: 9988
|
||||
|
||||
---
|
||||
|
||||
## 一、五层执行架构
|
||||
|
||||
所有 Dashboard 操作(养号、采集、登录、评论)都经过这五层:
|
||||
|
||||
```
|
||||
L5 ─── Dashboard UI ───────── frontend/src/views/*.js
|
||||
│ 用户点击按钮 → API 调用
|
||||
▼
|
||||
L4 ─── API 路由 ───────────── routes/ops.py (统一执行入口)
|
||||
│ POST /api/ops/run → 调 CommandBus
|
||||
▼
|
||||
L3 ─── CommandBus ─────────── services/command_bus.py
|
||||
│ CMD_REGISTRY 映射 cmd_type → mc 命令
|
||||
│ 按机器分组 → 队列 → 预检 → 分发
|
||||
▼
|
||||
L2 ─── mc 引擎 ────────────── scripts/mc/engine.py
|
||||
│ BatchEngine → 身份分组 → 启动浏览器 → 蓝图执行
|
||||
▼
|
||||
L1 ─── 平台 Ops ───────────── scripts/douyin_ops.py / ops/xhs_ops.py
|
||||
│ 原子操作(三段式:前置条件→执行→后置验证)
|
||||
▼
|
||||
L0 ─── Camoufox 浏览器 ────── 真实浏览器操作
|
||||
```
|
||||
|
||||
**核心原则**:
|
||||
- L3 CommandBus 是通往执行层的**唯一大门**
|
||||
- 前端/L4/CLI/定时任务 → 全部走 CommandBus
|
||||
- 不允许绕过 CommandBus 直接 subprocess
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
05_tools/10_dashboard/
|
||||
├── app.py ← FastAPI 主入口(插件加载 + 健康检查 + 基础API)
|
||||
├── run.py ← 启动脚本
|
||||
├── routes/
|
||||
│ ├── ops.py ← 统一操作执行入口(POST /api/ops/run)
|
||||
│ └── matrix.py ← 矩阵数据查询(账号/蓝图/采集历史等读操作)
|
||||
├── services/
|
||||
│ ├── command_bus.py ← CommandBus(命令总线 + CMD_REGISTRY 注册表)
|
||||
│ ├── command_chain.py ← 接力执行链
|
||||
│ ├── fleet_collector.py ← 联邦数据聚合
|
||||
│ ├── remote_exec.py ← 远程SSH执行
|
||||
│ ├── preflight.py ← 执行前预检
|
||||
│ └── ...
|
||||
├── plugins/ ← 插件式数据源(matrix/guardd/ave/crawl等15个)
|
||||
├── frontend/src/ ← 前端源码(Vite)
|
||||
│ ├── main.js ← 入口
|
||||
│ ├── router.js ← 路由 + apiRequest 工具函数
|
||||
│ ├── state.js ← AppState 全局状态
|
||||
│ ├── views/ ← 已迁移视图(45+个)
|
||||
│ │ ├── matrix-collect.js ← 信息采集视图
|
||||
│ │ ├── matrix-nurture.js ← 养号执行视图
|
||||
│ │ ├── matrix-accounts.js ← 账号管理视图
|
||||
│ │ ├── matrix-comment.js ← 定向评论视图
|
||||
│ │ ├── matrix-blueprints.js ← 蓝图编辑器
|
||||
│ │ └── ...
|
||||
│ ├── modules/ ← 功能模块(16个)
|
||||
│ └── components/ ← 共享组件
|
||||
├── static/ ← Vite 构建产物(index.html + assets/)
|
||||
└── PLANS/ ← 规划文档
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、API 路由说明
|
||||
|
||||
### 3.1 操作执行(唯一入口)
|
||||
|
||||
**`POST /api/ops/run`** — 所有操作统一入口
|
||||
|
||||
请求体格式:
|
||||
```json
|
||||
{
|
||||
"type": "collect", // 操作类型(见 CMD_REGISTRY)
|
||||
"accounts": ["douyin_01"], // 账号ID列表
|
||||
"params": { // 操作参数(可选,不传用注册表默认值)
|
||||
"rounds": 1
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
支持的 type(定义在 `CMD_REGISTRY`):
|
||||
|
||||
| type | 用途 | 默认 blueprint | 自动推断 |
|
||||
|:-----|:------|:---------------|:---------|
|
||||
| `nurture` | 养号执行 | `douyin_daily` | ❌ 不自动 |
|
||||
| `collect` | 主页信息采集 | 按平台自动选 | ✅ 自动按账号平台选择 |
|
||||
| `login` | 智能登录 | — | — |
|
||||
| `logout` | 登出 | `douyin_daily` | ❌ |
|
||||
| `comment` | 定向评论 | — | — |
|
||||
| `like` | 点赞 | `douyin_daily` | ❌ |
|
||||
|
||||
其他 API:
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|:-----|:------|:------|
|
||||
| `GET` | `/api/ops/status` | 查询所有命令状态 |
|
||||
| `GET` | `/api/ops/history` | 查询命令执行历史 |
|
||||
| `GET` | `/api/ops/machines` | 所有机器聚合状态 |
|
||||
| `POST` | `/api/ops/cancel/{run_id}` | 取消指定命令 |
|
||||
| `POST` | `/api/ops/test-atom` | 单步原子操作测试 |
|
||||
|
||||
### 3.2 数据查询(routes/matrix.py)
|
||||
|
||||
所有 `/api/matrix/*` 路由**只处理读操作**(GET),不处理写操作(POST)。
|
||||
|
||||
| 方法 | 路径 | 用途 |
|
||||
|:-----|:------|:------|
|
||||
| `GET` | `/api/matrix/accounts` | 账号列表 |
|
||||
| `GET` | `/api/matrix/profiles` | 已缓存主页资料 |
|
||||
| `GET` | `/api/matrix/homepage-info` | 主页信息采集结果(联邦聚合) |
|
||||
| `GET` | `/api/matrix/blueprints` | 蓝图列表 |
|
||||
| `GET` | `/api/matrix/atom-ops` | 原子操作列表 |
|
||||
| `GET` | `/api/matrix/nurture/status` | 养号运行状态 |
|
||||
|
||||
**禁止**在 routes/matrix.py 中新增 POST 写操作。
|
||||
所有写操作必须走 `routes/ops.py` → `CommandBus.dispatch()`。
|
||||
|
||||
---
|
||||
|
||||
## 四、CommandBus CMD_REGISTRY 注册表
|
||||
|
||||
### 4.1 定义位置
|
||||
|
||||
`services/command_bus.py:605-640`
|
||||
|
||||
```python
|
||||
CMD_REGISTRY = {
|
||||
"nurture": {
|
||||
"runner": "nurture_runner.sh",
|
||||
"defaults": {"blueprint": "douyin_daily", "rounds": 10},
|
||||
"auto_blueprint": False,
|
||||
},
|
||||
"collect": {
|
||||
"template": "mc run --accounts={ids} --blueprints={blueprint} --rounds={rounds}",
|
||||
"defaults": {"rounds": 1},
|
||||
"auto_blueprint": True,
|
||||
"blueprint_map": {
|
||||
"douyin": "douyin_read_profile",
|
||||
"xiaohongshu": "xiaohongshu_read_profile",
|
||||
},
|
||||
},
|
||||
"login": {
|
||||
"template": "mc smart-login {ids}",
|
||||
"single_account": True,
|
||||
},
|
||||
"comment": {
|
||||
"template": "mc task comment --account={ids} --url={url} --direction={direction}",
|
||||
"required_params": ["url"],
|
||||
},
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 字段说明
|
||||
|
||||
| 字段 | 含义 | 示例 |
|
||||
|:-----|:------|:------|
|
||||
| `template` | 命令模板,用 `{ids}` `{blueprint}` 等变量 | `mc run --accounts={ids} --blueprints={blueprint}` |
|
||||
| `defaults` | 参数默认值 | `{"rounds": 1}` |
|
||||
| `auto_blueprint` | 是否根据账号平台自动选择蓝图 | `True` |
|
||||
| `blueprint_map` | 平台→蓝图映射 | `{"douyin": "douyin_read_profile"}` |
|
||||
| `runner` | shell 包装器路径(nurture 专用) | `"nurture_runner.sh"` |
|
||||
| `single_account` | 是否一次只处理一个账号 | `True` |
|
||||
| `required_params` | 必填参数列表 | `["url"]` |
|
||||
|
||||
### 4.3 新增操作类型
|
||||
|
||||
新增操作类型只需在 `CMD_REGISTRY` 加一行,不需要改 `dispatch()` 逻辑。
|
||||
|
||||
```python
|
||||
# 例:新增 publish 发布操作
|
||||
"publish": {
|
||||
"template": "mc task publish --account={ids} --file={file}",
|
||||
"required_params": ["file"],
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、前端调用规范
|
||||
|
||||
### 5.1 统一调用格式
|
||||
|
||||
所有操作执行类请求必须使用 `apiRequest` 工具函数:
|
||||
|
||||
```javascript
|
||||
import { apiRequest } from '../router.js';
|
||||
|
||||
// ✅ 正确:走统一入口
|
||||
const result = await apiRequest('/ops/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'collect', // 操作类型
|
||||
accounts: ['douyin_01'], // 账号ID列表
|
||||
params: {
|
||||
rounds: 1, // 操作参数
|
||||
// blueprint 由服务端 CMD_REGISTRY 自动推断
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
// ❌ 错误:不要绕过统一入口
|
||||
// await apiRequest('/matrix/collect-homepage', ...) // 已删除
|
||||
// await apiRequest('/matrix/nurture/start', ...) // 不走CommandBus
|
||||
```
|
||||
|
||||
### 5.2 参数格式规则
|
||||
|
||||
| 字段 | 必须 | 类型 | 说明 |
|
||||
|:-----|:----|:-----|:------|
|
||||
| `type` | ✅ | string | `nurture`/`collect`/`login`/`logout`/`comment`/`like` |
|
||||
| `accounts` | ✅ | [string] | 账号ID数组 |
|
||||
| `params` | ❌ | object | 可选,不传则用CMD_REGISTRY默认值 |
|
||||
|
||||
### 5.3 错误处理
|
||||
|
||||
```javascript
|
||||
try {
|
||||
const d = await apiRequest('/ops/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'collect', accounts: ids, params: { rounds: 1 } }),
|
||||
});
|
||||
// d.status = 'accepted' | 'completed' | 'error'
|
||||
// d.errors = [...] // 校验错误
|
||||
// d.warnings = [...] // 警告
|
||||
} catch (e) {
|
||||
// 网络/HTTP错误
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、视图清单与说明
|
||||
|
||||
### 6.1 矩阵组 📱(20+视图)
|
||||
|
||||
| 视图文件 | 功能 | 说明 |
|
||||
|:---------|:-----|:------|
|
||||
| `matrix-accounts.js` | 账号管理 | 列表/搜索/筛选/建号/单账号采集🔑 |
|
||||
| `matrix-nurture.js` | 养号执行 | 全量/按账号执行养号蓝图 |
|
||||
| `matrix-collect.js` | 信息采集 | 批量采集主页信息,走统一 `/api/ops/run` |
|
||||
| `matrix-comment.js` | 定向评论 | 给指定视频发评论 |
|
||||
| `matrix-blueprints.js` | 蓝图编辑器 | 创建/编辑/删除/执行蓝图 |
|
||||
| `matrix-login.js` | 登录管理 | 查看/管理登录状态 |
|
||||
| `matrix-run.js` | 原子操作执行 | 单步测试原子操作 |
|
||||
| `matrix-like.js` | 收藏点赞 | 批量点赞/收藏 |
|
||||
| `matrix-publish.js` | 内容发布 | 发布视频/图文 |
|
||||
| `matrix-commands.js` | 执行历史 | 命令执行记录与状态 |
|
||||
| `matrix-record.js` | 录制工具 | 操作录制/分析/导出 |
|
||||
| `matrix-export.js` | 数据导出 | 账号/数据导出 |
|
||||
| `matrix-backup.js` | 备份管理 | 创建/恢复备份 |
|
||||
| `matrix-corpus.js` | 语料库 | 评论语料管理 |
|
||||
| `matrix-schedule.js` | 定时任务 | 养号定时调度 |
|
||||
| `matrix-sms-proxy.js` | 短信与代理 | API配置/测试 |
|
||||
| `matrix-settings.js` | 系统配置 | 查看/修改配置 |
|
||||
| `matrix-c2.js` | 联邦指挥台 | 跨机命令执行 |
|
||||
| `matrix-atom-ops.js` | 原子操作列表 | 查看所有支持的操作 |
|
||||
|
||||
### 6.2 视图规范
|
||||
|
||||
- ✅ 所有视图已从 inline.js 迁移到 `views/*.js`
|
||||
- ✅ 已注册到 `view-registry.js`
|
||||
- ✅ 使用 `router.js` 的 `loadMigratedView()` 动态加载
|
||||
- ✅ 通过 `apiRequest()` 调用后端 API
|
||||
|
||||
---
|
||||
|
||||
## 七、版本历史
|
||||
|
||||
| 版本 | 日期 | 变更 |
|
||||
|:-----|:------|:------|
|
||||
| 2.0.0 | 2026-06-25 | CommandBus CMD_REGISTRY 注册表;统一调用入口到 `/api/ops/run`;废弃 collect-homepage 路由 |
|
||||
| 1.0.0 | 2026-06-19 | 初始版本,五层架构建立 |
|
||||
|
||||
---
|
||||
|
||||
## 八、关联文档
|
||||
|
||||
| 文档 | 位置 |
|
||||
|:-----|:------|
|
||||
| 命令传导统一治理方案 | `PLANS/COMMAND_UNIFICATION_PLAN.md` |
|
||||
| 五层架构合规审计 | `PLANS/AUDIT_5LAYER_REPORT.md` |
|
||||
| 联邦系统使用指南 | `FEDERATION_GUIDE.md`(参考 §十一) |
|
||||
| 架构宪法 | `CONSTITUTION.md` |
|
||||
@@ -0,0 +1,2 @@
|
||||
# 系统级 Dashboard — 多模块生产监控面板
|
||||
# 版本: 1.0.0 | 更新: 2026-05-16
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "71a2e7d3",
|
||||
"configHash": "724aba97",
|
||||
"lockfileHash": "68d7d7e0",
|
||||
"browserHash": "f75d19e4",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>AgentOS Dashboard</title>
|
||||
<link rel="stylesheet" href="/src/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px">
|
||||
<h1 style="margin-bottom:0;cursor:pointer;font-size:14px" onclick="toggleSidebar()">🖥 AgentOS <span style="font-size:9px;margin-left:2px">v4</span></h1>
|
||||
<div onclick="toggleSidebar()" style="cursor:pointer;padding:2px 6px;border-radius:4px;font-size:12px;color:var(--text2);transition:.15s" title="折叠侧边栏" id="sidebarToggleBtn">◀</div>
|
||||
</div>
|
||||
<div id="identityBar" style="margin-bottom:12px;padding:6px 10px;background:var(--bg3);border-radius:8px;font-size:10px;color:var(--text2);line-height:1.6">
|
||||
<div id="identityHostname"></div>
|
||||
<div id="identityMeta" style="opacity:.7"></div>
|
||||
</div>
|
||||
<div id="navBar" class="nav"></div>
|
||||
<div style="flex:1"></div>
|
||||
<div style="padding:6px 0;display:flex;gap:4px">
|
||||
<div class="nav-item" onclick="collapseAllGroups()" style="flex:0;text-align:center;font-size:11px;padding:7px 6px" title="折叠全部">📂</div>
|
||||
<div class="nav-item" onclick="gitSync()" style="flex:1;text-align:center;font-size:11px">🔄 同步</div>
|
||||
<div class="nav-item" onclick="location.reload()" style="flex:1;text-align:center;font-size:11px">⟳ 刷新</div>
|
||||
</div>
|
||||
</nav>
|
||||
<main class="main" id="app">
|
||||
|
||||
<!-- Stats bar(默认隐藏,右侧小按钮可展开;用户反馈占地方影响操作) -->
|
||||
<div id="statsToggleWrap" style="display:flex;justify-content:flex-end;margin:0 0 2px">
|
||||
<button id="statsToggle" onclick="window.toggleStatsBar&&window.toggleStatsBar()"
|
||||
title="显示/隐藏顶部统计与机器状态"
|
||||
style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:11px;
|
||||
padding:1px 6px;opacity:.55">📊 统计</button>
|
||||
</div>
|
||||
<div class="stats" id="statsBar" style="display:none"></div>
|
||||
<!-- Machine status bar -->
|
||||
<div id="machineBar" class="machines-bar" style="display:none"><span style="font-size:11px;color:var(--text2)">⏳ 加载机器状态...</span></div>
|
||||
<!-- Execution history -->
|
||||
<!-- execHistory 已迁移到指挥台视图 -->
|
||||
|
||||
<!-- ═══ 系统知识中心 ═══ -->
|
||||
<div id="view-kb" class="hidden">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<h2 style="font-size:18px">📚 系统知识中心</h2>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button onclick="kbRefresh()" style="background:transparent;color:var(--text);border:1px solid var(--border);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">🔄 刷新</button>
|
||||
<button onclick="kbLoadLogs()" style="background:transparent;color:var(--text);border:1px solid var(--border);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:12px">📋 日志</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:16px;flex-wrap:wrap">
|
||||
<div class="kb-tab active" data-tab="kb-knowledge" onclick="kbSwitchTab('kb-knowledge',this)">📖 知识库</div>
|
||||
<div class="kb-tab" data-tab="kb-memory" onclick="kbSwitchTab('kb-memory',this)">🧠 记忆库</div>
|
||||
<div class="kb-tab" data-tab="kb-system" onclick="kbSwitchTab('kb-system',this)">🤖 联邦系统说明</div>
|
||||
<div class="kb-tab" data-tab="kb-skills" onclick="kbSwitchTab('kb-skills',this)">🧩 技能与工具</div>
|
||||
</div>
|
||||
<div id="kb-tab-knowledge" class="kb-tab-body">
|
||||
<div id="kbStats" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(110px,1fr));gap:8px;margin-bottom:12px"></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px">⚡ 操作面板</div>
|
||||
<div style="display:grid;gap:6px">
|
||||
<button onclick="kbShowRefine()" style="text-align:left;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:6px;cursor:pointer;font-size:12px">🧹 提纯收件箱</button>
|
||||
<button onclick="kbShowCollect()" style="text-align:left;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:6px;cursor:pointer;font-size:12px">📥 汇聚到收件箱</button>
|
||||
<button onclick="kbShowIngest()" style="text-align:left;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:6px;cursor:pointer;font-size:12px">📝 导入知识</button>
|
||||
<button onclick="kbShowSearch()" style="text-align:left;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:6px;cursor:pointer;font-size:12px">🔍 搜索</button>
|
||||
<button onclick="kbShowBackup()" style="text-align:left;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:6px;cursor:pointer;font-size:12px">💾 备份</button>
|
||||
<button onclick="kbGitSync()" style="text-align:left;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 12px;border-radius:6px;cursor:pointer;font-size:12px">🔄 Git同步</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px">📋 参数 / 结果</div>
|
||||
<div id="kbResultPanel" style="font-size:12px;color:var(--text2)">点击操作设置参数</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="kbLogs" style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)"></div>
|
||||
</div>
|
||||
<div id="kb-tab-memory" class="kb-tab-body" style="display:none">
|
||||
<div id="memStats" style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:12px"></div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px">🧠 记忆层级</div>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:8px">
|
||||
<button onclick="memShowL1()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px">🔑 L1 关键词</button>
|
||||
<button onclick="memShowL2()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px">📋 L2 事实</button>
|
||||
<button onclick="memShowL3()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 12px;border-radius:6px;cursor:pointer;font-size:11px">📄 L3 原文</button>
|
||||
</div>
|
||||
<div id="memContent" style="font-size:12px;color:var(--text2)">点击按钮查看各层详情</div>
|
||||
</div>
|
||||
<div id="memDetailPanel" style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border);overflow-y:auto;max-height:400px"><div style="font-size:13px;font-weight:600;margin-bottom:8px">📋 详情</div><div id="memDetailContent" style="font-size:12px;color:var(--text2)">选择左侧查看</div></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="kb-tab-system" class="kb-tab-body" style="display:none">
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border);margin-bottom:12px"><div style="font-size:13px;font-weight:600;margin-bottom:8px">🤖 联邦系统文档</div><div id="sysDocsList" style="font-size:12px;color:var(--text2)">加载中...</div></div>
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)"><div style="font-size:13px;font-weight:600;margin-bottom:8px">📋 文档内容预览</div><div id="sysDocPreview" style="font-size:12px;color:var(--text2);max-height:400px;overflow-y:auto;white-space:pre-wrap;font-family:monospace">选择左侧文档查看</div></div>
|
||||
</div>
|
||||
<div id="kb-tab-skills" class="kb-tab-body" style="display:none">
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-bottom:12px">
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)"><div style="font-size:13px;font-weight:600;margin-bottom:8px">🧩 已安装技能</div><div id="skillsList" style="font-size:12px">加载中...</div></div>
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)"><div style="font-size:13px;font-weight:600;margin-bottom:8px">🔧 工具目录</div><div id="toolsList" style="font-size:12px">加载中...</div></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ═══ 登录管理(新建)═══ -->
|
||||
<div id="view-matrix-login" class="hidden"><div class="loading">⏳ 加载登录管理...</div></div>
|
||||
|
||||
<!-- ═══ 动态视图容器(供46个已迁移视图用)═══ -->
|
||||
<div id="view-dynamic" class="hidden"></div>
|
||||
|
||||
<!-- ═══ 蓝图执行弹窗 ═══ -->
|
||||
<div id="bpExecModal" style="display:none;position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.7);align-items:center;justify-content:center" onclick="if(event.target===this)closeBpModal()">
|
||||
<div style="background:var(--bg2);border-radius:12px;padding:24px;max-width:500px;width:90%;max-height:80vh;overflow-y:auto" id="bpExecContent" onclick="event.stopPropagation()"></div>
|
||||
</div>
|
||||
|
||||
</main>
|
||||
<script type="module" src="/src/main.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,872 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"devDependencies": {
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/core": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz",
|
||||
"integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/wasi-threads": "1.2.1",
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/runtime": {
|
||||
"version": "1.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz",
|
||||
"integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@emnapi/wasi-threads": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz",
|
||||
"integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@napi-rs/wasm-runtime": {
|
||||
"version": "1.1.5",
|
||||
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz",
|
||||
"integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@tybys/wasm-util": "^0.10.2"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/Brooooooklyn"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@emnapi/core": "^1.7.1",
|
||||
"@emnapi/runtime": "^1.7.1"
|
||||
}
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.133.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz",
|
||||
"integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/Boshen"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz",
|
||||
"integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz",
|
||||
"integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz",
|
||||
"integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz",
|
||||
"integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz",
|
||||
"integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-wasm32-wasi": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz",
|
||||
"integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==",
|
||||
"cpu": [
|
||||
"wasm32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"@emnapi/core": "1.10.0",
|
||||
"@emnapi/runtime": "1.10.0",
|
||||
"@napi-rs/wasm-runtime": "^1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz",
|
||||
"integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/pluginutils": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
|
||||
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tybys/wasm-util": {
|
||||
"version": "0.10.2",
|
||||
"resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz",
|
||||
"integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"dependencies": {
|
||||
"tslib": "^2.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/detect-libc": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
|
||||
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fdir": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
|
||||
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"picomatch": "^3 || ^4"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"picomatch": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",
|
||||
"integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==",
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"dependencies": {
|
||||
"detect-libc": "^2.0.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"lightningcss-android-arm64": "1.32.0",
|
||||
"lightningcss-darwin-arm64": "1.32.0",
|
||||
"lightningcss-darwin-x64": "1.32.0",
|
||||
"lightningcss-freebsd-x64": "1.32.0",
|
||||
"lightningcss-linux-arm-gnueabihf": "1.32.0",
|
||||
"lightningcss-linux-arm64-gnu": "1.32.0",
|
||||
"lightningcss-linux-arm64-musl": "1.32.0",
|
||||
"lightningcss-linux-x64-gnu": "1.32.0",
|
||||
"lightningcss-linux-x64-musl": "1.32.0",
|
||||
"lightningcss-win32-arm64-msvc": "1.32.0",
|
||||
"lightningcss-win32-x64-msvc": "1.32.0"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-android-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-arm64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz",
|
||||
"integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-darwin-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-freebsd-x64": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz",
|
||||
"integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm-gnueabihf": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz",
|
||||
"integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-arm64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-gnu": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz",
|
||||
"integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-linux-x64-musl": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz",
|
||||
"integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-arm64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/lightningcss-win32-x64-msvc": {
|
||||
"version": "1.32.0",
|
||||
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz",
|
||||
"integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MPL-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">= 12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.12",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz",
|
||||
"integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.4",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz",
|
||||
"integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/jonschlinkert"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/postcss/"
|
||||
},
|
||||
{
|
||||
"type": "tidelift",
|
||||
"url": "https://tidelift.com/funding/github/npm/postcss"
|
||||
},
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz",
|
||||
"integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.133.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"rolldown": "bin/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-arm64": "1.0.3",
|
||||
"@rolldown/binding-darwin-x64": "1.0.3",
|
||||
"@rolldown/binding-freebsd-x64": "1.0.3",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.0.3",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.0.3",
|
||||
"@rolldown/binding-linux-x64-musl": "1.0.3",
|
||||
"@rolldown/binding-openharmony-arm64": "1.0.3",
|
||||
"@rolldown/binding-wasm32-wasi": "1.0.3",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.0.3",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.0.3"
|
||||
}
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/tinyglobby": {
|
||||
"version": "0.2.17",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
|
||||
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fdir": "^6.5.0",
|
||||
"picomatch": "^4.0.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=12.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/SuperchupuDev"
|
||||
}
|
||||
},
|
||||
"node_modules/tslib": {
|
||||
"version": "2.8.1",
|
||||
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
|
||||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"dev": true,
|
||||
"license": "0BSD",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.0.16",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz",
|
||||
"integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
"postcss": "^8.5.15",
|
||||
"rolldown": "1.0.3",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
"vite": "bin/vite.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/vitejs/vite?sponsor=1"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.1.18",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
"sass": "^1.70.0",
|
||||
"sass-embedded": "^1.70.0",
|
||||
"stylus": ">=0.54.8",
|
||||
"sugarss": "^5.0.0",
|
||||
"terser": "^5.16.0",
|
||||
"tsx": "^4.8.1",
|
||||
"yaml": "^2.4.2"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@types/node": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitejs/devtools": {
|
||||
"optional": true
|
||||
},
|
||||
"esbuild": {
|
||||
"optional": true
|
||||
},
|
||||
"jiti": {
|
||||
"optional": true
|
||||
},
|
||||
"less": {
|
||||
"optional": true
|
||||
},
|
||||
"sass": {
|
||||
"optional": true
|
||||
},
|
||||
"sass-embedded": {
|
||||
"optional": true
|
||||
},
|
||||
"stylus": {
|
||||
"optional": true
|
||||
},
|
||||
"sugarss": {
|
||||
"optional": true
|
||||
},
|
||||
"terser": {
|
||||
"optional": true
|
||||
},
|
||||
"tsx": {
|
||||
"optional": true
|
||||
},
|
||||
"yaml": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vite": "^8.0.12"
|
||||
}
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg">
|
||||
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||
</symbol>
|
||||
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||
</symbol>
|
||||
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||
</symbol>
|
||||
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||
</symbol>
|
||||
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||
</symbol>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -0,0 +1,128 @@
|
||||
/**
|
||||
* API 封装模块
|
||||
* 所有后端 API 调用集中在这里,统一错误处理
|
||||
*/
|
||||
|
||||
const BASE = '/api';
|
||||
|
||||
async function request(path, options = {}) {
|
||||
const url = `${BASE}${path}`;
|
||||
const config = {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
};
|
||||
// Remove Content-Type for GET/HEAD requests
|
||||
if (!config.method || config.method === 'GET' || config.method === 'HEAD') {
|
||||
delete config.headers['Content-Type'];
|
||||
}
|
||||
try {
|
||||
const r = await fetch(url, config);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '');
|
||||
throw new Error(`HTTP ${r.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return await r.json();
|
||||
} catch (e) {
|
||||
if (e.name === 'AbortError') throw e;
|
||||
throw new Error(`API ${url}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Matrix 账号 ──
|
||||
export function getAccounts() {
|
||||
return request('/matrix/accounts');
|
||||
}
|
||||
|
||||
export function getAccount(id) {
|
||||
return request(`/matrix/accounts/${encodeURIComponent(id)}`);
|
||||
}
|
||||
|
||||
export function getHomepageInfo() {
|
||||
return request('/matrix/homepage-info');
|
||||
}
|
||||
|
||||
// ── 养号执行 ──
|
||||
export function nurtureStart(body) {
|
||||
return request('/matrix/nurture/start', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function nurtureStatus() {
|
||||
return request('/matrix/nurture/status');
|
||||
}
|
||||
|
||||
export function nurtureResults(limit = 20) {
|
||||
return request(`/matrix/nurture/results?limit=${limit}`);
|
||||
}
|
||||
|
||||
export function nurtureLog(runId) {
|
||||
return request(`/matrix/nurture/log/${encodeURIComponent(runId)}`);
|
||||
}
|
||||
|
||||
export function nurtureCleanup(body = {}) {
|
||||
return request('/matrix/nurture/cleanup', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 信息采集 ──
|
||||
export function collectHomepage(body = {}) {
|
||||
return request('/matrix/collect-homepage', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
export function collectStatus() {
|
||||
return request('/matrix/collect-homepage/status');
|
||||
}
|
||||
|
||||
export function collectCancel() {
|
||||
return request('/matrix/collect-homepage/cancel', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 蓝图 ──
|
||||
export function getBlueprints() {
|
||||
return request('/matrix/blueprints');
|
||||
}
|
||||
|
||||
export function getAtomOps() {
|
||||
return request('/matrix/atom-ops');
|
||||
}
|
||||
|
||||
// ── 任务 ──
|
||||
export function taskRun(body) {
|
||||
return request('/matrix/task/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 统一操作 ──
|
||||
export function opsRun(type, accounts, params = {}) {
|
||||
return request('/ops/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type, accounts, params }),
|
||||
});
|
||||
}
|
||||
|
||||
// ── 联邦 ──
|
||||
export function getMachines() {
|
||||
return request('/machines');
|
||||
}
|
||||
|
||||
export function getMachineStatus(name) {
|
||||
return request(`/matrix/machine/${encodeURIComponent(name)}/status`);
|
||||
}
|
||||
|
||||
export function fleetSync() {
|
||||
return request('/fleet/reconcile', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 健康检查 ──
|
||||
export function health() {
|
||||
return request('/health');
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="32" height="32" viewBox="0 0 256 256"><path fill="#F7DF1E" d="M0 0h256v256H0V0Z"/><path d="m67.312 213.932l19.59-11.856c3.78 6.701 7.218 12.371 15.465 12.371c7.905 0 12.89-3.092 12.89-15.12v-81.798h24.057v82.138c0 24.917-14.606 36.259-35.916 36.259c-19.245 0-30.416-9.967-36.087-21.996m85.07-2.576l19.588-11.341c5.157 8.421 11.859 14.607 23.715 14.607c9.969 0 16.325-4.984 16.325-11.858c0-8.248-6.53-11.17-17.528-15.98l-6.013-2.58c-17.357-7.387-28.87-16.667-28.87-36.257c0-18.044 13.747-31.792 35.228-31.792c15.294 0 26.292 5.328 34.196 19.247l-18.732 12.03c-4.125-7.389-8.591-10.31-15.465-10.31c-7.046 0-11.514 4.468-11.514 10.31c0 7.217 4.468 10.14 14.778 14.608l6.014 2.577c20.45 8.765 31.963 17.7 31.963 37.804c0 21.654-17.012 33.51-39.867 33.51c-22.339 0-36.774-10.654-43.819-24.574"/></svg>
|
||||
|
After Width: | Height: | Size: 863 B |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,490 @@
|
||||
/**
|
||||
* 统一账号选择器(v3 — 联邦风格版)
|
||||
*
|
||||
* 保留原 v2 特性:
|
||||
* 按机器可折叠分组、搜索/筛选、checkbox 多选、getSelected() API
|
||||
*
|
||||
* 升级内容:
|
||||
* 状态显示:颜色圆点+中文文字
|
||||
* 行展示:☑ | 📱手机号 | 账号 | 🎵昵称 | 粉丝 | 🟢状态
|
||||
* 按身份分组,同一身份用同一颜色标记
|
||||
* 底部批量操作栏:📡采集 🔑登录 🏃养号 💬评论
|
||||
* 数据源:V2 API(统一口径)
|
||||
*
|
||||
* @param {HTMLElement|string} container 容器元素或ID
|
||||
* @param {object} opts
|
||||
* @param {Array} opts.accounts - 账号列表
|
||||
* @param {object} [opts.hpIndex] - 采集信息索引(兼容旧接口)
|
||||
* @param {boolean} [opts.checkAll=true] - 默认全选
|
||||
* @param {string} [opts.height='350px'] - 最大高度
|
||||
* @param {boolean} [opts.compact=false] - 紧凑模式(隐藏筛选栏+统计)
|
||||
* @param {boolean} [opts.showBatchBar=true] - 显示底部批量操作栏
|
||||
* @param {Array} [opts.batchActions=['collect','login','nurture','comment']] - 批量操作按钮
|
||||
* @param {function} [opts.onSelect] - 选择变更回调
|
||||
* @param {function} [opts.onBatchDone] - 批量操作完成回调
|
||||
*/
|
||||
|
||||
// ── 状态配置(统一来源: config/status-config.js)──
|
||||
import { STATUS_CFG, STATUS_ORDER } from '../config/status-config.js';
|
||||
let _uidCounter = 0;
|
||||
|
||||
// ── 身份颜色池(按身份分配,方便辨识同一身份的不同平台账号)──
|
||||
const IDENTITY_COLORS = [
|
||||
'#e3f2fd', '#fce4ec', '#e8f5e9', '#fff3e0', '#f3e5f5',
|
||||
'#e0f7fa', '#fff8e1', '#fbe9e7', '#e8eaf6', '#f1f8e9',
|
||||
'#fce4ec', '#e8f5e9', '#fff3e0', '#e0f7fa', '#f3e5f5',
|
||||
];
|
||||
|
||||
|
||||
export function createAccountSelector(container, opts = {}) {
|
||||
const el = typeof container === 'string' ? document.getElementById(container) : container;
|
||||
if (!el) return null;
|
||||
|
||||
const uid = `as_${++_uidCounter}`;
|
||||
const allAccts = (opts.accounts || []).filter(a => a.enabled !== false);
|
||||
const hpIndex = opts.hpIndex || {};
|
||||
const checkAll = opts.checkAll !== false;
|
||||
const compact = !!opts.compact;
|
||||
const showBatchBar = opts.showBatchBar !== false;
|
||||
const batchActions = opts.batchActions || ['collect','login','nurture','comment'];
|
||||
|
||||
// ── 全局筛选状态 ──
|
||||
let _filterQ = '';
|
||||
let _filterMachine = '';
|
||||
let _filterPlatform = '';
|
||||
let _filterStatus = '';
|
||||
let _filterTag = '';
|
||||
let _filterTagMode = 'include';
|
||||
|
||||
// ── 状态图标辅助 ──
|
||||
function _statusHtml(st) {
|
||||
const cfg = STATUS_CFG[st] || { dot: '⚪', label: st, color: '#8b8fa3' };
|
||||
return `<span style="color:${cfg.color}">${cfg.dot}</span> ${cfg.label}`;
|
||||
}
|
||||
|
||||
// ── 分配身份颜色 ──
|
||||
function _assignIdentityColors(accts) {
|
||||
// 同一机器内,同一身份用相同颜色
|
||||
const byMachine = {};
|
||||
accts.forEach(a => {
|
||||
const m = a.owner_machine || a._source_machine || '未分配';
|
||||
if (!byMachine[m]) byMachine[m] = {};
|
||||
const ident = a.identity_dir || a.id;
|
||||
if (!byMachine[m][ident]) byMachine[m][ident] = [];
|
||||
byMachine[m][ident].push(a);
|
||||
});
|
||||
|
||||
const colorMap = {};
|
||||
Object.keys(byMachine).forEach(m => {
|
||||
const idents = Object.keys(byMachine[m]);
|
||||
idents.forEach((ident, idx) => {
|
||||
colorMap[`${m}::${ident}`] = IDENTITY_COLORS[idx % IDENTITY_COLORS.length];
|
||||
});
|
||||
});
|
||||
return colorMap;
|
||||
}
|
||||
|
||||
// ── 构建 HTML ──
|
||||
function _render() {
|
||||
// 筛选所有账号
|
||||
let filtered = allAccts;
|
||||
if (_filterQ) {
|
||||
const lq = _filterQ.toLowerCase();
|
||||
filtered = filtered.filter(a =>
|
||||
(a.id || '').toLowerCase().includes(lq) ||
|
||||
(a.phone || '').toLowerCase().includes(lq) ||
|
||||
(a.nickname || '').toLowerCase().includes(lq) ||
|
||||
(a.owner_machine || '').toLowerCase().includes(lq)
|
||||
);
|
||||
}
|
||||
if (_filterMachine) filtered = filtered.filter(a => (a.owner_machine || a._source_machine) === _filterMachine);
|
||||
if (_filterPlatform) filtered = filtered.filter(a => a.platform === _filterPlatform);
|
||||
if (_filterStatus) filtered = filtered.filter(a => (a.login_status || a._status) === _filterStatus);
|
||||
if (_filterTag) {
|
||||
filtered = filtered.filter(a => {
|
||||
const tags = a.tags || [];
|
||||
const hasTag = tags.includes(_filterTag);
|
||||
return _filterTagMode === 'include' ? hasTag : !hasTag;
|
||||
});
|
||||
}
|
||||
|
||||
// 树形分组:机器 → 身份 → [账号]
|
||||
const fTree = {};
|
||||
filtered.forEach(a => {
|
||||
const machine = a.owner_machine || a._source_machine || '未分配';
|
||||
if (!fTree[machine]) fTree[machine] = {};
|
||||
const ident = a.identity_dir || a.id;
|
||||
if (!fTree[machine][ident]) fTree[machine][ident] = [];
|
||||
fTree[machine][ident].push(a);
|
||||
});
|
||||
|
||||
const fMachineOrder = Object.keys(fTree).sort((a, b) => {
|
||||
if (a === 'chengzigedeAir') return -1;
|
||||
if (b === 'chengzigedeAir') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
const colorMap = _assignIdentityColors(filtered);
|
||||
|
||||
let html = '';
|
||||
|
||||
// ── 快速条件栏(机器/平台/状态 → 只筛选显示,不自动选中)──
|
||||
if (!compact) {
|
||||
const machines = [...new Set(allAccts.map(a => a.owner_machine || a._source_machine || '').filter(Boolean))];
|
||||
|
||||
html += `<div style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;margin-bottom:4px">
|
||||
<select id="${uid}_machine" onchange="window._asFilter('${uid}')"
|
||||
style="padding:3px 6px;font-size:11px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="">🖥️ 全部机器</option>
|
||||
${machines.map(m => `<option value="${m}"${m === _filterMachine ? ' selected' : ''}>${m === 'chengzigedeAir' ? '🖥️' : '☁️'} ${m}</option>`).join('')}
|
||||
</select>
|
||||
<select id="${uid}_plat" onchange="window._asFilter('${uid}')"
|
||||
style="padding:3px 6px;font-size:11px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="">🌐 全部平台</option>
|
||||
<option value="douyin"${_filterPlatform === 'douyin' ? ' selected' : ''}>🎵 抖音</option>
|
||||
<option value="xiaohongshu"${_filterPlatform === 'xiaohongshu' ? ' selected' : ''}>📕 小红书</option>
|
||||
</select>
|
||||
<select id="${uid}_status" onchange="window._asFilter('${uid}')"
|
||||
style="padding:3px 6px;font-size:11px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="">全部状态</option>
|
||||
${STATUS_ORDER.map(k => {
|
||||
const cfg = STATUS_CFG[k] || { dot: '⚪', label: k };
|
||||
return `<option value="${k}"${k === _filterStatus ? ' selected' : ''}>${cfg.dot} ${cfg.label}</option>`;
|
||||
}).join('')}
|
||||
</select>
|
||||
<select id="${uid}_tagMode" onchange="window._asFilter('${uid}')"
|
||||
style="padding:3px 6px;font-size:11px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;width:60px">
|
||||
<option value="include"${_filterTagMode === 'include' ? ' selected' : ''}>🏷️ 含</option>
|
||||
<option value="exclude"${_filterTagMode === 'exclude' ? ' selected' : ''}>🚫 排除</option>
|
||||
</select>
|
||||
<select id="${uid}_tag" onchange="window._asFilter('${uid}')"
|
||||
style="padding:3px 6px;font-size:11px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;max-width:100px">
|
||||
<option value="">全部标签</option>
|
||||
${[...new Set(allAccts.flatMap(a => a.tags || []))].sort().map(t => `<option value="${t}"${t === _filterTag ? ' selected' : ''}>🏷️ ${t}</option>`).join('')}
|
||||
</select>
|
||||
<button onclick="window._as_selectFiltered('${uid}')"
|
||||
style="background:#6366f1;color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:10px;font-weight:600">✅ 全选筛选结果</button>
|
||||
<button onclick="window._as_resetSelection('${uid}')"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:10px">↺ 复位选择</button>
|
||||
<span id="${uid}_selCount" style="font-size:11px;color:var(--text2);white-space:nowrap"></span>
|
||||
</div>`;
|
||||
|
||||
// ── 搜索栏(进一步缩小显示范围)──
|
||||
html += `<div style="display:flex;gap:4px;flex-wrap:wrap;align-items:center;margin-bottom:4px">
|
||||
<input id="${uid}_q" placeholder="🔍 搜索账号ID/昵称/手机号"
|
||||
value="${_filterQ}"
|
||||
oninput="window._asFilter('${uid}')"
|
||||
style="flex:1;min-width:80px;padding:3px 6px;font-size:11px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<span style="font-size:11px;color:var(--text2);white-space:nowrap">共 ${filtered.length} 个</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 列表体 ──
|
||||
html += `<div id="${uid}_body" style="max-height:${opts.height || '350px'};overflow-y:auto">`;
|
||||
|
||||
if (fMachineOrder.length === 0) {
|
||||
html += `<div style="padding:12px;text-align:center;color:var(--text2);font-size:11px">无匹配账号</div>`;
|
||||
} else {
|
||||
fMachineOrder.forEach(machine => {
|
||||
const idents = fTree[machine];
|
||||
const isLocal = machine === 'chengzigedeAir';
|
||||
const grpId = `${uid}_m_${machine.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
const totalAccts = Object.values(idents).reduce((s, arr) => s + arr.length, 0);
|
||||
|
||||
// 机器头(可折叠)
|
||||
html += `<div style="margin-bottom:2px;border:1px solid var(--border);border-radius:6px;overflow:hidden">`;
|
||||
html += `<div onclick="_toggleASGroup('${uid}','${machine}')" style="display:flex;align-items:center;gap:4px;padding:4px 6px;background:var(--bg3);cursor:pointer;font-size:11px;font-weight:600;user-select:none">
|
||||
<span id="${grpId}_arrow">▼</span>
|
||||
<span>${isLocal ? '🖥️' : '☁️'} ${machine}</span>
|
||||
<span style="font-size:10px;color:var(--text2);font-weight:400">${totalAccts} 个</span>
|
||||
</div>`;
|
||||
|
||||
// 折叠体
|
||||
html += `<div id="${grpId}_body" style="display:block">`;
|
||||
|
||||
// 表头(精简 + 新增身份列)
|
||||
html += `<div style="display:flex;align-items:center;padding:2px 6px;font-size:9px;color:var(--text2);border-bottom:1px solid var(--border);background:var(--bg2)">
|
||||
<div style="width:22px"><input type="checkbox" id="${grpId}_all" onchange="_toggleGroupAS('${uid}','${machine}')" title="全选本机" ${checkAll ? 'checked' : ''}></div>
|
||||
<div style="width:70px;font-weight:400">📁 身份</div>
|
||||
<div style="width:95px;font-weight:400">📱 手机号</div>
|
||||
<div style="width:90px;font-weight:400">账号</div>
|
||||
<div style="flex:1;min-width:100px;font-weight:400">🎵 昵称</div>
|
||||
<div style="width:40px;text-align:right;font-weight:400">粉丝</div>
|
||||
<div style="width:80px;font-weight:400">状态</div>
|
||||
<div style="width:60px;font-weight:400">🏷️</div>
|
||||
</div>`;
|
||||
|
||||
// 按身份顺序输出
|
||||
const identKeys = Object.keys(idents);
|
||||
identKeys.forEach(ident => {
|
||||
const accts = idents[ident];
|
||||
const colorKey = `${machine}::${ident}`;
|
||||
const bgColor = colorMap[colorKey] || '#ffffff';
|
||||
const borderLeft = `3px solid ${bgColor}`;
|
||||
|
||||
// 身份分隔行(简单分隔线,去掉颜色背景)
|
||||
html += `<div style="display:flex;align-items:center;padding:1px 6px;font-size:8px;color:var(--text2);border-bottom:1px dotted var(--border);margin-top:1px">
|
||||
<span>📁 ${ident}</span>
|
||||
<span style="margin-left:8px">${accts.length} 个账号</span>
|
||||
</div>`;
|
||||
|
||||
accts.forEach(a => {
|
||||
const st = a.login_status || a._status || 'unknown';
|
||||
const platIcon = a.platform === 'xiaohongshu' ? '📕' : '🎵';
|
||||
const nick = a.nickname || a._nickname || '';
|
||||
const fans = a.fans || a._fans || '';
|
||||
const phone = a.phone || '';
|
||||
const aidEsc = a.id.replace(/'/g, "\\'");
|
||||
|
||||
html += `<div class="as-row-${uid}" data-account="${a.id}" data-machine="${machine}" data-platform="${a.platform}"
|
||||
style="display:flex;align-items:center;padding:3px 6px;font-size:11px;border-bottom:1px solid var(--border);cursor:pointer"
|
||||
onclick="window._asToggleDetail('${uid}','${aidEsc}')">`;
|
||||
|
||||
html += `<div style="width:22px"><input type="checkbox" class="as-cb-${uid}" value="${a.id}" data-plat="${a.platform}" data-machine="${machine}" ${checkAll ? 'checked' : ''} onclick="event.stopPropagation();_onASChange('${uid}')"></div>`;
|
||||
// 身份列(新增)
|
||||
const identShort = (a.identity_dir || a.id).length > 10 ? (a.identity_dir || a.id).slice(0,10)+'…' : (a.identity_dir || a.id);
|
||||
html += `<div style="width:70px;font-size:9px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${a.identity_dir || a.id}">${identShort}</div>`;
|
||||
// 📱手机号列
|
||||
html += `<div style="width:95px;font-size:10px;color:var(--text2);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${phone || '-'}</div>`;
|
||||
// 账号列
|
||||
html += `<div style="width:90px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap"><strong>${a.id}</strong></div>`;
|
||||
// 🎵昵称列
|
||||
html += `<div style="flex:1;min-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:10px">${platIcon} ${nick || '-'}</div>`;
|
||||
// 粉丝列
|
||||
html += `<div style="width:40px;text-align:right;font-size:10px">${fans || '-'}</div>`;
|
||||
// 状态列
|
||||
html += `<div style="width:80px;font-size:10px">${_statusHtml(st)}</div>`;
|
||||
// 标签列
|
||||
const tags = a.tags || [];
|
||||
html += `<div style="width:60px;font-size:8px;overflow:hidden;white-space:nowrap;text-overflow:ellipsis;color:var(--text2)">${tags.length ? tags.slice(0,2).join(',') + (tags.length > 2 ? '…' : '') : ''}</div>`;
|
||||
|
||||
html += `</div>`;
|
||||
});
|
||||
});
|
||||
|
||||
html += `</div></div>`;
|
||||
});
|
||||
}
|
||||
|
||||
html += `</div>`;
|
||||
|
||||
// ── 统计行 ──
|
||||
if (!compact) {
|
||||
html += `<div style="display:flex;justify-content:space-between;align-items:center;padding:3px 4px;font-size:10px;color:var(--text2)">
|
||||
<span>共 ${filtered.length} 个账号</span>
|
||||
<span id="${uid}_count">已选 ${checkAll ? filtered.length : 0} 个</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 批量操作栏 ──
|
||||
if (showBatchBar) {
|
||||
const actionLabels = {
|
||||
collect: '📡 采集', login: '🔑 登录',
|
||||
nurture: '🏃 养号', comment: '💬 评论',
|
||||
};
|
||||
html += `<div id="${uid}_batchBar" style="display:none;position:sticky;bottom:0;background:var(--bg2);border:1px solid var(--primary);border-radius:6px;padding:6px 10px;margin-top:4px;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<span id="${uid}_batchCount" style="font-size:11px;font-weight:600"></span>
|
||||
${batchActions.map(act => `<button class="as-batch-btn-${uid}" data-action="${act}" style="background:var(--primary);color:#fff;border:none;padding:3px 8px;border-radius:4px;cursor:pointer;font-size:10px">${actionLabels[act] || act}</button>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
el.innerHTML = html;
|
||||
|
||||
// ── 绑定事件 ──
|
||||
_refreshCount();
|
||||
if (showBatchBar) {
|
||||
el.querySelectorAll(`.as-batch-btn-${uid}`).forEach(btn => {
|
||||
btn.onclick = (e) => {
|
||||
e.stopPropagation();
|
||||
_handleBatch(btn.dataset.action);
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── 刷新选中计数 ──
|
||||
function _refreshCount() {
|
||||
const checked = document.querySelectorAll(`.as-cb-${uid}:checked`).length;
|
||||
const countEl = document.getElementById(`${uid}_count`);
|
||||
if (countEl) countEl.textContent = `已选 ${checked} 个`;
|
||||
const selCountEl = document.getElementById(`${uid}_selCount`);
|
||||
if (selCountEl) selCountEl.textContent = `已选 ${checked} 个`;
|
||||
|
||||
const batchBar = document.getElementById(`${uid}_batchBar`);
|
||||
const batchCount = document.getElementById(`${uid}_batchCount`);
|
||||
if (batchBar && batchCount) {
|
||||
if (checked > 0) {
|
||||
batchBar.style.display = 'flex';
|
||||
batchCount.textContent = `已选 ${checked} 个账号`;
|
||||
} else {
|
||||
batchBar.style.display = 'none';
|
||||
}
|
||||
}
|
||||
if (opts.onSelect) opts.onSelect(_getSelected());
|
||||
}
|
||||
|
||||
// ── 获取选中 ──
|
||||
function _getSelected() {
|
||||
return Array.from(document.querySelectorAll(`.as-cb-${uid}:checked`)).map(cb => ({
|
||||
id: cb.value,
|
||||
platform: cb.dataset.plat,
|
||||
machine: cb.dataset.machine,
|
||||
login_status: '',
|
||||
nickname: '',
|
||||
}));
|
||||
}
|
||||
|
||||
// ── 批量操作 ──
|
||||
async function _handleBatch(action) {
|
||||
const ids = _getSelected().map(s => s.id);
|
||||
if (!ids.length) return;
|
||||
|
||||
if (action === 'nurture') {
|
||||
_showNurtureDialog(ids);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/v2/accounts/batch', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account_ids: ids, action }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
alert(`✅ 已提交 ${ids.length} 个账号${action==='collect'?'采集':'登录'}任务`);
|
||||
if (opts.onBatchDone) opts.onBatchDone(d);
|
||||
} else {
|
||||
alert('❌ ' + (d.detail || '操作失败'));
|
||||
}
|
||||
} catch(e) {
|
||||
alert('❌ ' + e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 养号弹窗 ──
|
||||
function _showNurtureDialog(ids) {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:9999;display:flex;align-items:center;justify-content:center';
|
||||
overlay.onclick = e => { if (e.target === overlay) overlay.remove(); };
|
||||
overlay.innerHTML = `
|
||||
<div style="background:var(--bg2);border-radius:12px;padding:20px;max-width:340px;width:90%">
|
||||
<div style="font-size:14px;font-weight:600;margin-bottom:10px">🏃 养号设置</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px">选中 ${ids.length} 个账号</div>
|
||||
<div style="display:grid;gap:6px;font-size:12px">
|
||||
<label>蓝图 <select id="${uid}_bp" style="width:100%;padding:4px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="douyin_daily">🎵 douyin_daily</option>
|
||||
<option value="xhs_daily">📕 xhs_daily</option>
|
||||
</select></label>
|
||||
<label>轮次 <input id="${uid}_rounds" type="number" min="1" max="20" value="3" style="width:100%;padding:4px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px"></label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end;margin-top:10px">
|
||||
<button onclick="this.closest('div[style]').parentElement.remove()" style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:5px 14px;font-size:11px;cursor:pointer">取消</button>
|
||||
<button id="${uid}_nurtureOk" style="background:var(--primary);color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:11px;cursor:pointer">✅ 开始</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
overlay.querySelector(`#${uid}_nurtureOk`).onclick = async () => {
|
||||
const blueprint = document.getElementById(`${uid}_bp`).value;
|
||||
const rounds = parseInt(document.getElementById(`${uid}_rounds`).value) || 3;
|
||||
try {
|
||||
const r = await fetch('/api/v2/accounts/batch', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ account_ids: ids, action: 'nurture', params: { blueprint, rounds } }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
alert(`✅ 已提交 ${ids.length} 个账号养号`);
|
||||
overlay.remove();
|
||||
if (opts.onBatchDone) opts.onBatchDone(d);
|
||||
} else {
|
||||
alert('❌ ' + (d.detail || '操作失败'));
|
||||
}
|
||||
} catch(e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
}
|
||||
|
||||
// ── 全选筛选结果:勾选当前筛选条件下所有可见账号 ──
|
||||
window._as_selectFiltered = (u) => {
|
||||
if (u !== uid) return;
|
||||
document.querySelectorAll(`.as-cb-${uid}`).forEach(cb => cb.checked = true);
|
||||
// 同时勾选各机器全选框
|
||||
document.querySelectorAll(`[id^="${uid}_m_"][id$="_all"]`).forEach(cb => cb.checked = true);
|
||||
_refreshCount();
|
||||
};
|
||||
|
||||
// ── 复位选择:清空所有勾选 ──
|
||||
window._as_resetSelection = (u) => {
|
||||
if (u !== uid) return;
|
||||
document.querySelectorAll(`.as-cb-${uid}`).forEach(cb => cb.checked = false);
|
||||
document.querySelectorAll(`[id^="${uid}_m_"][id$="_all"]`).forEach(cb => cb.checked = false);
|
||||
_refreshCount();
|
||||
};
|
||||
|
||||
// ── 全局函数 ──
|
||||
window._asFilter = (u) => {
|
||||
if (u !== uid) return;
|
||||
_filterQ = (document.getElementById(`${uid}_q`)?.value || '').trim();
|
||||
_filterMachine = document.getElementById(`${uid}_machine`)?.value || '';
|
||||
_filterPlatform = document.getElementById(`${uid}_plat`)?.value || '';
|
||||
_filterStatus = document.getElementById(`${uid}_status`)?.value || '';
|
||||
_filterTag = document.getElementById(`${uid}_tag`)?.value || '';
|
||||
_filterTagMode = document.getElementById(`${uid}_tagMode`)?.value || 'include';
|
||||
_render();
|
||||
};
|
||||
|
||||
window._asToggleDetail = (u, id) => {
|
||||
if (u !== uid) return;
|
||||
const el = document.getElementById(`${uid}_detail_${id}`);
|
||||
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
};
|
||||
|
||||
window._onASChange = (u) => {
|
||||
if (u !== uid) return;
|
||||
_refreshCount();
|
||||
};
|
||||
|
||||
window._toggleASGroup = (u, machine) => {
|
||||
if (u !== uid) return;
|
||||
const grpId = `${uid}_m_${machine.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
const body = document.getElementById(`${grpId}_body`);
|
||||
const arrow = document.getElementById(`${grpId}_arrow`);
|
||||
if (body && arrow) {
|
||||
const isHidden = body.style.display === 'none';
|
||||
body.style.display = isHidden ? 'block' : 'none';
|
||||
arrow.textContent = isHidden ? '▼' : '▶';
|
||||
}
|
||||
};
|
||||
|
||||
window._toggleGroupAS = (u, machine) => {
|
||||
if (u !== uid) return;
|
||||
const grpId = `${uid}_m_${machine.replace(/[^a-zA-Z0-9]/g, '_')}`;
|
||||
const allCb = document.getElementById(`${grpId}_all`);
|
||||
const checked = allCb?.checked || false;
|
||||
document.querySelectorAll(`#${grpId}_body .as-cb-${uid}`).forEach(cb => cb.checked = checked);
|
||||
_refreshCount();
|
||||
};
|
||||
|
||||
// ── 初始渲染 ──
|
||||
_render();
|
||||
|
||||
// ── 返回 API(完全兼容 v2)──
|
||||
return {
|
||||
uid,
|
||||
el,
|
||||
getSelected() {
|
||||
return _getSelected();
|
||||
},
|
||||
getCount() {
|
||||
return document.querySelectorAll(`.as-cb-${uid}:checked`).length;
|
||||
},
|
||||
selectAll(checked) {
|
||||
document.querySelectorAll(`.as-cb-${uid}`).forEach(cb => cb.checked = !!checked);
|
||||
_refreshCount();
|
||||
if (opts.onSelect) opts.onSelect(this.getSelected());
|
||||
},
|
||||
destroy() {
|
||||
el.innerHTML = '';
|
||||
},
|
||||
};
|
||||
}
|
||||
+300
@@ -0,0 +1,300 @@
|
||||
/**
|
||||
* execution-pipeline.js — 统一执行管道组件 v5
|
||||
*
|
||||
* 所有操作(nurture/collect/login/logout/comment/like)共享此管道。
|
||||
* 流程: preflight → plan → confirm → dispatch → launch → poll → complete
|
||||
*
|
||||
* 用法:
|
||||
* import { createExecutionPipeline } from './components/execution-pipeline.js';
|
||||
*
|
||||
* const pipeline = createExecutionPipeline({
|
||||
* type: 'nurture',
|
||||
* getAccounts: () => selector.getSelected(),
|
||||
* params: { rounds: 10 },
|
||||
* logEl: document.getElementById('log'),
|
||||
* statusEl: document.getElementById('status'),
|
||||
* });
|
||||
* await pipeline.execute();
|
||||
*/
|
||||
|
||||
const API = '/api';
|
||||
|
||||
// 阶段定义
|
||||
const PHASES = {
|
||||
IDLE: { icon: '⏸️', label: '就绪' },
|
||||
PREFLIGHT: { icon: '🔍', label: '预检机器状态' },
|
||||
PLAN: { icon: '📋', label: '生成执行计划' },
|
||||
CONFIRM: { icon: '🖥️', label: '等待确认' },
|
||||
DISPATCHING: { icon: '📡', label: '发送命令' },
|
||||
DISPATCHED: { icon: '✅', label: '命令已发送' },
|
||||
LAUNCH_WAIT: { icon: '⏳', label: '等待进程启动' },
|
||||
LAUNCH_CHECK: { icon: '🔍', label: '检查进程启动' },
|
||||
RUNNING: { icon: '🟢', label: '运行中' },
|
||||
BROWSER_CHECK:{ icon: '🌐', label: '检查浏览器' },
|
||||
COMPLETED: { icon: '✅', label: '执行完成' },
|
||||
FAILED: { icon: '❌', label: '执行失败' },
|
||||
CANCELLED: { icon: '⏸️', label: '已取消' },
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建执行管道实例
|
||||
* @param {object} opts
|
||||
* @param {string} opts.type - 操作类型 (nurture/collect/login/logout/comment/like)
|
||||
* @param {function} opts.getAccounts - 获取选中账号列表的函数
|
||||
* @param {object} [opts.params] - 操作参数 {rounds, blueprint, url, direction, corpus, ...}
|
||||
* @param {HTMLElement} [opts.logEl] - 日志显示容器
|
||||
* @param {HTMLElement} [opts.statusEl] - 状态显示容器
|
||||
* @param {function} [opts.onPhaseChange] - 阶段变化回调
|
||||
* @param {function} [opts.customPlan] - 自定义计划预览(可选)
|
||||
* @returns {object} pipeline 实例
|
||||
*/
|
||||
export function createExecutionPipeline(opts) {
|
||||
const type = opts.type;
|
||||
const getAccounts = opts.getAccounts;
|
||||
const params = opts.params || {};
|
||||
const logEl = opts.logEl;
|
||||
const statusEl = opts.statusEl;
|
||||
const onPhaseChange = opts.onPhaseChange;
|
||||
let cancelled = false;
|
||||
|
||||
/** 当前执行中的命令列表(后端返回) */
|
||||
let commands = [];
|
||||
let pollTimer = null;
|
||||
|
||||
function setPhase(phaseKey, message) {
|
||||
const phase = PHASES[phaseKey] || { icon: '❓', label: message || phaseKey };
|
||||
const msg = `${phase.icon} ${message || phase.label}`;
|
||||
if (statusEl) statusEl.innerHTML = msg;
|
||||
if (logEl) logEl.textContent += (logEl.textContent ? '\n' : '') + msg;
|
||||
if (onPhaseChange) onPhaseChange(phaseKey, message);
|
||||
}
|
||||
|
||||
function log(msg) {
|
||||
if (logEl) logEl.textContent += (logEl.textContent ? '\n' : '') + msg;
|
||||
}
|
||||
|
||||
async function api(path, data) {
|
||||
const url = `${API}${path}`;
|
||||
const options = {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
};
|
||||
if (data) {
|
||||
options.method = 'POST';
|
||||
options.body = JSON.stringify(data);
|
||||
}
|
||||
const r = await fetch(url, options);
|
||||
if (!r.ok) throw new Error(`HTTP ${r.status}: ${await r.text()}`);
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/** 构建确认对话框文本 */
|
||||
function buildConfirmText(plan, machineStatus) {
|
||||
const cmds = plan.commands || [];
|
||||
let text = `📋 ${type}执行确认:\n\n`;
|
||||
|
||||
// 按机器分组
|
||||
const byMachine = {};
|
||||
cmds.forEach(c => {
|
||||
if (!byMachine[c.machine]) byMachine[c.machine] = { accounts: [], info: machineStatus.machines?.[c.machine] || {} };
|
||||
byMachine[c.machine].accounts.push(c);
|
||||
});
|
||||
|
||||
let hasUnreachable = false;
|
||||
Object.entries(byMachine).forEach(([machine, group]) => {
|
||||
const ms = group.info;
|
||||
const reachable = ms.reachable !== false;
|
||||
if (!reachable) hasUnreachable = true;
|
||||
const icon = reachable ? '🟢' : '🔴';
|
||||
const loc = machine === 'chengzigedeAir' ? ' (本机)' : ' (远程)';
|
||||
text += `${icon} ${machine}${loc}\n`;
|
||||
if (ms.browsers_running > 0) text += ` 浏览器: ${ms.browsers_running} 个运行中\n`;
|
||||
if (ms.active_commands > 0) text += ` 活跃命令: ${ms.active_commands} 个\n`;
|
||||
if (ms.slots_available !== undefined) text += ` 槽位: ${ms.slots_available}/${ms.max_concurrent || 3} 可用\n`;
|
||||
if (!reachable) text += ` ⚠️ 机器不可达,命令可能无法执行\n`;
|
||||
|
||||
group.accounts.forEach(c => {
|
||||
const cmdStr = c.command || '';
|
||||
text += ` 🖥️ ${c.account}: ${cmdStr.slice(0, 60)}\n`;
|
||||
});
|
||||
text += '\n';
|
||||
});
|
||||
|
||||
if (plan.errors && plan.errors.length) {
|
||||
text += '⚠️ 部分账号存在问题:\n';
|
||||
plan.errors.forEach(e => { text += ` ❌ ${e.account}: ${e.message}\n`; });
|
||||
}
|
||||
if (hasUnreachable) text += '\n⚠️ 部分机器不可达,继续执行将跳过这些机器';
|
||||
|
||||
text += '\n确认执行?';
|
||||
return text;
|
||||
}
|
||||
|
||||
/** 主入口:执行整个管道 */
|
||||
async function execute() {
|
||||
cancelled = false;
|
||||
|
||||
// 获取选中账号
|
||||
const selected = typeof getAccounts === 'function' ? getAccounts() : getAccounts;
|
||||
if (!selected || selected.length === 0) {
|
||||
setPhase('IDLE', '请先选择要执行的账号');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// ── Phase 0: Preflight ──
|
||||
setPhase('PREFLIGHT', '正在检查各机器状态...');
|
||||
const machineStatus = await api('/api/ops/machines');
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// ── Phase 1: Plan ──
|
||||
setPhase('PLAN', '正在生成执行计划...');
|
||||
const plan = await api('/api/ops/run', {
|
||||
type,
|
||||
accounts: selected.map(s => s.id || s),
|
||||
params: { ...params, dry_run: true },
|
||||
});
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// ── Phase 2: Confirm ──
|
||||
const confirmText = buildConfirmText(plan, machineStatus);
|
||||
if (logEl) logEl.textContent = confirmText;
|
||||
if (!confirm(confirmText)) {
|
||||
setPhase('CANCELLED', '用户取消');
|
||||
return;
|
||||
}
|
||||
|
||||
// ── Phase 3: Dispatch ──
|
||||
setPhase('DISPATCHING', `正在发送到 ${selected.length} 台机器...`);
|
||||
const result = await api('/api/ops/run', {
|
||||
type,
|
||||
accounts: selected.map(s => s.id || s),
|
||||
params,
|
||||
});
|
||||
|
||||
commands = result.commands || [];
|
||||
if (result.errors && result.errors.length) {
|
||||
result.errors.forEach(e => log(` ❌ ${e.account}: ${e.message}`));
|
||||
}
|
||||
|
||||
// 显示调度结果
|
||||
let dispatchText = '';
|
||||
commands.forEach(cmd => {
|
||||
const icon = cmd.status === 'dispatching' ? '📡' : '❌';
|
||||
dispatchText += ` ${icon} ${cmd.account} → ${cmd.machine}`;
|
||||
if (cmd.pid) dispatchText += ` (PID:${cmd.pid})`;
|
||||
dispatchText += '\n';
|
||||
if (cmd.log_path) dispatchText += ` 📄 ${cmd.log_path}\n`;
|
||||
});
|
||||
if (dispatchText) log(dispatchText);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// ── Phase 4: Launch Monitor ──
|
||||
setPhase('LAUNCH_WAIT', '等待进程启动...');
|
||||
await sleep(3000);
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// ── Phase 5: Launch Check ──
|
||||
setPhase('LAUNCH_CHECK', '检查进程是否已启动...');
|
||||
const statusAfter = await api('/api/ops/status');
|
||||
const activeCmds = (statusAfter.commands || []).filter(c =>
|
||||
commands.some(cmd => cmd.run_id === c.run_id)
|
||||
);
|
||||
let startedCount = activeCmds.filter(c => c.status === 'running' || c.status === 'dispatching').length;
|
||||
if (startedCount > 0) {
|
||||
log(` ✅ ${startedCount}/${commands.length} 个命令已启动`);
|
||||
}
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
// ── Phase 6: Execution Poll (with browser check) ──
|
||||
await pollExecution();
|
||||
|
||||
} catch (e) {
|
||||
setPhase('FAILED', `异常: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** 轮询执行状态 */
|
||||
async function pollExecution() {
|
||||
let pollCount = 0;
|
||||
const maxPolls = 60; // 10分钟
|
||||
|
||||
pollTimer = setInterval(async () => {
|
||||
if (cancelled || pollCount >= maxPolls) {
|
||||
clearInterval(pollTimer);
|
||||
return;
|
||||
}
|
||||
pollCount++;
|
||||
|
||||
try {
|
||||
const statusData = await api('/api/ops/status');
|
||||
const allCmds = statusData.commands || [];
|
||||
|
||||
// 匹配当前执行的命令
|
||||
const ourCmds = allCmds.filter(c =>
|
||||
commands.some(cmd => cmd.run_id === c.run_id)
|
||||
);
|
||||
|
||||
if (ourCmds.length === 0) return;
|
||||
|
||||
const running = ourCmds.filter(c => c.status === 'running');
|
||||
const completed = ourCmds.filter(c => c.status === 'completed');
|
||||
const failed = ourCmds.filter(c =>
|
||||
['failed', 'timed_out', 'crashed'].includes(c.status)
|
||||
);
|
||||
|
||||
// 浏览器检查(每3次轮询检查一次)
|
||||
if (pollCount % 3 === 0) {
|
||||
const machinesData = await api('/api/ops/machines');
|
||||
const browserInfo = Object.entries(machinesData.machines || {})
|
||||
.filter(([_, info]) => info.browsers_running > 0)
|
||||
.map(([m, info]) => `${m}: ${info.browsers_running}浏览器`)
|
||||
.join(', ');
|
||||
if (browserInfo) {
|
||||
setPhase('BROWSER_CHECK', `浏览器状态: ${browserInfo}`);
|
||||
}
|
||||
}
|
||||
|
||||
// 状态报告
|
||||
if (running.length > 0) {
|
||||
const elapsed = Math.round(running[0].elapsed_sec || 0);
|
||||
const msg = running.map(c =>
|
||||
`${c.account}: ${c.message || '运行中'} (${elapsed}s)`
|
||||
).join(' | ');
|
||||
setPhase('RUNNING', msg);
|
||||
}
|
||||
|
||||
// 完成检测
|
||||
const done = completed.length + failed.length;
|
||||
const total = ourCmds.length;
|
||||
if (done === total) {
|
||||
clearInterval(pollTimer);
|
||||
const successRate = completed.length > 0
|
||||
? `, 成功率 ${Math.round(completed.length / total * 100)}%`
|
||||
: '';
|
||||
setPhase('COMPLETED', `${completed.length}/${total} 完成${successRate}`);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
// 静默处理轮询错误
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
/** 取消 */
|
||||
function cancel() {
|
||||
cancelled = true;
|
||||
if (pollTimer) clearInterval(pollTimer);
|
||||
setPhase('CANCELLED', '用户取消');
|
||||
}
|
||||
|
||||
return { execute, cancel };
|
||||
}
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
/**
|
||||
* task-monitor.js — 全局任务指示器(任何页面都能看到在进行什么)
|
||||
*
|
||||
* 解决的问题:在导演页点了「🎥 生成成片」后切到别的页面,就找不到任务了。
|
||||
*
|
||||
* 行为:
|
||||
* · 有进行中的出片/流程任务时 → 右下角常驻小面板(进度 + 当前步骤 + 已用时)
|
||||
* · 点击「查看」→ 跳转「📋 生产记录」
|
||||
* · 全部完成时 → 提示一次并展示"最近完成",几秒后自动收起
|
||||
* · 无任务 → 完全不显示(不占地方)
|
||||
*
|
||||
* 注意:本文件通过 window.xxx = function; window.xxx(); 形式暴露并调用,
|
||||
* 避免 Rollup 判定"无副作用"而整段 tree-shake 掉。
|
||||
*/
|
||||
window.initTaskMonitor = function () {
|
||||
if (window.__taskMonitorReady) return;
|
||||
window.__taskMonitorReady = true;
|
||||
|
||||
var box = null;
|
||||
var idleTicks = 0;
|
||||
|
||||
function ensureBox() {
|
||||
if (box && document.body.contains(box)) return box;
|
||||
box = document.createElement('div');
|
||||
box.id = 'taskMonitorBox';
|
||||
box.style.cssText = [
|
||||
'position:fixed', 'right:16px', 'bottom:16px', 'z-index:99998',
|
||||
'width:300px', 'background:var(--bg2,#1c1c1e)', 'border:1px solid var(--border,#333)',
|
||||
'border-radius:10px', 'padding:10px 12px', 'box-shadow:0 8px 24px rgba(0,0,0,.5)',
|
||||
'font-size:12px', 'color:var(--text,#eee)', 'line-height:1.6', 'display:none',
|
||||
].join(';');
|
||||
document.body.appendChild(box);
|
||||
return box;
|
||||
}
|
||||
|
||||
function fmtDur(sec) {
|
||||
sec = Math.max(0, Math.round(sec || 0));
|
||||
if (sec < 60) return sec + ' 秒';
|
||||
var m = Math.floor(sec / 60), s = sec % 60;
|
||||
return m + ' 分' + (s ? s + ' 秒' : '');
|
||||
}
|
||||
|
||||
function bar(pct, color) {
|
||||
pct = Math.max(0, Math.min(100, pct || 0));
|
||||
return '<div style="height:5px;background:rgba(255,255,255,.12);border-radius:3px;overflow:hidden;margin:5px 0">'
|
||||
+ '<div style="height:100%;width:' + pct + '%;background:' + (color || '#22c55e') + ';transition:width .4s"></div></div>';
|
||||
}
|
||||
|
||||
function render(active, recent) {
|
||||
var el = ensureBox();
|
||||
if (!active.length && !recent) { el.style.display = 'none'; return; }
|
||||
el.style.display = 'block';
|
||||
var now = Date.now() / 1000;
|
||||
var html = '';
|
||||
|
||||
if (active.length) {
|
||||
html += '<div style="font-weight:600;margin-bottom:4px;display:flex;align-items:center;gap:6px">'
|
||||
+ '<span>⏳ ' + (active.length > 1 ? active.length + ' 个任务进行中' : '任务进行中') + '</span>'
|
||||
+ '<a href="#" onclick="event.preventDefault();if(window.switchView)window.switchView(\'productions\');return false" '
|
||||
+ 'style="margin-left:auto;color:#3b82f6;text-decoration:none;font-size:11px">查看 ›</a></div>';
|
||||
active.slice(0, 3).forEach(function (t) {
|
||||
var pct = t.percent || 0;
|
||||
var line = t.kind === 'render'
|
||||
? '🎬 出片 ' + (t.progress_total ? (t.progress_done + '/' + t.progress_total) : '')
|
||||
: '🔀 流程 ' + String(t.run_id || '').slice(0, 8);
|
||||
html += '<div style="margin-top:4px">'
|
||||
+ '<div style="font-size:11px;color:var(--text2,#aaa);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'
|
||||
+ line + (t.title ? ' · ' + escapeHtml(String(t.title).slice(0, 14)) : '') + '</div>'
|
||||
+ bar(pct, '#3b82f6')
|
||||
+ '<div style="font-size:10px;color:var(--text2,#aaa);display:flex;gap:8px">'
|
||||
+ '<span>' + pct + '%</span>'
|
||||
+ '<span>已用 ' + fmtDur(now - (t.started_at || now)) + '</span>'
|
||||
+ (t.current_step ? '<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + escapeHtml(t.current_step.slice(0, 18)) + '</span>' : '')
|
||||
+ '</div></div>';
|
||||
});
|
||||
}
|
||||
|
||||
if (recent && !active.length) {
|
||||
html += '<div style="font-weight:600;color:#22c55e">✅ ' + escapeHtml(recent.label) + '</div>'
|
||||
+ '<div style="font-size:11px;color:var(--text2,#aaa)">' + escapeHtml(recent.detail || '') + '</div>'
|
||||
+ '<a href="#" onclick="event.preventDefault();if(window.switchView)window.switchView(\'productions\');return false" '
|
||||
+ 'style="display:inline-block;margin-top:4px;color:#3b82f6;text-decoration:none;font-size:11px">查看产物 ›</a>';
|
||||
}
|
||||
|
||||
html += '<button onclick="this.parentNode.style.display=\'none\'" '
|
||||
+ 'style="position:absolute;top:4px;right:8px;background:none;border:none;color:var(--text2,#888);cursor:pointer;font-size:12px">✕</button>';
|
||||
el.innerHTML = html;
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, function (c) {
|
||||
return { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c];
|
||||
});
|
||||
}
|
||||
|
||||
var seenTerminal = {};
|
||||
var primed = false;
|
||||
|
||||
async function tick() {
|
||||
var renders = {}, runs = {};
|
||||
try {
|
||||
var res = await Promise.all([
|
||||
fetch('/api/ave/renders').then(function (r) { return r.json(); }).catch(function () { return {}; }),
|
||||
fetch('/api/workflow/runs').then(function (r) { return r.json(); }).catch(function () { return {}; }),
|
||||
]);
|
||||
renders = res[0] || {}; runs = res[1] || {};
|
||||
} catch (e) { return; }
|
||||
|
||||
var active = [];
|
||||
(renders.jobs || []).forEach(function (j) {
|
||||
if (j.status === 'running') {
|
||||
active.push({ kind: 'render', percent: j.percent, progress_done: j.progress_done,
|
||||
progress_total: j.progress_total, current_step: j.current_step,
|
||||
started_at: j.started_at, title: j.title });
|
||||
}
|
||||
});
|
||||
(runs.runs || []).forEach(function (r) {
|
||||
if (r.status === 'running') {
|
||||
active.push({ kind: 'wf', run_id: r.run_id, percent: r.percent,
|
||||
started_at: r.started_at });
|
||||
}
|
||||
});
|
||||
|
||||
// 最近完成(仅在无进行中任务时展示,且只提示一次)
|
||||
var recent = null;
|
||||
var nowSec = Date.now() / 1000;
|
||||
(renders.jobs || []).forEach(function (j) {
|
||||
var k = 'rd:' + j.job_id;
|
||||
if (j.status === 'done' && !seenTerminal[k]) {
|
||||
seenTerminal[k] = true;
|
||||
if (primed && (nowSec - (j.finished_at || 0)) < 180) {
|
||||
recent = { label: '出片完成', detail: (j.segments || 0) + ' 段 / ' + (j.duration || 0) + ' 秒' };
|
||||
}
|
||||
}
|
||||
});
|
||||
(renders.jobs || []).forEach(function (j) {
|
||||
var k = 'rd:' + j.job_id;
|
||||
if (j.status === 'interrupted' && !seenTerminal[k]) {
|
||||
seenTerminal[k] = true;
|
||||
if (primed) recent = { label: '⚠️ 任务已中断(服务重启)', detail: '已生成的镜头保留,可到「📋 生产记录」点续跑' };
|
||||
}
|
||||
});
|
||||
(runs.runs || []).forEach(function (r) {
|
||||
var k = 'wf:' + r.run_id;
|
||||
if (['completed', 'failed'].indexOf(r.status) >= 0 && !seenTerminal[k]) {
|
||||
seenTerminal[k] = true;
|
||||
if (primed && !recent && (nowSec - (r.finished_at || 0)) < 180) {
|
||||
recent = { label: r.status === 'completed' ? '流程完成' : '流程失败', detail: String(r.run_id || '').slice(0, 12) };
|
||||
}
|
||||
}
|
||||
});
|
||||
primed = true;
|
||||
|
||||
render(active, recent);
|
||||
if (recent) {
|
||||
// 已完成的提示 8 秒后自动收起
|
||||
setTimeout(function () { if (box) box.style.display = 'none'; }, 8000);
|
||||
}
|
||||
|
||||
// 有活跃任务 → 4 秒刷新;空闲 → 15 秒
|
||||
clearTimeout(window.__taskMonitorTimer);
|
||||
window.__taskMonitorTimer = setTimeout(tick, active.length ? 4000 : 15000);
|
||||
}
|
||||
|
||||
// 暴露 tick 供调试
|
||||
window.__taskMonitorTick = tick;
|
||||
tick();
|
||||
};
|
||||
|
||||
window.initTaskMonitor();
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* 账号状态统一配置 — 单点维护,两处引用
|
||||
*
|
||||
* 使用方:
|
||||
* - views/accounts-center.js (账号中心)
|
||||
* - components/account-selector.js (统一账号选择器)
|
||||
*
|
||||
* 维护规则:
|
||||
* 新增/修改状态码时,只改此文件,两边自动同步。
|
||||
*/
|
||||
|
||||
// ── 状态显示配置 ──
|
||||
export const STATUS_CFG = {
|
||||
logged_in: { color: '#22c55e', dot: '🟢', label: '已登录' },
|
||||
need_login: { color: '#ef4444', dot: '🔴', label: '需重新登录' },
|
||||
no_cookie: { color: '#8b8fa3', dot: '⚪', label: '无Cookie' },
|
||||
no_identity: { color: '#8b8fa3', dot: '⚪', label: '未配置' },
|
||||
banned: { color: '#000', dot: '⚫', label: '已封号' },
|
||||
sms_skip: { color: '#f97316', dot: '📱', label: '短信待验证' },
|
||||
running: { color: '#3b82f6', dot: '🔵', label: '执行中' },
|
||||
disabled: { color: '#d1d5db', dot: '⬜', label: '已禁用' },
|
||||
unknown: { color: '#8b8fa3', dot: '⚪', label: '未知' },
|
||||
};
|
||||
|
||||
// ── 状态排序优先级 ──
|
||||
export const STATUS_ORDER = ['banned', 'need_login', 'sms_skip', 'no_cookie', 'running', 'disabled', 'unknown', 'logged_in'];
|
||||
@@ -0,0 +1,9 @@
|
||||
export function setupCounter(element) {
|
||||
let counter = 0
|
||||
const setCounter = (count) => {
|
||||
counter = count
|
||||
element.innerHTML = `Count is ${counter}`
|
||||
}
|
||||
element.addEventListener('click', () => setCounter(counter + 1))
|
||||
setCounter(0)
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
/**
|
||||
* event-handlers.js — 事件处理函数(从 inline.js 提取)
|
||||
*
|
||||
* 所有 window.* 函数在此定义并注册到 window,
|
||||
* 因为 HTML onclick 属性直接引用它们。
|
||||
* 需在 inline.js 之后加载。
|
||||
*/
|
||||
|
||||
// ── Blueprint helpers ──
|
||||
window.bpToggleList = function() {
|
||||
window._bpListOpen = !window._bpListOpen;
|
||||
const body = document.getElementById('bp-list-body');
|
||||
const arrow = document.getElementById('bp-list-arrow');
|
||||
if (body && arrow) {
|
||||
body.style.display = window._bpListOpen ? 'block' : 'none';
|
||||
arrow.textContent = window._bpListOpen ? '▼ 收起' : '▶ 展开';
|
||||
}
|
||||
};
|
||||
|
||||
window.bpAddStep = function(name) {
|
||||
const op = window._matrixOps?.[name];
|
||||
if (!op) return;
|
||||
window._bpSteps = window._bpSteps || [];
|
||||
window._bpSteps.push({name, label: op.label||name, requires: op.requires||[], allows: op.allows||[]});
|
||||
bpRenderSteps();
|
||||
};
|
||||
|
||||
window.bpRemoveStep = function(idx) {
|
||||
window._bpSteps = window._bpSteps || [];
|
||||
window._bpSteps.splice(idx, 1);
|
||||
bpRenderSteps();
|
||||
};
|
||||
|
||||
window.bpSave = async function() {
|
||||
const name = document.getElementById('bp-name')?.value;
|
||||
if (!name) { alert('请输入蓝图名称'); return; }
|
||||
const steps = (window._bpSteps||[]).map(s => ({name: s.name, description: (window._matrixOps?.[s.name]?.desc)||''}));
|
||||
if (!steps.length) { alert('请添加至少一个步骤'); return; }
|
||||
try {
|
||||
const r = await fetch('/api/matrix/blueprints', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name, description: document.getElementById('bp-desc')?.value||'', platform: document.getElementById('bp-platform')?.value||'douyin', steps})
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
bpClear();
|
||||
loadMatrixBlueprints();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
};
|
||||
|
||||
window.bpClear = function() {
|
||||
window._bpSteps = [];
|
||||
const ne = document.getElementById('bp-name');
|
||||
if (ne) ne.value = '';
|
||||
window._editingBp = null;
|
||||
bpRenderSteps();
|
||||
};
|
||||
|
||||
window.bpFilterOps = function(filter) {
|
||||
window._bpFilter = filter;
|
||||
// Update tab styles
|
||||
document.querySelectorAll('#bp-op-tabs span').forEach(t => {
|
||||
if (t.style.display === 'none') return;
|
||||
t.className = t.dataset.ft === filter ? 'badge badge-blue' : 'badge badge-gray';
|
||||
t.style.cssText = 'cursor:pointer;padding:3px 8px;font-size:10px';
|
||||
});
|
||||
bpRenderOps();
|
||||
};
|
||||
|
||||
window.showBpEditor = async function(name) {
|
||||
if (!name) { bpClear(); return; }
|
||||
try {
|
||||
const r = await fetch('/api/matrix/blueprints');
|
||||
const d = await r.json();
|
||||
const bp = Array.isArray(d) ? d.find(b => b.name === name) : (d.blueprints||[]).find(b => b.name === name);
|
||||
if (!bp) return;
|
||||
window._editingBp = name;
|
||||
document.getElementById('bp-name').value = name;
|
||||
document.getElementById('bp-desc').value = bp.description||'';
|
||||
document.getElementById('bp-platform').value = bp.platform||'douyin';
|
||||
window._bpSteps = (bp.steps||[]).map(s => {
|
||||
const stepName = s.op || s.name || s;
|
||||
const op = window._matrixOps?.[stepName];
|
||||
return {name: stepName, label: op?.label||stepName, requires: op?.requires||[], allows: op?.allows||[]};
|
||||
});
|
||||
bpRenderSteps();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
};
|
||||
|
||||
window.delBp = async function(name) {
|
||||
if (!confirm(`删除蓝图 ${name}?`)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/matrix/blueprints/${name}`, {method:'DELETE'});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
loadMatrixBlueprints();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
};
|
||||
|
||||
// ── Collect helpers ──
|
||||
window.collectLogin = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
if (!selected.length) { alert('请先选择要登录的账号'); return; }
|
||||
const logEl = document.getElementById('collectLog');
|
||||
if (logEl) logEl.textContent = '🔑 登录 ' + selected.length + ' 个账号...\n';
|
||||
for (const s of selected) {
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'login',accounts:[s.id]})});
|
||||
const d = await r.json();
|
||||
if (logEl) logEl.textContent += s.id + ': ' + (d.status || 'OK') + '\n';
|
||||
} catch(e) { if (logEl) logEl.textContent += s.id + ': ❌ ' + e.message + '\n'; }
|
||||
}
|
||||
};
|
||||
|
||||
window.collectExec = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
if (!selected.length) { alert('请先选择要采集的账号'); return; }
|
||||
const logEl = document.getElementById('collectLog');
|
||||
if (logEl) logEl.textContent = '📥 采集 ' + selected.length + ' 个账号...\n';
|
||||
for (const s of selected) {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/collect-homepage', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({account_id:s.id})});
|
||||
const d = await r.json();
|
||||
document.getElementById('collectStatus').innerHTML = '<span style="color:var(--green)">🟢 采集中</span>';
|
||||
if (logEl) logEl.textContent += s.id + ': ' + (d.status || 'OK') + ' 机器:' + (d.machine||s.machine) + '\n';
|
||||
} catch(e) { if (logEl) logEl.textContent += s.id + ': ❌ ' + e.message + '\n'; }
|
||||
}
|
||||
};
|
||||
|
||||
window.collectAll = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
if (!selected.length) { alert('没有可采集的账号'); return; }
|
||||
if (!confirm('采集全部 ' + selected.length + ' 个账号?')) return;
|
||||
await collectExec();
|
||||
};
|
||||
|
||||
window.collectByIdentity = async function(dir) {
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
log.textContent = '⏳ 开始采集身份: ' + dir + '\n';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/collect-homepage', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({identity_dir:dir})});
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
|
||||
window.collectByPhone = async function() {
|
||||
const phone = document.getElementById('collectPhoneInput')?.value;
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
if (!phone) { log.textContent = '请输入手机号'; return; }
|
||||
log.textContent = '⏳ 开始采集手机号: ' + phone + '\n';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/collect-homepage/phone', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({phone})});
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
|
||||
window.cancelCollect = async function() {
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
log.textContent = '⏳ 停止中...\n';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/collect-homepage/cancel', {method:'POST'});
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
|
||||
window.loadCollectHistory = async function() {
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
log.textContent = '⏳ 加载历史...\n';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/homepage-history');
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
|
||||
// ── Comment / Like helpers ──
|
||||
window.runComment = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
const urlsText = document.getElementById('commentUrls')?.value;
|
||||
const dir = document.getElementById('commentDirection')?.value;
|
||||
const corpus = document.getElementById('commentCorpus')?.value;
|
||||
const el = document.getElementById('commentResult');
|
||||
if (!urlsText || !urlsText.trim()) { el.textContent = '请填写至少一个视频链接'; return; }
|
||||
const urls = urlsText.split('\n').map(u => u.trim()).filter(u => u);
|
||||
if (!urls.length) { el.textContent = '请填写有效的视频链接'; return; }
|
||||
if (!selected.length) { el.textContent = '请先选择要执行的账号'; return; }
|
||||
el.textContent = '⏳ 使用 ' + selected.length + ' 个账号评论 ' + urls.length + ' 个视频...\n';
|
||||
let results = [];
|
||||
for (const url of urls) {
|
||||
for (const s of selected) {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/task/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'comment',url,direction:dir||null,account:s.id,corpus:corpus||null})});
|
||||
const d = await r.json();
|
||||
results.push({url, account:s.id, status: d.error ? '❌' : '✅', msg: d.error || (d.task_id || 'OK')});
|
||||
} catch(e) { results.push({url, account:s.id, status: '❌', msg: e.message}); }
|
||||
}
|
||||
}
|
||||
const totalOk = results.filter(r => r.status === '✅').length;
|
||||
el.textContent = '✅ ' + totalOk + '/' + results.length + ' 完成\n' +
|
||||
results.slice(0,30).map(r => r.status + ' ' + (r.account||'') + ' ' + (r.url||'').slice(0,35) + ' ' + (r.msg||'')).join('\n');
|
||||
};
|
||||
|
||||
window.runLike = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
const urlsText = document.getElementById('likeUrls')?.value;
|
||||
const el = document.getElementById('likeResult');
|
||||
if (!urlsText || !urlsText.trim()) { el.textContent = '请填写视频链接'; return; }
|
||||
const urls = urlsText.split('\n').map(u => u.trim()).filter(u => u);
|
||||
if (!urls.length) { el.textContent = '请填写有效的视频链接'; return; }
|
||||
if (!selected.length) { el.textContent = '请先选择要执行的账号'; return; }
|
||||
el.textContent = '⏳ 执行 ' + urls.length + ' 个点赞...\n';
|
||||
let results = [];
|
||||
for (const url of urls) {
|
||||
for (const s of selected) {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/task/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'like',url,account:s.id})});
|
||||
const d = await r.json();
|
||||
results.push({url, account:s.id, status: d.error ? '❌' : '✅', msg: d.error || (d.task_id || 'OK')});
|
||||
} catch(e) { results.push({url, account:s.id, status: '❌', msg: e.message}); }
|
||||
}
|
||||
}
|
||||
const ok = results.filter(r => r.status === '✅').length;
|
||||
el.textContent = '✅ ' + ok + '/' + results.length + '\n' +
|
||||
results.slice(0,30).map(r => r.status + ' ' + (r.account||'') + ' ' + (r.url||'').slice(0,35)).join('\n');
|
||||
};
|
||||
|
||||
// ── Fleet helpers ──
|
||||
window.doFleetSync = async function() {
|
||||
const el = document.getElementById('fleetSyncLog');
|
||||
el.textContent = '⏳ 执行中...\n';
|
||||
try {
|
||||
const r = await fetch('/api/fleet/sync', {method:'POST'});
|
||||
const d = await r.json();
|
||||
el.textContent = d.output || JSON.stringify(d, null, 2);
|
||||
} catch(e) { el.textContent = '❌ ' + e.message; }
|
||||
};
|
||||
|
||||
window.doFleetReconcile = async function() {
|
||||
const el = document.getElementById('fleetReconcileLog');
|
||||
el.textContent = '⏳ 执行中...\n';
|
||||
try {
|
||||
const r = await fetch('/api/fleet/reconcile', {method:'POST'});
|
||||
const d = await r.json();
|
||||
el.textContent = d.output || JSON.stringify(d, null, 2);
|
||||
} catch(e) { el.textContent = '❌ ' + e.message; }
|
||||
};
|
||||
|
||||
window.doFleetExec = async function() {
|
||||
const machine = document.getElementById('fleetExecMachine')?.value;
|
||||
const cmd = document.getElementById('fleetExecCmd')?.value;
|
||||
const el = document.getElementById('fleetExecLog');
|
||||
if (!machine || !cmd) { el.textContent = '请填写目标机器和命令'; return; }
|
||||
el.textContent = '⏳ 执行 ' + cmd + ' @' + machine + '...\n';
|
||||
try {
|
||||
const r = await fetch('/api/fleet/exec', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({machine, cmd})});
|
||||
const d = await r.json();
|
||||
el.textContent = d.output || JSON.stringify(d, null, 2);
|
||||
} catch(e) { el.textContent = '❌ ' + e.message; }
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,45 @@
|
||||
/**
|
||||
* AgentOS Dashboard — Vite 入口
|
||||
*
|
||||
* 职责:
|
||||
* - 样式热更新(CSS HMR)
|
||||
* - 加载全部功能模块(switchView 补丁已内联到 matrix_views.js)
|
||||
*/
|
||||
|
||||
import './style.css';
|
||||
|
||||
// ── 内联视图函数(回退代码,未迁移视图使用)──
|
||||
import './inline.js';
|
||||
|
||||
// ── 工具函数 ──
|
||||
import './utils.js';
|
||||
|
||||
// ── 全局任务指示器(出片/流程进度,任何页面右下角可见)──
|
||||
import './components/task-monitor.js';
|
||||
|
||||
// ── 导航模块 ──
|
||||
import './navigation.js';
|
||||
|
||||
// ── 事件处理函数 ──
|
||||
import './event-handlers.js';
|
||||
|
||||
// ── 功能模块 ──
|
||||
import './modules/matrix_views.js';
|
||||
import './modules/batch_exec.js';
|
||||
import './modules/registration.js';
|
||||
import './modules/account_selector.js';
|
||||
import './modules/corpus.js';
|
||||
import './modules/ops_router.js';
|
||||
import './modules/recording.js';
|
||||
import './modules/cmd_tasks.js';
|
||||
import './modules/collect.js';
|
||||
import './modules/nurture.js';
|
||||
import './modules/schedule.js';
|
||||
import './modules/settings.js';
|
||||
import './modules/workflow.js';
|
||||
import './modules/c2_remote.js';
|
||||
import './modules/machine_bar.js';
|
||||
import './modules/kb_management.js';
|
||||
|
||||
// ── 视图注册表(注册已迁移视图,供 switchView 路由用)──
|
||||
import './view-registry.js';
|
||||
@@ -0,0 +1,701 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// Registration Form
|
||||
// ════════════════════════════════════════════════════════
|
||||
// [旧登记注册函数已移除,注册功能合并到 SMS 页面]
|
||||
|
||||
async function loadMatrixAtomOps() {
|
||||
const el = document.getElementById('matrixAtomOpsContent');
|
||||
el.innerHTML = '<div class="loading">加载中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/atom-ops');
|
||||
const d = await r.json();
|
||||
const ops = d.ops || [];
|
||||
document.getElementById('matrixAtomOpCount').textContent = `共 ${ops.length} 个操作`;
|
||||
if (!ops.length) { el.innerHTML = '<div class="error">暂无操作</div>'; return; }
|
||||
const groups = {};
|
||||
ops.forEach(o => {
|
||||
const p = o.platform || '通用';
|
||||
if(!groups[p]) groups[p]=[];
|
||||
groups[p].push(o);
|
||||
});
|
||||
const platformLabels = {douyin:'🎵 抖音', xiaohongshu:'📕 小红书', '通用':'⚙️ 通用'};
|
||||
el.innerHTML = Object.entries(groups).map(([p, items]) => {
|
||||
const subGroups = {};
|
||||
items.forEach(o => {
|
||||
const g = o.category||'其他';
|
||||
if(!subGroups[g]) subGroups[g]=[];
|
||||
subGroups[g].push(o);
|
||||
});
|
||||
const catLabels = {navigation:'🏠 导航', browse:'📱 浏览', interact:'💡 交互', utility:'⏳ 工具'};
|
||||
return `<div style="margin-bottom:16px">
|
||||
<div style="font-size:14px;font-weight:700;margin-bottom:10px;padding:6px 10px;border-radius:6px;background:var(--bg3)">${platformLabels[p]||p} <span style="font-weight:400;font-size:11px;color:var(--text2)">(${items.length})</span></div>
|
||||
${Object.entries(subGroups).map(([g, sub]) => `
|
||||
<div style="font-size:12px;font-weight:600;margin:8px 0 6px;color:var(--text2)">${catLabels[g]||g} (${sub.length})</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(230px,1fr));gap:6px;margin-bottom:6px">${sub.map(o => {
|
||||
let reqHtml = '';
|
||||
if (o.requires && o.requires.length && o.requires[0] !== '*') {
|
||||
reqHtml = '<span style="display:inline-block;font-size:10px;padding:2px 6px;border-radius:4px;background:rgba(37,99,235,.1);color:var(--blue);margin-top:4px">← 需前序: '+o.requires.slice(0,3).join(',')+(o.requires.length>3?'…':'')+'</span>';
|
||||
} else if (o.can_be_first) {
|
||||
reqHtml = '<span style="display:inline-block;font-size:10px;padding:2px 6px;border-radius:4px;background:rgba(5,150,105,.1);color:var(--green);margin-top:4px">✅ 可为首步</span>';
|
||||
}
|
||||
let nextHtml = '';
|
||||
if (o.allows && o.allows.length && o.allows[0] !== '*') {
|
||||
nextHtml = '<span style="display:inline-block;font-size:10px;padding:2px 6px;border-radius:4px;background:rgba(107,114,128,.1);color:var(--text2);margin-top:2px">→ 后继: '+o.allows.slice(0,4).join(',')+(o.allows.length>4?'…':'')+'</span>';
|
||||
}
|
||||
const pt = o.platform==='通用'?'⚙️通用':(o.platform==='douyin'?'🎵抖音':'📕小红书');
|
||||
return '<div style="background:var(--bg3);border-radius:6px;padding:8px;border:1px solid var(--border)">'
|
||||
+'<div style="display:flex;align-items:center;gap:4px;margin-bottom:2px">'
|
||||
+'<span style="font-size:9px;font-weight:600;padding:1px 5px;border-radius:3px;background:rgba(99,102,241,.15);color:#818cf8">'+pt+'</span>'
|
||||
+'<span style="font-size:12px;font-weight:600">'+(o.label||o.name)+'</span></div>'
|
||||
+'<div style="font-size:10px;color:var(--text2);margin-top:1px">'+(o.desc||o.doc||'')+'</div>'
|
||||
+'<div style="margin-top:3px;display:flex;flex-wrap:wrap;gap:2px">'+reqHtml+nextHtml+'</div></div>';
|
||||
}).join('')}</div>`
|
||||
).join('')}</div>`;
|
||||
}).join('');
|
||||
} catch(e) { el.innerHTML = `<div class="error">${e.message}</div>`; }
|
||||
}
|
||||
|
||||
async function loadMatrixBackup() {
|
||||
const el = document.getElementById('matrixBackupContent');
|
||||
el.innerHTML = '<div class="loading">加载中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/backups');
|
||||
const d = await r.json();
|
||||
const baks = d.backups || [];
|
||||
// 按身份分组展示最新
|
||||
const groups = {};
|
||||
baks.forEach(b => { if(!groups[b.identity]) groups[b.identity]=[]; groups[b.identity].push(b); });
|
||||
const html = Object.entries(groups).slice(0,6).map(([ident, items]) => {
|
||||
const latest = items[0];
|
||||
return `<div style="background:var(--bg3);border-radius:6px;padding:10px;border:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">
|
||||
<div><strong style="font-size:13px">${ident}</strong><br><span class="text-xs text-muted">${items.length} 备份 · 最新: ${latest.time_str} (${latest.size_kb}KB)</span></div>
|
||||
<div><button class="btn btn-sm btn-outline" onclick="restoreBackup('${ident}','${latest.path}')">恢复</button></div>
|
||||
</div>`;
|
||||
}).join('') || '<div class="text-muted text-sm">暂无备份</div>';
|
||||
el.innerHTML = `<div style="display:grid;gap:8px">${html}</div>
|
||||
<div style="margin-top:12px"><div style="display:flex;gap:8px;align-items:center">
|
||||
<input id="backupLabelInline" placeholder="备份标签" style="width:200px" value="inline">
|
||||
<button class="btn btn-primary btn-sm" onclick="createBackup()">创建备份</button>
|
||||
</div></div>
|
||||
<div id="backupResultInline" class="mt-4 text-sm"></div>`;
|
||||
} catch(e) { el.innerHTML = `<div class="error">${e.message}</div>`; }
|
||||
}
|
||||
|
||||
async function loadMatrixExport() {
|
||||
const el = document.getElementById('matrixExportContent');
|
||||
el.innerHTML = `<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
|
||||
<div style="background:var(--bg2);border-radius:var(--radius);padding:20px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;margin-bottom:8px">📤 导出账号</div>
|
||||
<p class="text-sm text-muted mb-2">导出所有账号配置 + Cookie + 指纹为 ZIP</p>
|
||||
<button class="btn btn-primary" onclick="doExport()">导出 ZIP</button>
|
||||
<div id="exportResultInline" class="mt-4 text-sm"></div>
|
||||
</div>
|
||||
<div style="background:var(--bg2);border-radius:var(--radius);padding:20px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;margin-bottom:8px">📥 导入账号</div>
|
||||
<p class="text-sm text-muted mb-2">从 ZIP 文件恢复</p>
|
||||
<div class="flex" style="align-items:center">
|
||||
<input id="importPathInline" placeholder="/tmp/matrix_export.zip" style="flex:1">
|
||||
<button class="btn btn-primary" onclick="doImport()">导入</button>
|
||||
</div>
|
||||
<div id="importResultInline" class="mt-4 text-sm"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Helper functions for inline sub-views
|
||||
async function createBackup() {
|
||||
const label = (document.getElementById('backupLabelHeader')?.value || document.getElementById('backupLabelInline')?.value || 'manual') + '_' + Date.now();
|
||||
// Try header result area first, fallback to inline
|
||||
let el = document.getElementById('backupResultHeader');
|
||||
if (!el) el = document.getElementById('backupResultInline');
|
||||
if (el) el.innerHTML = '<span style="color:var(--text2)">⏳ 备份中...</span>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/backup', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({label})});
|
||||
const res = await r.json();
|
||||
if (el) el.innerHTML = `<span style="color:var(--green)">✅ ${res.backup_count||0} 个身份已备份 (${label})</span>`;
|
||||
loadMatrixBackup();
|
||||
} catch(e) { if (el) el.innerHTML = `<span style="color:var(--red)">${e.message}</span>`; }
|
||||
}
|
||||
|
||||
async function restoreBackup(identity, path) {
|
||||
if (!confirm(`从备份恢复 ${identity} 的 Cookie?`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/matrix/restore', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({identity, path})});
|
||||
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
|
||||
alert('✅ 恢复成功');
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
}
|
||||
|
||||
async function doExport() {
|
||||
const el = document.getElementById('exportResultInline');
|
||||
if (el) el.innerHTML = '<div class="loading" style="padding:6px">导出中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/export');
|
||||
const res = await r.json();
|
||||
if (el) el.innerHTML = `<div style="color:var(--green);font-size:13px">✅ ${res.path} (${res.size_kb}KB)</div>`;
|
||||
} catch(e) { if (el) el.innerHTML = `<div class="error">${e.message}</div>`; }
|
||||
}
|
||||
|
||||
async function doImport() {
|
||||
const path = document.getElementById('importPathInline')?.value;
|
||||
if (!path) { alert('请输入ZIP路径'); return; }
|
||||
const el = document.getElementById('importResultInline');
|
||||
if (el) el.innerHTML = '<div class="loading" style="padding:6px">导入中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/import', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({path})});
|
||||
if (!r.ok) { const e=await r.json(); throw new Error(e.detail); }
|
||||
const res = await r.json();
|
||||
if (el) el.innerHTML = `<div style="color:var(--green);font-size:13px">✅ 配置:${res.accounts_imported} 身份:${res.identities_imported}</div>`;
|
||||
} catch(e) { if (el) el.innerHTML = `<div class="error">${e.message}</div>`; }
|
||||
}
|
||||
|
||||
// ═══ 计划对齐:Shell占位函数 + 重定向 ═══
|
||||
|
||||
// 通用占位Shell — 创建一个带状态标签的占位页面
|
||||
function _renderShell(containerId, title, status, note) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
const icons = {'无功能':'❌','无API':'🔌','无CLI':'🖥️','待集成':'⏳'};
|
||||
const icon = icons[status] || '📋';
|
||||
el.innerHTML = `
|
||||
<div style="display:flex;flex-direction:column;align-items:center;justify-content:center;padding:60px 20px;text-align:center">
|
||||
<div style="font-size:48px;margin-bottom:16px;opacity:.3">${icon}</div>
|
||||
<h2 style="font-size:20px;margin-bottom:8px;color:var(--text)">${title}</h2>
|
||||
<div style="display:inline-flex;align-items:center;gap:6px;padding:4px 12px;border-radius:6px;background:rgba(217,119,6,.1);color:#d97706;font-size:12px;font-weight:600;margin-bottom:12px">
|
||||
🏗️ ${status}
|
||||
</div>
|
||||
<p style="font-size:13px;color:var(--text2);max-width:400px;line-height:1.6">${note}</p>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── 加载策略条 ──
|
||||
async function _loadPolicyBar(containerId) {
|
||||
try {
|
||||
const r = await fetch('/api/ops/policy');
|
||||
const p = await r.json();
|
||||
const s = p.current_status || {};
|
||||
const browsers = s.browsers ?? '?';
|
||||
const maxC = p.max_concurrent ?? 3;
|
||||
const stagger = p.launch_stagger ?? 15;
|
||||
const disk = s.disk_gb ?? '?';
|
||||
const slots = p.slots?.length ?? 3;
|
||||
const ok = s.ok !== false;
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
el.innerHTML = `
|
||||
<span style="display:inline-flex;align-items:center;gap:3px">🖥️ <b>${browsers}/${maxC}</b> 浏览器</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:3px">⏱️ <b>${stagger}s</b> 启动间隔</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:3px">🪟 <b>${slots}</b> 窗口槽位</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:3px">💾 <b>${disk}GB</b> 磁盘</span>
|
||||
<span style="display:inline-flex;align-items:center;gap:3px;margin-left:auto;color:${ok ? 'var(--green)' : 'var(--red)'}">${ok ? '✅ 预检通过' : '❌ ' + (s.message||'阻塞')}</span>`;
|
||||
} catch(e) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (el) el.innerHTML = '<span style="color:var(--text2)">⚠️ 策略加载失败</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// 矩阵系列 — 养号执行(直接内联执行面板)
|
||||
async function loadMatrixNurture() {
|
||||
const el = document.getElementById('view-matrix-nurture');
|
||||
el.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div id="nurturePolicyBar" style="background:var(--bg3);border-radius:6px;padding:4px 8px;margin-bottom:6px;font-size:9px;color:var(--text2);display:flex;gap:12px;align-items:center">
|
||||
<span>⏳ 加载策略...</span>
|
||||
</div>
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:8px">🌱 养号执行 <span style="font-size:10px;color:var(--text2);font-weight:400">预检 → 窗口定位 → 执行 → 验证</span></div>
|
||||
<div id="nurtureAcctList" style="margin-bottom:6px">
|
||||
<div class="loading" style="padding:8px;font-size:12px">⏳ 加载账号列表...</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<label style="font-size:11px;color:var(--text2)">轮数:
|
||||
<select id="ndRounds" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:3px 6px;border-radius:4px;font-size:11px">
|
||||
<option value="1">1 测试</option><option value="5">5</option>
|
||||
<option value="10" selected>10 默认</option>
|
||||
<option value="20">20</option><option value="30">30</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="font-size:11px;color:var(--text2)">蓝图:
|
||||
<select id="ndBlueprint" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:3px 6px;border-radius:4px;font-size:11px">
|
||||
<option value="">自动匹配</option>
|
||||
<option value="douyin_daily">🎵 douyin_daily</option>
|
||||
<option value="xhs_daily">📕 xhs_daily</option>
|
||||
<option value="douyin_active_v1">🎵 douyin_active_v1</option>
|
||||
<option value="xhs_active_v1">📕 xhs_active_v1</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onclick="nurturePreflight()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px">🔍 预检</button>
|
||||
<button onclick="nurtureExec()" style="background:#22c55e;color:#000;border:none;padding:4px 14px;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600">🚀 执行选中</button>
|
||||
<button onclick="nurtureExecAll()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px">📋 全部启用</button>
|
||||
<span id="nurtureSelCount" style="font-size:10px;color:var(--text2)">已选 0 个</span>
|
||||
</div>
|
||||
<div id="nurtureStatus" style="font-size:11px;color:var(--text2);margin-top:4px"></div>
|
||||
<div id="nurturePreflightInfo" style="font-size:10px;color:var(--text2);margin-top:2px;font-family:monospace;white-space:pre-wrap"></div>
|
||||
<div id="nurtureLog" style="font-size:10px;background:var(--bg2);border-radius:6px;padding:6px;margin-top:4px;max-height:300px;overflow-y:auto;font-family:monospace;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
// 加载策略条 + 账号列表
|
||||
_loadPolicyBar('nurturePolicyBar');
|
||||
const data = await _loadAccounts();
|
||||
_renderAccountSelector('nurtureAcctList', {_data: data, height: '350px'});
|
||||
document.getElementById('nurtureSelCount').textContent = '已选 ' + _getSelectedAccounts().length + ' 个';
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadMatrixNurture = loadMatrixNurture;
|
||||
|
||||
|
||||
// 矩阵系列 — 信息采集(按机器分组 + 账号选择)
|
||||
// 矩阵系列 — 登录与信息采集(合并)
|
||||
async function loadMatrixCollect() {
|
||||
const el = document.getElementById('view-matrix-collect');
|
||||
el.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div id="collectPolicyBar" style="background:var(--bg3);border-radius:6px;padding:4px 8px;margin-bottom:6px;font-size:9px;color:var(--text2);display:flex;gap:12px;align-items:center">
|
||||
<span>⏳ 加载策略...</span>
|
||||
</div>
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px">📡 登录与信息采集 <span style="font-size:10px;color:var(--text2);font-weight:400">登录+采集一体化,选择账号后可仅登录或登录后采集</span></div>
|
||||
<div id="collectAccountList" style="margin-bottom:6px">
|
||||
<div class="loading" style="padding:8px;font-size:12px">⏳ 加载账号列表...</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||
<span id="collectStatus" style="font-size:10px;color:var(--text2)">⏸️ 空闲</span>
|
||||
<button onclick="collectLogin()" style="background:#6366f1;color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600">🔑 登录选中</button>
|
||||
<button onclick="collectExec()" style="background:var(--primary);color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600">📥 采集选中</button>
|
||||
<button onclick="collectAll()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px">📋 采集全部</button>
|
||||
<button onclick="cancelCollect()" style="background:rgba(220,38,38,.1);color:var(--red);border:1px solid rgba(220,38,38,.3);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px">⏹ 停止</button>
|
||||
<span id="collectSelCount" style="font-size:10px;color:var(--text2)">已选 0 个</span>
|
||||
</div>
|
||||
<div id="collectLog" style="font-size:10px;background:var(--bg2);border-radius:6px;padding:6px;margin-top:4px;max-height:250px;overflow-y:auto;font-family:monospace;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
_loadPolicyBar('collectPolicyBar');
|
||||
const data = await _loadAccounts();
|
||||
_renderAccountSelector('collectAccountList', {_data: data, height: '350px'});
|
||||
document.getElementById('collectSelCount').textContent = '已选 ' + _getSelectedAccounts().length + ' 个';
|
||||
// 加载采集状态
|
||||
try {
|
||||
const sr = await fetch('/api/ops/status');
|
||||
const sd = await sr.json();
|
||||
if (sd.running) document.getElementById('collectStatus').innerHTML = '<span style="color:var(--green)">🟢 采集中</span>';
|
||||
} catch(e) {}
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadMatrixCollect = loadMatrixCollect;
|
||||
|
||||
|
||||
// 采集:登录选中账号
|
||||
window.collectLogin = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
if (!selected.length) { alert('请先选择要登录的账号'); return; }
|
||||
const logEl = document.getElementById('collectLog');
|
||||
if (logEl) logEl.textContent = '🔑 登录 ' + selected.length + ' 个账号...\n';
|
||||
for (const s of selected) {
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'login',accounts:[s.id]})});
|
||||
const d = await r.json();
|
||||
if (logEl) logEl.textContent += s.id + ': ' + (d.status || 'OK') + '\n';
|
||||
} catch(e) { if (logEl) logEl.textContent += s.id + ': ❌ ' + e.message + '\n'; }
|
||||
}
|
||||
// 刷新账号列表
|
||||
if (typeof window._clearAccountCache === 'function') window._clearAccountCache();
|
||||
setTimeout(async function(){
|
||||
const data = await window._loadAccounts();
|
||||
_renderAccountSelector('collectAccountList', {_data: data, height: '350px'});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 采集:采集选中账号
|
||||
window.collectExec = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
if (!selected.length) { alert('请先选择要采集的账号'); return; }
|
||||
const logEl = document.getElementById('collectLog');
|
||||
if (logEl) logEl.textContent = '📥 采集 ' + selected.length + ' 个账号...\n';
|
||||
for (const s of selected) {
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'collect',accounts:[s.id],params:{rounds:1}})});
|
||||
const d = await r.json();
|
||||
document.getElementById('collectStatus').innerHTML = '<span style="color:var(--green)">🟢 采集中</span>';
|
||||
if (logEl) logEl.textContent += s.id + ': ' + (d.status || 'OK') + ' 机器:' + (d.machine||s.machine) + '\n';
|
||||
} catch(e) { if (logEl) logEl.textContent += s.id + ': ❌ ' + e.message + '\n'; }
|
||||
}
|
||||
// 刷新账号列表(清除缓存后重新加载)
|
||||
if (typeof window._clearAccountCache === 'function') window._clearAccountCache();
|
||||
setTimeout(async function(){
|
||||
const data = await window._loadAccounts();
|
||||
_renderAccountSelector('collectAccountList', {_data: data, height: '350px'});
|
||||
}, 1000);
|
||||
};
|
||||
|
||||
// 采集全部
|
||||
window.collectAll = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
if (!selected.length) { alert('没有可采集的账号'); return; }
|
||||
if (!confirm('采集全部 ' + selected.length + ' 个账号?')) return;
|
||||
await collectExec();
|
||||
};
|
||||
|
||||
// 采集折叠组
|
||||
function toggleCollectGroup(id) {
|
||||
const el = document.getElementById(id);
|
||||
const arrow = document.getElementById(id + '_arrow');
|
||||
if (!el) return;
|
||||
const hidden = el.style.display === 'none';
|
||||
el.style.display = hidden ? '' : 'none';
|
||||
if (arrow) arrow.textContent = hidden ? '▼' : '▶';
|
||||
}
|
||||
|
||||
function toggleCollectMachine(master, machine) {
|
||||
document.querySelectorAll('.collect-cb[data-machine="' + machine + '"]').forEach(cb => cb.checked = master.checked);
|
||||
}
|
||||
|
||||
// 采集选中账号(批量提交,后端路由到各机器)
|
||||
async function collectSelected() {
|
||||
const cbs = document.querySelectorAll('.collect-cb:checked');
|
||||
const ids = Array.from(cbs).map(cb => cb.value);
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!ids.length) { if (log) log.textContent = '请先选择要采集的账号'; return; }
|
||||
if (log) log.textContent = '⏳ 提交 ' + ids.length + ' 个采集任务(自动路由到各机器)...\n';
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'collect',accounts:ids,params:{rounds:1}})});
|
||||
const d = await r.json();
|
||||
const results = d.results || [];
|
||||
if (log) {
|
||||
let txt = '📋 采集分发结果:\n';
|
||||
results.forEach(r2 => {
|
||||
const icon = r2.status === 'started' ? '✅' : r2.status === 'dispatched' ? '📡' : '❌';
|
||||
const machine = r2.machine === 'chengzigedeAir' ? '🖥️' : '☁️' + r2.machine;
|
||||
txt += ' ' + icon + ' ' + machine + ' ' + (r2.target || '') + ': ' + r2.status;
|
||||
if (r2.pid) txt += ' (PID:' + r2.pid + ')';
|
||||
txt += '\n';
|
||||
});
|
||||
log.textContent = txt;
|
||||
}
|
||||
} catch(e) { if (log) log.textContent += '❌ ' + e.message + '\n'; }
|
||||
}
|
||||
|
||||
// 采集全部
|
||||
async function collectAll() {
|
||||
const cbs = document.querySelectorAll('.collect-cb');
|
||||
const ids = Array.from(cbs).map(cb => cb.value);
|
||||
if (!ids.length) {
|
||||
const log = document.getElementById('collectLog');
|
||||
if (log) log.textContent = '没有可采集的账号';
|
||||
return;
|
||||
}
|
||||
if (!confirm('采集全部 ' + ids.length + ' 个账号(自动路由到各机器)?')) return;
|
||||
await collectSelected();
|
||||
}
|
||||
|
||||
// 保留旧函数兼容
|
||||
window.collectByIdentity = async function(dir) {
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
log.textContent = '⏳ 开始采集身份: ' + dir + '\n';
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'collect',params:{identity_dir:dir,rounds:1}})});
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
window.collectByIdentity = async function(dir) {
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
log.textContent = '⏳ 开始采集身份: ' + dir + '\n';
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'collect',params:{identity_dir:dir,rounds:1}})});
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
window.collectByPhone = async function() {
|
||||
const phone = document.getElementById('collectPhoneInput')?.value;
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
if (!phone) { log.textContent = '请输入手机号'; return; }
|
||||
log.textContent = '⏳ 开始采集手机号: ' + phone + '\n';
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'collect',params:{phone,rounds:1}})});
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
window.cancelCollect = async function() {
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
log.textContent = '⏳ 停止中...\n';
|
||||
try {
|
||||
const r = await fetch('/api/ops/cancel', {method:'POST'});
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
window.loadCollectHistory = async function() {
|
||||
const log = document.getElementById('collectLog');
|
||||
if (!log) return;
|
||||
log.textContent = '⏳ 加载历史...\n';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/homepage-history');
|
||||
const d = await r.json();
|
||||
log.textContent += JSON.stringify(d, null, 2);
|
||||
} catch(e) { log.textContent += '❌ ' + e.message; }
|
||||
};
|
||||
|
||||
// 矩阵系列 — 内容发布(Shell — 无CLI)
|
||||
async function loadMatrixPublish() {
|
||||
_renderShell('view-matrix-publish', '📤 内容发布', '无CLI',
|
||||
'发布模块的 CLI 封装尚未完成。现有的 publish_video.py 在 agent-os/agent-sync/05_tools/07_matrix/scripts/ 目录下。需要 agentos matrix publish CLI 封装。');
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadMatrixPublish = loadMatrixPublish;
|
||||
|
||||
|
||||
// 矩阵系列 — 定向评论(共享选择器)
|
||||
async function loadMatrixComment() {
|
||||
const el = document.getElementById('view-matrix-comment');
|
||||
el.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px">💬 定向评论 <span style="font-size:10px;color:var(--text2);font-weight:400">选择账号 → 填入视频链接 → 方向/语料 → 执行</span></div>
|
||||
<div id="commentAccountList" style="margin-bottom:6px">
|
||||
<div class="loading" style="padding:8px;font-size:12px">⏳ 加载账号列表...</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start">
|
||||
<textarea id="commentUrls" placeholder="视频链接(每行一个,支持多个)" rows="3" style="flex:2;min-width:200px;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:5px;font-size:12px;resize:vertical"></textarea>
|
||||
<div style="display:flex;flex-direction:column;gap:4px;flex:1;min-width:140px">
|
||||
<select id="commentDirection" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:5px 8px;border-radius:4px;font-size:11px">
|
||||
<option value="">自动选方向</option>
|
||||
<option value="称赞">👍 称赞</option>
|
||||
<option value="提问">🤔 提问</option>
|
||||
<option value="共鸣">💗 共鸣</option>
|
||||
<option value="感慨">😌 感慨</option>
|
||||
<option value="客观">📊 客观</option>
|
||||
</select>
|
||||
<select id="commentCorpus" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:5px 8px;border-radius:4px;font-size:11px">
|
||||
<option value="">默认语料</option>
|
||||
<option value="通用">📚 通用</option>
|
||||
<option value="科技">💻 科技</option>
|
||||
<option value="生活">🏠 生活</option>
|
||||
<option value="美食">🍔 美食</option>
|
||||
<option value="娱乐">🎮 娱乐</option>
|
||||
</select>
|
||||
<button onclick="runComment()" style="background:var(--primary);color:#fff;border:none;padding:5px 12px;border-radius:4px;cursor:pointer;font-size:12px">🚀 执行评论</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="commentResult" style="font-size:10px;color:var(--text2);margin-top:4px;font-family:monospace;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const data = await _loadAccounts();
|
||||
_renderAccountSelector('commentAccountList', {_data: data, height: '350px'});
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadMatrixComment = loadMatrixComment;
|
||||
|
||||
window.runComment = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
const urlsText = document.getElementById('commentUrls')?.value;
|
||||
const dir = document.getElementById('commentDirection')?.value;
|
||||
const corpus = document.getElementById('commentCorpus')?.value;
|
||||
const el = document.getElementById('commentResult');
|
||||
if (!urlsText || !urlsText.trim()) { el.textContent = '请填写至少一个视频链接'; return; }
|
||||
const urls = urlsText.split('\n').map(u => u.trim()).filter(u => u);
|
||||
if (!urls.length) { el.textContent = '请填写有效的视频链接'; return; }
|
||||
if (!selected.length) { el.textContent = '请先选择要执行的账号'; return; }
|
||||
el.textContent = '⏳ 使用 ' + selected.length + ' 个账号评论 ' + urls.length + ' 个视频...\n';
|
||||
let results = [];
|
||||
for (const url of urls) {
|
||||
for (const s of selected) {
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'comment',accounts:[s.id],params:{url,direction:dir||null,rounds:1,corpus:corpus||null}})});
|
||||
const d = await r.json();
|
||||
results.push({url, account:s.id, status: d.error ? '❌' : '✅', msg: d.error || (d.task_id || 'OK')});
|
||||
} catch(e) { results.push({url, account:s.id, status: '❌', msg: e.message}); }
|
||||
}
|
||||
}
|
||||
const totalOk = results.filter(r => r.status === '✅').length;
|
||||
el.textContent = '✅ ' + totalOk + '/' + results.length + ' 完成\n' +
|
||||
results.slice(0,30).map(r => r.status + ' ' + (r.account||'') + ' ' + (r.url||'').slice(0,35) + ' ' + (r.msg||'')).join('\n');
|
||||
};
|
||||
|
||||
// 矩阵系列 — 定时任务(Shell — 待集成)
|
||||
// 矩阵系列 — 定时任务(有 API → 集成)
|
||||
async function loadMatrixSchedule() {
|
||||
const el = document.getElementById('view-matrix-schedule');
|
||||
el.innerHTML = '<div id="schedPanel" style="padding:20px"><div class="loading">⏳ 加载定时任务...</div></div>';
|
||||
try {
|
||||
const ri = await fetch('/api/matrix/schedules');
|
||||
const di = await ri.json();
|
||||
const tasks = di.tasks || di || [];
|
||||
document.getElementById('schedPanel').innerHTML = `
|
||||
<h2 style="font-size:18px;margin-bottom:12px">⏰ 定时任务</h2>
|
||||
<div style="background:var(--bg2);border-radius:var(--radius);padding:12px;border:1px solid var(--border);margin-bottom:12px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">📋 任务列表 (${Array.isArray(tasks)?tasks.length:0})</div>
|
||||
<div style="font-size:12px">${Array.isArray(tasks) && tasks.length ? tasks.map(t =>
|
||||
'<div style="display:flex;align-items:center;gap:8px;padding:6px 0;border-bottom:1px solid var(--border)">'+
|
||||
'<span style="font-size:10px">'+(t.enabled?'🟢':'⚪')+'</span>'+
|
||||
'<span>'+(t.account||t.id||'-')+'</span>'+
|
||||
'<span style="font-size:11px;color:var(--text2)">'+(t.blueprint||'')+'</span>'+
|
||||
'<span style="font-size:11px;color:var(--text2)">'+(t.time||'')+'</span>'+
|
||||
'</div>'
|
||||
).join('') : '<span style="color:var(--text2)">暂无定时任务</span>'}</div>
|
||||
</div>
|
||||
<div style="background:var(--bg2);border-radius:var(--radius);padding:12px;border:1px solid var(--border);margin-bottom:8px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">➕ 新建定时任务</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||
<button onclick="switchView('matrix-commands')" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">→ 前往命令与任务页</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text2)">完整调度管理在「矩阵 → 命令与任务」页面</div>`;
|
||||
} catch(e) {
|
||||
document.getElementById('schedPanel').innerHTML = '<div style="padding:20px"><h2 style="font-size:18px;margin-bottom:12px">⏰ 定时任务</h2><div style="background:var(--bg2);border-radius:var(--radius);padding:12px;border:1px solid var(--border)"><div class="error">❌ '+e.message+'</div><p style="font-size:12px;color:var(--text2);margin-top:8px">完整调度管理在「矩阵 → 命令与任务」页面</p></div></div>';
|
||||
}
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadMatrixSchedule = loadMatrixSchedule;
|
||||
|
||||
|
||||
// 矩阵系列 — 收藏点赞(共享选择器)
|
||||
async function loadMatrixLike() {
|
||||
const el = document.getElementById('view-matrix-like');
|
||||
el.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px">❤️ 收藏点赞 <span style="font-size:10px;color:var(--text2);font-weight:400">选择账号 → 填入视频链接 → 执行点赞</span></div>
|
||||
<div id="likeAccountList" style="margin-bottom:6px">
|
||||
<div class="loading" style="padding:8px;font-size:12px">⏳ 加载账号列表...</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start">
|
||||
<textarea id="likeUrls" placeholder="视频链接(每行一个,支持多个)" rows="3" style="flex:2;min-width:200px;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:5px;font-size:12px;resize:vertical"></textarea>
|
||||
<button onclick="runLike()" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:4px;cursor:pointer;font-size:12px;align-self:flex-start">❤️ 执行点赞</button>
|
||||
</div>
|
||||
<div id="likeResult" style="font-size:10px;color:var(--text2);margin-top:4px;font-family:monospace;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
const data = await _loadAccounts();
|
||||
_renderAccountSelector('likeAccountList', {_data: data, height: '350px'});
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadMatrixLike = loadMatrixLike;
|
||||
|
||||
|
||||
window.runLike = async function() {
|
||||
const selected = _getSelectedAccounts();
|
||||
const urlsText = document.getElementById('likeUrls')?.value;
|
||||
const el = document.getElementById('likeResult');
|
||||
if (!urlsText || !urlsText.trim()) { el.textContent = '请填写视频链接'; return; }
|
||||
const urls = urlsText.split('\n').map(u => u.trim()).filter(u => u);
|
||||
if (!urls.length) { el.textContent = '请填写有效的视频链接'; return; }
|
||||
if (!selected.length) { el.textContent = '请先选择要执行的账号'; return; }
|
||||
el.textContent = '⏳ 执行 ' + urls.length + ' 个点赞...\n';
|
||||
let results = [];
|
||||
for (const url of urls) {
|
||||
for (const s of selected) {
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'like',accounts:[s.id],params:{url,direction:'forward',rounds:1}})});
|
||||
const d = await r.json();
|
||||
results.push({url, account:s.id, status: d.error ? '❌' : '✅', msg: d.error || (d.task_id || 'OK')});
|
||||
} catch(e) { results.push({url, account:s.id, status: '❌', msg: e.message}); }
|
||||
}
|
||||
}
|
||||
const ok = results.filter(r => r.status === '✅').length;
|
||||
el.textContent = '✅ ' + ok + '/' + results.length + '\n' +
|
||||
results.slice(0,30).map(r => r.status + ' ' + (r.account||'') + ' ' + (r.url||'').slice(0,35)).join('\n');
|
||||
};
|
||||
|
||||
// 矩阵系列 — 登录管理 → 已合并到信息采集
|
||||
async function loadMatrixLogin() {
|
||||
const el = document.getElementById('view-matrix-login');
|
||||
el.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:16px;border:1px solid var(--border);text-align:center">
|
||||
<div style="font-size:24px;margin-bottom:8px">🔑</div>
|
||||
<div style="font-size:14px;font-weight:600;margin-bottom:4px">登录管理已合并到信息采集</div>
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:12px">登录 → 采集是同一流程,请前往「信息采集」页面操作</div>
|
||||
<button onclick="switchView('matrix-collect')" style="background:var(--primary);color:#fff;border:none;padding:8px 24px;border-radius:6px;cursor:pointer;font-size:13px">→ 前往信息采集</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadMatrixLogin = loadMatrixLogin;
|
||||
|
||||
|
||||
// 联邦指挥台(共享选择器 + 操作类型选择)
|
||||
async function loadOpsCommand() {
|
||||
const el = document.getElementById('view-ops-command');
|
||||
el.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border);margin-bottom:8px">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px">🖥️ 联邦指挥台 <span style="font-size:10px;color:var(--text2);font-weight:400">选机器 → 选操作 → 选账号 → 批量执行</span></div>
|
||||
<div id="opsAccountList" style="margin-bottom:6px">
|
||||
<div class="loading" style="padding:8px;font-size:12px">⏳ 加载账号列表...</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<label style="font-size:11px;color:var(--text2)">操作:
|
||||
<select id="opsType" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 6px;border-radius:4px;font-size:11px">
|
||||
<option value="nurture">🏃 养号</option>
|
||||
<option value="collect">📡 采集</option>
|
||||
<option value="login">🔑 登录</option>
|
||||
<option value="logout">🔒 登出</option>
|
||||
</select>
|
||||
</label>
|
||||
<label style="font-size:11px;color:var(--text2)" id="opsRoundsLabel">轮数:
|
||||
<select id="opsRounds" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 6px;border-radius:4px;font-size:11px">
|
||||
<option value="1">1</option><option value="5">5</option>
|
||||
<option value="10" selected>10</option><option value="20">20</option>
|
||||
</select>
|
||||
</label>
|
||||
<button onclick="opsBatchExec()" style="background:#22c55e;color:#000;border:none;padding:5px 16px;border-radius:4px;cursor:pointer;font-size:12px;font-weight:600">🚀 批量执行</button>
|
||||
<span id="opsSelCount" style="font-size:10px;color:var(--text2)">已选 0 个</span>
|
||||
</div>
|
||||
<div id="opsResult" style="font-size:10px;color:var(--text2);margin-top:4px;font-family:monospace;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
<div id="opsHistoryBox" style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border)">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:4px">📋 执行历史</div>
|
||||
<div id="opsHistoryList" style="font-size:10px;max-height:180px;overflow-y:auto">
|
||||
<div class="loading">⏳ 加载执行历史...</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
const data = await _loadAccounts();
|
||||
_renderAccountSelector('opsAccountList', {_data: data, height: '350px'});
|
||||
document.getElementById('opsSelCount').textContent = '已选 ' + _getSelectedAccounts().length + ' 个';
|
||||
loadOpsHistory();
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadOpsCommand = loadOpsCommand;
|
||||
|
||||
|
||||
async function loadOpsHistory() {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/nurture/results');
|
||||
const d = await r.json();
|
||||
const items = d.results || [];
|
||||
const el = document.getElementById('opsHistoryList');
|
||||
if (!items.length) { el.innerHTML = '<div style="color:var(--text2)">暂无执行记录</div>'; return; }
|
||||
let html = '<table style="width:100%"><tr><th>时间</th><th>账号</th><th>状态</th><th>步骤</th><th>耗时</th></tr>';
|
||||
items.slice(0, 20).forEach(r => {
|
||||
const st = r.status === 'completed' ? '✅' : r.status === 'running' ? '⏳' : '❌';
|
||||
const ts = r.completed_at || r.started_at || '';
|
||||
const time = ts ? new Date(ts).toLocaleString('zh-CN',{hour:'2-digit',minute:'2-digit'}) : '-';
|
||||
const steps = r.steps ? (r.steps.success||0)+'/'+(r.steps.total||0) : '-';
|
||||
const dur = r.duration_secs ? Math.round(r.duration_secs/60)+'min' : '-';
|
||||
html += `<tr><td>${time}</td><td>${r.account}</td><td>${st}</td><td>${steps}</td><td>${dur}</td></tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
el.innerHTML = html;
|
||||
} catch(e) {}
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.collectAll = collectAll;
|
||||
window.collectSelected = collectSelected;
|
||||
window.createBackup = createBackup;
|
||||
window.doExport = doExport;
|
||||
window.doImport = doImport;
|
||||
window.loadMatrixAtomOps = loadMatrixAtomOps;
|
||||
window.loadMatrixBackup = loadMatrixBackup;
|
||||
window.loadMatrixExport = loadMatrixExport;
|
||||
window.loadOpsHistory = loadOpsHistory;
|
||||
window.restoreBackup = restoreBackup;
|
||||
window.toggleCollectGroup = toggleCollectGroup;
|
||||
window.toggleCollectMachine = toggleCollectMachine;
|
||||
window._renderShell = _renderShell;
|
||||
@@ -0,0 +1,6 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// Matrix Sub-Views (inline, no new tab)
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
let _accountsData = [];
|
||||
let _accountsSort = {col:'id', asc:true};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,160 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 录制管理
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
async function loadMatrixRecord() {
|
||||
const listEl = document.getElementById('recordList');
|
||||
const detailEl = document.getElementById('recordDetail');
|
||||
const statusEl = document.getElementById('recordStatus');
|
||||
if (statusEl) statusEl.innerHTML = '';
|
||||
if (detailEl) detailEl.innerHTML = '';
|
||||
// 填充账号下拉
|
||||
const acctSel = document.getElementById('recordAccountSelect');
|
||||
if (acctSel) {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/sms/accounts');
|
||||
const d = await r.json();
|
||||
acctSel.innerHTML = '<option value="">选择账号…</option>' +
|
||||
(d.accounts||[]).filter(a => a.is_local).map(a =>
|
||||
'<option value="'+a.id+'">'+(a.platform==='xiaohongshu'?'📕':'🎵')+' '+a.nickname+'</option>').join('');
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
// 加载录制包列表
|
||||
refreshRecordingList();
|
||||
}
|
||||
|
||||
async function refreshRecordingList() {
|
||||
const listEl = document.getElementById('recordList');
|
||||
if (!listEl) return;
|
||||
const statusEl = document.getElementById('recordStatus');
|
||||
listEl.innerHTML = '<div class="loading">加载录制包...</div>';
|
||||
try {
|
||||
const [listR, statR] = await Promise.all([
|
||||
fetch('/api/matrix/recordings'),
|
||||
fetch('/api/matrix/recordings/status'),
|
||||
]);
|
||||
const list = await listR.json();
|
||||
const stat = await statR.json();
|
||||
const recordings = list.recordings || [];
|
||||
|
||||
// 更新录制状态
|
||||
if (statusEl) {
|
||||
if (stat.status === 'running') {
|
||||
statusEl.innerHTML = '<span style="color:var(--green)">🔴 录制中 (PID '+stat.pid+')</span>';
|
||||
document.getElementById('recordStartBtn').style.display = 'none';
|
||||
document.getElementById('recordStopBtn').style.display = '';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span style="color:var(--text2)">⏸ 空闲</span>';
|
||||
document.getElementById('recordStartBtn').style.display = '';
|
||||
document.getElementById('recordStopBtn').style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if (!recordings.length) {
|
||||
listEl.innerHTML = '<div style="color:var(--text2);font-size:13px;padding:20px;text-align:center">暂无录制包<br><span style="font-size:11px">选择一个账号点击「开始录制」,在浏览器中操作并按数字键 1-8 标记,按 0 结束</span></div>';
|
||||
return;
|
||||
}
|
||||
listEl.innerHTML = '<div style="display:grid;gap:6px">'+
|
||||
recordings.map(r => '<div style="background:var(--bg3);border-radius:6px;padding:8px 10px;border:1px solid var(--border);display:flex;justify-content:space-between;align-items:center">'+
|
||||
'<div><span style="font-weight:600;font-size:12px">'+(r.account||'?')+'</span> '+
|
||||
'<span style="color:var(--text2);font-size:10px">'+r.steps+'步 '+(r.duration||0)+'s</span>'+
|
||||
'<span style="color:var(--text2);font-size:9px;margin-left:6px">'+(r.created||'').slice(0,16)+'</span></div>'+
|
||||
'<div style="display:flex;gap:4px">'+
|
||||
'<button onclick="recordingAnalyze(\''+r.file+'\')" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:10px">🔍 分析</button>'+
|
||||
'<button onclick="recordingExport(\''+r.file+'\')" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:10px">📦 导出</button>'+
|
||||
'<button onclick="recordingDelete(\''+r.file+'\')" style="background:rgba(220,38,38,.08);color:var(--red);border:1px solid var(--red);padding:3px 8px;border-radius:4px;cursor:pointer;font-size:10px">🗑 删除</button>'+
|
||||
'</div></div>').join('')+'</div>';
|
||||
} catch(e) {
|
||||
listEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function recordingStart() {
|
||||
const sel = document.getElementById('recordAccountSelect');
|
||||
if (!sel || !sel.value) { alert('请先选择一个账号'); return; }
|
||||
const acct = sel.value;
|
||||
try {
|
||||
const r = await fetch('/api/matrix/recordings/start', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({account:acct})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
refreshRecordingList();
|
||||
} else {
|
||||
alert('❌ '+(d.error||d.detail||'启动失败'));
|
||||
}
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
}
|
||||
|
||||
async function recordingStop() {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/recordings/stop', { method:'POST' });
|
||||
const d = await r.json();
|
||||
refreshRecordingList();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
}
|
||||
|
||||
async function recordingAnalyze(name) {
|
||||
const detailEl = document.getElementById('recordDetail');
|
||||
if (!detailEl) return;
|
||||
detailEl.innerHTML = '<div class="loading">分析中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/recordings/'+encodeURIComponent(name));
|
||||
const d = await r.json();
|
||||
if (d.error) { detailEl.innerHTML = '<span style="color:var(--red)">❌ '+d.error+'</span>'; return; }
|
||||
const actions = d.actions || [];
|
||||
const suggestions = d.blueprint_suggestions || [];
|
||||
let html = '<div style="background:var(--bg2);border-radius:8px;padding:10px;border:1px solid var(--border);margin-top:6px">'+
|
||||
'<div style="font-weight:600;font-size:13px;margin-bottom:6px">🔍 分析结果 ('+d.steps_analyzed+'步)</div>';
|
||||
if (actions.length) {
|
||||
html += '<div style="font-size:11px;margin-bottom:6px;color:var(--text2)">操作序列:</div>'+
|
||||
actions.slice(0,15).map(a => '<div style="font-size:11px;padding:2px 0;border-bottom:1px solid var(--border)">'+
|
||||
'<span style="color:#6366f1">'+(a.action_type||'?')+'</span> '+
|
||||
'<span style="color:var(--text2)">→ '+(a.action_desc||'')+'</span>'+
|
||||
'</div>').join('');
|
||||
}
|
||||
if (suggestions.length) {
|
||||
html += '<div style="font-size:11px;margin-top:6px;color:var(--text2)">蓝图建议:</div>'+
|
||||
suggestions.slice(0,5).map(s => '<div style="font-size:11px;padding:2px 0">'+
|
||||
'<span style="color:var(--green)">步骤'+s.step+'</span>: '+(s.suggested_op||'')+'</div>').join('');
|
||||
}
|
||||
html += '</div>';
|
||||
detailEl.innerHTML = html;
|
||||
} catch(e) { detailEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>'; }
|
||||
}
|
||||
|
||||
async function recordingExport(name) {
|
||||
const detailEl = document.getElementById('recordDetail');
|
||||
if (!detailEl) return;
|
||||
detailEl.innerHTML = '<div class="loading">导出中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/recordings/'+encodeURIComponent(name)+'/export', { method:'POST' });
|
||||
const d = await r.json();
|
||||
if (d.error) { detailEl.innerHTML = '<span style="color:var(--red)">❌ '+d.error+'</span>'; return; }
|
||||
let html = '<div style="background:var(--bg2);border-radius:8px;padding:10px;border:1px solid var(--border);margin-top:6px">'+
|
||||
'<div style="font-weight:600;font-size:13px;margin-bottom:6px">📦 导出结果</div>'+
|
||||
'<div style="font-size:11px">蓝图: <code>'+(d.blueprint_name||'?')+'</code> ('+d.steps+'步)</div>'+
|
||||
'<div style="font-size:11px">代码: <code>'+(d.script_name||'?')+'</code></div>'+
|
||||
'<div style="font-size:11px;margin-top:4px;color:var(--text2)">导出位置: blueprints/ 和 scripts/recorded_ops/</div>'+
|
||||
'</div>';
|
||||
detailEl.innerHTML = html;
|
||||
refreshRecordingList();
|
||||
} catch(e) { detailEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>'; }
|
||||
}
|
||||
|
||||
async function recordingDelete(name) {
|
||||
if (!confirm('确定删除录制包 '+name+'?')) return;
|
||||
try {
|
||||
await fetch('/api/matrix/recordings/'+encodeURIComponent(name), { method:'DELETE' });
|
||||
refreshRecordingList();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.loadMatrixRecord = loadMatrixRecord;
|
||||
window.recordingAnalyze = recordingAnalyze;
|
||||
window.recordingDelete = recordingDelete;
|
||||
window.recordingExport = recordingExport;
|
||||
window.recordingStart = recordingStart;
|
||||
window.recordingStop = recordingStop;
|
||||
window.refreshRecordingList = refreshRecordingList;
|
||||
@@ -0,0 +1,133 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 命令与任务 TAB
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
function switchCmdTab(tab, el) {
|
||||
document.querySelectorAll('.cmd-tab').forEach(t => {
|
||||
t.style.borderBottom = '2px solid transparent';
|
||||
t.style.color = '#8b8fa3';
|
||||
});
|
||||
el.style.borderBottom = '2px solid #6366f1';
|
||||
el.style.color = '#e1e4f0';
|
||||
['cmd-run-content','cmd-comment-content','cmd-schedule-content','cmd-collect-content','cmd-corpus-content','cmd-nurture-content'].forEach(id => {
|
||||
document.getElementById(id).style.display = id === tab+'-content' ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMatrixCommands() {
|
||||
// 加载批量执行内容(复用原始 loadMatrixRun 的渲染结果)
|
||||
const runEl = document.getElementById('matrixRunContent2');
|
||||
if (runEl) {
|
||||
// 先渲染原始内容
|
||||
await loadMatrixRun();
|
||||
// 把渲染结果复制到命令与任务的 TAB 中
|
||||
const origEl = document.getElementById('matrixRunContent');
|
||||
if (origEl && origEl.innerHTML) {
|
||||
runEl.innerHTML = origEl.innerHTML;
|
||||
// 持续监控同步(原始内容变化时自动更新)
|
||||
if (!window._cmdRunObserver) {
|
||||
window._cmdRunObserver = new MutationObserver(() => {
|
||||
document.getElementById('matrixRunContent2').innerHTML = origEl.innerHTML;
|
||||
});
|
||||
window._cmdRunObserver.observe(origEl, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
// 加载定时任务列表
|
||||
refreshScheduleList();
|
||||
// 填充定时任务的账号下拉
|
||||
const schedAcct = document.getElementById('schedAccount');
|
||||
if (schedAcct) {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/sms/accounts');
|
||||
const d = await r.json();
|
||||
schedAcct.innerHTML = '<option value="">选账号</option>' +
|
||||
(d.accounts||[]).filter(a => a.is_local).map(a =>
|
||||
'<option value="'+a.id+'">'+(a.platform==='xiaohongshu'?'📕':'🎵')+' '+a.nickname+'</option>').join('');
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
// 语料库 TAB 绑定(同步原始 corpusContent 到 corpusContent3)
|
||||
const corpusEl3 = document.getElementById('corpusContent3');
|
||||
const corpusOrig = document.getElementById('corpusContent');
|
||||
if (corpusEl3 && corpusOrig && !window._corpusObs3) {
|
||||
window._corpusObs3 = new MutationObserver(() => {
|
||||
const target = document.getElementById('corpusContent3');
|
||||
const src = document.getElementById('corpusContent');
|
||||
if (target && src) target.innerHTML = src.innerHTML;
|
||||
});
|
||||
window._corpusObs3.observe(corpusOrig, { childList: true, subtree: true, characterData: true });
|
||||
// 强制加载语料库(无论原始页面是否已加载)
|
||||
if (typeof loadCorpus === 'function') {
|
||||
loadCorpus().catch(e => console.error('corpus load err', e));
|
||||
}
|
||||
corpusEl3.innerHTML = corpusOrig.innerHTML || '<div class="loading">加载语料库...</div>';
|
||||
}
|
||||
|
||||
// 加载采集模块账号列表(复用养号执行的折叠式选择器)
|
||||
try {
|
||||
const data = await _loadAccounts();
|
||||
_collectAccounts = data.accounts || [];
|
||||
_renderAccountSelector('collectList', {_data: data, height: '350px'});
|
||||
document.getElementById('collectSelCount2').textContent = '已选 ' + _getSelectedAccounts().length + ' 个';
|
||||
} catch(e) { /* ignore */ }
|
||||
|
||||
// 填充定向评论的账号选择器(折叠式)
|
||||
try {
|
||||
const data = await _loadAccounts();
|
||||
_renderAccountSelector('commentTaskAccountList', {_data: data, height: '350px'});
|
||||
document.getElementById('commentTaskSelCount').textContent = '已选 ' + _getSelectedAccounts().length + ' 个';
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
|
||||
async function cmdRunCommentTask() {
|
||||
const url = document.getElementById('cmdTaskUrl')?.value.trim();
|
||||
if (!url) { alert('请填写视频链接'); return; }
|
||||
const direction = document.getElementById('cmdTaskDirection')?.value || '';
|
||||
const selected = _getSelectedAccounts();
|
||||
const account = selected.length ? selected[0].id : '';
|
||||
const resultEl = document.getElementById('cmdTaskResult');
|
||||
if (resultEl) resultEl.innerHTML = '⏳ 执行中...';
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({type:'comment', accounts:[account], params:{url, direction}})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (resultEl) {
|
||||
if (d.status === 'ok') {
|
||||
resultEl.innerHTML = '<span style="color:var(--green)">✅ 完成!成功 '+d.success+'/'+d.total+' 步 ('+d.duration+'s)</span>'+
|
||||
'<div style="font-size:10px;color:var(--text2);margin-top:2px">'+d.task_summary+'</div>';
|
||||
} else {
|
||||
resultEl.innerHTML = '<span style="color:var(--red)">❌ '+(d.errors||[d.detail||'失败']).join('; ')+'</span>';
|
||||
}
|
||||
}
|
||||
} catch(e) { if (resultEl) resultEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>'; }
|
||||
}
|
||||
|
||||
// ── 从定时任务页面创建 WorkBuddy 任务 ──
|
||||
async function createAutoTaskFromSchedule() {
|
||||
const target = document.getElementById('autoNewTarget')?.value;
|
||||
const prompt = document.getElementById('autoNewPrompt')?.value.trim();
|
||||
if (!target) { alert('请选择目标机器'); return; }
|
||||
if (!prompt) { alert('请输入提示词'); return; }
|
||||
try {
|
||||
const r = await fetch('/api/kb/automations', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({target, type:'custom', params:prompt})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
alert('✅ 任务已创建');
|
||||
document.getElementById('autoNewPrompt').value = '';
|
||||
refreshScheduleList();
|
||||
} else {
|
||||
alert('❌ ' + (d.error || '创建失败'));
|
||||
}
|
||||
} catch(e) { alert('❌ ' + e.message); }
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.cmdRunCommentTask = cmdRunCommentTask;
|
||||
window.createAutoTaskFromSchedule = createAutoTaskFromSchedule;
|
||||
window.loadMatrixCommands = loadMatrixCommands;
|
||||
window.switchCmdTab = switchCmdTab;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,136 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// Machine Status Bar — 全局三台机器状态条
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
let _machineBarTimer = null;
|
||||
|
||||
async function loadMachineBar() {
|
||||
const bar = document.getElementById('machineBar');
|
||||
if (!bar) return;
|
||||
try {
|
||||
const rh = await fetch('/api/federation/health');
|
||||
const health = await rh.json();
|
||||
// 补充本机健康(federation/health 只含远程机器)
|
||||
try {
|
||||
const lr = await fetch('/api/health');
|
||||
const ld = await lr.json();
|
||||
health['chengzigedeAir'] = ld;
|
||||
} catch(e) {}
|
||||
|
||||
const rm = await fetch('/api/machines');
|
||||
const md = await rm.json();
|
||||
const machineList = md.machines || [];
|
||||
|
||||
const MACHINE_NAMES = {
|
||||
'chengzigedeAir': { label: '主控', icon: '🖥️' },
|
||||
'5kechengdeAir': { label: '养号', icon: '🖥️' },
|
||||
'7kecheng': { label: '养号', icon: '🖥️' },
|
||||
};
|
||||
|
||||
let html = '<div style="display:flex;gap:8px;flex-wrap:wrap">';
|
||||
for (const [name, info] of Object.entries(MACHINE_NAMES)) {
|
||||
const h = health[name] || {};
|
||||
const isOnline = h.status === 'ok';
|
||||
const mData = machineList.find(function(m){return m.hostname===name || m.hostname===h.hostname;});
|
||||
let statusClass = 'offline', statusText = '离线';
|
||||
if (isOnline) {
|
||||
statusClass = 'online';
|
||||
statusText = '在线';
|
||||
if (mData && mData.current_task) {
|
||||
statusClass = 'busy';
|
||||
statusText = '忙碌';
|
||||
}
|
||||
}
|
||||
var dotColor = statusClass === 'online' ? 'var(--green)' : (statusClass === 'busy' ? 'var(--amber)' : 'var(--text2)');
|
||||
var opacity = statusClass === 'offline' ? '0.3' : '1';
|
||||
html += '<div style="display:flex;align-items:center;gap:5px;padding:5px 10px;border-radius:6px;background:var(--bg2);border:1px solid var(--border);font-size:12px;opacity:'+opacity+'">'+
|
||||
'<span style="width:8px;height:8px;border-radius:50%;background:'+dotColor+'"></span>'+
|
||||
'<span style="font-weight:600;color:var(--text)">'+name+'</span>'+
|
||||
'<span style="font-size:10px;color:var(--text2)">'+info.label+'</span>'+
|
||||
'<span style="font-size:10px;color:'+dotColor+'">'+statusText+'</span></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
bar.innerHTML = html;
|
||||
} catch(e) {
|
||||
// silent — status bar is not critical
|
||||
}
|
||||
}
|
||||
|
||||
function initMachineBar() {
|
||||
if (_machineBarTimer) clearInterval(_machineBarTimer);
|
||||
loadMachineBar();
|
||||
_machineBarTimer = setInterval(loadMachineBar, 30000);
|
||||
}
|
||||
|
||||
// 在页面加载完成后初始化
|
||||
if (document.readyState === 'complete') {
|
||||
initMachineBar();
|
||||
} else {
|
||||
window.addEventListener('load', initMachineBar);
|
||||
}
|
||||
|
||||
async function sendC2Command() {
|
||||
const target = document.getElementById('c2Target')?.value;
|
||||
const type = document.getElementById('c2Type')?.value;
|
||||
const schedule = document.getElementById('c2Schedule')?.value;
|
||||
const resultEl = document.getElementById('c2Result');
|
||||
if (!resultEl) return;
|
||||
if (!target || !type) { resultEl.innerHTML = '<div style="color:var(--red);font-size:13px;padding:8px">请选择目标和命令类型</div>'; return; }
|
||||
|
||||
const params = {};
|
||||
if (type === 'nurture_run') {
|
||||
params.accounts = (document.getElementById('c2Accounts')?.value||'').split(',').map(s=>s.trim()).filter(Boolean);
|
||||
params.rounds = parseInt(document.getElementById('c2Rounds')?.value) || 10;
|
||||
}
|
||||
|
||||
resultEl.innerHTML = '<div style="text-align:center;padding:10px;color:var(--text2)">发送中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/c2/send', {method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({target,type,params,schedule_at:schedule||null})});
|
||||
const res = await r.json();
|
||||
const ch = res._channel || 'git';
|
||||
resultEl.innerHTML = '<div style="padding:8px;background:rgba(34,197,94,.1);border-radius:6px;font-size:13px;color:var(--green)">' +
|
||||
(ch==='http'?'⚡':'📤')+' 命令已发送 通道:'+ch+' 状态:'+(res.status||'?')+'<br>'+
|
||||
(res.message||'')+(res.command_id?'<br>ID:'+res.command_id:'')+'</div>';
|
||||
loadC2History();
|
||||
} catch(e) {
|
||||
resultEl.innerHTML = '<div style="color:var(--red);font-size:13px;padding:8px">'+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function loadC2History() {
|
||||
const el = document.getElementById('c2CommandHistory');
|
||||
if (!el) return;
|
||||
try {
|
||||
const r = await fetch('/api/c2/commands');
|
||||
const d = await r.json();
|
||||
const cmds = d.recent || [];
|
||||
if (!cmds.length) { el.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2);font-size:13px">暂无执行历史</div>'; return; }
|
||||
el.innerHTML = '<table><thead><tr><th>类型</th><th>目标</th><th>状态</th><th>时间</th><th>结果</th></tr></thead><tbody>' +
|
||||
cmds.slice(0,20).map(c => {
|
||||
const st = c.status||'?';
|
||||
const cl = st==='completed' ? '#22c55e' : (st==='failed'?'#ef4444':'#f59e0b');
|
||||
const out = c.output ? (c.output.accounts?c.output.accounts.join(','):Object.keys(c.output).slice(0,3).join('|')) : (c.error||'-');
|
||||
const ts = (c.completed_at||c.created_at||'').slice(0,19).replace('T',' ');
|
||||
return '<tr><td style="font-weight:600">'+c.type+'</td><td>'+(c.target_machine||'-')+'</td>' +
|
||||
'<td><span style="color:'+cl+'">●</span> '+st+'</td><td style="font-size:11px;color:var(--text2)">'+ts+'</td>' +
|
||||
'<td style="font-size:11px;color:var(--text2);max-width:150px;overflow:hidden;text-overflow:ellipsis">'+String(out).slice(0,40)+'</td></tr>';
|
||||
}).join('') + '</tbody></table>';
|
||||
} catch(e) {
|
||||
el.innerHTML = '<div style="padding:20px;color:var(--red)">'+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function c2Nurture(accountId) {
|
||||
document.getElementById('c2Target').value = 'chengzigedeAir';
|
||||
document.getElementById('c2Type').value = 'nurture_run';
|
||||
document.getElementById('c2Accounts').value = accountId;
|
||||
document.getElementById('c2NurtureParams').style.display = 'block';
|
||||
switchView('matrix-c2');
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.c2Nurture = c2Nurture;
|
||||
window.initMachineBar = initMachineBar;
|
||||
window.loadC2History = loadC2History;
|
||||
window.loadMachineBar = loadMachineBar;
|
||||
window.sendC2Command = sendC2Command;
|
||||
@@ -0,0 +1,46 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// C2 Remote Control
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
function toggleC2Params() {
|
||||
const type = document.getElementById('c2Type')?.value;
|
||||
const el = document.getElementById('c2NurtureParams');
|
||||
if (el) el.style.display = type === 'nurture_run' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
async function loadC2Machines() {
|
||||
const targetEl = document.getElementById('c2Target');
|
||||
const statusEl = document.getElementById('c2MachineStatus');
|
||||
if (!statusEl) return;
|
||||
statusEl.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2)">加载中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/c2/machines');
|
||||
const d = await r.json();
|
||||
if (targetEl) targetEl.innerHTML = '<option value="">选择机器...</option>' +
|
||||
d.machines.map(m => '<option value="'+m+'">'+m+(m==='chengzigedeAir'?' (本机)':'')+'</option>').join('');
|
||||
|
||||
let html = '';
|
||||
for (const m of d.machines) {
|
||||
try {
|
||||
const sr = await fetch('/api/c2/environment/'+encodeURIComponent(m));
|
||||
const st = await sr.json();
|
||||
if (st.output) {
|
||||
const o = st.output;
|
||||
html += '<div style="padding:8px 0;border-bottom:1px solid var(--border);font-size:12px;display:flex;justify-content:space-between">' +
|
||||
'<strong>'+m+'</strong>' +
|
||||
'<span>'+(o.guardd_running ? '✅ guardd运行' : '❌ guardd停')+' · 💾'+ (o.disk_avail_gb||'?')+'G' +' · 📱'+(o.matrix_accounts||'?')+'账号</span></div>';
|
||||
} else {
|
||||
html += '<div style="padding:8px 0;border-bottom:1px solid var(--border);font-size:12px">'+m+' · '+ (st.status||'未知') +'</div>';
|
||||
}
|
||||
} catch(e) {
|
||||
html += '<div style="padding:8px 0;border-bottom:1px solid var(--border);font-size:12px;color:var(--amber)">'+m+' · ⏳ 查询超时</div>';
|
||||
}
|
||||
}
|
||||
statusEl.innerHTML = html || '<div style="text-align:center;padding:20px;color:var(--text2)">无机器数据</div>';
|
||||
} catch(e) {
|
||||
statusEl.innerHTML = '<div style="padding:20px;color:var(--red)">'+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.loadC2Machines = loadC2Machines;
|
||||
window.toggleC2Params = toggleC2Params;
|
||||
@@ -0,0 +1,745 @@
|
||||
// ── State ──
|
||||
import { NAV_GROUPS } from '../nav-menu.js';
|
||||
const API = '';
|
||||
let currentView = 'productions';
|
||||
|
||||
// ── Sidebar ──
|
||||
function toggleSidebar() {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const btn = document.getElementById('sidebarToggleBtn');
|
||||
const isCollapsed = sb.classList.toggle('collapsed');
|
||||
btn.textContent = isCollapsed ? '▶' : '◀';
|
||||
btn.title = isCollapsed ? '展开侧边栏' : '折叠侧边栏';
|
||||
}
|
||||
function toggleGroup(el) {
|
||||
const body = el.nextElementSibling;
|
||||
if (!body || !body.classList) return;
|
||||
// 手风琴: 关闭其他所有分组
|
||||
document.querySelectorAll('.nav-group-header').forEach(h => {
|
||||
if (h === el) return;
|
||||
const b = h.nextElementSibling;
|
||||
if (b && b.classList && !b.classList.contains('collapsed')) {
|
||||
b.classList.add('collapsed');
|
||||
h.dataset.collapsed = 'true';
|
||||
const arrow = h.querySelector('span:last-child');
|
||||
if (arrow) arrow.textContent = '▶';
|
||||
}
|
||||
});
|
||||
// 切换当前分组
|
||||
const isCollapsed = body.classList.toggle('collapsed');
|
||||
el.dataset.collapsed = isCollapsed ? 'true' : 'false';
|
||||
el.querySelector('span:last-child').textContent = isCollapsed ? '▶' : '▼';
|
||||
}
|
||||
function collapseAllGroups() {
|
||||
document.querySelectorAll('.nav-group-header').forEach(h => {
|
||||
const body = h.nextElementSibling;
|
||||
if (body && body.classList && !body.classList.contains('collapsed')) {
|
||||
body.classList.add('collapsed');
|
||||
h.dataset.collapsed = 'true';
|
||||
const arrow = h.querySelector('span:last-child');
|
||||
if (arrow) arrow.textContent = '▶';
|
||||
}
|
||||
});
|
||||
}
|
||||
let currentPage = 0;
|
||||
const PAGE_SIZE = 30;
|
||||
let searchTimer;
|
||||
|
||||
// ── Init ──
|
||||
loadIdentity();
|
||||
loadPlugins();
|
||||
loadStats();
|
||||
// 自动刷新: 10秒轮询机器状态, 30秒轮询摘要
|
||||
setInterval(() => { if (currentView === 'summary') loadSummary(); }, 30000);
|
||||
|
||||
// ── Navigation ──
|
||||
// 路由统一由 inline.js switchView + router.js tryLoadView 处理
|
||||
// matrix_views.js 只负责 nav 高亮和子菜单展开
|
||||
const _origSwitchView = window.switchView;
|
||||
window.switchView = function(view) {
|
||||
// 先调原函数(inline.js switchView,里面含路由+加载)
|
||||
if (typeof _origSwitchView === 'function') {
|
||||
_origSwitchView(view);
|
||||
}
|
||||
|
||||
currentView = view;
|
||||
|
||||
// 所有 nav-item 和 nav-sub 的 active 状态
|
||||
document.querySelectorAll('.nav-item, .nav-sub').forEach(e => {
|
||||
e.classList.toggle('active', e.dataset.view === view);
|
||||
});
|
||||
|
||||
// 视频工厂子菜单展开
|
||||
const aveSubViews = ['ave-render', 'ave-script', 'ave-materials', 'ave-templates'];
|
||||
const isAve = view === 'plugin-ave' || aveSubViews.includes(view);
|
||||
document.querySelectorAll('.nav-sub[data-group="ave"]').forEach(e => {
|
||||
e.style.display = isAve ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// 矩阵养号子菜单展开
|
||||
const matrixSubViews = ['matrix-nurture','matrix-collect','matrix-publish','matrix-blueprints','matrix-interact','matrix-dm','matrix-live','comment-workbench','matrix-schedule','matrix-corpus','matrix-sms-proxy','ops-command','ops-recorder'];
|
||||
const isMatrix = view === 'plugin-matrix' || matrixSubViews.includes(view);
|
||||
document.querySelectorAll('.nav-sub[data-group="matrix"]').forEach(e => {
|
||||
e.style.display = isMatrix ? 'block' : 'none';
|
||||
});
|
||||
|
||||
// 如果点了子项,高亮父级
|
||||
if (aveSubViews.includes(view)) {
|
||||
document.querySelector('.nav-item[data-view="plugin-ave"]')?.classList.add('active');
|
||||
}
|
||||
if (matrixSubViews.includes(view)) {
|
||||
document.querySelector('.nav-item[data-view="plugin-matrix"]')?.classList.add('active');
|
||||
}
|
||||
}
|
||||
|
||||
// ── 迁移视图辅助函数(全部33个已迁移视图)──
|
||||
// ── 暴露全局(供 onclick 使用)──
|
||||
window.toggleGroup = toggleGroup;
|
||||
window.collapseAllGroups = collapseAllGroups;
|
||||
window.switchView = switchView;
|
||||
window.closeDetail = closeDetail;
|
||||
window.showDetail = showDetail;
|
||||
|
||||
// ── 执行历史(细腻度增强版:显示完整状态机 10 种状态)──
|
||||
var STATUS_MAP = {
|
||||
'queued': { icon: '📋', color: '#6b7280', label: '已排队', group: 'active' },
|
||||
'preflighting': { icon: '🔍', color: '#f59e0b', label: '预检中', group: 'active' },
|
||||
'preflight_failed':{ icon: '⚠️', color: '#ef4444', label: '预检失败', group: 'fail' },
|
||||
'dispatching': { icon: '📡', color: '#3b82f6', label: '分发中', group: 'active' },
|
||||
'running': { icon: '🟢', color: '#22c55e', label: '运行中', group: 'active' },
|
||||
'completed': { icon: '✅', color: '#22c55e', label: '已完成', group: 'ok' },
|
||||
'failed': { icon: '❌', color: '#ef4444', label: '执行失败', group: 'fail' },
|
||||
'timed_out': { icon: '⏰', color: '#f97316', label: '执行超时', group: 'fail' },
|
||||
'crashed': { icon: '💥', color: '#dc2626', label: '进程崩溃', group: 'fail' },
|
||||
'cancelled': { icon: '⏸️', color: '#9ca3af', label: '用户取消', group: 'fail' },
|
||||
};
|
||||
var STATUS_GROUPS = ['ok','active','fail'];
|
||||
var STATUS_GROUP_LABELS = { 'ok':'✅ 成功','active':'⏳ 活跃','fail':'❌ 终止' };
|
||||
var STATUS_GROUP_COLORS = { 'ok':'var(--green)','active':'var(--blue)','fail':'var(--red)' };
|
||||
|
||||
function formatElapsed(sec) {
|
||||
if (sec == null || sec === 0) return '-';
|
||||
sec = Math.round(sec);
|
||||
if (sec < 60) return sec + 's';
|
||||
var m = Math.floor(sec / 60);
|
||||
var s = sec % 60;
|
||||
return m + 'm' + (s > 0 ? s + 's' : '');
|
||||
}
|
||||
|
||||
// 执行历史已迁移到指挥台视图
|
||||
window.loadExecutionHistory = function() {};
|
||||
|
||||
async function loadTaskPanel() {
|
||||
// 已废弃 - execHistory 已从 index.html 移除
|
||||
return;
|
||||
}
|
||||
window.loadExecutionHistory = loadTaskPanel;
|
||||
window._stopRun = async function(runId) {
|
||||
if (!confirm('确定停止任务 ' + runId + '?')) return;
|
||||
try {
|
||||
const r = await fetch('/api/ops/cancel/' + runId, {method:'POST'});
|
||||
const d = await r.json();
|
||||
loadTaskPanel();
|
||||
} catch(e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
// 自动刷新 + 初始化
|
||||
setTimeout(function() { window.loadExecutionHistory(); }, 500);
|
||||
setInterval(function() {
|
||||
try { if (typeof window.loadStats === 'function') window.loadStats(); } catch(e) {}
|
||||
try { if (typeof window.loadMachineBar === 'function') window.loadMachineBar(); } catch(e) {}
|
||||
try { window.loadExecutionHistory(); } catch(e) {}
|
||||
}, 30000);
|
||||
|
||||
console.log('✅ Dashboard boot complete');
|
||||
|
||||
// ── Stats ──
|
||||
async function loadStats() {
|
||||
try {
|
||||
const r = await fetch(`${API}/api/summary`);
|
||||
const d = await r.json();
|
||||
// 从 guardd 插件获取机器数据(AVE 插件数据不全)
|
||||
const guarddPlugin = d.plugins && d.plugins.guardd;
|
||||
const guarddData = (guarddPlugin && guarddPlugin.data) || {};
|
||||
const machines = guarddData['各机器'] || {};
|
||||
const onlineCount = guarddData['在线'] || 0;
|
||||
const totalMachines = guarddData['总机器'] || Object.keys(machines).length || 0;
|
||||
|
||||
document.getElementById('statsBar').innerHTML = `
|
||||
<div class="stat-card"><div class="label">机器数</div><div class="value" style="color:var(--green)">${totalMachines}</div><div class="sub">在线 ${onlineCount}</div></div>
|
||||
<div class="stat-card"><div class="label">模块</div><div class="value" style="color:var(--blue)">${Object.keys(d.plugins||{}).length}</div><div class="sub">已加载</div></div>
|
||||
<div class="stat-card"><div class="label">主机</div><div class="value" style="color:var(--amber)">${d.source_hostname||'-'}</div></div>
|
||||
<div class="stat-card"><div class="label">知识库</div><div class="value" style="color:var(--primary)">${d.kb_stats?.total||'?'}</div><div class="sub">篇</div></div>
|
||||
`;
|
||||
} catch(e) { document.getElementById('statsBar').innerHTML = `<div class="error">❌ 连接失败</div>`; }
|
||||
}
|
||||
|
||||
// ── Productions ──
|
||||
async function loadProductions(page) {
|
||||
if (page !== undefined) currentPage = page;
|
||||
const el = document.getElementById('productionList');
|
||||
|
||||
// 矩阵养号运营数据看板(独立容器,不会被覆盖)
|
||||
try {
|
||||
let statsEl = document.getElementById('matrixDashboardStats');
|
||||
if (!statsEl) {
|
||||
statsEl = document.createElement('div');
|
||||
statsEl.id = 'matrixDashboardStats';
|
||||
el.parentNode.insertBefore(statsEl, el);
|
||||
}
|
||||
const sr = await fetch('/api/matrix/sms/accounts');
|
||||
const sd = await sr.json();
|
||||
const accts = (sd.accounts || []).filter(a => a.is_local);
|
||||
const online = accts.filter(a => a.has_cookie);
|
||||
const profiled = accts.filter(a => a.has_profile);
|
||||
statsEl.innerHTML = '<div style="display:grid;grid-template-columns:repeat(4,1fr);gap:8px;margin-bottom:14px">'+
|
||||
'<div style="background:var(--bg2);border-radius:var(--radius);padding:12px;border:1px solid var(--border);text-align:center">'+
|
||||
'<div style="font-size:24px;font-weight:700;color:#6366f1">'+accts.length+'</div>'+
|
||||
'<div style="font-size:11px;color:var(--text2)">📱 本机账号</div></div>'+
|
||||
'<div style="background:var(--bg2);border-radius:var(--radius);padding:12px;border:1px solid var(--border);text-align:center">'+
|
||||
'<div style="font-size:24px;font-weight:700;color:var(--green)">'+online.length+'</div>'+
|
||||
'<div style="font-size:11px;color:var(--text2)">🔑 已登录</div></div>'+
|
||||
'<div style="background:var(--bg2);border-radius:var(--radius);padding:12px;border:1px solid var(--border);text-align:center">'+
|
||||
'<div style="font-size:24px;font-weight:700;color:var(--amber)">'+profiled.length+'</div>'+
|
||||
'<div style="font-size:11px;color:var(--text2)">👤 已采集</div></div>'+
|
||||
'<div style="background:var(--bg2);border-radius:var(--radius);padding:12px;border:1px solid var(--border);text-align:center">'+
|
||||
'<div style="font-size:24px;font-weight:700;color:var(--text2)">'+sd.accounts.length+'</div>'+
|
||||
'<div style="font-size:11px;color:var(--text2)">🌐 联邦账号</div></div>'+
|
||||
'</div>';
|
||||
} catch(e) { /* ignore */ }
|
||||
el.innerHTML = '<div class="loading">加载中...</div>';
|
||||
try {
|
||||
const strategy = document.getElementById('filterStrategy').value;
|
||||
const status = document.getElementById('filterStatus').value;
|
||||
const search = document.getElementById('searchInput').value;
|
||||
const params = new URLSearchParams({limit: PAGE_SIZE, offset: currentPage * PAGE_SIZE});
|
||||
if (strategy) params.set('strategy', strategy);
|
||||
if (status) params.set('status', status);
|
||||
const r = await fetch(`${API}/api/productions?${params}`);
|
||||
const res = await r.json();
|
||||
const data = res.data || [];
|
||||
|
||||
if (!data.length) { el.innerHTML = '<div class="error" style="padding:40px">暂无生产记录</div>'; return; }
|
||||
|
||||
const filtered = search ? data.filter(p => (p.script_name||'').includes(search)) : data;
|
||||
if (!filtered.length) { el.innerHTML = '<div class="error">未找到匹配记录</div>'; return; }
|
||||
|
||||
el.innerHTML = `<table><thead><tr>
|
||||
<th>编号</th><th>策略</th><th>脚本</th><th>状态</th><th>费用</th><th>时长</th><th>时间</th>
|
||||
</tr></thead><tbody>${filtered.map(p => `<tr onclick="showDetail(${p.id})">
|
||||
<td>#${p.id}</td>
|
||||
<td>${p.strategy||'-'}</td>
|
||||
<td>${(p.script_name||'').slice(0,30)||'-'}</td>
|
||||
<td><span class="badge badge-${(p.status||'unknown').toLowerCase()}">${p.status}</span></td>
|
||||
<td>¥${(p.total_cost||0).toFixed(2)}</td>
|
||||
<td>${p.duration_sec ? p.duration_sec.toFixed(0) + 's' : '-'}</td>
|
||||
<td style="white-space:nowrap">${(p.created_at||'').slice(0,16)}</td>
|
||||
</tr>`).join('')}</tbody></table>`;
|
||||
|
||||
// Pagination
|
||||
document.getElementById('pagination').innerHTML = currentPage > 0
|
||||
? `<button onclick="loadProductions(${currentPage-1})">← 上一页</button><span style="padding:6px 12px;color:var(--text2)">第 ${currentPage+1} 页</span><button onclick="loadProductions(${currentPage+1})">下一页 →</button>`
|
||||
: `<span style="padding:6px 12px;color:var(--text2)">第 1 页</span><button onclick="loadProductions(${currentPage+1})">下一页 →</button>`;
|
||||
loadStats();
|
||||
} catch(e) { el.innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`; }
|
||||
}
|
||||
|
||||
function debounceSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(() => loadProductions(0), 400); }
|
||||
|
||||
// ── Detail ──
|
||||
async function showDetail(id) {
|
||||
const el = document.getElementById('detailModal');
|
||||
el.classList.remove('hidden');
|
||||
el.innerHTML = '<div class="loading" style="padding:60px">加载详情...</div>';
|
||||
try {
|
||||
const r = await fetch(`${API}/api/productions/${id}`);
|
||||
const d = await r.json();
|
||||
const p = d.production;
|
||||
const steps = d.steps || [];
|
||||
const assets = d.assets || [];
|
||||
const costs = d.costs || [];
|
||||
|
||||
el.innerHTML = `<div style="position:fixed;inset:0;z-index:100;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;padding:20px" onclick="event.target===this&&closeDetail()">
|
||||
<div style="background:var(--bg2);border-radius:12px;max-width:800px;width:100%;max-height:85vh;overflow-y:auto;padding:24px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
|
||||
<div class="detail-header"><h2>#${p.id} ${p.strategy||''}</h2></div>
|
||||
<button onclick="closeDetail()" style="background:var(--bg3);border:none;color:var(--text);padding:6px 14px;border-radius:6px;cursor:pointer">✕ 关闭</button>
|
||||
</div>
|
||||
<div class="detail-meta">
|
||||
<span>状态: <span class="badge badge-${(p.status||'').toLowerCase()}">${p.status}</span></span>
|
||||
<span>时长: ${p.duration_sec ? p.duration_sec.toFixed(0) + 's' : '-'}</span>
|
||||
<span>费用: ¥${(p.total_cost||0).toFixed(2)}</span>
|
||||
<span>输出: ${(p.output_path||'-').slice(-30)}</span>
|
||||
</div>
|
||||
${p.error_message ? `<div style="margin-top:12px;padding:10px;background:rgba(239,68,68,.1);border-radius:6px;color:var(--red);font-size:13px">${p.error_message}</div>` : ''}
|
||||
|
||||
<div class="section-title">📋 步骤 (${steps.length})</div>
|
||||
<div class="step-list">${steps.length ? steps.map(s => `<div class="step-item">
|
||||
<div class="dot" style="background:${s.status==='completed'?'var(--green)':s.status==='failed'?'var(--red)':'var(--amber)'}"></div>
|
||||
<span class="name">${s.step_name}</span>
|
||||
<span class="meta">${s.detail||''}${s.cost ? ' ¥'+s.cost.toFixed(2) : ''}</span>
|
||||
${s.error_message ? `<span style="color:var(--red);font-size:12px">${s.error_message}</span>` : ''}
|
||||
</div>`).join('') : '<div style="color:var(--text2);padding:10px">无步骤记录</div>'}</div>
|
||||
|
||||
<div class="section-title">💰 费用明细 (${costs.length})</div>
|
||||
${costs.length ? `<table><thead><tr><th>服务</th><th>时长</th><th>费用</th><th>备注</th></tr></thead><tbody>${costs.map(c => `<tr><td>${c.service||'-'}</td><td>${c.duration_s ? c.duration_s+'s' : c.chars ? c.chars+'字' : '-'}</td><td>¥${(c.cost_yuan||0).toFixed(2)}</td><td>${c.note||''}</td></tr>`).join('')}</tbody></table>`
|
||||
: '<div style="color:var(--text2);padding:10px">无费用记录</div>'}
|
||||
|
||||
<div class="section-title">💾 资产 (${assets.length})</div>
|
||||
<div class="asset-grid">${assets.length ? assets.map(a => `<div class="asset-card">
|
||||
<div class="type">${a.asset_type||'-'}</div>
|
||||
<div class="name">${(a.name||a.file_path||'-').slice(0,40)}</div>
|
||||
<div class="meta">${a.source||''}${a.file_size ? ' · '+(a.file_size/1024).toFixed(0)+'KB' : ''}</div>
|
||||
${a.tags ? `<div style="margin-top:6px;display:flex;gap:4px;flex-wrap:wrap">${a.tags.split(',').map(t => `<span style="background:var(--bg3);padding:1px 6px;border-radius:4px;font-size:10px">${t}</span>`).join('')}</div>` : ''}
|
||||
</div>`).join('') : '<div style="color:var(--text2);padding:10px">无关联资产</div>'}</div>
|
||||
</div>
|
||||
</div>`;
|
||||
} catch(e) { el.innerHTML = `<div class="error">❌ 加载失败</div>`; }
|
||||
}
|
||||
function closeDetail() { document.getElementById('detailModal').classList.add('hidden'); }
|
||||
|
||||
// ── Assets ──
|
||||
async function loadAssets(page) {
|
||||
const el = document.getElementById('assetList');
|
||||
el.innerHTML = '<div class="loading">加载中...</div>';
|
||||
try {
|
||||
const type = document.getElementById('assetTypeFilter').value;
|
||||
const tag = document.getElementById('assetSearch').value;
|
||||
const params = new URLSearchParams({limit: 50});
|
||||
if (type) params.set('type', type);
|
||||
if (tag) params.set('tag', tag);
|
||||
const r = await fetch(`${API}/api/assets?${params}`);
|
||||
const data = await r.json();
|
||||
if (!data.length) { el.innerHTML = '<div class="error" style="padding:40px">暂无资产</div>'; return; }
|
||||
el.innerHTML = `<div class="asset-grid">${data.map(a => `<div class="asset-card" onclick="showDetail(${a.production_id})" style="cursor:pointer">
|
||||
<div class="type">${a.asset_type||'-'}</div>
|
||||
<div class="name">${(a.name||a.file_path||'-').slice(0,40)}</div>
|
||||
<div class="meta">${a.source||''}${a.file_size ? ' · '+(a.file_size/1024).toFixed(0)+'KB' : ''}</div>
|
||||
${a.tags ? `<div style="margin-top:6px;display:flex;gap:4px;flex-wrap:wrap">${a.tags.split(',').map(t => `<span style="background:var(--bg3);padding:1px 6px;border-radius:4px;font-size:10px">${t}</span>`).join('')}</div>` : ''}
|
||||
</div>`).join('')}</div>`;
|
||||
} catch(e) { el.innerHTML = `<div class="error">❌ 加载失败</div>`; }
|
||||
}
|
||||
function debounceAssetSearch() { clearTimeout(searchTimer); searchTimer = setTimeout(() => loadAssets(), 400); }
|
||||
|
||||
// ── Costs ──
|
||||
async function loadCosts() {
|
||||
const el1 = document.getElementById('costSummary');
|
||||
const el2 = document.getElementById('costBreakdown');
|
||||
try {
|
||||
const r = await fetch(`${API}/api/summary`);
|
||||
const d = await r.json();
|
||||
const s = d.ave || d;
|
||||
el1.innerHTML = `<div class="stats" style="grid-template-columns:repeat(3,1fr)">
|
||||
<div class="stat-card"><div class="label">总费用</div><div class="value">¥${(s.total_cost||0).toFixed(2)}</div></div>
|
||||
<div class="stat-card"><div class="label">本月费用</div><div class="value">¥${(s.month_cost||0).toFixed(2)}</div></div>
|
||||
<div class="stat-card"><div class="label">平均费用/生产</div><div class="value">¥${s.total_productions ? (s.total_cost/s.total_productions).toFixed(2) : '0.00'}</div></div>
|
||||
</div>`;
|
||||
|
||||
// 按策略统计 (专用API)
|
||||
const r2 = await fetch(`${API}/api/costs/breakdown`);
|
||||
const strategies = await r2.json();
|
||||
const colors = ['#6366f1','#22c55e','#f59e0b','#ef4444','#3b82f6'];
|
||||
|
||||
if (!strategies.length) {
|
||||
el2.innerHTML = '<div class="error" style="padding:40px">暂无费用数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const maxCost = Math.max(...strategies.map(s => s.total_cost), 1);
|
||||
el2.innerHTML = `<div class="chart-box">
|
||||
<div style="font-size:14px;font-weight:600;margin-bottom:12px">按策略统计</div>
|
||||
<div class="cost-bar">${strategies.map((s,i) => `<div style="flex:${(s.total_cost/maxCost*100).toFixed(0)};background:${colors[i%5]};min-width:20px" title="${s.strategy}: ¥${s.total_cost.toFixed(2)}">¥${s.total_cost.toFixed(0)}</div>`).join('')}</div>
|
||||
<table><thead><tr><th>策略</th><th>次数</th><th>总时长</th><th>总费用</th><th>平均费用</th></tr></thead><tbody>
|
||||
${strategies.map(s => `<tr><td>${s.strategy}</td><td>${s.count}</td><td>${(s.total_duration||0).toFixed(0)}s</td><td>¥${(s.total_cost||0).toFixed(2)}</td><td>¥${s.avg_cost||'0.00'}</td></tr>`).join('')}
|
||||
</tbody></table>
|
||||
</div>`;
|
||||
} catch(e) { el1.innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`; }
|
||||
}
|
||||
|
||||
// ── Machine Status ──
|
||||
async function loadMachines() {
|
||||
const el = document.getElementById('machineGrid');
|
||||
const countEl = document.getElementById('machineCount');
|
||||
el.innerHTML = '<div class="loading">读取联邦心跳中...</div>';
|
||||
try {
|
||||
const r = await fetch(`${API}/api/machines`);
|
||||
const d = await r.json();
|
||||
const machines = d.machines || [];
|
||||
countEl.textContent = `共 ${machines.length} 台主机`;
|
||||
|
||||
if (!machines.length) {
|
||||
el.innerHTML = '<div class="error" style="padding:40px">无心跳数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
el.innerHTML = machines.map(m => {
|
||||
// 安全取值
|
||||
const isOnline = m.status === 'online' || m._live === true;
|
||||
const isRecent = m.status === 'recent';
|
||||
const isLive = m._live === true;
|
||||
const dotColor = isLive ? '#1D9E75' : isOnline ? '#22C55E' : isRecent ? '#F59E0B' : '#EF4444';
|
||||
const statusText = isLive ? '实时' : isOnline ? '在线' : isRecent ? '近期' : '离线';
|
||||
const lastPushSec = m._last_push_sec || 0;
|
||||
const minAgo = m.minutes_ago || Math.round(lastPushSec / 60) || 999;
|
||||
const timeStr = isLive
|
||||
? `${lastPushSec}秒前`
|
||||
: minAgo < 60
|
||||
? `${minAgo} 分钟前`
|
||||
: `${(minAgo/60).toFixed(1)} 小时前`;
|
||||
|
||||
// 磁盘条
|
||||
const diskUsed = m.disk_used_gb || 0;
|
||||
const diskTotal = m.disk_total_gb || 1;
|
||||
const diskPct = Math.round((diskUsed / diskTotal) * 100);
|
||||
const diskAvail = m.disk_avail_gb || 0;
|
||||
const diskColor = diskPct > 85 ? 'var(--red)' : diskPct > 60 ? 'var(--amber)' : 'var(--green)';
|
||||
const diskBar = `<div style="height:6px;background:var(--bg3);border-radius:3px;margin:6px 0;overflow:hidden">
|
||||
<div style="height:100%;width:${diskPct}%;background:${diskColor};border-radius:3px;transition:width .3s"></div>
|
||||
</div>`;
|
||||
|
||||
// 角色标签
|
||||
const roleColor = '#378ADD';
|
||||
|
||||
return `<div style="background:var(--bg2);border-radius:var(--radius);padding:20px;border:1px solid ${isOnline ? 'rgba(34,197,94,.2)' : 'var(--border)'}">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
|
||||
<div style="display:flex;align-items:center;gap:10px">
|
||||
<span style="width:10px;height:10px;border-radius:50%;background:${dotColor};display:inline-block;"></span>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:15px">${m.hostname}</div>
|
||||
${m._uid ? `<span style="font-size:10px;color:var(--text2);margin-left:6px">UID: ${m._uid}</span>` : ''}
|
||||
<div style="font-size:11px;color:var(--text2)">${m.os || ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;font-size:13px;margin-bottom:12px">
|
||||
<div><span style="color:var(--text2)">guardd</span><br>v${m.guardd_version||'-'}</div>
|
||||
<div><span style="color:var(--text2)">最后心跳</span><br>${timeStr}</div>
|
||||
<div><span style="color:var(--text2)">CPU 负载</span><br>${m.cpu_load||'-'}</div>
|
||||
<div><span style="color:var(--text2)">当前任务</span><br>${m.current_task||'无'}</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:2px">磁盘: ${(m.disk_used_gb||0).toFixed(0)}G / ${(m.disk_total_gb||0).toFixed(0)}G</div>
|
||||
${diskBar}
|
||||
<div style="display:flex;justify-content:space-between;font-size:11px;color:var(--text2)">
|
||||
<span>已用 ${diskPct}%</span>
|
||||
<span>剩余 ${(m.disk_avail_gb||0).toFixed(0)}G</span>
|
||||
</div>
|
||||
|
||||
${m.is_duplicate ? `<div style="margin-top:8px;padding:4px 8px;background:rgba(245,158,11,.1);border-radius:4px;font-size:11px;color:var(--amber)">⚠ hostname 变更: 原名 ${m.duplicate_of||''}</div>` : ''}
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) {
|
||||
el.innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Identity & Plugin Init ──
|
||||
async function loadIdentity() {
|
||||
try {
|
||||
const r = await fetch('/api/identity');
|
||||
const d = await r.json();
|
||||
document.getElementById('identityHostname').innerHTML = `📡 ${d.hostname}`;
|
||||
document.getElementById('identityMeta').innerHTML = `UID: ${d.uid} · ${d.role} · ${d.git_repo} @ ${(d.git_version||'').slice(0,7)}`;
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadPlugins() {
|
||||
try {
|
||||
const r = await fetch('/api/plugins');
|
||||
const res = await r.json();
|
||||
const nav = document.getElementById('navBar');
|
||||
const plugins = res.plugins || [];
|
||||
// 旧兼容: 忽略不存在的 pluginBadge/pluginCount
|
||||
|
||||
// 构建分组侧边栏 (可折叠) — 按 COMMAND-CENTER-PLAN 排列
|
||||
let html = '';
|
||||
const S = (label, status) => `<span style="font-size:9px;margin-left:4px;padding:1px 5px;border-radius:3px;background:rgba(217,119,6,.12);color:#d97706;font-weight:500">${status}</span>`;
|
||||
// ✅ 菜单定义已单点化 → 见 src/nav-menu.js(新增/删除菜单只改那里)
|
||||
const groups = NAV_GROUPS;
|
||||
|
||||
// ── 从 API 加载 agentos 插件注册的导航 ──
|
||||
// 已停用:插件导航与硬编码导航重叠(联邦管理/社交矩阵/系统设置),
|
||||
// 统一由硬编码导航控制,防止重复项
|
||||
async function loadNavFromAPI() {
|
||||
// 直接渲染,不使用插件导航
|
||||
_renderNav();
|
||||
}
|
||||
|
||||
function _renderNav() {
|
||||
let html = '';
|
||||
for (const [gname, g] of Object.entries(groups)) {
|
||||
html += `<div class="nav-group-header" onclick="toggleGroup(this)" data-collapsed="true">${g.icon} ${gname} <span style="float:right;font-size:10px;opacity:.5">▶</span></div>`;
|
||||
html += `<div class="nav-group-body collapsed">`;
|
||||
for (const item of g.items) {
|
||||
if (item.sub) {
|
||||
html += `<div class="nav-item" data-view="${item.view}" onclick="switchView('${item.view}')">${item.label}</div>`;
|
||||
for (const sub of item.sub) {
|
||||
html += `<div class="nav-sub" data-group="${item.view.replace('plugin-','')}" data-view="${sub.view}" onclick="switchView('${sub.view}')" style="display:none">${sub.label}</div>`;
|
||||
}
|
||||
} else {
|
||||
html += `<div class="nav-item" data-view="${item.view}" onclick="switchView('${item.view}')">${item.label}</div>`;
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="nav-item" data-view="timeline" onclick="switchView(\'timeline\')" style="display:none">📈 时间线</div>';
|
||||
nav.innerHTML = html;
|
||||
}
|
||||
|
||||
// 先渲染 fallback 导航,再加载 API 覆盖
|
||||
_renderNav();
|
||||
loadNavFromAPI();
|
||||
// 构建完成后恢复当前视图的激活状态
|
||||
// switchView(currentView) removed — 单路由后不再覆盖用户操作
|
||||
} catch(e) {
|
||||
// 保底:即使 API 失败也加载生产列表
|
||||
loadProductions();
|
||||
}
|
||||
}
|
||||
|
||||
// ── Matrix 养号矩阵汇总(inline 视图)──
|
||||
async function loadMatrixSummary() {
|
||||
const el = document.getElementById('matrixSummaryContent');
|
||||
const meta = document.getElementById('matrixSummaryMeta');
|
||||
el.innerHTML = '<div class="loading">加载矩阵状态...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/cross-machines');
|
||||
const d = await r.json();
|
||||
meta.textContent = `${d.total_machines} 机器 · ${d.total_accounts} 账号`;
|
||||
|
||||
// 获取身份目录统计
|
||||
let identityCount = 0;
|
||||
try {
|
||||
const ir = await fetch('/api/matrix/system-info');
|
||||
const id = await ir.json();
|
||||
identityCount = id.identity_dirs || 0;
|
||||
} catch(e) {}
|
||||
meta.textContent += ` · 🪪 ${identityCount} 身份目录`;
|
||||
|
||||
// 检测重复:同一账号出现在不同机器
|
||||
const allIds = {};
|
||||
const duplicates = [];
|
||||
d.machines.forEach(m => {
|
||||
(m.accounts||[]).forEach(a => {
|
||||
if (allIds[a.id] && allIds[a.id] !== m.hostname) {
|
||||
duplicates.push({id: a.id, m1: allIds[a.id], m2: m.hostname});
|
||||
}
|
||||
allIds[a.id] = m.hostname;
|
||||
});
|
||||
});
|
||||
|
||||
let html = '';
|
||||
|
||||
// 重复告警
|
||||
if (duplicates.length) {
|
||||
html += `<div style="padding:12px;background:rgba(239,68,68,.12);border-radius:8px;border:1px solid rgba(239,68,68,.25);margin-bottom:16px">
|
||||
<div style="font-weight:600;color:var(--red);margin-bottom:6px">🚨 账号重复分配检测</div>
|
||||
${duplicates.map(dp => `<div style="font-size:13px;margin:2px 0">⚠️ <strong>${dp.id}</strong> 同时分配给 <strong>${dp.m1}</strong> 和 <strong>${dp.m2}</strong></div>`).join('')}
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:4px">请编辑 accounts_registry.yaml 修正 assign_machine</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// 每机器一张卡片
|
||||
html += `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(360px,1fr));gap:14px">`;
|
||||
d.machines.forEach(m => {
|
||||
const localTag = m.hostname === d.source_hostname ? '<span style="font-size:10px;color:var(--green);margin-left:6px">(本机)</span>' : '';
|
||||
html += `<div style="background:var(--bg2);border-radius:var(--radius);padding:16px;border:1px solid var(--border)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<div><span style="font-weight:600;font-size:14px">🖥 ${m.hostname}</span>${localTag}</div>
|
||||
<div style="display:flex;gap:6px;font-size:11px">
|
||||
<span class="badge badge-blue">共${m.total}</span>
|
||||
<span class="badge badge-green">🟢${m.logged_in}</span>
|
||||
<span class="badge badge-gray">⏸${m.total-m.logged_in-m.remote}</span>
|
||||
${m.remote ? `<span class="badge badge-amber">📡${m.remote}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<table style="font-size:12px;margin:0"><thead><tr>
|
||||
<th>账号</th><th>平台</th><th>状态</th><th>归属</th>
|
||||
</tr></thead><tbody>${(m.accounts||[]).map(a => {
|
||||
const s = a.status === 'logged_in' ? '🟢' : a.status === 'disabled' ? '⏸' : a.enabled ? '🔴' : '⏸';
|
||||
const isRemote = a.owner_machine && a.owner_machine !== m.hostname && a.status !== 'remote' ? `📡${a.owner_machine}` : a.status === 'remote' ? `📡${a.owner_machine||'?'}` : '✅';
|
||||
return `<tr><td><strong>${a.id}</strong></td>
|
||||
<td>${a.platform==='douyin'?'🎵':'📕'} ${a.platform}</td>
|
||||
<td>${s} ${a.status}</td>
|
||||
<td style="font-size:11px;color:var(--text2)">${isRemote}</td></tr>`;
|
||||
}).join('')}</tbody></table>
|
||||
</div>`;
|
||||
});
|
||||
html += `</div>`;
|
||||
|
||||
// 蓝图简表
|
||||
try {
|
||||
const br = await fetch('/api/matrix/blueprints');
|
||||
const bd = await br.json();
|
||||
const bps = bd.blueprints || [];
|
||||
html += `<div style="margin-top:16px;background:var(--bg2);border-radius:var(--radius);padding:16px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;margin-bottom:10px">📋 蓝图 (${bps.length})</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px">${bps.map(b =>
|
||||
`<span style="background:var(--bg3);padding:4px 10px;border-radius:4px;font-size:12px">${b.name} <span class="badge badge-blue" style="font-size:9px">${b.step_count}步</span></span>`
|
||||
).join('')}</div>
|
||||
</div>`;
|
||||
} catch(e) {}
|
||||
|
||||
html += `<div style="margin-top:12px;text-align:right;font-size:11px;color:var(--text2)">
|
||||
<a href="/matrix-mgmt" target="_blank" style="color:var(--blue)">📱 打开完整管理页面 →</a>
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = `<div class="error">❌ ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
// 对账检查视图(内联 — 防 tree-shake)
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
async function loadFleetReconcileView(container) {
|
||||
const uid = 'fr_' + Math.random().toString(36).slice(2, 6);
|
||||
let _rawData = null;
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="padding:20px">
|
||||
<h2 style="font-size:18px;margin-bottom:12px">🔍 对账检查</h2>
|
||||
<p style="font-size:12px;color:var(--text2);margin-bottom:16px">检查本机是否符合 ORACLE.yaml 宪法定义</p>
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap">
|
||||
<button onclick="window._doFleetReconcile('${uid}')" style="background:var(--primary);color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px">🔍 执行对账</button>
|
||||
<button id="exportBtn_${uid}" onclick="window._exportReconcile('${uid}')" style="display:none;background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:8px 16px;border-radius:6px;cursor:pointer;font-size:12px">📋 导出报告</button>
|
||||
</div>
|
||||
<div id="stats_${uid}" style="display:none;margin-bottom:12px">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(100px,1fr));gap:8px">
|
||||
<div style="background:var(--bg3);border-radius:8px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:11px;color:var(--text2)">总项</div>
|
||||
<div id="statTotal_${uid}" style="font-size:22px;font-weight:500;margin-top:4px">0</div>
|
||||
</div>
|
||||
<div id="statPassCard_${uid}" style="background:#EAF3DE;border-radius:8px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:11px;color:#27500A">通过</div>
|
||||
<div id="statPass_${uid}" style="font-size:22px;font-weight:500;color:#3B6D11;margin-top:4px">0</div>
|
||||
</div>
|
||||
<div id="statWarnCard_${uid}" style="background:#FAEEDA;border-radius:8px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:11px;color:#633806">警告</div>
|
||||
<div id="statWarn_${uid}" style="font-size:22px;font-weight:500;color:#854F0B;margin-top:4px">0</div>
|
||||
</div>
|
||||
<div id="statFailCard_${uid}" style="background:#FCEBEB;border-radius:8px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:11px;color:#791F1F">失败</div>
|
||||
<div id="statFail_${uid}" style="font-size:22px;font-weight:500;color:#A32D2D;margin-top:4px">0</div>
|
||||
</div>
|
||||
<div style="background:var(--bg3);border-radius:8px;padding:10px 14px;text-align:center">
|
||||
<div style="font-size:11px;color:var(--text2)">通过率</div>
|
||||
<div id="statRate_${uid}" style="font-size:22px;font-weight:500;margin-top:4px">0%</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="filter_${uid}" style="display:none;margin-bottom:10px">
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;font-size:12px">
|
||||
${['全部','环境','目录','文件','Git','服务','任务'].map((label, i) =>
|
||||
'<div id="filterTab_'+uid+'_'+i+'" class="reconcile-filter-tab" style="height:28px;padding:0 14px;border-radius:6px;display:inline-flex;align-items:center;cursor:pointer;background:'+(i===0?'var(--primary)':'var(--bg3)')+';color:'+(i===0?'#fff':'var(--text)')+'" onclick="window._filterReconcile(\''+uid+'\','+i+')">'+label+'</div>'
|
||||
).join('')}
|
||||
</div>
|
||||
</div>
|
||||
<div id="detail_${uid}" style="display:none">
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:6px">明细</div>
|
||||
<div id="detailList_${uid}" style="display:flex;flex-direction:column;gap:4px"></div>
|
||||
</div>
|
||||
<div id="log_${uid}" style="margin-top:12px;background:var(--bg2);border-radius:var(--radius);padding:12px;border:1px solid var(--border);font-size:11px;font-family:monospace;white-space:pre-wrap;max-height:300px;overflow:auto;display:none"></div>
|
||||
</div>`;
|
||||
|
||||
window._doFleetReconcile = window._doFleetReconcile || (async function(uid) {
|
||||
const logEl = document.getElementById('log_' + uid);
|
||||
if (!logEl) return;
|
||||
logEl.style.display = '';
|
||||
logEl.textContent = '⏳ 执行对账...\n';
|
||||
try {
|
||||
const r = await fetch('/api/fleet/reconcile', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
const output = d.output || JSON.stringify(d, null, 2);
|
||||
logEl.textContent = output;
|
||||
_rawData = parseReconcileOutput(output);
|
||||
renderReconcileStats(uid, _rawData);
|
||||
} catch(e) { logEl.textContent = '❌ ' + e.message; }
|
||||
});
|
||||
|
||||
function parseReconcileOutput(text) {
|
||||
const items = []; let currentSection = '';
|
||||
for (const line of text.split('\n')) {
|
||||
const t = line.replace(/\\u001b\\[[\\d;]+m/g, '').trim();
|
||||
if (!t) continue;
|
||||
if (/^[═━]{3,}\\s*\\d+\\.\\s/.test(t)) { currentSection = t.replace(/[═━\\s]+/g, ' ').trim(); continue; }
|
||||
let status = null, content = t;
|
||||
if (/^✅/.test(t)) { status = 'pass'; content = t.replace(/^✅\\s*/, ''); }
|
||||
else if (/^⚠️/.test(t)) { status = 'warn'; content = t.replace(/^⚠️\\s*/, ''); }
|
||||
else if (/^❌/.test(t)) { status = 'fail'; content = t.replace(/^❌\\s*/, ''); }
|
||||
if (status && content) {
|
||||
const ci = content.indexOf(':'); const label = ci>0?content.slice(0,ci).trim():content; const detail = ci>0?content.slice(ci+1).trim():'';
|
||||
items.push({status,label,detail,section:currentSection});
|
||||
}
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
function renderReconcileStats(uid, items) {
|
||||
const total = items.length; const pass = items.filter(i=>i.status==='pass').length;
|
||||
const warn = items.filter(i=>i.status==='warn').length; const fail = items.filter(i=>i.status==='fail').length;
|
||||
const rate = total>0?Math.round(pass/total*100):0;
|
||||
document.getElementById('statTotal_'+uid).textContent = total;
|
||||
document.getElementById('statPass_'+uid).textContent = pass;
|
||||
document.getElementById('statWarn_'+uid).textContent = warn;
|
||||
document.getElementById('statFail_'+uid).textContent = fail;
|
||||
document.getElementById('statRate_'+uid).textContent = rate+'%';
|
||||
document.getElementById('stats_'+uid).style.display = '';
|
||||
document.getElementById('filter_'+uid).style.display = '';
|
||||
document.getElementById('detail_'+uid).style.display = '';
|
||||
const eb = document.getElementById('exportBtn_'+uid); if (eb) eb.style.display = '';
|
||||
_rawData = items; window._filterReconcile(uid, 0);
|
||||
}
|
||||
|
||||
window._filterReconcile = window._filterReconcile || function(uid, tabIdx) {
|
||||
const tabs = document.querySelectorAll('#filter_'+uid+' [id^="filterTab_"]');
|
||||
tabs.forEach((t,i)=>{ t.style.background=i===tabIdx?'var(--primary)':'var(--bg3)'; t.style.color=i===tabIdx?'#fff':'var(--text)'; });
|
||||
const filter = ['全部','环境','目录','文件','Git','服务','任务'][tabIdx];
|
||||
let filtered = _rawData||[];
|
||||
if (filter!=='全部') filtered = filtered.filter(i=>i.section.includes(filter));
|
||||
renderDetailList(uid, filtered);
|
||||
};
|
||||
|
||||
function renderDetailList(uid, items) {
|
||||
const list = document.getElementById('detailList_'+uid); if (!list) return;
|
||||
if (!items.length) { list.innerHTML = '<div style="font-size:12px;color:var(--text2);padding:8px">无匹配项</div>'; return; }
|
||||
let html = '';
|
||||
for (const item of items) {
|
||||
const icon = item.status==='pass'?'✅':item.status==='warn'?'⚠️':'❌';
|
||||
const color = item.status==='pass'?'var(--green)':item.status==='warn'?'var(--amber)':'var(--red)';
|
||||
html += '<div style="display:flex;gap:8px;align-items:flex-start;padding:6px 8px;background:var(--bg3);border-radius:6px;font-size:12px;line-height:1.5">';
|
||||
html += '<span style="color:'+color+';flex-shrink:0">'+icon+'</span>';
|
||||
html += '<span style="color:var(--text2);flex-shrink:0;min-width:80px">'+escapeHtml2(item.label)+'</span>';
|
||||
if (item.detail) html += '<span>'+escapeHtml2(item.detail)+'</span>';
|
||||
html += '</div>';
|
||||
}
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
window._exportReconcile = window._exportReconcile || function(uid) {
|
||||
if (!_rawData||!_rawData.length) return;
|
||||
const pass = _rawData.filter(i=>i.status==='pass').length, warn = _rawData.filter(i=>i.status==='warn').length, fail = _rawData.filter(i=>i.status==='fail').length;
|
||||
const total = _rawData.length, rate = total>0?Math.round(pass/total*100):0, now = new Date().toLocaleString();
|
||||
let md = '# AgentOS 联邦对账报告\\n\\n**生成时间**: '+now+'\\n\\n';
|
||||
md += '| 指标 | 数值 |\\n|:-----|:-----|\\n| 总项 | '+total+' |\\n| 通过 | '+pass+' |\\n| 警告 | '+warn+' |\\n| 失败 | '+fail+' |\\n| 通过率 | '+rate+'% |\\n\\n';
|
||||
md += '| 状态 | 检查项 | 详情 |\\n|:-----|:-------|:------|\\n';
|
||||
for (const item of _rawData) md += '| '+(item.status==='pass'?'✅':item.status==='warn'?'⚠️':'❌')+' | '+item.label+' | '+item.detail+' |\\n';
|
||||
navigator.clipboard.writeText(md).then(function(){
|
||||
var b = document.getElementById('exportBtn_'+uid); if(b){ var o=b.textContent; b.textContent='✅ 已复制'; setTimeout(function(){b.textContent=o;},2000); }
|
||||
}).catch(function(){
|
||||
var ta = document.createElement('textarea'); ta.value=md; ta.style.cssText='position:fixed;left:-9999px';
|
||||
document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta);
|
||||
});
|
||||
};
|
||||
|
||||
function escapeHtml2(str) { return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
||||
}
|
||||
window.loadFleetReconcileView = loadFleetReconcileView;
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.loadIdentity = loadIdentity;
|
||||
window.loadMatrixSummary = loadMatrixSummary;
|
||||
window.loadPlugins = loadPlugins;
|
||||
window.loadStats = loadStats;
|
||||
@@ -0,0 +1,302 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 信息采集(三维选择)
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
var _collectMode = 'identity';
|
||||
var _collectAccounts = [];
|
||||
|
||||
function switchCollectMode(mode, el) {
|
||||
_collectMode = mode;
|
||||
document.querySelectorAll('.collect-mode').forEach(e => {
|
||||
e.style.background = 'var(--bg3)';
|
||||
e.style.color = '#e1e4f0';
|
||||
});
|
||||
el.style.background = '#6366f1';
|
||||
el.style.color = '#fff';
|
||||
renderCollectList();
|
||||
}
|
||||
|
||||
function renderCollectList() {
|
||||
const listEl = document.getElementById('collectList');
|
||||
if (!listEl) return;
|
||||
const accts = _collectAccounts.filter(a => a.is_local);
|
||||
if (!accts.length) { listEl.innerHTML = '<div style="color:var(--text2);padding:6px">暂无本机账号</div>'; return; }
|
||||
|
||||
if (_collectMode === 'identity') {
|
||||
// 按手机号分组显示
|
||||
const groups = {};
|
||||
accts.forEach(a => {
|
||||
const phone = a.phone || '未设置手机号';
|
||||
if (!groups[phone]) groups[phone] = [];
|
||||
groups[phone].push(a);
|
||||
});
|
||||
listEl.innerHTML = Object.entries(groups).map(([phone, list]) =>
|
||||
'<div style="margin:2px 0">' +
|
||||
'<label style="cursor:pointer;font-size:12px">' +
|
||||
'<input type="checkbox" class="collect-id-item" data-phone="'+phone+'" onchange="toggleCollectIdentity(this)"> ' +
|
||||
'📱 ' + phone + ' (' + list.length + '个平台)</label>' +
|
||||
list.map(a => '<div style="margin-left:20px;font-size:11px;color:var(--text2)">' +
|
||||
(a.platform==='xiaohongshu'?'📕':'🎵')+' '+a.id+' ('+a.nickname+')</div>').join('') +
|
||||
'</div>'
|
||||
).join('');
|
||||
} else if (_collectMode === 'platform') {
|
||||
// 按平台分组
|
||||
const groups = { douyin: [], xiaohongshu: [] };
|
||||
accts.forEach(a => { if (groups[a.platform]) groups[a.platform].push(a); });
|
||||
listEl.innerHTML = Object.entries(groups).filter(g=>g[1].length).map(([plat, list]) =>
|
||||
'<div style="margin:2px 0">' +
|
||||
'<label style="cursor:pointer;font-size:12px">' +
|
||||
'<input type="checkbox" class="collect-plat-item" data-platform="'+plat+'" onchange="toggleCollectPlatform(this)"> ' +
|
||||
(plat==='xiaohongshu'?'📕 小红书':'🎵 抖音') + ' (' + list.length + '个账号)</label>' +
|
||||
list.map(a => '<div style="margin-left:20px;font-size:11px;color:var(--text2)">' +
|
||||
a.id+' ('+a.nickname+')</div>').join('') +
|
||||
'</div>'
|
||||
).join('');
|
||||
} else {
|
||||
// 按账号
|
||||
listEl.innerHTML = accts.map(a =>
|
||||
'<label style="display:block;cursor:pointer;font-size:12px;padding:2px 0">' +
|
||||
'<input type="checkbox" class="collect-acct-item" value="'+a.id+'"> ' +
|
||||
(a.platform==='xiaohongshu'?'📕':'🎵')+' '+a.id+' ('+a.nickname+')</label>'
|
||||
).join('');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCollectIdentity(el) {
|
||||
// 子账号全选/全不选
|
||||
const phone = el.getAttribute('data-phone');
|
||||
const checked = el.checked;
|
||||
document.querySelectorAll('.collect-acct-item').forEach(cb => {
|
||||
if (cb.getAttribute('data-phone') === phone) cb.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
function toggleCollectPlatform(el) {
|
||||
const plat = el.getAttribute('data-platform');
|
||||
const checked = el.checked;
|
||||
document.querySelectorAll('.collect-acct-item').forEach(cb => {
|
||||
if (cb.getAttribute('data-platform') === plat) cb.checked = checked;
|
||||
});
|
||||
}
|
||||
|
||||
function getSelectedAccounts() {
|
||||
const sel = new Set();
|
||||
if (_collectMode === 'identity') {
|
||||
document.querySelectorAll('.collect-id-item:checked').forEach(cb => {
|
||||
const phone = cb.getAttribute('data-phone');
|
||||
_collectAccounts.filter(a => a.is_local && a.phone === phone).forEach(a => sel.add(a.id));
|
||||
});
|
||||
} else if (_collectMode === 'platform') {
|
||||
document.querySelectorAll('.collect-plat-item:checked').forEach(cb => {
|
||||
const plat = cb.getAttribute('data-platform');
|
||||
_collectAccounts.filter(a => a.is_local && a.platform === plat).forEach(a => sel.add(a.id));
|
||||
});
|
||||
} else {
|
||||
document.querySelectorAll('.collect-acct-item:checked').forEach(cb => sel.add(cb.value));
|
||||
}
|
||||
return [...sel];
|
||||
}
|
||||
|
||||
async function cmdCollectSelected() {
|
||||
const ids = getSelectedAccounts();
|
||||
if (!ids.length) { alert('请先选择要采集的账号'); return; }
|
||||
const statusEl = document.getElementById('collectStatus');
|
||||
const logEl = document.getElementById('collectLog');
|
||||
if (logEl) logEl.textContent = '';
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 启动批量采集(所有身份,取选中结果)...';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({type:'collect', accounts:ids, params:{rounds:1}})});
|
||||
const data = await r.json();
|
||||
if (data.status !== 'accepted') {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ 启动失败: ' + (data.error||data.message||data.status) + '</span>';
|
||||
return;
|
||||
}
|
||||
const runId = data.commands?.[0]?.run_id;
|
||||
if (logEl) logEl.textContent += '🚀 采集任务已启动 (runId=' + (runId||'?') + ')\n';
|
||||
|
||||
if (!runId) { if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 已提交</span>'; return; }
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const pr = await fetch('/api/ops/status');
|
||||
const ps = await pr.json();
|
||||
const cmds = Array.isArray(ps) ? ps : (ps.commands || []);
|
||||
const cmd = cmds.find(c => c.run_id === runId);
|
||||
if (!cmd) return;
|
||||
if (cmd.status === 'running' || cmd.status === 'dispatching' || cmd.status === 'queued') {
|
||||
if (statusEl) statusEl.innerHTML = '⏳ ' + cmd.status + '... ' + (cmd.elapsed_sec>0 ? Math.round(cmd.elapsed_sec)+'s' : '');
|
||||
if (logEl) {
|
||||
const line = ' ⏳ ' + cmd.status + ' (' + ((cmd.elapsed_sec>0) ? Math.round(cmd.elapsed_sec)+'s' : '') + ')\n';
|
||||
if (!logEl.textContent.includes(line.trim())) logEl.textContent += line;
|
||||
}
|
||||
} else if (['completed','failed','crashed','cancelled','timed_out'].includes(cmd.status)) {
|
||||
clearInterval(poll);
|
||||
if (cmd.status === 'completed') {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 采集完成</span>';
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ ' + cmd.status + '</span>';
|
||||
}
|
||||
if (logEl) logEl.textContent += '🏁 状态: ' + cmd.status + ' - ' + (cmd.message||'') + '\n';
|
||||
await showCollectReport(ids, statusEl, logEl);
|
||||
}
|
||||
} catch(e) { /* silent */ }
|
||||
}, 5000);
|
||||
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ ' + e.message + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function cmdCollectAll() {
|
||||
const statusEl = document.getElementById('collectStatus');
|
||||
const logEl = document.getElementById('collectLog');
|
||||
if (logEl) logEl.textContent = '';
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 启动批量采集...';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', { method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify({type:'collect', params:{rounds:1}})});
|
||||
const data = await r.json();
|
||||
if (data.status !== 'accepted') {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ 启动失败: ' + (data.error||data.message||data.status) + '</span>';
|
||||
return;
|
||||
}
|
||||
const runId = data.commands?.[0]?.run_id;
|
||||
if (logEl) logEl.textContent += '🚀 批量采集已启动 (runId=' + (runId||'?') + ')\n';
|
||||
|
||||
if (!runId) { if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 已提交</span>'; return; }
|
||||
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const pr = await fetch('/api/ops/status');
|
||||
const ps = await pr.json();
|
||||
const cmds = Array.isArray(ps) ? ps : (ps.commands || []);
|
||||
const cmd = cmds.find(c => c.run_id === runId);
|
||||
if (!cmd) return;
|
||||
if (cmd.status === 'running' || cmd.status === 'dispatching' || cmd.status === 'queued') {
|
||||
if (statusEl) statusEl.innerHTML = '⏳ ' + cmd.status + '... ' + (cmd.elapsed_sec>0 ? Math.round(cmd.elapsed_sec)+'s' : '');
|
||||
} else if (['completed','failed','crashed','cancelled','timed_out'].includes(cmd.status)) {
|
||||
clearInterval(poll);
|
||||
if (cmd.status === 'completed') {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 采集完成</span>';
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ ' + cmd.status + '</span>';
|
||||
}
|
||||
if (logEl) logEl.textContent += '🏁 状态: ' + cmd.status + ' - ' + (cmd.message||'') + '\n';
|
||||
await showCollectReport(null, statusEl, logEl);
|
||||
}
|
||||
} catch(e) { /* silent */ }
|
||||
}, 5000);
|
||||
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ ' + e.message + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function showCollectReport(filterIds, statusEl, logEl) {
|
||||
// 读最终结果
|
||||
try {
|
||||
const hp = await (await fetch('/api/matrix/homepage-info')).json();
|
||||
const results = hp.results || [];
|
||||
const collectedAt = hp.collected_at || '未知';
|
||||
|
||||
// 筛选
|
||||
let display = results;
|
||||
if (filterIds && filterIds.length) {
|
||||
display = results.filter(r => {
|
||||
const accts = (_collectAccounts||[]).filter(a => a.phone === r.phone);
|
||||
return accts.some(a => filterIds.includes(a.id));
|
||||
});
|
||||
}
|
||||
|
||||
// 统计
|
||||
let dy_ok = 0, dy_fail = 0, xhs_ok = 0, xhs_fail = 0;
|
||||
display.forEach(r => {
|
||||
const dy = r.douyin;
|
||||
if (dy) {
|
||||
(dy.nickname && !dy.nickname.includes('登录后')) ? dy_ok++ : dy_fail++;
|
||||
}
|
||||
const xhs = r.xiaohongshu;
|
||||
if (xhs) {
|
||||
(xhs.nickname && !xhs.nickname.includes('问点点') && !xhs.nickname.includes('推荐')) ? xhs_ok++ : xhs_fail++;
|
||||
}
|
||||
});
|
||||
|
||||
// 格式化采集时间
|
||||
const ts = collectedAt && collectedAt !== '未知' ? collectedAt.replace('T',' ').slice(0,19) : '未知';
|
||||
// 计算相对时间
|
||||
let relativeTime = '';
|
||||
try {
|
||||
const then = new Date(collectedAt);
|
||||
const now = new Date();
|
||||
const diffMin = Math.floor((now - then) / 60000);
|
||||
if (diffMin < 1) relativeTime = '刚刚';
|
||||
else if (diffMin < 60) relativeTime = diffMin + '分钟前';
|
||||
else if (diffMin < 1440) relativeTime = Math.floor(diffMin/60) + '小时前';
|
||||
else relativeTime = Math.floor(diffMin/1440) + '天前';
|
||||
} catch(e) {}
|
||||
const ageColor = relativeTime.includes('小时前') || relativeTime === '刚刚' ? '#22c55e' :
|
||||
relativeTime.includes('天前') ? '#f59e0b' : '#8b8fa3';
|
||||
|
||||
// 渲染报告
|
||||
let html = '<div style="font-size:11px">';
|
||||
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">';
|
||||
html += '<span style="color:#6366f1;font-weight:600">🕐 更新于: ' + ts + '</span>';
|
||||
html += '<span style="color:' + ageColor + ';font-size:10px">(' + relativeTime + ')</span>';
|
||||
html += '</div>';
|
||||
html += '<div style="border:1px solid var(--border);border-radius:6px;overflow:hidden;margin-bottom:6px">';
|
||||
html += '<table style="width:100%;border-collapse:collapse;font-size:10px">';
|
||||
html += '<thead><tr style="background:var(--bg3)">';
|
||||
html += '<th style="padding:3px 6px;text-align:left">身份</th>';
|
||||
html += '<th style="padding:3px 6px;text-align:left">手机</th>';
|
||||
html += '<th style="padding:3px 6px;text-align:left">🎵 抖音昵称</th>';
|
||||
html += '<th style="padding:3px 6px;text-align:right">粉丝</th>';
|
||||
html += '<th style="padding:3px 6px;text-align:left">📕 小红书昵称</th>';
|
||||
html += '<th style="padding:3px 6px;text-align:right">粉丝</th>';
|
||||
html += '<th style="padding:3px 6px;text-align:center">状态</th>';
|
||||
html += '</tr></thead><tbody>';
|
||||
|
||||
display.forEach(r => {
|
||||
const dy = r.douyin;
|
||||
const xhs = r.xiaohongshu;
|
||||
const dyName = (dy && dy.nickname && !dy.nickname.includes('登录后')) ? dy.nickname : '<span style="color:var(--text2);font-style:italic">(无数据)</span>';
|
||||
const dyFans = (dy && dy.fans && !dy.nickname.includes('登录后')) ? dy.fans : '-';
|
||||
const xhsName = (xhs && xhs.nickname && !xhs.nickname.includes('问点点') && !xhs.nickname.includes('推荐')) ? xhs.nickname : '<span style="color:var(--text2);font-style:italic">(无数据)</span>';
|
||||
const xhsFans = (xhs && xhs.fans && !xhs.nickname.includes('问点点') && !xhs.nickname.includes('推荐')) ? xhs.fans : '-';
|
||||
const dyOk = dy && dy.nickname && !dy.nickname.includes('登录后');
|
||||
const xhsOk = xhs && xhs.nickname && !xhs.nickname.includes('问点点') && !xhs.nickname.includes('推荐');
|
||||
const statusIcon = dyOk || xhsOk ? '✅' : '❌';
|
||||
html += '<tr style="border-top:1px solid var(--border)">';
|
||||
html += '<td style="padding:3px 6px;white-space:nowrap">' + (r.display_name||'?') + '</td>';
|
||||
html += '<td style="padding:3px 6px;color:var(--text2);font-size:9px;white-space:nowrap">' + (r.phone||'-') + '</td>';
|
||||
html += '<td style="padding:3px 6px">' + dyName + '</td>';
|
||||
html += '<td style="padding:3px 6px;text-align:right;color:var(--text2)">' + dyFans + '</td>';
|
||||
html += '<td style="padding:3px 6px">' + xhsName + '</td>';
|
||||
html += '<td style="padding:3px 6px;text-align:right;color:var(--text2)">' + xhsFans + '</td>';
|
||||
html += '<td style="padding:3px 6px;text-align:center">' + statusIcon + '</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
|
||||
html += '</tbody></table></div>';
|
||||
html += '<div style="display:flex;gap:12px;font-size:10px">';
|
||||
html += '<span style="color:var(--green)">✅ 抖音: ' + dy_ok + '/' + (dy_ok+dy_fail) + '</span>';
|
||||
html += '<span style="color:#6366f1">📕 小红书: ' + xhs_ok + '/' + (xhs_ok+xhs_fail) + '</span>';
|
||||
html += '</div>';
|
||||
html += '</div>';
|
||||
|
||||
if (logEl) logEl.innerHTML = html;
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 采集完成</span>';
|
||||
|
||||
} catch(e) {
|
||||
if (logEl) logEl.textContent += '❌ 读取结果失败: ' + e.message + '\n';
|
||||
}
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.cmdCollectAll = cmdCollectAll;
|
||||
window.cmdCollectSelected = cmdCollectSelected;
|
||||
window.getSelectedAccounts = getSelectedAccounts;
|
||||
window.renderCollectList = renderCollectList;
|
||||
window.showCollectReport = showCollectReport;
|
||||
window.switchCollectMode = switchCollectMode;
|
||||
window.toggleCollectIdentity = toggleCollectIdentity;
|
||||
window.toggleCollectPlatform = toggleCollectPlatform;
|
||||
@@ -0,0 +1,335 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// Corpus Management
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
async function loadCorpus() {
|
||||
const el = document.getElementById('corpusContent');
|
||||
const meta = document.getElementById('corpusMeta');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">加载中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/corpus');
|
||||
const d = await r.json();
|
||||
const cats = d.categories || [];
|
||||
meta.textContent = `共 ${cats.length} 个分类 · ${d.total_comments||0} 条评论`;
|
||||
|
||||
let html = '<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:10px">';
|
||||
|
||||
// Group by platform
|
||||
const groups = {};
|
||||
cats.forEach(c => {
|
||||
const p = c.platform === 'xiaohongshu' ? '📕 小红书' : '🎵 抖音';
|
||||
if (!groups[p]) groups[p] = [];
|
||||
groups[p].push(c);
|
||||
});
|
||||
|
||||
Object.entries(groups).forEach(([platform, items]) => {
|
||||
html += `<div style="background:var(--bg2);border-radius:var(--radius);padding:14px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:14px;margin-bottom:10px">${platform}</div>
|
||||
<table><thead><tr><th>分类</th><th>权重</th><th>评论数</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
||||
${items.map(c => `<tr>
|
||||
<td><strong>${c.name}</strong></td>
|
||||
<td>${c.weight}</td>
|
||||
<td>${c.count}</td>
|
||||
<td>${c.enabled ? '<span class="badge badge-green">✅ 启用</span>' : '<span class="badge badge-gray">⏸ 停用</span>'}</td>
|
||||
<td><button class="btn btn-sm btn-outline" onclick="corpusShowDetail('${c.platform}','${c.name}')">📝</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div>';
|
||||
|
||||
// Add comment form
|
||||
html += `<div style="margin-top:12px;background:var(--bg2);border-radius:var(--radius);padding:14px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:8px">✏️ 添加评论</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<select id="corpusAddPlatform" style="width:120px">
|
||||
<option value="douyin">🎵 抖音</option>
|
||||
<option value="xiaohongshu">📕 小红书</option>
|
||||
</select>
|
||||
<input id="corpusAddCategory" placeholder="分类名" style="width:100px">
|
||||
<input id="corpusAddText" placeholder="评论内容" style="flex:1;min-width:200px">
|
||||
<button class="btn btn-primary btn-sm" onclick="corpusAdd()">+ 添加</button>
|
||||
<span id="corpusAddResult" style="font-size:12px"></span>
|
||||
</div>
|
||||
</div>
|
||||
<!-- 批量导入 -->
|
||||
<div style="margin-top:8px;background:var(--bg2);border-radius:var(--radius);padding:14px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:8px">📥 批量导入</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap;margin-bottom:6px">
|
||||
<select id="corpusBatchPlatform" style="width:120px">
|
||||
<option value="douyin">🎵 抖音</option>
|
||||
<option value="xiaohongshu">📕 小红书</option>
|
||||
</select>
|
||||
<input id="corpusBatchCategory" placeholder="分类名" style="width:100px">
|
||||
<button class="btn btn-primary btn-sm" onclick="corpusBatchImport()">📥 导入</button>
|
||||
<span id="corpusBatchResult" style="font-size:12px"></span>
|
||||
</div>
|
||||
<textarea id="corpusBatchTexts" placeholder="每行一条评论,直接粘贴即可 例: 讲得太好了,受益匪浅! 这个观点很新颖,学习了 干货满满,感谢分享" style="width:100%;height:100px;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px;border-radius:5px;font-size:12px;font-family:monospace"></textarea>
|
||||
<div style="font-size:10px;color:var(--text2);margin-top:4px">支持粘贴、拖入 txt 文件(点击下方「选择文件」或直接粘贴内容)</div>
|
||||
</div>
|
||||
<!-- 格式模板 -->
|
||||
<div style="margin-top:8px;background:var(--bg2);border-radius:var(--radius);padding:14px;border:1px solid var(--border)">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<div style="font-weight:600;font-size:13px">📋 格式模板</div>
|
||||
<span style="font-size:10px;color:var(--text2);cursor:pointer" onclick="copyTemplate()">📋 复制模板</span>
|
||||
</div>
|
||||
<pre style="font-size:10px;background:var(--bg2);padding:8px;border-radius:4px;margin-top:6px;overflow-x:auto;white-space:pre-wrap;color:var(--text2)">
|
||||
# 语料库格式模板 v2.0
|
||||
# 支持身份(Persona)×场景(Scene)×内容(Content) 三维结构
|
||||
# 也兼容当前的一维分类格式(categories)
|
||||
|
||||
## 一维格式(当前使用)
|
||||
categories:
|
||||
赞美:
|
||||
comments:
|
||||
- "讲得太好了,受益匪浅!"
|
||||
- "干货满满,感谢分享"
|
||||
提问:
|
||||
comments:
|
||||
- "请问这是在哪里?"
|
||||
|
||||
## 二维格式(推荐)
|
||||
personas:
|
||||
health_lover:
|
||||
name: "养生爱好者"
|
||||
tags: ["养生","健康"]
|
||||
scenes:
|
||||
first_comment:
|
||||
label: "首次评论"
|
||||
content:
|
||||
health_lover.first_comment:
|
||||
- "这个养生方法很实用"
|
||||
- "坚持调理最重要"
|
||||
|
||||
## 三维格式(含多轮对话)
|
||||
content:
|
||||
health_lover.follow_up:
|
||||
round_1:
|
||||
- "确实如此,{keyword}我也是这么认为的"
|
||||
round_2:
|
||||
- "对,后来我换了一种方法就好了"
|
||||
</pre>
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
} catch(e) {
|
||||
el.innerHTML = `<div class="error">❌ ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function corpusAdd() {
|
||||
const platform = document.getElementById('corpusAddPlatform')?.value;
|
||||
const category = document.getElementById('corpusAddCategory')?.value;
|
||||
const text = document.getElementById('corpusAddText')?.value;
|
||||
const result = document.getElementById('corpusAddResult');
|
||||
if (!category || !text) { result.textContent = '❌ 请输入分类和评论'; return; }
|
||||
try {
|
||||
const r = await fetch('/api/matrix/corpus/add', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({platform, category, text})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
result.textContent = '✅ 已添加';
|
||||
document.getElementById('corpusAddText').value = '';
|
||||
loadCorpus();
|
||||
} else {
|
||||
result.textContent = '❌ '+d.error;
|
||||
}
|
||||
} catch(e) { result.textContent = '❌ '+e.message; }
|
||||
}
|
||||
|
||||
async function corpusBatchImport() {
|
||||
const platform = document.getElementById('corpusBatchPlatform')?.value;
|
||||
const category = document.getElementById('corpusBatchCategory')?.value;
|
||||
const textsEl = document.getElementById('corpusBatchTexts');
|
||||
const result = document.getElementById('corpusBatchResult');
|
||||
if (!category || !textsEl?.value.trim()) { result.textContent = '❌ 请输入分类名和评论内容'; return; }
|
||||
const texts = textsEl.value.split('\n').filter(t => t.trim());
|
||||
result.textContent = '⏳ 导入 ' + texts.length + ' 条...';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/corpus/batch-add', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({platform, category, texts})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
result.textContent = '✅ 成功导入 ' + d.added + ' 条';
|
||||
textsEl.value = '';
|
||||
loadCorpus();
|
||||
} else {
|
||||
result.textContent = '❌ ' + (d.detail || d.error);
|
||||
}
|
||||
} catch(e) { result.textContent = '❌ ' + e.message; }
|
||||
}
|
||||
|
||||
function copyTemplate() {
|
||||
const text = `# 语料库模板 v2.0
|
||||
# 分类 + 评论(一维)
|
||||
categories:
|
||||
赞美:
|
||||
comments:
|
||||
- "讲得太好了,受益匪浅!"
|
||||
提问:
|
||||
comments:
|
||||
- "请问这是在哪里?"
|
||||
|
||||
# 身份 + 场景 + 内容(三维)
|
||||
personas:
|
||||
health_lover:
|
||||
name: "养生爱好者"
|
||||
tags: ["养生","健康"]
|
||||
scenes:
|
||||
first_comment:
|
||||
label: "首次评论"
|
||||
content:
|
||||
health_lover.first_comment:
|
||||
- "这个养生方法很实用"
|
||||
health_lover.follow_up:
|
||||
round_1:
|
||||
- "确实如此,{keyword}我也是这么认为的"
|
||||
round_2:
|
||||
- "对,后来我换了一种方法就好了"`;
|
||||
navigator.clipboard.writeText(text).then(() => alert('✅ 已复制模板')).catch(() => alert('复制失败,请手动选中复制'));
|
||||
}
|
||||
|
||||
let _corpusModalOpen = false;
|
||||
|
||||
function corpusShowDetail(platform, category) {
|
||||
if (_corpusModalOpen) return;
|
||||
_corpusModalOpen = true;
|
||||
|
||||
// Create modal
|
||||
const modal = document.createElement('div');
|
||||
modal.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;z-index:9999';
|
||||
modal.innerHTML = `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:20px;max-width:600px;width:90%;max-height:70vh;display:flex;flex-direction:column">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<span style="font-weight:600;font-size:15px">📚 ${platform}/${category}</span>
|
||||
<span style="cursor:pointer;font-size:20px" onclick="this.closest('div[style*=\\'fixed\\']').remove();_corpusModalOpen=false">✕</span>
|
||||
</div>
|
||||
<div id="corpusDetailBody" style="flex:1;overflow-y:auto;padding:4px 0">
|
||||
<div style="text-align:center;color:var(--text2);padding:20px">加载中...</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;display:flex;gap:6px">
|
||||
<input id="corpusDetailAddText" placeholder="添加评论..." style="flex:1">
|
||||
<button class="btn btn-primary btn-sm" onclick="corpusDetailAdd()">添加</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
|
||||
// Load comments
|
||||
fetch(`/api/matrix/corpus/detail?platform=${platform}&category=${encodeURIComponent(category)}`)
|
||||
.then(r => r.json())
|
||||
.then(d => {
|
||||
const body = document.getElementById('corpusDetailBody');
|
||||
if (!body) return;
|
||||
const allComments = [...(d.comments||[]).map((c,i) => ({type:'comment', text:c, index:i})),
|
||||
...(d.templates||[]).map((t,i) => ({type:'template', text:t, index:i+999}))];
|
||||
if (!allComments.length) {
|
||||
body.innerHTML = '<div style="text-align:center;padding:20px;color:var(--text2)">暂无评论</div>';
|
||||
return;
|
||||
}
|
||||
body.innerHTML = allComments.map((item,i) => `<div style="display:flex;align-items:center;gap:6px;padding:5px 0;border-bottom:1px solid var(--border)">
|
||||
<span style="font-size:10px;color:var(--text2);width:40px;flex-shrink:0">#${i+1}${item.type==='template'?' 📝':''}</span>
|
||||
<span style="flex:1;font-size:12px">${item.text}</span>
|
||||
<span style="cursor:pointer;color:var(--red);font-size:14px;opacity:.5" onclick="corpusDetailDelete('${platform}','${category}',${item.index >= 999 ? -1 : item.index})">✕</span>
|
||||
</div>`).join('');
|
||||
})
|
||||
.catch(e => {
|
||||
const body = document.getElementById('corpusDetailBody');
|
||||
if (body) body.innerHTML = `<div style="color:var(--red);text-align:center;padding:20px">❌ ${e.message}</div>`;
|
||||
});
|
||||
}
|
||||
|
||||
async function corpusDetailAdd() {
|
||||
const input = document.getElementById('corpusDetailAddText');
|
||||
if (!input || !input.value.trim()) return;
|
||||
const modal = input.closest('div[style*="fixed"]');
|
||||
const platform = modal?.querySelector('span')?.textContent?.split('/')[0]?.replace('📚 ','') || 'douyin';
|
||||
const category = modal?.querySelector('span')?.textContent?.split('/')[1] || '';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/corpus/add', {method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({platform, category, text: input.value.trim()})});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
input.value = '';
|
||||
corpusShowDetail(platform, category);
|
||||
loadCorpus();
|
||||
}
|
||||
} catch(e) { alert(e.message); }
|
||||
}
|
||||
|
||||
async function corpusDetailDelete(platform, category, index) {
|
||||
if (index === -1) { alert('模板暂不支持看板删除,请使用 CLI'); return; }
|
||||
if (!confirm(`删除第 ${index+1} 条评论?`)) return;
|
||||
try {
|
||||
const r = await fetch('/api/matrix/corpus/delete', {method:'POST',headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({platform, category, index})});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
// Close old modal, reopen
|
||||
document.querySelector('div[style*="fixed"]')?.remove();
|
||||
_corpusModalOpen = false;
|
||||
corpusShowDetail(platform, category);
|
||||
loadCorpus();
|
||||
}
|
||||
} catch(e) { alert(e.message); }
|
||||
}
|
||||
|
||||
// ── Git Sync ──
|
||||
async function gitSync() {
|
||||
var btn = document.querySelector('[onclick="gitSync()"]');
|
||||
if (!btn) return;
|
||||
btn.textContent = 'sync...';
|
||||
btn.style.opacity = '0.5';
|
||||
try {
|
||||
var r = await fetch('/api/git-sync', {method: 'POST'});
|
||||
var res = await r.json();
|
||||
btn.textContent = 'done';
|
||||
setTimeout(function(){ btn.textContent = 'sync'; btn.style.opacity = '1'; }, 2000);
|
||||
if (typeof loadMachines === 'function') loadMachines();
|
||||
if (typeof loadStats === 'function') loadStats();
|
||||
} catch(e) {
|
||||
btn.textContent = 'err';
|
||||
setTimeout(function(){ btn.textContent = 'sync'; btn.style.opacity = '1'; }, 2000);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 账号注册 ──
|
||||
async function accountRegister() {
|
||||
const plat = document.getElementById('regPlatform')?.value;
|
||||
const phone = document.getElementById('regPhone')?.value;
|
||||
const nick = document.getElementById('regNickname')?.value;
|
||||
const statusEl = document.getElementById('regStatus');
|
||||
if (!phone) { alert('请输入手机号'); return; }
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--text2)">⏳ 注册中...</span>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/accounts/register', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({platform:plat, phone:phone, nickname:nick})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ '+d.message+'</span>'+
|
||||
'<div style="margin-top:4px;font-size:10px;color:var(--text2)">账号ID: '+d.account_id+' | 登录后可以采集昵称</div>';
|
||||
document.getElementById('regPhone').value = '';
|
||||
document.getElementById('regNickname').value = '';
|
||||
setTimeout(loadSmsAccounts, 2000);
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+(d.error||'注册失败')+'</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>';
|
||||
}
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.accountRegister = accountRegister;
|
||||
window.copyTemplate = copyTemplate;
|
||||
window.corpusAdd = corpusAdd;
|
||||
window.corpusBatchImport = corpusBatchImport;
|
||||
window.corpusDetailAdd = corpusDetailAdd;
|
||||
window.corpusDetailDelete = corpusDetailDelete;
|
||||
window.corpusShowDetail = corpusShowDetail;
|
||||
window.gitSync = gitSync;
|
||||
window.loadCorpus = loadCorpus;
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* modules/project-store.js — 导演工作室 · 项目状态统一读写
|
||||
*
|
||||
* 为什么要它:
|
||||
* 原先进程内靠 localStorage 传参(5 个 key 散在 4 个文件):
|
||||
* ave_creative_handoff / ave_dir_cast_v1 / ave_dir_suggest_v1 /
|
||||
* ave_char_pending / ave_dir_cast_bind
|
||||
* → 清缓存就丢、各写各的、无版本、不可追溯
|
||||
*
|
||||
* 现在:服务端 project.json 是**单一真相源**;localStorage 只作过渡期镜像
|
||||
* (旧页面仍读 localStorage,双写保证它们不坏)
|
||||
*
|
||||
* 用法:
|
||||
* import { loadProject, patchProject, setStage, getProject, on } from '../modules/project-store.js';
|
||||
* await loadProject(pid);
|
||||
* patchProject({ setting: { tone: '平静' } }); // 自动带上当前 pid
|
||||
*/
|
||||
|
||||
const API = '/api/ave/project';
|
||||
|
||||
// 过渡期镜像(旧页面读这些 key,双写保证兼容)
|
||||
const MIRROR_KEYS = {
|
||||
cast: 'ave_dir_cast_v1',
|
||||
creative_handoff: 'ave_creative_handoff',
|
||||
};
|
||||
|
||||
let _pid = null;
|
||||
let _project = null;
|
||||
let _stages = [];
|
||||
const _listeners = new Set();
|
||||
|
||||
// ── 内部 ────────────────────────────────────────────────────
|
||||
function _emit(evt) { _listeners.forEach(fn => { try { fn(evt, _project); } catch (e) {} }); }
|
||||
|
||||
async function _req(path, opt) {
|
||||
const r = await fetch(API + path, opt);
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
|
||||
return d;
|
||||
}
|
||||
|
||||
function _mirror(project) {
|
||||
// 只镜像旧页面仍在用的字段,避免污染
|
||||
try {
|
||||
if (Array.isArray(project.cast)) {
|
||||
localStorage.setItem(MIRROR_KEYS.cast, JSON.stringify(project.cast));
|
||||
}
|
||||
} catch (e) { /* 隐私模式等,忽略 */ }
|
||||
}
|
||||
|
||||
// ── 对外 ────────────────────────────────────────────────────
|
||||
/** 订阅变更(返回取消订阅函数) */
|
||||
export function on(fn) { _listeners.add(fn); return () => _listeners.delete(fn); }
|
||||
|
||||
/** 当前项目对象(同步读,零延迟) */
|
||||
export function getProject() { return _project; }
|
||||
|
||||
/** 当前项目 ID */
|
||||
export function getPid() { return _pid; }
|
||||
|
||||
/** 流程阶段定义 */
|
||||
export function getStages() { return _stages; }
|
||||
|
||||
/** 加载项目(不存在则返回空白骨架,不落盘) */
|
||||
export async function loadProject(pid) {
|
||||
_pid = pid;
|
||||
const d = await _req('/' + encodeURIComponent(pid));
|
||||
_project = d.project || null;
|
||||
_stages = d.stages || [];
|
||||
_mirror(_project || {});
|
||||
_emit('load');
|
||||
return _project;
|
||||
}
|
||||
|
||||
/** 局部更新(深度合并;服务端为准 + 镜像 localStorage) */
|
||||
export async function patchProject(patch) {
|
||||
if (!_pid) throw new Error('未选择项目');
|
||||
const d = await _req('/' + encodeURIComponent(_pid), {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(patch),
|
||||
});
|
||||
_project = d.project || _project;
|
||||
_mirror(_project || {});
|
||||
_emit('patch');
|
||||
return _project;
|
||||
}
|
||||
|
||||
/** 切换阶段 */
|
||||
export async function setStage(stage) {
|
||||
if (!_pid) throw new Error('未选择项目');
|
||||
await _req('/' + encodeURIComponent(_pid) + '/stage', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ stage }),
|
||||
});
|
||||
if (_project) _project.stage = stage;
|
||||
_emit('stage');
|
||||
return stage;
|
||||
}
|
||||
|
||||
/** 项目列表 */
|
||||
export async function listProjects() {
|
||||
const d = await _req('/list');
|
||||
return d.projects || [];
|
||||
}
|
||||
|
||||
/** 任意路径的 JSON 请求(错误信息尽量透出 detail) */
|
||||
export async function fetchJSON(path, opt) {
|
||||
const r = await fetch(path, opt);
|
||||
let d = {};
|
||||
try { d = await r.json(); } catch (e) { /* 非 JSON */ }
|
||||
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
|
||||
return d;
|
||||
}
|
||||
|
||||
/** 便捷读:某一层的值(带默认) */
|
||||
export function slice(key, dflt = {}) {
|
||||
const v = _project && _project[key];
|
||||
return (v === undefined || v === null) ? dflt : v;
|
||||
}
|
||||
|
||||
/** 从旧的 localStorage 迁移一次(首次打开时把旧数据搬进项目对象) */
|
||||
export async function migrateFromLocal() {
|
||||
const out = {};
|
||||
try {
|
||||
const handoff = localStorage.getItem(MIRROR_KEYS.creative_handoff);
|
||||
if (handoff) out.creative = { handoff: JSON.parse(handoff) };
|
||||
} catch (e) {}
|
||||
try {
|
||||
const cast = localStorage.getItem(MIRROR_KEYS.cast);
|
||||
if (cast) out.cast = JSON.parse(cast);
|
||||
} catch (e) {}
|
||||
try {
|
||||
const sug = localStorage.getItem('ave_dir_suggest_v1');
|
||||
if (sug) out.storyboard = { suggest: JSON.parse(sug) };
|
||||
} catch (e) {}
|
||||
return out;
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// Operation Router — 统一操作路由 (本地/远程)
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
function _getAccountById(accountId) {
|
||||
// 优先从 _lastSmsAccounts 找(SMS代理视图加载后的缓存)
|
||||
var fromCache = (window._lastSmsAccounts || []).find(function(a){return a.id===accountId;});
|
||||
if (fromCache) return fromCache;
|
||||
// 兜底: 从 accounts API 查(Vite视图打开时 _lastSmsAccounts 可能为空)
|
||||
if (window._allAccounts) {
|
||||
return window._allAccounts.find(function(a){return a.id===accountId;});
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function _getMachineStatus(machineName) {
|
||||
try {
|
||||
var r = await fetch('/api/federation/machine-status/'+encodeURIComponent(machineName));
|
||||
return await r.json();
|
||||
} catch(e) {
|
||||
return {status: 'error', message: '查询失败'};
|
||||
}
|
||||
}
|
||||
|
||||
async function _routeOperation(accountId, opType, params) {
|
||||
var account = _getAccountById(accountId);
|
||||
if (!account) return {status: 'error', message: '账号不存在'};
|
||||
|
||||
var targetMachine = account.owner_machine || (account.is_local ? window.location.hostname : '远程');
|
||||
var isLocal = !targetMachine || targetMachine === window.location.hostname;
|
||||
var statusEl = document.getElementById('collectProfileStatus');
|
||||
|
||||
if (!isLocal) {
|
||||
// 远程执行
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 通过 '+targetMachine+' 远程执行...';
|
||||
var ep = '';
|
||||
var body = {machine: targetMachine, account: accountId};
|
||||
if (opType === 'login') ep = '/api/federation/login';
|
||||
else if (opType === 'logout') ep = '/api/federation/logout';
|
||||
else if (opType === 'comment') { ep = '/api/federation/comment'; body.url = params.url; if (params.direction) body.direction = params.direction; }
|
||||
else if (opType === 'nurture') { ep = '/api/federation/nurture'; body.accounts = [accountId]; body.blueprints = params.blueprints || ['douyin_daily']; body.rounds = params.rounds || 3; }
|
||||
else if (opType === 'collect') { ep = '/api/federation/collect'; body.phone = params.phone || ''; }
|
||||
else return {status: 'error', message: '未知操作类型: '+opType};
|
||||
|
||||
try {
|
||||
var r = await fetch(ep, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
|
||||
return await r.json();
|
||||
} catch(e) {
|
||||
return {status: 'error', message: e.message};
|
||||
}
|
||||
}
|
||||
|
||||
// 本地执行
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 正在执行...';
|
||||
if (opType === 'login') {
|
||||
var r = await fetch('/api/matrix/accounts/'+accountId+'/login', {method:'POST'});
|
||||
return await r.json();
|
||||
}
|
||||
if (opType === 'logout') {
|
||||
var r = await fetch('/api/matrix/accounts/'+accountId+'/clear-cookies', {method:'POST'});
|
||||
return await r.json();
|
||||
}
|
||||
return {status: 'error', message: '本地不支持操作: '+opType};
|
||||
}
|
||||
|
||||
// ── 账号登录(带操作路由)──
|
||||
async function accountLogin(accountId) {
|
||||
if (!accountId) return;
|
||||
var account = _getAccountById(accountId);
|
||||
if (!account) return;
|
||||
var machine = account.owner_machine || (account.is_local ? '本机' : '远程');
|
||||
if (!confirm('打开浏览器登录 '+accountId+' ?('+machine+')')) return;
|
||||
var result = await _routeOperation(accountId, 'login', {});
|
||||
var statusEl = document.getElementById('collectProfileStatus');
|
||||
if (statusEl) {
|
||||
if (result.status === 'ok') {
|
||||
statusEl.innerHTML = '<span style="color:var(--green)">✅ 登录命令已发送到 '+machine+'</span>';
|
||||
} else {
|
||||
statusEl.innerHTML = '<span style="color:var(--red)">❌ '+(result.message||result.error||'失败')+'</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 加载 accounts API 缓存(供 _getAccountById 兜底)──
|
||||
(async function() {
|
||||
try {
|
||||
var r = await fetch('/api/matrix/accounts');
|
||||
var d = await r.json();
|
||||
window._allAccounts = Array.isArray(d) ? d : (d.accounts || []);
|
||||
} catch(e) { /* 静默 */ }
|
||||
})();
|
||||
|
||||
// ── 清除登录状态(带操作路由)──
|
||||
async function clearCookies(accountId, platform) {
|
||||
if (!accountId) return;
|
||||
var account = _getAccountById(accountId);
|
||||
if (!account) return;
|
||||
var machine = account.owner_machine || (account.is_local ? '本机' : '远程');
|
||||
var platName = platform === 'xiaohongshu' ? '小红书' : '抖音';
|
||||
if (!confirm('清除 '+accountId+' ('+platName+') 的登录状态?('+machine+')')) return;
|
||||
var result = await _routeOperation(accountId, 'logout', {});
|
||||
var statusEl = document.getElementById('collectProfileStatus');
|
||||
if (statusEl) {
|
||||
if (result.status === 'ok' || result.returncode === 0) {
|
||||
statusEl.innerHTML = '<span style="color:var(--green)">✅ 清除命令已发送到 '+machine+'</span>';
|
||||
loadSmsAccounts().catch(function(){});
|
||||
} else {
|
||||
statusEl.innerHTML = '<span style="color:var(--red)">❌ '+(result.message||result.error||'清除失败')+'</span>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── 全部登录 / 全部采集 / 删除整个身份 ──
|
||||
async function loginAllPlatforms(phone) {
|
||||
const accts = (window._lastSmsAccounts || []).filter(a => a.is_local && a.phone === phone);
|
||||
if (!accts.length) return;
|
||||
if (!confirm('打开浏览器登录 '+phone+' 下所有平台(共 '+accts.length+' 个)?')) return;
|
||||
const statusEl = document.getElementById('collectProfileStatus');
|
||||
for (const a of accts) {
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 正在登录 '+a.id+'...';
|
||||
try {
|
||||
await fetch('/api/matrix/accounts/'+a.id+'/login', { method:'POST' });
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 全部登录已启动</span>';
|
||||
}
|
||||
|
||||
async function collectAllPlatforms(phone) {
|
||||
const accts = (window._lastSmsAccounts || []).filter(a => a.is_local && a.phone === phone);
|
||||
if (!accts.length) return;
|
||||
if (!confirm('📡 采集 ' + phone + ' 下的 ' + accts.length + ' 个平台?')) return;
|
||||
const statusEl = document.getElementById('collectProfileStatus');
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 启动采集(仅该身份,不分批)...';
|
||||
|
||||
try {
|
||||
// 按手机号采集已迁移到统一入口,通过 /api/matrix/accounts 获取 account_id 后调 /api/ops/run
|
||||
const accts = await (await fetch('/api/matrix/accounts')).json();
|
||||
const accountList = Array.isArray(accts) ? accts : (accts.accounts || []);
|
||||
const targetIds = accountList.filter(a => a.phone === phone).map(a => a.id);
|
||||
if (!targetIds.length) { if (statusEl) statusEl.innerHTML = '❌ 未找到手机号对应的账号'; return; }
|
||||
const r = await fetch('/api/ops/run', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({type:'collect', accounts:targetIds, params:{rounds:1}})
|
||||
});
|
||||
const data = await r.json();
|
||||
if (data.status === 'accepted') {
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 采集任务已提交(' + targetIds.join(',') + ')...';
|
||||
const runId = data.commands?.[0]?.run_id;
|
||||
if (runId) {
|
||||
const poll = setInterval(async () => {
|
||||
try {
|
||||
const pr = await fetch('/api/ops/status');
|
||||
const ps = await pr.json();
|
||||
const cmds = Array.isArray(ps) ? ps : (ps.commands || []);
|
||||
const cmd = cmds.find(c => c.run_id === runId);
|
||||
if (cmd && ['completed','failed','crashed','cancelled','timed_out'].includes(cmd.status)) {
|
||||
clearInterval(poll);
|
||||
if (statusEl) statusEl.innerHTML = cmd.status === 'completed'
|
||||
? '<span style="color:var(--green)">✅ 采集完成</span>'
|
||||
: '<span style="color:var(--red)">❌ ' + cmd.status + '</span>';
|
||||
loadSmsAccounts();
|
||||
} else if (cmd) {
|
||||
if (statusEl) statusEl.innerHTML = '⏳ ' + (cmd.status||'running') + '...';
|
||||
}
|
||||
} catch(e) { /* silent */ }
|
||||
}, 5000);
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 已提交</span>';
|
||||
}
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ ' + (data.error||data.message) + '</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ ' + e.message + '</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteIdentityByPhone(phone) {
|
||||
const accts = (window._lastSmsAccounts || []).filter(a => a.is_local && a.phone === phone);
|
||||
if (!accts.length) return;
|
||||
const msg = '⚠️ 确定要删除 '+phone+' 下的所有账号?\n\n将删除 '+accts.length+' 个账号:\n' + accts.map(a => ' - '+a.id+' ('+a.platform+')').join('\n')+'\n\n以及共享身份目录。';
|
||||
if (!confirm(msg)) return;
|
||||
const statusEl = document.getElementById('collectProfileStatus');
|
||||
for (const a of accts) {
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 正在删除 '+a.id+'...';
|
||||
try {
|
||||
await fetch('/api/matrix/accounts/'+a.id+'?delete_identity=true', { method:'DELETE' });
|
||||
await fetch('/api/matrix/accounts/'+a.id+'/cleanup', { method:'POST' });
|
||||
} catch(e) { /* ignore */ }
|
||||
}
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 已删除 '+phone+' 下所有账号</span>';
|
||||
loadSmsAccounts();
|
||||
}
|
||||
|
||||
// ── 删除账号 ──
|
||||
async function deleteAccount(accountId) {
|
||||
if (!accountId) return;
|
||||
if (!confirm('确定删除账号 '+accountId+' ?\n身份目录和配置将被清理,不可恢复。')) return;
|
||||
const statusEl = document.getElementById('collectProfileStatus');
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 正在删除...';
|
||||
try {
|
||||
// app.py 删除(有 bug,不清理 accounts.yaml)
|
||||
const r = await fetch('/api/matrix/accounts/'+accountId+'?delete_identity=true', { method:'DELETE' });
|
||||
const d = await r.json();
|
||||
// 补清理 accounts.yaml + registry
|
||||
await fetch('/api/matrix/accounts/'+accountId+'/cleanup', { method:'POST' });
|
||||
if (d.status === 'ok') {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 已删除,刷新中...</span>';
|
||||
// 立即刷新 + 1秒后重试保障
|
||||
loadSmsAccounts().catch(e => console.error('refresh err', e));
|
||||
setTimeout(() => loadSmsAccounts().catch(e => {}), 1000);
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+(d.error||'失败')+'</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// ── 采集昵称 ──
|
||||
async function collectProfile(accountId) {
|
||||
if (!accountId) { alert('请先选择一个账号'); return; }
|
||||
const statusEl = document.getElementById('collectProfileStatus');
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 正在采集昵称(会打开浏览器)…';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/accounts/'+accountId+'/collect-profile', { method:'POST' });
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ '+d.message+'</span>';
|
||||
// 立即刷新 + 3秒后重试保障
|
||||
loadSmsAccounts().catch(e => {});
|
||||
setTimeout(() => loadSmsAccounts().catch(e => {}), 3000);
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+(d.error||'采集失败')+'</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>';
|
||||
}
|
||||
}
|
||||
|
||||
// 定向评论任务已合并到 cmdRunCommentTask(命令与任务页面)
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 录制管理 — 列表 / 详情 / 导出
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
async function loadMatrixRecord() {
|
||||
// 先填充账号下拉框
|
||||
try {
|
||||
var acctR = await fetch('/api/matrix/accounts');
|
||||
var acctD = await acctR.json();
|
||||
var accts = Array.isArray(acctD) ? acctD : (acctD.accounts || []);
|
||||
var sel = document.getElementById('recordAccountSelect');
|
||||
if (sel) {
|
||||
sel.innerHTML = '<option value="">选择账号…</option>';
|
||||
accts.forEach(function(a) {
|
||||
sel.innerHTML += '<option value="'+a.id+'">'+a.id+'</option>';
|
||||
});
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
var listEl = document.getElementById('recordList');
|
||||
if (!listEl) return;
|
||||
listEl.innerHTML = '<div class="loading">⏳ 加载录制包...</div>';
|
||||
|
||||
try {
|
||||
var r = await fetch('/api/matrix/record/list');
|
||||
var d = await r.json();
|
||||
var recs = d.recordings || [];
|
||||
|
||||
if (!recs.length) {
|
||||
listEl.innerHTML = '<div style="padding:20px;text-align:center;color:var(--text2)">暂无录制包。选择账号后点击「开始录制」。</div>';
|
||||
document.getElementById('recordDetail').innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
var html = '<div style="display:grid;gap:4px">';
|
||||
recs.forEach(function(rec) {
|
||||
var created = (rec.created || '').slice(0, 16).replace('T', ' ');
|
||||
html += '<div class="record-item" data-name="'+rec.name+'" onclick="recordingDetail(this.dataset.name)" style="padding:6px 10px;cursor:pointer;border-radius:4px;background:var(--bg2);display:flex;justify-content:space-between;font-size:12px">';
|
||||
html += '<span><b>'+rec.account+'</b> · '+rec.platform+' · '+rec.steps+'步 · '+rec.duration+'s</span>';
|
||||
html += '<span style="color:var(--text2)">'+created+' <span style="color:var(--text2)">'+rec.size_kb+'KB</span></span>';
|
||||
html += '</div>';
|
||||
});
|
||||
html += '</div>';
|
||||
listEl.innerHTML = html;
|
||||
} catch(e) {
|
||||
listEl.innerHTML = '<div style="color:var(--red);padding:10px">❌ 加载失败: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function recordingStart() {
|
||||
var sel = document.getElementById('recordAccountSelect');
|
||||
var aid = sel ? sel.value : '';
|
||||
if (!aid) { alert('请先选择账号'); return; }
|
||||
|
||||
// 先确认账号有没有登录(浏览器打开)
|
||||
try {
|
||||
var acctR = await fetch('/api/matrix/accounts');
|
||||
var acctD = await acctR.json();
|
||||
var accts = Array.isArray(acctD) ? acctD : (acctD.accounts || []);
|
||||
var acct = accts.find(function(a){return a.id===aid;});
|
||||
if (acct && !acct.is_login && !acct.is_local) {
|
||||
if (!confirm('账号 '+aid+' 可能未登录。是否先登录?')) return;
|
||||
await accountLogin(aid);
|
||||
}
|
||||
} catch(e) {}
|
||||
|
||||
document.getElementById('recordStartBtn').style.display = 'none';
|
||||
document.getElementById('recordStopBtn').style.display = 'inline-block';
|
||||
document.getElementById('recordStatus').innerHTML = '⏳ 正在启动录制...';
|
||||
try {
|
||||
var r = await fetch('/api/matrix/accounts/'+aid+'/record', {method:'POST'});
|
||||
var d = await r.json();
|
||||
document.getElementById('recordStatus').innerHTML = '<span style="color:var(--green)">🎬 录制中... 操作浏览器,然后按反引号 `·` 标记步骤(截图),Esc 结束保存</span>';
|
||||
} catch(e) {
|
||||
document.getElementById('recordStatus').innerHTML = '<span style="color:var(--green)">🎬 录制已启动(操作浏览器后按数字键标记步骤,0=结束)</span>';
|
||||
}
|
||||
}
|
||||
|
||||
async function recordingStop() {
|
||||
document.getElementById('recordStatus').innerHTML = '⏳ 刷新录制列表...';
|
||||
document.getElementById('recordStartBtn').style.display = 'inline-block';
|
||||
document.getElementById('recordStopBtn').style.display = 'none';
|
||||
try {
|
||||
loadMatrixRecord();
|
||||
document.getElementById('recordStatus').innerHTML = '<span style="color:var(--green)">✅ 录制已保存</span>';
|
||||
} catch(e) {
|
||||
document.getElementById('recordStatus').innerHTML = '<span style="color:var(--text2)">💡 在浏览器中按 0 键结束录制并保存</span>';
|
||||
}
|
||||
}
|
||||
|
||||
var _recordLabels = {}; // step_num → {op, args}
|
||||
|
||||
async function recordingDetail(name) {
|
||||
var detailEl = document.getElementById('recordDetail');
|
||||
if (!detailEl) return;
|
||||
detailEl.innerHTML = '<div class="loading">⏳ 加载录制详情...</div>';
|
||||
|
||||
try {
|
||||
var r = await fetch('/api/matrix/record/detail/'+encodeURIComponent(name));
|
||||
var d = await r.json();
|
||||
var meta = d.meta || {};
|
||||
var steps = d.steps || [];
|
||||
var analysis = d.analysis || {};
|
||||
|
||||
_recordLabels = {};
|
||||
var html = '<div style="background:var(--bg2);padding:10px;border-radius:6px;margin-top:8px">';
|
||||
html += '<div style="display:flex;justify-content:space-between;margin-bottom:8px;font-size:12px;color:var(--text2)">';
|
||||
html += '<span>账号: <b>'+meta.account_id+'</b> · 平台: '+meta.platform+' · 共 '+steps.length+' 步</span>';
|
||||
html += '<span style="display:flex;gap:4px">';
|
||||
html += '<button onclick="recordingExport(\''+name+'\')" style="background:#6366f1;color:#fff;border:none;padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">📦 导出为蓝图</button>';
|
||||
html += '<button onclick="if(confirm(\'确定删除?\')){fetch(\'/api/matrix/record/delete\',{method:\'POST\',headers:{\'Content-Type\':\'application/json\'},body:JSON.stringify({name:\''+name+'\'})}).then(function(){loadMatrixRecord();document.getElementById(\'recordDetail\').innerHTML=\'\';})}" style="background:rgba(220,38,38,.08);color:var(--red);border:1px solid var(--red);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">🗑 删除</button>';
|
||||
html += '</span></div>';
|
||||
|
||||
// 分析建议
|
||||
var actions = analysis.actions || [];
|
||||
var actMap = {};
|
||||
actions.forEach(function(a) {
|
||||
var idx = a.step_after || a.step_before;
|
||||
actMap[idx] = a;
|
||||
});
|
||||
|
||||
// 每一步
|
||||
var opOptions = ['skip','goto_home','like','collect','follow','comment','open_comments','close_comments','post_comment','search','scroll_feed','wait_watch','next_video','prev_video','go_back','open_video','xhs_like','xhs_collect','xhs_comment','xhs_follow','xhs_scroll_feed','xhs_click_note','xhs_search'];
|
||||
|
||||
steps.forEach(function(step, i) {
|
||||
var sn = step.step || (i+1);
|
||||
var pg = step.page || {};
|
||||
var suggests = actMap[sn] ? (actMap[sn].action_type || '') : '';
|
||||
var suggestText = actMap[sn] ? (actMap[sn].action_desc || suggests) : '';
|
||||
|
||||
html += '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:6px;font-size:12px">';
|
||||
html += '<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">';
|
||||
html += '<span><b>步骤 '+sn+'</b> <span style="color:var(--text2)">'+pg.page_mode+'</span> <span style="color:var(--text2);font-size:11px">'+(pg.url||'').slice(0,60)+'</span></span>';
|
||||
html += '<span>';
|
||||
if (suggests) html += '<span style="color:#6366f1;font-size:11px;margin-right:6px">💡 '+suggestText+'</span>';
|
||||
html += '<select id="rec_op_'+sn+'" onchange="_recordLabels['+sn+']={op:this.value}" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:2px 6px;border-radius:4px;font-size:11px">';
|
||||
html += '<option value="">— 选择操作 —</option>';
|
||||
opOptions.forEach(function(op) {
|
||||
var sel = (suggests === op || suggests === op.replace('xhs_', '').replace('dy_', '')) ? ' selected' : '';
|
||||
html += '<option value="'+op+'"'+sel+'>'+op+'</option>';
|
||||
});
|
||||
html += '</select>';
|
||||
html += '</span></div>';
|
||||
|
||||
if (step.events_since_last > 0) {
|
||||
html += '<div style="color:var(--text2);font-size:11px">事件: '+step.events_since_last+' 个</div>';
|
||||
}
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
detailEl.innerHTML = html;
|
||||
} catch(e) {
|
||||
detailEl.innerHTML = '<div style="color:var(--red);padding:10px">❌ 加载失败: '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function recordingExport(name) {
|
||||
var labels = [];
|
||||
Object.keys(_recordLabels).forEach(function(sn) {
|
||||
var sel = document.getElementById('rec_op_'+sn);
|
||||
if (sel && sel.value) {
|
||||
labels.push({step: parseInt(sn), op: sel.value});
|
||||
}
|
||||
});
|
||||
if (!labels.length) {
|
||||
alert('请先为至少一步选择操作类型');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
var r = await fetch('/api/matrix/record/export', {
|
||||
method:'POST',
|
||||
headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({name:name, labels:labels, blueprint_name: name.replace('recording_','bp_')})
|
||||
});
|
||||
var d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
alert('✅ 蓝图已生成: '+d.blueprint+' ('+d.steps+'步)');
|
||||
loadMatrixRecord();
|
||||
document.getElementById('recordDetail').innerHTML = '';
|
||||
} else {
|
||||
alert('❌ 导出失败: '+(d.message||d.error));
|
||||
}
|
||||
} catch(e) {
|
||||
alert('❌ 导出异常: '+e.message);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.accountLogin = accountLogin;
|
||||
window.loadMatrixRecord = loadMatrixRecord;
|
||||
window.recordingStart = recordingStart;
|
||||
window.recordingStop = recordingStop;
|
||||
window.recordingDetail = recordingDetail;
|
||||
window.recordingExport = recordingExport;
|
||||
window._recordLabels = _recordLabels;
|
||||
window.clearCookies = clearCookies;
|
||||
window.collectAllPlatforms = collectAllPlatforms;
|
||||
window.collectProfile = collectProfile;
|
||||
window.deleteAccount = deleteAccount;
|
||||
window.deleteIdentityByPhone = deleteIdentityByPhone;
|
||||
window.loginAllPlatforms = loginAllPlatforms;
|
||||
window._getAccountById = _getAccountById;
|
||||
window._routeOperation = _routeOperation;
|
||||
@@ -0,0 +1,860 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// Batch Execution View
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
async function loadMatrixRun() {
|
||||
const el = document.getElementById('matrixRunContent');
|
||||
const meta = document.getElementById('batchRunMeta');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">加载中...</div>';
|
||||
|
||||
try {
|
||||
// Load accounts + blueprints + corpus in parallel
|
||||
const [ar, br, cr] = await Promise.all([
|
||||
fetch('/api/matrix/accounts'),
|
||||
fetch('/api/matrix/blueprints'),
|
||||
fetch('/api/matrix/corpus'),
|
||||
]);
|
||||
const ad = await ar.json();
|
||||
const bd = await br.json();
|
||||
const cd = await cr.json();
|
||||
|
||||
const accounts = (ad.accounts || []).filter(a => a.is_local || a._status === 'logged_in');
|
||||
const blueprints = bd.blueprints || [];
|
||||
const corpusCats = cd.categories || [];
|
||||
|
||||
meta.textContent = `${accounts.length} 可用账号 · ${blueprints.length} 蓝图`;
|
||||
|
||||
let html = '';
|
||||
|
||||
// ── 账号选择(按平台分组)──
|
||||
const douyinAccts = accounts.filter(a => a.platform === 'douyin');
|
||||
const xhsAccts = accounts.filter(a => a.platform === 'xiaohongshu');
|
||||
const platGroups = [['🎵 抖音', douyinAccts], ['📕 小红书', xhsAccts]].filter(g => g[1].length);
|
||||
|
||||
html += `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:10px">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:8px">👥 选择账号</div>${
|
||||
platGroups.map(([label, list]) =>
|
||||
`<div style="font-size:11px;color:var(--text2);margin:4px 0 2px">${label}</div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px" class="batchAcctGroup">${
|
||||
list.map((a, idx) => {
|
||||
const s = a._status === 'logged_in' ? '🟢' : '⏸';
|
||||
const phone = String(a.phone || a.phone_mask || '??').slice(0, 11);
|
||||
return `<label style="background:var(--bg3);padding:5px 10px;border-radius:5px;border:1px solid var(--border);cursor:pointer;font-size:12px;white-space:nowrap">
|
||||
<input type="checkbox" class="batch-acct" value="${a.id}" data-platform="${a.platform}">
|
||||
${s} <strong>${a.id}</strong> <span style="color:var(--text2);font-size:10px">${phone}</span>
|
||||
</label>`;
|
||||
}).join('')
|
||||
}</div>`
|
||||
).join('')
|
||||
}</div>`;
|
||||
// 旧 forEach 已替换为上方 map 渲染
|
||||
|
||||
// ── 蓝图选择(带平台过滤) ──
|
||||
html += `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:10px">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:8px">📋 选择蓝图 <span style="font-size:11px;color:var(--text2)">(自动根据所选账号过滤平台)</span></div>
|
||||
<div style="display:flex;flex-wrap:wrap;gap:6px" id="batchBpList"></div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:4px" id="bpFilterHint">加载中...</div>
|
||||
</div>`;
|
||||
|
||||
// ── 蓝图过滤逻辑(账号选择变化时自动过滤) ──
|
||||
window._allBlueprints = blueprints;
|
||||
const allBlueprints = blueprints;
|
||||
// 蓝图列表渲染(不过滤,显示全部,平台匹配在预览时检查)
|
||||
function renderBlueprints() {
|
||||
const list = document.getElementById('batchBpList');
|
||||
const hint = document.getElementById('bpFilterHint');
|
||||
if (!list) return;
|
||||
const prevChecked = new Set([...document.querySelectorAll('.batch-bp:checked')].map(e => e.value));
|
||||
list.innerHTML = allBlueprints.map(b => {
|
||||
const checked = prevChecked.has(b.name) ? 'checked' : '';
|
||||
const platTag = b.platform ? (b.platform === 'douyin' ? '🎵' : '📕') : '❓';
|
||||
return '<label style="background:var(--bg3);padding:5px 10px;border-radius:5px;border:1px solid var(--border);cursor:pointer;font-size:12px">' +
|
||||
'<input type="checkbox" class="batch-bp" value="' + b.name + '" ' + checked + '> ' +
|
||||
b.name + ' <span class="badge badge-blue" style="font-size:9px">' + (b.step_count || '?') + '步</span> ' +
|
||||
'<span style="font-size:9px;color:var(--text2)">' + platTag + '</span></label>';
|
||||
}).join('');
|
||||
if (hint) hint.textContent = '✅ 显示全部 ' + allBlueprints.length + ' 个蓝图(平台匹配在执行时自动处理)';
|
||||
}
|
||||
|
||||
// ── 参数表单 ──
|
||||
html += `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:10px">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:8px">⚙️ 执行参数</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr 1fr 1fr;gap:8px">
|
||||
<div><label style="font-size:11px;color:var(--text2)">轮数</label><input id="bpRounds" value="5" type="number" min="1" max="100" style="width:100%"></div>
|
||||
<div><label style="font-size:11px;color:var(--text2)">轮间隔(秒)</label><input id="bpInterval" value="30-60" style="width:100%"></div>
|
||||
<div><label style="font-size:11px;color:var(--text2)">账号间延迟(秒)</label><input id="bpStagger" value="15-30" style="width:100%"></div>
|
||||
<div><label style="font-size:11px;color:var(--text2)">模式</label>
|
||||
<div style="display:flex;align-items:center;gap:6px;padding-top:4px">
|
||||
<input type="checkbox" id="bpMix" checked> <label for="bpMix" style="font-size:12px">混合随机</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:8px">
|
||||
<label style="font-size:11px;color:var(--text2)">📚 语料分类</label>
|
||||
<div id="corpusPills" style="display:flex;flex-wrap:wrap;gap:4px;margin-top:4px">
|
||||
<label style="background:var(--bg3);padding:3px 8px;border-radius:4px;border:1px solid var(--border);cursor:pointer;font-size:11px">
|
||||
<input type="checkbox" id="bpCorpusRandom" onchange="corpusToggleRandom()"> 🎲 随机
|
||||
</label>`;
|
||||
corpusCats.forEach(c => {
|
||||
if (!c.enabled) return;
|
||||
html += `<label style="background:var(--bg3);padding:3px 8px;border-radius:4px;border:1px solid var(--border);cursor:pointer;font-size:11px" class="corpus-cat-label">
|
||||
<input type="checkbox" class="batch-corpus" value="${c.name}"> ${c.name}
|
||||
</label>`;
|
||||
});
|
||||
html += `</div></div>
|
||||
<div style="margin-top:8px">
|
||||
<label style="font-size:11px;color:var(--text2)">🌐 代理策略</label>
|
||||
<select id="bpProxy" style="width:150px"><option value="auto">auto</option><option value="none">不使用</option></select>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── 命令预览 + 执行按钮 ──
|
||||
html += `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:14px;margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<span style="font-weight:600;font-size:13px">💻 命令预览</span>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button class="btn btn-outline btn-sm" onclick="batchPreview()">🔄 更新预览</button>
|
||||
<button class="btn btn-primary btn-sm" onclick="batchExecute()">▶ 执行</button>
|
||||
</div>
|
||||
</div>
|
||||
<pre id="batchCmdPreview" style="background:var(--bg3);padding:10px;border-radius:6px;font-size:12px;font-family:monospace;overflow-x:auto;margin:0">mc run --accounts ...</pre>
|
||||
</div>`;
|
||||
|
||||
// ── 执行结果 ──
|
||||
html += `<div id="batchOutput" style="display:none;background:var(--bg2);border:1px solid var(--border);border-radius:var(--radius);padding:14px">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:8px">📊 执行输出</div>
|
||||
<pre id="batchOutputContent" style="background:var(--bg3);padding:10px;border-radius:6px;font-size:11px;font-family:monospace;max-height:400px;overflow-y:auto;margin:0"></pre>
|
||||
</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
renderBlueprints();
|
||||
batchPreview();
|
||||
} catch(e) {
|
||||
el.innerHTML = `<div class="error">❌ ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Batch execution helpers
|
||||
function corpusToggleRandom() {
|
||||
const isRandom = document.getElementById('bpCorpusRandom')?.checked;
|
||||
document.querySelectorAll('.corpus-cat-label input').forEach(cb => {
|
||||
cb.disabled = isRandom;
|
||||
cb.closest('label').style.opacity = isRandom ? '0.4' : '1';
|
||||
});
|
||||
batchPreview();
|
||||
}
|
||||
|
||||
// 保存最后一次预览的选中值,供 batchExecute 使用
|
||||
var _lastPreview = { accts: [], bps: [], rounds: 5, stagger: '15-30', mix: false, corpus: [], proxy: 'auto' };
|
||||
|
||||
function batchPreview() {
|
||||
try {
|
||||
const accts = [...document.querySelectorAll('.batch-acct:checked')].map(e => e.value);
|
||||
const bps = [...document.querySelectorAll('.batch-bp:checked')].map(e => e.value);
|
||||
const rounds = document.getElementById('bpRounds')?.value || '5';
|
||||
const interval = document.getElementById('bpInterval')?.value || '30-60';
|
||||
const stagger = document.getElementById('bpStagger')?.value || '15-30';
|
||||
const mix = document.getElementById('bpMix')?.checked;
|
||||
const isRandom = document.getElementById('bpCorpusRandom')?.checked;
|
||||
const corpus = isRandom ? ['random'] : [...document.querySelectorAll('.batch-corpus:checked')].map(e => e.value);
|
||||
const proxy = document.getElementById('bpProxy')?.value || 'auto';
|
||||
|
||||
if (!accts.length) {
|
||||
document.getElementById('batchCmdPreview').textContent = '⚠️ 请先选择账号';
|
||||
return;
|
||||
}
|
||||
if (!bps.length) {
|
||||
document.getElementById('batchCmdPreview').textContent = '⚠️ 请选择至少一个蓝图';
|
||||
return;
|
||||
}
|
||||
|
||||
// 平台一致性检查
|
||||
let warning = '';
|
||||
try {
|
||||
const platforms = new Set([...document.querySelectorAll('.batch-acct:checked')].map(e => e.getAttribute('data-platform')));
|
||||
const allBps = window._allBlueprints || [];
|
||||
if (allBps.length > 0) {
|
||||
const bpPlatforms = new Set(
|
||||
[...document.querySelectorAll('.batch-bp:checked')]
|
||||
.map(e => { try { const b = allBps.find(x => x.name === e.value); return b ? b.platform : '?'; } catch(ex) { return '?'; } })
|
||||
.filter(p => p && p !== '?')
|
||||
);
|
||||
if (bpPlatforms.size === 1 && platforms.has('douyin') && bpPlatforms.has('xiaohongshu')) {
|
||||
warning = '\n⚠️ 注意:选择了抖音账号和小红书蓝图,平台不匹配!';
|
||||
} else if (bpPlatforms.size === 1 && platforms.has('xiaohongshu') && bpPlatforms.has('douyin')) {
|
||||
warning = '\n⚠️ 注意:选择了小红书账号和抖音蓝图,平台不匹配!';
|
||||
}
|
||||
}
|
||||
} catch(ex) { warning = '\n⚠️ 平台检查异常: ' + ex.message; }
|
||||
|
||||
let cmd = `mc run --accounts ${accts.join(',')}`;
|
||||
cmd += ` --blueprints ${bps.join(',')}`;
|
||||
cmd += ` --rounds ${rounds}`;
|
||||
cmd += ` --interval ${interval}`;
|
||||
cmd += ` --stagger ${stagger}`;
|
||||
if (mix) cmd += ' --mix';
|
||||
if (corpus.length) cmd += ` --corpus ${corpus.join(',')}`;
|
||||
cmd += ` --proxy ${proxy}`;
|
||||
cmd += ` --keep`;
|
||||
|
||||
// 保存到全局,供 batchExecute 使用
|
||||
_lastPreview.accts = accts;
|
||||
_lastPreview.bps = bps;
|
||||
_lastPreview.rounds = rounds;
|
||||
_lastPreview.interval = interval;
|
||||
_lastPreview.stagger = stagger;
|
||||
_lastPreview.mix = mix;
|
||||
_lastPreview.corpus = corpus;
|
||||
_lastPreview.proxy = proxy;
|
||||
document.getElementById('batchCmdPreview').textContent = cmd + warning;
|
||||
} catch(e) {
|
||||
document.getElementById('batchCmdPreview').textContent = '❌ 预览出错: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
async function batchExecute() {
|
||||
// 使用 _lastPreview 中保存的值(不重新查询 DOM)
|
||||
const accts = _lastPreview.accts;
|
||||
const bps = _lastPreview.bps;
|
||||
const output = document.getElementById('batchOutput');
|
||||
const content = document.getElementById('batchOutputContent');
|
||||
if (!output || !content) return;
|
||||
if (!accts.length || !bps.length) {
|
||||
content.textContent = '❌ 请先在预览中确认账号和蓝图已选择';
|
||||
output.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
output.style.display = 'block';
|
||||
content.textContent = '⏳ 执行中...\n';
|
||||
const rounds = _lastPreview.rounds;
|
||||
const interval = _lastPreview.interval;
|
||||
const stagger = _lastPreview.stagger;
|
||||
const mix = _lastPreview.mix;
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/matrix/batch-run', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({accounts: accts, blueprints: bps, rounds, interval, stagger, mix})
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'started') {
|
||||
content.textContent = `✅ 已启动 (PID: ${d.pid})\n\n命令:\n ${d.cmd}\n\n查看执行日志:\n tail -f /tmp/mc_run.log`;
|
||||
} else {
|
||||
content.textContent = `❌ 启动失败\n${JSON.stringify(d, null, 2)}`;
|
||||
}
|
||||
} catch(e) {
|
||||
content.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// 已合并到下方 loadMatrixAccounts2
|
||||
async function loadIdentities() {
|
||||
const el = document.getElementById('matrixIdentityContent');
|
||||
if (!el) return;
|
||||
try {
|
||||
const r = await fetch('/api/matrix/identities');
|
||||
const d = await r.json();
|
||||
const identities = d.identities || [];
|
||||
if (!identities.length) { el.innerHTML = '<div style="color:var(--text2);font-size:13px;padding:20px;text-align:center">暂无本地身份</div>'; return; }
|
||||
el.innerHTML = identities.map(id => {
|
||||
const idName = id.identity_dir;
|
||||
const phone = id.phone || '未知';
|
||||
const cookieStatus = id.has_cookie ? '🍪 已就绪' : '❌ 无Cookie';
|
||||
return `<div style="background:var(--bg2);border-radius:10px;padding:12px;border:1px solid var(--border);margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||
<div><span style="font-size:14px">📱</span> <strong style="font-size:14px">${phone}</strong>
|
||||
<span style="color:var(--text2);font-size:11px;margin-left:6px">${idName}</span>
|
||||
<span style="font-size:11px;margin-left:6px;color:${id.has_cookie?'#22c55e':'#ef4444'}">${cookieStatus}</span>
|
||||
</div>
|
||||
<button onclick="deleteIdentity('${idName}')" style="background:#ef4444;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px">🗑 删除身份</button>
|
||||
</div>
|
||||
<div style="margin-top:6px">
|
||||
${id.accounts.map(a => `<div style="display:flex;align-items:center;gap:6px;padding:4px 6px;background:var(--bg3);border-radius:4px;margin-bottom:3px;font-size:12px">
|
||||
<span style="color:${a.status==='logged_in'?'#22c55e':'#8b8fa3'}">${a.status==='logged_in'?'✅':'⏸'}</span>
|
||||
<span style="font-weight:600;width:100px">${a.id}</span>
|
||||
<span style="color:var(--text2);width:70px;font-size:11px">${a.platform}</span>
|
||||
<span style="font-size:11px;color:${a.status==='logged_in'?'#22c55e':'#f59e0b'}">${a.status}</span>
|
||||
<span style="flex:1"></span>
|
||||
${id.accounts.length > 1 ? `<button onclick="unbindAccount('${a.id}')" style="background:transparent;color:var(--amber);border:1px solid var(--border);padding:2px 8px;border-radius:3px;cursor:pointer;font-size:10px">解绑</button>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) { if (el) el.innerHTML = '<span style="color:var(--red)">'+e.message+'</span>'; }
|
||||
}
|
||||
|
||||
async function loadMatrixAccounts() {
|
||||
const el = document.getElementById('matrixAccountsContent');
|
||||
if (!el) return;
|
||||
el.innerHTML = '<div class="loading">加载中...</div>';
|
||||
try {
|
||||
let machinesInfo = '';
|
||||
try {
|
||||
const mr = await fetch('/api/matrix/cross-machines');
|
||||
const md = await mr.json();
|
||||
if (md.total_machines) {
|
||||
machinesInfo = '<div style="display:flex;gap:12px;margin-bottom:12px;font-size:12px;color:var(--text2);background:var(--bg2);border-radius:var(--radius);padding:8px 12px;border:1px solid var(--border)">'+
|
||||
'<span>🖥️ 机器: <strong>'+md.total_machines+'</strong></span>'+
|
||||
'<span>👤 账号: <strong>'+md.total_accounts+'</strong></span>'+
|
||||
'<span>📡 在线: <strong style="color:var(--green)">'+md.online_machines+'</strong></span>'+
|
||||
'</div>';
|
||||
}
|
||||
} catch(e) { /* ignore */ }
|
||||
const r = await fetch('/api/matrix/accounts');
|
||||
const d = await r.json();
|
||||
_accountsData = Array.isArray(d) ? d : (d.accounts || []);
|
||||
if (!_accountsData.length) { el.innerHTML = machinesInfo+'<div class="error">暂无账号</div>'; return; }
|
||||
el.innerHTML = machinesInfo;
|
||||
renderAccountsTable();
|
||||
} catch(e) { el.innerHTML = '<span style="color:var(--red)">'+e.message+'</span>'; }
|
||||
}
|
||||
|
||||
function acctSwitchTab(tab, el) {
|
||||
document.querySelectorAll('.acct-tab').forEach(t => t.classList.remove('active'));
|
||||
if (el) el.classList.add('active');
|
||||
document.getElementById('acct-tab-list').style.display = tab === 'list' ? 'block' : 'none';
|
||||
document.getElementById('acct-tab-identity').style.display = tab === 'identity' ? 'block' : 'none';
|
||||
if (tab === 'identity') loadIdentities();
|
||||
}
|
||||
|
||||
async function deleteIdentity(identityDir) {
|
||||
const accts = document.querySelector('#acct-tab-identity .loading');
|
||||
const msg = '⚠️ 确定要删除此身份吗?\\n\\n将删除以下内容:';
|
||||
if (!confirm(msg + '\\n- 身份目录: ' + identityDir)) return;
|
||||
try {
|
||||
const r = await fetch('/api/matrix/identities/' + encodeURIComponent(identityDir), {method:'DELETE'});
|
||||
const res = await r.json();
|
||||
if (res.status === 'ok') {
|
||||
alert('✅ 已删除身份: ' + identityDir + '\\n移除账号: ' + (res.removed_accounts||[]).join(', '));
|
||||
loadIdentities();
|
||||
} else alert('❌ ' + (res.detail||res.error));
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
}
|
||||
|
||||
async function unbindAccount(accountId) {
|
||||
if (!confirm('⚠️ 解除 ' + accountId + ' 的绑定?\\nCookie 将保留,以后可重新绑定。')) return;
|
||||
try {
|
||||
const r = await fetch('/api/matrix/accounts/' + encodeURIComponent(accountId) + '/unbind', {method:'POST'});
|
||||
const res = await r.json();
|
||||
if (res.status === 'ok') {
|
||||
alert('✅ ' + accountId + ' 已解绑');
|
||||
loadIdentities();
|
||||
} else alert('❌ ' + (res.detail||res.error));
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
}
|
||||
|
||||
function sortAccounts(col) {
|
||||
if (_accountsSort.col === col) _accountsSort.asc = !_accountsSort.asc;
|
||||
else { _accountsSort.col = col; _accountsSort.asc = true; }
|
||||
renderAccountsTable();
|
||||
}
|
||||
|
||||
function renderAccountsTable() {
|
||||
const el = document.getElementById('matrixAccountsContent');
|
||||
const sorted = [..._accountsData].sort((a, b) => {
|
||||
let va = '', vb = '';
|
||||
const c = _accountsSort.col;
|
||||
if (c === 'id') { va = a.id; vb = b.id; }
|
||||
else if (c === 'platform') { va = a.platform||''; vb = b.platform||''; }
|
||||
else if (c === 'phone') { va = String(a.phone||a.phone_mask||''); vb = String(b.phone||b.phone_mask||''); }
|
||||
else if (c === 'machine') { va = a.is_local ? '本机' : (a.owner_machine||'远程'); vb = b.is_local ? '本机' : (b.owner_machine||'远程'); }
|
||||
else if (c === 'status') { va = a._status||''; vb = b._status||''; }
|
||||
else if (c === 'display_name') { va = a.display_name||''; vb = b.display_name||''; }
|
||||
else if (c === 'identity_dir') { va = a.identity_dir||''; vb = b.identity_dir||''; }
|
||||
return _accountsSort.asc ? va.localeCompare(vb) : vb.localeCompare(va);
|
||||
});
|
||||
const arrow = (col) => _accountsSort.col === col ? (_accountsSort.asc ? ' ▲' : ' ▼') : '';
|
||||
const th = (col, label) => `<th style="cursor:pointer;user-select:none" onclick="sortAccounts('${col}')">${label}${arrow(col)}</th>`;
|
||||
|
||||
el.innerHTML = `<table><thead><tr>
|
||||
${th('id','ID')}${th('platform','平台')}${th('phone','手机')}${th('machine','归属机器')}${th('status','状态')}${th('display_name','昵称')}
|
||||
${th('identity_dir','身份目录')}<th>备注</th><th>操作</th>
|
||||
</tr></thead><tbody>${sorted.map(a => {
|
||||
const s = a._status === 'logged_in' ? '<span class="badge badge-green" style="background:rgba(5,150,105,.1);color:var(--green);padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600">✅ 已登录</span>'
|
||||
: a._status === 'remote' ? '<span class="badge badge-gray" style="background:rgba(107,114,128,.1);color:var(--text2);padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600">📡 远程</span>'
|
||||
: a._status === 'disabled' ? '<span class="badge badge-gray" style="background:rgba(107,114,128,.1);color:var(--text2);padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600">⏸ 停用</span>'
|
||||
: `<span class="badge badge-amber" style="background:rgba(217,119,6,.1);color:var(--amber);padding:2px 8px;border-radius:12px;font-size:11px;font-weight:600">⚠️ ${a._status}</span>`;
|
||||
const platformIcon = a.platform === 'douyin' ? '🎵' : (a.platform === 'xiaohongshu' ? '📕' : '🌐');
|
||||
const machineLabel = a.is_local
|
||||
? '<span style="color:#6366f1;font-size:12px">💻 本机</span>'
|
||||
: '<span style="color:var(--text2);font-size:12px">📡 '+(a.owner_machine||'远程')+'</span>';
|
||||
return `<tr>
|
||||
<td><strong>${a.id}</strong></td>
|
||||
<td>${platformIcon} ${a.platform}</td>
|
||||
<td style="font-family:monospace;font-size:12px">${String(a.phone||a.phone_mask||'-').slice(0,11)}</td>
|
||||
<td>${machineLabel}</td>
|
||||
<td>${s}</td>
|
||||
<td style="font-size:12px;color:var(--text2);max-width:120px;overflow:hidden;text-overflow:ellipsis">${a.display_name || '-'}</td>
|
||||
<td style="font-size:11px;color:var(--text2)">${a.identity_dir ? a.identity_dir.split('/').pop() : '-'}</td>
|
||||
<td style="font-size:11px;max-width:100px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">
|
||||
<span class="notes-display" data-account="${a.id}">${(a.notes||'-').slice(0,10)}</span>
|
||||
<span class="notes-edit" style="display:none">
|
||||
<input class="notes-input" data-account="${a.id}" value="${a.notes||''}" style="width:70px;font-size:10px;padding:2px 4px;background:var(--bg2);border:1px solid var(--primary);border-radius:4px;color:var(--text)">
|
||||
<span style="cursor:pointer;color:var(--green);font-size:12px" onclick="saveNote('${a.id}')">✓</span>
|
||||
</span>
|
||||
<span style="cursor:pointer;color:var(--text2);font-size:10px;margin-left:2px" onclick="editNote('${a.id}')">✏️</span>
|
||||
</td>
|
||||
<td>${a.is_local
|
||||
? `<button class="btn btn-sm" style="background:transparent;color:var(--text);border:1px solid var(--border);padding:4px 10px;border-radius:6px;cursor:pointer;font-size:11px" onclick="alert('编辑: ${a.id}')">编辑</button>
|
||||
<button class="btn btn-sm" style="background:#6366f1;color:#fff;border:none;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:11px;margin-left:4px" onclick="c2Nurture('${a.id}')">养号</button>`
|
||||
: '<span style="font-size:11px;color:var(--text2)">-</span>'}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('')}</tbody></table>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:6px">共 ${_accountsData.length} 个账号 · 点击表头排序</div>`;
|
||||
}
|
||||
|
||||
async function loadMatrixBlueprints() {
|
||||
const el = document.getElementById('matrixBlueprintsContent');
|
||||
el.innerHTML = '<div class="loading">加载中...</div>';
|
||||
// 加载蓝图列表 + 原子操作(用于编辑器)
|
||||
try {
|
||||
const [br, or] = await Promise.all([
|
||||
fetch('/api/matrix/blueprints'),
|
||||
fetch('/api/matrix/atom-ops'),
|
||||
]);
|
||||
const bd = await br.json();
|
||||
const od = await or.json();
|
||||
const bps = Array.isArray(bd) ? bd : (bd.blueprints || []);
|
||||
const allOps = od.ops || [];
|
||||
window._matrixOps = {};
|
||||
allOps.forEach(o => { window._matrixOps[o.name] = o; });
|
||||
|
||||
let html = '';
|
||||
|
||||
// ── 可折叠蓝图列表 (默认折叠) ──
|
||||
html += `<div style="margin-bottom:10px;background:var(--bg2);border-radius:10px;border:1px solid var(--border);overflow:hidden">`;
|
||||
html += `<div onclick="bpToggleList()" style="padding:10px 14px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;user-select:none">
|
||||
<span style="font-weight:600;font-size:13px">📋 蓝图库 <span class="badge badge-blue" style="font-size:10px;background:rgba(37,99,235,.1);color:var(--blue);padding:2px 6px;border-radius:6px">${bps.length}</span></span>
|
||||
<span id="bp-list-arrow" style="color:var(--text2);font-size:12px">▶ 展开</span>
|
||||
</div>`;
|
||||
html += `<div id="bp-list-body" style="display:none;padding:0 14px 10px">`;
|
||||
if (!bps.length) {
|
||||
html += '<div style="padding:6px 0;color:var(--text2);font-size:13px">暂无蓝图</div>';
|
||||
} else {
|
||||
bps.forEach(b => {
|
||||
const stepsPreview = (b.steps||[]).slice(0,3).map(s => { var sn = s.op||s.name; return window._matrixOps?.[sn]?.label||sn||''; }).join(' → ');
|
||||
html += `<div style="display:flex;align-items:center;gap:8px;padding:8px 10px;border-bottom:1px solid var(--border);font-size:13px">
|
||||
<span style="flex:1"><strong>${b.name}</strong> <span style="color:var(--text2);font-size:11px">${b.step_count}步 · ${b.platform||'douyin'}</span>
|
||||
${stepsPreview ? `<br><span style="color:var(--text2);font-size:11px">${stepsPreview}</span>` : ''}</span>
|
||||
<span style="display:flex;gap:4px;flex-shrink:0">
|
||||
<button class="btn btn-sm" style="background:#6366f1;color:#fff;border:none;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:11px" onclick="event.stopPropagation();bpExecute('${b.name}')">▶ 执行</button>
|
||||
<button class="btn btn-sm" style="background:transparent;color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:6px;cursor:pointer;font-size:11px" onclick="event.stopPropagation();showBpEditor('${b.name}')">✏️</button>
|
||||
<button class="btn btn-sm" style="background:transparent;color:var(--red);border:1px solid rgba(239,68,68,.3);padding:4px 8px;border-radius:6px;cursor:pointer;font-size:11px" onclick="event.stopPropagation();delBp('${b.name}')">🗑</button>
|
||||
</span>
|
||||
</div>`;
|
||||
});
|
||||
}
|
||||
html += `</div></div>`;
|
||||
|
||||
// ── 编辑器主区域 (全宽) ──
|
||||
html += `<div style="background:var(--bg2);border-radius:var(--radius);padding:16px;border:1px solid var(--border)">`;
|
||||
html += `
|
||||
<div id="bp-editor">
|
||||
<div style="display:flex;gap:12px;align-items:end;flex-wrap:wrap;margin-bottom:12px">
|
||||
<div style="flex:2;min-width:200px">
|
||||
<label style="font-size:11px;color:var(--text2);display:block;margin-bottom:2px">蓝图名称</label>
|
||||
<input id="bp-name" placeholder="my_blueprint" style="font-size:14px;font-weight:600">
|
||||
</div>
|
||||
<div style="flex:1;min-width:120px">
|
||||
<label style="font-size:11px;color:var(--text2);display:block;margin-bottom:2px">平台</label>
|
||||
<select id="bp-platform" onchange="bpUpdatePlatform()" style="font-size:13px">
|
||||
<option value="douyin">🎵 抖音</option>
|
||||
<option value="xiaohongshu">📕 小红书</option>
|
||||
</select>
|
||||
</div>
|
||||
<div style="flex:2;min-width:150px">
|
||||
<label style="font-size:11px;color:var(--text2);display:block;margin-bottom:2px">说明</label>
|
||||
<input id="bp-desc" placeholder="可选说明">
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-shrink:0">
|
||||
<button class="btn btn-primary" onclick="bpSave()" style="padding:7px 18px">💾 保存</button>
|
||||
<button class="btn btn-outline" onclick="bpClear()" style="padding:7px 14px">🗑 清空</button>
|
||||
<button class="btn btn-outline" onclick="showBpEditor(null)" style="padding:7px 14px">+ 新建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin:14px 0 8px;display:flex;align-items:center;gap:8px">
|
||||
<span style="font-weight:600;font-size:13px;color:var(--text2);white-space:nowrap">⚡ 原子操作</span>
|
||||
<div style="display:flex;gap:3px;flex-wrap:wrap" id="bp-op-tabs">
|
||||
<span class="badge badge-blue" style="cursor:pointer;padding:3px 8px;font-size:10px" data-ft="all" onclick="bpFilterOps('all')">📋 全部</span>
|
||||
<span class="badge badge-gray" style="cursor:pointer;padding:3px 8px;font-size:10px" data-ft="navigation" onclick="bpFilterOps('navigation')">🏠 导航</span>
|
||||
<span class="badge badge-gray" style="cursor:pointer;padding:3px 8px;font-size:10px" data-ft="browse" onclick="bpFilterOps('browse')">📱 浏览</span>
|
||||
<span class="badge badge-gray" style="cursor:pointer;padding:3px 8px;font-size:10px" data-ft="interact" onclick="bpFilterOps('interact')">💡 交互</span>
|
||||
<span class="badge badge-gray" style="cursor:pointer;padding:3px 8px;font-size:10px" data-ft="utility" onclick="bpFilterOps('utility')">⏳ 工具</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bp-op-list" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(160px,1fr));gap:5px;margin:0 0 16px"></div>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:8px">
|
||||
<span style="font-weight:600;font-size:13px;color:var(--text2)">📋 编排步骤</span>
|
||||
<span id="bp-step-count" style="font-size:11px;color:var(--text2)">0 步</span>
|
||||
<span id="bp-validate" style="font-size:11px"></span>
|
||||
</div>
|
||||
<div id="bp-steps" style="min-height:80px;border:2px dashed var(--border);border-radius:8px;padding:10px">
|
||||
<div style="color:var(--text2);font-size:13px;text-align:center;padding:20px">👆 点击上方原子操作卡片添加到步骤,可无限向下编排</div>
|
||||
</div>
|
||||
</div>`;
|
||||
html += `</div>`;
|
||||
|
||||
el.innerHTML = html;
|
||||
// 初始化编辑器状态
|
||||
window._bpSteps = [];
|
||||
window._editingBp = null;
|
||||
window._bpFilter = 'all';
|
||||
window._bpListOpen = false;
|
||||
bpUpdatePlatform();
|
||||
} catch(e) { el.innerHTML = `<div class="error">${e.message}</div>`; }
|
||||
}
|
||||
// 导出供其他模块调用
|
||||
window.loadMatrixBlueprints = loadMatrixBlueprints;
|
||||
|
||||
|
||||
// Blueprint editor helper functions
|
||||
window.bpToggleList = function() {
|
||||
window._bpListOpen = !window._bpListOpen;
|
||||
const body = document.getElementById('bp-list-body');
|
||||
const arrow = document.getElementById('bp-list-arrow');
|
||||
if (body && arrow) {
|
||||
body.style.display = window._bpListOpen ? 'block' : 'none';
|
||||
arrow.textContent = window._bpListOpen ? '▼ 收起' : '▶ 展开';
|
||||
}
|
||||
};
|
||||
|
||||
window.bpAddStep = function(name) {
|
||||
const op = window._matrixOps?.[name];
|
||||
if (!op) return;
|
||||
window._bpSteps = window._bpSteps || [];
|
||||
window._bpSteps.push({name, label: op.label||name, requires: op.requires||[], allows: op.allows||[]});
|
||||
bpRenderSteps();
|
||||
};
|
||||
|
||||
window.bpRemoveStep = function(idx) {
|
||||
window._bpSteps = window._bpSteps || [];
|
||||
window._bpSteps.splice(idx, 1);
|
||||
bpRenderSteps();
|
||||
};
|
||||
|
||||
function bpRenderSteps() {
|
||||
const el = document.getElementById('bp-steps');
|
||||
const ve = document.getElementById('bp-validate');
|
||||
const sc = document.getElementById('bp-step-count');
|
||||
const steps = window._bpSteps || [];
|
||||
if (!el) return;
|
||||
if (sc) sc.textContent = `${steps.length} 步`;
|
||||
if (!steps.length) {
|
||||
el.innerHTML = '<div style="color:var(--text2);font-size:13px;text-align:center;padding:20px">👆 点击上方原子操作卡片添加到步骤,可无限向下编排</div>';
|
||||
if (ve) ve.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = steps.map((s, i) => {
|
||||
// 检查与前一步的依赖
|
||||
let warn = '';
|
||||
if (i > 0) {
|
||||
const prev = steps[i-1];
|
||||
const prevOp = window._matrixOps?.[prev.name];
|
||||
if (prevOp && prevOp.allows && prevOp.allows[0] !== '*' && !prevOp.allows.includes(s.name)) {
|
||||
warn = '<span style="color:var(--red);font-size:11px;margin-left:6px">⚠ 与前一步不兼容</span>';
|
||||
}
|
||||
}
|
||||
if (i === 0) {
|
||||
const op = window._matrixOps?.[s.name];
|
||||
if (op && !op.can_be_first) {
|
||||
warn = '<span style="color:var(--red);font-size:11px;margin-left:6px">⚠ 不能作为首步</span>';
|
||||
} else if (op && op.can_be_first) {
|
||||
warn = '<span style="color:var(--green);font-size:11px;margin-left:6px">✅ 起点</span>';
|
||||
}
|
||||
}
|
||||
// 箭头
|
||||
const arrow = i < steps.length - 1
|
||||
? '<div style="padding:2px 0 2px 32px;color:var(--text2);font-size:14px">↓</div>'
|
||||
: '';
|
||||
// 序号圆
|
||||
const numStyle = warn.includes('不兼容') || warn.includes('不能作为首步')
|
||||
? 'background:var(--red);color:#fff'
|
||||
: 'background:var(--primary);color:#fff';
|
||||
// 兜底:保证 label 是字符串(防止 [object Object])
|
||||
var label = s.label;
|
||||
if (typeof label !== 'string') {
|
||||
var op2 = window._matrixOps?.[s.name];
|
||||
label = (typeof op2?.label === 'string') ? op2.label : (s.name || '(未知)');
|
||||
}
|
||||
if (typeof label !== 'string') {
|
||||
// 最终兜底:尝试用 s.op 或 s.name 直接查 _matrixOps
|
||||
var stepKey = s.op || s.name;
|
||||
if (stepKey && window._matrixOps?.[stepKey]) {
|
||||
var lbl = window._matrixOps[stepKey].label;
|
||||
if (typeof lbl === 'string') label = lbl;
|
||||
else label = stepKey;
|
||||
} else {
|
||||
label = '❌错误:' + String(typeof s) + '/' + String(s);
|
||||
}
|
||||
}
|
||||
return `<div style="padding:2px 0">
|
||||
<div style="display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg3);border-radius:6px;border:1px solid var(--border)">
|
||||
<span style="width:24px;height:24px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:11px;flex-shrink:0;${numStyle}">${i+1}</span>
|
||||
<span style="flex:1;font-size:13px"><strong>${label}</strong> ${warn}</span>
|
||||
<span style="cursor:pointer;color:var(--red);font-size:16px;opacity:.5" onclick="bpRemoveStep(${i})">✕</span>
|
||||
</div>
|
||||
${arrow}
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
// 验证
|
||||
if (ve) {
|
||||
fetch('/api/matrix/blueprints/validate', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({steps: steps.map(s => ({name: s.name}))})
|
||||
}).then(r=>r.json()).then(res => {
|
||||
ve.innerHTML = res.valid
|
||||
? '<span style="color:var(--green);font-size:12px">✅ 编排合法</span>'
|
||||
: '<span style="color:var(--red);font-size:12px">❌ '+(res.errors||[]).map(e=>e.msg).join('; ')+'</span>';
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
window.bpSave = async function() {
|
||||
const name = document.getElementById('bp-name')?.value;
|
||||
if (!name) { alert('请输入蓝图名称'); return; }
|
||||
const steps = (window._bpSteps||[]).map(s => ({name: s.name, description: (window._matrixOps?.[s.name]?.desc)||''}));
|
||||
if (!steps.length) { alert('请添加至少一个步骤'); return; }
|
||||
try {
|
||||
const r = await fetch('/api/matrix/blueprints', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({name, description: document.getElementById('bp-desc')?.value||'', platform: document.getElementById('bp-platform')?.value||'douyin', steps})
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
bpClear();
|
||||
loadMatrixBlueprints();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
};
|
||||
|
||||
window.bpClear = function() {
|
||||
window._bpSteps = [];
|
||||
const ne = document.getElementById('bp-name');
|
||||
if (ne) ne.value = '';
|
||||
window._editingBp = null;
|
||||
bpRenderSteps();
|
||||
};
|
||||
|
||||
// Atomic op filter & render for blueprint editor
|
||||
window._bpFilter = 'all';
|
||||
|
||||
// Platform → available categories mapping
|
||||
// Platform → available categories mapping (for tab visibility)
|
||||
const BP_PLATFORM_CATEGORIES = {
|
||||
douyin: ['navigation', 'browse', 'interact', 'utility'],
|
||||
xiaohongshu: ['navigation', 'browse', 'interact', 'utility'],
|
||||
};
|
||||
|
||||
const BP_CATEGORY_TABS = {
|
||||
all: '📋 全部',
|
||||
navigation: '🏠 导航',
|
||||
browse: '📱 浏览',
|
||||
interact: '💡 交互',
|
||||
utility: '⏳ 工具',
|
||||
};
|
||||
|
||||
window.bpFilterOps = function(filter) {
|
||||
window._bpFilter = filter;
|
||||
// Update tab styles
|
||||
document.querySelectorAll('#bp-op-tabs span').forEach(t => {
|
||||
if (t.style.display === 'none') return;
|
||||
t.className = t.dataset.ft === filter ? 'badge badge-blue' : 'badge badge-gray';
|
||||
t.style.cssText = 'cursor:pointer;padding:3px 8px;font-size:10px';
|
||||
});
|
||||
bpRenderOps();
|
||||
};
|
||||
|
||||
function bpGetPlatform() {
|
||||
const sel = document.getElementById('bp-platform');
|
||||
return sel ? sel.value : 'douyin';
|
||||
}
|
||||
|
||||
function bpUpdatePlatform() {
|
||||
// Get allowed categories for this platform
|
||||
const platform = bpGetPlatform();
|
||||
const cats = BP_PLATFORM_CATEGORIES[platform] || ['navigation', 'browse', 'interact', 'utility'];
|
||||
|
||||
// Update filter tab visibility
|
||||
document.querySelectorAll('#bp-op-tabs span').forEach(t => {
|
||||
const ft = t.dataset.ft;
|
||||
// "all" always visible, others only if in this platform's categories
|
||||
const visible = ft === 'all' || cats.includes(ft);
|
||||
t.style.display = visible ? 'inline-block' : 'none';
|
||||
});
|
||||
|
||||
// If current filter is not available for this platform, reset to 'all'
|
||||
if (window._bpFilter !== 'all' && !cats.includes(window._bpFilter)) {
|
||||
window._bpFilter = 'all';
|
||||
}
|
||||
|
||||
bpRenderOps();
|
||||
|
||||
// Save the platform choice to registry for future (optional)
|
||||
}
|
||||
|
||||
function bpCanFollow(lastStepName, opName) {
|
||||
// If no steps yet: only ops that can_be_first or have no requires
|
||||
if (!lastStepName) {
|
||||
const op = window._matrixOps?.[opName];
|
||||
return op ? !!op.can_be_first : true;
|
||||
}
|
||||
// Check the last step's allows list
|
||||
const lastOp = window._matrixOps?.[lastStepName];
|
||||
if (!lastOp) return true;
|
||||
if (lastOp.allows && lastOp.allows[0] === '*') return true;
|
||||
return lastOp.allows ? lastOp.allows.includes(opName) : true;
|
||||
}
|
||||
|
||||
function bpRenderOps() {
|
||||
const el = document.getElementById('bp-op-list');
|
||||
if (!el) return;
|
||||
|
||||
const allOps = Object.values(window._matrixOps || {});
|
||||
const platform = bpGetPlatform();
|
||||
const filter = window._bpFilter || 'all';
|
||||
|
||||
const steps = window._bpSteps || [];
|
||||
const lastStep = steps.length > 0 ? steps[steps.length - 1] : null;
|
||||
const lastName = lastStep ? lastStep.name : null;
|
||||
|
||||
// Filter by: platform field matches OR is "通用"
|
||||
const platformOps = allOps.filter(o => {
|
||||
const opPlatform = o.platform || '通用';
|
||||
return opPlatform === platform || opPlatform === '通用';
|
||||
});
|
||||
|
||||
// Then apply category filter
|
||||
const filtered = filter === 'all' ? platformOps : platformOps.filter(o => (o.category || '') === filter);
|
||||
|
||||
el.innerHTML = filtered.map(o => {
|
||||
const canFollow = bpCanFollow(lastName, o.name);
|
||||
let deps = '';
|
||||
if (o.requires && o.requires.length && o.requires[0] !== '*') {
|
||||
deps = `<span style="font-size:9px;color:var(--blue)">← ${o.requires.join(',')}</span>`;
|
||||
} else if (o.can_be_first && !lastName) {
|
||||
deps = `<span style="font-size:9px;color:var(--green)">✅ 首步</span>`;
|
||||
}
|
||||
const opacity = canFollow ? '1' : '.35';
|
||||
const cursor = canFollow ? 'pointer' : 'not-allowed';
|
||||
const clickHandler = canFollow ? `bpAddStep('${o.name}')` : '';
|
||||
const disabledLabel = canFollow ? '' : '<span style="font-size:9px;color:var(--red);margin-left:4px">🚫</span>';
|
||||
return `<div class="op-card" style="background:var(--bg3);border-radius:6px;padding:8px;border:1px solid var(--border);cursor:${cursor};opacity:${opacity}" onclick="${clickHandler}">
|
||||
<div style="font-size:12px;font-weight:600">${o.label||o.name}${disabledLabel}</div>
|
||||
<div style="font-size:10px;color:var(--text2);margin-top:2px">${o.desc||''}</div>
|
||||
<div style="margin-top:2px">${deps}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
if (!filtered.length) {
|
||||
el.innerHTML = '<div style="color:var(--text2);font-size:12px;padding:10px;grid-column:1/-1">当前平台/分类无可用操作</div>';
|
||||
}
|
||||
}
|
||||
|
||||
window.showBpEditor = async function(name) {
|
||||
if (!name) { bpClear(); return; }
|
||||
try {
|
||||
const r = await fetch('/api/matrix/blueprints');
|
||||
const d = await r.json();
|
||||
const bp = Array.isArray(d) ? d.find(b => b.name === name) : (d.blueprints||[]).find(b => b.name === name);
|
||||
if (!bp) return;
|
||||
window._editingBp = name;
|
||||
document.getElementById('bp-name').value = name;
|
||||
document.getElementById('bp-desc').value = bp.description||'';
|
||||
document.getElementById('bp-platform').value = bp.platform||'douyin';
|
||||
window._bpSteps = (bp.steps||[]).map(s => {
|
||||
const stepName = s.op || s.name || s;
|
||||
const op = window._matrixOps?.[stepName];
|
||||
return {name: stepName, label: op?.label||stepName, requires: op?.requires||[], allows: op?.allows||[]};
|
||||
});
|
||||
bpRenderSteps();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
};
|
||||
|
||||
window.delBp = async function(name) {
|
||||
if (!confirm(`删除蓝图 ${name}?`)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/matrix/blueprints/${name}`, {method:'DELETE'});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
loadMatrixBlueprints();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
};
|
||||
|
||||
// Blueprint execute modal
|
||||
let _bpExecName = '';
|
||||
function bpExecute(name) {
|
||||
_bpExecName = name;
|
||||
fetch('/api/matrix/accounts').then(r=>r.json()).then(d => {
|
||||
const accts = (d.accounts||[]).filter(a => a.is_local && a._status === 'logged_in');
|
||||
const modal = document.getElementById('bpExecModal');
|
||||
const content = document.getElementById('bpExecContent');
|
||||
if (!modal || !content) return;
|
||||
content.innerHTML = `
|
||||
<div style="margin-bottom:12px"><strong style="font-size:15px">▶ 执行蓝图: ${name}</strong></div>
|
||||
<div style="margin-bottom:10px">
|
||||
<label style="display:block;font-size:12px;color:var(--text2);margin-bottom:4px">选择身份</label>
|
||||
<select id="bpExecIdentity" style="width:100%;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:8px 10px;border-radius:6px;font-size:13px">
|
||||
${accts.map(a => `<option value="${a.id}">${a.id} (${a.platform})</option>`).join('')}
|
||||
${accts.length ? '' : '<option value="">暂无已登录账号</option>'}
|
||||
</select>
|
||||
</div>
|
||||
<div style="margin-bottom:10px">
|
||||
<label style="display:flex;align-items:center;gap:6px;font-size:12px;color:var(--text2);cursor:pointer">
|
||||
<input type="checkbox" id="bpExecProxy" checked> 使用代理
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button onclick="bpDoExec()" style="background:#6366f1;color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:13px">▶ 执行</button>
|
||||
<button onclick="closeBpModal()" style="background:transparent;color:var(--text);border:1px solid var(--border);padding:8px 20px;border-radius:6px;cursor:pointer;font-size:13px">取消</button>
|
||||
</div>
|
||||
<div id="bpExecResult" style="margin-top:10px;font-size:13px"></div>`;
|
||||
modal.style.display = 'flex';
|
||||
}).catch(e => alert('❌ '+e.message));
|
||||
}
|
||||
|
||||
async function bpDoExec() {
|
||||
const sel = document.getElementById('bpExecIdentity');
|
||||
const proxy = document.getElementById('bpExecProxy')?.checked;
|
||||
const resultEl = document.getElementById('bpExecResult');
|
||||
if (!sel || !sel.value) { resultEl.innerHTML = '<span style="color:var(--red)">请选择身份</span>'; return; }
|
||||
resultEl.innerHTML = '<span style="color:var(--text2)">⏳ 执行中...</span>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/blueprints/'+encodeURIComponent(_bpExecName)+'/execute', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({account: sel.value, use_proxy: proxy})
|
||||
});
|
||||
const res = await r.json();
|
||||
resultEl.innerHTML = r.ok
|
||||
? '<span style="color:var(--green)">✅ 已启动 · 日志: '+(res.log||'N/A')+'</span>'
|
||||
: '<span style="color:var(--red)">❌ '+(res.detail||'执行失败')+'</span>';
|
||||
} catch(e) {
|
||||
resultEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>';
|
||||
}
|
||||
}
|
||||
|
||||
function closeBpModal() {
|
||||
const modal = document.getElementById('bpExecModal');
|
||||
if (modal) modal.style.display = 'none';
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.acctSwitchTab = acctSwitchTab;
|
||||
window.batchExecute = batchExecute;
|
||||
window.batchPreview = batchPreview;
|
||||
window.bpCanFollow = bpCanFollow;
|
||||
window.bpDoExec = bpDoExec;
|
||||
window.bpExecute = bpExecute;
|
||||
window.bpGetPlatform = bpGetPlatform;
|
||||
window.bpRenderOps = bpRenderOps;
|
||||
window.bpRenderSteps = bpRenderSteps;
|
||||
window.bpUpdatePlatform = bpUpdatePlatform;
|
||||
window.closeBpModal = closeBpModal;
|
||||
window.corpusToggleRandom = corpusToggleRandom;
|
||||
window.deleteIdentity = deleteIdentity;
|
||||
window.loadIdentities = loadIdentities;
|
||||
window.loadMatrixAccounts = loadMatrixAccounts;
|
||||
window.loadMatrixRun = loadMatrixRun;
|
||||
window.renderAccountsTable = renderAccountsTable;
|
||||
window.sortAccounts = sortAccounts;
|
||||
window.unbindAccount = unbindAccount;
|
||||
@@ -0,0 +1,366 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 养号调度器
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
let nurturePollTimer = null;
|
||||
|
||||
// ── 养号执行(新版:账号选择 + 自动蓝图 + 机器路由)──
|
||||
|
||||
// 全局存账号列表
|
||||
let _nurtureAccounts = [];
|
||||
|
||||
async function loadCmdNurture() {
|
||||
// 已迁至独立页面 view-matrix-nurture,此 tab 仅保留入口
|
||||
}
|
||||
|
||||
async function loadNurtureAccounts() {
|
||||
const el = document.getElementById('nurtureAcctList');
|
||||
if (!el) return;
|
||||
try {
|
||||
const [r1, r2] = await Promise.all([
|
||||
fetch('/api/matrix/accounts'),
|
||||
fetch('/api/matrix/homepage-info').catch(() => ({json: () => ({})}))
|
||||
]);
|
||||
const accts = await r1.json();
|
||||
const hpData = r2.json ? await r2.json() : {results:[]};
|
||||
const allAccts = Array.isArray(accts) ? accts.filter(a => a.enabled !== false) : [];
|
||||
_nurtureAccounts = allAccts;
|
||||
const collectedAt = hpData.collected_at || '';
|
||||
|
||||
const hpIndex = {};
|
||||
(hpData.results || []).forEach(r => {
|
||||
const key = r.identity_dir || r.phone;
|
||||
if (key) hpIndex[key] = r;
|
||||
if (r.phone) hpIndex[r.phone] = r;
|
||||
});
|
||||
|
||||
// 三级分组: 机器 → 身份(identity_dir) → 账号
|
||||
const tree = {};
|
||||
allAccts.forEach(a => {
|
||||
const machine = a.owner_machine || '未分配';
|
||||
const ident = a.identity_dir || a.id;
|
||||
if (!tree[machine]) tree[machine] = {};
|
||||
if (!tree[machine][ident]) tree[machine][ident] = {phone: a.phone || '', accounts: []};
|
||||
tree[machine][ident].accounts.push(a);
|
||||
});
|
||||
|
||||
// 机器排序:本机优先
|
||||
const machineOrder = Object.keys(tree).sort((a,b) => {
|
||||
if (a === 'chengzigedeAir') return -1;
|
||||
if (b === 'chengzigedeAir') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
// 搜索框 + 统计
|
||||
let html = '<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:6px">';
|
||||
html += '<input id="nurtureFilter" placeholder="🔍 搜索账号ID/手机号/昵称..." style="flex:1;min-width:120px;padding:4px 8px;font-size:12px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px" oninput="filterNurtureList()">';
|
||||
html += '<span style="font-size:11px;color:var(--text2)">' + allAccts.length + ' 个启用</span>';
|
||||
html += '<span style="font-size:11px;color:var(--green)">🎵 ' + allAccts.filter(a=>a.platform==='douyin').length + '</span>';
|
||||
html += '<span style="font-size:11px;color:#6366f1">📕 ' + allAccts.filter(a=>a.platform==='xiaohongshu').length + '</span>';
|
||||
if (collectedAt) html += '<span style="font-size:10px;color:var(--text2)">采集: ' + new Date(collectedAt).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) + '</span>';
|
||||
html += '</div>';
|
||||
|
||||
// 三级渲染: 机器 → 身份(手机号) → 账号
|
||||
machineOrder.forEach(machine => {
|
||||
const idents = tree[machine];
|
||||
const isLocal = machine === 'chengzigedeAir';
|
||||
const machineLabel = isLocal ? '🖥️ ' + machine : '☁️ ' + machine;
|
||||
const grpId = 'nt_m_' + machine.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const totalAccts = Object.values(idents).reduce((s, i) => s + i.accounts.length, 0);
|
||||
const identCount = Object.keys(idents).length;
|
||||
const loggedIn = Object.values(idents).reduce((s, i) => s + i.accounts.filter(a => a._status === 'logged_in').length, 0);
|
||||
|
||||
html += '<div style="border:1px solid var(--border);border-radius:6px;margin-bottom:4px;overflow:hidden">';
|
||||
html += '<div onclick="toggleNurtureGroup(\'' + grpId + '\')" style="display:flex;align-items:center;gap:6px;padding:5px 8px;background:var(--bg3);cursor:pointer;font-size:12px;user-select:none">';
|
||||
html += '<span id="' + grpId + '_arrow" style="font-size:10px;opacity:.6">▼</span>';
|
||||
html += '<input type="checkbox" onclick="event.stopPropagation();ntToggleMachine(this,\'' + machine + '\')" checked>';
|
||||
html += '<strong>' + machineLabel + '</strong>';
|
||||
html += '<span style="font-size:10px;color:var(--text2)">' + identCount + ' 身份 ' + totalAccts + ' 账号 | ' + loggedIn + ' 账号已登录</span>';
|
||||
html += '</div>';
|
||||
|
||||
html += '<div id="' + grpId + '" style="overflow-x:auto">';
|
||||
// 一台机器一张表,手机号用 rowspan 合并
|
||||
html += '<table style="width:100%;font-size:11px;border-collapse:collapse"><tbody>';
|
||||
Object.keys(idents).sort().forEach(ident => {
|
||||
const identity = idents[ident];
|
||||
const hp = hpIndex[ident] || hpIndex[identity.phone];
|
||||
const phoneDisplay = identity.phone ? identity.phone.slice(0,11) : ident.replace('phone_','');
|
||||
const nickDisplay = hp?.display_name ? hp.display_name.slice(0,10) : '';
|
||||
const rowspan = identity.accounts.length;
|
||||
|
||||
identity.accounts.forEach((a, idx) => {
|
||||
const plat = a.platform === 'douyin' ? '🎵' : '📕';
|
||||
const defBP = a.platform === 'xiaohongshu' ? 'xhs_daily' : 'douyin_daily';
|
||||
const phone = a.phone || identity.phone || '-';
|
||||
const hpAcc = hp ? (a.platform === 'douyin' ? hp.douyin : hp.xiaohongshu) : null;
|
||||
const nickname = hpAcc?.nickname ? hpAcc.nickname.slice(0,12) : '';
|
||||
const hpTime = hpAcc?.collected_at || '';
|
||||
const hpTimeDisplay = hpTime ? new Date(hpTime).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}) : '';
|
||||
|
||||
let statusIcon, cookieIcon;
|
||||
if (a._status === 'logged_in') {
|
||||
statusIcon = '<span class="badge badge-green" style="font-size:10px">已登录</span>';
|
||||
cookieIcon = '<span style="color:var(--green);font-size:10px">🍪有效</span>';
|
||||
} else if (a._status === 'no_cookie' || a._status === 'no_session' || a._status === 'empty_cookie') {
|
||||
statusIcon = '<span class="badge badge-amber" style="font-size:10px">未登录</span>';
|
||||
cookieIcon = '<span style="color:var(--red);font-size:10px">🍪'+(a._status||'')+'</span>';
|
||||
} else {
|
||||
statusIcon = '<span class="badge badge-red" style="font-size:10px">'+(a._status||'')+'</span>';
|
||||
cookieIcon = '';
|
||||
}
|
||||
|
||||
html += '<tr class="nurture-row" data-account="'+a.id+'" data-phone="'+phone+'" data-nick="'+nickname+'" data-machine="'+machine+'">';
|
||||
// 手机号列: 同身份第一个显示(rowspan合并),后续隐藏
|
||||
if (idx === 0) {
|
||||
html += '<td rowspan="'+rowspan+'" style="padding:3px 6px;font-size:10px;color:var(--text2);vertical-align:middle;border-right:1px solid var(--border);white-space:nowrap">📱'+phoneDisplay+(nickDisplay?' '+nickDisplay:'')+'</td>';
|
||||
}
|
||||
html += '<td style="padding:3px 4px;width:24px"><input type="checkbox" class="nurture-cb" value="'+a.id+'" data-plat="'+a.platform+'" data-bp="'+defBP+'" data-machine="'+machine+'" checked></td>';
|
||||
html += '<td style="padding:3px 4px;white-space:nowrap"><strong>'+a.id+'</strong></td>';
|
||||
html += '<td style="padding:3px 4px">'+plat+'</td>';
|
||||
html += '<td style="padding:3px 4px;font-size:10px;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">'+(nickname||'<span class="text-muted">-</span>')+'</td>';
|
||||
html += '<td style="padding:3px 4px;white-space:nowrap">'+statusIcon+'</td>';
|
||||
html += '<td style="padding:3px 4px;font-size:10px;white-space:nowrap">'+cookieIcon+'</td>';
|
||||
html += '<td style="padding:3px 4px;font-size:10px;color:var(--text2);white-space:nowrap">采集:'+hpTimeDisplay+'</td>';
|
||||
html += '<td style="padding:3px 4px;font-size:10px;color:var(--text2);white-space:nowrap">📋'+defBP+'</td>';
|
||||
html += '</tr>';
|
||||
});
|
||||
});
|
||||
html += '</tbody></table></div></div>';
|
||||
});
|
||||
|
||||
el.innerHTML = html;
|
||||
updateNurtureCount();
|
||||
} catch(e) {
|
||||
const el2 = document.getElementById('nurtureAcctList');
|
||||
if (el2) el2.innerHTML = '<div class="error">❌ '+e.message+'</div>';
|
||||
}
|
||||
}
|
||||
|
||||
// 折叠/展开机器组
|
||||
function toggleNurtureGroup(grpId) {
|
||||
const el = document.getElementById(grpId);
|
||||
const arrow = document.getElementById(grpId + '_arrow');
|
||||
if (!el) return;
|
||||
const hidden = el.style.display === 'none';
|
||||
el.style.display = hidden ? '' : 'none';
|
||||
if (arrow) arrow.textContent = hidden ? '▼' : '▶';
|
||||
}
|
||||
|
||||
function ntToggleMachine(master, machine) {
|
||||
document.querySelectorAll('.nurture-cb[data-machine="'+machine+'"]').forEach(cb => cb.checked = master.checked);
|
||||
updateNurtureCount();
|
||||
}
|
||||
|
||||
function ntToggleIdentity(master) {
|
||||
const table = master.closest('div').nextElementSibling?.querySelector('table');
|
||||
if (table) table.querySelectorAll('.nurture-cb').forEach(cb => cb.checked = master.checked);
|
||||
updateNurtureCount();
|
||||
}
|
||||
|
||||
// 搜索过滤
|
||||
function filterNurtureList() {
|
||||
const q = (document.getElementById('nurtureFilter')?.value || '').toLowerCase();
|
||||
document.querySelectorAll('.nurture-row').forEach(row => {
|
||||
const acct = row.dataset.account || '';
|
||||
const phone = row.dataset.phone || '';
|
||||
const nick = row.dataset.nick || '';
|
||||
const match = !q || acct.includes(q) || phone.includes(q) || nick.includes(q);
|
||||
row.style.display = match ? '' : 'none';
|
||||
});
|
||||
updateNurtureCount();
|
||||
}
|
||||
|
||||
function nurtureToggleAll(master) {
|
||||
document.querySelectorAll('.nurture-cb').forEach(cb => cb.checked = master.checked);
|
||||
updateNurtureCount();
|
||||
}
|
||||
|
||||
function updateNurtureCount() {
|
||||
const checked = document.querySelectorAll('.nurture-cb:checked').length;
|
||||
const el = document.getElementById('nurtureSelCount');
|
||||
if (el) el.textContent = '已选 ' + checked + ' 个';
|
||||
}
|
||||
|
||||
// ── 预检 ──
|
||||
async function nurturePreflight() {
|
||||
const statusEl = document.getElementById('nurtureStatus');
|
||||
const logEl = document.getElementById('nurtureLog');
|
||||
const preflightEl = document.getElementById('nurturePreflightInfo');
|
||||
if (statusEl) statusEl.innerHTML = '🔍 正在预检各机器状态...';
|
||||
if (logEl) logEl.textContent = '';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/matrix/nurture/status');
|
||||
const d = await r.json();
|
||||
|
||||
let html = '🖥️ 本机: ' + (d.local_browsers?.length || 0) + ' 浏览器运行中\n';
|
||||
(d.local_browsers || []).forEach(b => {
|
||||
html += ' PID=' + b.pid + ' ' + b.cmd.slice(0, 60) + '\n';
|
||||
});
|
||||
|
||||
(d.remote_machines || []).forEach(m => {
|
||||
html += '☁️ ' + m.machine + ': ' + (m.browsers_running || 0) + ' 浏览器 | 可用槽位: ' + (m.slots_available || '?') + '\n';
|
||||
if (m.error) html += ' ❌ ' + m.error + '\n';
|
||||
});
|
||||
|
||||
if (preflightEl) preflightEl.textContent = html;
|
||||
if (statusEl) statusEl.innerHTML = '✅ 预检完成';
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.innerHTML = '❌ 预检失败: ' + e.message;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 执行选中 ──
|
||||
async function nurtureExec() {
|
||||
const selected = _getSelectedAccounts();
|
||||
if (!selected.length) { alert('请先选择要执行的账号'); return; }
|
||||
await doNurtureExec(selected.map(s => s.id));
|
||||
}
|
||||
|
||||
// ── 执行全部启用 ──
|
||||
async function nurtureExecAll() {
|
||||
const selected = _getSelectedAccounts();
|
||||
// 如果选中计数和总计数不一致,说明用户手动反选了某些
|
||||
const total = document.querySelectorAll('.as-cb').length;
|
||||
const checked = selected.length;
|
||||
if (checked < total) {
|
||||
if (!confirm('当前仅 ' + checked + '/' + total + ' 个账号被选中。确认执行这 ' + checked + ' 个?')) return;
|
||||
} else {
|
||||
if (!confirm('执行全部 ' + checked + ' 个账号(每台机器各自运行)?')) return;
|
||||
}
|
||||
await doNurtureExec(selected.map(s => s.id));
|
||||
}
|
||||
|
||||
async function doNurtureExec(accountIds) {
|
||||
const statusEl = document.getElementById('nurtureStatus');
|
||||
const logEl = document.getElementById('nurtureLog');
|
||||
const preflightEl = document.getElementById('nurturePreflightInfo');
|
||||
const rounds = parseInt(document.getElementById('ndRounds').value) || 10;
|
||||
const bpOverride = document.getElementById('ndBlueprint').value;
|
||||
|
||||
if (logEl) logEl.textContent = '';
|
||||
if (preflightEl) preflightEl.textContent = '';
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 启动中 (' + accountIds.length + ' 个账号)...';
|
||||
|
||||
try {
|
||||
const body = {accounts: accountIds, rounds, dry_run: true};
|
||||
if (bpOverride) body.blueprint = bpOverride;
|
||||
|
||||
// 先 dry_run 预览执行计划
|
||||
const r1 = await fetch('/api/ops/run', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
type: 'nurture',
|
||||
accounts: accountIds,
|
||||
params: {
|
||||
blueprint: bpOverride || 'douyin_daily',
|
||||
rounds: rounds || 10
|
||||
}
|
||||
})
|
||||
});
|
||||
const plan = await r1.json();
|
||||
|
||||
if (plan.status !== 'plan') {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+(plan.message||JSON.stringify(plan))+'</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
let planText = '📋 执行计划:\n';
|
||||
plan.plan.forEach(p => {
|
||||
const loc = p.is_local ? '🖥️' : '☁️';
|
||||
planText += loc + ' ' + p.account + ' → ' + p.machine + ' [' + p.blueprint + '] ' + p.rounds + '轮\n';
|
||||
});
|
||||
if (logEl) logEl.textContent = planText;
|
||||
if (statusEl) statusEl.innerHTML = '📋 计划确认中...';
|
||||
|
||||
if (!confirm(planText + '\n确认执行?')) {
|
||||
if (statusEl) statusEl.innerHTML = '⏸ 已取消';
|
||||
return;
|
||||
}
|
||||
|
||||
// ── 真正执行 ──
|
||||
if (statusEl) statusEl.innerHTML = '⏳ 执行中(预检 → 分配窗口 → 启动 → 验证)...';
|
||||
delete body.dry_run;
|
||||
|
||||
const r2 = await fetch('/api/ops/run', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body: JSON.stringify({
|
||||
type: 'nurture',
|
||||
accounts: accountIds,
|
||||
params: {
|
||||
blueprint: bpOverride || 'douyin_daily',
|
||||
rounds: rounds || 10
|
||||
}
|
||||
})
|
||||
});
|
||||
const result = await r2.json();
|
||||
|
||||
if (result.status === 'started') {
|
||||
// 显示预检结果
|
||||
if (preflightEl && result.preflight) {
|
||||
let pfText = '🔍 预检结果:\n';
|
||||
Object.entries(result.preflight).forEach(([machine, info]) => {
|
||||
pfText += ' ' + machine + ': ' + info.message + '\n';
|
||||
if (info.slot) pfText += ' 槽位 ' + info.slot.id + ' 位置 (' + info.slot.position + ')\n';
|
||||
});
|
||||
preflightEl.textContent = pfText;
|
||||
}
|
||||
|
||||
// 显示执行结果
|
||||
let out = '🚀 执行结果:\n';
|
||||
(result.results || []).forEach(r => {
|
||||
const icon = r.status === 'started' ? '✅' : r.status === 'verify_failed' ? '⚠️' : '❌';
|
||||
const verified = r.verified_pid ? ' PID=' + r.verified_pid : '';
|
||||
out += ' ' + icon + ' ' + r.account + ': ' + r.status + ' 槽位' + (r.slot||'?') + verified + '\n';
|
||||
});
|
||||
if (logEl) logEl.textContent = out;
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 已启动,正在轮询...</span>';
|
||||
|
||||
// 轮询结果
|
||||
const pollInterval = setInterval(async () => {
|
||||
try {
|
||||
const pr = await fetch('/api/matrix/nurture/results?limit=10');
|
||||
const pd = await pr.json();
|
||||
const items = pd.results || [];
|
||||
if (!items.length) return;
|
||||
|
||||
let html = '<div style="font-size:10px"><table style="width:100%"><tr><th>账号</th><th>状态</th><th>步骤</th><th>耗时</th><th>槽位</th></tr>';
|
||||
let allDone = true;
|
||||
items.slice(0, 8).forEach(r => {
|
||||
const st = r.status === 'completed' ? '✅' : r.status === 'running' ? '⏳' : '❌';
|
||||
if (r.status === 'running') allDone = false;
|
||||
const steps = r.steps ? (r.steps.success||0) + '/' + (r.steps.total||0) : '-';
|
||||
const dur = r.duration_secs ? Math.round(r.duration_secs/60) + 'min' : '-';
|
||||
html += `<tr><td>${r.account}</td><td>${st} ${r.status}</td><td>${steps}</td><td>${dur}</td><td>槽${r.slot||'-'}</td></tr>`;
|
||||
});
|
||||
html += '</table></div>';
|
||||
if (logEl) logEl.innerHTML = html;
|
||||
|
||||
if (allDone) {
|
||||
clearInterval(pollInterval);
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">✅ 全部执行完成</span>';
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">⏳ 执行中...</span>';
|
||||
}
|
||||
} catch(e) {}
|
||||
}, 10000);
|
||||
} else {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+(result.message||JSON.stringify(result))+'</span>';
|
||||
}
|
||||
} catch(e) {
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--red)">❌ '+e.message+'</span>';
|
||||
}
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.doNurtureExec = doNurtureExec;
|
||||
window.filterNurtureList = filterNurtureList;
|
||||
window.loadCmdNurture = loadCmdNurture;
|
||||
window.loadNurtureAccounts = loadNurtureAccounts;
|
||||
window.ntToggleIdentity = ntToggleIdentity;
|
||||
window.ntToggleMachine = ntToggleMachine;
|
||||
window.nurtureExec = nurtureExec;
|
||||
window.nurtureExecAll = nurtureExecAll;
|
||||
window.nurturePreflight = nurturePreflight;
|
||||
window.nurtureToggleAll = nurtureToggleAll;
|
||||
window.toggleNurtureGroup = toggleNurtureGroup;
|
||||
window.updateNurtureCount = updateNurtureCount;
|
||||
@@ -0,0 +1,144 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 定时任务
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
async function refreshScheduleList() {
|
||||
const listEl = document.getElementById('scheduleList');
|
||||
const histEl = document.getElementById('scheduleHistory');
|
||||
const statusEl = document.getElementById('scheduleStatus');
|
||||
// 加载任务列表
|
||||
if (listEl) {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/schedules');
|
||||
const d = await r.json();
|
||||
const scheds = d.schedules || {};
|
||||
const entries = Object.entries(scheds);
|
||||
if (!entries.length) {
|
||||
listEl.innerHTML = '<div style="color:var(--text2);padding:10px;text-align:center">暂无定时任务,在上方添加</div>';
|
||||
} else {
|
||||
listEl.innerHTML = entries.map(([sid, s]) =>
|
||||
'<div style="background:var(--bg3);border-radius:4px;padding:6px 8px;border:1px solid var(--border);display:flex;justify-content:space-between;align-items:center;font-size:11px;margin-bottom:3px">' +
|
||||
'<div><strong>' + sid + '</strong> <span style="color:var(--text2)">' + (s.account||'') + ' → ' + (s.blueprint||'') + '</span></div>' +
|
||||
'<div style="display:flex;align-items:center;gap:6px">' +
|
||||
'<span style="color:#6366f1">' + (s.time||'') + '</span>' +
|
||||
'<span style="color:var(--text2);font-size:10px">' + (s.days||'每天') + '</span>' +
|
||||
'<span style="cursor:pointer" onclick="scheduleToggle(\''+sid+'\',' + (!s.enabled) + ')">' + (s.enabled ? '🟢' : '⚪') + '</span>' +
|
||||
'<span style="cursor:pointer;color:var(--red);font-size:10px" onclick="scheduleDelete(\''+sid+'\')">✕</span>' +
|
||||
'</div></div>'
|
||||
).join('');
|
||||
}
|
||||
} catch(e) { if (listEl) listEl.innerHTML = '<span style="color:var(--red)">'+e.message+'</span>'; }
|
||||
}
|
||||
// 加载历史
|
||||
if (histEl) {
|
||||
try {
|
||||
const r = await fetch('/api/matrix/schedules/history');
|
||||
const d = await r.json();
|
||||
const hist = d.history || [];
|
||||
if (!hist.length) {
|
||||
histEl.innerHTML = '<div style="color:var(--text2);padding:6px">暂无执行记录</div>';
|
||||
} else {
|
||||
histEl.innerHTML = hist.map(e =>
|
||||
'<div style="padding:3px 0;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;font-size:10px">' +
|
||||
'<span>' + (e.timestamp||'').slice(0,16) + ' ' + (e.schedule_id||'') + '</span>' +
|
||||
'<span>' + (e.error ? '❌ '+e.error.slice(0,20) : (e.status||'✅')) + ' ' + (e.success||0)+'/'+((e.success||0)+(e.failed||0)) + ' ' + (e.duration||0)+'s</span>' +
|
||||
'</div>'
|
||||
).join('');
|
||||
}
|
||||
} catch(e) { if (histEl) histEl.innerHTML = '<span style="color:var(--red)">'+e.message+'</span>'; }
|
||||
}
|
||||
// 调度器状态
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--text2)">需手动启动: mc schedule start</span>';
|
||||
// 加载 WorkBuddy 自动化任务
|
||||
const autoEl = document.getElementById('autoTasksSummary');
|
||||
if (autoEl) {
|
||||
try {
|
||||
const r = await fetch('/api/kb/automations');
|
||||
const d = await r.json();
|
||||
const machines = d.machines || {};
|
||||
const entries = Object.entries(machines);
|
||||
if (!entries.length) {
|
||||
autoEl.innerHTML = '<div style="color:var(--text2);font-size:11px;padding:4px 0">暂无自动化任务</div>' +
|
||||
'<div style="margin-top:8px;display:flex;gap:6px"><select id="autoNewTarget" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:11px">' +
|
||||
'<option value="">选目标机器</option>' +
|
||||
Object.keys(machines).map(m => '<option value="'+m+'">'+m+'</option>').join('') +
|
||||
'</select>' +
|
||||
'<input id="autoNewPrompt" placeholder="输入提示词..." style="flex:1;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:11px">' +
|
||||
'<button onclick="createAutoTaskFromSchedule()" style="background:#6366f1;color:#fff;border:none;padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px">+ 创建任务</button></div>';
|
||||
} else {
|
||||
let html = entries.map(([machine, tasks]) => {
|
||||
const wbTasks = tasks.filter(t => t.source_type === 'workbuddy');
|
||||
if (!wbTasks.length) return '';
|
||||
return '<div style="margin-bottom:6px">' +
|
||||
'<div style="font-weight:600;font-size:11px;color:#6366f1;margin-bottom:3px">💻 ' + machine + '</div>' +
|
||||
wbTasks.map(t => {
|
||||
const prompt = t.prompt || t.name || '';
|
||||
return '<div style="padding:3px 6px;font-size:10px;background:var(--bg3);border-radius:4px;margin-bottom:3px;border:1px solid var(--border)">' +
|
||||
'<div style="display:flex;justify-content:space-between">' +
|
||||
'<span style="color:' + (t.status === 'ACTIVE' ? '#22c55e' : '#8b8fa3') + '">' + (t.status === 'ACTIVE' ? '▶ 运行中' : '⏸ 暂停') + '</span>' +
|
||||
'<span style="color:var(--text2)">' + (t.rrule || t.scheduleType || '即时') + '</span>' +
|
||||
'</div>' +
|
||||
'<div style="margin-top:2px;color:var(--text);word-break:break-all">' + (prompt.slice(0, 120) + (prompt.length > 120 ? '...' : '')) + '</div>' +
|
||||
'</div>';
|
||||
}).join('') +
|
||||
'</div>';
|
||||
}).filter(Boolean).join('');
|
||||
if (!html) html = '<div style="color:var(--text2);font-size:11px;padding:4px 0">无 WorkBuddy 任务</div>';
|
||||
// 新建任务表单
|
||||
html += '<div style="margin-top:8px;padding:6px;background:var(--bg3);border-radius:4px">' +
|
||||
'<div style="font-size:11px;font-weight:600;margin-bottom:4px">✏️ 新建自动化任务</div>' +
|
||||
'<div style="display:flex;gap:6px">' +
|
||||
'<select id="autoNewTarget" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:3px;font-size:10px;width:120px">' +
|
||||
'<option value="">选目标</option>' +
|
||||
Object.keys(machines).map(m => '<option value="'+m+'">'+m+'</option>').join('') +
|
||||
'</select>' +
|
||||
'<input id="autoNewPrompt" placeholder="输入提示词,描述要执行的任务..." style="flex:1;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:3px;font-size:10px">' +
|
||||
'<button onclick="createAutoTaskFromSchedule()" style="background:#6366f1;color:#fff;border:none;padding:4px 10px;border-radius:3px;cursor:pointer;font-size:10px">+ 创建</button>' +
|
||||
'</div></div>';
|
||||
autoEl.innerHTML = html;
|
||||
}
|
||||
} catch(e) { autoEl.innerHTML = '<span style="color:var(--text2);font-size:11px">自动化任务API不可用</span>'; }
|
||||
}
|
||||
}
|
||||
|
||||
async function scheduleAdd() {
|
||||
const id = document.getElementById('schedId')?.value.trim();
|
||||
const account = document.getElementById('schedAccount')?.value;
|
||||
const blueprint = document.getElementById('schedBlueprint')?.value;
|
||||
const time = document.getElementById('schedTime')?.value || '09:00';
|
||||
const days = document.getElementById('schedDays')?.value || '1,2,3,4,5,6,7';
|
||||
const rounds = parseInt(document.getElementById('schedRounds')?.value) || 3;
|
||||
if (!id) { alert('请输入任务ID'); return; }
|
||||
if (!account) { alert('请选择账号'); return; }
|
||||
// 通过 mc CLI 添加
|
||||
const cmd = 'mc schedule add --id '+id+' --account '+account+' --blueprint '+blueprint+' --time '+time+' --days '+days+' --rounds '+rounds;
|
||||
if (!confirm('将在终端执行:\n'+cmd+'\n\n确认?')) return;
|
||||
alert('请在终端执行:\n'+cmd+'\n\n或手动编辑 config/schedule.yaml');
|
||||
refreshScheduleList();
|
||||
}
|
||||
|
||||
async function scheduleToggle(sid, enabled) {
|
||||
try {
|
||||
await fetch('/api/matrix/schedules/toggle', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({id:sid, enabled})
|
||||
});
|
||||
refreshScheduleList();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
}
|
||||
|
||||
async function scheduleDelete(sid) {
|
||||
if (!confirm('删除定时任务 '+sid+' ?')) return;
|
||||
try {
|
||||
await fetch('/api/matrix/schedules/delete', {
|
||||
method:'POST', headers:{'Content-Type':'application/json'},
|
||||
body:JSON.stringify({id:sid})
|
||||
});
|
||||
refreshScheduleList();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.refreshScheduleList = refreshScheduleList;
|
||||
window.scheduleAdd = scheduleAdd;
|
||||
window.scheduleDelete = scheduleDelete;
|
||||
window.scheduleToggle = scheduleToggle;
|
||||
@@ -0,0 +1,268 @@
|
||||
/**
|
||||
* modules/upstream.js — 统一的「📥 上游带入」渲染
|
||||
*
|
||||
* 为什么要有这个文件:
|
||||
* 之前每个阶段各写各的「上游」区块 —— 有的写了、有的没写、
|
||||
* 写了的也只显示「💡 创意:已选中方向」这种空话,用户看不出到底带了什么。
|
||||
* 结果就是「流程链条感觉不通」:数据其实传过去了,但**看得见**才叫通。
|
||||
*
|
||||
* 设计原则:
|
||||
* 1. 显示**实际内容**(不是「已选中」这类占位词)
|
||||
* 2. 空的时候明确说「去哪个标签产出」,而不是留白
|
||||
* 3. 每个区块都标注来源(来自哪个标签)
|
||||
*
|
||||
* 用法:
|
||||
* import { upBlock } from '../modules/upstream.js';
|
||||
* container.innerHTML = `... ${upBlock('script', project)} ...`;
|
||||
*/
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
/** 上游内容的定义:每个阶段「应该」从哪拿什么 */
|
||||
const SPEC = {
|
||||
creative_card: {
|
||||
from: '💡 创意 + 🌍 设定',
|
||||
go: 'creative',
|
||||
pick(p) {
|
||||
const out = [];
|
||||
const cr = p.creative || {};
|
||||
const sel = cr.selected || [];
|
||||
if (Array.isArray(sel) && sel.length) {
|
||||
out.push(['💡 已选创意', sel.slice(0, 3).map(x =>
|
||||
(x && (x.one_liner || x.one_line || x.title || x.text || x.cid))
|
||||
|| '(这条没存下描述,去创意工坊重新勾选一次)').join(';')]);
|
||||
} else {
|
||||
out.push(['💡 已选创意', '(还没选 —— 去「💡 创意」挑一句话)']);
|
||||
}
|
||||
const st = p.setting || {};
|
||||
if (st.world) out.push(['🌍 世界观', st.world]);
|
||||
if (st.tone) out.push(['🌍 基调', st.tone]);
|
||||
if (st.taboo) out.push(['🌍 禁忌', st.taboo]);
|
||||
if (!st.world && !st.tone) out.push(['🌍 设定', '(建议先填 —— 创意卡会更准)']);
|
||||
if (cr.card) out.push(['📋 已有创意卡', cr.card.title || '(已生成)']);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
setting: {
|
||||
from: '💡 创意',
|
||||
go: 'creative',
|
||||
pick(p) {
|
||||
const cr = p.creative || {};
|
||||
const out = [];
|
||||
const sel = cr.selected || cr.items || [];
|
||||
if (Array.isArray(sel) && sel.length) {
|
||||
out.push(['创意方向', sel.slice(0, 3).map(x =>
|
||||
(x && (x.one_liner || x.one_line || x.title || x.text || x.cid))
|
||||
|| '(这条没存下描述,去创意工坊重新勾选一次)').join(';')]);
|
||||
}
|
||||
if (cr.inject_mode) out.push(['注入方式', cr.inject_mode]);
|
||||
if (cr.card && cr.card.title) out.push(['📋 创意卡', cr.card.title]);
|
||||
if (!sel.length) out.push(['💡 创意', '(还没选 —— 去「💡 创意」挑一句话)']);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
script: {
|
||||
from: '📋 创意卡 + 🌍 设定 + 🧑 演员',
|
||||
go: 'creative_card',
|
||||
pick(p) {
|
||||
const out = [];
|
||||
const cr = p.creative || {};
|
||||
const card = cr.card;
|
||||
// ★ 有创意卡就优先显示它(剧本现在的最高依据就是这张卡)
|
||||
if (card) {
|
||||
out.push(['📋 故事标题', card.title || '(未命名)']);
|
||||
if (card.concept) out.push(['📋 一句话概念', card.concept]);
|
||||
const cc = card.core_conflict || {};
|
||||
if (cc.who) {
|
||||
out.push(['📋 核心冲突', `${cc.who} / 受阻于 ${cc.blocker || '—'}`]);
|
||||
}
|
||||
if (card.hook) out.push(['📋 传播钩子', String(card.hook).slice(0, 60)]);
|
||||
if (card.content_mode) out.push(['📋 内容模式', card.content_mode]);
|
||||
if (card.core_driver) out.push(['📋 核心驱动力', card.core_driver]);
|
||||
if (card.verdict && card.verdict.conclusion) {
|
||||
out.push(['📋 价值判断', card.verdict.conclusion]);
|
||||
}
|
||||
} else {
|
||||
const sel = cr.selected || cr.items || [];
|
||||
if (Array.isArray(sel) && sel.length) {
|
||||
out.push(['💡 创意方向', sel.slice(0, 3).map(x =>
|
||||
(x && (x.one_liner || x.one_line || x.title || x.text || x.cid))
|
||||
|| '(这条没存下描述,去创意工坊重新勾选一次)').join(';')]);
|
||||
out.push(['⚠️ 建议', '去「📋 创意卡」扩写成完整故事卡 —— 剧本会更准']);
|
||||
}
|
||||
}
|
||||
const st = p.setting || {};
|
||||
if (st.world) out.push(['🌍 世界观', st.world]);
|
||||
if (st.tone) out.push(['🌍 基调', st.tone]);
|
||||
if (st.taboo) out.push(['🌍 禁忌', st.taboo]);
|
||||
const cast = p.cast || [];
|
||||
if (cast.length) {
|
||||
out.push(['🧑 演员', cast.map(c => `${c.slot || ''}(${c.name || c.asset_id || ''})`).join('、')]);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
storyboard: {
|
||||
from: '📝 剧本',
|
||||
go: 'script',
|
||||
pick(p) {
|
||||
const sc = p.script || {};
|
||||
const paras = sc.paragraphs || [];
|
||||
const out = [];
|
||||
if (sc.driver) out.push(['核心驱动', sc.driver]);
|
||||
if (paras.length) {
|
||||
out.push(['剧情段落', `${paras.length} 段`]);
|
||||
out.push(['前 2 段', paras.slice(0, 2).map(x =>
|
||||
(x.plot || x.action || '').slice(0, 34)).filter(Boolean).join(' / ')]);
|
||||
}
|
||||
const er = sc.emotion_route || {};
|
||||
const route = [er.start, er.middle, er.peak, er.end].filter(Boolean).join(' → ');
|
||||
if (route) out.push(['情绪路线', route]);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
shots: {
|
||||
from: '🎬 故事板 + 🌍 设定 + 🧑 演员',
|
||||
go: 'storyboard',
|
||||
pick(p) {
|
||||
const out = [];
|
||||
const sbp = p.storyboard_paper || {};
|
||||
const shots = sbp.shots || [];
|
||||
if (shots.length) {
|
||||
out.push(['故事板镜头', `${shots.length} 格`]);
|
||||
out.push(['前 2 格', shots.slice(0, 2).map(x =>
|
||||
`${x.name || ''}${x.function ? '(' + x.function + ')' : ''}`).join(' / ')]);
|
||||
}
|
||||
const st = p.setting || {};
|
||||
if (st.world) out.push(['🌍 世界观', st.world]);
|
||||
const cast = p.cast || [];
|
||||
if (cast.length) out.push(['🧑 演员', `${cast.length} 位`]);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
kf_plan: {
|
||||
from: '🎬 分镜 + 🎬 故事板',
|
||||
go: 'shots',
|
||||
pick(p) {
|
||||
const out = [];
|
||||
const sb = p.storyboard || {};
|
||||
const shots = sb.shots || [];
|
||||
if (shots.length) {
|
||||
out.push(['分镜镜头', `${shots.length} 个`]);
|
||||
out.push(['前 2 个', shots.slice(0, 2).map(s => {
|
||||
const sc2 = s.shot_contract || {};
|
||||
return `${s.shot_id || ''}(${sc2.shot_size || ''}${sc2.angle ? '·' + sc2.angle : ''})`;
|
||||
}).join(' / ')]);
|
||||
}
|
||||
const sbp = p.storyboard_paper || {};
|
||||
if ((sbp.shots || []).length) out.push(['故事板', `${sbp.shots.length} 格规划`]);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
keyframes: {
|
||||
from: '🖼 关键帧设计 + 🔧 资产',
|
||||
go: 'kf_plan',
|
||||
pick(p) {
|
||||
const out = [];
|
||||
const plan = p.keyframe_plan || {};
|
||||
const ids = Object.keys(plan);
|
||||
if (ids.length) {
|
||||
const total = ids.reduce((a, k) => a + ((plan[k].frames || []).length), 0);
|
||||
out.push(['关键帧设计', `${ids.length} 镜 · 共 ${total} 帧`]);
|
||||
const first = plan[ids[0]];
|
||||
if (first && (first.frames || []).length) {
|
||||
out.push(['首镜首帧', (first.frames[0].func || first.frames[0].visual || '').slice(0, 40)]);
|
||||
}
|
||||
}
|
||||
const cast = p.cast || [];
|
||||
if (cast.length) out.push(['🧑 演员', `${cast.length} 位(参考图来源)`]);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
videos: {
|
||||
from: '🖼 关键帧 + 🖼 关键帧设计',
|
||||
go: 'keyframes',
|
||||
pick(p) {
|
||||
const out = [];
|
||||
const st = p._stats || {};
|
||||
const plan = p.keyframe_plan || {};
|
||||
const n = Object.keys(plan).length;
|
||||
if (n) out.push(['关键帧设计', `${n} 镜已设计`]);
|
||||
if (st.keyframe_shots) {
|
||||
out.push(['已有可用的帧', `${st.keyframe_shots} 镜 · 共 ${st.keyframes} 张`]);
|
||||
}
|
||||
return out;
|
||||
},
|
||||
},
|
||||
|
||||
post: {
|
||||
from: '🎞 视频片段',
|
||||
go: 'videos',
|
||||
pick(p) {
|
||||
const out = [];
|
||||
const st = p._stats || {};
|
||||
if (st.video_shots) out.push(['已有视频', `${st.video_shots} 镜 · ${st.videos} 段`]);
|
||||
if (st.adopted_videos) out.push(['✅ 已采用', `${st.adopted_videos} 段(会被拼进成片)`]);
|
||||
if (st.exports) out.push(['已导出成片', `${st.exports} 个`]);
|
||||
const sc = p.script || {};
|
||||
if ((sc.paragraphs || []).length) out.push(['剧本段落', `${sc.paragraphs.length} 段(可做字幕)`]);
|
||||
return out;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 渲染某阶段的「📥 上游带入」区块
|
||||
* @param {string} stage 阶段 id:setting/script/storyboard/shots/kf_plan/keyframes/videos/post
|
||||
* @param {object} project 项目对象
|
||||
* @param {object} [opts] { compact: 紧凑模式 }
|
||||
*/
|
||||
export function upBlock(stage, project, opts = {}) {
|
||||
const spec = SPEC[stage];
|
||||
const p = project || {};
|
||||
if (!spec) return '';
|
||||
let rows = [];
|
||||
try { rows = spec.pick(p) || []; } catch (e) { rows = []; }
|
||||
rows = rows.filter(([, v]) => v && String(v).trim());
|
||||
|
||||
const body = rows.length
|
||||
? `<div style="display:flex;flex-direction:column;gap:3px">${rows.map(([k, v]) => `
|
||||
<div style="display:flex;gap:8px;font-size:11px;line-height:1.7">
|
||||
<span style="color:var(--text2);flex:0 0 auto;min-width:74px">${esc(k)}</span>
|
||||
<span style="flex:1;min-width:0">${esc(v)}</span>
|
||||
</div>`).join('')}</div>`
|
||||
: `<div style="font-size:11px;color:#f59e0b;line-height:1.8">
|
||||
还没有上游内容 —— 先去「${esc(spec.from)}」标签产出,这里会自动带过来。</div>`;
|
||||
|
||||
if (opts.compact) return body;
|
||||
|
||||
return `
|
||||
<div class="up-sec" style="background:var(--bg3);border:1px solid #3b82f6;
|
||||
border-radius:8px;padding:9px 11px;margin-bottom:9px">
|
||||
<div style="display:flex;align-items:center;gap:8px;margin-bottom:5px">
|
||||
<span style="font-size:12px;font-weight:600">📥 上游带入</span>
|
||||
<span style="font-size:10px;color:var(--text2)">来自 ${esc(spec.from)}</span>
|
||||
<span style="font-size:10px;color:var(--text2);margin-left:auto">
|
||||
↓ 这些会随「AI 生成」一起送给大模型</span>
|
||||
</div>
|
||||
${body}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 只取「有没有上游」的布尔判断(用于禁用按钮等) */
|
||||
export function hasUpstream(stage, project) {
|
||||
const spec = SPEC[stage];
|
||||
if (!spec) return false;
|
||||
try { return (spec.pick(project || {}) || []).some(([, v]) => v && String(v).trim()); }
|
||||
catch (e) { return false; }
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
// ════════════════════════════════════════════════════════
|
||||
// 矩阵设置 TAB
|
||||
// ════════════════════════════════════════════════════════
|
||||
|
||||
function switchSetTab(tab, el) {
|
||||
document.querySelectorAll('.set-tab').forEach(t => {
|
||||
t.style.borderBottom = '2px solid transparent';
|
||||
t.style.color = '#8b8fa3';
|
||||
});
|
||||
el.style.borderBottom = '2px solid #6366f1';
|
||||
el.style.color = '#e1e4f0';
|
||||
['set-export-content','set-backup-content','set-corpus-content'].forEach(id => {
|
||||
document.getElementById(id).style.display = id === tab+'-content' ? 'block' : 'none';
|
||||
});
|
||||
}
|
||||
|
||||
async function loadMatrixSettings() {
|
||||
// 三个 TAB 都使用 MutationObserver 同步原始内容
|
||||
const syncMap = [
|
||||
{ id: 'matrixExportContent2', load: 'loadMatrixExport', orig: 'matrixExportContent' },
|
||||
{ id: 'matrixBackupContent2', load: 'loadMatrixBackup', orig: 'matrixBackupContent' },
|
||||
{ id: 'corpusContent2', load: 'loadCorpus', orig: 'corpusContent' },
|
||||
];
|
||||
for (const {id, load, orig} of syncMap) {
|
||||
const el = document.getElementById(id);
|
||||
const origEl = document.getElementById(orig);
|
||||
if (el && origEl && typeof window[load] === 'function') {
|
||||
await window[load]();
|
||||
el.innerHTML = origEl.innerHTML;
|
||||
if (!window['_setObs_' + id]) {
|
||||
window['_setObs_' + id] = new MutationObserver(() => {
|
||||
const target = document.getElementById(id);
|
||||
const source = document.getElementById(orig);
|
||||
if (target && source) target.innerHTML = source.innerHTML;
|
||||
});
|
||||
window['_setObs_' + id].observe(origEl, { childList: true, subtree: true, characterData: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Keyboard ──
|
||||
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeDetail(); });
|
||||
|
||||
// ── Timeline ──
|
||||
let timelineChart = null;
|
||||
async function loadTimeline(hostname) {
|
||||
if (!hostname) {
|
||||
// 展示 selector
|
||||
const sel = document.getElementById('timelineSelector');
|
||||
const r = await fetch('/api/machines'); const res = await r.json();
|
||||
sel.innerHTML = (res.machines||[]).map(m =>
|
||||
`<button class="btn" onclick="loadTimeline('${m.hostname}')">${m.hostname}</button>`
|
||||
).join('');
|
||||
document.getElementById('timelineStatus').textContent = '点击机器查看';
|
||||
return;
|
||||
}
|
||||
document.getElementById('timelineStatus').textContent = `加载 ${hostname}...`;
|
||||
const r = await fetch(`/api/timeline/${hostname}?window=120`);
|
||||
const res = await r.json();
|
||||
const pts = res.points || [];
|
||||
if (!pts.length) { document.getElementById('timelineStatus').textContent = `${hostname}: 暂无数据`; return; }
|
||||
|
||||
const labels = pts.map(p => { try { return new Date(p.t).toLocaleTimeString(); } catch(e) { return ''; }});
|
||||
const cpu = pts.map(p => p.cpu || 0);
|
||||
const disk = pts.map(p => p.disk_pct || 0);
|
||||
|
||||
document.getElementById('timelineStatus').textContent = `${hostname}: ${pts.length} 个采样点 (最近${res.total}条)`;
|
||||
|
||||
// 简单 Canvas 绘图
|
||||
const canvas = document.getElementById('timelineChart');
|
||||
const ctx = canvas.getContext('2d');
|
||||
canvas.width = canvas.offsetWidth * 2; canvas.height = canvas.offsetHeight * 2;
|
||||
ctx.scale(2,2); const w = canvas.offsetWidth, h = canvas.offsetHeight;
|
||||
|
||||
const maxVal = Math.max(...cpu, ...disk, 1);
|
||||
const pad = {top:20, bottom:20, left:40, right:20};
|
||||
const cw = w - pad.left - pad.right, ch = h - pad.top - pad.bottom;
|
||||
|
||||
ctx.clearRect(0,0,w,h);
|
||||
// grid
|
||||
ctx.strokeStyle = 'rgba(255,255,255,0.05)'; ctx.lineWidth = 0.5;
|
||||
for(let i=0;i<5;i++) { ctx.beginPath(); ctx.moveTo(pad.left, pad.top+ch*i/4); ctx.lineTo(w-pad.right, pad.top+ch*i/4); ctx.stroke(); }
|
||||
|
||||
function drawLine(data, color) {
|
||||
ctx.strokeStyle = color; ctx.lineWidth = 2; ctx.beginPath();
|
||||
data.forEach((v,i) => {
|
||||
const x = pad.left + (i/(data.length-1||1))*cw;
|
||||
const y = pad.top + ch - (v/maxVal)*ch;
|
||||
i===0 ? ctx.moveTo(x,y) : ctx.lineTo(x,y);
|
||||
}); ctx.stroke();
|
||||
}
|
||||
drawLine(cpu, '#378ADD');
|
||||
drawLine(disk, '#D85A30');
|
||||
|
||||
// legend
|
||||
ctx.fillStyle = '#378ADD'; ctx.fillRect(w-130, 10, 12, 12);
|
||||
ctx.fillStyle = 'var(--text)'; ctx.font = '11px sans-serif'; ctx.fillText('CPU', w-112, 20);
|
||||
ctx.fillStyle = '#D85A30'; ctx.fillRect(w-80, 10, 12, 12);
|
||||
ctx.fillStyle = 'var(--text)'; ctx.fillText('磁盘(G)', w-62, 20);
|
||||
}
|
||||
|
||||
// ── Alerts ──
|
||||
async function loadAlerts() {
|
||||
const el = document.getElementById('alertsList');
|
||||
try {
|
||||
const r = await fetch('/api/alerts'); const res = await r.json();
|
||||
const alerts = res.alerts || [];
|
||||
if (!alerts.length) { el.innerHTML = '<div class="card"><div style="color:var(--green)">✅ 所有机器运行正常</div></div>'; return; }
|
||||
el.innerHTML = alerts.map(a => {
|
||||
const color = a.level === 'critical' ? 'var(--red)' : a.level === 'warning' ? 'var(--amber)' : 'var(--text2)';
|
||||
const since = a.since_sec < 3600 ? `${(a.since_sec/60).toFixed(0)}分钟` : `${(a.since_sec/3600).toFixed(1)}小时`;
|
||||
return `<div class="card" style="border-left:3px solid ${color}">
|
||||
<div style="font-weight:600">${a.hostname}</div>
|
||||
<div style="font-size:12px;color:${color}">${a.level==='critical'?'🔴 严重':a.level==='warning'?'🟡 警告':'ℹ️ 提示'}: 离线 ${since}</div>
|
||||
${a.note ? `<div style="font-size:11px;color:var(--text2)">${a.note}</div>` : ''}
|
||||
<div style="margin-top:8px;display:flex;gap:8px">
|
||||
<button class="btn" onclick="wakeupMachine('${a.hostname}')">🔄 唤醒</button>
|
||||
<button class="btn" onclick="upgradeMachine('${a.hostname}')">⬆️ 升级</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
} catch(e) { el.innerHTML = `<div class="error">❌ ${e.message}</div>`; }
|
||||
}
|
||||
|
||||
async function wakeupMachine(hostname) {
|
||||
await fetch(`/api/wakeup/${hostname}`, {method:'POST'});
|
||||
alert(`已发送唤醒命令到 ${hostname}`);
|
||||
}
|
||||
async function upgradeMachine(hostname) {
|
||||
if (!confirm(`确认对 ${hostname} 执行 git pull + 重启服务?`)) return;
|
||||
await fetch(`/api/upgrade/${hostname}`, {method:'POST'});
|
||||
alert(`已发送升级命令到 ${hostname}`);
|
||||
}
|
||||
// ── 导出(防 Rollup tree-shake)──
|
||||
window.loadAlerts = loadAlerts;
|
||||
window.loadMatrixSettings = loadMatrixSettings;
|
||||
window.loadTimeline = loadTimeline;
|
||||
window.switchSetTab = switchSetTab;
|
||||
window.upgradeMachine = upgradeMachine;
|
||||
window.wakeupMachine = wakeupMachine;
|
||||
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* nav-menu.js — 侧边栏菜单「单点定义」
|
||||
*
|
||||
* ⚠️ 全项目菜单只在这里维护!新增/删除菜单项只改本文件。
|
||||
*
|
||||
* 背景(技术债修复 2026-09-20):
|
||||
* 此前菜单在 3 个文件里各有一份硬编码拷贝(inline.js / navigation.js / modules/matrix_views.js),
|
||||
* main.js 的 import 顺序决定谁生效(最后 import 的 matrix_views.js 覆盖前两个),
|
||||
* 导致"改了菜单却看不到"(曾加 api-config 时踩坑)。
|
||||
* 现在三处统一 import 本文件 → 改一处全生效。
|
||||
*
|
||||
* 视图命名:view 对应 views/{name}.js(已迁移视图,走 router.js 动态加载到 #view-dynamic)
|
||||
*/
|
||||
|
||||
export const NAV_GROUPS = {
|
||||
'矩阵': { icon: '📱', items: [
|
||||
{view:'accounts-center', label:'👤 账号中心'},
|
||||
// {view:'matrix-sms-proxy', label:'🪪 短信与代理'}, // 已合并到账号中心展开栏
|
||||
// {view:'matrix-collect', label:'📡 信息采集'}, // 已合并到账号中心批量操作
|
||||
// {view:'matrix-corpus', label:'📚 语料库'}, // 暂未上菜单(视图已迁移)
|
||||
{view:'matrix-nurture', label:'🏃 养号执行'},
|
||||
{view:'matrix-publish', label:'📤 内容发布'},
|
||||
{view:'matrix-interact', label:'💬 评论互动'},
|
||||
{view:'matrix-dm', label:'💬 私信沟通'},
|
||||
{view:'matrix-live', label:'🔴 直播互动'},
|
||||
{view:'comment-workbench', label:'🎭 评论工作台'},
|
||||
{view:'matrix-blueprints', label:'📋 蓝图管理'},
|
||||
// {view:'matrix-login', label:'🔑 登录管理'}, // 已合并到信息采集
|
||||
{view:'matrix-schedule', label:'⏱ 定时任务'},
|
||||
{view:'ops-recorder', label:'🎬 录制标注'},
|
||||
{view:'ops-command', label:'🖥️ 联邦指挥台'},
|
||||
]},
|
||||
|
||||
'🎬 视频工厂 · 资源库': { icon: '📦', items: [
|
||||
// 💡 创意工坊已整合进「导演工作室 · 💡 创意」标签(避免两处入口重复)
|
||||
// 需要单独打开时恢复此行即可(文件与视图注册都还在):
|
||||
// {view:'creative-studio', label:'💡 创意工坊'},
|
||||
{view:'creatives', label:'📚 创意库'},
|
||||
{view:'characters', label:'🧑 角色库'},
|
||||
{view:'char-gen', label:'🎭 角色生成器'},
|
||||
{view:'scene-gen', label:'🏠 场景生成器'},
|
||||
{view:'ave-locations', label:'🏞️ 场景库'},
|
||||
{view:'ave-real-locations', label:'🏙️ 真实场地'},
|
||||
{view:'ave-props', label:'🧰 道具库'},
|
||||
{view:'ave-products', label:'📦 产品库'},
|
||||
{view:'ave-wardrobe', label:'👗 服装库'},
|
||||
{view:'assets', label:'🖼️ 素材库'},
|
||||
{view:'ave-voices', label:'🔊 音色库'},
|
||||
{view:'ave-bgm', label:'🎵 BGM 库'},
|
||||
{view:'ave-sfx', label:'💥 音效库'},
|
||||
{view:'ave-ambience', label:'🌆 环境音库'},
|
||||
{view:'ave-vfx-presets', label:'✨ 特效预设'},
|
||||
]},
|
||||
|
||||
'🎬 视频工厂 · 流程': { icon: '🎞️', items: [
|
||||
// ★ 主路径:导演工作室(单页 8 标签全流程)
|
||||
{view:'ave-studio', label:'🎬 导演工作室 ★(主路径)'},
|
||||
// ⚠️ 以下 3 项已被工作室整合(作为其标签嵌入),菜单不再单列 ——
|
||||
// 文件本身被 ave-studio.js 引用,**不能删**;如需临时独立入口,取消注释即可
|
||||
// {view:'ave-director', label:'🎬 导演创作(旧)'},
|
||||
// {view:'ave-keyframes', label:'🖼 关键帧工作台(旧)'},
|
||||
// {view:'ave-keyframe-video', label:'🎬 关键帧转视频(旧)'},
|
||||
{view:'ave-flow-short-drama', label:'🎭 短剧创作'},
|
||||
{view:'ave-flow-dub', label:'🎙️ 基础口播'},
|
||||
{view:'ave-flow-beat', label:'🥁 卡点视频'},
|
||||
{view:'ave-flow-narrative', label:'📖 角色叙事'},
|
||||
{view:'ave-flow-digital-human',label:'👤 数字人口播'},
|
||||
{view:'ave-flow-hybrid', label:'🎞️ 口播+混剪'},
|
||||
{view:'person-swap', label:'🔄 人物置换'},
|
||||
{view:'workflow', label:'🔀 流程画布'},
|
||||
{view:'ave-new-flow', label:'➕ 新建流程'},
|
||||
]},
|
||||
|
||||
'🎬 视频工厂 · 能力中心': { icon: '🧩', items: [
|
||||
{view:'ave-capabilities', label:'⚙️ 能力配置'},
|
||||
{view:'ave-providers', label:'🔌 供应商管理'},
|
||||
{view:'ave-test-cap', label:'🧪 能力试跑'},
|
||||
{view:'ave-docs', label:'📚 API 文档中心'},
|
||||
]},
|
||||
|
||||
'🎬 视频工厂 · 运营': { icon: '📊', items: [
|
||||
{view:'productions', label:'📋 生产记录'},
|
||||
{view:'costs', label:'💰 费用统计'},
|
||||
{view:'ave-history', label:'🕐 历史操作'},
|
||||
{view:'ave-settings', label:'⚙️ 设置'},
|
||||
]},
|
||||
|
||||
'内容抓取': { icon: '📡', items: [
|
||||
{view:'crawl-tasks', label:'📥 抓取任务'},
|
||||
{view:'crawl-sources', label:'📋 源管理'},
|
||||
{view:'crawl-history', label:'📜 抓取历史'},
|
||||
]},
|
||||
|
||||
'联邦': { icon: '🖥️', items: [
|
||||
{view:'fleet-sync', label:'一键同步'},
|
||||
{view:'fleet-reconcile', label:'对账检查'},
|
||||
{view:'fleet-exec', label:'远程Shell'},
|
||||
// {view:'matrix-commands', label:'🎯 命令与任务'}, // 旧菜单里有,当前生效菜单未上;需要时取消注释
|
||||
]},
|
||||
|
||||
'服务': { icon: '⚙️', items: [
|
||||
{view:'api-config', label:'🔑 API 配置'},
|
||||
{view:'serve-mcp', label:'MCP状态'},
|
||||
{view:'serve-dashboard', label:'Dashboard日志'},
|
||||
{view:'serve-schedule', label:'全局定时任务'},
|
||||
{view:'timeline', label:'📈 时间线'},
|
||||
]},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* navigation.js — 侧边栏导航渲染
|
||||
*
|
||||
* 从 inline.js 提取。处理导航栏分组定义、渲染、折叠/展开。
|
||||
* 所有函数同时注册到 window,因为 HTML onclick 属性直接引用它们。
|
||||
*/
|
||||
|
||||
// ── 导航分组定义 ──
|
||||
// ✅ 菜单定义已单点化 → 见 src/nav-menu.js(新增/删除菜单只改那里)
|
||||
import { NAV_GROUPS } from './nav-menu.js';
|
||||
const groups = NAV_GROUPS;
|
||||
|
||||
// ── Sidebar toggle ──
|
||||
function toggleSidebar() {
|
||||
const sb = document.getElementById('sidebar');
|
||||
const btn = document.getElementById('sidebarToggleBtn');
|
||||
const isCollapsed = sb.classList.toggle('collapsed');
|
||||
btn.textContent = isCollapsed ? '▶' : '◀';
|
||||
btn.title = isCollapsed ? '展开侧边栏' : '折叠侧边栏';
|
||||
}
|
||||
|
||||
// ── Nav group accordion ──
|
||||
function toggleGroup(el) {
|
||||
const body = el.nextElementSibling;
|
||||
if (!body || !body.classList) return;
|
||||
// 手风琴: 关闭其他所有分组
|
||||
document.querySelectorAll('.nav-group-header').forEach(h => {
|
||||
if (h === el) return;
|
||||
const b = h.nextElementSibling;
|
||||
if (b && b.classList && !b.classList.contains('collapsed')) {
|
||||
b.classList.add('collapsed');
|
||||
h.dataset.collapsed = 'true';
|
||||
const arrow = h.querySelector('span:last-child');
|
||||
if (arrow) arrow.textContent = '▶';
|
||||
}
|
||||
});
|
||||
// 切换当前分组
|
||||
const isCollapsed = body.classList.toggle('collapsed');
|
||||
el.dataset.collapsed = isCollapsed ? 'true' : 'false';
|
||||
el.querySelector('span:last-child').textContent = isCollapsed ? '▶' : '▼';
|
||||
}
|
||||
|
||||
function collapseAllGroups() {
|
||||
document.querySelectorAll('.nav-group-header').forEach(h => {
|
||||
const body = h.nextElementSibling;
|
||||
if (body && body.classList && !body.classList.contains('collapsed')) {
|
||||
body.classList.add('collapsed');
|
||||
h.dataset.collapsed = 'true';
|
||||
const arrow = h.querySelector('span:last-child');
|
||||
if (arrow) arrow.textContent = '▶';
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ── Status badge helper ──
|
||||
const S = (label, status) => `<span style="font-size:9px;margin-left:4px;padding:1px 5px;border-radius:3px;background:rgba(217,119,6,.12);color:#d97706;font-weight:500">${status}</span>`;
|
||||
|
||||
// ── Render nav ──
|
||||
function _renderNav() {
|
||||
const nav = document.getElementById('navBar');
|
||||
if (!nav) return;
|
||||
let html = '';
|
||||
for (const [gname, g] of Object.entries(groups)) {
|
||||
html += `<div class="nav-group-header" onclick="toggleGroup(this)" data-collapsed="true">${g.icon} ${gname} <span style="float:right;font-size:10px;opacity:.5">▶</span></div>`;
|
||||
html += `<div class="nav-group-body collapsed">`;
|
||||
for (const item of g.items) {
|
||||
if (item.sub) {
|
||||
html += `<div class="nav-item" data-view="${item.view}" onclick="switchView('${item.view}')">${item.label}</div>`;
|
||||
for (const sub of item.sub) {
|
||||
html += `<div class="nav-sub" data-group="${item.view.replace('plugin-','')}" data-view="${sub.view}" onclick="switchView('${sub.view}')" style="display:none">${sub.label}</div>`;
|
||||
}
|
||||
} else {
|
||||
html += `<div class="nav-item" data-view="${item.view}" onclick="switchView('${item.view}')">${item.label}</div>`;
|
||||
}
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
html += '<div class="nav-item" data-view="timeline" onclick="switchView(\'timeline\')" style="display:none">📈 时间线</div>';
|
||||
nav.innerHTML = html;
|
||||
}
|
||||
|
||||
/**
|
||||
* renderNav() — 渲染侧边栏导航
|
||||
* 由 loadPlugins() 在 inline.js 中调用
|
||||
*/
|
||||
export function renderNav() {
|
||||
_renderNav();
|
||||
// 恢复当前视图的激活状态由调用方负责
|
||||
}
|
||||
|
||||
// ── 暴露到 window(供 HTML onclick 使用)──
|
||||
window.toggleSidebar = toggleSidebar;
|
||||
window.toggleGroup = toggleGroup;
|
||||
window.collapseAllGroups = collapseAllGroups;
|
||||
window.renderNav = renderNav;
|
||||
|
||||
// 自动渲染(模块加载时 DOM 可能尚未就绪 → 就绪后再渲染一次)
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', _renderNav);
|
||||
} else {
|
||||
_renderNav();
|
||||
}
|
||||
|
||||
// 内部函数也 export 以便测试
|
||||
export { toggleSidebar, toggleGroup, collapseAllGroups, _renderNav, groups };
|
||||
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* AgentOS Dashboard — 路由模块
|
||||
*
|
||||
* 职责:
|
||||
* - 管理已迁移视图的动态加载
|
||||
* - 提供工具函数供已迁移视图使用
|
||||
* - 与 index.html 内联代码共存(暴露到 window 供电线函数调用)
|
||||
*/
|
||||
|
||||
let _migrated = new Set();
|
||||
|
||||
export function registerMigrated(viewName) {
|
||||
_migrated.add(viewName);
|
||||
}
|
||||
|
||||
export function isMigrated(viewName) {
|
||||
return _migrated.has(viewName);
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态加载已迁移视图
|
||||
* @param {string} viewName - 视图名称(对应 views/{viewName}.js)
|
||||
* @param {HTMLElement} container - 渲染容器
|
||||
* @returns {Promise<boolean>} 是否成功
|
||||
*/
|
||||
export async function loadMigratedView(viewName, container) {
|
||||
if (!container) return false;
|
||||
try {
|
||||
const mod = await import(`./views/${viewName}.js`);
|
||||
if (mod && typeof mod.loadView === 'function') {
|
||||
await mod.loadView(container);
|
||||
return true;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[router] Failed to load view "${viewName}":`, err.message);
|
||||
container.innerHTML = `<div class="error">❌ 加载视图失败: ${err.message}</div>`;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── 全局接口(供 inline.js 调用)─────────────────────────
|
||||
|
||||
/**
|
||||
* 尝试加载已迁移视图。供电线函数 switchView() 使用。
|
||||
* 内部检查视图是否已注册,已注册则加载到 #view-dynamic。
|
||||
*
|
||||
* @param {string} viewName
|
||||
* @returns {boolean} 是否找到并开始加载
|
||||
*/
|
||||
export function tryLoadView(viewName) {
|
||||
if (!_migrated.has(viewName)) return false;
|
||||
|
||||
const container = document.getElementById('view-dynamic');
|
||||
if (!container) return false;
|
||||
|
||||
// 隐藏所有旧视图(class + inline style 双重保障,防内联 display 覆盖)
|
||||
document.querySelectorAll('[id^="view-"]').forEach(el => { el.classList.add('hidden'); el.style.display = 'none'; });
|
||||
document.querySelectorAll('[id^="plugin-view-"]').forEach(el => { el.classList.add('hidden'); el.style.display = 'none'; });
|
||||
|
||||
// 显示动态容器(必须先清除 inline style,因为上方的 querySelectorAll 对 view-dynamic 也设置了 style.display='none')
|
||||
container.classList.remove('hidden');
|
||||
container.style.removeProperty('display');
|
||||
container.style.display = 'block';
|
||||
container.innerHTML = '<div class="loading">⏳ 加载中...</div>';
|
||||
|
||||
// 异步加载(不阻塞 switchView 返回)
|
||||
loadMigratedView(viewName, container).catch(err => {
|
||||
console.error(`[router] tryLoadView failed:`, err);
|
||||
});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// 暴露到 window,供电线函数 switchView() 使用
|
||||
window.tryLoadView = tryLoadView;
|
||||
|
||||
// ── 工具函数 ──
|
||||
|
||||
export function showLoading(el, msg = '⏳ 加载中...') {
|
||||
if (el) el.innerHTML = `<div class="loading">${msg}</div>`;
|
||||
}
|
||||
|
||||
export function showError(el, msg) {
|
||||
if (el) el.innerHTML = `<div class="error">❌ ${msg}</div>`;
|
||||
}
|
||||
|
||||
const BASE = '/api';
|
||||
export async function apiRequest(path, options = {}) {
|
||||
const url = `${BASE}${path}`;
|
||||
const config = {
|
||||
headers: { 'Content-Type': 'application/json', ...options.headers },
|
||||
...options,
|
||||
};
|
||||
if (!config.method || config.method === 'GET' || config.method === 'HEAD') {
|
||||
delete config.headers['Content-Type'];
|
||||
}
|
||||
const r = await fetch(url, config);
|
||||
if (!r.ok) {
|
||||
const text = await r.text().catch(() => '');
|
||||
throw new Error(`HTTP ${r.status}: ${text.slice(0, 200)}`);
|
||||
}
|
||||
return r.json();
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用执行前确认弹窗
|
||||
* @param {string} message - 确认信息
|
||||
* @param {string} detail - 详情(可选)
|
||||
* @returns {Promise<boolean>} 是否确认
|
||||
*/
|
||||
export async function confirmExecute(message, detail = '') {
|
||||
return new Promise((resolve) => {
|
||||
const overlay = document.createElement('div');
|
||||
overlay.style.cssText = 'position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,0.4);z-index:99999;display:flex;align-items:center;justify-content:center';
|
||||
overlay.innerHTML = `
|
||||
<div style="background:var(--bg2);border-radius:12px;padding:20px;max-width:420px;width:90%;box-shadow:0 8px 30px rgba(0,0,0,0.2)">
|
||||
<div style="font-size:15px;font-weight:600;margin-bottom:8px">⚠️ 确认执行</div>
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:4px">${message}</div>
|
||||
${detail ? `<div style="font-size:11px;color:var(--text2);margin-bottom:12px;background:var(--bg3);padding:8px;border-radius:6px;font-family:monospace">${detail}</div>` : '<div style="margin-bottom:12px"></div>'}
|
||||
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||
<button id="confirmCancel" style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:6px 16px;font-size:12px;cursor:pointer">取消</button>
|
||||
<button id="confirmOk" style="background:#22c55e;color:#000;border:none;border-radius:6px;padding:6px 16px;font-size:12px;font-weight:600;cursor:pointer">✅ 确认执行</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
document.getElementById('confirmOk').onclick = () => { overlay.remove(); resolve(true); };
|
||||
document.getElementById('confirmCancel').onclick = () => { overlay.remove(); resolve(false); };
|
||||
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* 全局状态管理
|
||||
* 替代原来的 _allAccounts, _nurtureAccounts 等全局变量
|
||||
*/
|
||||
|
||||
class AppState {
|
||||
constructor() {
|
||||
this._accounts = [];
|
||||
this._hpIndex = {};
|
||||
this._collectedAt = '';
|
||||
this._listeners = {};
|
||||
}
|
||||
|
||||
get accounts() { return this._accounts; }
|
||||
get hpIndex() { return this._hpIndex; }
|
||||
get collectedAt() { return this._collectedAt; }
|
||||
|
||||
async loadAccounts() {
|
||||
if (this._accounts.length) return;
|
||||
const [r1, r2] = await Promise.all([
|
||||
fetch('/api/matrix/accounts'),
|
||||
fetch('/api/matrix/homepage-info').catch(() => ({ json: () => ({}) }))
|
||||
]);
|
||||
const accts = await r1.json();
|
||||
const hpData = r2.json ? await r2.json() : { results: [] };
|
||||
this._accounts = Array.isArray(accts) ? accts.filter(a => a.enabled !== false) : [];
|
||||
this._hpIndex = {};
|
||||
(hpData.results || []).forEach(r => {
|
||||
if (r.identity_dir) this._hpIndex[r.identity_dir] = r;
|
||||
if (r.phone) this._hpIndex[r.phone] = r;
|
||||
});
|
||||
this._collectedAt = hpData.collected_at || '';
|
||||
return this._accounts;
|
||||
}
|
||||
|
||||
getSelectedAccounts() {
|
||||
return Array.from(document.querySelectorAll('.as-cb:checked')).map(cb => ({
|
||||
id: cb.value,
|
||||
platform: cb.dataset.plat,
|
||||
blueprint: cb.dataset.bp,
|
||||
machine: cb.dataset.machine,
|
||||
}));
|
||||
}
|
||||
|
||||
// 事件监听
|
||||
on(event, fn) {
|
||||
if (!this._listeners[event]) this._listeners[event] = [];
|
||||
this._listeners[event].push(fn);
|
||||
}
|
||||
|
||||
emit(event, data) {
|
||||
(this._listeners[event] || []).forEach(fn => fn(data));
|
||||
}
|
||||
}
|
||||
|
||||
export const state = new AppState();
|
||||
@@ -0,0 +1,152 @@
|
||||
|
||||
:root {
|
||||
--bg: #f8f9fc; --bg2: #ffffff; --bg3: #f0f2f7;
|
||||
--text: #1a1a2e; --text2: #6b7280;
|
||||
--primary: #4f46e5; --primary-dim: #4338ca;
|
||||
--green: #059669; --red: #dc2626; --amber: #d97706; --blue: #2563eb;
|
||||
--border: #e2e8f0; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,.06);
|
||||
}
|
||||
* { margin: 0; padding: 0; box-sizing: border-box; }
|
||||
body { font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
||||
background: var(--bg); color: var(--text); min-height: 100vh; display: flex; }
|
||||
.sidebar { width: 200px; background: var(--bg2); padding: 16px 10px;
|
||||
border-right: 1px solid var(--border); display: flex; flex-direction: column; flex-shrink: 0;
|
||||
overflow-y: auto; max-height: 100vh; box-shadow: 1px 0 0 var(--border); }
|
||||
.sidebar::-webkit-scrollbar { width: 3px; }
|
||||
.sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
.sidebar.collapsed { width: 56px; }
|
||||
.sidebar.collapsed .nav-item span, .sidebar.collapsed .nav-group,
|
||||
.sidebar.collapsed .nav-sub, .sidebar.collapsed #identityBar { display: none; }
|
||||
.sidebar.collapsed h1 span { display: none; }
|
||||
.sidebar.collapsed .nav-item { justify-content: center; padding: 7px 0; font-size: 16px; }
|
||||
.sidebar.collapsed .nav-group-header { justify-content: center; padding: 8px 0; }
|
||||
.sidebar.collapsed .nav-group-header span { display: none; }
|
||||
.sidebar.collapsed .nav-group-body { max-height: 0 !important; }
|
||||
.sidebar.collapsed #sidebarToggleBtn { transform: rotate(180deg); }
|
||||
.sidebar.collapsed + .main { margin-left: 0; }
|
||||
.sidebar h1 { font-size: 14px; margin-bottom: 16px; display: flex; align-items: center; gap: 6px; white-space:nowrap; cursor: pointer; color: var(--text); font-weight: 700; }
|
||||
.sidebar h1:hover { opacity: .7; }
|
||||
.sidebar h1 span { background: var(--primary); color: #fff; padding: 1px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; }
|
||||
.nav-item { padding: 7px 10px; border-radius: 6px; cursor: pointer; margin-bottom: 2px;
|
||||
color: var(--text2); transition: .12s; display: flex; align-items: center; gap: 8px; font-size: 13px; }
|
||||
.nav-item:hover { background: var(--bg3); color: var(--text); }
|
||||
.nav-item.active { background: var(--primary); color: #fff; font-weight: 500; }
|
||||
.nav-group { font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: .5px;
|
||||
padding: 14px 12px 4px; font-weight: 600; opacity: .5; }
|
||||
.nav-sub { padding: 6px 12px 6px 32px; border-radius: 6px; cursor: pointer; margin-bottom: 2px;
|
||||
color: var(--text2); transition: .12s; display: flex; align-items: center; gap: 8px; font-size: 12px; }
|
||||
.nav-sub:hover { background: var(--bg3); color: var(--text); }
|
||||
.nav-sub.active { background: rgba(79,70,229,.08); color: var(--primary); font-weight: 500; }
|
||||
|
||||
/* Collapsible groups */
|
||||
.nav-group-header { font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: .5px;
|
||||
padding: 10px 12px 4px; font-weight: 600; opacity: .5; cursor: pointer; display: flex; align-items: center; gap: 6px;
|
||||
transition: .12s; user-select: none; }
|
||||
.nav-group-header:hover { opacity: .8; color: var(--text); }
|
||||
.nav-group-body { overflow: hidden; transition: max-height .2s ease; max-height: 800px; }
|
||||
.nav-group-body.collapsed { max-height: 0; }
|
||||
|
||||
/* KB tabs */
|
||||
.kb-tab { padding: 6px 14px; border-radius: 6px; cursor: pointer; background: var(--bg2); color: var(--text2); border: 1px solid var(--border); font-size: 12px; transition: .12s; }
|
||||
.kb-tab:hover { background: var(--bg3); color: var(--text); }
|
||||
.kb-tab.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.kb-tab-body { min-height: 200px; }
|
||||
|
||||
/* Account management tabs */
|
||||
.acct-tab { padding: 5px 12px; border-radius: 5px; cursor: pointer; background: var(--bg2); color: var(--text2); border: 1px solid var(--border); font-size: 12px; transition: .12s; }
|
||||
.acct-tab:hover { background: var(--bg3); color: var(--text); }
|
||||
.acct-tab.active { background: var(--primary); color: #fff; border-color: var(--primary); }
|
||||
.main { flex: 1; padding: 24px 32px; overflow-y: auto; max-height: 100vh; }
|
||||
|
||||
/* Stats */
|
||||
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 8px; margin-bottom: 12px; }
|
||||
.stat-card { background: var(--bg2); border-radius: var(--radius); padding: 10px 14px; border: 1px solid var(--border); box-shadow: var(--shadow); }
|
||||
.stat-card .label { font-size: 10px; color: var(--text2); text-transform: uppercase; letter-spacing: .4px; font-weight: 600; }
|
||||
.stat-card .value { font-size: 18px; font-weight: 700; margin-top: 2px; }
|
||||
.stat-card .sub { font-size: 10px; color: var(--text2); margin-top: 2px; }
|
||||
|
||||
/* Table */
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
th, td { text-align: left; padding: 10px 12px; border-bottom: 1px solid var(--border); font-size: 13px; }
|
||||
th { color: var(--text2); font-weight: 600; font-size: 11px; text-transform: uppercase; letter-spacing: .4px; }
|
||||
tr:hover td { background: var(--bg3); cursor: pointer; }
|
||||
.badge { padding: 2px 8px; border-radius: 12px; font-size: 11px; font-weight: 600; }
|
||||
.badge-完成, .badge-completed { background: rgba(5,150,105,.1); color: var(--green); }
|
||||
.badge-失败, .badge-failed { background: rgba(220,38,38,.1); color: var(--red); }
|
||||
.badge-运行中, .badge-running { background: rgba(37,99,235,.1); color: var(--blue); }
|
||||
|
||||
/* Detail View */
|
||||
.detail-header { margin-bottom: 24px; }
|
||||
.detail-header h2 { font-size: 20px; margin-bottom: 8px; }
|
||||
.detail-meta { display: flex; gap: 20px; font-size: 13px; color: var(--text2); }
|
||||
.step-list { margin-bottom: 28px; }
|
||||
.step-item { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; }
|
||||
.step-item .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.step-item .name { flex: 1; font-size: 13px; }
|
||||
.step-item .meta { font-size: 12px; color: var(--text2); }
|
||||
|
||||
/* Filter bar */
|
||||
.filter-bar { display: flex; gap: 10px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
|
||||
.filter-bar select, .filter-bar input { background: var(--bg2); border: 1px solid var(--border);
|
||||
color: var(--text); padding: 8px 12px; border-radius: 6px; font-size: 13px; box-shadow: var(--shadow); }
|
||||
.filter-bar input { flex: 1; min-width: 200px; }
|
||||
.filter-bar button { background: var(--primary); color: #fff; border: none; padding: 8px 16px;
|
||||
border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 500; }
|
||||
.filter-bar button:hover { background: var(--primary-dim); }
|
||||
|
||||
/* Cost breakdown */
|
||||
.cost-bar { display: flex; height: 24px; border-radius: 6px; overflow: hidden; margin: 12px 0; }
|
||||
.cost-bar div { display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; }
|
||||
|
||||
/* Section title */
|
||||
.section-title { font-size: 14px; font-weight: 600; margin: 20px 0 12px; color: var(--text2); }
|
||||
|
||||
/* Asset grid */
|
||||
.asset-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px,1fr)); gap: 12px; }
|
||||
.asset-card { background: var(--bg2); border-radius: var(--radius); padding: 14px; border: 1px solid var(--border); box-shadow: var(--shadow); }
|
||||
.asset-card .type { font-size: 11px; color: var(--primary); text-transform: uppercase; letter-spacing: .4px; }
|
||||
.asset-card .name { font-size: 13px; margin: 4px 0; }
|
||||
.asset-card .meta { font-size: 11px; color: var(--text2); }
|
||||
|
||||
/* Chart placeholder */
|
||||
.chart-box { background: var(--bg2); border-radius: var(--radius); padding: 20px;
|
||||
border: 1px solid var(--border); margin-bottom: 16px; box-shadow: var(--shadow); }
|
||||
|
||||
/* Pagination */
|
||||
.pagination { display: flex; justify-content: center; gap: 6px; margin-top: 16px; }
|
||||
.pagination button { background: var(--bg2); border: 1px solid var(--border); color: var(--text);
|
||||
padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 13px; }
|
||||
.pagination button:hover { background: var(--bg3); }
|
||||
.pagination button.active { background: var(--primary); border-color: var(--primary); color: #fff; }
|
||||
|
||||
.loading { text-align: center; padding: 40px; color: var(--text2); }
|
||||
.error { text-align: center; padding: 20px; color: var(--red); }
|
||||
.hidden { display: none; }
|
||||
|
||||
/* Blueprint editor */
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-bottom: 8px; }
|
||||
.form-row label { font-size: 11px; color: var(--text2); display: block; margin-bottom: 2px; }
|
||||
.form-row input, .form-row select { background: var(--bg2); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 5px; font-size: 12px; width: 100%; }
|
||||
.op-card { transition: .12s; }
|
||||
.op-card:hover { border-color: var(--primary) !important; }
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar { width: 60px; padding: 16px 8px; }
|
||||
.sidebar h1 span, .nav-item span { display: none; }
|
||||
.main { padding: 16px; }
|
||||
.stats { grid-template-columns: repeat(2, 1fr); }
|
||||
}
|
||||
|
||||
/* Machine status bar */
|
||||
.machines-bar { display: flex; gap: 8px; margin-bottom: 12px; flex-wrap: wrap; }
|
||||
.machine-status-card { display: flex; align-items: center; gap: 6px; padding: 6px 12px; border-radius: 6px; background: var(--bg2); border: 1px solid var(--border); font-size: 12px; box-shadow: var(--shadow); }
|
||||
.machine-status-card .status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.machine-status-card.online .status-dot { background: var(--green); }
|
||||
.machine-status-card.busy .status-dot { background: var(--amber); }
|
||||
.machine-status-card.offline .status-dot { background: var(--text2); opacity: .3; }
|
||||
.machine-status-card .machine-name { font-weight: 600; color: var(--text); }
|
||||
.machine-status-card .machine-label { font-size: 10px; color: var(--text2); }
|
||||
.machine-status-card .machine-status { font-size: 10px; }
|
||||
.machine-status-card.online .machine-status { color: var(--green); }
|
||||
.machine-status-card.busy .machine-status { color: var(--amber); }
|
||||
.machine-status-card.offline .machine-status { color: var(--text2); opacity: .5; }
|
||||
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* utils.js — 通用工具函数(从 inline.js 提取)
|
||||
*
|
||||
* 用途:视图渲染辅助、字符处理、DOM 操作
|
||||
* 注意:这些是纯函数,不依赖外部状态
|
||||
*/
|
||||
|
||||
// ── Shell 视图渲染(用于尚未对接 API 的视图占位)──
|
||||
export function renderShell(containerId, title, status, note) {
|
||||
const el = document.getElementById(containerId);
|
||||
if (!el) return;
|
||||
el.classList.remove('hidden');
|
||||
el.style.display = 'block';
|
||||
el.innerHTML = `
|
||||
<div style="padding:40px;text-align:center;color:var(--text2)">
|
||||
<div style="font-size:32px;margin-bottom:12px">🚧</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:8px">${title}</div>
|
||||
<div style="font-size:12px;margin-bottom:4px">状态: ${status}</div>
|
||||
<div style="font-size:11px;opacity:.7">${note}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// ── HTML 转义 ──
|
||||
export function esc(s) {
|
||||
if (typeof s !== 'string') return s ?? '';
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ── 字符串截断 ──
|
||||
export function trunc(s, n) {
|
||||
if (typeof s !== 'string') return s ?? '';
|
||||
return s.length > n ? s.slice(0, n) + '...' : s;
|
||||
}
|
||||
|
||||
// ── 时间格式化 ──
|
||||
export function formatTime(iso) {
|
||||
if (!iso) return '';
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||
} catch { return iso.slice(0, 16) || iso; }
|
||||
}
|
||||
|
||||
// ── 数字格式化 ──
|
||||
export function fmtNum(n) {
|
||||
const num = parseInt(n) || 0;
|
||||
if (num >= 10000) return (num / 10000).toFixed(1) + 'w';
|
||||
return num.toLocaleString();
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* view-registry.js — 已迁移视图注册表
|
||||
*
|
||||
* 所有已完成 Vite 迁移的视图在此注册。
|
||||
* 注册后,switchView 会自动使用动态加载代替 inline 渲染。
|
||||
*
|
||||
* 命名规则:视图名 = views/{name}.js 文件名(不含 .js)
|
||||
* 对应 inline.js 中 switchView() 的视图名
|
||||
*/
|
||||
import { registerMigrated } from './router.js';
|
||||
|
||||
const MIGRATED_VIEWS = [
|
||||
// ── 矩阵养号 ──
|
||||
'comment-workbench',
|
||||
'accounts-center',
|
||||
'matrix-summary',
|
||||
'matrix-accounts',
|
||||
'matrix-nurture',
|
||||
'matrix-collect',
|
||||
'matrix-publish',
|
||||
'matrix-blueprints',
|
||||
'matrix-interact',
|
||||
'matrix-dm',
|
||||
'matrix-live',
|
||||
'matrix-schedule',
|
||||
'matrix-corpus',
|
||||
'matrix-sms-proxy',
|
||||
// 'matrix-login', // 已合并到信息采集
|
||||
'matrix-atom-ops',
|
||||
|
||||
// ── 命令与任务 ──
|
||||
'matrix-commands',
|
||||
'ops-command',
|
||||
|
||||
// ── 系统配置 ──
|
||||
'api-config',
|
||||
'ave-capabilities',
|
||||
'ave-director',
|
||||
// ── 导演工作室(整合后的主路径:7 阶段标签单页全流程)──
|
||||
'ave-studio',
|
||||
// ── 视频工厂 P1 菜单重组:资源库 ──
|
||||
'creative-studio',
|
||||
'creatives',
|
||||
'ave-locations',
|
||||
'ave-real-locations',
|
||||
'ave-props',
|
||||
'ave-products',
|
||||
'ave-wardrobe',
|
||||
'ave-voices',
|
||||
'ave-bgm',
|
||||
'ave-sfx',
|
||||
'ave-ambience',
|
||||
'ave-vfx-presets',
|
||||
// ── 流程 ──
|
||||
'ave-keyframes',
|
||||
'ave-keyframe-video',
|
||||
'ave-flow-short-drama',
|
||||
'ave-flow-dub',
|
||||
'ave-flow-beat',
|
||||
'ave-flow-narrative',
|
||||
'ave-flow-digital-human',
|
||||
'ave-flow-hybrid',
|
||||
'ave-new-flow',
|
||||
// ── 能力中心 ──
|
||||
'ave-providers',
|
||||
'ave-test-cap',
|
||||
'ave-docs',
|
||||
// ── 运营 ──
|
||||
'ave-history',
|
||||
'ave-settings',
|
||||
|
||||
// ── 录制标注 ──
|
||||
'ops-recorder',
|
||||
|
||||
// ── 视频工厂 ──
|
||||
'workflow',
|
||||
'capabilities',
|
||||
'characters', // 角色列表
|
||||
'char-gen', // 角色生成器
|
||||
'scene-gen', // 场景生成器(分层字段 → 场景图)
|
||||
|
||||
// ── 内容抓取 ──
|
||||
'scrape',
|
||||
|
||||
// ── 联邦管理 ──
|
||||
'fleet-sync',
|
||||
'fleet-reconcile',
|
||||
'fleet-exec',
|
||||
|
||||
// ── 视频工厂 — AVE 子视图 ──
|
||||
'ave-render',
|
||||
'ave-script',
|
||||
'ave-materials',
|
||||
'ave-templates',
|
||||
|
||||
// ── 视频工厂 — 人物置换(独立业务) ──
|
||||
'person-swap',
|
||||
|
||||
// ── 服务 ──
|
||||
'serve-mcp',
|
||||
'serve-dashboard',
|
||||
'serve-schedule',
|
||||
|
||||
// ── 概览 ──
|
||||
'productions',
|
||||
'assets',
|
||||
'costs',
|
||||
|
||||
// ── 其他 ──
|
||||
'timeline', // 时间线(暂留观察)
|
||||
];
|
||||
|
||||
// 注册所有已迁移视图
|
||||
MIGRATED_VIEWS.forEach(registerMigrated);
|
||||
|
||||
export default MIGRATED_VIEWS;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,105 @@
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `<div class="view-loading"><div class="spinner"></div><p>加载告警中心...</p></div>`;
|
||||
|
||||
try {
|
||||
await renderAlerts(container);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="view-error"><h3>❌ 加载失败</h3><p>${err.message}</p></div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function renderAlerts(container) {
|
||||
const res = await fetch('/api/matrix/alerts/list');
|
||||
if (!res.ok) throw new Error(`获取告警列表失败: ${res.status}`);
|
||||
const alerts = await res.json();
|
||||
const list = Array.isArray(alerts) ? alerts : (alerts.alerts || alerts.data || []);
|
||||
|
||||
container.innerHTML = `
|
||||
<div class="view-header">
|
||||
<h2>🔔 告警中心</h2>
|
||||
<span class="badge">${list.length} 条</span>
|
||||
</div>
|
||||
<div class="alerts-toolbar">
|
||||
<button id="alerts-refresh-btn" class="btn btn-secondary">🔄 刷新</button>
|
||||
</div>
|
||||
<div class="alerts-list">
|
||||
${list.length === 0
|
||||
? '<div class="empty-state">暂无告警 🎉</div>'
|
||||
: list.map((alert, idx) => renderAlertItem(alert, idx)).join('')
|
||||
}
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Wire up refresh
|
||||
container.querySelector('#alerts-refresh-btn').addEventListener('click', async () => {
|
||||
container.innerHTML = `<div class="view-loading"><div class="spinner"></div><p>刷新中...</p></div>`;
|
||||
try {
|
||||
await renderAlerts(container);
|
||||
} catch (err) {
|
||||
container.innerHTML = `<div class="view-error"><h3>❌ 刷新失败</h3><p>${err.message}</p></div>`;
|
||||
}
|
||||
});
|
||||
|
||||
// Wire up dismiss buttons
|
||||
container.querySelectorAll('.alert-dismiss-btn').forEach(btn => {
|
||||
btn.addEventListener('click', async (e) => {
|
||||
const alertId = e.target.dataset.alertId;
|
||||
if (!alertId) return;
|
||||
if (!confirm('确认清除此告警?')) return;
|
||||
|
||||
try {
|
||||
const delRes = await fetch(`/api/matrix/alerts/${alertId}`, { method: 'DELETE' });
|
||||
if (!delRes.ok) throw new Error(`清除失败: ${delRes.status}`);
|
||||
// Remove the item from DOM
|
||||
const item = e.target.closest('.alert-item');
|
||||
if (item) item.remove();
|
||||
// Update badge
|
||||
const badge = container.querySelector('.badge');
|
||||
const remaining = container.querySelectorAll('.alert-item').length;
|
||||
if (badge) badge.textContent = `${remaining} 条`;
|
||||
if (remaining === 0) {
|
||||
const listEl = container.querySelector('.alerts-list');
|
||||
if (listEl) listEl.innerHTML = '<div class="empty-state">暂无告警 🎉</div>';
|
||||
}
|
||||
} catch (err) {
|
||||
alert(`清除告警失败: ${err.message}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function renderAlertItem(alert, idx) {
|
||||
const id = alert.id || alert.alert_id || `alert-${idx}`;
|
||||
const severity = (alert.severity || alert.level || 'info').toLowerCase();
|
||||
const message = alert.message || alert.msg || alert.title || JSON.stringify(alert);
|
||||
const time = alert.time || alert.timestamp || alert.created_at || '';
|
||||
const severityLabels = { critical: '严重', error: '错误', warning: '警告', info: '信息' };
|
||||
const severityLabel = severityLabels[severity] || severity;
|
||||
const severityClass = severity === 'critical' ? 'severity-critical'
|
||||
: severity === 'error' ? 'severity-error'
|
||||
: severity === 'warning' ? 'severity-warning'
|
||||
: 'severity-info';
|
||||
|
||||
return `
|
||||
<div class="alert-item ${severityClass}">
|
||||
<div class="alert-icon">${severity === 'critical' ? '🔴' : severity === 'error' ? '⛔' : severity === 'warning' ? '⚠️' : 'ℹ️'}</div>
|
||||
<div class="alert-body">
|
||||
<div class="alert-severity">${severityLabel}</div>
|
||||
<div class="alert-message">${message}</div>
|
||||
${time ? `<div class="alert-time">${formatTime(time)}</div>` : ''}
|
||||
</div>
|
||||
<button class="alert-dismiss-btn btn btn-sm" data-alert-id="${id}">✕</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function formatTime(ts) {
|
||||
if (!ts) return '';
|
||||
try {
|
||||
const d = new Date(ts);
|
||||
if (isNaN(d.getTime())) return ts;
|
||||
return d.toLocaleString('zh-CN', { hour12: false });
|
||||
} catch {
|
||||
return ts;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
/**
|
||||
* ⚙️ API 配置(已迁移视图)
|
||||
*
|
||||
* 让每个使用者配置自己的 API 密钥:
|
||||
* - 密钥只写入本机 agent-local(不进 git 仓库)
|
||||
* - 接口返回一律脱敏(前 5 + 后 4 位)
|
||||
* - 支持「🧪 测试」就地验证连通性
|
||||
*/
|
||||
let _acData = null;
|
||||
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px;max-width:920px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||
<h2 style="font-size:18px;margin:0">⚙️ API 配置</h2>
|
||||
<span id="acSummary" style="font-size:12px;color:var(--text2)"></span>
|
||||
</div>
|
||||
<div style="background:rgba(34,197,94,.08);border:1px solid rgba(34,197,94,.3);border-radius:6px;
|
||||
padding:9px 11px;font-size:11px;color:var(--text2);margin-bottom:12px;line-height:1.6">
|
||||
🔐 密钥只保存在<b>本机</b> <code id="acPath" style="background:var(--bg3);padding:1px 5px;border-radius:3px">…</code>,
|
||||
<b>不会提交到 git 仓库</b>;每个使用者配置自己的 key,互不影响。<br>
|
||||
💡 输入框留空 + 点保存 = 清除该项配置。
|
||||
</div>
|
||||
<div id="acGroups"><div class="loading">加载配置状态…</div></div>
|
||||
</div>`;
|
||||
await _acLoad();
|
||||
}
|
||||
|
||||
async function _acLoad() {
|
||||
try {
|
||||
const r = await fetch('/api/config/api-keys');
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '加载失败');
|
||||
_acData = d;
|
||||
_acRender(d);
|
||||
} catch (e) {
|
||||
const el = document.getElementById('acGroups');
|
||||
if (el) el.innerHTML = `<div class="error" style="padding:20px">❌ ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function _acRender(d) {
|
||||
const sum = document.getElementById('acSummary');
|
||||
if (sum) sum.textContent = `已配置 ${d.configured} / ${d.total} 项`;
|
||||
const pathEl = document.getElementById('acPath');
|
||||
if (pathEl) pathEl.textContent = d.config_path || 'agent-local/…/local.yaml';
|
||||
|
||||
const wrap = document.getElementById('acGroups');
|
||||
if (!wrap) return;
|
||||
wrap.innerHTML = (d.groups || []).map(g => `
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:10px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">${_esc(g.name)}</div>
|
||||
${g.items.map(it => `
|
||||
<div style="padding:7px 0;border-top:1px solid var(--border)">
|
||||
<div style="display:flex;align-items:center;gap:8px;flex-wrap:wrap">
|
||||
<span style="font-size:11px;font-weight:500;min-width:150px">${_esc(it.label)}${it.required ? ' <span style="color:var(--red);font-size:9px">*必需</span>' : ''}</span>
|
||||
<span id="acBadge_${_esc(it.path)}" style="font-size:10px;padding:1px 7px;border-radius:9px;${it.configured
|
||||
? 'background:rgba(34,197,94,.15);color:#22c55e;border:1px solid rgba(34,197,94,.35)'
|
||||
: 'background:rgba(239,68,68,.12);color:#ef4444;border:1px solid rgba(239,68,68,.3)'}">
|
||||
${it.configured ? '✅ 已配置' : '⚠️ 未配置'}
|
||||
</span>
|
||||
${it.masked ? `<code style="font-size:10px;color:var(--text2);background:var(--bg2);padding:1px 5px;border-radius:3px">${_esc(it.masked)}</code>` : ''}
|
||||
${it.testable ? `<button onclick="window._acTest('${_esc(it.path)}')" style="background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:2px 9px;border-radius:4px;cursor:pointer;font-size:10px">🧪 测试</button>` : ''}
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text2);margin:3px 0 5px">${_esc(it.hint || '')}</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<input id="acInput_${_esc(it.path)}" type="${it.secret === false ? 'text' : 'password'}" autocomplete="off"
|
||||
placeholder="${it.secret === false
|
||||
? (it.masked ? '当前: ' + it.masked : '输入新值(如 deepseek-v4-flash)')
|
||||
: '输入新的 key' + (it.configured ? '(留空保存=清除)' : '')}"
|
||||
style="flex:1;padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px">
|
||||
<button onclick="window._acSave('${_esc(it.path)}')" style="background:var(--primary);color:#fff;border:none;padding:4px 14px;border-radius:4px;cursor:pointer;font-size:10px;font-weight:600">💾 保存</button>
|
||||
<span id="acMsg_${_esc(it.path)}" style="font-size:10px;color:var(--text2);min-width:60px"></span>
|
||||
</div>
|
||||
${(it.history && it.history.length) ? `
|
||||
<details style="margin-top:5px">
|
||||
<summary style="cursor:pointer;font-size:10px;color:var(--text2)">📚 历史版本 (${it.history.length}) — 可切换 / 删除</summary>
|
||||
<div style="margin-top:5px;display:grid;gap:4px">
|
||||
${it.history.map(h => `
|
||||
<div style="display:flex;align-items:center;gap:6px;font-size:10px;flex-wrap:wrap;
|
||||
background:var(--bg2);padding:3px 6px;border-radius:4px;${h.active ? 'border:1px solid rgba(34,197,94,.4)' : ''}">
|
||||
<span>${h.active ? '🟢' : '⚪'}</span>
|
||||
<code style="color:var(--text2);background:var(--bg3);padding:1px 5px;border-radius:3px">${_esc(h.masked)}</code>
|
||||
<span style="color:var(--text2)">${_esc(h.label || '')}</span>
|
||||
<span style="color:var(--text2);opacity:.65">${_esc(h.added_at || '')}</span>
|
||||
${h.active
|
||||
? '<span style="color:#22c55e;font-weight:600">使用中</span>'
|
||||
: `<button onclick="window._acActivate('${_esc(it.path)}', ${h.idx})" title="切换为该版本(当前版本会保留在历史,可回退)"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:1px 8px;border-radius:3px;cursor:pointer;font-size:9px">🔄 启用</button>
|
||||
<button onclick="window._acDelete('${_esc(it.path)}', ${h.idx})" title="从历史中删除该版本"
|
||||
style="background:var(--bg3);color:var(--red);border:1px solid var(--border);padding:1px 7px;border-radius:3px;cursor:pointer;font-size:9px">🗑</button>`}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
</details>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>`).join('');
|
||||
}
|
||||
|
||||
function _msg(path, text, color) {
|
||||
const el = document.getElementById(`acMsg_${path}`);
|
||||
if (el) {
|
||||
el.textContent = text || '';
|
||||
el.style.color = color || 'var(--text2)';
|
||||
}
|
||||
}
|
||||
|
||||
function _isSecret(path) {
|
||||
if (_acData) {
|
||||
for (const g of (_acData.groups || [])) {
|
||||
for (const it of (g.items || [])) {
|
||||
if (it.path === path) return it.secret !== false;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
window._acSave = async (path) => {
|
||||
const input = document.getElementById(`acInput_${path}`);
|
||||
const val = (input?.value || '').trim();
|
||||
if (val && _isSecret(path) && !confirm(`确认保存该 key?\n(只写入本机 agent-local,不会进 git)\n\n${val.slice(0, 6)}…${val.slice(-4)}`)) return;
|
||||
_msg(path, '⏳ 保存中…');
|
||||
try {
|
||||
const r = await fetch('/api/config/api-keys', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ updates: { [path]: val } }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '保存失败');
|
||||
if (input) input.value = '';
|
||||
_msg(path, val ? '✅ 已保存' : '🗑 已清除', '#22c55e');
|
||||
await _acLoad();
|
||||
} catch (e) {
|
||||
_msg(path, '❌ ' + e.message, '#ef4444');
|
||||
}
|
||||
};
|
||||
|
||||
window._acTest = async (path) => {
|
||||
const input = document.getElementById(`acInput_${path}`);
|
||||
const typed = (input?.value || '').trim();
|
||||
_msg(path, '⏳ 测试中…');
|
||||
try {
|
||||
const r = await fetch('/api/config/api-keys/test', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, value: typed }), // 输入框有值则测新 key
|
||||
});
|
||||
const d = await r.json();
|
||||
const color = d.ok === true ? '#22c55e' : (d.ok === false ? '#f59e0b' : 'var(--text2)');
|
||||
_msg(path, d.detail || '(无返回)', color);
|
||||
} catch (e) {
|
||||
_msg(path, '❌ ' + e.message, '#ef4444');
|
||||
}
|
||||
};
|
||||
|
||||
window._acActivate = async (path, idx) => {
|
||||
if (!confirm('切换到该版本?\n\n当前版本会自动保留在历史里,可随时切回。')) return;
|
||||
_msg(path, '⏳ 切换中…');
|
||||
try {
|
||||
const r = await fetch('/api/config/api-keys/activate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, idx }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '切换失败');
|
||||
_msg(path, `🔄 已切换为 ${d.activated}`, '#22c55e');
|
||||
await _acLoad();
|
||||
} catch (e) {
|
||||
_msg(path, '❌ ' + e.message, '#ef4444');
|
||||
}
|
||||
};
|
||||
|
||||
window._acDelete = async (path, idx) => {
|
||||
if (!confirm('删除该历史版本?\n\n此操作不可撤销(不影响当前使用中的版本)。')) return;
|
||||
_msg(path, '⏳ 删除中…');
|
||||
try {
|
||||
const r = await fetch('/api/config/api-keys/delete', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path, idx }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '删除失败');
|
||||
_msg(path, `🗑 已删除 ${d.removed}`, '#22c55e');
|
||||
await _acLoad();
|
||||
} catch (e) {
|
||||
_msg(path, '❌ ' + e.message, '#ef4444');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,670 @@
|
||||
/**
|
||||
* assets-common.js — 通用资产浏览器(配置驱动)
|
||||
*
|
||||
* 场景库 / 真实场地 / 产品库 / 道具库 / 服装库 / BGM / 音效 / 音色 共用这套 UI:
|
||||
* 搜索 + 筛选 + 排序 + 卡片/表格 + 详情抽屉(编辑 + 上传参考图/音频 + 删除)
|
||||
*
|
||||
* 用法(各资产页面):
|
||||
* import { renderAssetPage } from './assets-common.js';
|
||||
* export async function loadView(container) {
|
||||
* await renderAssetPage(container, LOCATION_CONFIG);
|
||||
* }
|
||||
*
|
||||
* 配置项:
|
||||
* kind 资产类型(API 路径 /api/ave/assets/{kind})
|
||||
* title/icon 页面标题
|
||||
* refKeys [[key, label], ...] 参考图槽位(如 [['wide','全景'],['detail','细节']])
|
||||
* audio true=提供音频上传(BGM/音效/音色)
|
||||
* filters 额外筛选项 [{key,label,options:{v:label}}]
|
||||
* editFields 详情里的可编辑字段 [{key,label,type:'text'|'textarea'|'list'}]
|
||||
* desc "规划内容"说明(列表为空时展示)
|
||||
* uid 页面实例 id(避免多页冲突)
|
||||
*/
|
||||
|
||||
import { openGenerateReview } from './gen-review.js';
|
||||
|
||||
const API_BASE = '/api/ave/assets';
|
||||
|
||||
export async function renderAssetPage(container, cfg) {
|
||||
const uid = cfg.uid || cfg.kind;
|
||||
const state = {
|
||||
items: [], stats: {},
|
||||
view: localStorage.getItem(`ave_asset_view_${cfg.kind}`) || 'card',
|
||||
filters: { q: '', sort: 'updated_desc' },
|
||||
};
|
||||
cfg.filters?.forEach(f => { state.filters[f.key] = ''; });
|
||||
|
||||
container.innerHTML = shell(cfg, uid);
|
||||
bind(cfg, uid, state);
|
||||
injectDirPending(cfg, uid);
|
||||
injectSceneSlots(cfg, uid);
|
||||
await refresh(cfg, uid, state);
|
||||
}
|
||||
|
||||
function shell(cfg, uid) {
|
||||
const extraFilters = (cfg.filters || []).map(f => `
|
||||
<select id="${uid}_f_${f.key}" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
<option value="">${f.label}:全部</option>
|
||||
${Object.entries(f.options).map(([v, l]) => `<option value="${esc(v)}">${esc(l)}</option>`).join('')}
|
||||
</select>`).join('');
|
||||
|
||||
return `
|
||||
<div style="padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
||||
<h2 style="font-size:18px;margin:0">${cfg.icon} ${cfg.title}
|
||||
<span id="${uid}_count" style="font-size:12px;color:var(--text2)"></span></h2>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button onclick="window._assetToggleView('${uid}','${cfg.kind}')" id="${uid}_viewbtn"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px">📋 表格视图</button>
|
||||
${cfg.kind === 'characters' ? `<button onclick="window._assetCheckQuality(${JSON.stringify(cfg)} ,'${uid}')"
|
||||
title="检查参考图背景是否干净(带场景的参考图会污染图生视频)"
|
||||
style="background:#f59e0b;color:#000;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px;font-weight:600;margin-right:6px">🔍 质检参考图</button>` : ''}
|
||||
<button onclick="window._assetNew('${uid}','${cfg.kind}')"
|
||||
style="background:var(--primary);color:#fff;border:none;padding:5px 14px;border-radius:6px;cursor:pointer;font-size:12px">➕ 新建</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px;padding:10px;background:var(--bg3);border:1px solid var(--border);border-radius:8px">
|
||||
<input id="${uid}_q" placeholder="🔍 搜索名称 / 标签 / 描述"
|
||||
style="flex:1;min-width:170px;padding:5px 10px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
${extraFilters}
|
||||
<input id="${uid}_tag" placeholder="标签" style="width:86px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
<select id="${uid}_sort" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
<option value="updated_desc">最近更新</option><option value="name">名称</option><option value="usage">使用次数</option>
|
||||
</select>
|
||||
<button onclick="window._assetReset('${uid}','${cfg.kind}')"
|
||||
style="background:none;border:1px solid var(--border);color:var(--text2);padding:5px 10px;border-radius:6px;cursor:pointer;font-size:11px">重置</button>
|
||||
</div>
|
||||
|
||||
<div id="${uid}_list"><div style="color:var(--text2);font-size:12px">⏳ 加载中…</div></div>
|
||||
</div>
|
||||
|
||||
<div id="${uid}_drawer" style="display:none;position:fixed;top:0;right:0;width:520px;max-width:92vw;height:100vh;background:var(--bg2);border-left:1px solid var(--border);z-index:999;overflow-y:auto;box-shadow:-4px 0 24px rgba(0,0,0,.3)">
|
||||
<div id="${uid}_body" style="padding:16px"></div>
|
||||
</div>
|
||||
<div id="${uid}_mask" onclick="window._assetClose('${uid}')" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:998"></div>`;
|
||||
}
|
||||
|
||||
function bind(cfg, uid, state) {
|
||||
const q = document.getElementById(`${uid}_q`);
|
||||
let t = null;
|
||||
const debounce = (fn) => { clearTimeout(t); t = setTimeout(fn, 300); };
|
||||
q?.addEventListener('input', () => debounce(() => { state.filters.q = q.value.trim(); refresh(cfg, uid, state); }));
|
||||
document.getElementById(`${uid}_tag`)?.addEventListener('input', e =>
|
||||
debounce(() => { state.filters.tag = e.target.value.trim(); refresh(cfg, uid, state); }));
|
||||
(cfg.filters || []).forEach(f => {
|
||||
document.getElementById(`${uid}_f_${f.key}`)?.addEventListener('change', e => {
|
||||
state.filters[f.key] = e.target.value; refresh(cfg, uid, state);
|
||||
});
|
||||
});
|
||||
document.getElementById(`${uid}_sort`)?.addEventListener('change', e => {
|
||||
state.filters.sort = e.target.value; refresh(cfg, uid, state);
|
||||
});
|
||||
updateViewBtn(uid, state);
|
||||
}
|
||||
|
||||
function updateViewBtn(uid, state) {
|
||||
const b = document.getElementById(`${uid}_viewbtn`);
|
||||
if (b) b.textContent = state.view === 'card' ? '📋 表格视图' : '🔳 卡片视图';
|
||||
}
|
||||
|
||||
window._assetToggleView = (uid, kind) => {
|
||||
const cur = localStorage.getItem(`ave_asset_view_${kind}`) || 'card';
|
||||
const next = cur === 'card' ? 'table' : 'card';
|
||||
localStorage.setItem(`ave_asset_view_${kind}`, next);
|
||||
const st = (window.__assetState || {})[uid];
|
||||
const cf = (window.__assetCfg || {})[uid];
|
||||
if (st && cf) { st.view = next; updateViewBtn(uid, st); renderList(cf, uid, st); }
|
||||
};
|
||||
|
||||
window._assetReset = (uid, kind) => {
|
||||
const p = new URLSearchParams();
|
||||
// 清空 UI
|
||||
['_q', '_tag'].forEach(s => { const e = document.getElementById(uid + s); if (e) e.value = ''; });
|
||||
const sortEl = document.getElementById(`${uid}_sort`); if (sortEl) sortEl.value = 'updated_desc';
|
||||
document.querySelectorAll(`[id^="${uid}_f_"]`).forEach(e => e.value = '');
|
||||
const cfg = window.__assetCfg?.[uid];
|
||||
if (cfg) refresh(cfg, uid, window.__assetState[uid]);
|
||||
};
|
||||
|
||||
async function refresh(cfg, uid, state) {
|
||||
const p = new URLSearchParams();
|
||||
const f = state.filters;
|
||||
if (f.q) p.set('q', f.q);
|
||||
if (f.tag) p.set('tag', f.tag);
|
||||
(cfg.filters || []).forEach(x => { if (f[x.key]) p.set(x.key, f[x.key]); });
|
||||
p.set('sort', f.sort);
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/${cfg.kind}?${p}`);
|
||||
const d = await r.json();
|
||||
state.items = d.items || [];
|
||||
renderList(cfg, uid, state);
|
||||
const sr = await fetch(`${API_BASE}/${cfg.kind}/stats`);
|
||||
state.stats = (await sr.json()).stats || {};
|
||||
const c = document.getElementById(`${uid}_count`);
|
||||
if (c) c.textContent = `共 ${state.stats.total || 0} 个`;
|
||||
// 注册给 reset 用
|
||||
window.__assetCfg = window.__assetCfg || {}; window.__assetCfg[uid] = cfg;
|
||||
window.__assetState = window.__assetState || {}; window.__assetState[uid] = state;
|
||||
} catch (e) {
|
||||
const el = document.getElementById(`${uid}_list`);
|
||||
if (el) el.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderList(cfg, uid, state) {
|
||||
const el = document.getElementById(`${uid}_list`);
|
||||
if (!el) return;
|
||||
if (!state.items.length) {
|
||||
el.innerHTML = `<div style="color:var(--text2);font-size:12px;padding:24px;text-align:center">
|
||||
${cfg.desc ? `<div style="margin-bottom:8px">${esc(cfg.desc)}</div>` : ''}
|
||||
还没有内容,点右上角「➕ 新建」开始</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = state.view === 'card' ? cardHtml(cfg, uid, state) : tableHtml(cfg, uid, state);
|
||||
}
|
||||
|
||||
function thumb(cfg, m) {
|
||||
const refs = m.reference_images || {};
|
||||
const keys = (cfg.refKeys || []).map(x => x[0]);
|
||||
const k = keys.find(x => refs[x]);
|
||||
if (!k) return `<div style="width:100%;height:120px;background:var(--bg3);border-radius:6px;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:30px">${cfg.icon}</div>`;
|
||||
return `<img src="${API_BASE}/${cfg.kind}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(refs[k])}" loading="lazy"
|
||||
style="width:100%;height:120px;object-fit:cover;border-radius:6px;background:var(--bg3)" onerror="this.style.display='none'">`;
|
||||
}
|
||||
|
||||
function subtitle(cfg, m) {
|
||||
const bits = [];
|
||||
(cfg.filters || []).forEach(f => { if (m[f.key]) bits.push(f.options[m[f.key]] || m[f.key]); });
|
||||
if ((m.tags || []).length) bits.push(m.tags.slice(0, 2).join('/'));
|
||||
return bits.join(' · ');
|
||||
}
|
||||
|
||||
function cardHtml(cfg, uid, state) {
|
||||
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px">
|
||||
${state.items.map(m => `
|
||||
<div onclick="window._assetOpen('${uid}','${cfg.kind}','${esc(m.id)}')"
|
||||
style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:10px;cursor:pointer">
|
||||
${thumb(cfg, m)}
|
||||
<div style="font-size:13px;font-weight:600;margin-top:8px">${esc(m.name || m.id)}</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:2px">${esc(subtitle(cfg, m)) || '—'}</div>
|
||||
<div style="font-size:10px;color:var(--text2);margin-top:4px;display:flex;align-items:center;gap:6px">
|
||||
用过 ${m.usage_count || 0} 次
|
||||
${cfg.kind === 'characters' && window._qualBadge ? window._qualBadge(m.id) : ''}
|
||||
${cfg.audio && (m.audio || m.voice || {}).sample
|
||||
? `<button onclick="event.stopPropagation();window._assetPlay('${cfg.kind}','${esc(m.id)}','${esc((m.audio || m.voice).sample)}',this)"
|
||||
style="background:#3b82f6;color:#fff;border:none;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:10px;font-weight:600">▶ 试听</button>`
|
||||
: ''}
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function tableHtml(cfg, uid, state) {
|
||||
const cols = (cfg.filters || []).map(f => f.label);
|
||||
return `<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:12px">
|
||||
<thead><tr style="text-align:left;color:var(--text2);border-bottom:1px solid var(--border)">
|
||||
<th style="padding:6px">名称</th>${cols.map(c => `<th style="padding:6px">${esc(c)}</th>`).join('')}
|
||||
<th style="padding:6px">标签</th><th style="padding:6px">使用</th><th style="padding:6px">更新</th><th style="padding:6px">操作</th>
|
||||
</tr></thead><tbody>
|
||||
${state.items.map(m => `<tr style="border-bottom:1px solid var(--border)">
|
||||
<td style="padding:6px;font-weight:600">${esc(m.name || m.id)}</td>
|
||||
${(cfg.filters || []).map(f => `<td style="padding:6px;color:var(--text2)">${esc(f.options[m[f.key]] || m[f.key] || '—')}</td>`).join('')}
|
||||
<td style="padding:6px;color:var(--text2)">${(m.tags || []).slice(0, 3).map(esc).join(' / ') || '—'}</td>
|
||||
<td style="padding:6px">${cfg.audio && (m.audio || m.voice || {}).sample
|
||||
? `<button onclick="event.stopPropagation();window._assetPlay('${cfg.kind}','${esc(m.id)}','${esc((m.audio || m.voice).sample)}',this)"
|
||||
style="background:#3b82f6;color:#fff;border:none;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:10px">▶</button>`
|
||||
: '—'}</td>
|
||||
<td style="padding:6px;color:var(--text2)">${m.usage_count || 0}</td>
|
||||
<td style="padding:6px;color:var(--text2)">${esc((m.updated_at || '').slice(5, 16))}</td>
|
||||
<td style="padding:6px"><button onclick="window._assetOpen('${uid}','${cfg.kind}','${esc(m.id)}')"
|
||||
style="background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:11px">详情</button></td>
|
||||
</tr>`).join('')}
|
||||
</tbody></table></div>`;
|
||||
}
|
||||
|
||||
// ── 详情抽屉 ────────────────────────────────────────────
|
||||
|
||||
let _cur = {}; // uid → 当前条目
|
||||
|
||||
window._assetOpen = async (uid, kind, aid) => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(aid)}`);
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '加载失败')); return; }
|
||||
_cur[uid] = d.item;
|
||||
renderDrawer(uid, kind, d.item);
|
||||
document.getElementById(`${uid}_drawer`).style.display = 'block';
|
||||
document.getElementById(`${uid}_mask`).style.display = 'block';
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
// ── 参考图大图预览(lightbox)── 点缩略图看原图;点任意处关闭
|
||||
window._assetViewImg = (url, caption) => {
|
||||
let box = document.getElementById('_assetLightbox');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = '_assetLightbox';
|
||||
box.onclick = () => { box.style.display = 'none'; };
|
||||
box.innerHTML = `<div style="position:fixed;inset:0;background:rgba(0,0,0,.9);z-index:2000;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:zoom-out">
|
||||
<img id="_assetLightboxImg" src="" alt=""
|
||||
style="max-width:93vw;max-height:85vh;object-fit:contain;border-radius:8px;box-shadow:0 10px 50px rgba(0,0,0,.7)">
|
||||
<div id="_assetLightboxCap" style="color:#e5e5e5;font-size:13px;margin-top:12px;text-align:center;max-width:82vw"></div>
|
||||
<div style="color:#8a8a8a;font-size:11px;margin-top:6px">点击任意处关闭</div>
|
||||
</div>`;
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
document.getElementById('_assetLightboxImg').src = url;
|
||||
document.getElementById('_assetLightboxCap').textContent = caption || '';
|
||||
box.style.display = 'block';
|
||||
};
|
||||
|
||||
window._assetClose = (uid) => {
|
||||
document.getElementById(`${uid}_drawer`).style.display = 'none';
|
||||
document.getElementById(`${uid}_mask`).style.display = 'none';
|
||||
delete _cur[uid];
|
||||
};
|
||||
|
||||
function renderDrawer(uid, kind, m) {
|
||||
const cfg = (window.__assetCfg || {})[uid] || {};
|
||||
const refs = m.reference_images || {};
|
||||
const refSlotsCfg = (cfg.refKeys && cfg.refKeys.length) ? cfg.refKeys
|
||||
: (cfg.audio ? [] : [['front', '正面']]); // 音频类资产默认无图槽
|
||||
const refHints = cfg.refHints || {}; // 可选:每个槽位一句用途说明(场景按机位取图用)
|
||||
const refSlots = refSlotsCfg.map(([k, label]) => {
|
||||
const f = refs[k];
|
||||
const url = f ? `${API_BASE}/${kind}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(f)}` : '';
|
||||
const hint = refHints[k]
|
||||
? `<div style="font-size:9px;color:var(--text2);opacity:.8;line-height:1.5;margin-bottom:4px">${esc(refHints[k])}</div>`
|
||||
: '';
|
||||
return `<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:6px">
|
||||
<div style="font-size:10px;color:var(--text2);margin-bottom:4px">${esc(label)}</div>
|
||||
${hint}
|
||||
${f ? `<img src="${url}" data-cap="${esc(label)}" title="点击看大图"
|
||||
onclick="window._assetViewImg(this.src, this.dataset.cap)"
|
||||
style="width:100%;height:90px;object-fit:cover;border-radius:4px;cursor:zoom-in">
|
||||
<button onclick="window._assetDelRef('${uid}','${kind}','${k}')" style="margin-top:4px;background:none;border:1px solid var(--border);color:#f59e0b;padding:2px 6px;border-radius:4px;cursor:pointer;font-size:10px">删除</button>`
|
||||
: `<div style="height:90px;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:20px">+</div>`}
|
||||
<input type="file" accept="image/*" onchange="window._assetUpRef('${uid}','${kind}','${k}', this)" style="margin-top:4px;font-size:10px;width:100%">
|
||||
<button onclick="window._assetGenRef('${uid}','${kind}','${esc(m.id)}','${k}','${esc(label)}')"
|
||||
style="margin-top:4px;width:100%;background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:3px 6px;border-radius:4px;cursor:pointer;font-size:10px">🎨 AI 生成${f ? '(覆盖)' : ''}</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
|
||||
const editFields = (cfg.editFields || [{ key: 'description', label: '描述', type: 'textarea' }]).map(f => {
|
||||
const v = m[f.key] ?? '';
|
||||
if (f.type === 'textarea') {
|
||||
return `<label style="font-size:11px;color:var(--text2)">${esc(f.label)}
|
||||
<textarea id="${uid}_e_${f.key}" rows="3" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px;resize:vertical">${esc(v)}</textarea></label>`;
|
||||
}
|
||||
if (f.type === 'list') {
|
||||
return `<label style="font-size:11px;color:var(--text2)">${esc(f.label)}(逗号分隔)
|
||||
<input id="${uid}_e_${f.key}" value="${esc((Array.isArray(v) ? v : []).join(', '))}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></label>`;
|
||||
}
|
||||
if (f.type === 'select') {
|
||||
return `<label style="font-size:11px;color:var(--text2)">${esc(f.label)}
|
||||
<select id="${uid}_e_${f.key}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
${Object.entries(f.options || {}).map(([k, l]) => `<option value="${esc(k)}" ${v === k ? 'selected' : ''}>${esc(l)}</option>`).join('')}
|
||||
</select></label>`;
|
||||
}
|
||||
if (f.type === 'datalist') {
|
||||
// 可选可自由填:输入框 + 建议列表(浏览器原生 datalist)
|
||||
const listId = `${uid}_dl_${f.key}`;
|
||||
const opts = Array.isArray(f.options) ? f.options : Object.values(f.options || {});
|
||||
return `<label style="font-size:11px;color:var(--text2)">${esc(f.label)}
|
||||
<input id="${uid}_e_${f.key}" list="${listId}" value="${esc(v)}" placeholder="${esc(f.placeholder || '可下拉选择,也可自己填')}"
|
||||
style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
<datalist id="${listId}">${opts.map(o => `<option value="${esc(o)}"></option>`).join('')}</datalist></label>`;
|
||||
}
|
||||
return `<label style="font-size:11px;color:var(--text2)">${esc(f.label)}
|
||||
<input id="${uid}_e_${f.key}" value="${esc(v)}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></label>`;
|
||||
}).join('');
|
||||
|
||||
const audioBlock = cfg.audio ? `
|
||||
<div style="font-size:12px;font-weight:600;margin:12px 0 6px">🎵 音频样本</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:12px">
|
||||
${(m.audio || m.voice || {}).sample
|
||||
? `<audio controls src="${API_BASE}/${kind}/${encodeURIComponent(m.id)}/file/${encodeURIComponent((m.audio || m.voice).sample)}" style="width:100%"></audio>`
|
||||
: '<div style="font-size:11px;color:var(--text2)">还没有音频</div>'}
|
||||
<input type="file" accept="audio/*" onchange="window._assetUpAudio('${uid}','${kind}', this)" style="margin-top:6px;font-size:10px;width:100%">
|
||||
</div>` : '';
|
||||
|
||||
// ★ 角色专属:参考图背景清理(带场景背景的参考图会被模型"绑定")
|
||||
const cleanBlock = cfg.kind === 'characters' ? `
|
||||
<div style="font-size:12px;font-weight:600;margin:12px 0 6px">🧹 参考图背景清理</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:12px">
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:6px;line-height:1.7">
|
||||
参考图若带场景背景,图生视频时模型会把背景"绑定"到角色上。<br>
|
||||
清理 = 抠图换纯色底(本地计算,免费;<b>原图会备份</b>为 *_orig)。
|
||||
</div>
|
||||
<button onclick="window._assetCleanRef('${uid}','${esc(m.id)}')"
|
||||
style="background:#8b5cf6;color:#fff;border:none;border-radius:6px;padding:5px 14px;cursor:pointer;font-size:12px;font-weight:600">🧹 清理参考图背景</button>
|
||||
<div id="${uid}_cleanMsg" style="font-size:11px;color:var(--text2);margin-top:6px"></div>
|
||||
</div>` : '';
|
||||
|
||||
document.getElementById(`${uid}_body`).innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<div style="font-size:16px;font-weight:600">${cfg.icon || ''} ${esc(m.name || m.id)}</div>
|
||||
<button onclick="window._assetClose('${uid}')" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:18px">✕</button>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:12px">
|
||||
ID:<code>${esc(m.id)}</code> · 用过 ${m.usage_count || 0} 次 · 更新 ${esc((m.updated_at || '').slice(0, 16))}
|
||||
${cfg.referenceDoc ? `<br>规范:${esc(cfg.referenceDoc)}` : ''}
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:8px;margin-bottom:16px">
|
||||
<label style="font-size:11px;color:var(--text2)">名称
|
||||
<input id="${uid}_e_name" value="${esc(m.name || '')}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></label>
|
||||
${editFields}
|
||||
<label style="font-size:11px;color:var(--text2)">标签(逗号分隔)
|
||||
<input id="${uid}_e_tags" value="${esc((m.tags || []).join(', '))}" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></label>
|
||||
<button onclick="window._assetSave('${uid}','${kind}')" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;justify-self:start">💾 保存</button>
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">📷 参考图</div>
|
||||
${cfg.refIntro ? `<div style="font-size:10px;color:var(--text2);line-height:1.75;margin-bottom:8px;background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:7px 9px">${esc(cfg.refIntro)}</div>` : ''}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">${refSlots}</div>
|
||||
${audioBlock}${cleanBlock}
|
||||
|
||||
<div style="display:flex;gap:8px;border-top:1px solid var(--border);padding-top:12px">
|
||||
<button onclick="window._assetUse('${uid}','${kind}')" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:11px">🔗 引用信息</button>
|
||||
<button onclick="window._assetDelete('${uid}','${kind}')" style="background:none;color:#f59e0b;border:1px solid var(--border);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:11px;margin-left:auto">🗑 删除</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window._assetSave = async (uid, kind) => {
|
||||
const m = _cur[uid]; if (!m) return;
|
||||
const cfg = (window.__assetCfg || {})[uid] || {};
|
||||
const patch = {};
|
||||
const nameEl = document.getElementById(`${uid}_e_name`);
|
||||
if (nameEl) patch.name = nameEl.value.trim();
|
||||
(cfg.editFields || [{ key: 'description' }]).forEach(f => {
|
||||
const el = document.getElementById(`${uid}_e_${f.key}`); if (!el) return;
|
||||
patch[f.key] = f.type === 'list'
|
||||
? el.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
: el.value;
|
||||
});
|
||||
const tagEl = document.getElementById(`${uid}_e_tags`);
|
||||
if (tagEl) patch.tags = tagEl.value.split(',').map(s => s.trim()).filter(Boolean);
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(m.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(patch),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '保存失败')); return; }
|
||||
_cur[uid] = d.item;
|
||||
renderDrawer(uid, kind, d.item);
|
||||
refresh(cfg, uid, window.__assetState[uid]);
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._assetUpRef = async (uid, kind, key, input) => {
|
||||
const f = input.files && input.files[0];
|
||||
const m = _cur[uid]; if (!f || !m) return;
|
||||
const ext = (f.name.match(/\.\w+$/) || ['.png'])[0];
|
||||
const fd = new FormData(); fd.append('file', f);
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(m.id)}/file?name=${encodeURIComponent('ref_' + key + ext)}`,
|
||||
{ method: 'POST', body: fd });
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '上传失败')); return; }
|
||||
const refs = { ...(m.reference_images || {}), [key]: d.path };
|
||||
const r2 = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(m.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reference_images: refs }),
|
||||
});
|
||||
_cur[uid] = (await r2.json()).item;
|
||||
renderDrawer(uid, kind, _cur[uid]);
|
||||
refresh((window.__assetCfg || {})[uid], uid, window.__assetState[uid]);
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._assetGenRef = async (uid, kind, aid, slot, label) => {
|
||||
const m = _cur[uid]; if (!m) return;
|
||||
|
||||
// ① 先取「将发送的提示词」(纯计算,零费用)
|
||||
let pv;
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(aid)}/preview-image-prompt`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot, provider: 'kling' }),
|
||||
});
|
||||
pv = await r.json();
|
||||
if (pv.status !== 'ok') { alert('❌ ' + (pv.detail || '预览失败')); return; }
|
||||
} catch (e) {
|
||||
alert('❌ 预览失败:' + e.message);
|
||||
return;
|
||||
}
|
||||
|
||||
const doGenerate = async (promptText) => {
|
||||
const r = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(aid)}/generate-image`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slot, provider: 'kling', prompt: promptText }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '生成失败');
|
||||
const r2 = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(aid)}`);
|
||||
_cur[uid] = (await r2.json()).item;
|
||||
renderDrawer(uid, kind, _cur[uid]);
|
||||
refresh((window.__assetCfg || {})[uid], uid, window.__assetState[uid]);
|
||||
};
|
||||
|
||||
// ② 弹「生成前审核」面板(提示词可直接改)
|
||||
if (typeof window.openGenerateReview === 'function') {
|
||||
window.openGenerateReview({
|
||||
title: `🎨 生成参考图 · ${label}`,
|
||||
subtitle: '下面是将发送给模型的完整内容 —— 可直接修改后再生成(这才是真正的审核)',
|
||||
items: [{
|
||||
key: 'prompt',
|
||||
label: '提示词(发送给模型)',
|
||||
value: pv.prompt,
|
||||
rows: 9,
|
||||
hint: pv.is_custom ? '(自定义提示词)' : '(系统按资产字段自动组装,可直接编辑)',
|
||||
}],
|
||||
meta: [
|
||||
['供应商', pv.provider_name],
|
||||
['尺寸', pv.aspect_ratio],
|
||||
['保存槽位', slot],
|
||||
['费用', pv.cost_hint],
|
||||
],
|
||||
sourceFields: pv.source_fields,
|
||||
notice: '若提示词描述不够具体,建议先关闭本窗口,回到资产里补充字段(如场景的光线/材质、角色的外观/神态),再回来生成 —— 描述越具体,出图越准。',
|
||||
confirmText: '✅ 确认生成(约 1 分钟)',
|
||||
onConfirm: async (values) => { await doGenerate(values.prompt); },
|
||||
});
|
||||
} else {
|
||||
// 兜底:审核组件未加载时,确认框里也**显示提示词**(不再只讲费用)
|
||||
if (!confirm(`🎨 生成参考图「${label}」\n\n将发送的提示词:\n${pv.prompt}\n\n费用:${pv.cost_hint}\n\n确认生成?`)) return;
|
||||
try { await doGenerate(pv.prompt); } catch (e) { alert('❌ ' + e.message); }
|
||||
}
|
||||
};
|
||||
|
||||
window._assetDelRef = async (uid, kind, key) => {
|
||||
const m = _cur[uid]; if (!m || !confirm(`移除「${key}」参考图引用?(文件保留在目录)`)) return;
|
||||
const refs = { ...(m.reference_images || {}) }; delete refs[key];
|
||||
const r = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(m.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reference_images: refs }),
|
||||
});
|
||||
_cur[uid] = (await r.json()).item;
|
||||
renderDrawer(uid, kind, _cur[uid]);
|
||||
refresh((window.__assetCfg || {})[uid], uid, window.__assetState[uid]);
|
||||
};
|
||||
|
||||
window._assetUpAudio = async (uid, kind, input) => {
|
||||
const f = input.files && input.files[0];
|
||||
const m = _cur[uid]; if (!f || !m) return;
|
||||
const ext = (f.name.match(/\.\w+$/) || ['.mp3'])[0];
|
||||
const fd = new FormData(); fd.append('file', f);
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(m.id)}/file?name=${encodeURIComponent('sample' + ext)}`,
|
||||
{ method: 'POST', body: fd });
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '上传失败')); return; }
|
||||
const r2 = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(m.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ audio: { ...(m.audio || {}), sample: d.path } }),
|
||||
});
|
||||
_cur[uid] = (await r2.json()).item;
|
||||
renderDrawer(uid, kind, _cur[uid]);
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._assetUse = (uid, kind) => {
|
||||
const m = _cur[uid]; if (!m) return;
|
||||
alert(`🔗 在流程中引用\n\n节点里填:${kind.slice(0, -1)}_id = ${m.id}\n\n执行器会按 ID 从资源库取参考图/音频注入生成请求。`);
|
||||
};
|
||||
|
||||
window._assetDelete = async (uid, kind) => {
|
||||
const m = _cur[uid]; if (!m || !confirm(`删除「${m.name || m.id}」?(移到 _trash/ 可恢复)`)) return;
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/${kind}/${encodeURIComponent(m.id)}`, { method: 'DELETE' });
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '删除失败')); return; }
|
||||
window._assetClose(uid);
|
||||
refresh((window.__assetCfg || {})[uid], uid, window.__assetState[uid]);
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._assetNew = async (uid, kind) => {
|
||||
const cfg = (window.__assetCfg || {})[uid] || {};
|
||||
const name = prompt('名称:');
|
||||
if (!name) return;
|
||||
const body = { name };
|
||||
(cfg.filters || []).forEach(f => {
|
||||
const first = Object.keys(f.options)[0];
|
||||
if (first) body[f.key] = f.options[first] ? first : '';
|
||||
});
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/${kind}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '创建失败')); return; }
|
||||
await refresh(cfg, uid, window.__assetState[uid]);
|
||||
window._assetOpen(uid, kind, d.item.id);
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
/**
|
||||
* 场景/场地资产:标注「多角度槽位」—— 让"按镜头机位自动选图"真正可用
|
||||
*
|
||||
* 选图规则(composer/pipeline.py 的 _resolve_location_ref):
|
||||
* 俯视镜头 → top · 侧向镜头 → side/left/right · 正面 → front · 默认 → wide(广角全景)
|
||||
*
|
||||
* 只传 wide 也能用(其他机位回退到它);传得越全,换机位时空间越准。
|
||||
*/
|
||||
function injectSceneSlots(cfg, uid) {
|
||||
if (['locations', 'real_locations'].indexOf(cfg.kind) < 0) return;
|
||||
const root = document.getElementById(uid);
|
||||
if (!root || document.getElementById(`${uid}_sceneHint`)) return;
|
||||
|
||||
const slots = [
|
||||
['wide', '广角全景', '主图 · 交代空间全貌(必传;其他机位缺失时回退到它)'],
|
||||
['front', '正面', '正对场景的主要视线(对话戏常用)'],
|
||||
['side', '侧面', '左/右侧视角(走位、过肩镜头)'],
|
||||
['top', '俯视 / 顶图', '从上往下看(交代空间关系、机位调度)'],
|
||||
['detail', '细节特写', '床 / 窗 / 仪器等局部(近景、特写镜头)'],
|
||||
];
|
||||
const box = document.createElement('div');
|
||||
box.id = `${uid}_sceneHint`;
|
||||
box.style.cssText = 'background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:10px;font-size:11px;line-height:1.9';
|
||||
box.innerHTML = `
|
||||
<div style="font-weight:600;font-size:12px;margin-bottom:4px">🏠 多角度场景图(可选,但强烈建议)</div>
|
||||
<div style="color:var(--text2);margin-bottom:6px">
|
||||
主要拍摄地建议传多张不同机位的图 —— 分镜时会<b>按镜头机位自动选用</b>对应那张,
|
||||
人物在不同机位间走动时空间才不会乱。
|
||||
</div>
|
||||
${slots.map(([k, name, desc]) => `
|
||||
<div>· <b>${name}</b> <code style="color:var(--text2)">${k}</code>
|
||||
<span style="color:var(--text2)">— ${desc}</span></div>`).join('')}
|
||||
<div style="color:var(--text2);margin-top:5px">只传 <code>wide</code> 也能跑(其他机位会回退到它)。</div>`;
|
||||
root.insertBefore(box, root.firstChild);
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
|
||||
/**
|
||||
* 🎨 来自导演台「去生成」的资产建议提示
|
||||
* 角色类由 char-gen 处理(含生成后自动绑定);道具/场景/产品在此提示引导。
|
||||
*/
|
||||
function injectDirPending(cfg, uid) {
|
||||
let pend = null;
|
||||
try { pend = JSON.parse(localStorage.getItem('ave_char_pending') || 'null'); } catch (e) { return; }
|
||||
if (!pend || !pend.slot) return;
|
||||
if (pend.at && Date.now() - pend.at > 30 * 60 * 1000) return;
|
||||
const kmap = { props: 'props', locations: 'locations', real_locations: 'locations', products: 'products' };
|
||||
if (kmap[pend.kind] !== cfg.kind) return; // 只在本类资产页提示
|
||||
const host = document.getElementById(`${uid}_count`)?.closest('div[style*="justify-content"]')?.parentNode
|
||||
|| document.getElementById(uid) || document.querySelector('#app > div') || document.body;
|
||||
const bar = document.createElement('div');
|
||||
bar.style.cssText = 'background:rgba(139,92,246,.1);border:1px solid #8b5cf6;border-radius:6px;padding:8px 10px;margin-bottom:10px;font-size:12px;line-height:1.7;color:var(--text)';
|
||||
bar.innerHTML = `
|
||||
🎨 <b>导演台建议新建:${esc(pend.slot)}</b>
|
||||
<span style="color:var(--text2)">(${esc(pend.why || 'AI 判断这场戏需要它')})</span>
|
||||
${pend.prompt ? `<div style="font-size:11px;color:var(--text2);margin-top:4px">参考描述:${esc(String(pend.prompt).slice(0, 160))}
|
||||
<button onclick="navigator.clipboard.writeText(${JSON.stringify(JSON.stringify(pend.prompt))});this.textContent='已复制'" style="margin-left:6px;background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:1px 8px;cursor:pointer;font-size:10px">📋 复制</button></div>` : ''}
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:4px">建好后回「🎬 导演创作」继续(演员表已暂存,不会丢)</div>
|
||||
<button onclick="localStorage.removeItem('ave_char_pending');this.parentNode.remove()" style="margin-top:5px;background:none;border:none;color:var(--text2);cursor:pointer;font-size:11px">关闭提示</button>`;
|
||||
(host.parentNode || document.body).insertBefore(bar, host);
|
||||
}
|
||||
|
||||
// ═══ 角色参考图质检(背景是否干净 —— 带场景的参考图会污染图生视频)═══
|
||||
window._assetQuality = null; // { aid: {ok, worst_score, issues} }
|
||||
|
||||
window._qualBadge = (aid) => {
|
||||
const q = (window._assetQuality || {})[aid];
|
||||
if (!q) return '';
|
||||
if (q.no_reference) return `<span style="color:var(--text2)">⚪ 无参考图</span>`;
|
||||
return q.ok
|
||||
? `<span style="color:#22c55e" title="参考图背景干净,可安全用于图生视频">✅ 参考图合格</span>`
|
||||
: `<span style="color:#f59e0b" title="${(q.issues || []).join(';')}">⚠️ 参考图带背景(${q.worst_score}分)</span>`;
|
||||
};
|
||||
|
||||
window._assetCheckQuality = async (cfg, uid) => {
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/characters-quality/all`);
|
||||
const d = await r.json();
|
||||
window._assetQuality = {};
|
||||
(d.items || []).forEach(x => { window._assetQuality[x.aid] = x; });
|
||||
const info = document.getElementById(`${uid}_count`);
|
||||
if (info) info.textContent = `${d.total} 个角色 · ⚠️ ${d.bad} 个参考图不合格`;
|
||||
// 重渲染列表以显示徽章
|
||||
if (typeof renderList === 'function') renderList(cfg, uid, window._assetState || { items: [] });
|
||||
const bad = (d.items || []).filter(x => !x.ok);
|
||||
if (bad.length) {
|
||||
alert(`发现 ${bad.length} 个角色的参考图不合格:\n\n` +
|
||||
bad.map(x => `· ${x.name}(${x.worst_score}分):${(x.issues || [])[0] || ''}`).join('\n') +
|
||||
`\n\n原因:参考图带场景背景 → 图生视频时模型会把背景一起绑定到角色上,` +
|
||||
`导致生成画面环境错乱。\n\n建议:重新生成定妆照(纯白无缝背景 + 多视角网格)。`);
|
||||
} else {
|
||||
alert('✅ 所有角色参考图均合格(背景干净)');
|
||||
}
|
||||
} catch (e) { alert('❌ 质检失败: ' + e.message); }
|
||||
};
|
||||
|
||||
// ═══ 角色参考图背景清理(一键)═══
|
||||
window._assetCleanRef = async (uid, aid) => {
|
||||
const msg = document.getElementById(`${uid}_cleanMsg`);
|
||||
if (!confirm(`清理「${aid}」的参考图背景?\n\n✓ 本地抠图换纯色底(免费,约 1-2 秒)\n✓ 原图备份为 *_orig.*(可回溯)\n✓ 清理后不再污染图生视频的环境\n\n继续?`)) return;
|
||||
if (msg) msg.textContent = '⏳ 清理中…';
|
||||
try {
|
||||
const r = await fetch(`${API_BASE}/characters/${encodeURIComponent(aid)}/clean-reference`,
|
||||
{ method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ bg: 'cool_grey' }) });
|
||||
const d = await r.json();
|
||||
if (r.status >= 400) throw new Error(d.detail || '清理失败');
|
||||
const lines = (d.results || []).map(x => {
|
||||
if (x.skipped) return `· ${x.slot}: 已很干净(${x.before}),跳过`;
|
||||
if (x.ok) return `· ${x.slot}: ${x.before} → ${x.after}(改善 ${x.improved})`;
|
||||
return `· ${x.slot}: ❌ ${x.reason || ''}`;
|
||||
});
|
||||
if (msg) msg.innerHTML = `✅ 已清理 ${d.updated} 张<br>` + lines.map(esc).join('<br>');
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = '❌ ' + e.message;
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* assets.js — 🖼️ 素材库(通用资产组件驱动)
|
||||
*
|
||||
* 与结构化资产(角色/场景/产品…)的区别:素材库是**文件级素材**(图片/视频/音频),
|
||||
* 供流程节点直接引用;结构化资产带业务字段(如产品的 must_keep 必保特征)。
|
||||
*
|
||||
* 存储:agent-local/tools/ave/assets/materials/{id}/(meta.json + 素材文件)
|
||||
*/
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const MEDIA_TYPES = { image: '图片', video: '视频', audio: '音频', other: '其他' };
|
||||
const SOURCES = { upload: '自己上传', crawled: '采集', generated: 'AI 生成', provided: '客户提供' };
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'materials', title: '素材库', icon: '🖼️', uid: 'mat',
|
||||
refKeys: [['file', '素材文件']],
|
||||
audio: true, // 音频/视频可上传播放
|
||||
filters: [
|
||||
{ key: 'media_type', label: '类型', options: MEDIA_TYPES },
|
||||
{ key: 'source', label: '来源', options: SOURCES },
|
||||
],
|
||||
editFields: [
|
||||
{ key: 'media_type', label: '类型', type: 'select', options: MEDIA_TYPES },
|
||||
{ key: 'source', label: '来源', type: 'select', options: SOURCES },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '上传的原始素材(图片 / 视频 / 音频);供流程节点直接引用',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* ave-ambience.js — 🌆 环境音库
|
||||
*
|
||||
* 环境音 = 画面所在空间的「氛围底噪」(街道车流 / 室内空调 / 雨声 / 人群低声)。
|
||||
* 配音时按分镜的「环境音提示」(Shot.audio.cue,如"远处车流声")做关键词匹配,
|
||||
* 命中后在该镜头期间低音量循环铺底(音量默认 0.15,可调)。
|
||||
*
|
||||
* 与「音效库」的区别:
|
||||
* · 环境音 = 持续的氛围(几秒~循环)
|
||||
* · 音效 = 短促的点音(关门、脚步、手机铃)
|
||||
*/
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const CATEGORIES = {
|
||||
street: '街道/交通',
|
||||
indoor: '室内',
|
||||
nature: '自然',
|
||||
crowd: '人群',
|
||||
machine: '机械/设备',
|
||||
weather: '天气',
|
||||
other: '其他',
|
||||
};
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'ambience', title: '环境音库', icon: '🌆', uid: 'amb',
|
||||
audio: true, refKeys: [],
|
||||
filters: [
|
||||
{ key: 'category', label: '类别', options: CATEGORIES },
|
||||
],
|
||||
editFields: [
|
||||
{ key: 'category', label: '类别', type: 'select', options: CATEGORIES },
|
||||
{ key: 'tags', label: '匹配关键词(逗号分隔,用于命中分镜的环境音提示)', type: 'list' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '环境音铺底素材:上传音频后建议填「匹配关键词」,配音时按分镜的环境音提示自动命中'
|
||||
+ '(如关键词填「车流,街道,马路」,分镜写"远处车流声"即命中)',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* ave-bgm.js — 🎵 BGM 库
|
||||
* 规范:背景音乐:标签(情绪/节奏)· 时长 · 版权;供流程的 BGM 节点选用
|
||||
*/
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const MOODS = { warm: '温暖', calm: '平静', tense: '紧张', happy: '欢快', sad: '忧伤', epic: '宏大', funny: '搞怪' };
|
||||
const RHYTHMS = { slow: '慢节奏', medium: '中速', fast: '快节奏' };
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'bgm', title: 'BGM 库', icon: '🎵', uid: 'bgm',
|
||||
audio: true, refKeys: [],
|
||||
filters: [
|
||||
{ key: 'mood', label: '情绪', options: MOODS },
|
||||
{ key: 'rhythm', label: '节奏', options: RHYTHMS },
|
||||
],
|
||||
editFields: [
|
||||
{ key: 'mood', label: '情绪', type: 'select', options: MOODS },
|
||||
{ key: 'rhythm', label: '节奏', type: 'select', options: RHYTHMS },
|
||||
{ key: 'duration', label: '时长(秒)', type: 'text' },
|
||||
{ key: 'license', label: '版权说明', type: 'textarea' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '背景音乐:标签(情绪/节奏)· 时长 · 版权;供流程的 BGM 节点选用',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
// views/ave-capabilities.js — 视频工厂「🧩 能力库」(S1 能力注册表管理)
|
||||
// 数据源: GET /api/ave/capabilities
|
||||
// POST /api/ave/capabilities/{id}/default 改默认供应商/模型
|
||||
// POST /api/ave/capabilities/{id}/param 改参数默认值
|
||||
|
||||
let _capData = null;
|
||||
let _capCat = 'all';
|
||||
const _capEdits = {}; // { cap_id: { provider?, model?, params: {k: v} } }
|
||||
|
||||
const _STATUS_STYLE = {
|
||||
ready: { label: '✅ 可用', color: '#22c55e', bg: 'rgba(34,197,94,.12)' },
|
||||
partial: { label: '🟡 部分', color: '#f59e0b', bg: 'rgba(245,158,11,.12)' },
|
||||
planned: { label: '⚪ 规划中', color: '#94a3b8', bg: 'rgba(148,163,184,.12)' },
|
||||
};
|
||||
|
||||
// 字段中文名(输入/输出来说人话)
|
||||
const _FIELD_CN = {
|
||||
// 输入
|
||||
text: '文本内容', emotion: '情绪', genre: '音乐风格', mood: '情绪描述',
|
||||
duration: '时长(秒)', audio_path: '音频文件', voice: '人声', bgm: '背景音乐', sfx: '音效',
|
||||
prompt: '提示词', ref_image: '参考图', image: '图片', video: '视频',
|
||||
character_image: '角色图', clips: '视频片段', script: '脚本', script_path: '脚本文件',
|
||||
story: '故事文本', character_block: '角色描述块', outline: '讨论/剧情走向',
|
||||
search: '搜索词', count: '数量', orientation: '画幅方向', max_duration: '最长时长(秒)',
|
||||
workflow_id: '工作流 ID', images: '图片列表', node_overrides: '节点参数覆盖',
|
||||
vfx_type: '特效类型', shot_count: '镜头数', character_id: '角色 ID', shots: '分镜列表',
|
||||
topic: '主题', style: '风格', camera_move: '运镜', seed: '随机种子', n: '生成数量',
|
||||
aspect_ratio: '画幅比例', sensitivity: '灵敏度', speed: '速度倍率', factor: '速度倍率',
|
||||
ramp: '速度斜坡', resolution: '分辨率', fps: '帧率', format: '格式', rigging: '自动绑骨',
|
||||
text_duration: '音频时长', character_3d_id: '3D 角色 ID', product_id: '产品 ID',
|
||||
transition: '转场类型', transition_duration: '转场时长(秒)', position: '位置',
|
||||
blur_sigma: '模糊强度', grain: '胶片颗粒', saturation: '饱和度偏移', temperature: '色温(K)',
|
||||
consistency_seed: '一致性种子', mode: '模式', detail: '详细度', emotion_tags: '情绪标签',
|
||||
// 输出
|
||||
bpm: '节拍 BPM', beats: '拍点时间轴', emotion_curve: '情绪曲线', image_path: '图片文件',
|
||||
video_path: '视频文件', locked_prompts: '锁定提示词', subtitle_path: '字幕文件',
|
||||
director_script: '导演脚本', prompts: '提示词列表', anchors: '一致性锚点',
|
||||
output_files: '输出文件', model_file: '3D 模型文件', preview: '预览图',
|
||||
};
|
||||
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:4px">
|
||||
<h2 style="font-size:18px;margin:0">🧩 能力库</h2>
|
||||
<span id="capMeta" style="font-size:11px;color:var(--text2)"></span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px;line-height:1.7">
|
||||
<b>能力真相源</b>:每个能力可切换供应商、覆盖模型、调整参数默认值,改动即时生效(所有流程共用)。
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text2);margin-bottom:12px;line-height:1.9;background:var(--bg2);border-left:3px solid var(--primary);padding:7px 11px;border-radius:4px">
|
||||
🔑 <b>密钥在哪配</b>:统一在「⚙️ 服务 → 🔑 API 配置」页配置;本页只显示「已配置 / 未配置」,不存密钥<br>
|
||||
🤖 <b>模型怎么选</b>:模型由供应商决定(如可灵 = kling-v1、火山 = cosyvoice),卡片里可覆盖成该家其他模型<br>
|
||||
➕ <b>加新能力</b>:只改 <code>09_ave/config/capabilities.yaml</code> 一处,刷新即出现
|
||||
</div>
|
||||
<div id="capStats" style="display:flex;gap:8px;flex-wrap:wrap;margin-bottom:12px"></div>
|
||||
<div id="capTabs" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px"></div>
|
||||
<div id="capList"><div class="loading" style="padding:30px;text-align:center">加载能力注册表...</div></div>
|
||||
<div id="capMaintenance" style="margin-top:18px"></div>
|
||||
</div>`;
|
||||
await _capLoad();
|
||||
}
|
||||
|
||||
async function _capLoad() {
|
||||
try {
|
||||
const r = await fetch('/api/ave/capabilities');
|
||||
_capData = await r.json();
|
||||
if (_capData.status !== 'ok') throw new Error(_capData.detail || '读取失败');
|
||||
const meta = document.getElementById('capMeta');
|
||||
if (meta) meta.textContent = `v${_capData.version} · 更新 ${_capData.updated} · 共 ${_capData.total} 个能力`;
|
||||
_capRenderStats();
|
||||
_capRenderTabs();
|
||||
_capRenderList();
|
||||
_capRenderMaintenance();
|
||||
} catch (e) {
|
||||
const el = document.getElementById('capList');
|
||||
if (el) el.innerHTML = `<div class="error" style="padding:20px">❌ ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _capRenderStats() {
|
||||
const el = document.getElementById('capStats');
|
||||
if (!el) return;
|
||||
const s = _capData.by_status || {};
|
||||
const box = (txt, color) =>
|
||||
`<span style="padding:3px 10px;border-radius:12px;font-size:11px;background:${color}22;color:${color};border:1px solid ${color}44">${txt}</span>`;
|
||||
el.innerHTML =
|
||||
box(`总计 ${_capData.total}`, '#6366f1') +
|
||||
box(`✅ 可用 ${s.ready || 0}`, '#22c55e') +
|
||||
box(`🟡 部分 ${s.partial || 0}`, '#f59e0b') +
|
||||
box(`⚪ 规划中 ${s.planned || 0}`, '#94a3b8');
|
||||
}
|
||||
|
||||
function _capRenderTabs() {
|
||||
const el = document.getElementById('capTabs');
|
||||
if (!el) return;
|
||||
const cats = _capData.categories || [];
|
||||
const btn = (id, label, active) => `
|
||||
<button onclick="window._capSetCat('${id}')" style="padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px;
|
||||
${active ? 'background:var(--primary);color:#fff;border:none;font-weight:600'
|
||||
: 'background:var(--bg3);color:var(--text);border:1px solid var(--border)'}">${label}</button>`;
|
||||
el.innerHTML =
|
||||
btn('all', `全部 (${_capData.total})`, _capCat === 'all') +
|
||||
cats.map(c => btn(c.id, `${c.icon} ${c.name} (${(_capData.by_category || {})[c.id] || 0})`, _capCat === c.id)).join('');
|
||||
}
|
||||
|
||||
window._capSetCat = (id) => { _capCat = id; _capRenderTabs(); _capRenderList(); };
|
||||
|
||||
function _capList() {
|
||||
const all = _capData.capabilities || [];
|
||||
return _capCat === 'all' ? all : all.filter(c => c.category === _capCat);
|
||||
}
|
||||
|
||||
function _capRenderList() {
|
||||
const el = document.getElementById('capList');
|
||||
if (!el) return;
|
||||
const caps = _capList();
|
||||
if (!caps.length) { el.innerHTML = '<div style="padding:20px;color:var(--text2)">该分类下暂无能力</div>'; return; }
|
||||
el.innerHTML = caps.map(c => _capCard(c)).join('');
|
||||
}
|
||||
|
||||
function _capCard(c) {
|
||||
const st = _STATUS_STYLE[c.status] || _STATUS_STYLE.planned;
|
||||
const cat = (_capData.categories || []).find(x => x.id === c.category) || {};
|
||||
const providers = c.providers || [];
|
||||
const defProvider = (c.defaults || {}).provider || (providers.find(p => p.default) || {}).id || '';
|
||||
const selProvider = defProvider;
|
||||
const prov = providers.find(p => p.id === selProvider) || {};
|
||||
const keyState = prov.key_configured;
|
||||
const keyTag = keyState === true
|
||||
? '<span style="color:#22c55e;font-size:10px">🔑 已配置</span>'
|
||||
: keyState === false
|
||||
? '<span style="color:#f59e0b;font-size:10px">⚠️ 未配置密钥</span>'
|
||||
: '<span style="color:var(--text2);font-size:10px">本地能力</span>';
|
||||
|
||||
// 参数表单
|
||||
const schema = c.params_schema || {};
|
||||
const defaults = c.defaults || {};
|
||||
const paramRows = Object.entries(schema).map(([k, spec]) => {
|
||||
const val = defaults[k] !== undefined ? defaults[k] : spec.default;
|
||||
const id = `capP_${c.id}_${k}`;
|
||||
let input;
|
||||
if (spec.type === 'enum') {
|
||||
input = `<select id="${id}" onchange="window._capEditParam('${c.id}','${k}', this.value)"
|
||||
style="width:100%;padding:3px 6px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px">
|
||||
${(spec.options || []).map(o => `<option value="${o}" ${String(o) === String(val) ? 'selected' : ''}>${o}</option>`).join('')}
|
||||
</select>`;
|
||||
} else if (spec.type === 'boolean') {
|
||||
input = `<input type="checkbox" id="${id}" ${val ? 'checked' : ''}
|
||||
onchange="window._capEditParam('${c.id}','${k}', this.checked)">`;
|
||||
} else if (spec.type === 'number') {
|
||||
input = `<input type="number" id="${id}" value="${val ?? ''}" min="${spec.min ?? ''}" max="${spec.max ?? ''}" step="${spec.step ?? 'any'}"
|
||||
onchange="window._capEditParam('${c.id}','${k}', this.value)"
|
||||
style="width:100%;padding:3px 6px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px">`;
|
||||
} else {
|
||||
input = `<input type="text" id="${id}" value="${_esc(val ?? '')}"
|
||||
onchange="window._capEditParam('${c.id}','${k}', this.value)"
|
||||
style="width:100%;padding:3px 6px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px">`;
|
||||
}
|
||||
const range = spec.type === 'number' && (spec.min !== undefined || spec.max !== undefined)
|
||||
? `<span style="font-size:9px;color:var(--text2)">${spec.min ?? ''}~${spec.max ?? ''}</span>` : '';
|
||||
return `<div style="margin-bottom:5px">
|
||||
<div style="font-size:10px;color:var(--text2);display:flex;justify-content:space-between">
|
||||
<span>${spec.label || k}</span>${range}</div>
|
||||
${input}</div>`;
|
||||
}).join('');
|
||||
|
||||
return `
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:12px;margin-bottom:10px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:10px;margin-bottom:6px">
|
||||
<div style="flex:1">
|
||||
<div style="display:flex;align-items:center;gap:6px;flex-wrap:wrap">
|
||||
<span style="font-size:13px;font-weight:600">${c.name}</span>
|
||||
<code style="font-size:10px;color:var(--text2)">${c.id}</code>
|
||||
<span style="padding:1px 7px;border-radius:10px;font-size:9px;color:${st.color};background:${st.bg}">${st.label}</span>
|
||||
<span style="font-size:9px;color:var(--text2)">${cat.icon || ''} ${cat.name || c.category}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:3px">${_esc(c.description || '')}</div>
|
||||
</div>
|
||||
<button onclick="window._capSave('${c.id}')" id="capSaveBtn_${c.id}"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:4px 12px;border-radius:4px;cursor:pointer;font-size:11px;flex-shrink:0">💾 保存</button>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:8px">
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text2);display:flex;justify-content:space-between">
|
||||
<span>供应商</span>${keyTag}</div>
|
||||
<select id="capProv_${c.id}" onchange="window._capEditProvider('${c.id}', this.value)"
|
||||
style="width:100%;padding:3px 6px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px">
|
||||
${providers.map(p => `<option value="${p.id}" ${p.id === selProvider ? 'selected' : ''}>
|
||||
${p.name}${p.key_configured === false ? ' ⚠️' : ''}</option>`).join('')}
|
||||
</select>
|
||||
<div style="font-size:9px;color:var(--text2);margin-top:3px;line-height:1.6">
|
||||
${prov.api_key_ref
|
||||
? `🔑 密钥在「⚙️ 服务 → 🔑 API 配置」里配置(字段 <code>${prov.api_key_ref}</code>)`
|
||||
: '🔒 本地能力,无需密钥'}
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text2);margin-top:5px;display:flex;justify-content:space-between">
|
||||
<span>模型</span>
|
||||
<span style="font-size:9px">各供应商自带默认模型,此处可覆盖</span>
|
||||
</div>
|
||||
<input type="text" id="capModel_${c.id}" value="${_esc(prov.model || (c.defaults || {}).model || '')}"
|
||||
onchange="window._capEditModel('${c.id}', this.value)" placeholder="(用供应商默认模型)"
|
||||
style="width:100%;padding:3px 6px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px">
|
||||
${providers.length > 1
|
||||
? `<div style="font-size:9px;color:var(--text2);margin-top:3px">该能力有 ${providers.length} 家供应商可选:${providers.map(p => p.name).join(' / ')}</div>`
|
||||
: ''}
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:10px;color:var(--text2);margin-bottom:3px">参数默认值</div>
|
||||
${paramRows || '<div style="font-size:10px;color:var(--text2)">(无参数)</div>'}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:10px;padding-top:8px;border-top:1px dashed var(--border);font-size:10px;line-height:1.8">
|
||||
<div><span style="color:#22c55e;font-weight:600">📥 输入</span>:${_ioText(c.input)}</div>
|
||||
<div><span style="color:#818cf8;font-weight:600">📤 输出</span>:${_ioText(c.output)}</div>
|
||||
<div style="color:var(--text2);margin-top:4px;display:flex;gap:14px;flex-wrap:wrap">
|
||||
<span>执行方式:${c.execution === 'remote' ? '远程(guardd 任务)' : '本地线程'}</span>
|
||||
${c.impl ? `<span>实现位置:<code>${c.impl}</code></span>` : ''}
|
||||
<span>来源:${_esc(c.source || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 输入/输出 → 中文可读文本,如「文本内容(text) · 情绪(emotion,可选)」 */
|
||||
function _ioText(io) {
|
||||
if (!io) return '—';
|
||||
const parts = Object.entries(io).map(([k, v]) => {
|
||||
const cn = _FIELD_CN[k] || k;
|
||||
const optional = String(v).includes('?') ? ',可选' : '';
|
||||
return `${cn}(${k}${optional})`;
|
||||
});
|
||||
return parts.length ? parts.join(' · ') : '—';
|
||||
}
|
||||
|
||||
function _esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
|
||||
// ── 编辑(本地暂存 → 保存时提交)────────────────────
|
||||
|
||||
function _capEdit(capId) {
|
||||
if (!_capEdits[capId]) _capEdits[capId] = { params: {} };
|
||||
const btn = document.getElementById(`capSaveBtn_${capId}`);
|
||||
if (btn) { btn.style.borderColor = '#f59e0b'; btn.style.color = '#f59e0b'; btn.textContent = '💾 保存 *'; }
|
||||
return _capEdits[capId];
|
||||
}
|
||||
|
||||
window._capEditProvider = (capId, provider) => { _capEdit(capId).provider = provider; };
|
||||
window._capEditModel = (capId, model) => { _capEdit(capId).model = model; };
|
||||
window._capEditParam = (capId, param, value) => { _capEdit(capId).params[param] = value; };
|
||||
|
||||
window._capSave = async (capId) => {
|
||||
const edit = _capEdits[capId];
|
||||
if (!edit) { alert('没有改动'); return; }
|
||||
const btn = document.getElementById(`capSaveBtn_${capId}`);
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ 保存中...'; }
|
||||
try {
|
||||
if (edit.provider || edit.model) {
|
||||
const r = await fetch(`/api/ave/capabilities/${capId}/default`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: edit.provider, model: edit.model }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || '默认值保存失败');
|
||||
}
|
||||
for (const [param, value] of Object.entries(edit.params || {})) {
|
||||
const r = await fetch(`/api/ave/capabilities/${capId}/param`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ param, value }),
|
||||
});
|
||||
if (!r.ok) throw new Error((await r.json()).detail || `${param} 保存失败`);
|
||||
}
|
||||
delete _capEdits[capId];
|
||||
if (btn) { btn.textContent = '✅ 已保存'; btn.style.borderColor = '#22c55e'; btn.style.color = '#22c55e'; }
|
||||
setTimeout(() => { if (btn) { btn.textContent = '💾 保存'; btn.style.borderColor = 'var(--border)'; btn.style.color = 'var(--text)'; btn.disabled = false; } }, 1500);
|
||||
} catch (e) {
|
||||
alert('❌ ' + e.message);
|
||||
if (btn) { btn.disabled = false; btn.textContent = '💾 保存 *'; }
|
||||
}
|
||||
};
|
||||
|
||||
async function _capRenderMaintenance() {
|
||||
const el = document.getElementById('capMaintenance');
|
||||
if (!el) return;
|
||||
try {
|
||||
const r = await fetch('/api/ave/capabilities/maintenance');
|
||||
const d = await r.json();
|
||||
const dirs = d.legacy_empty_dirs || [];
|
||||
if (!dirs.length) { el.innerHTML = ''; return; }
|
||||
el.innerHTML = `
|
||||
<div style="background:var(--bg2);border:1px dashed var(--border);border-radius:8px;padding:10px;font-size:11px;color:var(--text2)">
|
||||
🧹 <b>可清理的空壳目录</b>(早期分层规划残留,实现已在无编号目录):
|
||||
${dirs.map(x => `<code style="margin-left:4px">${x}</code>`).join('')}
|
||||
<div style="margin-top:4px;font-size:10px">建议归档到 90_archive/(框架分层由 capabilities/ directors/ executor/ assets/ 承担)</div>
|
||||
</div>`;
|
||||
} catch (e) { el.innerHTML = ''; }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* views/ave-docs.js — 📚 API 文档中心
|
||||
*
|
||||
* 展示本地存档的各平台官方 API 文档(09_ave/docs/api/),
|
||||
* 与能力中心互相跳转。数据来自 /api/ave/docs/*。
|
||||
*/
|
||||
const API = '/api/ave/docs';
|
||||
|
||||
let _idx = null; // 索引
|
||||
let _cur = null; // 当前打开的文档 path
|
||||
let _filter = ''; // 搜索词
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
/** 轻量 Markdown 渲染:标题/粗体/行内代码做样式,其余保持等宽原样(表格因此对齐) */
|
||||
function mdRender(md) {
|
||||
const parts = String(md).split(/```/);
|
||||
return parts.map((seg, i) => {
|
||||
if (i % 2 === 1) { // 代码块(去掉语言标记行)
|
||||
const body = seg.replace(/^[a-zA-Z0-9]*\n/, '');
|
||||
return `<pre style="background:var(--bg2);border:1px solid var(--border);border-radius:6px;
|
||||
padding:10px;overflow-x:auto;font-size:11.5px;line-height:1.65">${esc(body)}</pre>`;
|
||||
}
|
||||
let t = esc(seg)
|
||||
.replace(/^###### (.*)$/gm, '<div style="font-weight:600;margin:10px 0 4px">$1</div>')
|
||||
.replace(/^##### (.*)$/gm, '<div style="font-weight:600;margin:10px 0 4px">$1</div>')
|
||||
.replace(/^#### (.*)$/gm, '<div style="font-weight:600;margin:12px 0 5px">$1</div>')
|
||||
.replace(/^### (.*)$/gm, '<div style="font-weight:700;font-size:14px;margin:14px 0 6px">$1</div>')
|
||||
.replace(/^## (.*)$/gm, '<div style="font-weight:700;font-size:15px;margin:16px 0 8px;border-bottom:1px solid var(--border);padding-bottom:4px">$1</div>')
|
||||
.replace(/^# (.*)$/gm, '<div style="font-weight:700;font-size:17px;margin:18px 0 8px">$1</div>')
|
||||
.replace(/\*\*(.+?)\*\*/g, '<b>$1</b>')
|
||||
.replace(/`([^`]+)`/g, '<code style="background:var(--bg3);padding:1px 5px;border-radius:3px;font-size:11px">$1</code>');
|
||||
return `<div style="line-height:1.85;font-size:12px;white-space:pre-wrap;word-break:break-word">${t}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function fillList() {
|
||||
const box = document.getElementById('adList');
|
||||
if (!box || !_idx) return;
|
||||
const kw = _filter.trim().toLowerCase();
|
||||
box.innerHTML = (_idx.sources || []).map(s => {
|
||||
const pages = (s.pages || []).filter(p =>
|
||||
!kw || (p.title + ' ' + p.url).toLowerCase().includes(kw));
|
||||
if (!pages.length) return '';
|
||||
const tag = s.link_only
|
||||
? `<span style="font-size:9px;color:#f59e0b;border:1px solid #f59e0b;border-radius:3px;padding:0 4px;margin-left:4px">仅链接</span>`
|
||||
: '';
|
||||
const rows = pages.map(p => {
|
||||
const active = _cur === p.file ? 'background:var(--primary);color:#fff' : '';
|
||||
const onclick = p.file
|
||||
? `window._adOpen('${esc(p.file)}', this)`
|
||||
: `window.open('${esc(p.url)}','_blank')`;
|
||||
return `<div onclick="${onclick}" title="${esc(p.title)}"
|
||||
style="padding:4px 8px;border-radius:4px;cursor:pointer;font-size:11px;margin-bottom:2px;${active}">
|
||||
${esc(p.title)}</div>`;
|
||||
}).join('');
|
||||
return `<div style="margin-bottom:10px">
|
||||
<div style="font-size:11px;font-weight:700;color:var(--text2);margin:6px 0 4px">
|
||||
${esc(s.name)}${tag}
|
||||
<span style="font-weight:400">(${pages.length})</span></div>
|
||||
${rows}</div>`;
|
||||
}).join('') || '<div style="color:var(--text2);font-size:11px">无匹配</div>';
|
||||
}
|
||||
|
||||
window._adOpen = async (file, el) => {
|
||||
document.querySelectorAll('#adList div[onclick]').forEach(d => {
|
||||
d.style.background = ''; d.style.color = '';
|
||||
});
|
||||
if (el) { el.style.background = 'var(--primary)'; el.style.color = '#fff'; }
|
||||
_cur = file;
|
||||
const box = document.getElementById('adBody');
|
||||
box.innerHTML = '<div style="color:var(--text2);font-size:12px">⏳ 载入…</div>';
|
||||
try {
|
||||
const r = await fetch(`${API}/page?file=${encodeURIComponent(file)}`);
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || '载入失败');
|
||||
const src = (_idx.sources || []).flatMap(s => s.pages || []).find(p => p.file === file);
|
||||
box.innerHTML = `
|
||||
<div style="font-size:10px;color:var(--text2);margin-bottom:8px">
|
||||
${esc(file)} · ${(d.bytes / 1024).toFixed(0)} KB
|
||||
${src ? ` · <a href="${esc(src.url)}" target="_blank" style="color:var(--primary)">原文 ↗</a>` : ''}
|
||||
</div>
|
||||
${mdRender(d.content)}`;
|
||||
box.scrollTop = 0;
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div style="color:#ef4444;font-size:12px">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
window._adSearch = (v) => { _filter = v || ''; fillList(); };
|
||||
|
||||
window._adRefresh = async () => {
|
||||
const btn = document.getElementById('adRefresh');
|
||||
if (!confirm('从官方重新抓取文档?\n(只读网络,不产生模型费用;耗时约 1-3 分钟)')) return;
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ 抓取中…'; }
|
||||
try {
|
||||
const r = await fetch(`${API}/refresh?force=false`, { method: 'POST' });
|
||||
const d = await r.json();
|
||||
alert(d.note || '已开始');
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
setTimeout(async () => {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '🔄 更新文档'; }
|
||||
await loadIndex();
|
||||
}, 8000);
|
||||
};
|
||||
|
||||
async function loadIndex() {
|
||||
const box = document.getElementById('adList');
|
||||
try {
|
||||
const r = await fetch(`${API}/index`);
|
||||
_idx = await r.json();
|
||||
const n = (_idx.sources || []).reduce((a, s) => a + (s.count || 0), 0);
|
||||
const meta = document.getElementById('adMeta');
|
||||
if (meta) meta.textContent = `${n} 页 · 更新于 ${_idx.generated_at || '—'}`;
|
||||
fillList();
|
||||
if (box && !box.dataset.inited) {
|
||||
box.dataset.inited = '1';
|
||||
const first = (_idx.sources || []).flatMap(s => s.pages || []).find(p => p.file);
|
||||
if (first) window._adOpen(first.file);
|
||||
}
|
||||
} catch (e) {
|
||||
if (box) box.innerHTML = `<div style="color:#ef4444;font-size:11px">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="max-width:1240px">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:4px">
|
||||
<div style="font-size:15px;font-weight:700">📚 API 文档中心</div>
|
||||
<span id="adMeta" style="font-size:11px;color:var(--text2)"></span>
|
||||
<div style="margin-left:auto;display:flex;gap:6px;align-items:center">
|
||||
<input id="adSearchBox" placeholder="搜索文档…" oninput="window._adSearch(this.value)"
|
||||
style="width:200px;padding:5px 9px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:5px;font-size:12px">
|
||||
<button id="adRefresh" onclick="window._adRefresh()"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:5px 12px;cursor:pointer;font-size:12px">🔄 更新文档</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:10px">
|
||||
各平台官方 API 文档的本地存档 —— 与「⚙️ 能力配置」里的能力条目互相跳转,不用每次现抓。
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:290px 1fr;gap:12px;height:calc(100vh - 200px)">
|
||||
<div id="adList" style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:8px;overflow-y:auto"></div>
|
||||
<div id="adBody" style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:14px;overflow-y:auto"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
await loadIndex();
|
||||
}
|
||||
|
||||
export function unloadView() {}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* ave-flow-beat.js — 🥁 卡点视频(流程入口)
|
||||
* 对应模板:beat_sync(4 节点)
|
||||
*/
|
||||
import { renderFlowEntry } from './flow-entry.js';
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderFlowEntry(container, {
|
||||
templateId: 'beat_sync', title: '卡点视频', icon: '🥁',
|
||||
desc: 'BGM → 节拍检测 → 素材匹配 → 变速卡点合成',
|
||||
tips: [
|
||||
'含「🛡️ 审核门」节点:跑到那里会**暂停**等你确认,点「⏩ 继续」放行',
|
||||
'节拍检测/素材匹配的实现仍在接入中',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* ave-flow-digital-human.js — 👤 数字人口播(流程入口)
|
||||
* 对应模板:digital_human(4 节点)
|
||||
*/
|
||||
import { renderFlowEntry } from './flow-entry.js';
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderFlowEntry(container, {
|
||||
templateId: 'digital_human', title: '数字人口播', icon: '👤',
|
||||
desc: '角色照片 + 文案 → TTS → 数字人 → 口型同步 → 输出',
|
||||
tips: [
|
||||
'这条线依赖**数字人/对口型能力**(可灵 Avatar 等),尚未接入',
|
||||
'可先在「🧑 角色库」把数字人角色和音色准备好',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* ave-flow-dub.js — 🎙️ 基础口播(流程入口)
|
||||
* 对应模板:basic_dub(5 节点)
|
||||
*/
|
||||
import { renderFlowEntry } from './flow-entry.js';
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderFlowEntry(container, {
|
||||
templateId: 'basic_dub', title: '基础口播', icon: '🎙️',
|
||||
desc: '标准口播视频:脚本 → TTS → 素材 → BGM → 字幕 → 合成',
|
||||
tips: [
|
||||
'TTS / 素材搜索 / BGM / 字幕的**执行实现仍在接入中**(运行时会明确跳过并说明)',
|
||||
'脚本节点现在会真正把文本透传给下游(改脚本内容后重跑看变化)',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* ave-flow-hybrid.js — 🎞️ 口播+混剪(流程入口)
|
||||
* 对应模板:hybrid_dub(4 节点)
|
||||
*/
|
||||
import { renderFlowEntry } from './flow-entry.js';
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderFlowEntry(container, {
|
||||
templateId: 'hybrid_dub', title: '口播+混剪', icon: '🎞️',
|
||||
desc: '口播脚本 → 人声合成 → 混合风格 → 混合视频',
|
||||
tips: [
|
||||
'混合风格(口播 + 素材混剪)的实现仍在接入中',
|
||||
'可先用「基础口播」线验证脚本与 TTS 环节',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* ave-flow-narrative.js — 📖 角色叙事(流程入口)
|
||||
* 对应模板:character_narrative(8 节点)
|
||||
*/
|
||||
import { renderFlowEntry } from './flow-entry.js';
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderFlowEntry(container, {
|
||||
templateId: 'character_narrative', title: '角色叙事', icon: '📖',
|
||||
desc: '定妆照 → 剧本 → 多场景 → 可灵批量生成 → 角色一致性拼接',
|
||||
tips: [
|
||||
'**当前最能跑通的一条线**:故事 → 分镜 → 可灵生成 → 去AI化 → 成片(已实测通过)',
|
||||
'角色一致性需要先在「🧑 角色库」建角色并上传参考图',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* ave-flow-short-drama.js — 🎭 短剧创作(流程入口)
|
||||
* 对应模板:short_drama(12 节点)
|
||||
*/
|
||||
import { renderFlowEntry } from './flow-entry.js';
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderFlowEntry(container, {
|
||||
templateId: 'short_drama', title: '短剧创作', icon: '🎭',
|
||||
desc: '完整短剧流水线:故事 → 脚本 → 角色 → 策略 → 分镜 → 提示词 → 场景 → 配音 → 配乐 → 字幕 → 审核 → 成片',
|
||||
tips: [
|
||||
'12 个节点里:分镜/提示词/视频生成/合成为「已接入」能力,配音/素材/字幕等仍在接入中(会明确标注跳过)',
|
||||
'成本最高的一条线,建议先用「镜头数=1」试跑',
|
||||
],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
/**
|
||||
* ave-history.js — 历史操作(占位页)
|
||||
*
|
||||
* P1 菜单重组时创建:菜单结构已按新架构就位(见 PLANS/VIDEO_FACTORY_MASTER.md 第二节),
|
||||
* 页面功能待实现。
|
||||
*/
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:40px;max-width:760px;margin:0 auto">
|
||||
<h2 style="font-size:20px;margin:0 0 6px">🕐 历史操作</h2>
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:16px">视频工厂 · 菜单已就位,功能开发中</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:20px">
|
||||
<div style="font-size:13px;color:var(--text);line-height:1.9">
|
||||
🚧 <b>此页待实现</b>
|
||||
</div>
|
||||
<div style="font-size:12px;color:var(--text2);line-height:1.9;margin-top:10px">
|
||||
<b>规划内容</b>:谁在什么时候改了什么(流程/资产/配置的变更记录)
|
||||
</div>
|
||||
</div>
|
||||
<div style="margin-top:16px;font-size:11px;color:var(--text2)">
|
||||
架构基准:<code>PLANS/VIDEO_FACTORY_MASTER.md</code> 第二节 · 设计原文:<code>PLANS/reference/DCS_说明书_v2.0_原文.md</code>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,801 @@
|
||||
import * as store from '../modules/project-store.js';
|
||||
import { upBlock } from '../modules/upstream.js';
|
||||
/**
|
||||
* views/ave-keyframes.js — 🖼 关键帧工作台(v2 · 两列 · 键盘导航)
|
||||
*
|
||||
* 流程第②步:剧本(分镜)→ 【关键帧】→ 视频
|
||||
*
|
||||
* 布局(用户要的):
|
||||
* 左列 = 关键帧带(纵向连环画,每格一张图 / 「待生成」)
|
||||
* 右列 = 这一帧的全部内容(意图 + 拍摄参数 + 资产 + 生成参数 + 版本)
|
||||
*
|
||||
* 键盘:
|
||||
* ↑ / ↓ 切换镜头(像翻连环画)
|
||||
* ← / → 切换该镜头的版本
|
||||
* G 生成当前镜头
|
||||
*
|
||||
* 4 种生成方式:多图融合 / 文生图 / 图生图(单参考)/ 改造已有图
|
||||
* 参考图可视化:缩略图 + 勾选框,可取消/加入
|
||||
*/
|
||||
const API = '/api/ave/keyframe';
|
||||
|
||||
let _pid = '';
|
||||
let _shots = [];
|
||||
let _curShot = null;
|
||||
let _ctx = null; // 单镜头完整上下文
|
||||
let _selRole = []; // 勾选的角色参考图路径
|
||||
let _selScene = ''; // 勾选的场景参考图
|
||||
let _selExtra = []; // 手动追加的参考图
|
||||
let _curVer = 0; // 当前查看的版本号
|
||||
let _sizes = {}; // 画幅预设
|
||||
let _defSize = '720*1280'; // 竖屏 720P(测试期默认,省算力)
|
||||
let _modelByMode = {}; // 生成方式 → 默认引擎模型
|
||||
let _models = []; // 模型注册表(含能力注释)
|
||||
let _busy = false;
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
async function api(path, opt) {
|
||||
const r = await fetch(API + path, opt);
|
||||
const d = await r.json().catch(() => ({}));
|
||||
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
|
||||
return d;
|
||||
}
|
||||
|
||||
function _mode() {
|
||||
return (document.querySelector('input[name="kfMode"]:checked') || {}).value || 'multi';
|
||||
}
|
||||
function _val(id) {
|
||||
const el = document.getElementById(id);
|
||||
return el ? el.value : '';
|
||||
}
|
||||
|
||||
// ── 项目 ────────────────────────────────────────────────────
|
||||
async function loadProjects() {
|
||||
const sel = document.getElementById('kfProject');
|
||||
if (!sel) return;
|
||||
// ★ 在工作室里运行时跟随工作室的项目(原来两处各选各的 → 容易看成"另一批分镜")
|
||||
const emb = document.body && document.body.dataset && document.body.dataset.embedded === '1';
|
||||
if (emb && store.getPid && store.getPid()) {
|
||||
_pid = store.getPid();
|
||||
sel.innerHTML = `<option value="${_pid}">${(store.getProject() || {}).title || _pid}</option>`;
|
||||
sel.value = _pid;
|
||||
sel.disabled = true;
|
||||
sel.title = '跟随工作室顶部选的项目';
|
||||
await loadShots();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const d = await api('/projects');
|
||||
const ps = d.projects || [];
|
||||
sel.innerHTML = ps.length
|
||||
? ps.map(p => `<option value="${esc(p.project_id)}">${esc(p.title || p.project_id)}` +
|
||||
`(${p.shots || 0} 镜 · 关键帧 ${p.kf_done || 0}/${p.kf_total || 0})</option>`).join('')
|
||||
: '<option value="">(暂无分镜项目)</option>';
|
||||
if (ps.length) { _pid = sel.value = ps[0].project_id; await loadShots(); }
|
||||
} catch (e) {
|
||||
sel.innerHTML = `<option value="">加载失败: ${esc(e.message)}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSizes() {
|
||||
try {
|
||||
const d = await api('/size-presets');
|
||||
_sizes = d.presets || {};
|
||||
_defSize = d.default || '720*1280';
|
||||
_modelByMode = d.model_by_mode || {};
|
||||
_models = d.models || [];
|
||||
} catch (e) { _sizes = { '竖屏 9:16 · 720P': '720*1280' }; }
|
||||
}
|
||||
|
||||
// ── 镜头 ────────────────────────────────────────────────────
|
||||
async function loadShots() {
|
||||
const box = document.getElementById('kfStrip');
|
||||
if (!box) return;
|
||||
if (!_pid) { box.innerHTML = '<div style="color:var(--text2);font-size:11px">请先选项目</div>'; return; }
|
||||
box.innerHTML = '<div style="color:var(--text2);font-size:11px">⏳ 载入…</div>';
|
||||
try {
|
||||
const d = await api(`/shots?project_id=${encodeURIComponent(_pid)}`);
|
||||
_shots = d.shots || [];
|
||||
renderStrip();
|
||||
const keep = _curShot && _shots.find(s => s.shot_id === _curShot.shot_id);
|
||||
if (keep) selectShot(keep.shot_id);
|
||||
else if (_shots.length) selectShot(_shots[0].shot_id);
|
||||
else document.getElementById('kfDetail').innerHTML =
|
||||
'<div style="color:var(--text2);font-size:12px">(无镜头)</div>';
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div style="color:#ef4444;font-size:11px">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function _statusOf(s) {
|
||||
if (s.adopted) return { icon: '✅', txt: `采用 v${s.adopted}`, color: '#22c55e' };
|
||||
if ((s.versions || []).length) return { icon: '🔄', txt: `${s.versions.length} 版待审`, color: '#f59e0b' };
|
||||
return { icon: '⬜', txt: '待生成', color: 'var(--text2)' };
|
||||
}
|
||||
|
||||
/** 左列:关键帧带(纵向连环画) */
|
||||
function renderStrip() {
|
||||
const box = document.getElementById('kfStrip');
|
||||
if (!box) return;
|
||||
const done = _shots.filter(s => s.adopted).length;
|
||||
// ★ 按段落排序(无段落号的排最后)
|
||||
_shots = [..._shots].sort((a, b) => {
|
||||
const na = parseInt(a.paragraph_no, 10), nb = parseInt(b.paragraph_no, 10);
|
||||
return (isNaN(na) ? 1e9 : na) - (isNaN(nb) ? 1e9 : nb);
|
||||
});
|
||||
let _lastP = '__init__';
|
||||
box.innerHTML = `
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px;position:sticky;top:0;
|
||||
background:var(--bg3);padding:4px 0;z-index:2">
|
||||
${_shots.length} 镜 · ✅${done} 🔄${_shots.filter(s => !s.adopted && (s.versions || []).length).length}
|
||||
⬜${_shots.filter(s => !(s.versions || []).length).length}
|
||||
<span style="float:right">↑↓ 切换</span>
|
||||
</div>` + _shots.map((s, i) => {
|
||||
let _phead = '';
|
||||
const _pk = s.paragraph_no ? String(s.paragraph_no) : '';
|
||||
if (_pk !== _lastP) {
|
||||
_lastP = _pk;
|
||||
const _n = _shots.filter(x => String(x.paragraph_no || '') === _pk).length;
|
||||
_phead = `<div style="font-size:11px;font-weight:600;margin:10px 0 5px;
|
||||
padding:3px 8px;background:var(--bg3);border-left:3px solid #8b5cf6;
|
||||
border-radius:3px">${_pk ? '🎬 第 ' + _pk + ' 场' : '⚠️ 未归属'}
|
||||
<span style="color:var(--text2);font-weight:400">(${_n} 镜)</span></div>`;
|
||||
}
|
||||
const st = _statusOf(s);
|
||||
const on = _curShot && _curShot.shot_id === s.shot_id;
|
||||
const vs = s.versions || [];
|
||||
const adopted = vs.find(v => v.version === s.adopted);
|
||||
const thumb = adopted || vs[0];
|
||||
const img = thumb && thumb.url
|
||||
? `<img src="${esc(thumb.url)}" loading="lazy"
|
||||
onclick="window._kfZoom('${esc(thumb.url)}','${esc(s.shot_id)}')"
|
||||
style="width:100%;height:150px;object-fit:cover;border-radius:5px;display:block;cursor:zoom-in">`
|
||||
: `<div style="height:150px;display:flex;flex-direction:column;align-items:center;justify-content:center;
|
||||
border:1px dashed var(--border);border-radius:5px;color:var(--text2);font-size:11px;gap:4px">
|
||||
<div>⬜ 待生成</div></div>`;
|
||||
return _phead + `<div onclick="window._kfSel('${esc(s.shot_id)}')"
|
||||
style="padding:6px;border-radius:7px;cursor:pointer;margin-bottom:7px;
|
||||
border:2px solid ${on ? 'var(--primary)' : 'transparent'};
|
||||
background:var(--bg2)">
|
||||
${img}
|
||||
<div style="font-size:11px;margin-top:5px;display:flex;align-items:center;gap:4px">
|
||||
<b>${i + 1}</b> <span>${esc(s.shot_id)}</span>
|
||||
<span style="margin-left:auto;color:${st.color}">${st.icon} ${esc(st.txt)}</span>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text2);margin-top:2px">
|
||||
${esc(s.shot_size || '')} ${esc(s.angle || '')} · ${esc((s.visual || '').slice(0, 22))}
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── 选中镜头 ────────────────────────────────────────────────
|
||||
async function selectShot(sid) {
|
||||
_curShot = _shots.find(s => s.shot_id === sid) || null;
|
||||
_curVer = 0;
|
||||
renderStrip();
|
||||
const box = document.getElementById('kfDetail');
|
||||
if (!box || !_curShot) return;
|
||||
box.innerHTML = '<div style="color:var(--text2);font-size:11px">⏳ 载入…</div>';
|
||||
try {
|
||||
_ctx = await api(`/context?project_id=${encodeURIComponent(_pid)}&shot_id=${encodeURIComponent(sid)}`);
|
||||
_selRole = (_ctx.role_images || []).map(r => r.path);
|
||||
_selScene = _ctx.scene_image || '';
|
||||
_selExtra = [];
|
||||
renderDetail();
|
||||
const strip = document.getElementById('kfStrip');
|
||||
const el = strip && strip.querySelector('.kf-on');
|
||||
if (el) el.scrollIntoView({ block: 'nearest' });
|
||||
} catch (e) {
|
||||
box.innerHTML = `<div style="color:#ef4444;font-size:11px">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 资产图路径 → 可访问 URL
|
||||
* ".../assets/characters/角色名/ref_front.png"
|
||||
* → "/api/ave/assets/characters/角色名/file/ref_front.png"
|
||||
*/
|
||||
function _assetUrl(p) {
|
||||
const s = String(p || '');
|
||||
// 已经是可直接访问的 URL(后端接口路径 / http(s) / data:)→ 原样返回
|
||||
if (s.startsWith('/api/') || /^https?:/i.test(s) || s.startsWith('data:')) return s;
|
||||
const m = s.match(/assets\/([^/]+)\/([^/]+)\/([^/]+)$/);
|
||||
if (!m) return s; // 认不出就原样返回(宁可不显示也不要拼错)
|
||||
return `/api/ave/assets/${encodeURIComponent(m[1])}/${encodeURIComponent(m[2])}`
|
||||
+ `/file/${encodeURIComponent(m[3])}`;
|
||||
}
|
||||
|
||||
/** 参考图小卡片(带勾选) */
|
||||
function _refCard(path, label, on, toggleFn) {
|
||||
const url = _assetUrl(path);
|
||||
return `<div onclick="${toggleFn}" title="${esc(path)}"
|
||||
style="width:104px;border:2px solid ${on ? 'var(--primary)' : 'var(--border)'};border-radius:6px;
|
||||
overflow:hidden;cursor:pointer;background:var(--bg2);position:relative;opacity:${on ? 1 : .45}">
|
||||
<img src="${esc(url)}" onerror="this.style.visibility='hidden'" loading="lazy"
|
||||
style="width:100%;height:76px;object-fit:cover;display:block;background:#111">
|
||||
<div style="font-size:9.5px;padding:2px 4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">
|
||||
${on ? '☑' : '☐'} ${esc(label)}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
/** 「📐 关键帧设计」区块 —— 把设计层的 A/B/C 帧接到生成层
|
||||
*
|
||||
* 之前断链:设计层写好了 A/B/C,但工作台看不到、也没法按帧生成,
|
||||
* 只能在真空中随便生成一张图。这个区块是两层的**接口**。
|
||||
*/
|
||||
function renderKfPlanBlock() {
|
||||
const plan = (_ctx || {}).kf_plan || {};
|
||||
const frames = plan.frames || [];
|
||||
const gen = (_ctx || {}).generated_frames || [];
|
||||
if (!frames.length) {
|
||||
return `<div class="kf-sec" style="border-color:#8b5cf6">
|
||||
<div class="kf-h">📐 关键帧设计</div>
|
||||
<div style="font-size:11px;color:var(--text2);line-height:1.7">
|
||||
这一镜还没做设计 —— 先到「🖼 关键帧设计」标签生成方案,<br>
|
||||
之后这里会出现 A / B / C 各帧,可<b>逐帧生成</b>。
|
||||
</div></div>`;
|
||||
}
|
||||
const col = { '必须扩展': '#ef4444', '建议扩展': '#f59e0b', '可不扩展': '#22c55e' }[plan.need_expand] || '#8b5cf6';
|
||||
return `<div class="kf-sec" style="border-color:${col}">
|
||||
<div class="kf-h">📐 关键帧设计 —— 这一镜拆 ${frames.length} 张
|
||||
<span class="kf-tag" style="color:${col}">${esc(plan.need_expand || '')} · ${esc(plan.type || '')}</span>
|
||||
</div>
|
||||
${plan.reason ? `<div style="font-size:10.5px;color:var(--text2);margin:3px 0 6px">
|
||||
理由:${esc(plan.reason)}</div>` : ''}
|
||||
<div style="display:grid;gap:6px">
|
||||
${frames.map(f => {
|
||||
const mine = gen.filter(g => String(g.frame_seq) === String(f.seq));
|
||||
const adopted = mine.find(g => g.adopted);
|
||||
return `<div style="display:flex;gap:8px;align-items:flex-start;
|
||||
background:var(--bg2);border:1px solid var(--border);border-radius:5px;padding:6px 8px">
|
||||
<b style="font-size:11.5px;color:#8b5cf6;min-width:16px">${esc(f.seq)}</b>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:11px;font-weight:600">
|
||||
${esc(f.func || '')}
|
||||
${f.role ? `<span style="font-size:9.5px;color:var(--text2)">
|
||||
${({ first: '① 首帧', mid: '● 中间帧', last: '⏹ 尾帧' })[f.role] || ''}</span>` : ''}
|
||||
</div>
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-top:2px;line-height:1.6">
|
||||
${esc((f.visual || '').slice(0, 70))}
|
||||
${f.action ? `|动作:${esc((f.action || '').slice(0, 40))}` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:3px;align-items:flex-end">
|
||||
<button onclick="window._kfGenFrame('${esc(f.seq)}')"
|
||||
style="background:#8b5cf6;color:#fff;border:none;border-radius:4px;
|
||||
padding:3px 10px;cursor:pointer;font-size:10.5px">生成这一帧</button>
|
||||
<span style="font-size:9.5px;color:${adopted ? '#22c55e' : 'var(--text2)'}">
|
||||
${mine.length ? `${mine.length} 版${adopted ? ' · ✅已采用' : ''}` : '未生成'}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
${plan.consistency ? `<div style="font-size:10px;color:var(--text2);margin-top:6px">
|
||||
统一性:${esc(plan.consistency)}</div>` : ''}
|
||||
${plan.avoid ? `<div style="font-size:10px;color:#f59e0b;margin-top:2px">
|
||||
禁止:${esc(plan.avoid)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const box = document.getElementById('kfDetail');
|
||||
if (!box || !_ctx || !_curShot) return;
|
||||
const s = _curShot;
|
||||
const d = _ctx.detail || {};
|
||||
const it = d.intent || {}, pa = d.params || {}, asst = d.asset || {}, gl = d.global || {};
|
||||
const roles = _ctx.role_images || [];
|
||||
const vs = s.versions || [];
|
||||
|
||||
// 版本条:显示为可点小图,当前版本高亮
|
||||
if (!_curVer && vs.length) _curVer = s.adopted || vs[vs.length - 1].version;
|
||||
const verBar = vs.length ? vs.map(v => `
|
||||
<div onclick="event.stopPropagation();window._kfVer(${v.version})"
|
||||
style="width:88px;border:2px solid ${v.version === _curVer ? 'var(--primary)' : 'var(--border)'};
|
||||
border-radius:5px;overflow:hidden;cursor:pointer;background:var(--bg2);position:relative">
|
||||
<img src="${esc(v.url)}" loading="lazy"
|
||||
onclick="window._kfZoom('${esc(v.url)}','${esc(s.shot_id)} v${v.version}')"
|
||||
style="width:100%;height:120px;object-fit:cover;display:block;cursor:zoom-in">
|
||||
<div style="font-size:9.5px;padding:2px 4px">v${v.version}${v.version === s.adopted ? ' ✅' : ''}</div>
|
||||
</div>`).join('') : '';
|
||||
|
||||
const bigCur = vs.find(v => v.version === _curVer);
|
||||
const big = `<div onclick="${bigCur ? `window._kfZoom('${esc(bigCur.url)}','${esc(s.shot_id)} v${_curVer}')` : ''}"
|
||||
style="flex:1;min-width:190px;aspect-ratio:9/16;max-height:340px;border-radius:8px;overflow:hidden;
|
||||
background:#0d0d10;display:flex;align-items:center;justify-content:center;cursor:zoom-in">
|
||||
${bigCur ? `<img src="${esc(bigCur.url)}" style="width:100%;height:100%;object-fit:contain">`
|
||||
: '<span style="color:var(--text2);font-size:12px">⬜ 还没生成<br><span style="font-size:10px">点下面「🖼 生成」</span></span>'}
|
||||
</div>`;
|
||||
|
||||
box.innerHTML = `
|
||||
<!-- ① 版本区(大图 + 版本条 + 采用/丢弃) -->
|
||||
<div style="display:flex;gap:10px;margin-bottom:12px">
|
||||
${big}
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:13px;font-weight:700;margin-bottom:6px">
|
||||
${esc(s.shot_id)} · ${esc(s.shot_size || '')} ${esc(s.angle || '')}
|
||||
<span style="font-size:10.5px;color:var(--text2)">(←→ 切版本)</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:8px">${verBar || '<span style="font-size:11px;color:var(--text2)">暂无版本</span>'}</div>
|
||||
${bigCur ? `<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
${bigCur.version === s.adopted ? '<span style="font-size:11px;color:#22c55e">✅ 已采用</span>' :
|
||||
`<button onclick="window._kfAdopt(${bigCur.version})"
|
||||
style="background:#22c55e;color:#fff;border:none;border-radius:4px;padding:4px 12px;cursor:pointer;font-size:11px">✅ 采用 v${bigCur.version}</button>`}
|
||||
<button onclick="window._kfDiscard(${bigCur.version})"
|
||||
style="background:none;color:#f59e0b;border:1px solid var(--border);border-radius:4px;padding:4px 12px;cursor:pointer;font-size:11px">🗑 丢弃</button>
|
||||
</div>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ①.5 关键帧设计(来自「🖼 关键帧设计」标签)-->
|
||||
${renderKfPlanBlock()}
|
||||
|
||||
<!-- ② 意图(来自导演分镜,改后可回填) -->
|
||||
<div class="kf-sec">
|
||||
<div class="kf-h">🎬 意图 <span class="kf-tag">来自导演分镜 · 改后可 ⬆ 回填</span></div>
|
||||
<div style="font-size:10.5px;color:var(--text2);margin:4px 0 2px">画面描述</div>
|
||||
<textarea id="kfVis" rows="3" class="kf-in">${esc(it.visual_description || '')}</textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;flex-wrap:wrap">
|
||||
<div style="flex:1;min-width:170px">
|
||||
<div style="font-size:10.5px;color:var(--text2)">动作</div>
|
||||
<input id="kfAct" class="kf-in" value="${esc(it.action || '')}"></div>
|
||||
<div style="flex:1;min-width:170px">
|
||||
<div style="font-size:10.5px;color:var(--text2)">台词</div>
|
||||
<input id="kfTxt" class="kf-in" value="${esc(it.text || '')}"></div>
|
||||
<div style="flex:0 0 130px">
|
||||
<div style="font-size:10.5px;color:var(--text2)">情绪</div>
|
||||
<input id="kfSen" class="kf-in" value="${esc(it.sentiment || '')}"></div>
|
||||
</div>
|
||||
<button onclick="window._kfBackfill()"
|
||||
style="margin-top:7px;background:var(--bg2);color:var(--text);border:1px solid var(--border);
|
||||
border-radius:4px;padding:4px 12px;cursor:pointer;font-size:11px">⬆ 回填到导演分镜</button>
|
||||
<span id="kfBackMsg" style="font-size:10.5px;color:var(--text2);margin-left:6px"></span>
|
||||
</div>
|
||||
|
||||
<!-- ③ 拍摄参数 + 资产(只读,全展示) -->
|
||||
<div class="kf-sec">
|
||||
<div class="kf-h">🎥 拍摄参数 <span class="kf-tag">导演定的 · 只读</span></div>
|
||||
<div class="kf-kv">
|
||||
${_kv('景别', pa.shot_size)} ${_kv('机位', pa.angle)} ${_kv('运镜', pa.camera_move)}
|
||||
${_kv('光线', pa.lighting)} ${_kv('时长', it.duration ? it.duration + 's' : '')}
|
||||
${_kv('类型', it.type)} ${_kv('连续性', pa.continuity_mode)}
|
||||
${_kv('生成能力', pa.generation_capability)}
|
||||
</div>
|
||||
<div class="kf-kv" style="margin-top:5px">
|
||||
${_kv('出场', (asst.cast_names || []).map(c => c.name || c.slot).join('、') || asst.character_id)}
|
||||
${_kv('场地', asst.location_id)} ${_kv('道具', (asst.props || []).join('、'))}
|
||||
${asst.product_id ? _kv('产品', asst.product_id + (asst.product_placement ? `(${asst.product_placement})` : '')) : ''}
|
||||
</div>
|
||||
${(pa.tech_requirements && Object.keys(pa.tech_requirements).length)
|
||||
? `<div style="font-size:10.5px;color:var(--text2);margin-top:6px">技术需求:${esc(JSON.stringify(pa.tech_requirements))}</div>` : ''}
|
||||
${(pa.audio && Object.keys(pa.audio).length)
|
||||
? `<div style="font-size:10.5px;color:var(--text2);margin-top:4px">音频:${esc(JSON.stringify(pa.audio))}</div>` : ''}
|
||||
${(pa.expression_detail && Object.keys(pa.expression_detail).length)
|
||||
? `<div style="font-size:10.5px;color:var(--text2);margin-top:4px">表情:${esc(JSON.stringify(pa.expression_detail))}</div>` : ''}
|
||||
${gl.shooting_style ? `<div style="font-size:10.5px;color:var(--text2);margin-top:4px">
|
||||
全局:风格 ${esc(gl.shooting_style)} · 反AI感 ${esc(gl.realism_boost || '')} · ${esc(gl.lighting_state || '')}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<!-- ④ 生成参数(本页控制) -->
|
||||
<div class="kf-sec">
|
||||
<div class="kf-h">⚙️ 生成参数 <span class="kf-tag">只属于这一帧 · 不回填</span></div>
|
||||
|
||||
<div style="font-size:10.5px;color:var(--text2);margin:6px 0 3px">生成方式</div>
|
||||
<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:8px" id="kfMode">
|
||||
${[['multi', '★ 多图融合'], ['text2img', '文生图'], ['single', '图生图(单参考)'], ['edit', '改造已有图']]
|
||||
.map(([v, t], i) => `<label style="font-size:11px;display:flex;align-items:center;gap:4px;cursor:pointer">
|
||||
<input type="radio" name="kfMode" value="${v}" ${i === 0 ? 'checked' : ''}
|
||||
onchange="window._kfModeChange()"> ${t}</label>`).join('')}
|
||||
</div>
|
||||
|
||||
<div style="font-size:10.5px;color:var(--text2);margin:8px 0 3px">引擎模型(换模型=换能力)</div>
|
||||
<select id="kfEngineSel" onchange="window._kfEngineChange()"
|
||||
style="width:100%;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);
|
||||
color:var(--text);border-radius:4px;font-size:11.5px;box-sizing:border-box"></select>
|
||||
<div id="kfEngineNote" style="font-size:10.5px;color:var(--text2);line-height:1.65;
|
||||
background:var(--bg2);border:1px solid var(--border);border-radius:5px;
|
||||
padding:6px 8px;margin-top:5px"></div>
|
||||
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:3px">
|
||||
参考图 <span style="color:var(--text2)">(点缩略图 = 勾选/取消;顺序=提示词里的「图1/图2…」)</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:4px">
|
||||
${roles.map((r, i) => _refCard(r.path, '角色·' + (r.slot || ('#' + (i + 1))),
|
||||
_selRole.includes(r.path), `window._kfTogRole('${esc(r.path)}')`)).join('')}
|
||||
${_ctx.scene_image ? _refCard(_ctx.scene_image, '场景图',
|
||||
!!_selScene, `window._kfTogScene()`) : ''}
|
||||
${_selExtra.map((p, i) => _refCard(p, '追加' + (i + 1), true, `window._kfRmExtra(${i})`)).join('')}
|
||||
<div onclick="window._kfAddExtra()"
|
||||
style="width:104px;height:99px;border:1px dashed var(--border);border-radius:6px;
|
||||
display:flex;align-items:center;justify-content:center;cursor:pointer;
|
||||
font-size:20px;color:var(--text2);background:var(--bg2)">+</div>
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text2);margin-bottom:8px">
|
||||
${esc((_ctx.hints || []).join(' · '))}
|
||||
</div>
|
||||
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:3px">提示词(可编辑)</div>
|
||||
<textarea id="kfPrompt" rows="5" class="kf-in">${esc(_ctx.prompt || '')}</textarea>
|
||||
|
||||
<div style="font-size:10.5px;color:var(--text2);margin:7px 0 3px">负向提示词</div>
|
||||
<input id="kfNegative" class="kf-in" value="people duplicated, extra fingers, distorted face">
|
||||
|
||||
<div style="display:flex;gap:10px;align-items:center;margin-top:9px;flex-wrap:wrap">
|
||||
<span style="font-size:11px;color:var(--text2)">画幅</span>
|
||||
<select id="kfSize" style="padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px">
|
||||
${Object.entries(_sizes).map(([k, v]) =>
|
||||
`<option value="${esc(v)}" ${v === _defSize ? 'selected' : ''}>${esc(k)} (${esc(v)})</option>`).join('')}
|
||||
</select>
|
||||
<span style="font-size:11px;color:var(--text2)">张数</span>
|
||||
<select id="kfN" style="padding:4px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px">
|
||||
${[1, 2, 3, 4].map(n => `<option value="${n}">${n}</option>`).join('')}
|
||||
</select>
|
||||
<button onclick="window._kfGenShot()"
|
||||
style="background:var(--primary);color:#fff;border:none;border-radius:5px;padding:6px 18px;
|
||||
cursor:pointer;font-size:12px;font-weight:600">🖼 生成(G)</button>
|
||||
<span id="kfMsg" style="font-size:11px;color:var(--text2)"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
// 高亮左列当前项 + 初始化引擎提示
|
||||
_markCurrent();
|
||||
window._kfModeChange();
|
||||
}
|
||||
|
||||
function _kv(k, v) {
|
||||
if (v == null || v === '' || v === 'undefined') return '';
|
||||
return `<span><span style="color:var(--text2)">${esc(k)}</span> ${esc(String(v))}</span>`;
|
||||
}
|
||||
|
||||
function _markCurrent() {
|
||||
const box = document.getElementById('kfStrip');
|
||||
if (!box || !_curShot) return;
|
||||
[...box.children].forEach(c => { if (c.classList) c.classList.remove('kf-on'); });
|
||||
const idx = _shots.findIndex(s => s.shot_id === _curShot.shot_id);
|
||||
if (idx >= 0 && box.children[idx + 1]) box.children[idx + 1].classList.add('kf-on');
|
||||
}
|
||||
|
||||
// ── 交互 ────────────────────────────────────────────────────
|
||||
window._kfSel = (sid) => selectShot(sid);
|
||||
window._kfOnProject = async (v) => { _pid = v; _curShot = null; await loadShots(); };
|
||||
|
||||
window._kfTogRole = (path) => {
|
||||
const i = _selRole.indexOf(path);
|
||||
if (i >= 0) _selRole.splice(i, 1); else _selRole.push(path);
|
||||
renderDetail();
|
||||
};
|
||||
window._kfTogScene = () => {
|
||||
_selScene = _selScene ? '' : (_ctx.scene_image || '');
|
||||
renderDetail();
|
||||
};
|
||||
window._kfRmExtra = (i) => { _selExtra.splice(i, 1); renderDetail(); };
|
||||
// ── 资产选择器(从角色库/场景库直接点图)────────────────────
|
||||
const _PICK_KINDS = [
|
||||
['characters', '角色'],
|
||||
['locations', '场景'],
|
||||
['real_locations', '真实场地'],
|
||||
['props', '道具'],
|
||||
['products', '产品'],
|
||||
['wardrobe', '服装'],
|
||||
];
|
||||
|
||||
window._kfAddExtra = () => window._kfPickAsset();
|
||||
|
||||
window._kfPickAsset = async () => {
|
||||
let box = document.getElementById('_kfPicker');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = '_kfPicker';
|
||||
box.onclick = (e) => { if (e.target === box) box.style.display = 'none'; };
|
||||
box.innerHTML = `<div style="position:fixed;inset:0;background:rgba(0,0,0,.75);z-index:2100;
|
||||
display:flex;align-items:center;justify-content:center">
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;
|
||||
width:min(1050px,94vw);max-height:86vh;display:flex;flex-direction:column;overflow:hidden">
|
||||
<div style="padding:11px 14px;border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px">
|
||||
<b style="font-size:13px">📚 从资产库选参考图</b>
|
||||
<span id="_kfPickMsg" style="font-size:11px;color:var(--text2)"></span>
|
||||
<button onclick="document.getElementById('_kfPicker').style.display='none'"
|
||||
style="margin-left:auto;background:none;border:1px solid var(--border);color:var(--text);
|
||||
border-radius:4px;padding:3px 10px;cursor:pointer;font-size:11px">关闭</button>
|
||||
</div>
|
||||
<div id="_kfPickBody" style="padding:12px;overflow-y:auto;flex:1">
|
||||
<div style="color:var(--text2);font-size:12px">⏳ 载入资产…</div>
|
||||
</div>
|
||||
</div></div>`;
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
box.style.display = 'block';
|
||||
|
||||
const body = document.getElementById('_kfPickBody');
|
||||
body.innerHTML = '<div style="color:var(--text2);font-size:12px">⏳ 载入资产…</div>';
|
||||
const cur = new Set(_selRole.concat(_selScene ? [_selScene] : [], _selExtra));
|
||||
let html = '';
|
||||
for (const [kind, label] of _PICK_KINDS) {
|
||||
let items = [];
|
||||
try {
|
||||
const r = await fetch(`/api/ave/assets/${kind}`);
|
||||
const d = await r.json();
|
||||
items = d.items || [];
|
||||
} catch (e) { continue; }
|
||||
// 只保留有参考图的资产
|
||||
const withRefs = items.filter(it => Object.keys(it.reference_images || {}).length);
|
||||
if (!withRefs.length) continue;
|
||||
html += `<div style="font-size:12px;font-weight:600;margin:10px 0 6px">${label}(${withRefs.length})</div>`;
|
||||
html += '<div style="display:flex;gap:10px;flex-wrap:wrap;margin-bottom:6px">';
|
||||
for (const it of withRefs) {
|
||||
const refs = Object.entries(it.reference_images || {});
|
||||
html += `<div style="border:1px solid var(--border);border-radius:7px;padding:6px;background:var(--bg3);max-width:100%">
|
||||
<div style="font-size:11px;font-weight:600;margin-bottom:4px">${esc(it.name || it.id)}</div>
|
||||
<div style="display:flex;gap:5px;flex-wrap:wrap">`;
|
||||
for (const [slot, fn] of refs) {
|
||||
const p = `/api/ave/assets/${encodeURIComponent(kind)}/${encodeURIComponent(it.id)}/file/${encodeURIComponent(fn)}`;
|
||||
const on = cur.has(p);
|
||||
html += `<div onclick="window._kfPickAdd('${esc(p)}','${esc((it.name || it.id) + '·' + slot)}')"
|
||||
title="${esc(p)}"
|
||||
style="width:84px;border:2px solid ${on ? '#22c55e' : 'var(--border)'};border-radius:5px;
|
||||
overflow:hidden;cursor:pointer;background:var(--bg2);opacity:${on ? .55 : 1}">
|
||||
<img src="${esc(p)}" loading="lazy" onerror="this.style.visibility='hidden'"
|
||||
style="width:100%;height:88px;object-fit:cover;display:block;background:#111">
|
||||
<div style="font-size:9px;padding:1px 3px;white-space:nowrap;overflow:hidden;
|
||||
text-overflow:ellipsis">${on ? '✓ ' : ''}${esc(slot)}</div>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div></div>';
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
body.innerHTML = html || '<div style="color:var(--text2);font-size:12px">资产库里还没有带参考图的资产</div>';
|
||||
};
|
||||
|
||||
window._kfPickAdd = (path, label) => {
|
||||
const all = _selRole.concat(_selScene ? [_selScene] : [], _selExtra);
|
||||
if (all.includes(path)) { alert('这张已经在参考图里了'); return; }
|
||||
_selExtra.push(path);
|
||||
const msg = document.getElementById('_kfPickMsg');
|
||||
if (msg) msg.textContent = `✅ 已加入:${label}(关掉选择器即可看到)`;
|
||||
renderDetail();
|
||||
};
|
||||
/** 按当前生成方式重建引擎下拉(只列 supports 包含该方式的模型) */
|
||||
window._kfModeChange = () => {
|
||||
const sel = document.getElementById('kfEngineSel');
|
||||
if (!sel) return;
|
||||
const m = _mode();
|
||||
const list = _models.filter(x => (x.supports || []).includes(m));
|
||||
const def = _modelByMode[m] || '';
|
||||
sel.innerHTML = list.length
|
||||
? list.map(x => {
|
||||
const tag = x.status ? `[${x.status}]` : (x.verified ? '' : '[未实测]');
|
||||
return `<option value="${esc(x.id)}" ${x.id === def ? 'selected' : ''}>`
|
||||
+ `${esc(x.name)} · ${esc(x.vendor)} ${esc(tag)}</option>`;
|
||||
}).join('')
|
||||
: '<option value="">(该方式暂无可用模型)</option>';
|
||||
window._kfEngineChange();
|
||||
};
|
||||
|
||||
window._kfEngineChange = () => {
|
||||
const sel = document.getElementById('kfEngineSel');
|
||||
const note = document.getElementById('kfEngineNote');
|
||||
if (!sel || !note) return;
|
||||
const info = _models.find(x => x.id === sel.value);
|
||||
if (!info) { note.textContent = ''; return; }
|
||||
const lim = info.limits || {};
|
||||
const bits = [];
|
||||
if (info.note) bits.push(info.note);
|
||||
if (lim.max_input_images) bits.push(`最多 ${lim.max_input_images} 张输入图`);
|
||||
if (lim.size_range) bits.push(`尺寸:${lim.size_range}`);
|
||||
if (lim.n_max) bits.push(`一次最多 ${lim.n_max} 张`);
|
||||
if (info.price) bits.push(`价格 ${info.price}`);
|
||||
if (info.status) bits.push(`⚠️ ${info.status}(暂不可用)`);
|
||||
note.innerHTML = `<b>${esc(info.name)}</b> · ${esc(info.vendor)}<br>${esc(bits.join(' · '))}`;
|
||||
};
|
||||
|
||||
window._kfVer = (v) => { _curVer = v; renderDetail(); };
|
||||
|
||||
window._kfZoom = (url, cap) => {
|
||||
let box = document.getElementById('_kfLightbox');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = '_kfLightbox';
|
||||
box.onclick = () => { box.style.display = 'none'; };
|
||||
box.innerHTML = `<div style="position:fixed;inset:0;background:rgba(0,0,0,.93);z-index:2000;
|
||||
display:flex;flex-direction:column;align-items:center;justify-content:center;cursor:zoom-out">
|
||||
<img id="_kfLbImg" style="max-width:94vw;max-height:86vh;object-fit:contain;border-radius:8px">
|
||||
<div id="_kfLbCap" style="color:#ddd;font-size:13px;margin-top:10px"></div></div>`;
|
||||
document.body.appendChild(box);
|
||||
}
|
||||
document.getElementById('_kfLbImg').src = url;
|
||||
document.getElementById('_kfLbCap').textContent = cap || '';
|
||||
box.style.display = 'block';
|
||||
};
|
||||
|
||||
async function genShot(frameSeq) {
|
||||
if (_busy) return;
|
||||
const sid = _curShot && _curShot.shot_id;
|
||||
if (!sid) return;
|
||||
const mode = _mode();
|
||||
let prompt = _val('kfPrompt');
|
||||
const negative = _val('kfNegative');
|
||||
const n = parseInt(_val('kfN') || '1', 10);
|
||||
const size = _val('kfSize') || _defSize;
|
||||
const model = _val('kfEngineSel') || '';
|
||||
|
||||
// 按「关键帧设计」的某一帧生成:提示词可留空(后端用该帧设计自动组装)
|
||||
let fr = null;
|
||||
if (frameSeq) {
|
||||
fr = (((_ctx || {}).kf_plan || {}).frames || []).find(x => String(x.seq) === String(frameSeq));
|
||||
if (!fr) { alert(`设计里没有帧 ${frameSeq}`); return; }
|
||||
} else if (!prompt.trim()) { alert('提示词为空'); return; }
|
||||
const refs = mode === 'text2img' ? [] : _selRole.concat(_selScene ? [_selScene] : [], _selExtra);
|
||||
if (mode === 'edit' && !refs.length) { alert('「改造已有图」至少要勾 1 张底图'); return; }
|
||||
if (mode === 'single' && refs.length !== 1) { alert('「图生图(单参考)」请只勾 1 张参考图'); return; }
|
||||
|
||||
const cost = (n * 0.2).toFixed(1);
|
||||
const minfo = _models.find(x => x.id === model);
|
||||
const head = fr
|
||||
? `🖼 生成关键帧 ${frameSeq}(${fr.func || fr.role || ''})\n\n镜头:${sid}\n`
|
||||
+ `画面:${(fr.visual || '').slice(0, 80)}\n动作:${(fr.action || '').slice(0, 60)}\n`
|
||||
: `🖼 生成关键帧\n\n镜头:${sid}\n方式:${mode}\n`;
|
||||
if (!confirm(head + `引擎:${minfo ? minfo.name : model}\n参考图:${refs.length} 张\n`
|
||||
+ `画幅:${size}\n张数:${n}(约 ¥${cost})\n\n确认生成?`)) return;
|
||||
|
||||
_busy = true;
|
||||
const msg = document.getElementById('kfMsg');
|
||||
if (msg) msg.textContent = '⏳ 生成中…(约 20-60 秒)';
|
||||
try {
|
||||
const d = await api('/generate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: _pid, shot_id: sid, prompt, negative, mode, n, size, model,
|
||||
role_images: _selRole, scene_image: _selScene, ref_images: _selExtra,
|
||||
frame_seq: frameSeq || '', confirm_cost: true,
|
||||
}),
|
||||
});
|
||||
if (msg) msg.textContent = `✅ ${d.note || '完成'}`;
|
||||
await loadShots();
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = '❌ ' + e.message;
|
||||
} finally { _busy = false; }
|
||||
}
|
||||
window._kfGenShot = genShot;
|
||||
window._kfGenFrame = (seq) => genShot(seq);
|
||||
|
||||
window._kfAdopt = async (v) => {
|
||||
try {
|
||||
await api('/adopt', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project_id: _pid, shot_id: _curShot.shot_id, version: v }) });
|
||||
await loadShots();
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._kfDiscard = async (v) => {
|
||||
if (!confirm(`丢弃 v${v}?(图片与记录一并删除)`)) return;
|
||||
try {
|
||||
await api('/discard', { method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project_id: _pid, shot_id: _curShot.shot_id, version: v }) });
|
||||
_curVer = 0;
|
||||
await loadShots();
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._kfBackfill = async () => {
|
||||
if (!_curShot) return;
|
||||
const fields = {
|
||||
visual_description: _val('kfVis'), action: _val('kfAct'),
|
||||
text: _val('kfTxt'), sentiment: _val('kfSen'),
|
||||
};
|
||||
if (!confirm('把「意图」的改动回填到导演分镜?\n(原分镜会自动备份为 .bak)')) return;
|
||||
const msg = document.getElementById('kfBackMsg');
|
||||
try {
|
||||
const d = await api('/update-shot', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ project_id: _pid, shot_id: _curShot.shot_id, fields }),
|
||||
});
|
||||
if (msg) msg.textContent = `✅ 已回填 ${d.changed.join('/')}(备份 ${d.backup})`;
|
||||
} catch (e) { if (msg) msg.textContent = '❌ ' + e.message; }
|
||||
};
|
||||
|
||||
// 批量生成
|
||||
window._kfBatch = async () => {
|
||||
if (!_shots.length) { alert('无镜头'); return; }
|
||||
const targets = _shots.filter(s => !(s.versions || []).length);
|
||||
if (!targets.length) { alert('所有镜头都已有版本'); return; }
|
||||
if (!confirm(`批量生成 ${targets.length} 个未生成的镜头?\n(串行,每镜约 ¥0.2,合计约 ¥${(targets.length * 0.2).toFixed(1)})`)) return;
|
||||
const msg = document.getElementById('kfBatchMsg');
|
||||
for (let i = 0; i < targets.length; i++) {
|
||||
if (msg) msg.textContent = `⏳ ${i + 1}/${targets.length} — ${targets[i].shot_id}`;
|
||||
await genQuiet(targets[i].shot_id);
|
||||
}
|
||||
if (msg) msg.textContent = `✅ 批量完成(${targets.length} 镜)`;
|
||||
await loadShots();
|
||||
};
|
||||
|
||||
async function genQuiet(sid) {
|
||||
try {
|
||||
const ctx = await api(`/context?project_id=${encodeURIComponent(_pid)}&shot_id=${encodeURIComponent(sid)}`);
|
||||
await api('/generate', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
project_id: _pid, shot_id: sid, prompt: ctx.prompt || '',
|
||||
negative: 'people duplicated, extra fingers, distorted face',
|
||||
mode: 'multi', n: 1, size: _defSize,
|
||||
role_images: (ctx.role_images || []).map(r => r.path),
|
||||
scene_image: ctx.scene_image || '', confirm_cost: true,
|
||||
}),
|
||||
});
|
||||
} catch (e) { console.warn('批量生成失败', sid, e); }
|
||||
}
|
||||
|
||||
// ── 键盘(像翻连环画)────────────────────────────────────────
|
||||
function _onKey(e) {
|
||||
const tag = (e.target.tagName || '').toLowerCase();
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select') return;
|
||||
if (!_shots.length || !_curShot) return;
|
||||
const i = _shots.findIndex(s => s.shot_id === _curShot.shot_id);
|
||||
if (e.key === 'ArrowDown' || e.key === 'j') {
|
||||
e.preventDefault();
|
||||
if (i + 1 < _shots.length) selectShot(_shots[i + 1].shot_id);
|
||||
} else if (e.key === 'ArrowUp' || e.key === 'k') {
|
||||
e.preventDefault();
|
||||
if (i > 0) selectShot(_shots[i - 1].shot_id);
|
||||
} else if (e.key === 'ArrowRight' || e.key === 'l') {
|
||||
e.preventDefault();
|
||||
const vs = _curShot.versions || [];
|
||||
const idx = vs.findIndex(v => v.version === _curVer);
|
||||
if (idx + 1 < vs.length) { _curVer = vs[idx + 1].version; renderDetail(); }
|
||||
} else if (e.key === 'ArrowLeft' || e.key === 'h') {
|
||||
e.preventDefault();
|
||||
const vs = _curShot.versions || [];
|
||||
const idx = vs.findIndex(v => v.version === _curVer);
|
||||
if (idx > 0) { _curVer = vs[idx - 1].version; renderDetail(); }
|
||||
} else if (e.key === 'g' || e.key === 'G') {
|
||||
e.preventDefault();
|
||||
genShot();
|
||||
}
|
||||
}
|
||||
|
||||
// ── 入口 ────────────────────────────────────────────────────
|
||||
export async function loadView(container) {
|
||||
_curShot = null; _ctx = null; _curVer = 0;
|
||||
container.innerHTML = `
|
||||
<style>
|
||||
.kf-sec{background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:9px 10px;margin-bottom:9px}
|
||||
.kf-h{font-size:12px;font-weight:600;margin-bottom:4px}
|
||||
.kf-tag{font-size:10px;color:var(--text2);font-weight:400}
|
||||
.kf-in{width:100%;padding:5px 7px;background:var(--bg2);border:1px solid var(--border);
|
||||
color:var(--text);border-radius:4px;font-size:11.5px;box-sizing:border-box}
|
||||
.kf-kv{display:flex;gap:14px;flex-wrap:wrap;font-size:11.5px}
|
||||
#kfStrip>div.kf-on{border-color:var(--primary)!important;outline:1px solid var(--primary)}
|
||||
</style>
|
||||
<div style="max-width:1500px">
|
||||
${upBlock('keyframes', store.getProject() || {})}
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:4px;flex-wrap:wrap">
|
||||
<div style="font-size:15px;font-weight:700">🖼 关键帧工作台</div>
|
||||
<span style="font-size:11px;color:var(--text2)">剧本 → <b>关键帧</b> → 视频(第②步)</span>
|
||||
<div style="margin-left:auto;display:flex;gap:6px;align-items:center">
|
||||
<select id="kfProject" onchange="window._kfOnProject(this.value)"
|
||||
style="padding:5px 9px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:5px;font-size:12px;max-width:300px"></select>
|
||||
<button onclick="window._kfBatch()"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:5px;padding:5px 12px;cursor:pointer;font-size:12px">🖼 批量生成</button>
|
||||
<span id="kfBatchMsg" style="font-size:11px;color:var(--text2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:9px">
|
||||
↑↓ 翻镜头 · ←→ 切版本 · G 生成 · 每张约 ¥0.2;满意后「采用」,采用的关键帧才能进第③步。
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:290px 1fr;gap:12px;align-items:start">
|
||||
<div id="kfStrip" style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;
|
||||
padding:8px;max-height:calc(100vh - 190px);overflow-y:auto"></div>
|
||||
<div id="kfDetail" style="max-height:calc(100vh - 190px);overflow-y:auto;padding-right:4px"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
await loadSizes();
|
||||
await loadProjects();
|
||||
document.addEventListener('keydown', _onKey);
|
||||
}
|
||||
|
||||
export function unloadView() {
|
||||
document.removeEventListener('keydown', _onKey);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* ave-locations.js — 🏞️ 场景库
|
||||
* 规范:DCS 说明书 v2.0 第五节(场景:空间锚点 / 光源 / 材质)
|
||||
*/
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const SPACE_TYPES = {
|
||||
indoor: '室内', outdoor: '室外', vehicle: '交通工具', fantasy: '虚构空间',
|
||||
};
|
||||
|
||||
// 预设建议(可下拉选,也可自己填)
|
||||
const LIGHTING_PRESETS = [
|
||||
'日光(自然)', '日光灯(冷白)', '暖光(黄昏/钨丝)', '阴天散射光',
|
||||
'夜景灯光', '混合光源', '无自然光(内景)', '顶光', '侧光', '逆光',
|
||||
];
|
||||
const MATERIAL_PRESETS = [
|
||||
'白色乳胶漆墙', '瓷砖地面', '木地板', '玻璃幕墙', '金属质感',
|
||||
'水泥裸墙', '布艺软装', '石材台面', '环氧地坪', '木质家具',
|
||||
];
|
||||
const TOPOLOGY_PRESETS = [
|
||||
'入口在南侧', '主空间朝东', '窗户在左手边', '单一开间', '走廊 + 房间',
|
||||
'L 形空间', '层高较高(3m+)', '层高较低(2.4m)',
|
||||
];
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'locations', title: '场景库', icon: '🏞️', uid: 'loc',
|
||||
// ★ 与场景生成器 SCENE_VIEWS 对齐(wide 主图 + 5 个机位)
|
||||
// 此前只有 wide/detail/light 三槽 → 生成器产出的 front/side/top/back 四张图
|
||||
// 在场景库里**完全没有入口**,这是"套图看不到"的根因。
|
||||
refKeys: [
|
||||
['wide', '① 广角全景(主图)'],
|
||||
['front', '② 正面视线'],
|
||||
['side', '③ 侧面 45°'],
|
||||
['top', '④ 俯视 / 顶图'],
|
||||
['detail', '⑤ 细节特写'],
|
||||
['back', '⑥ 门口 / 反向'],
|
||||
],
|
||||
refIntro: '这 6 张是【同一个空间的 6 个机位】,不是 6 个场景 —— 不用合并。'
|
||||
+ '分镜时你也不需要挑图:只要写明镜头怎么拍,系统按机位自动选一张作参考'
|
||||
+ '(俯视→顶图 · 仰视→正面 · 侧拍→侧面 · 过肩→反向 · 特写→细节 · 默认→广角)。'
|
||||
+ '想看清细节,点缩略图看大图。',
|
||||
// 每个机位在分镜里怎么被自动选用(对应 pipeline._resolve_location_ref 的映射)
|
||||
refHints: {
|
||||
wide: '建立镜头 · 前后景都清晰 —— 分镜「全景」自动用它',
|
||||
front: '正对主墙平视 —— 分镜「仰视 / 正面」用它',
|
||||
side: '侧方看纵深,家具与墙关系清楚 —— 分镜「侧拍 / 45°」用它',
|
||||
top: '正上方俯视平面图 —— 分镜「俯视 / 鸟瞰」用它(理解空间的钥匙)',
|
||||
detail: '焦点物件 / 材质特写 —— 分镜「特写 / 近景」用它',
|
||||
back: '从房间另一端回看门口 —— 分镜「过肩 / 反向」用它',
|
||||
},
|
||||
filters: [{ key: 'space_type', label: '空间类型', options: SPACE_TYPES }],
|
||||
editFields: [
|
||||
{ key: 'space_type', label: '空间类型', type: 'select', options: SPACE_TYPES },
|
||||
{
|
||||
key: 'lighting', label: '光源状态', type: 'datalist',
|
||||
options: LIGHTING_PRESETS, placeholder: '如:日光(自然)—— 可下拉也可以自己描述',
|
||||
},
|
||||
{
|
||||
key: 'materials', label: '材质要点', type: 'datalist',
|
||||
options: MATERIAL_PRESETS, placeholder: '如:白色乳胶漆墙、瓷砖地面 —— 可下拉或自己填',
|
||||
},
|
||||
{
|
||||
key: 'topology', label: '空间拓扑(可走动区域/朝向)', type: 'datalist',
|
||||
options: TOPOLOGY_PRESETS, placeholder: '如:入口在南侧,主空间朝东',
|
||||
},
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: 'AI 生成场景的参考图(全景/细节/光线)+ 空间锚点、光源、材质(DCS 第五节规范)',
|
||||
referenceDoc: 'DCS 说明书 v2.0 第五节',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// views/ave-materials.js
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:40px;text-align:center;color:var(--text2)">
|
||||
<div style="font-size:32px;margin-bottom:12px">📦</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:8px">素材库</div>
|
||||
<div style="font-size:12px;opacity:.7">功能开发中,敬请期待</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* ave-new-flow.js — ➕ 新建流程(流程入口)
|
||||
*
|
||||
* 打开空白流程画布,从左侧节点面板拖节点自建 DAG。
|
||||
*/
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:20px;max-width:780px">
|
||||
<h2 style="font-size:20px;margin:0 0 6px">➕ 新建流程</h2>
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:14px">从空白画布开始,自己拖节点拼一条流水线</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:14px;font-size:12px;color:var(--text2);line-height:2">
|
||||
<b>可用节点(15 种)</b><br>
|
||||
📜 脚本输入 · 📝 剧本原型 · 🔄 剧本改编 · 🎯 策略选择 · 🎬 分镜规划 · ✨ 分镜提示词<br>
|
||||
🤖 AI 模型(可灵生成)· 🔌 素材源 · 🎤 TTS · 🎵 BGM · 🎨 风格预设 · 👤 角色<br>
|
||||
🛡️ 审计门 · 📦 输出 · 💬 字幕生成
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:14px;font-size:11px;color:var(--text2);line-height:1.9">
|
||||
<b>怎么用</b><br>
|
||||
· 从画布上方节点面板**拖节点**到画布 → 连端口成 DAG → 点节点「配置」改参数<br>
|
||||
· 点「▶ 运行」执行;生成类节点会调用可灵(<b>产生费用</b>),用「镜头数」限制<br>
|
||||
· **推荐最小可用链**:📜 脚本输入(填故事)→ 🎬 分镜规划 → 🤖 AI 模型 → 📦 输出
|
||||
</div>
|
||||
|
||||
<button onclick="window.switchView && window.switchView('workflow')"
|
||||
style="background:var(--primary);color:#fff;border:none;padding:9px 22px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">➕ 打开空白画布</button>
|
||||
<div style="margin-top:12px;font-size:10px;color:var(--text2)">
|
||||
提示:也可以先用已有模板(如「📖 角色叙事」)再改,比从零搭快
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* ave-products.js — 📦 产品库
|
||||
* 规范:DCS 说明书 v2.0 第五节(产品:四角度 + must_keep 必保特征 + 一致性种子)
|
||||
*/
|
||||
// 必保特征建议(可下拉也可自由填)
|
||||
const MUST_KEEP_PRESETS = [
|
||||
'logo 位置与大小', '包装主色', '瓶身/盒型形状', '标签字体',
|
||||
'开盖方式', '整体比例', '材质质感', '品牌色带',
|
||||
];
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const CATEGORIES = { food: '食品', daily: '日用品', beauty: '美妆', health: '健康', digital: '数码', other: '其他' };
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'products', title: '产品库', icon: '📦', uid: 'prod',
|
||||
refKeys: [['front', '正面'], ['angle', '侧面'], ['detail', '细节'], ['in_hand', '手持']],
|
||||
filters: [{ key: 'category', label: '类别', options: CATEGORIES }],
|
||||
editFields: [
|
||||
{ key: 'category', label: '类别', type: 'select', options: CATEGORIES },
|
||||
{ key: 'must_keep', label: '必须保持一致的特征', type: 'datalist', options: MUST_KEEP_PRESETS, placeholder: '如:logo 位置、包装主色、瓶身形状 —— 可下拉或自己填' },
|
||||
{ key: 'consistency_seed', label: '一致性种子(数字,同一种子生成风格更一致)', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '产品四角度参考图(正/侧/细节/手持)+ category + must_keep 必保特征 + 一致性种子',
|
||||
referenceDoc: 'DCS 说明书 v2.0 第五节',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* ave-props.js — 🧰 道具库
|
||||
* 规范:DCS 说明书 v2.0 第五节(道具:结构 / 比例 / 可见面 / 持用状态 / 新旧状态)
|
||||
*/
|
||||
// 结构/比例建议
|
||||
const STRUCTURE_PRESETS = [
|
||||
'长约 20cm', '可开合', '金属拉丝质感', '木质纹理', '透明材质',
|
||||
'便携小巧', '大件醒目', '带文字标识',
|
||||
];
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const USAGE = { held: '需手持', placed: '需摆放', background: '背景道具', worn: '需穿戴' };
|
||||
const NEWNESS = { new: '全新', normal: '正常使用痕迹', worn: '陈旧', damaged: '破损' };
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'props', title: '道具库', icon: '🧰', uid: 'prop',
|
||||
refKeys: [['front', '正面'], ['side', '侧面'], ['detail', '细节']],
|
||||
filters: [
|
||||
{ key: 'usage_state', label: '持用状态', options: USAGE },
|
||||
{ key: 'newness', label: '新旧状态', options: NEWNESS },
|
||||
],
|
||||
editFields: [
|
||||
{ key: 'usage_state', label: '持用状态', type: 'select', options: USAGE },
|
||||
{ key: 'newness', label: '新旧状态', type: 'select', options: NEWNESS },
|
||||
{ key: 'structure', label: '结构 / 比例说明', type: 'datalist', options: STRUCTURE_PRESETS, placeholder: '如:长约 20cm、可开合、金属拉丝 —— 可下拉或自己填' },
|
||||
{ key: 'visible_faces', label: '可见面(逗号分隔)', type: 'list' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '道具结构/比例/可见面/持用状态/新旧状态 + 参考图',
|
||||
referenceDoc: 'DCS 说明书 v2.0 第五节',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* ave-providers.js — 🔌 供应商管理
|
||||
*
|
||||
* 按能力汇总所有可选供应商:默认标记 / 密钥配置状态 / 模型名 / 计费说明
|
||||
* 数据源:GET /api/ave/capabilities(能力注册表 + 实时密钥状态)
|
||||
* 改默认供应商:POST /api/ave/capabilities/{id}/default
|
||||
*/
|
||||
const API = '/api/ave';
|
||||
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px;flex-wrap:wrap;gap:8px">
|
||||
<h2 style="font-size:18px;margin:0">🔌 供应商管理
|
||||
<span id="pvCount" style="font-size:12px;color:var(--text2)"></span></h2>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button onclick="window.switchView && window.switchView('api-config')"
|
||||
style="background:var(--primary);color:#fff;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px">🔑 去配密钥</button>
|
||||
<button onclick="window._pvLoad()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px">🔄 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:8px;font-size:12px;color:var(--text2);line-height:1.8;margin-bottom:12px">
|
||||
💡 <b>这里是"同一个能力有哪些供应商可选"的总览</b>(只读):<br>
|
||||
· ✅/⚠️ 表示该供应商的密钥是否已配置(密钥去「🔑 API 配置」填)<br>
|
||||
· 🟢 默认 = 流程未特别指定时用它;点「设为默认」可切换<br>
|
||||
· 想改参数默认值(语速/时长等)→ 去「🧩 能力配置」
|
||||
</div>
|
||||
|
||||
<div id="pvFilter" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px"></div>
|
||||
<div id="pvList"><div style="color:var(--text2);font-size:12px">⏳ 加载中…</div></div>
|
||||
</div>`;
|
||||
|
||||
window._pvCat = '';
|
||||
await window._pvLoad();
|
||||
}
|
||||
|
||||
window._pvLoad = async () => {
|
||||
const el = document.getElementById('pvList');
|
||||
if (el) el.innerHTML = '<div style="color:var(--text2);font-size:12px">⏳ 加载中…</div>';
|
||||
try {
|
||||
const r = await fetch(`${API}/capabilities`);
|
||||
const d = await r.json();
|
||||
const caps = d.capabilities || [];
|
||||
const cats = d.categories || [];
|
||||
|
||||
// 统计
|
||||
let provTotal = 0, provReady = 0;
|
||||
caps.forEach(c => (c.providers || []).forEach(p => { provTotal++; if (p.key_configured) provReady++; }));
|
||||
const cnt = document.getElementById('pvCount');
|
||||
if (cnt) cnt.textContent = `${caps.length} 个能力 · ${provTotal} 个供应商(密钥已配 ${provReady})`;
|
||||
|
||||
// 分类筛选
|
||||
const f = document.getElementById('pvFilter');
|
||||
if (f) {
|
||||
f.innerHTML = `<button onclick="window._pvSetCat('')" id="pvc_all"
|
||||
style="background:${window._pvCat === '' ? 'var(--primary)' : 'var(--bg3)'};color:${window._pvCat === '' ? '#fff' : 'var(--text)'};border:1px solid var(--border);padding:4px 12px;border-radius:14px;cursor:pointer;font-size:11px">全部 ${caps.length}</button>`
|
||||
+ cats.map(c => {
|
||||
const n = caps.filter(x => x.category === c.id).length;
|
||||
const act = window._pvCat === c.id;
|
||||
return `<button onclick="window._pvSetCat('${c.id}')"
|
||||
style="background:${act ? 'var(--primary)' : 'var(--bg3)'};color:${act ? '#fff' : 'var(--text)'};border:1px solid var(--border);padding:4px 12px;border-radius:14px;cursor:pointer;font-size:11px">${c.icon || ''} ${c.name} ${n}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
const list = window._pvCat ? caps.filter(c => c.category === window._pvCat) : caps;
|
||||
const el2 = document.getElementById('pvList');
|
||||
if (!el2) return;
|
||||
el2.innerHTML = list.map(c => card(c)).join('');
|
||||
} catch (e) {
|
||||
const el2 = document.getElementById('pvList');
|
||||
if (el2) el2.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
window._pvSetCat = (cat) => { window._pvCat = cat; window._pvLoad(); };
|
||||
|
||||
function card(c) {
|
||||
const provs = c.providers || [];
|
||||
const cur = (c.defaults || {}).provider || '';
|
||||
const rows = provs.map(p => {
|
||||
const isDefault = p.id === cur;
|
||||
const keyOk = p.key_configured;
|
||||
return `<div style="display:flex;align-items:center;gap:8px;padding:6px 8px;background:var(--bg2);border:1px solid var(--border);border-radius:6px;margin-top:4px">
|
||||
<span style="font-size:11px;${isDefault ? 'color:#22c55e;font-weight:600' : 'color:var(--text2)'}">${isDefault ? '🟢 默认' : '⚪'}</span>
|
||||
<span style="font-size:12px;flex:1">${esc(p.name || p.id)}
|
||||
${p.model ? `<code style="font-size:10px;color:var(--text2);margin-left:6px">${esc(p.model)}</code>` : ''}</span>
|
||||
<span style="font-size:10px;color:${keyOk ? '#22c55e' : '#f59e0b'}">${keyOk ? '✅ 密钥已配' : '⚠️ 密钥未配'}</span>
|
||||
${p.key_ref ? `<code style="font-size:9px;color:var(--text2)">${esc(p.key_ref)}</code>` : ''}
|
||||
${isDefault ? '' : `<button onclick="window._pvSetDefault('${esc(c.id)}','${esc(p.id)}')"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:2px 8px;border-radius:4px;cursor:pointer;font-size:10px">设为默认</button>`}
|
||||
</div>`;
|
||||
}).join('') || '<div style="font-size:11px;color:var(--text2);padding:4px">(暂无供应商)</div>';
|
||||
|
||||
const statusColor = { ready: '#22c55e', partial: '#f59e0b', planned: '#94a3b8' }[c.status] || '#94a3b8';
|
||||
const statusIcon = { ready: '✅', partial: '🟡', planned: '⚪' }[c.status] || '';
|
||||
|
||||
return `<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:6px">
|
||||
<div style="font-size:13px;font-weight:600">${esc(c.name)}
|
||||
<code style="font-size:10px;color:var(--text2);margin-left:6px">${esc(c.id)}</code></div>
|
||||
<span style="font-size:10px;color:${statusColor}">${statusIcon} ${esc(c.status)}</span>
|
||||
</div>
|
||||
${c.description ? `<div style="font-size:11px;color:var(--text2);margin-top:3px">${esc(c.description)}</div>` : ''}
|
||||
<div style="margin-top:6px">${rows}</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window._pvSetDefault = async (cid, pid) => {
|
||||
try {
|
||||
const r = await fetch(`${API}/capabilities/${encodeURIComponent(cid)}/default`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ provider: pid }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '设置失败')); return; }
|
||||
await window._pvLoad();
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* ave-real-locations.js — 🏙️ 真实场地
|
||||
* 规范:DCS 说明书 v2.0 第五节(实拍场地:多角度 + 实拍视频,供生成时对齐真实空间)
|
||||
*/
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'real_locations', title: '真实场地', icon: '🏙️', uid: 'rloc',
|
||||
refKeys: [['wide', '全景'], ['detail', '细节'], ['light', '光线']],
|
||||
editFields: [
|
||||
{ key: 'address', label: '地址 / 位置描述', type: 'text' },
|
||||
{ key: 'shooting_notes', label: '实拍要点(机位/时间/限制)', type: 'textarea' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '实拍场地参考:多角度图(全景/细节/光线)+ 实拍视频,供生成时对齐真实空间',
|
||||
referenceDoc: 'DCS 说明书 v2.0 第五节',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// views/ave-render.js
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:40px;text-align:center;color:var(--text2)">
|
||||
<div style="font-size:32px;margin-bottom:12px">🎬</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:8px">渲染任务</div>
|
||||
<div style="font-size:12px;opacity:.7">功能开发中,敬请期待</div>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// views/ave-script.js
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:40px;text-align:center;color:var(--text2)">
|
||||
<div style="font-size:32px;margin-bottom:12px">📝</div>
|
||||
<div style="font-size:16px;font-weight:600;margin-bottom:8px">脚本生成</div>
|
||||
<div style="font-size:12px;opacity:.7">功能开发中,敬请期待</div>
|
||||
</div>`;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user