feat(monitor): 抖音 Web 接口客户端 —— 绕开爬虫子进程,直接发 HTTP
Deploy VitePress site to Pages / build (push) Canceled after 0s
Deploy VitePress site to Pages / Deploy (push) Canceled after 0s

移植自 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:
2026-10-10 17:12:25 +08:00
parent 21ff01b894
commit 2112c1a870
1601 changed files with 367425 additions and 0 deletions
@@ -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 = '';
},
};
}
@@ -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 { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
}
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="每行一条评论,直接粘贴即可&#10;例:&#10;讲得太好了,受益匪浅!&#10;这个观点很新颖,学习了&#10;干货满满,感谢分享" 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 => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ── 字符串截断 ──
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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 => (
{ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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 => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
}
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
}
@@ -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 =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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) =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
@@ -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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}

Some files were not shown because too many files have changed in this diff Show More