Files
workphone-sdk/sdk/app/static/hub.html
2026-03-16 10:31:33 +08:00

1391 lines
65 KiB
HTML
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>机擎 · 工作手机统一工作台</title>
<style>
*{box-sizing:border-box;margin:0;padding:0}
:root{
--bg:#f2f4f8;
--panel:rgba(255,255,255,.72);
--panel-strong:rgba(255,255,255,.88);
--border:rgba(15,23,42,.06);
--border-active:rgba(37,99,235,.22);
--shadow:0 8px 32px rgba(15,23,42,.06),0 1.5px 4px rgba(15,23,42,.04);
--shadow-lg:0 16px 48px rgba(15,23,42,.1),0 2px 6px rgba(15,23,42,.05);
--text:#0f172a;
--sub:#64748b;
--muted:#94a3b8;
--blue:#2563eb;
--teal:#0ea5a6;
--green:#16a34a;
--orange:#f59e0b;
--red:#dc2626;
--purple:#7c3aed;
--radius:20px;
--radius-sm:14px;
--blur:20px;
--ease:cubic-bezier(.22,1,.36,1);
}
html{-webkit-font-smoothing:antialiased;scroll-behavior:smooth}
body{font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","PingFang SC","Helvetica Neue",sans-serif;background:var(--bg);color:var(--text);line-height:1.5}
body::before{content:"";position:fixed;inset:0;z-index:-1;background:
radial-gradient(ellipse 60% 50% at 20% -5%,rgba(37,99,235,.1),transparent),
radial-gradient(ellipse 50% 45% at 80% 5%,rgba(14,165,166,.08),transparent),
radial-gradient(ellipse 40% 40% at 10% 90%,rgba(124,58,237,.06),transparent)}
@keyframes fadeUp{from{opacity:0;transform:translateY(12px)}to{opacity:1;transform:none}}
.app{max-width:1600px;margin:0 auto;padding:16px 20px 48px}
/* glass mixin */
.glass{background:var(--panel);border:1px solid var(--border);border-radius:var(--radius);backdrop-filter:blur(var(--blur)) saturate(180%);-webkit-backdrop-filter:blur(var(--blur)) saturate(180%);box-shadow:var(--shadow);transition:box-shadow .3s var(--ease),border-color .3s var(--ease),transform .3s var(--ease)}
/* topbar */
.topbar{position:sticky;top:10px;z-index:20;display:flex;justify-content:space-between;align-items:center;gap:12px;padding:16px 24px;box-shadow:var(--shadow-lg)}
.brand{display:flex;align-items:center;gap:14px}
.brand-icon{width:44px;height:44px;border-radius:13px;display:grid;place-items:center;background:linear-gradient(135deg,var(--blue),var(--teal));color:#fff;font-size:22px;box-shadow:0 8px 20px rgba(37,99,235,.22)}
.brand h1{font-size:20px;font-weight:700;letter-spacing:-.03em}
.brand p{font-size:11.5px;color:var(--sub);margin-top:2px}
.chips{display:flex;gap:8px;flex-wrap:wrap}
.chip{display:inline-flex;align-items:center;gap:6px;padding:6px 13px;border-radius:999px;background:rgba(15,23,42,.04);border:1px solid var(--border);font-size:11.5px;font-weight:600;color:var(--sub);transition:.2s}
.dot{width:7px;height:7px;border-radius:999px;background:var(--orange);flex-shrink:0}
.dot.ok{background:var(--green);box-shadow:0 0 8px rgba(22,163,74,.45)}
.dot.bad{background:var(--red);box-shadow:0 0 8px rgba(220,38,38,.35)}
/* layout */
.layout{display:grid;grid-template-columns:256px minmax(0,1fr);gap:16px;margin-top:16px}
@media (max-width:980px){.layout{grid-template-columns:1fr}.sidebar{position:static}}
/* sidebar */
.sidebar{position:sticky;top:96px;align-self:start;padding:12px 10px;max-height:calc(100vh - 110px);overflow-y:auto;scrollbar-width:none}
.sidebar::-webkit-scrollbar{width:0}
.nav-title{font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.1em;font-weight:700;padding:14px 10px 6px}
.nav-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border-radius:var(--radius-sm);cursor:pointer;border:1px solid transparent;transition:all .2s var(--ease);margin-bottom:2px}
.nav-item:hover{background:rgba(37,99,235,.05);border-color:rgba(37,99,235,.08)}
.nav-item.active{background:linear-gradient(135deg,var(--blue),var(--teal));color:#fff;box-shadow:0 6px 16px rgba(37,99,235,.2);transform:scale(1.01)}
.nav-item.active .nav-icon{background:rgba(255,255,255,.2)}
.nav-icon{width:32px;height:32px;border-radius:9px;display:grid;place-items:center;background:rgba(15,23,42,.04);font-size:15px;flex-shrink:0;transition:.2s}
/* content */
.content{display:flex;flex-direction:column;gap:16px;animation:fadeUp .4s var(--ease) both}
/* stat cards */
.stats{display:grid;grid-template-columns:repeat(5,1fr);gap:10px}
@media (max-width:1180px){.stats{grid-template-columns:repeat(3,1fr)}}
@media (max-width:720px){.stats{grid-template-columns:repeat(2,1fr)}}
.stat{padding:16px 18px;transition:transform .25s var(--ease),box-shadow .25s var(--ease)}
.stat:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg)}
.stat .k{font-size:11.5px;color:var(--sub);font-weight:500}
.stat .v{font-size:26px;font-weight:800;letter-spacing:-.04em;margin-top:3px;background:linear-gradient(135deg,var(--text),var(--blue));-webkit-background-clip:text;-webkit-text-fill-color:transparent;background-clip:text}
.stat .d{font-size:10.5px;color:var(--muted);margin-top:4px}
/* panel */
.panel{padding:24px;transition:box-shadow .3s var(--ease)}
.panel:hover{box-shadow:var(--shadow-lg)}
.panel-head{display:flex;justify-content:space-between;align-items:flex-start;gap:12px;flex-wrap:wrap;margin-bottom:18px}
.panel h2{font-size:19px;font-weight:700;letter-spacing:-.03em}
.panel p.sub{font-size:12.5px;color:var(--sub);margin-top:4px;line-height:1.6}
.btns{display:flex;gap:8px;flex-wrap:wrap}
.btn{border:0;border-radius:11px;padding:9px 16px;font-size:12px;font-weight:600;cursor:pointer;transition:all .2s var(--ease)}
.btn.primary{background:linear-gradient(135deg,var(--blue),var(--teal));color:#fff;box-shadow:0 4px 12px rgba(37,99,235,.2)}
.btn.primary:hover{transform:translateY(-1px);box-shadow:0 6px 18px rgba(37,99,235,.3)}
.btn.soft{background:rgba(15,23,42,.05);color:var(--text)}
.btn.soft:hover{background:rgba(15,23,42,.09);transform:translateY(-1px)}
/* grid */
.grid{display:grid;gap:12px}
.grid.col2{grid-template-columns:repeat(2,minmax(0,1fr))}
.grid.col3{grid-template-columns:repeat(3,minmax(0,1fr))}
.grid.col4{grid-template-columns:repeat(4,minmax(0,1fr))}
@media (max-width:980px){.grid.col2,.grid.col3,.grid.col4{grid-template-columns:1fr}}
/* card */
.card{padding:18px;border-radius:16px;background:var(--panel-strong);border:1px solid var(--border);transition:all .25s var(--ease)}
.card:hover{border-color:var(--border-active);box-shadow:0 8px 24px rgba(37,99,235,.06)}
.card h3{font-size:14.5px;font-weight:700;margin-bottom:6px}
.card p{font-size:12px;color:var(--sub);line-height:1.65}
.path{display:inline-flex;align-items:center;gap:6px;background:rgba(15,23,42,.04);padding:4px 10px;border-radius:999px;font-size:10.5px;color:var(--sub);margin-top:8px;font-weight:500}
/* flow steps */
.flow{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:12px}
@media (max-width:980px){.flow{grid-template-columns:1fr}}
.flow-item{position:relative;padding:18px;border-radius:16px;background:var(--panel-strong);border:1px solid var(--border);transition:all .25s var(--ease)}
.flow-item:hover{border-color:var(--border-active);transform:translateY(-2px)}
.flow-item:not(:last-child)::after{content:"\2192";position:absolute;right:-10px;top:50%;transform:translateY(-50%);font-size:18px;color:var(--muted);font-weight:300}
@media (max-width:980px){.flow-item:not(:last-child)::after{content:"\2193";right:auto;left:50%;bottom:-20px;top:auto;transform:translateX(-50%)}}
.flow-step{font-size:10px;color:var(--blue);font-weight:800;letter-spacing:.08em;text-transform:uppercase}
.flow-item h3{margin-top:8px;font-size:15px;font-weight:700}
.flow-item p{margin-top:6px;font-size:12px;color:var(--sub);line-height:1.7}
/* doc layout */
.doc-layout{display:grid;grid-template-columns:340px minmax(0,1fr);gap:16px}
@media (max-width:1180px){.doc-layout{grid-template-columns:1fr}}
.doc-list{display:flex;flex-direction:column;gap:8px;max-height:calc(100vh - 200px);overflow-y:auto;scrollbar-width:thin;padding-right:4px}
.doc-list::-webkit-scrollbar{width:3px}
.doc-list::-webkit-scrollbar-thumb{background:rgba(0,0,0,.12);border-radius:3px}
.doc-item{padding:14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--panel-strong);cursor:pointer;transition:all .2s var(--ease)}
.doc-item:hover{border-color:var(--border-active);background:rgba(255,255,255,.95)}
.doc-item.active{border-color:var(--blue);box-shadow:0 0 0 2px rgba(37,99,235,.12),0 6px 16px rgba(37,99,235,.08);background:#fff}
.doc-item h3{font-size:13.5px;font-weight:700}
.doc-item p{font-size:11.5px;color:var(--sub);margin-top:4px;line-height:1.55}
.doc-meta{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
.pill{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:999px;background:rgba(15,23,42,.04);font-size:10.5px;color:var(--sub);font-weight:500;border:1px solid rgba(15,23,42,.04)}
/* doc view */
.doc-view{padding:24px;border-radius:var(--radius);background:rgba(255,255,255,.95);border:1px solid var(--border);min-height:640px;box-shadow:inset 0 1px 0 rgba(255,255,255,1)}
.doc-view .doc-header{padding-bottom:16px;border-bottom:1px solid rgba(15,23,42,.06);margin-bottom:18px}
.doc-view .doc-header h3{font-size:22px;font-weight:800;letter-spacing:-.04em}
.doc-view .doc-header p{font-size:13px;color:var(--sub);margin-top:8px;line-height:1.6}
.doc-body{font-size:14px;line-height:1.85;color:#1e293b}
.doc-body h1{font-size:22px;font-weight:800;margin:1.4em 0 .6em;letter-spacing:-.03em}
.doc-body h2{font-size:18px;font-weight:700;margin:1.2em 0 .5em;padding-bottom:6px;border-bottom:1px solid rgba(15,23,42,.06)}
.doc-body h3{font-size:15px;font-weight:700;margin:1em 0 .4em}
.doc-body p,.doc-body ul,.doc-body ol,.doc-body table,.doc-body pre,.doc-body blockquote{margin:.8em 0}
.doc-body ul,.doc-body ol{padding-left:1.4em}
.doc-body li{margin:.3em 0}
.doc-body pre{overflow:auto;padding:16px;border-radius:var(--radius-sm);background:#0f172a;color:#e2e8f0;font-size:13px;line-height:1.7}
.doc-body code{font-family:ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,monospace;font-size:.9em}
.doc-body :not(pre)>code{background:rgba(37,99,235,.07);color:var(--blue);padding:2px 6px;border-radius:5px}
.doc-body table{width:100%;border-collapse:collapse}
.doc-body th,.doc-body td{border:1px solid rgba(15,23,42,.08);padding:8px 12px;text-align:left;font-size:13px}
.doc-body th{background:rgba(15,23,42,.03);font-weight:600}
.doc-body blockquote{border-left:3px solid var(--blue);padding:8px 16px;background:rgba(37,99,235,.03);border-radius:0 10px 10px 0;color:var(--sub)}
/* device layout */
.device-layout{display:grid;grid-template-columns:280px minmax(0,1.1fr) 320px;gap:16px}
@media (max-width:1280px){.device-layout{grid-template-columns:1fr}}
.device-list{display:flex;flex-direction:column;gap:8px}
.device-item{display:flex;align-items:center;gap:12px;padding:12px 14px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--panel-strong);cursor:pointer;transition:all .2s var(--ease)}
.device-item:hover{border-color:var(--border-active);transform:translateX(2px)}
.device-item.active{border-color:var(--blue);box-shadow:0 0 0 2px rgba(37,99,235,.1),0 4px 12px rgba(37,99,235,.08)}
.device-badge{width:42px;height:42px;border-radius:12px;display:grid;place-items:center;font-size:19px;background:linear-gradient(135deg,var(--blue),var(--teal));color:#fff;box-shadow:0 4px 10px rgba(37,99,235,.15)}
.device-name{font-size:13.5px;font-weight:700}
.device-meta{font-size:11px;color:var(--sub);margin-top:2px}
.status-dot{width:9px;height:9px;border-radius:999px;background:var(--orange);flex-shrink:0}
.status-dot.online,.status-dot.adb{background:var(--green);box-shadow:0 0 6px rgba(22,163,74,.4)}
/* connection mode badges */
.mode-badges{display:flex;gap:4px;flex-wrap:wrap;margin-top:6px}
.mode-badge{display:inline-flex;align-items:center;gap:3px;padding:2px 7px;border-radius:999px;font-size:9.5px;font-weight:600;border:1px solid transparent}
.mode-badge.available{background:rgba(22,163,74,.08);color:var(--green);border-color:rgba(22,163,74,.15)}
.mode-badge.unavailable{background:rgba(148,163,184,.06);color:var(--muted);border-color:rgba(148,163,184,.1)}
.mode-badge.best{background:rgba(37,99,235,.1);color:var(--blue);border-color:rgba(37,99,235,.2);font-weight:700}
.mode-badge .badge-dot{width:5px;height:5px;border-radius:999px}
.mode-badge.available .badge-dot{background:var(--green)}
.mode-badge.unavailable .badge-dot{background:var(--muted)}
.mode-badge.best .badge-dot{background:var(--blue);box-shadow:0 0 4px rgba(37,99,235,.5)}
.priority-tag{font-size:9px;font-weight:800;color:var(--blue);letter-spacing:.05em;text-transform:uppercase}
.mode-detail-grid{display:grid;grid-template-columns:repeat(5,1fr);gap:8px;margin-top:12px}
@media(max-width:1280px){.mode-detail-grid{grid-template-columns:repeat(3,1fr)}}
@media(max-width:720px){.mode-detail-grid{grid-template-columns:1fr}}
.mode-detail-card{padding:12px;border-radius:12px;border:1px solid var(--border);background:var(--panel-strong);text-align:center;transition:all .2s var(--ease)}
.mode-detail-card:hover{border-color:var(--border-active);transform:translateY(-1px)}
.mode-detail-card.active{border-color:var(--green);box-shadow:0 0 0 2px rgba(22,163,74,.08)}
.mode-detail-card.best-active{border-color:var(--blue);box-shadow:0 0 0 2px rgba(37,99,235,.12),0 4px 12px rgba(37,99,235,.06)}
.mode-detail-card .mode-icon{font-size:20px;margin-bottom:4px}
.mode-detail-card .mode-name{font-size:11.5px;font-weight:700}
.mode-detail-card .mode-status{font-size:10px;margin-top:4px}
.mode-detail-card .mode-prio{font-size:9px;color:var(--muted);margin-top:2px}
/* actions */
.actions{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.action{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:6px;padding:14px 10px;border-radius:var(--radius-sm);border:1px solid var(--border);background:var(--panel-strong);cursor:pointer;transition:all .2s var(--ease);font-size:11.5px;font-weight:600}
.action:hover{border-color:var(--border-active);transform:translateY(-2px);box-shadow:0 6px 16px rgba(37,99,235,.08)}
.action .icon{font-size:22px}
/* command */
.command-area{display:flex;gap:10px;flex-wrap:wrap}
.command-area input{flex:1;min-width:240px;padding:11px 16px;border-radius:12px;border:1px solid var(--border);background:rgba(255,255,255,.9);font-size:13.5px;outline:0;transition:.2s}
.command-area input:focus{border-color:var(--blue);box-shadow:0 0 0 3px rgba(37,99,235,.08)}
.hint{font-size:11.5px;color:var(--sub);line-height:1.7;margin-top:10px}
.mode-tabs{display:flex;gap:6px;flex-wrap:wrap;margin-bottom:12px}
.mode-btn{padding:7px 14px;border-radius:10px;background:rgba(15,23,42,.04);font-size:11.5px;color:var(--sub);cursor:pointer;transition:.2s;border:1px solid transparent;font-weight:500}
.mode-btn:hover{background:rgba(37,99,235,.06)}
.mode-btn.active{background:rgba(37,99,235,.1);color:var(--blue);font-weight:700;border-color:rgba(37,99,235,.15)}
/* log */
.log-box{min-height:200px;max-height:320px;overflow:auto;padding:14px 16px;border-radius:var(--radius-sm);background:#0f172a;color:#cbd5e1;font-family:ui-monospace,SFMono-Regular,"JetBrains Mono",Menlo,monospace;font-size:12px;line-height:1.75}
.log-info{color:#7dd3fc}.log-error{color:#fca5a5}
/* screenshot */
.shot{aspect-ratio:9/19.5;border-radius:24px;overflow:hidden;border:2px solid rgba(15,23,42,.08);background:linear-gradient(180deg,#0f172a,#1e293b);display:flex;align-items:center;justify-content:center}
.shot img{width:100%;height:100%;object-fit:cover}
.placeholder{font-size:12px;color:var(--muted);text-align:center;padding:14px}
/* detail table */
.detail-table{width:100%;border-collapse:collapse}
.detail-table td{padding:7px 0;border-bottom:1px solid rgba(15,23,42,.06);font-size:12.5px;vertical-align:top}
.detail-table td:first-child{width:88px;color:var(--sub);font-weight:500}
/* protocol */
.proto{background:#0f172a;color:#dbeafe;padding:16px;border-radius:var(--radius-sm);font-size:12px;line-height:1.8}
.proto strong{color:#fff}
.mini-table{width:100%;border-collapse:collapse;margin-top:8px}
.mini-table th,.mini-table td{border-bottom:1px solid rgba(255,255,255,.06);padding:6px 4px;text-align:left;font-size:11px}
/* asset */
.asset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:14px}
@media (max-width:980px){.asset-grid{grid-template-columns:1fr}}
.asset-card{padding:16px;border-radius:16px;background:var(--panel-strong);border:1px solid var(--border);transition:all .25s var(--ease)}
.asset-card:hover{transform:translateY(-2px);box-shadow:var(--shadow-lg)}
.asset-card img{width:100%;height:auto;border-radius:12px;border:1px solid rgba(15,23,42,.06);background:#fff}
.asset-card h3{margin-bottom:6px;font-size:14px;font-weight:700}
/* table */
.list-table{width:100%;border-collapse:collapse}
.list-table th,.list-table td{padding:10px 12px;border-bottom:1px solid rgba(15,23,42,.06);text-align:left;font-size:12px}
.list-table th{font-size:11px;color:var(--sub);font-weight:600}
.list-table tr:hover td{background:rgba(37,99,235,.02)}
.empty{padding:32px 14px;text-align:center;color:var(--muted);font-size:13px}
/* image zoom */
.asset-card img,.doc-body img{cursor:zoom-in;transition:transform .25s var(--ease),box-shadow .25s var(--ease)}
.asset-card img:hover,.doc-body img:hover{transform:scale(1.015);box-shadow:0 12px 32px rgba(0,0,0,.12)}
/* lightbox */
.lightbox{position:fixed;inset:0;z-index:200;background:rgba(0,0,0,.88);display:none;align-items:center;justify-content:center;cursor:zoom-out;backdrop-filter:blur(12px);-webkit-backdrop-filter:blur(12px);opacity:0;transition:opacity .25s ease}
.lightbox.show{display:flex;opacity:1}
.lightbox img{max-width:94vw;max-height:94vh;border-radius:16px;box-shadow:0 24px 64px rgba(0,0,0,.5);object-fit:contain;transform:scale(.92);transition:transform .3s var(--ease)}
.lightbox.show img{transform:scale(1)}
.lb-close{position:absolute;top:20px;right:24px;width:40px;height:40px;border-radius:12px;background:rgba(255,255,255,.15);border:none;color:#fff;font-size:20px;cursor:pointer;display:grid;place-items:center;transition:.2s;backdrop-filter:blur(8px)}
.lb-close:hover{background:rgba(255,255,255,.25);transform:scale(1.05)}
.lb-title{position:absolute;bottom:24px;left:50%;transform:translateX(-50%);color:rgba(255,255,255,.8);font-size:13px;font-weight:600;background:rgba(0,0,0,.4);padding:6px 16px;border-radius:999px;backdrop-filter:blur(8px);white-space:nowrap}
.foot{text-align:center;font-size:11px;color:var(--muted);padding:16px;margin-top:12px}
</style>
</head>
<body>
<div class="app">
<header class="topbar glass">
<div class="brand">
<div class="brand-icon">⚙️</div>
<div>
<h1>机擎 · 工作手机统一工作台</h1>
<p>说明文档、设备控制、Hook、AI Agent、连接协议、架构图和项目管理全部融合到一个站内页面</p>
</div>
</div>
<div class="chips">
<span class="chip" id="chip-health"><span class="dot"></span>检测中</span>
<span class="chip" id="chip-ws">WS 0</span>
<span class="chip" id="chip-adb">ADB 0</span>
<span class="chip" id="chip-version">v-</span>
</div>
</header>
<div class="layout">
<aside class="sidebar glass" id="sidebar"></aside>
<main class="content">
<section class="stats" id="stats"></section>
<section id="view"></section>
</main>
</div>
<div class="foot">机擎 v3.0 · 工作手机SDK · 统一工作台 · 站内文档渲染 · Agent 集群</div>
</div>
<div class="lightbox" id="lightbox" onclick="closeLightbox()">
<button class="lb-close" onclick="closeLightbox()">&times;</button>
<img id="lb-img" src="" alt="放大预览">
<div class="lb-title" id="lb-title"></div>
</div>
<script>
const API = location.origin;
const NAVS = [
{id:'overview', icon:'🏠', name:'首页总览', desc:'总览卡片、系统链路、控制模式'},
{id:'docs', icon:'📖', name:'说明文档', desc:'文档总览卡片 + 站内正文页'},
{id:'devices', icon:'📱', name:'设备控制', desc:'设备列表、截图、快捷操作、AI 指令'},
{id:'hook', icon:'🪝', name:'Hook 控制', desc:'模块、事件、脚本、设备 Hook 状态'},
{id:'agent', icon:'🤖', name:'AI Agent', desc:'自然语言控制、能力、执行模式'},
{id:'agentCluster', icon:'🧩', name:'Agent 集群', desc:'架构部署、技能引擎、Hawk、Hook 脚本'},
{id:'connection', icon:'🔌', name:'连接与协议', desc:'WebSocket、心跳、device_request、event'},
{id:'architecture', icon:'🧭', name:'架构与经验', desc:'架构图、控制模式、经验沉淀'},
{id:'project', icon:'📊', name:'项目管理', desc:'进度、文档清单、项目入口'},
];
const state = {
current: new URLSearchParams(location.search).get('tab') || 'overview',
overview: null,
docs: [],
assets: [],
selectedDoc: null,
selectedAgentDoc: null,
docFilter: '',
devices: [],
currentDevice: null,
protocol: null,
connection: null,
hookModules: [],
hookEvents: [],
currentDeviceModules: null,
logs: [],
cmdMode: 'pattern',
};
function esc(text=''){
return String(text).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
function log(message, type='info'){
const ts = new Date().toLocaleTimeString('zh-CN', {hour12:false});
state.logs.push({message, type, ts});
if (state.logs.length > 80) state.logs.shift();
if (state.current === 'devices') renderCurrentView();
}
async function apiGet(url){
const res = await fetch(`${API}${url}`);
if(!res.ok) throw new Error(`请求失败: ${res.status}`);
return res.json();
}
async function apiPost(url, body){
const res = await fetch(`${API}${url}`, {
method:'POST',
headers:{'Content-Type':'application/json'},
body: JSON.stringify(body || {})
});
if(!res.ok) throw new Error(`请求失败: ${res.status}`);
return res.json();
}
function renderSidebar(){
const box = document.getElementById('sidebar');
box.innerHTML = `
<div class="nav-title">统一工作台</div>
${NAVS.map(item => `
<div class="nav-item ${state.current===item.id?'active':''}" onclick="navigate('${item.id}')">
<div class="nav-icon">${item.icon}</div>
<div>
<div style="font-size:13px;font-weight:700">${item.name}</div>
<div style="font-size:11px;opacity:.8;margin-top:2px">${item.desc}</div>
</div>
</div>
`).join('')}
`;
}
function renderStats(){
const summary = state.overview?.summary || {};
document.getElementById('stats').innerHTML = `
${statCard('服务状态', summary.sdk_online ? '健康' : '离线', summary.sdk_online ? `版本 ${summary.sdk_version || '-'}` : '等待连接')}
${statCard('WS 在线', summary.ws_online ?? 0, '设备长连接')}
${statCard('ADB 设备', summary.adb_count ?? 0, '本地/真机直连')}
${statCard('说明文档', summary.manual_docs ?? 0, '站内阅读')}
${statCard('Agent 文档', state.docs.filter(d => d.category === 'agent').length || 0, 'Agent 集群')}
`;
document.getElementById('chip-health').innerHTML = `<span class="dot ${summary.sdk_online ? 'ok' : 'bad'}"></span>${summary.sdk_online ? '在线' : '离线'}`;
document.getElementById('chip-ws').textContent = `WS ${summary.ws_online ?? 0}`;
document.getElementById('chip-adb').textContent = `ADB ${summary.adb_count ?? 0}`;
document.getElementById('chip-version').textContent = `v${summary.sdk_version || '-'}`;
}
function statCard(title, value, desc){
return `
<div class="stat glass">
<div class="k">${title}</div>
<div class="v">${value}</div>
<div class="d">${desc}</div>
</div>
`;
}
function navigate(id){
state.current = id;
const url = new URL(location.href);
url.searchParams.set('tab', id);
history.replaceState({}, '', url.toString());
renderSidebar();
renderCurrentView();
}
function card(title, desc, action){
return `
<div class="card">
<h3>${title}</h3>
<p>${desc}</p>
${action || ''}
</div>
`;
}
function renderOverview(){
const docs = state.docs.filter(d => d.category === 'manual').slice(0, 4);
const modes = state.overview?.control_modes || [];
const devices = state.overview?.devices || [];
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>首页总览</h2>
<p class="sub">把说明文档、控制台、Hook、AI Agent、连接状态和架构图收进一个站内工作台。</p>
</div>
<div class="btns">
<button class="btn primary" onclick="navigate('devices')">进入设备控制</button>
<button class="btn soft" onclick="navigate('agentCluster')">Agent 集群</button>
<button class="btn soft" onclick="navigate('docs')">说明文档</button>
</div>
</div>
<div class="flow">
<div class="flow-item">
<div class="flow-step">STEP 1</div>
<h3>业务请求进入</h3>
<p>存客宝、管理端或本地控制入口统一进入工作手机 SDK先经过统一工作台和接口收口。</p>
</div>
<div class="flow-item">
<div class="flow-step">STEP 2</div>
<h3>服务器决策</h3>
<p>FastAPI、unified、WebSocket Hub、Hook、ADB、AI Agent 共同决定最合适的执行通道。</p>
</div>
<div class="flow-item">
<div class="flow-step">STEP 3</div>
<h3>设备执行</h3>
<p>设备端 Agent、SkillExecutor、uiautomator2、Frida、scrcpy 分别承担自动化、深度 Hook 和人工接管。</p>
</div>
<div class="flow-item">
<div class="flow-step">STEP 4</div>
<h3>结果沉淀</h3>
<p>response、event、状态、截图、经验和说明文档统一回到工作台形成持续复用的操作体系。</p>
</div>
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>控制模式</h2>
<p class="sub">5 种控制模式按优先级自动选择。绿色 = 可用,蓝色 = 当前最优。</p>
</div>
<div class="btns">
<button class="btn soft" onclick="refreshConnectionModes()">检测模式</button>
</div>
</div>
<div id="overview-modes-grid" class="grid col3">
${modes.map(m => {
const icons = {hook:'🪝', agent:'🔌', adb:'📱', ai:'🤖', scrcpy:'🖥️'};
return card(
m.name,
m.desc,
'<div class="path" style="gap:8px">' +
'<span class="priority-tag">P' + (m.priority || '?') + '</span>' +
(icons[m.id] || '⚡') + ' ' + m.id +
'</div>'
);
}).join('')}
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>说明文档首页</h2>
<p class="sub">只保留一个文档首页,卡片展示后在站内直接展开正文,不再使用 iframe。</p>
</div>
</div>
<div class="grid col2">
${docs.map(d => `
<div class="card" onclick="openDocFromHome('${d.id}')" style="cursor:pointer">
<h3>${d.title}</h3>
<p>${d.summary}</p>
<div class="doc-meta">
<span class="pill">${d.section}</span>
<span class="pill">${d.folder}/${d.file}</span>
</div>
</div>
`).join('')}
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>设备快照</h2>
<p class="sub">实时展示设备状态、连接模式和心跳。点击设备行可查看详情。</p>
</div>
<div class="btns">
<button class="btn soft" onclick="refreshAll()">刷新全部</button>
</div>
</div>
<div class="card">
<table class="list-table">
<thead>
<tr><th>设备</th><th>状态</th><th>连接模式</th><th>项目</th><th>当前 APP</th><th>心跳</th></tr>
</thead>
<tbody>
${devices.length ? devices.slice(0, 8).map(d => {
const best = d.best_mode;
const modes = (d.connection_modes || []).filter(m => m.available);
const statusClass = d.status === 'online' ? 'online' : (d.status === 'adb' ? 'adb' : '');
return `
<tr onclick="navigate('devices');selectDevice('${d.device_id}')" style="cursor:pointer">
<td><strong>${esc(d.model || d.device_id || '-')}</strong></td>
<td><span class="status-dot ${statusClass}" style="display:inline-block;vertical-align:middle;margin-right:4px"></span>${esc(d.status || '-')}</td>
<td>
<div class="mode-badges">
${best ? `<span class="mode-badge best"><span class="badge-dot"></span>${esc(best.name)}</span>` : ''}
${modes.filter(m => !best || m.id !== best.id).slice(0,2).map(m => `<span class="mode-badge available"><span class="badge-dot"></span>${esc(m.id)}</span>`).join('')}
</div>
</td>
<td>${esc(d.project_id || '-')}</td>
<td>${esc(d.current_app || '-')}</td>
<td>${typeof d.heartbeat_age_seconds === 'number' ? d.heartbeat_age_seconds + 's' : (d.last_heartbeat ? '活跃' : '-')}</td>
</tr>`;
}).join('') : `<tr><td colspan="6" class="empty">暂无设备数据</td></tr>`}
</tbody>
</table>
</div>
</div>
`;
}
function openDocFromHome(docId){
state.current = 'docs';
state.selectedDoc = docId;
renderSidebar();
renderCurrentView();
}
async function openDoc(docId){
state.selectedDoc = docId;
document.querySelectorAll('#view .doc-item').forEach(el => {
el.classList.toggle('active', el.dataset.docid === docId);
});
const target = document.getElementById('doc-view');
if (target) target.innerHTML = `<div class="empty">正文加载中...</div>`;
try {
const res = await apiGet(`/api/v3/workbench/docs/${docId}`);
const doc = res.data;
const view = document.getElementById('doc-view');
if (!view) return;
view.innerHTML = `
<div class="doc-header">
<h3>${doc.title}</h3>
<p>${doc.summary}</p>
<div class="doc-meta">
<span class="pill">${doc.section}</span>
<span class="pill">${doc.folder}/${doc.file}</span>
<span class="pill">站内正文渲染</span>
</div>
</div>
<div class="doc-body">${doc.content_html}</div>
`;
} catch (error) {
const view = document.getElementById('doc-view');
if (view) view.innerHTML = `<div class="empty">正文加载失败:${esc(error.message)}</div>`;
}
}
function renderDocs(){
const allDocs = state.docs;
const cats = [...new Set(allDocs.map(d => d.section))];
const filter = state.docFilter || '';
const docs = filter ? allDocs.filter(d => d.section === filter) : allDocs;
const selected = state.selectedDoc || docs[0]?.id;
state.selectedDoc = selected;
if (selected) requestAnimationFrame(() => {
const v = document.getElementById('doc-view');
if (v && !v.dataset.loaded) { v.dataset.loaded = '1'; openDoc(selected); }
});
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>说明文档</h2>
<p class="sub">全部文档站内阅读,点击左侧卡片直接展开正文。支持按分类筛选。</p>
</div>
</div>
<div class="mode-tabs" style="margin-bottom:14px">
<div class="mode-btn ${!filter?'active':''}" onclick="filterDocs('')">全部 (${allDocs.length})</div>
${cats.map(c => `<div class="mode-btn ${filter===c?'active':''}" onclick="filterDocs('${c}')">${c} (${allDocs.filter(d=>d.section===c).length})</div>`).join('')}
</div>
<div class="doc-layout">
<div class="doc-list">
${docs.map(doc => `
<div class="doc-item ${doc.id===selected ? 'active' : ''}" data-docid="${doc.id}" onclick="openDoc('${doc.id}')">
<h3>${doc.title}</h3>
<p>${doc.summary}</p>
<div class="doc-meta">
<span class="pill">${doc.section}</span>
<span class="pill">${doc.folder}</span>
</div>
</div>
`).join('')}
</div>
<div class="doc-view" id="doc-view"><div class="empty">选择左侧文档查看正文</div></div>
</div>
</div>
`;
}
function filterDocs(section){
state.docFilter = section;
state.selectedDoc = null;
const v = document.getElementById('doc-view');
if (v) delete v.dataset.loaded;
renderCurrentView();
}
function renderDevicePanel(){
const devices = state.devices;
const current = devices.find(item => item.device_id === state.currentDevice) || devices[0];
if (current && !state.currentDevice) state.currentDevice = current.device_id;
const protocol = state.protocol || {};
const connection = state.connection || {};
const logs = state.logs.map(item => `<div class="${item.type==='error'?'log-error':'log-info'}">[${item.ts}] ${esc(item.message)}</div>`).join('');
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>设备控制</h2>
<p class="sub">把原来的控制中心融合进工作台统一展示设备列表、详情、截图、快捷操作、AI 对话和连接状态。</p>
</div>
<div class="btns">
<button class="btn soft" onclick="refreshDevices()">刷新设备</button>
<button class="btn primary" onclick="navigate('connection')">查看协议</button>
</div>
</div>
<div class="device-layout">
<div class="card">
<h3 style="margin-bottom:12px">设备列表</h3>
<div class="device-list">
${devices.length ? devices.map(device => {
const best = device.best_mode;
const avail = (device.connection_modes || []).filter(m => m.available);
return `
<div class="device-item ${device.device_id===state.currentDevice?'active':''}" onclick="selectDevice('${device.device_id}')">
<div class="device-badge">📱</div>
<div style="flex:1">
<div class="device-name">${esc(device.model || device.device_id)}</div>
<div class="device-meta">${esc(device.device_id?.substring(0,12))}... · ${esc(device.status || '-')} · ${esc(device.project_id || '未绑定')}</div>
<div class="mode-badges">
${best ? `<span class="mode-badge best"><span class="badge-dot"></span>${esc(best.name)}</span>` : ''}
${avail.filter(m => !best || m.id !== best.id).slice(0,3).map(m => `<span class="mode-badge available"><span class="badge-dot"></span>${esc(m.id)}</span>`).join('')}
${avail.length === 0 ? '<span class="mode-badge unavailable">离线</span>' : ''}
</div>
</div>
<div class="status-dot ${esc(device.status || '')}"></div>
</div>`;
}).join('') : `<div class="empty">暂无设备请先接入 Agent 或连接 ADB 设备</div>`}
</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div class="card">
<h3 style="margin-bottom:12px">快捷操作</h3>
<div class="actions">
${actionBtn('📸','截屏',"doAction('screenshot')")}
${actionBtn('💬','打开微信',"doAction('open_wechat')")}
${actionBtn('🎵','打开抖音',"doAction('open_douyin')")}
${actionBtn('📕','打开小红书',"doAction('open_xhs')")}
${actionBtn('🐟','打开闲鱼',"doAction('open_xianyu')")}
${actionBtn('🏠','回主页',"doAction('home')")}
${actionBtn('⬅️','返回',"doAction('back')")}
${actionBtn('🌳','UI 树',"doAction('ui_tree')")}
${actionBtn('','设备信息',"doAction('device_info')")}
</div>
</div>
<div class="card">
<h3>AI 对话控制</h3>
<div class="mode-tabs">
<div class="mode-btn ${state.cmdMode==='pattern'?'active':''}" onclick="setMode('pattern')">模式匹配</div>
<div class="mode-btn ${state.cmdMode==='ai'?'active':''}" onclick="setMode('ai')">AI 智能</div>
<div class="mode-btn ${state.cmdMode==='ai-only'?'active':''}" onclick="setMode('ai-only')">AI 纯解析</div>
</div>
<div class="command-area">
<input id="cmd-input" placeholder="${cmdPlaceholder()}" onkeydown="if(event.key==='Enter')sendCmd()">
<button class="btn primary" onclick="sendCmd()">发送</button>
</div>
<div class="hint">${cmdHint()}</div>
</div>
<div class="card">
<h3>连接方式与协议</h3>
<div class="proto">
<div><strong>WS 入口</strong> ${esc(connection.ws_path || '-')}</div>
<div><strong>Hook 事件流</strong> ${esc(connection.hook_events_stream || '-')}</div>
<div><strong>在线设备</strong> ${connection.online_ws_count ?? '-'} / <strong>ADB</strong> ${connection.adb_count ?? '-'}</div>
<div><strong>推荐心跳</strong> ${(protocol.heartbeat?.recommended_interval_seconds || []).join(' / ') || '-'}</div>
<table class="mini-table">
<thead><tr><th>设备</th><th>项目</th><th>状态</th><th>心跳</th></tr></thead>
<tbody>
${(connection.devices || []).length ? (connection.devices || []).slice(0,6).map(item => `
<tr>
<td>${esc(item.device_id)}</td>
<td>${esc(item.project_id || '-')}</td>
<td>${esc(item.status || '-')}</td>
<td>${typeof item.heartbeat_age_seconds === 'number' ? item.heartbeat_age_seconds : '-'}</td>
</tr>
`).join('') : `<tr><td colspan="4">暂无连接状态</td></tr>`}
</tbody>
</table>
</div>
</div>
<div class="card">
<h3>实时日志</h3>
<div class="log-box">${logs || '<div>暂无日志</div>'}</div>
</div>
</div>
<div style="display:flex;flex-direction:column;gap:16px">
<div class="card">
<h3 style="margin-bottom:12px">设备截屏</h3>
<div class="shot" id="shot-box"><div class="placeholder">点击截屏查看当前设备画面</div></div>
</div>
<div class="card">
<h3 style="margin-bottom:12px">设备详情</h3>
${current ? `
<table class="detail-table">
<tr><td>设备 ID</td><td style="font-family:monospace;font-size:11px;word-break:break-all">${esc(current.device_id)}</td></tr>
<tr><td>型号</td><td>${esc(current.model || '-')}</td></tr>
<tr><td>品牌</td><td>${esc(current.brand || '-')}</td></tr>
<tr><td>Android</td><td>${esc(current.android_version || current.sdk_version || '-')}</td></tr>
<tr><td>状态</td><td><span class="status-dot ${current.status || ''}" style="display:inline-block;vertical-align:middle;margin-right:4px"></span>${esc(current.status || '-')}</td></tr>
<tr><td>当前模式</td><td>${current.best_mode ? `<span class="mode-badge best" style="font-size:11px"><span class="badge-dot"></span>${esc(current.best_mode.name)}</span>` : '-'}</td></tr>
<tr><td>当前 APP</td><td>${esc(current.current_app || current.quick_status?.current_app || '-')}</td></tr>
<tr><td>网络</td><td>${esc(current.network_type || current.quick_status?.network_type || '-')}</td></tr>
<tr><td>能力</td><td>${esc((current.capabilities || []).join(', ') || '-')}</td></tr>
</table>
${(current.connection_modes || []).length ? `
<h3 style="margin:14px 0 8px">连接模式 (${(current.connection_modes||[]).filter(m=>m.available).length}/5 可用)</h3>
<div class="mode-detail-grid">
${(current.connection_modes || []).map(m => {
const icons = {hook:'🪝', agent:'🔌', adb:'📱', ai:'🤖', scrcpy:'🖥️'};
const cls = m.available ? (current.best_mode?.id === m.id ? 'best-active' : 'active') : '';
return `<div class="mode-detail-card ${cls}">
<div class="mode-icon">${icons[m.id] || '⚡'}</div>
<div class="mode-name">${esc(m.name)}</div>
<div class="mode-status" style="color:${m.available ? 'var(--green)' : 'var(--muted)'}">${m.available ? '✓ 可用' : '✗ 不可用'}</div>
<div class="mode-prio">P${m.priority} · ${esc(m.detail?.substring(0,20) || '')}</div>
</div>`;
}).join('')}
</div>` : ''}
` : `<div class="empty">未选中设备</div>`}
</div>
</div>
</div>
</div>
`;
}
function actionBtn(icon, label, handler){
return `<div class="action" onclick="${handler}"><div class="icon">${icon}</div><div>${label}</div></div>`;
}
function cmdHint(){
if (state.cmdMode === 'pattern') return '模式匹配:打开[APP]、点击[文字]、输入[内容]、返回/主页、截图。复杂命令自动升级为 AI 解析。';
if (state.cmdMode === 'ai') return 'AI 智能:用自然语言描述操作,适合多步任务和跨应用动作。';
return 'AI 纯解析:只解析不执行,适合看 AI 理解步骤和调试任务编排。';
}
function cmdPlaceholder(){
if (state.cmdMode === 'pattern') return '输入指令:打开微信;然后截图';
if (state.cmdMode === 'ai') return '输入自然语言任务:打开微信给文件传输助手发消息说下午开会';
return '输入要解析的指令:打开淘宝搜索蓝牙耳机';
}
function renderHookPanel(){
const modules = state.hookModules || [];
const currentModules = state.currentDeviceModules?.modules || [];
const events = state.hookEvents || [];
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>Hook 控制</h2>
<p class="sub">参考奥创后台的设备控制习惯,把 Hook 模块、事件流、脚本部署和设备 Hook 状态直接放进工作台。</p>
</div>
<div class="btns">
<button class="btn soft" onclick="refreshHook()">刷新 Hook 数据</button>
<button class="btn primary" onclick="navigate('devices')">切到设备控制</button>
</div>
</div>
<div class="grid col3">
${card('统一入口', '业务请求先进入 /api/v3/hook/execute再自动选择 WebSocket Agent 或 ADB + 本地 Frida 通道。')}
${card('设备侧 Hook 模块', 'FridaManager、HookExecutor、wechat_hook_v2.js、EventReporter 共同组成设备侧 Hook 能力。')}
${card('详细功能', '消息拦截、撤回拦截、联系人读取、好友管理、群管理、朋友圈、进程信息、Hook 状态。')}
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>Hook 模块清单</h2>
<p class="sub">服务器端模块管理、脚本部署和设备侧加载状态。</p>
</div>
</div>
<div class="grid col2">
<div class="card">
<h3>服务器模块</h3>
<table class="list-table">
<thead><tr><th>模块</th><th>版本</th><th>状态</th><th>能力</th></tr></thead>
<tbody>
${modules.length ? modules.slice(0,12).map(module => `
<tr>
<td>${esc(module.name || module.module_id)}</td>
<td>${esc(module.version || '-')}</td>
<td>${module.enabled ? '启用' : '停用'}</td>
<td>${esc((module.capabilities || []).slice(0,3).join(', ') || '-')}</td>
</tr>
`).join('') : `<tr><td colspan="4" class="empty">暂无 Hook 模块</td></tr>`}
</tbody>
</table>
</div>
<div class="card">
<h3>当前设备 Hook 状态</h3>
<p>当前设备:${esc(state.currentDevice || '未选中')}</p>
<div class="doc-meta" style="margin:10px 0 14px">
<span class="pill">支持 Hook${state.currentDeviceModules?.supports_hook ? '是' : '否'}</span>
<span class="pill">Frida${esc(state.currentDeviceModules?.frida_version || '-')}</span>
<span class="pill">Root${state.currentDeviceModules?.root_status ? '已就绪' : '未就绪'}</span>
</div>
<table class="list-table">
<thead><tr><th>模块</th><th>状态</th><th>进程</th><th>事件</th></tr></thead>
<tbody>
${currentModules.length ? currentModules.map(item => `
<tr>
<td>${esc(item.module_id || '-')}</td>
<td>${esc(item.status || '-')}</td>
<td>${esc(item.target_process || '-')}</td>
<td>${item.events_today ?? 0}</td>
</tr>
`).join('') : `<tr><td colspan="4" class="empty">暂无设备模块状态</td></tr>`}
</tbody>
</table>
</div>
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>Hook 事件流</h2>
<p class="sub">事件接收与广播面板,适合看消息监听、撤回、设备回传和脚本结果。</p>
</div>
</div>
<div class="card">
<table class="list-table">
<thead><tr><th>时间</th><th>类型</th><th>设备</th><th>平台</th><th>内容</th></tr></thead>
<tbody>
${events.length ? events.slice(0,20).map(event => `
<tr>
<td>${esc(event.created_at || event.timestamp || '-')}</td>
<td>${esc(event.event_type || '-')}</td>
<td>${esc(event.device_id || '-')}</td>
<td>${esc(event.platform || '-')}</td>
<td>${esc(JSON.stringify(event.payload || {})).slice(0, 90)}</td>
</tr>
`).join('') : `<tr><td colspan="5" class="empty">暂无 Hook 事件</td></tr>`}
</tbody>
</table>
</div>
</div>
`;
}
function renderAgentPanel(){
const agent = state.overview?.agent || {};
const docCards = state.docs.filter(doc => ['agent-install','usage-solution','wechat-e2e'].includes(doc.id));
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>AI Agent</h2>
<p class="sub">自然语言任务、模式匹配、设备执行和结果回传统一收口在工作台里。</p>
</div>
</div>
<div class="grid col3">
${card('当前状态', agent.available ? `${agent.backend || 'AI'} · ${agent.model || '已连接'}` : 'AI 引擎当前不可用')}
${card('能力路径', '任务理解 → 步骤拆解 → 平台识别 → 动作编排 → 设备执行 → 结果回传')}
${card('建议分工', '简单动作走模式匹配;多步任务走 AI 智能;调试时先走 AI 纯解析')}
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>AI Agent 说明入口</h2>
<p class="sub">把安装、使用和验证类文档挂到 Agent 板块里,形成工作流式说明入口。</p>
</div>
</div>
<div class="grid col3">
${docCards.map(doc => `
<div class="card" onclick="openDocFromAgent('${doc.id}')" style="cursor:pointer">
<h3>${doc.title}</h3>
<p>${doc.summary}</p>
<div class="path">${doc.folder}/${doc.file}</div>
</div>
`).join('')}
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>控制台入口</h2>
<p class="sub">AI Agent 不再是单独页面,而是直接融进工作台控制区。</p>
</div>
</div>
<div class="grid col2">
${card('进入设备控制', '在“设备控制”板块直接用 AI 对话控制设备、发起截图、查看日志。','<button class="btn primary" onclick="navigate(\'devices\')">去设备控制</button>')}
${card('连接协议', '在“连接与协议”板块查看 register、heartbeat、execute、response、event、device_request。','<button class="btn soft" onclick="navigate(\'connection\')">看协议链路</button>')}
</div>
</div>
`;
}
function openDocFromAgent(docId){
navigate('docs');
setTimeout(() => openDoc(docId), 50);
}
function renderConnectionPanel(){
const protocol = state.protocol || {};
const connection = state.connection || {};
const handshake = protocol.handshake || {};
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>连接与协议</h2>
<p class="sub">设备端 Agent、服务端 WebSocket Hub 和 Hook 事件流的完整消息类型与实时状态。</p>
</div>
</div>
<div class="grid col3">
${card('WebSocket 入口', connection.ws_path || protocol.transport?.agent_ws || '-')}
${card('Hook 事件流', connection.hook_events_stream || protocol.transport?.hook_events_ws || '-')}
${card('REST 入口', protocol.transport?.rest_base || `${location.origin}/api/v3`)}
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>消息类型</h2>
<p class="sub">把注册、心跳、执行、响应、事件和设备请求统一展示出来。</p>
</div>
</div>
<div class="grid col2">
<div class="card">
<h3>设备 -> 服务端</h3>
<div class="doc-meta">
${(protocol.message_types?.device_to_server || []).map(item => `<span class="pill">${item}</span>`).join('')}
</div>
</div>
<div class="card">
<h3>服务端 -> 设备</h3>
<div class="doc-meta">
${(protocol.message_types?.server_to_device || []).map(item => `<span class="pill">${item}</span>`).join('')}
</div>
</div>
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>握手与在线状态</h2>
<p class="sub">协议示例与在线设备数据同屏查看。</p>
</div>
</div>
<div class="grid col2">
<div class="card">
<h3>注册握手</h3>
<div class="proto">
<div><strong>register</strong> ${esc(JSON.stringify(handshake.client_register || {}))}</div>
<div style="margin-top:10px"><strong>registered</strong> ${esc(JSON.stringify(handshake.server_ack || {}))}</div>
</div>
</div>
<div class="card">
<h3>在线设备</h3>
<table class="list-table">
<thead><tr><th>设备</th><th>项目</th><th>状态</th><th>心跳</th></tr></thead>
<tbody>
${(connection.devices || []).length ? (connection.devices || []).map(item => `
<tr>
<td>${esc(item.device_id)}</td>
<td>${esc(item.project_id || '-')}</td>
<td>${esc(item.status || '-')}</td>
<td>${typeof item.heartbeat_age_seconds === 'number' ? item.heartbeat_age_seconds + 's' : '-'}</td>
</tr>
`).join('') : `<tr><td colspan="4" class="empty">暂无在线设备</td></tr>`}
</tbody>
</table>
</div>
</div>
</div>
`;
}
function renderArchitecturePanel(){
const assets = state.assets || [];
const archDocs = state.docs.filter(doc => ['architecture','api'].includes(doc.category));
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>架构与经验</h2>
<p class="sub">把核心架构图、控制模式和关键文档做成站内展区,方便工作台直接查看和复盘。</p>
</div>
</div>
<div class="asset-grid">
${assets.map(asset => `
<div class="asset-card">
<h3>${asset.title}</h3>
<p style="font-size:12px;color:var(--sub);margin-bottom:10px">${asset.summary}</p>
<img src="${asset.url}" alt="${asset.title}">
</div>
`).join('')}
</div>
</div>
<div class="panel glass">
<div class="panel-head">
<div>
<h2>架构文档入口</h2>
<p class="sub">架构类文档同样走站内正文页,不再跳出单独预览页。</p>
</div>
</div>
<div class="grid col3">
${archDocs.map(doc => `
<div class="card" onclick="openDocFromHome('${doc.id}')" style="cursor:pointer">
<h3>${doc.title}</h3>
<p>${doc.summary}</p>
<div class="path">${doc.folder}/${doc.file}</div>
</div>
`).join('')}
</div>
</div>
`;
}
function renderProjectPanel(){
const projectDocs = state.docs.filter(doc => ['project'].includes(doc.category));
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>项目管理</h2>
<p class="sub">开发进度、项目入口和说明文档统一在工作台收口,方便每次对话直接续上工作流。</p>
</div>
</div>
<div class="grid col3">
${projectDocs.map(doc => `
<div class="card" onclick="openDocFromHome('${doc.id}')" style="cursor:pointer">
<h3>${doc.title}</h3>
<p>${doc.summary}</p>
<div class="path">${doc.folder}/${doc.file}</div>
</div>
`).join('')}
${card('Swagger 文档', '接口调试仍然保留,但不再内嵌在工作台里。','<a class="btn soft" href="/docs" target="_blank">新窗口打开</a>')}
${card('ReDoc', '大纲式 API 文档继续保留为外部阅读入口。','<a class="btn soft" href="/redoc" target="_blank">新窗口打开</a>')}
${card('语音控制', '语音控制能力保留原入口,工作台负责统一导航。','<a class="btn soft" href="/voice" target="_blank">新窗口打开</a>')}
</div>
</div>
`;
}
function renderAgentClusterPanel(){
const agentDocs = state.docs.filter(d => d.category === 'agent');
const selected = state.selectedAgentDoc || agentDocs[0]?.id;
state.selectedAgentDoc = selected;
if (selected) requestAnimationFrame(() => {
const v = document.getElementById('agent-doc-view');
if (v && !v.dataset.loaded) { v.dataset.loaded = '1'; openAgentDoc(selected); }
});
return `
<div class="panel glass">
<div class="panel-head">
<div>
<h2>Agent 集群文档</h2>
<p class="sub">设备端 Agent 的架构部署、技能引擎、网络恢复和 Hook 脚本系统完整文档。</p>
</div>
</div>
<div class="doc-layout">
<div class="doc-list">
${agentDocs.map(doc => `
<div class="doc-item ${doc.id===selected ? 'active' : ''}" data-docid="${doc.id}" onclick="openAgentDoc('${doc.id}')">
<h3>${doc.title}</h3>
<p>${doc.summary}</p>
<div class="doc-meta">
<span class="pill">${doc.section}</span>
<span class="pill">${doc.folder}/${doc.file}</span>
</div>
</div>
`).join('')}
${!agentDocs.length ? '<div class="empty">Agent 文档加载中...</div>' : ''}
</div>
<div class="doc-view" id="agent-doc-view"><div class="empty">选择左侧文档查看正文</div></div>
</div>
</div>
`;
}
async function openAgentDoc(docId){
state.selectedAgentDoc = docId;
document.querySelectorAll('#view .doc-item').forEach(el => {
el.classList.toggle('active', el.dataset.docid === docId);
});
const view = document.getElementById('agent-doc-view');
if (view) view.innerHTML = '<div class="empty">正文加载中...</div>';
try {
const res = await apiGet('/api/v3/workbench/docs/' + docId);
const doc = res.data;
if (!view) return;
view.innerHTML = '<div class="doc-header"><h3>' + doc.title + '</h3><p>' + doc.summary + '</p><div class="doc-meta"><span class="pill">' + doc.section + '</span><span class="pill">' + doc.folder + '/' + doc.file + '</span></div></div><div class="doc-body">' + doc.content_html + '</div>';
} catch(e) {
if (view) view.innerHTML = '<div class="empty">正文加载失败:' + esc(e.message) + '</div>';
}
}
function renderCurrentView(){
const view = document.getElementById('view');
if (state.current === 'overview') view.innerHTML = renderOverview();
else if (state.current === 'docs') view.innerHTML = renderDocs();
else if (state.current === 'devices') view.innerHTML = renderDevicePanel();
else if (state.current === 'hook') view.innerHTML = renderHookPanel();
else if (state.current === 'agent') view.innerHTML = renderAgentPanel();
else if (state.current === 'agentCluster') view.innerHTML = renderAgentClusterPanel();
else if (state.current === 'connection') view.innerHTML = renderConnectionPanel();
else if (state.current === 'architecture') view.innerHTML = renderArchitecturePanel();
else if (state.current === 'project') view.innerHTML = renderProjectPanel();
view.style.animation = 'none';
void view.offsetHeight;
view.style.animation = 'fadeUp .35s cubic-bezier(.22,1,.36,1) both';
}
async function refreshOverview(){
try{
const res = await apiGet('/api/v3/workbench/overview');
state.overview = res.data;
state.devices = res.data.devices || [];
if (!state.currentDevice && state.devices.length) state.currentDevice = state.devices[0].device_id;
renderStats();
renderCurrentView();
}catch(error){
log(`工作台概览加载失败:${error.message}`, 'error');
}
}
async function refreshDocs(){
try{
const res = await apiGet('/api/v3/workbench/docs');
state.docs = res.data.docs || [];
state.assets = res.data.architecture_assets || [];
if (!state.selectedDoc && state.docs.length) state.selectedDoc = state.docs[0].id;
if (['docs','overview','architecture','project','agent','agentCluster'].includes(state.current)) {
const dv = document.getElementById('doc-view');
if (dv) delete dv.dataset.loaded;
const av = document.getElementById('agent-doc-view');
if (av) delete av.dataset.loaded;
renderCurrentView();
}
}catch(error){
log(`文档清单加载失败:${error.message}`, 'error');
}
}
async function refreshConnection(){
try{
const [protocolRes, statusRes] = await Promise.all([
apiGet('/api/v3/connection/protocol'),
apiGet('/api/v3/connection/status')
]);
state.protocol = protocolRes.data || {};
state.connection = statusRes.data || {};
if (['devices','connection'].includes(state.current)) renderCurrentView();
}catch(error){
log(`连接状态加载失败:${error.message}`, 'error');
}
}
async function refreshHook(){
try{
const [modulesRes, eventsRes] = await Promise.all([
apiGet('/api/v3/modules'),
apiGet('/api/v3/hook/events?limit=20')
]);
state.hookModules = modulesRes.data?.modules || [];
state.hookEvents = eventsRes.data?.events || [];
if (state.currentDevice){
try{
const deviceModulesRes = await apiGet(`/api/v3/devices/${state.currentDevice}/modules`);
state.currentDeviceModules = deviceModulesRes.data || null;
}catch(_){}
}
if (state.current === 'hook') renderCurrentView();
}catch(error){
log(`Hook 数据加载失败:${error.message}`, 'error');
}
}
async function refreshDevices(){
await Promise.all([refreshOverview(), refreshConnection()]);
if (state.currentDevice) await loadDeviceDetail(state.currentDevice);
}
async function refreshAll(){
await Promise.all([refreshOverview(), refreshConnection(), refreshDocs()]);
log('全部数据已刷新', 'info');
}
async function refreshConnectionModes(){
try {
const res = await apiGet('/api/v3/connection/modes');
const modeData = res.data || [];
for (const item of modeData) {
state.devices = (state.devices || []).map(d => {
if (d.device_id === item.device_id) {
return {...d, connection_modes: item.modes, best_mode: item.best_mode};
}
return d;
});
}
log(`连接模式检测完成:${modeData.length} 台设备`, 'info');
renderCurrentView();
} catch(error) {
log(`连接模式检测失败:${error.message}`, 'error');
}
}
function selectDevice(deviceId){
state.currentDevice = deviceId;
renderCurrentView();
loadDeviceDetail(deviceId);
refreshHook();
log(`已切换设备:${deviceId}`, 'info');
}
async function loadDeviceDetail(deviceId){
try{
const [detailRes, modesRes] = await Promise.all([
apiGet(`/api/v3/devices/${deviceId}`),
apiGet(`/api/v3/connection/modes/${deviceId}`).catch(() => null)
]);
const detail = detailRes.data || {};
const modes = modesRes?.data || {};
const merged = {...detail, connection_modes: modes.modes || [], best_mode: modes.best_mode || null};
state.devices = (state.devices || []).map(item => item.device_id === deviceId ? {...item, ...merged} : item);
if (state.current === 'devices') renderCurrentView();
}catch(error){
log(`设备详情加载失败:${error.message}`, 'error');
}
}
async function doAction(action){
if (!state.currentDevice) return log('请先选择设备', 'error');
log(`执行操作:${action}`, 'info');
try{
if (action === 'screenshot'){
const res = await apiPost(`/api/v3/adb/devices/${state.currentDevice}/screenshot`);
const box = document.getElementById('shot-box');
if (box && res.data?.base64){
box.innerHTML = `<img src="data:image/png;base64,${res.data.base64}" alt="截图">`;
log(`截屏成功 ${res.data.width || ''}x${res.data.height || ''}`.trim(), 'info');
}
return;
}
if (action === 'ui_tree'){
const res = await apiGet(`/api/v3/adb/devices/${state.currentDevice}/ui-tree`);
log(`UI 树获取成功:${(res.data?.xml || '').length} 字符`, 'info');
return;
}
if (action === 'device_info'){
await loadDeviceDetail(state.currentDevice);
log('设备详情已刷新', 'info');
return;
}
const actionMap = {
open_wechat: {url:`/api/v3/adb/devices/${state.currentDevice}/app/start`, body:{package:'com.tencent.mm'}},
open_douyin: {url:`/api/v3/adb/devices/${state.currentDevice}/app/start`, body:{package:'com.ss.android.ugc.aweme'}},
open_xhs: {url:`/api/v3/adb/devices/${state.currentDevice}/app/start`, body:{package:'com.xingin.xhs'}},
open_xianyu: {url:`/api/v3/adb/devices/${state.currentDevice}/app/start`, body:{package:'com.taobao.idlefish'}},
home: {url:`/api/v3/adb/devices/${state.currentDevice}/key`, body:{key:'home'}},
back: {url:`/api/v3/adb/devices/${state.currentDevice}/key`, body:{key:'back'}},
};
const config = actionMap[action];
if (!config) return log(`未知操作:${action}`, 'error');
const res = await apiPost(config.url, config.body);
log(`操作完成:${action} ${JSON.stringify(res.data || {}).slice(0, 80)}`, 'info');
setTimeout(() => doAction('screenshot'), 900);
}catch(error){
log(`操作失败:${error.message}`, 'error');
}
}
function setMode(mode){
state.cmdMode = mode;
if (state.current === 'devices') renderCurrentView();
}
async function sendCmd(){
const input = document.getElementById('cmd-input');
const task = input?.value?.trim();
if (!task) return;
if (input) input.value = '';
if (!state.currentDevice && state.cmdMode !== 'ai-only') return log('请先选择设备', 'error');
const start = Date.now();
log(`命令开始:${task}`, 'info');
try{
if (state.cmdMode === 'pattern'){
const res = await apiPost('/api/v3/agent/execute', {device_id: state.currentDevice, task});
const data = res.data || {};
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
if (data.success) log(`执行成功:${(data.steps || []).join(' → ') || '完成'} (${elapsed}s)`, 'info');
else log(`执行失败:${data.error || '未知错误'} (${elapsed}s)`, 'error');
} else {
const body = {message: task};
if (state.cmdMode === 'ai' && state.currentDevice) body.device_id = state.currentDevice;
const res = await apiPost('/api/v3/ai/chat', body);
const data = res.data || {};
const elapsed = ((Date.now() - start) / 1000).toFixed(1);
log(`AI 返回:${data.message || '已解析'} (${elapsed}s)`, 'info');
(data.executed || []).forEach(item => log(`${item.success ? '完成' : '失败'}${item.step}`, item.success ? 'info' : 'error'));
}
if (state.cmdMode !== 'ai-only') setTimeout(() => doAction('screenshot'), 1200);
}catch(error){
log(`命令执行失败:${error.message}`, 'error');
}
}
/* ═══ Lightbox ═══ */
function openLightbox(src, title){
const lb = document.getElementById('lightbox');
const img = document.getElementById('lb-img');
const tt = document.getElementById('lb-title');
img.src = src;
tt.textContent = title || '';
lb.classList.add('show');
}
function closeLightbox(){
document.getElementById('lightbox').classList.remove('show');
}
document.addEventListener('click', function(e){
const img = e.target;
if (img.tagName !== 'IMG') return;
if (img.closest('.asset-card') || img.closest('.doc-body') || img.closest('.shot')) {
e.stopPropagation();
openLightbox(img.src, img.alt || '');
}
});
document.addEventListener('keydown', function(e){
if (e.key === 'Escape') closeLightbox();
});
async function bootstrap(){
renderSidebar();
await Promise.all([refreshOverview(), refreshDocs(), refreshConnection(), refreshHook()]);
renderCurrentView();
}
bootstrap();
setInterval(() => {
refreshOverview();
refreshConnection();
if (state.current === 'hook') refreshHook();
}, 15000);
</script>
</body>
</html>