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:
@@ -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>`;
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* ave-settings.js — ⚙️ 视频工厂设置(全局默认)
|
||||
*
|
||||
* 三组:🎨 去 AI 化(模糊/颗粒/饱和/色温/CRF)· 📐 输出规格(分辨率/帧率/码率)· ⚡ 渲染(并发/模型/时长)
|
||||
* 存储:agent-local/tools/ave/config/settings.yaml(不进 git,本机生效)
|
||||
*/
|
||||
const API = '/api/ave';
|
||||
let _s = null;
|
||||
let _path = '';
|
||||
|
||||
const FIELDS = {
|
||||
de_ai: [
|
||||
{ key: 'enabled', label: '启用去 AI 化', type: 'bool', hint: '关闭则生成片段直接拼接' },
|
||||
{ key: 'blur_sigma', label: '模糊 sigma', type: 'number', min: 0, max: 3, step: 0.1, hint: '轻微模糊降低"锐利AI感"(默认 0.4)' },
|
||||
{ key: 'grain', label: '颗粒强度', type: 'number', min: 0, max: 40, step: 1, hint: '模拟手机拍摄噪点(默认 12)' },
|
||||
{ key: 'saturation', label: '饱和度偏移', type: 'number', min: -1, max: 1, step: 0.01, hint: '略降更自然(默认 -0.12)' },
|
||||
{ key: 'temperature', label: '色温 (K)', type: 'number', min: 3000, max: 9000, step: 100, hint: '偏暖显真实(默认 5300)' },
|
||||
{ key: 'crf', label: '输出质量 CRF', type: 'number', min: 15, max: 30, step: 1, hint: '越大文件越小、画质略降(默认 23)' },
|
||||
],
|
||||
output: [
|
||||
{ key: 'resolution', label: '分辨率', type: 'select', options: ['1080x1920', '720x1280', '1080x1080', '1920x1080'] },
|
||||
{ key: 'fps', label: '帧率', type: 'select', options: ['24', '25', '30', '60'] },
|
||||
{ key: 'bitrate', label: '码率', type: 'select', options: ['4M', '6M', '8M', '12M'] },
|
||||
],
|
||||
render: [
|
||||
{ key: 'max_workers', label: '并发出片数', type: 'number', min: 1, max: 5, step: 1, hint: '越大越快,但可能触发供应商限流(默认 3)' },
|
||||
{ key: 'kling_model', label: '可灵默认模型', type: 'select', options: ['kling-v3', 'kling-v2-6', 'kling-v1-6'] },
|
||||
{ key: 'kling_duration', label: '单镜头时长(秒)', type: 'select', options: ['5', '10'] },
|
||||
],
|
||||
cost: [
|
||||
{ key: 'kling_price_per_sec', label: '可灵单价(元/秒)', type: 'number', min: 0, max: 100, step: 0.1, hint: '用于运行前的费用预估(默认 0.6 = Kling 3.0 无声 720P);实际以供应商账单为准' },
|
||||
{ key: 'max_shots_per_run', label: '单次运行镜头上限', type: 'number', min: 1, max: 50, step: 1, hint: '费用硬保护(默认 5)—— 超过会被后端拒绝' },
|
||||
],
|
||||
};
|
||||
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px;max-width:820px">
|
||||
<h2 style="font-size:18px;margin:0 0 12px">⚙️ 视频工厂设置
|
||||
<span style="font-size:12px;color:var(--text2)">全局默认(流程/节点未特别指定时生效)</span></h2>
|
||||
<div id="asBody"><div style="color:var(--text2);font-size:12px">⏳ 加载中…</div></div>
|
||||
</div>`;
|
||||
await load();
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const r = await fetch(`${API}/settings`);
|
||||
const d = await r.json();
|
||||
_s = d.settings || {};
|
||||
_path = d.path || '';
|
||||
render();
|
||||
} catch (e) {
|
||||
document.getElementById('asBody').innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function group(title, icon, key, note) {
|
||||
const fields = FIELDS[key].map(f => {
|
||||
const v = (_s[key] || {})[f.key];
|
||||
const label = `<div style="font-size:11px;color:var(--text2)">${esc(f.label)}${f.hint ? `<br><span style="font-size:10px;opacity:.75">${esc(f.hint)}</span>` : ''}</div>`;
|
||||
let input = '';
|
||||
if (f.type === 'bool') {
|
||||
input = `<input type="checkbox" data-g="${key}" data-k="${f.key}" ${v ? 'checked' : ''} style="width:16px;height:16px">`;
|
||||
} else if (f.type === 'select') {
|
||||
input = `<select data-g="${key}" data-k="${f.key}" style="width:100%;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
${f.options.map(o => `<option value="${esc(o)}" ${String(v) === String(o) ? 'selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
|
||||
} else {
|
||||
input = `<input type="number" data-g="${key}" data-k="${f.key}" value="${esc(v)}" step="${f.step || 1}" ${f.min !== undefined ? `min="${f.min}"` : ''} ${f.max !== undefined ? `max="${f.max}"` : ''}
|
||||
style="width:100%;padding:5px 8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">`;
|
||||
}
|
||||
return `<label style="display:grid;grid-template-columns:1fr 160px;align-items:center;gap:10px;padding:6px 0;border-bottom:1px solid var(--border)">
|
||||
${label}${input}</label>`;
|
||||
}).join('');
|
||||
|
||||
return `<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px 14px;margin-bottom:12px">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:4px">${icon} ${esc(title)}</div>
|
||||
${note ? `<div style="font-size:11px;color:var(--text2);margin-bottom:6px">${esc(note)}</div>` : ''}
|
||||
${fields}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function render() {
|
||||
document.getElementById('asBody').innerHTML = `
|
||||
${group('去 AI 化', '🎨', 'de_ai', '让生成的片段更像手机实拍(模糊 + 颗粒 + 降饱和 + 暖色温)')}
|
||||
${group('输出规格', '📐', 'output', '成片的分辨率 / 帧率 / 码率')}
|
||||
${group('渲染', '⚡', 'render', '并发出片数 · 可灵默认模型与单镜头时长')}
|
||||
${group('费用', '💰', 'cost', '运行前费用预估用的单价与镜头上限(只影响提示,不改实际计费)')}
|
||||
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button onclick="window._asSave()" style="background:var(--primary);color:#fff;border:none;padding:7px 18px;border-radius:6px;cursor:pointer;font-size:12px">💾 保存设置</button>
|
||||
<button onclick="window._asReset()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:7px 14px;border-radius:6px;cursor:pointer;font-size:12px">↩️ 恢复默认值</button>
|
||||
<span id="asMsg" style="font-size:11px;color:var(--text2)"></span>
|
||||
</div>
|
||||
<div style="margin-top:10px;font-size:10px;color:var(--text2)">
|
||||
配置文件:<code>${esc(_path)}</code>(本机生效,不进 git)
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function collect() {
|
||||
const out = {};
|
||||
document.querySelectorAll('[data-g]').forEach(el => {
|
||||
const g = el.dataset.g, k = el.dataset.k;
|
||||
out[g] = out[g] || {};
|
||||
if (el.type === 'checkbox') out[g][k] = el.checked;
|
||||
else if (el.type === 'number') out[g][k] = Number(el.value);
|
||||
else out[g][k] = el.value;
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
window._asSave = async () => {
|
||||
const msg = document.getElementById('asMsg');
|
||||
try {
|
||||
const r = await fetch(`${API}/settings`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: collect() }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { if (msg) msg.textContent = '❌ ' + (d.detail || '保存失败'); return; }
|
||||
if (msg) { msg.style.color = '#22c55e'; msg.textContent = '✅ 已保存(新任务生效)'; }
|
||||
} catch (e) { if (msg) { msg.style.color = '#f59e0b'; msg.textContent = '❌ ' + e.message; } }
|
||||
};
|
||||
|
||||
window._asReset = async () => {
|
||||
if (!confirm('恢复为系统默认值?(不会删除文件,仅覆盖为默认)')) return;
|
||||
const def = {
|
||||
de_ai: { enabled: true, blur_sigma: 0.4, grain: 12, saturation: -0.12, temperature: 5300, crf: 23 },
|
||||
output: { resolution: '1080x1920', fps: '30', bitrate: '8M' },
|
||||
render: { max_workers: 3, kling_model: 'kling-v3', kling_duration: '5' },
|
||||
};
|
||||
try {
|
||||
await fetch(`${API}/settings`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ settings: def }),
|
||||
});
|
||||
await load();
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
/**
|
||||
* ave-sfx.js — 💥 音效库
|
||||
* 规范:音效素材:标签 · 时长;可在分镜的 sfx 字段按秒插入
|
||||
*/
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const CATS = { transition: '转场音', ambient: '环境音', impact: '打击/强调', ui: '界面音', funny: '搞怪音' };
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'sfx', title: '音效库', icon: '💥', uid: 'sfx',
|
||||
audio: true, refKeys: [],
|
||||
filters: [{ key: 'category', label: '类别', options: CATS }],
|
||||
editFields: [
|
||||
{ key: 'category', label: '类别', type: 'select', options: CATS },
|
||||
{ key: 'duration', label: '时长(秒)', type: 'text' },
|
||||
{ key: 'insert_at', label: '建议插入位置(如 片头/转场/第3秒)', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '音效素材:标签 · 时长;可在分镜的 sfx 字段按秒插入',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,510 @@
|
||||
/**
|
||||
* views/ave-studio.js — 🎬 导演工作室(单页标签式全流程)
|
||||
*
|
||||
* 设计依据(业界实读):
|
||||
* - Liang80mrer/AI-Animation-Studio:11 步流程 + Modular Skill(标准接口传结构化数据)
|
||||
* + Cross-Skill Inheritance(下游继承上游约束)
|
||||
* - yfge/ai-video-studio:单一真相源,「把 AI 产出变成可追溯的生产资产」
|
||||
*
|
||||
* 本页 = 一条流水线,7 个标签:
|
||||
* 💡创意 → 🌍设定 → 📝剧本 → 🎬分镜 → 🖼关键帧 → 🎞视频 → 🎙后期
|
||||
* 右侧常驻「📦 资源」面板;数据全部走 modules/project-store.js(服务端单一真相源)
|
||||
*/
|
||||
import * as store from '../modules/project-store.js';
|
||||
|
||||
const TABS = [
|
||||
{ id: 'creative', label: '💡 创意', hint: '参数选型 → 候选 → 选中',
|
||||
mod: () => import('./creative-studio.js') },
|
||||
{ id: 'setting', label: '🌍 设定', hint: '世界观 / 人物关系 / 基调',
|
||||
mod: () => import('./studio-setting.js') },
|
||||
{ id: 'creative_card', label: '📋 创意卡', hint: '创意 + 设定 → 想清楚的故事卡(剧本的依据)',
|
||||
mod: () => import('./studio-creative-card.js') },
|
||||
{ id: 'script', label: '📝 剧本', hint: '按段落写:剧情/动作/表情/情绪/画面重点',
|
||||
mod: () => import('./studio-script.js') },
|
||||
{ id: 'shots', label: '🎬 分镜', hint: '导演风格 + 演员表 → 分镜表',
|
||||
mod: () => import('./ave-director.js') },
|
||||
{ id: 'kf_plan', label: '🖼 关键帧设计', hint: '把镜头拆成 2-5 张连续关键帧(每张单独提示词)',
|
||||
mod: () => import('./studio-keyframe-plan.js') },
|
||||
{ id: 'keyframes', label: '🖼 关键帧', hint: '逐镜生成 / 审图 / 采用',
|
||||
mod: () => import('./ave-keyframes.js') },
|
||||
{ id: 'videos', label: '🎞 视频', hint: '关键帧 → 视频片段',
|
||||
mod: () => import('./ave-keyframe-video.js') },
|
||||
{ id: 'post', label: '🎙 后期', hint: '去AI化 / 字幕 / 混音 / 成片',
|
||||
mod: () => import('./studio-post.js') },
|
||||
];
|
||||
|
||||
// 右侧常驻资源栏:点分类**在本面板内展开**(不跳转,避免工作室状态丢失)
|
||||
// 需要完整管理时用「↗ 新标签页」—— 新标签页不影响当前页
|
||||
const ASSETS = [
|
||||
{ kind: 'characters', label: '🧑 角色', view: 'characters' },
|
||||
{ kind: 'locations', label: '🏞️ 场景', view: 'ave-locations' },
|
||||
{ kind: 'real_locations', label: '🏙️ 真实场地', view: 'ave-real-locations' },
|
||||
{ kind: 'props', label: '🧰 道具', view: 'ave-props' },
|
||||
{ kind: 'products', label: '📦 产品', view: 'ave-products' },
|
||||
{ kind: 'wardrobe', label: '👗 服装', view: 'ave-wardrobe' },
|
||||
{ kind: 'voices', label: '🔊 音色', view: 'ave-voices' },
|
||||
{ kind: 'bgm', label: '🎵 BGM', view: 'ave-bgm' },
|
||||
{ kind: 'sfx', label: '💥 音效', view: 'ave-sfx' },
|
||||
{ kind: 'ambience', label: '🌆 环境音', view: 'ave-ambience' },
|
||||
];
|
||||
|
||||
let _pid = '';
|
||||
let _curTab = null;
|
||||
let _curMod = null;
|
||||
let _unsub = null;
|
||||
let _busy = false;
|
||||
|
||||
// ── 项目 ────────────────────────────────────────────────────
|
||||
async function loadProjects(sel) {
|
||||
const el = document.getElementById('stuProject');
|
||||
if (!el) return;
|
||||
try {
|
||||
const list = await store.listProjects();
|
||||
if (!list.length) {
|
||||
el.innerHTML = '<option value="">(还没有项目)</option>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = list.map(p =>
|
||||
`<option value="${esc(p.project_id)}">${esc(p.title)} · ${esc(p.stage)}</option>`).join('');
|
||||
_pid = sel || el.value || list[0].project_id;
|
||||
el.value = _pid;
|
||||
await store.loadProject(_pid);
|
||||
renderHeader();
|
||||
} catch (e) {
|
||||
el.innerHTML = `<option value="">加载失败:${esc(e.message)}</option>`;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => (
|
||||
{ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
const p = store.getProject() || {};
|
||||
const el = document.getElementById('stuMeta');
|
||||
if (!el) return;
|
||||
const kf = Object.keys(p.keyframes || {}).length;
|
||||
const vd = Object.keys(p.videos || {}).length;
|
||||
const cast = (p.cast || []).length;
|
||||
el.innerHTML = `
|
||||
<span style="color:var(--text2);font-size:11px">
|
||||
当前阶段 <b style="color:var(--primary)">${esc(stageLabel(p.stage))}</b>
|
||||
· 演员 ${cast} · 关键帧 ${kf} · 视频 ${vd}
|
||||
${p._new ? ' · <span style="color:#f59e0b">尚未开始</span>' : ''}
|
||||
</span>`;
|
||||
}
|
||||
|
||||
function stageLabel(id) {
|
||||
const t = TABS.find(x => x.id === id);
|
||||
return t ? t.label : (id || '—');
|
||||
}
|
||||
|
||||
function renderTabs() {
|
||||
const bar = document.getElementById('stuTabs');
|
||||
if (!bar) return;
|
||||
bar.innerHTML = TABS.map(t => {
|
||||
const on = t.id === _curTab;
|
||||
return `<button onclick="window._stuTab('${t.id}')" title="${esc(t.hint)}"
|
||||
style="padding:6px 13px;border-radius:7px 7px 0 0;cursor:pointer;font-size:12.5px;
|
||||
white-space:nowrap;border:1px solid ${on ? 'var(--primary)' : 'var(--border)'};
|
||||
border-bottom:${on ? '1px solid var(--bg)' : '1px solid var(--border)'};
|
||||
background:${on ? 'var(--bg)' : 'var(--bg3)'};
|
||||
color:${on ? 'var(--text)' : 'var(--text2)'};
|
||||
font-weight:${on ? '600' : '400'};position:relative;top:1px">
|
||||
${t.label}</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
// ── 标签切换 ────────────────────────────────────────────────
|
||||
async function switchTab(id) {
|
||||
if (_busy) return;
|
||||
_busy = true;
|
||||
const body = document.getElementById('studioBody');
|
||||
try {
|
||||
// 卸载上一个
|
||||
if (_curMod && typeof _curMod.unloadView === 'function') {
|
||||
try { _curMod.unloadView(); } catch (e) { /* 忽略 */ }
|
||||
}
|
||||
_curTab = id;
|
||||
renderTabs();
|
||||
body.innerHTML = `<div style="padding:40px;text-align:center;color:var(--text2);font-size:12px">
|
||||
⏳ 载入${esc(stageLabel(id))}…</div>`;
|
||||
body.dataset.embedded = '1';
|
||||
// 通知服务端当前阶段(可追溯)
|
||||
try { await store.setStage(id); renderHeader(); } catch (e) { /* 非致命 */ }
|
||||
const t = TABS.find(x => x.id === id);
|
||||
if (!t) return;
|
||||
_curMod = await t.mod();
|
||||
await _curMod.loadView(body);
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div style="padding:30px;color:#ef4444;font-size:12px">
|
||||
❌ 载入失败:${esc(e.message)}</div>`;
|
||||
} finally {
|
||||
_busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 右侧资源栏(常驻 · 面板内展开,不跳转)────────────────────
|
||||
let _curAssetKind = null;
|
||||
|
||||
function renderAssetMenu() {
|
||||
const cell = 'padding:4px 6px;background:var(--bg2);color:var(--text);' +
|
||||
'border:1px solid var(--border);border-radius:4px;cursor:pointer;font-size:11px;' +
|
||||
'text-align:left;overflow:hidden;text-overflow:ellipsis;white-space:nowrap';
|
||||
return `
|
||||
<button onclick="window._stuArtifacts()"
|
||||
style="display:block;width:100%;text-align:left;margin-bottom:7px;padding:6px 8px;
|
||||
background:rgba(139,92,246,.15);color:var(--text);font-weight:600;
|
||||
border:1px solid #8b5cf6;border-radius:5px;cursor:pointer;font-size:11.5px">
|
||||
📤 本项目产物</button>
|
||||
<div style="font-size:10px;color:var(--text2);margin-bottom:5px;line-height:1.5">
|
||||
点分类在此展开(不跳页)
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:3px">
|
||||
${ASSETS.map(a => `
|
||||
<button onclick="window._stuAsset('${a.kind}')" title="${esc(a.label)}"
|
||||
style="${cell}">${a.label}</button>`).join('')}
|
||||
</div>
|
||||
<button onclick="window._stuOpenFull()"
|
||||
style="display:block;width:100%;text-align:left;margin-top:7px;padding:5px 7px;
|
||||
background:none;color:var(--primary);border:1px dashed var(--border);
|
||||
border-radius:4px;cursor:pointer;font-size:10.5px">↗ 新标签页打开完整资源库</button>`;
|
||||
}
|
||||
|
||||
/** 🖼 产物弹窗预览 —— 图片 / 音频 / 视频直接在弹窗里看,关掉就回到工作室
|
||||
*
|
||||
* 为什么不做新窗口打开:新标签页会跳出工作室(用户已填的内容在视觉上「丢失」),
|
||||
* 而且要手动切回来。弹窗原地预览、关闭即回,不打断流程。
|
||||
*/
|
||||
window._stuPreview = (url, name) => {
|
||||
const ext = String(name || '').split('.').pop().toLowerCase();
|
||||
const IMG = ['png', 'jpg', 'jpeg', 'webp', 'gif', 'bmp', 'svg'];
|
||||
const VID = ['mp4', 'webm', 'mov', 'm4v', 'mkv'];
|
||||
const AUD = ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'flac'];
|
||||
|
||||
let inner, kind;
|
||||
if (IMG.includes(ext)) {
|
||||
kind = '图片';
|
||||
inner = `<img src="${url}" style="max-width:92vw;max-height:80vh;object-fit:contain;
|
||||
border-radius:8px;background:#0d0d10;display:block">`;
|
||||
} else if (VID.includes(ext)) {
|
||||
kind = '视频';
|
||||
inner = `<video src="${url}" controls autoplay playsinline
|
||||
style="max-width:92vw;max-height:80vh;border-radius:8px;background:#000;display:block"></video>`;
|
||||
} else if (AUD.includes(ext)) {
|
||||
kind = '音频';
|
||||
inner = `<div style="text-align:center;padding:22px 30px">
|
||||
<div style="font-size:34px;margin-bottom:12px">🎵</div>
|
||||
<audio src="${url}" controls autoplay style="width:min(520px,84vw);display:block"></audio>
|
||||
</div>`;
|
||||
} else {
|
||||
kind = '未知类型';
|
||||
inner = `<div style="padding:26px;text-align:center;color:var(--text2);font-size:12px">
|
||||
这个类型(.${esc(ext)})不能直接预览<br>
|
||||
<a href="${url}" target="_blank" style="color:var(--primary)">新窗口打开</a></div>`;
|
||||
}
|
||||
|
||||
let box = document.getElementById('_stuPreviewBox');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = '_stuPreviewBox';
|
||||
box.style.cssText = 'position:fixed;inset:0;z-index:3000;display:none;' +
|
||||
'align-items:center;justify-content:center;background:rgba(0,0,0,.9);cursor:zoom-out';
|
||||
box.innerHTML = `
|
||||
<div id="_stuPreviewInner" onclick="event.stopPropagation()"
|
||||
style="cursor:default;max-width:94vw;max-height:88vh;display:flex;
|
||||
flex-direction:column;align-items:center;gap:9px"></div>`;
|
||||
box.onclick = () => window._stuPreviewClose();
|
||||
document.body.appendChild(box);
|
||||
// Esc 关闭
|
||||
document.addEventListener('keydown', (e) => {
|
||||
if (e.key === 'Escape') window._stuPreviewClose();
|
||||
});
|
||||
}
|
||||
document.getElementById('_stuPreviewInner').innerHTML = `
|
||||
<div style="display:flex;align-items:center;gap:10px;max-width:92vw">
|
||||
<span style="color:#eee;font-size:12.5px;overflow:hidden;text-overflow:ellipsis;
|
||||
white-space:nowrap">${kind} · ${esc(name)}</span>
|
||||
<button onclick="window._stuPreviewClose()"
|
||||
style="background:#2a2a30;color:#eee;border:1px solid #444;border-radius:5px;
|
||||
padding:3px 12px;cursor:pointer;font-size:12px;flex:0 0 auto">✕ 关闭 (Esc)</button>
|
||||
<a href="${url}" target="_blank"
|
||||
style="color:#8ab4f8;font-size:11px;flex:0 0 auto;white-space:nowrap">↗ 新窗口</a>
|
||||
</div>
|
||||
${inner}`;
|
||||
box.style.display = 'flex';
|
||||
};
|
||||
|
||||
window._stuPreviewClose = () => {
|
||||
const box = document.getElementById('_stuPreviewBox');
|
||||
if (!box) return;
|
||||
// 停掉播放,避免关掉还在响
|
||||
box.querySelectorAll('video, audio').forEach(el => { try { el.pause(); } catch (e) {} });
|
||||
box.style.display = 'none';
|
||||
const inner = document.getElementById('_stuPreviewInner');
|
||||
if (inner) inner.innerHTML = '';
|
||||
};
|
||||
|
||||
/** 📤 本项目产物(脚本/关键帧/视频/音频/成片,按类型归类) */
|
||||
async function loadProjectArtifacts() {
|
||||
const body = document.getElementById('stuAssetBody');
|
||||
const back = document.getElementById('stuAssetBack');
|
||||
if (!body) return;
|
||||
if (back) back.style.display = 'inline-block';
|
||||
body.innerHTML = '<div style="font-size:11px;color:var(--text2)">⏳ 载入产物…</div>';
|
||||
try {
|
||||
const r = await fetch(`/api/ave/project/${encodeURIComponent(_pid)}/artifacts`);
|
||||
const d = await r.json();
|
||||
const groups = d.groups || [];
|
||||
if (!groups.length) {
|
||||
body.innerHTML = `<div style="font-size:11px;color:var(--text2);line-height:1.8">
|
||||
这个项目还没有产物。<br>按流程走一遍(剧本 → 关键帧 → 视频 → 导出)就会出现。</div>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = `
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:5px">
|
||||
共 ${d.totals?.items || 0} 项 · ${groups.length} 类</div>
|
||||
${groups.map(g => `
|
||||
<div style="margin-bottom:9px">
|
||||
<div style="font-size:11px;font-weight:600;margin-bottom:3px">
|
||||
${esc(g.label)} <span style="color:var(--text2);font-weight:400">
|
||||
(${g.items.length})</span></div>
|
||||
${g.items.map(it => `
|
||||
<div style="display:flex;align-items:center;gap:5px;padding:3px 5px;
|
||||
border-bottom:1px solid var(--border);font-size:10px">
|
||||
${it.url ? `<a href="javascript:void(0)"
|
||||
onclick="window._stuPreview('${esc(it.url)}','${esc(it.name)}')"
|
||||
style="color:var(--primary);text-decoration:none;cursor:zoom-in;
|
||||
overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1"
|
||||
title="点击预览:${esc(it.name)}">🔍 ${esc(it.name)}</a>`
|
||||
: `<span style="flex:1;overflow:hidden;text-overflow:ellipsis;
|
||||
white-space:nowrap" title="${esc(it.name)}">${esc(it.name)}</span>`}
|
||||
${it.meta ? `<span style="color:var(--text2);flex:0 0 auto">${esc(it.meta)}</span>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>`).join('')}`;
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div style="font-size:11px;color:#ef4444">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadAssetList(kind) {
|
||||
const body = document.getElementById('stuAssetBody');
|
||||
const back = document.getElementById('stuAssetBack');
|
||||
if (!body) return;
|
||||
const a = ASSETS.find(x => x.kind === kind) || { label: kind };
|
||||
if (back) back.style.display = 'inline-block';
|
||||
body.innerHTML = `<div style="font-size:11px;color:var(--text2)">⏳ 载入${esc(a.label)}…</div>`;
|
||||
try {
|
||||
const r = await fetch(`/api/ave/assets/${encodeURIComponent(kind)}`);
|
||||
const d = await r.json();
|
||||
const items = d.items || d.assets || d.data || [];
|
||||
if (!items.length) {
|
||||
body.innerHTML = `
|
||||
<div style="font-size:11px;color:var(--text2);line-height:1.8">
|
||||
这一分类还没有内容。<br>
|
||||
点下面「↗ 新标签页」去创建,建好后回本页刷新即可。</div>
|
||||
<button onclick="window._stuOpenFull('${esc(a.view || '')}')"
|
||||
style="margin-top:7px;width:100%;padding:5px;background:var(--bg2);color:var(--primary);
|
||||
border:1px dashed var(--border);border-radius:4px;cursor:pointer;font-size:11px">
|
||||
↗ 去创建</button>`;
|
||||
return;
|
||||
}
|
||||
body.innerHTML = `
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:5px">
|
||||
${esc(a.label)} · 共 ${items.length} 个</div>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:5px">
|
||||
${items.map(it => {
|
||||
const refs = it.reference_images || {};
|
||||
const first = Object.values(refs)[0] || it.thumb || '';
|
||||
const img = first
|
||||
? `<img src="/api/ave/assets/${encodeURIComponent(kind)}/${encodeURIComponent(it.id)}/file/${encodeURIComponent(first)}"
|
||||
loading="lazy" style="width:100%;height:58px;object-fit:cover;border-radius:3px;background:#111">`
|
||||
: `<div style="height:58px;display:flex;align-items:center;justify-content:center;
|
||||
background:var(--bg2);border-radius:3px;font-size:16px">📄</div>`;
|
||||
return `<div onclick="window._stuAssetPick('${esc(kind)}','${esc(it.id)}','${esc(it.name || it.id)}')"
|
||||
title="${esc(it.name || it.id)}"
|
||||
style="border:1px solid var(--border);border-radius:5px;padding:3px;cursor:pointer;
|
||||
background:var(--bg2)">
|
||||
${img}
|
||||
<div style="font-size:9.5px;margin-top:2px;overflow:hidden;text-overflow:ellipsis;
|
||||
white-space:nowrap">${esc(it.name || it.id)}</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
body.innerHTML = `<div style="font-size:11px;color:#ef4444">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function backToAssetMenu() {
|
||||
_curAssetKind = null;
|
||||
const body = document.getElementById('stuAssetBody');
|
||||
const back = document.getElementById('stuAssetBack');
|
||||
if (body) body.innerHTML = renderAssetMenu();
|
||||
if (back) back.style.display = 'none';
|
||||
}
|
||||
|
||||
function toggleAssets() {
|
||||
const p = document.getElementById('stuAssets');
|
||||
if (!p) return;
|
||||
const show = p.style.display === 'none';
|
||||
p.style.display = show ? 'block' : 'none';
|
||||
}
|
||||
|
||||
// ── 入口 ────────────────────────────────────────────────────
|
||||
export async function loadView(container) {
|
||||
_curTab = null; _curMod = null; _pid = '';
|
||||
container.innerHTML = `
|
||||
<style>
|
||||
.stu-tabbar{display:flex;gap:4px;overflow-x:auto;border-bottom:1px solid var(--border);
|
||||
padding-bottom:0;margin-bottom:0}
|
||||
.stu-body{background:var(--bg);border:1px solid var(--border);border-top:none;
|
||||
border-radius:0 0 8px 8px;padding:10px;min-height:60vh;
|
||||
max-height:calc(100vh - 250px);overflow-y:auto}
|
||||
</style>
|
||||
<div style="max-width:1680px">
|
||||
<!-- 顶部:项目 + 阶段状态 -->
|
||||
<div style="display:flex;align-items:center;gap:11px;margin-bottom:9px;flex-wrap:wrap">
|
||||
<div style="font-size:15px;font-weight:700">🎬 导演工作室</div>
|
||||
<div id="stuMeta"></div>
|
||||
<div style="margin-left:auto;display:flex;gap:7px;align-items:center">
|
||||
<button onclick="window._stuToggleAssets()"
|
||||
style="padding:5px 11px;border-radius:5px;cursor:pointer;font-size:11.5px;
|
||||
background:var(--bg3);color:var(--text);border:1px solid var(--border)">
|
||||
📦 资源面板</button>
|
||||
<span style="font-size:10.5px;color:var(--text2);margin-right:2px"
|
||||
title="这里切换项目,所有标签都会跟着切">📁 项目</span>
|
||||
<select id="stuProject" onchange="window._stuProject(this.value)"
|
||||
title="切换项目 —— 所有标签(分镜/关键帧/视频/后期)都会跟着换成这个项目的数据"
|
||||
style="padding:5px 9px;background:var(--bg2);border:1px solid var(--primary);
|
||||
color:var(--text);border-radius:5px;font-size:12px;max-width:280px"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr auto;gap:11px;align-items:start">
|
||||
<div style="min-width:0">
|
||||
<!-- 7 个阶段标签 -->
|
||||
<div id="stuTabs" class="stu-tabbar"></div>
|
||||
<!-- 当前阶段内容 -->
|
||||
<div id="studioBody" class="stu-body"></div>
|
||||
</div>
|
||||
<!-- 右侧常驻资源栏(可收起;点分类在本面板内展开,不跳转) -->
|
||||
<div id="stuAssets" style="display:block;width:186px;background:var(--bg3);
|
||||
border:1px solid var(--border);border-radius:8px;padding:8px;
|
||||
max-height:calc(100vh - 210px);overflow-y:auto">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:7px">
|
||||
<div style="font-size:11.5px;font-weight:600">📦 资源库</div>
|
||||
<button onclick="window._stuAssetBack()" id="stuAssetBack"
|
||||
style="display:none;margin-left:auto;background:none;color:var(--primary);
|
||||
border:1px solid var(--border);border-radius:4px;padding:1px 7px;
|
||||
cursor:pointer;font-size:10.5px">← 返回</button>
|
||||
</div>
|
||||
<div id="stuAssetBody">${renderAssetMenu()}</div>
|
||||
<div style="border-top:1px dashed var(--border);margin:8px 0 6px"></div>
|
||||
<div style="font-size:10px;color:var(--text2);line-height:1.7">
|
||||
当前项目:<b>${esc(_pid || '—')}</b><br>
|
||||
数据存服务端,点资源不跳页、已填内容不丢
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-top:8px;line-height:1.7">
|
||||
ℹ️ 旧页面(菜单「流程」下)仍可独立使用,过渡期后移除。
|
||||
本页是整合后的主路径 —— 一条流水线走到底,不用来回跳页。
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
window._stuTab = (id) => switchTab(id);
|
||||
window._stuProject = async (v) => {
|
||||
_pid = v;
|
||||
await store.loadProject(_pid);
|
||||
renderHeader();
|
||||
if (_curTab) await switchTab(_curTab);
|
||||
};
|
||||
window._stuToggleAssets = toggleAssets;
|
||||
// 点分类 → 在本面板内展开(**不跳转**,工作室已填内容不丢)
|
||||
window._stuAsset = (kind) => { _curAssetKind = kind; loadAssetList(kind); };
|
||||
// 📤 本项目产物(脚本/关键帧/视频/音频/成片)
|
||||
window._stuArtifacts = () => { _curAssetKind = '__artifacts__'; loadProjectArtifacts(); };
|
||||
window._stuAssetBack = backToAssetMenu;
|
||||
// 新标签页打开完整资源库(不影响当前页)
|
||||
window._stuOpenFull = (view) => {
|
||||
const v = view || (_curAssetKind
|
||||
? (ASSETS.find(a => a.kind === _curAssetKind) || {}).view : '') || 'characters';
|
||||
window.open(`${location.pathname}#${v}`, '_blank');
|
||||
};
|
||||
// 点某个资产 → 资源库里打开详情(新标签页),或加入当前项目
|
||||
window._stuAssetPick = (kind, id, name) => {
|
||||
const a = ASSETS.find(x => x.kind === kind) || {};
|
||||
const go = confirm(`「${name}」\n\n`
|
||||
+ `点「确定」→ 在新标签页打开资源库管理它\n`
|
||||
+ `点「取消」→ 什么都不做`);
|
||||
if (go) window.open(`${location.pathname}#${a.view || 'characters'}`, '_blank');
|
||||
};
|
||||
|
||||
await loadProjects();
|
||||
// ★ 接收「创意库 → 带到导演工作室」的带入(写进项目对象,不再是临时传参)
|
||||
const gotCreative = await applyCreativeHandoff();
|
||||
// 打开时跳到服务端记录的当前阶段(若刚带入了创意,直接去创意标签)
|
||||
const cur = gotCreative ? 'creative' : ((store.getProject() || {}).stage || 'creative');
|
||||
await switchTab(cur);
|
||||
}
|
||||
|
||||
/** 把「创意库」点『带到导演工作室』送来的创意,写进 project.creative
|
||||
*
|
||||
* 原来这套 handoff 是给「老导演页」的一次性传参(读一次就删),
|
||||
* 现在已经统一到工作室,所以在这里消费 —— 并且落到项目对象里,
|
||||
* 这样剧本阶段(读 project.creative)才真的拿得到。
|
||||
*/
|
||||
async function applyCreativeHandoff() {
|
||||
let h = null;
|
||||
try {
|
||||
const raw = localStorage.getItem('ave_creative_handoff');
|
||||
if (raw) { h = JSON.parse(raw); localStorage.removeItem('ave_creative_handoff'); }
|
||||
} catch (e) { return false; }
|
||||
if (!h) return false;
|
||||
const age = Date.now() - new Date(h.at || 0).getTime();
|
||||
if (!(age >= 0 && age < 30 * 60 * 1000)) return false; // 仅 30 分钟内有效
|
||||
try {
|
||||
const prev = ((store.getProject() || {}).creative) || {};
|
||||
const picked = [...(prev.selected || [])];
|
||||
const one = h.one_liner || h.title || '';
|
||||
if (one && !picked.some(x => x && (x.cid === h.id
|
||||
|| (x.one_liner || x.one_line) === one))) {
|
||||
picked.unshift({ cid: h.id, one_liner: one, gap_score: h.gap_score,
|
||||
from_library: true, scene_card: h.scene_card });
|
||||
}
|
||||
await store.patchProject({
|
||||
creative: {
|
||||
selected: picked,
|
||||
brief: h.brief || prev.brief || '',
|
||||
count: picked.length,
|
||||
inject_mode: h.inject_mode || prev.inject_mode,
|
||||
from_library: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
if (window.showToast) {
|
||||
window.showToast(`✅ 已从创意库带入:<b>${(one || '').slice(0, 30)}</b>`);
|
||||
}
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export function unloadView() {
|
||||
if (_curMod && typeof _curMod.unloadView === 'function') {
|
||||
try { _curMod.unloadView(); } catch (e) { /* 忽略 */ }
|
||||
}
|
||||
if (_unsub) { _unsub(); _unsub = null; }
|
||||
_curMod = null; _curTab = null; _curAssetKind = null;
|
||||
// 清理挂在 window 上的回调,避免残留
|
||||
['_stuTab', '_stuProject', '_stuToggleAssets', '_stuAsset', '_stuAssetBack',
|
||||
'_stuOpenFull', '_stuAssetPick', '_stuArtifacts'].forEach(k => {
|
||||
try { delete window[k]; } catch (e) {}
|
||||
});
|
||||
// 关掉可能还开着的产物预览弹窗(它挂在 document.body 上,不会被 container 清掉)
|
||||
try { if (window._stuPreviewClose) window._stuPreviewClose(); } catch (e) {}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
// views/ave-templates.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,209 @@
|
||||
/**
|
||||
* ave-test-cap.js — 🧪 能力试跑
|
||||
*
|
||||
* 单独测一个能力(不跑整条流程):
|
||||
* 1. 选能力 → 自动带出「预检」(用哪个供应商/模型/参数/密钥是否就绪)
|
||||
* 2. 按 params_schema 填参数
|
||||
* 3. 🔍 预检(**零费用**,只校验配置)| ▶️ 真跑(**可能产生费用**,二次确认)
|
||||
*
|
||||
* 真跑当前支持:文本/图片 → 视频(可灵 kling-v3);其余能力给出"实现未接入 + 位置"
|
||||
*/
|
||||
const API = '/api/ave';
|
||||
|
||||
let _caps = [];
|
||||
let _cats = [];
|
||||
let _cur = null;
|
||||
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<h2 style="font-size:18px;margin:0 0 12px">🧪 能力试跑
|
||||
<span style="font-size:12px;color:var(--text2)">单独测一个能力,不用跑整条流程</span></h2>
|
||||
|
||||
<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>)—— <b>预检零费用</b>;<br>
|
||||
确认无误后再点「▶️ 真跑」实际调用(<b>可能产生费用</b>,会二次确认)。
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:12px">
|
||||
<select id="tcCat" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></select>
|
||||
<select id="tcCap" style="flex:1;min-width:240px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px"></select>
|
||||
</div>
|
||||
|
||||
<div id="tcBody"><div style="color:var(--text2);font-size:12px">请选择能力</div></div>
|
||||
</div>`;
|
||||
|
||||
await loadCaps();
|
||||
document.getElementById('tcCat').addEventListener('change', () => fillCapSelect());
|
||||
document.getElementById('tcCap').addEventListener('change', () => selectCap());
|
||||
}
|
||||
|
||||
async function loadCaps() {
|
||||
try {
|
||||
const r = await fetch(`${API}/capabilities`);
|
||||
const d = await r.json();
|
||||
_caps = d.capabilities || [];
|
||||
_cats = d.categories || [];
|
||||
const c = document.getElementById('tcCat');
|
||||
c.innerHTML = `<option value="">全部类别</option>` +
|
||||
_cats.map(x => `<option value="${esc(x.id)}">${x.icon || ''} ${esc(x.name)}</option>`).join('');
|
||||
fillCapSelect();
|
||||
} catch (e) {
|
||||
document.getElementById('tcBody').innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 加载能力失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function fillCapSelect() {
|
||||
const cat = document.getElementById('tcCat').value;
|
||||
const list = cat ? _caps.filter(c => c.category === cat) : _caps;
|
||||
const s = document.getElementById('tcCap');
|
||||
const keep = s.value;
|
||||
s.innerHTML = list.map(c => `<option value="${esc(c.id)}">${statusIcon(c.status)} ${esc(c.name)}(${esc(c.id)})</option>`).join('');
|
||||
if (list.some(c => c.id === keep)) s.value = keep;
|
||||
selectCap();
|
||||
}
|
||||
|
||||
function statusIcon(st) { return { ready: '✅', partial: '🟡', planned: '⚪' }[st] || '⚪'; }
|
||||
|
||||
function selectCap() {
|
||||
const cid = document.getElementById('tcCap').value;
|
||||
_cur = _caps.find(c => c.id === cid) || null;
|
||||
renderForm();
|
||||
}
|
||||
|
||||
function renderForm() {
|
||||
const el = document.getElementById('tcBody');
|
||||
if (!_cur) { el.innerHTML = ''; return; }
|
||||
const c = _cur;
|
||||
const defaults = c.defaults || {};
|
||||
const provs = c.providers || [];
|
||||
const provId = defaults.provider || (provs[0] || {}).id || '';
|
||||
const prov = provs.find(p => p.id === provId) || {};
|
||||
|
||||
// 参数表单(按 params_schema)
|
||||
const schema = c.params_schema || {};
|
||||
const fields = Object.entries(schema).map(([k, s]) => {
|
||||
const v = defaults[k] ?? s.default ?? '';
|
||||
const label = `<span style="font-size:11px;color:var(--text2)">${esc(s.label || k)}${s.required ? ' *' : ''}</span>`;
|
||||
if (s.type === 'enum') {
|
||||
return `<label style="display:block">${label}
|
||||
<select data-p="${esc(k)}" 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">
|
||||
${(s.options || []).map(o => `<option value="${esc(o)}" ${String(v) === String(o) ? 'selected' : ''}>${esc(o)}</option>`).join('')}
|
||||
</select></label>`;
|
||||
}
|
||||
if (s.type === 'number') {
|
||||
return `<label style="display:block">${label}
|
||||
<input type="number" data-p="${esc(k)}" value="${esc(v)}" ${s.min !== undefined ? `min="${s.min}"` : ''} ${s.max !== undefined ? `max="${s.max}"` : ''}
|
||||
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>`;
|
||||
}
|
||||
return `<label style="display:block">${label}
|
||||
<input data-p="${esc(k)}" 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('');
|
||||
|
||||
// 常用输入(prompt / text 等,schema 里没有也给一个)
|
||||
const extra = (!schema.prompt && !schema.text) ? `
|
||||
<label style="display:block"><span style="font-size:11px;color:var(--text2)">提示词 / 文本(prompt)</span>
|
||||
<textarea id="tcPrompt" rows="3" 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;resize:vertical"></textarea></label>` : '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;gap:8px;flex-wrap:wrap">
|
||||
<div>
|
||||
<div style="font-size:14px;font-weight:600">${esc(c.name)}
|
||||
<code style="font-size:10px;color:var(--text2)">${esc(c.id)}</code></div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:3px">${esc(c.description || '')}</div>
|
||||
</div>
|
||||
<span style="font-size:10px;color:${c.status === 'ready' ? '#22c55e' : '#f59e0b'}">${statusIcon(c.status)} ${esc(c.status)}</span>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-top:10px">
|
||||
<label style="display:block"><span style="font-size:11px;color:var(--text2)">供应商</span>
|
||||
<select id="tcProv" style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
${provs.map(p => `<option value="${esc(p.id)}" ${p.id === provId ? 'selected' : ''}>${esc(p.name)}${p.key_configured ? ' ✅' : ' ⚠️未配密钥'}</option>`).join('') || '<option value="">(无)</option>'}
|
||||
</select></label>
|
||||
<label style="display:block"><span style="font-size:11px;color:var(--text2)">模型(由供应商决定)</span>
|
||||
<input id="tcModel" value="${esc(prov.model || '')}" readonly style="width:100%;margin-top:3px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text2);border-radius:6px;font-size:12px"></label>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:10px;display:grid;gap:8px">
|
||||
${extra}
|
||||
${fields || '<div style="font-size:11px;color:var(--text2)">(该能力无可调参数)</div>'}
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;margin-top:12px;flex-wrap:wrap">
|
||||
<button onclick="window._tcRun(true)" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">🔍 预检(零费用)</button>
|
||||
<button onclick="window._tcRun(false)" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">▶️ 真跑(可能产生费用)</button>
|
||||
<span style="font-size:10px;color:var(--text2);align-self:center">
|
||||
密钥位置:<code>${esc(prov.key_ref || '—')}</code> · 实现:<code>${esc((c.impl || '').slice(0, 40))}</code>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div id="tcResult" style="margin-top:12px"></div>
|
||||
</div>`;
|
||||
|
||||
// 供应商切换 → 更新模型显示
|
||||
document.getElementById('tcProv')?.addEventListener('change', (e) => {
|
||||
const p = provs.find(x => x.id === e.target.value) || {};
|
||||
const m = document.getElementById('tcModel');
|
||||
if (m) m.value = p.model || '';
|
||||
});
|
||||
}
|
||||
|
||||
window._tcRun = async (dryRun) => {
|
||||
if (!_cur) return;
|
||||
const params = {};
|
||||
document.querySelectorAll('[data-p]').forEach(el => {
|
||||
let v = el.value;
|
||||
if (el.type === 'number') v = Number(v);
|
||||
params[el.dataset.p] = v;
|
||||
});
|
||||
const pt = document.getElementById('tcPrompt');
|
||||
if (pt && pt.value.trim()) params.prompt = pt.value.trim();
|
||||
const prov = document.getElementById('tcProv')?.value;
|
||||
if (prov) params.provider = prov;
|
||||
|
||||
if (!dryRun) {
|
||||
const warn = `▶️ 真跑「${_cur.name}」\n\n供应商:${prov}\n参数:${JSON.stringify(params).slice(0, 180)}\n\n⚠️ 这会实际调用外部 API,可能产生费用。\n确认继续?`;
|
||||
if (!confirm(warn)) return;
|
||||
}
|
||||
|
||||
const el = document.getElementById('tcResult');
|
||||
el.innerHTML = `<div style="font-size:12px;color:var(--text2)">${dryRun ? '🔍 预检中…' : '▶️ 执行中(可能需要 1~2 分钟)…'}</div>`;
|
||||
try {
|
||||
const r = await fetch(`${API}/capabilities/${encodeURIComponent(_cur.id)}/test`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ params, dry_run: dryRun }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') {
|
||||
el.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ ${esc(d.detail || '失败')}</div>`;
|
||||
return;
|
||||
}
|
||||
const p = d.preflight || {};
|
||||
if (dryRun || d.not_implemented) {
|
||||
el.innerHTML = `
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.9">
|
||||
<div style="color:#22c55e;font-weight:600">${d.not_implemented ? 'ℹ️ 预检通过(真跑未接入)' : '✅ 预检完成'}</div>
|
||||
<div>供应商:<b>${esc(p.provider_name)}</b> ${p.key_ready ? '(✅ 密钥已配)' : '(⚠️ 密钥未配 → 去「🔑 API 配置」)'}</div>
|
||||
<div>模型:<code>${esc(p.model || '—')}</code> · 执行:${esc(p.execution)}</div>
|
||||
<div>参数:<code>${esc(JSON.stringify(p.params))}</code></div>
|
||||
<div style="color:var(--text2);margin-top:4px">${esc(d.message || '')}</div>
|
||||
</div>`;
|
||||
} else {
|
||||
el.innerHTML = `
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:10px;font-size:12px;line-height:1.9">
|
||||
<div style="color:#22c55e;font-weight:600">✅ ${esc(d.message || '试跑完成')}</div>
|
||||
<div>产物:<code>${esc(d.output || '')}</code></div>
|
||||
${(d.output || '').endsWith('.mp4') ? `<video controls src="/api/ave/file?path=${encodeURIComponent(d.output)}" style="width:100%;max-width:320px;margin-top:8px;border-radius:6px"></video>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
/**
|
||||
* ave-vfx-presets.js — ✨ 特效预设
|
||||
* 规范:DCS 说明书 v2.0 第八节(特效运镜库:子弹时间 / 环绕 / 慢动作 / 焦点锁定)
|
||||
*/
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const VFX_TYPES = {
|
||||
bullet_time: '子弹时间',
|
||||
orbit: '环绕运镜',
|
||||
slow_motion: '慢动作',
|
||||
focus_lock: '焦点锁定',
|
||||
custom: '自定义',
|
||||
};
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'vfx_presets', title: '特效预设', icon: '✨', uid: 'vfx',
|
||||
refKeys: [['preview', '效果预览图']],
|
||||
filters: [{ key: 'vfx_type', label: '特效类型', options: VFX_TYPES }],
|
||||
editFields: [
|
||||
{ key: 'vfx_type', label: '特效类型', type: 'select', options: VFX_TYPES },
|
||||
{ key: 'camera_control', label: '运镜参数(可灵 camera_control:horizontal/vertical/pan/tilt/roll/zoom)', type: 'textarea' },
|
||||
{ key: 'prompt_suffix', label: '提示词后缀(追加到镜头提示词)', type: 'textarea' },
|
||||
{ key: 'duration', label: '建议时长(秒)', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '子弹时间 / 环绕运镜 / 慢动作 / 焦点锁定的参数模板;分镜的 vfx 字段按 id 引用',
|
||||
referenceDoc: 'DCS 说明书 v2.0 第八节',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* ave-voices.js — 🔊 音色库(含「从官方挑选」与试听)
|
||||
*
|
||||
* 音色 = TTS 的「嗓子」:一个音色资产 = 显示名 + 供应商音色 ID(voice_id)。
|
||||
* 配音时按演员表绑定:主角指定音色、其余角色按代号从路人池稳定分配。
|
||||
*
|
||||
* 官方音色目录:09_ave/config/cosyvoice_voices.yaml(CosyVoice 88 个可用音色)
|
||||
* ⚠️ 音色必须与 TTS 模型匹配(cosyvoice-v3-flash 支持全部;v3-plus 仅 2 个)
|
||||
*/
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const GENDERS = { male: '男声', female: '女声', child: '童声', other: '其他' };
|
||||
const SOURCES = { official: '官方音色库', cloned: 'AI 提炼', recorded: '自己录音' };
|
||||
const API = '/api/ave/assets';
|
||||
let _uid = 'voice';
|
||||
|
||||
export async function loadView(container) {
|
||||
_uid = 'voice';
|
||||
await renderAssetPage(container, {
|
||||
kind: 'voices', title: '音色库', icon: '🔊', uid: 'voice',
|
||||
audio: true, refKeys: [],
|
||||
filters: [
|
||||
{ key: 'gender', label: '性别', options: GENDERS },
|
||||
{ key: 'source', label: '来源', options: SOURCES },
|
||||
],
|
||||
editFields: [
|
||||
{ key: 'gender', label: '性别', type: 'select', options: GENDERS },
|
||||
{ key: 'source', label: '来源', type: 'select', options: SOURCES },
|
||||
{ key: 'provider_voice_id', label: '供应商音色 ID(CosyVoice voice 参数,如 longxiaochun_v3)', type: 'text' },
|
||||
{ key: 'description', label: '描述 / 适用角色', type: 'textarea' },
|
||||
],
|
||||
desc: '音色样本音频 + 来源(官方库 / AI 提炼 / 录音)+ 供应商音色 ID;配音时按演员表绑定,未指定的角色自动走路人音色池',
|
||||
});
|
||||
injectCatalogEntry(container);
|
||||
window._vcAdd = addVoice;
|
||||
window._vcPreview = previewVoice;
|
||||
window._vcClose = () => { const m = document.getElementById('vcModal'); if (m) m.remove(); };
|
||||
}
|
||||
|
||||
/** 在页面头部插入「📚 从官方挑选」入口 */
|
||||
function injectCatalogEntry(container) {
|
||||
const hdr = container.querySelector('div[style*="justify-content:space-between"]');
|
||||
if (!hdr) return;
|
||||
const btns = hdr.querySelector('div[style*="display:flex"]');
|
||||
if (!btns || btns.querySelector('#vcPickBtn')) return;
|
||||
const b = document.createElement('button');
|
||||
b.id = 'vcPickBtn';
|
||||
b.textContent = '📚 从官方挑选';
|
||||
b.style.cssText = 'background:#8b5cf6;color:#fff;border:none;padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px';
|
||||
b.onclick = openCatalog;
|
||||
btns.insertBefore(b, btns.firstChild);
|
||||
}
|
||||
|
||||
async function openCatalog() {
|
||||
let d = {};
|
||||
try {
|
||||
const r = await fetch(`${API}/voices/catalog`);
|
||||
d = await r.json();
|
||||
} catch (e) { alert('❌ 音色目录加载失败: ' + e.message); return; }
|
||||
const groups = d.groups || [];
|
||||
const pools = d.pools || {};
|
||||
const poolLabels = {
|
||||
male_lead: '年轻男主', male_mature: '成熟男主', female_lead: '女主角',
|
||||
female_charm: '魅惑/御姐', extra_male: '路人男', extra_female: '路人女', narrator: '旁白',
|
||||
};
|
||||
const poolHint = Object.keys(pools).length
|
||||
? `<div style="font-size:11px;color:var(--text2);margin-bottom:8px;line-height:1.8">
|
||||
💡 角色分配建议:${Object.entries(pools).map(([k, vs]) =>
|
||||
`<b>${poolLabels[k] || k}</b> ${vs.slice(0, 3).join(' / ')}`).join(' | ')}</div>`
|
||||
: '';
|
||||
const modal = document.createElement('div');
|
||||
modal.id = 'vcModal';
|
||||
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:100000;display:flex;align-items:center;justify-content:center';
|
||||
modal.innerHTML = `
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:12px;width:min(920px,94vw);max-height:86vh;overflow:auto;padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<div style="font-weight:600">📚 从官方音色库挑选
|
||||
<span style="font-size:11px;color:var(--text2);margin-left:6px">模型 ${esc(d.model || '')} · ${groups.reduce((n, g) => n + (g.voices || []).length, 0)} 个音色</span>
|
||||
</div>
|
||||
<button onclick="window._vcClose()" style="background:none;border:none;color:var(--text2);font-size:18px;cursor:pointer">✕</button>
|
||||
</div>
|
||||
<input id="vcSearch" placeholder="🔍 搜索音色名 / 特质 / 适用标签…" oninput="window._vcFilter(this.value)"
|
||||
style="width:100%;margin-bottom:8px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 10px;font-size:12px">
|
||||
${poolHint}
|
||||
<div id="vcList">
|
||||
${groups.map(g => `
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-size:12px;font-weight:600;color:var(--text2);margin:6px 0 4px">${esc(g.name)}(${(g.voices || []).length})</div>
|
||||
${(g.voices || []).map(v => `
|
||||
<div class="vc-row" data-s="${esc([v.name, v.voice_id, v.trait, (v.tags || []).join(' '), v.age].join(' ').toLowerCase())}"
|
||||
style="display:flex;align-items:center;gap:10px;padding:5px 8px;border-bottom:1px solid var(--border)">
|
||||
<span style="min-width:74px;font-size:12px">${esc(v.name)}</span>
|
||||
<code style="font-size:11px;color:#8b5cf6;min-width:160px">${esc(v.voice_id)}</code>
|
||||
<span style="font-size:11px;color:var(--text2);flex:1">${esc(v.trait || '')} ${v.age ? '· ' + esc(v.age) + '岁' : ''}
|
||||
${(v.tags || []).map(t => `<span style="background:var(--bg3);border-radius:3px;padding:0 4px;margin-left:3px">${esc(t)}</span>`).join('')}</span>
|
||||
<button onclick="window._vcPreview('${esc(v.voice_id)}')" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:2px 9px;border-radius:4px;cursor:pointer;font-size:11px">▶ 试听</button>
|
||||
<button onclick="window._vcAdd('${esc(v.name)}','${esc(v.voice_id)}','${esc(v.trait || '')}')" style="background:#22c55e;color:#000;border:none;padding:2px 10px;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600">+ 添加</button>
|
||||
</div>`).join('')}
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<div id="vcMsg" style="font-size:11px;color:var(--text2);margin-top:6px"></div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:6px">试听会合成一小段(含少量 TTS 费用,同一音色只合一次);添加后可在音色库里编辑描述</div>
|
||||
</div>`;
|
||||
document.body.appendChild(modal);
|
||||
window._vcFilter = (q) => {
|
||||
const kw = (q || '').trim().toLowerCase();
|
||||
document.querySelectorAll('#vcList .vc-row').forEach(el => {
|
||||
el.style.display = (!kw || (el.dataset.s || '').includes(kw)) ? '' : 'none';
|
||||
});
|
||||
};
|
||||
}
|
||||
|
||||
async function addVoice(name, voiceId, trait) {
|
||||
const msg = document.getElementById('vcMsg');
|
||||
try {
|
||||
const r = await fetch(`${API}/voices`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name, provider_voice_id: voiceId, source: 'official',
|
||||
description: trait || '',
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.status >= 400) throw new Error(d.detail || '添加失败');
|
||||
if (msg) msg.textContent = `✅ 已添加「${name}」——可关闭本窗口刷新查看`;
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
async function previewVoice(voiceId) {
|
||||
const msg = document.getElementById('vcMsg');
|
||||
if (msg) msg.textContent = `⏳ 正在合成试听(${voiceId})…`;
|
||||
try {
|
||||
const r = await fetch(`${API}/voices/preview`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice_id: voiceId }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (r.status >= 400) throw new Error(d.detail || '试听失败');
|
||||
const audio = new Audio(d.url);
|
||||
audio.play();
|
||||
if (msg) msg.textContent = `${d.cached ? '♻️ 复用缓存' : '✅ 已合成'}:${voiceId}`;
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* ave-wardrobe.js — 👗 服装库
|
||||
* 规范:DCS 说明书 v2.0 第五节(服装:正背面图 + 材质;可挂到角色的"服装变体")
|
||||
*/
|
||||
// 材质建议
|
||||
const MATERIAL_PRESETS = [
|
||||
'纯棉', '麻料', '真丝', '羊毛', '牛仔', '皮革', '针织', '化纤',
|
||||
'绸缎', '灯芯绒', '雪纺', 'PU 皮',
|
||||
];
|
||||
import { renderAssetPage } from './assets-common.js';
|
||||
|
||||
const SEASONS = { spring: '春秋', summer: '夏季', winter: '冬季', all: '四季' };
|
||||
|
||||
export async function loadView(container) {
|
||||
await renderAssetPage(container, {
|
||||
kind: 'wardrobe', title: '服装库', icon: '👗', uid: 'ward',
|
||||
refKeys: [['front', '正面'], ['back', '背面'], ['detail', '细节']],
|
||||
filters: [{ key: 'season', label: '季节', options: SEASONS }],
|
||||
editFields: [
|
||||
{ key: 'season', label: '季节', type: 'select', options: SEASONS },
|
||||
{ key: 'material', label: '材质', type: 'datalist', options: MATERIAL_PRESETS, placeholder: '如:纯棉、真丝、牛仔 —— 可下拉或自己填' },
|
||||
{ key: 'linked_character', label: '关联角色 ID', type: 'text' },
|
||||
{ key: 'description', label: '描述', type: 'textarea' },
|
||||
],
|
||||
desc: '服装正背面图 + 材质;可挂到角色的「服装变体」,角色换装时切换',
|
||||
referenceDoc: 'DCS 说明书 v2.0 第五节',
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
/**
|
||||
* 能力目录视图(已迁移)
|
||||
*/
|
||||
let _capGroup = 0;
|
||||
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">
|
||||
<h2 style="font-size:18px;margin:0">⚡ 能力目录</h2>
|
||||
<span id="capCount" style="font-size:12px;color:var(--text2)"></span>
|
||||
</div>
|
||||
<div id="capGroupTabs" style="display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px"></div>
|
||||
<div id="capabilityContent"><div class="loading">加载原子能力...</div></div>
|
||||
</div>`;
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/capabilities');
|
||||
const d = await r.json();
|
||||
const groups = d.groups || [];
|
||||
const matrix = d.matrix || [];
|
||||
|
||||
const countEl = document.getElementById('capCount');
|
||||
if (countEl) countEl.textContent = `共 ${d.total_items || 0} 个能力`;
|
||||
|
||||
if (!groups.length) {
|
||||
document.getElementById('capabilityContent').innerHTML = '<div class="error" style="padding:40px">暂无能力数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// 分组标签
|
||||
document.getElementById('capGroupTabs').innerHTML = groups.map((g, i) =>
|
||||
`<button class="btn ${i === 0 ? 'active' : ''}" onclick="window._switchCapGroup(${i})"
|
||||
style="${i === 0 ? 'background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px'
|
||||
: 'background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px'}">
|
||||
${g.name} (${g.items.length})
|
||||
</button>`
|
||||
).join('');
|
||||
|
||||
_capGroup = 0;
|
||||
renderGroup(groups[0], matrix);
|
||||
|
||||
window._switchCapGroup = function(idx) {
|
||||
_capGroup = idx;
|
||||
// 更新按钮样式
|
||||
document.querySelectorAll('#capGroupTabs .btn').forEach((btn, i) => {
|
||||
Object.assign(btn.style, i === idx
|
||||
? { background: 'var(--primary)', color: '#fff', border: 'none' }
|
||||
: { background: 'var(--bg2)', border: '1px solid var(--border)', color: 'var(--text)' });
|
||||
});
|
||||
renderGroup(groups[idx], matrix);
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroup(group, matrix) {
|
||||
const el = document.getElementById('capabilityContent');
|
||||
if (!el) return;
|
||||
if (!group || !group.items || !group.items.length) {
|
||||
el.innerHTML = '<div class="error" style="padding:40px">暂无数据</div>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `
|
||||
<div style="background:var(--bg2);border-radius:var(--radius);padding:14px;border:1px solid var(--border);overflow-x:auto">
|
||||
<table style="width:100%;font-size:12px;border-collapse:collapse">
|
||||
<thead><tr>
|
||||
${Object.keys(group.items[0] || {}).map(h => `<th style="text-align:left;padding:6px 8px;border-bottom:2px solid var(--border);white-space:nowrap">${h}</th>`).join('')}
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${group.items.map(item =>
|
||||
`<tr>${Object.values(item).map(v =>
|
||||
`<td style="padding:4px 8px;border-bottom:1px solid var(--border);font-size:11px">${v || '-'}</td>`
|
||||
).join('')}</tr>`
|
||||
).join('')}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>`;
|
||||
}
|
||||
@@ -0,0 +1,649 @@
|
||||
/**
|
||||
* char-gen.js — 🎭 角色生成器(标签页版)
|
||||
*
|
||||
* 交互:
|
||||
* ① 顶部**标签页**切换 10 个层(核心身份/面部轮廓/五官细节/头发/皮肤/体型/服装/神态/光线/背景)
|
||||
* —— 点击即切换,不用折叠展开
|
||||
* ② 底部**固定提示词预览**,边填边更新(调后端「纯计算组装」接口,**不扣费**)
|
||||
* ③ 选视角 + 引擎 + 角色名 → 生成(弹费用确认)→ 自动入库角色库
|
||||
*
|
||||
* 字段定义:GET /api/ave/assets/character-layers
|
||||
* 预览组装:POST /api/ave/assets/characters/assemble-prompt(纯计算)
|
||||
* 真实生成:POST /api/ave/assets/characters/generate-by-layers(**扣费,需 confirm_cost**)
|
||||
*/
|
||||
const API = '/api/ave/assets';
|
||||
|
||||
let _layers = []; // 层定义(含 sub_layers)
|
||||
let _variants = []; // 视角
|
||||
let _engines = []; // 引擎
|
||||
let _tab = 0; // 当前标签页
|
||||
let _values = {}; // ★ 字段值缓存 {layerId:{key:value}} — 切层不丢值
|
||||
let _previewTimer = null;
|
||||
let _lastPrompt = '';
|
||||
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px;max-width:1150px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:8px">
|
||||
<h2 style="font-size:18px;margin:0">🎭 角色生成器
|
||||
<span style="font-size:12px;color:var(--text2);font-weight:400">标签页填字段 · 实时预览提示词 · 一键生成入库</span></h2>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button onclick="window._cgReset()" 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 id="cgPending"></div>
|
||||
|
||||
<!-- 标签页 -->
|
||||
<div id="cgTabs" style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px;border-bottom:1px solid var(--border);padding-bottom:8px">
|
||||
<span style="color:var(--text2);font-size:12px">⏳ 加载…</span>
|
||||
</div>
|
||||
|
||||
<!-- 当前层字段 -->
|
||||
<div id="cgTabBody" style="min-height:220px"></div>
|
||||
|
||||
<!-- 生成设置 -->
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px 14px;margin-top:12px">
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr));gap:10px">
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:4px">🎬 生成视角</div>
|
||||
<select id="cgVariant" onchange="window._cgLivePreview()" title="定妆照视角 —— 补齐全套视角是保人脸一致性的关键"
|
||||
style="width:100%;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px">
|
||||
<option value="multi_angle:front">🧍 全身 · 正面</option>
|
||||
<option value="multi_angle:side">🧍 全身 · 左侧</option>
|
||||
<option value="multi_angle:right_three_quarter">🧍 全身 · 右前 3/4</option>
|
||||
<option value="multi_angle:right_side">🧍 全身 · 右侧</option>
|
||||
<option value="multi_angle:back">🧍 全身 · 背面</option>
|
||||
<option value="front_face">👤 面部特写(定妆照基准)</option>
|
||||
<option value="full_body">🖼 全身照(旧)</option>
|
||||
<option value="grid">🎞 六格定妆表(多视角一张图)</option>
|
||||
<option value="grid_sport">🏃 运动格(8 格)</option>
|
||||
<option value="scene">🌄 场景照</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:4px">⚙️ 生成引擎</div>
|
||||
<select id="cgEngine" style="width:100%;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px"></select>
|
||||
<div id="cgEngineNote" style="font-size:10px;color:var(--text2);margin-top:3px"></div>
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:4px">🏷️ 角色名(入库用)</div>
|
||||
<input id="cgName" placeholder="如:宋佳主任" style="width:100%;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px">
|
||||
</div>
|
||||
<div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:4px">📸 生成张数</div>
|
||||
<select id="cgCount" style="width:100%;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px">
|
||||
<option value="1">1 张</option>
|
||||
<option value="2">2 张(多挑一张)</option>
|
||||
<option value="3">3 张(多挑一张)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:12px;flex-wrap:wrap">
|
||||
<button id="cgGenBtn" onclick="window._cgGenerate()"
|
||||
style="background:var(--primary);color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">
|
||||
🎨 生成定妆照
|
||||
</button>
|
||||
<button onclick="window._cgSetGenerate()"
|
||||
title="短剧角色卡标准做法:5 个半身视角分开生成,其余 4 张以主定妆为参考图(五官不崩)"
|
||||
style="background:#8b5cf6;color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">
|
||||
📸 生成套装(5 视角)
|
||||
</button>
|
||||
<label style="font-size:11px;color:var(--text2);display:flex;align-items:center;gap:4px">
|
||||
<input type="checkbox" id="cgSave" checked> 存入角色库
|
||||
</label>
|
||||
<span style="font-size:11px;color:#f59e0b">⚠️ 会真实扣费(可灵图片额度),点击后弹确认</span>
|
||||
<span id="cgStatus" style="font-size:11px;color:var(--text2)"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 实时预览(固定在底部)-->
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px 14px;margin-top:12px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
|
||||
<div style="font-size:12px;font-weight:600">📝 提示词预览(实时生成,改动即更新)</div>
|
||||
<span id="cgPreviewMeta" style="font-size:10px;color:var(--text2)"></span>
|
||||
</div>
|
||||
<textarea id="cgPreview" rows="5" oninput="window._cgPromptEdited=true;var m=document.getElementById('cgPreviewMeta');if(m)m.textContent='✏️ 已手动编辑(点「重新生成」可回到字段自动组装)'"
|
||||
placeholder="填字段后这里会实时显示最终提示词…(可直接编辑,生成时以这里为准)"
|
||||
style="width:100%;padding:8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px;line-height:1.7"></textarea>
|
||||
<div style="display:flex;gap:8px;margin-top:6px;align-items:center">
|
||||
<button onclick="window._cgCopyPrompt()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">📋 复制提示词</button>
|
||||
<button onclick="window._cgRebuildPreview()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">🔄 重新生成预览</button>
|
||||
<span style="font-size:10px;color:var(--text2)">这份提示词会在生成时传给引擎;<b>可直接编辑</b>(编辑后字段变化不再覆盖,点「重新生成预览」恢复自动组装)</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 结果 -->
|
||||
<div id="cgResultWrap" style="display:none;background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px 14px;margin-top:12px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:8px">🎬 生成结果</div>
|
||||
<div id="cgResult"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
await window._cgLoad();
|
||||
}
|
||||
|
||||
// ══ 加载字段定义 ══
|
||||
window._cgLoad = async () => {
|
||||
try {
|
||||
const d = await _json(`${API}/character-layers`);
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '加载失败');
|
||||
_layers = d.layers || [];
|
||||
_variants = d.variants || [];
|
||||
_engines = d.engines || [];
|
||||
|
||||
const vs = document.getElementById('cgVariant');
|
||||
if (vs) vs.innerHTML = _variants.map(v => `<option value="${esc(v.id)}">${esc(v.label)}</option>`).join('');
|
||||
const es = document.getElementById('cgEngine');
|
||||
if (es) es.innerHTML = _engines.map(e => `<option value="${esc(e.id)}">${esc(e.label)}</option>`).join('');
|
||||
const note = document.getElementById('cgEngineNote');
|
||||
if (note && _engines[0]) note.textContent = _engines[0].note || '';
|
||||
|
||||
_cgRenderTabs();
|
||||
_cgApplyPending();
|
||||
_cgSwitchTab(0);
|
||||
} catch (e) {
|
||||
const body = document.getElementById('cgTabBody');
|
||||
if (body) body.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 字段定义加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ══ 标签页 ══
|
||||
function _cgRenderTabs() {
|
||||
const el = document.getElementById('cgTabs');
|
||||
if (!el) return;
|
||||
el.innerHTML = _layers.map((L, i) => {
|
||||
const n = (L.fields || []).length + (L.sub_layers || []).reduce((s, x) => s + (x.fields || []).length, 0);
|
||||
return `<button id="cgTab_${i}" onclick="window._cgSwitchTab(${i})"
|
||||
style="padding:6px 12px;border-radius:6px 6px 0 0;cursor:pointer;font-size:12px;white-space:nowrap;border:1px solid var(--border);border-bottom:none">
|
||||
${L.icon || ''} ${esc(L.label)}<span style="font-size:10px;color:var(--text2)"> ${n}</span></button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _cgSaveTab() {
|
||||
document.querySelectorAll('#cgTabBody [data-layer][data-key]').forEach(el => {
|
||||
const lid = el.dataset.layer;
|
||||
(_values[lid] = _values[lid] || {})[el.dataset.key] = el.value || '';
|
||||
});
|
||||
}
|
||||
window._cgCollect = () => { _cgSaveTab(); const o = {}; Object.entries(_values).forEach(([lid, kv]) => { const keep = {}; Object.entries(kv || {}).forEach(([k, v]) => { if (String(v || '').trim()) keep[k] = String(v).trim(); }); if (Object.keys(keep).length) o[lid] = keep; }); return o; };
|
||||
|
||||
window._cgSwitchTab = (i, skipSave) => {
|
||||
if (!skipSave) _cgSaveTab(); // ★ 保存当前层输入(预填重渲染时传 skipSave=true 避免覆盖)
|
||||
_tab = i;
|
||||
_layers.forEach((_, k) => {
|
||||
const b = document.getElementById(`cgTab_${k}`);
|
||||
if (!b) return;
|
||||
const on = k === i;
|
||||
b.style.background = on ? 'var(--primary)' : 'var(--bg3)';
|
||||
b.style.color = on ? '#fff' : 'var(--text)';
|
||||
b.style.fontWeight = on ? '600' : '400';
|
||||
});
|
||||
const L = _layers[i];
|
||||
const body = document.getElementById('cgTabBody');
|
||||
if (!L || !body) return;
|
||||
const fields = (L.fields || []).map(f => _cgRenderField(L.id, f)).join('');
|
||||
const subs = (L.sub_layers || []).map(s => `
|
||||
<div style="margin-top:10px;padding-top:10px;border-top:1px dashed var(--border)">
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:6px">${s.icon || ''} ${esc(s.label)}</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:8px">
|
||||
${(s.fields || []).map(f => _cgRenderField(s.id, f)).join('')}
|
||||
</div>
|
||||
</div>`).join('');
|
||||
body.innerHTML = `
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px">
|
||||
${L.icon || ''} <b>${esc(L.label)}</b> —— 选填,留空即用默认
|
||||
</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:8px">${fields}</div>
|
||||
${L.id === 'L6_clothing' ? `
|
||||
<div style="margin-top:10px;padding:8px 10px;background:var(--bg3);border:1px solid var(--border);border-radius:6px">
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:5px">👗 从服装库选(选中的服装描述会并入生成提示词,不占用上面的字段)</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<select id="cgWardrobe" style="flex:1;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px">
|
||||
<option value="">(不使用服装库 —— 即"不带服装",由上面的字段描述)</option>
|
||||
</select>
|
||||
<button onclick="window._cgApplyWardrobe()" style="background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:5px 12px;border-radius:4px;cursor:pointer;font-size:11px">应用</button>
|
||||
<button onclick="window._cgClearWardrobe()" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:11px">清除</button>
|
||||
</div>
|
||||
<div id="cgWardrobeMsg" style="font-size:11px;color:var(--text2);margin-top:4px"></div>
|
||||
</div>` : ''}
|
||||
${subs}`;
|
||||
if (L.id === 'L6_clothing') { _cgLoadWardrobe(); _cgShowWardrobe(); }
|
||||
};
|
||||
|
||||
function _cgRenderField(layerId, f) {
|
||||
const key = f.key || '', label = f.label || key, type = f.type || 'text';
|
||||
const ph = f.placeholder || '', opts = f.options || [];
|
||||
const fid = `cgf_${layerId}_${key}`;
|
||||
const common = `id="${esc(fid)}" data-layer="${esc(layerId)}" data-key="${esc(key)}" oninput="window._cgLivePreview()" onchange="window._cgLivePreview()"
|
||||
style="width:100%;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:12px"`;
|
||||
const cur = ((_values[layerId] || {})[key]) || ''; // ★ 从缓存回填(切层/预填后不丢)
|
||||
let input;
|
||||
if ((type === 'select' || opts.length) && opts.length) {
|
||||
input = `<select ${common}><option value="">(不指定)</option>${opts.map(o => `<option value="${esc(o)}" ${o === cur ? 'selected' : ''}>${esc(o)}</option>`).join('')}</select>`;
|
||||
} else if (type === 'textarea') {
|
||||
input = `<textarea ${common} rows="2" placeholder="${esc(ph)}">${esc(cur)}</textarea>`;
|
||||
} else {
|
||||
input = `<input type="text" ${common} placeholder="${esc(ph)}" value="${esc(cur)}">`;
|
||||
}
|
||||
return `<div><div style="font-size:11px;color:var(--text2);margin-bottom:3px">${esc(label)}</div>${input}</div>`;
|
||||
}
|
||||
|
||||
// ══ 收集 / 清空 ══
|
||||
// (_cgCollect 已在上方基于 _values 缓存实现)
|
||||
|
||||
window._cgReset = () => {
|
||||
_values = {}; // ★ 清缓存
|
||||
window._cgPrefill = null;
|
||||
window._cgPromptEdited = false;
|
||||
document.querySelectorAll('#cgTabBody [data-layer][data-key]').forEach(el => { el.value = ''; });
|
||||
const n = document.getElementById('cgName'); if (n) n.value = '';
|
||||
_layers.forEach((_, i) => { /* 各层已渲染过,切回时取值会重渲染 */ });
|
||||
_cgSwitchTab(_tab); // 重新渲染当前层(清空 DOM 值)
|
||||
_lastPrompt = '';
|
||||
const ta = document.getElementById('cgPreview'); if (ta) ta.value = '';
|
||||
const meta = document.getElementById('cgPreviewMeta'); if (meta) meta.textContent = '';
|
||||
const st = document.getElementById('cgStatus'); if (st) st.textContent = '';
|
||||
const rw = document.getElementById('cgResultWrap'); if (rw) rw.style.display = 'none';
|
||||
if (window.showToast) window.showToast('已清空表单');
|
||||
};
|
||||
|
||||
// ══ 实时预览(防抖 350ms,调后端纯计算接口)══
|
||||
window._cgLivePreview = () => {
|
||||
clearTimeout(_previewTimer);
|
||||
const meta = document.getElementById('cgPreviewMeta');
|
||||
if (meta) meta.textContent = '⏳ 组装中…';
|
||||
_previewTimer = setTimeout(async () => {
|
||||
if (window._cgPromptEdited) { // ★ 用户手动改过 → 不覆盖他的编辑
|
||||
if (meta) meta.textContent = '✏️ 已手动编辑(点「重新生成预览」回到自动组装)';
|
||||
return;
|
||||
}
|
||||
const layers = window._cgCollect();
|
||||
const _rawV = document.getElementById('cgVariant')?.value || 'front_face';
|
||||
const _parts = _rawV.split(':');
|
||||
const variant = _parts[0];
|
||||
const angle = _parts[1] || '';
|
||||
if (!Object.keys(layers).length) {
|
||||
const ta = document.getElementById('cgPreview');
|
||||
if (ta) ta.value = '';
|
||||
if (meta) meta.textContent = '(还没填字段)';
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const d = await _json(`${API}/characters/assemble-prompt`, 'POST', { layers, variant, angle });
|
||||
const ta = document.getElementById('cgPreview');
|
||||
if (d.status === 'ok') {
|
||||
_lastPrompt = d.prompt || '';
|
||||
if (ta) ta.value = _lastPrompt;
|
||||
if (meta) meta.textContent = `${d.chars || 0} 字 · 已填 ${(d.filled_layers || []).length} 层`;
|
||||
} else {
|
||||
if (ta) ta.value = '';
|
||||
if (meta) meta.textContent = '❌ ' + (d.detail || '组装失败');
|
||||
}
|
||||
} catch (e) {
|
||||
if (meta) meta.textContent = '❌ ' + e.message;
|
||||
}
|
||||
}, 350);
|
||||
};
|
||||
|
||||
window._cgCopyPrompt = async () => {
|
||||
if (!_lastPrompt) { alert('提示词还是空的 —— 先填点字段'); return; }
|
||||
try {
|
||||
await navigator.clipboard.writeText(_lastPrompt);
|
||||
if (window.showToast) window.showToast('📋 提示词已复制');
|
||||
} catch (e) {
|
||||
alert('复制失败(可手动选中文本框复制)');
|
||||
}
|
||||
};
|
||||
|
||||
// ══ 生成 ══
|
||||
// ═══ 套装生成(5 视角 · 分开生成 + 主定妆作参考图)═══
|
||||
window._cgSetGenerate = async () => {
|
||||
const layers = _cgCollect();
|
||||
if (!Object.keys(layers).length) { alert('请先在上面填写角色字段(至少:身份 / 年龄 / 发型 / 服装)'); return; }
|
||||
const bits = [];
|
||||
Object.values(layers).forEach(kv => Object.values(kv || {}).forEach(v => {
|
||||
const s = String(v || '').trim();
|
||||
if (s && !bits.includes(s)) bits.push(s);
|
||||
}));
|
||||
const desc = bits.join(',');
|
||||
const name = (document.getElementById('cgName')?.value || '').trim() || '未命名角色';
|
||||
|
||||
if (!confirm(`📸 生成短剧角色参考图套装\\n\\n角色:${name}\\n描述:${desc.slice(0, 80)}…\\n\\n会生成 5 张(半身):\\n 1. 正面半身(主定妆)★基准\\n 2~5. 45°侧 / 纯侧面 / 面部特写 / 回头斜侧\\n (均以主定妆为参考图,锁定五官)\\n\\n⚠️ 真实扣费 约 ¥1(5 张 × ¥0.2)\\n\\n继续?`)) return;
|
||||
|
||||
let box = document.getElementById('cgSetBox');
|
||||
if (!box) {
|
||||
box = document.createElement('div');
|
||||
box.id = 'cgSetBox';
|
||||
box.style.cssText = 'margin-top:12px;padding:10px;background:var(--bg3);border:1px solid #8b5cf6;border-radius:8px;font-size:12px';
|
||||
const btn = document.getElementById('cgGenBtn');
|
||||
(btn?.parentNode || document.body).appendChild(box);
|
||||
}
|
||||
|
||||
box.innerHTML = '⏳ 提交中…';
|
||||
let jobId = '';
|
||||
try {
|
||||
const d = await _json(`${API}/characters/portrait-set/generate`, 'POST',
|
||||
{ desc, character_name: name, confirm_cost: true });
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '提交失败');
|
||||
jobId = d.job_id;
|
||||
} catch (e) {
|
||||
box.innerHTML = `❌ ${esc(e.message)}`;
|
||||
return;
|
||||
}
|
||||
|
||||
const poll = async () => {
|
||||
try {
|
||||
const j = await _json(`${API}/characters/portrait-set/jobs/${jobId}`, 'GET');
|
||||
const pct = j.progress || 0;
|
||||
box.innerHTML = `
|
||||
<div style="font-weight:600;margin-bottom:6px">📸 套装生成中 ${pct}%</div>
|
||||
<div style="height:6px;background:var(--bg2);border-radius:3px;overflow:hidden;margin-bottom:6px">
|
||||
<div style="height:100%;width:${pct}%;background:#8b5cf6;transition:width .5s"></div>
|
||||
</div>
|
||||
<div style="color:var(--text2);font-size:11px">${esc(j.note || '')}</div>`;
|
||||
if (j.status === 'running') { setTimeout(poll, 3000); return; }
|
||||
if (j.status === 'failed') { box.innerHTML = `❌ 失败: ${esc(j.error || '')}`; return; }
|
||||
const views = (j.result || {}).views || {};
|
||||
const rows = Object.entries(views).map(([vid, x]) => {
|
||||
const tag = x.is_anchor ? '★' : ' ';
|
||||
const q = x.quality != null ? `背景纯净度 ${x.quality}` : '';
|
||||
const ref = x.used_ref ? '(锚定主定妆)' : (x.is_anchor ? '(文生图·基准)' : '');
|
||||
return `<div style="margin:3px 0">${tag} ${esc(vid)} ${x.ok ? '✅' : '❌'} ${q} ${ref}</div>`;
|
||||
}).join('');
|
||||
box.innerHTML = `
|
||||
<div style="font-weight:600;color:#22c55e;margin-bottom:6px">✅ ${esc((j.result || {}).note || '完成')}</div>
|
||||
${rows}
|
||||
<div style="margin-top:8px;color:var(--text2);font-size:11px">
|
||||
文件:agent-local/tools/ave/cache/character_sheet/${esc(name)}/
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
box.innerHTML = `❌ 查询失败: ${esc(e.message)}`;
|
||||
}
|
||||
};
|
||||
setTimeout(poll, 2000);
|
||||
};
|
||||
|
||||
window._cgGenerate = async () => {
|
||||
const layers = window._cgCollect();
|
||||
if (!Object.keys(layers).length) { alert('请至少填一些字段(如「核心身份」)'); return; }
|
||||
const _rawV2 = document.getElementById('cgVariant')?.value || 'front_face';
|
||||
const _p2 = _rawV2.split(':');
|
||||
const variant = _p2[0];
|
||||
const angle = _p2[1] || '';
|
||||
const engine = document.getElementById('cgEngine')?.value || 'kling';
|
||||
const count = parseInt(document.getElementById('cgCount')?.value || '1');
|
||||
const name = (document.getElementById('cgName')?.value || '').trim()
|
||||
|| (layers.L0_identity || {}).name || '新角色';
|
||||
const save = !!document.getElementById('cgSave')?.checked;
|
||||
const vlabel = (_variants.find(v => v.id === variant) || {}).label || variant;
|
||||
const elabel = (_engines.find(e => e.id === engine) || {}).label || engine;
|
||||
|
||||
// ⚡ 生成前审核:显示**将发送给模型的完整提示词**(可编辑),而不是只告知费用
|
||||
let promptText = (document.getElementById('cgPreview')?.value || '').trim();
|
||||
// ⚡ 来自导演台「🎨 去生成」的 AI 建议 → 用**用户编辑后**的版本并入(审核面板可见可改)
|
||||
const _pp = ((document.getElementById('cgPendingPrompt') || {}).value
|
||||
|| (window._cgPending && window._cgPending.prompt) || '').trim();
|
||||
if (_pp) promptText += `\n\n【AI 建议补充】${_pp}`;
|
||||
// 👗 服装库引用("带服装"生成)
|
||||
if (window._cgWardrobeRef) {
|
||||
const w = window._cgWardrobeRef;
|
||||
promptText += `\n\n【服装】${w.name}${w.desc ? ':' + w.desc : ''}`;
|
||||
}
|
||||
const cost = (count * 0.2).toFixed(1);
|
||||
|
||||
if (window.openGenerateReview) {
|
||||
window.openGenerateReview({
|
||||
title: `🎨 生成定妆照 · ${name}`,
|
||||
subtitle: `${vlabel} · ${elabel} · ${count} 张`,
|
||||
items: [{
|
||||
key: 'prompt', label: '将发送给模型的完整提示词(可编辑)',
|
||||
value: promptText, rows: 8,
|
||||
hint: '改这里会直接改变生成结果;如需增删字段,取消后回去改(预览会自动更新)',
|
||||
}],
|
||||
meta: [
|
||||
['角色名', name], ['视角', vlabel + (angle ? ' · ' + angle : '')], ['引擎', elabel],
|
||||
['张数', String(count)], ['入库', save ? '是' : '否'],
|
||||
['费用', `约 ¥${cost}(按张计费)`],
|
||||
],
|
||||
notice: '⚠️ 确认后会真实扣费(约 ¥0.2/张);想多挑几张,先取消、把「📸 生成张数」改成 2~3 张再生成(按张数计费)',
|
||||
confirmText: '✅ 确认生成',
|
||||
onConfirm: (values) => _cgDoGenerate({
|
||||
layers, variant, engine, count, name, save,
|
||||
promptOverride: (values.prompt || '').trim(),
|
||||
}),
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// 兜底(审核组件未加载):确认框里**也显示提示词**,不再只讲费用
|
||||
if (!confirm(`🎨 生成定妆照\n\n角色:${name}\n视角:${vlabel}\n引擎:${elabel}\n张数:${count}\n入库:${save ? '是' : '否'}\n\n📝 将发送的提示词:\n${promptText.slice(0, 300)}${promptText.length > 300 ? '…' : ''}\n\n⚠️ 真实扣费(约 ¥0.2/张),预计 ≈ ¥${cost}(想多挑几张,请先把「生成张数」改成 2~3)\n\n确认生成?`)) return;
|
||||
return _cgDoGenerate({ layers, variant, angle, engine, count, name, save, promptOverride: '' });
|
||||
};
|
||||
|
||||
/** 实际执行生成(供「生成前审核面板」调用) */
|
||||
async function _cgDoGenerate({ layers, variant, angle, engine, count, name, save, promptOverride }) {
|
||||
const btn = document.getElementById('cgGenBtn');
|
||||
const st = document.getElementById('cgStatus');
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ 生成中…(每张约 30~90 秒)'; }
|
||||
if (st) st.textContent = '';
|
||||
const ok = [], fail = [];
|
||||
try {
|
||||
for (let i = 0; i < count; i++) {
|
||||
if (count > 1 && st) st.textContent = `⏳ 第 ${i + 1}/${count} 张生成中…`;
|
||||
try {
|
||||
const d = await _json(`${API}/characters/generate-by-layers`, 'POST', {
|
||||
layers, variant, angle, engine,
|
||||
character_name: count > 1 ? `${name}_${i + 1}` : name,
|
||||
save_to_library: save, confirm_cost: true,
|
||||
prompt_override: promptOverride || undefined, // ⚡ 用审核面板里编辑后的提示词
|
||||
});
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '生成失败');
|
||||
ok.push(d);
|
||||
// ⚡ 来自导演台「去生成」→ 写绑定回执(回导演台自动绑定演员表)
|
||||
try {
|
||||
const pend = window._cgPending;
|
||||
if (pend && pend.slot && d.saved && d.saved.id) {
|
||||
const bind = JSON.parse(localStorage.getItem('ave_dir_cast_bind') || '{}');
|
||||
bind[pend.slot] = { asset_id: d.saved.id, name: d.saved.name || '', at: Date.now() };
|
||||
localStorage.setItem('ave_dir_cast_bind', JSON.stringify(bind));
|
||||
}
|
||||
} catch (e2) { /* 忽略 */ }
|
||||
if (st) st.textContent = `✅ 已完成 ${ok.length}/${count}`;
|
||||
} catch (e) {
|
||||
fail.push(`${i + 1}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ⚡ 来自导演台:生成成功后清理带入标记并提示
|
||||
if (ok.length && window._cgPending) {
|
||||
const _slot = window._cgPending.slot;
|
||||
window._cgPending = null;
|
||||
try { localStorage.removeItem('ave_char_pending'); } catch (e2) {}
|
||||
const pb = document.getElementById('cgPending');
|
||||
if (pb) pb.innerHTML = `<div style="background:rgba(34,197,94,.1);border:1px solid #22c55e;border-radius:6px;padding:6px 9px;margin-bottom:8px;font-size:12px;color:#22c55e">
|
||||
✅ 已生成 —— 回「🎬 导演创作」会自动绑定到演员表的「${esc(_slot)}」</div>`;
|
||||
}
|
||||
|
||||
const wrap = document.getElementById('cgResultWrap');
|
||||
const box = document.getElementById('cgResult');
|
||||
if (wrap) wrap.style.display = 'block';
|
||||
if (box) {
|
||||
box.innerHTML = `
|
||||
<div style="font-size:12px;margin-bottom:8px">
|
||||
${ok.length ? `<span style="color:#22c55e">✅ 成功 ${ok.length} 张</span>` : ''}
|
||||
${fail.length ? `<span style="color:#f59e0b;margin-left:8px">❌ 失败 ${fail.length} 张:${esc(fail.join(';')).slice(0, 200)}</span>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap">
|
||||
${ok.map(d => `
|
||||
<div style="max-width:260px">
|
||||
<img src="${esc(d.image_url)}" style="width:100%;border-radius:8px;border:1px solid var(--border)"
|
||||
onerror="this.style.display='none';this.insertAdjacentHTML('afterend','<div style=\\'font-size:10px;color:#f59e0b\\'>(图片 URL 可能已过期,30 天内有效)</div>')">
|
||||
<div style="font-size:10px;color:var(--text2);margin-top:4px">
|
||||
${d.saved ? `📥 已入库 <b>${esc(d.saved.name)}</b> · 槽位 ${esc(d.saved.slot)}` : '(未入库)'}
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
${ok.length ? `<div style="margin-top:8px;font-size:11px;color:var(--text)">
|
||||
<b>最终提示词:</b>
|
||||
<div style="background:var(--bg2);border-radius:4px;padding:6px;margin-top:3px;max-height:140px;overflow:auto">${esc(ok[0].prompt || '')}</div>
|
||||
</div>` : ''}
|
||||
${ok.length ? `<button onclick="window.switchView && window.switchView('characters')"
|
||||
style="margin-top:8px;background:var(--primary);color:#fff;border:none;padding:5px 14px;border-radius:4px;cursor:pointer;font-size:11px">🧑 去角色库查看</button>` : ''}`;
|
||||
}
|
||||
if (window.showToast) {
|
||||
window.showToast(ok.length ? `✅ 生成完成(成功 ${ok.length}${fail.length ? ` / 失败 ${fail.length}` : ''})` : `❌ 全部失败`,
|
||||
ok.length ? { action: "window.switchView && window.switchView('characters')", actionLabel: '🧑 角色库' } : { color: '#ef4444' });
|
||||
}
|
||||
} catch (e) {
|
||||
if (st) st.textContent = '❌ ' + e.message;
|
||||
if (window.showToast) window.showToast(`❌ 生成失败<br>${esc(e.message)}`, { color: '#ef4444' });
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '🎨 生成定妆照'; }
|
||||
}
|
||||
};
|
||||
|
||||
// ══ 工具:健壮的 JSON 请求(避免 Safari 的 "did not match the expected pattern")══
|
||||
async function _json(url, method = 'GET', body = null) {
|
||||
const opt = { method, headers: {} };
|
||||
if (body) { opt.headers['Content-Type'] = 'application/json'; opt.body = JSON.stringify(body); }
|
||||
const r = await fetch(url, opt);
|
||||
const text = await r.text(); // ← 先取 text(不直接 r.json())
|
||||
let d;
|
||||
try {
|
||||
d = JSON.parse(text);
|
||||
} catch (e) {
|
||||
// 非 JSON 响应(常见于后端 500 返回 HTML/纯文本)→ 给出可诊断的信息
|
||||
throw new Error(`服务端返回异常(HTTP ${r.status}):${text.slice(0, 160) || '空响应'}`);
|
||||
}
|
||||
if (!r.ok && !d.detail) d.detail = `HTTP ${r.status}`;
|
||||
return d;
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// 🎨 导演台「去生成」带入:显示待建角色 + 预填 + 生成后回传
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
function _cgApplyPending() {
|
||||
let pend = null;
|
||||
try { pend = JSON.parse(localStorage.getItem('ave_char_pending') || 'null'); } catch (e) { return; }
|
||||
if (!pend || !pend.slot) return;
|
||||
// 30 分钟内有效(避免陈旧的带入)
|
||||
if (pend.at && Date.now() - pend.at > 120 * 60 * 1000) {
|
||||
try { localStorage.removeItem('ave_char_pending'); } catch (e) {}
|
||||
return;
|
||||
}
|
||||
window._cgPending = pend;
|
||||
const box = document.getElementById('cgPending');
|
||||
if (box) box.innerHTML = `
|
||||
<div style="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">
|
||||
🎨 <b>正在为「${esc(pend.slot)}」创建角色</b>
|
||||
<span style="color:var(--text2)">(导演台 AI 建议${pend.why ? ':' + esc(String(pend.why).slice(0, 40)) : ''})</span>
|
||||
<div style="color:var(--text2);font-size:11px;margin-top:3px">✓ 生成成功后会自动绑定回导演台的演员表(不用手动找)</div>
|
||||
${pend.prompt ? `
|
||||
<div style="margin-top:6px">
|
||||
<div style="color:var(--text2);font-size:11px;margin-bottom:3px">📝 AI 建议描述(<b>可直接编辑</b>,生成时会并入提示词)
|
||||
<span style="opacity:.7">— 或点右边按钮让它自动预选下面各标签页的字段</span></div>
|
||||
<textarea id="cgPendingPrompt" rows="3" style="width:100%;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:4px;padding:5px 8px;font-size:12px">${esc(String(pend.prompt))}</textarea>
|
||||
</div>` : ''}
|
||||
<div style="margin-top:6px;display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
${pend.prompt ? `<button id="cgPrefillBtn" onclick="window._cgPrefillFromPrompt()"
|
||||
style="background:#8b5cf6;color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600">✨ 按提示词预填字段</button>` : ''}
|
||||
<span id="cgPrefillMsg" style="font-size:11px;color:var(--text2)"></span>
|
||||
<button onclick="window._cgDropPending()" style="margin-left:auto;background:none;border:none;color:var(--text2);cursor:pointer;font-size:11px">取消带入</button>
|
||||
</div>
|
||||
</div>`;
|
||||
const nm = document.getElementById('cgName');
|
||||
if (nm && !nm.value) nm.value = pend.slot;
|
||||
}
|
||||
|
||||
window._cgDropPending = () => {
|
||||
window._cgPending = null;
|
||||
try { localStorage.removeItem('ave_char_pending'); } catch (e) {}
|
||||
const box = document.getElementById('cgPending');
|
||||
if (box) box.innerHTML = '';
|
||||
};
|
||||
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
// ✨ 按 AI 提示词预填字段 / 👗 服装库引用(用户 2026-10-03 需求)
|
||||
// ═══════════════════════════════════════════════════════════
|
||||
|
||||
window._cgPrefillFromPrompt = async () => {
|
||||
const ta = document.getElementById('cgPendingPrompt');
|
||||
const text = (ta ? ta.value : '').trim();
|
||||
const msg = document.getElementById('cgPrefillMsg');
|
||||
const btn = document.getElementById('cgPrefillBtn');
|
||||
if (!text) { alert('提示词为空 —— 可以先填一些人物描述'); return; }
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ AI 解析中…'; }
|
||||
if (msg) msg.textContent = '';
|
||||
try {
|
||||
const d = await _json(`${API}/characters/parse-prompt`, 'POST', { text });
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '解析失败');
|
||||
_cgSaveTab(); // ① 先保存用户当前输入
|
||||
let n = 0;
|
||||
Object.entries(d.layers || {}).forEach(([lid, kv]) => {
|
||||
_values[lid] = _values[lid] || {};
|
||||
Object.entries(kv || {}).forEach(([k, v]) => { _values[lid][k] = v; n++; });
|
||||
});
|
||||
window._cgPromptEdited = false; // 预填后恢复自动组装
|
||||
_cgSwitchTab(_tab, true); // ② 跳过保存地重渲染(否则刚写的预填被空 DOM 覆盖)
|
||||
if (window._cgLivePreview) window._cgLivePreview();
|
||||
if (msg) msg.textContent = `✅ 已预填 ${n} 个字段 —— 切到各标签页查看/调整(未提及的字段保持为空)`;
|
||||
window._cgPending = window._cgPending || {};
|
||||
window._cgPending.prompt = text; // 用户编辑后的版本(生成时并入)
|
||||
} catch (e) {
|
||||
if (msg) msg.textContent = '❌ ' + e.message;
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '✨ 按提示词预填字段'; }
|
||||
}
|
||||
};
|
||||
|
||||
let _wardrobes = [];
|
||||
async function _cgLoadWardrobe() {
|
||||
const sel = document.getElementById('cgWardrobe');
|
||||
if (!sel) return;
|
||||
try {
|
||||
const d = await _json(`${API}/wardrobe?limit=200`, 'GET');
|
||||
_wardrobes = d.items || [];
|
||||
const cur = window._cgWardrobeRef ? window._cgWardrobeRef.id : '';
|
||||
sel.innerHTML = '<option value="">(不使用服装库 —— 即"不带服装",由上面的字段描述)</option>' +
|
||||
_wardrobes.map(x => `<option value="${esc(x.id)}" ${x.id === cur ? 'selected' : ''}>${esc(x.name || x.id)}</option>`).join('');
|
||||
} catch (e) { /* 静默 */ }
|
||||
}
|
||||
|
||||
window._cgApplyWardrobe = () => {
|
||||
const sel = document.getElementById('cgWardrobe');
|
||||
const id = sel ? sel.value : '';
|
||||
const msg = document.getElementById('cgWardrobeMsg');
|
||||
if (!id) { alert('请先选择一个服装'); return; }
|
||||
const w = _wardrobes.find(x => x.id === id);
|
||||
if (!w) return;
|
||||
window._cgWardrobeRef = { id: w.id, name: w.name || w.id, desc: (w.description || w.notes || '') };
|
||||
if (msg) msg.textContent = `✅ 已引用「${w.name || w.id}」—— 生成时会并入提示词`;
|
||||
if (window._cgLivePreview) window._cgLivePreview();
|
||||
};
|
||||
|
||||
window._cgClearWardrobe = () => {
|
||||
window._cgWardrobeRef = null;
|
||||
const sel = document.getElementById('cgWardrobe');
|
||||
if (sel) sel.value = '';
|
||||
const msg = document.getElementById('cgWardrobeMsg');
|
||||
if (msg) msg.textContent = '已清除服装引用';
|
||||
if (window._cgLivePreview) window._cgLivePreview();
|
||||
};
|
||||
|
||||
function _cgShowWardrobe() {
|
||||
const msg = document.getElementById('cgWardrobeMsg');
|
||||
if (msg && window._cgWardrobeRef) msg.textContent = `已引用「${window._cgWardrobeRef.name}」`;
|
||||
}
|
||||
|
||||
/** 🔄 重新按字段组装预览(覆盖手动编辑) */
|
||||
window._cgRebuildPreview = () => {
|
||||
window._cgPromptEdited = false;
|
||||
window._cgLivePreview();
|
||||
if (window.showToast) window.showToast('已按字段重新组装预览');
|
||||
};
|
||||
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* characters.js — 🧑 角色库(P3 重做版)
|
||||
*
|
||||
* 后端:/api/ave/assets/characters(列表/搜索/筛选/详情/新建/更新/删除/上传文件)
|
||||
* 数据:agent-local/tools/ave/assets/characters/{id}/(meta.json + 参考图 + 音色)
|
||||
*
|
||||
* 功能:
|
||||
* · 搜索(名字/标签/描述)+ 筛选(类型/用途/项目/标签/有音色)+ 排序
|
||||
* · 卡片 / 表格 两种视图
|
||||
* · 新建(名字/类型/用途/描述)
|
||||
* · 详情抽屉:编辑字段 + 上传参考图(正/侧/背/全身)+ 删除
|
||||
*/
|
||||
|
||||
const API = '/api/ave/assets/characters';
|
||||
|
||||
// 模块状态
|
||||
let _items = [];
|
||||
let _stats = {};
|
||||
let _view = localStorage.getItem('ave_char_view') || 'card'; // card | table
|
||||
let _filters = { q: '', type: '', role: '', tag: '', hasVoice: false, sort: 'updated_desc' };
|
||||
let _current = null; // 当前打开详情的角色
|
||||
let _uid = '';
|
||||
|
||||
const TYPES = { real: '真人', anime: '动漫', '3d': '3D' };
|
||||
const ROLES = { lead: '主角', support: '配角', extra: '群演' };
|
||||
const REF_KEYS = [
|
||||
['front', '正面'], ['side', '侧面'], ['back', '背面'], ['full', '全身'],
|
||||
];
|
||||
|
||||
export async function loadView(container) {
|
||||
_uid = 'ch';
|
||||
container.innerHTML = shell();
|
||||
bind();
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function shell() {
|
||||
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">🧑 角色库
|
||||
<span id="chCount" style="font-size:12px;color:var(--text2)"></span>
|
||||
</h2>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button onclick="window._chToggleView()" id="chViewBtn" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:5px 12px;border-radius:6px;cursor:pointer;font-size:12px">📋 表格视图</button>
|
||||
<button onclick="window._chNew()" 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="chQ" placeholder="🔍 搜索名字 / 标签 / 描述" style="flex:1;min-width:180px;padding:5px 10px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
<select id="chType" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
<option value="">类型:全部</option>
|
||||
<option value="real">真人</option><option value="anime">动漫</option><option value="3d">3D</option>
|
||||
</select>
|
||||
<select id="chRole" style="padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
<option value="">用途:全部</option>
|
||||
<option value="lead">主角</option><option value="support">配角</option><option value="extra">群演</option>
|
||||
</select>
|
||||
<input id="chTag" placeholder="标签" style="width:90px;padding:5px 8px;background:var(--bg2);border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px">
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:12px;color:var(--text2);cursor:pointer">
|
||||
<input type="checkbox" id="chVoice"> 有音色
|
||||
</label>
|
||||
<select id="chSort" 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._chReset()" 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="chList"><div style="color:var(--text2);font-size:12px">⏳ 加载中...</div></div>
|
||||
</div>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<div id="chDrawer" 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="chDrawerBody" style="padding:16px"></div>
|
||||
</div>
|
||||
<div id="chMask" onclick="window._chClose()" style="display:none;position:fixed;inset:0;background:rgba(0,0,0,.35);z-index:998"></div>`;
|
||||
}
|
||||
|
||||
function bind() {
|
||||
const q = document.getElementById('chQ');
|
||||
let t = null;
|
||||
q.addEventListener('input', () => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => { _filters.q = q.value.trim(); refresh(); }, 300);
|
||||
});
|
||||
document.getElementById('chType').addEventListener('change', e => { _filters.type = e.target.value; refresh(); });
|
||||
document.getElementById('chRole').addEventListener('change', e => { _filters.role = e.target.value; refresh(); });
|
||||
document.getElementById('chTag').addEventListener('input', e => {
|
||||
clearTimeout(t);
|
||||
t = setTimeout(() => { _filters.tag = e.target.value.trim(); refresh(); }, 300);
|
||||
});
|
||||
document.getElementById('chVoice').addEventListener('change', e => { _filters.hasVoice = e.target.checked; refresh(); });
|
||||
document.getElementById('chSort').addEventListener('change', e => { _filters.sort = e.target.value; refresh(); });
|
||||
updateViewBtn();
|
||||
}
|
||||
|
||||
function updateViewBtn() {
|
||||
const b = document.getElementById('chViewBtn');
|
||||
if (b) b.textContent = _view === 'card' ? '📋 表格视图' : '🔳 卡片视图';
|
||||
}
|
||||
|
||||
window._chToggleView = () => {
|
||||
_view = _view === 'card' ? 'table' : 'card';
|
||||
localStorage.setItem('ave_char_view', _view);
|
||||
updateViewBtn();
|
||||
renderList();
|
||||
};
|
||||
|
||||
window._chReset = () => {
|
||||
_filters = { q: '', type: '', role: '', tag: '', hasVoice: false, sort: 'updated_desc' };
|
||||
['chQ', 'chTag'].forEach(id => { const e = document.getElementById(id); if (e) e.value = ''; });
|
||||
['chType', 'chRole', 'chSort'].forEach(id => { const e = document.getElementById(id); if (e) e.value = id === 'chSort' ? 'updated_desc' : ''; });
|
||||
const v = document.getElementById('chVoice'); if (v) v.checked = false;
|
||||
refresh();
|
||||
};
|
||||
|
||||
async function refresh() {
|
||||
const p = new URLSearchParams();
|
||||
if (_filters.q) p.set('q', _filters.q);
|
||||
if (_filters.type) p.set('type', _filters.type);
|
||||
if (_filters.role) p.set('role', _filters.role);
|
||||
if (_filters.tag) p.set('tag', _filters.tag);
|
||||
if (_filters.hasVoice) p.set('has_voice', '1');
|
||||
p.set('sort', _filters.sort);
|
||||
try {
|
||||
const r = await fetch(`${API}?${p}`);
|
||||
const d = await r.json();
|
||||
_items = d.items || [];
|
||||
renderList();
|
||||
const sr = await fetch(`${API}/stats`);
|
||||
_stats = (await sr.json()).stats || {};
|
||||
const c = document.getElementById('chCount');
|
||||
if (c) c.textContent = `共 ${_stats.total || 0} 个(真人 ${(_stats.by_type || {}).real || 0} · 动漫 ${(_stats.by_type || {}).anime || 0} · 有音色 ${_stats.with_voice || 0})`;
|
||||
} catch (e) {
|
||||
const el = document.getElementById('chList');
|
||||
if (el) el.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 加载失败:${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById('chList');
|
||||
if (!el) return;
|
||||
if (!_items.length) {
|
||||
const hasFilter = _filters.q || _filters.type || _filters.role || _filters.tag || _filters.hasVoice;
|
||||
el.innerHTML = `<div style="color:var(--text2);font-size:12px;padding:20px;text-align:center">
|
||||
${hasFilter ? '🔍 没有匹配的角色(试试重置筛选)' : '还没有角色,点右上角「➕ 新建角色」开始'}</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = _view === 'card' ? _cardHtml() : _tableHtml();
|
||||
}
|
||||
|
||||
/** 📌 加载生成历史(版本 / 提示词 / seed / 模型 —— 供版本说明追溯) */
|
||||
window._chLoadHistory = async () => {
|
||||
const el = document.getElementById('chHistory');
|
||||
if (!el || !_current) return;
|
||||
el.innerHTML = '<div style="font-size:11px;color:var(--text2)">⏳ 加载生成历史…</div>';
|
||||
try {
|
||||
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/history`);
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { el.innerHTML = ''; return; }
|
||||
const hist = d.history || [];
|
||||
if (!hist.length) {
|
||||
el.innerHTML = `<div style="font-size:11px;color:var(--text2)">📌 暂无生成记录(用 AI 生成的图会自动记录:提示词 / seed / 模型 / 引擎)</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = `<div style="font-size:12px;font-weight:600;margin-bottom:6px">📌 生成历史(${hist.length} 版${d.active_version ? ` · 当前 v${d.active_version}` : ''})</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
${hist.slice().reverse().map(h => `
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px;font-size:11px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<b>v${h.version}</b><span style="color:var(--text2);font-size:10px">${esc(h.at || '')}</span>
|
||||
</div>
|
||||
<div style="color:var(--text2);margin-top:4px">
|
||||
${h.engine ? `引擎 <b>${esc(h.engine)}</b>` : ''}${h.model ? ` · 模型 <b>${esc(h.model)}</b>` : ''}${h.seed != null ? ` · seed <b>${h.seed}</b>` : ''}${h.slot ? ` · 槽位 ${esc(h.slot)}` : ''}
|
||||
</div>
|
||||
${h.prompt ? `<div style="margin-top:5px;background:var(--bg2);border-radius:4px;padding:5px;max-height:90px;overflow:auto;line-height:1.6">${esc(h.prompt)}</div>` : ''}
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
el.innerHTML = `<div style="font-size:11px;color:#f59e0b">❌ 历史加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
window._chLock = async () => {
|
||||
const reason = prompt('🔒 锁定原因(可留空):', '手动锁定');
|
||||
if (reason === null) return;
|
||||
try {
|
||||
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/lock`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason || '手动锁定' }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.detail) { alert('❌ ' + d.detail); return; }
|
||||
alert('🔒 已锁定(参考图/seed/音色已冻结)'); window._chOpen(_current.id);
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._chUnlock = async () => {
|
||||
const reason = prompt('🔓 解锁原因(会记入历史,便于追溯):', '');
|
||||
if (reason === null) return;
|
||||
try {
|
||||
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/unlock`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reason: reason || '' }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.detail) { alert('❌ ' + d.detail); return; }
|
||||
alert('🔓 已解锁(改动已记入历史)'); window._chOpen(_current.id);
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
function thumb(m) {
|
||||
const refs = m.reference_images || {};
|
||||
// 优先标准槽位;否则取**任意第一个**有图的槽位(兼容 gen1 / portrait / front_face 等)
|
||||
const preferred = ['front', 'portrait', 'front_face', 'side', 'full', 'back'];
|
||||
const k = preferred.find(x => refs[x]) || Object.keys(refs).find(x => refs[x]);
|
||||
if (!k || !refs[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:28px">🧑</div>`;
|
||||
}
|
||||
const url = `${API}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(refs[k])}`;
|
||||
return `<img src="${url}" loading="lazy" title="点击看大图"
|
||||
onclick="event.stopPropagation();window._chViewImage('${esc(url)}','${esc(m.name || m.id)}')"
|
||||
style="width:100%;height:120px;object-fit:contain;border-radius:6px;background:var(--bg2);cursor:zoom-in"
|
||||
onerror="this.outerHTML='<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:28px\\'>🧑</div>'">`;
|
||||
}
|
||||
|
||||
/** 点击缩略图 → 全屏看大图(含「保存到本地」) */
|
||||
window._chViewImage = (url, title) => {
|
||||
const ov = document.createElement('div');
|
||||
ov.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.92);z-index:99999;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:10px;cursor:zoom-out';
|
||||
ov.onclick = () => ov.remove();
|
||||
ov.innerHTML = `
|
||||
<div style="color:#fff;font-size:13px">${title}</div>
|
||||
<img src="${url}" style="max-width:92vw;max-height:80vh;object-fit:contain;border-radius:8px;background:#111">
|
||||
<div style="display:flex;gap:10px;align-items:center">
|
||||
<a href="${url}" download="${title}.png" onclick="event.stopPropagation()"
|
||||
style="color:#4ade80;font-size:12px;text-decoration:none">⬇ 下载原图</a>
|
||||
<span style="color:#888;font-size:11px">点击任意处关闭</span>
|
||||
</div>`;
|
||||
document.body.appendChild(ov);
|
||||
};
|
||||
|
||||
function _cardHtml() {
|
||||
return `<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px">
|
||||
${_items.map(m => `
|
||||
<div onclick="window._chOpen('${esc(m.id)}')" style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:10px;cursor:pointer">
|
||||
${thumb(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">
|
||||
${TYPES[m.type] || '真人'} · ${ROLES[m.role] || '主角'}${(m.voice || {}).sample ? ' · 🔊' : ''}
|
||||
</div>
|
||||
<div style="font-size:10px;color:var(--text2);margin-top:4px">
|
||||
用过 ${m.usage_count || 0} 次${(m.tags || []).length ? ' · ' + (m.tags || []).slice(0, 2).map(esc).join('/') : ''}
|
||||
</div>
|
||||
</div>`).join('')}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _tableHtml() {
|
||||
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><th style="padding:6px">类型</th><th style="padding:6px">用途</th>
|
||||
<th style="padding:6px">标签</th><th style="padding:6px">音色</th>
|
||||
<th style="padding:6px">使用</th><th style="padding:6px">更新时间</th><th style="padding:6px">操作</th>
|
||||
</tr></thead>
|
||||
<tbody>
|
||||
${_items.map(m => `<tr style="border-bottom:1px solid var(--border)">
|
||||
<td style="padding:6px;font-weight:600">${esc(m.name || m.id)}</td>
|
||||
<td style="padding:6px">${TYPES[m.type] || '真人'}</td>
|
||||
<td style="padding:6px">${ROLES[m.role] || '主角'}</td>
|
||||
<td style="padding:6px;color:var(--text2)">${(m.tags || []).slice(0, 3).map(esc).join(' / ') || '—'}</td>
|
||||
<td style="padding:6px">${(m.voice || {}).sample ? '🔊' : '—'}</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="event.stopPropagation();window._chOpen('${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>`;
|
||||
}
|
||||
|
||||
// ── 详情抽屉 ────────────────────────────────────────────
|
||||
|
||||
window._chOpen = async (aid) => {
|
||||
try {
|
||||
const r = await fetch(`${API}/${encodeURIComponent(aid)}`);
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '加载失败')); return; }
|
||||
_current = d.item;
|
||||
renderDrawer();
|
||||
document.getElementById('chDrawer').style.display = 'block';
|
||||
document.getElementById('chMask').style.display = 'block';
|
||||
window._chLoadHistory(); // 📌 加载生成历史(版本/提示词/seed/模型)
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._chClose = () => {
|
||||
document.getElementById('chDrawer').style.display = 'none';
|
||||
document.getElementById('chMask').style.display = 'none';
|
||||
_current = null;
|
||||
};
|
||||
|
||||
function renderDrawer() {
|
||||
const m = _current;
|
||||
const refs = m.reference_images || {};
|
||||
const files = m.files || {};
|
||||
document.getElementById('chDrawerBody').innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:12px">
|
||||
<div style="font-size:16px;font-weight:600">🧑 ${esc(m.name || m.id)}</div>
|
||||
<button onclick="window._chClose()" 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))}
|
||||
</div>
|
||||
|
||||
<!-- 🔒 锁定状态(角色一致性保障:使用后自动锁定) -->
|
||||
<div style="margin-bottom:12px;padding:8px 10px;border-radius:6px;font-size:11px;
|
||||
background:${m.locked ? 'rgba(245,158,11,.1)' : 'var(--bg3)'};
|
||||
border:1px solid ${m.locked ? '#f59e0b' : 'var(--border)'}">
|
||||
${m.locked
|
||||
? `<b style="color:#f59e0b">🔒 已锁定</b> · ${esc(m.locked_reason || '')}
|
||||
<div style="color:var(--text2);margin-top:2px">锁定时间:${esc(m.locked_at || '')}${m.locked_by === 'auto' ? '(自动)' : '(手动)'} · 参考图/生成历史/seed/音色已冻结</div>
|
||||
<button onclick="window._chUnlock()" style="margin-top:6px;background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">🔓 解锁(需说明原因)</button>`
|
||||
: `<b>🔓 未锁定</b> · 参考图/seed/音色可修改
|
||||
<div style="color:var(--text2);margin-top:2px">提示:角色一经流程使用会**自动锁定**(保证一致性);也可手动锁定</div>
|
||||
<button onclick="window._chLock()" style="margin-top:6px;background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:3px 10px;border-radius:4px;cursor:pointer;font-size:11px">🔒 立即锁定</button>`}
|
||||
</div>
|
||||
|
||||
<!-- 📌 生成历史(版本 / 提示词 / seed / 模型 —— 供版本说明追溯) -->
|
||||
<div id="chHistory" style="margin-bottom:12px"></div>
|
||||
|
||||
<div style="display:grid;gap:8px;margin-bottom:16px">
|
||||
<label style="font-size:11px;color:var(--text2)">名字
|
||||
<input id="dName" 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>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px">
|
||||
<label style="font-size:11px;color:var(--text2)">类型
|
||||
<select id="dType" 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(TYPES).map(([k, v]) => `<option value="${k}" ${m.type === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select></label>
|
||||
<label style="font-size:11px;color:var(--text2)">用途
|
||||
<select id="dRole" 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(ROLES).map(([k, v]) => `<option value="${k}" ${m.role === k ? 'selected' : ''}>${v}</option>`).join('')}
|
||||
</select></label>
|
||||
</div>
|
||||
<label style="font-size:11px;color:var(--text2)">标签(逗号分隔)
|
||||
<input id="dTags" 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>
|
||||
<label style="font-size:11px;color:var(--text2)">描述
|
||||
<textarea id="dDesc" 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(m.description || '')}</textarea></label>
|
||||
<button onclick="window._chSave()" 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>
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:8px;margin-bottom:12px">
|
||||
${(() => {
|
||||
// 🔧 动态槽位:**已存在的全部槽位**(含 gen1 / portrait 等自定义名)+ 未填的标准槽位
|
||||
const LABELS = { front: '正面', side: '侧面', back: '背面', full: '全身',
|
||||
portrait: '半身', front_face: '正面脸部', grid: '九宫格',
|
||||
scene: '场景照', expression: '表情特写' };
|
||||
const out = [];
|
||||
const seen = new Set();
|
||||
Object.keys(refs).filter(x => refs[x]).forEach(x => {
|
||||
out.push([x, LABELS[x] || x]); seen.add(x);
|
||||
});
|
||||
REF_KEYS.forEach(([x, lb]) => { if (!seen.has(x)) out.push([x, lb]); });
|
||||
return out;
|
||||
})().map(([k, label]) => {
|
||||
const f = refs[k];
|
||||
const url = f ? `${API}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(f)}` : '';
|
||||
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">${label}</div>
|
||||
${f ? `<img src="${url}" title="点击看大图"
|
||||
onclick="window._chViewImage('${esc(url)}','${esc(label)}')"
|
||||
style="width:100%;height:120px;object-fit:contain;border-radius:4px;background:var(--bg2);cursor:zoom-in">
|
||||
<button onclick="window._chDelRef('${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:120px;display:flex;align-items:center;justify-content:center;color:var(--text2);font-size:20px">+</div>`}
|
||||
<input type="file" accept="image/*" onchange="window._chUpRef('${k}', this)" style="margin-top:4px;font-size:10px;width:100%">
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">🔊 音色</div>
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:8px;margin-bottom:12px">
|
||||
${(m.voice || {}).sample
|
||||
? `<audio controls src="${API}/${encodeURIComponent(m.id)}/file/${encodeURIComponent(m.voice.sample)}" style="width:100%"></audio>`
|
||||
: '<div style="font-size:11px;color:var(--text2)">还没有音色样本</div>'}
|
||||
<input type="file" accept="audio/*" onchange="window._chUpVoice(this)" style="margin-top:6px;font-size:10px;width:100%">
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;border-top:1px solid var(--border);padding-top:12px">
|
||||
<button onclick="window._chUse()" 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._chDelete()" 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>
|
||||
|
||||
${Object.keys(files).length ? `<div style="margin-top:12px;font-size:10px;color:var(--text2)">目录文件:${Object.entries(files).map(([d, fs]) => `${d}/ (${fs.length})`).join(' · ')}</div>` : ''}`;
|
||||
}
|
||||
|
||||
window._chSave = async () => {
|
||||
if (!_current) return;
|
||||
const patch = {
|
||||
name: document.getElementById('dName').value.trim(),
|
||||
type: document.getElementById('dType').value,
|
||||
role: document.getElementById('dRole').value,
|
||||
tags: document.getElementById('dTags').value.split(',').map(s => s.trim()).filter(Boolean),
|
||||
description: document.getElementById('dDesc').value.trim(),
|
||||
};
|
||||
try {
|
||||
const r = await fetch(`${API}/${encodeURIComponent(_current.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; }
|
||||
_current = d.item;
|
||||
renderDrawer();
|
||||
await refresh();
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._chUpRef = async (key, input) => {
|
||||
const f = input.files && input.files[0];
|
||||
if (!f || !_current) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
try {
|
||||
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/file?name=${encodeURIComponent('ref_' + key + (f.name.match(/\.\w+$/) || ['.png'])[0])}`,
|
||||
{ method: 'POST', body: fd });
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '上传失败')); return; }
|
||||
// 更新 meta 里的 reference_images 映射
|
||||
const refs = { ...(_current.reference_images || {}), [key]: d.path };
|
||||
const r2 = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reference_images: refs }),
|
||||
});
|
||||
_current = (await r2.json()).item;
|
||||
renderDrawer();
|
||||
await refresh();
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._chDelRef = async (key) => {
|
||||
if (!_current || !confirm(`删除「${key}」参考图?(仅解除引用,文件保留在目录)`)) return;
|
||||
const refs = { ...(_current.reference_images || {}) };
|
||||
delete refs[key];
|
||||
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ reference_images: refs }),
|
||||
});
|
||||
_current = (await r.json()).item;
|
||||
renderDrawer();
|
||||
await refresh();
|
||||
};
|
||||
|
||||
window._chUpVoice = async (input) => {
|
||||
const f = input.files && input.files[0];
|
||||
if (!f || !_current) return;
|
||||
const fd = new FormData();
|
||||
fd.append('file', f);
|
||||
try {
|
||||
const ext = (f.name.match(/\.\w+$/) || ['.mp3'])[0];
|
||||
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}/file?name=${encodeURIComponent('voice_sample' + ext)}`,
|
||||
{ method: 'POST', body: fd });
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '上传失败')); return; }
|
||||
const r2 = await fetch(`${API}/${encodeURIComponent(_current.id)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ voice: { ...(_current.voice || {}), sample: d.path } }),
|
||||
});
|
||||
_current = (await r2.json()).item;
|
||||
renderDrawer();
|
||||
await refresh();
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._chUse = () => {
|
||||
if (!_current) return;
|
||||
// 存到 localStorage → 导演创作页自动预选(不用手抄 ID)
|
||||
try { localStorage.setItem('dir_preset_character', _current.id); } catch (e) {}
|
||||
if (confirm(`🔗 使用角色「${_current.name}」\n\n· 执行器会按 ID 从资源库取该角色的参考图 / 音色\n· 分镜级:可在每个镜头单独指定不同角色\n\n是否跳转到「🎬 导演创作」并自动选中它?`)) {
|
||||
if (window.switchView) window.switchView('ave-director');
|
||||
}
|
||||
};
|
||||
|
||||
window._chDelete = async () => {
|
||||
if (!_current || !confirm(`删除角色「${_current.name || _current.id}」?\n(默认移到 _trash/,可恢复)`)) return;
|
||||
try {
|
||||
const r = await fetch(`${API}/${encodeURIComponent(_current.id)}`, { method: 'DELETE' });
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '删除失败')); return; }
|
||||
window._chClose();
|
||||
await refresh();
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
window._chNew = async () => {
|
||||
const name = prompt('角色名字:');
|
||||
if (!name) return;
|
||||
const type = prompt('类型(real 真人 / anime 动漫 / 3d):', 'real') || 'real';
|
||||
const role = prompt('用途(lead 主角 / support 配角 / extra 群演):', 'lead') || 'lead';
|
||||
const desc = prompt('一句话描述(可空):') || '';
|
||||
try {
|
||||
const r = await fetch(API, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ name, type, role, description: desc }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ ' + (d.detail || '创建失败')); return; }
|
||||
await refresh();
|
||||
window._chOpen(d.item.id); // 直接打开详情,方便补图和音色
|
||||
} catch (e) { alert('❌ ' + e.message); }
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,114 @@
|
||||
/**
|
||||
* costs.js — 💰 费用统计(视频工厂)
|
||||
*
|
||||
* 数据源:GET /api/ave/costs(只读,不产生费用)
|
||||
* 三块:可灵(任务数/时长/估算)· 出片(记录/段数/大小)· AI 文本(调用/token)
|
||||
*/
|
||||
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 style="font-size:12px;color:var(--text2)">视频工厂消耗(估算,以供应商账单为准)</span></h2>
|
||||
<button onclick="window._costLoad()" 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 id="costBody"><div style="color:var(--text2);font-size:12px">⏳ 加载中…</div></div>
|
||||
</div>`;
|
||||
await window._costLoad();
|
||||
}
|
||||
|
||||
function bigCard(value, unit, label, color, sub) {
|
||||
return `<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:14px;text-align:center">
|
||||
<div style="font-size:22px;font-weight:700;color:${color}">${value}<span style="font-size:12px;font-weight:400;color:var(--text2);margin-left:3px">${unit || ''}</span></div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:2px">${esc(label)}</div>
|
||||
${sub ? `<div style="font-size:10px;color:var(--text2);opacity:.8;margin-top:3px">${esc(sub)}</div>` : ''}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window._costLoad = async () => {
|
||||
const el = document.getElementById('costBody');
|
||||
if (el) el.innerHTML = '<div style="color:var(--text2);font-size:12px">⏳ 加载中…</div>';
|
||||
try {
|
||||
const r = await fetch(`${API}/costs`);
|
||||
const d = await r.json();
|
||||
const k = d.kling || {};
|
||||
const rd = d.renders || {};
|
||||
const ai = d.ai_text || {};
|
||||
const est = d.estimate || {};
|
||||
|
||||
const kErr = k.error ? `<div style="color:#f59e0b;font-size:11px;margin-top:6px">⚠️ ${esc(k.error)}</div>` : '';
|
||||
const klingCards = k.error ? '' : `
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:8px">
|
||||
${bigCard(k.tasks || 0, '个', '成功任务', 'var(--text)')}
|
||||
${bigCard((k.total_seconds || 0).toFixed(0), '秒', '生成总时长', 'var(--text)')}
|
||||
${bigCard('¥' + (est.kling_cost || 0).toFixed(2), '', '估算费用', '#f59e0b', k.unit_price || '')}
|
||||
${bigCard((k.by_kind || {}).text2video ? k.by_kind.text2video.tasks : 0, '个', '文生视频', 'var(--text2)')}
|
||||
${bigCard((k.by_kind || {}).image2video ? k.by_kind.image2video.tasks : 0, '个', '图生视频', 'var(--text2)')}
|
||||
</div>`;
|
||||
|
||||
// ★ 官方资源包余量(真实数据,非估算)
|
||||
const ka = d.kling_account || {};
|
||||
const packCards = (ka.packs || []).map(p => {
|
||||
const exp = p.expire_at ? new Date(p.expire_at).toLocaleDateString('zh-CN') : '?';
|
||||
const pct = p.used_pct || 0;
|
||||
const color = pct >= 80 ? '#ef4444' : (pct >= 50 ? '#f59e0b' : '#22c55e');
|
||||
return `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:10px">
|
||||
<div style="font-size:12px;font-weight:600">${esc(p.name)}</div>
|
||||
<div style="font-size:18px;font-weight:700;color:${color};margin:4px 0">
|
||||
${p.remaining}<span style="font-size:11px;font-weight:400;color:var(--text2)"> / ${p.total} 剩余</span></div>
|
||||
<div style="height:5px;background:var(--bg3);border-radius:3px;overflow:hidden;margin:4px 0">
|
||||
<div style="height:100%;width:${pct}%;background:${color}"></div></div>
|
||||
<div style="font-size:10px;color:var(--text2)">已用 ${p.used}(${pct}%)· 到期 ${exp} · ${esc(p.status || '')}</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const accountBlock = (ka.packs && ka.packs.length) ? `
|
||||
<div style="background:var(--bg3);border:1px solid #3b82f6;border-radius:8px;padding:12px 14px;margin-bottom:12px">
|
||||
<div style="font-size:13px;font-weight:600">💰 可灵账号资源包(官方数据
|
||||
<span style="font-size:10px;font-weight:400;color:var(--text2)">余量有 12 小时延迟 · 现金余额请看可灵控制台</span>)</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:8px;margin-top:8px">${packCards}</div>
|
||||
</div>` : (ka.error ? `
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:10px 14px;margin-bottom:12px;font-size:11px;color:#f59e0b">
|
||||
⚠️ 资源包查询失败:${esc(ka.error)}</div>` : '');
|
||||
|
||||
el.innerHTML = `
|
||||
${accountBlock}
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px 14px;margin-bottom:12px">
|
||||
<div style="font-size:13px;font-weight:600">🎬 可灵(视频生成)</div>
|
||||
${kErr}${klingCards}
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px 14px;margin-bottom:12px">
|
||||
<div style="font-size:13px;font-weight:600">📋 出片</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:8px">
|
||||
${bigCard(rd.count || 0, '条', '总记录', 'var(--text)')}
|
||||
${bigCard(rd.finished || 0, '条', '已出片', '#22c55e')}
|
||||
${bigCard(rd.segments || 0, '段', '总镜头段数', 'var(--text2)')}
|
||||
${bigCard(rd.total_mb || 0, 'MB', '产物总大小', 'var(--text2)')}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:12px 14px;margin-bottom:12px">
|
||||
<div style="font-size:13px;font-weight:600">🤖 AI 文本(剧本/分镜/评论)</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fit,minmax(140px,1fr));gap:8px;margin-top:8px">
|
||||
${bigCard(ai.calls || 0, '次', '调用次数', 'var(--text)')}
|
||||
${bigCard(((ai.input_tokens || 0) / 1000).toFixed(1), 'K', '输入 token', 'var(--text2)')}
|
||||
${bigCard(((ai.output_tokens || 0) / 1000).toFixed(1), 'K', '输出 token', 'var(--text2)')}
|
||||
</div>
|
||||
${ai.unit_price ? `<div style="font-size:10px;color:var(--text2);margin-top:6px">计费:${esc(ai.unit_price)}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="font-size:11px;color:var(--text2);line-height:1.8">
|
||||
📌 说明:以上为**估算值**(可灵按官方价 ¥0.6/秒 · Kling 3.0 无声 720P 折算),
|
||||
实际以可灵/供应商的账单为准。点击「🔄 刷新」会重新查询可灵任务历史(**只读,不产生费用**)。
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
if (el) el.innerHTML = `<div style="color:#f59e0b;font-size:12px">❌ 加载失败:${esc(e.message)}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
@@ -0,0 +1,966 @@
|
||||
/**
|
||||
* creative-studio.js — 💡 创意工坊(GapEngine)
|
||||
*
|
||||
* 五标签页:
|
||||
* ① 六层参数(38 参数全平铺,挑着设 → 底部实时缺口试算)
|
||||
* ② 负面矩阵(🔴硬违禁锁死 / 🟡观众反感可放开做反差)
|
||||
* ③ 正向矩阵(勾选价值方向,命中加分)
|
||||
* ④ 道具/场景(勾选可用变量 + 手动增补)
|
||||
* ⑤ 生成&筛选(输入 1-2 句 → AI 出 30 条 → 密集列表筛选 → 入库)
|
||||
*
|
||||
* 设计取向:一切为"快速筛选"服务——单行一条、键盘/批量操作、风险一眼可见。
|
||||
*/
|
||||
import * as store from '../modules/project-store.js';
|
||||
|
||||
const API = '/api/ave/creatives';
|
||||
|
||||
const LS_KEY = 'ave_creative_studio_v1'; // 本页状态缓存(输入/选择/候选/矩阵编辑)
|
||||
|
||||
/**
|
||||
* ★ 把「选中的创意」同步进项目对象(project.creative)
|
||||
*
|
||||
* 为什么必须做:之前这里只写 localStorage —— 而「📝 剧本」生成时读的是
|
||||
* project.creative → **永远是空的** → 创意对下游完全没起作用(用户说"链条不通",
|
||||
* 这是真正的断点)。localStorage 只是本页缓存,不能当跨阶段的传递通道。
|
||||
*/
|
||||
let _syncTimer = null;
|
||||
async function syncCreative(immediate) {
|
||||
const picked = [...S.selected]
|
||||
.map(cid => S.candidates.find(c => c.cid === cid))
|
||||
.filter(Boolean)
|
||||
.map(c => ({
|
||||
cid: c.cid,
|
||||
one_liner: c.one_liner || c.one_line || c.title || '',
|
||||
// 摘要字段:避免候选全文塞进 project.json
|
||||
params: c.params || c.picks || undefined,
|
||||
gap_score: c.gap_score ?? c.score ?? undefined,
|
||||
risks: Array.isArray(c.risks) ? c.risks.slice(0, 3) : undefined,
|
||||
}));
|
||||
const patch = {
|
||||
creative: {
|
||||
selected: picked,
|
||||
brief: S.brief || '',
|
||||
count: picked.length,
|
||||
inject_mode: (S.genMeta && S.genMeta.inject_mode) || undefined,
|
||||
gen_meta: S.genMeta ? {
|
||||
mode: S.genMeta.mode, total: S.genMeta.total,
|
||||
bystander_ratio: S.genMeta.bystander_ratio,
|
||||
} : undefined,
|
||||
updated_at: new Date().toISOString(),
|
||||
},
|
||||
};
|
||||
const doIt = async () => {
|
||||
try { await store.patchProject(patch); } catch (e) { /* 静默:不打断创作 */ }
|
||||
};
|
||||
if (immediate) return doIt();
|
||||
clearTimeout(_syncTimer);
|
||||
_syncTimer = setTimeout(doIt, 600); // 防抖:连点选多个不刷爆接口
|
||||
}
|
||||
let _uid = 'cs';
|
||||
const S = { // 页面状态
|
||||
library: null, matrix: null,
|
||||
tab: 'params',
|
||||
picks: {}, // {layerId|paramId: value} Tab① 的手动选择
|
||||
gap: null, // 实时试算结果
|
||||
brief: '', candidates: [], selected: new Set(), showFiltered: false,
|
||||
matrixDirty: false, genMeta: null, schemes: [], sortMode: 'combo',
|
||||
card: null, cardAt: '', // 故事创意卡(业界 01 Skill 的产出)
|
||||
};
|
||||
|
||||
/** 状态持久化:切标签/切页面/刷新都不丢(用户要求"一直保存") */
|
||||
function saveState() {
|
||||
try {
|
||||
localStorage.setItem(LS_KEY, JSON.stringify({
|
||||
brief: S.brief, picks: S.picks, candidates: S.candidates,
|
||||
selected: [...S.selected], showFiltered: S.showFiltered,
|
||||
genMeta: S.genMeta, tab: S.tab, matrix: S.matrix, schemes: S.schemes,
|
||||
sortMode: S.sortMode, card: S.card, cardAt: S.cardAt,
|
||||
}));
|
||||
} catch (e) { /* 超出配额时忽略(候选过多时) */ }
|
||||
}
|
||||
|
||||
function restoreState() {
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
if (!raw) return false;
|
||||
const d = JSON.parse(raw);
|
||||
S.brief = d.brief || '';
|
||||
S.picks = d.picks || {};
|
||||
S.candidates = d.candidates || [];
|
||||
S.selected = new Set(d.selected || []);
|
||||
S.showFiltered = !!d.showFiltered;
|
||||
S.genMeta = d.genMeta || null;
|
||||
S.tab = d.tab || 'params';
|
||||
if (d.matrix) S.matrix = d.matrix; // 保留未保存的矩阵勾选
|
||||
if (d.schemes) S.schemes = d.schemes; // AI 预选方案
|
||||
if (d.sortMode) S.sortMode = d.sortMode;
|
||||
if (d.card) { S.card = d.card; S.cardAt = d.cardAt || ''; }
|
||||
return true;
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
|
||||
export async function loadView(container) {
|
||||
_uid = container.id || 'cs';
|
||||
const restored = restoreState(); // ← 恢复上次状态(输入/选择/候选/矩阵)
|
||||
|
||||
// ★ 创意卡以项目对象为准(单一真相源)—— 本地缓存只作离线兜底
|
||||
try {
|
||||
await store.loadProject(store.getPid() || (await pidOf()));
|
||||
const pc = ((store.getProject() || {}).creative) || {};
|
||||
if (pc.card) { S.card = pc.card; S.cardAt = pc.card_at || ''; }
|
||||
} catch (e) { /* 忽略:不影响主流程 */ }
|
||||
|
||||
// ★ 迁移:localStorage 里有旧选择、但 project.creative 还是空的 → 补写进去
|
||||
// (之前只存 localStorage,导致下游剧本阶段读不到 —— 这里把历史选择救回来)
|
||||
if (restored && S.selected.size) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const cr = (store.getProject() || {}).creative || {};
|
||||
if (!(cr.selected || []).length) syncCreative(true);
|
||||
} catch (e) { /* 忽略 */ }
|
||||
}, 1200);
|
||||
}
|
||||
|
||||
container.innerHTML = shell();
|
||||
bindEvents(container);
|
||||
try {
|
||||
const [lib, mat] = await Promise.all([
|
||||
fetch(`${API}/library`).then(r => r.json()),
|
||||
fetch(`${API}/matrix`).then(r => r.json()),
|
||||
]);
|
||||
S.library = lib;
|
||||
if (!restored || !S.matrix) S.matrix = mat; // 本地未保存的矩阵编辑优先
|
||||
// ③ 避免 restore 后 library 尚未就绪时渲染失败
|
||||
applyTab(S.tab);
|
||||
updateGapLocal();
|
||||
} catch (e) {
|
||||
document.getElementById(`${_uid}body`).innerHTML = `<div class="error" style="padding:20px">❌ 加载失败: ${esc(e.message)}</div>`;
|
||||
}
|
||||
window._csTab = switchTab;
|
||||
window._csPick = pickValue;
|
||||
window._csCalc = updateGapServer;
|
||||
window._csMatrixToggle = matrixToggle;
|
||||
window._csMatrixSave = saveMatrix;
|
||||
window._csGenerate = generate;
|
||||
window._csToggleCand = toggleCand;
|
||||
window._csReject = rejectOne;
|
||||
window._csBatch = batchOp;
|
||||
window._csSaveCards = saveCards;
|
||||
// 📝 故事创意卡
|
||||
window._csExpand = expandCard;
|
||||
window._csCardSave = saveCardEdits;
|
||||
window._csGoScript = async () => {
|
||||
// ★ 必须先把这个项目的 stage 改成 script —— 否则工作室打开时会
|
||||
// 「回到上次记录的阶段」(还是 creative)→ 用户看到的是原地打转
|
||||
try { await store.setStage('script'); } catch (e) { /* 非致命 */ }
|
||||
// 工作室已经挂载 → 直接切标签(不用重载)
|
||||
if (typeof window._stuTab === 'function') { window._stuTab('script'); return; }
|
||||
if (window.switchView) { window.switchView('ave-studio'); return; }
|
||||
location.hash = '#ave-studio';
|
||||
};
|
||||
window._csToggleFiltered = () => { S.showFiltered = !S.showFiltered; renderCandidates(); saveState(); };
|
||||
window._csDetail = (i) => toggleDetail(i);
|
||||
window._csAddProp = addManualProp;
|
||||
window._csBrief = (v) => { S.brief = v; saveState(); };
|
||||
window._csSort = (v) => { S.sortMode = v; saveState(); renderCandidates(); };
|
||||
window._csRandom = randomPicks;
|
||||
window._csPreselect = aiPreselect;
|
||||
window._csUseScheme = useScheme;
|
||||
window._csClearBatch = () => {
|
||||
if (S.candidates.length && !confirm('清空本批候选?(已入库的创意卡不受影响)')) return;
|
||||
S.candidates = []; S.selected = new Set(); S.genMeta = null;
|
||||
syncCreative(true); // ★ 清空也要同步(下游不该再拿到已清的创意)
|
||||
saveState(); renderCandidates();
|
||||
};
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// 骨架
|
||||
// ══════════════════════════════════════════════════════════
|
||||
function shell() {
|
||||
const T = (id, label) => `<button class="cs-tab" data-tab="${id}" onclick="window._csTab('${id}')">${label}</button>`;
|
||||
return `
|
||||
<div style="max-width:1200px">
|
||||
<div style="display:flex;align-items:center;gap:10px;margin-bottom:7px;flex-wrap:wrap">
|
||||
<div style="font-size:14px;font-weight:600">💡 创意工坊</div>
|
||||
<span style="font-size:11px;color:var(--text2)">
|
||||
六层参数打底 → AI 出反差候选 → 你筛 → 存成创意卡(可被分镜/口播/脚本复用)</span>
|
||||
</div>
|
||||
<div id="${_uid}tabs" style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:9px;border-bottom:1px solid var(--border);padding-bottom:5px">
|
||||
${T('params', '① 六层参数')}${T('negative', '② 负面矩阵')}${T('positive', '③ 正向矩阵')}${T('props', '④ 道具/场景')}${T('gen', '⑤ 生成&筛选')}
|
||||
</div>
|
||||
<div id="${_uid}body"></div>
|
||||
</div>
|
||||
<style>
|
||||
.cs-tab{background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px}
|
||||
.cs-tab.on{background:#3b82f6;color:#fff;border-color:#3b82f6;font-weight:600}
|
||||
.cs-row{display:flex;align-items:center;gap:8px;padding:5px 8px;border-bottom:1px solid var(--border);font-size:12px}
|
||||
.cs-row:hover{background:var(--bg3)}
|
||||
.cs-rowexp{font-size:11px;color:var(--text2)}
|
||||
.cs-pill{display:inline-block;padding:1px 6px;border-radius:9px;font-size:10px;border:1px solid var(--border)}
|
||||
.cs-card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:8px}
|
||||
.cs-sel{background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px;padding:3px 6px;max-width:230px}
|
||||
</style>`;
|
||||
}
|
||||
|
||||
function bindEvents(container) {
|
||||
container.addEventListener('keydown', (e) => {
|
||||
// 键盘流:j/k 上下移动、空格勾选、Enter 展开
|
||||
if (S.tab !== 'gen') return;
|
||||
const rows = [...document.querySelectorAll(`#${_uid}body .cs-row[data-cid]`)];
|
||||
if (!rows.length) return;
|
||||
let idx = rows.findIndex(r => r.classList.contains('focus'));
|
||||
if (e.key === 'j' || e.key === 'k') {
|
||||
e.preventDefault();
|
||||
idx = e.key === 'j' ? Math.min(rows.length - 1, idx + 1) : Math.max(0, idx - 1);
|
||||
rows.forEach(r => r.classList.remove('focus'));
|
||||
rows[idx].classList.add('focus');
|
||||
rows[idx].style.outline = '1px solid #3b82f6';
|
||||
rows.forEach((r, i) => { if (i !== idx) r.style.outline = ''; });
|
||||
rows[idx].scrollIntoView({ block: 'nearest' });
|
||||
} else if (e.key === ' ' && idx >= 0) {
|
||||
e.preventDefault();
|
||||
toggleCand(rows[idx].dataset.cid);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function switchTab(t) {
|
||||
S.tab = t;
|
||||
applyTab(t);
|
||||
saveState();
|
||||
}
|
||||
|
||||
/** 只渲染指定标签(不写缓存) */
|
||||
function applyTab(t) {
|
||||
document.querySelectorAll(`#${_uid}tabs .cs-tab`).forEach(b => b.classList.toggle('on', b.dataset.tab === t));
|
||||
if (t === 'params') renderParams();
|
||||
else if (t === 'negative' || t === 'positive' || t === 'props') renderMatrix();
|
||||
else renderGenArea();
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ① 六层参数
|
||||
// ══════════════════════════════════════════════════════════
|
||||
function renderParams() {
|
||||
const lib = S.library;
|
||||
if (!lib) return;
|
||||
const body = document.getElementById(`${_uid}body`);
|
||||
const picked = Object.keys(S.picks).length;
|
||||
body.innerHTML = `
|
||||
<!-- ✨ AI 参数预选(先给基础搭配,人再微调) -->
|
||||
<div class="cs-card" style="background:linear-gradient(90deg,rgba(59,130,246,.08),rgba(34,197,94,.06))">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">✨ AI 帮预选 <span style="font-weight:400;color:var(--text2);font-size:11px">先让 AI 按你的矩阵给几套基础搭配,你在这上面改(不用自己挑 ${lib.stats.params} 个参数)</span></div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">
|
||||
<input id="${_uid}briefTab" placeholder="用 1-2 句话描述场景/诉求(与⑤共用)" value="${esc(S.brief)}"
|
||||
oninput="window._csBrief(this.value)"
|
||||
style="flex:3;min-width:280px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:7px 10px;font-size:12px">
|
||||
<button onclick="window._csPreselect()" id="${_uid}preBtn"
|
||||
style="background:#3b82f6;color:#fff;border:none;border-radius:6px;padding:7px 18px;font-size:12px;font-weight:600;cursor:pointer">✨ AI 帮预选</button>
|
||||
<span style="font-size:11px;color:var(--text2)">按当前矩阵(②③④)生成 3 套</span>
|
||||
</div>
|
||||
<div id="${_uid}schemeBox" style="margin-top:8px"></div>
|
||||
</div>
|
||||
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px;line-height:1.7">
|
||||
挑你想要的参数值(<b>不选 = 中性默认,不进缺口计算</b>,照原文规则)。
|
||||
每层最多建议选 2 个——选得越聚焦,缺口分越能反映你想制造的反差。
|
||||
共 ${lib.stats.params} 个参数 / ${lib.stats.values} 个值(含第七层表达层)。
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;margin-bottom:8px;flex-wrap:wrap;align-items:center">
|
||||
<button onclick="window._csRandom()" style="background:#8b5cf6;color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:12px;font-weight:600;cursor:pointer" title="七层各随机挑一个参数值(零成本、瞬时;不满意连点即可)">🎲 随机摇一组</button>
|
||||
<button onclick="window._csCalc()" style="background:#3b82f6;color:#fff;border:none;border-radius:6px;padding:5px 14px;font-size:12px;cursor:pointer">🧮 试算缺口(不调 AI,零费用)</button>
|
||||
<button onclick="S_clearPicks()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:5px 12px;font-size:12px;cursor:pointer">清空选择</button>
|
||||
<span style="font-size:11px;color:var(--text2)">已选 <b>${picked}</b> 项</span>
|
||||
<span id="${_uid}gapInline" style="font-size:12px;margin-left:auto"></span>
|
||||
</div>
|
||||
${lib.layers.map(L => layerBlock(L)).join('')}`;
|
||||
bindPicks();
|
||||
renderGapInline();
|
||||
renderSchemes();
|
||||
}
|
||||
|
||||
/** 渲染 AI 预选方案(3 套,点选用) */
|
||||
function renderSchemes() {
|
||||
const box = document.getElementById(`${_uid}schemeBox`);
|
||||
if (!box) return;
|
||||
const list = S.schemes || [];
|
||||
if (!list.length) { box.innerHTML = ''; return; }
|
||||
box.innerHTML = list.map((sc, i) => {
|
||||
const scScore = Math.max(sc.gap_score || 0, sc.extreme_score || 0);
|
||||
const color = scScore >= 60 ? '#22c55e' : scScore >= 30 ? '#f59e0b' : '#6b7280';
|
||||
return `<div class="cs-row" style="align-items:flex-start;background:var(--bg2);border:1px solid var(--border);border-radius:6px;margin-bottom:4px;padding:7px 9px">
|
||||
<span style="font-size:16px;font-weight:700;color:${color};min-width:38px" title="综合分 = max(落差, 异质)">${Math.round(scScore)}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:12px;font-weight:600">${esc(sc.name)}<span class="cs-rowexp"> · 落差 ${Math.round(sc.gap_score || 0)} / 异质 ${Math.round(sc.extreme_score || 0)}</span></div>
|
||||
<div class="cs-rowexp" style="line-height:1.6">${esc(sc.rationale || '')}</div>
|
||||
<div class="cs-rowexp" style="margin-top:3px;color:var(--text2)">${(sc.selections || []).map(s => `${esc(s.param_name)}:${esc(s.value)}`).join(' · ')}</div>
|
||||
</div>
|
||||
<button onclick="window._csUseScheme(${i})" style="flex:none;background:#22c55e;color:#000;border:none;border-radius:5px;padding:5px 12px;font-size:11px;font-weight:600;cursor:pointer">用这套</button>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
/** 采用某套预选方案 → 填入 Tab① 选择 → 自动算缺口 */
|
||||
function useScheme(idx) {
|
||||
const sc = (S.schemes || [])[idx];
|
||||
if (!sc) return;
|
||||
S.picks = {};
|
||||
(sc.selections || []).forEach(s => { S.picks[`${s.layer_id}|${s.param_id}`] = s.value; });
|
||||
saveState();
|
||||
renderParams(); // 重绘下拉(显示已选值)
|
||||
updateGapServer(); // 展示缺口详情与联想卡
|
||||
const st = document.getElementById(`${_uid}gapInline`);
|
||||
if (st) st.innerHTML = `<span style="color:#22c55e">✅ 已填入「${esc(sc.name)}」,可在上方微调</span>`;
|
||||
}
|
||||
|
||||
async function aiPreselect() {
|
||||
const brief = (S.brief || '').trim();
|
||||
if (!brief) { alert('请先填写场景诉求(1-2 句话)'); return; }
|
||||
const btn = document.getElementById(`${_uid}preBtn`);
|
||||
const box = document.getElementById(`${_uid}schemeBox`);
|
||||
if (btn) { btn.disabled = true; btn.textContent = '⏳ 预选中…'; }
|
||||
if (box) box.innerHTML = '<div class="cs-rowexp">⏳ AI 正在按你的矩阵搭配基础参数(约 15-40 秒)…</div>';
|
||||
try {
|
||||
const r = await fetch(`${API}/preselect`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ brief, n_schemes: 3, matrix: S.matrix }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '预选失败');
|
||||
S.schemes = d.schemes || [];
|
||||
saveState();
|
||||
renderSchemes();
|
||||
} catch (e) {
|
||||
if (box) box.innerHTML = `<div class="cs-rowexp" style="color:#ef4444">❌ ${esc(e.message)}</div>`;
|
||||
} finally {
|
||||
if (btn) { btn.disabled = false; btn.textContent = '✨ AI 帮预选'; }
|
||||
}
|
||||
}
|
||||
|
||||
function layerBlock(L) {
|
||||
const on = L.params.reduce((n, p) => n + (S.picks[`${L.id}|${p.id}`] ? 1 : 0), 0);
|
||||
return `
|
||||
<details style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;margin-bottom:6px" ${on ? 'open' : ''}>
|
||||
<summary style="cursor:pointer;padding:8px 10px;font-size:13px;font-weight:600">
|
||||
${esc(L.name)} <span style="font-size:11px;color:var(--text2);font-weight:400">(${L.params.length} 参数${on ? ` · 已选 ${on}` : ''})</span>
|
||||
</summary>
|
||||
<div style="padding:0 10px 10px">
|
||||
${L.params.map(p => paramRow(L, p)).join('')}
|
||||
</div>
|
||||
</details>`;
|
||||
}
|
||||
|
||||
function paramRow(L, p) {
|
||||
const key = `${L.id}|${p.id}`;
|
||||
const cur = S.picks[key] || '';
|
||||
const opts = ['<option value="">(不选)</option>'].concat(
|
||||
p.values.map(v => `<option value="${esc(v.value)}" ${v.value === cur ? 'selected' : ''}>${esc(v.value)} · ${v.intensity}</option>`)
|
||||
).join('');
|
||||
const pv = p.values.find(v => v.value === cur);
|
||||
const cue = pv ? `<span class="cs-rowexp" style="margin-left:6px">💬 ${esc((pv.cue || '').slice(0, 56))}…</span>` : '';
|
||||
return `<div class="cs-row">
|
||||
<span style="width:130px;flex:none;color:var(--text2)">${esc(p.name)}</span>
|
||||
<select class="cs-sel" data-key="${key}">${opts}</select>${cue}
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function bindPicks() {
|
||||
document.querySelectorAll(`#${_uid}body select[data-key]`).forEach(sel => {
|
||||
sel.onchange = () => pickValue(sel.dataset.key, sel.value);
|
||||
});
|
||||
}
|
||||
|
||||
function pickValue(key, value) {
|
||||
if (value) S.picks[key] = value; else delete S.picks[key];
|
||||
// 局部更新(避免整页重绘丢失展开态)
|
||||
const [lid] = key.split('|');
|
||||
const row = document.querySelector(`#${_uid}body select[data-key="${key}"]`);
|
||||
if (row) {
|
||||
const wrap = row.closest('.cs-row');
|
||||
const old = wrap.querySelector('.cs-rowexp'); if (old) old.remove();
|
||||
if (value) {
|
||||
const layer = S.library.layers.find(L => L.id === lid);
|
||||
const param = layer?.params.find(p => p.id === key.split('|')[1]);
|
||||
const pv = param?.values.find(v => v.value === value);
|
||||
if (pv) wrap.insertAdjacentHTML('beforeend', `<span class="cs-rowexp" style="margin-left:6px">💬 ${esc((pv.cue || '').slice(0, 56))}…</span>`);
|
||||
}
|
||||
}
|
||||
updateGapLocal();
|
||||
saveState();
|
||||
}
|
||||
|
||||
window.S_clearPicks = () => { S.picks = {}; S.gap = null; renderParams(); };
|
||||
|
||||
function updateGapLocal() {
|
||||
// 本地快速试算(与后端同口径),避免每次选择都请求
|
||||
const sels = picksToSelections();
|
||||
const n = Object.keys(S.picks).length;
|
||||
const el = document.getElementById(`${_uid}gapInline`);
|
||||
if (!el) return;
|
||||
if (sels.length < 2) { el.innerHTML = `<span style="color:var(--text2)">已选 ${n} 项(≥2 才计算)</span>`; return; }
|
||||
const its = sels.map(s => s.intensity);
|
||||
const mean = its.reduce((a, b) => a + b, 0) / its.length;
|
||||
const varc = its.reduce((a, b) => a + (b - mean) ** 2, 0) / (its.length - 1);
|
||||
const score = Math.min(100, varc * 400);
|
||||
const lvl = varc > 0.06 ? ['显著', '#22c55e'] : varc > 0.03 ? ['中等', '#f59e0b'] : ['微弱', '#6b7280'];
|
||||
el.innerHTML = `<span style="color:${lvl[1]};font-weight:600">预估缺口 ${score.toFixed(1)}(${lvl[0]})</span>`;
|
||||
}
|
||||
|
||||
function picksToSelections() {
|
||||
const out = [];
|
||||
for (const [key, value] of Object.entries(S.picks)) {
|
||||
const [lid, pid] = key.split('|');
|
||||
const L = S.library.layers.find(x => x.id === lid);
|
||||
const p = L?.params.find(x => x.id === pid);
|
||||
const pv = p?.values.find(v => v.value === value);
|
||||
if (pv) out.push({ layer_id: lid, param_id: pid, param_name: p.name, value, intensity: pv.intensity, cue: pv.cue });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function updateGapServer() {
|
||||
const sels = picksToSelections();
|
||||
if (sels.length < 2) { alert('至少选 2 个参数才能计算缺口'); return; }
|
||||
const r = await fetch(`${API}/calculate`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ selections: sels.map(s => ({ layer_id: s.layer_id, param_id: s.param_id, value: s.value })) }),
|
||||
});
|
||||
const d = await r.json();
|
||||
S.gap = d;
|
||||
const box = document.getElementById(`${_uid}gapBox`) || (() => {
|
||||
const el = document.createElement('div'); el.id = `${_uid}gapBox`;
|
||||
el.className = 'cs-card'; document.getElementById(`${_uid}body`).prepend(el); return el;
|
||||
})();
|
||||
box.innerHTML = `
|
||||
<div style="display:flex;gap:16px;align-items:baseline;flex-wrap:wrap">
|
||||
<span style="font-size:22px;font-weight:700;color:${d.gap_score >= 60 ? '#22c55e' : d.gap_score >= 30 ? '#f59e0b' : '#6b7280'}">${d.gap_score}</span>
|
||||
<span style="font-size:12px;color:var(--text2)">缺口分 · 方差 ${d.variance} · ${esc(d.level)} · 高${d.high_count}/低${d.low_count}</span>
|
||||
<button onclick="document.getElementById('${_uid}gapBox').remove()" style="margin-left:auto;background:none;border:none;color:var(--text2);cursor:pointer">✕</button>
|
||||
</div>
|
||||
<div style="margin-top:8px;font-size:12px;line-height:1.9;white-space:pre-wrap">${esc(d.scene_card)}</div>`;
|
||||
}
|
||||
|
||||
/** 🎲 随机摇一组参数:七层各随机挑一个参数与值(零成本、瞬时) */
|
||||
function randomPicks() {
|
||||
if (!S.library) return;
|
||||
const picks = {};
|
||||
(S.library.layers || []).forEach(L => {
|
||||
if (!L.params?.length) return;
|
||||
if (Math.random() < 0.9) { // 10% 概率该层留空
|
||||
const p = L.params[Math.floor(Math.random() * L.params.length)];
|
||||
if (p.values?.length) {
|
||||
const v = p.values[Math.floor(Math.random() * p.values.length)];
|
||||
picks[`${L.id}|${p.id}`] = v.value;
|
||||
}
|
||||
}
|
||||
});
|
||||
S.picks = picks;
|
||||
saveState();
|
||||
renderParams();
|
||||
updateGapServer();
|
||||
}
|
||||
|
||||
function renderGapInline() { updateGapLocal(); }
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ②③④ 矩阵
|
||||
// ══════════════════════════════════════════════════════════
|
||||
function renderMatrix() {
|
||||
const m = S.matrix;
|
||||
if (!m) return;
|
||||
const body = document.getElementById(`${_uid}body`);
|
||||
const tab = S.tab;
|
||||
if (tab === 'negative') {
|
||||
const hard = m.negative.hard.map(h => `
|
||||
<div class="cs-row" style="opacity:.55">
|
||||
<span style="width:14px">🔒</span>
|
||||
<b style="width:96px;flex:none;color:#ef4444">${esc(h.name)}</b>
|
||||
<span class="cs-rowexp">${esc(h.detail)}</span>
|
||||
</div>`).join('');
|
||||
const soft = m.negative.soft.map(s => `
|
||||
<div class="cs-row">
|
||||
<input type="checkbox" data-soft="${s.id}" ${s.relaxed ? 'checked' : ''}>
|
||||
<b style="width:96px;flex:none">${esc(s.name)}</b>
|
||||
<span class="cs-rowexp" style="flex:1">${esc(s.detail)}</span>
|
||||
<span class="cs-rowexp" style="max-width:280px;color:#f59e0b">${s.relaxed ? '✅ 已放开' : '放开后:' + esc(s.hint || '')}</span>
|
||||
</div>`).join('');
|
||||
body.innerHTML = `
|
||||
<div class="cs-card">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:4px">🔴 平台硬违禁(锁死,AI 绝不生成)</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:6px">这 7 类不参与创意,任何候选涉及即被淘汰</div>
|
||||
${hard}
|
||||
</div>
|
||||
<div class="cs-card">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:4px">🟡 观众反感项(默认排除;<b>放开哪项,就允许 AI 在哪一维制造反差</b>)</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:6px">过度过滤会失去反差感——泰国广告的本质就是在"极端夸张+贩卖焦虑"上放开了限制</div>
|
||||
${soft}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center">
|
||||
<button onclick="window._csMatrixSave()" style="background:#22c55e;color:#000;border:none;border-radius:6px;padding:6px 16px;font-size:12px;font-weight:600;cursor:pointer">💾 保存矩阵</button>
|
||||
<span style="font-size:11px;color:var(--text2)">保存后用于⑤生成;也可以另存 presets(配置文件里加一套)</span>
|
||||
</div>`;
|
||||
} else if (tab === 'positive') {
|
||||
body.innerHTML = `
|
||||
<div class="cs-card">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px">正向价值(勾选引入;命中的候选项在排序时加分)</div>
|
||||
${m.positive.map(p => `
|
||||
<div class="cs-row">
|
||||
<input type="checkbox" data-pos="${p.id}" checked>
|
||||
<b style="width:110px;flex:none">${esc(p.name)}</b>
|
||||
<span class="cs-rowexp" style="flex:1">${esc(p.detail)}</span>
|
||||
<span class="cs-pill" style="color:#22c55e">+${p.bonus}</span>
|
||||
</div>`).join('')}
|
||||
</div>
|
||||
<button onclick="window._csMatrixSave()" style="background:#22c55e;color:#000;border:none;border-radius:6px;padding:6px 16px;font-size:12px;font-weight:600;cursor:pointer">💾 保存矩阵</button>`;
|
||||
} else {
|
||||
const propRow = (p, kind) => `
|
||||
<div class="cs-row">
|
||||
<input type="checkbox" data-${kind}="${p.id}" checked>
|
||||
<b style="width:96px;flex:none">${esc(p.name)}</b>
|
||||
<span class="cs-rowexp" style="flex:1">${esc((p.examples || []).join('、'))}</span>
|
||||
<span class="cs-rowexp" style="color:var(--text2)">${esc(p.contrast || '')}</span>
|
||||
</div>`;
|
||||
body.innerHTML = `
|
||||
<div class="cs-card">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px">🧰 道具变量(勾选可用)</div>
|
||||
${m.props.map(p => propRow(p, 'prop')).join('')}
|
||||
<div style="margin-top:8px;display:flex;gap:6px">
|
||||
<input id="${_uid}newProp" placeholder="手动增补道具(如:棒棒糖)" style="flex:1;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 10px;font-size:12px">
|
||||
<button onclick="window._csAddProp()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer">+ 添加</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-card">
|
||||
<div style="font-weight:600;font-size:13px;margin-bottom:6px">🎬 场景变量(勾选可用)</div>
|
||||
${m.scenes.map(s => propRow(s, 'scene')).join('')}
|
||||
</div>
|
||||
<button onclick="window._csMatrixSave()" style="background:#22c55e;color:#000;border:none;border-radius:6px;padding:6px 16px;font-size:12px;font-weight:600;cursor:pointer">💾 保存矩阵</button>`;
|
||||
}
|
||||
}
|
||||
|
||||
let _manualProps = [];
|
||||
function addManualProp() {
|
||||
const inp = document.getElementById(`${_uid}newProp`);
|
||||
const v = (inp.value || '').trim();
|
||||
if (!v) return;
|
||||
S.matrix.props.push({ id: 'custom_' + Date.now(), name: v, examples: [], contrast: '手动增补' });
|
||||
inp.value = '';
|
||||
saveState();
|
||||
renderMatrix();
|
||||
}
|
||||
|
||||
function matrixToggle() { /* 勾选状态在保存时统一读取 */ }
|
||||
|
||||
async function saveMatrix() {
|
||||
const m = JSON.parse(JSON.stringify(S.matrix));
|
||||
document.querySelectorAll(`#${_uid}body input[data-soft]`).forEach(el => {
|
||||
const it = m.negative.soft.find(x => x.id === el.dataset.soft);
|
||||
if (it) it.relaxed = el.checked;
|
||||
});
|
||||
document.querySelectorAll(`#${_uid}body input[data-pos]`).forEach(el => {
|
||||
const it = m.positive.find(x => x.id === el.dataset.pos);
|
||||
if (it) it.enabled = el.checked;
|
||||
});
|
||||
const r = await fetch(`${API}/matrix`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ matrix: m }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') { S.matrix = d.matrix; saveState(); alert('✅ 矩阵已保存'); }
|
||||
else alert('❌ ' + (d.detail || '保存失败'));
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════
|
||||
// ⑤ 生成 & 筛选
|
||||
// ══════════════════════════════════════════════════════════
|
||||
/** 📝 创意卡区块 —— 「选中一句话」→「想清楚的故事」→ 交给剧本
|
||||
*
|
||||
* 依据 AI-Animation-Studio 01 故事创意 Skill:
|
||||
* 业界不写小说,但要求把故事**想清楚**(标题/核心冲突/传播钩子/
|
||||
* 内容模式/核心驱动力/角色方向/世界观方向),再进剧本。
|
||||
* 这一区块就是补上这一步 —— 之前选完候选就断了,剧本只能自由发挥。
|
||||
*/
|
||||
function renderCreativeCardBlock() {
|
||||
const n = S.selected.size;
|
||||
const card = (S.card || null);
|
||||
const F = (k, label, rows) => `
|
||||
<div style="margin-bottom:6px">
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:2px">${label}</div>
|
||||
<textarea rows="${rows || 1}" class="cs-in" data-card="${k}"
|
||||
style="width:100%;background:var(--bg2);border:1px solid var(--border);color:var(--text);
|
||||
border-radius:4px;padding:5px 7px;font-size:11.5px;box-sizing:border-box;
|
||||
resize:vertical;font-family:inherit">${esc(card ? (card[k] || '') : '')}</textarea>
|
||||
</div>`;
|
||||
|
||||
if (!card) {
|
||||
return `
|
||||
<div class="cs-card" style="border-color:#8b5cf6;margin-top:10px">
|
||||
<div style="font-size:12.5px;font-weight:600;margin-bottom:5px">
|
||||
📝 下一步:把选中的灵感变成「故事创意卡」</div>
|
||||
<div style="font-size:11px;color:var(--text2);line-height:1.8;margin-bottom:8px">
|
||||
业界做法:创意不能只有一句话 —— 要先<b>想清楚</b>才进剧本。<br>
|
||||
AI 会帮你补全:<b>故事标题 · 核心冲突(4问) · 传播钩子 · 内容模式(8选1) ·
|
||||
核心驱动力 · 情绪/动作方向 · 角色方向 · 世界观方向</b><br>
|
||||
<span style="color:var(--text2)">(不需要写小说 —— 这一层就是把故事想清楚,不是写长)</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button onclick="window._csExpand()" ${n ? '' : 'disabled'}
|
||||
style="background:${n ? '#8b5cf6' : 'var(--bg3)'};color:${n ? '#fff' : 'var(--text2)'};
|
||||
border:none;border-radius:6px;padding:8px 20px;font-size:13px;font-weight:600;
|
||||
cursor:${n ? 'pointer' : 'not-allowed'}">🤖 扩写为创意卡</button>
|
||||
<span style="font-size:11px;color:${n ? '#22c55e' : '#f59e0b'}">
|
||||
${n ? `已选 ${n} 条 · 会作为依据` : '请先在上方勾选至少一条候选'}</span>
|
||||
</div>
|
||||
<div id="csCardMsg" style="font-size:11px;color:var(--text2);margin-top:7px"></div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const cc = card.core_conflict || {};
|
||||
return `
|
||||
<div class="cs-card" style="border-color:#8b5cf6;margin-top:10px">
|
||||
<div style="display:flex;align-items:center;gap:9px;margin-bottom:6px;flex-wrap:wrap">
|
||||
<span style="font-size:12.5px;font-weight:600">📝 故事创意卡</span>
|
||||
<span style="font-size:10.5px;color:var(--text2)">生成于 ${esc(S.cardAt || '')} · 可直接改,改完点保存</span>
|
||||
<button onclick="window._csExpand()" style="margin-left:auto;background:var(--bg3);
|
||||
color:var(--text);border:1px solid var(--border);border-radius:5px;padding:3px 11px;
|
||||
cursor:pointer;font-size:11px">🔄 重新扩写</button>
|
||||
</div>
|
||||
${card.verdict ? `<div style="font-size:11px;background:var(--bg2);border:1px solid var(--border);
|
||||
border-radius:5px;padding:6px 8px;margin-bottom:7px;line-height:1.75">
|
||||
<b>价值判断:${esc(card.verdict.conclusion || '')}</b><br>
|
||||
<span style="color:var(--text2)">理由:${esc(card.verdict.reason || '')}</span><br>
|
||||
${card.verdict.max_problem ? `<span style="color:#f59e0b">最大问题:${esc(card.verdict.max_problem)}</span><br>` : ''}
|
||||
${card.verdict.fix ? `<span style="color:#22c55e">改造方向:${esc(card.verdict.fix)}</span>` : ''}
|
||||
</div>` : ''}
|
||||
<div style="display:grid;grid-template-columns:1fr 1fr;gap:6px 10px">
|
||||
<div style="grid-column:span 2">${F('title', '故事标题', 1)}</div>
|
||||
<div style="grid-column:span 2">${F('concept', '一句话概念', 2)}</div>
|
||||
<div style="grid-column:span 2">
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:2px">
|
||||
核心冲突(谁想做什么 / 谁阻止 / 为什么出问题 / 观众为什么看下去)</div>
|
||||
<div style="font-size:11px;line-height:1.8;background:var(--bg2);border:1px solid var(--border);
|
||||
border-radius:4px;padding:6px 8px">
|
||||
${esc(cc.who || '—')}<br>
|
||||
<span style="color:#ef4444">受阻于:${esc(cc.blocker || '—')}</span><br>
|
||||
<span style="color:var(--text2)">${esc(cc.why_problem || '')}</span><br>
|
||||
<span style="color:#22c55e">为什么看下去:${esc(cc.why_watch || '')}</span>
|
||||
${cc.type ? `<br><span style="color:var(--text2)">类型:${esc(cc.type)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<div style="grid-column:span 2">${F('hook', '传播钩子(前 3 秒)', 2)}</div>
|
||||
${F('content_mode', '内容模式', 1)}${F('core_driver', '核心驱动力', 1)}
|
||||
${F('emotion_arc', '情绪方向', 1)}${F('action_arc', '动作方向', 1)}
|
||||
${F('mood', '情绪基调', 1)}${F('series_potential', '系列化潜力', 1)}
|
||||
<div style="grid-column:span 2">
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:2px">角色方向</div>
|
||||
<div style="font-size:11px;line-height:1.8;background:var(--bg2);border:1px solid var(--border);
|
||||
border-radius:4px;padding:6px 8px">
|
||||
主角:${esc((card.characters || {}).protagonist || '—')}<br>
|
||||
对手:${esc((card.characters || {}).antagonist || '—')}<br>
|
||||
关系:${esc((card.characters || {}).relationship || '—')}
|
||||
</div>
|
||||
</div>
|
||||
<div style="grid-column:span 2">
|
||||
<div style="font-size:10.5px;color:var(--text2);margin-bottom:2px">世界观方向(空间 / 规则 / 能否持续拍)</div>
|
||||
<div style="font-size:11px;line-height:1.8;background:var(--bg2);border:1px solid var(--border);
|
||||
border-radius:4px;padding:6px 8px">
|
||||
空间:${esc((card.world || {}).space || '—')}<br>
|
||||
规则:${esc((card.world || {}).rule || '—')}<br>
|
||||
可持续:${esc((card.world || {}).can_sustain || '—')}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;margin-top:9px;flex-wrap:wrap">
|
||||
<button onclick="window._csCardSave()" style="background:#22c55e;color:#000;border:none;
|
||||
border-radius:6px;padding:7px 18px;font-size:12px;font-weight:600;cursor:pointer">💾 保存创意卡</button>
|
||||
<button onclick="window._csGoScript()" style="background:#3b82f6;color:#fff;border:none;
|
||||
border-radius:6px;padding:7px 18px;font-size:12px;font-weight:600;cursor:pointer">📝 去写剧本 →</button>
|
||||
<span id="csCardMsg" style="font-size:11px;color:var(--text2)"></span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function renderGenArea() {
|
||||
const body = document.getElementById(`${_uid}body`);
|
||||
const relaxed = (S.matrix?.negative?.soft || []).filter(s => s.relaxed).map(s => s.name);
|
||||
const pos = (S.matrix?.positive || []).filter(p => p.enabled !== false).map(p => p.name);
|
||||
body.innerHTML = `
|
||||
<div class="cs-card">
|
||||
<div style="display:flex;gap:8px;align-items:flex-start;flex-wrap:wrap">
|
||||
<textarea id="${_uid}brief" rows="2" placeholder="用 1-2 句话描述场景或诉求,例:肛肠科患者术后第一次下床的 30 秒短片,要能吸引流量"
|
||||
oninput="window._csBrief(this.value)"
|
||||
style="flex:4;min-width:320px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:8px 10px;font-size:12px;resize:vertical">${esc(S.brief)}</textarea>
|
||||
<div style="flex:1;min-width:150px">
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:4px">本批条数</div>
|
||||
<input id="${_uid}num" type="number" value="${S.matrix?.generation?.candidates_per_round || 30}" min="5" max="80"
|
||||
style="width:100%;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:12px">
|
||||
</div>
|
||||
<div style="display:flex;flex-direction:column;gap:6px">
|
||||
<button onclick="window._csGenerate('constrained')" id="${_uid}genC"
|
||||
style="background:#22c55e;color:#000;border:none;border-radius:6px;padding:9px 18px;font-size:13px;font-weight:600;cursor:pointer"
|
||||
title="按「① 六层参数」里当前选定的参数作为硬框架,30 条都在这个做法里编点子">🚀 按当前参数生成</button>
|
||||
<button onclick="window._csGenerate('random')" id="${_uid}genR"
|
||||
style="background:#3b82f6;color:#fff;border:none;border-radius:6px;padding:9px 18px;font-size:13px;font-weight:600;cursor:pointer"
|
||||
title="每条候选各自打乱参数,风格散开(不看 Tab① 的选择)">🎲 完全随机生成</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:6px">
|
||||
当前参数(①):<b style="color:#22c55e">${Object.keys(S.picks).length} 项</b> ·
|
||||
当前矩阵:🟡已放开 [${relaxed.length ? relaxed.map(esc).join('、') : '无'}] · 正向 [${pos.map(esc).join('、')}] ·
|
||||
<b style="color:#f59e0b">AI 出候选 → 自动过滤硬违禁 → 缺口分排序</b>
|
||||
</div>
|
||||
</div>
|
||||
<div id="${_uid}candArea"></div>`;
|
||||
window._csGen = generate;
|
||||
renderCandidates();
|
||||
}
|
||||
|
||||
async function generate(mode) {
|
||||
mode = mode || 'random';
|
||||
const brief = document.getElementById(`${_uid}brief`)?.value?.trim();
|
||||
const n = parseInt(document.getElementById(`${_uid}num`)?.value || '30');
|
||||
if (!brief) { alert('请先输入场景诉求'); return; }
|
||||
const payload = { brief, n };
|
||||
if (mode === 'constrained') {
|
||||
const sels = picksToSelections();
|
||||
if (!sels.length) {
|
||||
alert('「按当前参数生成」需要先在「① 六层参数」里定好参数。\n\n去 ① 点「🎲 随机摇一组」或「✨ AI 帮预选」,也可以直接用「🎲 完全随机生成」。');
|
||||
return;
|
||||
}
|
||||
payload.selections = sels.map(s => ({ layer_id: s.layer_id, param_id: s.param_id, value: s.value }));
|
||||
}
|
||||
S.brief = brief; S.genMode = mode; saveState();
|
||||
const area = document.getElementById(`${_uid}candArea`);
|
||||
area.innerHTML = `<div class="cs-card" style="text-align:center;color:var(--text2)">⏳ 正在生成 ${n} 条候选…(${mode === 'constrained' ? '按当前参数框架' : '完全随机参数'},约 20-60 秒)</div>`;
|
||||
try {
|
||||
const r = await fetch(`${API}/generate`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') throw new Error(d.detail || '生成失败');
|
||||
S.candidates = d.candidates || [];
|
||||
S.genMeta = d.meta; S.selected = new Set();
|
||||
saveState();
|
||||
renderCandidates();
|
||||
} catch (e) {
|
||||
area.innerHTML = `<div class="cs-card" style="color:#ef4444">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
/** 排序:综合 = max(落差, 异质);也可单按某一维(均含正向加分) */
|
||||
function sortCandidates(list) {
|
||||
const score = (c) => {
|
||||
const gap = c.gap_score || 0, ext = c.extreme_score || 0, bon = c.bonus || 0;
|
||||
if (S.sortMode === 'gap') return gap + bon;
|
||||
if (S.sortMode === 'extreme') return ext + bon;
|
||||
return Math.max(gap, ext) + bon;
|
||||
};
|
||||
return [...list].sort((a, b) => {
|
||||
if (!!a.filtered !== !!b.filtered) return a.filtered ? 1 : -1; // 被过滤的排最后
|
||||
return score(b) - score(a);
|
||||
});
|
||||
}
|
||||
|
||||
function renderCandidates() {
|
||||
const area = document.getElementById(`${_uid}candArea`);
|
||||
if (!area) return;
|
||||
if (!S.candidates.length) { area.innerHTML = ''; return; }
|
||||
const shown = sortCandidates(S.candidates).filter(c => S.showFiltered || !c.filtered);
|
||||
const kept = S.candidates.filter(c => !c.filtered).length;
|
||||
const filtered = S.candidates.length - kept;
|
||||
const mt = S.genMeta || {};
|
||||
area.innerHTML = `
|
||||
<div class="cs-card" style="display:flex;gap:12px;align-items:center;flex-wrap:wrap">
|
||||
<span style="font-size:12px">本批 <b>${kept}</b> 条可用${filtered ? ` · <span style="color:#ef4444">${filtered} 条被硬过滤</span>` : ''}</span>
|
||||
<span style="font-size:11px;color:var(--text2)">已选 <b>${S.selected.size}</b> 条</span>
|
||||
<span style="font-size:11px;color:var(--text2)">键盘:<b>J/K</b> 移动 · <b>空格</b> 勾选</span>
|
||||
<div style="margin-left:auto;display:flex;gap:6px;flex-wrap:wrap">
|
||||
<button onclick="window._csBatch('all')" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 10px;font-size:11px;cursor:pointer">全选</button>
|
||||
<button onclick="window._csBatch('top')" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 10px;font-size:11px;cursor:pointer">选中 60 分以上</button>
|
||||
<button onclick="window._csBatch('risky')" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 10px;font-size:11px;cursor:pointer">淘汰有风险项</button>
|
||||
<button onclick="window._csBatch('none')" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 10px;font-size:11px;cursor:pointer">清空</button>
|
||||
<button onclick="window._csClearBatch()" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 10px;font-size:11px;cursor:pointer" title="清空本批候选(已入库的创意卡不受影响)">🗑 清空本批</button>
|
||||
<select onchange="window._csSort(this.value)" title="排序依据(综合=max(落差,异质))"
|
||||
style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:5px 8px;font-size:11px">
|
||||
<option value="combo" ${S.sortMode === 'combo' ? 'selected' : ''}>排序:综合</option>
|
||||
<option value="gap" ${S.sortMode === 'gap' ? 'selected' : ''}>排序:落差</option>
|
||||
<option value="extreme" ${S.sortMode === 'extreme' ? 'selected' : ''}>排序:异质</option>
|
||||
</select>
|
||||
<label style="font-size:11px;color:var(--text2);align-self:center"><input type="checkbox" ${S.showFiltered ? 'checked' : ''} onchange="window._csToggleFiltered()"> 显示被过滤的</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="cs-card" style="padding:0;overflow:hidden">
|
||||
${shown.map(c => candRow(c)).join('') || '<div style="padding:16px;text-align:center;color:var(--text2);font-size:12px">(无可显示候选)</div>'}
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;align-items:center;flex-wrap:wrap">
|
||||
<button onclick="window._csSaveCards()" style="background:#22c55e;color:#000;border:none;border-radius:6px;padding:8px 20px;font-size:13px;font-weight:600;cursor:pointer">💾 存进创意库</button>
|
||||
<button onclick="window._csGenerate()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);border-radius:6px;padding:8px 16px;font-size:12px;cursor:pointer">🔄 再生成一批</button>
|
||||
<span style="font-size:11px;color:var(--text2)">入库后可在「📚 创意库」检索</span>
|
||||
</div>
|
||||
<div style="margin-top:10px;padding:9px 12px;background:var(--bg3);border:1px solid #8b5cf6;
|
||||
border-radius:8px;font-size:11.5px;line-height:1.85">
|
||||
✅ 选好之后 → 去 <b>「📋 创意卡」</b> 标签,结合「🌍 设定」扩写成完整故事卡,再去写剧本。<br>
|
||||
<span style="font-size:10.5px;color:var(--text2)">
|
||||
(业界的做法:创意要先「想清楚」才进剧本 —— 不是写小说,是把标题/冲突/钩子/模式想明白)</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function candRow(c) {
|
||||
const sel = S.selected.has(c.cid);
|
||||
const riskN = c.risks?.length || 0;
|
||||
const score = Math.max(c.gap_score || 0, c.extreme_score || 0);
|
||||
const color = score >= 60 ? '#22c55e' : score >= 30 ? '#f59e0b' : '#6b7280';
|
||||
if (c.filtered) {
|
||||
return `<div class="cs-row" style="opacity:.5">
|
||||
<span style="width:16px">🚫</span>
|
||||
<span style="flex:1">${esc(c.one_liner || '(无描述)')}</span>
|
||||
<span class="cs-rowexp" style="color:#ef4444">${esc(c.filter_reason)}</span>
|
||||
</div>`;
|
||||
}
|
||||
const kp = (c.key_points || []).slice(0, 3);
|
||||
const params = (c.selections || []).slice(0, 6)
|
||||
.map(s => `${esc(s.param_name)}:${esc(s.value)}`).join(' · ');
|
||||
const more = (c.selections || []).length > 6 ? ' …' : '';
|
||||
return `<div class="cs-row" data-cid="${c.cid}" style="align-items:flex-start;gap:8px">
|
||||
<input type="checkbox" ${sel ? 'checked' : ''} onclick="event.stopPropagation();window._csToggleCand('${c.cid}')" style="margin-top:3px">
|
||||
<span style="width:34px;flex:none;color:${color};font-weight:700;font-size:14px" title="综合分(落差 ${Math.round(c.gap_score || 0)} / 异质 ${Math.round(c.extreme_score || 0)})">${Math.round(score)}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:12.5px;line-height:1.55">${esc(c.one_liner || '(无描述)')}</div>
|
||||
${kp.length ? `<div style="margin-top:3px;font-size:11px;line-height:1.7;color:#9db4dd">${kp.map(k => '· ' + esc(k)).join('<br>')}</div>` : ''}
|
||||
<div class="cs-rowexp" style="margin-top:3px;color:var(--text2);line-height:1.6">${params}${more}</div>
|
||||
</div>
|
||||
<span style="flex:none;white-space:nowrap">
|
||||
${riskN ? `<span class="cs-pill" style="color:#f59e0b" title="${esc((c.risks || []).map(r => r.reason).join(';'))}">🟡${riskN}</span>` : ''}
|
||||
${c.bonus ? `<span class="cs-pill" style="color:#22c55e">+${c.bonus}</span>` : ''}
|
||||
</span>
|
||||
<button onclick="window._csReject('${c.cid}')" style="background:none;border:none;color:#6b7280;cursor:pointer;font-size:14px" title="淘汰">✕</button>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function toggleCand(cid) {
|
||||
if (S.selected.has(cid)) S.selected.delete(cid); else S.selected.add(cid);
|
||||
syncCreative(false); // ★ 同步到项目对象(剧本阶段要读它)
|
||||
saveState();
|
||||
renderCandidates();
|
||||
}
|
||||
|
||||
function toggleDetail(cid) {
|
||||
const el = document.getElementById(`${_uid}det_${cid}`);
|
||||
if (el) el.style.display = el.style.display === 'none' ? 'block' : 'none';
|
||||
}
|
||||
|
||||
function rejectOne(cid) {
|
||||
S.candidates = S.candidates.filter(c => c.cid !== cid);
|
||||
S.selected.delete(cid);
|
||||
saveState();
|
||||
renderCandidates();
|
||||
}
|
||||
|
||||
function batchOp(op) {
|
||||
const avail = S.candidates.filter(c => !c.filtered);
|
||||
if (op === 'all') S.selected = new Set(avail.map(c => c.cid));
|
||||
else if (op === 'none') S.selected = new Set();
|
||||
else if (op === 'top') S.selected = new Set(avail.filter(c => (c.final_score || c.gap_score) >= 60).map(c => c.cid));
|
||||
else if (op === 'risky') {
|
||||
S.candidates = S.candidates.filter(c => !(c.risks?.length));
|
||||
S.selected = new Set([...S.selected].filter(cid => S.candidates.some(c => c.cid === cid)));
|
||||
}
|
||||
syncCreative(true); // ★ 批量操作后立即同步(剧本阶段要读)
|
||||
saveState();
|
||||
renderCandidates();
|
||||
}
|
||||
|
||||
/** 🤖 扩写为「故事创意卡」(业界 01 故事创意 Skill 的产出)
|
||||
* 「选中一句话」→「想清楚的故事」→ 剧本才有依据 */
|
||||
async function expandCard() {
|
||||
if (!S.selected.size) { alert('请先勾选至少一条候选'); return; }
|
||||
if (!confirm('🤖 扩写为「故事创意卡」\n\n'
|
||||
+ `依据已选 ${S.selected.size} 条 + 设定,补全:\n`
|
||||
+ '故事标题 / 核心冲突(4问) / 传播钩子 / 内容模式 / 核心驱动力 /\n'
|
||||
+ '情绪·动作方向 / 角色方向 / 世界观方向\n\n'
|
||||
+ '费用:一次几分钱。确认生成?')) return;
|
||||
const m = document.getElementById('csCardMsg');
|
||||
if (m) { m.textContent = '🤖 扩写中…(约 10-30 秒)'; m.style.color = '#8b5cf6'; }
|
||||
try {
|
||||
// 先把选中的候选同步进 project(扩写接口读的是 project.creative.selected)
|
||||
await syncCreative(true);
|
||||
const r = await fetch(`${API.replace('/creatives', '')}/project/`
|
||||
+ encodeURIComponent((await pidOf()) ) + '/creative/expand', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ confirm_cost: true }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!r.ok) throw new Error(d.detail || ('HTTP ' + r.status));
|
||||
S.card = d.card || null;
|
||||
S.cardAt = new Date().toISOString().slice(0, 16).replace('T', ' ');
|
||||
saveState();
|
||||
renderGenArea();
|
||||
if (window.showToast) window.showToast('✅ 创意卡已生成');
|
||||
} catch (e) {
|
||||
if (m) { m.textContent = '❌ ' + e.message; m.style.color = '#ef4444'; }
|
||||
}
|
||||
}
|
||||
|
||||
/** 取当前项目 id(优先 store,其次 URL hash 里的 pid) */
|
||||
async function pidOf() {
|
||||
if (store.getPid()) return store.getPid();
|
||||
try {
|
||||
const st = await fetch('/api/ave/project/list').then(r => r.json());
|
||||
const first = (st.projects || [])[0];
|
||||
if (first) { await store.loadProject(first.project_id); return first.project_id; }
|
||||
} catch (e) { /* 忽略 */ }
|
||||
return '';
|
||||
}
|
||||
|
||||
/** 💾 保存创意卡(把界面上改过的字段写回 project.creative.card) */
|
||||
async function saveCardEdits() {
|
||||
if (!S.card) return;
|
||||
const next = JSON.parse(JSON.stringify(S.card));
|
||||
document.querySelectorAll('[data-card]').forEach(el => {
|
||||
next[el.getAttribute('data-card')] = el.value;
|
||||
});
|
||||
// 冲突的四个子字段是只读展示,保留原值
|
||||
next.core_conflict = S.card.core_conflict || {};
|
||||
next.characters = S.card.characters || {};
|
||||
next.world = S.card.world || {};
|
||||
next.verdict = S.card.verdict || {};
|
||||
try {
|
||||
const pid = await pidOf();
|
||||
await fetch(`/api/ave/project/${encodeURIComponent(pid)}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ creative: { card: next,
|
||||
card_at: S.cardAt, updated_at: new Date().toISOString() } }),
|
||||
});
|
||||
S.card = next; saveState();
|
||||
const m = document.getElementById('csCardMsg');
|
||||
if (m) { m.textContent = '💾 已保存'; m.style.color = '#22c55e';
|
||||
setTimeout(() => { if (m.textContent === '💾 已保存') m.textContent = ''; }, 1800); }
|
||||
} catch (e) { alert('❌ 保存失败:' + e.message); }
|
||||
}
|
||||
|
||||
async function saveCards() {
|
||||
await syncCreative(true); // ★ 保存入库 = 确认选中 → 同步进项目对象
|
||||
const picks = S.candidates.filter(c => S.selected.has(c.cid));
|
||||
if (!picks.length) { alert('请先勾选要保留的候选'); return; }
|
||||
const r = await fetch(`${API}/save-batch`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
brief: S.brief, matrix: S.matrix,
|
||||
cards: picks.map(c => ({ candidate: c, brief: S.brief, inject_mode: 'reference' })),
|
||||
}),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
S.selected = new Set();
|
||||
saveState();
|
||||
renderCandidates();
|
||||
alert(`✅ 已入库 ${d.saved} 张创意卡\n去「📚 创意库」查看/调整注入模式`);
|
||||
}
|
||||
else alert('❌ ' + (d.detail || '保存失败'));
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
@@ -0,0 +1,243 @@
|
||||
/**
|
||||
* creatives.js — 📚 创意库(GapEngine 沉淀)
|
||||
*
|
||||
* 已保存的创意卡:检索 / 查看详情 / 调整注入模式 / 送往后续流程。
|
||||
* 注入模式:严格约束(照此分镜生成)· 参考(给导演方向)· 仅创意(只留方向感)
|
||||
*/
|
||||
const API = '/api/ave/creatives';
|
||||
let _uid = 'cl';
|
||||
const S = { cards: [], stats: null, q: '', status: '', inject_mode: '', sort: 'updated_desc', detail: null };
|
||||
|
||||
const MODE_META = {
|
||||
strict: { label: '严格约束', icon: '🔒', desc: '后续流程必须照此执行(适合单镜头直接生成)' },
|
||||
reference: { label: '参考', icon: '🧭', desc: '作为方向交给导演拆分(适合需要重组镜头的长内容)' },
|
||||
idea_only: { label: '仅创意', icon: '💡', desc: '只保留方向感,不约束任何参数' },
|
||||
};
|
||||
|
||||
export async function loadView(container) {
|
||||
_uid = container.id || 'cl';
|
||||
container.innerHTML = shell();
|
||||
window._clSearch = (v) => { S.q = v; load(); };
|
||||
window._clFilter = (k, v) => { S[k] = v; load(); };
|
||||
window._clSort = (v) => { S.sort = v; load(); };
|
||||
window._clOpen = openDetail;
|
||||
window._clClose = () => { S.detail = null; renderDetail(); };
|
||||
window._clMode = setMode;
|
||||
window._clStatus = setStatus;
|
||||
window._clDel = delCard;
|
||||
window._clHandoff = handoff;
|
||||
await load();
|
||||
}
|
||||
|
||||
function shell() {
|
||||
return `
|
||||
<div style="padding:14px;max-width:1400px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px;flex-wrap:wrap;gap:8px">
|
||||
<h2 style="font-size:18px;margin:0">📚 创意库</h2>
|
||||
<div id="${_uid}stats" style="font-size:11px;color:var(--text2)"></div>
|
||||
</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
|
||||
<input id="${_uid}q" placeholder="搜索主题 / 一句话 / 道具 / 场景…" oninput="window._clSearch(this.value)"
|
||||
style="flex:3;min-width:220px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 10px;font-size:12px">
|
||||
<select onchange="window._clFilter('status', this.value)" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:12px">
|
||||
<option value="">全部状态</option><option value="selected">已选</option><option value="draft">草稿</option><option value="used">已使用</option>
|
||||
</select>
|
||||
<select onchange="window._clFilter('inject_mode', this.value)" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:12px">
|
||||
<option value="">全部注入模式</option><option value="strict">🔒 严格约束</option><option value="reference">🧭 参考</option><option value="idea_only">💡 仅创意</option>
|
||||
</select>
|
||||
<select onchange="window._clSort(this.value)" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:6px;padding:6px 8px;font-size:12px">
|
||||
<option value="updated_desc">最近更新</option><option value="gap_desc">缺口分最高</option><option value="created_desc">最近创建</option>
|
||||
</select>
|
||||
<span style="font-size:11px;color:var(--text2)">生成新创意 → 「💡 创意工坊」</span>
|
||||
</div>
|
||||
<div id="${_uid}list"></div>
|
||||
<div id="${_uid}detail"></div>
|
||||
</div>
|
||||
<style>
|
||||
.cl-card{background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px 12px;margin-bottom:8px;cursor:pointer}
|
||||
.cl-card:hover{border-color:#3b82f6}
|
||||
.cl-pill{display:inline-block;padding:1px 7px;border-radius:9px;font-size:10px;border:1px solid var(--border);margin-right:4px}
|
||||
</style>`;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const qs = new URLSearchParams({ q: S.q, status: S.status, inject_mode: S.inject_mode, sort: S.sort, limit: '300' });
|
||||
const [r, sr] = await Promise.all([
|
||||
fetch(`${API}/?${qs}`).then(x => x.json()),
|
||||
fetch(`${API}/stats`).then(x => x.json()),
|
||||
]);
|
||||
S.cards = r.cards || []; S.stats = sr;
|
||||
renderStats(); renderList();
|
||||
} catch (e) {
|
||||
document.getElementById(`${_uid}list`).innerHTML = `<div class="error" style="padding:16px">❌ ${esc(e.message)}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderStats() {
|
||||
const s = S.stats || {};
|
||||
const modes = s.by_inject_mode || {};
|
||||
document.getElementById(`${_uid}stats`).innerHTML =
|
||||
`共 <b>${s.total || 0}</b> 张 · 平均缺口 <b>${s.avg_gap || 0}</b> · ` +
|
||||
Object.entries(modes).map(([k, v]) => `${MODE_META[k]?.icon || ''}${MODE_META[k]?.label || k} ${v}`).join(' · ');
|
||||
}
|
||||
|
||||
function renderList() {
|
||||
const el = document.getElementById(`${_uid}list`);
|
||||
if (!S.cards.length) {
|
||||
el.innerHTML = `<div style="padding:28px;text-align:center;color:var(--text2);font-size:12px;line-height:2">
|
||||
创意库还是空的。<br>去「💡 创意工坊」输入 1-2 句话 → 生成 30 条候选 → 筛选后入库。</div>`;
|
||||
return;
|
||||
}
|
||||
el.innerHTML = S.cards.map(c => {
|
||||
const m = MODE_META[c.inject_mode] || MODE_META.reference;
|
||||
const color = (c.gap_score || 0) >= 60 ? '#22c55e' : (c.gap_score || 0) >= 30 ? '#f59e0b' : '#6b7280';
|
||||
return `<div class="cl-card" onclick="window._clOpen('${c.id}')">
|
||||
<div style="display:flex;gap:10px;align-items:flex-start">
|
||||
<span style="font-size:18px;font-weight:700;color:${color};min-width:44px;text-align:center">${Math.round(c.gap_score || 0)}</span>
|
||||
<div style="flex:1;min-width:0">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:2px">${esc(c.title || '(无标题)')}</div>
|
||||
<div style="font-size:12px;color:var(--text2);line-height:1.6">${esc(c.one_liner || '')}</div>
|
||||
<div style="margin-top:6px">
|
||||
<span class="cl-pill">${m.icon} ${m.label}</span>
|
||||
${c.level ? `<span class="cl-pill">缺口${esc(c.level)}</span>` : ''}
|
||||
${(c.props || []).slice(0, 3).map(p => `<span class="cl-pill">🧰 ${esc(p)}</span>`).join('')}
|
||||
${(c.scenes || []).slice(0, 2).map(p => `<span class="cl-pill">🎬 ${esc(p)}</span>`).join('')}
|
||||
${c.status === 'used' ? '<span class="cl-pill" style="color:#3b82f6">已使用</span>' : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span style="font-size:10px;color:var(--text2);white-space:nowrap">${(c.updated_at || '').slice(5, 16).replace('T', ' ')}</span>
|
||||
</div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
async function openDetail(cid) {
|
||||
const r = await fetch(`${API}/${cid}`);
|
||||
const d = await r.json();
|
||||
if (d.status !== 'ok') { alert('❌ 加载失败'); return; }
|
||||
S.detail = d.card; renderDetail();
|
||||
}
|
||||
|
||||
function renderDetail() {
|
||||
const el = document.getElementById(`${_uid}detail`);
|
||||
const c = S.detail;
|
||||
if (!c) { el.innerHTML = ''; return; }
|
||||
const m = MODE_META[c.inject_mode] || MODE_META.reference;
|
||||
const snap = c.matrix_snapshot || {};
|
||||
el.innerHTML = `
|
||||
<div style="position:fixed;inset:0;background:rgba(0,0,0,.5);z-index:900" onclick="window._clClose()"></div>
|
||||
<div style="position:fixed;right:0;top:0;bottom:0;width:min(680px,94vw);background:var(--bg);border-left:1px solid var(--border);z-index:901;overflow-y:auto;padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<div style="font-size:15px;font-weight:600">${esc(c.title || '')}</div>
|
||||
<button onclick="window._clClose()" style="background:none;border:none;color:var(--text2);font-size:18px;cursor:pointer">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="cs-card" style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:10px">
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:4px">一句话联想</div>
|
||||
<div style="font-size:13px;line-height:1.7">${esc(c.one_liner || '—')}</div>
|
||||
${c.brief ? `<div style="font-size:11px;color:var(--text2);margin-top:8px">原始诉求:${esc(c.brief)}</div>` : ''}
|
||||
<div style="margin-top:8px;font-size:12px">
|
||||
<span style="color:${(c.gap_score || 0) >= 60 ? '#22c55e' : '#f59e0b'};font-weight:700">缺口分 ${c.gap_score}</span>
|
||||
<span style="color:var(--text2)"> · 方差 ${c.variance} · ${esc(c.level || '')}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:10px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">🎯 注入模式(决定后续流程怎么用它)</div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap">
|
||||
${Object.entries(MODE_META).map(([k, v]) => `
|
||||
<button onclick="window._clMode('${k}')" style="background:${c.inject_mode === k ? '#3b82f6' : 'var(--bg3)'};color:${c.inject_mode === k ? '#fff' : 'var(--text)'};border:1px solid ${c.inject_mode === k ? '#3b82f6' : 'var(--border)'};border-radius:6px;padding:6px 12px;font-size:12px;cursor:pointer">
|
||||
${v.icon} ${v.label}</button>`).join('')}
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:6px">${esc(m.desc)}</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:10px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">六层参数(${(c.selections || []).length} 项)</div>
|
||||
${(c.selections || []).map(s => `<div style="font-size:12px;line-height:1.9">· <b>${esc(s.param_name || s.param_id)}</b>:${esc(s.value)} <span style="color:var(--text2)">(强度 ${s.intensity})</span></div>`).join('') || '<div style="color:var(--text2);font-size:12px">—</div>'}
|
||||
${(c.props?.length || c.scenes?.length) ? `<div style="font-size:12px;margin-top:6px">道具/场景:${esc([...(c.props || []), ...(c.scenes || [])].join('、'))}</div>` : ''}
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:10px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">🖼️ 画面联想卡</div>
|
||||
<div style="font-size:12px;line-height:1.9;white-space:pre-wrap">${esc(c.scene_card || '—')}</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--bg2);border:1px solid var(--border);border-radius:8px;padding:10px;margin-bottom:10px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">📌 矩阵快照(本次放开了什么)</div>
|
||||
<div style="font-size:11px;color:var(--text2);line-height:1.8">
|
||||
🟡 已放开:${(snap.negative_soft_relaxed || []).join('、') || '无'}<br>
|
||||
🟡 已排除:${(snap.negative_soft_excluded || []).length} 项<br>
|
||||
正向:${(snap.positive || []).join('、') || '无'} · 记录于 ${esc((snap.at || '').slice(0, 16))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:14px;align-items:center">
|
||||
<button onclick="window._clHandoff('studio')"
|
||||
style="background:#22c55e;color:#000;border:none;border-radius:6px;padding:9px 20px;
|
||||
font-size:13px;font-weight:600;cursor:pointer">🎬 带到导演工作室</button>
|
||||
<span style="font-size:10.5px;color:var(--text2);line-height:1.6">
|
||||
送入工作室的「💡 创意」标签 —— 之后走:设定 → 剧本 → 故事板 → 分镜 → 关键帧 → 视频</span>
|
||||
<button onclick="window._clDel('${c.id}')"
|
||||
style="margin-left:auto;background:none;color:#ef4444;border:1px solid #ef4444;
|
||||
border-radius:6px;padding:9px 14px;font-size:12px;cursor:pointer">删除</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
async function setMode(mode) {
|
||||
const r = await fetch(`${API}/${S.detail.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ inject_mode: mode }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') { S.detail = d.card; renderDetail(); renderStats(); }
|
||||
}
|
||||
|
||||
async function setStatus(status) {
|
||||
await fetch(`${API}/${S.detail.id}`, {
|
||||
method: 'PATCH', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status }),
|
||||
});
|
||||
}
|
||||
|
||||
async function delCard(cid) {
|
||||
if (!confirm('删除这张创意卡?')) return;
|
||||
await fetch(`${API}/${cid}`, { method: 'DELETE' });
|
||||
S.detail = null; renderDetail(); await load();
|
||||
}
|
||||
|
||||
/** 送往后续流程:写 handoff 到 localStorage 并跳转 */
|
||||
async function handoff(target) {
|
||||
const c = S.detail;
|
||||
if (!c) return;
|
||||
const payload = {
|
||||
id: c.id, title: c.title, one_liner: c.one_liner, brief: c.brief,
|
||||
inject_mode: c.inject_mode, scene_card: c.scene_card,
|
||||
selections: c.selections || [], props: c.props || [], scenes: c.scenes || [],
|
||||
gap_score: c.gap_score, at: new Date().toISOString(),
|
||||
};
|
||||
localStorage.setItem('ave_creative_handoff', JSON.stringify(payload));
|
||||
// 标记"已使用"用 fire-and-forget,避免网络异常阻塞跳转
|
||||
setStatus('used').catch(() => {});
|
||||
|
||||
// ★ 统一送到「导演工作室」的 💡 创意 标签
|
||||
// (原来是分镜/口播/脚本三条老路径,其中"只脚本"根本不跳转,
|
||||
// 另外两个跳的是老页面 —— 用户反馈"这条路走不通",故合并为一条)
|
||||
const goStudio = () => {
|
||||
if (window.switchView) { window.switchView('ave-studio'); return true; }
|
||||
location.hash = '#ave-studio';
|
||||
return false;
|
||||
};
|
||||
const ok = goStudio();
|
||||
if (!ok) {
|
||||
alert('✅ 已把这条创意带好。\n\n请手动打开「🎬 导演工作室」,'
|
||||
+ '它会自动把创意放进「💡 创意」标签。\n\n'
|
||||
+ '一句话:' + (c.one_liner || c.title || ''));
|
||||
}
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* 远程Shell视图(v2 — 动态机器 + 快捷命令 + 语法高亮 + 历史)
|
||||
*/
|
||||
export async function loadView(container) {
|
||||
const uid = 'fe_' + Math.random().toString(36).slice(2, 6);
|
||||
const PRESET_CMDS = [
|
||||
'hostname',
|
||||
'df -h /',
|
||||
'pgrep -a camoufox',
|
||||
'ps aux | wc -l',
|
||||
'curl -s localhost:9988/api/health',
|
||||
'agentos guardd status',
|
||||
];
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="padding:20px">
|
||||
<h2 style="font-size:18px;margin-bottom:12px">💻 远程Shell</h2>
|
||||
<p style="font-size:12px;color:var(--text2);margin-bottom:16px">在远程机器上执行命令(通过 mc remote exec)</p>
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-bottom:8px">
|
||||
<select id="execMachine_${uid}" style="background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:5px;font-size:12px;min-width:160px">
|
||||
<option value="">⏳ 加载机器列表...</option>
|
||||
</select>
|
||||
<input id="execCmd_${uid}" placeholder="输入命令..." style="flex:1;min-width:200px;background:var(--bg2);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:5px;font-size:12px"
|
||||
onkeydown="if(event.key==='Enter')window._doFleetExec('${uid}')">
|
||||
<button onclick="window._doFleetExec('${uid}')" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">➡ 执行</button>
|
||||
<button onclick="document.getElementById('execCmd_${uid}').value=''; document.getElementById('log_${uid}').innerHTML=''" style="background:var(--bg2);color:var(--text);border:1px solid var(--border);padding:6px 10px;border-radius:6px;cursor:pointer;font-size:11px">✕ 清空</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-bottom:10px;display:flex;gap:6px;flex-wrap:wrap">
|
||||
${PRESET_CMDS.map(cmd =>
|
||||
`<button onclick="document.getElementById('execCmd_${uid}').value='${cmd.replace(/'/g, "\\'")}';window._doFleetExec('${uid}')" style="height:26px;background:var(--bg3);border:0.5px solid var(--border);border-radius:6px;padding:0 10px;display:inline-flex;align-items:center;font-size:11px;color:var(--text);cursor:pointer">${cmd}</button>`
|
||||
).join('')}
|
||||
</div>
|
||||
|
||||
<div id="log_${uid}" style="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:420px;overflow:auto"></div>
|
||||
|
||||
<div id="history_${uid}" style="margin-top:8px;font-size:11px;display:none">
|
||||
<div style="color:var(--text2);margin-bottom:4px">最近执行</div>
|
||||
<div id="historyList_${uid}" style="display:flex;gap:6px;flex-wrap:wrap"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
// ── 动态加载机器列表 ──
|
||||
const machineSel = document.getElementById('execMachine_' + uid);
|
||||
(async () => {
|
||||
try {
|
||||
const r = await fetch('/api/machines');
|
||||
const d = await r.json();
|
||||
const machines = d.machines || [];
|
||||
machineSel.innerHTML = machines.map(m =>
|
||||
`<option value="${m.hostname}">${m.hostname} ${m.status === 'online' ? '🟢' : '🔴'}</option>`
|
||||
).join('');
|
||||
// 选择第一台在线的
|
||||
const online = machines.find(m => m.status === 'online');
|
||||
if (online) machineSel.value = online.hostname;
|
||||
} catch(e) {
|
||||
machineSel.innerHTML = '<option value="">加载失败</option>';
|
||||
}
|
||||
})();
|
||||
|
||||
// ── 执行 ──
|
||||
window._doFleetExec = window._doFleetExec || (async function(uid) {
|
||||
const machine = document.getElementById('execMachine_' + uid)?.value;
|
||||
const cmd = document.getElementById('execCmd_' + uid)?.value;
|
||||
const logEl = document.getElementById('log_' + uid);
|
||||
const historyEl = document.getElementById('history_' + uid);
|
||||
const historyList = document.getElementById('historyList_' + uid);
|
||||
if (!cmd || !machine) {
|
||||
if (logEl) logEl.innerHTML = '<span style="color:var(--red)">⚠️ 请选择机器并输入命令</span>';
|
||||
return;
|
||||
}
|
||||
|
||||
logEl.innerHTML = '<span style="color:var(--text2)">⏳ 执行中...</span>\n';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/federation/exec', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ machine, command: cmd, timeout: 30 }),
|
||||
});
|
||||
const d = await r.json();
|
||||
const rawOutput = d.output || d.stdout || JSON.stringify(d, null, 2);
|
||||
const hasError = d.status === 'error' || d.returncode > 0 || rawOutput.includes('❌');
|
||||
|
||||
// ── 语法高亮 ──
|
||||
let html = '';
|
||||
const lines = rawOutput.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) { html += '<br>'; continue; }
|
||||
if (trimmed.startsWith('$ ')) {
|
||||
html += `<span style="color:var(--green)">${escapeHtml(trimmed)}</span>\n`;
|
||||
} else if (trimmed.startsWith('❌') || trimmed.toLowerCase().includes('error') || trimmed.toLowerCase().includes('failed')) {
|
||||
html += `<span style="color:var(--red)">${escapeHtml(trimmed)}</span>\n`;
|
||||
} else if (trimmed.startsWith('✅') || trimmed.startsWith('✓') || trimmed.startsWith('+')) {
|
||||
html += `<span style="color:var(--green)">${escapeHtml(trimmed)}</span>\n`;
|
||||
} else if (/^\d/.test(trimmed) || trimmed.includes('GB') || trimmed.includes('%')) {
|
||||
html += `<span style="color:var(--blue)">${escapeHtml(trimmed)}</span>\n`;
|
||||
} else {
|
||||
html += `${escapeHtml(trimmed)}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// 如果是错误状态,顶部加红色 banner
|
||||
if (hasError) {
|
||||
html = `<div style="padding:6px 10px;background:rgba(227,75,74,.1);border-radius:4px;margin-bottom:8px;color:var(--red)">❌ 命令返回非零退出码</div>\n` + html;
|
||||
}
|
||||
|
||||
logEl.innerHTML = `<span style="color:var(--green)">$ ${escapeHtml(cmd)}</span>\n` + html;
|
||||
|
||||
// ── 追加执行历史 ──
|
||||
const now = new Date();
|
||||
const timeStr = now.getHours().toString().padStart(2,'0') + ':' + now.getMinutes().toString().padStart(2,'0');
|
||||
const icon = hasError ? '❌' : '✅';
|
||||
const entry = document.createElement('div');
|
||||
entry.style.cssText = 'background:var(--bg3);padding:4px 8px;border-radius:4px;font-size:10px;white-space:nowrap';
|
||||
entry.innerHTML = `${icon} ${machine}: ${cmd.slice(0,18)}${cmd.length>18?'..':''} (${timeStr})`;
|
||||
if (hasError) entry.style.color = 'var(--red)';
|
||||
else entry.style.color = 'var(--green)';
|
||||
|
||||
if (historyList) {
|
||||
historyList.prepend(entry);
|
||||
// 保留最近10条
|
||||
while (historyList.children.length > 10) {
|
||||
historyList.removeChild(historyList.lastChild);
|
||||
}
|
||||
}
|
||||
if (historyEl) historyEl.style.display = '';
|
||||
|
||||
} catch(e) {
|
||||
logEl.innerHTML = `<span style="color:var(--red)">❌ ${escapeHtml(e.message)}</span>`;
|
||||
}
|
||||
});
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,262 @@
|
||||
/**
|
||||
* 对账检查视图(v2 — 统计卡片 + 维度筛选 + 状态明细 + 导出)
|
||||
*/
|
||||
export async function loadView(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>
|
||||
|
||||
<!-- 筛选 tab -->
|
||||
<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);
|
||||
const statsEl = document.getElementById('stats_' + uid);
|
||||
const filterEl = document.getElementById('filter_' + uid);
|
||||
const detailEl = document.getElementById('detail_' + uid);
|
||||
const detailList = document.getElementById('detailList_' + uid);
|
||||
|
||||
if (!logEl) return;
|
||||
logEl.style.display = '';
|
||||
logEl.textContent = '⏳ 执行对账...\n';
|
||||
|
||||
try {
|
||||
const r = await fetch('/api/fleet/reconcile', { method: 'POST', timeout: 120000 });
|
||||
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 = '';
|
||||
|
||||
const lines = text.split('\n');
|
||||
for (const line of lines) {
|
||||
const trimmed = line.replace(/\u001b\[[\d;]+m/g, '').trim(); // strip ANSI
|
||||
if (!trimmed) continue;
|
||||
|
||||
// Section header: 「━━━ N. Section ━━━」 or 「═...═」
|
||||
if (/^[═━]{3,}\s*\d+\.\s/.test(trimmed)) {
|
||||
currentSection = trimmed.replace(/[═━\s]+/g, ' ').trim();
|
||||
continue;
|
||||
}
|
||||
|
||||
// Status item: 「 ✅ Item: detail」 or 「 ⚠️ Item」 or 「 ❌ Item」
|
||||
let status = null;
|
||||
let content = trimmed;
|
||||
if (/^✅/.test(trimmed)) { status = 'pass'; content = trimmed.replace(/^✅\s*/, ''); }
|
||||
else if (/^⚠️/.test(trimmed)) { status = 'warn'; content = trimmed.replace(/^⚠️\s*/, ''); }
|
||||
else if (/^❌/.test(trimmed)) { status = 'fail'; content = trimmed.replace(/^❌\s*/, ''); }
|
||||
|
||||
if (status && content) {
|
||||
// Split into label and detail
|
||||
const colonIdx = content.indexOf(':');
|
||||
let label = content;
|
||||
let detail = '';
|
||||
if (colonIdx > 0) {
|
||||
label = content.slice(0, colonIdx).trim();
|
||||
detail = content.slice(colonIdx + 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;
|
||||
|
||||
// Stats cards
|
||||
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 exportBtn = document.getElementById('exportBtn_' + uid);
|
||||
if (exportBtn) exportBtn.style.display = '';
|
||||
|
||||
// Stats card colors
|
||||
const passCard = document.getElementById('statPassCard_' + uid);
|
||||
const warnCard = document.getElementById('statWarnCard_' + uid);
|
||||
const failCard = document.getElementById('statFailCard_' + uid);
|
||||
|
||||
if (pass > 0 && fail === 0) {
|
||||
passCard.style.background = '#EAF3DE';
|
||||
} else if (total > 0 && fail === 0) {
|
||||
// warn only
|
||||
} else if (fail > 0) {
|
||||
failCard.style.background = '#FCEBEB';
|
||||
}
|
||||
|
||||
// Store data for filters
|
||||
_rawData = items;
|
||||
// Set all filter tab (index 0) active by default
|
||||
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 filterLabels = ['全部', '环境', '目录', '文件', 'Git', '服务', '任务'];
|
||||
const filter = filterLabels[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 === 0) {
|
||||
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">${escapeHtml(item.label)}</span>`;
|
||||
if (item.detail) html += `<span>${escapeHtml(item.detail)}</span>`;
|
||||
html += `</div>`;
|
||||
}
|
||||
list.innerHTML = html;
|
||||
}
|
||||
|
||||
// ── 导出报告 ──
|
||||
window._exportReconcile = window._exportReconcile || function(uid) {
|
||||
if (!_rawData || _rawData.length === 0) return;
|
||||
|
||||
const pass = _rawData.filter(i => i.status === 'pass').length;
|
||||
const warn = _rawData.filter(i => i.status === 'warn').length;
|
||||
const fail = _rawData.filter(i => i.status === 'fail').length;
|
||||
const total = _rawData.length;
|
||||
const rate = total > 0 ? Math.round(pass / total * 100) : 0;
|
||||
const now = new Date().toLocaleString();
|
||||
|
||||
let md = `# AgentOS 联邦对账报告\n\n`;
|
||||
md += `**生成时间**: ${now}\n\n`;
|
||||
md += `## 统计\n\n`;
|
||||
md += `| 指标 | 数值 |\n|:-----|:-----|\n`;
|
||||
md += `| 总项 | ${total} |\n`;
|
||||
md += `| 通过 | ${pass} |\n`;
|
||||
md += `| 警告 | ${warn} |\n`;
|
||||
md += `| 失败 | ${fail} |\n`;
|
||||
md += `| 通过率 | ${rate}% |\n\n`;
|
||||
md += `## 明细\n\n`;
|
||||
md += `| 状态 | 检查项 | 详情 |\n|:-----|:-------|:------|\n`;
|
||||
for (const item of _rawData) {
|
||||
const icon = item.status === 'pass' ? '✅' : item.status === 'warn' ? '⚠️' : '❌';
|
||||
md += `| ${icon} | ${item.label} | ${item.detail} |\n`;
|
||||
}
|
||||
|
||||
// Copy to clipboard
|
||||
navigator.clipboard.writeText(md).then(() => {
|
||||
const btn = document.getElementById('exportBtn_' + uid);
|
||||
if (btn) {
|
||||
const orig = btn.textContent;
|
||||
btn.textContent = '✅ 已复制';
|
||||
setTimeout(() => { btn.textContent = orig; }, 2000);
|
||||
}
|
||||
}).catch(() => {
|
||||
// Fallback: show in textarea
|
||||
const 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 escapeHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
/**
|
||||
* 一键同步视图(已迁移)
|
||||
*/
|
||||
export async function loadView(container) {
|
||||
const uid = 'fs_' + Math.random().toString(36).slice(2, 6);
|
||||
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">通过 Git 同步三台机器(本机 / 5kecheng / 7kecheng)的最新代码</p>
|
||||
<div style="display:flex;gap:8px;margin-bottom:12px">
|
||||
<button id="btnSync_${uid}" onclick="window._doFleetSync('${uid}')" style="background:var(--primary);color:#fff;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;font-size:13px">🔄 执行同步</button>
|
||||
</div>
|
||||
<div id="log_${uid}" style="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:400px;overflow:auto"></div>
|
||||
</div>`;
|
||||
|
||||
window._doFleetSync = window._doFleetSync || async function(uid) {
|
||||
const logEl = document.getElementById(`log_${uid}`);
|
||||
const btn = document.getElementById(`btnSync_${uid}`);
|
||||
if (!logEl) return;
|
||||
logEl.textContent = '⏳ 执行中...\n';
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 同步中...';
|
||||
try {
|
||||
const r = await fetch('/api/fleet/sync', { method: 'POST' });
|
||||
const d = await r.json();
|
||||
logEl.textContent = d.output || JSON.stringify(d, null, 2);
|
||||
} catch(e) {
|
||||
logEl.textContent = '❌ ' + e.message;
|
||||
}
|
||||
btn.disabled = false;
|
||||
btn.textContent = '🔄 执行同步';
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* flow-entry.js — 流程入口页共享组件
|
||||
*
|
||||
* 「流程」菜单里的各入口页(短剧创作/基础口播/卡点视频/…)用它渲染:
|
||||
* 展示该流程的步骤预览 → 点「▶️ 开始」把对应模板加载到流程画布。
|
||||
*/
|
||||
|
||||
export async function renderFlowEntry(container, cfg) {
|
||||
let tmpl = null;
|
||||
try {
|
||||
const r = await fetch('/api/workflow/templates');
|
||||
const d = await r.json();
|
||||
tmpl = (d.templates || {})[cfg.templateId] || null;
|
||||
} catch (e) { /* 模板拉取失败不阻塞页面 */ }
|
||||
|
||||
const nodeList = tmpl?.nodes || [];
|
||||
const chain = nodeList.map(n => n.label || n.type).join(' → ');
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="padding:20px;max-width:780px">
|
||||
<h2 style="font-size:20px;margin:0 0 6px">${esc(cfg.icon)} ${esc(cfg.title)}
|
||||
${tmpl?.cost ? `<span style="font-size:12px;color:var(--text2)">预估 ${esc(tmpl.cost)}</span>` : ''}</h2>
|
||||
<div style="font-size:12px;color:var(--text2);margin-bottom:14px">${esc(cfg.desc || tmpl?.description || '')}</div>
|
||||
|
||||
<div style="background:var(--bg3);border:1px solid var(--border);border-radius:8px;padding:14px;margin-bottom:12px">
|
||||
<div style="font-size:12px;font-weight:600;margin-bottom:6px">流程步骤(${nodeList.length} 个节点)</div>
|
||||
<div style="font-size:12px;color:var(--text2);line-height:2">${chain ? esc(chain) : '(模板未加载,可直接打开画布手动搭)'}</div>
|
||||
</div>
|
||||
|
||||
${(cfg.tips && cfg.tips.length) ? `<div style="font-size:11px;color:var(--text2);line-height:1.9;margin-bottom:12px;padding:10px 12px;background:var(--bg3);border:1px solid var(--border);border-radius:6px">
|
||||
<b>怎么用</b><br>${cfg.tips.map(t => '· ' + esc(t)).join('<br>')}
|
||||
</div>` : ''}
|
||||
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button onclick="window._flowStart('${esc(cfg.templateId)}')"
|
||||
style="background:var(--primary);color:#fff;border:none;padding:8px 20px;border-radius:6px;cursor:pointer;font-size:13px;font-weight:600">▶️ 开始(加载到流程画布)</button>
|
||||
<button onclick="window.switchView && window.switchView('workflow')"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:8px 16px;border-radius:6px;cursor:pointer;font-size:12px">打开空白画布</button>
|
||||
<button onclick="window.switchView && window.switchView('ave-director')"
|
||||
style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:8px 16px;border-radius:6px;cursor:pointer;font-size:12px">🎬 先去导演创作</button>
|
||||
</div>
|
||||
|
||||
<div style="margin-top:12px;font-size:10px;color:var(--text2);line-height:1.8">
|
||||
进入画布后点「▶ 运行」开始执行 · 生成类节点会实际调用可灵(<b>产生费用</b>),用右上角「镜头数」限制 · 每步产物可在右侧面板预览,可「🔄 重做此节点」
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
window._flowStart = (templateId) => {
|
||||
if (templateId) localStorage.setItem('wf_pending_template', templateId);
|
||||
if (window.switchView) window.switchView('workflow');
|
||||
};
|
||||
|
||||
function esc(s) {
|
||||
return String(s ?? '').replace(/[&<>"']/g, m =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
}
|
||||
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* gen-review.js — 生成前审核面板(通用组件)
|
||||
* ============================================
|
||||
*
|
||||
* 为什么需要它:
|
||||
* 用户反馈「只告诉我有费用、却不告诉我传了什么,那不叫审核,那叫通知」。
|
||||
* 所以任何"会产生费用 / 会把内容发给大模型"的操作,都应在真正执行前,
|
||||
* 把**将发送的完整内容 + 参数 + 费用**摆出来,允许就地修改,
|
||||
* 确认后才执行 —— 这样每一步都可审核、可返回修改。
|
||||
*
|
||||
* 用法:
|
||||
* import { openGenerateReview } from './gen-review.js';
|
||||
*
|
||||
* openGenerateReview({
|
||||
* title: '🎨 生成参考图 · front',
|
||||
* subtitle: '下面就是即将发送给模型的全部内容,可直接修改',
|
||||
* items: [
|
||||
* { key: 'prompt', label: '提示词(发送给模型)', value: '...', rows: 8 },
|
||||
* ],
|
||||
* meta: [['供应商', '可灵(Kling)'], ['尺寸', '9:16'], ['预计费用', '约 ¥0.2~0.5/张']],
|
||||
* sourceFields: { name: '病房-302', space_type: '医院单人病房' }, // 可选
|
||||
* notice: '生成前请确认提示词描述准确;生成结果会保存为该资产的参考图',
|
||||
* confirmText: '✅ 确认生成',
|
||||
* onConfirm: async (values) => { /* values.prompt = 编辑后的值 *\/ },
|
||||
* });
|
||||
*/
|
||||
|
||||
export function openGenerateReview(opts) {
|
||||
const {
|
||||
title = '生成前审核',
|
||||
subtitle = '下面是将要发送的全部内容,可直接修改后再执行',
|
||||
items = [],
|
||||
meta = [],
|
||||
sourceFields = null,
|
||||
notice = '',
|
||||
confirmText = '✅ 确认生成',
|
||||
cancelText = '取消',
|
||||
onConfirm = null,
|
||||
} = opts || {};
|
||||
|
||||
const uid = 'gr_' + Math.random().toString(36).slice(2, 9);
|
||||
|
||||
const esc = (s) => String(s ?? '').replace(/[&<>"']/g, (m) =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[m]));
|
||||
|
||||
const itemHtml = items.map((it, i) => `
|
||||
<div style="margin-bottom:10px">
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:3px">${esc(it.label || it.key)}</div>
|
||||
<textarea id="${uid}_v_${i}" rows="${it.rows || 6}" ${it.readOnly ? 'readonly' : ''}
|
||||
style="width:100%;padding:8px 10px;background:${it.readOnly ? 'var(--bg2)' : 'var(--bg3)'};border:1px solid var(--border);color:var(--text);border-radius:6px;font-size:12px;line-height:1.6;resize:vertical;font-family:inherit">${esc(it.value || '')}</textarea>
|
||||
${it.hint ? `<div style="font-size:10px;color:var(--text2);margin-top:2px">${esc(it.hint)}</div>` : ''}
|
||||
</div>`).join('');
|
||||
|
||||
const metaHtml = meta.length ? `
|
||||
<div style="display:grid;grid-template-columns:auto 1fr;gap:4px 12px;font-size:11px;margin-bottom:10px;
|
||||
background:var(--bg2);border:1px solid var(--border);border-radius:6px;padding:8px 10px">
|
||||
${meta.map(([k, v]) => `<div style="color:var(--text2)">${esc(k)}</div><div>${esc(v)}</div>`).join('')}
|
||||
</div>` : '';
|
||||
|
||||
const srcHtml = (sourceFields && Object.keys(sourceFields).length) ? `
|
||||
<details style="margin-bottom:10px">
|
||||
<summary style="font-size:11px;color:var(--text2);cursor:pointer">
|
||||
📎 提示词来源字段(点开查看 · 若描述不全,可回资产编辑补充)</summary>
|
||||
<div style="font-size:11px;line-height:1.8;background:var(--bg3);border-radius:6px;padding:8px 10px;margin-top:5px;word-break:break-all">
|
||||
${Object.entries(sourceFields).map(([k, v]) =>
|
||||
`<div><span style="color:var(--text2)">${esc(k)}:</span> ${esc(typeof v === 'object' ? JSON.stringify(v) : v)}</div>`).join('')}
|
||||
</div>
|
||||
</details>` : '';
|
||||
|
||||
const mask = document.createElement('div');
|
||||
mask.id = uid;
|
||||
mask.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.6);z-index:99999;display:flex;align-items:center;justify-content:center;padding:20px';
|
||||
|
||||
mask.innerHTML = `
|
||||
<div style="background:var(--bg);border:1px solid var(--border);border-radius:10px;
|
||||
width:100%;max-width:640px;max-height:88vh;overflow:auto;padding:16px 18px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:flex-start;margin-bottom:4px">
|
||||
<div>
|
||||
<div style="font-size:15px;font-weight:600">${esc(title)}</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-top:3px">${esc(subtitle)}</div>
|
||||
</div>
|
||||
<button id="${uid}_x" style="background:none;border:none;color:var(--text2);cursor:pointer;font-size:16px;line-height:1">✕</button>
|
||||
</div>
|
||||
<div style="height:1px;background:var(--border);margin:10px 0"></div>
|
||||
${metaHtml}
|
||||
${itemHtml}
|
||||
${srcHtml}
|
||||
${notice ? `<div style="font-size:11px;color:#f59e0b;background:rgba(245,158,11,.08);
|
||||
border:1px solid rgba(245,158,11,.3);border-radius:6px;padding:8px 10px;margin-bottom:10px">${esc(notice)}</div>` : ''}
|
||||
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:12px">
|
||||
<button id="${uid}_c" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);
|
||||
padding:7px 16px;border-radius:6px;cursor:pointer;font-size:12px">${esc(cancelText)}</button>
|
||||
<button id="${uid}_ok" style="background:var(--primary);border:none;color:#fff;padding:7px 18px;
|
||||
border-radius:6px;cursor:pointer;font-size:12px;font-weight:600">${esc(confirmText)}</button>
|
||||
</div>
|
||||
</div>`;
|
||||
|
||||
document.body.appendChild(mask);
|
||||
|
||||
const close = () => { try { mask.remove(); } catch (e) { } };
|
||||
const onEsc = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onEsc); } };
|
||||
document.addEventListener('keydown', onEsc);
|
||||
|
||||
mask.querySelector(`#${uid}_x`).onclick = close;
|
||||
mask.querySelector(`#${uid}_c`).onclick = close;
|
||||
mask.addEventListener('click', (e) => { if (e.target === mask) close(); });
|
||||
|
||||
mask.querySelector(`#${uid}_ok`).onclick = async () => {
|
||||
const btn = mask.querySelector(`#${uid}_ok`);
|
||||
const values = {};
|
||||
items.forEach((it, i) => {
|
||||
const el = mask.querySelector(`#${uid}_v_${i}`);
|
||||
values[it.key] = el ? el.value : (it.value || '');
|
||||
});
|
||||
if (typeof onConfirm !== 'function') { close(); return; }
|
||||
try {
|
||||
btn.disabled = true;
|
||||
btn.textContent = '⏳ 执行中...';
|
||||
await onConfirm(values);
|
||||
close();
|
||||
} catch (e) {
|
||||
btn.disabled = false;
|
||||
btn.textContent = confirmText;
|
||||
alert('❌ ' + (e && e.message ? e.message : e));
|
||||
}
|
||||
};
|
||||
|
||||
return { close };
|
||||
}
|
||||
|
||||
// 挂到 window(内联 onclick 与跨模块调用都需要)
|
||||
if (typeof window !== 'undefined') {
|
||||
window.openGenerateReview = openGenerateReview;
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* 机器状态视图(已迁移)
|
||||
* 替换 index.html 中 inline 的 loadMachines()
|
||||
*/
|
||||
|
||||
import { showLoading, showError, apiRequest } from '../router.js';
|
||||
|
||||
export async function loadView(container) {
|
||||
showLoading(container, '读取联邦心跳中...');
|
||||
|
||||
try {
|
||||
const d = await apiRequest('/machines');
|
||||
const machines = d.machines || [];
|
||||
|
||||
// 更新计数
|
||||
const countEl = document.getElementById('machineCount');
|
||||
if (countEl) countEl.textContent = `共 ${machines.length} 台主机`;
|
||||
|
||||
if (!machines.length) {
|
||||
container.innerHTML = '<div class="error" style="padding:40px">无心跳数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:16px" id="machineGrid">
|
||||
${machines.map(m => renderMachineCard(m)).join('')}
|
||||
</div>
|
||||
`;
|
||||
} catch (e) {
|
||||
showError(container, `加载失败: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function renderMachineCard(m) {
|
||||
// 在线状态 = 信任 API 的 status 字段
|
||||
// 心跳时间仅做参考提示,不改变状态判定
|
||||
const lastPushSec = m._last_push_sec || 0;
|
||||
const isOnline = m.status === 'online';
|
||||
const isRecent = m.status === 'recent';
|
||||
const dotColor = isOnline ? '#22C55E' : isRecent ? '#F59E0B' : '#EF4444';
|
||||
const statusText = isOnline ? '在线' : isRecent ? '近期' : '离线';
|
||||
const minAgo = m.minutes_ago || Math.round(lastPushSec / 60) || 0;
|
||||
const timeStr = lastPushSec < 60
|
||||
? `${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 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>`;
|
||||
|
||||
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">磁盘: ${(diskUsed).toFixed(0)}G / ${(diskTotal).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>`;
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
/**
|
||||
* 账号管理视图(v3 — 已废弃,由 accounts-center.js 替代)
|
||||
*
|
||||
* 导航已改为指向 accounts-center,此文件仅保留用于过渡兼容,
|
||||
* 后续清理时移除本文件即可。
|
||||
*
|
||||
* @deprecated 使用 accounts-center.js 替代
|
||||
*/
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = '<div class="loading">⏳ 加载中...</div>';
|
||||
try {
|
||||
const BASE = '/api';
|
||||
const [ar, hr] = await Promise.all([
|
||||
fetch(BASE + '/v2/accounts').then(r => r.ok ? r.json() : { accounts: [] }).catch(() => ({ accounts: [] })),
|
||||
fetch(BASE + '/matrix/homepage-info').then(r => r.ok ? r.json() : { results: [] }).catch(() => ({ results: [] })),
|
||||
]);
|
||||
const allAccounts = Array.isArray(ar) ? ar : (ar.accounts || []);
|
||||
const results = (hr.results || []);
|
||||
|
||||
const hpIndex = {};
|
||||
results.forEach(r => { if (r.identity_dir) hpIndex[r.identity_dir] = r; if (r.phone) hpIndex[r.phone] = r; });
|
||||
|
||||
// 构建展示数据
|
||||
function buildAccounts(accounts) {
|
||||
const machineOrder = ['chengzigedeAir', '5kechengdeAir', '7kecheng'];
|
||||
const groups = {};
|
||||
accounts.forEach(a => {
|
||||
const m = a.owner_machine || 'unknown';
|
||||
if (!groups[m]) groups[m] = [];
|
||||
const hp = hpIndex[a.identity_dir] || hpIndex[a.phone];
|
||||
const hpAcc = hp ? (a.platform === 'douyin' ? hp.douyin : hp.xiaohongshu) : null;
|
||||
groups[m].push({
|
||||
id: a.id, platform: a.platform, phone: a.phone || '',
|
||||
identity_dir: (a.identity_dir || '').replace('identities/', ''),
|
||||
nickname: hpAcc?.nickname || a.nickname || '',
|
||||
fans: hpAcc?.fans || a.fans || '', following: hpAcc?.following || a.following || '',
|
||||
likes: hpAcc?.likes || a.likes || '', posts: hpAcc?.posts || a.posts || '',
|
||||
status: a._status || 'unknown',
|
||||
});
|
||||
});
|
||||
let html = '<table style="width:100%;font-size:11px;border-collapse:collapse">';
|
||||
html += '<tr style="font-size:9px;color:var(--text2);border-bottom:1px solid var(--border)">'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:left">机器</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:left">手机号</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:left">身份</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:left">账号ID</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:left">平台</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:left">昵称</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:right">粉丝</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:right">关注</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:right">获赞</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:right">作品</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:left">登录状态</th>'
|
||||
+ '<th style="padding:4px 6px;font-weight:400;text-align:left">操作</th></tr>';
|
||||
machineOrder.filter(m => groups[m]).forEach(m => {
|
||||
groups[m].sort((a, b) => (a.phone || '').localeCompare(b.phone || ''));
|
||||
groups[m].forEach(a => {
|
||||
const statusMap = { logged_in:'已登录', expired:'已过期', no_cookie:'无Cookie', disabled:'已禁用', banned:'已封号', new:'新建' };
|
||||
const s = statusMap[a.status] || (a.status === 'remote' ? '远程' : a.status);
|
||||
html += `<tr style="border-bottom:1px solid var(--border)" data-filter="${(a.id+a.phone+a.nickname+a.identity_dir+m).toLowerCase()}">`
|
||||
+ `<td style="padding:3px 6px">${m === 'chengzigedeAir' ? '🖥️' : '☁️'}${m}</td>`
|
||||
+ `<td style="padding:3px 6px;font-size:10px;color:var(--text2)">${a.phone || '-'}</td>`
|
||||
+ `<td style="padding:3px 6px;font-size:10px;color:var(--text2)">📁${a.identity_dir}</td>`
|
||||
+ `<td style="padding:3px 6px"><strong>${a.id}</strong></td>`
|
||||
+ `<td style="padding:3px 6px">${a.platform === 'douyin' ? '🎵' : '📕'}</td>`
|
||||
+ `<td style="padding:3px 6px">${a.nickname || '-'}</td>`
|
||||
+ `<td style="padding:3px 6px;text-align:right">${a.fans || '-'}</td>`
|
||||
+ `<td style="padding:3px 6px;text-align:right">${a.following || '-'}</td>`
|
||||
+ `<td style="padding:3px 6px;text-align:right">${a.likes || '-'}</td>`
|
||||
+ `<td style="padding:3px 6px;text-align:right">${a.posts || '-'}</td>`
|
||||
+ `<td style="padding:3px 6px;font-size:10px">${s}</td>`
|
||||
+ `<td style="padding:3px 6px;white-space:nowrap">
|
||||
<button onclick="window._actCol(this,'${a.id.replace(/'/g,"\\'")}','${a.platform}')" style="background:transparent;border:1px solid var(--border);padding:1px 5px;border-radius:4px;cursor:pointer;font-size:10px;color:var(--text)" title="采集">📡</button>
|
||||
<button onclick="window._actLogin(this,'${a.id.replace(/'/g,"\\'")}','${a.platform}')" style="background:transparent;border:1px solid var(--border);padding:1px 5px;border-radius:4px;cursor:pointer;font-size:10px;color:var(--text)" title="登录">🔑</button>
|
||||
</td></tr>`;
|
||||
});
|
||||
});
|
||||
html += '</table>';
|
||||
return html;
|
||||
}
|
||||
|
||||
function render(q, machine, platform) {
|
||||
const source = window._allAccounts || allAccounts;
|
||||
let filtered = source;
|
||||
if (q) { const lq = q.toLowerCase(); filtered = source.filter(a => (a.id+a.phone+(a.nickname||'')+(a.identity_dir||'')+(a.owner_machine||'')).toLowerCase().includes(lq)); }
|
||||
if (machine) filtered = filtered.filter(a => a.owner_machine === machine);
|
||||
if (platform) filtered = filtered.filter(a => a.platform === platform);
|
||||
const tableDiv = document.getElementById('acctTable');
|
||||
if (tableDiv) tableDiv.innerHTML = buildAccounts(filtered);
|
||||
const cnt = document.getElementById('acctCount');
|
||||
if (cnt) cnt.textContent = `共 ${filtered.length}/${source.length} 个`;
|
||||
}
|
||||
|
||||
const machineList = [...new Set(allAccounts.map(a => a.owner_machine).filter(Boolean))];
|
||||
container.innerHTML = `
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h2 style="font-size:18px;margin:0">👤 账号管理</h2>
|
||||
<div style="display:flex;gap:6px">
|
||||
<button onclick="window._showCreateAcct()" style="background:var(--primary);color:#fff;border:none;padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">+ 新建账号</button>
|
||||
<button onclick="window._filterAcct()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">🔄 刷新</button>
|
||||
<button onclick="try{window.switchView('matrix-sms-proxy')}catch(e){}" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:6px 14px;border-radius:6px;cursor:pointer;font-size:12px">📡 短信与代理</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-bottom:8px;align-items:center">
|
||||
<input id="acctSearch" placeholder="🔍 搜索手机号/昵称/账号ID/机器..." oninput="_filterAcct()"
|
||||
style="flex:1;padding:4px 8px;font-size:12px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<select id="acctMachineFilter" onchange="_filterAcct()" style="padding:4px 8px;font-size:11px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="">全部机器</option>
|
||||
${machineList.map(m => `<option value="${m}">${m}</option>`).join('')}
|
||||
</select>
|
||||
<select id="acctPlatformFilter" onchange="_filterAcct()" style="padding:4px 8px;font-size:11px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="">全部平台</option>
|
||||
<option value="douyin">🎵 抖音</option>
|
||||
<option value="xiaohongshu">📕 小红书</option>
|
||||
</select>
|
||||
<span id="acctCount" style="font-size:10px;color:var(--text2);white-space:nowrap">共 ${allAccounts.length} 个</span>
|
||||
</div>
|
||||
<div id="acctTable" style="overflow-x:auto">${buildAccounts(allAccounts)}</div>`;
|
||||
|
||||
window._filterAcct = () => {
|
||||
const q = document.getElementById('acctSearch')?.value || '';
|
||||
const m = document.getElementById('acctMachineFilter')?.value || '';
|
||||
const p = document.getElementById('acctPlatformFilter')?.value || '';
|
||||
render(q, m, p);
|
||||
};
|
||||
|
||||
// ── 单账号操作按钮 ──
|
||||
window._actCol = async (btn, aid, platform) => {
|
||||
if (btn) { btn.textContent = '⏳'; btn.disabled = true; }
|
||||
const bp = platform === 'xiaohongshu' ? 'xiaohongshu_read_profile' : 'douyin_read_profile';
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({type:'collect', accounts:[aid], params:{blueprint:bp, rounds:1}}) });
|
||||
const d = await r.json();
|
||||
alert(d.status === 'error' ? '❌ '+(d.message||d.error||'') : '✅ 已提交采集');
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
if (btn) { btn.textContent = '📡'; btn.disabled = false; }
|
||||
};
|
||||
window._actLogin = async (btn, aid) => {
|
||||
if (btn) { btn.textContent = '⏳'; btn.disabled = true; }
|
||||
try {
|
||||
const r = await fetch('/api/ops/run', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({type:'login', accounts:[aid], params:{machine: (document.getElementById('acctMachineFilter')?.value||'')} }) });
|
||||
const d = await r.json();
|
||||
alert(d.status === 'error' ? '❌ '+(d.message||d.error||'') : '✅ 已提交登录');
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
if (btn) { btn.textContent = '🔑'; btn.disabled = false; }
|
||||
};
|
||||
|
||||
// 新建账号对话框(ID 自动生成: douyin_手机号 / xhs_手机号)
|
||||
window.showCreateAccount = window._showCreateAcct = () => {
|
||||
// 可选机器列表(不重复+本机排第一)
|
||||
const machines = [...new Set(allAccounts.map(a => a.owner_machine).filter(Boolean))];
|
||||
const primaryMachine = 'chengzigedeAir';
|
||||
const sorted = [primaryMachine, ...machines.filter(m => m !== primaryMachine)];
|
||||
const machineOpts = sorted.map(m => `<option value="${m}">${m === primaryMachine ? '🖥️ 本机 ' + m : '☁️ ' + m}</option>`).join('');
|
||||
|
||||
// 检查是否重复
|
||||
const checkDup = (plat, phone) => allAccounts.find(a => a.platform === plat && a.phone === phone && a.enabled !== false);
|
||||
|
||||
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.onclick = e => { if (e.target === overlay) overlay.remove(); };
|
||||
overlay.innerHTML = `
|
||||
<div style="background:var(--bg2);border-radius:12px;padding:20px;max-width:380px;width:90%">
|
||||
<div style="font-size:15px;font-weight:600;margin-bottom:12px">+ 新建账号</div>
|
||||
<div style="display:grid;gap:6px;font-size:12px">
|
||||
<label>目标机器 <select id="newAcctMachine" style="width:100%;padding:4px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">${machineOpts}</select></label>
|
||||
<label>平台 <select id="newAcctPlat" onchange="_updateAcctId()" style="width:100%;padding:4px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="douyin">🎵 抖音</option><option value="xiaohongshu">📕 小红书</option></select></label>
|
||||
<label>手机号 <input id="newAcctPhone" oninput="_updateAcctId()" placeholder="手机号" autocomplete="off" style="width:100%;padding:4px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px"></label>
|
||||
<label>账号ID <input id="newAcctId" readonly style="width:100%;padding:4px;background:var(--bg3);border:1px solid var(--border);color:var(--text2);border-radius:4px;font-size:10px"></label>
|
||||
<div id="newAcctDupWarning" style="font-size:11px;color:#f59e0b;display:none">⚠️ 该手机号已有同类账号</div>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end;margin-top:12px">
|
||||
<button onclick="this.closest('div[style]').parentElement.remove()" style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:6px 16px;font-size:12px;cursor:pointer">取消</button>
|
||||
<button id="newAcctOk" style="background:var(--primary);color:#fff;border:none;border-radius:6px;padding:6px 16px;font-size:12px;cursor:pointer">✅ 创建</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
|
||||
window._updateAcctId = () => {
|
||||
const plat = document.getElementById('newAcctPlat')?.value || 'douyin';
|
||||
const phone = document.getElementById('newAcctPhone')?.value?.trim() || '';
|
||||
const prefix = plat === 'douyin' ? 'douyin_' : 'xhs_';
|
||||
document.getElementById('newAcctId').value = phone ? prefix + phone : '';
|
||||
// 重复检测
|
||||
const warn = document.getElementById('newAcctDupWarning');
|
||||
if (phone && checkDup(plat, phone)) { warn.style.display = 'block'; }
|
||||
else { warn.style.display = 'none'; }
|
||||
};
|
||||
|
||||
document.getElementById('newAcctOk').onclick = async () => {
|
||||
const id = document.getElementById('newAcctId').value.trim();
|
||||
const plat = document.getElementById('newAcctPlat').value;
|
||||
const phone = document.getElementById('newAcctPhone').value.trim();
|
||||
const machine = document.getElementById('newAcctMachine').value;
|
||||
if (!id) { alert('请先输入手机号'); return; }
|
||||
if (checkDup(plat, phone)) {
|
||||
if (!confirm('⚠️ 该手机号已有 "' + plat + '" 账号,确定仍创建?')) return;
|
||||
}
|
||||
const btn = document.getElementById('newAcctOk');
|
||||
btn.textContent = '⏳ 创建中...'; btn.disabled = true;
|
||||
try {
|
||||
const r = await fetch(BASE + '/matrix/accounts', {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ id, platform: plat, phone, owner_machine: machine, enabled: true }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (d.status === 'ok') {
|
||||
overlay.remove();
|
||||
// 重新获取数据 → 动态刷新页面内容
|
||||
const r2 = await fetch(BASE + '/v2/accounts');
|
||||
const d2 = await r2.json();
|
||||
window._allAccounts = Array.isArray(d2) ? d2 : (d2.accounts || []);
|
||||
window._filterAcct();
|
||||
} else { alert('❌ ' + (d.detail || d.message)); btn.textContent = '✅ 创建'; btn.disabled = false; }
|
||||
} catch(e) { alert('❌ ' + e.message); btn.textContent = '✅ 创建'; btn.disabled = false; }
|
||||
};
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="error">❌ ${e.message || e}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
/**
|
||||
* 原子操作视图(已迁移)
|
||||
*/
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = '<div class="loading">加载中...</div>';
|
||||
try {
|
||||
const r = await fetch('/api/matrix/atom-ops');
|
||||
const d = await r.json();
|
||||
const ops = d.ops || [];
|
||||
|
||||
const groups = {};
|
||||
ops.forEach(o => {
|
||||
const p = o.platform || '通用';
|
||||
if (!groups[p]) groups[p] = [];
|
||||
groups[p].push(o);
|
||||
});
|
||||
|
||||
const platformLabels = { douyin: '🎵 抖音', xiaohongshu: '📕 小红书', '通用': '⚙️ 通用' };
|
||||
|
||||
let html = `<div style="padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<h2 style="font-size:18px;margin:0">⚙️ 原子操作</h2>
|
||||
<span style="font-size:12px;color:var(--text2)">共 ${ops.length} 个操作</span>
|
||||
</div>`;
|
||||
|
||||
Object.entries(groups).forEach(([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: '⏳ 工具' };
|
||||
|
||||
html += `<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).forEach(([cat, catItems]) => {
|
||||
html += `<div style="margin-bottom:8px">
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:4px;padding-left:4px">${catLabels[cat] || cat}</div>
|
||||
<div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:4px">`;
|
||||
catItems.forEach(o => {
|
||||
html += `<div style="background:var(--bg3);border-radius:6px;padding:6px 8px;border:1px solid var(--border)">
|
||||
<div style="font-size:11px;font-weight:500">${o.name}</div>
|
||||
<div style="font-size:9px;color:var(--text2);margin-top:1px">${o.description || o.desc || '-'}</div>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
});
|
||||
html += '</div>';
|
||||
});
|
||||
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,187 @@
|
||||
/**
|
||||
* 蓝图管理(step1:列表 + 执行/编辑/删除)
|
||||
*/
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = '<div class="loading">⏳ 加载中...</div>';
|
||||
try {
|
||||
const [br, ar] = await Promise.all([
|
||||
fetch('/api/matrix/blueprints'),
|
||||
fetch('/api/v2/accounts'),
|
||||
]);
|
||||
const bpData = await br.json();
|
||||
const bps = Array.isArray(bpData) ? bpData : (bpData.blueprints || []);
|
||||
const accts = (await ar.json()).accounts || [];
|
||||
const platIcon = p => p === 'xiaohongshu' ? '📕' : '🎵';
|
||||
|
||||
// 执行蓝图
|
||||
window._bpExec = name => {
|
||||
const ids = accts.map(a => a.id).join(', ');
|
||||
const input = prompt(`输入账号ID(逗号分隔,可用: ${ids.slice(0,40)}...)`, accts.slice(0,2).map(a=>a.id).join(','));
|
||||
if (!input) return;
|
||||
fetch('/api/ops/run', {method:'POST',headers:{'Content-Type':'application/json'}, body: JSON.stringify({type:'collect', accounts: input.split(',').map(s=>s.trim()), params:{blueprint: name, rounds:1}})})
|
||||
.then(r=>r.json()).then(d=>alert(d.status==='error'?'❌ '+d.message:'✅ 已提交')).catch(e=>alert('❌ '+e.message));
|
||||
};
|
||||
window._bpDel = async name => {
|
||||
if (!confirm(`删除 "${name}"?`)) return;
|
||||
try {
|
||||
const r = await fetch(`/api/matrix/blueprints/${encodeURIComponent(name)}`, {method:'DELETE'});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
location.reload();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
};
|
||||
window._bpEdit = name => {
|
||||
const bp = bps.find(b => b.name === name);
|
||||
if (!bp) return;
|
||||
document.getElementById('bpName').value = bp.name;
|
||||
if (bp.platform) document.getElementById('bpPlatform').value = bp.platform;
|
||||
document.getElementById('bpDesc').value = bp.description || '';
|
||||
window._bpSteps = (bp.steps || []).map(s => ({ name: s.op || s.name || '', label: (window._matrixOps?.[s.op||s.name]?.label) || s.op || s.name || '' }));
|
||||
renderSteps();
|
||||
document.getElementById('bpListBody').style.display = 'block';
|
||||
document.getElementById('bpListArrow').textContent = '▼ 收起';
|
||||
};
|
||||
|
||||
let html = `<div style="padding:16px">
|
||||
<h2 style="font-size:18px;margin:0 0 10px">📋 蓝图管理</h2>
|
||||
<div style="margin-bottom:10px;background:var(--bg2);border-radius:10px;border:1px solid var(--border);overflow:hidden">
|
||||
<div onclick="window._bpToggle()" style="padding:10px 14px;font-weight:600;font-size:13px;cursor:pointer;display:flex;justify-content:space-between;align-items:center;user-select:none">
|
||||
<span>📋 全部 (${bps.length})</span>
|
||||
<span id="bpListArrow" style="color:var(--text2);font-size:12px">▶ 展开</span>
|
||||
</div>
|
||||
<div id="bpListBody" style="display:none;padding:0 14px 10px">`;
|
||||
bps.forEach(b => {
|
||||
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.steps||[]).length}步 · ${b.platform||'douyin'}</span></span>
|
||||
<span style="display:flex;gap:4px;flex-shrink:0">
|
||||
<button onclick="window._bpExec('${b.name.replace(/'/g,"\\'")}')" style="background:#6366f1;color:#fff;border:none;padding:4px 10px;border-radius:6px;cursor:pointer;font-size:11px">▶ 执行</button>
|
||||
<button onclick="window._bpEdit('${b.name.replace(/'/g,"\\'")}')" style="background:transparent;color:var(--text);border:1px solid var(--border);padding:4px 8px;border-radius:6px;cursor:pointer;font-size:11px">✏️</button>
|
||||
<button onclick="window._bpDel('${b.name.replace(/'/g,"\\'")}')" 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">🗑</button>
|
||||
</span>
|
||||
</div>`;
|
||||
});
|
||||
html += '</div></div>';
|
||||
|
||||
// 编辑器区域
|
||||
html += `<div style="background:var(--bg2);border-radius:10px;padding:16px;border:1px solid var(--border)">
|
||||
<div style="font-size:13px;font-weight:600;margin-bottom:8px">✏️ 编辑器 <span style="font-size:11px;color:var(--text2);font-weight:400">(编辑/新建蓝图)</span></div>
|
||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">
|
||||
<input id="bpName" placeholder="蓝图名称" style="flex:2;min-width:150px;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:5px;font-size:13px">
|
||||
<select id="bpPlatform" onchange="window._bpPlatChange()" style="flex:1;min-width:80px;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:5px;font-size:12px">
|
||||
<option value="douyin">🎵 抖音</option>
|
||||
<option value="xiaohongshu">📕 小红书</option>
|
||||
</select>
|
||||
<input id="bpDesc" placeholder="备注" style="flex:2;min-width:150px;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:6px 10px;border-radius:5px;font-size:12px">
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap" id="bpOpTabs"></div>
|
||||
<div id="bpOpList" style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:8px;min-height:40px">加载原子操作...</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:4px">步骤 <span id="bpStepCount">0 步</span></div>
|
||||
<div id="bpSteps" style="min-height:40px;background:var(--bg3);border-radius:6px;padding:6px;border:1px solid var(--border);margin-bottom:8px"></div>
|
||||
<div id="bpValidate" style="font-size:12px;margin-bottom:8px"></div>
|
||||
<div style="display:flex;gap:8px">
|
||||
<button onclick="window._bpSave()" style="background:#6366f1;color:#fff;border:none;padding:6px 16px;border-radius:6px;cursor:pointer;font-size:12px">💾 保存</button>
|
||||
<button onclick="window._bpClear()" style="background:var(--bg3);color:var(--text);border:1px solid var(--border);padding:6px 16px;border-radius:6px;cursor:pointer;font-size:12px">🗑 清空</button>
|
||||
</div>
|
||||
</div></div>`;
|
||||
|
||||
container.innerHTML = html;
|
||||
|
||||
// 加载原子操作
|
||||
const or = await fetch('/api/matrix/atom-ops');
|
||||
const od = await or.json();
|
||||
const allOps = od.ops || [];
|
||||
window._matrixOps = {};
|
||||
allOps.forEach(o => { window._matrixOps[o.name] = o; });
|
||||
window._bpSteps = [];
|
||||
|
||||
const CAT_LABELS = { all:'📋 全部', navigation:'🏠 导航', browse:'📱 浏览', interact:'💡 交互', utility:'⏳ 工具' };
|
||||
const CATEGORIES = { douyin: ['navigation','browse','interact','utility'], xiaohongshu: ['navigation','browse','interact','utility'] };
|
||||
|
||||
// 渲染分类标签
|
||||
document.getElementById('bpOpTabs').innerHTML = Object.entries(CAT_LABELS).map(([k,v]) =>
|
||||
`<span data-ft="${k}" onclick="window._bpf('${k}')" style="cursor:pointer;padding:3px 8px;font-size:10px;border-radius:4px;${k==='all'?'background:#6366f1;color:#fff':'background:var(--bg3);color:var(--text)'}">${v}</span>`
|
||||
).join('');
|
||||
|
||||
window._bpf = filter => { window._bpFilter = filter || 'all'; renderOps(); };
|
||||
window._bpPlatChange = () => { renderOps(); };
|
||||
window._bpToggle = () => {
|
||||
const body = document.getElementById('bpListBody'); const arrow = document.getElementById('bpListArrow');
|
||||
if (!body || !arrow) return;
|
||||
body.style.display = body.style.display === 'none' ? 'block' : 'none';
|
||||
arrow.textContent = body.style.display === 'none' ? '▶ 展开' : '▼ 收起';
|
||||
};
|
||||
window._bpRemoveStep = i => { window._bpSteps.splice(i, 1); renderSteps(); };
|
||||
window._bpStepUp = i => { if (i <= 0) return; const s = window._bpSteps; [s[i-1], s[i]] = [s[i], s[i-1]]; renderSteps(); };
|
||||
window._bpStepDown = i => { const s = window._bpSteps; if (i >= s.length-1) return; [s[i], s[i+1]] = [s[i+1], s[i]]; renderSteps(); };
|
||||
window._bpClear = () => { window._bpSteps = []; document.getElementById('bpName').value = ''; window._editingBp = null; renderSteps(); };
|
||||
window._bpSave = async () => {
|
||||
const name = document.getElementById('bpName')?.value;
|
||||
if (!name) { alert('请输入名称'); return; }
|
||||
const steps = (window._bpSteps||[]).map(s => ({name: s.name}));
|
||||
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('bpDesc')?.value||'', platform: document.getElementById('bpPlatform')?.value||'douyin', steps})});
|
||||
if (!r.ok) throw new Error((await r.json()).detail);
|
||||
window._bpClear(); location.reload();
|
||||
} catch(e) { alert('❌ '+e.message); }
|
||||
};
|
||||
|
||||
// 渲染原子操作
|
||||
function canFollow(lastName, opName) {
|
||||
if (!lastName) { const op = window._matrixOps?.[opName]; return op ? op.can_be_first !== false : true; }
|
||||
const lastOp = window._matrixOps?.[lastName];
|
||||
if (!lastOp) return true;
|
||||
if (lastOp.allows && lastOp.allows[0] === '*') return true;
|
||||
return lastOp.allows ? lastOp.allows.includes(opName) : true;
|
||||
}
|
||||
function renderOps() {
|
||||
const el = document.getElementById('bpOpList'); if (!el) return;
|
||||
const ops = Object.values(window._matrixOps || {});
|
||||
const platform = document.getElementById('bpPlatform')?.value || 'douyin';
|
||||
const filter = window._bpFilter || 'all';
|
||||
const steps = window._bpSteps || [];
|
||||
const last = steps.length > 0 ? steps[steps.length-1] : null;
|
||||
const filtered = ops.filter(o => o.platform === platform || !o.platform)
|
||||
.filter(o => filter === 'all' || (o.category||o.group||'') === filter);
|
||||
el.innerHTML = filtered.map(o => {
|
||||
const ok = canFollow(last?.name, o.name);
|
||||
return `<div onclick="${ok ? 'window._bpa(\''+o.name+'\')' : ''}" style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:5px 8px;font-size:11px;${ok?'cursor:pointer':'opacity:.4;cursor:not-allowed'}">
|
||||
<strong>${o.label||o.name}</strong>${o.desc?'<div style="font-size:9px;color:var(--text2)">'+o.desc+'</div>':''}</div>`;
|
||||
}).join('');
|
||||
}
|
||||
// 加步骤
|
||||
window._bpa = name => {
|
||||
const op = window._matrixOps?.[name]; if (!op) return;
|
||||
const steps = window._bpSteps;
|
||||
if (steps.length > 0) { const lo = window._matrixOps?.[steps[steps.length-1].name]; if (lo && lo.allows && lo.allows[0] !== '*' && !lo.allows.includes(name)) { if (!confirm('⚠️ 不兼容,仍添加?')) return; } }
|
||||
else if (op.can_be_first === false) { alert('⚠️ 不能作为首步'); return; }
|
||||
steps.push({name, label: op.label||name});
|
||||
renderSteps();
|
||||
};
|
||||
function renderSteps() {
|
||||
const el = document.getElementById('bpSteps'); const ve = document.getElementById('bpValidate'); const sc = document.getElementById('bpStepCount');
|
||||
const steps = window._bpSteps || []; if (sc) sc.textContent = `${steps.length} 步`;
|
||||
if (!el) return;
|
||||
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 w = '';
|
||||
if (i>0) { const po = window._matrixOps?.[steps[i-1].name]; if (po && po.allows && po.allows[0]!=='*' && !po.allows.includes(s.name)) w = '⚠ 不兼容'; }
|
||||
if (i===0) { const op = window._matrixOps?.[s.name]; if (op && op.can_be_first === false) w = '⚠ 不能为首步'; }
|
||||
const nb = w.includes('不兼容')||w.includes('不能为首步') ? 'background:var(--red)' : 'background:#6366f1';
|
||||
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;${nb};color:#fff">${i+1}</span>
|
||||
<span style="flex:1;font-size:13px"><strong>${s.label||s.name}</strong></span>
|
||||
<span style="cursor:pointer;font-size:12px;color:var(--text2);opacity:.5" onclick="window._bpStepUp(${i})">↑</span>
|
||||
<span style="cursor:pointer;font-size:12px;color:var(--text2);opacity:.5" onclick="window._bpStepDown(${i})">↓</span>
|
||||
<span style="cursor:pointer;color:var(--red);font-size:16px;opacity:.5" onclick="window._bpRemoveStep(${i})">✕</span>
|
||||
</div></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?'✅ 合法':'❌ '+(res.errors||[]).map(e=>e.msg).join('; ')}).catch(()=>{});
|
||||
}
|
||||
}
|
||||
renderOps();
|
||||
renderSteps();
|
||||
|
||||
} catch(e) { container.innerHTML = `<div class="error">❌ ${e.message || e}</div>`; }
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* 远程控制视图 — C2 机器列表与命令下发
|
||||
*/
|
||||
export async function loadView(container) {
|
||||
container.innerHTML = '<div class="loading">⏳ 加载中...</div>';
|
||||
try {
|
||||
const uid = Date.now().toString(36);
|
||||
const res = await fetch('/api/c2/machines');
|
||||
const data = res.ok ? await res.json() : { machines: [] };
|
||||
const machines = Array.isArray(data) ? data : (data.machines || []);
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:10px">
|
||||
<h2 style="font-size:18px;margin:0">🖥️ 远程控制</h2>
|
||||
<span style="font-size:12px;color:var(--text2)">
|
||||
<strong>${machines.length}</strong> 台机器
|
||||
<span style="margin-left:8px;font-size:11px">🟢在线 <strong id="onlineCount_${uid}">0</strong></span>
|
||||
<span style="margin-left:4px;font-size:11px">🔴离线 <strong id="offlineCount_${uid}">0</strong></span>
|
||||
</span>
|
||||
</div>
|
||||
<div id="machineGrid_${uid}" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(340px,1fr));gap:10px"></div>
|
||||
</div>`;
|
||||
|
||||
const gridEl = document.getElementById('machineGrid_' + uid);
|
||||
if (!gridEl) { container.innerHTML = '<div class="error">❌ 渲染错误: 容器丢失</div>'; return; }
|
||||
|
||||
function renderMachines(list) {
|
||||
const online = list.filter(m => m.online !== false);
|
||||
const onlineEl = document.getElementById('onlineCount_' + uid);
|
||||
const offlineEl = document.getElementById('offlineCount_' + uid);
|
||||
if (onlineEl) onlineEl.textContent = online.length;
|
||||
if (offlineEl) offlineEl.textContent = list.length - online.length;
|
||||
|
||||
if (!list.length) {
|
||||
gridEl.innerHTML = '<div style="font-size:12px;color:var(--text2);padding:40px;text-align:center">暂无机器数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
gridEl.innerHTML = list.map(m => {
|
||||
const isOnline = m.online !== false;
|
||||
return `<div style="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;margin-bottom:8px">
|
||||
<div style="display:flex;align-items:center;gap:6px">
|
||||
<span style="width:8px;height:8px;border-radius:50%;background:${isOnline ? 'var(--green, #22c55e)' : 'var(--red, #ef4444)'};display:inline-block"></span>
|
||||
<span style="font-weight:600;font-size:13px">${m.hostname || m.name || m.id}</span>
|
||||
</div>
|
||||
<span style="font-size:10px;color:var(--text2)">${m.ip || m.address || ''}</span>
|
||||
</div>
|
||||
<div style="font-size:11px;color:var(--text2);margin-bottom:8px">
|
||||
${m.os ? `<span style="margin-right:8px">${m.os}</span>` : ''}
|
||||
${m.platform ? `<span>${m.platform}</span>` : ''}
|
||||
${m.version ? `<span style="margin-left:8px">v${m.version}</span>` : ''}
|
||||
</div>
|
||||
<div style="display:flex;gap:4px;flex-wrap:wrap">
|
||||
<button onclick="window._sendC2('${m.id || m.hostname || m.name}','shell','')" style="background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:3px 10px;font-size:10px;cursor:pointer">💻 Shell</button>
|
||||
<button onclick="window._sendC2('${m.id || m.hostname || m.name}','exec','')" style="background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:3px 10px;font-size:10px;cursor:pointer">⚡ 执行</button>
|
||||
<button onclick="window._sendC2('${m.id || m.hostname || m.name}','file','')" style="background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:3px 10px;font-size:10px;cursor:pointer">📁 文件</button>
|
||||
<button onclick="window._sendC2('${m.id || m.hostname || m.name}','ping','')" style="background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:3px 10px;font-size:10px;cursor:pointer">🏓 Ping</button>
|
||||
<button onclick="window._showCustomC2('${m.id || m.hostname || m.name}')" style="background:var(--primary);color:#fff;border:none;border-radius:4px;padding:3px 10px;font-size:10px;cursor:pointer">✏️ 自定义</button>
|
||||
</div>
|
||||
<div id="c2Result_${(m.id || m.hostname || m.name).replace(/[^a-zA-Z0-9]/g, '_')}" style="margin-top:6px;font-size:10px;color:var(--text2);min-height:0"></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
renderMachines(machines);
|
||||
|
||||
// 通用 C2 发送函数
|
||||
window._sendC2 = async (target, type, params) => {
|
||||
const resultId = 'c2Result_' + target.replace(/[^a-zA-Z0-9]/g, '_');
|
||||
const el = document.getElementById(resultId);
|
||||
if (el) el.innerHTML = '<span style="color:var(--amber)">⏳ 发送中...</span>';
|
||||
try {
|
||||
const r = await fetch('/api/c2/send', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ target, type, params }),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (el) {
|
||||
const output = d.output || d.result || d.message || JSON.stringify(d);
|
||||
el.innerHTML = `<span style="color:${r.ok ? 'var(--green)' : 'var(--red)'}">${r.ok ? '✅' : '❌'} ${output}</span>`;
|
||||
}
|
||||
} catch (e) {
|
||||
if (el) el.innerHTML = `<span style="color:var(--red)">❌ ${e.message}</span>`;
|
||||
}
|
||||
};
|
||||
|
||||
// 自定义命令弹窗
|
||||
window._showCustomC2 = (target) => {
|
||||
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:400px;width:90%">
|
||||
<div style="font-size:15px;font-weight:600;margin-bottom:12px">✏️ 发送命令 → ${target}</div>
|
||||
<div style="display:grid;gap:8px;font-size:12px">
|
||||
<label>类型
|
||||
<select id="customC2Type" style="width:100%;padding:4px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px">
|
||||
<option value="shell">💻 Shell</option>
|
||||
<option value="exec">⚡ 执行</option>
|
||||
<option value="file">📁 文件</option>
|
||||
<option value="ping">🏓 Ping</option>
|
||||
<option value="custom">🔧 自定义</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>参数
|
||||
<textarea id="customC2Params" rows="4" placeholder='JSON 格式或文本参数' style="width:100%;padding:4px;background:var(--bg3);border:1px solid var(--border);color:var(--text);border-radius:4px;font-size:11px;resize:vertical"></textarea>
|
||||
</label>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;justify-content:flex-end;margin-top:12px">
|
||||
<button onclick="this.closest('div[style]').parentElement.remove()" style="background:var(--bg3);border:1px solid var(--border);border-radius:6px;padding:6px 16px;font-size:12px;cursor:pointer">取消</button>
|
||||
<button onclick="window._doCustomC2('${target}')" style="background:var(--primary);color:#fff;border:none;border-radius:6px;padding:6px 16px;font-size:12px;cursor:pointer">🚀 发送</button>
|
||||
</div>
|
||||
</div>`;
|
||||
document.body.appendChild(overlay);
|
||||
};
|
||||
|
||||
window._doCustomC2 = (target) => {
|
||||
const type = document.getElementById('customC2Type')?.value || 'shell';
|
||||
const paramsRaw = document.getElementById('customC2Params')?.value || '';
|
||||
let params = paramsRaw;
|
||||
// 尝试解析 JSON
|
||||
try { params = JSON.parse(paramsRaw); } catch {}
|
||||
window._sendC2(target, type, params);
|
||||
document.querySelector('div[style*="position:fixed"]')?.remove();
|
||||
};
|
||||
|
||||
} catch (e) {
|
||||
container.innerHTML = `<div class="error">❌ ${e.message || e}</div>`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
/**
|
||||
* 信息采集视图(已废弃,由 accounts-center.js 替代)
|
||||
*
|
||||
* 功能已合并到账号中心的批量操作栏:
|
||||
* 📡 采集 → 批量选中后点「采集」按钮
|
||||
* 🔑 登录 → 批量选中后点「登录」按钮
|
||||
* 执行日志可在「执行历史」页面查看。
|
||||
*
|
||||
* @deprecated 使用 accounts-center.js 替代
|
||||
*/
|
||||
|
||||
/* 调用规范:
|
||||
* 所有操作通过 POST /api/ops/run 统一入口
|
||||
* {type:'collect', accounts:[...], params:{rounds:N}}
|
||||
* → CommandBus CMD_REGISTRY 自动按账号平台选择蓝图
|
||||
*
|
||||
* 不再使用已删除的 /api/matrix/collect-homepage 路由
|
||||
*/
|
||||
|
||||
import { apiRequest } from '../router.js';
|
||||
import { createAccountSelector } from '../components/account-selector.js';
|
||||
|
||||
let _selector = null;
|
||||
|
||||
export async function loadView(container) {
|
||||
const uid = container.id || 'collect';
|
||||
container.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="acctList_${uid}" style="margin-bottom:6px"><div class="loading">⏳ 加载账号列表...</div></div>
|
||||
<div style="display:flex;gap:6px;flex-wrap:wrap;align-items:center">
|
||||
<span id="status_${uid}" style="font-size:10px;color:var(--text2)">⏸️ 空闲</span>
|
||||
<button onclick="window._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="window._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="window._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="window._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="selCount_${uid}" style="font-size:10px;color:var(--text2)">已选 0 个</span>
|
||||
</div>
|
||||
<div id="log_${uid}" 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>`;
|
||||
|
||||
await loadAccounts(uid);
|
||||
registerGlobals(uid);
|
||||
|
||||
// 获取采集状态
|
||||
try {
|
||||
const sr = await apiRequest('/ops/status');
|
||||
if (sr.running) document.getElementById(`status_${uid}`).innerHTML = '<span style="color:var(--green)">🟢 采集中</span>';
|
||||
} catch(e) {}
|
||||
}
|
||||
|
||||
async function loadAccounts(uid) {
|
||||
const listEl = document.getElementById(`acctList_${uid}`);
|
||||
if (!listEl) return;
|
||||
try {
|
||||
const [ar, hr] = await Promise.all([
|
||||
apiRequest('/v2/accounts'),
|
||||
apiRequest('/matrix/homepage-info').catch(() => ({ results: [] })),
|
||||
]);
|
||||
const allAccts = Array.isArray(ar) ? ar.filter(a => a.enabled !== false) : (ar.accounts || []).filter(a => a.enabled !== false);
|
||||
const hpIndex = {};
|
||||
(hr.results || []).forEach(r => {
|
||||
if (r.identity_dir) hpIndex[r.identity_dir] = r;
|
||||
if (r.phone) hpIndex[r.phone] = r;
|
||||
});
|
||||
|
||||
_selector = createAccountSelector(listEl, {
|
||||
accounts: allAccts,
|
||||
hpIndex,
|
||||
checkAll: false,
|
||||
height: '300px',
|
||||
showBlueprint: false,
|
||||
});
|
||||
} catch (e) {
|
||||
listEl.innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function registerGlobals(uid) {
|
||||
if (window._collectGlobals) return;
|
||||
window._collectGlobals = true;
|
||||
|
||||
window._collectLogin = async function () {
|
||||
const selected = _selector?.getSelected() || [];
|
||||
if (!selected.length) { alert('请先选择要登录的账号'); return; }
|
||||
const logEl = document.getElementById(`log_${uid}`);
|
||||
if (logEl) logEl.textContent = '🔑 登录 ' + selected.length + ' 个账号...\n';
|
||||
|
||||
// 按机器分组,一次API调用发送同机器的所有账号
|
||||
const byMachine = {};
|
||||
selected.forEach(s => {
|
||||
const m = s.machine || 'unknown';
|
||||
if (!byMachine[m]) byMachine[m] = [];
|
||||
byMachine[m].push(s.id);
|
||||
});
|
||||
for (const [machine, ids] of Object.entries(byMachine)) {
|
||||
try {
|
||||
const r = await apiRequest('/ops/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ type: 'login', accounts: ids }),
|
||||
});
|
||||
if (logEl) logEl.textContent += `🔑 [${machine}] ${ids.join(',')}: ${r.status || 'OK'}\n`;
|
||||
} catch (e) {
|
||||
if (logEl) logEl.textContent += `❌ [${machine}] ${ids.join(',')}: ${e.message}\n`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window._collectExec = async function () {
|
||||
const selected = _selector?.getSelected() || [];
|
||||
if (!selected.length) { alert('请先选择要采集的账号'); return; }
|
||||
const logEl = document.getElementById(`log_${uid}`);
|
||||
const statusEl = document.getElementById(`status_${uid}`);
|
||||
if (logEl) logEl.textContent = '📥 采集 ' + selected.length + ' 个账号...\n';
|
||||
|
||||
// 按机器分组,一次API调用发送同机器的所有账号
|
||||
// 利用 CommandBus 注册表自动按账号平台选择采集蓝图
|
||||
const byMachine = {};
|
||||
selected.forEach(s => {
|
||||
const m = s.machine || 'unknown';
|
||||
if (!byMachine[m]) byMachine[m] = [];
|
||||
byMachine[m].push(s.id);
|
||||
});
|
||||
const totalCmds = Object.keys(byMachine).length;
|
||||
let done = 0;
|
||||
for (const [machine, ids] of Object.entries(byMachine)) {
|
||||
try {
|
||||
const d = await apiRequest('/ops/run', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
type: 'collect',
|
||||
accounts: ids,
|
||||
params: { rounds: 1 },
|
||||
}),
|
||||
});
|
||||
done++;
|
||||
if (statusEl) statusEl.innerHTML = '<span style="color:var(--green)">🟢 采集中</span>';
|
||||
if (logEl) logEl.textContent += `📦 [${machine}] ${ids.join(',')}: ${d.status || 'OK'} (${done}/${totalCmds})\n`;
|
||||
} catch (e) {
|
||||
if (logEl) logEl.textContent += `❌ [${machine}] ${ids.join(',')}: ${e.message}\n`;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
window._collectAll = async function () {
|
||||
const selected = _selector?.getSelected() || [];
|
||||
if (!selected.length) { alert('没有可采集的账号'); return; }
|
||||
if (!confirm('采集全部 ' + selected.length + ' 个账号?')) return;
|
||||
await window._collectExec();
|
||||
};
|
||||
|
||||
window._cancelCollect = async function () {
|
||||
const logEl = document.getElementById(`log_${uid}`);
|
||||
if (logEl) logEl.textContent += '⏹ 查看执行状态...\n';
|
||||
try {
|
||||
const r = await apiRequest('/ops/status');
|
||||
const active = (r.commands || []).filter(c => c.status === 'running' || c.status === 'dispatching');
|
||||
if (logEl) logEl.textContent += active.length ? `⏳ 有 ${active.length} 个任务运行中,可在执行历史页取消\n` : '⏸️ 当前无活跃采集任务\n';
|
||||
} catch (e) { if (logEl) logEl.textContent += '❌ ' + e.message + '\n'; }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
/**
|
||||
* 命令与任务视图(v2 — 按机器分Tab,含取消/清理/查看日志)
|
||||
*
|
||||
* 调用规范:
|
||||
* 所有操作通过 POST /api/ops/run 统一入口
|
||||
* 状态通过 GET /api/ops/status 轮询
|
||||
* 取消通过 POST /api/ops/cancel/{run_id}
|
||||
* 日志通过 GET /api/ops/log/{run_id}
|
||||
* 僵尸清理通过 POST /api/ops/cleanup-stale
|
||||
*/
|
||||
|
||||
import { apiRequest } from '../router.js';
|
||||
|
||||
let _pollTimer = null;
|
||||
const STATUS_ICONS = {
|
||||
'completed': '✅', 'failed': '❌', 'crashed': '💥', 'cancelled': '⏸️',
|
||||
'timed_out': '⏰', 'running': '🟢', 'dispatching': '📡', 'queued': '⏳',
|
||||
'preflighting': '🔍', 'preflight_failed': '🔍❌',
|
||||
};
|
||||
|
||||
export async function loadView(container) {
|
||||
const uid = 'cmd_' + Math.random().toString(36).slice(2, 6);
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
||||
<h2 style="font-size:18px;margin:0">🎯 命令与任务</h2>
|
||||
<div style="display:flex;gap:6px">
|
||||
<span id="statusSummary_${uid}" style="font-size:11px;color:var(--text2);display:flex;align-items:center;gap:6px"></span>
|
||||
<button onclick="window._cleanupStale_${uid}()" 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>
|
||||
<button onclick="window._refreshCmds_${uid}()" style="background:var(--bg3);border:1px solid var(--border);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:11px">⟳ 刷新</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 机器 Tab -->
|
||||
<div id="machineTabs_${uid}" style="display:flex;gap:4px;margin-bottom:8px;flex-wrap:wrap;border-bottom:1px solid var(--border);padding-bottom:6px"></div>
|
||||
|
||||
<!-- 命令列表 -->
|
||||
<div id="cmdList_${uid}" style="font-size:11px"></div>
|
||||
</div>`;
|
||||
|
||||
await renderAll(uid);
|
||||
startPoll(uid);
|
||||
}
|
||||
|
||||
async function renderAll(uid) {
|
||||
try {
|
||||
const data = await apiRequest('/ops/status');
|
||||
const allCmds = data.commands || (Array.isArray(data) ? data : []);
|
||||
|
||||
// 按机器分组
|
||||
const byMachine = { '_all': allCmds };
|
||||
allCmds.forEach(c => {
|
||||
const m = c.machine || 'unknown';
|
||||
if (!byMachine[m]) byMachine[m] = [];
|
||||
byMachine[m].push(c);
|
||||
});
|
||||
|
||||
const machineOrder = ['chengzigedeAir', '5kechengdeAir', '7kecheng', 'unknown'];
|
||||
const machines = machineOrder.filter(m => byMachine[m]);
|
||||
|
||||
// 渲染 Tab
|
||||
renderTabs(uid, machines, byMachine);
|
||||
|
||||
// 渲染汇总
|
||||
renderSummary(uid, allCmds);
|
||||
|
||||
// 默认选中第一个 Tab
|
||||
const activeTab = document.querySelector(`[data-cmdtab="_all_${uid}"]`) || document.querySelector(`[data-cmdtab^="_"][data-cmdtab$="_${uid}"]`);
|
||||
if (activeTab) activeTab.click();
|
||||
} catch (e) {
|
||||
document.getElementById(`cmdList_${uid}`).innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderTabs(uid, machines, byMachine) {
|
||||
const tabContainer = document.getElementById(`machineTabs_${uid}`);
|
||||
const allCount = byMachine['_all']?.length || 0;
|
||||
let html = `<button data-cmdtab="_all_${uid}" onclick="window._switchCmdTab_${uid}('_all')" style="background:var(--primary);color:#fff;border:none;padding:4px 12px;border-radius:4px;cursor:pointer;font-size:11px;font-weight:600">📋 全部 (${allCount})</button>`;
|
||||
|
||||
machines.filter(m => m !== '_all').forEach(m => {
|
||||
const cmds = byMachine[m] || [];
|
||||
const active = cmds.filter(c => c.status === 'running' || c.status === 'dispatching' || c.status === 'queued').length;
|
||||
const icon = m === 'chengzigedeAir' ? '🖥️' : '☁️';
|
||||
const badge = active > 0 ? ` <span style="color:var(--green)">●${active}</span>` : '';
|
||||
html += `<button data-cmdtab="${m}_${uid}" onclick="window._switchCmdTab_${uid}('${m}')" style="background:var(--bg3);border:1px solid var(--border);padding:4px 12px;border-radius:4px;cursor:pointer;font-size:11px">${icon} ${m}${badge}</button>`;
|
||||
});
|
||||
tabContainer.innerHTML = html;
|
||||
|
||||
// 注册 Tab 切换函数
|
||||
window[`_switchCmdTab_${uid}`] = function(machine) {
|
||||
// 高亮 Tab
|
||||
tabContainer.querySelectorAll('button').forEach(b => {
|
||||
if (b.dataset.cmdtab === `${machine}_${uid}` || (machine === '_all' && b.dataset.cmdtab === `_all_${uid}`)) {
|
||||
b.style.background = 'var(--primary)';
|
||||
b.style.color = '#fff';
|
||||
b.style.fontWeight = '600';
|
||||
} else {
|
||||
b.style.background = 'var(--bg3)';
|
||||
b.style.color = 'var(--text)';
|
||||
b.style.fontWeight = '400';
|
||||
}
|
||||
});
|
||||
renderCmdList(uid, byMachine[machine] || byMachine['_all'] || []);
|
||||
};
|
||||
}
|
||||
|
||||
function renderCmdList(uid, cmds) {
|
||||
const listEl = document.getElementById(`cmdList_${uid}`);
|
||||
if (!cmds.length) {
|
||||
listEl.innerHTML = '<div style="color:var(--text2);padding:12px;font-size:12px">暂无命令记录</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
let html = `<table style="width:100%;border-collapse:collapse;font-size:10px">
|
||||
<tr style="border-bottom:1px solid var(--border);color:var(--text2);font-size:9px">
|
||||
<th style="padding:3px 4px;font-weight:400;text-align:left;width:28px">状态</th>
|
||||
<th style="padding:3px 4px;font-weight:400;text-align:left;width:40px">类型</th>
|
||||
<th style="padding:3px 4px;font-weight:400;text-align:left;width:50px">机器</th>
|
||||
<th style="padding:3px 4px;font-weight:400;text-align:left;width:80px">账号</th>
|
||||
<th style="padding:3px 4px;font-weight:400;text-align:left">消息</th>
|
||||
<th style="padding:3px 4px;font-weight:400;text-align:right;width:40px">耗时</th>
|
||||
<th style="padding:3px 4px;font-weight:400;text-align:center;width:110px">操作</th>
|
||||
</tr>`;
|
||||
|
||||
cmds.slice(0, 100).forEach(c => {
|
||||
const icon = STATUS_ICONS[c.status] || '❓';
|
||||
const accts = (c.accounts || [c.account || '']).filter(Boolean).join(',');
|
||||
const isActive = c.status === 'running' || c.status === 'dispatching';
|
||||
const isQueued = c.status === 'queued';
|
||||
const canCancel = isActive || isQueued;
|
||||
const bg = isActive ? 'background:rgba(34,197,94,.05)' : isQueued ? 'background:rgba(250,204,21,.05)' : '';
|
||||
|
||||
html += `<tr style="border-bottom:1px solid var(--border);${bg}">
|
||||
<td style="padding:3px 4px;font-size:11px" title="${c.status}">${icon}</td>
|
||||
<td style="padding:3px 4px">${c.type || c.cmd_type || '?'}</td>
|
||||
<td style="padding:3px 4px;font-size:9px;color:var(--text2)">${c.machine || '?'}</td>
|
||||
<td style="padding:3px 4px;max-width:80px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${accts}">${accts.slice(0,20)}</td>
|
||||
<td style="padding:3px 4px;max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;color:var(--text2)" title="${c.message || ''}">${c.message || c.status || ''}</td>
|
||||
<td style="padding:3px 4px;text-align:right;font-size:9px;color:var(--text2)">${fmtTime(c.elapsed_sec)}</td>
|
||||
<td style="padding:3px 4px;text-align:center;white-space:nowrap">
|
||||
${canCancel ? `<button onclick="window._cancelCmd('${c.run_id}')" style="background:rgba(220,38,38,.1);border:1px solid rgba(220,38,38,.3);padding:1px 6px;border-radius:3px;cursor:pointer;font-size:9px;color:var(--red)">⏹取消</button>` : ''}
|
||||
<button onclick="window._viewLog('${c.run_id}')" style="background:var(--bg3);border:1px solid var(--border);padding:1px 6px;border-radius:3px;cursor:pointer;font-size:9px;color:var(--text)">📋日志</button>
|
||||
</td>
|
||||
</tr>`;
|
||||
});
|
||||
html += '</table>';
|
||||
listEl.innerHTML = html;
|
||||
}
|
||||
|
||||
function renderSummary(uid, cmds) {
|
||||
const el = document.getElementById(`statusSummary_${uid}`);
|
||||
const running = cmds.filter(c => c.status === 'running' || c.status === 'dispatching').length;
|
||||
const queued = cmds.filter(c => c.status === 'queued').length;
|
||||
const done = cmds.filter(c => c.status === 'completed').length;
|
||||
const failed = cmds.filter(c => ['failed','crashed','timed_out'].includes(c.status)).length;
|
||||
let html = '';
|
||||
if (running) html += `<span style="color:var(--green)">🟢 ${running}运行</span>`;
|
||||
if (queued) html += `<span style="color:#f59e0b">⏳ ${queued}排队</span>`;
|
||||
html += `<span style="color:var(--text2)">✅ ${done}完成</span>`;
|
||||
if (failed) html += `<span style="color:var(--red)">❌ ${failed}失败</span>`;
|
||||
el.innerHTML = html || '<span style="color:var(--text2)">⏸️ 空闲</span>';
|
||||
}
|
||||
|
||||
function fmtTime(sec) {
|
||||
if (sec == null || sec === 0) return '-';
|
||||
if (sec < 60) return Math.round(sec) + 's';
|
||||
const m = Math.floor(sec / 60);
|
||||
const s = Math.round(sec % 60);
|
||||
return m + 'm' + (s > 0 ? s + 's' : '');
|
||||
}
|
||||
|
||||
function startPoll(uid) {
|
||||
if (_pollTimer) clearInterval(_pollTimer);
|
||||
_pollTimer = setInterval(() => {
|
||||
renderAll(uid).catch(() => {});
|
||||
}, 5000);
|
||||
|
||||
// 注册全局 cancel/log 函数
|
||||
window._cancelCmd = async function(runId) {
|
||||
if (!confirm(`取消命令 ${runId.slice(0,20)}...?`)) return;
|
||||
try {
|
||||
const r = await apiRequest(`/ops/cancel/${runId}`, { method: 'POST' });
|
||||
if (r.ok || r.status === 'ok') {
|
||||
renderAll(uid);
|
||||
}
|
||||
} catch(e) {
|
||||
alert('❌ ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window._viewLog = async function(runId) {
|
||||
try {
|
||||
const r = await apiRequest(`/ops/log/${runId}`);
|
||||
const log = r.log || '(日志为空)';
|
||||
const logWin = window.open('', '_blank', 'width=800,height=600');
|
||||
logWin.document.write(`<pre style="font-size:11px;padding:12px;background:#1a1a2e;color:#e0e0e0;white-space:pre-wrap;word-break:break-all">${escapeHtml(log)}</pre>`);
|
||||
logWin.document.close();
|
||||
} catch(e) {
|
||||
alert('❌ ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window[`_cleanupStale_${uid}`] = async function() {
|
||||
if (!confirm('清理所有僵尸命令吗?(进程已死但状态为 running 的命令)')) return;
|
||||
try {
|
||||
const r = await apiRequest('/ops/cleanup-stale', { method: 'POST' });
|
||||
alert(`🧹 已清理 ${r.cleaned} 个僵尸命令`);
|
||||
renderAll(uid);
|
||||
} catch(e) {
|
||||
alert('❌ ' + e.message);
|
||||
}
|
||||
};
|
||||
|
||||
window[`_refreshCmds_${uid}`] = function() {
|
||||
renderAll(uid);
|
||||
};
|
||||
}
|
||||
|
||||
function escapeHtml(str) {
|
||||
return String(str).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
/**
|
||||
* 智能评论视图(v3)— 导入热帖 + 跳过分析 + 执行记录
|
||||
*
|
||||
* 流程:
|
||||
* 导入模式: 点"导入热帖" → 选择视频 → 导入 → 一键执行(跳过分析)
|
||||
* 手动模式: 粘贴URL → 分析预览 → 确认分发
|
||||
* 后端: POST /api/ops/run {type:"smart_comment", ...}
|
||||
* POST /api/ops/import-topics
|
||||
* POST/GET /api/ops/batch-log
|
||||
*/
|
||||
|
||||
import { apiRequest, confirmExecute } from '../router.js';
|
||||
import { createAccountSelector } from '../components/account-selector.js';
|
||||
|
||||
let _selector = null;
|
||||
let _previewData = {};
|
||||
let _uid = '';
|
||||
let _allCorpusCategories = [];
|
||||
let _importedTitles = {}; // {url: "标题文本"} — 导入热帖时存
|
||||
let _apiUrl = localStorage.getItem('_sc_api_url') || 'https://wx.tyhtak.com/api/biz/activity/api/v1/activity/recordswx1';
|
||||
|
||||
/* ─── 加载视图 ─────────────────────────────────── */
|
||||
export async function loadView(container, params) {
|
||||
_uid = container.id || 'sc';
|
||||
_previewData = {};
|
||||
_importedTitles = {};
|
||||
|
||||
container.innerHTML = `
|
||||
<div style="padding:16px">
|
||||
<!-- 主面板 -->
|
||||
<div style="background:var(--bg2);border-radius:10px;padding:14px;border:1px solid var(--border)">
|
||||
<div style="font-weight:600;font-size:14px;margin-bottom:10px">
|
||||
💬 定向评论
|
||||
<span style="font-size:10px;color:var(--text2);font-weight:400">导入热帖一键分发,或手动粘贴分析后分发</span>
|
||||
</div>
|
||||
|
||||
<!-- ⚙ 数据源设置(可折叠) -->
|
||||
<details style="margin-bottom:8px;font-size:11px">
|
||||
<summary style="cursor:pointer;color:var(--text2)">⚙ 数据源设置</summary>
|
||||
<div style="margin-top:6px;display:flex;gap:6px;align-items:center">
|
||||
<input id="apiUrl_${_uid}" type="text" value="${escapeHtml(_apiUrl)}"
|
||||
style="flex:1;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 8px;border-radius:4px;font-size:11px">
|
||||
<button id="btn_testApi_${_uid}" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 10px;border-radius:4px;cursor:pointer;font-size:10px">测试连接</button>
|
||||
<span id="apiTestResult_${_uid}" style="font-size:10px;color:var(--text2)"></span>
|
||||
</div>
|
||||
</details>
|
||||
|
||||
<!-- 账号选择 -->
|
||||
<div id="acctList_${_uid}" style="margin-bottom:8px"><div class="loading">⏳ 加载账号列表...</div></div>
|
||||
|
||||
<!-- 参数输入 -->
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:flex-start">
|
||||
<textarea id="urls_${_uid}" 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:160px">
|
||||
<select id="dir_${_uid}" style="background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:5px 8px;border-radius:4px;font-size:11px">
|
||||
<option value="praise">👍 称赞</option>
|
||||
<option value="question">🤔 提问</option>
|
||||
<option value="empathy">💗 共鸣</option>
|
||||
<option value="agree">✅ 认同</option>
|
||||
</select>
|
||||
<details style="font-size:10px;margin:2px 0">
|
||||
<summary style="cursor:pointer;color:var(--text2)">📂 语料分类(可多选)</summary>
|
||||
<div id="corpusList_${_uid}" style="margin-top:4px;max-height:120px;overflow-y:auto;background:var(--bg3);border-radius:4px;padding:4px">
|
||||
<label style="display:flex;align-items:center;gap:4px;padding:2px 4px;cursor:pointer;font-size:10px"><input type="checkbox" class="corpus-cb-${_uid}" value="" checked onchange="window._sc_updateCorpus('${_uid}')"> 🌐 自动匹配</label>
|
||||
</div>
|
||||
</details>
|
||||
<input id="corpusInput_${_uid}" type="hidden" value="">
|
||||
|
||||
<label style="display:flex;align-items:center;gap:4px;font-size:10px;color:var(--text2);cursor:pointer">
|
||||
<input id="skipAnalysis_${_uid}" type="checkbox" checked>
|
||||
⏭ 跳过分析(导入热帖时默认开启)
|
||||
</label>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button id="btn_import_${_uid}" onclick="window._sc_import('${_uid}')"
|
||||
style="flex:1;background:var(--primary);color:#fff;border:none;padding:5px 8px;border-radius:4px;cursor:pointer;font-size:11px">📥 导入热帖</button>
|
||||
<button id="btn_go_${_uid}" onclick="window._sc_go('${_uid}')"
|
||||
style="flex:1;background:#16a34a;color:#fff;border:none;padding:5px 8px;border-radius:4px;cursor:pointer;font-size:11px">🚀 一键执行</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态/结果 -->
|
||||
<div id="result_${_uid}" style="font-size:10px;color:var(--text2);margin-top:4px;font-family:monospace;white-space:pre-wrap"></div>
|
||||
</div>
|
||||
|
||||
<!-- 导入面板(折叠) -->
|
||||
<div id="importPanel_${_uid}" style="display:none;margin-top:10px"></div>
|
||||
|
||||
<!-- 分析预览区 -->
|
||||
<div id="preview_${_uid}" style="display:none;margin-top:10px"></div>
|
||||
|
||||
<!-- 📋 执行记录 -->
|
||||
<div id="batchLogs_${_uid}" style="margin-top:16px"></div>
|
||||
</div>`;
|
||||
|
||||
// 加载账号 + 语料分类
|
||||
try {
|
||||
const [acctRes, corpRes] = await Promise.all([
|
||||
apiRequest('/v2/accounts'),
|
||||
apiRequest('/matrix/corpus').catch(() => ({ categories: [] })),
|
||||
]);
|
||||
const accts = Array.isArray(acctRes) ? acctRes : (acctRes.accounts || []);
|
||||
_allCorpusCategories = corpRes.categories || [];
|
||||
|
||||
const listEl = document.getElementById(`acctList_${_uid}`);
|
||||
if (listEl) {
|
||||
_selector = createAccountSelector(listEl, {
|
||||
accounts: accts, checkAll: false, height: '160px',
|
||||
showBlueprint: false, showCollectTime: false,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
const el = document.getElementById(`acctList_${_uid}`);
|
||||
if (el) el.innerHTML = `<div class="error">❌ ${e.message}</div>`;
|
||||
}
|
||||
|
||||
// 填充语料分类(多选 checkboxes)
|
||||
const corpusList = document.getElementById(`corpusList_${_uid}`);
|
||||
if (corpusList) {
|
||||
_allCorpusCategories.forEach(c => {
|
||||
const label = document.createElement('label');
|
||||
label.style.cssText = 'display:flex;align-items:center;gap:4px;padding:2px 4px;cursor:pointer;font-size:10px';
|
||||
label.innerHTML = `<input type="checkbox" class="corpus-cb-${_uid}" value="${c.name}" onchange="window._sc_updateCorpus('${_uid}')"> ${c.name} (${(c.accessible||[]).join(',')||'通用'})`;
|
||||
corpusList.appendChild(label);
|
||||
});
|
||||
}
|
||||
// 初始化 corpusInput
|
||||
window._sc_updateCorpus = (u) => {
|
||||
const checked = document.querySelectorAll(`.corpus-cb-${u}:checked`);
|
||||
const vals = Array.from(checked).map(cb => cb.value).filter(v => v);
|
||||
document.getElementById(`corpusInput_${u}`).value = vals.join(',');
|
||||
};
|
||||
|
||||
// 加载执行记录
|
||||
_loadBatchLogs(_uid);
|
||||
|
||||
registerGlobals(_uid);
|
||||
}
|
||||
|
||||
/* ─── 注册全局函数 ──────────────────────────────────── */
|
||||
function registerGlobals(uid) {
|
||||
if (window[`_sc_ready_${uid}`]) return;
|
||||
window[`_sc_ready_${uid}`] = true;
|
||||
|
||||
// ⚙ 测试 API 连接
|
||||
window[`_sc_testApi`] = async (u) => {
|
||||
const url = document.getElementById(`apiUrl_${u}`)?.value;
|
||||
const resultEl = document.getElementById(`apiTestResult_${u}`);
|
||||
if (!url) { resultEl.textContent = '❌ 请输入 API 地址'; return; }
|
||||
resultEl.textContent = '⏳';
|
||||
try {
|
||||
const resp = await apiRequest('/ops/import-topics', {
|
||||
method: 'POST', body: JSON.stringify({ api_url: url, page: 1, page_size: 1 })
|
||||
});
|
||||
if (resp.status === 'ok') {
|
||||
resultEl.textContent = `✅ ${resp.total} 条`;
|
||||
localStorage.setItem('_sc_api_url', url);
|
||||
_apiUrl = url;
|
||||
} else {
|
||||
resultEl.textContent = `❌ ${resp.message}`;
|
||||
}
|
||||
} catch (e) {
|
||||
resultEl.textContent = `❌ ${e.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 📥 导入热帖
|
||||
window[`_sc_import`] = async (u) => {
|
||||
const apiUrl = document.getElementById(`apiUrl_${u}`)?.value || _apiUrl;
|
||||
if (!apiUrl) { alert('请先在⚙ 数据源设置中配置 API 地址'); return; }
|
||||
localStorage.setItem('_sc_api_url', apiUrl);
|
||||
_apiUrl = apiUrl;
|
||||
|
||||
const panel = document.getElementById(`importPanel_${u}`);
|
||||
panel.style.display = 'block';
|
||||
panel.innerHTML = '<div class="loading" style="padding:10px">⏳ 加载热帖列表...</div>';
|
||||
|
||||
try {
|
||||
const resp = await apiRequest('/ops/import-topics', {
|
||||
method: 'POST', body: JSON.stringify({ api_url: apiUrl, page: 1, page_size: 100 })
|
||||
});
|
||||
if (resp.status !== 'ok') {
|
||||
panel.innerHTML = `<div class="error">❌ ${resp.message || '加载失败'}</div>`;
|
||||
return;
|
||||
}
|
||||
const items = resp.items || [];
|
||||
const total = resp.total || 0;
|
||||
const unfinished = items.filter(i => !i.completed && !i.already_commented);
|
||||
|
||||
panel.innerHTML = `
|
||||
<div style="background:var(--bg2);border-radius:8px;border:1px solid var(--border);overflow:hidden">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center;padding:8px 10px;background:var(--bg3);font-size:11px;font-weight:600">
|
||||
<span>📋 今日热帖(共 ${total} 条,未完成 ${unfinished.length} 条)</span>
|
||||
<div style="display:flex;gap:4px">
|
||||
<button onclick="window._sc_toggleAll('${u}', true)" style="background:none;border:1px solid var(--border);color:var(--text);padding:2px 8px;border-radius:3px;cursor:pointer;font-size:10px">☑ 全选未完成</button>
|
||||
<button onclick="window._sc_toggleAll('${u}', false)" style="background:none;border:1px solid var(--border);color:var(--text);padding:2px 8px;border-radius:3px;cursor:pointer;font-size:10px">☐ 取消全选</button>
|
||||
<button onclick="window._sc_importSelected('${u}')" style="background:#16a34a;color:#fff;border:none;padding:2px 10px;border-radius:3px;cursor:pointer;font-size:10px">导入选中</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="importList_${u}" style="max-height:320px;overflow-y:auto;font-size:10px">
|
||||
${items.map(item => {
|
||||
const disabled = item.completed || item.already_commented;
|
||||
const tagStr = (item.tags||[]).length ? '#' + (item.tags||[]).slice(0,3).join(' #') : '';
|
||||
const timeStr = item.created_at ? item.created_at.slice(5,16) : '';
|
||||
return `<label style="display:flex;align-items:center;gap:4px;padding:4px 10px;border-bottom:1px solid var(--border);cursor:${disabled?'not-allowed':'pointer'};opacity:${disabled?0.5:1}">
|
||||
<input type="checkbox" class="import-cb" data-url="${escapeHtml(item.url)}" data-title="${escapeHtml(item.title || '')}" data-id="${item.id}" ${disabled?'disabled':''} ${!disabled?'checked':''}>
|
||||
<div style="flex:1;overflow:hidden;min-width:0">
|
||||
<div style="font-size:10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml((item.title||'').slice(0,55))}</div>
|
||||
<div style="font-size:8px;color:var(--text2);margin-top:1px">
|
||||
${escapeHtml(item.author||'')}
|
||||
${tagStr ? `<span style="margin-left:4px">${escapeHtml(tagStr)}</span>` : ''}
|
||||
${timeStr ? `<span style="margin-left:4px">${escapeHtml(timeStr)}</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<span style="font-size:9px;color:${item.already_commented?'#16a34a':'var(--text2)'};white-space:nowrap">${item.already_commented?'✅已评':item.completed?'☑已完成':''}</span>
|
||||
</label>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</div>`;
|
||||
} catch (e) {
|
||||
panel.innerHTML = `<div class="error">❌ 加载失败: ${e.message}</div>`;
|
||||
}
|
||||
};
|
||||
|
||||
// ☑ 全选/取消
|
||||
window[`_sc_toggleAll`] = (u, checked) => {
|
||||
document.querySelectorAll(`#importList_${u} .import-cb:not(:disabled)`).forEach(cb => cb.checked = checked);
|
||||
};
|
||||
|
||||
// 导入选中
|
||||
window[`_sc_importSelected`] = (u) => {
|
||||
const checked = document.querySelectorAll(`#importList_${u} .import-cb:checked`);
|
||||
if (!checked.length) { alert('请先勾选要导入的视频'); return; }
|
||||
|
||||
const urls = [];
|
||||
_importedTitles = {};
|
||||
checked.forEach(cb => {
|
||||
urls.push(cb.dataset.url);
|
||||
_importedTitles[cb.dataset.url] = cb.dataset.title;
|
||||
});
|
||||
document.getElementById(`urls_${u}`).value = urls.join('\n');
|
||||
document.getElementById(`skipAnalysis_${u}`).checked = true;
|
||||
document.getElementById(`importPanel_${u}`).style.display = 'none';
|
||||
document.getElementById(`result_${u}`).textContent = `✅ 已导入 ${urls.length} 个视频(跳过分析模式)`;
|
||||
};
|
||||
|
||||
// 🔍 分析预览
|
||||
window[`_sc_analyze`] = async (u) => { /* unchanged from v2 - omitted for brevity */
|
||||
const selected = _selector?.getSelected() || [];
|
||||
const urlsText = document.getElementById(`urls_${u}`)?.value;
|
||||
const direction = document.getElementById(`dir_${u}`)?.value || 'praise';
|
||||
const corpusCategory = document.getElementById(`corpusInput_${u}`)?.value || '';
|
||||
const resultEl = document.getElementById(`result_${u}`);
|
||||
const previewEl = document.getElementById(`preview_${u}`);
|
||||
|
||||
if (!urlsText || !urlsText.trim()) { resultEl.textContent = '请填写至少一个视频链接'; return; }
|
||||
const urls = urlsText.split('\n').map(x => x.trim()).filter(x => x);
|
||||
if (!urls.length) { resultEl.textContent = '请填写有效的视频链接'; return; }
|
||||
if (!selected.length) { resultEl.textContent = '请先选择要执行的账号'; return; }
|
||||
|
||||
resultEl.textContent = `⏳ 正在分析 ${urls.length} 个视频...`;
|
||||
previewEl.style.display = 'none';
|
||||
const firstAcctId = selected[0].id;
|
||||
|
||||
try {
|
||||
const resp = await apiRequest('/ops/run', {
|
||||
method: 'POST', body: JSON.stringify({
|
||||
type: 'smart_comment', accounts: [firstAcctId],
|
||||
params: { urls, direction, corpus_category: corpusCategory || undefined, preview: true }
|
||||
})
|
||||
});
|
||||
const preview = resp.preview || {};
|
||||
const items = Object.entries(preview);
|
||||
if (!items.length) { resultEl.textContent = '⚠️ 分析完成但没有返回结果'; return; }
|
||||
_previewData = preview;
|
||||
previewEl.innerHTML = `
|
||||
<div style="font-weight:600;font-size:12px;margin-bottom:6px">📋 分析结果(共 ${items.length} 个视频)<span style="font-size:10px;color:var(--text2);font-weight:400"> — 可修改后确认分发</span></div>
|
||||
${items.map(([url, data]) => `
|
||||
<div style="background:var(--bg2);border-radius:6px;padding:8px 10px;border:1px solid var(--border);margin-bottom:4px">
|
||||
<div style="display:flex;align-items:center;gap:6px;margin-bottom:4px">
|
||||
<span style="font-size:11px;color:var(--text2);max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${escapeHtml(url.slice(0,60))}</span>
|
||||
<span style="font-size:9px;padding:1px 6px;border-radius:3px;background:${data.industry==='health'?'rgba(34,197,94,.15)':'var(--bg3)'};color:${data.industry==='health'?'#16a34a':'var(--text2)'}">${data.industry||'general'}</span>
|
||||
<span style="font-size:10px;color:var(--text2)">${escapeHtml((data.title||'').slice(0,30))}</span>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;align-items:center">
|
||||
<textarea class="sc-preview-input" data-url="${escapeHtml(url)}" rows="2"
|
||||
style="flex:1;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:3px 8px;border-radius:4px;font-size:11px;resize:vertical;font-family:inherit">${escapeHtml(data.comment||'')}</textarea>
|
||||
<span style="font-size:9px;color:var(--text2);cursor:pointer" onclick="navigator.clipboard.writeText(this.parentElement.querySelector('.sc-preview-input').value)">📋</span>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
<div style="font-size:10px;color:var(--text2);margin-top:6px;text-align:center">当前选中 ${selected.length} 个账号</div>
|
||||
<button onclick="window[\`_sc_confirmDistribute\`]('${u}')" style="background:#16a34a;color:#fff;border:none;padding:5px 20px;border-radius:4px;cursor:pointer;font-size:12px;margin-top:6px;float:right">✅ 确认并分发</button>
|
||||
<div style="clear:both"></div>`;
|
||||
previewEl.style.display = 'block';
|
||||
resultEl.textContent = `✅ 分析完成,${items.length} 个视频`;
|
||||
} catch (e) {
|
||||
resultEl.textContent = `❌ 分析失败: ${e.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
// ✅ 确认分发(预览→手动编辑后分发)
|
||||
window[`_sc_confirmDistribute`] = async (u) => {
|
||||
const selected = _selector?.getSelected() || [];
|
||||
if (!selected.length) { alert('请选择账号'); return; }
|
||||
const inputs = document.querySelectorAll('.sc-preview-input');
|
||||
const comments = {};
|
||||
inputs.forEach(inp => { comments[inp.getAttribute('data-url')] = inp.value; });
|
||||
if (!Object.keys(comments).length) { alert('没有可分发的评论'); return; }
|
||||
|
||||
const count = selected.length * Object.keys(comments).length;
|
||||
const ok = await confirmExecute(`确认分发到 ${selected.length} 个账号`, `共 ${count} 条评论任务(P0 优先)`);
|
||||
if (!ok) return;
|
||||
|
||||
const resultEl = document.getElementById(`result_${u}`);
|
||||
resultEl.textContent = `⏳ 正在分发 ${count} 条任务...`;
|
||||
try {
|
||||
const resp = await apiRequest('/ops/run', {
|
||||
method: 'POST', body: JSON.stringify({
|
||||
type: 'smart_comment', accounts: selected.map(s => s.id),
|
||||
params: { urls: Object.keys(comments), comments, direction: document.getElementById(`dir_${u}`)?.value || 'praise' }
|
||||
})
|
||||
});
|
||||
resultEl.textContent = `✅ 已分发 ${count} 条评论任务 (P0)`;
|
||||
setTimeout(() => _loadBatchLogs(u), 2000);
|
||||
} catch (e) {
|
||||
resultEl.textContent = `❌ 分发失败: ${e.message}`;
|
||||
}
|
||||
};
|
||||
|
||||
// 🚀 一键执行
|
||||
window[`_sc_go`] = async (u) => {
|
||||
const selected = _selector?.getSelected() || [];
|
||||
const urlsText = document.getElementById(`urls_${u}`)?.value;
|
||||
const dir = document.getElementById(`dir_${u}`)?.value;
|
||||
const corpus = document.getElementById(`corpus_${u}`)?.value || '';
|
||||
const skipAnalysis = document.getElementById(`skipAnalysis_${u}`)?.checked;
|
||||
const resultEl = document.getElementById(`result_${u}`);
|
||||
|
||||
if (!urlsText || !urlsText.trim()) { resultEl.textContent = '请填写至少一个视频链接'; return; }
|
||||
const urls = urlsText.split('\n').map(x => x.trim()).filter(x => x);
|
||||
if (!selected.length) { resultEl.textContent = '请先选择账号'; return; }
|
||||
|
||||
const count = selected.length * urls.length;
|
||||
const details = `账号 ${selected.length} 个 · 视频 ${urls.length} 个\n方向: ${dir}${skipAnalysis ? ' · ⏭ 跳过分析' : ''}`;
|
||||
const ok = await confirmExecute(`即将${skipAnalysis?'':'分析并'}分发 ${count} 条评论`, details);
|
||||
if (!ok) { resultEl.textContent = '已取消'; return; }
|
||||
|
||||
resultEl.textContent = `⏳ 分发 ${count} 条任务...`;
|
||||
|
||||
try {
|
||||
const resp = await apiRequest('/ops/run', {
|
||||
method: 'POST', body: JSON.stringify({
|
||||
type: 'smart_comment', accounts: selected.map(s => s.id),
|
||||
params: {
|
||||
urls, direction: dir, corpus_category: corpus || undefined,
|
||||
preview: false,
|
||||
skip_analysis: skipAnalysis && Object.keys(_importedTitles).length > 0,
|
||||
titles: skipAnalysis ? _importedTitles : {},
|
||||
video_ids: urls.map(u => ''), // 前端没有 video_id,传空数组
|
||||
}
|
||||
})
|
||||
});
|
||||
const machines = resp?.per_machine || {};
|
||||
let log = '';
|
||||
const hasMachines = Object.keys(machines).length > 0;
|
||||
for (const [m, data] of Object.entries(machines)) {
|
||||
log += `🖥 ${m}: ✅${data?.success||0} ❌${data?.failed||0}\n`;
|
||||
}
|
||||
const cmdCount = resp?.commands?.length || 0;
|
||||
resultEl.textContent = hasMachines ? log : (cmdCount > 0 ? `✅ 已提交 ${cmdCount} 条任务,正在排队执行` : `✅ 已分发 ${count} 条评论任务`);
|
||||
setTimeout(() => _loadBatchLogs(u), 2000);
|
||||
} catch (e) {
|
||||
resultEl.textContent = `❌ 执行失败: ${e.message}`;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/* ─── 执行记录 ────────────────────────────────────── */
|
||||
async function _loadBatchLogs(uid) {
|
||||
const el = document.getElementById(`batchLogs_${uid}`);
|
||||
if (!el) return;
|
||||
try {
|
||||
const resp = await apiRequest('/ops/batch-logs?limit=30');
|
||||
const logs = resp.logs || [];
|
||||
if (!logs.length) { el.innerHTML = ''; return; }
|
||||
|
||||
el.innerHTML = `
|
||||
<details open>
|
||||
<summary style="cursor:pointer;font-weight:600;font-size:12px;color:var(--text);margin-bottom:4px">
|
||||
📋 执行记录(${logs.length} 条)
|
||||
</summary>
|
||||
<div style="font-size:10px">
|
||||
${logs.map((log, idx) => {
|
||||
const mc = log.machine_counts || {};
|
||||
const machineStr = Object.entries(mc).map(([m, d]) => `${m}:✅${d.success||0}❌${d.failed||0}`).join(' ');
|
||||
return `<div style="background:var(--bg2);border:1px solid var(--border);border-radius:4px;padding:6px 8px;margin-bottom:3px;cursor:pointer" onclick="this.nextElementSibling.style.display=this.nextElementSibling.style.display==='none'?'block':'none'">
|
||||
<div style="display:flex;justify-content:space-between;align-items:center">
|
||||
<span>${log.time || ''}</span>
|
||||
<span>${log.video_count||0}视频×${log.account_count||0}账号=${log.total||0}条</span>
|
||||
<span>${machineStr || '等待执行'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div style="display:none;padding:4px 8px;margin-bottom:4px;font-size:9px;color:var(--text2);background:var(--bg3);border-radius:0 0 4px 4px">
|
||||
<div>方向: ${log.direction||'-'}</div>
|
||||
<div>视频: ${(log.urls||[]).slice(0,5).map(u => u.slice(0,40)).join('; ')}${(log.urls||[]).length>5?'...':''}</div>
|
||||
</div>`;
|
||||
}).join('')}
|
||||
</div>
|
||||
</details>`;
|
||||
} catch (e) {
|
||||
// silent fail
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(s) {
|
||||
if (!s) return '';
|
||||
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user